本文会介绍一些容易被忽视、但是非常实用的 npm 小知识点,带你从独特的角度认识每天手边常用的 npm 工具。

  • 容易被忽视的 dist tag
  • 耳熟能详的npm install
  • node_modules 的目录组织规律
  • 踩坑指南

1. 容易被忽视的 dist tag

dist tag 是一个特殊的版本标记指针

模块发布:每个版本号在发布时都会有一个 dist tag 指向它

  • 自定义 dist tag :发布时可以指定 dist tag ,它可以为任何字符串(如:’alpha’,’beta’),自定义 dist tag 通常用于发布内部版本
  • 默认 dist tag 为 ‘latest’:发布时不指定 dist tag 时会默认 dist tag 为 ‘latest’,对外发布的正式版本 dist tag 一定是 ‘latest’

模块安装

  • 自定义 dist tag
    • 自定义 dist tag 的版本必须通过指定 tag name 才能安装到,形如
      npm install modulename@tagname
    • 默认 dist tag:模块安装不指定 dist tag 时默认拉取 dist tag 为 ‘latest’ 的版本

举一只🌰:

  • 模块发布:

    我们发布 PackageA 的 1.0.0-alpha 时,执行 npm publish --tag alpha 来发布 alpha 版本;修改版本号后,再次执行 npm publish –tag alpha 时,则 alpha 版本会指向新的版本号。

  • 模块安装:

    通过 npm i PackageA@alpha 来安装这个 alpha 版本 ( npm i PackageA 不能安装到该 alpha 版本)。提示:npm install PackageA 与 npm install PackageA@latest 等价。
    gy有三个 dist tag 表示不同版本:latest 、docker、alpha,详情可以参考 gy 发版规范

    2. 耳熟能详的npm install

    我们每天都在用 npm install 命令,下面分几个场景讲一下你可能会忽视的细节,让你有更清晰的认识。

姿势:除了 install modulename,你还可以:

  • install git-url:接 git 地址,较常见
  • install folder:接本地文件目录,类似于 link 原理
  • install tarball-file:接本地压缩包,支持.tar, .tar.gz, .tgz

版本

npm install 安装模块时,默认安装最晚发布的版本号,而不是最大的版本号,所以不要通过发布比线上更低的版本号来进行内部测试,请使用 dist tag

性能

该命令大部分耗时都在解析模块依赖树上,只有很小部分时间花在下载压缩包上(正常网速下),所以只是缓存下载的 zip 包或持久化 npm 的本地缓存并不能节省很多时间。

场景一

  • 用法: npm install
  • 适用场景:用于安装本地项目工程的所有依赖。
  • 规则:
    • 命令会安装 dependencies 和 devDependencies,如果不想安装devDependencies,执行 npm install –production 即可
    • 该命令执行时会匹配 package-lock.json 信息、 package.json 信息、远程 registry信息是否一致, 会分析已有 node_modules 目录内的模块状态,按照 lock 文件中锁定的版本进行依赖安装。

场景二

  • 用法: npm install PackageA
  • 适用场景:安装特定的包 PackageA 到本地工程。
  • 规则:
    • 该命令只安装 PackageA 的 dependencies ,而不会安装 PackageA 的 devDependencies。
    • 该命令无视工程目录中 package-lock.json 中锁定的版本,直接安装(或升级) PackageA 到 package.json 中允许的最新版(有时候并不是最大版本号),同时会向 package.json 和 package-lock.json 文件写入该模块的最新版。

场景三

  • 用法: npm install -g PackageA
  • 适用场景:安装特定的包 PackageA 到全局。
  • 规则:
    • 该命令只安装 PackageA 的 dependencies ,而不会安装 devDependencies
    • 使用 n 管理 node 版本的情况,packageA 模块是安装到系统全局目录中
      • 优点:纯 js 模块在不同 nodejs 版本下无需再次安装
      • 缺点:模块如果含有一些 c 模块,nodejs 版本切换时可能需要重新安装该模块,如 node-sass。
    • 对于 nvm 管理的模块,会把 packageA 安装到当前 nodejs 版本所在目录中,优缺点跟 n 正好相反。

3. node_modules 的目录组织规律

新版的 npm ,工程的所有依赖 (包括依赖的下级依赖)都会被安装到 node_modules 根目录下以最大可能复用模块。
但是,如有项目工程目录的 package.json 如下:
// package.json
{
“name”: “project”,
“dependencies”: {
“B”: “0.0.3” // B中存在依赖 A@0.0.1
},
“devDependencies”: {
“A”: “0.0.2”
}
}

  • 模块 B 的 package.json 如下:
    {
    "name": "B",
    "dependencies": {
      "A": "0.0.1"
    }
    
    }
    工程根目录执行 npm install 时,得到如下目录
    1
    2
    3
    4
    5
    6
    7
    project
    ├──package.json
    ├──node_modules // 第一级
    ├── A@0.0.2
    ├── B
    ├── node_modules // 第二级
    ├── A@0.0.1

可以看到模块 A 同时被 project 的 devDependencies和 B 模块的 dependencies引用,且引用的版本不同,所以会安装两份 A ,project 依赖的 A@0.0.2 安装在工程的 node_moduels 根目录中,B 模块依赖的 A@0.0.1 安装在自己目录下层的 node_modules 中。

工程根目录执行 npm install –production 对比结果:

1
2
3
4
5
6
project
├──package.json
├──node_modules // 第一级
├── B
├── node_modules // 第二级
├── A@0.0.1

可以看到,虽然通过 --production 指令,在安装 project 的依赖时过滤了 devDependencies 中模块 A 的安装,但是 B 模块中的依赖 A 仍旧安装在的模块 B 目录下的 node_modules 内,并没有提到项目根目录中。

由此发现了 npm 一个非常不人性化的地方:npm install 时目录的组织只会考虑 package.json 文件本身,而不会兼顾命令行使用姿势造成的 devDependencies 是否安装的影响。 这个特点大多情况下并没有什么副作用,但总存在例外的情况,可以查看下面的章节

4. node_modules 目录组织与 peerDependencies 的坑

peerDependencies 有什么用?

考虑下面几个模块的关系:

  • babel-plugins 与 babel
  • eslint-plugins 与 eslint

以 babel 为例,其中 babel-plugin 会调用 babel ,但是针对每一个 babel-plugin,是不可能都把 babel 作为 dependency 单独安装一遍的。而 babel-plugin 的不同版本可能会对 babel 版本有要求,在这种情况 babel 会作为 babel-plugin 的 peerDependency 出现。

在 npm install babel-plugin-A 时,若工程中没有安装对应版本的 babel,则会发出警告,但不会主动安装 babel。

类似的情况还有 antd 之于 react

1
2
3
4
5
6
7
{
"name": "antd",
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
}

坑来了

项目中碰到过如下情况,在开发 @scopt/gy-lint 模块时,@scopt/gy-lint 的 package.json 如下:

1
2
3
4
5
6
7
{
"name": "@scopt/gy-lint",
"dependencies": {
"eslint": "5.9.0",
"eslint-plugin-import": "^2.14.0"
}
}

gy 主框架模块的 package.json 如下

1
2
3
4
5
6
7
8
9
{
"name": "@scopt/gy",
"dependencies": {
"@scopt/gy-lint": "^1.0.0"
},
"devDependencies": {
"eslint": "5.8.0",
}
}

我们执行 npm install -g @scopt/gy 时,得到的目录如下:

1
2
3
4
5
6
7
gy
├──package.json
├──node_modules // 第一级
├── eslint-plugin-import@2.14.0
├── @scopt/gy-lint
├── node_modules // 第二级
├── eslint

解释:因为 gy 主框架模块中的 devDepenencies 中存在 eslint 库,即使安装 gy 时并没有安装该 eslint@5.8.0 ,npm 分析 gy-lint 的依赖时依旧会认为外层 eslint@5.8.0 存在 ,从而把 gy-lint 依赖的 eslint@5.9.0 安装到了 gy-lint 模块内部的 node_modules 中。

而 gy-lint 的另外一个依赖 eslint-plugin-import@2.14.0 会被默认安装到 node_modules 根目录中。

如此导致:外层的 eslint-plugin-import 插件在 require(‘eslint’) 时会找不到模块 eslint 而报错,因为 eslint 被安装到了 @scopt/gy-lint 内部。

可以通过统一外层 gy 主框架与 gy-lint 的 eslint 版本以回避这个问题。

5. npm ci

上面提到,npm install 大部分时间都消耗在依赖树计算分析上,使用 npm ci 可以优化这个过程。使用 npm ci 需要了解以下几点:

  • 该项目必须有一个package-lock.json 或 npm-shrinkwrap.json。
  • 如果程序包锁中的依赖项与其中的依赖项不匹配package.json,npm ci则将退出并显示错误,而不是更新程序包锁。
  • npm ci 只能一次安装整个项目:使用此命令无法添加单个依赖项。
  • 如果 node_modules已经存在,它将在npm ci开始安装之前自动删除。
  • 它永远不会写入package.json或任何包锁:安装基本上是冻结的。

不满足于 npm ci ,想要安装更快?可以了解一下 gy fastinstall 插件 依赖缓存方案设计

6.npm deprecate

开发工具库的同学可以关注一下这个命令,命令用于标记历史的某版本已作废过期,如

npm deprecate modulename@"<2.0.0" "小于 2.0.0 的版本已经过期,请升级"