前言
为什么有npm?
回答这个问题会想起刚学前端不久,还不会使用npm的时候,那会想要用成熟的第三方库都是从BootCDN上把代码下载下来放到自己的野生项目里。当项目复杂时,手动管理依赖变成一件很麻烦且容易出错的事情。
npm的诞生离不开Node.js,Node.js是在2009年兴起的一个项目,同年出现的还有CommonJS。CommonJS试图定义一套普通应用程序使用的 API,目的为了能够在浏览器环境之外构建 JavaScript 生态系统,其中包括模块、包、系统等部分的规范定义,填补了JavaScript 标准库的不足。
2009年年底Node.js诞生之后,吸引了一群有趣的人参与 Node.js 早期的开发,其中包括Yahoo 员工 Isaac Schlueter,他发觉软件包管理工具未来会在 Node.js 有非常大的用武之地,于是辞职专心研发包管理工具,深度参与Node.js开发,在Node.js实现CommomJs模块化的基础之上利用该规范实现了包机制。后面的事情大家也都知道了,因为npm的足够优秀开始和Node的安装包捆绑打包,成为Node官方认证的金字招牌。
npm和很多操作系统包管理器类似,提供给开发者上传包,查阅包,下载包,管理包等便捷操作,是不是听起来很像CURD。npm-cli作为客户端,搭一个巨大的代码仓库,俗称注册表(registry)作为数据库,存放软件包信息,输入npm脚本命令时转化成接口调用,对注册表里的包信息增删查改,下载时查阅包信息里指向的仓库站点,下载相关软件包,解压软件包。至于包规范里定义的package.json,就可以理解为接口字段相关定义了。
包机制
包机制是npm工作的基础,其范畴包括包结构规范、包描述文件、发包规范。所谓的包,其实是模块的集合,在模块的基础上做更深一步的抽象。
包规范
完全符合CommonJS规范的包结构如下:
bin - 存放可执行二进制文件
lib - 存放Javascript代码
doc - 存放文档
test - 存放单元测试用例
package.json - 包描述文件
复制代码
事实上Node.js对包的要求并没有那么严格,只要顶层目录下有package.json,并符合一些规范即可。
npm提供命令npm init -y,在当前目录下快速生成一份默认的packgae.json文件,使之成为一个合格的包:
包描述文件
package.json是CommonJS 规定用来描述包的文件,npm整个系统依赖于此,其囊括的字段及大致字段含义如下图:
如要了解具体的字段含义指路文章 juejin.cn/post/684490…
npm依赖管理
npm的职责之处在于帮助开发者管理依赖,在了解依赖管理之前,先来看看依赖可以是什么:
npm install 后面跟的依赖形式可以是一个仓库地址、一个github project、一个解析gzip的url、甚至一个本地文件夹等等,当然,平常开发中我们主要见到的是“包名+包版本”来声明依赖。
npm ls 命令可以查看当前仓库依赖树上所有包的版本信息:
SemVer规范
SemVer,即Semantic Version(语义化版本)的缩写,是一个由Github起草的统一的语义版本号表示规则,被当下大多数软件库采用,npm是其中之一。
SemVer规范官网:semver.org/
npm在发布新版本时需遵循SemVer版本规范如下:
-
若版本为标准版本,版本号必须采用X.Y.Z格式,其中X为主版本号,Y为次版本号,Z为修订号:
- 主版本号(major): 当你做了不兼容的API修改
- 次版本号(minor): 当你做了向下兼容的功能性递增
- 修订号(patch): 当你做了向下兼容的问题修正
-
若版本变动比较大因而暂时无法满足兼容性预期时,可以先发布一个先行版本,先行版本号标注在修订版本号之后:
- X.Y.Z-alpha: 内测版本
- X.Y.Z-beta: 公测版本
- X.Y.Z-rc: 正式版本的候选版本
版本管理
固定的版本号很好理解,但我们在项目里看到的版本往往是添加了诸如^、~等符号前缀的。这是因为npm设置了一些规则,便于在执行npm update时将包更新到最新版本。
@ ^
只会执行到不更改左边非零数字的更新。例如:”redux”: “^3.7.2”,每次执行更新时,主版本号不允许更新,因此会更新到3.Y.Z版本下的最高版本。而”react-swipeable-views”: “^0.13.3”,则会更新到0.13.Z下的最高版本。
@ ~
只更新修订版本,主版本号和次版本号都不允许更新。例如”react-svg”: “~10.0.8”,执行更新时,只能更新到10.0.Z的最高版本。
@ x
x作为X.Y.Z中的任意一个,代表此位置可更新。
依赖管理
npm提供的管理依赖的几个字段有:
- dependencies
- devDependencies
- peerDependencies
- optionalDependencies
- bundledDependencies
dependencies 与 devDependencies
dependencies 指定的是生产环境下的依赖,devDependencies指定的是开发环境下的依赖。
在npm的世界里,每个人既是包的开发者,也是包的使用者。
当我们在开发一个包时,会执行npm install命令,命令证明了当前包处在开发环境下,npm会帮我们把package.json文件中dependencies和devDependencies中依赖的包都下载下来。而除了正在开发的包,其他在开发过程中使用到的第三方包对于我们来说都只是作用在运行时,即为生产依赖,npm在下载指定的包时只会链式下载该第三方包package.json下dependencies字段指定的所有依赖项。
dependencies 与 peerDependencies
peerDependencies声明的是当前包对宿主环境的依赖,也有的翻译为同伴依赖,常用在插件系统里。与dependencies不同的是,当在peerDependencies里声明一个依赖时,npm不会直接下载它,而是去检查宿主环境是否装有与其相匹配的包,若有,npm会转向依赖宿主环境下的包,若没有,npm2会把它作为dependencies来处理,下载到node_modules里,而npm3仅仅只会在控制台抛出警告,并不会打断安装流程也不会主动去下载它。
peerDependencies实际上是npm2的产物,npm2受限于处理依赖的方式为嵌套结构,导致一个同样的包被多次安装,带来很多冗余的代码,只好用peerDependencies机制复用相同的包。自从npm3把依赖处理成扁平结构之后,猜测也不怎么需要这个字段了。
optionalDependencies 与 bundledDependencies
optionalDependencies指定的依赖为可选依赖,当一个包可有可无时,可以把它声明到这里,同时程序需要做好缺乏依赖的可靠性处理。npm在安装optionalDependencies里的依赖时找不到或安装失败时并不会抛出错误停止安装,而是继续运行下去,毕竟它“可有可无”。
bundledDependencies指的是,如果在打包发布的使用希望一些依赖包也出现在最终的包里,那么把这些包放在bundledDependencies。
npm install也据此提供了不同的依赖管理行为:
- npm install:默认下载dependencies和devDependencies中的依赖
- npm install —production:只下载dependencies中的依赖
- npm install —development:只下载devDependencies中的依赖
- npm install —no-optional:不需要下载optionalDependencies中的可选依赖
- npm install package —save-dev:指明该依赖为项目的开发依赖
- npm install package —save:指明该依赖为项目的生产依赖
- npm install package –save-optional:指明该依赖为项目的可选依赖
npm install原理
我们都知道,在执行npm install时,依赖包被安装到了node_modules里,那这个过程具体到底发生了什么呢?
上图是npm 5执行install之后完整的流程图,我们可以把它拆解成三部分:
1. 预安装
预安装指的是安装依赖前做的一些预备工作。我们结合yarn和npm提供的verbose日志来观察(yarn 执行yarn —verbose、npm执行npm install –timing=true –loglevel=verbose)
- 执行工程自身的preinstall,当前 npm 工程如果定义了 preinstall 钩子此时会被执行。
-
检查config,检查的顺序如下:
- 命令行中的配置,例如 npm install –registry=http://****
- 主目录中的.npmrc文件(.yarnrc)
- 用户主目录(home)的.npmrc文件
2. 构建依赖树
依赖管理方式
npm管理依赖的方式,经历了几个版本的变化。
嵌套结构
在npm的早期版本,npm处理依赖的方式非常简单,以递归的形式,严格按照package.json结构和子依赖的package.json结构将它们安装到各自的node_modules中,直到子依赖不再有自己的依赖。
node_modules结构和package.json一一对应,层级结构明显,保证了每次安装目录结构都是相同的。
举个例子,假设同时安装了react-router、react-router-dom、history,根据package.json,此时的依赖结构为:
可以看到,首层依赖里有许多相同的子依赖,甚至依赖了同等层级的history,但因为依赖结构不同被放置在不同的目录里无法复用,npm依然会下载全部的包,导致代码大量冗余。与此同时带来的问题是,随着依赖越来越多,node_modules文件夹将会十分庞大,嵌套层级过深也可能带来不可预知的问题。
扁平结构
为解决以上问题,npm在3.0版本做了一次较大更新,在构建依赖树时加入了depute,将模块嵌套结构改为扁平结构。因此无论是首层依赖或者子依赖,都优先安装到主目录下的node_modules里。
同样的例子,npm升级后的依赖结构为:
当安装相同依赖时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则不重复安装,不符合则在当前模块的node_modules下安装该模块。
因此,在项目里引用一个模块时,其查找规则如下:
- 在当前的模块路径下搜索
- 在当前的node_modules路径下搜索
- 在父级的node_modules路径下搜索
- …
- 直到搜索到全局的node_modules
package-lock.json
由于npm版本管理的特性,在package.json声明依赖时允许只锁定大版本,这就意味着在某些依赖包小范围变动时,也可能造成整个依赖结构的变动,从而带来不可预知的问题。针对于此,yarn自定义了yarn.lock文件来锁定版本,而npm在npm5之后也引入了package-lock.json。只要目录下有package.json,就能保证生成的node_modules目录结构完全一致。
在处理package.json和package-lock.json的冲突时,不同版本有一些细微差异:
- npm@5.0:根据lock文件,即使更改了package.json文件,只要有lock文件,那么还是会根据lock文件安装。
- npm@5.1:无视lock文件,只要json文件改了,那么就根据json的来。
- npm@5.4:lock和package文件都兼顾,如果package.json和lock文件不同,就根据package.json文件去更新包,并且更新lock文件,如果他们是一样的,那么会去根据lock文件更新包。
依赖树构建细节
我们依然根据verbose日志,观察npm依赖树的构建细节。
首先,执行npm install时,会初始化一个对象Installer,部分关键字段如下:
args: 参数
currentTree: 当前node_modules中包信息构成的依赖树
idealTree: 安装完依赖后,最终形成的依赖树
differences: 上面两树的差异队列
todo: 安装依赖时需要执行的动作
started: 记录下每个步骤的完成时间,用于日志输出
复制代码
再根据上图,日志记录的关键节点有:
loadCurrentTree:
构建当前依赖树,从node_modules读取包,去除首字母为”.”的文件夹(.bin),序列化读取到的结果,将其存放到上述对象的currentTree里。
loadIdealTree:
构建最终需要形成的依赖树,包括三个步骤:
-
cloneCurrentTreeToIdealTree: 把当前依赖树拷贝一份赋给idealTree。
-
loadShrinkwrap: 依次读取npm-shrinkwrap.json、package-lock.json、package.json、不考虑安装参数,重新构建一颗依赖树,形成idealTree的雏形。
-
loadAllDepsIntoIdealTree: 完成构建的核心部分。前面只序列化了整颗依赖树的根节点,由于每个首层依赖下都维护一颗子树,npm将继续从每个首层依赖出发寻找更深层级的节点。这里涉及到包的获取和去重工作:
包获取
- 判断package.json中存在的包信息与lock文件中的包信息是否一致,如果一致,则直接取这个包信息。
- 如果lock文件不存在包信息,但存在满足构造包信息的条件,比如integrity,就依此构建一个包信息。
- 如果都不满足,则根据Semver规则去仓库中获取对应版本的包信息。
- 如果仓库都找不到包,则报错并退出安装流程。
包去重
在经过上述步骤后,我们会得到一颗完整的依赖树,其中可能存在大量冗余的模块,在npm3之前会严格按照该结构安装,而在npm升级之后加入了dedupe处理。它会遍历所有节点,将模块放到根节点下面,打平依赖结构,当发现重复依赖时(模块名相同且semver 兼容),将其丢弃。
因此,npm会在依赖上处理两次。
generateActionsToTake:
经过上两步处理后,会在Installer对象上挂载两颗树,currentTree和IdealTree。因此这个步骤会比较这两棵树,将其打平,对比得到哪些包执行add、remove、update、move等等,然后放到differences字段上。
decomposeActions:
遍历differences字段,根据其存放actions类型(add、remove…)生成一系列更详细的指令,并将指令放在installer对象的todo字段里。到这里依赖树的构建基本完成。
3. 安装依赖
在这一步,我们只需要执行上一步被存放在todo里的指令即可。
按照流程,npm拿到包,把包解压到node_modules里,顺序执行生命周期函数(preinstall、install、postinstall),最后更新package.lock文件,安装完成。
那npm是怎么拿到包的呢?在npm5之后,加入了缓存机制。npm会先去检查本地缓存是否有对应的包,如果有的话就直接拿来用,这样可以减少大量网络请求,否则才去包信息里指定的仓库地址下载并缓存起来,等待二次利用。就此,我们可以看看npm是怎么找到缓存的包的:
我们用npm config get cache命令查询到npm缓存包的目录.npm/_cacache:
可以从字面意思猜测到,content-v2目录用于储存tar包内容,index-v5目录用于储存tar包的索引信息。随便打开一个index文件,可以看到这里存放了react包的一些元信息:
在npm工程文件里,另一个可以存放依赖元信息的文件是package-lock.json。我们找到react相关地方:
因此可以猜到npm根据package-lock.json里的字段,整合并按照一定的格式加密(sha-512/sha1/sha256),得到该依赖包的hash值,取前4位作为目录索引,在index-v5下找到包索引文件,再从中取出包内容的位置索引(也是一串hash值),在content-v2下找到tar包。
npm存放缓存的机制不同于yarn,根据版本的升级也做了一些调整,很多网上的破解方式也已经失效了,因此了解思路即可。
小结
本文尝试不同角度认识npm,包括npm的渊源、包机制设计、依赖管理、缓存管理等等,如有错误,欢迎指正,争取做一个合格的包管理者。