JavaScript库打包指南

JavaScript库打包指南

本指南旨在提供一些大多数库都应该遵循的一目了然的建议。以及一些额外的信息,用来帮助你了解这些建议被提出的原因,或帮助你判断是否不需要遵循某些建议。这个指南仅适用于 库(libraries),不适用于应用(app)。

要强调的是,这只是一些建议,并不是所有库都必须要遵循的。每个库都是独特的,它们可能有充足的理由不采用本文中的任何建议。

最后,这个指南不针对某一个特定的打包工具 —— 已经有许多指南来说明如何在配置特定的打包工具。相反我们聚焦于每个库和打包工具(或不用打包工具)都适用的事项。

关于模块化设计

什么是模块化设计

模块化设计(Modular design),是一种将系统分解为更小的“模块”的生产方式。

这一思想广泛运用于机械制造、电子和软件工业中。

代码的模块化

常见的生产级编程语言都支持模块化,如 C++、Java、Python、PHP、JS 中都有 import 或 include 保留字。通常以单个文件作为模块的最小单元。

代码的模块化设计一般可抽象为三个部分:

输入(import)

计算(业务代码)

输出(export)

为什么要用模块化

把复杂问题分解成多个子问题

关注点分离

大型软件开发的技术基础

可扩展

可替换

代码重用

使多人并行开发成为可能

面向接口开发(而不是面向实现开发)

JS 中的模块化方案

JS 的模块化经历了各种历史时期,在不同时期产生了不同的模块化方案。

到目前为止,对于编写源码来说,主流的方案只剩下两种。

esm: 从 ES6 起官方规范自带的方案

cjs: Node.js 使用的方案

但是为了支持不同的目标运行环境,需要编译成不同的输出格式(方案),了解不同的模块化方案是很有必要的。

流行打包工具 Rollup.js 和 Webpack都支持导出格式功能。

以 Rollup 文档为例子,一共有以下几种:

cjs (CommonJS) — 适用于 Node 和其他打包工具(别名:commonjs)。

amd (Asynchronous Module Definition,异步模块化定义) — 与 RequireJS 等模块加载工具一起使用。

umd (Universal Module Definition,通用模块化定义) — amd,cjs 和 iife 包含在一个文件中。

es — 将 bundle 保存为 ES 模块文件。适用于其他打包工具,在现代浏览器中用

但是如果你像本 demo 中那样依赖了其他的模块,那你就必须保证以下两点才能正常运行:

1、此包所依赖的包,已在此包之前完成加载。

2、前置依赖的包,和 IIFE 只执行入参的变量命名是一致的。

以本 demo 的 IIFE 构建结果为例:

1、它前置依赖了 lodash,因此需要在它加载之前完成 lodash 的加载。

2、此 IIFE 的第二个入参是 lodash,作为前置条件,我们需要让 window.lodash 也指向 lodash。

因此,运行时,代码如下:

优缺点

优点:

通过闭包营造了一个“私有”命名空间,防止影响全局,并防止被从外部修改私有变量。

简单易懂

对代码体积的影响不大

缺点:

输出的变量可能影响全局变量;引入依赖包时依赖全局变量。

需要使用者自行维护 script 标签的加载顺序。

优点就不细说了,缺点详细解释一下。

缺点一:输出的变量可能影响全局变量;引入依赖包时依赖全局变量。

前半句:输出的变量可能影响全局变量; 其实很好理解,以上面 demo 的输出为例: window.Test 就已经被影响了。

这种明显的副作用在程序中其实是有隐患的。

后半句:引入依赖包时依赖全局变量; 我们为了让 demo 正常运行,因此加了一行代码让 window.lodash 也指向 lodash,但它确实是太脆弱了。

你瞧,IIFE 的执行对环境的依赖是苛刻的,除非它完全不依赖外部包。(Jquery: 正是在下!)

虽然 IIFE 的缺点很多,但并不妨碍它在 Jquery 时代极大地推动了 web 开发的进程,因为它确实解决了 js 本身存在的很多问题。

那么?后续是否还有 更为优秀 的前端模块化方案问世呢?

当然有,往下看吧。

CJS (CommonJS)

CJS 适用于浏览器之外的 Node 和其他生态系统。它在服务端被广泛使用。CJS 可以通过使用 require() 函数和 module.exports 来识别。require() 是一个可用于从另一个模块导入 symbols 到当前作用域的函数。 module.exports 是当前模块在另一个模块中引入时返回的对象。

CJS 模块的设计考虑到了服务器开发。这个 API 天生是同步的。换言之,在源文件中按 require 的顺序瞬时加载模块。

由于 CJS 是同步的且不能被浏览器识别,CJS 模块不能在浏览器端使用,除非它被转译器打包。像 Babel 和 Traceur 那样的转译器,是一种帮助我们在新版 JavaScript 中编码的工具。如果环境原生不支持新版本的 JavaScript,转译器将它们编译成支持的 JS 版本。

下面是 Rollup 生成的 CJS 格式的文件:

'use strict';

var lodash = require('lodash');

const result = lodash.every([true, 1, null, 'yes'], Boolean);

module.exports = result;

特点

由 Node.js 实现

多用在服务器端安装模块时

没有 runtime/async 模块

通过 require 导入模块

通过 module.exports 导出模块

无法使用摇树优化,因为当你导入时会得到一个模块时,得到的是一个对象,所以属性查找在运行时进行,无法静态分析

会得到一个对象的副本,因此模块本身不会实时更改

循环依赖的不能优雅处理

语法简单

如何运行

// run.js

const MyBundle = require('./bundle')

console.log(MyBundle)

# 执行脚本

node run.js

可以看出,node.js 环境是天然支持 CommonJS 的。

优缺点

优点:

完善的模块化方案,完美解决了 IIFE 的各种缺点。

缺点:

不支持浏览器,执行后才能拿到依赖信息,由于用户可以动态 require(例如 react 根据开发和生产环境导出不同代码 的写法),无法做到提前分析依赖以及 Tree-Shaking 。

CommonJS 采用同步加载模块,而加载的文件资源大多数在本地服务器,所以执行速度或时间没问题。但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。

AMD

AMD 是”Asynchronous Module Definition”的缩写,意思就是”异步模块定义”。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。其中 RequireJS 是最佳实践者。

模块功能主要的几个命令:define、require、return和define.amd。define是全局函数,用来定义模块,define(id?, dependencies?, factory)。require 命令用于输入其他模块提供的功能,return 命令用于规范模块的对外接口,define.amd 属性是一个对象,此属性的存在来表明函数遵循 AMD 规范。

下面是 Rollup 生成的 AMD 格式的文件:

define(['lodash'], (function (lodash) { 'use strict';

const result = lodash.every([true, 1, null, 'yes'], Boolean);

return result;

}));

特点

由 RequireJs 实现

当你在客户端(浏览器)环境中,异步加载模块时使用

通过 require 实现导入

语法复杂

如何运行

优缺点

优点:

解决了 CommonJS 的缺点

解决了 IIFE 的缺点

一套完备的浏览器里 js 文件模块化方案

缺点:

代码组织形式别扭,可读性差

但好在我们拥有了各类打包工具,浏览器内的代码可读性再差也并不影响我们写出可读性ok的代码。

现在,我们拥有了面向 node.js 的 CommonJs 和 面向浏览器的 AMD 两套标准。

如果我希望我写出的代码能同时被浏览器和nodejs识别,我应该怎么做呢?

UMD

UMD(Universal Module Definition - 通用模块定义)模式,该模式主要用来解决 CommonJS 模式和 AMD 模式代码不能通用的问题,并同时还支持老式的全局变量规范。

1、判断define为函数,并且是否存在define.amd,来判断是否为 AMD 规范,

2、判断module是否为一个对象,并且是否存在module.exports来判断是否为CommonJS规范

3、如果以上两种都没有,设定为原始的全局变量规范。

下面是 Rollup 生成的 UMD 格式的文件:

(function (global, factory) {

typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('lodash')) :

typeof define === 'function' && define.amd ? define(['lodash'], factory) :

(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.myBundle = factory(global.lodash));

})(this, (function (lodash) { 'use strict';

const result = lodash.every([true, 1, null, 'yes'], Boolean);

return result;

}));

特点

CommonJs + AMD 的组合(即 CommonJs 的语法 + AMD 的异步加载)

可以用于 AMD/CommonJs 环境

UMD 还支持全局变量定义,因此,UMD 模块能够在客户端和服务器上工作。

如何运行

在浏览器端,它的运行方式和 amd 完全一致。

在node.js端,它则和 CommonJS 的运行方式完全一致,在此就不赘述了。

优缺点

优点:

抹平了一个包在 AMD 和 CommonJS 里的差异

缺点:

会为了兼容产生大量不好理解的代码。(理解难度与包体积)

虽然在社区的不断努力下,CommonJS 、 AMD 、 UMD 都给业界交出了自己的答卷。

但很显然,它们都是不得已的选择。

浏览器应该有自己的加载标准。

ES6 草案里,虽然描述了模块应该如何被加载,但它没有 “加载程序的规范”。

system

SystemJs 是一个通用的模块加载器,支持 CJS,AMD 和 ESM 模块。Rollup 可以将代码打包成 SystemJS 的原生格式。

下面是 Rollup 生成的 System 格式的文件:

System.register(['lodash'], (function (exports) {

'use strict';

var every;

return {

setters: [function (module) {

every = module.every;

}],

execute: (function () {

const result = exports('default', every([true, 1, null, 'yes'], Boolean));

})

};

}));

用的比较少,这里仅介绍。

ESM (ES Module)

ES modules(ESM)是 JavaScript 官方的标准化模块系统。

1、它因为是标准,所以未来很多浏览器会支持,可以很方便的在浏览器中使用。(浏览器默认加载不能省略.js)

2、它同时兼容在 node 环境下运行。

3、模块的导入导出,通过import和export来确定。 可以和 Commonjs 模块混合使用。

4、ES modules 输出的是值的引用,输出接口动态绑定,而 CommonJS 输出的是值的拷贝

5、ES modules 模块编译时执行,而 CommonJS 模块总是在运行时加载

下面是 Rollup 生成的 ESM 格式的文件:

import { every } from 'lodash';

const result = every([true, 1, null, 'yes'], Boolean);

export { result as default };

特点

用于服务器/客户端

支持模块的 Runtime/static loading

当你导入时,获得是实际对象

通过 import 导入,通过 export 导出

静态分析——你可以决定编译时的导入和导出(静态),你只需要看源码,不需要执行它

由于 ES6 支持静态分析 ,因此摇树优化是可行的

始终获取实际值 ,以便实时更改模块本身

比 CommonJS 有更好的循环依赖管理

如何运行

部分现代浏览器已经开始实装

总结

分别适合在什么场景使用?

IIFE: 适合部分场景作为SDK进行使用,尤其是需要把自己挂到 window 上的场景。

CommonJS: 仅node.js使用的库。

AMD: 只需要在浏览器端使用的场景。

UMD: 既可能在浏览器端也可能在node.js里使用的场景。

SystemJs: 和UMD类似。目前较出名的 Angular 用的就是它。

ESM: 1. 还会被引用、二次编译的场景(如组件库等);2.浏览器调试场景如 vite.js的开发时。3.对浏览器兼容性非常宽松的场景。

esm 被认为是“未来”,但 cjs 仍然在社区和生态系统中占有重要地位。esm 对打包工具来说更容易正确地进行 treeshaking,因此对于库来说,拥有这种格式很重要。或许在将来的某一天,你的库只需要输出 esm。

打包最佳实践

Tree shaking

Tree shaking是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静态结构特性,例如 import 和 export。这个术语和概念实际上是兴起于 ES2015 模块打包工具 rollup。新的 webpack 4 正式版本,扩展了这个检测能力,通过 package.json 的 “sideEffects” 属性作为标记,向 compiler 提供提示,表明项目中的哪些文件是纯的 ES2015 模块,由此可以安全地删除文件中未使用的部分。

webpack 和 Rollup 都支持摇树优化,这意味着我们需要牢记某些事情,以便我们的代码可被 Tree Shaking。

tree shaking 的实际例子

// main.js

import * as utils from "./utils";

const array = [1, 2, 3, 1, 2, 3];

console.log(utils.arrayUnique(array));

没有 Tree-shaking 的情况下,会将 utils 中的所有文件都进行打包,使得体积暴增。

ES Modules 之所以能 Tree-shaking 主要为以下四个原因:

1、import 只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面。

2、import 的模块名只能是字符串常量。

3、不管 import 的语句出现的位置在哪里,在模块初始化的时候所有的 import 都必须已经导入完成。

4、import binding 是 immutable 的,类似 const。比如说你不能 import { a } from ‘./a’ 然后给 a 赋值个其他什么东西。

tree shaking 应该注意什么

没错,就是副作用,那么什么是副作用呢,请看下面的例子。

// effect.js

console.log(unused());

export function unused() {

console.log(1);

}

// index.js

import { unused } from "./effect";

console.log(42);

此例子中 console.log(unused()); 就是副作用。在 index.js 中并不需要这一句 console.log。而 rollup 并不知道这个全局的函数去除是否安全。因此在打包地时候你可以显示地指定treeshake.moduleSideEffects 为 false,可以显示地告诉 rollup 外部依赖项没有其他副作用。

不指定的情况下的打包输出。 npx rollup index.js –file bundle.js

console.log(unused());

function unused() {

console.log(1);

}

console.log(42);

指定没有副作用下的打包输出。npx rollup index.js –file bundle-no-effect.js –no-treeshake.moduleSideEffects

console.log(42);

当然以上只是副作用的一种,详情其他几种看查看 https://rollupjs.org/guide/en/

发布所有模块形态

我们应该发布所有模块形态,例如 UMD 和 ES Module ,因为我们永远不知道用户在哪个版本的浏览器或 webpack 中使用此库/包。

即使所有打包程序(如 webpack 和 Rollup)都能解析 ES Module ,但如果我们的使用者使用的是 webpack 1.x,则它无法解析 ES 模块。

使用现代的新特性,如果有需要,让开发者支持旧的浏览器:

1、当使用你的库时,能够让开发者去支持老版本的浏览器。

2、输出多个产出来支持不同版本的浏览器。

举个例子,如果你使用 TypeScript,你可以创建两个版本的包代码:

1、通过在 tsconfig.json 中设置 “target”=”esnext”,生成一个用现代 JavaScript 的 esm 版本

2、通过在 tsconfig.json 中设置 “target”=”es5” 生成一个兼容低版本 JavaScript 的 umd 版本

有了这些设置,大多数用户将获得现代版本的代码,但那些使用老的打包工具配置或使用

现在,如果我们只是简单地将 src 转换为 lib 并将该 lib 托管在 CDN 上,那么我们的使用者实际上可以得到他们想要的任何东西而没有任何开销,“代码更少,加载更快”。

Core Packages

Core Packages(核心包)永远不会通过