本文共 6354 字,大约阅读时间需要 21 分钟。
偶然的一个周末复习了一下 JS 的模块标准,刷新了一下对 JS 模块化的理解。
从开始 Coding 以来,总会周期性地突发奇想进行 Code Review。既是对一段时期的代码进行总结,也是对那一段时光的怀念。
距离上一次 Review 已经过去近两个月,这次竟然把两年前在源续写的代码翻了出来,代码杂乱无章的程度就像那时更加浮躁的自己,让人感慨时光流逝之快。
话不多说,直接上码。
当时在做的是一个境外电商项目(),作为非 CS 的新手程序员,接触 Coding 时间不长和工程化观念不强,在当时的项目中出现了这样的代码:
import.js:
这段代码看起来就是不断地从 DOM 中插进 CSS 和 JS,虽然写得很烂,但是很能反映以前的 Web 开发方式。
在 Web 开发中,有一个原则叫“关注点分离(separation of concerns)“,意思是各种技术只负责自己的领域,不互相耦合混合在一起,所以催生出了 HTML、CSS 和 JavaScript。
其中,在 Web 中负责逻辑和交互 的 JavaScript,是一门只用 10 天设计出来的语言,虽然借鉴了许多优秀静态和动态语言的优点,但却一直没有模块 ( module ) 体系。这导致了它将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能,比如 Ruby
的 require
、Python
的 import
,甚至就连 CSS
都有 @import
,但是 JavaScript 任何这方面的支持都没有。而且 JS 是一种加载即运行的技术,在页面中插入脚本时还需要考虑库的依赖,JS 在这方面的缺陷,对开发大型的、复杂的项目形成了巨大障碍。
虽然 JS 本身并不支持模块化,但是这并不能阻挡 JS 走向模块化的道路。既然本身不支持,那么就从代码层面解决问题。活跃的社区开始制定了一些模块方案,其中最主要的是 CommonJS 和 AMD,ES6 规范出台之后,以一种更简单的形式制定了 JS 的模块标准 (ES Module),并融合了 CommonJS 和 AMD 的优点。
大致的发展过程:
CommonJS(服务端) => AMD (浏览器端) => CMD / UMD => ES Module
2009年,Node.js 横空出世,JS 得以脱离浏览器运行,我们可以使用 JS 来编写服务端的代码了。对于服务端的 JS,没有模块化简直是不能忍。
CommonJs (前 ServerJS) 在这个阶段应运而生,制定了 ,定义了第一版模块标准。
标准内容:
exports
来向外暴露 API,exports
只能是一个对象,暴露的 API 须作为此对象的属性。require
,通过传入模块标识来引入其他模块,执行的结果即为别的模块暴露出来的 API。require
函数引入的模块中也包含依赖,那么依次加载这些依赖。特点:
// a.jsmodule.exports = { moduleFunc: function() { return true; };}// 或exports.moduleFunc = function() { return true;};// 在 b.js 中引用var moduleA = require('a.js');// 或var moduleFunc = require('a.js').moduleFunc;console.log(moduleA.moduleFunc());console.log(moduleFunc())
CommonJS 规范出现后,在 Node 开发中产生了非常良好的效果,开发者希望借鉴这个经验来解决浏览器端 JS 的模块化。
但大部分人认为浏览器和服务器环境差别太大,毕竟浏览器端 JS 是通过网络动态依次加载的,而不是像服务端 JS 存在本地磁盘中。因此,浏览器需要实现的是异步模块,模块在定义的时候就必须先指明它所需要依赖的模块,然后把本模块的代码写在回调函数中去执行,最终衍生出了 。
AMD 的主要思想是异步模块,主逻辑在回调函数中执行,这和浏览器前端所习惯的开发方式不谋而合, 应运而生。
标准内容:
define
来定义模块,用法为:define(id?, dependencies?, factory)
;["require", "exports", "module"]
,factory 中也会默认传入 require, exports, module,与 ComminJS 中的实现保持一致特点:
它的用法看起来是这样的:
// a.jsdefine(function (require, exports, module) { console.log('a.js'); exports.name = 'Jack';});// b.jsdefine(function (require, exports, module) { console.log('b.js'); exports.desc = 'Hello World';});// main.jsrequire(['a', 'b'], function (moduleA, moduleB) { console.log('main.js'); console.log(moduleA.name + ', ' + moduleB.desc);});// 执行顺序:// a.js// b.js// main.js人无完人,AMD/RequireJS 也存在饱受诟病的缺点。按照 AMD 的规范,在定义模块的时候需要把所有依赖模块都罗列一遍(前置依赖),而且在使用时还需要在 factory 中作为形参传进去。
define(['a', 'b', 'c', 'd', 'e', 'f', 'g'], function(a, b, c, d, e, f, g){ ..... });
看起来略微不爽 ...
RequireJS 模块化的顺序是这样的:模块预加载 => 全部模块预执行 => 主逻辑中调用模块
,所以实质是依赖加载完成后还会预先一一将模块执行一遍,这种方式会使得程序效率有点低。
所以 RequireJS 也提供了就近依赖,会在执行至 require 方法才会去进行依赖加载和执行,但这种方式的用户体验不是很好,用户的操作会有明显的延迟(下载依赖过程),虽然可以通过各种 loading 去解决。
// 就近依赖define(function () { setTimeout(function () { require(['a'], function (moduleA) { console.log(moduleA.name); }); }, 1000);});
AMD/RequireJS 的 JS 模块实现上有很多不优雅的地方,长期以来在开发者中广受诟病,原因主要是不能以一种更好的管理模块的依赖加载和执行,虽然有不足的地方,但它提出的思想在当时是非常先进的。
既然优缺点那么必然有人出来完善它, 在这个时候出现。
SeaJS 遵循的是 ,CMD 是在 AMD 基础上改进的一种规范,解决了 AMD 对依赖模块的执行时机处理问题。
SeaJS 模块化的顺序是这样的:模块预加载 => 主逻辑调用模块前才执行模块中的代码
,通过依赖的延迟执行,很好解决了 RequireJS 被诟病的缺点。
SeaJS 用法和 AMD 基本相同,并且融合了 CommonJS 的写法:
// a.jsdefine(function (require, exports, module) { console.log('a.js'); exports.name = 'Jack';});// main.jsdefine(function (require, exports, module) { console.log('main.js'); var moduleA = require('a'); console.log(moduleA.name);});// 执行顺序// main.js// a.js除此之外,SeaJS 还提供了 async API,实现依赖的延迟加载。
// main.jsdefine(function (require, exports, module) { var moduleA = require.async('a'); console.log(moduleA.name);});
SeaJS 的出现,貌似以一种比较完美的形式解决了 JS 模块化的问题,是 CommonJS 在浏览器端的践行者,并吸收了 RequestJS 的优点。
ES Module 是目前 web 开发中使用率最高的模块化标准。
随着 JS 模块化开发的呼声越来越高,作为 JS 语言规范的官方组织 ECMA 也开始将 JS 模块化纳入 TC39 提案中,并在 ECMAScript 6.0 中得到实践。
ES Module 吸收了其他方案的优点并以更优雅的形式实现模块化,它的思想是尽量的静态化,即在编译时就确定所有模块的依赖关系,以及输入和输出的变量,和 CommonJS 和 AMD/CMD 这些标准不同的是,它们都是在运行时才能确定需要依赖哪一些模块并且执行它。ES Module 使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,实现一些只能靠静态分析实现的功能(比如引入宏(macro)和类型检验(type system)。
标准内容:
export
和 import
。export
命令用于规定模块的对外接口,import
命令用于输入其他模块提供的功能。export
命令定义了模块的对外接口,其他 JS 文件就可以通过 import
命令加载这个模块。ES Module 可以有多种用法:
模块的定义:
/** * export 只支持对象形式导出,不支持值的导出,export default 命令用于指定模块的默认输出, * 只支持值导出,但是只能指定一个,本质上它就是输出一个叫做 default 的变量或方法 */// 写法 1export var m = 1;// 写法 2var m = 1;export { m };// 写法 3var n = 1;export { n as m };// 写法 4var n = 1;export default n;模块的引入:
// 解构引入import { firstName, lastName, year } from 'a-module';// 为输入的变量重新命名import { lastName as surname } from 'a-module';// 引出模块对象(引入所有)import * as ModuleA from 'a-module';
在使用 ES Module 值得注意的是:import
和 export
命令只能在模块的顶层,在代码块中将会报错,这是因为 ES Module 需要在编译时期进行模块静态优化,import
和 export
命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行,这种设计有利于编译器提高效率,但也导致无法在运行时加载模块(动态加载)。
对于这个缺点,TC39 有了一个新的提案 -- ,提案的内容是建议引入 import()
方法,实现模块动态加载。
// specifier: 指定所要加载的模块的位置import(specifier)
import()
方法返回的是一个 Promise 对象。 import('b-module') .then(module => { module.helloWorld(); }) .catch(err => { console.log(err.message); });
import()
函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()
函数与所加载的模块没有静态连接关系,这点也是与 import
语句不相同。import()
类似于 Node 的 require
方法,区别主要是前者是异步加载,后者是同步加载。
通过 import
和 export
命令以及 import()
方法,ES Module 几乎实现了 CommonJS/AMD/CMD 方案的所有功能,更重要的是它是作为 ECMAScript 标准出现的,带有正统基因,这也是它在现在 Web 开发中广泛应用的原因之一。
但 ES Module 是在 ECMAScript 6.0 标准中的,而目前绝大多数的浏览器并直接支持 ES6 语法,ES Module 并不能直接使用在浏览器上,所以需要 Babel 先进行转码,将 import 和 export 命令转译成 ES2015 语法才能被浏览器解析。
JS 模块化的出现使得前端工程化程度越来越高,让使用 JS 开发大型应用成为触手可及的现实(VScode)。纵观 JS 模块化的发展,其中很多思想都借鉴了其他优秀的动态语言(Python),然后结合 JS 运行环境的特点,衍生出符合自身的标准。但其实在本质上,浏览器端的 JS 仍没有真正意义上的支持模块化,只能通过工具库(RequireJS、SeaJS)或者语法糖(ES Module)去 Hack 实现模块化。随着 Node 前端工程化工具的繁荣发展(Grunt/Gulp/webpack),使我们可以不关注模块化的实现过程,直接享受 JS 模块化编程的快感。
在复习 JS 模块化的过程中,对 Webpack 等工具的模块化语法糖转码产生了新的兴趣,希望有时间可以去分析一下模块化的打包机制和转译代码,然后整理出来加深一下自己对模块化实现原理的认识和理解。
期待下一篇。
参考文章: