前言

作为一名前端开发,特别是现在实际工作中基本上都是用 React 、Vue 这样的框架直接上手撸代码,对于模块肯定是很熟悉的了,也知道是 webpack 帮我们处理文件打包的问题, 很自然的就 exportimport 一把梭。知其然更要知其所以然,今天就重新梳理一下 JS 模块化的发展过程。

什么是模块化

首先,要了解模块的定义。在程序开发中,随着代码越来越多,代码都在一个文件中会越来越长,不利于维护。为了编写可维护的代码,我们需要按照功能或者业务将函数分开,这样抽离成单独一块的一组函数的集合就是模块,例如一个 js 文件就是一个单独的模块。

模块化的好处

  • 提高可维护性
    每个模块都是独立的,可以单独更新修改,更好的维护代码
  • 避免命名冲突
    使用模块化开发来封装变量,避免污染全局环境
  • 复用代码
    针对一些通用的工具函数或业务方法,可以单独抽离成模块,直接在别处引用

js 模块化的发展历程

全局 function

在早期的开发中,就是将重复的代码封装到函数中,再将一系列的函数放到一个 js 文件,也就是按文件来区分。

// moduleA.js
function fn1 () {

}

// moduleB.js
function fn2 () {

}
<script src="moduleA.js"></script>
<script src="moduleB.js"></script>

这种方案只是单纯的分割了代码块,并没有解决命名冲突的问题,同时也看不出模块之间的依赖关系。而且如果模块多的话,还会使得引入的 js 文件过多,增加网络请求次数,导致页面加载时间的增加。

对象命名空间

通过对象命名空间的形式,从某种程度上解决了变量命名冲突的问题。

var moduleA = {
  data: 'A Module',
  func() {
    console.log(this.data);
  }
}
moduleA.func(); // 'A Module'
moduleA.data = 'hahaha'; // 模块内部数据被篡改

但是存在严重的问题就是模块内部状态可以再外部改写。

立即执行函数

既然命名空间存在模块数据被篡改的问题,那么将模块数据放在一个单独的作用域下呢。
在 ES6 之前 js 没有块级作用域,通过立即执行函数(IIFE)来实现块级作用域的效果。我们可以使用它来封装模块。

var moduleA = (function() {
  var data = 'A Module'

  function getData () {
    retuen data
  }
  function setData(value) {
    data = value
  }

  retutn {
    getData: getData,
    setData: setData
  }
})();

moduleA.data; // undefined
moduleA.getData();  // 'A Module'

moduleA.setData('data changed');
moduleA.getData();  // 'data changed'

这样就保证了模块内部状态的私有,同时可以针对性的向外暴露方法,加入模块之间有依赖关系的话,还可以通过参数来解决:

var utilsModule = (function() {
  function log(target) {
    console.log('log:', target);
  }

  reutrn {
    log: log
  }
})();

var moduleA = (function(utils) {
  var data = 'A Module'

  function getData () {
    retuen data
  }
  function setData(value) {
    data = value
  }

  function logData() {
    utils.log(data);
  }

  retutn {
    getData: getData,
    setData: setData,
    logData: logData
  }
})(utilsModule);

moduleA.logData(); /// 'log: A Module'

这种模块化方案就是比较成熟的了,后面的 CommonJS 、 AMD 这些模块化规范底层都是使用这种方法来构建模块的。

CommonJS 规范

CommonJS 规范,也就是 NodeJS 采用的模块化方案,一个文件就是一个模块,各个模块相互隔离,有单独的作用域、方法和变量,对其他文件不可见。
文件可以被重复引用、加载。第一次加载时会被缓存,之后再引用就直接读取缓存。
加载某个模块时,输出的是值的拷贝,一旦这个值被输出,模块内再发生变化不会影响已经输出的值。
使用 require 方法导入别的模块。
在每个模块内部有一个 module 对象,代表当前模块,记录当前模块信息,通过它来导出当前模块里的一些 API:
id 模块标识符
exports 对外的接口,其他模块加载的就是这个对象
filename 模块的文件名
loaded 模块是否加载完
····

// a.js
var count = 1;

function fn() {
  console.log('module method');
}

module.export = {
  count: count,
  fn: fn
}

// b.js
var moduleA = require('./a.js');
moduleA.fn(); // 'module method'

CommonJS 是同步加载模块,如果是在服务端,文件都是在本地磁盘,读取快不会有什么影响,但是在浏览器端,因为网络问题加载慢是很常见的,所以说这种同步加载的方式对浏览器来说是不合适的(browserify 是 CommonJS 规范在浏览器端的实现。)。更合理的应该是异步加载,也就是下面的 AMD 规范。

AMD 规范

AMD(Asynchronous Module Definition),也就是异步模块定义。
这里的异步,就是模块的加载不影响后面代码的执行,所有依赖这个模块的语句都定义在一个回调函数中,等到模块加载完才会执行这个回调函数。
define(id, [dependencies], factory) 定义模块

  • id 模块名称,不允许同名,如果不传默认是脚本文件名
  • dependencies 当前模块所依赖的模块数组,依赖模块会先执行并将执行结果按照数组顺序以参数形式传入工厂方法
  • factory 工厂方法,如果是函数,则会在初始化是执行一次,如果是对象,这个对象就是模块的输出

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

  • module 调用的模块数组
  • callback 模块加载完后执行的回调函数

RequireJS 是 AMD 规范的一种实现。
在官网下载并引入 require.js ,data-main 属性不能省略,指向入口文件,当 require.js 加载完成之后,会直接去请求这个文件并执行。

<script data-main="./app.js" src="./require.js"></script>
// module.js
define(function() {
  function log(target) {
    console.log('Log:', target);
  }

  return {
    log: log
  }
});

// 使用模块
require(['module'], function(module) {
  module.log('xxx');
});

一开始,AMD 的理念是依赖前置、提前执行,在加载模块完成后就会执行该模块,所有模块都加载执行完后会进入 require 的回调函数,执行主逻辑。
但是 RequireJS 2.0 之后也支持依赖就近、延迟执行,像 CommonJs 一样,在主逻辑内加载依赖的模块。

define(function(require) {
  var module = require('./modules/a');
});

CMD 规范

CMD(Common Module Definition)是 SeaJS 中模块化方案的一种实现,和 AMD 类似。
CMD 定义模块的方法也是 define

define(id, [dependencies], factory)

但是,带 iddependencies 参数的 define 用法不属于 CMD 规范,只是它兼容 AMD 的写法,这也是它叫 Common 的原因。
define 接受 factory 参数,factory 可以是一个函数,也可以是一个对象或字符串。
factory 为对象、字符串时,表示模块的接口就是该对象、字符串。
factory 为函数时,表示是模块的工厂方法,执行方法可以得到模块向外提供的接口。

define(function(require, exports, module) {
  // 模块代码
});

需要导出的内容放到 exports 对象下。
通过工厂方法的参数,可以在模块内部 调用 require 别的模块,就地执行依赖。

UMD 规范

UMD(Universal Module Definition)是一种 js 通用模块定义规范,让你的模块能在 js 所有运行环境中运行,结合了 CommonJs 和 AMD 。
UMD 先判断是否支持 NodeJs 的模块,支持就直接使用 NodeJs 的模块方案,如果不支持就选择 AMD 的方式加载模块。

(function(root, factory) {
  if (typeof exports === "object" && typeof module === "object") {
    // CommonJS 规范
    module.exports = factory(require);
  } else if (typeof define === "function" && define.md) {
    // AMD 规范
    define(factory());
  } else {
    // 直接挂载到全局对象
    root.umdModule = factory();
  }
})(this, function() {
  var data = {}

  return {
    data: data
  }
});

ES6 Module

上面说的这些规范,都是在原有的 JS 语法基础上的二次封装,ES6 Module 则是语言层的规范,为浏览器和服务器提供通用的模块解决方案。未来无论是浏览器还是服务器,都会统一使用 ES6 Module 。
ES6 Module 的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

// CommonJS模块
const { join } = require('path');

// 等同于
const _path = require('path');
const join = _path.stat;

实质上是加载整个模块,生成一个对象,再从这个对象上读取对应的方法。这种就是“运行时加载”,只有运行时才能得到这个对象,导致没办法在编译时做优化处理。
ES6 Module 是通过 export 命令显式指定输出的代码,再通过 import 命令直接导入。

// a.js
import { join } = require('path');

cosnt state = 1;

export {
  state
}

ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高,这也导致了没法引用 ES6 模块本身,因为它不是对象。
ES6 Module 自动采用严格模式,不管有没有在模块头部加上 "use strict"

导出的方式有多种,但都是通过 export 命令。

// 单独导出一个变量
export const age = 16;

// 也可以直接导出一个方法或类
export function add(x, y) {
  return x + y;
}

// 导出一组变量
const year = 2022;
cosnt month = 11;
cosnt date = 8;

export {
  year,
  month,
  date
}

// 默认导出
export default {
  age
}

要注意, 不能直接导出一个值、变量,以及函数和类

cosnt a = 1;

export 1  // 错误
export a  // 错误

使用 export 命令定义了模块的导出以后,其他 JS 文件就可以通过 import 命令加载这个模块。

// 单个导出或多个一组导出都通过这种对象解构的形式获取
// 模块 .js 的后缀可以省略
import { age, year } from './a';

// 如果是默认导出的可直接赋值给一个变量
import data from './a';

import 会被提升到整个模块的头部,首先执行。

console.log(year);

import { year } from './a';

上面的代码不会报错,因为 import 的执行早于 console.log 方法的调用,也就是 import 命令是在编译阶段执行的。也正是因为这样,不能使用表达式和变量这些只有在运行时才能得到结果的语法结构。

import {'a' + 'ge' } from './a';  // 错误

// 错误
const module = './a';
import { age } from module

// 错误
if(useModuleA) {
  import { age } from './a';
} else {
  import { age } from './a';
}

多次加载一个模块也只会执行一次

import { yaer } from './a';
import { month } from './a';

// 等同于
import { yaer, month } from './a';

如果需要将所有的导出都赋值给一个对象,可以直接使用星号(*)。

import * as mod from './a';

console.log(mod.age);

如果在模块中同样要导出在别的模块引入的值,可以将 import 语句和 export 语句结合起来。

export { yaer } from './a';

// 等同于
import { yaer } from './a';
export { yaer }

浏览器使用 ES Module 需要写上 rype 属性

<script type="module" src="foo.js"></script>

浏览器对于带有 type="module"<script> ,都是异步加载外部脚本,不会堵塞浏览器。

毫无疑问, ES Module 是最好的解决方案,也是目前开发用的最多的了。但是它有个致命的问题就是兼容性,作为在 ES6 提出的新特性,很多老版本的浏览器是没法兼容的。不过现在又 babel 这样的”翻译官“,也不至于没法用。

总结

可以发现,除了 ES Module 这种语言层面的标准,其他的各种都是函数 + 闭包来实现的。无论是 CommonJs ,还是 AMD 、CMD ,还有 UMD ,都是对于函数的不同封装。
至此,了解完了 JS 模块化的发展历史,可以发现模块化方案的不断成熟完善,从一开始的各种社区的解决方案到 JS 语言层面对于模块化的支持。