阶段 3:工程化体系 - 从"能跑"到"能搭"

7 阅读4分钟

阶段 3:工程化体系 - 从"能跑"到"能搭"

工程化是架构师的硬通货。你不仅要会用,还要能从零搭一套体系


一、Webpack 核心机制

1.1 Loader vs Plugin —— 面试必问

维度LoaderPlugin
职责转换模块内容(文件 → 模块)扩展构建流程(监听事件、修改输出)
执行时机模块加载时整个构建生命周期
配置方式module.rulesplugins
常见例子babel-loader、css-loader、file-loaderHtmlWebpackPlugin、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 必要条件

  1. 使用 ES Module(require 不行,因为 CommonJS 是动态的)
  2. package.json 中设置 "sideEffects": false
  3. 生产模式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 核心区别

对比维度WebpackVite
开发模式打包所有模块(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,匹配则跳过