Skip to content

前端模块化历程

前言

在李录的《文明、现代化、价值投资与中国》里,他把人类的文明史分为三大跃升阶段:即 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代码

javascript
// 某某A模块
function foo() {}
// 某某B模块
function bar() {}

优点:极简

缺点:

  • 有些功能可能忘了加注释
  • JS 文件会越来越大

函数方式

既然用文件和注释解决不了,那么按照功能点将它划分为多个函数方法,然后在总 main.js 中引入调用方法即可

javascript
signInAndOut()
userModule()
frinedModule()
feedsModule()

但一个问题:全局变量过多,也许引入的第三方脚本会和你命名的全局变量冲突,所以我们需要减少全局变量

优点:简单

缺点:

  • 全局函数名太多了
  • 调用关系复杂,难以调整顺序
  • JS 文件会越来越大

命名空间方式

javascript
var app = app || {}
app.user.module1()
app.user.module1.submodule1()
app.user.module2()
app.feeds()
app. signInAndOut()
app.friends()

大概2010-2012年的时候有这样写代码的,现在没人这样写了

优点:占用的全局变量少(只有一个 app)

缺点:

  • 名字越来越长
  • 依赖关系不清晰

IIFE(立即执行函数)

javascript
// 没有名字直接执行
!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

导出模块

javascript
exports.getUser = () => {}

const x = () => {}

exports.x = x

// 如果要导出整个模块
module.exports = () => {
    return 'hi'
}

引入模块

javascript
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 主要解决两个问题:

  • 异步加载模块
  • 模块之间依赖模糊

特点:依赖必须提前声明好

导出模块

javascript
define('user', ['require', 'exports', 'dom', 'axios', 'time'], (require, exports, dom, axios, time) => {
    dom.find('#x')
    axios.get()
    time.now()
    
    exports.getUser = function () {}
})

引入模块

javascript
define(['require', 'exports', 'user'], (require, exports, user) => {
    user.getUser
})

优点:

  • 用字符串当名字,不用全局变量
  • 可以声明依赖
  • 可以选择导出内容
  • 可以循环依赖
  • 可以异步

缺点:

  • 对同步的支持不如 CommonJS
  • 没有写入 ECMAScript 文档

CMD

CMD 是阿里的玉伯提出的,js 的函数为 sea.js 。它与 require.js 最主要的区别是实现了按需加载,推崇依赖就近原则,模块延迟执行

特点: 支持动态引入依赖

javascript
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 模块)

导入模块

html
<body>
    <script src="main.js" type="module"></script>	
</body>

导出模块

main.js

javascript
import { a } from './user.js'
console.log(a)

user.js

javascript
export const a = 1

优点:

  • 该支持的都支持
  • 支持静态分析(tree-shaking)
  • 支持按需加载(import())

缺点:

  • 不支持拼接字符串
    • 与静态分析冲突
  • 不支持模块加载
    • 与静态分析冲突
  • 不兼容 Node 的 CommonJS
    • 使用 .mjs 后缀

其模块功能主要由两个命令构成:exportimport

前端打包库的兴起

Webpack-集大成者,使用 webpack 进行统一打包

对标的有 Rollup

总结

笔者这里按照重点梳理

IIFE(声明即执行的函数表达式),特点:在一个单独的函数作用于中执行代码,避免变量冲突

javascript
!(function () {
  return {
    data: [],
  };
})();

CommonJS: NodeJS 中自带的模块化

javascript
var fs = require('fs');
exports.x = x

AMD:使用 requireJS 来编写模块化,特点:依赖必须提前声明好

javascript
define('./index.js', function (code) {
  // code 就是index.Js 返回的内容
});

CMD:使用 seaJS 来编写模块化,特点: 支持动态引入依赖

javascript
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 代码的话就等你加载完在执行,那为什么它不会慢呢?它按需加载以及会进行静态分析

静态分析减少了不用用到的代码

按需加载,只执行这个页面所需要的代码模块

参考资料