第 26 章 模块

第 26 章 模块

26.1 理解模块模式

将代码拆分成独立的块,然后再把这些块连接起来可以通过模块模式来实现。这种模式背后的思想很简单:把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,同时自行决定引入执行哪些外部代码。不同的实现和特性让这些基本的概念变得有点复杂,但这个基本的思想是所有 JavaScript 模块系统的基础。

26.1.1 模块标识符

26.1.2 模块依赖

26.1.3 模块加载

在浏览器中,加载模块涉及几个步骤。加载模块涉及执行其中的代码,但必须是在所有依赖都加载并执行之后。如果浏览器没有收到依赖模块的代码,则必须发送请求并等待网络返回。收到模块代码之后,浏览器必须确定刚收到的模块是否也有依赖。然后递归地评估并加载所有依赖,直到所有依赖模块都加载完成。只有整个依赖图都加载完成,才可以执行入口模块。

26.1.4 入口

26.1.5 异步依赖

26.1.6 动态依赖

26.1.7 静态分析

26.1.8 循环依赖

26.2 凑合的模块系统

ES6 之前的模块有时候会使用函数作用域和立即调用函数表达式(IIFE, Immediately Invoked Function Expression)将模块定义封装在匿名闭包中。

var Foo = (function () {
  console.log('bar')
})()
// 'bar'

为了暴露公共 API,模块 IIFE 会返回一个对象,其属性就是模块命名空间中的公共成员:

var Foo = (function () {
  return {
    bar: 'baz',
    baz: function () {
      console.log(this.bar)
    }
  }
})()
console.log(Foo.bar) // 'baz'
Foo.baz() // 'baz'

类似地,还有一种模式叫作“泄露模块模式”​(revealing module pattern)​。这种模式只返回一个对象,其属性是私有数据和成员的引用:

var Foo = (function () {
  var bar = 'baz'
  var baz = function () {
    console.log(bar)
  }
  return {
    bar: bar,
    baz: baz
  }
})()
console.log(Foo.bar) // 'baz'
Foo.baz() // 'baz'

在模块内部也可以定义模块,这样可以实现命名空间嵌套:

var Foo = (function () {
  return {
    bar: 'baz'
  }
})()
Foo.baz = (function () {
  return {
    qux: function () {
      console.log('baz')
    }
  }
})()
console.log(Foo.bar) // 'baz'
Foo.baz.qux() // 'baz'

为了让模块正确使用外部的值,可以将它们作为参数传给 IIFE:

var globalBar = 'baz'
var Foo = (function (bar) {
  return {
    bar: bar,
    baz: function () {
      console.log(bar)
    }
  }
})(globalBar)
console.log(Foo.bar) // 'baz'
Foo.baz() // 'baz'

因为这里的模块实现其实就是在创建 JavaScript 对象的实例,所以完全可以在定义之后再扩展模块:

// 原始的Foo
var Foo = (function (bar) {
  var bar = 'baz'
  return {
    bar: bar
  }
})()
// 扩展Foo
var Foo = (function (FooModule) {
  FooModule.baz = function () {
    console.log(FooModule.bar)
  }
  return FooModule
})(Foo)
console.log(Foo.bar) // 'baz'
Foo.baz() // 'baz'

无论模块是否存在,配置模块扩展以执行扩展也很有用:

// 扩展Foo以增加新方法
var Foo = (function (FooModule) {
  FooModule.baz = function () {
    console.log(FooModule.bar)
  }
  return FooModule
})(Foo || {})
// 扩展Foo以增加新数据
var Foo = (function (FooModule) {
  FooModule.bar = 'baz'
  return FooModule
})(Foo || {})
console.log(Foo.bar) // 'baz'
Foo.baz() // 'baz'

26.3 使用 ES6 之前的模块加载器

26.3.1 CommonJS

注意
一般认为,Node.js 的模块系统使用了 CommonJS 规范,实际上并不完全正确。Node.js 使用了轻微修改版本的 CommonJS,因为 Node.js 主要在服务器环境下使用,所以不需要考虑网络延迟问题。考虑到一致性,本节使用 Node.js 风格的模块定义语法。

CommonJS 模块定义需要使用 require()指定依赖,而使用 exports 对象定义自己的公共 API。调用 require()意味着模块会原封不动地加载进来。无论一个模块在 require()中被引用多少次,模块永远是单例。模块第一次加载后会被缓存,后续加载会取得缓存的模块。在 CommonJS 中,模块加载是模块系统执行的同步操作。

var moduleB = require('./moduleB')
module.exports = {
  stuff: moduleB.doStuff()
}

var a1 = require('./moduleA')
var a2 = require('./moduleA')
console.log(a1 === a2) // true

注意
此模块不导出任何内容。即使它没有公共接口,如果应用程序请求了这个模块,那也会在加载时执行这个模块体。

module.exports 对象非常灵活,有多种使用方式。如果只想导出一个实体,可以直接给 module.exports 赋值。导出多个值也很常见,可以使用对象字面量赋值或每个属性赋一次值来实现。

// moduleB
module.exports = 'foo'

var moduleA = require('./moduleB')
console.log(moduleB) // 'foo'

// 等价操作:
module.exports = {
  a: 'A',
  b: 'B'
}
module.exports.a = 'A'
module.exports.b = 'B'

CommonJS 也支持动态依赖

if (condition) {
  var A = require('./moduleA')
}

26.3.2 异步模块定义

CommonJS 以服务器端为目标环境,能够一次性把所有模块都加载到内存,而异步模块定义(AMD, Asynchronous Module Definition)的模块定义系统则以浏览器为目标执行环境,这需要考虑网络延迟的问题。AMD 的一般策略是让模块声明自己的依赖,而运行在浏览器中的模块系统会按需获取依赖,并在依赖加载完成后立即执行依赖它们的模块。

AMD 模块实现的核心是用函数包装模块定义。这样可以防止声明全局变量,并允许加载器库控制何时加载模块。包装函数也便于模块代码的移植,因为包装函数内部的所有模块代码使用的都是原生 JavaScript 结构。包装模块的函数是全局 define 的参数,它是由 AMD 加载器库的实现定义的。

AMD 模块可以使用字符串标识符指定自己的依赖,而 AMD 加载器会在所有依赖模块加载完毕后立即调用模块工厂函数。与 CommonJS 不同,AMD 支持可选地为模块指定字符串标识符。

// ID为’moduleA’的模块定义。moduleA依赖moduleB,
// moduleB会异步加载
define('moduleA', ['moduleB'], function (moduleB) {
  return {
    stuff: moduleB.doStuff()
  }
})

AMD 也支持 requireexports 对象,通过它们可以在 AMD 模块工厂函数内部定义 CommonJS 风格的模块。这样可以像请求模块一样请求它们,但 AMD 加载器会将它们识别为原生 AMD 结构,而不是模块定义:

define('moduleA', ['require', 'exports'], function (require, exports) {
  var moduleB = require('moduleB')
  exports.stuff = moduleB.doStuff()
})

动态依赖也是通过这种方式支持的:

define('moduleA', ['require'], function (require) {
  if (condition) {
    var moduleB = require('moduleB')
  }
})

26.3.3 通用模块定义

通用模块定义(UMD, Universal Module Definition)

UMD 可用于创建这两个系统都可以使用的模块代码。本质上,UMD 定义的模块会在启动时检测要使用哪个模块系统,然后进行适当配置,并把所有逻辑包装在一个立即调用的函数表达式(IIFE)中。

;(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD。注册为匿名模块
    define(['moduleB'], factory)
  } else if (typeof module === 'object' && module.exports) {
    // Node。不支持严格CommonJS
    // 但可以在Node这样支持module.exports的
    // 类CommonJS环境下使用
    module.exports = factory(require(' moduleB '))
  } else {
    // 浏览器全局上下文(root是window)
    root.returnExports = factory(root.moduleB)
  }
})(this, function (moduleB) {
  // 以某种方式使用moduleB
  // 将返回值作为模块的导出
  // 这个例子返回了一个对象
  // 但是模块也可以返回函数作为导出值
  return {}
})

26.3.4 模块加载器终将没落

26.4 使用 ES6 模块

26.4.1 模块标签及定义

type="module"

<script type="module">
  // 模块代码
</script>
<script type="module" src="path/to/myModule.js"></script>

与传统脚本不同,所有模块都会像<script defer>加载的脚本一样按顺序执行。解析到<script type="module">标签后会立即下载模块文件,但执行会延迟到文档解析完成。无论对嵌入的模块代码,还是引入的外部模块文件,都是这样。<script type="module">在页面中出现的顺序就是它们执行的顺序。与<script defer>一样,修改模块标签的位置,无论是在<head>还是在<body>中,只会影响文件什么时候加载,而不会影响模块什么时候加载。

也可以给模块标签添加 async 属性。这样影响就是双重的:不仅模块执行顺序不再与<script>标签在页面中的顺序绑定,模块也不会等待文档完成解析才执行。不过,入口模块仍必须等待其依赖加载完成。

<script type="module">标签关联的 ES6 模块被认为是模块图中的入口模块。一个页面上有多少个入口模块没有限制,重复加载同一个模块也没有限制。同一个模块无论在一个页面中被加载多少次,也不管它是如何加载的,实际上都只会加载一次。

26.4.2 模块加载

26.4.3 模块行为

ECMAScript 6 模块借用了 CommonJS 和 AMD 的很多优秀特性。下面简单列举一些。

❑ 模块代码只在加载后执行。
❑ 模块只能加载一次。
❑ 模块是单例。
❑ 模块可以定义公共接口,其他模块可以基于这个公共接口观察和交互。
❑ 模块可以请求加载其他模块。
❑ 支持循环依赖。

ES6 模块系统也增加了一些新行为。

❑ ES6 模块默认在严格模式下执行。
❑ ES6 模块不共享全局命名空间。
❑ 模块顶级 this 的值是 undefined(常规脚本中是 window)​。
❑ 模块中的 var 声明不会添加到 window 对象。
❑ ES6 模块是异步加载和执行的。

<script type="module">关联或者通过 import 语句加载的 JavaScript 文件会被认定为模块。

26.4.4 模块导出

ES6 模块的公共导出系统与 CommonJS 非常相似。控制模块的哪些部分对外部可见的是 export 关键字。ES6 模块支持两种导出:命名导出和默认导出。

export 关键字用于声明一个值为命名导出。导出语句必须在模块顶级,不能嵌套在某个块中。

// 允许
export ...
// 不允许
if (condition) {
    export ...
}

导出值对模块内部 JavaScript 的执行没有直接影响,因此 export 语句与导出值的相对位置或者 export 关键字在模块中出现的顺序没有限制。export 语句甚至可以出现在它要导出的值之前。

// 允许
const foo = 'foo'
export { foo }
// 允许
export const foo = 'foo'
// 允许,但应该避免
export { foo }
const foo = 'foo'

命名导出(named export)就好像模块是被导出值的容器。行内命名导出,顾名思义,可以在同一行执行变量声明。变量声明跟导出可以不在一行。导出时也可以提供别名,别名必须在 export 子句的大括号语法中指定。

export const foo = 'foo'

const foo = 'foo'
export { foo }

const foo = 'foo'
export { foo as myFoo }

因为 ES6 命名导出可以将模块作为容器,所以可以在一个模块中声明多个命名导出。

默认导出(default export)就好像模块与被导出的值是一回事。默认导出使用 default 关键字将一个值声明为默认导出,每个模块只能有一个默认导出。重复的默认导出会导致 SyntaxError。ES6 模块系统会识别作为别名提供的 default 关键字。

const foo = 'foo'
export default foo

// 等同于export default foo;
export { foo as default }

因为命名导出和默认导出不会冲突,所以 ES6 支持在一个模块中同时定义这两种导出。

const foo = 'foo'
const bar = 'bar'
export { bar }
export default foo

export { foo as default, bar }
// 命名行内导出
export const baz = 'baz'
export const foo = 'foo',
  bar = 'bar'
export function foo() {}
export function* foo() {}
export class Foo {}
// 命名子句导出
export { foo }
export { foo, bar }
export { foo as myFoo, bar }
// 默认导出
export default 'foo'
export default 123
export default /[a-z]*/
export default { foo: 'foo' }
export { foo, bar as default }
export default foo
export default function () {}
export default function foo() {}
export default function* () {}
export default class {}
// 会导致错误的不同形式:
// 行内默认导出中不能出现变量声明
export default const foo = 'bar'
// 只有标识符可以出现在export子句中
export { 123 as foo }
// 别名只能在export子句中出现
export const foo = 'foo' as myFoo

26.4.5 模块导入

模块可以通过使用 import 关键字使用其他模块导出的值。与 export 类似,import 必须出现在模块的顶级。

import 语句被提升到模块顶部。因此,与 export 关键字类似,import 语句与使用导入值的语句的相对位置并不重要。不过,还是推荐把导入语句放在模块顶部。

模块标识符可以是相对于当前模块的相对路径,也可以是指向模块文件的绝对路径。它必须是纯字符串,不能是动态计算的结果。

如果在浏览器中通过标识符原生加载模块,则文件必须带有 .js 扩展名,不然可能无法正确解析。不过,如果是通过构建工具或第三方模块加载器打包或解析的 ES6 模块,则可能不需要包含文件扩展名。

不是必须通过导出的成员才能导入模块。如果不需要模块的特定导出,但仍想加载和执行模块以利用其副作用,可以只通过路径加载它。

import './foo.js'

导入对模块而言是只读的,实际上相当于 const 声明的变量。在使用 * 执行批量导入时,赋值给别名的命名导出就好像使用 Object.freeze()冻结过一样。直接修改导出的值是不可能的,但可以修改导出对象的属性。同样,也不能给导出的集合添加或删除导出的属性。要修改导出的值,必须使用有内部变量和属性访问权限的导出方法。

import foo, * as Foo from './foo.js'
foo = 'foo' // 错误
Foo.foo = 'foo' // 错误
foo.bar = 'bar' // 允许

命名导出和默认导出的区别也反映在它们的导入上。命名导出可以使用 * 批量获取并赋值给保存导出集合的别名,而无须列出每个标识符。要指名导入,需要把标识符放在 import 子句中。使用 import 子句可以为导入的值指定别名。

const foo = 'foo',
  bar = 'bar',
  baz = 'baz'
export { foo, bar, baz }
import * as Foo from './foo.js'
console.log(Foo.foo) // foo
console.log(Foo.bar) // bar
console.log(Foo.baz) // baz

import { foo, bar, baz as myBaz } from './foo.js'
console.log(foo) // foo
console.log(bar) // bar
console.log(myBaz) // baz

默认导出就好像整个模块就是导出的值一样。可以使用 default 关键字并提供别名来导入。也可以不使用大括号,此时指定的标识符就是默认导出的别名。

// 等效
import { default as foo } from './foo.js'
import foo from './foo.js'

如果模块同时导出了命名导出和默认导出,则可以在 import 语句中同时取得它们。可以依次列出特定导出的标识符来取得,也可以使用 * 来取得。

import foo, { bar, baz } from './foo.js'
import { default as foo, bar, baz } from './foo.js'
import foo, * as Foo from './foo.js'

26.4.6 模块转移导出

模块导入的值可以直接通过管道转移到导出。此时,也可以将默认导出转换为命名导出,或者相反。如果想把一个模块的所有命名导出集中在一块,可以像下面这样在 bar.js 中使用 * 导出:

export * from './foo.js'

这样,foo.js 中的所有命名导出都会出现在导入 bar.js 的模块中。如果 foo.js 有默认导出,则该语法会忽略它。使用此语法也要注意导出名称是否冲突。如果 foo.js 导出 baz,bar.js 也导出 baz,则最终导出的是 bar.js 中的值。这个“重写”是静默发生的:

// foo.js
export const baz = 'origin:foo'
// bar.js
export * from './foo.js'
export const baz = 'origin:bar'
// main.js
import { baz } from './bar.js'
console.log(baz) //origin:bar

此外也可以明确列出要从外部模块转移本地导出的值。该语法支持使用别名:

export { foo, bar as myBar } from './foo.js'
export { default } from './foo.js'
export { foo as default } from './foo.js'

26.4.7 工作者模块

ECMAScript 6 模块与 Worker 实例完全兼容。在实例化时,可以给工作者传入一个指向模块文件的路径,与传入常规脚本文件一样。Worker 构造函数接收第二个参数,用于说明传入的是模块文件。

// 第二个参数默认为{ type: 'classic' }
const scriptWorker = new Worker('scriptWorker.js')
const moduleWorker = new Worker('moduleWorker.js', { type: 'module' })

在基于模块的工作者内部,self.importScripts()方法通常用于在基于脚本的工作者中加载外部脚本,调用它会抛出错误。这是因为模块的 import 行为包含了 importScripts()

26.4.8 向后兼容

<!--  支持模块的浏览器会执行这段脚本 -->
<!--  不支持模块的浏览器不会执行这段脚本 -->
<script type="module" src="module.js"></script>
<!--  支持模块的浏览器不会执行这段脚本 -->
<!--  不支持模块的浏览器会执行这段脚本 -->
<script nomodule src="script.js"></script>

浏览器要求模块说明符具有以下几种格式之一:

❑ 以/开头的解析为从根目录开始。
❑ 以./开头的解析为从当前目录开始。
❑ 以…/开头的解析为从父目录开始。
❑ URL 格式。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值