前端项目共享node_modules

1. 前言

在npm、yarn等包管理工具的帮助下,前端工程化模块化的进程日益加快,项目的依赖包也日益增大,特别是若项目采用webpack来构建用到许多webpack的插、一些辅助开发如(eslint、Browsersync之类的插件)以及一些单元测试可能需要用到的插件,项目中的node_module就会变的十分庞大。

因此团队开发者每次重新初始化项目进行npm install会十分缓慢,并且若占用大量空间,更进一步来说项目中往往很多前端团队会用到一台构建服务器,对前端项目的代码获取、打包、构建、部署进行管理(例如我自己所在的团队就在使用jenkins对前端项目的打包、构建、部署进行自动化托管)。npm这样的项目级依赖管理的特性就会造成大量的时间以及资源的消耗。

当然笔者也想在文章开始之前表明一个态度,npm本身portable的模块化设计,使每个项目互不干系,也是一种它设计的精华所在,本文仅是针对实际使用中遇到的一些小困扰进行解读,希望提供一个新的思路。

此本篇文章就在这样的背景下诞生了。纯粹是笔者在项目中积累的一些经验,若有不足望指出。

2. 问题的总结

接前言,若不对项目做任何优化以及设置正常一般前端项目的依赖应该是这样的:

如图:

可以看到图中 webpackA 与 webpackB项目在devDependencies层面其实只有less和sass,以及他们两者的webpackloader的差异,但是其项目中的node_modules剩余的依赖都会被重复获取。parcel项目A项目A 与 parcel项目B项目B同理,项目中的devDependencies只有parcel-plugin-vue 1.5.0和babel-preset-react有区别。

p.s:

1. dependencies一般项目都会有较大差异,因此不在本文讨论范围,该图还是列出以便更接近现实状况

2. 使用webpack和parcel技术栈也可能会有一部分devDependencies,但本文暂不考虑两个项目差异极大的node_modules共享

这样的情况,总结如下来会带来如下问题:

  • 项目devDependencies以及dependencies过多造成项目初始化过慢。
  • 各个项目devDependencies大量重复,打包时项目占用空间过大。

这样的问题在项目数不大时并不会明显体现,并且这些重复很大一部分是因为构建是devDependencies中的依赖所导致的。

3. 现有的一些解决方案以及存在的一些问题

那难道没有方法让几个项目的依赖指向同一路径吗?答案当然是有

(1)解决办法

可以通过配置NODE_PATH,具体来说就是:

在对应系统的环境变量配置文件中增加环境变量NODE_PATH,例如在MacOS中

1
vi /ect/profile # 或/etc/bashrc或~/.bashrc此处不赘述配置问价的加载顺序以及原理
1
2
-> export PATH=$PATH: # 将 /usr/bin 追加到 PATH 变量中
-> export NODE_PATH="/usr/lib/node_modules;/usr/local/lib/node_modules" #指定 NODE_PATH 变量

那 NODE_PATH 就是NODE中用来寻找模块所提供的路径注册环境变量。我们可以使用上面的方法指定NODE_PATH环境变量。并且用;分割多个不同的目录。

关于 node 的包加载机制我就不在这里赘述了。NODE_PATH中的路径被遍历是发生在
从项目的根位置递归搜寻 node_modules 目录,直到文件系统根目录的 node_modules,如果还没有查找到指定模块的话,就会去 NODE_PATH中注册的路径中查找。

(2)存在问题

经过调整后,依赖的结构目前变为了:

如图:

看似不错让多个项目指向的依赖指向了全局的node_modules,但这种方式存在很明显的缺点:

a. 不会生成package.json,无法增量安装

在使用 –global 参数的时候 –save 或 –save-dev参数是无效的。
此时 package.json 中的 dependencies, devDependencies 将无法享受到npm增量更新带来的便利,不使用 dependencies, devDependencies 字段对整个依赖库的管理是非常不利的。

b.若项目模块出现版本差异,很难维护以及解决

差异性的解决方法
如果 项目A 使用了,express的3.x版本,项目B 使用了 express的4.x版本,那这种情况该怎么办呢?

如图:

可以将 NODE_PATH 指定的位置中存放 webpack的3.x版本,项目B需要webpack 目录中放置 2.x 版本呢?

可以利用node_module依赖包加载的机制,将webpack的3.x版本放在项目A自己的node_modules中

,这样确实可以解决了模块版本差异性问题,并不能做到根据让项目按技术框架去划分并且因为若项目数量多,依然无法做到统一管理,当然这种灵活使用node_module依赖包加载的机制仍十分有借鉴意义,也启发了我们可以使用一个更灵活的方式去解决问题。

4.进一步增强以及改进的方案

总而言之上述两种方式,在实际使用中都存在一定的问题。

目前笔者的理想方案应该能够达到下列几个目的:

  • 可以将项目构建相似技术栈的项目统一在一起,共享依赖

  • 可以实现对公共模块的维护和管理,并实现增量安装

  • 对原来npm install流程改动不会太大

因此笔者我经过了一系列的实践,采用了这种结构去维护模块,以下是主要思路:

  • 由于node的包加载机制是从项目的根位置递归搜寻 node_modules 目录,因此我们只需要向当前项目的上层的node_modules安装依赖,既可以使项目正常引入。

  • 对于相同构建技术栈的项目,建立一个框架目录,并将相同技术栈项目放在该目录中,在此目录下的node_modules和package.json中管理以及共享依赖包,项目目录如: webpack、gulp

  • 在每个项目框架下维护一个自己的package.json以及一个框架级的node_modules,为了更好的区分哪些是我们框架需要的依赖包,并仍可以在项目中直接操作,故我们在项目级增加一个devDependenciesGlobal字段以从项目层面更好的维护公共框架的依赖

  • 使用nodejs、shell脚本,结合npm script中的preinstall hook npm install的流程进行一些补充

  • 在框架目录中建立带有md5的package.json来维护、管理公共的依赖

  • 检查框架目录下是否存在package.json,若不存在视为需要初始化

  • 若被视为初始化,会生成一个含有devDependenciesGlobal指纹的package.json,并在这个package.json的devDependencies写入,devDependenciesGlobal中所有描述的依赖

  • 若已存在,跳过此步骤对项目自己的devdependencies以及devDependencies进行安装

  • 若md5改变,则进行npm install,此时npm install会对借助npm本身的机制,对包进行分析,进行增量安装或更新

故按这种结构来管理,假设团队项目中有2套技术框架 A: webpack B: gulp

调整下的依赖关系图:

如图:

示例webpack项目的github地址:https://github.com/Roxyhuang/npm-module-share

下面是本人的一些具体实践的核心代码以及,希望能够对大家有所启发:

(1)按技术框架划分项目目录,并增加devDependenciesGlobal

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
"dependencies": {
"react": "^16.2.0",
"react-dom": "^16.2.0"
},
"devDependencies": {
"babel-cli": "6.26.0",
"babel-plugin-transform-decorators-legacy": "1.3.4",
"babel-plugin-transform-runtime": "6.23.0",
"babel-polyfill": "6.23.0",
"babel-preset-env": "1.6.0",
"babel-preset-react": "6.24.1",
"babel-preset-stage-0": "6.24.1"
},
"devDependenciesGlobal": {
"babel-core": "6.26.0",
"babel-loader": "7.1.2",
"chalk": "2.1.0",
"css-loader": "0.28.4",
"connect-history-api-fallback": "1.3.0",
"express": "4.16.1",
"webpack": "3.6.0",
"style-loader": "0.18.2",
"less": "2.7.3",
"rimraf": "2.6.1",
"html-webpack-plugin": "2.30.1",
"less-loader": "4.0.5",
"webpack-dev-middleware": "1.12.0",
"webpack-hot-middleware": "2.19.1"
}

(2)利用npm script的hook机制,增加preinstall脚本

接下来为了更好和本身npm依赖管理机制结合的更好,可以利用npm script的hook,在script中增加preinstall

1
2
3
"scripts": {
"preinstall": "node build/script/preinstall.js"
},

下面是具体的node.js代码:

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
const path = require('path');

const fs = require('fs');

const exec = require('child_process').exec;

const preList = require('../../package.json');

const crypto = require('crypto');

const execPath = path.resolve('../');

const preListObj = preList.devDependenciesGlobal;

const preListObjArray = Object.keys(preListObj);

const isFirst = !fs.existsSync(`${execPath}/package.json`);

const currentMd5 = md5(JSON.stringify(preListObj));

let script = `cd ${execPath} && npm i`;

let isUpdate = false;

console.log(`The md5 for this project: ${currentMd5}`);

const md5 = function (str) {
const md5sum = crypto.createHash('md5');
md5sum.update(str);
str = md5sum.digest('hex');
return str;
};

if (isFirst){
console.log('init global modules...');
fs.writeFileSync(`${execPath}/package.json`, JSON.stringify({"md5": currentMd5,dependencies: preListObj}));
isUpdate = true;
} else {
console.log('will check update');
const lastVersion = require(`${execPath}/package.json`).md5;
if (lastVersion !== currentMd5) {
fs.writeFileSync(`${execPath}/package.json`, JSON.stringify({"md5": currentMd5,dependencies: preListObj}));
isUpdate = true;
}
}

if (isUpdate) {
console.log(`will install in ${execPath}`);
console.log('start install public package for neo-antd');
console.log('please wait some minutes.....');

exec(script, function (err, stdout, stderr) {
console.log(stdout);
if (err) {
console.log('error:' + stderr);
} else {
console.log(stdout);
console.log('package init success');
console.log(`The global install in ${execPath}`);
}
});

} else {
console.log('not any update');
console.log('will be run next');
}

(3) 通过node.js生成package.json,并维护md5安装框架级依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"md5": "4c4ee67828b3e09f07b17774cb1633c7",
"dependencies": {
"babel-core": "6.26.0",
"babel-loader": "7.1.2",
"chalk": "2.1.0",
"css-loader": "0.28.4",
"connect-history-api-fallback": "1.3.0",
"express": "4.16.1",
"webpack": "3.6.0",
"style-loader": "0.18.2",
"less": "2.7.3",
"rimraf": "2.6.1",
"html-webpack-plugin": "2.30.1",
"less-loader": "4.0.5",
"webpack-dev-middleware": "1.12.0",
"webpack-hot-middleware": "2.19.1"
}
}

(4) 初始化项目,并在框架目录生成存有md的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
npm install #

> npm-module-share@1.0.0 preinstall /Users/neo/home/webpack/npm-module-share
> node build/script/preinstall.js

...

The md5 for this project: 4c4ee67828b3e09f07b17774cb1633c7
init global modules...
will install in /Users/neo/home/webpack
start install public package for neo-antd
please wait some minutes....

....


added 704 packages in 7.777s

package init success
The global install in /Users/neo/home/webpack

....


> fsevents@1.1.3 install /Users/neo/home/webpack/npm-module-share/node_modules/fsevents
> node install

[fsevents] Success: "/Users/neo/home/webpack/npm-module-share/node_modules/fsevents/lib/binding/Release/node-v57-darwin-x64/fse.node" already installed
Pass --update-binary to reinstall or --build-from-source to recompile
npm WARN npm-module-share@1.0.0 No description

added 341 packages in 13.46s

(5) 检查md5是否存在变化,判断是否需要进行增量安装

1
2
3
4
5
6
7
8
9
npm install # 若没有任何更新,md5不会有任何变化,也不会再对会直接跳过preinstall的步骤

The md5 for this project: 4c4ee67828b3e09f07b17774cb1633c7
will check update
not any update
will be run next
npm WARN npm-module-share@1.0.0 No description

update in 1.291s

(6) 几点需要注意的地方

a. npm script中直接需要调用的模块,需要放置到该项目的dependencies和devDependencies中

关于这点阮一峰前辈的博客中: http://www.ruanyifeng.com/blog/2016/10/npm_scripts.html

在对npm script的原理中有提到,在此因为篇幅有限不赘述了,简单来说就是npm script当中用到的模块需要存在当前项目的devdependencies或devDependencies中。

b. 经过测试在windows下需要将babel以及babelrc相关的依赖

目前这点笔者暂时还没有找到原因,在mac中并不会报错,但是在windows中会包找不到这些模块。

希望知道的同行能够与我联系告知我具体原因

c. 若有eslint需要增加 .eslintignore
d. 需要尽量保证一套技术框架下的devDependenciesGlobal尽量一致

结语:

经过上述操作,我们便可以将前端项目根据框架进行依赖管理以及划分了,其实如此改进仍并非最完美以及优化,不足之处仍非常之多,如:同一技术框架下,框架依赖包的升级策略并不完善,期待更加优雅的解决方案,使我们前端项目的架构更佳健壮和灵活。

Share