NPM 原理简介

780 阅读8分钟

npm原理

1. 怎样算个npm包

只要符合以下 a) 到 g) 其中之一条件,就是一个 package:

说明#例子
一个包含了程序和描述该程序的 package.json 文件 的 文件夹a)./local-module/
一个包含了 (a) 的 gzip 压缩文件b)./module.tar.gz
一个可以下载得到 (b) 资源的 url (通常是 http(s) url)c)registry.npmjs.org/webpack/-/w…
一个格式为 <name>@<version> 的字符串,可指向 npm 源(通常是官方源 npmjs.org)上已发布的可访问 url,且该 url 满足条件 (c)d)webpack@4.1.0
一个格式为 <name>@<tag> 的字符串,在 npm 源上该<tag>指向某 <version> 得到 <name>@<version>,后者满足条件 (d)e)webpack@latest
一个格式为 <name> 的字符串,默认添加 latest 标签所得到的 <name>@latest 满足条件 (e)f)webpack
一个 git url, 该 url 所指向的代码库满足条件 (a)g)git@github.com:webpack/webpack.git

上面表格的定义意味着,我们在共享依赖包时,并不是非要将包发表到 npm 源上才可以提供给使用者来安装。这对于私有的不方便 publish 到远程源(即使是私有源),或者需要对某官方源进行改造,但依然需要把包共享出去的场景来说非常实用。

2. npm的几种依赖:

  • dependencies 项目依赖
  • devDependencies 开发依赖
  • peerDependencies 同版本的依赖(宿主依赖)
  • bundledDependencies 捆绑依赖
  • optionalDependencies 可选依赖(依赖安装失败都不会对整个安装有影响,最好勿用)

它们起到的作用和声明意义是各不相同的。下面我们来具体介绍一下:

  1. dependencies 表示项目依赖,这些依赖都会成为你的线上生产环境中的代码组成的部分。当 它关联到 npm 包被下载的时候,dependencies下的模块也会作为依赖, 一起被下载。
  2. devDependencies表示开发依赖, 不会被自动下载的。因为 devDependencies 一般是用于开发阶段起作用或是只能用于开发环境中被用到的。 比如说我们用到的 Webpack,预处理器 babel-loaderscss-loader,测试工具E2E等, 这些都相当于是辅助的工具包, 无需在生产环境被使用到的。

并不是只有在dependencies中的模块才会被一起打包, 而是在 devDependencies 中的依赖一定不会被打包的。 实际上, 依赖是否是被打包,完全是取决你的项目里的是否是被引入了该模块

  1. peerDependencies 表示同版本的依赖, 简单一点说就是: 如果你已经安装我了, 那么你最好也安装我对应的依赖。 举个小例子: 加入我们需要开发一个react-ui 就是一个基于react 开发的UI组件库, 它本身是会需要一个宿主环境去运行的, 这个宿主环境还需要指定的 react版本来搭配使用的, 所以需要我们去 package.json中去配置:
{
  "name": "react-ui",
  "version": "1.3.5",
  "peerDependencies": {
    "react": "2.x"
  }
}
  1. bundledDependencies:数组
{
  "name": "awesome-web-framework",
  "version": "1.0.0",
  "bundledDependencies": [
    "renderized", "super-streams"
  ]
}

如果你需要在本地保存npm包,或者通过单个文件下载来获得这些包,你可以通过在bundledDependencies数组中指定包名并执行npm pack,将包捆绑在一个tarball文件中。

假定上述是我们配置的包依赖信息。执行npm pack会将renderized, super-streams两个包一起打包到awesome-web-framework中,当下次在新项目中执行npm i awesome-web-framework时会将这两个包一起下载下来。

3. lock文件

package.lock.json文件,如果项目中没有lock文件。那么将在执行npm install时自动生成与package.json同级的文件。

它的作用主要用来锁默认的依赖版本,最好让别人的依赖和自己的依赖保持统一,这样的错误率最小。

"devDependencies": {
    "@commitlint/cli": "^13.2.1",
    "@commitlint/config-conventional": "^13.2.0",
    "commitizen": "^4.2.4",
    "cpx": "^1.5.0",
    "cz-customizable": "^6.3.0",
    "husky": "^7.0.2"
}

一个小知识:

  • ~会匹配最近的小版本依赖包,比如~1.2.3会匹配所有1.2.x版本,但是不包括1.3.0
  • ^会匹配最新的大版本依赖包,比如^1.2.3会匹配所有1.x.x的包,包括1.3.0,但是不包括2.0.0
  • *会安装最新版本的依赖包

4. npm的扁平化优化:

旧版本的npm安装依赖会存在依赖地狱的问题,类比js中的回调地狱。

{
  "name": "my-app",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4",
  }
}
{
  "name": "buffer",
  "dependencies": {
    "base64-js": "^1.0.2",
    "ieee754": "^1.1.4"
  }
}

一层一层依赖下去,照成无止境的依赖关联。

  • 当遇到相同模块时,判断已放置在依赖树的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下放置该模块。

5. 缓存机制:

在执行 npm installnpm update命令下载依赖后,除了将依赖包安装在node_modules 目录下外,还会在本地的缓存目录缓存一份。

  1. 查看npm本地缓存的命令:

    npm config get cache
    // ~\AppData\Local\npm-cache_cacache
    

    其实你也看到了_cacache的目录有三个文件:

    • content-v2(存储 tar包的缓存,二进制的文件,具体资源)
    • index-v5( 存储tar包的 hash,content-v2 文件的索引)
    • tmp
  2. npm下载依赖的时候, 先下载到缓存当中,再解压到我们的项目的 node_modules中。 其实 pacote是依赖npm-registry-fetch来下载包, npm-registry-fetch 可以通过设置 cache 字段进行相关的缓存工作。

  1. !!! !!! 紧接着呢, 我们在每次去安装资源的时候,会根据package-lock.json中的integrity、verison、name 相关信息会生成一个唯一的key;

    这个key 就能够对应上 index-v5 目录下的缓存记录;

    如果发现有缓存资源,就会去找到 tar 包对应的hash值. 根据 hash再去找缓存中的tar包,

    然后再次通过 pacote将二进制文件解压缩进我们项目的 node_modules目录中,这样就省去了资源下载的网络开销。

5. package.json 的不足之处

某些依赖项自上次安装以来,可能已发布了新版本 。会导致代码拉下来之后相关依赖升级,带来一些依赖版本不同的bug。

解决办法

在 npm 5.0 版本后,npm install 后都会自动生成一个 package-lock.json 文件 ,当包中有 package-lock.json 文件时,npm install 执行时,如果 package.json 和 package-lock.json 中的版本兼容,会根据 package-lock.json 中的版本下载;如果不兼容,将会根据 package.json 的版本,更新 package-lock.json 中的版本,已保证 package-lock.json 中的版本兼容 package.json。

6. 整体过程:

  • 检查 .npmrc 文件:优先级为:项目级的 .npmrc 文件 > 用户级的 .npmrc 文件> 全局级的 .npmrc 文件 > npm 内置的 .npmrc 文件

  • 检查项目中有无 lock 文件。

  • 无 lock

    文件:

    • npm 远程仓库获取包信息

    • 根据

      package.json
      

      构建依赖树,构建过程:

      • 构建依赖树时,不管其是直接依赖还是子依赖的依赖,优先将其放置在 node_modules 根目录。
      • 当遇到相同模块时,判断已放置在依赖树的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下放置该模块。
      • 注意这一步只是确定逻辑上的依赖树,并非真正的安装,后面会根据这个依赖结构去下载或拿到缓存中的依赖包
    • 在缓存中依次查找依赖树中的每个包

      • 不存在缓存:

        • npm 远程仓库下载包

        • 校验包的完整性

        • 校验不通过:

          • 重新下载
        • 校验通过:

          • 将下载的包复制到 npm 缓存目录
          • 将下载的包按照依赖结构解压到 node_modules

      存在缓存:将缓存按照依赖结构解压到 node_modules

    • 将包解压到 node_modules

    • 生成 lock 文件

lock 文件:

  • 检查 package.json 中的依赖版本是否和 package-lock.json 中的依赖有冲突。
  • 如果没有冲突,直接跳过获取包信息、构建依赖树过程,开始在缓存中查找包信息,后续过程相同

npm script

每当执行npm run,就会自动新建一个 Shell,在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(一般是 Bash)可以运行的命令,就可以写在 npm 脚本里面。

比较特别的是,npm run新建的这个 Shell,会将当前目录的node_modules/.bin子目录加入PATH变量,执行结束后,再将PATH变量恢复原样。

这意味着,当前目录的node_modules/.bin子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。比如,当前项目的依赖里面有 Mocha,只要直接写mocha test就可以了。

也可以使用npx调用项目安装的模块。npx 的原理很简单,就是运行的时候,会到node_modules/.bin路径和环境变量$PATH里面,检查命令是否存在。

1.png

2.png

通配符

*表示任意文件名,**表示任意一层子目录。

"lint": "jshint *.js"
"lint": "jshint **/*.js"

执行顺序

如果是并行执行(即同时的平行执行),可以使用&符号连接。

继发执行(即只有前一个任务成功,才执行下一个任务),可以使用&&符号。

钩子

npm 脚本有prepost两个钩子。举例来说,build脚本命令的钩子就是prebuildpostbuild

npm 默认提供下面这些钩子。

prepublish,postpublish
preinstall,postinstall
preuninstall,postuninstall
preversion,postversion
pretest,posttest
prestop,poststop
prestart,poststart
prerestart,postrestart

参数

向 npm 脚本传入参数,要使用--标明。

参考目录:

npm install 原理分析

字节的一个小问题 npm 和 yarn不一样吗?

npm scripts 使用指南

npx使用教程