Skip to content

工程化

说明

共 15 题,难度 ⭐ ~ ⭐⭐⭐,覆盖构建工具(Vite / Webpack)、代码质量、CI/CD、Monorepo、微前端、监控等前端工程化体系。

1. Vite 为什么比 Webpack 快?原理区别? ⭐⭐

对比两大构建工具的核心差异。

考察点:构建工具原理

根本区别

Webpack(Bundle-based):
  入口 → 分析所有依赖 → 构建完整依赖图 → 打包成 bundle → 启动 dev server
  → 项目越大,启动越慢(几十秒到几分钟)

Vite(Native ESM + esbuild):
  启动 dev server(几乎瞬间)→ 浏览器请求某个模块
  → Vite 按需编译该模块并返回 → 浏览器原生 ESM 加载
  → 不需要提前打包!

开发模式对比

Webpack 启动:
  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
  │ 扫描所有文件  │ →  │ 构建依赖图    │ →  │ 打包 Bundle  │ → 启动
  └─────────────┘    └─────────────┘    └─────────────┘
  耗时: O(全部模块数量)

Vite 启动:
  ┌─────────────┐    ┌───────────────────────┐
  │ 启动 Server  │ →  │ 浏览器请求什么编译什么    │
  └─────────────┘    └───────────────────────┘
  耗时: O(1) 启动 + O(请求的模块数)

依赖预构建 (Pre-bundling):
  Vite 用 esbuild 预编译 node_modules 中的依赖
  → esbuild 是 Go 写的,比 JS 写的打包器快 10-100 倍
  → 将 CJS 依赖转为 ESM
  → 合并小模块(如 lodash-es 有 600+ 个文件 → 合成 1 个)

HMR 速度对比

Webpack HMR:
  修改一个文件 → 重新构建受影响的 chunk
  → 项目越大,HMR 越慢

Vite HMR:
  修改一个文件 → 只失效该模块
  → 浏览器只重新请求该模块
  → 与项目大小无关,始终快速

生产构建

Vite 生产构建用的是 Rollup(不是 esbuild):
  - Rollup 的 Tree Shaking 更成熟
  - 生态插件更丰富
  - 代码分割更灵活

Vite 6+ 正在探索用 Rolldown(Rust 实现的 Rollup)替代:
  - 开发和生产使用同一打包器
  - 消除行为不一致问题

配置对比

typescript
// vite.config.ts — 简洁
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { '@': '/src' }
  },
  server: {
    proxy: { '/api': 'http://localhost:3000' }
  }
})

// webpack.config.js — 复杂
module.exports = {
  entry: './src/index.js',
  output: { path: path.resolve(__dirname, 'dist'), filename: '[name].[contenthash].js' },
  module: {
    rules: [
      { test: /\.tsx?$/, use: 'babel-loader' },
      { test: /\.css$/, use: ['style-loader', 'css-loader'] },
      { test: /\.(png|jpg)$/, type: 'asset' },
    ]
  },
  plugins: [new HtmlWebpackPlugin(), new MiniCssExtractPlugin()],
  resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
  devServer: { proxy: { '/api': 'http://localhost:3000' } }
}

追问延伸

  • Vite 的依赖预构建什么时候会重新执行?(node_modules 变化、lockfile 变化、vite.config 变化)
  • 为什么 Vite 生产构建不直接用 esbuild?(代码分割、CSS 处理不够成熟)
  • Turbopack 和 Vite 有什么区别?(Turbopack 是增量计算引擎,Rust 实现)

2. Webpack 的 Loader 和 Plugin 的区别?执行顺序? ⭐⭐

理解 Webpack 构建流程中的两大扩展机制。

考察点:Webpack 原理

Loader vs Plugin

维度LoaderPlugin
作用转换文件内容(A 格式 → B 格式)扩展构建流程(任意时机介入)
输入输出接收源文件内容,返回转换后的内容通过 Hooks 订阅构建事件
配置位置module.rulesplugins
执行时机模块被加载时整个构建生命周期任意阶段
粒度单个文件级别整个编译/chunk/asset 级别

Loader 执行顺序

配置:
  rules: [
    { test: /\.css$/, use: ['style-loader', 'css-loader', 'postcss-loader'] }
  ]

执行顺序: 从右到左、从下到上

  源文件 (.css)

  postcss-loader  (自动前缀、嵌套语法)

  css-loader      (处理 @import、url(),转为 JS 模块)

  style-loader    (将 CSS 注入 <style> 标签)

为什么从右到左?
  → 函数组合 compose: style(css(postcss(source)))
  → 管道式处理,每个 loader 只做一件事

Loader 分类

前置 Loader  (pre):    enforce: 'pre'   → 最先执行(如 eslint-loader)
普通 Loader  (normal): 默认             → 按配置顺序执行
行内 Loader  (inline): import 中指定     → import 'style-loader!./style.css'
后置 Loader  (post):   enforce: 'post'  → 最后执行

完整执行顺序: pre → normal → inline → post
每组内部: 从右到左 / 从下到上

手写一个简单 Loader

javascript
// markdown-loader.js
const { marked } = require('marked')

module.exports = function(source) {
  const html = marked(source)
  return `export default ${JSON.stringify(html)}`
}

// webpack.config.js
module.exports = {
  module: {
    rules: [
      { test: /\.md$/, use: './markdown-loader.js' }
    ]
  }
}

Plugin 机制

javascript
class MyPlugin {
  apply(compiler) {
    // compiler: 整个编译过程的核心对象

    compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
      // compilation: 本次编译的上下文
      // emit 钩子: 生成资源到 output 目录之前

      const assets = compilation.assets
      const fileList = Object.keys(assets).join('\n')

      compilation.assets['filelist.txt'] = {
        source: () => fileList,
        size: () => fileList.length
      }

      callback()
    })
  }
}

常用 Loader & Plugin

Loader:
  babel-loader        → ES6+ / JSX → ES5
  ts-loader           → TypeScript → JavaScript
  css-loader          → CSS → JS 模块
  style-loader        → CSS 注入 DOM
  postcss-loader      → CSS 后处理(自动前缀等)
  file-loader         → 文件 → URL(Webpack5 用 asset modules 替代)
  svg-loader          → SVG → React 组件

Plugin:
  HtmlWebpackPlugin    → 生成 HTML 并注入 bundle
  MiniCssExtractPlugin → 提取 CSS 到独立文件
  DefinePlugin         → 定义编译时常量
  CopyWebpackPlugin    → 复制静态文件
  BundleAnalyzerPlugin → 可视化分析 bundle 大小
  CompressionPlugin    → gzip/brotli 压缩

追问延伸

  • Loader 的 pitch 方法是什么?什么时候需要用?
  • Plugin 中 compilercompilation 有什么区别?
  • 如何写一个 Webpack Plugin 来自动上传 Source Map 到监控平台?

3. Webpack 的 HMR(热更新)原理? ⭐⭐⭐

解释 Hot Module Replacement 的完整实现原理。

考察点:HMR 机制

HMR 解决了什么

没有 HMR:
  修改代码 → 刷新页面 → 丢失应用状态(表单输入、滚动位置、Redux state)

有 HMR:
  修改代码 → 只替换变化的模块 → 保留应用状态 ✅

完整流程

┌──────────┐     ┌──────────────┐     ┌──────────┐
│  编辑器   │ →   │  Webpack Dev  │ →   │  浏览器   │
│  保存文件  │     │  Server      │     │  HMR     │
└──────────┘     └──────────────┘     │  Runtime  │
                                      └──────────┘

1. 文件修改
   开发者修改 src/App.tsx 并保存

2. Webpack 重新编译
   文件系统监听到变化 → 增量编译 → 生成:
   - 更新的模块代码 (hot-update.js)
   - 更新清单 (hot-update.json: 哪些 chunk 变了)

3. 通知浏览器
   Dev Server 通过 WebSocket 发送消息:
   { type: 'hash', data: 'abc123' }
   { type: 'ok' }

4. 浏览器拉取更新
   HMR Runtime 收到通知 →
   fetch('/main.abc123.hot-update.json')  → 获取变化清单
   fetch('/main.abc123.hot-update.js')    → 获取新模块代码

5. 模块替换
   新代码加载后 → 调用模块的 accept 回调 → 替换旧模块
   如果模块没有 accept 处理 → 向上冒泡 → 直到找到处理者或全量刷新

accept 回调

javascript
// React Fast Refresh / Vue HMR 底层原理
if (module.hot) {
  module.hot.accept('./App', () => {
    // 模块更新后的回调
    // React: 重新渲染组件,保留 state
    // CSS: 直接替换样式表
    const NextApp = require('./App').default
    render(<NextApp />)
  })
}

// CSS 天然支持 HMR(style-loader 内置了 accept)
// 修改 CSS → 直接替换 <style> 标签 → 不影响 JS 状态

Vite 的 HMR

Vite HMR 更简单:
  - 基于原生 ESM → 精确到单个模块
  - 不需要打包 → 直接发送新模块
  - WebSocket 通知 → import 新模块 → 替换

  // Vite HMR API
  if (import.meta.hot) {
    import.meta.hot.accept((newModule) => {
      // 新模块已加载
    })
  }

  React / Vue 的 Vite 插件已经自动处理了 HMR
  → 开发者通常不需要手写 HMR 代码

追问延伸

  • 为什么修改 index.html 需要全量刷新?(入口文件没有 HMR 处理)
  • React Fast Refresh 和旧的 React Hot Loader 有什么区别?
  • HMR 失败时的降级策略?(module.hot.decline → 拒绝更新 → 全量刷新)

4. Tree Shaking 的原理?为什么需要 ESM?sideEffects 字段? ⭐⭐⭐

深入理解死代码消除机制。

考察点:构建优化

什么是 Tree Shaking

Tree Shaking = 删除未使用的导出(Dead Code Elimination)

import { debounce } from 'lodash-es'
// 只用了 debounce → 其他 600+ 个函数不应打包进去

没有 Tree Shaking:
  bundle 包含整个 lodash (~70KB)

有 Tree Shaking:
  bundle 只包含 debounce + 它的依赖 (~2KB)

为什么必须是 ESM

ESM (ES Modules):
  import { a } from './module'
  export const a = 1
  → 静态结构:导入/导出在编译时确定
  → 构建工具可以在编译时分析出哪些导出被使用

CJS (CommonJS):
  const mod = require('./module')
  module.exports = { a: 1 }
  → 动态结构:require() 可以出现在 if/for 中
  → 导出的内容只有运行时才知道
  → 无法在编译时确定哪些被使用 → 无法 Tree Shake

// ❌ CJS 无法 Tree Shake
const { debounce } = require('lodash')
// 构建工具不知道 lodash 导出了什么 → 只能全量打包

// ✅ ESM 可以 Tree Shake
import { debounce } from 'lodash-es'
// 构建工具静态分析 → 只打包 debounce

sideEffects 字段

json
// package.json
{
  "name": "my-lib",
  "sideEffects": false
}

// sideEffects: false 意味着:
//   "这个包的所有模块都是纯净的(无副作用)"
//   如果某个模块的导出没被使用 → 可以安全删除整个模块

// 什么是副作用?
// 导入模块时,除了导出值之外还做了其他事:
import './polyfill'     // ← 副作用: 修改了全局对象
import './global.css'   // ← 副作用: 注入了样式

// sideEffects 可以指定哪些文件有副作用:
{
  "sideEffects": ["*.css", "*.scss", "./src/polyfill.ts"]
}
// → 这些文件即使没被直接使用也不会被删除
// → 其他文件如果导出未被使用就可以被 Tree Shake 掉

Tree Shaking 失效的常见原因

javascript
// ❌ 1. 使用了 CJS 的库
import lodash from 'lodash'  // CJS → 全量打包
// ✅ import { debounce } from 'lodash-es'  // ESM

// ❌ 2. 导出了一个对象
export default { a: 1, b: 2, c: 3 }
// 构建工具不知道 a/b/c 哪个被使用 → 全部保留
// ✅ export const a = 1; export const b = 2  // 具名导出

// ❌ 3. 类的方法无法 Tree Shake
export class Utils {
  static debounce() {}
  static throttle() {}
}
// 只用了 debounce → throttle 也被保留(类是一个整体)
// ✅ 导出独立函数

// ❌ 4. 副作用代码
export const a = 1
console.log('I am a side effect')  // 即使 a 没被用,这行也不能删
// ✅ 标记 sideEffects: false 或 /*#__PURE__*/ 注释
const result = /*#__PURE__*/ createSomething()

追问延伸

  • /*#__PURE__*/ 注释是给谁看的?(给 Terser/Rollup,标记纯函数调用可安全删除)
  • Webpack 的 usedExports 和 Rollup 的 Tree Shaking 有什么区别?
  • 如何验证 Tree Shaking 是否生效?(webpack-bundle-analyzer 可视化分析)

5. 如何编写一个 Babel 插件?AST 转换过程? ⭐⭐⭐

理解 Babel 的编译流程和插件机制。

考察点:Babel、AST

Babel 编译三阶段

源代码

  ↓  ① 解析 (Parse)
  │  @babel/parser
  │  源代码字符串 → AST(抽象语法树)

  ↓  ② 转换 (Transform)
  │  @babel/traverse
  │  遍历 AST → 插件修改节点 → 生成新 AST

  ↓  ③ 生成 (Generate)
  │  @babel/generator
  │  新 AST → 目标代码字符串 + Source Map

  目标代码

AST 结构示例

javascript
// 源代码
const greeting = 'hello'

// AST (简化)
{
  type: 'VariableDeclaration',
  kind: 'const',
  declarations: [{
    type: 'VariableDeclarator',
    id: { type: 'Identifier', name: 'greeting' },
    init: { type: 'StringLiteral', value: 'hello' }
  }]
}

// 工具: astexplorer.net → 在线查看任何代码的 AST

手写 Babel 插件

javascript
// babel-plugin-remove-console.js
// 功能: 移除所有 console.log 调用

module.exports = function({ types: t }) {
  return {
    name: 'remove-console',
    visitor: {
      CallExpression(path) {
        const { callee } = path.node

        if (
          t.isMemberExpression(callee) &&
          t.isIdentifier(callee.object, { name: 'console' }) &&
          t.isIdentifier(callee.property, { name: 'log' })
        ) {
          path.remove()
        }
      }
    }
  }
}

// 使用
// babel.config.js
module.exports = {
  plugins: ['./babel-plugin-remove-console.js']
}

// 输入: console.log('debug'); doSomething();
// 输出: doSomething();

更复杂的示例:自动埋点

javascript
// babel-plugin-auto-track.js
// 功能: 在所有函数入口自动插入埋点代码

module.exports = function({ types: t, template }) {
  const tracker = template.ast(`
    window.__tracker && window.__tracker.push({
      fn: FUNCTION_NAME,
      time: Date.now()
    })
  `)

  return {
    visitor: {
      'FunctionDeclaration|ArrowFunctionExpression|FunctionExpression'(path) {
        const name = path.node.id?.name || 'anonymous'

        const trackAST = tracker({
          FUNCTION_NAME: t.stringLiteral(name)
        })

        if (path.node.body.type === 'BlockStatement') {
          path.node.body.body.unshift(trackAST)
        }
      }
    }
  }
}

Babel 配置体系

Presets (预设) = 一组 Plugin 的集合

@babel/preset-env     → 根据目标环境自动选择需要的语法转换
@babel/preset-react   → JSX 转换
@babel/preset-typescript → TS 类型擦除

执行顺序:
  Plugins → 先于 Presets
  Plugins → 从左到右
  Presets → 从右到左

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: '> 0.25%, not dead',
      useBuiltIns: 'usage',    // 按需引入 polyfill
      corejs: 3
    }],
    '@babel/preset-typescript',
    '@babel/preset-react'
  ],
  plugins: [
    '@babel/plugin-proposal-decorators'
  ]
}

追问延伸

  • @babel/preset-envuseBuiltIns: 'usage''entry' 有什么区别?
  • SWC 和 Babel 的区别?(Rust 实现,快 20-70 倍,Vite/Next.js 已采用)
  • 如何用 Babel 实现可选链 ?. 的降级?(AST 转换为三元表达式)

6. SplitChunks 的配置策略?如何优化首屏加载? ⭐⭐

理解代码分割和 chunk 优化。

考察点:代码分割

为什么需要代码分割

不分割:
  app.js (2MB) → 用户首次加载必须下载全部 2MB

分割后:
  vendor.js   (500KB) → 第三方库(缓存命中率高)
  app.js      (300KB) → 业务逻辑入口
  dashboard.js (200KB) → 路由懒加载(访问时才下载)
  settings.js  (100KB) → 路由懒加载

效果:
  首屏只需加载 vendor.js + app.js = 800KB
  vendor.js 几乎不变 → 浏览器强缓存 → 实际只下载 300KB

SplitChunks 配置

javascript
// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20000,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      cacheGroups: {
        reactVendor: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
          name: 'react-vendor',
          priority: 20,
          chunks: 'all',
        },
        libs: {
          test: /[\\/]node_modules[\\/]/,
          name: 'libs',
          priority: 10,
          chunks: 'all',
        },
        commons: {
          minChunks: 2,
          name: 'commons',
          priority: 5,
          reuseExistingChunk: true,
        }
      }
    },
    runtimeChunk: 'single',
  }
}

Vite 的手动分包

typescript
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react') || id.includes('react-dom')) {
              return 'react-vendor'
            }
            if (id.includes('echarts')) {
              return 'echarts'
            }
            return 'vendor'
          }
        }
      }
    }
  }
})

路由级懒加载

typescript
// React
import { lazy, Suspense } from 'react'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import(
  /* webpackChunkName: "settings" */
  /* webpackPrefetch: true */
  './pages/Settings'
))

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  )
}

// Vue
const Dashboard = defineAsyncComponent(() => import('./pages/Dashboard.vue'))

首屏优化 Checklist

□ 路由级代码分割(首屏只加载当前路由)
□ 第三方库独立 chunk(利用浏览器缓存)
□ 关键 CSS 内联(消除 CSS 阻塞渲染)
□ 图片懒加载(loading="lazy")
□ 首屏数据 SSR/SSG(减少白屏时间)
□ 预加载下一页(<link rel="prefetch">)
□ 分析 bundle(webpack-bundle-analyzer / rollup-plugin-visualizer)
□ 删除未使用代码(Tree Shaking + sideEffects)

追问延伸

  • chunks: 'all'chunks: 'async' 的区别?
  • 如何避免"瀑布式"加载?(preload 关键 chunk)
  • 什么是 Module Federation?跨应用代码共享?

7. Monorepo 的优缺点?pnpm workspace vs Turborepo vs Nx? ⭐⭐

对比 Monorepo 工具链。

考察点:工程架构

Monorepo vs Polyrepo

Polyrepo (多仓库):
  repo-a/   → 组件库
  repo-b/   → 主应用
  repo-c/   → 工具库
  → 各自独立版本 / 独立 CI / 通过 npm 发包互相依赖

Monorepo (单仓库):
  project/
    packages/
      components/  → 组件库
      app/         → 主应用
      utils/       → 工具库
  → 统一版本管理 / 统一 CI / 内部 workspace 依赖
维度MonorepoPolyrepo
代码复用✅ 直接 import,实时联调❌ 发版后才能更新
原子化提交✅ 跨包改动一次提交❌ 需要多仓库协调
统一规范✅ 共享 ESLint/TS/CI 配置❌ 各仓库各自维护
构建速度🟡 需要增量构建工具✅ 独立构建,互不影响
仓库体积❌ 随包增多而膨胀✅ 各仓库独立,轻量
权限控制❌ 所有人看到所有代码✅ 按仓库分配权限

工具对比

工具定位包管理构建缓存任务编排
pnpm workspace包管理✅ 核心功能
Turborepo构建系统❌ (依赖 pnpm/npm)✅ 远程缓存✅ 并行
Nx全功能框架❌ (依赖 pnpm/npm)✅ 远程缓存✅ 依赖图
Lerna发版管理🟡 (v7+ 用 Nx)✅ (via Nx)🟡

pnpm workspace

yaml
# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

# 安装
pnpm add react --filter @my/app
pnpm add @my/utils --filter @my/app --workspace
# → @my/app 依赖 @my/utils,走 workspace 软链接

# 执行命令
pnpm run build --filter @my/utils
pnpm run build --filter ...@my/app  # 构建 app 及其所有依赖

Turborepo

json
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "test": {
      "dependsOn": ["build"]
    }
  }
}

// ^build 意味着: 先构建依赖包,再构建当前包
// 构建缓存: 如果输入没变 → 直接用缓存的 dist/ → 跳过构建
Turborepo 远程缓存:
  开发者 A 构建了 @my/utils → 结果缓存到远程
  开发者 B 构建 @my/utils → 命中远程缓存 → 直接下载结果
  CI 构建 → 也命中缓存 → 大幅加速

  本地: turbo run build → 1秒 (缓存命中)
  无缓存: turbo run build → 30秒

追问延伸

  • pnpm 的 --filter 语法有哪些?如何只构建变更的包?
  • Monorepo 中如何处理不同包的不同 TypeScript 配置?
  • 如何在 Monorepo 中实现增量 CI?(只测试受影响的包)

8. CI/CD 流水线如何设计?GitHub Actions 的核心概念? ⭐⭐

设计前端项目的持续集成/持续部署。

考察点:自动化、DevOps

CI/CD 是什么

CI (Continuous Integration) — 持续集成:
  每次提交代码 → 自动运行:
    ✅ 代码风格检查 (ESLint / Prettier)
    ✅ 类型检查 (TypeScript)
    ✅ 单元测试 (Vitest / Jest)
    ✅ 构建验证 (build 是否成功)
  → 尽早发现问题

CD (Continuous Deployment) — 持续部署:
  代码合入主分支 → 自动:
    ✅ 构建生产包
    ✅ 部署到预发环境
    ✅ E2E 测试
    ✅ 部署到生产环境
  → 自动化上线

GitHub Actions 核心概念

yaml
# .github/workflows/ci.yml

name: CI                          # Workflow 名称

on:                               # 触发条件
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:                             # 作业列表
  lint-and-test:                  # Job 名称
    runs-on: ubuntu-latest        # 运行环境

    strategy:
      matrix:                     # 矩阵策略(多版本并行)
        node-version: [18, 20]

    steps:                        # 步骤列表
      - uses: actions/checkout@v4     # 检出代码
      - uses: pnpm/action-setup@v4    # 安装 pnpm
        with:
          version: 9

      - uses: actions/setup-node@v4   # 安装 Node
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'               # 缓存依赖

      - run: pnpm install             # 安装依赖
      - run: pnpm lint                # Lint
      - run: pnpm typecheck           # 类型检查
      - run: pnpm test                # 测试
      - run: pnpm build               # 构建

完整的前端 CI/CD 流水线

PR 提交:
  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌──────────┐
  │  Lint   │→ │  Type   │→ │  Test   │→ │  Build   │
  │  Check  │  │  Check  │  │         │  │  Preview │
  └─────────┘  └─────────┘  └─────────┘  └──────────┘

                                     Vercel Preview URL
                                     → 评审人可直接预览

合入 main:
  ┌─────────┐  ┌─────────┐  ┌──────────┐  ┌──────────┐
  │  Build  │→ │  E2E    │→ │  Deploy  │→ │  Notify  │
  │  Prod   │  │  Test   │  │  Prod    │  │  Slack   │
  └─────────┘  └─────────┘  └──────────┘  └──────────┘

实用技巧

yaml
# 缓存 node_modules(加速 CI)
- uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}

# 只在特定文件变化时运行
on:
  push:
    paths:
      - 'packages/components/**'
      - 'package.json'

# 并行运行多个 Job
jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]
  test:
    runs-on: ubuntu-latest
    steps: [...]
  build:
    needs: [lint, test]     # 依赖 lint 和 test 通过
    runs-on: ubuntu-latest
    steps: [...]

追问延伸

  • GitHub Actions 和 GitLab CI 的区别?各自的优势?
  • 如何实现 Monorepo 的增量 CI?(只测试受影响的包)
  • 蓝绿部署 / 金丝雀发布 / 滚动更新的区别?

9. npm / pnpm / yarn 的区别?pnpm 为什么快?幽灵依赖问题? ⭐⭐

理解包管理器的演进和原理。

考察点:包管理

三代包管理器对比

维度npmyarn (classic)pnpm
node_modules 结构扁平化扁平化内容寻址存储 + 符号链接
安装速度(硬链接复用)
磁盘占用(全局存储 + 硬链接)
幽灵依赖✅ 有✅ 有❌ 无
Lock 文件package-lock.jsonyarn.lockpnpm-lock.yaml
Workspacenpm workspacesyarn workspacespnpm workspace
确定性✅ (lockfile)✅ (lockfile)✅ (lockfile)

幽灵依赖 (Phantom Dependencies)

npm/yarn 的扁平化 node_modules:

项目依赖: express
express 依赖: cookie, debug, ...

node_modules/
  express/
  cookie/        ← express 的依赖被提升到顶层
  debug/         ← express 的依赖被提升到顶层

// 你的代码:
import cookie from 'cookie'  // ✅ 居然能 import!
// 但 cookie 不在你的 package.json 中 → 幽灵依赖

风险:
  - express 升级后不再依赖 cookie → 你的代码就报错了
  - 不同版本的 express 依赖不同版本的 cookie → 行为不一致

pnpm 的解决方案

pnpm 的 node_modules 结构:

node_modules/
  .pnpm/                          ← 全局内容寻址存储的硬链接
    express@4.18.2/
      node_modules/
        express/                  ← 实际文件(硬链接到全局存储)
        cookie/                   ← express 的依赖在这里
        debug/
  express → .pnpm/express@4.18.2/node_modules/express  ← 符号链接

// 你的代码:
import cookie from 'cookie'  // ❌ 报错!
// cookie 不在顶层 node_modules → 无法直接访问
// → 强制你在 package.json 中声明所有直接依赖

pnpm 为什么快

1. 全局内容寻址存储 (~/.pnpm-store/)
   所有项目共享同一份包文件
   通过硬链接(不是复制)引用到 node_modules
   → 不同项目用同一个版本的 react → 磁盘上只存一份

2. 安装过程
   ① 解析依赖树
   ② 检查全局存储中是否已有该包
   ③ 有 → 直接硬链接(几乎零成本)
   ④ 没有 → 下载到全局存储 → 再硬链接

   10 个项目都用 react@18 → 磁盘上只有 1 份 react
   npm/yarn → 磁盘上有 10 份 react(每个 node_modules 一份)

版本管理

语义化版本 (SemVer): major.minor.patch
  ^1.2.3  → >=1.2.3 <2.0.0  (允许 minor 和 patch 更新)
  ~1.2.3  → >=1.2.3 <1.3.0  (只允许 patch 更新)
  1.2.3   → 精确版本

lockfile 的作用:
  package.json: "react": "^18.0.0"  → 范围
  lockfile: "react": "18.2.0"       → 精确版本 + 完整依赖树
  → 保证团队所有人和 CI 安装完全相同的版本

追问延伸

  • pnpmshamefully-hoist 是什么?什么时候需要用?(兼容不规范的包)
  • npm audit 和依赖安全扫描怎么集成到 CI?
  • Bun 的包管理器和 pnpm 有什么区别?

10. 什么是 Source Map?各种 devtool 模式的区别? ⭐⭐

理解 Source Map 在调试中的作用。

考察点:调试、构建

Source Map 是什么

问题:
  生产代码经过压缩/混淆/转译后,和源代码完全不同
  报错信息: "Error at app.min.js:1:2345" → 无法定位原始代码

Source Map:
  一个 .map 文件,记录了压缩代码和源代码之间的映射关系
  → 浏览器 DevTools 可以将错误定位到原始的 .ts/.tsx 文件

  app.min.js       → 压缩后的代码
  app.min.js.map   → 映射文件
  //# sourceMappingURL=app.min.js.map  → 关联声明

Source Map 内容

json
{
  "version": 3,
  "sources": ["../src/App.tsx", "../src/utils.ts"],
  "sourcesContent": ["原始源代码..."],
  "names": ["handleClick", "useState"],
  "mappings": "AAAA,SAAS,OAAO;AAChB...",
  "file": "app.min.js"
}

// mappings 字段使用 VLQ 编码
// 记录了: 生成代码的行列 ↔ 源代码的文件/行/列

Webpack devtool 模式

模式速度质量推荐场景
eval🚀🚀🚀 最快⭐ 无行映射开发(极速要求)
eval-source-map🚀🚀⭐⭐⭐ 高质量开发推荐
eval-cheap-module-source-map🚀🚀🚀⭐⭐ 行级映射开发(平衡速度和质量)
source-map🐌 最慢⭐⭐⭐ 最高生产(配合错误监控)
hidden-source-map🐌⭐⭐⭐生产(不暴露给浏览器)
nosources-source-map🐌⭐⭐ 仅位置生产(保护源码)
false生产(不需要调试)

各模式的关键字含义

eval:
  每个模块用 eval() 执行 → 速度快,但调试体验差

cheap:
  只映射到行(不映射到列)→ 速度更快
  cheap-module 时映射到 Loader 转换前的源码

module:
  映射到 Loader 处理前的源代码
  (如 babel-loader 前的 JSX,而不是编译后的 React.createElement)

source-map:
  独立的 .map 文件 → 质量最高,速度最慢

hidden:
  生成 .map 文件但不在 bundle 中添加 sourceMappingURL
  → 需要手动上传到监控平台

nosources:
  .map 文件中不包含源代码内容
  → 可以定位行号但看不到代码 → 保护源码

生产环境的 Source Map 策略

方案 1: hidden-source-map + 上传到监控平台
  → 用户看不到 Source Map
  → Sentry 等监控平台能还原错误堆栈
  → 安全 + 可调试

方案 2: 独立部署 Source Map
  → .map 文件部署在内网
  → 通过 Chrome DevTools 的 URL mapping 功能访问
  → 安全 + 可调试

方案 3: nosources-source-map
  → 只暴露行号,不暴露源码
  → 一定程度的调试能力 + 源码保护
typescript
// Vite 的 Source Map 配置
export default defineConfig({
  build: {
    sourcemap: true,           // 生成 source map
    // sourcemap: 'hidden',    // 不关联到 bundle
  }
})

// 上传到 Sentry
// sentry-cli sourcemaps upload --release=1.0.0 ./dist

追问延伸

  • Source Map 泄露有什么安全风险?(源码完全暴露)
  • 如何在 CI 中自动上传 Source Map 到 Sentry?
  • VLQ 编码是什么?为什么 Source Map 使用它?(Base64 VLQ,高效压缩映射数据)

11. 微前端是什么?qiankun / Module Federation / iframe 对比? ⭐⭐⭐

理解微前端的架构设计和技术选型。

考察点:微前端架构

什么是微前端

微前端 = 将前端应用拆分为多个独立子应用,各自独立开发/部署/运行

类比微服务:
  后端: 一个大服务 → 拆成多个微服务(用户服务、订单服务…)
  前端: 一个大 SPA → 拆成多个子应用(首页、商城、管理后台…)

适用场景:
  ① 巨型应用拆分(百万行代码级别)
  ② 多团队协作(各团队独立迭代)
  ③ 渐进式迁移(旧技术栈逐步替换为新技术栈)
  ④ 不同技术栈共存(React + Vue + Angular)

主流方案对比

方案原理JS 隔离CSS 隔离通信适用场景
iframe原生沙箱✅ 天然隔离✅ 天然隔离postMessage简单集成、安全要求高
qiankun运行时加载 HTML✅ Proxy 沙箱✅ Shadow DOM / Scoped全局状态成熟方案、国内主流
Module Federation构建时共享模块❌ 无隔离❌ 无隔离直接 import模块共享、同技术栈
Micro-appWebComponent✅ with 沙箱✅ Shadow DOM自定义事件轻量接入
Wujieiframe + WebComponent✅ iframe 沙箱✅ iframe 隔离Props/事件iframe 优化版
single-spa路由劫持❌ 需自行实现❌ 需自行实现自定义事件灵活、底层框架

qiankun 原理

qiankun (基于 single-spa 封装):

1. 主应用注册子应用
   registerMicroApps([{
     name: 'sub-app',
     entry: '//sub.example.com',  ← 子应用地址
     container: '#sub-container',
     activeRule: '/sub-app',       ← 路由匹配规则
   }])

2. 加载子应用
   → fetch 子应用的 HTML → 解析 <script> 和 <style>
   → 在沙箱环境中执行 JS

3. JS 沙箱(Proxy 实现)
   每个子应用有独立的 window 代理
   → 子应用修改 window.xxx → 实际修改的是代理对象
   → 子应用卸载时 → 代理对象销毁 → 全局状态恢复

4. CSS 隔离
   方案 A: Shadow DOM → 样式完全隔离
   方案 B: Scoped CSS → 自动给选择器加前缀
   方案 C: CSS Modules → 编译时隔离

Module Federation (Webpack 5)

javascript
// host 应用 (消费者)
new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    remoteApp: 'remoteApp@http://remote.example.com/remoteEntry.js'
  },
  shared: ['react', 'react-dom']
})

// remote 应用 (提供者)
new ModuleFederationPlugin({
  name: 'remoteApp',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/components/Button',
    './utils': './src/utils'
  },
  shared: ['react', 'react-dom']
})

// host 中使用 remote 的组件
const RemoteButton = React.lazy(() => import('remoteApp/Button'))
// → 运行时从 remote 服务器加载 Button 组件
// → react / react-dom 共享同一份(不重复加载)

iframe 的问题与优化

iframe 的问题:
  ❌ 弹窗无法居中(相对 iframe 而非页面)
  ❌ 路由不同步(刷新后丢失 iframe 路由状态)
  ❌ 通信复杂(只能用 postMessage)
  ❌ 性能开销(每个 iframe 独立上下文)
  ❌ SEO 不友好

Wujie 的优化:
  → 用 iframe 做 JS 沙箱(保留天然隔离优势)
  → 用 WebComponent 做 DOM 渲染(解决弹窗、白屏问题)
  → 路由同步到主应用 URL
  → 最佳实践的 iframe 微前端方案

追问延伸

  • 微前端的样式冲突如何彻底解决?(CSS Modules + Shadow DOM + BEM 约定)
  • 主子应用如何共享全局状态?(qiankun 的 initGlobalState / 发布订阅)
  • 微前端和 Monorepo 的关系?是否可以结合使用?

12. 前端监控怎么做?错误采集 / 性能采集 / 用户行为? ⭐⭐⭐

设计完整的前端监控体系。

考察点:监控体系

前端监控三大维度

┌─────────────────────────────────────────┐
│  错误监控                                │
│  JS 异常 / 资源加载失败 / API 错误       │
├─────────────────────────────────────────┤
│  性能监控                                │
│  Core Web Vitals / 资源加载 / API 耗时   │
├─────────────────────────────────────────┤
│  行为监控                                │
│  PV/UV / 点击热图 / 用户路径 / 录屏回放  │
└─────────────────────────────────────────┘

错误采集

javascript
// ① 全局 JS 错误
window.addEventListener('error', (event) => {
  report({
    type: 'js_error',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    stack: event.error?.stack,
  })
})

// ② Promise 未捕获的 rejection
window.addEventListener('unhandledrejection', (event) => {
  report({
    type: 'promise_error',
    reason: event.reason?.message || String(event.reason),
    stack: event.reason?.stack,
  })
})

// ③ 资源加载失败(img/script/css/font)
window.addEventListener('error', (event) => {
  const target = event.target
  if (target instanceof HTMLElement) {
    report({
      type: 'resource_error',
      tagName: target.tagName,
      src: target.src || target.href,
    })
  }
}, true)  // ← 捕获阶段!资源错误不冒泡

// ④ API 错误(拦截 fetch / XMLHttpRequest)
const originalFetch = window.fetch
window.fetch = async function(...args) {
  const start = Date.now()
  try {
    const response = await originalFetch.apply(this, args)
    report({
      type: 'api',
      url: args[0],
      status: response.status,
      duration: Date.now() - start,
      ok: response.ok,
    })
    return response
  } catch (error) {
    report({ type: 'api_error', url: args[0], error: error.message })
    throw error
  }
}

// ⑤ React 错误边界
class ErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    report({
      type: 'react_error',
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
    })
  }
}

性能采集

javascript
// ① Navigation Timing
const timing = performance.getEntriesByType('navigation')[0]
report({
  type: 'performance',
  dns: timing.domainLookupEnd - timing.domainLookupStart,
  tcp: timing.connectEnd - timing.connectStart,
  ttfb: timing.responseStart - timing.requestStart,
  domReady: timing.domContentLoadedEventEnd - timing.fetchStart,
  load: timing.loadEventEnd - timing.fetchStart,
})

// ② Core Web Vitals
import { onLCP, onINP, onCLS } from 'web-vitals'

onLCP((metric) => report({ type: 'cwv', name: 'LCP', value: metric.value }))
onINP((metric) => report({ type: 'cwv', name: 'INP', value: metric.value }))
onCLS((metric) => report({ type: 'cwv', name: 'CLS', value: metric.value }))

// ③ 长任务监控
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      report({
        type: 'long_task',
        duration: entry.duration,
        startTime: entry.startTime,
      })
    }
  }
})
observer.observe({ type: 'longtask', buffered: true })

数据上报策略

javascript
// ① 使用 navigator.sendBeacon(页面卸载时也能发送)
function report(data) {
  const body = JSON.stringify(data)
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/report', body)
  } else {
    fetch('/api/report', { method: 'POST', body, keepalive: true })
  }
}

// ② 批量上报 + 防抖(减少请求数)
const buffer = []
const flush = debounce(() => {
  if (buffer.length === 0) return
  navigator.sendBeacon('/api/report/batch', JSON.stringify(buffer))
  buffer.length = 0
}, 5000)

function report(data) {
  buffer.push({ ...data, timestamp: Date.now(), url: location.href })
  if (buffer.length >= 10) flush.flush()
  else flush()
}

// ③ 采样率控制(高流量场景)
function shouldReport() {
  return Math.random() < 0.1  // 10% 采样率
}

监控平台选型

平台类型特点
Sentry错误监控Source Map 还原、Issue 聚合、告警
Datadog RUM全链路APM + RUM + 日志、全栈关联
自建灵活可定制采集/清洗/分析/告警
Google Analytics行为分析免费、PV/UV/路径分析
百度统计 / 友盟国内行为国内合规、中文支持

追问延伸

  • 如何实现前端录屏回放?(rrweb 库:DOM 快照 + 增量变更记录)
  • 错误聚合怎么做?相同错误如何归类?(Stack Trace 指纹 + 采样)
  • 监控数据如何设置告警规则?(错误率突增 / P95 延迟超标 / CLS 劣化)

13. 如何设计一个组件库?构建 / 文档 / 测试 / 发布全流程? ⭐⭐⭐

从零设计一个企业级前端组件库。

考察点:组件库工程

组件库架构

my-ui/
├── packages/
│   ├── components/          ← 组件源码
│   │   ├── button/
│   │   │   ├── Button.tsx
│   │   │   ├── Button.test.tsx
│   │   │   ├── style.ts
│   │   │   └── index.ts
│   │   ├── input/
│   │   └── ...
│   ├── theme/               ← 主题/样式变量
│   ├── utils/               ← 公共工具
│   └── hooks/               ← 公共 Hooks
├── docs/                    ← 文档站(Storybook / VitePress)
├── scripts/                 ← 构建/发布脚本
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

构建策略

typescript
// 输出三种格式:
// ESM  → import { Button } from 'my-ui'   (Tree Shaking ✅)
// CJS  → const { Button } = require('my-ui')
// UMD  → <script src="my-ui.min.js">      (CDN 引入)

// package.json
{
  "name": "my-ui",
  "main": "dist/cjs/index.js",        // CJS 入口
  "module": "dist/esm/index.mjs",     // ESM 入口
  "types": "dist/types/index.d.ts",   // TypeScript 类型
  "exports": {
    ".": {
      "import": "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.js",
      "types": "./dist/types/index.d.ts"
    },
    "./button": {
      "import": "./dist/esm/button/index.mjs",
      "require": "./dist/cjs/button/index.js"
    }
  },
  "sideEffects": ["*.css"],
  "files": ["dist"]
}
构建工具选择:
  Rollup → 库的标准选择(输出干净、Tree Shaking 好)
  tsup   → 基于 esbuild 的零配置库构建工具
  unbuild → nuxt 团队出品,支持 stub 模式(开发时直接引用源码)
  Vite Library Mode → Vite 内置的库构建模式

CSS 方案

选项对比:
  CSS-in-JS (styled-components) → 运行时开销,SSR 复杂
  CSS Modules                   → 编译时隔离,零运行时
  Tailwind CSS                  → 原子化,体积小
  CSS 变量 + BEM                → 无依赖,主题定制方便 ← 推荐

推荐: CSS 变量 + 按需引入
  → 用户只引入 Button → 只加载 Button 的 CSS
  → 支持通过 CSS 变量覆盖主题
css
/* button/style.css */
.my-button {
  --btn-bg: var(--my-color-primary, #6366f1);
  --btn-height: var(--my-size-md, 36px);

  background: var(--btn-bg);
  height: var(--btn-height);
  border-radius: var(--my-radius-md, 6px);
}

/* 用户覆盖主题 */
:root {
  --my-color-primary: #10b981;
}

文档与测试

文档方案:
  Storybook  → 组件开发时的独立沙箱,支持交互调试
  VitePress  → 静态文档站,Markdown 写文档
  Docusaurus → React 生态文档站

测试策略:
  单元测试: Vitest + Testing Library
    → 测试组件渲染、Props、事件、状态
  视觉回归: Chromatic / Percy
    → 截图对比,发现 UI 变更
  可访问性: jest-axe
    → 自动检测 ARIA 属性、键盘导航

发布流程

1. 版本管理
   Changesets → 每个 PR 附带 changeset 描述
   → 自动聚合 → 生成 CHANGELOG → 更新版本号

2. 发布流水线
   PR 合入 → CI 构建 → 测试 → Changesets 生成版本 PR
   → 合入版本 PR → 自动 npm publish → 通知

3. 发布命令
   pnpm changeset         # 创建变更描述
   pnpm changeset version # 更新版本号 + CHANGELOG
   pnpm changeset publish # 发布到 npm

追问延伸

  • 按需加载怎么实现?babel-plugin-import 和 ES Module 的区别?
  • 组件库如何做主题定制?Design Token 是什么?
  • 如何保证组件库的 API 稳定性?Breaking Change 怎么管理?

14. Docker 在前端部署中的应用?Nginx 配置? ⭐⭐

前端项目的容器化部署方案。

考察点:部署

前端部署架构

开发者 Push → CI 构建 → Docker 镜像 → 容器编排 → 用户访问

┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│  GitHub  │ →  │  CI/CD   │ →  │  Docker  │ →  │  K8s /   │
│  Push    │    │  Build   │    │  Image   │    │  ECS     │
└──────────┘    └──────────┘    └──────────┘    └──────────┘


                                               ┌──────────┐
                                               │  CDN +   │
                                               │  Nginx   │
                                               └──────────┘

Dockerfile(多阶段构建)

dockerfile
# 阶段 1: 构建
FROM node:20-alpine AS builder
WORKDIR /app

RUN corepack enable && corepack prepare pnpm@9 --activate

COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

# 阶段 2: 运行(只保留构建产物)
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
多阶段构建的优势:
  builder 阶段: ~1GB(Node.js + node_modules + 源码)
  最终镜像:     ~30MB(Nginx + 静态文件)
  → 大幅减小镜像体积 → 更快的部署速度

Nginx 配置

nginx
# nginx.conf
server {
    listen 80;
    server_name example.com;
    root /usr/share/nginx/html;

    # ① Gzip 压缩
    gzip on;
    gzip_types text/plain text/css application/json
               application/javascript text/xml image/svg+xml;
    gzip_min_length 1000;

    # ② SPA 路由 — 所有路径回退到 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # ③ 静态资源长缓存(带 hash 的文件)
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # ④ HTML 不缓存
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
    }

    # ⑤ API 反向代理
    location /api/ {
        proxy_pass http://backend:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # ⑥ 安全头
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header X-XSS-Protection "1; mode=block";
    add_header Content-Security-Policy "default-src 'self'";
}

docker-compose

yaml
# docker-compose.yml
version: '3.8'
services:
  frontend:
    build: .
    ports:
      - "80:80"
    depends_on:
      - backend

  backend:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./server:/app
    command: node index.js
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://db:5432/myapp

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

部署方式对比

方式特点适用场景
Vercel / Netlify零配置、自动 CI/CD个人项目、小团队
Docker + K8s完全可控、可扩展中大型团队、企业
CDN + OSS静态资源直接上传纯前端项目
Serverless按需计费、自动扩缩API 路由、SSR

追问延伸

  • Docker 的 .dockerignore 应该忽略哪些文件?(node_modules / .git / dist)
  • Nginx 如何配置 HTTPS?(Let's Encrypt + certbot 自动续签)
  • K8s 的 Deployment / Service / Ingress 分别是什么?

15. 如何做 npm 包的版本管理?Changesets 怎么用? ⭐⭐

管理开源/内部 npm 包的版本和发布流程。

考察点:发包流程

语义化版本 (SemVer)

格式: MAJOR.MINOR.PATCH

  MAJOR (主版本): 不兼容的 API 变更(Breaking Change)
    1.0.0 → 2.0.0: 删除了某个 API / 改变了参数

  MINOR (次版本): 向后兼容的新功能
    1.0.0 → 1.1.0: 新增了一个组件

  PATCH (补丁版本): 向后兼容的 Bug 修复
    1.0.0 → 1.0.1: 修复了一个样式问题

预发布版本:
  1.0.0-alpha.1  → 内测
  1.0.0-beta.1   → 公测
  1.0.0-rc.1     → 发布候选

npm dist-tag:
  npm publish --tag beta    → 安装时 npm i pkg@beta
  npm publish --tag latest  → 默认安装(npm i pkg)

Changesets 工作流

Changesets = 自动化版本管理 + CHANGELOG 生成

流程:
  ① 开发者提 PR 时创建 changeset
  ② CI 自动检查是否有 changeset
  ③ PR 合入后,changesets bot 聚合所有变更 → 生成版本 PR
  ④ 合入版本 PR → 自动发布到 npm
bash
# 初始化
pnpm add -Dw @changesets/cli
pnpm changeset init

# 日常开发: 创建变更描述
pnpm changeset
# 交互式:
#   ? 选择受影响的包: @my/button, @my/input
#   ? 版本类型: patch / minor / major
#   ? 变更描述: Fix button hover style
# → 生成 .changeset/xxx.md

# 发版时: 聚合变更
pnpm changeset version
# → 更新所有受影响包的 package.json 版本号
# → 生成 CHANGELOG.md

# 发布
pnpm changeset publish
# → npm publish 所有版本变更的包

Changeset 文件格式

markdown
---
"@my/button": patch
"@my/theme": minor
---

修复 Button 组件在 hover 状态下的背景色问题,
同时更新主题包新增 `--hover-opacity` 变量。

GitHub Actions 自动发布

yaml
# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'

      - run: pnpm install --frozen-lockfile
      - run: pnpm build

      - name: Create Release PR or Publish
        uses: changesets/action@v1
        with:
          publish: pnpm changeset publish
          version: pnpm changeset version
          commit: 'chore: version packages'
          title: 'chore: version packages'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

发布前的检查清单

□ 所有测试通过
□ 构建成功(ESM / CJS / Types)
□ CHANGELOG 已更新
□ package.json 的 files 字段正确(只发布 dist)
□ types 字段指向正确的 .d.ts
□ peerDependencies 声明正确
□ exports 字段配置正确
□ npm pack 预览发布内容(检查没有多余文件)
□ 在本地 link 测试过(pnpm link / yalc)

本地测试未发布的包

bash
# 方案 1: pnpm link(本地软链接)
cd packages/button
pnpm link --global
cd ../my-app
pnpm link --global @my/button

# 方案 2: yalc(本地发布)
npx yalc publish         # 在包目录中"发布"到本地
npx yalc add @my/button  # 在项目中"安装"

# 方案 3: pnpm workspace
# Monorepo 内自动 link,开发时零配置联调

追问延伸

  • peerDependenciesdependencies 在组件库中应该怎么声明?
  • 如何处理 Monorepo 中多包联动发版?(Changesets 的 linkedfixed 策略)
  • 发布了有 Bug 的版本怎么办?npm deprecatenpm unpublish 的区别?

用心学习,用代码说话 💻