JavaScript 模块化发展历程

342 阅读7分钟

ES module(ESM) 为 JavaScript 带来了标准化模块系统,在 Firefox 60 发布之后,所有的主流浏览器就都支持 ESM 了,更加值得开心的是从 Node.js v13.2.0 开始,已经可以直接在 Node 中使用 ESM。

然而,JavaScript 在这条模块标准化的道路上却花费了很长的时间,那么它的发展历程是究竟是怎样的?下面就来了解一下。

无模块化

由于早期前端业务比较简单,JS 承担的业务较少,工程师可能随便几行代码就搞定了,直接写在一个文件里即可,稍微复杂些的会分文件引入,然后手动维护加载顺序,代码的大概样子如下:

moduleA.js:

function add(a, b) {
  return a + b;
}

moduleB.js:

function average(a, b) {
  return add(a, b) / 2;
}

main.js:

var result = average(5, 10);
console.log(result);

然后在 HTML 中的引入如下:

<!-- ...一些业务代码 -->
<script src="./moduleA.js"></script>
<script src="./moduleB.js"></script>
<script src="./main.js"></script>

可以看到这样写的话, moduleA.js、moduleB.js、main.js 这些文件里的函数变量等都会直接暴露在全局之中,很容易造成命名冲突,并且文件之间的依赖关系也很难确定,毕竟只根据一个方法名很难判断。

命名空间

为了解决命名冲突问题及团队成员间方便合作,这个时候提出了命名空间的思路,此时 moduleA.js, moduleB.js 的写法类似这样:

moduleA.js

var moduleA = {
  add: function (a, b) {
    return a + b;
  },
  foo: function () {},
  bar: 1,
};

moduleA.baz = function () {};

moduleB.js

var moduleB = {
  average: function (a, b) {
    return moduleA.add(a, b) / 2;
  },
};

引入命名空间后,命名冲突问题得到了一定程度的解决,模块间的依赖也比较容易的看出来,但是 moduleA 和 moduleB 里面的成员变量及方法都暴露了出来,外界可以任意的对其进行更改,无法创建私有变量及私有方法,模块不够安全。

IIFE(自执行函数)

为了创建私有变量或私有方法,引入了自执行函数及闭包的思路,此时写法类似:

// moduleA.js
var moduleA = (function () {
  var bar = 1; // 私有属性
  var moduleA = window.moduleA || {};

  // 私有方法
  function getBar() {
    return bar;
  }

  moduleA.add = function (a, b) {
    return a + b;
  };

  moduleA.foo = function () {
    return getBar();
  };

	// 或者挂到 window 下:  window.moduleA = moduleA
  return moduleA;
})();

这样保证了部分变量及方法的安全性,另外还可以通过传参的方式传递模块的引用:

// moduleB.js
(function (modA) {
  var moduleB = window.moduleB || {};

  moduleB.average = function (a, b) {
    return modA.add(a, b) / 2;
  };

  window.moduleB = moduleB;
})(moduleA);

但是,此时问题依然很多:

  • 各个模块依然要创建全局变量污染全局
  • 编写模块无法保证不影响其它模块
  • 模块间的引用及依赖关系还是不够清晰
  • 各个 JS 的加载顺序需要手动管理

直到 Node.js 的到来、CommonJS 规范的落地,这成为了一切的转折点…

CommonJS

2009年1月,Mozilla 的工程师 Kevin Dangoor 创建了一个项目,当时的名字是 ServerJS。在2009年8月,这个项目被改名为 CommonJS,以显示其 API 的更广泛实用性,其中,Node.js 采用的就是这个规范。

CommonJS 约定:

  • 每个文件就是一个模块, 有自己的作用域
  • 每个文件中定义的变量、函数、类都是私有的,对其它文件不可见
  • 每个模块内部可以通过 exports 或者 module.exports 对外暴露接口
  • 每个模块通过 require 加载另外的模块

CommonJS 代码示例:

// moduleA.js
var foo = 1;

function add(a, b) {
  return a + b;
}

function foo() {
  return foo;
}

module.exports = {
  add: add,
  foo: foo,
};

// moduleB.js
const moduleA = require('./moduleA');

const result = moduleA.add(5, 10);
console.log(result);

但是,CommonJS 是一套同步的方案,由于 Node.js 主要运行在服务端,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步的加载方式,所以 CommonJS 规范比较适用。但对于浏览器就无法适用了,必须要有异步的加载机制。

AMD

即 Asynchronous Module Definition,几乎与 CommonJS 同一时期,AMD 规范出现了,它采用异步的方式加载 JavaScript 模块,模块的加载并不会影响它后面语句的运行。

AMD 规范规定用 define 定义模块用 require 加载模块,语法如下:

# 模块定义
define(id?, dependencies?, factory);

# 模块加载
require([module], callback);

说起 AMD 规范,不得不提其代表产物 RequireJS,它不仅实现了异步加载,还可以按需加载,一时间成为了众多项目的选择,RequireJS + jQuery 几乎成为了前端项目标配。

下面就来看下基于 RequireJS 的代码是什么样子的。

目录结构如下:

.
├── index.html
└── js
    ├── entry.js
    ├── handleClick.js
    ├── lib
    │   ├── jquery.js
    │   └── require.js
    ├── main.js
    ├── moduleA.js
    ├── moduleB.js
    └── utils
        └── index.js

然后是在 HTML 文件中引入 require.js 并声明 data-main 属性:

<!-- ... -->
<body>
  <button id="btn-click">click me</button>
  <script data-main="./js/main.js" src="./js/lib/require.js"></script>
</body>

data-main 属性的作用是指定项目的 JS 主模块,这个模块会被第一个加载,并且可以对模块进行一些路径、别名等配置。

// main.js
require.config({
  baseUrl: 'js/',
  paths: {
    jquery: './lib/jquery',
    utils: './utils/index',
  },
});

require(['./entry'], function (entry) {
  entry.init();
});

这里对 baseUrl 进行指定,require 模块时就不用每次都写 ./js/ 路径,同理 utils: './utils/index'utils 模块进行了别名声明也能起到同样的效果。

模块定义示例:

// moduleA.js
define(['utils', './moduleA', './moduleB'], function (utils, moduleA, moduleB) {
  console.log('---- entry.js utils', utils);
  console.log('---- entry.js moduleA', moduleA);
  console.log('---- entry.js moduleB', moduleB);
  return {
    init: function () {
      moduleB.init();
      console.log('entry.js', 'init');
    },
  };
});

JS 文件的加载顺序会按照模块的依赖声明顺序进行加载,例如上面代码中依赖模块的加载顺序就是 utils/index.js -> moduleA.js -> moduleB.js。当然 RequireJS 会对加载过的模块进行缓存,如果有多次依赖,就只加载一次。

另外,RequireJS 也可以实现模块的懒加载,只需要在需要时再 require 模块即可,代码示例如下:

define(['jquery'], function ($) {
  return {
    init: function () {
      var $btn = $('#btn-click');
      $btn.click(function () {
		  // 事件触发时再加载
        require(['./handleClick'], function (handleClick) {
          handleClick.init($btn);
        });
      });
    },
  };
});

上面代码在事件触发时才加载需要的 handleClick.js 文件,这样就实现了 JS 文件的懒加载,不用页面刚进入时就加载过多的 JS 文件。但是代码却不够优雅,这种回调看起来太难受了。

CMD

即 Common Module Definition,以玉伯大大的 Sea.js 为代表,SeaJS 要解决的问题和 RequireJS 一样,都是浏览器端的模块加载方案,只不过在模块定义方式和执行时机上有所不同,AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行

下面来看下 Sea.js 的项目代码大概是什么样子的。

目录结构如下:

├── index.html
└── js
    ├── entry.js
    ├── handleClick.js
    ├── lib
    │   ├── jquery.js
    │   └── sea.js
    ├── main.js
    ├── moduleA.js
    ├── moduleB.js
    └── utils
        └── index.js

首先在 HTML 文件中引入 sea.js 及 main.js:

<!-- ... -->
<body>
  <button id="btn-click">click me</button>
  <script src="./js/lib/sea.js"></script>
  <script src="./js/main.js"></script>
</body>

这里的 main.js 作为页面入口文件,对 SeaJS 进行了配置 baseUrl 配置及别名配置,并且通过 seajs.use() 加载入口 JS 文件:

seajs.config({
  base: './js/',
  alias: {
    jquery: 'lib/jquery',
    utils: 'utils/index',
  },
});

seajs.use(['jquery', 'entry'], function ($, entry) {
  entry.init();
});

之后就是模块的定义及引用了,以 moduleA.js 为例:

define(function (require, exports, module) {
  return {
    a: function () {
      var utils = require('utils');

      utils.formatDate();
      console.log('moduleA.js');
    },
  };
});

这里就近引用 utils 模块,然后代码执行到此位置时 utils 模块相关的代码才会执行。

UMD

Universal Module Definition,UMD 是为了兼容浏览器、Node.js 等多种环境所产生的一种模块规范,经典的代码片段如下:

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined'
    ? (module.exports = factory())
    : typeof define === 'function' && define.amd
    ? define(factory)
    : (global.mylib = factory());
})(this, function () {
  'use strict';
  var mylib = {};
  mylib.version = '0.0.1';
  mylib.say = function (message) {
    console.log(message);
  };
  return mylib;
});

符合 UMD 规范的 JS 模块既可以在 Node.js 中运行,也可以作为 AMD 模块运行,否则就挂载到当前的上下文环境中,如浏览器中的 window。

ES Module

2015 年,ES6 发布,JavaScript 终于在语言标准层面上有了自己的模块系统,ES6 使用 import 引入模块,使用 export 导出模块。

引用模块:

import moduleA from './moduleA';
import { foo } from './moduleB';

moduleA();
foo();

document.getElementById('btn-click').onclick = () => {
  import('./handleClick').then(({ handleClick }) => {
    handleClick();
  });
};

导出模块:

// moduleA.js 默认导出
export default function () {
  console.log('moduleA');
}

// moduleB.js 具名导出
export const NAME = 'moduleB';

export function foo() {
  console.log('foo');
}

export function bar() {
  console.log('bar');
}

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

另外,CommonJS 模块输出的是值的拷贝,而 ES6 模块输出的是值的引用,并且 CommonJS 模块是运行时加载,而 ES6 模块是编译时输出接口,基于这个特性,ES6 模块就很容做静态分析,比如在 Webpack 打包构建时通过tree shaking 去除无用代码减少代码体积。

展望

现在已经是 React、Vue 的时代了,ES Module 已经成为了标配,模块化、组件化在这个时代得到更好的实践,虽然目前在实际项目中 ES Module 仍然需要通过 Webpack、Babel 等做编译处理,但最新的浏览器和 NodeJS 已经直接支持 ES Module 了,相信在不久的未来,一定又是一个新的时代。

本文代码示例:github.com/verlime/jav…

相关参考