可以把“模块化和构建工具的演进”理解成:
前端代码越来越大后,人类是怎么一步步解决“代码管理问题”的。
一、最开始:所有代码写同一个文件里
<script>
function add(a, b) {
return a + b
}
function login() {}
function render() {}
...
</script>
几万行代码都写在同一个文件里,于是人们想: “能不能拆文件?”
二、开始拆文件(但还没有模块化)
<script src="math.js"></script>
<script src="user.js"></script>
<script src="app.js"></script>
现在代码分文件了。 但问题来了:
问题1:加载顺序不能错
例如:app.js依赖于math.js
如果:就炸了。
<script src="app.js"></script>
<script src="math.js"></script>
因为app.js 先执行,但 math.js 还不存在
问题2:全局变量污染
所有东西都挂在:window上。
例如:不同文件给同一个属性名赋值会直接覆盖。
// a.js
var name = "Tom"
// b.js
var name = "Jack"
于是:“我们需要真正的模块系统。”
每个文件有自己的作用域,并且文件之间可以互相导入导出
三、CommonJS:Node.js 带来的模块化(2009)
CommonJS 的设计目标: 每个文件就是一个模块,并且模块内部变量默认私有, 只有主动导出的内容别的文件才能使用。
// 导出
module.exports = {}
// 导入
const math = require('./math')
module.exports 到底是什么?
Node 会给每个模块自动创建模块对象:
module = {
exports: {}
}
你写:其实是在修改exports对象
module.exports = {
add
}
最后:
require('./math')
返回的其实就是:module.exports
所以:
// math.js
module.exports = {
add
}
对应:
// app.js
const math = require('./math')
最终:
math === {
add
}
require的本质是“读取文件”
Node 会: 1、找到文件
./math.js
2、读取文件内容(Node从硬盘同步读取文件,速度很快,但是浏览器读取文件需要通过异步网络请求,等待时间很长,页面必须卡住等待,用户体验差。所以CommonJS天然更适合服务器)
const code = fs.readFileSync('./math.js')
3、执行这个文件
Node 内部大概会这样:
(function() {
// math.js 的代码
})()
注意: 每个模块都会被函数包裹
这就是为什么不会污染全局。
// math.js
var name = 'Tom'
因为其实变成了函数作用域:name 只在函数内部。
(function() {
var name = 'Tom'
})()
四、AMD / CMD:浏览器模块化探索(2010~2014)
CommonJS必须同步读取文件,但浏览器加载文件本质是 HTTP 请求。
网络请求是异步的,浏览器不能同步阻塞页面等待模块加载。那么浏览器必须使用“异步模块化”
AMD
核心思想一句话: “提前声明依赖”
例如:AMD会先分析依赖 math,再异步下载math.js,最后执行回调函数
define(['math'], function(math) {
console.log(math.add(1, 2))
})
先把依赖写好, 等依赖加载完成, 再执行回调函数
浏览器不能同步读取,于是 AMD 改成:“先异步下载模块,再执行代码”
但是AMD的依赖必须提前写,特别是大型项目,代码很复杂,于是就出现了CMD。
CMD
核心思想一句话:“依赖就近”
define(function(require, exports) {
const math = require('./math')
})
什么时候需要模块: 什么时候再 require
五、Webpack 时代(2014~2020)
虽然 AMD / CMD 解决了浏览器模块化问题。
但随着前端项目越来越大: 模块越来越多, 依赖越来越复杂
前端开始进入:工程化时代,于是: Webpack 出现了
Webpack 的核心思想一句话: “一切皆模块”
不仅 JavaScript:
- CSS
- 图片
- 字体
- Vue 文件
- React JSX
都可以作为模块处理。
Webpack 本质上只干了一件事: “提前帮浏览器处理所有依赖”,你可以把它理解成:“前端代码编译器”
第一步:分析依赖
Webpack 会先读取: app.js, 发现:
依赖:
- index.css
- logo.png
- math.js
于是形成 依赖图
app.js
├── index.css
├── logo.png
└── math.js
第二步:loader 转换
这是 Webpack 最核心的地方。
因为: 浏览器只认识 JS,所以:Webpack 要把一切转成 JS
例如: CSS
import './index.css'
Webpack 会变成:
const style = document.createElement('style')
style.innerHTML = '...css内容...'
document.head.appendChild(style)
于是:CSS 被转换成 JS
图片
import logo from './logo.png'
Webpack 会:把图片变成最终资源路径
const logo = '/assets/logo.xxxxx.png'
JSX
<App />
Webpack + Babel: 会变成:
React.createElement(App)
第三步:全部打包
最后Webpack 会:合并所有模块
生成:
bundle.js
浏览器只需要:
<script src="bundle.js"></script>
为什么“打包”这么重要?
因为:浏览器太弱了
当年浏览器:
- 不支持模块
- 不支持 JSX
- 不支持 TS
- 不支持 CSS import
于是:必须提前编译
六、ES Module 官方标准(2015)
前面的:
- CommonJS
- AMD
- CMD
虽然都实现了模块化。 但它们都有一个共同问题:都不是 JavaScript 官方标准,于是: 前端生态越来越混乱,模块规范不统一。
于是:ES6 正式推出了官方模块系统,也就是:ES Module(ESM)
核心语法:
export
import
ES Module 和 CommonJS 最大区别
CommonJS
const math = require('./math')
是:运行时加载 ,代码执行到这里时:Node 才去读取模块
ES Module
import { add } from './math.js'
是:编译时静态分析
也就是说:浏览器或构建工具在代码运行前,就已经知道依赖关系
这点非常重要。因为: 静态分析让现代工程优化成为可能
例如:
- Tree Shaking
- 按需加载
- 更快构建
- 更好的类型分析
都是建立在 ESM 基础上的。
浏览器现在会自动处理依赖,例如:
import { add } from './math.js'
浏览器会:
自动请求:
GET /math.js
所以:浏览器终于原生支持模块化
七、Vite 时代(2020~现在)
随着前端项目越来越大。,Webpack 开始暴露一个严重问题:太慢了
因为 Webpack 的开发模式是:
先扫描整个项目
分析所有依赖
再整体打包
最后启动开发服务器
项目小的时候问题不大。
但大型项目可能:
- 启动几十秒
- 热更新越来越慢
- 修改一个文件都要重新构建很多内容
开发体验非常差。
Vite 的核心思想: “开发环境不再整体打包”
以前: Webpack
启动项目
↓
打包整个项目
↓
浏览器运行
现在:Vite
浏览器请求哪个模块
Vite 就即时返回哪个模块
Vite 利用了浏览器原生 ESM
浏览器看到:
import App from './App.jsx'
会自动请求:
GET /App.jsx
然后:如果 App.jsx 又依赖:
import Button from './Button.jsx'
浏览器继续请求:
GET /Button.jsx
所以:浏览器自己开始负责模块加载,Vite 不需要像 Webpack 一样:提前打包整个项目,而是浏览器请求哪个模块 ,Vite 就即时编译哪个模块,即无打包开发服务器。
生产环境时执行:
vite build
Vite 仍然会:(Rollup)
- 打包
- 压缩
- Tree Shaking
- 代码分割
最终生成:
dist/
assets/
index.js
chunk.js
| Webpack | Vite |
|---|---|
| 开发前先整体打包 | 开发时按需加载 |
| 项目越大启动越慢 | 几乎秒启动 |
| JS 构建为主 | 基于浏览器原生 ESM |
| 配置复杂 | 配置更简单 |