阶段 3:工程化体系 - 从"能跑"到"能搭"
工程化是架构师的硬通货。你不仅要会用,还要能从零搭一套体系。
一、Webpack 核心机制
1.1 Loader vs Plugin —— 面试必问
| 维度 | Loader | Plugin |
|---|---|---|
| 职责 | 转换模块内容(文件 → 模块) | 扩展构建流程(监听事件、修改输出) |
| 执行时机 | 模块加载时 | 整个构建生命周期 |
| 配置方式 | module.rules | plugins |
| 常见例子 | babel-loader、css-loader、file-loader | HtmlWebpackPlugin、MiniCssExtractPlugin |
核心区别一句话:
Loader 处理"怎么翻译文件",Plugin 处理"在什么时候做什么事"。
1.2 Loader 原理 —— 链式调用
// 一个简单的 scss-loader 原理
module.exports = function(source) {
// source 是上一个 loader 的输出(或原始文件内容)
const result = compileScssToCss(source)
// 必须返回 JS 模块代码
return `module.exports = ${JSON.stringify(result)}`
}
// 配置:loader 从右到左执行
// sass-loader → postcss-loader → css-loader → style-loader
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
}
手写一个简单的 loader:
// markdown-loader.js
const marked = require('marked')
module.exports = function(source) {
// 将 markdown 转为 HTML
const html = marked.parse(source)
// 返回 JS 模块
return `export default ${JSON.stringify(html)}`
}
1.3 Plugin 原理 —— 事件钩子
Webpack 基于 Tapable 实现插件系统,本质是发布订阅。
// 一个简单的插件:在打包完成后输出文件大小
class FileSizePlugin {
apply(compiler) {
// 监听 'emit' 钩子(生成资源到 output 目录之前)
compiler.hooks.emit.tapAsync('FileSizePlugin', (compilation, callback) => {
// compilation.assets 包含所有待输出的文件
Object.keys(compilation.assets).forEach(filename => {
const size = compilation.assets[filename].size()
console.log(`${filename}: ${(size / 1024).toFixed(2)} KB`)
})
callback()
})
}
}
// 使用
module.exports = {
plugins: [new FileSizePlugin()]
}
常见钩子执行顺序:
初始化 → beforeRun → run → beforeCompile → compile →
make(构建模块) → afterCompile →
emit(输出资源前) → afterEmit → done
二、Tree Shaking —— 干掉死代码
2.1 原理
基于 ES Module 的静态结构(import/export 必须在顶层,不能动态导入),通过 AST 分析哪些导出没有被使用,然后删除。
// math.js
export const add = (a, b) => a + b
export const multiply = (a, b) => a * b
// main.js
import { add } from './math'
console.log(add(1, 2))
// 打包后,multiply 被删除
2.2 必要条件
- 使用 ES Module(require 不行,因为 CommonJS 是动态的)
- package.json 中设置
"sideEffects": false - 生产模式(
mode: 'production',webpack 会自动启用)
// package.json
{
"sideEffects": [
"*.css", // 所有 CSS 文件都有副作用(不能删)
"*.scss"
]
}
2.3 如何排查 Tree Shaking 失败?
# 使用 webpack 分析工具
webpack --profile --json > stats.json
# 上传到 https://webpack.github.io/analyse/ 查看
三、代码分割(Code Splitting)
3.1 三种方式
| 方式 | 方法 | 适用场景 |
|---|---|---|
| 入口分割 | entry: { page1: './a.js', page2: './b.js' } | 多页应用 |
| 动态导入 | import('./module').then() | 路由懒加载、组件按需加载 |
| 公共库抽取 | SplitChunksPlugin | 提取 vendor、复用模块 |
3.2 SplitChunksPlugin 核心配置
module.exports = {
optimization: {
splitChunks: {
chunks: 'all', // async(只分割异步) / initial(同步+异步) / all
// 缓存组:定义打包规则
cacheGroups: {
// 1. 第三方库单独打包
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10, // 优先级
chunks: 'all'
},
// 2. 公共模块(被引用 >= 2 次)
common: {
name: 'common',
minChunks: 2,
minSize: 0,
priority: 5
},
// 3. React 全家桶单独打包
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
name: 'react-vendor',
chunks: 'all',
priority: 20
}
}
}
}
}
3.3 动态导入配合 React
// 路由懒加载
const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
// webpack 魔法注释
import(
/* webpackChunkName: "my-chunk" */
/* webpackPreload: true */
'./module'
)
四、构建优化实战
4.1 速度优化
// webpack.config.js
module.exports = {
// 1. 减少 resolve 范围
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
alias: {
'@': path.resolve(__dirname, 'src')
},
modules: [path.resolve(__dirname, 'node_modules')]
},
// 2. 使用缓存
cache: {
type: 'filesystem', // 持久化缓存
buildDependencies: {
config: [__filename] // 配置变化时失效
}
},
// 3. 多进程/多实例
module: {
rules: [
{
test: /\.js$/,
use: [
'thread-loader', // 多进程处理
'babel-loader'
]
}
]
},
// 4. 开发环境禁用不必要的插件
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false
}
}
4.2 体积优化
module.exports = {
optimization: {
// 1. 启用 Tree Shaking
usedExports: true,
// 2. 压缩代码
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: {
drop_console: true, // 删除 console
drop_debugger: true
}
}
}),
new CssMinimizerPlugin()
]
},
plugins: [
// 3. 提取 CSS 到单独文件
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css'
}),
// 4. 打包体积分析
new BundleAnalyzerPlugin(),
// 5. 替换环境变量
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production')
})
]
}
五、Vite —— 为什么比 Webpack 快?
5.1 核心区别
| 对比维度 | Webpack | Vite |
|---|---|---|
| 开发模式 | 打包所有模块(Bundle) | 直接启动服务器(No Bundle) |
| 更新方式 | 重新打包 | 基于 ESM 的 HMR |
| 生产构建 | 打包 + 优化 | Rollup 打包 |
| 冷启动速度 | 慢(需要分析所有模块) | 快(按需编译) |
5.2 Vite 原理
// 开发服务器:利用浏览器原生 ESM
// 浏览器请求 http://localhost:3000/src/main.js
// Vite 拦截请求,实时编译 .vue / .tsx 等文件
// 简单实现
const Koa = require('koa')
const fs = require('fs')
const path = require('path')
const app = new Koa()
app.use(async (ctx) => {
const url = ctx.request.url
if (url === '/') {
ctx.type = 'text/html'
ctx.body = fs.readFileSync('./index.html', 'utf-8')
} else if (url.endsWith('.js')) {
const filePath = path.join(process.cwd(), url)
let content = fs.readFileSync(filePath, 'utf-8')
// 重写 node_modules 导入路径
content = content.replace(/from ['"](?!\.\/)/g, (match) => {
return `from '/@modules/`
})
ctx.type = 'application/javascript'
ctx.body = content
}
})
app.listen(3000)
六、Monorepo 体系搭建
6.1 选型对比
| 工具 | 特点 | 适用场景 |
|---|---|---|
| pnpm workspace | 硬链接省磁盘、严格依赖隔离 | 推荐首选 |
| yarn workspace | 成熟稳定 | 团队熟悉 yarn |
| Turborepo | 智能缓存、并行执行 | 大型项目 |
| Lerna | 包版本管理(已整合到 nx) | 历史项目 |
6.2 完整 Monorepo 搭建(pnpm + turborepo)
目录结构:
my-monorepo/
├── apps/
│ ├── web/ # React 前端
│ ├── admin/ # 后台管理
│ └── docs/ # 文档站点
├── packages/
│ ├── ui/ # 公共组件库
│ ├── utils/ # 工具函数
│ ├── config/ # 配置文件(eslint、tsconfig)
│ └── types/ # 公共类型定义
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
Step 1: pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
Step 2: 根目录 package.json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"clean": "turbo run clean",
"changeset": "changeset",
"version": "changeset version",
"release": "pnpm build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.26.0",
"turbo": "^1.10.0",
"typescript": "^5.0.0"
},
"packageManager": "pnpm@8.6.0",
"engines": {
"node": ">=18"
}
}
Step 3: turbo.json(任务编排)
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"], // 先构建依赖的包
"outputs": ["dist/**", ".next/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_*"]
},
"dev": {
"cache": false, // 开发模式不缓存
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"clean": {
"cache": false
}
}
}
Step 4: packages/ui/package.json
{
"name": "@my/ui",
"version": "0.0.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./button": {
"import": "./dist/button/index.mjs",
"require": "./dist/button/index.js",
"types": "./dist/button/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@my/config": "workspace:*",
"react": "^18.2.0",
"typescript": "^5.0.0"
}
}
Step 5: apps/web/package.json
{
"name": "@my/web",
"version": "0.0.1",
"private": true,
"dependencies": {
"@my/ui": "workspace:*",
"@my/utils": "workspace:*"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}
6.3 常见操作命令
# 在根目录安装公共依赖
pnpm add -D typescript -w
# 在特定包安装依赖
pnpm add react --filter @my/web
# 包之间互相引用(自动建立软链接)
# 在 packages/ui 中:pnpm build
# 在 apps/web 中自动使用最新版本
# 在所有包中执行命令
pnpm run build --filter "./packages/*"
# 清理所有 node_modules
pnpm -r exec rm -rf node_modules
rm -rf node_modules
6.4 版本管理(Changesets)
# 安装
pnpm add -D @changesets/cli -w
pnpm changeset init
# 使用流程
pnpm changeset # 1. 选择要发布的包,填写 changelog
pnpm changeset version # 2. 自动升级版本号并生成 CHANGELOG
pnpm build # 3. 构建
pnpm changeset publish # 4. 发布到 npm
七、面试总结
当被问到 "搭建 Monorepo" 时:
"我会选择 pnpm workspace + Turborepo 的方案。pnpm 用硬链接节省磁盘,依赖安装极快,同时严格隔离依赖提升安全性;Turborepo 提供智能缓存和任务编排,可以避免重复构建。目录结构分
apps(应用)和packages(共享包),通过 workspace 协议管理内部依赖。配合 Changesets 做版本管理和发布,CI 中配置缓存恢复,可以实现分钟级的构建流程。"
常见追问:
| 追问 | 回答要点 |
|---|---|
| "pnpm 为什么比 npm/yarn 快?" | 内容可寻址存储 + 硬链接,相同包只存一份 |
| "遇到循环依赖怎么办?" | 检查架构设计,提取公共模块到单独包 |
| "怎么处理 ESLint/TS 配置统一?" | 抽成 @my/config 包,各项目引用并扩展 |
| "怎么做构建缓存?" | Turbo 根据源码 + 环境变量 + 依赖生成 hash,匹配则跳过 |