跳到主要内容

模块

模块化编程 的目标是能够将不同作者及来源的代码模块组成大型程序。

实践中,模块化的作用主要体现在封装、隐藏私有实现细节、保证全局命名空间清洁上,因此模块之间不会意外地相互修改变量等。

基于类、对象、闭包的模块

使用类和对象实现模块化是 JS 编程中常用的技术,因为它们自身也是对属性方法的封装,不会污染全局命名空间。

在函数中声明的变量及嵌套函数也是私有的,可以将变量等封装到一个对象里作为返回值,达到导出一个模块的目的。

Node 中的模块

在 Node 中,每个文件都是一个拥有私有命名空间的独立模块。在模块文件中可以定义要导出的东西,然后在另一个模块文件里导入。

Node 的导出

Node 定义了一个全局 exports 对象,它实际上是 module.exports 的引用。可以将要导出的东西设置为 exports 的属性:

const sum = (x, y) => x + y

exports.sum = sum // => 等价于 `module.exports.sum = sum`

如果只是想导出一个函数、类、封装对象等,而非多个,可以直接赋值给 module.exports

module.exports = class Rectangle { /* ... */ }
class Rectangle { /* ... */ }
const sum = (x, y) => x + y

module.exports = { Rectangle, sum }

但是切记,exportsmodule.exports 的引用,所以对 exports 赋值是没有意义的。

Node 的导入

Node 模块通过 require("文件路径") 导入其他模块导出的对象:

const sum = require("./sum.js")

可以省略文件名的 .js 后缀:

const { Rectangle, sum } = require("./file")

Node 自己内置了许多系统模块,可以直接传入模块名进行导入。通过包管理安装的模块也可以:

// Node 内置的文件系统和 HTTP 模块
const fs = require("fs")
const http = require("http")

// 通过包管理安装的 Express 服务器框架模块
const express = require("express")

ES6 中的模块

ES6 中添加了 import 和 export 关键字及语法,ES6 模块化与 Node 在概念上是相同的。

ES6 模块自动应用严格模式,且在顶级代码中的 this 也是 undefined

在之前,需要使用 webpack 等打包工具来将所有 JS 模块文件组合成一个大的非模块文件,来方便在网页中使用。不过目前大部分浏览器已经支持 ES6 模块。而 Node 在 13 版本开始支持 ES6 模块,但绝大部分 Node 程序依然使用 Node 模块。

ES6 的导出

可以在声明语句前加上 export 导出变量、函数、类等:

export const PI = Math.PI

export function sum(a, b) { return a + b }

export class Rectangle { /* ... */ }

或者用一对花括号来只用一条 export 语句导出。比如上面的例子等价于:

这个语法仅仅表示逗号分隔的标识符列表,而非对象字面量,也不是任意表达式列表。

export { PI, sum, Rectangle }

也可以用 export default 来导出任意表达式。但是注意只会有一个 export default ,后者会覆盖前者。但是常规导出 export 可以和默认导出 export default 共存:

export { PI, sum }

export default Rectangle
export 关键字只能出现在 JS 的顶层代码,不能在循环、条件、函数、类等的内部。

ES6 的导入

对于默认导出,可以用 import 和一个标识符进行导入:

// 对于上一个导出例子
import Rectangle from "./file.js"

导入值得标识符是个常量,类似 const 声明。import 关键字也只能出现在顶层代码,但是不强制放在代码顶部,与函数声明类似,导入会被提升到顶部。

字符串为模块位置的 URL ,且 ES6 规范不允许像 "util.js" 这样的非限定标识符(但是 webpack 等打包工具通常不会限制)。

对于常规导出,可以用解构语法导入:

import { PI, sum } from "./file.js"

或者将所有常规导出构建为一个对象并命名。同样,对象的属性为常量不可修改:

import * as m from "./file.js"  // m = { PI, sum }

或者既导入默认导入,又导入常规导入(这种场景很不常见):

import Rectangle, { PI, sum } from "./file.js"

或者:

import { default as Rectangle, PI, sum } from "./file.js"
import 实际上会执行模块中代码一次(?)。可以什么也不导入,来直接应用模块的副作用:
import "./side_effect.js"

导入导出时重命名

重命名主要是用来避免两个模块存在相同标识符的情况。使用 as 关键字来重命名:

let sum = (x, y) => x + y
export { sum as add }
import { add as sum } from "./file.js"
sum(1, 3) // => 4

导出时重命名仅限于花括号形式,同时切记括号内部应当为标识符列表,而不是表达式列表。

再导出

有的时候,比如做模块的整合,需要从别的模块导入再导出,可以将 export 像 import 一样使用:

export { sum } from "./file.js"

将另一个模块的默认导出重命名,以及将常规导出作为默认导出:

export { default as Rectangle, PI, sum as default } from "./file.js"

也有将另一个模块的默认导出直接作为当前模块的默认导出:

export { default } from "./file.js"

网页中使用 ES6 模块

在以前,使用 ES6 的代码需要用 webpack 等工具打包,这样做会有一定的代价。比如对于更新频繁的 Web 应用,经常访问的用户会发现使用小模块比使用大文件的平均加载时间更短,因为可以更好地利用浏览器缓存。

但是总体来看,打包后的性能是比较好的,但是浏览器厂商也会不断优化自己的 ES6 模块实现。

如果想要在浏览器中使用模块,必须通过 <script type="module"> 标签来告诉浏览器你的代码是个模块,因为模块代码和常规代码运行方式是有区别的。

对于 Node ,需要在 package.json 里添加 type: "module" 属性。或者将文件后缀改为 .mjs 来表示模块文件。

带有 type="module" 属性的脚本会像带有 defer 属性的脚本一样被加载执行。HTML 解析器一碰到 <script> 标签就会开始加载代码,但是代码执行会推迟到 HTML 解析完成。如果脚本标签里添加了 async 属性,则会在加载后立即执行。

常规 <script> 脚本支持从网上任何服务器加载 JS 代码文件。但模块脚本有跨源加载的限制,即只能在 HTML 文档所在的域加载模块,除非服务器添加了适当的 CORS 头部允许跨源加载。同时,就算在开发模式中也不能使用 file:URL 测试模块,需要启动一个静态 Web 服务器来测试。

import() 动态导入

前面的 import 和 export 语句都是静态的,在代码运行之前导入的模块就已经可以使用。

在 ES2020 引入了 import() 来动态加载模块,目前支持 ES6 模块的浏览器都支持 import() 。给 import() 传入模块标识符,则会返回一个 Promise 对象,来进行后续异步的代码执行:

import("./file.js").then(Rectangle => {
let r = new Rectangle()
})

切记,import 是关键字,是操作符,虽然 import() 看起来像函数调用。

import.meta.url

在 ES6 模块中,import.meta 指向当前执行模块的元数据,该对象的 url 属性则是加载模块时使用的 URL( Node 中则是 file:URL )。

参考

export - MDN

import - MDN

What is the difference between .js and .mjs files? - Stack Overflow