在我看来,nodejs 的成功缘由除了它采用了前端 js 相同的语法,直接吸引了一大波前端开发者做为初始用户以外,它内置的包管理器 npm 也居功至伟。npm 可以很好的管理 nodejs 项目的依赖,也使得开发者发布本身的包变的异常容易。这样一来,不论你使用别人的包,仍是本身发布包给别人使用,成本都不大。这和我大学学习的 Java 1.x 相比就轻松愉快的多(如今 Java 已今非昔比,我不敢乱评论),开发者热情高涨的话,整个生态就会更加活跃,进步速度也就更加快了。看一看 GitHub 上 JS 项目的占比,再看看 npm 官网包的数量,就能略知一二。javascript
前阵子公司的一名新人问了我一个问题:如何区分项目的依赖中,哪些应该放在 dependencies,而哪些应该放在 devDependencies 呢?css
其实这个问题我在早先也有过,因此很是可以体会他的心情。为了防止误人子弟,我查阅了一些资料,发现其实 nodejs 中总共有 5 种依赖:前端
因此我趁此机会,整理了这篇文章,分享给更多仍有此迷茫的人们。java
这是 npm 最基本的依赖,经过命令 npm i xxx -S
或者 npm i xxx --save
来安装一个包,而且添加到 package.json 的 dependencies 里面(这里 i
是 install
的简写,二者都可)。node
若是直接只写一个包的名字,则安装当前 npm registry 中这个包的最新版本;若是要指定版本的,能够把版本号写在包名后面,例如 npm i webpack@3.0.0 --save
。webpack
npm install
也支持 tag,tar 包地址等等,不过那些不太经常使用,能够查看官方文档。git
dependencies 比较简单,我就再也不多作解释了。注意一点:npm 5.x 开始能够省略 --save
,即若是执行 npm install xxx
,npm 同样会把包的依赖添加到 package.json 中去。要关闭这个功能,可使用 npm config set save false
。程序员
不少 nodejs 新手都分不清 dependencies 和 devDependencies,致使依赖随便分配,或者把依赖通通都写在 dependencies。这也是我编写本文的初衷。github
先说定义。顾名思义,devDependencies 就是开发中使用的依赖,它区别于实际的依赖。也就是说,在线上状态不须要使用的依赖,就是开发依赖。web
再说意义。为何 npm 要把它单独分拆出来呢?最终目的是为了减小 node_modules 目录的大小以及 npm install
花费的时间。由于 npm 的依赖是嵌套的,因此可能看上去 package.json 中只有几个依赖,但实际上它又扩散到 N 个,而 N 个又扩散到 N 平方个,一层层扩散出去,可谓子子孙孙无穷尽也。若是可以尽可能减小不使用的依赖,那么就可以节省线上机器的硬盘资源,也能够节省部署上线的时间。
在实际开发中,大概有这么几类能够归为开发依赖:
构建工具
如今比较热门的是 webpack 和 rollup,以往还有 grunt, gulp 等等。这些构建工具会生成生产环境的代码,以后在线上使用时就直接使用这些压缩过的代码。因此这类构建工具是属于开发依赖的。
像 webpack 还分为代码方式使用(webpack
)和命令行方式使用 (webpack-cli
),这些都是开发依赖。另外它们可能还会提供一些内置的经常使用插件,如 xxx-webpack-plugin
,这些也都算开发依赖。
预处理器
这里指的是对源代码进行必定的处理,生成最终代码的工具。比较典型的有 CSS 中的 less, stylus, sass, scss 等等,以及 JS 中的 coffee-script, babel 等等。它们作的事情虽然各有不一样,但原理是一致的。
以 babel 为例,经常使用的有两种使用方式。其一是内嵌在 webpack 或者 rollup 等构件工具中,通常以 loader 或者 plugin 的形式出现,例如 babel-loader
。其二是单独使用(小项目较多),例如 babel-cli
。babel 还额外有本身的插件体系,例如 xxx-babel-plugin
。相似地,less 也有与之对应的 less-loader
和 lessc
。这些都算做开发依赖。
在 babel 中还有一个注意点,那就是 babel-runtime
是 dependencies 而不是 devDependencies。具体分析我在以前的 babel 文章中提过,就再也不重复了。
测试工具
严格来讲,测试和开发并非一个过程。但它们同属于“线上状态不须要使用的依赖”,所以也就纳入开发依赖了。经常使用的如 chai
, e2e
, karma
, coveralls
等等都在此列。
真的是开发才用的依赖包
最后一类比较杂,很难用一个大类囊括起来,总之就是开发时须要使用的,而实际上线时要么是已经打包成最终代码了,要么就是不须要使用了。好比 webpack-dev-server
支持开发热加载,线上是不用的;babel-register
由于性能缘由也不能用在线上。其余还可能和具体业务相关,就看各位开发者本身识别了。
把依赖安装成开发依赖,则可使用 npm i -D
或者 npm i --save-dev
命令。
若是想达成刚才说的缩减安装包的目的,可使用命令 npm i --production
忽略开发依赖,只安装依赖,这一般在线上机器(或者 QA 环境)上使用。所以还有一个最根本的识别依赖的方式,那就是用这条命令安装,若是项目跑不起来,那就是识别有误了。
若是仅做为 npm 包的使用者,了解前两项就足够咱们平常的使用了。接下来的三种依赖都是做为包的发布者带会使用到的字段,因此咱们转换角色,以发布者的身份来讨论接下来的问题。
若是咱们开发一个常规的包,例如命名为 my-lib
。其中须要使用 request
这个包来发送请求,所以代码里必定会有 const request = require('request')
。如上面的讨论,这种状况下 request
是做为 dependencies 出如今 package.json 里面的。那么在使用者经过命令 npm i my-lib
安装咱们的时候,这个 request
也会做为依赖的一部分被安装到使用者的项目中。
那咱们还为何须要这个 peerDependencies 呢?
根据 npm 官网的文档,这个属性主要用于插件类 (Plugin) 项目。常规来讲,为了插件生态的繁荣,插件项目通常会被设计地尽可能简单,经过数据结构和固定的方法接口进行耦合,而不会要求插件项目去依赖本体。例如咱们比较熟悉的 express 中间件,只要你返回一个方法 return function someMiddleware(req, res, next)
,它就成为了 express 中间件,受本体调用,并经过三个参数把本体的信息传递过来,在插件内部使用。所以 express middleware 是不须要依赖 express 的。相似的状况还包括 Grunt 插件,Chai 插件和 Winston transports 等。
但很明显,这类插件脱离本体是不能单独运行的。所以虽然插件不依赖本体,但想要本身可以实际运行起来,还得要求使用者把本体也归入依赖。这就是介于“不依赖”和“依赖”之间的中间状态,就是 peerDependencies 的主要使用场景。
例如咱们提供一个包,其中的 package.json 以下:
{
"name": "my-greate-express-middleware",
"version": "1.0.0",
"peerDependencies": {
"express": "^3.0.0"
}
}
复制代码
在 npm 3.x 及之后版本,若是使用者安装了咱们的插件,而且在本身的项目中没有依赖 express 时,会在最后弹出一句提示,表示有一个包须要您依赖 express 3.x,所以您必须本身额外安装。另外若是使用者依赖了不一样版本的 express,npm 也会弹出提示,让开发者本身决断是否继续使用这个包。
这是一种比起 peerDependencies 更加少见的依赖项,也能够写做 bundleDependencies (bundle 后面的 d 省略)。和上述的依赖不一样,这个属性并非一个键值对的对象,而是一个数组,元素为表示包的名字的字符串。例如
{
"name": "awesome-web-framework",
"version": "1.0.0",
"bundledDependencies": [
"renderized", "super-streams"
]
}
复制代码
当咱们但愿以压缩包的方式发布项目时(好比你不想放到 npm registry 里面去),咱们会使用 npm pack
来生成(如上述例子,就会生成 awesome-web-framework-1.0.0.tgz
)。编写了 bundledDependencies 以后,npm 会把这里面的两个包 (renderized
, super-streams
) 也一块儿加入到压缩包中。这样以后其余使用者执行 npm install awesome-web-framework-1.0.0.tgz
时也会安装这两个依赖了。
若是咱们使用常规的 npm publish
的方式来发布的话,这个属性不会生效;而做为使用方的话,大部分项目也都是从 npm registry 中搜索并引用依赖的,因此使用到的场景也至关少。
这也是一种不多见的依赖项,从名字能够得知,它描述一种”可选“的依赖。和 dependencies 相比,它的不一样点有:
即便这个依赖安装失败,也不影响整个安装过程
程序应该本身处理安装失败时的状况
关于第二点,我想表达的意思是:
let foo
let fooVersion
try {
foo = require('foo')
fooVersion = require('foo/package.json').version
} catch (e) {
// 安装依赖失败时找不到包,须要本身处理
}
// 若是安装的依赖版本不符合实际要求,咱们也须要本身处理,当作没安装到
if (!isSupportVersion(fooVersion)) {
foo = null
}
// 若是安装成功,执行某些操做
if (foo) {
foo.doSomeThing()
}
复制代码
须要注意的是,若是一个依赖同时出如今 dependencies 和 optionalDependencies 中,那么 optionalDependencies 会得到更高的优先级,可能形成预期以外的效果,所以最好不要出现这种状况。
在实际项目中,若是某个包已经失效,咱们一般会寻找他的替代者,或者压根换一个实现方案。使用这种”不肯定“的依赖,一方面会增长代码中的判断,增长逻辑的复杂度;另外一方面也会大大下降测试覆盖率,增长构造测试用例的难度。因此我不建议使用这个依赖项,若是你原先就不知道有这个,那就继续当作不知道吧。
如上的 5 种依赖,除了 bundledDependencies,其余四种都是须要写版本号的。若是做为使用者,使用 npm i --save
或者 npm i --save-dev
会自动生成依赖的版本号,不过我建议你们仍是略微了解下经常使用的版本号的写法。
首先咱们得搞清三位版本号的定义,以 "a.b.c" 举例,它们的含义是:
a - 主要版本(也叫大版本,major version)
大版本的升级极可能意味着与低版本不兼容的 API 或者用法,是一次颠覆性的升级(想一想 webpack 3 -> 4)。
b - 次要版本(也叫小版本,minor version)
小版本的升级应当兼容同一个大版本内的 API 和用法,所以应该对开发者透明。因此咱们一般只说大版本号,不多会精确到小版本号。
特殊状况是若是大版本号是 0
的话,意味着整个包处于内测状态,因此每一个小版本之间也可能会不兼容。因此在选择依赖时,尽可能避开大版本号是 0
的包。
c - 补丁 (patch)
通常用于修复 bug 或者很细微的变动,也须要保持向前兼容。
以后咱们看一下常规的版本号写法:
"1.2.3" - 无视更新的精确版本号
表示只依赖这个版本,任何其余版本号都不匹配。在一些比较重要的线上项目中,我比较建议使用这种方式锁定版本。前阵子的 npm 挖矿以及 ant-design 彩蛋,其实均可以经过锁定版原本规避问题(彩蛋略难一些,挖矿是确定能够规避)。
"^1.2.3" - 兼具更新和安全的折中考虑
这是 npm i xxx --save
以后系统生成的默认版本号(^
加上当前最新版本号),官方的定义是“可以兼容除了最左侧的非 0 版本号以外的其余变化”(Allows changes that do not modify the left-most non-zero digit in the [major, minor, patch] tuple)。这句话很拗口,举几个例子你们就明白了:
"^1.2.3" 等价于 ">= 1.2.3 < 2.0.0"。即只要最左侧的 "1" 不变,其余均可以改变。因此 "1.2.4", "1.3.0" 均可以兼容。
"^0.2.3" 等价于 ">= 0.2.3 < 0.3.0"。由于最左侧的是 "0",因此这个不算,顺延到第二位 "2"。那么只要这个 "2" 不变,其余的都兼容,好比 "0.2.4" 和 "0.2.99"。
"^0.0.3" 等价于 ">= 0.0.3 < 0.0.4"。这里最左侧的非 0 只有 "3",且没有其余版本号了,因此这个也等价于精确的 "0.0.3"。
从这几个例子能够看出,^
是一个更新和安全兼容的写法。通常大版本号升级到 1 就表示项目正式发布了,而 0 开头就表示还在测试版,这也是 ^
区别对待二者的缘由。
"~1.2.3" - 比 ^
更加安全的小版本更新
关于 ~
的定义分为两部分:若是列出了小版本号(第二位),则只兼容 patch(第三位)的修改;若是没有列出小版本号,则兼容第二和第三位的修改。咱们分两种状况理解一下这个定义:
"~1.2.3" 列出了小版本号(2
),所以只兼容第三位的修改,等价于 ">= 1.2.3 < 1.3.0"。
"~1.2" 也列出了小版本号,所以和上面同样兼容第三位的修改,等价于 ">= 1.2.0 < 1.3.0"。
"~1" 没有列出小版本号,能够兼容第二第三位的修改,所以等价于 ">= 1.0.0 < 2.0.0"
和 ^
不一样的是,~
并不对 0
或者 1
区别对待,因此 "~0" 等价于 ">= 0.0.0 < 1.0.0",和 "~1" 是相同的算法。比较而言,~
更加谨慎。当首位是 0
而且列出了第二位的时候,二者是等价的,例如 ~0.2.3
和 ^0.2.3
。
在 nodejs 的上古版本(v0.10.26,2014年2月发布的),npm i --save
默认使用的是 ~
,如今已经改为 ^
了。这个改动也是为了让使用者能最大限度的更新依赖包。
"1.x" 或者 "1.*" - 使用通配符
这个比起上面那两个符号就好理解的多。x
(大小写皆可)和 *
的含义相同,都表示能够匹配任何内容。具体来讲:
"*" 或者 "" (空字符串) 表示能够匹配任何版本。
"1.x", "1.*" 和 "1" 都表示要求大版本是 1
,所以等价于 ">=1.0.0 < 2.0.0"。
"1.2.x", "1.2.*" 和 "1.2" 都表示锁定前两位,所以等价于 ">= 1.2.0 < 1.3.0"。
由于位于结尾的通配符通常能够省略,而常规也不太可能像正则那样把匹配符写在中间,因此大多数状况通配符均可以省略。使用最多的仍是匹配全部版本的 *
这个了。
"1.2.3-beta.2" - 带预发布关键词的,如 alpha, beta, rc, pr 等
先说预发布的定义,咱们须要以包开发者的角度来考虑这个问题。假设当前线上版本是 "1.2.3",若是我做了一些改动须要发布版本 "1.2.4",但我不想直接上线(由于使用 "~1.2.3" 或者 `^1.2.3" 的用户都会直接静默更新),这就须要使用预发布功能。所以我可能会发布 "1.2.4-alpha.1" 或者 "1.2.4-beta.1" 等等。
理解了它诞生的初衷,以后的使用就很天然了。
">1.2.4-alpha.1",表示我接受 "1.2.4" 版本全部大于1的 alpha 预发布版本。所以如 "1.2.4-alpha.7" 是符合要求的,但 "1.2.4-beta.1" 和 "1.2.5-alpha.2" 都不符合。此外若是是正式版本(不带预发布关键词),只要版本号符合要求便可,不检查预发布版本号,例如 "1.2.5", "1.3.0" 都是承认的。
"~1.2.4-alpha.1" 表示 ">=1.2.4-alpha.1 < 1.3.0"。这样 "1.2.5", "1.2.4-alpha.2" 都符合条件,而 "1.2.5-alpha.1", "1.3.0" 不符合。
"^1.2.4-alpha.1" 表示 ">=1.2.4-alpha.1 < 2.0.0"。这样 "1.2.5", "1.2.4-alpha.2", "1.3.0" 都符合条件,而 "1.2.5-alpha.1", "2.0.0" 不符合。
版本号还有更多的写法,例如范围(a - b),大于小于号(>=a <b),或(表达式1 || 表达式2)等等,由于用的很少,这里再也不展开。详细的文档能够参见 semver,它同时也是一个 npm 包,能够用来比较两个版本号的大小,以及是否符合要求等。
除了版本号,依赖包还能够经过以下几种方式来进行依赖(使用的也不算太多,能够粗略了解一下):
除了版本号以外,一般某个包还可能会有 Tag 来标识一些里程碑意义的版本。例如 express@next 表示即将到来的下一个大版本(可提早体验),而 some-lib@latest 等价于 some-lib,由于 latest 是默认存在并指向最新版本的。其余的自定义 Tag 均可以由开发者经过 npm tag
来指定。
由于 npm i package@version
和 npm i package@tag
的语法是相同的,所以 Tag 和版本号必须不能重复。因此通常建议 Tag 不要以数字或者字母 v 开头。
能够指定 URL 指明依赖包的源地址,一般是一个 tar 包,例如 "https://some.site.com/lib.tar.gz"
。这个 tar 包一般是经过 npm pack
来发布的。
顺带提一句:本质上,npm 的全部包都是以 tar 包发布的。使用 npm publish
常规发布的包也是被 npm 冠上版本号等后缀,由 npm registry 托管供你们下载的。
能够指定一个 Git 地址(不单纯指 GitHub,任何 git 协议的都可),npm 自动从该地址下载并安装。这里就须要指明协议,用户名,密码,路径,分支名和版本号等,比较复杂。详情能够查看官方文档,举例以下:
git+ssh://git@github.com:npm/cli.git#v1.0.27
git+ssh://git@github.com:npm/cli#semver:^5.0
git+https://isaacs@github.com/npm/cli.git
git://github.com/npm/cli.git#v1.0.27
复制代码
做为最大的 Git 代码库,若是使用的是 GitHub 存放代码,还能够直接使用 user/repo 的简写方式,例如:
{
"dependencies": {
"express": "expressjs/express",
"mocha": "mochajs/mocha#4727d357ea",
"module": "user/repo#feature\/branch"
}
}
复制代码
npm 支持使用本地路径来指向一个依赖包,这时候须要在路径以前添加 file:
,例如:
{
"dependencies": {
"bar1": "file:../foo/bar1",
"bar2": "file:~/foo/bar2",
"bar3": "file:/foo/bar3"
}
}
复制代码
从 npm 5.x 开始,在执行 npm i
以后,会在根目录额外生成一个 package-lock.json。既然讲到了依赖,我就额外扩展一下这个 package-lock.json 的结构和做用。
package-lock.json 内部记录的是每个依赖的实际安装信息,例如名字,安装的版本号,安装的地址 (npm registry 上的 tar 包地址)等等。额外的,它会把依赖的依赖也记录起来,所以整个文件是一个树形结构,保存依赖嵌套关系(相似之前版本的 node_modules 目录)。一个简单的例子以下:
{
"name": "my-lib",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"array-union": {
"version": "1.0.2",
"resolved": "http://registry.npm.taobao.org/array-union/download/array-union-1.0.2.tgz",
"integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=",
"dev": true,
"requires": {
"array-uniq": "^1.0.1"
}
}
}
}
复制代码
在执行 npm i
的时候,若是发现根目录下只有 package.json 存在(这一般发生在刚建立项目时),就按照它的记录逐层递归安装依赖,并生成一个 package-lock.json 文件。若是发现根目录下二者皆有(这一般发生在开发同事把项目 checkout 到本地以后),则 npm 会比较二者。若是二者所示含义不一样,则以 package.json 为准,并更新 package-lock.json;不然就直接按 package-lock 所示的版本号安装。
它存在的意义主要有 4 点:
在团队开发中,确保每一个团队成员安装的依赖版本是一致的。不然由于依赖版本不一致致使的效果差别,通常很难查出来。
一般 node_modules 目录都不会提交到代码库,所以要回溯到某一天的状态是不可能的。但如今 node_modules 目录和 package.json 以及 package-lock.json 是一一对应的。因此若是开发者想回退到以前某一天的目录状态,只要把这两个文件回退到那一天的状态,再 npm i
就好了。
由于 package-lock.json 已经足以描述 node_modules 的大概信息(尤为是深层嵌套依赖),因此经过这个文件就能够查阅某个依赖包是被谁依赖进来的,而不用去翻 node_modules 目录(事实上如今目录结构打平而非嵌套,翻也翻不出来了)
在安装过程当中,npm 内部会检查 node_modules 目录中已有的依赖包,并和 package-lock.json 进行比较。若是重复,则跳过安装,能大大优化安装时间。
npm 官网建议:把 package-lock.json 一块儿提交到代码库中,不要 ignore。可是在执行 npm publish
的时候,它会被忽略而不会发布出去。
从 nodejs 诞生之初,npm 就是其内置的包管理器,而且以其易于使用,易于发布的特色极大地助推了 nodejs 在开发者中的流行和使用。但事物总有其两面性,易于发布的确大大推进生态的繁荣,但同时也下降了发布的门槛。包的数量在日新月异,一个项目的依赖项从几个上升到几十个,再加上内部的嵌套循环依赖,给使用者带来了极大的麻烦,node_modules 目录愈来愈大,npm install
的时间也愈来愈长。
在这种状况下,Facebook 率先站出来,发布了由他们开发的另外一个包管理器 yarn(1.0版本于2017年9月)。一旦有了挑战者出现,势必会引起双方对于功能,稳定性,易用性等各方面的竞争,对于开发者来讲也是极其有利的。从结果来讲,npm 也吸取了很多从 yarn 借鉴来的优势,例如上面谈论的 package-lock.json,最先就出自 yarn.lock。因此咱们来粗略比较一下二者的区别,以及咱们应当如何选择。
版本锁定
这个在 package-lock.json 已经讨论过了,再也不赘述。 在这个功能点上,二者都已具有。
多个包的管理 (monorepositories)
一个包在 npm 中能够被称为 repositories。一般咱们发布某个功能,其实就是发布一个包,由它提供各类 API 来提供功能。但随着功能愈来愈复杂以及按需加载,把全部东西所有放到一个包中发布已经不够优秀,所以出现了多个包管理的需求。
一般一个类库会把本身的功能分拆为核心部分和其余部分,而后每一个部分是一个 npm repositories,能够单独发布。而使用者一般在使用核心以后,能够本身选择要使用哪些额外的部分。这种方式比较常见的如 babel 和它的插件,express 和它的中间件等。
做为一个多个包的项目的开发者/维护者,安装依赖和发布都会是一件很麻烦的事情。由于 npm 只认根目录的 package.json,那么就必须进入每一个包进行 npm install
。而发布时,也必须逐个修改每一个包的版本号,并到每一个目录中进行 npm publish
。
为了解决这个问题,社区一个叫作 lerna 的库经过增长 lerna.json 来帮助咱们管理全部的包。而在 yarn 这边,引入了一个叫作工做区(workspace)的概念。所以这点上来讲,应该是 yarn 胜出了,不过 npm 配合 lerna 也可以实现这个需求。
安装速度
npm 被诟病最多的问题之一就是其安装速度。有些依赖不少的项目,安装 npm 须要耗费 5-10 分钟甚至更久。形成这个问题的本质是 npm 采用串行的安装方式,一个装完再装下一个。针对这一点,yarn 改成并行安装,所以本质上提高了安装速度。
离线可用
yarn 默认支持离线安装,即安装过一次的包,会在电脑中保留一份(缓存位置能够经过 yarn config set yarn-offline-mirror
进行指定)。以后再次安装,直接复制过来就能够。
npm 早先是所有经过网络请求的(为了保持其时效性),但后期也借鉴了 yarn 建立了缓存。从 npm 5.x 开始咱们可使用 npm install xxx --prefer-offline
来优先使用缓存(意思是缓存没有再发送网络请求),或者 npm install xxx --offline
来彻底使用缓存(意思是缓存没有就安装失败)。
控制台信息
常年使用 npm 的同窗知道,安装完依赖后,npm 会列出一颗依赖树。这颗树一般会很长很复杂,咱们不会过多关注。所以 yarn 精简了这部分信息,直接输出安装结果。这样万一安装过程当中有报错日志也不至于被刷掉。
不过 npm 5.x 也把这颗树给去掉了。这又是一个互相借鉴提升的例子。
总结来讲,yarn 的推出主要是针对 npm 早期版本的不少问题。但 npm 也意识到了来自竞争对手的强大压力,所以在 5.x 开始逐个优化看齐。从 5.x 开始就已经和 yarn 不分伯仲了,所以如何选择多数看是否有历史包袱。若是是新项目的话,就看程序员我的的喜爱了。
本文从一个很小的问题开始,本意是想分享如何鉴别一个应用应该归类在 dependencies 仍是 devDependencies。后来层层深刻,经过查阅资料发现了好多依赖相关的知识,例如其余几种依赖,版本锁定的机制以及和 yarn 的比较等等,最终变成一篇长文。但愿经过本文能让你们了解到依赖管理的一些大概,在以后的搬砖道路上可以更加顺利,也能反过来为整个生态的繁荣贡献本身的力量。