Skip to content

如何复用代码(模块化)

作为前端工程师的你,或许早已习惯了在编写浏览器组件时使用 import 和 from 来管理代码模块,在编写 Node.js 服务时通过 require 和 module.exports 来复用代码。但 JavaScript 模块化之路充满了坎坷。这一课时就带你由近及远,看看 JavaScript 模块发展史上那些著名的模块规范与实现。

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'。

js
// a.js
export var a = "";
setTimeout(() => (a = "a"), 500);
// b.js
import { a } from "./a.js";
console.log(a); // ''
setTimeout(() => console.log(a), 1000); // 'a'

ES6 模块对于引用声明有严格的要求,首先必须在文件的首部,不允许使用变量或表达式,不允许被嵌入到其他语句中。所以下面 3 种引用模块方式都会报错。

js
// 必须首部声明
let a = 1
import { app } from './app';
// 不允许使用变量或表达式
import { 'a' + 'p' + 'p' } from './app';
// 不允许被嵌入语句逻辑
if (moduleName === 'app') {
  import { init } from './app';
} else {
  import { init } from './bpp';
}

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

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

延伸 1:import 的动态模块提案

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

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

js
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 定义和引用

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

  • id 模块的识别符,通常是带有绝对路径的模块文件名;

  • filename 模块的文件名,带有绝对路径;

  • loaded 返回一个布尔值,表示模块是否已经完成加载;

  • parent 返回一个对象,表示调用该模块的模块;

  • children 返回一个数组,表示该模块要用到的其他模块;

  • exports 表示模块对外输出的值。

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

CommonJS 特性

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

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

js
// a.js
var a = "";
setTimeout(() => (a = "a"), 500);
module.exports = a;
// b.js
var a = require("./a.js");
console.log(a); // ''
setTimeout(() => console.log(a), 1000); // ''

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

AMD

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

AMD 定义和引用

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

js
define(id?, dependencies?, factory);

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

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

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

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

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

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

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

require 函数主要作用是创建 script 标签请求对应的模块,然后加载和执行模块。下面是部分源码,有兴趣的同学可以看完整的源码。

js
var requirejs, require, define;
(function (global, setTimeout) {
  ...
  define = function (name, deps, callback) {
    ...
    if (context) {
      context.defQueue.push([name, deps, callback]);
      context.defQueueMap[name] = true;
    } else {
      globalDefQueue.push([name, deps, callback]);
    }
  };
  ...
  req.load = function (context, moduleName, url) {
    ...
    if (isBrowser) {
      node = req.createNode(config, moduleName, url);
      ...
      if (baseElement) {
        head.insertBefore(node, baseElement)
      } else {
        head.appendChild(node)
      }
      currentlyAddingScript = null;
      return node
    }
  };
  ...
}(this, (typeof setTimeout === 'undefined' ? undefined : setTimeout)));

CMD

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

CMD 定义和引用

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

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

js
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 函数。

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

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

用一句话来形容就是,它整合了 CommonJS 和 AMD 规范的特点。遵循 CMD 规范的代表开源项目是 sea.js ,它的实现和 requirejs 没有本质差别,有兴趣的同学可以看其源码:seajs/seajs: A Module Loader for the Web (github.com)

UMD

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

  • 先判断是否支持 Node.js 模块格式(exports 是否存在),存在则使用 Node.js 模块格式;

  • 再判断是否支持 AMD(define 是否存在),存在则使用 AMD 方式加载模块;

  • 若前两个都不存在,则将模块公开到全局(Window 或 Global)。

大致实现如下:

js
(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 {};
});

延伸 2:ES5 标准下如何编写模块

模块的核心就是创建独立的作用域,要实现这个目的,我们在第 08 课时中提到过,可以通过函数来实现。 如果直接在全局作用域下定义函数会很容易因为命名冲突而导致代码覆盖,为了避免这种情况可以通过对象创建“命名空间”。但是它有个缺点,就是无法创建私有变量,并不符合“高内聚、低耦合”的编码原则,也容易出现 bug。

js
var mod = {
  a: '',
  f: function() {
     ...
  },
}

为了解决这个问题,立即执行函数的形式也就出现了,这种形式就是在定义函数的时候就调用它并导出模块 API。

js
var mod = (function(w){
  function f() {
    ...
  }
  var a = ''
  ...
  return {
    f,
    a
  };
})(window);

有了这两个基础知识点,我们再通过 webpack 编译 ES6 模块的例子加深理解。

下面的代码定义了 2 个 ES6 模块,分别是 index.js、m.js,其中模块 index.js 依赖 m.js 模块的 API:

js
// index.js
import { text, write } from "./m";
write(`<h1>${text} ${text2}</h1>`);
// m.js
const write = (content) => document.write(content);
var text = "hello";
export { text, write };

查看编译后的代码我们发现,整个代码就是一个立即执行函数,这个立即执行函数的参数就是对象形式的模块定义。

js
// bundle.js
(function(modules) {
  ...
})({
  "./index.js": (function(module, __webpack_exports__, __webpack_require__) {
    ...
  },
  "./m.js": (function(module, __webpack_exports__, __webpack_require__) {
    ...
  }
})

这个立即执行函数会加载一个初始模块,也就是 webpack 配置的 entry 模块,按照依赖关系调用模块对应的函数并缓存。

js
function (modules) {
  var installedModules = {};
  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  ...
  return __webpack_require__(__webpack_require__.s = "./index.js");
}

那么这些 ES6 模块是怎么转化成函数的呢?

从上面的代码我们可以看到,每个模块定义函数都会传入 3 个参数,其中参数 module 可以理解为当前模块的配置参数,包含模块 id 等信息。参数 webpack_exports 是一个对象,模块需要导出的 API 都可以添加到这个对象上;参数 webpack_require 是一个函数,负责引用依赖的模块。

js
// index.js 中引入 m.js 模块
var _m__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./m */ "./m.js");
// m.js 中导出字符串 text 和函数 write
__webpack_require__.d(__webpack_exports__, "text", function () {
	return text;
});
__webpack_require__.d(__webpack_exports__, "write", function () {
	return write;
});
const write = (content) => document.write(content);
var text = "hello";

这就是通过对象和立即执行函数来实现代码模块化的基本方法,对实现细节有兴趣的同学可以找一段编译后的代码进行研究。

模块化复用代码总结

本课时主要介绍了 JavaScript 模块化规范,包括原生规范 ES6 模块、Node.js 采用的 CommonJS,以及开源社区早期为浏览器提供的规范 AMD,具有 CommonJS 特性和 AMD 特性的 CMD,让 CommonJS 和 AMD 模块跨端运行的 UMD。希望你对模块系统有更全面地认识,从而加深对 JavaScript 的理解。

最后留一道思考题:如果要实现一个支持动态加载的 import() 函数,该怎么做呢?

答:

  • 首先实现一个配置表,当需要某个 JS 时 去匹配配置表,然后用 JSONP 请求过来,eveal 执行
  • 使用 Promise 来实现 动态加载的 import()

Released under the MIT License.