模块

334 阅读7分钟

理解模块

模块的由来

现代JS开发会遇到代码量大和广泛使用第三方库的问题。解决这个问题的方案通常需要把代码拆分成很多部分,然后再通过某种方式将他们连接起来

因为JS是异步加载的解释型语言,所以得到广泛应用的各种模块实现也表现出不同的形态。这些不同的形态决定了不同的结果,但最终他们都实现了经典的模块模式

加载模块的概念派生自依赖契约

理解模块模式

将代码拆分成独立的块,然后再把这些块连接起来可以通过模块模式来实现。

这种模式背后的思想很简单:把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,同时自行决定引入执行哪些外部代码。

模块系统本质上是键/值实体,其中每个模块都有个可用于引用它的标识符(可能是字符串,或路径)。

完善的模块系统一定不会存在模块标识冲突的问题,且系统中的任何模块都应该能够无歧义地引用其他模块。

原生浏览器模块标识符必须提供实际JS文件的路径。除了文件路径,Node.js还会搜索node_modules目录,用标识符去匹配包含index.js的目录

模块系统的核心是管理依赖。

指定依赖的模块与周围的环境会达成一种契约。本地模块向模块系统声明一组外部模块(依赖),这些外部模块对于当前模块正常运行是必需的。模块系统检视这些依赖,进而保证这些外部模块能够被加载并在本地运行时初始化所有依赖。说人话就是,每个模块运行前必须保证他所依赖的模块已经正常加载。

每个模块都会与某个唯一的标识符关联,该标识符可用于检索模块。

这个标识符通常是JS文件的路径,但在某些模块系统中,这个标识符也可以是在模块本身内部声明的命名空间路径字符串

在浏览器中,加载模块涉及几个步骤:

  • 加载模块涉及及执行其中的代码,但必须是在所有依赖都加载并执行之后

  • 如果浏览器没有收到依赖模块的代码,则必须发送请求并等待网络返回

  • 收到模块代码之后,浏览器必须确定刚收到的模块是否也有依赖

  • 然后递归地评估并加载所有依赖,直到所有依赖模块都加载完成

  • 只有整个依赖图都加载完成,才可以执行入口模块

相互依赖的模块必须指定一个模块作为入口,这也是代码执行的起点。 入口模块也可能依赖其他模块,其他模块同样可能有自己的依赖。于是模块化JS应用程序的所有模块会构成依赖图。

模块加载是“阻塞的”,这意味着前置操作必须完成才能执行后续操作。每个模块在自己的代码到达浏览器之后完成加载,此时其依赖已经加载并初始化。

因为JS可以异步执行,所以可以让JS通知模块系统在必要时加载新模块,并在模块加载完成后提供回调,达到按需加载的效果:

load('module').then(function(module) {
    module.something();
})

如果重写上面的代码,只使用动态模块加载,那么使用一个<script>标签即可完成模块的加载,模块按需请求其他模块文件,而不会生成必需的依赖列表。

动态依赖可以支持更复杂的依赖关系,但代价是增加了对模块进行静态分析的难度。

静态分析

模块中包含的发送到浏览器的JS代码经常会被静态分析,分析工具会检查代码结构并在不实际执行代码的情况下推断其行为。对静态分析友好的模块系统可以让模块打包系统更容易将代码处理为较少的文件。(它还将支持在智能编辑器里智能自动完成)

更复杂的模块行为,例如动态依赖,会导致静态分析更困难。不同的模块系统和模块加载器具有不同层次的复杂度。至于模块的依赖,额外的复杂度会导致相关工具更难预测模块在执行时到底需要那些依赖。

使用ES6之前的模块加载器

在ES6原生支持模块之前,使用模块的JS代码本质上是希望使用默认没有的语言特性。因此必须按照符合某种规范的模块语法来编写代码(CommonJS/AMD),另外还需要单独的模块工具把这些模块语法与JS运行时连接起来。(通常需要在浏览器中额外加载库或者在构建时完成预处理)

CommonJS

CommonJS规范概述了同步声明依赖的模块定义,使用require()指定依赖,exports对象定义自己的公共API。

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

module.exports = {
    something: moduleB.do();
};

这个规范主要用于在服务器端实现模块化代码组织,也可以用于定义在浏览器中使用的模块依赖(但是不能在浏览器中直接运行)。

无论一个模块在require()中被引用多少次,模块永远是单例(只会被加载一次)。模块第一次加载后会被缓存,后续加载会取得缓存的模块。

在CommonJS中,模块加载是模块系统执行的同步操作。

所有CommonJS风格的实现共同之处是模块不会指定自己的标识符,他们的标识符由其在模块文件层级中的位置决定。

module.exports对象的灵活使用

// moduleA.js 

// 导出一个实体
module.export = 'echo';

// 导出对象方式1:
module.exports = {
    a: 'A',
    b: 'B'
}
// 导出对象方式2
module.exports.a = 'A';
module.exports.b = 'B';


// 引入使用
const moduleA = require('./moduleA');
console.log(moduleA);

如果想在浏览器中使用CommonJS模块,就需要与其非原生的模块语法之间构筑“桥梁”。

常见的解决方案是提前把模块文件打包好,把全局属性转换为原生JS结构,将模块代码封装在函数闭包中,最终只提供一个文件。

为了以正确的顺序打包模块,需要事先生成全面的依赖图

AMD(异步模块定义)

AMD的模块定义系统 以浏览器为目标执行环境,这需要考虑网络延迟的问题。

AMD的一般策略是让模块声明自己的依赖,而运行在浏览器中的模块系统会按需获取依赖,并在依赖加载完成后立即执行依赖他们的模块。AMD支持 可选地 为模块指定字符串标识符。

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

包装模块的函数是全局define的参数,它是由AMD加载器库的实现定义的

// 定义模块moduleA,它依赖moduleB
// moduleB会异步加载
define('moduleA', ['moduleB'], function(moduleB) {
    return {
        something: moduleB.do();
    };
});

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

define('moduleA', ['require', 'exports'], function(require) {
    const moduleB = require('moduleB');
    
    exports.something = moduleB.do();
});

// 动态依赖也是通过这种方式支持的
define('moduleA', ['require'], function(require) {
    if(condition) {
        const moduleB = require('moduleB');
    }
})

通用模块定义

为了统一CommonJS和AMD生态系统,通用模块定义(UMD)规范应运而生。

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 {...}
}))

此模式有支持严格CommonJS和浏览器全局上下文的变体。不应该期望手写这个包装函数,它应该由构建工具自动生成。

开发者只需专注于模块的内容,而不必关心这些样板代码

模块加载器终将没落

随着ECMAScript6模块规范得到越来越广泛的支持,这些模式最终会走向没落。

不过为了了解为什么选择设计决策,了解ES6模块规范的由来仍是非常有用的。CommonJS与AMD之间的冲突正式我们现在享用的ECMAScript6模块规范诞生的温床。

使用ES6模块

ES6最大的一个改进就是引入了模块规范。

这个规范全方位简化了之前出现的模块加载器,原生浏览器支持意味着加载器及其他预处理都不再必要。

ECMAScript6模块是作为一整块JS代码而存在的。 带有type="module"属性的<script>标签会告诉浏览器相关代码应该作为模块执行,而不是作为传统的脚本执行。

浏览器在遇到<script>标签上无法识别的type属性时会拒绝执行其内容。对于不支持模块的浏览器,这意味着<script type="module">不会被执行。

如果需要向后兼容,可以使用<script type="module" src="...">配合<script nomodule src="...">一起使用。

模块可以嵌入在网页中,也可以作为外部文件引入:

<script type="module">...</script>

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

与传统脚本不同,所有模块都会像<script defer>加载的脚本一样按顺序执行。解析到<script type="module">标签后会立即下载模块文件,但执行会延迟到文档解析完成。无论对嵌入的模块代码,还是引入的外部模块文件,都是这样。

<script type="module">在页面中出现的顺序就是他们执行的顺序,与<script defer>一样,修改模块标签的位置,无论是在<head>还是<body>中,只会影响文件什么时候加载,而不会影响模块什么时候加载。

<script type="module">标签关联的ES6模块被认为是模块图中的入口模块。一个页面上有多少个入口模块没有限制,重复加载同一个模块也没有限制。

嵌入的模块定义代码(<script type="module">...</script>)不能使用import加载到其他模块。只有通过外部文件加载的模块(<script type="module" src="..."></script>)才可以使用import加载。因此,嵌入模块只适合作为入口模块。

模块加载

ES6模块的独特之处在于,既可以通过浏览器原生加载,也可以与第三方加载器和构建工具一起加载。(事实上,很多时候使用第三方工具可能会更方便,有些浏览器还没有原生支持ES6模块)。

完全支持ES6模块的浏览器可以从顶级模块加载整个依赖图,且是异步完成的。浏览器会解析入口模块,确定依赖,并发送对依赖模块的请求。这个异步递归加载过程会持续到整个应用程序的依赖图都解析完成。

这个过程与AMD风格的模块加载非常相似。模块文件按需加载,且后续模块的请求会因为每个依赖模块的网络延迟而同步延迟。

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

  • ES6模块默认在严格模式下执行

  • ES6模块不共享全局命名空间

  • 模块顶级this的值是undefined

  • 模块中的var声明不会添加到window对象

  • ES6模块是异步加载和执行的

模块导出

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

// 允许
export ...

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

ES6模块支持两种导出:

  • 命名导出。
const foo = 'foo';
export { foo };

// 一个声明变量同时又导出变量
export const foo = 'foo';

// 导出时提供别名
const foo = 'foo';
export { foo as myFoo };
  • 默认导出(使用default关键字将一个值声明为默认导出)

每个模块只能有一个默认导出。重复的默认导出会导致SyntaxError

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 default const foo = 'bar';

// 只有标识符可以出现在export子句中
export {123 as foo}

// 别名只能在export子句中出现
export const foo = 'foo' as myFoo;

一般来说,声明、赋值和导出标识符最好分开。这样就不容易搞错了,同时也可以让export语句集中在一块

命名导出和默认导出的区别也反应在他们的导入上

命名导出可以使用批量获取并赋值给保存导出集合的别名,而无须列出每个标识符

模块导入

export类似,import必须出现在模块的顶级。

import xx from 'xx.js';

// 只通过路径加载模块
import 'xx.js';

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

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

导入对模块而言是只读的,实际上相当于const声明的变量。

在使用*执行批量导入时,赋值给别名的命名导出就好像使用Object.freeze()冻结过一样。

不能直接修改导出的值,但可以修改导出对象的属性。也不能给导出的集合添加或删除导出的属性。要修改导出的值,必须使用有内部变量和属性访问权限的导出方法

举例说明:

// 导入所有
import * as Foo from './foo.js';

// 给导入定义别名
import {bar, baz as myBaz} from './foo.js';

// 为默认导出定义别名
import {default as foo} from './foo.js';
// 等效于
import foo from './foo.js';

// 同时读取命名导出和默认导出
import foo, {bar, baz} from './foo.js';
import {default as foo, bar, baz} from './foo.js';
import foo, * as Foo from './foo.js';

如果想把一个模块的所有命名导出集中在一起,可以使用*

export * from './foo.js';

这样,foo.js中的所有命名导出都会出现在导入foo.js的模块中。如果foo.js有默认导出,则该语法会忽略它

使用此语法也要注意导出名称是否冲突。