《打包系列|工程化管理工具篇》

介绍

前端工程化离不开npm或者Yarn 这些管理工具。npm或Yarn 在工程项目中,除了负责依赖的安装和维护以外,还能通过 npm scripts 串联起各个职能部分,让独立的环节自动运转起来。另外在使用管理工具的时候会遇到一些问题如下:

  • 项目依赖出现问题时,删除大法好,即删除 node_modules 和 lockfiles,再重新 install,这样操作是否存在风险

    重新安装可能会改变依赖版本, 是有风险的
    因为删除lock文件以后, 模块的安装顺序可能影响 node_modules 内的文件结构
    insatll命令执行的时候,获取依赖包的顺序不一样,可能导致扁平化机制生成的依赖树不一样

  • 我们的应用依赖了公共库 A 和公共库 B,同时公共库 A 也依赖了公共库 B,那么公共库 B 会被多次安装或重复打包吗

    看版本范围是否在一个范围内,同一个版本范围,不会重复。

  • 一个项目中,既有人用 npm,也有人用 Yarn,这会引发什么问题

    lock文件不同,可能会存在冲突,导致最终安装版本不一致。

  • 我们是否应该提交 lockfiles 文件到项目仓库呢

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

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

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

  • 为什么单一的 package.json 不能确定唯一的依赖树

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

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

  • 为什么有时候可以离线安装一些包

    因为包管理工具的缓存机制

另外很多时候我们在配置package.json中除了scripts属性的配置,其他的配置也会有些陌生,npm和yarn的一些操作也不太懂.

关于package.json

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
{
name: 'woyao_cli',
// 执行脚本命令
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
// 当不配置 homepage 属性,build 打包之后的文件资源应用路径默认是 /
// 当你设置了 homepage 属性后, 打包后的资源路径就会加上 homepage 的地址
// 相当于output的publicPath
homepage: '/',
// bin的作用,它是一个命令名和本地文件名的映射。
// 在安装时,如果是全局安装,npm将会使用符号链接把这些文件链接到prefix/bin
// 如果是本地安装,会链接到./node_modules/.bin/
// 通俗点理解如果我们是全局安装我们就可以在全局命令行中执行bin指向的这个文件
// 本地安装我们只能在在当前工程目录的命令行中执行bin指向的该文件。
// 其实做的事情就是在node_modules/bin文件夹下加入一个命令名, 命令名会映射到我们bin指向的文件
// 请确保你的bin指向的文件里面最开头写上 #!/usr/bin/env node
// 这样才能保证该文件的执行环境是在node中,否则你还得这么写 node woyao_cli xxx
// 确保环境以后你就可以这么写woyao_cli xxx
// 命令名的配置如果是单一文件,默认就是package.json中的name字段
// 当然也可以自己设置: {"bin": {"woyao_cli": 'index.js'}}
// 这样在别的机器中全局安装我们这个项目包的时候
bin: 'index.js',
// 代码入口
// 再写公共库的时候这个配置项很重要
// 比如我们打包后的文件放在dist文件下,包名叫做instance
// 别的业务项目要引用我们的包需要这样require('instance/dist/inddex.js')
// 如果指定main:'dist/index.js',那么就可以直接require('instance')了
main: 'index.js',
// "main": "dist/index.cjs.js", // 打包出一份commonjs规范的bundle
// "module": "dist/index.esm.js", // 打包出一份tree shaking后的ESM规范的bundle
// module 一般用于和main进行区分
module: 'xxxx.js',
// 代码运行时所需要的依赖
"dependencies": {
"core-js": "^3.6.5",
"vue": "^3.0.0"
},
// 开发依赖,就是那些只在开发过程中需要,而运行时不需要的依赖
// 也就是说打包过程要用到的依赖,打包以后你就不再要用到了
"devDependencies": {
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0"
},
// 可选依赖,这种依赖即便安装失败,Yarn也会认为整个依赖安装过程是成功的
"optionalDependences": {

},
// 同版本依赖
// 如果你安装我,那么你最好也安装我对应的依赖。
// 举个例子,假设 react-ui@1.2.2
// 只提供一套基于 React 的 UI 组件库,
// 它需要宿主环境提供指定的 React 版本来搭配使用
// 另外在开发公共包的时候,配置同版本依赖
// 可以解决核心依赖库被重复下载
// 在有一个 helloWorld 工程,
// 已经在其 package.json 的 dependencies 中声明了 packageA,
// 有两个插件 plugin1 和 plugin2 他们也依赖 packageA
// 如果在插件中使用 dependencies 而不是 peerDependencies 来声明 packageA
// 那么 $ npm install 安装完 plugin1 和 plugin2 之后的依赖图是这样的:
/*
├── helloWorld
│ └── node_modules
│ ├── packageA
│ ├── plugin1
│ │ └── nodule_modules
│ │ └── packageA
│ └── plugin2
│ │ └── nodule_modules
│ │ └── packageA
*/
// 而 peerDependency 就可以避免类似的核心依赖库被重复下载的问题
"peerDependencies": {
"classnames": "^2.2.6"
}
}
/* 注意:
并不是只有在 dependencies 中的模块才会被一起打包,
而在 devDependencies 中的依赖一定不会被打包。
实际上,依赖是否被打包,完全取决于项目里是否被引入了该模块。
dependencies 和 devDependencies 在业务中更多的只是一个规范作用,
我们自己的应用项目中,使用 npm install 命令安装依赖时,
dependencies 和 devDependencies 内容都会被下载。
*/

npm

npm的安装机制

注意

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
29
30
31
32
33
34
35
36
37
38
1. 这里的config配置(npm配置)是指`.npmrc文件`
(项目级的 .npmrc 文件> 用户级的 .npmrc 文件> 全局级的
.npmrc 文件 > npm 内置的 .npmrc 文件)。

2. 构建依赖树时,当前依赖项目不管其是直接依赖还是子依赖的依赖,
都应该按照扁平化原则,优先将其放置在 node_modules 根目录.
在这个过程中,遇到相同模块就判断已放置在依赖树中的模块版本是否符合新模块的版本范围,
如果符合则跳过;不符合则在当前模块的 node_modules 下放置该模块
3. 依赖关系:
并不是所有的子依赖都有 dependencies 属性,
只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,
才会有这个属性

"@babel/helper-annotate-as-pure": {
"version": "7.12.13",
"resolved": "https://registry.npm.taobao.org/@babel/helper-annotate-as-pure/download/@babel/helper-annotate-as-pure-7.12.13.tgz",
"integrity": "sha1-D1jobfxLs7H819uAZXDhd9Q5tqs=",
"dev": true,
"requires": {
"@babel/types": "^7.12.13"
},
"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"
}
},
// ...
}
},

npm缓存机制

对于一个依赖包的同一版本进行本地化缓存,是当代依赖包管理工具的一个常见设计。
通过npm config get cache可以获得npm缓存配置的路径, 比如:/Users/chen/.npm
切换到缓存配置的路径以后,可以看到一个_cacache文件夹。该文件夹存放依赖包的缓存。
当然你可以通过 npm cache clean --force清空缓存。打开_cacache文件夹

1
2
3
4
5
6
# cache文件夹下的目录文件:
- content-v2
存放二进制文件,将二进制文件的扩展名改为.tgz,然后进行解压,得到的结果其实就是我们的 npm 包资源
- index-v5
缓存记录
- tmp

Npm v5+ 缓存策略如下:

当 npm install 执行时,通过pacote把相应的包解压在对应的 node_modules 下面。npm 在下载依赖时,先下载到缓存当中,再解压到项目 node_modules 下。pacote 依赖npm-registry-fetch来下载包,npm-registry-fetch 可以通过设置 cache 属性,在给定的路径下根据IETF RFC 7234生成缓存数据。

接着,在每次安装资源时,根据 package-lock.json 中存储的 integrity、version、name 信息生成一个唯一的 key,这个 key 能够对应到 index-v5 目录下的缓存记录。如果发现有缓存资源,就会找到 tar 包的 hash,根据 hash 再去找缓存的 tar 包,并再次通过pacote把对应的二进制文件解压到相应的项目 node_modules 下面,省去了网络下载资源的开销。

npm v5 版本之前:

每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是:{cache}/{name}/{version}。

npm常用命令

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 调用 shell 脚本输出一个初始化的 package.json 文件
npm init

// 安装所有依赖包
npm install

// 包安装
npm install [package] --save-dev
npm install [package] --save
npm install [package] -g

// 重建
npm rebuild

// 删除包
npm uninstall [package]
npm uninstall --save [package]
npm uninstall --save-dev [package]

// 查看npm镜像
npm config get registry

// 设置npm的镜像
// ---官方镜像
npm config set registry https://registry.npmjs.org/
// ---淘宝镜像
npm config set registry https://registry.npm.taobao.org

// 用户登录
npm login

// 发布个人项目到npm上
// 第一次发布时,需要创建用户:npm adduser
npm publish

// 创建用户
npm adduse

// 可以把当前目录下node_modules子目录里边的对应模块更新至最新版本
npm update <package>

// 查看缓存存放路径
npm config get cache
// 可以清空NPM本地缓存,用于对付使用相同版本号发布新版本代码的人。
npm cache clear --force

// 可以撤销发布自己发布过的某个版本代码。
npm unpublish <package>@<version>

// 删除整个版本
npm unpublish <package>

/* 建立软链接(这个很重要)
为目标npm模块创建软链接,
将其链接到全局node模块安装路径 "/xxx/lib/node_modules/" 中
为目标npm模块的可执行bin文件创建软链接,
将其链接到全局node命令安装路径 "/usr/local/bin/" 中
*/
npm link

关于npm link 这个命令:

在开发公共包的时候,比如我开发了一个组件库, 现在组件库内新增了一个组件,但我不能发布新版本,因为不确定这个组件的正确性。那我如何确定这个新增的组件能够在我的业务项目中使用呢?使用教程的代码参考

  1. 比较笨的办法就是打包现在的组件库,然后把打包的组件库放入node_modules里面,然后在验证
  2. 正确的做法是通过npm link. (假设我们之前的组件库打包后叫做 npm-package-ui)
    1. 首先我们进入我们的组件库,执行npm link(这样 npm link 通过链接目录和可执行文件,实现 npm 包命令的全局可执行)。
    2. 然后进入本地的业务项目,执行 npm link npm-package-ui. 它就会去 /usr/local/lib/node_modules/ 这个路径下寻找是否有这个包,如果有就建立软链接.
    3. 这样我本地的业务项目就可以和本地最新的组件库建立软链接了,然后启动程序,我就可以用新组件了。不过每次npm-package-ui的更新,都需要本地业务项目重新
      启动,才可以得到新的变化。
    4. 最后测试结束以后, npm unlink取消关联,然后测试没问题就可以发布新版本的组件库包了。

npx的用法

《阮一峰npx教程》

  • npx 可以自动去 node_modules/.bin 路径和环境变量 $PATH 里面检查命令是否存在,而不需要再在 package.json 中定义相关的 script。通过npm install -g npx命令来安装npx (需要npm V5.2+)

比如之前需要这样才能执行eslint

1
2
3
4
"scripts": {
"eslint:init": "./node_modules/.bin/eslint --init",
"eslint:run": "./node_modules/.bin/eslint yourfile.js"
},

使用npx以后:

1
2
npx eslint --init
npx eslint yourfile.js
  • 另外除了调用项目内部模块,npx 还能避免全局安装的模块。比如,create-react-app这个模块是全局安装,npx 可以运行它,而且不进行全局安装。
1
2
3
// npx 将create-react-app下载到一个临时目录,使用以后再删除。
// 再次执行下面的命令,会重新下载create-react-app
npx create-react-app my-react-app
  • 切换node版本
1
2
3
4
5
// nvm 的命令如下
nvm install 14.5.0
nvm use 14.5.0
// npx 的命令如下
npx node@14.5.0 -v

使用nvm 管理 node版本

1
2
3
4
5
6
7
8
// 全局安装nvm
npm install -g nvm
// 下载具体的node包
nvm install 14.5.0
// 切换到某个node版本包
nvm use 14.5.0
// 查看当前所有的Node包的版本
nvm list

使用nrm切换npm 源

有时候我们可能觉得切换比较麻烦,老是要npm config set registry 某npm镜像url . 这时候可以使用nrm

1
2
3
4
5
6
7
8
9
10
11
12
// 全局安装nrm
npm install -g nrm
// 列举可以选的源
nrm ls
// 切换到想要的源
nrm use taobao
// 增加源
nrm add <registry> <url> [home]
// 删除源
nrm del <registry>
// 测试某源的速度
nrm test taobao

yarn

Yarn 是一个由 Facebook、Google、Exponent 和 Tilde 构建的新的 JavaScript 包管理器
下面是yarn包管理器的优点(个人倾向于使用npm):

  • 确定性:通过 yarn.lock 等机制,保证了确定性。即不管安装顺序如何,相同的依赖关系在任何机器和环境下,都可以以相同的方式被安装。(在 npm v5 之前,没有 package-lock.json 机制,只有默认并不会使用的npm-shrinkwrap.json。)

  • __采用模块扁平安装模式__:将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余(npm 目前也有相同的优化)。Yarn 在安装依赖时会自动执行 dedupe 命令

  • __网络性能更好__:Yarn 采用了请求排队的理念,类似并发连接池,能够更好地利用网络资源;同时引入了更好的安装失败时的重试机制。

  • 采用缓存机制: 实现了离线模式(npm 目前也有类似实现)。

关于yarn的锁文件yarn.lock

区别于package-lock.json, yarn.lock 并没有使用 JSON 格式,而是采用了一种自定义的标记格式
另外和npm lock文件相比, yarn.lock 中子依赖的版本号不是固定版本。这就说明单独一个 yarn.lock
确定不了 node_modules 目录结构,还需要和 package.json 文件进行配合。

不管是 npm 还是 Yarn,说到底它们都是一个包管理工具,在项目中如果想进行 npm/Yarn 切换,并不是一件麻烦的事情。甚至还有一个专门的 synp 工具,它可以将 yarn.lock 转换为 package-lock.json

yarn的安装机制

安装过程: 检测(checking)→ 解析包(Resolving Packages) → 获取包(Fetching Packages)→ 链接包(Linking Packages)→ 构建包(Building Packages)

检测包

这一步主要是检测项目中是否存在一些 npm 相关文件,比如 package-lock.json 等。如果有,会提示用户注意:这些文件的存在可能会导致冲突。在这一步骤中,也会检查系统 OS、CPU 等信息

解析包

这一步会解析依赖树中每一个包的版本信息:

首先获取当前项目中 package.json 定义的 dependencies、devDependencies、optionalDependencies 的内容,这属于首层依赖

接着采用遍历首层依赖的方式获取依赖包的版本信息,以及递归查找每个依赖下嵌套依赖的版本信息,并将解析过和正在解析的包用一个 Set 数据结构来存储,这样就能保证同一个版本范围内的包不会被重复解析

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

yarn解析包

获取包

这一步我们首先需要检查缓存中是否存在当前的依赖包,同时将缓存中不存在的依赖包下载到缓存目录
如何判断缓存中是否存在当前的依赖包?Yarn 会根据 cacheFolder+slug+node_modules+pkg.name 生成一个 path,判断系统中是否存在该 path,如果存在证明已经有缓存,不用重新下载。这个 path 也就是依赖包缓存的具体路径

对于没有命中缓存的包,Yarn 会维护一个 fetch 队列,按照规则进行网络请求。如果下载包地址是一个 file 协议,或者是相对路径,就说明其指向一个本地目录,此时调用 Fetch From Local 从离线缓存中获取包;否则调用 Fetch From External 获取包。最终获取结果使用 fs.createWriteStream 写入到缓存目录下
yarn获取包

链接包

将项目中的依赖复制到项目 node_modules 下,同时遵循扁平化原则。在复制依赖前,Yarn 会先解析 peerDependencies (同伴依赖,它用来告知宿主环境需要什么依赖以及依赖的版本范围 ),如果找不到符合 peerDependencies 的包,则进行 warning 提示,并最终拷贝依赖到项目中
yarn链接包

构建包

如果依赖包中存在二进制包需要进行编译,会在这一步进行

yarn的常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 安装所有依赖包
yarn install
// 添加某一个依赖包
yarn add [package] -dev
yarn add [package]
yarn global add [package]
// 重建
yarn install --force
// 删除包
yarn remove [package]
yarn global remove [package]
// 查看缓存目录
yarn cache dir
// 清除缓存
yarn cache clean

关于包管理工具的扁平化机制

包管理工具的扁平化机制

文章作者: woyao
文章链接: https://chenwoyao.github.io/2021/05/23/前端笔记/打包系列/工程化管理工具/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 woyao的博客