聊一下前端模块化的前世今生

前端模块化的发展史

1. 第一阶段

在 JavaScript 诞生之初,开发人员仅仅是用它写一些页面上的小交互效果,这些工作并不复杂,往往都是几百行代码就能搞定的事情,因此只要开发人员足够的小心谨慎,那么一般不会出什么差错。这个时候 ECMAScript 标准甚至都还没有问世,也没有专门的前端开发,这些简单的页面交互往往交给后端人员进行开发。

2. 第二阶段

后来 AJAX 技术的出现,使得 JavaScript 不仅可以实现页面上的小效果,还具备了和服务器进行交互的能力。这极大增强了客户的使用体验,也因此 JS 的代码量大大增加,从最初的几百行到几万行。自然写 JS 的后端人员就难以招架了这种情况了,也是这个时候一些公司开始分化出了前端职位。当然这个时候的前端工程师负责的工作相比后端仍然是非常简单的,培训几天就可以胜任这份工作,满足前端开发的需要。但此时前端开发还有几大问题没能解决,这些问题都制约着前端的发展:

  1. 浏览器执行JS代码的速度太慢
  2. 用户的计算机配置不足
  3. 大量代码带来的全局命名污染和依赖混乱

上面的三个问题使得前端在传统开发和前后端分离之间无助的徘徊,处在一个非常尴尬的境地。

3. 第三阶段

时间继续推移,到了 2008 年谷歌推出了 Chrome 以及随之而来的 V8 引擎,使得 JavaScript 在浏览器上的执行速度有了质的飞跃,同时在摩尔定律的持续发酵下,用户的电脑配置也上升了许多。一时间,限制前端发展的两大问题就被解决了。现在只剩下最后一个问题:全局变量污染和依赖混乱

这个问题在全世界的前端社区中引起了广泛且激烈的讨论以寻求解决之道……

也是在这一年,一个加拿大小伙 Ryan DahI 对结合 JavaScript 和异步 IO 以及一些 HTTP 服务器产生了浓厚的兴趣,他致力于用 JS 实现一个高性能的、基于异步的的 WEB 服务器,这时又恰好有 V8 引擎这个优秀的 JS 运行时的加持,再加上一些 API 就组成了现在的 Node.js

这区别于传统的多线程后端应用程序,在多个并发的应用场景下,由于多个线程公用同一个进程的运行资源,对于单核心的CPU来说多线程并没有带来执行速度上的提升,反而因为频繁的切换线程的执行上下文而导致总体执行之间变长。单线程的 JS 和它天生的异步特性使得这个问题迎刃而解。

img

node.js 诞生使得 JS 无法模块化的问题被摆在了最显眼的位置,要知道 node.js 是作用于服务端的,如果 js 不支持模块化开发,那就基本不可能向其他后端语言那样开发服务器应用程序。在这样的背景下,经过社区的激烈讨论,最终形成了一个模块化方案即 CommonJS 规范,这个方案彻底解决了全局变量污染和依赖混乱的问题,它的一经问世就得到了 node.js 的支持和实践,node.js 成为了第一个实现 js 模块化的运行环境。

4. 第四阶段

想来确实奇怪, 浏览器才是 JS 诞生和发光发热的温床,反而是 node.js 率先解决了 JS 无法模块化的问题,于是人们开始想方设法把 CommonJS 应用在浏览器中。

但问题在于,由于 CommonJS 所支持的是模块同步加载,且浏览器的环境跟服务端大不相同,服务端在本地磁盘读取文件的速度是相当快的,而对于浏览器来说这需要进行大量的 Ajax 网络请求才能读取到所需的模块文件,这导致页面解析受到严重的阻塞!因此 CommonJS 的同步导入特性根本不适合浏览器。

后来出现了 AMD (异步模块定义)规范,该规范的一个重要特性是支持异步加载模块,将使用到该模块的代码包裹在回调函数中,当模块被加载且执行完毕后立即执行回调。由于加载模块不阻塞页面渲染和其他脚本执行,这是一个浏览器可用的模块化规范。

再后来又出现了 CMD ,它是对 AMD 的改进。

但这些非 ES 官方的模块化规范都有各自的要命的缺点,最终在 2015年 ES6 的发布使得 JS 真正成为了一门具有模块化特性的语言,这是 JS 发展历程上的一个重要里程碑。

CommonJS

CommonJS 规定要有requireexportsrequire 将所依赖的模块导入,exports负责将模块中需要暴露给用户的 API 暴露出去,而模块内部的实现对用户来说是隐藏的。

1
2
3
4
5
6
7
8
9
// 导出 moduleA.js
exports = {
foo: function(){}
bar: 'bar'
}

// 导入 index.js
const moduleA = require('./moduleA.js')
console.log(moduleA.bar)
  • 多次导入同一个模块,只会加载一次模块文件,第一次加载会放入缓存,剩下的导入的其实是缓存
  • 由于 CommonJS 模块加载是同步的,因此可以在非顶级代码块中使用reruire 导入模块
1
2
3
if(loadCondition){
require('./moduleA')
}

这也说明 CommonJS 是支持动态导入的

  • 在Node.js 实现的 CommonJS 中有一套自己的模块路径解析规则,除了相对路径、绝对路径以及动态路径,还有比如require('axios')会自动到node_modules目录下按照其规则来找寻模块

AMD、CMD 和 UMD

1. 浏览器端实现模块化的难题

Node.js 是 CommonJS 规范的实践者,它运行在服务端,因此 require的同步加载机制对它来说是很合适的。但它却不适合在浏览器中运行。

  1. 浏览器要加载 JS 文件,需要远程从服务器读取,而网络传输的效率远远低于 node 环境中读取本地文件的效率。由于 CommonJS 是同步的,这会极大的降低运行性能
  2. 如果需要读取 JS 文件内容并把它放入到一个环境中执行,需要浏览器厂商的支持,可是浏览器厂商不愿意提供支持,最大的原因是 CommonJS 属于社区标准,并非官方标准

新的规范 基于以上两点原因,浏览器无法支持模块化 可这并不代表模块化不能在浏览器中实现 要在浏览器中实现模块化,只要能解决上面的两个问题就行了 解决办法其实很简单:

  1. 远程加载 JS 浪费了时间?做成异步即可,加载完成后调用一个回调就行了
1
require("./a.js", function () {}); //回调
  1. 模块中的代码需要放置到函数中执行?编写模块时,直接放函数中就行了

2. 对应的解决方案

基于这种简单有效的思路,出现了 AMD 和 CMD 规范,有效的解决了浏览器模块化的问题。

require.js是 AMD(异步模块定义) 的实现,提供了define方法来导入或导出模块。下列代码定义了moduleA模块,它依赖于moduleBmoduleC,并暴露了一个对象。

1
2
3
4
5
6
7
8
9
define('moduleA',['moduleB','moduleC'], function (moduleB,moduleC) {
//模块内部的代码
//......
// 导出的内容
return {
stuff:moduleB.dostuff(),
stuff1:moduleC.doStuff1()
}
});

sea.js 是 CMD 的实现,具体代码就不贴了,懒。。。

优点:支持异步加载模块,可以并行加载多个模块,适用于浏览器环境

缺点:可以看出这两种方法的代码编写方式是比较别扭的,并不符合通常的模块化开发思维,增加了开发的心智负担。

UMD(通用模块定义) 是 CommonJS 和 AMD 的杂糅版,本质上是程序启动前先判断该环境中支持哪个模块系统,再根据响应的规则进行 IIFE 函数的封装。先判断是否支持 CommonJS 的模块系统,支持就用,不支持就看是否支持 AMD,这就是所谓的 UMD。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function (window, factory) {
if (typeof exports === "object") {
// CommonJS
module.exports = factory();
} else if (typeof define === "function" && define.amd) {
// AMD
define(factory);
} else {
// 浏览器全局定义
window.eventUtil = factory();
}
})(this, function () {
// do something
});

ES6 模块化——ESModule

1. ESModule 简介

ES6 模块化的设计思想是使其尽量的静态化,在编译时就能根据导入导出语句确定依赖关系图。而 CommonJS 和 AMD 只能在运行时才能确定这些关系。

  • 模块内部自动使用严格模式
  • 模块中的顶层**this**指向 **undefined**

在浏览器中通过给script标签加上type:"module"来标识该代码块是个模块,它基本等效于defer,在文档解析时表现为异步加载,因此不阻塞渲染器的渲染流程,但当它加载完成后就要立即执行其中的代码。

1
2
3
<script src="入口文件" type="module">
//module作为模块运行
//当成了模块,就不会污染全局变量

怎么理解commonjs的运行时确定依赖关系,以及esModule的编译时确定依赖关系?

ES6 模块的设计思想是尽量的静态化,通过import和export显式的导入需要的代码,使得编译时就能确定模块的依赖关系,以及输入和输出的变量,import是编译时就执行的

CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块导出的就是对象,而且是一整个对象(不管其中有些东西用没用到,这不利于treeShaking,但也有凑合的方法,虽然不如es6的import来的直接有效),输入时必须查找对象属性。

1
2
3
4
5
6
7
// CommonJS模块
let { stat, exists, readfile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。

1
2
// ES6模块
import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。

2. ESModule 的导入和导出

2.1. 基本导入导出

1
2
3
4
5
6
7
8
9
10
export` 命名导出中`export`后面只能跟 `声明表达式`和`{具名变量}
// export 后面跟声明表达式
export const foo = 'foo'
export let bar = 'bar'
export var baz = 'baz'

// export 后面跟{具名变量},注意这里的花括号仅仅就是花括号,跟对象没有关系
const aa = 'aa'
const bb = 'bb'
export {aa,bb}

import 命令

  1. 导入的变量是只读的(类似const,基本类型不可修改值,引用类型可修改属性)
  2. 有提升的效果,即使没有写在顶部,也会提升到顶部
  3. 重复执行同一个import命令只导入一次
  4. import命令的路径只能是纯字符串,这与require不同
1
2
3
4
5
6
7
8
9
// 导入成员
import {foo,bar,baz} from './moduleA.js'

// 导入成员起别名alias
import {foo as ff,bar as bb} from './moduleA.js'

// 全部导入所有成员
import * as FBB from './moduleA.js'
console.log(FBB.aa,FBB.bb)

2.2. 默认导入导出

export default命令为模块指定一个默认输出,本质上是给default变量进行赋值操作,因此export default 后边跟的是一个表达式,而不是声明表达式。

一个模块中只能有一个默认导出(export default只能调用一次,但可以和export混用)

1
2
3
4
5
6
7
8
9
10
11
// 默认导出一个匿名函数
export default function(){
console.log(123)
}
/********************************************/
// 报错!export default 后面跟了声明表达式
export default const a = 1

// 正确的写法
const a = 1;
export default a

export defaultexport的区别:简单来说,export {}导出的是值的引用,export default 导出的是default的值(除export default function是引用)而不是引用。即:如果在模块里修改导出成员,默认导出不会更新,而命名导出会实时更新。

一般来说我们希望导入的是引用值而不是值的快照,所以在实践中要少用默认导出,以及{prop}=await import()

3. Import()

ES2020 引入 import() 函数支持动态加载模块

  • import() 函数可以用在任何地方,不仅仅是模块非模块的脚本也可以使用
  • import() 函数是运行时执行
  • import() 函数与所加载的模块没有静态连接关系
  • import() 函数类似于 Node.js 中的 require() 函数,区别主要是前者是异步加载后者是同步加载
  • import() 函数的返回值是 Promise 对象
1
2
3
4
5
6
7
import('./dialogBox.js')
.then((dialogBox) => {
dialogBox.open()
})
.catch((error) => {
/* Error handling */
})

import() 函数的使用场景

  • 按需加载
  • 条件加载
  • 动态的模块路径

聊一下前端模块化的前世今生
http://example.com/2024/12/04/FEmodule/
作者
Jabin
发布于
2024年12月4日
许可协议