了如指掌 JavaScript 模块化规范

68 阅读9分钟

模块的核心就是创建独立的作用域

ES6模块

目前最主流的模块化方案应该是 ECMAScript 2015 提出的模块化规范(也称 “ES6 模块”),这个规范

同时适用于 JavaScript 的前后端环境。

定义和引用

由于目前大多数项目都使用了 ES6 模块规范,这里只补充 3个小知识:

  • ES6 模块强制自动采用严格模式,所以说不管有没有 “user strict” 声明都是一样的,换言之,编写代码的时候不必再刻意声明了;
  • 虽然大部分主流浏览器支持 ES6 模块,但是和引入普通 JS 的方式略有不同,需要在对应 script 标签中将属性 type 值设置为 “module” 才能被正确地解析为 ES6 模块;
  • 在 Node.js 下使用 ES6 模块则需要将文件名后缀改为 “.mjs”,用来和 Node.js 默认使用的CommonJS 规范模块作区分。

特性

ES6 模块有两个重要特性一定要掌握,一个是值引用,另一个是静态声明。

值引用是指 export 语句输出的接口,与其对应的值是动态绑定关系。即通过该接口,可以取到模块内部实时的值,可以简单地理解为变量浅拷贝。

下面是一个简单的例子,模块 a 导出变量 a,初始值为空字符串,500 毫秒后赋值为字符串'a';模块 b

引用模块 a 并打印,控制台输出空字符串,1 秒后继续打印,控制台输出字符串'a'。

export var a = '';
setTimeout(() = >a = 'a', 500)

import {a} from './a.js'
console.log(a) 
setTimeout(() = >console.log(a), 1000)

ES6 模块对于引用声明有严格的要求,首先必须在文件的首部,不允许使用变量或表达式,不允许被嵌

入到其他语句中。所以下面 3 种引用模块方式都会报错。

// 错误1
let a = 1
import { app } from './app';

// 错误2
import { 'a' + 'p' + 'p' } from './app';

// 错误3
if (moduleName === 'app') { 
  import { init } from './app'; 
} else { 
  import { init } from './bpp'; 
}

定义这些严格的要求可不仅仅是为了代码的可读性,更重要的是可以对代码进行静态分析。

静态分析是指不需要执行代码,只从字面量上对代码进行分析。例如,在上面的错误代码中,有一段代码需要通过判断变量 moduleName 的值来加载对应的模块,这就意味着需要执行代码之后才能判断加载哪个模块,而ES6 模块则不需要。这样做的好处是方便优化代码体积,比如通过 Tree-shaking 操作消除模块中没有被引用或者执行结果不会被用到的无用代码。

import的动态模块提案

虽然 ES6 模块设计在 90% 情况下是很有用的,特别是配合一些工具使用,但是却无法应付某些特殊场景。比如,出于性能原因对代码进行动态加载,所以在 ES2020 规范提案中,希望通过 import() 函数来支持动态引入模块。

具体用法如下所示,调用 import() 函数传入模块路径,得到一个 Promise 对象。

import(`. / section - modules / $ {
  link.dataset.entryModule
}.js`).then(module = >{
  module.loadPageInto(main);
}).
catch(err = >{
  main.textContent = err.message;
});

import() 函数违反了上面静态声明的所有要求,并且提供了其他更强大的功能特性。

  • 违反首部声明要求,那么就意味着可以在代码运行时按需加载模块,这个特性就可以用于首屏优化,根据路由和组件只加载依赖的模块。

  • 违反变量或表达式要求,则意味着可以根据参数动态加载模块。

  • 违反嵌入语句逻辑规则,可想象空间更大,比如可以通过 Promise.race 方式同时加载多个模块,选择加载速度最优模块来使用,从而提升性能。

CommonJS

CommonJS 最初名为 Server.js,是为浏览器之外的 JavaScript 运行环境提供的模块规范,最终被Node.js 采用。

定义和引用

CommonJS 规定每个文件就是一个模块,有独立的作用域。 每个模块内部,都有一个 module 对象,代表当前模块。通过它来导出 API,它有以下属性:

  • id 模块的识别符,通常是带有绝对路径的模块文件名;
  • filename 模块的文件名,带有绝对路径;
  • loaded 返回一个布尔值,表示模块是否已经完成加载;
  • parent 返回一个对象,表示调用该模块的模块;
  • children 返回一个数组,表示该模块要用到的其他模块;
  • exports 表示模块对外输出的值。

引用模块则需要通过 require 函数,它的基本功能是,读入并执行一个 JavaScript 文件,然后返回该模块的 exports 对象。

特性

CommonJS 特性和 ES6 恰恰相反,它采用的是值拷贝和动态声明。值拷贝和值引用相反,一旦输出一个值,模块内部的变化就影响不到这个值了,可以简单地理解为变量深拷贝。

仍然使用上面的例子,改写成 CommonJS 模块,在 Node.js 端运行,控制台会打印两个空字符串。

var a = '';
setTimeout(() = >a = 'a', 500)
module.exports = a

var a = require('./a.js')
console.log(a) 
setTimeout(() = >console.log(a), 1000)

动态声明就很好理解了,就是消除了静态声明的限制,可以 “自由” 地在表达式语句中引用模块。

AMD

在 ES6 模块出现之前,AMD(Asynchronous Module Definition,异步模块定义)是一种很热门的浏览器模块化方案。

定义和引用

AMD 规范只定义了一个全局函数 define,通过它就可以定义和引用模块,它有 3 个参数:

define(id?, dependencies?, factory);

第 1 个参数 id 为模块的名称,该参数是可选的。如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字;如果提供了该参数,模块名必须是 “顶级” 的和绝对的(不允许相对名字)。

第 2 个参数 dependencies 是个数组,它定义了所依赖的模块。依赖模块必须根据模块的工厂函数优先级执行,并且执行的结果应该按照依赖数组中的位置顺序以参数的形式传入(定义中模块的)工厂函数中。

第 3 个参数 factory 为模块初始化要执行的函数或对象。如果是函数,那么该函数是单例模式,只会被执行一次;如果是对象,此对象应该为模块的输出值。

下面是一个简单的例子,创建一个名为 “alpha” 的模块,依赖了 require、exports、beta 3 个模块,并

导出了 verb 函数。

define("alpha", ["require", "exports", "beta"],
function(require, exports, beta) {
  exports.verb = function() {
    return beta.verb();
  }
})

特性

它的重要特性就是异步加载。所谓异步加载,就是指同时并发加载所依赖的模块,当所有依赖模块都加载完成之后,再执行当前模块的回调函数。这种加载方式和浏览器环境的性能需求刚好吻合。

由于 AMD 并不是浏览器原生支持的模块规范,所以需要借助第三方库来实现,其中最有名的就是RequireJS。它的核心是两个全局函数 define 和 require,define 函数可以将依赖注入队列中,并将回调函数定义成模块;

require 函数主要作用是创建 script 标签请求对应的模块,然后加载和执行模块。

CMD

CMD(Common Module Definition,通用模块定义)是基于浏览器环境制定的模块规范。

定义和引用

CMD 定义模块也是通过一个全局函数 define 来实现的,但只有一个参数,该参数既可以是函数也可以是对象:

如果这个参数是对象,那么模块导出的就是对象;如果这个参数为函数,那么这个函数会被传入 3 个参数 require 、 exports 和 module。

define(function(require, exports, module) { });

第 1 个参数 require 是一个函数,通过调用它可以引用其他模块,也可以调用 require.async 函数来异步调用模块。

第 2 个参数 exports 是一个对象,当定义模块的时候,需要通过向参数 exports 添加属性来导出模块API。

第 3 个参数 module 是一个对象,它包含 3 个属性:

  • uri,模块完整的 URI 路径;
  • dependencies,模块的依赖;
  • exports,模块需要被导出的 API,作用同第二个参数 exports。

下面是一个简单的例子,定义了一个名为 increment 的模块,引用了 math 模块的 add 函数,经过封

装后导出成 increment 函数。

define(function(require, exports, module) {
  var add = require('math').add;
  exports.increment = function(val) {
    return add(val, 1);
  };
  module.id = "increment";
});

特性

CMD 最大的特点就是懒加载,和上面示例代码一样,不需要在定义模块的时候声明依赖,可以在模块执行时动态加载依赖。当然还有一点不同,那就是 CMD 同时支持同步加载模块和异步加载模块。

用一句话来形容就是,它整合了 CommonJS 和 AMD 规范的特点。遵循 CMD 规范的代表开源项目是sea.js ,它的实现和 requirejs 没有本质差别。

UMD

UMD(Universal Module Definition,统一模块定义)其实并不是模块管理规范,而是带有前后端同构思想的模块封装工具。通过 UMD 可以在合适的环境选择对应的模块规范。比如在 Node.js 环境中采用 CommonJS 模块管理,在浏览器端且支持 AMD 的情况下采用 AMD 模块,否则导出为全局函数。

它的实现原理也比较简单:

  • 先判断是否支持 Node.js 模块格式(exports 是否存在),存在则使用 Node.js 模块格式;
  • 再判断是否支持 AMD(define 是否存在),存在则使用 AMD 方式加载模块;
  • 若前两个都不存在,则将模块公开到全局(Window 或 Global)。

大致实现如下:

(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    define([], factory);
  } else if (typeof exports === 'object') {
    module.exports,
    module.exports = factory();
  } else {
    root.returnExports = factory();
  }
} (this,
function() {
  return {};
}));

总结

JavaScript模块化规范主要包含了以下几类:

  • 原生规范ES6模块
  • Node.js采用的CommonJS模块
  • 开源社区早期为浏览器提供的AMD规范
  • 具有 CommonJS 特性和 AMD 特性的 CMD规范
  • 让CommonJS 和 AMD 模块跨端运行的 UMD规范

—— 公众号「风度前端」 ——

为每一个有志成功的「前端er」引路领航,筑梦心声。

Just to be an excellent front end engineer!