前端依赖包的种种往事
本文简单聊聊前端依赖管理器npm的大致逻辑
npm基本上是目下的前端项目管理依赖的默认工具。一般项目工程中所涉及到的所有依赖包,均会记录在package.json文件中。去到公司,初次down下来的项目,需要我们通过命令npm install来安装所有依赖。那么承担依赖管理器的npm,具体是如何管理的呢?同时他跟其他的比如cnpm、yarn有什么区别呢?
一. 版本规则
一般在package.json文件中,依赖的信息格式如下:
"dependencies": {
"antd": "3.1.2",
"react": "~16.0.1",
"redux": "^3.7.2",
"lodash": "*"
}
版本格式为:主版本号.次版本号.修订号
版本号的递增规则如下:
- 主版本号:当你做了不兼容的 API 修改。
大改,更新需谨慎。 - 次版本号:当你做了向下兼容的功能性新增。
加功能的小改, 如antd新增了某些组件 - 修订号:当你做了向下兼容的问题修正。
修复问题的版本
除了指定明确的版本号,也可以指定一个范围。
- ~:只升级修订号
- ^:升级次版本号和修订号
- *:升级到最新版本
二. 依赖管理
1. npm
基本分为三个重大的版本变化
npm v1:嵌套模式:
假设有如下依赖:
"dependencies": {
A: "1.0.0",
C: "1.0.0",
D: "1.0.0"
}
执行npm install生成的如下结构文件:
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── D@1.0.0
└── node_modules
└── B@1.0.0
从结构能看出,分门别类很清晰但是槽点明显,依赖冗余
npm v3:
为了解决v1的问题,v3版本的目录如下:
node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0
├── D@1.0.0
从上面v3结构可以看出,被依赖的包被放置在了顶层目录而不是纯粹嵌套。只有存在冲突时才会去子node_modules中寻找目标依赖。
但是,此版本存在两个重大问题:幽灵依赖和不确定性。
- 项目代码可以「意外访问未在 package.json 中声明的依赖。举例:项目依赖 react,而 react 依赖 loose-envify;npm v3 会将 loose-envify 提升到顶层 node_modules;项目代码未在 package.json 中声明 loose-envify,但依然可以 require(‘loose-envify’) 成功;:如果后续 react 升级移除了 loose-envify 依赖,项目代码会突然报错(依赖缺失),且问题难以排查。
- 确定性是指无论在何种环境下执行
npm install,都能得到相同的目录结构。v3下,依赖安装顺序会影响最终的依赖树结构(如先装 A 再装 B,和先装 B 再装 A,顶层依赖可能不同),导致「同一 package.json 在不同环境下安装的依赖树不一致」。
npm v5的解决方案:
package-lock.json固化结构
比如现在有一个package.json"dependencies": { "redux": "^3.7.2" }其对应的lock文件如下:
{ "name": "test", "version": "1.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { "redux": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz", "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==", "requires": { "lodash": "4.17.4", "lodash-es": "4.17.4", "loose-envify": "1.3.1", "symbol-observable": "1.1.0" } } } }package-lock.json 文件里记录了安装的每一个依赖的确定版本,这样在下次安装时就能通过这个文件来安装一样的依赖了。
增强依赖校验:默认开启 –save(安装依赖自动写入 package.json),减少幽灵依赖的意外使用;
2. yarn
yarn 是在 2016.10.11 开源的,yarn 的出现是为了解决 npm v3 中的存在的一些问题,那时 npm v5 还没发布。yarn 被定义为快速、安全、可靠的依赖管理。yarn 生成的 node_modules 目录结构和 npm v5 是相同的,同时默认生成一个 yarn.lock 文件。对于上面的例子,只安装 redux 的依赖生成的 yarn.lock 文件内容如下:
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
redux@^3.7.2:
version "3.7.2"
resolved "http://registry.npm.alibaba-inc.com/redux/download/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b"
dependencies:
lodash "^4.2.1"
lodash-es "^4.2.1"
loose-envify "^1.1.0"
symbol-observable "^1.0.3"
3. cnpm
简单理解就是国内的npm镜像版本,感谢GFW。
三. 循环依赖
循环依赖指的是,a模块的执行依赖b模块,而b模块的执行又依赖a模块。循环依赖可能导致递归加载,处理不好的话可能使得程序无法执行。探讨循环依赖之前,先让我们了解一下 JavaScript 中的模块规范。因为,不同的规范在处理循环依赖时的做法是不同的。
3.1 CommonJS: require时即执行模块代码。第一,加载时执行;第二,已加载的模块会进行缓存,不会重复加载。
3.2 AMD: “Asynchronous Module Definition” 的缩写,意思就是“异步模块定义”。它采用异步加载方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。最有代表性的实现则是 requirejs。
3.3 ES6: 在遇到模块加载命令 import 时,不会去执行模块,而是只生成一个引用。等到真的需要用到时,再到模块里面去取值。这是和 CommonJS 模块规范的最大不同。静态执行,动态绑定
CommonJS如何破依赖循环的局?
看一个循环依赖的例子:
// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done=%j, b.done=%j', a.done, b.done);
在上述例子中,执行main.js就会出现a、b俩模块的循环依赖。但是并没有报错,输出如下:
$ node main.js
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done=true, b.done=true
为什么?很简单,因为commonjs的两个特性:
第一: 加载时执行
第二: 已加载的模块会进行缓存,不会重复加载
ES6如何破依赖循坏的局?
从一个例子入手:
// foo.js
console.log('foo is running');
import {bar} from './bar'
console.log('bar = %j', bar);
setTimeout(() => console.log('bar = %j after 500 ms', bar), 500);
console.log('foo is finished');
// bar.js
console.log('bar is running');
export let bar = false;
setTimeout(() => bar = true, 500);
console.log('bar is finished');
执行foo.js,输出:
bar is running
bar is finished
foo is running
bar = false
foo is finished
bar = true after 500 ms
需要注意的点:
import 命令是在编译阶段执行,在代码运行之前先被 JavaScript 引擎静态分析,所以优先于 foo.js 自身内容执行。同时我们也看到 500 毫秒之后也可以取到 bar 更新后的值也说明了 export 命令输出的接口与其对应的值是动态绑定关系。
在看一个例子:
// foo.js
console.log('foo is running');
import {bar} from './bar'
console.log('bar = %j', bar);
setTimeout(() => console.log('bar = %j after 500 ms', bar), 500);
export let foo = false;
console.log('foo is finished');
// bar.js
console.log('bar is runnin`g');
import {foo} from './foo';
console.log('foo = %j', foo)
export let bar = false;
setTimeout(() => bar = true, 500);
console.log('bar is finished');
// 输出结果
bar is running
foo = undefined
bar is finished
foo is running
bar = false
foo is finished
bar = true after 500 ms
import 是在编译阶段执行的,这样就使得程序在编译时就能确定模块的依赖关系,一旦发现循环依赖,ES6 本身就不会再去执行依赖的那个模块了,所以程序可以正常结束。
备注: 本文严重参考某位知友的好文
https://zhuanlan.zhihu.com/p/33049803
