探索 JavaScript 中的依赖管理及循环依赖

咱们一般会把项目中使用的第三方依赖写在 package.json 文件里,而后使用 npm 、cnpm 或者 yarn 这些流行的依赖管理工具来帮咱们管理这些依赖。可是它们是如何管理这些依赖的、它们之间有什么区别,若是出现了循环依赖应该怎么解决。node

在回答上面几个问题以前,先让咱们了解下语义化版本规则。react

语义化版本

使用第三方依赖时,一般须要指定依赖的版本范围,好比webpack

"dependencies": {
    "antd": "3.1.2",
    "react": "~16.0.1",
    "redux": "^3.7.2",
    "lodash": "*"
  }
复制代码

上面的 package.json 文件代表,项目中使用的 antd 的版本号是 3.1.2,可是 3.1.1 和 3.1.二、3.0.一、2.1.1 之间有什么不一样呢。语义化版本规则规定,版本格式为:主版本号.次版本号.修订号,而且版本号的递增规则以下:git

  • 主版本号:当你作了不兼容的 API 修改
  • 次版本号:当你作了向下兼容的功能性新增
  • 修订号:当你作了向下兼容的问题修正

主版本号的更新一般意味着大的修改和更新,升级主版本后可能会使你的程序报错,所以升级主版本号需谨慎,可是这每每也会带来更好的性能和体验。次版本号的更新则一般意味着新增了某些特性,好比 antd 的版本从 3.1.1 升级到 3.1.2,以前的 Select 组件不支持搜索功能,升级以后支持了搜索。修订号的更新则每每意味着进行了一些 bug 修复。所以次版本号和修订号应该保持更新,这样能让你以前的代码不会报错还能获取到最新的功能特性。github

可是,每每咱们不会指定依赖的具体版本,而是指定版本范围,好比上面的 package.json 文件里的 react、redux 以及 lodash,这三个依赖分别使用了三个符号来代表依赖的版本范围。语义化版本范围规定:web

  • ~:只升级修订号
  • ^:升级次版本号和修订号
  • *:升级到最新版本

所以,上面的 package.json 文件安装的依赖版本范围以下:算法

  • react@~16.0.1:>=react@16.0.1 && < react@16.1.0
  • redux@^3.7.2:>=redux@3.7.2 && < redux@4.0.0
  • lodash@*:lodash@latest

语义化版本规则定义了一种理想的版本号更新规则,但愿全部的依赖更新都能遵循这个规则,可是每每会有许多依赖不是严格遵循这些规定的。所以,如何管理好这些依赖,尤为是这些依赖的版本就显得尤其重要,不然一不当心就会陷入因依赖版本不一致致使的各类问题中。npm

依赖管理

在项目开发中,一般会使用 npmyarn 或者 cnpm 来管理项目中的依赖,下面咱们就来看看它们是如何帮助咱们管理这些依赖的。json

npm

npm 发展到今天,能够说经历过三个重大的版本变化。redux

npm v1

最先的 npm 版本在管理依赖时使用了一种很简单的方式。咱们称之为嵌套模式。好比,在你的项目中有以下的依赖。

"dependencies": {
    A: "1.0.0",
    C: "1.0.0",
    D: "1.0.0"
}
复制代码

这些模块都依赖 B 模块,并且依赖的 B模块的版本还不一样。

A@1.0.0 -> B@1.0.0
C@1.0.1 -> B@2.0.0
D@1.0.0 -> B@1.0.0
复制代码

经过执行 npm install 命令,npm v1 生成的 node_modules目录以下:

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
复制代码

很明显,每一个模块下面都会有一个 node_modules 目录存放该模块的直接依赖。模块的依赖下面还会存在一个 node_modules 目录来存放模块的依赖的依赖。很明显这种依赖管理简单明了,但存在很大的问题,除了 node_modules 目录长度的嵌套过深以外,还会形成相同的依赖存储多份的问题,好比上面的 B@1.0.0 就存放了两份,这明显也是一种浪费。因而在 npm v3 发布后,npm 的依赖管理作出了重大的改变。

npm v3

对于一样的上述依赖,使用 npm v3 执行 npm install 命令后生成的 node_modules 目录以下:

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
    └── node_modules
        └── B@2.0.0
├── D@1.0.0
复制代码

显而易见,npm v3 使用了一种扁平的模式,把项目中使用的全部的模块和模块的依赖都放在了 node_modules 目录下的顶层,遇到版本冲突的时候才会在模块下的 node_modules 目录下存放该模块须要用到的依赖。之因此能这么实现是基于包搜索机制的。包搜索机制是指当你在项目中直接 require('A') 时,首先会在当前路径下搜索 node_modules 目录中是否存在该依赖,若是不存在则往上查找也就是继续查找该路径的上一层目录下的 node_modules。正由于此,npm v3 才能把以前的嵌套结构拍平,把全部的依赖都放在项目根目录的 node_modules,这样就避免了 node_modules 目录嵌套过深的问题。此外,npm v3 还会解析模块的依赖的多个版本为一个版本,好比 A依赖 B@^1.0.1,D 依赖 B@^1.0.2,则只会有一个 B@1.0.2 的版本存在。虽然 npm v3 解决了这两个问题,可是此时的 npm 仍然存在诸多问题,被人诟病最多的应该就是它的不肯定性了。

npm v5

什么是肯定性。在 JavaScript 包管理的背景下,肯定性是指在给定的 package.json 和 lock 文件下始终能获得一致的 node_modules 目录结构。简单点说就是不管在何种环境下执行 npm install 都能获得相同的 node_modules 目录结构。npm v5 正是为解决这个问题而产生的,npm v5 生成的 node_modules 目录和 v3 是一致的,区别是 v5 会默认生成一个 package-lock.json 文件,来保证安装的依赖的肯定性。好比,对于以下的一个 package.json 文件

"dependencies": {
    "redux": "^3.7.2"
  }
复制代码

对应的 package-lock.json 文件内容以下:

{
  "name": "test",
  "version": "1.0.0",
  "lockfileVersion": 1,
  "requires": true,
  "dependencies": {
    "js-tokens": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
      "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
    },
    "lodash": {
      "version": "4.17.4",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz",
      "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4="
    },
    "lodash-es": {
      "version": "4.17.4",
      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.4.tgz",
      "integrity": "sha1-3MHXVS4VCgZABzupyzHXDwMpUOc="
    },
    "loose-envify": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
      "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
      "requires": {
        "js-tokens": "3.0.2"
      }
    },
    "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"
      }
    },
    "symbol-observable": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.1.0.tgz",
      "integrity": "sha512-dQoid9tqQ+uotGhuTKEY11X4xhyYePVnqGSoSm3OGKh2E8LZ6RPULp1uXTctk33IeERlrRJYoVSBglsL05F5Uw=="
    }
  }
}
复制代码

不难看出,package-lock.json 文件里记录了安装的每个依赖的肯定版本,这样在下次安装时就能经过这个文件来安装同样的依赖了。

image

yarn

yarn 是在 2016.10.11 开源的,yarn 的出现是为了解决 npm v3 中的存在的一些问题,那时 npm v5 还没发布。yarn 被定义为快速、安全、可靠的依赖管理。

  • 快速:全局缓存、并行下载、离线模式
  • 安全:安装包被执行前校验其完整性
  • 可靠:lockfile文件、肯定性算法

yarn 生成的 node_modules 目录结构和 npm v5 是相同的,同时默认生成一个 yarn.lock 文件。对于上面的例子,只安装 redux 的依赖生成的 yarn.lock 文件内容以下:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

js-tokens@^3.0.0:
  version "3.0.2"
  resolved "http://registry.npm.alibaba-inc.com/js-tokens/download/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"

lodash-es@^4.2.1:
  version "4.17.4"
  resolved "http://registry.npm.alibaba-inc.com/lodash-es/download/lodash-es-4.17.4.tgz#dcc1d7552e150a0640073ba9cb31d70f032950e7"

lodash@^4.2.1:
  version "4.17.4"
  resolved "http://registry.npm.alibaba-inc.com/lodash/download/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"

loose-envify@^1.1.0:
  version "1.3.1"
  resolved "http://registry.npm.alibaba-inc.com/loose-envify/download/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
  dependencies:
    js-tokens "^3.0.0"

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"

symbol-observable@^1.0.3:
  version "1.1.0"
  resolved "http://registry.npm.alibaba-inc.com/symbol-observable/download/symbol-observable-1.1.0.tgz#5c68fd8d54115d9dfb72a84720549222e8db9b32"
复制代码

不难看出,yarn.lock 文件和 npm v5 生成的 package-lock.json 文件有以下几点不一样:

  1. 文件格式不一样,npm v5 使用的是 json 格式,yarn 使用的是一种自定义格式
  2. package-lock.json 文件里记录的依赖的版本都是肯定的,不会出现语义化版本范围符号(~ ^ *),而 yarn.lock 文件里仍然会出现语义化版本范围符号
  3. package-lock.json 文件内容更丰富,npm v5 只须要 package.lock 文件就能够肯定 node_modules 目录结构,而 yarn 却须要同时依赖 package.json 和 yarn.lock 两个文件才能肯定 node_modules 目录结构

关于为何会有这些不一样、yarn 的肯定性算法以及和 npm v5 的区别,yarn 官方的一篇文章详细介绍了这几点。因为篇幅有限,这里就再也不赘述,感兴趣的能够移步到个人翻译文章 Yarn 肯定性去看。

yarn 的出现除了带来安装速度的提高之外,最大的贡献是经过 lock 文件来保证安装依赖的肯定性,保证相同的 package.json 文件,在何种环境何种机器上安装依赖都会获得相同的结果也就是相同的 node_modules 目录结构。这在很大程度上避免了一些“在我电脑上是正常的,在其余机器上失败”的 bug。可是在使用 yarn 作依赖管理时,仍然须要注意如下3点。

  • 不要手动修改 yarn.lock 文件
  • yarn.lock 文件应该提交到版本控制的仓库里
  • 升级依赖时,使用yarn upgrade命令,避免手动修改 package.json 和 yarn.lock 文件。

cnpm

cnpm 在国内的用户应该仍是蛮多的,尤为是对于有搭建私有仓库需求的人来讲。cnpm 在安装依赖时使用的是 npminstall,简单来讲, cnpm 使用连接 link 的安装方式,最大限度地提升了安装速度,生成的 node_modules 目录采用的是和 npm 不同的布局。 用 cnpm 装的包都是在 node_modules 文件夹下以 版本号 @包名 命名,而后再作软连接到只以包名命名的文件夹上。一样的例子,使用 cnpm 只安装 redux 依赖时生成的 node_modules 目录结构以下:

image

cnpm 和 npm 以及 yarn 之间最大的区别就在于生成的 node_modules 目录结构不一样,这在某些场景下可能会引起一些问题。此外也不会生成 lock 文件,这就致使在安装肯定性方面会比 npm 和 yarn 稍逊一筹。可是 cnpm 使用的 link 安装方式仍是很好的,既节省了磁盘空间,也保持了 node_modules 的目录结构清晰,能够说是在嵌套模式和扁平模式之间找到了一个平衡。

npm、yarn 和 cnpm 均提供了很好的依赖管理来帮助咱们管理项目中使用到的各类依赖以及版本,可是若是依赖出现了循环调用也就是循环依赖应该怎么解决呢?

循环依赖

循环依赖指的是,a 模块的执行依赖 b 模块,而 b 模块的执行又依赖 a 模块。循环依赖可能致使递归加载,处理很差的话可能使得程序没法执行。探讨循环依赖以前,先让咱们了解一下 JavaScript 中的模块规范。由于,不一样的规范在处理循环依赖时的作法是不一样的。

目前,通行的 JavaScript 规范能够分为三种,CommonJSAMDES6

模块规范

CommonJS

从2009年 node.js 出现以来,CommonJS 模块系统逐渐深刻人心。CommonJS 的一个模块就是一个脚本文件,经过 require 命令来加载这个模块,并使用模块暴漏出的接口。加载时执行是 CommonJS 模块的重要特性,即脚本代码在 require 的时候就会执行模块中的代码。这个特性在服务端是没问题的,但若是引入一个模块就要等待它执行完才能执行后面的代码,这在浏览器端就会有很大的问题了。所以出现了 AMD 规范,以支持浏览器环境。

AMD

AMD 是 “Asynchronous Module Definition” 的缩写,意思就是“异步模块定义”。它采用异步加载方式加载模块,模块的加载不影响它后面语句的运行。全部依赖这个模块的语句,都定义在一个回调函数中,等到加载完成以后,这个回调函数才会运行。最有表明性的实现则是 requirejs

ES6

不一样于 CommonJS 和 AMD 的模块加载方案,ES6 在 JavaScript 语言层面上实现了模块功能。它的设计思想是,尽可能的静态化,使得编译时就能肯定模块的依赖关系。在遇到模块加载命令 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);
复制代码

在这个例子中,a 模块调用 b 模块,b 模块又须要调用 a 模块,这就使得 a 和 b 之间造成了循环依赖,可是当咱们执行 node main.js 时代码却没有陷入无限循环调用当中,而是输出了以下内容:

$ 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 模块的两个特性。第一,加载时执行;第二,已加载的模块会进行缓存,不会重复加载。下面让咱们分析下程序的执行过程:

  1. main.js 执行,输出 main starting
  2. main.js 加载 a.js,执行 a.js 并输出 a starting,导出 done = false
  3. a.js 加载 b.js,执行 b.js 并输出 b starting,导出 done = false
  4. b.js 加载 a.js,因为以前 a.js 已加载过一次所以不会重复加载,缓存中 a.js 导出的 done = false,所以,b.js 输出 in b, a.done = false
  5. b.js 导出 done = true,并输出 b done
  6. b.js 执行完毕,执行权交回给 a.js,执行 a.js,并输出 in a, b.done = true
  7. a.js 导出 done = true,并输出 a done
  8. a.js 执行完毕,执行权交回给 main.js,main.js 加载 b.js,因为以前 b.js 已加载过一次,不会重复执行
  9. main.js 输出 in main, a.done=true, b.done=true

从上面的执行过程当中,咱们能够看到,在 CommonJS 规范中,当遇到 require() 语句时,会执行 require 模块中的代码,并缓存执行的结果,当下次再次加载时不会重复执行,而是直接取缓存的结果。正由于此,出现循环依赖时才不会出现无限循环调用的状况。虽然这种模块加载机制能够避免出现循环依赖时报错的状况,但稍不注意就极可能使得代码并非像咱们想象的那样去执行。所以在写代码时仍是须要仔细的规划,以保证循环模块的依赖能正确工做(官方原文:Careful planning is required to allow cyclic module dependencies to work correctly within an application)。

除了仔细的规划还有什么办法能够避免出现循环依赖吗?一个不太优雅的方法是在循环依赖的每一个模块中先写 exports 语句,再写 require 语句,利用 CommonJS 的缓存机制,在 require() 其余模块以前先把自身要导出的内容导出,这样就能保证其余模块在使用时能够取到正确的值。好比:

A.js

exports.done = true;

let B = require('./B');
console.log(B.done)
复制代码

B.js

exports.done = true;

let A = require('./A');
console.log(A.done)
复制代码

这种写法简单明了,缺点是要改变每一个模块的写法,并且大部分同窗都习惯了在文件开头先写 require 语句。

我的经验来看,在写代码中只要咱们注意一下循环依赖的问题就能够了,大部分同窗在写 node.js 中应该不多碰到须要手动去处理循环依赖的问题,更甚的是极可能大部分同窗都没想过这个问题。

ES6 中循环依赖的解法

要想知道 ES6 中循环依赖的解法就必须先了解 ES6 的模块加载机制。咱们都知道 ES6 使用 export 命令来规定模块的对外接口,使用 import 命令来加载模块。那么在遇到 import 和 export 时发生了什么呢?ES6 的模块加载机制能够归纳为四个字一静一动

  • 一静:import 静态执行
  • 一动:export 动态绑定

import 静态执行是指,import 命令会被 JavaScript 引擎静态分析,优先于模块内的其余内容执行。
export 动态绑定是指,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);
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');
复制代码

执行 node foo.js 时会输出以下内容:

bar is running
bar is finished
foo is running
bar = false
foo is finished
bar = true after 500 ms
复制代码

是否是和你想的不同呢?当咱们执行 node foo.js 时第一行输出的不是 foo.js 的第一个 console 语句,而是先输出了 bar.js 里的 console 语句。这就是由于 import 命令是在编译阶段执行,在代码运行以前先被 JavaScript 引擎静态分析,因此优先于 foo.js 自身内容执行。同时咱们也看到 500 毫秒以后也能够取到 bar 更新后的值也说明了 export 命令输出的接口与其对应的值是动态绑定关系。这样的设计使得程序在编译时就能肯定模块的依赖关系,这是和 CommonJS 模块规范的最大不一样。还有一点须要注意的是,因为 import 是静态执行,因此 import 具备提高效果即 import 命令的位置并不影响程序的输出。

在咱们了解了 ES6 的模块加载机制以后来让咱们来看一下 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);
export let foo = false;
console.log('foo is finished');
复制代码

bar.js

console.log('bar is running');
import {foo} from './foo';
console.log('foo = %j', foo)
export let bar = false;
setTimeout(() => bar = true, 500);
console.log('bar is finished');
复制代码

执行 node foo.js 时会输出以下内容:

bar is running
foo = undefined
bar is finished
foo is running
bar = false
foo is finished
bar = true after 500 ms
复制代码

foo.js 和 bar.js 造成了循环依赖,可是程序却没有因陷入循环调用报错而是执行正常,这是为何呢?仍是由于 import 是在编译阶段执行的,这样就使得程序在编译时就能肯定模块的依赖关系,一旦发现循环依赖,ES6 自己就不会再去执行依赖的那个模块了,因此程序能够正常结束。这也说明了 ES6 自己就支持循环依赖,保证程序不会由于循环依赖陷入无限调用。虽然如此,可是咱们仍然要尽可能避免程序中出现循环依赖,由于可能会发生一些让你迷惑的状况。注意到上面的输出,在 bar.js 中输出的 foo = undefined,若是没注意到循环依赖会让你以为明明在 foo.js 中 export foo = false,为何在 bar.js 中倒是 undefined 呢,这就是循环依赖带来的困惑。在一些复杂大型项目中,你是很难用肉眼发现循环依赖的,而这会给排查异常带来极大的困难。对于使用 webpack 进行项目构建的项目,推荐使用 webpack 插件 circular-dependency-plugin 来帮助你检测项目中存在的全部循环依赖,尽早发现潜在的循环依赖可能会免去将来很大的麻烦。

小结

讲了那么多,但愿此文能帮助你更好的了解 JavaScript 中的依赖管理,而且处理好项目中的循环依赖问题。

相关文章
相关标签/搜索