本文介绍了Javascript模块化的历史发展过程,内容包括:模块化的理解、模块化的常见推荐、从发展历史了解 ES6 模块化三个部分,介绍了 CJS、AMD、UMD、ESM 的用法和历史发展渊源。本文的学习目标是了解 JavaScript 模块化的发展历程,了解常见的模块化规范、使用方法和相互关系,了解 ESM 出现的里程碑意义。
模块化的理解
什么是模块?
- 将一个复杂的程序依据一定的规则(规范)封装成几个模块(文件), 并进行打包组合在一起。
- 模块的内部数据/实现是私有的, 向外部暴露一些接口(方法)与外部进行模块通信。
模块的组成
- 数据:内部的属性
- 操作数据的行为:内部的函数
模块化的进化过程
- 全局function模式:编码: 全局变量/函数;问题: 污染全局命名空间, 容易引起命名冲突/数据不安全。
- namespace模式:编码: 将数据/行为封装到对象中;解决: 命名冲突(减少了全局变量);问题: 数据不安全(外部可以直接修改模块内部的数据)。
- IIFE模式/增强:
IIFE : 立即调用函数表达式(匿名函数自调用)。
编码: 将数据和行为封装到一个函数内部, 通过给window添加属性来向外暴露接口
引入依赖: 通过函数形参来引入依赖模块。
(function (window, module2) {
var data = "atguigu.com";
function foo() {
module2.xxx();
console.log("foo()" + data);
}
function bar() {
console.log("bar()" + data);
}
window.module = { foo };
})(window, module2);
模块化规范
CommonJS
Node.js : 服务器端;Browserify : 浏览器端,也称为js的打包工具。
基本语法:
// 定义暴露模块 : exports
exports.xxx = value
module.exports = value
// 引入模块 : require
var module = require('模块名/模块相对路径')
引入模块发生在什么时候?
Node : 运行时, 动态同步引入;Browserify : 在运行前对模块进行编译、打包处理(将依赖的模块包含), 运行的是打包生成的js, 运行时不需要再从远程引入依赖模块。
AMD : 浏览器端
require.js,基本语法:
定义暴露模块: define([依赖模块名], function(){return 模块对象})
。
引入模块: require(['模块1', '模块2', '模块3'], function(m1, m2){//使用模块对象})
。
配置:
require.config({
//基本路径
baseUrl : 'js/',
//标识名称与路径的映射
paths : {
'模块1' : 'modules/模块1',
'模块2' : 'modules/模块2',
'angular' : 'libs/angular',
'angular-messages' : 'libs/angular-messages'
},
//非AMD的模块
shim : {
'angular' : {
exports : 'angular'
},
'angular-messages' : {
exports : 'angular-messages',
deps : ['angular']
}
}
})
CMD : 浏览器端
sea.js,基本语法:
// 定义暴露模块
define(function(require, module, exports){
// 通过require引入依赖模块
// 通过module/exports来暴露模块
exports.xxx = value
})
// 使用模块
seajs.use(['模块1', '模块2'])
ES6
ES6 内置了模块化的实现。
基本语法:
// 定义暴露模块: export
export default 对象
export var xxx = value1
export let yyy = value2
var xxx = value1
let yyy = value2
export {xxx, yyy}
// 引入使用模块 : import
import xxx from '模块路径/模块名'
import {xxx, yyy} from '模块路径/模块名'
import * as module1 from '模块路径/模块名'
问题: 并非所有浏览器都能支持ES6模块化的语法。
解决:使用Babel将ES6转译为ES5(使用了CommonJS) ,浏览器还不能直接运行。使用Browserify等打包工具打包处理,浏览器可以运行。
从发展历史了解 ES6 模块化
ES6 在很长一段时间里给 JavaScript 带来了最大的变化,包括一些管理大型复杂代码库的新特性。这些特性,主要是 import 和 export 关键字,统称为模块。
如果您是 JavaScript 的新手,特别是如果您来自已经内置支持模块化的另一种语言(使用不同名称如模块、包或单元) ,那么 es6模块的设计可能看起来很奇怪。许多设计都来自于 JavaScript 社区多年来设计的解决方案,以弥补内置支持的不足。
我们将看看 JavaScript 社区在每个解决方案中克服了哪些挑战,哪些仍然没有解决。最后,我们将看到这些解决方案如何影响 es6模块的设计,以及 es6 模块如何着眼于定位自己的未来。
从 <script> 标签开始,然后是争议
起初,HTML 仅限于面向文本的元素,这些元素以非常静态的方式处理。Mosaic 是早期最流行的浏览器之一,在所有 HTML 下载完成之前不会显示任何内容。在90年代早期的拨号连接中,这会让用户盯着空白的屏幕几分钟。
20世纪90年代中后期,Netscape Navigator 浏览器几乎一出现就迅速流行起来。像许多当前的颠覆性创新者一样,网景公司推动了一些并不被普遍喜欢的变革。Navigator 的众多创新之一是在下载时渲染 HTML,允许用户尽快开始阅读页面,标志着 Mosaic 在这一过程中的终结。
在1995年著名的10天时间里,Brendan Eich 为 Netscape 创建了 JavaScript。<script> 标签阻塞 HTML 下载和呈现的过程。当时普遍使用的有限通信资源无法同时处理获取两个数据源的操作,所以当浏览器在标记中看到 <script> 时,它会暂停 HTML 的执行并切换到处理 JS。此外,通过浏览器提供的称为 DOM 的 API 执行的任何影响 HTML 呈现的 JS 操作,甚至给当前最先进 Pentium CPU 带来了计算压力。因此,当 JavaScript 完成下载后,它将只在继续处理 HTML 之后解析并执行。
起初,很少有程序员使用 JS 做重要的工作。这个名字也表明,与 Java 和 ASP 这样的服务器端相关语言相比,JavaScript 属于二等公民。世纪之交时大多数 JavaScript 都局限于服务器无法影响的客户端的运行条件——通常是简单的表单逻辑,比如将焦点放在第一个字段,或者在提交表单之前验证表单输入。AJAX 当时最广为人知的含义仍然是指苛刻的家庭清洁器,几乎所有重要的操作都需要完整的 HTTP 往返于服务器和服务器之间,所以几乎所有的 web 开发者都是后端开发者,他们看不起这种“玩具”语言。
你在最后一段中抓住重点了吗?验证一个表单输入可能很简单,但验证多个表单上的多个输入就变得复杂了——毫无疑问,JS 代码库也是如此。正因为客户端脚本拥有不可否认的可用性优势,script 标签的问题也出现了: DOM 准备就绪通知的不可预测性; 文件连接中的变量冲突; 依赖管理等。
JS 开发人员很容易找到工作,但很难享受这些工作。当 jQuery 在2006年出现的时候,开发者们热情地接受了它。今天,在排名前1000万的网站中,有65% 到70% 安装了 jQuery。但是它从来没有打算,也无法解决架构问题。
我们到底需要什么?
幸运的是,其他语言已经遇到了这个复杂性的障碍,并且找到了一个解决方案: 模块化编程。模块化产生了很多最佳实践:
- 代码分离: 代码需要被分割成更小的块,这样才具有可读性。最佳实践建议这些块应该采用文件的形式。
- 可组合性: 代码分离到文件中,但需要在其他文件中重用代码。这样可以提高代码库的灵活性。
- 依赖管理:
- 命名空间管理
- 实现的一致性: 如果每个人都对同样的问题提出自己的解决方案就会造成不一致性。
早期的解决方案
开发人员提出的这些问题的每一个解决方案都对 es6 模块的结构产生了影响。我们将回顾它们发展过程中的主要里程碑,以及社区在每个步骤中学到了什么,最后以今天的 es6 模块的形式显示结果。
- Object Literal pattern
- IIFE/Revealing Module pattern
- CommonJS
- AMD
- UMD
Object Literal pattern 对象字面量模式
JavaScript 已经有了一个内置的组织结构: 对象。object 语法曾是早期的组织代码模式。这种方法的主要好处是易于理解和实现。
缺陷:它依赖于全局作用域中的一个变量作为它的 root,会导致变量污染;它没有可重用性。
IIFE/Revealing Module pattern
IIFE(Immediately Invoked Function Expression) 是一个立即被调用的函数表达式。
闭包让我们在 IIFE 中有了更多的控制权。内部变量是私有的,外部无法访问。我们有类似构造函数的功能。我们可以控制导入依赖项。最后,将这些技术结合起来,我们就可以实现装饰器模式,从而开始将代码与传递给它依赖离开来。
IIFE/Revealing 模块提供了许多功能,正如我们将要看到的,对 AMD 有很大的影响。但是语法很丑陋,它是一个没有标准的临时的 hack,而且它仍然严重依赖于全局环境。
CommonJS
在进化的同步分支中,JavaScript 进军到服务器端。尽管 Node 赢得了这场战斗,但是早期有很多竞争者,包括 Ringo,Narwhal 和 Wakanda。这些开发人员知道客户端 JS 经历了模块化问题,希望解决混乱和低效的问题。因此,他们成立了一个工作组,为服务器端模块开发一个标准,但是他们做的并不好。不过,在这段时间里,Node 使用 CommonJS 中的一些思想创建了自己的模块实现。由于 Node 在服务器端广受欢迎,Node 格式被(不正确地)称为 CommonJS,并在今天蓬勃发展。
作为一个服务器端解决方案,CommonJS 假定一个兼容的脚本加载器作为先决条件。这个脚本加载器必须支持名为 require 和 module.export 的函数,这些函数将模块相互传输(还有 module.export 的语法快捷方式 export)。虽然它从未在浏览器中流行起来,但确实有一些工具支持在浏览器中加载它,如 Browserify。
考虑到加载程序的外部需求,语法清晰、简洁,并且直接影响 es6模块的语法。此外,模块将变量范围限制在自己内部; 甚至不再可以声明全局变量。
缺点是,CJS 在异步环境中不能很好地运行。所有 require 的调用都必须在代码可以处理之前执行。这解释了前面的预编译步骤,但是这也使得“延迟加载”非常困难——在任何执行开始之前都需要加载所有代码。
AMD
具有极大讽刺意味的是,虽然 CommonJS 工作组未能就服务器端标准达成一致,但讨论的确就客户端格式达成了共识。AMD,或者叫做异步模块定义(Asynchronous Module Definition),诞生于 CommonJS 的讨论中。像 IBM 和 BBC 这样的主要竞争者支持 AMD,并给予他们影响力。它很快就成为了前端开发实践者的主导形式。
AMD 对脚本加载器有一个与 CJS 类似的先决条件,尽管 AMD 只假定支持一个名为 define 的方法。Define 方法有三个参数: 正在定义的模块的名称、正在定义的模块运行所需的依赖项数组,以及一旦所有依赖项都可用时执行的函数(它按照依赖项声明的顺序将依赖项作为参数接收)。
虽然官方不提倡命名模块,而且它们本身也不会增加任何好处,但不幸的是,它们在实践中很常见。一旦使用了命名模块,就必须为每个模块设置 baseUrl 和路径,从而失去了在过程中更改代码位置的自由。
AMD 的 define 方法对应于 CJS 的 export,但是 AMD 没有指定等效的 import 或 require 方法。模块内部的依赖关系由要定义的第二个参数处理,并由脚本加载程序在模块外部加载。
这种格式利用了这样一个事实,即 JavaScript 有两个步骤: 解析,解释代码(以及发现语法错误) ,然后执行代码(可能会遇到运行时错误)。在解析过程中,变量的指向还不一定存在; 代码只需要在语法上正确。等待执行函数直到所有依赖项完成加载的责任落在脚本加载程序身上。
AMD 统治了很多年,但严重依赖于一个丑陋的语法。浏览器要求的异步特性,意味着它不能被静态分析,还有其他类似的小原因,导致它不是每个人的解决方案。
UMD
通用模块定义(Universal Module Definition)试图将 AMD 和 CJS 结合在一起,通常是在 AMD 兼容的包装器中包装 CJS 语法。它朝着可以在服务器和客户机上运行 JavaScript 的圣杯迈出了第一步。
ES6 modules
负责 es6 设计的 tc39 委员会很好地吸取了 AMD 和 CJS 的教训,es6 是15年来语言最大的变化。Es6 模块将最终带来对模块化这一其他语言已经享受多年的特性的内置支持,并包括提议的功能,满足前端和后端开发人员的需求。
只剩下一个小问题: 前端生态系统几乎不支持 es6 模块。没有浏览器本身支持新的导入和导出关键字,或者提议的 html5 模块元素。像 Babel 和 Traceur 这样的 transpiler 工具可以将 es6 模块预编译成有效的现在的浏览器可以处理的 es5 代码;但是 es5 必须用异步语法包装,然后由 RequireJS、 Browserify 或 SystemJS 这样的脚本加载器处理。试图通过这两个抽象层(transpilation 和异步加载)传递一个简单的 es6 模块,往往会带来实现上的挑战。