前端模块化历程
前言
在李录的《文明、现代化、价值投资与中国》里,他把人类的文明史分为三大跃升阶段:即 1.0 狩猎采集文明、2.0 农业文明和 3.0 科技文明。针对 JavaScript 的模块化,笔者认为模块化历程也可类比为这样的历程。在前端页面不复杂的情况下,我们只需引入需要的库、js 文件或模块,这就像“狩猎采集文明”时,饿了去打猎、摘果子一样,及时反馈,速度 NO1;后来NodeJS 兴起,让前端有了操作文件的能力,这时候就进入了农业文明时代,这里有两个标准最为突出,一是 NodeJS 社区推崇的 CommonJS,一个是浏览器推崇的 AMD,两个之所以有所不同,是因为两者所处的位置(环境)不同;之后发展演变,ECMAScript 提出了统一标准 ESM 标准,这也是前端模块化的终点,也就是“3.0 科技文明”时代
我会在每个阶段中讲解这个时代代表性的解决方案和对应的库,让你清晰地了解前端模块化的发展历程
目录
狩猎采集阶段——IIFE 统治前端工程化
- 文件、注释划分方式
- 函数方式
- 命名空间方式
- IIFE(立即执行函数)
农业文明阶段——CommonJS 与 AMD
- CommonJS(NodeJS社区定义)
- AMD(异步模块定义)
科技文明阶段——ESM 延续至今
- ESM
- 前端打包库的兴起
狩猎采集阶段——IIFE 统治前端工程化
最开始前端是没有模块化这一说法的,但网站脚本变多后,有规模后以及要多人协助开发后才有了模块化开发
文件、注释划分方式
字面意义,通过文件名、注释来规划你的JS代码
// 某某A模块
function foo() {}
// 某某B模块
function bar() {}
优点:极简
缺点:
- 有些功能可能忘了加注释
- JS 文件会越来越大
函数方式
既然用文件和注释解决不了,那么按照功能点将它划分为多个函数方法,然后在总 main.js 中引入调用方法即可
signInAndOut()
userModule()
frinedModule()
feedsModule()
但一个问题:全局变量过多,也许引入的第三方脚本会和你命名的全局变量冲突,所以我们需要减少全局变量
优点:简单
缺点:
- 全局函数名太多了
- 调用关系复杂,难以调整顺序
- JS 文件会越来越大
命名空间方式
var app = app || {}
app.user.module1()
app.user.module1.submodule1()
app.user.module2()
app.feeds()
app. signInAndOut()
app.friends()
大概2010-2012年的时候有这样写代码的,现在没人这样写了
优点:占用的全局变量少(只有一个 app)
缺点:
- 名字越来越长
- 依赖关系不清晰
IIFE(立即执行函数)
// 没有名字直接执行
!function user(dom, axios, time) {
// 内部代码
}(dom, axios, time)
// 有名字通过闭包导出代码
!function (dom, axios, time) {
// 内部代码
let uer = {}
const getUser = () => {
user = { name: 'johan' }
}
const updateUser = () => {
user.name = 'elaine'
}
// 暴露 API
return {
getUser,
updateUser
}
}(dom, axios, time)
优点:
- 可以有名字,也可以没有名字
- 可以声明依赖
- 可以选择导出内容
缺点:
- 无法处理循环依赖
在 NodeJS 出现之前,前端库都是通过 IIFE(立即执行函数)来实现的。关于 IIFE,我们之前在 JavaScript 篇的 立即执行函数(IIFE) 中提到过。他已经完成了一个模块化方案的基本功能,也是现代模块化实现的基石
农业文明阶段——CommonJS 与 AMD
在上文说了IIFE(立即执行函数)除了无法处理循环依赖外,没有什么大问题,所以在前端(浏览器端)只要不去循环依赖就没有问题(IIFE很好用不需要改革),但是在 NodeJS 社区不能这样,因为 NodeJS 没有 HTML,它不像前端在 HTML 入口处引入 JS 模块就可以,它需要在 main.js 中使用其他js文件的方法,所以 NodeJS 社区就有了 CommonJS,即自己的引入导出功能
CommonJS
导出模块
exports.getUser = () => {}
const x = () => {}
exports.x = x
// 如果要导出整个模块
module.exports = () => {
return 'hi'
}
引入模块
const user = require('./user.js')
user.getUser()
user.x
// 如果导出整个模块的情况下
user() // hi
exports 指向
module.exports
,即exports = module.exports
优点:
- 用文件名当名字,不用全局变量
- 可以声明依赖
- 可以选择导出内容
- 可以循环依赖
缺点:
- 不支持异步
它只能适用于 NodeJS,不适用浏览器,为什么因为引入依赖是同步的,而JS是单线程,但引入依赖时间过长时,页面会有卡顿现象
AMD(异步模块定义)
翻译:Async Module Definition
AMD 规范是异步加载模块,允许指定回调函数,代表函数库:require.js
require.js
主要解决两个问题:
- 异步加载模块
- 模块之间依赖模糊
特点:依赖必须提前声明好
导出模块
define('user', ['require', 'exports', 'dom', 'axios', 'time'], (require, exports, dom, axios, time) => {
dom.find('#x')
axios.get()
time.now()
exports.getUser = function () {}
})
引入模块
define(['require', 'exports', 'user'], (require, exports, user) => {
user.getUser
})
优点:
- 用字符串当名字,不用全局变量
- 可以声明依赖
- 可以选择导出内容
- 可以循环依赖
- 可以异步
缺点:
- 对同步的支持不如 CommonJS
- 没有写入 ECMAScript 文档
CMD
CMD 是阿里的玉伯提出的,js 的函数为 sea.js
。它与 require.js
最主要的区别是实现了按需加载,推崇依赖就近原则,模块延迟执行
特点: 支持动态引入依赖
define(function (require, exports, moduke) {
var indexCode = require('./index.js');
});
AMD 和 CMD 最大的区别是对依赖模块的执行时机处理不同
AMD:依赖前置,提前执行
CMD:依赖就近,延迟执行
AMD && CMD 背后的实现原理
一种解决方 案是采用 UMD(Universal Module Definition, https://github.com/umdjs/umd),这种模式的语法有点复杂,它同时支持 AMD 和 CommonJS
科技文明阶段——ESM 延续至今
ES Modules(ES 模块)
导入模块
<body>
<script src="main.js" type="module"></script>
</body>
导出模块
main.js
import { a } from './user.js'
console.log(a)
user.js
export const a = 1
优点:
- 该支持的都支持
- 支持静态分析(tree-shaking)
- 支持按需加载(import())
缺点:
- 不支持拼接字符串
- 与静态分析冲突
- 不支持模块加载
- 与静态分析冲突
- 不兼容 Node 的 CommonJS
- 使用 .mjs 后缀
其模块功能主要由两个命令构成:export
和import
前端打包库的兴起
Webpack-集大成者,使用 webpack 进行统一打包
对标的有 Rollup
总结
笔者这里按照重点梳理
IIFE(声明即执行的函数表达式),特点:在一个单独的函数作用于中执行代码,避免变量冲突
!(function () {
return {
data: [],
};
})();
CommonJS: NodeJS 中自带的模块化
var fs = require('fs');
exports.x = x
AMD:使用 requireJS 来编写模块化,特点:依赖必须提前声明好
define('./index.js', function (code) {
// code 就是index.Js 返回的内容
});
CMD:使用 seaJS 来编写模块化,特点: 支持动态引入依赖
define(function (require, exports, moduke) {
var indexCode = require('./index.js');
});
UMD:是 AMD 和 CommonJS 的糅合,跨平台的解决方案
ES Modules:现代浏览器的最终解。可以使用 import
关键字引入模块, 通过export
关键字导出模块
常见面试题
Q:ES Modules与 CommonJS 模块的差异
A:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
CommonJS 模块是运行时加载,ES6 模块是编译时加载(懒加载的核心)
Q:老浏览器(IE6之类)是不认识 ES Modules 的,那 webpack 编译后的产物 ES Modules 动态引入会变成什么样子
A:IE6 不支持 ES Modules,所以需要 Babel 将 ES6+ 代码转译成 ES5。动态引入也是 Babel 转译成其他代码(通过将动态引入转化为一个普通的脚本加载调用)
CommonJS 是同步的,因为它适用的地方是服务器端,读取文件都是毫秒级别,不需要等待多久时间,而AMD 适用的场景是浏览器端,它必须异步,因为JS是单线程,如果引入的依赖时间过长,页面会出现卡顿现象,不利于交互。所以 AMD 代码看起来很丑(因为有回调)
而 ES Modules 它虽然写的是同步的import a from './a'
,但它执行时浏览器引擎知道你是 module 代码的话就等你加载完在执行,那为什么它不会慢呢?它按需加载以及会进行静态分析
静态分析减少了不用用到的代码
按需加载,只执行这个页面所需要的代码模块