模块化&包管理

最后修改于

模块化作为一个编程语言的基本功能,本不应该占有太多内容,所需要解决的问题是类似/相同的,因此本文仅对 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.exportsrequire,可以定义 / 引用一个 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.exportsrequire,可以定义 / 引用一个 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 的方式进行模块定义。
其中 iddeps两个字段可以省略(可以通过构建工具自动生成)

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 有较好的支持,相对完善。