模块化作为一个编程语言的基本功能,本不应该占有太多内容,所需要解决的问题是类似 / 相同的,因此本文仅对 JS 中的各种模块化方案 / 工具做简单介绍。
无模块时代#
在 JS 只停留在浏览器中的那些年,因为没有语言标准层面上的支持, 只能采取一些很另类的 hacker 方式来模块化实现(模拟命名空间、闭包模拟)。
// 1.模拟命名空间
var MyLibrary = {
utils: {
formatDate: function() { },
parseDate: function() { }
},
ui: {
createDialog: function() { },
showMessage: function() { }
}
};
// 使用
MyLibrary.utils.formatDate();
MyLibrary.ui.showMessage();
// 2. 通过闭包模拟进行封装
var MyModule = (function() {
// 私有变量和函数
var privateVariable = 'Hello';
function privateFunction() {
console.log(privateVariable);
}
// 返回公共接口
return {
publicMethod: function() {
privateFunction();
}
};
})();
// 使用方式
MyModule.publicMethod();
这种模式可以达到封装的目的,但难以清晰的声明依赖项,而且还有一个隐含假设:依赖项在执行这个脚本时一定是可用的,这也限制了依赖项的加载策略。
将视角切换到另外一面。09 年 Ryan Dahl 创建了 NodeJS,它引入了一个运行时来在服务器上运行 JavaScript 代码。如同其他语言一样,Node 需要一个模块化 / 打包系统,也就是 CommonJS
,通过exports/module.exports
和 require
,可以定义 / 引用一个 Node 模块,再通过 npm registry
进行分发。这种架构的可复用强太多了。
// filename: foo.js
// dependencies
var $ = require('jquery');
// methods
function myFunc(){};
// exposed public method (single)
module.exports = myFunc;
借着 CommonJS
给予的灵感,各种模块化标准、方案开始涌现。
CommonJS#
09 年 Ryan Dahl 创建了 NodeJS,它引入了一个运行时来在服务器上运行 JavaScript 代码。如同其他语言一样,Node 需要一个模块化 / 打包系统,也就是 CommonJS
,通过exports/module.exports
和 require
,可以定义 / 引用一个 Node 模块,再通过 npm registry
进行分发。这种架构的可复用强太多了。
// filename: foo.js
// dependencies
var $ = require('jquery');
// methods
function myFunc(){};
// exposed public method (single)
module.exports = myFunc;
借着 CommonJS
给予的灵感,各种模块化标准、方案开始涌现。
AMD(Asynchronous Module Definition)#
异步模块定义,通过define
来定义模块,显式声明依赖项。在所有依赖项加载完毕之后,再执行模块主体。
//Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {
//Define the module value by returning a value.
return function () {};
});
相比于 CommonJS
适用于 Node,AMD 为浏览器进行了适配。一种典型实现是RequireJS
CMD(Common Module Definition)#
相比于 AMD,CMD 更有 CommonJS
的风格,但也用类似于 AMD
的方式进行模块定义。
其中 id
和 deps
两个字段可以省略(可以通过构建工具自动生成)
define('hello', ['./a','./b', './c', './d'], function(require, exports, module) {
// 获取模块 a 的接口
var a = require('./a');
// 调用模块 a 的方法
a.doSomething();
// 异步加载一个模块,在加载完成时,执行回调
require.async('./b', function(b) {
b.doSomething();
});
// 异步加载多个模块,在加载完成时,执行回调
require.async(['./c', './d'], function(c, d) {
c.doSomething();
d.doSomething();
});
});
相应的实现是 Sea.js
UMD(Universal Module Definition)#
在经历各种 Module Definition
扰乱开发者的心智之后,有人受不了,尝试提出一种通用的模块定义规范umdjs/umd
,尝试兼容AMD
,CJS
。
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['jquery', 'underscore'], factory);
} else if (typeof exports === 'object') {
// Node, CommonJS-like
module.exports = factory(require('jquery'), require('underscore'));
} else {
// Browser globals (root is window)
root.returnExports = factory(root.jQuery, root._);
}
}(this, function ($, _) {
// methods
function a(){}; // private because it's not returned (see below)
function b(){}; // public because it's returned
function c(){}; // public because it's returned
// exposed public methods
return {
b: b,
c: c
}
}));
是的,他只是一种范式、可以同时兼容不同的模块化机制,没有提供另外一个实现(否则还会出现 UUMD、UUUMD),这种范式很丑陋,但确实有效。
ESM(ESModule)#
终于,ECMA 委员会也知道自己落后了,在 ES6(2015 年) 中提出了 模块化的标准,对 JS 模块化方案进行了统一。只是时至今日,CJS 因其先发优势,还有很高的使用率。
import { f } from "helper";
export const a = () => { // dosomething }
export const b = () => {
f()
// dosomething
}
export default b
包管理#
node_modules#
先说说 Node 在处理依赖引用时的逻辑,这个流程会有如下两种情况
我们通过 require/import
获取模块,如果是一个核心模块(如 'fs')或是一个明确的相对路径(如./lib.js
),Node 会直接使用对应的文件。
如果不是上面所述的情况,那么 Node 会开始寻找一个名为 node_modules
的目录。首先 Node 会在当前目录寻找,如果没有则到父目录查找,以此类推直到根目录。
找到 node_modules
之后,按照导入路径定位再在该目录中寻找名对应的文件并使用(找不到就报错)。这个过程称为链接 (node-linker
)。
npm#
npm 是 node 自带的包管理器,在 node 发布一年之后正式发布,它为 JS 的包管理设定了标准,让共享和管理代码变得简单。这里不讨论的 npm registry
这样的基础设施。仅从包管理器的角度介绍 npm。
node 支持通过 package.json
的方式声明 Node 项目的依赖,然后通过 npm install
这样的命令
安装依赖到 node_modules
中。
将所有依赖放在项目的node_modules
文件夹下,而且不同项目不可复用,这使得node_modules
十分庞大。
在最初的版本中,实现非常简单,依赖的下载是串行化的,非常缓慢。无法处理包的依赖冲突。
另外一方面,最初默认的安装方式不支持版本锁定(需要 shrinkwrap),每次安装时都会安装可用的最新版本,导致在不同环境下可能安装的依赖不一致。
后续版本中又通过扁平化引入了幽灵依赖(可以直接使用间接依赖的包)等问题。
随着 JS 社区的快速发展,NPM 开始显示出它在包安装的速度、安全性和一致性方面的局限性,Yarn 和 PNPM 等替代工具开始出现。
yarn#
Yarn 于 2016 年由 Facebook 推出,引入了离线缓存和lockfile
等功能。
yarn berry(PnP)#
yarn2 做了很多重构,带来了一个重要特性 PnP (Plug'n'Play) 。
从上面的 node_modules
描述中可以发现,依赖查找这个过程中涉及到大量的文件 I/O 操作,效率很低。PnP 尝试解决这个问题。
Yarn 维护一张静态映射表(也就是*.pnp.js
),该表中包含了以下信息:
- 当前依赖树中包含了哪些依赖包的哪些版本
- 这些依赖包是如何互相关联的
- 这些依赖包在文件系统中的具体位置
在安装依赖时,在第 3 步完成之后,Yarn 并不再拷贝依赖到node_modules
目录,而是会在.pnp.js
中记录下该依赖在缓存中的具体位置。这样就避免了大量的 I/O 操作同时项目目录也不会有node_modules
目录生成。
在支持的包管理器中通常可以通过设置node-linker
启用。
pnpm#
PNPM 最初发布于 2017 年,为了解决 node_modules 过大的问题,通过符号链接来全局引用包,从而显著减小空间占用。对 workspace 有较好的支持,相对完善。