主题
工程化
说明
共 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
| 维度 | Loader | Plugin |
|---|---|---|
| 作用 | 转换文件内容(A 格式 → B 格式) | 扩展构建流程(任意时机介入) |
| 输入输出 | 接收源文件内容,返回转换后的内容 | 通过 Hooks 订阅构建事件 |
| 配置位置 | module.rules | plugins |
| 执行时机 | 模块被加载时 | 整个构建生命周期任意阶段 |
| 粒度 | 单个文件级别 | 整个编译/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 中
compiler和compilation有什么区别? - 如何写一个 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'
// 构建工具静态分析 → 只打包 debouncesideEffects 字段
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-env的useBuiltIns: '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 几乎不变 → 浏览器强缓存 → 实际只下载 300KBSplitChunks 配置
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 依赖| 维度 | Monorepo | Polyrepo |
|---|---|---|
| 代码复用 | ✅ 直接 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 为什么快?幽灵依赖问题? ⭐⭐
理解包管理器的演进和原理。
考察点:包管理
三代包管理器对比
| 维度 | npm | yarn (classic) | pnpm |
|---|---|---|---|
| node_modules 结构 | 扁平化 | 扁平化 | 内容寻址存储 + 符号链接 |
| 安装速度 | 慢 | 中 | 快(硬链接复用) |
| 磁盘占用 | 大 | 大 | 小(全局存储 + 硬链接) |
| 幽灵依赖 | ✅ 有 | ✅ 有 | ❌ 无 |
| Lock 文件 | package-lock.json | yarn.lock | pnpm-lock.yaml |
| Workspace | npm workspaces | yarn workspaces | pnpm 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 安装完全相同的版本追问延伸
pnpm的shamefully-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-app | WebComponent | ✅ with 沙箱 | ✅ Shadow DOM | 自定义事件 | 轻量接入 |
| Wujie | iframe + 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 → 自动发布到 npmbash
# 初始化
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,开发时零配置联调追问延伸
peerDependencies和dependencies在组件库中应该怎么声明?- 如何处理 Monorepo 中多包联动发版?(Changesets 的
linked和fixed策略) - 发布了有 Bug 的版本怎么办?
npm deprecate和npm unpublish的区别?