前言
JavaScript最初作为简单的“玩具”语言被设计出来,并不支持模块化,目的只是用来进行表单校验、实现简单的动画效果等。代码从上往下堆积,全局变量管理困难,基本只能实现简单的业务逻辑。
短短二十年间,前端拥有了主动向服务端发送请求并操作返回数据的能力,并且从原生的HTML+CSS+JS程序衍生出各具特色的框架,JavaScript更是从客户端迈向服务器端,进而促进了全栈开发。
而在这些演进过程中,JavaScript Modules 的发展起了关键作用。本文旨在通过模块化的发展历史和各模块化解决方案的特点和作用来加深对JS模块化的理解。
1. 开宗明义
1.1 模块是什么
Good authors divide their books into chapters and sections; good programmers divide their programs into modules.
优秀的作家把书分成章节;优秀的程序员把他们的程序分成模块。
就如一本书中的章节,模块是程序中的语句或代码块的集群。
好的模块是高度自包含的,具有独特的功能,根据需求对它们进行调整、删除或添加时不会影响整体的代码结构。
1.2 为什么使用模块
在ES6已普遍应用的现今,似乎很难想象在没有模块化设计的年代,前端前辈们是如何编写出优雅的程序、如何高效明晰的进行团队开发:
全局变量泛滥,一经更改就会影响后续的代码环节;JS文件需要通过script标签按顺序引入,以处理相互的依赖关系;后引入的文件可以访问先引入的文件中的变量名、函数名,对同类型的事物需定义五花八门的名字来避免命名冲突,不相关的代码却能共享全局变量……
使用模块来支持扩展的、相互依赖的代码的优点:
-
命名空间:通过为变量创建私有空间来避免名称空间污染,通过局部变量来避免命名冲突。
-
可维护性:可以与其他代码解耦,独立地进行更新和改进。
-
可重用性:可以将频繁使用的代码组件化、模块化,使代码简洁、易于更新。
2. 发展史简介
1999年至今流行过的 JavaScript 模块化解决方案主要包括:
- 直接声明依赖(Directly Defined Dependences)1999 by Erik Arvidsson
- 第一次尝试将模块的结构引入JavaScript;第一次实现依赖分离定义。
- 命名空间(Namespace Pattern)2002 by Erik Arvidsson
- 解决了名称冲突的问题,然大型项目维护不便。
- 模块模式(Module Pattern)2003 by Richard Cornford
- 实现代码和数据的隔离的先驱。
- 主要思想是用闭包封装数据和代码,并通过可从外部访问的方法提供对它们的访问。
- 依赖分离定义(Detached Dependency Definitions)
- 模板定义依赖 Template Defined Dependencies (2006)
- 注释定义依赖 Comment Defined Dependencies (2006)
- 外部定义依赖 Externally Defined Dependencies (2007)
- 沙盒(Sandbox Pattern)2009 by Adam Moore
- 使用全局构造函数而不是全局对象,将模块定义为该构造函数的属性。例子 >
- 依赖注入(Dependency Injection)2009 by Miško Hevery
- 所有依赖都来自组件外部,组件不负责初始化依赖,只使用它们。
- CommonJS Modules 2009 by Kevin Dangoor
- 服务器端JavaScript API (ServerJS),后更名为CommonJS。
- 提供了加载模块和导出模块接口的能力。
- 专为同步加载和服务器端设计。
- AMD(Asynchronous Module Definition)2009 by James Burke
- 客户端异步加载模块的解决方案,目的为加速web应用程序的加载。
- UMD(Universal Module Definition)2011
- 代表通用模块定义,解决CommonJS与AMD不能共存的问题。
- 允许在AMD工具以及CommonJS环境中使用相同的模块。
- 标签化模块(Labeled Modules)2012 by Sebastian Markbage
- 使用"exports" 标签导出, “require” 标签导入。
- YModules 2013 by the teams of Yandex.Maps and BEM
- 尽可能透明地使用具有异步特性的模块;重新定义模块的可能性。
- ES2015 Modules
- 目标是创建一种使CommonJS和AMD用户都满意的格式。
- 具有紧凑的语法,对单个导出的优先选择以及对循环依赖的支持。
- 直接支持异步加载和可配置模块加载。
3. 模块化解决方案
本节抽出第二节中广为人知的几个解决方案结合代码简单进行介绍。
3.1 命名空间
// file app.js
var app = {};
// file greeting.js
app.helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
// file hello.js
app.writeHello = function (lang) {
document.write(app.helloInLang[lang]);
};
如上例,使用对象的属性来定义数据或方法,从而避免污染全局作用域,从不同的文件访问app的各个部分。
命名空间为代码组织提供了一些思路,但没有提供代码和数据隔离的解决方案。
3.2 模块模式
模块模式模仿类的概念,在一个对象中存储公共和私有方法和变量。允许创建一个面向公共的API,同时将私有变量和方法封装在一个闭包作用域中。
实现模块模式的几种方法:
使用匿名函数闭包
var globalLang = 'en';
(function () {
// keep these variables private inside this closure scope
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
var writeHello = function (lang) {
document.write(helloInLang[lang])
};
writeHello(globalLang)
}());
例中匿名函数有自己的求值环境或“闭包”,对父名称空间(全局)隐藏了变量。
这种方法的好处在于,可以在这个函数中使用局部变量,而不会意外地覆盖现有的全局变量,但仍然可以访问全局变量。
全局导入
jQuery等库使用的另一种流行方法是全局导入。它类似于上例中的匿名闭包,除了使用参数传入全局变量:
(function (globalVariable) {
// Keep this variables private inside this closure scope
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
// 通过 globalVariable 接口公开下面的方法,同时隐藏function()中的实现
globalVariable.writeHello = function (lang) {
document.write(helloInLang[lang]);
};
}(globalVariable));
在本例中,globalVariable是唯一的全局变量。
这种方法优于匿名函数闭包的地方是预先声明全局变量,让阅读代码的人清楚地看到它。
对象接口
使用自包含对象接口创建模块:
var greeting = (function () {
var module = {};
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
module.getHello = function (lang) {
return helloInLang[lang];
};
module.writeHello = function (lang) {
document.write(module.getHello(lang))
};
return module;
}());
本例中的立即调用的函数返回一个模块对象,对象getHello方法通过闭包访问helloInLang对象。
通过这种方法可以达到对私有的变量/方法(helloInLang)和公开的变量/方法(getHello、writeHello)的控制。外部无法访问私有变量,避免了命名冲突。
显式公开
这与上面的方法非常相似,除了它确保所有方法和变量都保持私有,直到显式公开:
var greeting = (function () {
var module = {};
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
var getHello = function (lang) {
return helloInLang[lang];
};
var writeHello = function (lang) {
document.write(module.getHello(lang))
};
// 显式地指向我们想要公开的私有函数的公共指针
return {
getHello: getHello,
writeHello: writeHello
}
}());
以上方法的共同点是使用单个全局变量将其代码包装在函数中,从而使用闭包作用域为自己创建一个私有名称空间。
模块模式虽然是实现代码和数据的隔离的先驱,但文件的依赖关系仍然管理困难,并且仍然会导致名称空间冲突。
3.3 依赖注入
2004年,Martin Fowler引入了“依赖注入”的概念,用于描述Java中组件通信的新机制。关键思想是,所有依赖都来自组件外部,因此组件只使用而不负责初始化依赖。
Angular中的模块是通过依赖注入机制实现的。
// file greeting.js
angular.module('greeter', [])
.value('greeting', {
helloInLang: {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
},
sayHello: function (lang) {
return this.helloInLang[lang];
}
});
// file app.js
angular.module('app', ['greeter'])
.controller('GreetingController', ['$scope', 'greeting', function ($scope, greeting) {
$scope.phrase = greeting.sayHello('en');
}]);
2.4 CommonJS
// file greeting.js
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
var sayHello = function (lang) {
return helloInLang[lang];
}
module.exports.sayHello = sayHello;
// file hello.js
var sayHello = require('./lib/greeting').sayHello;
var phrase = sayHello('en');
console.log(phrase);
CommonJS 引入了实体require、 exports来导入导出。其中module代表当前模块,作为一个对象,有id, filename, loaded, parent, children, exports等属性。
module.exports 属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取 module.exports 变量。而且模块加载的是输出值的拷贝,外部模块输出值被更改时,当前模块的导入值不会发生变化。
2.5 ES2015 Modules
ES2015 Modules 是JavaScript第一次拥有的内置模块。模块存储在文件中,每个文件只有一个模块,每个模块只有一个文件。与CommonJS不同,ES6模块是单例的,即使模块多次导入,也仅存在一个“实例”。另外,ES6模式可以支持文件之间的循环依赖。
ES6模块包括两个部分:
- 声明性语法(用于导入和导出)
- 程序加载器API:配置模块的加载方式以及有条件地加载模块
导出方式
有两种从模块导出内容的方法。这两种方法可以混合使用,但通常分开使用更好。
-
命名导出 named exports
// file lib/greeting.js const helloInLang = { en: 'Hello world!', es: '¡Hola mundo!', ru: 'Привет мир!' }; export function getHello(lang) { return helloInLang[lang]; } export function sayHello(lang) { console.log(getHello(lang)) } // file hello.js import {sayHello} from "./lib/greeting"; sayHello("en"); // 或者导入完整的模块 import * as greeting from './lib/greeting'; greeting.sayHello("en"); -
默认导出 default export
//------ myFunc.js ------ export default function () { ··· } //------ main1.js ------ import myFunc from 'myFunc'; myFunc(); // or a class: //------ MyClass.js ------ export default class { ··· } //------ main2.js ------ import MyClass from 'MyClass'; const inst = new MyClass();
程序加载器API(programmatic loader API)
ES6的模块加载器API允许我们以编程方式使用模块、配置模块加载,但模块加载器API并不属于ES6标准,而且仍在开发中。
几个重要的加载方法:
System.import(module):导入模块System.module(source, options?):评估模块中的代码System.set(name, module):注册模块System.define(name, source, options?):评估模块并进行注册
通过基于Promises的API以编程方式导入模块:
System.import('some_module').then(some_module => {
// use some_module
}).catch(error => {
//...
});
// 使用 Promise.all() 批量导入模块
Promise.all(
['module1', 'module2', 'module3'].map(x => System.import(x)))
.then(([module1, module2, module3]) => {
// Use module1, module2, module3
});
同步Scripts vs 异步Modules
| Scripts | Modules | |
|---|---|---|
| HTML element | <script> | <script type="module"> |
| 默认模式 | non-strict | strict |
| 顶级变量 | global | local to module |
顶级变量 this 指向 | window | undefined |
| 执行同/异步方式 | 同步 | 异步 |
声明式 imports (使用import 语句) | no | yes |
| 程序化 imports (Promise-based API) | yes | yes |
| 文件扩展名 | .js | .js |
总结
JavaScript Modules 的演变历程大致可分为以下几个阶段:
-
命名空间形式的代码封装
-
通过立即执行函数创建的命名空间
-
服务器端运行时 Nodejs 的 CommonJS 规范
-
将模块化运行在浏览器端的 AMD/CMD 规范
-
兼容 CommonJS 和 AMD 的 UMD 规范
-
JavaScript内置模块 ES2015 Module
为了解决全局变量污染、命令冲突、文件依赖管理复杂等问题,JS模块化从无到有,结构从零散到系统,吸取经验、扬长避短,最终有了今天的原生JS模块,不失为一个以痛点为导向逐渐完善解决方案的经典案例。
参考资料
JavaScript Modules: A Beginner’s Guide
The Evolution of JavaScript Modularity