javascript 模块笔记

javascript 也学了一段时间了,但从来没有接触过大型 javascript 项目。最近在翻阅 mdn 英文文档时发现有对 javascript 模块化的介绍,读后醍醐灌顶,这篇博客算是总结了一下自己对 mdn 英文文档中相关内容的一点理解。
原文链接:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules

模块化背景

早期 js 程序体积较小,大多只需要实现简单的页面交互逻辑。随着相关技术的发展利用 javascript 开发的项目越来越复杂,代码量也越来越大。这时候就需要对代码进行模块化,以便于代码的维护和复用以及提高代码的团队开发效率。最初 node.js 实现了 js 模块化,同时许多框架与库也实现了自己的模块化方案。后来现代浏览器也逐渐开始支持原生的模块化方案,ES6 标准中也加入了对模块化的支持,mdn 中介绍的就是 javascript 原生语言的模块化方案。
将大型项目全部写在一个 js 文件将给 debug 和后期维护带来巨大灾难,故而按功能将代码拆分到不同的 js 文件中,例如,现在要实现一个画有圆形,正方形和等边三角形的页面,文件结构如下:

index.html
main.js
modules/
    square.js
    triangle.js
    circle.js
    canvas.js

其中 canvas.js 文件中实现了画布的相关功能,而 square.js,triangle.js 和 circle.js 中的 draw 方法分别实现了画正方形,等边三角形和圆形的功能,main.js 文件中调用了这些文件中的方法,实现了画图的功能。
早期的代码可能会被拆分为如下形式:

<!-- index.html -->
<script src="./modules/canvas.js"></script>
<script src="./modules/triangle.js"></script>
<script src="./modules/square.js"></script>
<script src="./modules/circle.js"></script>
<script src="main.js"></script>

像上述这样简单的代码还好,但当 js 文件逐渐增多,文件之间的依赖关系变得越来越复杂时,光是调整 script 标签的顺序便足以令人头大,而且这样的方式还免不了变量污染的问题,譬如 triangle.js 文件中的 draw 方法实现了画三角形的功能,而 square.js 文件中也有 draw 方法实现了画正方形的功能,这时候如果在 main.js 文件中调用 draw 方法,哪个 draw 方法将被调用呢?对此,在 js 模块化的发展过程中出现过许多不同的解决方案,AMD和CMD都是很有影响力的模块化规范。而在 ES6 标准中推出后,原生的javascript语言也支持模块编程,许多人认为在javascript模块化领域,es6标准将实现最终的大一统。遵循es6语法,可以用如下代码实现导入模块:

<!-- index.html -->
<script type="module" src="main.js"></script>
// main.js
import { draw } from "./modules/triangle.js";
// triangle.js
function draw() {
  // ...
}
export { draw };

javascript模块基本结构

js 模块化需要通过 import 和 export 语句来实现,import 语句用于导入其他模块中的内容,export 语句用于导出当前模块中的内容。在上面的代码中,main.js 文件中通过 import 语句引入了 triangle.js 文件中的 draw 方法,而 triangle.js 文件中通过 export 语句将 draw 方法导出,main.js 文件中可以调用 triangle.js 导出的 draw 方法。很容易理解的是,在这些模块中,main.js 文件 import 了其他的模块文件,main.js 就是顶层模块,在 html 中引入该<script>标签时,需要添加 type="module"属性,表明这是一个模块文件。

导入映射表(import maps)

在上述的 import,export 语句中,浏览器通过一个模块说明符来确定要导入的模块,模块说明符可以是一个相对路径,也可以是一个绝对路径,也可以是一个 URL。为了便于使用,可以建立导入映射表(impot map),这样可以让几乎任意文本成为模块说明符。例如:

<!-- index.html -->
<script type="importmap">
   "imports": {
      "triangle": "./modules/triangle.js",
  }
</script>
<script type="module" src="main.js"></script>
// main.js
import { draw } from "triangle";

更多关于 import map,请参照原文档:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_modules_using_import_maps.

导入导出的重命名

此时 main.js 文件中还要引入 square.js 和 circle.js 文件中的 draw 方法,但是目前 main.js 已经导入了一个模块中的 draw 方法,要再 import 其他模块的 draw 方法,若继续 import{draw},则会引起变量名冲突,这时候就需要在 main.js 中使用 as 关键字,将导入的 draw 方法重命名,例如:

// main.js
import { draw as drawTriangle } from "./modules/triangle.js";
import { draw as drawSquare } from "./modules/square.js";
import { draw as drawCircle } from "./modules/circle.js";
// triangle.js
function draw() {
  // ...
}
export { draw };
// square.js
function draw() {
  // ...
}
export { draw };
// circle.js
function draw() {
  // ...
}
export { draw };

此外,还有一种解决方式,在被导入模块的 export 语句中使用 as 关键字,将导出的 draw 方法重命名,例如:

// triangle.js
function draw() {
  // ...
}
export { draw as drawTriangle };
// square.js
function draw() {
  // ...
}
export { draw as drawSquare };
// circle.js
function draw() {
  // ...
}
export { draw as drawCircle };
// main.js
import { drawTriangle } from "./modules/triangle.js";
import { drawSquare } from "./modules/square.js";
import { drawCircle } from "./modules/circle.js";

相比之下,在顶层模块中将导入的对象重命名的方式更加清晰,更有利于子模块的复用。

使用模块对象

此外,还可以使用*关键字将模块中的所有导出内容导入,例如:

// main.js
import * as triangle from "./modules/triangle.js";

上述代码会在 main.js 中导入一个模块对象,该对象中包含了 triangle.js 中导出的所有内容,可以通过 triangle.draw()来调用 triangle.js 中导出的 draw 方法。
同理,在模块内,也可以使用*关键字将所有导出内容导出,例如,现在要在 triangle.js 文件中加入 erase 方法,实现擦除三角形的功能,可以这样写:

// triangle.js
function draw() {
  // ...
}
function erase() {
  // ...
}
export { draw, erase };

也可以用*关键字将所有导出内容导出:

// triangle.js
function draw() {
  // ...
}
function erase() {
  // ...
}
export *

通过*关键字导入导出模块对象的方式,用模块名.导出内容的方式调用导出内容,也可以有效避免变量名冲突的问题。这样的方式类似于在一个模块文件中仅定义一个类,然后将该类的所有属性和方法都定义在该类中,然后导出导入该类。

默认导入导出

此外,当在模块内只有一个导出内容时,可以使用 default 关键字将该导出内容默认导出,例如:

// triangle.js
function draw() {
  // ...
}
export default draw;

当只准备对外暴露一个对象时,可以用到该关键字。导入时同样使用 default 关键字,并且不需要使用花括号,例如:

// main.js
import drawTriangle from "./modules/triangle.js";
//等价于:import {default as drawTriangle} from "./modules/triangle.js";

模块合并

上述代码所实现的页面已经实现了画三角形、正方形、圆形的功能,但假若后面想扩展画正五边形、正六边形等等新图形的功能,就需要在 main.js 文件中再次引入新的模块文件,最终可能会在 main.js 文件开头出现一大堆 import 语句。相比之下,新建一个父模块,在该父模块中导入各级子模块,可以将各子模块聚合到一个模块中,可以让顶层模块更加简洁,且更便于抽象,例如,现在新建一个 shapes.js 的文件,该文件合并多个模块。

// shapes.js
export { Triangle } from "./modules/triangle.js";
export { Square } from "./modules/square.js";
export { Circle } from "./modules/circle.js";

此处 export {…}from "…"实际上是将目标模块的内容导入到当前模块中,然后再导出,例如:

//parent.js
export * from "child.js";
//child.js
function func1() {
  // ...
}
function func2() {
  // ...
}
export { func1 };

此时,parent.js 中就可以使用 func1()函数,但是无法使用 func2()函数,因为 func2()函数并没有被导出,只有 func1()函数被导出了。所以在 parent.js 文件中的 export * from "child.js"语句并不等同于 child.js 文件中的 export *语句,export _语句会将 child.js 中的所有内容导出,而 export _ from "child.js"语句只会将 child.js 中导出的内容导出,未导出的内容不会被导出。

// main.js
import { Triangle, Square, Circle } from "./shapes.js";

模块的动态加载

假设我们实现了这样一个页面,在 shapes.js 文件中有许多导出内容,分别对应不同图形的类(假设有上百个),main.js 文件的开头要引入上百个模块文件,打开该页面就能看见许多已被绘制好的图形。但现在,要在原有页面加入不同的图形生成按钮,用户点击不同的按钮即可生成相应图形,而大多数情况下,用户只会点击其中几个按钮,这时候就需要按需加载模块,而不是一次性加载所有模块,原有的 Import 语句就显得冗余了,这时候就需要使用动态导入模块的方式。现在我们在页面中加入一个圆形生成按钮:

<!-- index.html -->
<button id="circleBtn">生成圆形</button>
const circleBtn = document.querySelector("#circleBtn");
circleBtn.addEventListener("click", () => {
  import("./modules/circle.js").then((module) => {
    const Circle = module.Circle;
    const circle = new Circle(100);
    circle.draw();
  });
});

顶层模块等待

在上述 js 语句中,import()函数返回一个 promise 对象,当模块加载完成时,可以在 then()中获取到模块对象 module,然后通过 module.Circle 获取 Circle 类,创建并绘制圆形,这样就实现了动态加载的功能。
动态加载以及 Promise 对象涉及到关于 js 异步编程的内容,同样,js 模块支持顶层模块等待子模块加载完成,mdn 文档中给出了示例,例如,现在新建一个 colors.json 文件:

// colors.json
{
  "red": "#ff0000",
  "green": "#00ff00",
  "blue": "#0000ff"
}

现在,新建一个 getColors.js 模块

// getColors.js
const colors = fetch("./colors.json").then((response) => response.json());
export default await colors;
// main.js
import { colors } from "./getColors.js";
import { Canvas } from "./modules/canvas.js";
import { Square, Circle, Triangle } from "./modules/shapes.js";
//创建画布相关操作
let myCanvas = new Canvas("myCanvas", document.body, 480, 320);
myCanvas.create();
myCanvas.createReportList();
//绘制圆形
const circle = new Circle(myCanvas.ctx, myCanvas.listId, 100, colors.red);
circle.draw();

也就是说,在 main.js 导入 getColors.js 模块时,会等待 getColors.js 模块中的 colors.json 文件加载完成,然后再继续执行 main.js 中的代码,同时加载过程中 canvas.js 和 shapes.js 模块并不会被阻塞。

模块的变量提升(hoisting)

模块导入变量的对象会被变量提升,例如:

// main.js
let circle = new Circle(100);
import { Circle } from "./modules/shapes.js";

在 import 语句执行之前,Circle 类已经被提升,所以可以在 import 语句之前使用 Circle 类。变量提升会导致很多不合直觉的问题,尽量在代码开头导入模块。

模块间的循环依赖问题

模块之间可以互相导入,这就会产生可能存在的循环依赖问题,例如模块 a 中导入了模块 b,但模块 b 直接或间接依赖模块 a。编写模块时要避免循环依赖关系,但存在循环依赖的模块也不一定会报错,只有在实际使用被导入变量时才会检索导入变量的值,所以只有在使用导入变量时导入变量尚未被初始化才会报错。mdn 上给出了示例:

// -- a.js --
import { b } from "./b.js";

setTimeout(() => {
  console.log(b); // 1
}, 10);

export const a = 2;

// -- b.js --
import { a } from "./a.js";

setTimeout(() => {
  console.log(a); // 2
}, 10);

export const b = 1;

a 模块导入了 b 模块的常量 b,b 模块导入了 a 模块的常量 a,但在 a 模块中,使用了常量 b 的语句延时执行,在执行该语句时 b 模块中的常量 b 已经初始化并导出,所以不会报错,同理 b 模块中使用常量 a 也没有报错。

编写“同构”模块(Authoring “isomorphic” modules)

最后,为了支持模块的跨平台复用,mdn文档给出了三种方法:

  • 使用模块时将模块隔离为 2 类,“core"和"binding”,其中"core"指纯粹的 javascript 模块,比如仅涉及数据的计算的模块,“binding”指与环境相关的模块,例如在模块中访问了 DOM 元素,或者使用了浏览器的 API,这样的模块在 node.js 环境中可能就无法运行了。例如,我们可以在 square.js 模块中仅声明一个 square 类,声明正方形的边长为其属性,并声明一个计算正方形面积的方法,这个模块仅涉及正方形的部分关键数据,这就是 core 模块,然后再新建一个 名为 squareDrawer.js 的模块文件,在文件中定义一个 squareDrawer 类,定义画正方形的方法,在该方法中将会用到 canvas api,需要在浏览器端运行,这就是 binding 模块。这样将模块分离为 core 和 binding 模块,可以使得 core 模块在别的运行环境中仍能复用;
  • 在使用全局变量之前检测其是否存在,这些全局对象可能只能存在与特定环境中;
  • 使用 pollyfill 为缺少的功能提供兼容性支持。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值