JavaScript 模块系统

| 分类 FRONTEND  | 标签 javascript  module 

原文:JavaScript Module Systems Showdown: CommonJS vs AMD vs ES2015

译文:【模块化】JavaScript 模块系统大对决:CommonJS vs AMD vs ES2015

引言

当 JavaScript 开发变得越来越普遍,处理变量的命名空间和各模块间的依赖关系也变得越来越棘手。为了应对 JavaScript 模块化发展需求,不同的解决方案相继出现。在这篇文章中,我们将探讨当前 JavaScript 开发者们所使用的模块化系统,是如何试着解决模块化开发难题的。

介绍:为什么 JavaScript 模块化是必要的?

如果你熟悉其它软件开发平台,你可能已经听说过有关一些封装依赖的概念。一个项目通常会划分为不同的功能模块进行独立开发,直到一些功能的实现需要通过复用先前的代码。当这些代码被引入时,代码与模块之间就形成了一种依赖关系。因为这些代码需要依赖模块一起工作,所以避免它们之间可能产生的冲突就非常关键。乍一听起来你可能觉得微不足道,但是如果没有经过某些封装处理,两个模块之间产生冲突是迟早的事。这也是 C 函数库经常添加后缀的原因之一。

#ifndef MYLIB_INIT_H
#define MYLIB_INIT_H

enum mylib_init_code {

  mylib_init_code_success,

  mylib_init_code_error

};

enum mylib_init_code mylib_init(void);

// (...)

#endif //MYLIB_INIT_H

封装是避免冲突和简化开发所不可或缺的。

当提及依赖,JavaScript 传统的客户端开发是隐晦的。换句话说,开发者的任务之一就是确保每个依赖的代码段被正确执行。同时开发者们也需要确保执行的顺序是正确的。

下列是 Backbone.js 中的例子。脚本将按照从上至下的顺序加载:

<!DOCTYPE html>
<html lang="en">
  <head>
     <meta charset="utf-8">
     <title>Backbone.js Todos</title>
     <link rel="stylesheet" href="todos.css"/>
  </head>

  <body>
        <script src="../../test/vendor/json2.js"></script>
        <script src="../../test/vendor/jquery.js"></script>
        <script src="../../test/vendor/underscore.js"></script>
        <script src="../../backbone.js"></script>
        <script src="../backbone.localStorage.js"></script>
        <script src="todos.js"></script>
  </body>
  
  <!-- (...) -->
  
</html>

随着 JavaScript 开发日趋复杂,以往的依赖管理方式变得十分笨拙。重构也十分不便:如何保证新的依赖不会干扰先前的加载链?

JavaScript 模块系统的目的就是试图去解决上述问题以及其它的附带问题。它们的诞生都是为了适应逐渐扩大的 JavaScript 开发生态。

Ad-Hoc:模块揭示模式

绝大多数 JavaScript 模块系统都是相对较新的。在它们出现之前,一个特别的编程方法已经开始出现在越来越多的 JavaScript 代码中:它就是模块暴露模式。

var myRevealingModule = (function () {
  var privateVar = "Ben Cherry",
      publicVar = "Hey there!";
      
  function privateFunction() {
    console.log( "Name:" + privateVar );
  }
  
  function publicSetName( strName ) {
    privateVar = strName;
  }
  
  function publicGetName() {
    privateFunction();
  }
  
  // 暴露私有属性和方法的接口
  return {
     setName: publicSetName,
     greeting: publicVar,
     getName: publicGetName
  };
})();

myRevealingModule.setName( "Paul Kinlan" );

该例子摘自 Addy Osmani 的《 JavaScript 设计模式》一书。

JavaScript 的作用域是函数级的。也就是说,一个函数内部的任何声明都被包含在作用域内。正因如此,模块暴露依赖函数对私有方法和属性的封装(正如许多其它的 JavaScript 设计模式)。

在上述的例子中,外部可访问的内容被返回。其它的内部声明因函数作用域被保护起来了。无需使用 var 关键字就可以立即调用函数中的私有方法;命名函数也可以用在该模块中。

这个模式已经在 JavaScript 项目当中使用了相当多的时间,它能很好的处理封装问题,但它并不擅长处理依赖问题。一个良好的模块系统也应该去试着解决依赖问题,它还有另一个缺陷,无法在同一个源文件中同时引入其他模块(除非使用 eval)。

优势

  • 简单易用(不需要库和语言支持)
  • 多个模块可以被定义在同一个文件中

劣势

  • 无法以编程方式导入其它模块(除非使用 eval)
  • 需要手动处理依赖
  • 不支持模块的异步加载
  • 循环依赖可能会很麻烦
  • 难以对静态代码分析器进行分析

CommonJS

CommonJS 是一项方案,它定义了一系列开发规范,旨在帮助开发 JavaScript 服务端应用。CommonJS 团队试图解决的一个领域是模块。最初 Node.js 的开发者企图遵循 CommonJS 的标准,但是后来决定放弃使用。提到模块,Node.js 的实现受其影响很大:

// 在circle.js中  
const PI = Math.PI;

exports.area = (r) => PI * r * r;

exports.circumference = (r) => 2 * PI * r;

// 在另一些文件中
const circle = require('./circle.js');
console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);

在 Joyent 公司的某个晚上,当我提到对某个不合理的请求感到有点沮丧时(我也知道那是个糟糕的想法),他对我说:”忘记 CommonJS, CommonJS 已死。我们是服务端的 JavaScript。“—— NPM 创始人 Isaac Z. Schlueter 引述 Node.js 创始人 Ryan Dahl 的话

在 Node.js 的上层做了封装抽象,Node.js 的模块系统以库的形式架起了 Node.js 与 CommonJS 的桥梁。由于本文的目的,我们仅展示这些基本相似的特性。

Node 与 CommonJS 的模块系统共同使用了两个关键字与模块进行交互:require 和 exports。require 是一个函数,它能从另一个模块中导入被暴露的方法到当前作用域。传递给 require 的参数是模块的id。在 Node 的实现中,它是 node_modules 文件目录里的文件名。exports 是个特殊的对象:任何放入它的都将会作为模块被暴露出去,而模块内部的声明将被保护。Node 和 CommonJS 特有的区别在于对象的形式。在 Node 中,module.exports 是实际导出的对象,而 exports 只是默认绑定到 module.exports 的变量。相反,CommonJS 没有 module.exports 对象。实际的含义是,在 Node 中,不经过 module.exports 是无法导出一个完全预先构建的对象的。

// 将 module.exports 替换成 exports 是无效的
exports = (width) => {
 return {
  area: () => width * width
 };
}

// 将产生预期的效果
module.exports = (width) => {
 return {
  area: () => width * width
 };
}

CommonJS 模块规范的设计是为服务端开发,自然其 API 是同步的。换句话说,模块将按源文件中要求的顺序在同一时刻被加载。

优势

  • 简易:开发者无需参考文档就可以理解其概念
  • 集成依赖管理:模块与模块间将按照需要的顺序加载
  • require 可以在任何地方调用:模块能够以编程方式引入
  • 支持循环依赖

劣势

  • 同步 API 导致它不适用于客户端
  • 每个模块需要单独的文件
  • 浏览器需要加载库或转义
  • 模块没有构造函数
  • 难以对静态代码分析器进行分析

实现方式

我们已经讨论了其中一种实现方式:Node.js。

对于客户端来说,目前有两个流行的选项:webpack 和 browserify。

Browserify 用于解析类 Node 的模块定义,并将你的代码和来自这些模块的代码打包到一个包含所有依赖项的文件当中。另一个 Webpack,被用来在项目发布之前处理复杂的资源打包。这包括将 CommonJS 模块打包在一起。

AMD

AMD 诞生于一群对 CommonJS 发展方向不满的开发者中。实际上,AMD 是从CommonJS 的早期开发中分离出来的。AMD 与 CommonJS 之间最主要的区别是前者支持异步模块加载。

// 通过依赖数组和工厂函数声明调用
define(['dep1', 'dep2'], function (dep1, dep2) {

  // 通过返回值定义模块
  return function () {};
});

// 或:
define(function (require) {
  var dep1 = require('dep1'),
      dep2 = require('dep2');

  return function () {};
});

通过使用 JavaScript 的经典闭包机制使模块异步加载成为可能:当请求的模块加载完毕后,函数才被调用执行。模块的定义和引入通过同一个函数实现:模块定义完成时其依赖关系图就被确定了 AMD 的加载器因此只要给定了项目在运行时就拥有了完整的依赖关系图。因此不相互依赖的库能够同时被加载。对浏览器来说启动时间尤为重要,它是用户获得良好体验的要素。

优势

  • 异步加载(更少启动时间)
  • 对循环依赖的支持
  • 同时兼容 require 和 exports
  • 依赖管理完全整合
  • 如果有需要,模块可以被分散在多个文件
  • 支持构造函数
  • 支持插件(自定义加载)

劣势

  • 稍有复杂的语法
  • 转义之前需要加载库
  • 难以对静态代码分析器进行分析

实现方式

AMD 规范当下最流行的实现方式是 require.js 和 Dojo。

require.js 的使用非常直截了当:在你的 HTML 文件当中引入所需的库并用 data-main 属性告知 require.js 哪一个模块需要最先加载。Dojo 则类似。

ES2015 模块

幸运的是 ECMA 团队旗下的 JavaScript 标准化组织已经决定去应对模块化问题。解决的方案我们可以在最新的 JavaScript 发行版本 ECMAScript 2015(也就是我们熟知的 ES6)当中看见。语法上是令人愉快的,同时兼容同步和异步模块操作。

//------ lib.js ------
export const sqrt = Math.sqrt;
export function square(x) {
  return x * x;
}
export function diag(x, y) {
  return sqrt(square(x) + square(y));
}

//------ main.js ------
import { square, diag } from 'lib';
console.log(square(11)); // 121
console.log(diag(4, 3)); // 5

示例取自 Axel Rauschmayer 的博客

import 声明被用于在当前作用域中引入模块。该声明与 require 和 define 相反,它不是动态的(不能随意的再其它地方调用)。export 声明用于使私有方法暴露。

import 和 export 声明的静态特点允许静态分析器在没有允许代码的情况下构建完整的依赖关系树。虽然 ES2015 不支持模块动态加载,但是一个标准化草案已经出现:

System.import('some_module')
   .then(some_module => {
      // 引入 some_module 模块
   })
   .catch(error => {
      // ...
   });

事实上,ES2015 仅明确规定了静态模块加载的语法。实际上,在解析声明之后,ES2015 的实现不要求执行任何操作。仍然需要 System.js 等模块加载器。一个浏览器模块的加载规范草案是可行的。

该做法的优点是集成在了 ES6 当中,它允许运行时婚嫁自动为模块选择最佳的加载策略。也就是说,当异步加载有利时,运行时能够异步加载它。

更新(Feb 2017):这里有一个模块动态加载的规范。这是 ECMAScript 未来标准版本的建议。

优势

  • 支持同步与异步加载
  • 语法简洁
  • 支持静态分析工具
  • 集成在语言当中
  • 支持循环依赖

劣势

  • 兼容性支持不佳

实现方式

不幸的是主流的 JavaScript 运行时最新的稳定版本,没有支持 ES2015 模块。这也意味着 Firefox、Chrome 和 Node.js 不支持这种规范。幸运的是许多转换器支持模块化,并且 polyfill 也是可行的。当前,Babel 可以很好的支持模块化。

一体化解决方案:System.js

当然,你或许发现自己在尝试使用某种模块系统去摆脱历史遗留代码。或者你可能想要保障无论发生什么情况,你所选择的解决方案仍然奏效。System.js 就是一个通用的模块加载工具,支持 CommonJS,AMD 和 ES6。可能够与诸如 Babel,Traceur 转换工具协同工作并且支持 Node 与 IE8 以上的运行环境。在你的代码中加载 System.js 是个问题,需要设置指向你的基础 URL:

<script src="system.js"></script>
<script>
   // 设置我们的基础 URL 参考路径
   System.config({
     baseURL: '/app',
     // 或 'traceur' 或 'typescript'
     transpiler: 'babel',
     // 或 traceurOptions 或 typescriptOptions
     babelOptions: {
     
     }
   });

   // 加载 /app/main.js
   System.import('main.js');
</script>

由于 System.js 是即时生效的,在生产环境下的使用 ES6 模块构建项目,通常应当交由转义器来处理。在非生产环境下,System.js 能自动为你调用转义器,提供生产和调试环境间的无缝过渡。

结论

在过去模块化构建和处理依赖关系是不便的。模块库和 ES6,已经为我们的模块化工作减轻了很多痛苦。如果你正要启动一个新的模块或项目,ES6 将会是一个正确的选择。它将永远获得支持,且在当下支持它的转义工具和 polyfills 都是非常优秀的。如果你倾向于坚持使用 ES5 编码,通常的选择仍然是客户端使用 AMD,服务端使用 CommonJS 和 Node。


上一篇     下一篇