阅读提示: 本文很多外链需要科学上网访问
对于 JavaScript 新手,看到 "CommonJS vs AMD" 、"Requirejs vs Seajs"、"Webpack vs. Browserify"等这些可能会不知所措。
特别是在大部分浏览器都已经实现 ES6 模块化规范的今天,我们新开发的项目基本都是 ES6 搭配 Webpack ,这些 AMD、CMD、UMD、Requirejs、Seajs 都已经是过去式了,很多同学并没有使用过。
但模块化是 JavaScript 开发体系的一部分,了解它的历史还是很有必要的,至少不会在这方面与其他开发者失去对话能力,比如你的面试官。
Foreword
从 1995 年发布 JavaScript 开始,浏览器端加载 JS 模块就是使用简单的 script 标签。早在 1996 年,就涌现了很多 服务器端 JavaScript 实现 , 例如 2009 年发布的 nodejs。无论是浏览器端还是服务端 JavaScript, 在 ES6 规范提出之前,JavaScript 本身一直没有模块体系。
那么什么是模块?
优秀的作者把他们的书分成章节,优秀的程序员把他们的程序分成模块。好的模块是高度独立的,具有特定功能的,可以根据需要对它们进行修改,删除或添加,而不会破坏整个系统。
模块化有什么好处?
模块化带来的好处主要是这些:
- 命名空间
在 JavaScript 中,每个 JS 文件的接口都暴露在全局作用域中,每个人都可以访问它们,并且容易造成命名空间污染。模块化可以为变量创建私有空间来避免命名空间污染。
- 可重用性
有没有曾经在某个时候将之前编写的代码复制到新的项目中呢?如果将此代码模块化,则可以反复使用,且在需要修改时只需要修改此模块,而不需要在项目中的每个此代码处做修改。
- 可维护性
模块应该是独立的,一个设计良好的模块应尽可能减少对部分代码库的依赖,从而使其能够独立地删减和修改。当模块与其他代码片段分离时,更新单个模块要容易得多,还可以对每次修改的内容做版本管理。
传统的模块化开发方式
当多个 JS 文件为变量和方法取相同名称而造成命名冲突时,可以采用 Java 中的命名空间的方式。
// 代码来自:https://github.com/seajs/seajs/issues/547
var org = {};
org.CoolSite = {};
org.CoolSite.Utils = {};
org.CoolSite.Utils.each = function (arr) {
// 实现代码
};
org.CoolSite.Utils.log = Function (str) {
// 实现代码
};
类似于 Java 或 Python 等其他编程语言中使用类的方式,可以将公共以及私有的方法和变量存储在单个对象中。将要公开给全局作用域的方法写在闭包外,将私有变量和方法封装在闭包范围内,这样就可以解决变量都暴露在全局作用域的问题。
// 全局作用局可访问
var global = 'Hello World';
(function() {
// 只能在闭包内访问
var a = 2;
})()
虽然这种方法有其好处,但也有其缺点。
- 通过立即执行的工厂函数定义的模块(IIFE: Immediately Invoked Function Expression)。
- 对依赖项的引用是通过通过HTML脚本标记加载的全局变量名完成的。
- 依赖关系是非常弱的:开发人员需要知道正确的依赖顺序。例如,使用 Backbone 的文件不能在 jQuery 标记之前。
- 需要额外的工具来将一组脚本标记替换为一个标记以优化部署。
这在大型项目上很难管理,特别是当脚本以重叠和嵌套的方式具有许多依赖关系时。手写脚本标记的可伸缩性不高,而且它没有按需加载脚本的能力。
那是否有方法,可以不用在全局范围内请求依赖的模块,而是在模块内部请求依赖的模块呢?CommonJS、AMD、CMD、UMD等应运而生,这些模块化规范告诉开发者:
- 如何引入模块的依赖(imports )
- 如何定义模块(code )
- 如何导出模块的接口(exports)
从模块化开发思想提出以来,无论是浏览器端还是服务端 Javascript 开发,开发者们一直在探索满足实际需求的模块化规范及其实现,它们要解决的问题是相同的,即模块化开发和模块依赖的问题,但它们发起的原因却各有不同。
CommonJS
Mozilla 工程师 Kevin Dangoor 于 2009 年 1 月发起 ServerJS 项目,旨在规范化 JavaScript 在服务端使用时的模块化,以及 Filesystem API、I/O Streams、Socket IO 等服务端开发领域所涉及内容的标准化。
他在 what-server-side-javascript-needs 中提到了服务端 JavaScript 需要什么:
a cross-interpreter standard library
a handful of standard interfaces
a standard way to include other modules
a way to package up code for deployment and distribution and further to install packages
并希望这些在尽可能多的操作系统和解释器上工作,包括三个主要的操作系统(Windows、Mac、Linux)和四个主要的解释器( SpiderMonkey 、Rhino、v8、JavaScriptCore),另外还有"浏览器"(本身就是一个独特的环境)。
为了展示其定义的 API 可以广泛适用,在 2009 年 8 月 ,ServerJS 被 改名为 CommonJS 。后来的很多开发 吐槽 ,认为 CommonJS 的模块格式对浏览器很不友好(不支持异步写法),把浏览器当第二类公民,它更适合 ServerJS 这个名称。
I also feel like CommonJS has treated browser use as a second class citizen, which may have made more sense when it was ServerJS. As it stands today, the CommonJS module format is unfriendly to the browser.
NodeJS
同年 5 月 31,美国程序员 Ryan Dahl 实现了 Node.js 项目( New server-side js project: Node),并在同年 11 月 8 日在 JSConf 大会上首次介绍 Node.js( Ryan Dahl at JSConf EU 2009 Video)。
直接使用 CommonJS 规范实现模块体系的 Node.js 广受欢迎,相信绝大部分 Web 开发者至今都管 Node.js 的模块体系叫 CommonJS 规范:
//math.js
exports.sum = function(...nums){
return nums.reduce((result, num) => result num, 0)
}
//index.js
var math = require('math')
exports.result = math.sum(1,3);
事实上,两者的关系并非我们认为的标准制定者和标准执行者的角色。在 2011 年 5 月, Ryan 应 r/node 的版主要求开了个 问答的帖子 , 在回答问题时说到 CommonJS 已死,不值得我们去讨论,那已经是 2009 年的事情:
Consider CommonJS extinct - not worth thinking further about. That was a 2009 thing.
到 2013 年 3 月, brettz9 就此 在 nodejs 社区发问 :
What is the reason for the indifference to CommonJS? I understand you are no longer looking to adhere to it.* Are all contributors abandoning it or just you?
npm 创始人 Isaac Schlueter 对此做出了回应:
A few good things came out of CommonJS. The module system we have now is basically indistinguishable from the original "securable modules" proposal that Kris Kowal originally came up with. (Of course, we went pretty far off the reservation in v0.4, with the whole node_modules folder thing, and loading packages via their "main" field. Maybe we should remove those features, I'm sure that Node users would appreciate us being more spec-compliant!)
评论中认为,CommonJS 标准已经成为小众服务端 JS(Server Side JS)方案的文档集中地,而 Node.js 已经赢得服务端 JS 的竞争,如同 Node.js 创始人 Ryan Dahl 所说:
"Forget CommonJS. It's dead. We are server side JavaScript."
Node.js 就是服务端 JavaScript。更为重要的是,Isaac 更看重真实用户的声音而不是所谓标准制定者的意见,而当时 CommonJS 工作组所提出的新标准更多的是添乱(比如所谓的 Package 标准)。到 2013 年的时候,其实 Node.js Modules 就已经自成一家了。
Module Loader
回到 2009 年,网页开发者们正对着一堆 <script> 标签发愁。如何在浏览器中管理依赖,是一个很让人头疼的问题。YUI 2 和 Google Closure Library 都提出过基于 namespace 的方案,但治标不治本,仍然需要人肉确保脚本的加载、打包顺序。
CommonJS 提出后, 有人疑问 为什么 CommmonJS 只关注服务端 , Kevin Dangoor 在它的 博客 中提到的特别加粗的几点内容并不只能是服务端专属,浏览器端的 JS 同样可以拥有。
In agreement on the desire to have some standardization around the areas that you've bold-ed in your post. One nit though: there's not really anything server-specific about this stuff. It applies to browser-based JS usage, and even other JS usage, like folks integrating with Gnome, Cocoa, etc.
CommonJS 致力于 JavaScript 的服务端生态,模块同步加载,语法非常简洁,对服务端开发很友好。但这在浏览器端是无法接受的,从网络上读取一个模块比从磁盘上读取要花费更长的时间,只要加载模块的脚本正在运行,就会阻止浏览器运行其他,直到模块加载完成。
在 CommonJS 的论坛 中,Kevin Dangoor 发起过 关于异步加载 Commonjs 模块的讨论 以及征集浏览器端的模块加载方案 。论坛中也出现了很多关于如何在浏览器中异步加载 Commonjs 模块的帖子。
- 有提出 transport 方案的,在浏览器上运行前,先通过转换工具将模块转换为符合 Transport 规范的代码.
- 有提出 XHR 加载模块代码文本,再在浏览器中使用 eval 或者 new Function 执行的;
- 有提出应当直接改良 CommonJS,推出纯异步的模块加载方案的;
第三种方案的提出者 James Burke 认为:CommonJS 的模块格式不支持浏览器端的异步加载,需要通过 XHR 等其他方式加载 CommonJS 的模块,对 web 前端开发者很不友好。提出者认为浏览器端开发的最佳实践是:每个页面只加载一个模块。就像这样:
<!-- loader.js defines LOADER_ENTRY_FUNCTION -->
<script src="loader.js"></script>
<script>LOADER_ENTRY_FUNCTION(["page1"]);</script>
page1 模块可能长这样:
LOADER_ENTRY_FUNCTION(
"page1",
["b", "c"],
function(b, c) { //
document.addEventListener("DOMContentLoaded", function() {
//Do page setup in here, use b and c
}, false);
}
);
在 dev 模式下,每个模块都可以单独加载,以提供最佳的调试体验。然后可以通过编译将所有 page1 模块的依赖项和嵌套依赖项并入其中,或者可以通过 loader.js 在运行时组合加载依赖。
RequireJS in AMD
James Burke 于 09 年 12 月在 CommonJS in the browser 中写了很长的篇幅阐述了直接改良 CommonJS 的模块格式以适应浏览器端开发的诉求,但是 CommonJS 的发起者 Kevin Dangoor 并不同意此方案,这也就催生了 RequireJS ,
RequireJS 产生的过程可以翻看 James Burke 曾发起的讨论帖:
- New amd-implement list 11/5/25
- Split off AMD? (was Re: [CommonJS] New amd-implement list)
- Harmony module execution
- AMD proposal change: define.amd
- AMD vs Wrappings
- Function.prototype.toString to discover function dependencies 10/9/16
- Updated Transport proposals 10/3/31
James Burke 制定了 AMD 规范,并在 2010 年实现了遵循 AMD 规范的模块加载器 RequireJS
。建议看下官网的这篇 WHY AMD?
require.config({
path: {
module: './module',
}
});
require(['module/module1.js','module/module2.js'],function(module1,module2){
module1.printModule1FileName();
module2.printModule2FileName();
});
SeaJS in CMD
玉伯觉得 RequireJS 不够完善 ,给 RequireJS 团队提的很多意见都不被采纳,就 自己写了 Sea.js 于 2011 年 11 月发布 ,并制定了 CMD 规范 ,Sea.js 遵循 CMD 规范。
define(function(require, exports, module) {
// exports 是 module.exports 的一个引用
console.log(module.exports === exports); // true
// 重新给 module.exports 赋值
module.exports = new SomeClass();
// exports 不再等于 module.exports
console.log(module.exports === exports); // false
});
ES Module
本章节大部分摘自: Chen Yangjian 的博客:前端模块的现状
在2015 年 6 月, ECMAScript6 标准正式发布,其中的 ES 模块化规范的提出目标是整合 CommonJS、AMD 等已有模块方案,在语言标准层面实现模块化,成为浏览器和服务器通用的模块解决方案。
模块功能由 export 和 import 两个命令完成。export 对外输出模块,import 用于引入模块。 import 更多用法 , export 更多用法 。
// 导入单个接口
import {myExport} from '/modules/my-module.js';
// 导入多个接口
import {foo, bar} from '/modules/my-module.js';
// 导出早前定义的函数
export { myFunction };
// 导出常量
export const foo = Math.sqrt(2);
ES Module 与 CommonJS 及 Loaders 等方案的区别主要在以下方面:
- 声明式而非命令式,或者说 import 是声明语句 Declaration 而非表达式 Statement,在 ES Module 中无法使用 import 声明带变量的依赖、或者动态引入依赖:
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- import 是预先解析、预先加载的,不像 RequireJS 等是执行到点了再发一个请求
对务实主义的 Node.js 开发者来说,这些区别都让 npm 所营造出来的海量社区代码陷入一种尴尬的境地,无论是升级还是兼容都需要大量的工作。对此,David Herman 撰文解释, ES Module 所带来的好处远大于不便 :
- 静态 import 能确保被编译成变量引用 ,这些引用在当前执行环境运行时能被解析器(通过 JIT 编译 polymorphic inline cache )优化,执行更有效率
- 静态 export 能让变量检测更准确 ,在 JSHint、ESLint 等代码检测工具中,变量是否定义是个非常受欢迎的功能,而静态 export 能让这一检测更具准确性
- 更完备的循环依赖处理 ,在 Node.js 等已有的 CommonJS 实现中,循环依赖是通过传递未完成的 exports 对象解决的,对于直接引用 exports.foo 或者父模块覆盖 module.exports 的情况,传统方式无从解决,而因为 ES Module 传递的是引用,便不会有这些问题
其他还有对未来可能新增的标准(宏、类型系统等)更兼容等。
ES Module in Browser
在 ES Module 标准出来之前,尽管社区实现的 Loader 一箩筐,但浏览器自身一直没有选定模块方案,支持 ES Module 对浏览器来说还是比较少顾虑的。
由于 ES Module 的执行环境和普通脚本不同,浏览器选择增加 <script type="module"> ,只有 <script type="module"> 中的脚本(和 import 进来的脚本)才是 module 模式。也只有 module 模式执行的脚本,才可以声明 import 。也就是说,下面这种代码是不行的:
<script>
import foo from "./foo.js"
</script>
<script type="javascript">
import bar from "./bar.js"
</script>
目前,几大常青浏览器都 已支持 ES Module 。最后一个支持的是 Firefox,2018 年 5 月 8 日发布的 Firefox 60 正式支持 ES Module。
此外,考虑到向后兼容,浏览器还增加 <script nomodule> 标签。开发者可以使用 <script nomodule> 标签兼容不支持 ES Module 的浏览器:
// 在浏览器中,import 语句只能在声明了 type="module" 的 script 的标签中使用。
<script type="module" src="./app.js"></script>
// 在 script 标签中使用 nomodule 属性,可以确保向后兼容。
<script nomodule src="./app.bundle.js"></script>
ES Module in Node.js
但在 Node.js 这边,ES Module 遭遇的声音要大很多。前 Node.js 领导者 Isaacs Schlutuer 甚至认为 ES Module 太过阳春白雪且不考虑实际情况,毫无价值( adds nothing )。
首先纠结的是如何支持 module 执行模式,是自动检测,还是 'use module' ,还是在 package.json 里增加 module 属性作为专门的入口,还是干脆增加一个新的扩展名?
最终 Node.js 选择增加新的扩展名 .mjs :
- 在 .mjs 中可以自如使用 import 和 export
- 在 .mjs 中不可以使用 require
- 在 .js 中只能使用 require
- 在 .js 中不可以使用 import 和 export
也就是两套模块系统完全独立。此外,依赖查找方式也有变化,原本 require.extensions是:
{ '.js': [Function],
'.json': [Function],
'.node': [Function] }
如今(需要开启 --experimental-modules 选项)则是:
{ '.js': [Function],
'.json': [Function],
'.node': [Function],
'.mjs': [Function] }
但两套独立的模块系统也导致第二个纠结的方面,模块系统彼此之间如何互通?对浏览器来说这不是问题,但对 Node.js 来说,npm 中海量的 CommonJS 模块是它不得不考虑的。
最终确定的方案倒也简单,在 .mjs 里,开发者可以 import CommonJS(虽然只能 import default ):
import 'fs' from 'fs'
import { readFile } from 'fs'
import foo from './foo'
// etc.
在 .js 里,开发者自然不能 import ES Module,但他们可以 import() :
import('./foo').then(foo => {
// use foo
})
async function() {
const bar = await import('./bar')
// use bar
}()
注意,和浏览器以引入方式判断运行模式不同,Node.js 中脚本的运行模式是和扩展名绑定的。也就是说,依赖的查找方式会有所不同:
- 在 .js 中 require('./foo') 找的是 ./foo.js 或者 ./foo/index.js
- 在 .mjs 中 import './bar' 找的是 ./bar.mjs 或者 ./bar/index.mjs
善用这些特性,我们现在就可以将已有的 npm 模块升级成 ES Module,并且仍然支持 CommonJS 方式。
Dynamic Import
静态型的 import 是初始化加载依赖项的最优选择,使用静态 import 更容易从代码静态分析工具和 tree shaking 中受益。但当希望按照一定的条件或者按需加载模块的时候,需要动态引入依赖,例如:
if (process.env.NODE_ENV !== 'production') {
require('./cjs/react.development.js')
} else {
require('./cjs/react.production.js')
}
if (process.env.BROWSER) {
require('./browser.js')
}
为此,Domenic Denicola 起草 import() 标准 提案 。
//这是一个处于第三阶段的提案。
var promise = import("module-name");
除了可以用来处理动态依赖,HTML 中的 script 标签不需要声明 type="module" 。
<script>
import('./foo.js').then(foo => {
// use foo
})
</script>
在 Node.js 中( .js 文件)还可以使用 import() 引入使用 import 的 ES Module :
import('./foo.mjs').then(foo => {
// use foo
})
使用 ES Module 编写浏览器、Node.js 通用的 JavaScript 模块化代码已经完全可行,我们还需要编译或者打包工具吗?
Module Bundler
本章节大部分摘自: Chen Yangjian 的博客:前端模块的历史沿革
在浏览器端使用模块加载器也存在很多弊端。例如 RequireJS 编码方式不友好、加载其他规范的模块比较麻烦、提前执行等, SeaJS 规则一直变化导致升级出现各种问题等,而 CommonJS 在服务端的使用就很方便稳定,引用第三方库只需简单三步:
- 在 package.json 里面配置模块名和版本号
- npm install 安装模块
- 直接使用 require 引入
那能否在浏览器中也使用 CommonJS 规范的方式引入模块并可以很方便调用其他规范的模块呢?
一种解决办法就是 预编译 ,我们用 CommonJS 规范的方式书写代码定义和引入模块,然后将模块和依赖编译成一个 js 文件,我们都叫它 bundlejs。
Browserify 和 webpack 都是这种预编译的模块化方案, 最终都是 build 生成一个 bundle 文件,在这个 build 的过程里进行依赖关系的解析。
Browserify
Node.js 社区早期活跃成员 substack 开发 Browserify 的初衷非常简单:
Browsers don't have the require method defined, but Node.js does. With Browserify you can write code that uses require in the same way that you would use it in Node.
Browserify[Github] 可以让你使用类似于 node 的 require() 的方式来组织浏览器端的 Javascript 代码,通过预编译让前端 Javascript 可以直接使用 Node NPM 安装的一些库, 也可以引入非 CommonJS 模块,但需要使用 transform(browserify.transform 配置转换插件)。
Browserify 的 require 与 Node.js 保持一致,不支持异步加载。社区希望Browserify支持异步加载的呼声一直很高 ,可见: Support for asynchronous loading (and not packing everything in one file) ,但作者坚持认为 Browserify 的 require 应当和 Node.js 保持一致:
1: wrapping a whole file in a function block is ugly
2: node modules use synchronous requires
3: browserify's goal is to let code written for node run in the browser
Webpack
晚于 Browserify 一年发布的 Webpack 结合了 CommonJS 和 AMD 的优缺点,开发时可按照 CommonJS 的编写方式,支持编译后按需加载和异步加载所有资源。
Webpack 最出色的特性一是它的模块解析粒度以及因此带来的强大打包能力,二是它的可扩展性,相关转换工具(Babel、PostCSS、CSS Modules)可以变成插件快速接入,还能自定义 Loader。这些特性加在一起,无往而不利。
而且它还支持 ES Module :
import defaultExport from "module-name";
import * as name from "module-name";
import { export } from "module-name";
这便是构建工具带来的好处了,发挥空间远比传统浏览器 Loader 来得大,可以轻松加入像 Babel、Traceur 等 transpiler 支持。
更多推荐阅读:
- webpack for browserify users
- how-is-webpack-different
- webpack 与 竞品的对比
Afterword
正如玉伯在 前端模块化开发那点历史 中所说: 随着 W3C 等规范、以及浏览器的飞速发展,前端的模块化开发会逐步成为基础设施。一切终究都会成为历史,未来会更好。
我们现在开发中不必再纠结使用哪种模块化方案, ES6 在语言标准层面为我们解决了这个问题。
前端模块化发展时间线:
- 2009 年,美国程序员 Ryan Dahl 创造了node.js 项目,node.js 的模块系统就是参照CommonJS的模块规范写的。
- 但是 CommonJS 规范中的 require 是同步的,这在浏览器端是不能接受的。所以后来就有了 AMD 规范,2010 年,RequireJS 实现了是 AMD 规范。
- 2012 年来玉伯觉得 RequireJS 不够完善,给 RequireJS 团队提的很多意见都不被采纳,就自己写了 Sea.js,并制定了CMD 规范,Sea.js 遵循 CMD 规范。
- 2015 年 6 月正式发布了 ECMAScript6 标准,在语言标准层面实现了模块功能,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。(这是未来)
- 2015 年 10 月,UMD 出现,整合了 CommonJS 和 AMD 两个模块定义规范的方法。这时候 ES6 模块标准才刚出来,很多浏览器还不支持 ES6 模块化规范。
- 2016 年 browserify
- 2017 年 webpack
前端模块化发展现状:
- CommonJSNodejs Start 67.3k 已成为服务端 JavaScript 标准
- Module Loader(模块加载器已成过去式)RequireJS [GitHub] Start: 12.4k,已经不维护了seajs[Github] Start: 8k, 已经不维护了。作者2015年就发布微博: 应该给 Sea.js 树一块墓碑了。
- ES6 Module语法在主流浏览器和Nodejs8.5版本以上都已支持,查看 浏览器兼容性 。
- Module Bundlerwebpack [GitHub] Star: 52.5k, 现在最火的打包工具browserify [GitHub] Star: 13k, 2019年11月有更新
最后,本文部分参考或摘录自以下文章:
- 前端模块的历史沿革
- 前端模块的现状
- 前端模块化开发那点历史
- JavaScript Modules: A Beginner’s Guide
- JavaScript Modules Part 2: Module Bundling
来源:https://www.tuicool.com/articles/R7bQ32Q
,