一文弄懂 npm & yarn 包管理机制(深度解析!)

前端下午茶

共 9885字,需浏览 20分钟

 ·

2021-09-08 03:27


背景

使用 npm 或 yarn 管理项目依赖时,可能会产生以下疑问:

  1. 项目依赖出现问题怎么办?删了重装,即先删除 node_modules 再重新 install,那这样的操作会不会存在风险?
  2. 把所有依赖都安装到 dependencies 中,不区分 devDependencies 会有问题吗?
  3. 我们的应用依赖了 pkg-a 和 pkg-b,同时 pkg-a 也依赖了 pkg-b,那么 pkg-b 会被多次安装或重复打包吗?
  4. 一个项目中,我使用 npm 别人使用 yarn,这会引发什么问题?
  5. 我们是否要提交 lockfile(package-lock.json/yarn.lock) 到项目仓库呢?
  6. lockfile 在 git 操作时,时常会出现大量的冲突,你是怎么解决的呢?

npm 内部机制和背后的思考

先来看下第一个问题,“删除 node_modules,重新 npm install” 这样解决依赖安装问题百试不爽,其中的原理是什么?这样做存在怎样的风险?下面我们一起探究一下。

npm 的安装机制非常值得探究。pip 是全局安装,但 npm 的安装机制秉承了不同的设计哲学。

npm 会优先将依赖包安装到项目目录。 这样做的好处是使不同项目的依赖各成体系,同时还减轻了包作者的 API 压力;缺点也比较明显,如果我们的 repo_arepo_b 都有一个相同的依赖 pkg_c,那么这个公共依赖将在两个项目中各被安装一次。也就是说,同一个依赖可能在我们的电脑上多次安装。

npm install

上图是 npm 安装依赖大致的过程,其中这样几个步骤需要关注:

  1. 检查配置。包括项目级、用户级、全局级、内置的 .npmrc 文件。
  2. 确定依赖版本,构建依赖树。确定项目依赖版本有两个来源,一是 package.json 文件,一是 lockfile 文件,两个确认版本、构建依赖树的来源,互不可少、相辅相成。如果 package-lock.json 文件存在且符合 package.json 声明的的情况下,直接读取;否则重新确认依赖的版本。
  3. 下载包资源。下载前先确认本地是否存在匹配的缓存版本,如果有就直接使用缓存文件,如果没有就下载并添加到缓存,然后将包按依赖树解压到 node_modules 目录。
  4. 生成 lockfile 文件。

可以确认这样几个逻辑:

  1. 构建依赖树的过程中,版本确认需要结合 package.json 和 package-lock.json 两个文件。先确认 package-lock.json 安装版本,符合规则就以此为准,否则由 package.json 声明的版本范围重新确认。特别地,若是在开发中手动更改包信息,会导致lockfile 版本信息异常,也可能由 package.json 确认。确认好的依赖树会存到 package-lock.json 文件中,这里跟 yarn.lock 存在差异。
  2. 同一个依赖,更高版本的包会安装到顶层目录,即 node_modules 目录;否则会分散在某些依赖的 node_modules 目录,如:node_modules/expect-jsx/node_modules/react 目录。
  3. 如果依赖升级,造成版本不兼容,需要多版本共存,那么仍然是将高版本安装到顶层,低版本分散到各级目录。
  4. lockfile 的存在,保证了项目依赖结构的确定性,保障了项目在多环境运行的稳定性。
  5. ...

yarn 安装理念以及破解依赖管理困境

yarn 作为区别于 npm 的依赖管理工具,诞生之初就是为了解决历史上 npm 的某些不足,比如 npm 缺乏对于依赖的完整性和一致性保障,以及 npm 安装速度过慢的问题等,尽管 npm 发展至今,已经在很多方面向 yarn 看齐,但 yarn 的安装理念仍然需要我们关注。yarn 提出的安装理念很好的解决了当时 npm 的依赖管理问题:

  1. 确定性。通过 yarn.lock 等机制,保证了确定性,这里的确定性包括但不限于明确的依赖版本、明确的依赖安装结构等。即在任何机器和环境下,都可以以相同的方式被安装。
  2. 模块扁平化安装。将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余。(npm 也有相同的优化)
  3. 更好的网络性能。Yarn 采用了请求排队的理念,类似并发连接池,能够更好地利用网络资源;同时引入了更好的安装失败时的重试机制。(npm 较早的版本是顺序下载,当第一个包完全下载完成后,才会将下载控制权交给下一个包)
  4. 引入缓存机制,实现离线策略。(npm 也有类似的优化)

yarn.lock 文件结构

以 react 等依赖为例,先大致了解一下 yarn.lock 文件的结构以及确定依赖版本的方式:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 
# yarn lockfile v1 
expect-jsx@^5.0.0
 version "5.0.0" 
 resolved "[http://registry.npmjs.org/expect-jsx/-/expect-jsx-5.0.0.tgz#61761b43365f285a80eb280c785e0783bbe362c7](http://registry.npmjs.org/expect-jsx/-/expect-jsx-5.0.0.tgz#61761b43365f285a80eb280c785e0783bbe362c7 "http://registry.npmjs.org/expect-jsx/-/expect-jsx-5.0.0.tgz#61761b43365f285a80eb280c785e0783bbe362c7")" 
 integrity sha1-YXYbQzZfKFqA6ygMeF4Hg7vjYsc= 
 dependencies: 
 collapse-white-space "^1.0.0" 
react "^16.0.0"
 react-element-to-jsx-string "^13.0.0" 
react-rater@^6.0.0
 version "6.0.0" 
 resolved "[http://registry.npmjs.org/react-rater/-/react-rater-6.0.0.tgz#2e666b6e5e5c33b622541df6a7124f6c99606927](http://registry.npmjs.org/react-rater/-/react-rater-6.0.0.tgz#2e666b6e5e5c33b622541df6a7124f6c99606927 "http://registry.npmjs.org/react-rater/-/react-rater-6.0.0.tgz#2e666b6e5e5c33b622541df6a7124f6c99606927")" 
 integrity sha512-NP1+rEeL3LyJqA5xF7U2fSHpISMcVeMgbQ0u/P1WmayiHccI7Ixx5GohygmJY82g7SxdJnIun2OOB6z8WTExmg== 
 dependencies: 
 prop-types "^15.7.2" 
react "^16.8.0"
 react-dom "^16.8.0" 
//一或多个具有相同版本范围的依赖声明,确定一个可用的版本。这就是 lockfile 的确定性。
react@^16.0.0, react@^16.8.0:
version "16.14.0"
 resolved "[http://registry.npmjs.org/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d](http://registry.npmjs.org/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d "http://registry.npmjs.org/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d")" 
 integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== 
 dependencies: 
 loose-envify "^1.1.0" 
 object-assign "^4.1.1" 
 prop-types "^15.6.2" 
//如果同一个依赖存在多个版本,那么最高版本安装在顶层目录,即 node_modules 目录。
react@^17.0.1:
version "17.0.2"
 resolved "[http://registry.npmjs.org/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037](http://registry.npmjs.org/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037 "http://registry.npmjs.org/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037")" 
 integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== 
 dependencies: 
 loose-envify "^1.1.0" 
 object-assign "^4.1.1"

从上面依赖版本描述的信息中,可以确定以下几点:

  1. 所有依赖,不管是项目声明的依赖,还是依赖的依赖,都是扁平化管理。
  2. 依赖的版本是由所有依赖的版本声明范围确定的,具备相同版本声明范围的依赖归结为一类,确定一个该范围下的依赖版本。如果同一个依赖多个版本共存,那么会并列归类。
  3. 每个依赖确定的版本中,是由以下几项构成:
    1. 多个依赖的声明版本,且符合 semver 规范;
    2. 确定的版本号 version 字段;
    3. 版本的完整性验证字段
    4. 依赖列表
  4. 相比 npm,Yarn 一个显著区别是 yarn.lock 中子依赖的版本号不是固定版本。 也就是说单独一个 yarn.lock 确定不了 node_modules 目录结构,还需要和 package.json 文件进行配合。

yarn install

以下是在 yarn 安装依赖时的步骤:1、检查(checking)主要是检查项目中是否存在一些 npm 相关的配置文件,如 package-lock.json 等。如果存在,可能会警告提示,因为它们可能会存在冲突。在这一阶段,也会检查系统 OS、CPU 等信息。

2、解析包(resolving packages)这一步主要是解析依赖树,确定版本信息等。首先获取项目 package.json 中声明的首层依赖,包括 dependencies, devDependencies, optionalDependencies 声明的依赖。接着采用遍历首层依赖的方式获取依赖包的版本信息,以及递归查找每个依赖下嵌套依赖的版本信息,并将解析过和正在解析的包用一个 Set 数据结构来存储,这样就能保证同一个版本范围内的包不会被重复解析。

  • 对于没有解析过的包,首次尝试从 yarn.lock 中获取到版本信息,并标记为已解析;
  • 如果在 yarn.lock 中没有找到包,则向 Registry 发起请求获取满足版本范围的已知最高版本的包信息,获取后将当前包标记为已解析。

总之,在经过复杂的解析算法后,我们就确定了所有依赖的具体版本信息以及下载地址。

3、获取包(fetching packages)这一步主要是利用系统缓存,到缓存中找到具体的包资源。首先会尝试在缓存中查找依赖包,如果没有命中缓存,则将依赖包下载到缓存中。对于没有命中缓存的包,Yarn 会维护一个 fetch 队列,按照规则进行网络请求。这里也是 yarn 诞生之初解决 npm v3 安装缓慢问题的优化点,支持并行下载。

如何判断有没有命中缓存?

判断系统中存在符合 "cachefolder+slug+node_modules+pkg.name" 规则的路径,如果存在则判断为命中缓存,否则就会重新下载。值得注意的是,不同版本的包在缓存中是扁平化管理。以下是缓存中 webpack 的依赖缓存,可以通过 yarn cache dir 查看。4、链接包(linking dependencies)这一步主要是将缓存中的依赖,复制到项目目录下,同时遵循扁平化原则。前面说到,npm 优先将依赖安装到项目目录,因此需要将全局缓存中的依赖复制到项目。在复制依赖前,Yarn 会先解析 peerDependencies,如果找不到符合 peerDependencies 声明的依赖版本,则进行 warning 提示(这并不会影响命令执行),并最终拷贝依赖到项目中。

5、构建包(building fresh package)如果依赖包中存在二进制包需要进行编译,会在这一步进行。

如果破解依赖管理困境

在 npm v2 时期,安装的依赖会存在于引用依赖的 node_modules 目录,如果依赖过多,会形成一颗巨大的依赖树。这种结构虽然简单明了,但是对于大型项目十分不友好。依赖层级深对开发排查不利,并且依赖的复用也是问题。在 npm v3 中引入扁平化的概念。看几个场景的例子🌰:

场景一:不同 npm 版本安装依赖的结构

pkg-a@1.0.0 依赖 pkg-b@1.0.0,npm v3 是扁平化管理依赖。

场景二:不同 npm 版本处理依赖多版本共存问题 

在场景一的基础上,安装 pkg-c@1.0.0,而它依赖另一个版本的 pgk-b@2.0.0。由于根目录下已存在 pkg-b@1.0.0 的依赖,npm v3 会把 pkg-b@2.0.0 安装到 pkg-c@1.0.0 依赖的 node_modules 目录。

靓仔疑惑:为什么 pkg-b@1.0.0 在顶级,而 pkg-b@2.0.0 在子级呢?

场景三:依赖的多版本的数量与依赖版本分布关系 

在场景二的基础上,安装 pkg-d@1.0.0,而它也依赖 pkg-b@2.0.0。同样的,由于根目录下已存在 pkg-b@1.0.0 的依赖,npm v3 会把 pkg-b@2.0.0 安装到 pkg-d@1.0.0 依赖的 node_modules 目录。

靓仔疑惑:你可能会疑问,此时存在2个 pkg-b@2.0.0 和1个 pkg-b@1.0.0,出现在顶级安装目录的不应该是 v2 版本而非 v1 版本嘛?

其实这是由依赖的安装顺序决定的,真就是依赖的某个版本如果出现在合适的时间,那么它就会被安装到顶级 node_modules 目录。不同版本的出场顺序导致依赖结构的差异,npm v3 注定不是稳定的包管理工具。跟生活一样,人物的出场顺序很重要,它决定了你在哪里做什么事。

场景四:依赖版本存在重复和可用 

在场景三的基础上,安装 pkg-e@1.0.0,它依赖 pkg-b@1.0.0。由于顶级目录已存在目标版本,因此 npm v3 会跳过该依赖的安装。

场景五:版本升级囧境在

场景三的基础上,如果更新了 pkg-a@2.0.0,同时它的依赖是 pkg-b@2.0.0。那么 npm v3 的执行顺序是,删除 pkg-a@1.0.0,安装 pkg-a@2.0.0,安装 pkg-b@2.0.0,留下了 pkg-b@1.0.0 在顶层目录,因此 pkg-b@2.0.0 会安装到其父依赖的 node_modules 目录。

场景六:依赖版本多目录存在且符合复用条件 

在场景五的基础上,更新 pkg-e@2.0.0,它依赖了 pkg-b@2.0.0。那么 npm v3 的执行顺序是,删除 pkg-a@1.0.0,安装 pkg-e@2.0.0,删除 pkg-b@1.0.0,安装 pkg-b@2.0.0,于是出现以下结构。

此时你会发现,存在多个 pkg-b@2.0.0 分布在不同的 node_modules 目录,他们是不是只要在顶级目录存在一份即可?没错,我们删除 node_modules 目录重装,得到的就是你想的清晰的结构。实际上,更优雅的方式是使用 npm dedupe 命令达到上述结构。而 yarn 在安装依赖时会自动执行 dedupe 命令。

正是由于上述一些 npm 历史的坑,所以更建议使用 yarn 作为项目协作的包管理工具。当然 npm 发展至今,很多问题已经优化掉,现在 yarn 和 npm 是两款互相看齐、互相获取灵感的依赖管理工具。

npm vs. yarn 💪

这里简单对比 npm v6 和 yarn v1. 这是我们生产开发常用的版本。

npm 和 yarn 作为两款相似的包管理工具,在一些功能实现上它们互相获取灵感。

相同点:

  1. package.json 作为项目依赖描述文件。
  2. node_modules 作为依赖存储目录,yarn v2 不再是这样。
  3. lockfile 锁定版本依赖,在 yarn 中叫 yarn.lock,在 npm 中叫 package-lock.json,在 npm v7 也支持了 yarn.lock。它确保在不同机器或不同环境中,能够得到稳定的 node_modules 目录结构。

差异:

  1. 依赖管理策略。
  2. lockfile。package-lock.json 自带版本锁定+依赖结构,你想改动一些依赖,可能影响的范围要比表面看起来的复杂的多;而 yarn.lock 自带版本锁定,并没有确定的依赖结构,使用 yarn 管理项目依赖,需要 package.json + yarn.lock 共同确定依赖的结构。
  3. 性能。(对比 npm v6 和 yarn v1)目前 npm v7 优化了缓存和下载网络策略,性能的差异在缩小。

[拓展] npm 企业级部署私服原理

npm 中的源(registry),其实就是一个查询服务。以 npmjs.org 为例,它的查询服务网址是 https://registry.npmjs.org/ ,在这个网址后加上依赖的名字,就会得到一个 JSON 对象,里面包含了依赖所有的信息。例如:

  • https://registry.npmjs.org/react
  • https://registry.npm.taobao.org/react

我们可以通过 npm config set registry 命令来设置安装源。你知道我们公司为什么要部署私有的 npm 镜像吗?虽然 npm 并没有被屏蔽,但是下载第三方依赖包的速度依然较缓慢,这严重影响 CI/CD 流程或本地开发效率。通常我们认为部署 npm 私服具备以下优点:

  1. 确保高速、稳定的 npm 服务
  2. 确保发布私有模块的安全性
  3. 审核机制可以保障私服上 npm 模块质量和安全

部署企业级私服,能够获得安全、稳定、高速的保障。

管理项目依赖的小技巧(集思广益...)

  1. 推荐使用 yarn 作为团队包管理工具,而不是 npm。尽管在 npm v6 之后的版本趋向稳定和安全,但由于历史原因和团队管理兼容性,仍然是推荐使用 yarn 作为团队统一的包管理工具。
  2. 项目中一定要存在 lockfile 文件,且禁止手动修改,因为这是项目稳定性运行的保障。
  3. 如果 yarn.lock 在代码合并的过程中出现了问题,可以尝试使用 yarn install 解决问题。
  4. ...

参考资料

  • npm install[1]
  • yarn install[2]
  • yarn.lock[3]
  • NPM vs. Yarn: Which Package Manager Should You Choose?[4]
  • Lockfiles should be committed on all projects[5]

参考资料

[1]

npm install: https://docs.npmjs.com/cli/v7/commands/npm-install

[2]

yarn install: https://classic.yarnpkg.com/en/docs/cli/install

[3]

yarn.lock: https://classic.yarnpkg.com/en/docs/yarn-lock

[4]

NPM vs. Yarn: Which Package Manager Should You Choose?: https://www.whitesourcesoftware.com/free-developer-tools/blog/npm-vs-yarn-which-should-you-choose/

[5]

Lockfiles should be committed on all projects: https://classic.yarnpkg.com/blog/2016/11/24/lockfiles-for-all/

❤️ 谢谢支持

我们来自字节跳动,是旗下大力教育前端部门,负责字节跳动教育全线产品前端开发工作。

我们围绕产品品质提升、开发效率、创意与前沿技术等方向沉淀与传播专业知识及案例,为业界贡献经验价值。包括但不限于性能监控、组件库、多端技术、Serverless、可视化搭建、音视频、人工智能、产品设计与营销等内容。

欢迎感兴趣的同学在评论区或使用内推码内推到作者部门拍砖哦 🤪

字节跳动校/社招内推码:  WKVVX3V

投递链接: https://jobs.toutiao.com/s/dLLnGv


最后



如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-)

  2. 欢迎加我微信「 sherlocked_93 」拉你进技术群,长期交流学习...

  3. 关注公众号「前端下午茶」,持续为你推送精选好文,也可以加我为好友,随时聊骚。


点个在看支持我吧,转发就更好了




浏览 81
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报