• 前端基础建设与架构03 CI 环境上的 npm 优化及更多工程化问题解析


    前两讲,我们围绕着 npm 和 Yarn 的核心原理展开了讲解,实际上 npm 和 Yarn 涉及项目的方方面面,加之本身设计复杂度较高,这一讲我将继续讲解 CI 环境上的 npm 优化以及更多工程化相关问题。希望通过这一讲的学习你能够学习到 CI 环境上使用包管理工具的方方面面,并能够解决非本地环境下(一般是在容器上)使用包管理工具解决相关问题。

    CI 环境上的 npm 优化

    CI 环境下的 npm 配置和开发者本地 npm 操作有些许不同,接下来我们一起看看 CI 环境上的 npm 相关优化。

    合理使用 npm ci 和 npm install

    顾名思义,npm ci 就是专门为 CI 环境准备的安装命令,相比 npm install 它的不同之处在于:

    • npm ci 要求项目中必须存在 package-lock.json 或 npm-shrinkwrap.json;

    • npm ci 完全根据 package-lock.json 安装依赖,这可以保证整个开发团队都使用版本完全一致的依赖;

    • 正因为 npm ci 完全根据 package-lock.json 安装依赖,在安装过程中,它不需要计算求解依赖满足问题、构造依赖树,因此安装过程会更加迅速;

    • npm ci 在执行安装时,会先删除项目中现有的 node_modules,然后全新安装;

    • npm ci 只能一次安装整个项目所有依赖包,无法安装单个依赖包;

    • 如果 package-lock.json 和 package.json 冲突,那么 npm ci 会直接报错,并非更新 lockfiles;

    • npm ci 永远不会改变 package.json 和 package-lock.json。

    基于以上特性,我们在 CI 环境使用 npm ci 代替 npm install,一般会获得更加稳定、一致和迅速的安装体验

    更多 npm ci 的内容你也可以在官网查看。

    使用 package-lock.json 优化依赖安装时间

    上面提到过,对于应用项目,建议上传 package-lock.json 到仓库中,以保证依赖安装的一致性。事实上,如果项目中使用了 package-lock.json 一般还可以显著加速依赖安装时间。这是因为package-lock.json 中已经缓存了每个包的具体版本和下载链接,你不需要再去远程仓库进行查询,即可直接进入文件完整性校验环节,减少了大量网络请求

    除了上面所述内容,CI 环境上,缓存 node_modules 文件也是企业级使用包管理工具常用的优化做法。

    更多工程化相关问题解析

    下面这部分,我将通过剖析几个问题,来加深你对这几讲学习概念的理解,以及对工程化中可能遇到的问题进行预演。

    为什么要 lockfiles,要不要提交 lockfiles 到仓库?

    从 npm v5 版本开始,增加了 package-lock.json 文件。我们知道package-lock.json 文件的作用是锁定依赖安装结构,目的是保证在任意机器上执行 npm install 都会得到完全相同的 node_modules 安装结果

    你需要明确,为什么单一的 package.json 不能确定唯一的依赖树:

    • 不同版本的 npm 的安装依赖策略和算法不同;

    • npm install 将根据 package.json 中的 semver-range version 更新依赖,某些依赖项自上次安装以来,可能已发布了新版本。

    因此,保证能够完整准确地还原项目依赖,就是 lockfiles 出现的原因。

    首先我们了解一下 package-lock.json 的作用机制。上一讲中我们已经解析了 yarn.lock 文件结构,这里我们看下 package-lock.json 的内容举例:

    "@babel/core": {
    	  "version": "7.2.0",
    	  "resolved": "http://www.npm.com/@babel%2fcore/-/core-7.2.0.tgz",
    	  "integrity": "sha1-pN04FJAZmOkzQPAIbphn/voWOto=",
    	  "dev": true,
    	  "requires": {
    	    "@babel/code-frame": "^7.0.0",
    	    // ...
    	  },
    	  "dependencies": {
    	    "@babel/generator": {
    	      "version": "7.2.0",
    	      "resolved": "http://www.npm.com/@babel%2fgenerator/-/generator-7.2.0.tgz",
    	      "integrity": "sha1-6vOCH6AwHZ1K74jmPUvMGbc7oWw=",
    	      "dev": true,
    	      "requires": {
    	        "@babel/types": "^7.2.0",
    	        "jsesc": "^2.5.1",
    	        "lodash": "^4.17.10",
    	        "source-map": "^0.5.0",
    	        "trim-right": "^1.0.1"
    	      }
    	    },
    	    // ...
    	  }
    	},
    	// ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    通过上述代码示例,我们看到:一个 package-lock.json 的 dependency 主要由以下部分构成。

    • Version:依赖包的版本号

    • Resolved:依赖包安装源(可简单理解为下载地址)

    • Integrity:表明包完整性的 Hash 值

    • Dev:表示该模块是否为顶级模块的开发依赖或者是一个的传递依赖关系

    • requires:依赖包所需要的所有依赖项,对应依赖包 package.json 里 dependencies 中的依赖项

    • dependencies:依赖包 node_modules 中依赖的包(特殊情况下才存在)

    事实上,并不是所有的子依赖都有 dependencies 属性,只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,才会有这个属性。这就涉及嵌套情况的依赖管理,我已经在前文做了说明。

    至于要不要提交 lockfiles 到仓库?这就需要看项目定位决定了。

    • 如果开发一个应用,我建议把 package-lock.json 文件提交到代码版本仓库。这样可以保证项目组成员、运维部署成员或者 CI 系统,在执行 npm install 后,能得到完全一致的依赖安装内容。

    • 如果你的目标是开发一个给外部使用的库,那就要谨慎考虑了,因为库项目一般是被其他项目依赖的,在不使用 package-lock.json 的情况下,就可以复用主项目已经加载过的包,减少依赖重复和体积

    • 如果我们开发的库依赖了一个精确版本号的模块,那么提交 lockfiles 到仓库可能会造成同一个依赖不同版本都被下载的情况。如果作为库开发者,真的有使用某个特定版本依赖的需要,一个更好的方式是定义 peerDependencies

    因此,一个推荐的做法是:把 package-lock.json 一起提交到代码库中,不需要 ignore。但是执行 npm publish 命令,发布一个库的时候,它应该被忽略而不是直接发布出去

    理解上述概念并不够,对于 lockfiles 的处理,你需要更加精细。这里我列出几条建议供你参考。

    1. 早期 npm 锁定版本的方式是使用 npm-shrinkwrap.json,它与 package-lock.json 不同点在于:npm 包发布的时候默认将 npm-shrinkwrap.json 发布,因此类库或者组件需要慎重。

    2. 使用 package-lock.json 是 npm v5.x 版本新增特性,而 npm v5.6 以上才逐步稳定,在 5.0 - 5.6 中间,对 package-lock.json 的处理逻辑进行过几次更新。

    3. 在 npm v5.0.x 版本中,npm install 时都会根据 package-lock.json 文件下载,不管 package.json 内容究竟是什么。

    4. npm v5.1.0 版本到 npm v5.4.2,npm install 会无视 package-lock.json 文件,会去下载最新的 npm 包并且更新 package-lock.json。

    5. npm 5.4.2 版本后:

    • 如果项目中只有 package.json 文件,npm install 之后,会根据它生成一个 package-lock.json 文件;

    • 如果项目中存在 package.json 和 package-lock.json 文件,同时 package.json 的 semver-range 版本 和 package-lock.json 中版本兼容,即使此时有新的适用版本,npm install 还是会根据 package-lock.json 下载;

    • 如果项目中存在 package.json 和 package-lock.json 文件,同时 package.json 的 semver-range 版本和 package-lock.json 中版本不兼容,npm install 时 package-lock.json 将会更新到兼容 package.json 的版本;

    • 如果 package-lock.json 和 npm-shrinkwrap.json 同时存在于项目根目录,package-lock.json 将会被忽略。

    以上内容你可以结合 01 讲中 npm 安装流程进一步理解。

    为什么有 xxxDependencies?

    npm 设计了以下几种依赖类型声明:

    • dependencies 项目依赖

    • devDependencies 开发依赖

    • peerDependencies 同版本依赖

    • bundledDependencies 捆绑依赖

    • optionalDependencies 可选依赖

    它们起到的作用和声明意义各不相同。dependencies 表示项目依赖,这些依赖都会成为线上生产环境中的代码组成部分。当它关联的 npm 包被下载时,dependencies 下的模块也会作为依赖,一起被下载

    devDependencies 表示开发依赖,不会被自动下载,因为 devDependencies 一般只在开发阶段起作用或只是在开发环境中需要用到。比如 Webpack,预处理器 babel-loader、scss-loader,测试工具 E2E、Chai 等,这些都是辅助开发的工具包,无须在生产环境使用。

    这里需要特别说明的是:并不是只有在 dependencies 中的模块才会被一起打包,而在 devDependencies 中的依赖一定不会被打包。实际上,依赖是否被打包,完全取决于项目里是否被引入了该模块。dependencies 和 devDependencies 在业务中更多的只是一个规范作用,我们自己的应用项目中,使用 npm install 命令安装依赖时,dependencies 和 devDependencies 内容都会被下载。

    peerDependencies 表示同版本依赖,简单来说就是:如果你安装我,那么你最好也安装我对应的依赖。举个例子,假设 react-ui@1.2.2 只提供一套基于 React 的 UI 组件库,它需要宿主环境提供指定的 React 版本来搭配使用,因此我们需要在 React-ui 的 package.json 中配置:

    "peerDependencies": {
        "React": "^17.0.0"
    }
    
    • 1
    • 2
    • 3
    • 1
    • 2
    • 3

    举一个场景实例,对于插件类 (Plugin) 项目,比如我开发一个 Koa 中间件,很明显这类插件或组件脱离(Koa)本体是不能单独运行且毫无意义的,但是这类插件又无须声明对本体(Koa)的依赖声明,更好的方式是使用宿主项目中的本体(Koa)依赖。这就是peerDependencies 主要的使用场景。这类场景有以下特点:

    • 插件不能单独运行

    • 插件正确运行的前提是核心依赖库必须先下载安装

    • 我们不希望核心依赖库被重复下载

    • 插件 API 的设计必须要符合核心依赖库的插件编写规范

    • 在项目中,同一插件体系下,核心依赖库版本最好相同

    bundledDependencies 和 npm pack 打包命令有关。假设 package.json 中有如下配置:

    {
      "name": "test",
      "version": "1.0.0",
      "dependencies": {
        "dep": "^0.0.2",
        ...
      },
      "devDependencies": {
        ...
        "devD1": "^1.0.0"
      },
      "bundledDependencies": [
        "bundleD1",
        "bundleD2"
      ]
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    在执行 npm pack 时,就会产出一个 test-1.0.0.tgz 压缩包,且该压缩包中包含了 bundle D1 和 bundle D2 两个安装包。业务方使用 npm install test-1.0.0.tgz 命令时,也会安装 bundle D1 和 bundle D2。

    这里你需要注意的是:在 bundledDependencies 中指定的依赖包,必须先在 dependencies 和 devDependencies 声明过,否则在 npm pack 阶段会进行报错

    optionalDependencies 表示可选依赖,就是说即使对应依赖项安装失败了,也不会影响整个安装过程。一般我们很少使用到它,这里我也不建议大家使用,因为它大概率会增加项目的不确定性和复杂性

    学习了以上内容,现在你已经知道 npm 规范中的相关依赖声明含义了,接下来我们再谈谈版本规范,帮助你进一步解析依赖库锁版本行为。

    再谈版本规范——依赖库锁版本行为解析

    npm 遵循 SemVer 版本规范,具体内容你可以参考语义化版本 2.0.0,这里不再展开。这部分内容我希望聚焦到工程建设的一个细节点上——依赖库锁版本行为。

    Vue 官方有这样的内容

    每个 vue 包的新版本发布时,一个相应版本的 vue-template-compiler 也会随之发布。编译器的版本必须和基本的 vue 包保持同步,这样 vue-loader 就会生成兼容运行时的代码。这意味着你每次升级项目中的 vue 包时,也应该匹配升级 vue-template-compiler。

    据此,我们需要考虑的是:作为库开发者,如何保证依赖包之间的强制最低版本要求?

    我们先看看 create-react-app 的做法,在 create-react-app 的核心 react-script 当中,它利用 verify PackageTree 方法,对业务项目中的依赖进行比对和限制。源码如下:

    function verifyPackageTree() {
      const depsToCheck = [
        'babel-eslint',
        'babel-jest',
        'babel-loader',
        'eslint',
        'jest',
        'webpack',
        'webpack-dev-server',
      ];
      const getSemverRegex = () =>
        /\bv?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?\b/gi;
      const ownPackageJson = require('../../package.json');
      const expectedVersionsByDep = {};
      depsToCheck.forEach(dep => {
        const expectedVersion = ownPackageJson.dependencies[dep];
        if (!expectedVersion) {
          throw new Error('This dependency list is outdated, fix it.');
        }
        if (!getSemverRegex().test(expectedVersion)) {
          throw new Error(
            `The ${dep} package should be pinned, instead got version ${expectedVersion}.`
          );
        }
        expectedVersionsByDep[dep] = expectedVersion;
      });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    let currentDir = __dirname;

    while (true) {
    const previousDir = currentDir;
    currentDir = path.resolve(currentDir, ‘…’);
    if (currentDir === previousDir) {
    // We’ve reached the root.
    break;
    }
    const maybeNodeModules = path.resolve(currentDir, ‘node_modules’);
    if (!fs.existsSync(maybeNodeModules)) {
    continue;
    }
    depsToCheck.forEach(dep => {
    const maybeDep = path.resolve(maybeNodeModules, dep);
    if (!fs.existsSync(maybeDep)) {
    return;
    }
    const maybeDepPackageJson = path.resolve(maybeDep, ‘package.json’);
    if (!fs.existsSync(maybeDepPackageJson)) {
    return;
    }
    const depPackageJson = JSON.parse(
    fs.readFileSync(maybeDepPackageJson, ‘utf8’)
    );
    const expectedVersion = expectedVersionsByDep[dep];
    if (!semver.satisfies(depPackageJson.version, expectedVersion)) {
    console.error(//…);
    process.exit(1);
    }
    });
    }
    }

    根据上述代码,我们不难发现,create-react-app 会对项目中的 babel-eslint、babel-jest、babel-loader、ESLint、Jest、webpack、webpack-dev-server 这些核心依赖进行检索——是否符合 create-react-app 对这些核心依赖的版本要求。如果不符合依赖版本要求,那么 create-react-app 的构建过程会直接报错并退出

    create-react-app 这么做的理由是:需要上述依赖项的某些确定版本,以保障 create-react-app 源码的相关功能稳定

    我认为这样做看似强硬且无理由,实则是对前端社区、npm 版本混乱现象的一种妥协。这种妥协确实能保证 create-react-app 的正常构建工作。因此现阶段来看,也不失为一种值得推荐的做法。而作为 create-react-app 的使用者,我们依然可以通过 SKIP_PREFLIGHT_CHECK 这个环境变量,跳过核心依赖版本检查,对应源码

    const verifyPackageTree = require('./utils/verifyPackageTree');
    if (process.env.SKIP_PREFLIGHT_CHECK !== 'true') {
      verifyPackageTree();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 1
    • 2
    • 3
    • 4

    create-react-app 的锁版本行为无疑彰显了目前前端社区中工程依赖问题的方方面面,从这个细节管中窥豹,希望能引起你更深入的思考。

    最佳实操建议

    前面我们讲了很多 npm 的原理和设计理念,理解了这些内容,你应该能总结出一个适用于团队的最佳实操建议。对于实操我有以下想法,供你参考。

    1. 优先使用 npm v5.4.2 以上的 npm 版本,以保证 npm 的最基本先进性和稳定性。

    2. 项目的第一次搭建使用 npm install 安装依赖包,并提交 package.json、package-lock.json,而不提交 node_modules 目录。

    3. 其他项目成员首次 checkout/clone 项目代码后,执行一次 npm install 安装依赖包。

    4. 对于升级依赖包的需求:

    • 依靠 npm update 命令升级到新的小版本;

    • 依靠 npm install @ 升级大版本;

    • 也可以手动修改 package.json 中版本号,并执行 npm install 来升级版本;

    • 本地验证升级后新版本无问题,提交新的 package.json、package-lock.json 文件。

    1. 对于降级依赖包的需求:执行 npm install @ 命令,验证没问题后,提交新的 package.json、package-lock.json 文件。

    2. 删除某些依赖:

    • 执行 npm uninstall 命令,验证没问题后,提交新的 package.json、package-lock.json 文件;

    • 或者手动操作 package.json,删除依赖,执行 npm install 命令,验证没问题后,提交新的 package.json、package-lock.json 文件。

    1. 任何团队成员提交 package.json、package-lock.json 更新后,其他成员应该拉取代码后,执行 npm install 更新依赖。

    2. 任何时候都不要修改 package-lock.json。

    3. 如果 package-lock.json 出现冲突或问题,建议将本地的 package-lock.json 文件删除,引入远程的 package-lock.json 文件和 package.json,再执行 npm install 命令。

    如果以上建议你都能理解,并能够解释其中缘由,那么这三讲内容,你已经大致掌握了。

    总结

    通过本讲学习,相信你已经掌握了在 CI 环境中优化包管理器的方法以及更多、更全面的 npm 设计规范。希望不管是在本地开发,还是 CI 环境中,你在面对包管理方面的问题时能够游刃有余,轻松面对。

    前端基建 金句.png

    随着前端的发展,npm/Yarn 也在互相借鉴,不断改进,比如 npm v7 会带来一流的 Monorepo 支持。历史总是螺旋式前进,其间可能出现困局和曲折,但是对前端从业人员来说,时刻保持对工程化理念的学习,抽丝剥茧、理清概念,必能从中受益。

    npm/Yarn 相关的话题不是一个独立的点,它是成体系的一个面,甚至可以算得上是一个完整的生态。这部分知识我们虽没有面面俱到,但是聚焦在依赖管理、安装机制、CI 提效等话题上。更多 npm 的内容,比如 npm scripts、公共库相关设计、npm 发包、npm 安全、package.json 等话题我会在后面章节中也会继续讲解,希望你能坚持学习。

    不管是本地开发环境还是 CI 环境,不管是使用 npm 还是 Yarn,都离不开构建工具。下一讲我会带你对比主流构建工具,继续深入工程化和基建的深水区。我们下一讲再见。


    精选评论

    *仔:

    老师,看了你的文章有一个问题想咨询一下,项目仓库里面有 package-lock.json;本地开发环境(node10+,npm6+)npm install 不会没有报错;但是在 Jenkins 打包环境(node8+,npm5+) npm install 偶尔会出现下面这种类型的错误; npm ERR! code EINTEGRITYnpm ERR! sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA== integrity checksum failed when using sha512: wanted sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA== but got sha512-WXI95kpJrxw4Nnx8vVI90PuUhrQjnNgghBl5tn54rUNKZYbxv+4ACxUzPVpJEtWxKmeDwnQrzjc0C2bYmRJVKg==. (65117 bytes) npm ERR! A complete log of this run can be found in:npm ERR! /home/ubuntu/.npm/_logs/2017-11-29T05_33_52_182Z-debug.log想问一下老师,这种情况一般从哪些方向思考:1.网络不稳定导致下载的依赖不完整?2.npm 版本不同导致?3.本地开发是 window10,打包服务器是 ubuntu,系统不同 npm 根据 package-lock.json ji计算依赖包的 hash 不同导致?

        讲师回复:

        首先对齐一下 CI 机器上和本地的各环境版本,包括 node npm 等

    **茏:

    可以讲讲pnpm吗,感觉挺好用的,下载也比较可观,但还是不太敢在项目开发中使用

        讲师回复:

        pnpm 非常不错,npm7 和 pnpm 其实是我认为设计上目前最好的了,纯设计上。可以大胆在项目中尝试下。

    **宇:

    如果我们开发的库依赖了一个精确版本号的模块,那么提交 lockfiles 到仓库可能会造成同一个依赖不同版本都被下载的情况。如果作为库开发者,真的有使用某个特定版本依赖的需要,一个更好的方式是定义 peerDependencies。这样做的话如果遇到了不符合规则的 peerDenpendencies,npm 也会正常安装使用,那怎么确保版本号精确

        讲师回复:

        会有 warning,业务方处理

    *滨:

    摘录:历史总是螺旋式前进,其间可能出现困局和曲折,但是对前端从业人员来说,时刻保持对工程化理念的学习,抽丝剥茧、理清概念,必能从中受益。赞

    *鑫:

    怎么看待yarn v2的巨大变化😀

        讲师回复:

        正向进化

    **的小叶酱:

    现在项目里,yarn和npm都在使用,请问更推荐使用哪个呢

        讲师回复:

        目前看区别不大,我个人喜欢 yarn

    L:

    bundledDependencies 的意义是什么?如果只是需要这个依赖得话,那为什么不直接使用 dependencies 呢?

        讲师回复:

        恰好这里有个人跟你疑问一样:https://stackoverflow.com/questions/11207638/advantages-of-bundleddependencies-over-normal-dependencies-in-npm 简单来说 使用 bundledDependencies,直接把我的依赖打包进来,这是最安全的——不管 npm 机制怎么变,我都是最安全的,直接打包我的依赖

    **3335:

    我们的CI/CD环境,能缓存上次构建的node_modules目录。那如果换成npm ci 安装,先删除 node_modules。会不会反而减慢了下载依赖的速度?

        讲师回复:

        是存在这种可能的,一般缓存肯定会更快,如果单纯从头安装的话,CI 依赖确定的依赖树,会有速度优势

  • 相关阅读:
    虹科分享 | 知识产权盗窃:它是什么以及如何预防
    CentOS中实现基于Docker部署BI数据分析
    Golang反射相关知识总结
    Flask安装
    使用react-amanda快速搭建管理类型的系统
    2M大小windows11 改 windows10
    日志轮转logrotate
    Element UI库 之 el-upload 图片上传组件多次上传或重新上传失效bug
    鲜花商城管理系统
    统一建模语言UML(1~8章在线测试参考答案)
  • 原文地址:https://blog.csdn.net/fegus/article/details/126813607