模块化是一种软件设计技术,它将程序分解为独立的、可重用的模块,每个模块封装特定的功能或逻辑。本文主要介绍 JavaScript 的模块化规范。
模块化的作用
维护、封装和复用上的优势
因为每个模块只关注特定的功能,模块内部的实现细节对外部隐藏,只暴露必要的接口,减少代码之间的耦合,所以代码更易于理解和维护,多个开发人员可以同时开发不同的模块,提高开发效率。
封装成模块的代码可以在不同的项目中重复使用,减少重复代码。
全局变量污染和数据保护
模块化解决了全局变量污染和数据保护的问题。
全局变量污染是指在一个程序中,多个部分共享同一个全局命名空间,导致变量名冲突和意外覆盖。模块化通过将代码分隔到独立的模块中,减少了全局命名空间的使用,从而避免了全局变量污染。
数据保护是指防止模块内部的数据被外部直接访问和修改。模块化通过封装和导出机制,只暴露必要的接口,隐藏内部实现细节,从而保护数据。
依赖关系
模块化解决了模块之间依赖关系的维护问题。通过明确的导入和导出机制,模块化使得依赖关系更加清晰和可管理。
在没有模块化的情况下,依赖关系通常是隐式的,难以追踪和管理。这会导致以下问题:
- 依赖混乱:难以确定一个模块依赖于哪些其他模块。
- 难以重构:重构代码时,难以确保所有依赖关系都被正确更新。
- 加载顺序问题:在浏览器环境中,脚本的加载顺序可能会导致依赖关系问题。
模块化通过明确的导入和导出机制,使依赖关系变得清晰和可管理:
- 明确的导入和导出:每个模块明确声明它依赖于哪些其他模块,以及它向外部暴露哪些功能。
- 自动化依赖管理:现代模块打包工具(如 Webpack、Rollup)可以自动解析和管理依赖关系,确保模块按正确的顺序加载。
- 易于重构:由于依赖关系是显式的,重构代码时可以轻松更新依赖关系。
JavaScript 模块化规范
JavaScript 有许多不同的模块化规范,例如原生 Node.js 已经提供模块化功能很长时间了(CommonJS),还有很多的 JavaScript 库和框架已经开始了模块的使用(AMD 和 CMD 等)。最新的浏览器开始原生支持模块功能(ES6 Module)。
在后文中将详细介绍 ES6 Module 和 CommonJS。
ES6 Module
从 ES6 开始,在语言标准的层面上,实现了模块化功能(ES6 Module),而且实现得相当简单,完全可以取代 CommonJS 和 CMD、AMD 规范,成为浏览器和服务器通用的模块解决方案。
任何模块化,都必须考虑的两个问题就是导入依赖和导出接口。ES6 Module 也是如此,模块功能主要由两个命令构成:export 和 import。export 命令用于导出模块的对外接口,import 命令用于导入其他模块导出的内容。
模块文件
ES6 使用 .js 或者 .mjs 扩展名的文件定义 模块。(建议使用 .js)
import 和 export 语句只能在模块内部使用,不能在普通脚本文件中使用。
浏览器通过 script 标签的 type 属性来判断一个 .js 文件/内部脚本是模块文件还是普通脚本文件。例如:
<script type="module" src="main.js"></script>
Node.js 通过 package.json 中的 type 来判断 .js 文件是当作 ES6 模块文件 (module) 还是 CommonJS 模块文件 (commonjs)。
导出模块的功能
使用 export 语句将一个模块的功能导出给其他模块使用。能够导出函数、变量和类。export 要放在模块的最外层。
分别导出
在函数、变量或类之前加上 export 就可以分别导出这些功能:
export const name = "square";
export function draw(){...};
export class Square {...};
集中导出
使用 export 加上花括号可以集中导出定义好的功能:
export { name, draw, reportArea, reportPerimeter };
重命名导出
如果想为这些功能取一个新的名字,可以使用 as 关键字重命名导出:
export { function1 as newFunctionName, function2 as anotherNewFunctionName };
导入模块
分别导入
使用 import 语句导入其他模块来使用其导出的功能:
import { name, draw, reportArea, reportPerimeter } from '/square.js';
import后被花括号包围的用逗号分隔的是想导入的功能列表,from后是模块文件的路径。- 模块文件的路径以
/开头是相对于站点根目录的路径。以.开头表示相对当前文件的路径。必须提供文件扩展名。
导入后就可以使用相应的功能了。
重命名导入
如果想为这些功能取一个新的名字,避免命名冲突,可以使用 as 关键字重命名导入:
import {
function1 as newFunctionName,
function2 as anotherNewFunctionName,
} from "/modules/module.js";
作为模块对象全部导入
如果不想分别导入,可以导入模块的全部功能到一个模块功能对象上,并为这个对象取一个名字:
import * as Module from "/modules/module.js";
这样模块的全部功能都在 Module 对象上,可以通过如下方式使用:
Module.function1();
Module.function2();
无命名导入
只是运行模块的代码,不使用其导出的任何功能:
import './module.js';
使用默认导出和导入
默认导出
使用 default 关键字导出的内容为默认导出:
export default randomSquare;
每个模块只允许有一个默认导出。
导入默认值
默认导入时无需使用花括号:
import randomSquare from "./modules/square.js";
相当于下面的缩写:
import { default as randomSquare } from "./modules/square.js";
如果使用全部导入 (import *),默认导出的内容在模块对象的 default 属性上。
导入后导出
使用下面的简写语法可以在导入后重新导出部分或全部导出子模块的功能:
export * from "x.js";
export { name } from "x.js";
动态导入
浏览器中可用的 JavaScript 模块功能的最新部分是动态模块加载。这允许你仅在需要时动态加载模块,而不必预先加载所有模块。
这个新功能允许你将 import() 作为函数调用,将模块的路径作为参数传递。它返回一个 Promise,它用一个模块对象来实现 Promise,让你可以通过模块对象访问该模块导出的功能,例如:
import("/modules/mymodule.js").then((module) => {
// 用这个模块做点什么...
});
// 或者将模块对象解构
import('/modules/mymodule.js').then(({ default: DefaultExport, NamedExport }) => {
// 用这个模块做点什么...
});
如果在模块顶层使用 import() 仍然会在模块运行时导入子模块,可以使用下面的方法将模块封装成一个函数:
const MyModule = () => import("/modules/mymodule.js")
只有在运行 MyModule() 时才会导入子模块。
CommonJS
CommonJS 模块是为 Node.js 打包 JavaScript 代码的原始方式。
模块文件
在 Node.js 中,每个文件都被视为一个单独的模块。
扩展名为 .cjs 的文件会被视为 CommonJS 模块。
Node.js 通过 package.json 中的 type 来判断 .js 文件是当作 ES6 模块文件 (module) 还是 CommonJS 模块文件 (commonjs)。
CommonJS 的工作原理
所有非 Node.js 原生模块(如 fs、path 等等)都是 Module 类的实例,它包含了该模块的一些信息。当使用一个模块时,会创建模块实例。
编写的模块代码最终是被放在一个函数内部执行的,每个模块都处于单独的函数作用域,所以不会造成污染全局变量的问题。
function wrap(exports, require, module, __filename, __dirname) {
// 模块代码
}
exports,等同于module.exports,作为module.exports的快捷访问;require,通常为模块原型上的require方法,用于加载其他模块;module,当前模块对象,通过new Module()实例化创建的;__filename,当前模块的绝对路径文件名;__dirname,当前模块所在目录的绝对路径。
在编写模块代码时,通过使用函数的参数,可以实现模块的导入和导出:通过 module.exports 实现了模块导出,可以将模块内部的某些代码共享给其他模块复用,通过 require 可以方便地加载其他模块的导出内容供当前模块使用。
此外,在非严格模式下,this 默认指向 module.exports,因此你可以直接在 this 上添加属性,这些属性会被添加到 module.exports 上。在严格模式下,this 是 undefined,因此不能直接在 this 上添加属性,否则会抛出错误。
导出模块的功能
模块导出的内容为 module.export,默认是一个对象 {},在 module.export 对象上增加属性即可导出模块的功能。
module.exports.age = 18;
module.exports.name = "My Name";
在初始时,exports 和 this (非严格模式) 均指向 module.exports,所以也可以使用下面的简写方法:
// 使用下面的简写(更常用)
exports.age = 18;
exports.name = "My Name";
// 或者
this.age = 18;
this.name = "My Name";
你也可以将任何你想导出的内容赋值给 module.exports,以将其作为模块的导出内容。
module.exports = foo;
module.exports = {
age: 18,
name: "My Name"
};
导入模块
使用 require() 函数导入其他模块。
// 内置模块
const fs = require('fs')
// 相对路径文件模块
const foo = require('./foo.js')
// 绝对路径文件模块
const absolutePathModule = require('/absolute/path/to/module')
// 第三方模块
const koa = require('koa')
相关知识库条目
参考资料
指南:JavaScript 模块
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules
Node.js ECMAScript 模块
https://nodejs.cn/api/esm.html
Node.js CommonJS 模块
https://nodejs.cn/api/modules.html