前端模块化发展

有开发经验的同学应该很明白模块化对于代码可读性和可维护性的好处,这里就不多说了,我们在这里说说前端模块化的发展历程。

闭包

闭包函数是典型的模块化解决方案,有效的防止了全局变量污染,可维护性等一系列问题。早期的许多库,如 JQuery 都是使用类似的形式来避免代码中定义的变量污染全局。

IIFE(自执行函数)+闭包也是最早的模块化形式:

const obj = (function () {
  let a = 1
  return {
    getA: () => a,
    setA: (newA) => {
      a = newA
    },
  }
})()
console.log(obj.a) // undefined
console.log(obj.getA()) // 1
obj.setA(5)
console.log(obj.getA()) // 5

在上面的代码中,我们定义了对象 obj,通过暴露 setget 方法来对内部变量 a 进行处理,外部无法直接访问到 a,这就是闭包的优势。

但它的缺点显而易见:如果一个闭包里面需要用到另一个闭包里的东西该怎么办?也许我们会这样做:

;(function () {
  const $ = (target) => document.querySelector(target)
  window.$ = $
})()
;(function () {
  const div = window.$('div')
})()

通过这种方式我们成功的通过 window$ 共享至另一个闭包中,可如果偶然其他闭包中也在 window 上定义了 $,这个方法可就行不通了。因此 IIFE 并不能完美的解决模块化问题。

CommonJS

CommonJS 就像它的名字一样将模块“公共化”了,CommonJS 规范如下:

  • 一个 js 文件就是一个模块
  • 每个模块有单独的作用域
  • 通过 module.exports 导出成员
  • 通过 require 函数载入模块
const module1 = require('./module1.js')
module.exports = {
  a: 1,
}

CommonJS 有两个问题:浏览器不理解它。其次,加载模块是同步进行的,这也许会产生不好的体验。

为了在浏览器上实现 CommonJS,可以使用一些模块化打包方案如 Webpack。

在 Node 上,CommonJS 大放异彩,Node 在 CommonJS 的基础上进行了一些修改,增加了一些特性,同时也催生了 npm

AMD & CMD

为了解决 CommonJS 规范在浏览器端的不足,AMD 诞生了。RequireJS 就是其中的代表。

AMD 定义了 define 方法来定义和加载模块。

// 声明模块名及其位置
require.config({
  paths: {
    module1: 'libs/module1',
    module2: 'libs/module2',
  },
})

// 定义模块
define(function () {
  return module3
})

// 在模块中引入其他模块
define(['module1', 'module2'], function (m1, m2) {
  return module3
})

// 引入模块并使用
require(['module1', 'module2'], function (m1, m2) {
  // ...
})

AMD 的模块加载是异步的,也是浏览器所采用的规范。

CMD 专门用于浏览器,模块异步加载且在使用的时候才会加载,代表作 SeaJS。

//定义没有依赖的模块
define(function (require, exports, module) {
  exports.xxx = value
  module.exports = value
})

//定义有依赖的模块
define(function (require, exports, module) {
  //引入依赖模块(同步)
  var module2 = require('./module2')
  //引入依赖模块(异步)
  require.async('./module3', function (m3) {})
  //暴露模块
  exports.xxx = value
})

//引入使用模块
define(function (require) {
  var m1 = require('./module1')
  var m4 = require('./module4')
  m1.show()
  m4.show()
})

AMD 和 CMD 的区别

  • AMD 是预加载,在并行加载模块同时,还会解析执行该模块(因为还需要执行,所以在加载某个模块前,这个模块的依赖模块需要先加载完成)。(不过 RequireJS 从 2.0 开始,也改成可以延迟执行)
  • CMD 是懒加载,虽然会一开始就并行加载模块,但是不会执行,而是在需要的时候才执行。
  • CMD 推崇依赖就近(执行时使用到了再引入),AMD 推崇依赖前置(在定义时就提前声明要用到的模块)。
  • AMD 的 API 一个当多个用,职责单一。CMD 将 API 细分,各司其职。

ESM(ES modules)

👉 重点!敲黑板 👈

ESM 是现代浏览器的最佳实践,现在 Node 也支持 ESM 了。

规范

在使用 ESM 之前,我们需要知道 ESM 的规范:

  1. ESM 自动采用严格模式,忽略 use strict
<script type="module">
  console.log(this) // this在非严格模式下直接使用指向的是window,而在严格模式下指向的是undefined
</script>
  1. 每个 ES Module 都是运行在单独的私有作用域中:
<script type="module">
  var a = 123
</script>
<script type="module">
  console.log(a) // Uncaught ReferenceError: a is not defined
</script>
  1. ESM 是通过 CORS 的方式请求外部 JS 模块的,意味着,我们的模块不在同源地址下的话,需要我们请求的服务端地址在响应的响应头中提供有效的 CORS 标头
  2. ESM 的 script 标签会延迟执行脚本,等同于 defer 属性:
<script type="module">
  console.log(123)
</script>
<script>
  console.log(234)
</script>

先输出 234,后输出 123

在浏览器中的用法:

// file "module.js"
export var someVar = "Some data"
export function someFunc() {
    return " for output"
}
function someOtherFunction() {
    return 1
}
<!-- index.html -->
<script type="module">
  // 导入模块
  import {someVar, someFunc} from './module.js'

  // "Some data for output"
  console.log(someVar + someFunc())
</script>

ESM 的默认导入导出:

// 导出
const obj = { a: 1 }
export default obj
// 导入,导入模块名是自定义的
import m1 from './module1.js'
// 上面这个是下面的缩写
import { default as m1 } from './module1.js'

有选择地导出多个:

// 导出
export const a = 1
export const b = 2
export const c = 3
// 上面两句等价于下面
// const a = 1
// const b = 2
// const c = 3
// export { a, b, c }

// 导入,模块名称要一致
import { a, b, c } from './module.js'
// 选择性导入
import { a, b } from './module.js'
// 将a命名为d并导入
import { a as d, b, c } from './module.js'
// 提取默认成员和其它成员
// import { name, age, default as title } from './module.js'
import abc, { name, age } from './module.js'

export 的时候也可以为模块定义别名:

export { a as d }

如果导入的模块太多,觉得乱,可以将这些模块全部导入到一个对象上:

import * as module1 from './module1.js'

使用导入导出时要注意:

  1. 导入成员并不是复制一个副本,而是直接导入模块成员的引用地址,也就是说 import 得到的变量与 export 导入的变量在内存中是同一块空间。一旦模块中成员修改了,这里也会同时修改。
  2. 导入模块成员变量是只读的,但是需要注意如果导入的是一个对象,对象的属性读写不受影响。
  3. exportexport default 不同,我们看下面代码:
export { name, age } // { name, age } 不是一个对象字面量,它只是语法上的规则而已
export default { name, age } // 而 export default 导出的是一个字面量对象
  1. import/export 是 top-level 的声明,且是静态(与执行无关,在预编译时就已经存在了)的,因此在 javascript 代码执行前,模块就已经被引入了。

import()

import 导入的模块是静态的,在代码加载的时候就被编译了,这样会降低页面加载速度。

可以使用 import 函数来按需加载模块,import 函数是异步的,这和 require 不同。

// 执行a的时候才会加载模块
async function a() {
  import('./module.js').then(function (module) {
    console.log(module)
  })
  let module = await import('/modules/my-module.js')
}

import 路径匹配规则

这个和 ES6 没有关系,是模块系统的约定以及实现。在 node 文档里面详细描述了处理过程。

  1. 如果 X 是内置模块,则直接返回该模块。如 require('http')
  2. 如果 X 以 .//../ 开头:
    1. 根据 X 所在的父模块,确定 X 的绝对路径。
    2. 将 X 当做文件,依次查找下面的文件,如果找到,则直接返回。
      1. X
      2. X.js
      3. X.json
      4. X.node
    3. 将 X 当做目录,依次查找下面的文件,如果找到,则直接返回。
      1. X/package.json(查找 main 字段中的文件,规则同上)
      2. X/index.js
      3. X/index.json
      4. X/index.node
  3. 如果 X 不带路径:
    1. 会查找当前文件目录,父级目录直至根目录下的 node_modules(默认) 文件夹,看是否有对应名称的模块。

在浏览器中使用

想要在浏览器中使用 ESM,引入的时候要在 script 标签定义一个 type='module'

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

ESM 与 CommonJS 的差异

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
    • 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
    • 编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import 时采用静态命令的形式。即在 import 时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。模块内部引用的变化,会反应在外部。

参考文章