构建工具热门面试题
使用说明
本篇共 60 道题。
构建工具题重点是模块图、开发/生产差异、产物和性能。回答不要只比较“谁快”,应说明快在哪里、牺牲什么以及如何验证。
Q1: 前端构建工具主要解决什么问题?
答案:
它从入口解析模块依赖,转换浏览器暂不直接支持的语法和资源,做代码分割、优化与产物管理,并提供开发服务器、HMR 和诊断。
构建工具还承载环境变量、兼容目标和插件生态,因此选型不只是打包速度。
- 开发阶段要解决语言转换、模块解析、HMR、类型检查和调试;生产阶段还要做依赖图优化、代码分割、压缩、缓存和兼容输出。
- 面试时最好强调构建工具不是“把文件合在一起”,而是在源码、浏览器能力与部署环境之间建立可重复的交付流水线。
Q2: Webpack 的核心构建流程是什么?
答案:
Webpack 的构建流程分为三大阶段:
1. 初始化阶段
- 合并 CLI 参数、配置文件(
webpack.config.ts)和默认配置 - 创建 Compiler 对象(全局唯一),代表完整的 Webpack 环境
- 遍历
plugins数组,调用每个 Plugin 的apply(compiler)方法注册钩子 - 注入 Webpack 内置插件(
EntryPlugin、ChunkPlugin等)
2. 构建阶段(Make)
- 从
entry配置的入口文件出发 - 调用匹配的 Loader 链对模块进行转译(如 TS -> JS)
- 使用 acorn 将转译后的代码解析为 AST(抽象语法树)
- 遍历 AST,找出
import、require等依赖声明 - 对每个依赖递归执行上述流程,最终构建出完整的模块依赖图(ModuleGraph)
3. 生成阶段(Seal)
- 根据依赖图和配置将模块组织成 Chunk(Entry Chunk、Async Chunk)
- 执行优化操作:Tree Shaking、Scope Hoisting、代码分割(splitChunks)
- 为每个 Chunk 生成代码,注入 Webpack 运行时代码(
__webpack_require__等) - 将最终的 Bundle 文件写入磁盘(
output.path目录)
// 核心钩子触发顺序
compiler.hooks.beforeRun // 准备运行
compiler.hooks.run // 开始运行
compiler.hooks.compile // 创建 Compilation 前
compiler.hooks.compilation // 创建 Compilation 后
compiler.hooks.make // 开始构建(从入口出发)
compilation.hooks.seal // 构建完成,开始生成
compiler.hooks.emit // 输出文件前(最后修改资源的机会)
compiler.hooks.done // 构建完成
提到 Tapable 钩子系统 是整个流程的调度机制,Webpack 本身和所有 Plugin 都是通过订阅钩子来介入构建流程的。
Q3: Loader 和 Plugin 有什么区别?
答案:
核心区别:Loader 用于转换文件,Plugin 用于扩展功能。
| 维度 | Loader | Plugin |
|---|---|---|
| 职责 | 将非 JS 文件转换为 Webpack 能处理的模块 | 在构建流程的特定时机执行更广泛的任务 |
| 本质 | 一个导出为函数的模块 | 一个包含 apply 方法的类 |
| 作用范围 | 仅作用于匹配的文件 | 可以影响整个构建流程 |
| 配置 | module.rules | plugins |
| 执行时机 | 模块加载阶段 | 贯穿整个构建生命周期 |
Loader 代码示例:
// Loader 是一个函数,接收源代码,返回转换结果
import { marked } from 'marked';
export default function markdownLoader(source: string): string {
const html = marked(source);
// 返回 JS 模块代码
return `export default ${JSON.stringify(html)}`;
}
Plugin 代码示例:
import type { Compiler } from 'webpack';
// Plugin 是一个类,通过 apply 方法注册钩子
class BuildInfoPlugin {
apply(compiler: Compiler): void {
compiler.hooks.done.tap('BuildInfoPlugin', (stats) => {
const { time, assets } = stats.toJson({ assets: true });
console.log(`构建耗时: ${time}ms`);
console.log(`输出文件: ${assets?.length} 个`);
});
}
}
export default BuildInfoPlugin;
Loader 是翻译官(把一种语言翻译成另一种),Plugin 是项目经理(在项目的各个阶段介入并执行特定任务)。
Q4: Webpack 的 HMR 大致如何工作?
答案:
HMR(Hot Module Replacement)能够在不刷新整个页面的情况下,对运行中的应用进行模块级别的替换、添加或删除,同时保留应用状态(如表单输入、滚动位置等)。
完整通信流程分为 5 个步骤:
1. 文件监听
Webpack Dev Server 内部使用 chokidar 监听文件系统的变化。当源代码文件被修改并保存时,触发 Webpack 的增量编译。
2. 增量编译
Webpack 不会重新编译所有模块,而是只对变化的模块及其受影响的依赖进行重新编译。编译完成后生成两个关键文件:
// 更新清单(Manifest)—— 记录哪些 Chunk 需要更新
// 文件名格式:[hash].hot-update.json
const manifest = {
c: { main: true }, // 需要更新的 chunk
r: [], // 需要移除的 chunk
m: [], // 需要移除的模块
};
// 更新模块代码 —— 包含变更模块的新代码
// 文件名格式:[chunkId].[hash].hot-update.js
self["webpackHotUpdate"]("main", {
"./src/render.ts": (module, exports, __webpack_require__) => {
// 新的模块代码
}
});
3. WebSocket 通知
Webpack Dev Server 和浏览器之间在页面加载时就已建立了 WebSocket 长连接。编译完成后,服务端通过 WebSocket 向浏览器推送新的 hash 值:
// 服务端推送
{ type: 'hash', data: 'abc123def456' }
{ type: 'ok' } // 编译成功
4. 浏览器端拉取更新
浏览器端注入的 HMR Runtime 收到 WebSocket 消息后,主动向服务端发起 HTTP 请求,拉取更新清单和更新后的模块代码:
// 1. 收到新 hash → 请求 manifest
const manifest = await fetch(`/${newHash}.hot-update.json`);
// 2. 根据 manifest 请求变更的 chunk
for (const chunkId of Object.keys(manifest.c)) {
await loadScript(`/${chunkId}.${newHash}.hot-update.js`);
}
// 3. 用新模块代码替换内部模块缓存 __webpack_modules__
// 4. 执行 module.hot.accept() 中注册的回调
5. 模块替换与冒泡机制
在实际开发中,React 和 Vue 框架通过各自的 HMR 方案自动处理模块替换,开发者不需要手动写 module.hot.accept():
- React:使用
react-refresh+@pmmmwh/react-refresh-webpack-plugin,能在保留组件状态的前提下热替换组件 - Vue:
vue-loader内置了 HMR 支持,组件的<template>、<script>、<style>各部分变更时分别处理
Q5: Vite 的 Module Graph 在开发和 HMR 中有什么作用?
答案:
Module Graph 记录开发服务器已处理模块之间的导入、被导入关系,以及对应 URL、转换结果和 HMR 边界。它让 Vite 在文件变化后沿依赖关系向上传播更新,找到能够接收更新的边界,而不是无条件刷新整页。
它还帮助开发服务器失效受影响模块的转换缓存。若一个模块没有可接受的 HMR 边界,更新会继续向导入方传播;传播到入口仍无法处理时,才回退到完整刷新。
排查“改了文件却没有热更新”时,应重点检查模块是否进入图、插件是否正确声明依赖、框架插件能否建立稳定边界,以及条件导入或虚拟模块是否让依赖关系缺失。
Q6: Vite 开发和生产为什么使用不同思路?
答案:
开发强调按需处理、低延迟反馈和精准 HMR;生产强调完整依赖分析、压缩、分块、内容哈希与长期缓存。
截至 Vite 8,底层构建能力已经统一到 Rolldown/Oxc 工具链,但开发服务与生产输出的目标仍然不同;“底层工具统一”不等于两个阶段执行完全相同的流水线。
- 开发环境优先启动速度、按需转换和精准 HMR,超大型项目还可评估实验性的 bundled dev。
- 生产环境更关心请求数量、兼容性、Tree Shaking、代码分割和可缓存产物,需要对完整图进行优化。
- 插件钩子、环境变量和动态导入在两个阶段仍可能表现不同,所以必须分别测试 dev 与 build。
Q7: Tree Shaking 的前提是什么?
答案:
Tree Shaking 的原理是基于 ES Module 的静态结构进行分析。整个过程分为两个阶段:
- 标记阶段:从入口文件出发,递归分析所有
import语句,构建模块依赖图。对每个模块的每个export,检查是否被其他模块import,未被引用的标记为"unused" - 删除阶段:在压缩阶段(Terser/SWC),将标记为"unused"的代码从产物中移除
必须使用 ESM 的原因:
ESM 的 import/export 是静态声明,具有以下约束:
- 必须在模块顶层,不能在条件语句中
- 模块路径必须是字符串字面量,不能是变量
- 导入的绑定是只读的
这些约束使构建工具可以在编译时确定模块依赖关系。而 CJS 的 require() 是一个运行时函数调用,参数可以是变量、可以在条件语句中调用,构建工具无法在编译时确定依赖关系:
// ESM — 编译时就能确定依赖
import { add } from './math';
// CJS — 运行时才能确定
const modulePath = getModulePath(); // 动态计算路径
const math = require(modulePath); // 无法静态分析
Q8: 代码分割有哪些常见方式?
答案:
常见是动态 import() 的路由/功能分割、多个入口,以及构建器提取共享依赖。目标是让首屏只加载必要代码,并让稳定依赖获得长期缓存。
过度拆分会产生网络瀑布、运行时开销和加载失败处理,应按用户路径和缓存收益设计。
- 入口拆分适合多页面,动态
import()适合路由或低频功能,splitChunks/manualChunks用于提取稳定公共依赖。 - 拆分后要分析重复模块、瀑布请求和缓存命中;公共 chunk 过大或频繁变化,反而会让用户每次更新都重新下载。
Q9: Chunk 名称中的 hash 有什么区别?
答案:
整体构建 hash 会因任意变化影响广;chunk hash 与分块相关;content hash 更贴近单个资产内容,适合长期缓存。
真正缓存稳定还依赖确定性的模块 ID、运行时代码拆分和稳定分块,否则改一行仍可能让大量文件 hash 变化。
Q10: Source Map 是怎么工作的?
答案:
Source Map 保存生成代码位置到原始源码位置的映射,让调试器和错误平台还原文件、行列与符号。
生产可上传私有 Source Map 到监控平台而不公开源码;构建时间、文件体积、列级精度和源码泄露风险需要权衡。
- Source Map 用生成代码的位置映射回原源码的文件、行列和符号名,调试器或监控平台据此还原堆栈。
- 生产环境通常上传私有 Source Map 到监控平台而不公开访问,并确保 Map 与具体发布版本一一对应,避免错误符号化。
Q11: Babel 的核心流程是什么?
答案:
解析源码生成 AST,插件遍历并转换 AST,最后重新生成代码和映射。Preset 是一组插件/配置的集合。
语法转换不等于补齐运行时 API;Promise、Array 新方法等还需按目标环境引入 polyfill,并避免全局污染或重复注入。
Q12: PostCSS 和 Sass 有什么区别?
答案:
PostCSS 是一个用 JavaScript 转换 CSS 的工具,它本身不提供任何转换功能,所有功能都通过插件实现。它的工作流程是:将 CSS 解析为 AST → 插件遍历和修改 AST → 将 AST 序列化回 CSS。因此,PostCSS 经常被称为 "CSS 界的 Babel"。
与 Sass/Less 的核心区别:
| 维度 | PostCSS | Sass/Less |
|---|---|---|
| 定位 | CSS 转换工具平台 | CSS 预处理语言 |
| 语法 | 标准 CSS 语法 | 自定义语法(.scss/.less) |
| 能力来源 | 外部插件(按需安装) | 语言内置(变量、mixin、函数等) |
| 可扩展性 | 高(任何人可写插件) | 低(受限于语言设计) |
| 典型用途 | 自动前缀、未来 CSS 降级、压缩 | 变量、嵌套、模块化编写样式 |
在实际项目中,两者通常配合使用而非互斥:Sass 负责编写阶段的语法增强,PostCSS 负责编译后的 CSS 优化和兼容处理。
// Webpack 中 Sass + PostCSS 的配置
const cssRules = {
test: /\.scss$/,
use: [
'style-loader',
'css-loader',
'postcss-loader', // 第二步:PostCSS 处理(加前缀、压缩等)
'sass-loader', // 第一步:Sass 编译为标准 CSS
],
};
Q13: Rollup 为什么常用于库打包?
答案:
Rollup 以 ESM 和静态分析为核心,产物结构和 Tree Shaking 适合库,并能输出 ESM、CJS 等格式。
库打包还要正确 externalize peer dependencies、生成类型声明、配置 exports 和验证 Node/浏览器消费场景。
Q14: esbuild 和 SWC 为什么通常比传统 JavaScript 编译器快?
答案:
二者都使用原生编译语言实现,并围绕并行解析、紧凑数据结构、减少中间转换和一次性完成常见任务做了工程优化,因此在 TS/JSX 转译、压缩等场景通常比纯 JavaScript 工具快。
但“快”不等于能力完全等价:
- esbuild 强项是快速打包、转译和压缩,插件与深度语义转换能力有边界。
- SWC 是 Rust 编写的编译平台,常用于转译、压缩和框架编译能力。
- Babel 的生态和定制 AST 插件仍更成熟,复杂语义转换不能只看基准速度替换。
- TypeScript 转译快不代表已经做类型检查,通常仍需单独运行
tsc --noEmit。
选型应比较真实项目的兼容性、插件、Source Map、产物和冷/热构建,而不是引用“快几十倍”的固定宣传数字。
Q15: Rspack 等 Rust 构建工具的迁移价值和风险是什么?
答案:
价值是尽量兼容 Webpack 配置和生态的同时提升构建性能;风险在于长尾 Plugin/Loader 行为、诊断、产物差异和团队运维成熟度。
迁移应选择真实项目基准,比较构建、HMR、内存、产物与功能测试,保留回滚,不只跑一个 Hello World。
Q16: Module Federation 解决什么问题?
答案:
Module Federation(模块联邦)是 Webpack 5 引入的一项核心特性,它允许多个独立构建、独立部署的应用在运行时动态共享 JavaScript 模块。
解决的核心问题:
- 跨应用代码共享:传统方式(npm 包发布)需要构建时安装依赖、应用重新打包部署。Module Federation 让应用在运行时直接加载另一个应用暴露的模块,实现即时共享
- 依赖重复加载:多个微前端子应用可能各自打包了一份 React、lodash 等公共库。Module Federation 的
shared机制让多个应用在运行时共享同一份依赖 - 独立部署的模块更新:组件库更新后,所有消费方应用自动获取最新版本,无需重新构建和部署
核心概念:
- Remote(提供者):通过
exposes暴露模块 - Host(消费者):通过
remotes声明并动态加载远程模块 - Shared:声明共享依赖,运行时自动版本协商,避免重复加载
// Remote 端:暴露模块
exposes: { './Button': './src/components/Button' }
// Host 端:消费模块(使用方式和本地模块一样)
const Button = React.lazy(() => import('remoteApp/Button'));
// Shared:共享 React,全局只加载一份
shared: { react: { singleton: true } }
Q17: 如何系统优化 Webpack 构建速度?
答案:
Webpack 构建速度优化可以从以下几个维度入手:
1. 缩小构建范围:
- 通过
include/exclude限制 loader 的处理范围,只编译src目录 - 使用
noParse跳过不需要解析依赖的大型库(jQuery、lodash UMD) - 优化
resolve.extensions、resolve.modules,减少文件查找耗时
2. 利用缓存:
- Webpack 5 启用
cache.type: 'filesystem'持久化缓存(最推荐) babel-loader设置cacheDirectory: true- Webpack 4 使用
hard-source-webpack-plugin
3. 多线程构建:
- 使用
thread-loader对耗时 loader(babel-loader、ts-loader)开启多线程
4. DLL 预编译(适用于 Webpack 4):
DllPlugin+DllReferencePlugin将稳定的第三方库预编译- Webpack 5 中用持久化缓存替代
5. 减少不必要的插件:
- 开发环境移除
TerserPlugin、CssMinimizerPlugin - 开发环境使用
eval-cheap-module-source-map代替source-map
6. 使用更快的工具链:
- 用
esbuild-loader替代babel-loader(编译速度快 10~100 倍) - 用
swc-loader替代ts-loader
import { EsbuildPlugin } from 'esbuild-loader';
const config: Configuration = {
module: {
rules: [{
test: /\.tsx?$/,
loader: 'esbuild-loader',
options: { target: 'es2020' },
}],
},
optimization: {
minimizer: [new EsbuildPlugin({ target: 'es2020' })],
},
};
Q18: Webpack 迁移 Vite 应关注什么?
答案:
迁移不是把配置名逐项翻译,首先要盘点两套工具的运行模型和隐式约定:
- 入口与 HTML:Vite 把 HTML 视为入口,公开目录、环境变量和资源 URL 规则不同。
- 模块兼容:检查 CommonJS、动态 require、Node Polyfill、路径别名和 monorepo 链接包。
- 插件迁移:Loader/Plugin 不一定有一一对应项,应确认是 Vite 插件、框架插件还是代码本身需要改造。
- 环境变量:客户端变量暴露规则变化,Secret 仍不能进入前端包。
- 产物与部署:重新验证分包、CSS、base 路径、旧浏览器、SSR、Source Map 和 CDN 缓存。
- 当前工具链:Vite 8 已使用 Rolldown/Oxc 工具链,不能继续按早期“开发 esbuild、生产 Rollup”的固定描述做判断。
- 验收:对比冷启动、HMR、构建时间、产物、功能回归和线上指标,并保留回滚路径。
Q19: 环境变量为什么不能直接放前端 Secrets?
答案:
构建时注入到客户端代码的变量最终会出现在可下载产物中,前缀约定只是暴露规则,不是加密。
前端只能持有公开配置。私密 API Key 留在服务端,通过鉴权代理和最小权限调用。
- 被注入前端 Bundle 的环境变量最终都能被用户下载或在 DevTools 中看到,命名为
SECRET也不会增加安全性。 - 前端只能放公开配置;真正的 API Key、数据库凭据必须留在服务端,通过受权限控制的接口代理访问,并配置轮换与配额。
Q20: 如何设计一个库的构建产物?
答案:
先明确消费环境,再决定 ESM/CJS、浏览器目标、类型声明、CSS/资源和是否保留源码映射;用 package exports 明确公开入口。
把运行时依赖与 peer dependency 分清,避免重复框架;在真实消费项目测试 Tree Shaking、SSR、Node 和类型解析。
Q21: Webpack 持久化缓存为什么会失效?
答案:
Webpack 5 的 filesystem cache 会根据配置、构建依赖、模块内容和环境生成缓存键。配置文件、Loader/Plugin 版本、锁文件、环境变量或被声明为 build dependency 的文件变化,都可能让相关缓存失效。
常见问题是自定义 Loader/Plugin 读取了外部文件或环境,却没有把它们声明为依赖,导致缓存错误复用;另一种是把频繁变化的无关信息放进配置,导致每次全量失效。
排查时看缓存日志和命中范围,确保构建逻辑确定、依赖完整、不同 CI 环境的 Node/工具版本一致。不要为了“命中率”缓存不安全的非确定产物。
Q22: Vite 为什么通常有较快的开发启动和 HMR?
答案:
Vite 的经典开发模式把依赖和业务源码分开处理:依赖预构建并缓存,源码通过原生 ESM 按浏览器请求进行转换,不需要启动前先生成完整业务 Bundle;HMR 也围绕受影响的模块边界传播。
截至 Vite 8,依赖优化和生产构建已统一到 Rolldown/Oxc 工具链,不应继续回答成“依赖一定由 esbuild、生产一定由 Rollup”。超大型项目还可能受浏览器模块请求数量影响,因此 Vite 也提供实验性的 bundled dev 方向。
速度最终取决于插件、框架编译、模块数量、monorepo 解析和缓存。面试回答应区分冷启动、页面首次加载、HMR 与生产构建,不要笼统说“Vite 所有阶段都更快”。
Q23: Webpack 中 Module、Chunk 和 Asset 有什么区别?
答案:
- Module:Webpack 依赖图中的基本节点,通常来自一个源文件或 Loader 处理结果。
- Chunk:构建过程中按入口、动态导入和分包规则组织的一组 Module,是代码生成与加载关系的中间单位。
- Asset:最终写入输出目录的文件,例如 JavaScript、CSS、图片和 Source Map。
一个 Chunk 可能生成多个 Asset,一个 Module 也可能因运行时、目标或拆分策略出现在不同 Chunk 关系中。分析体积时要先确认工具报告的是原始 Module、Chunk 还是压缩后的 Asset,否则很容易把概念混在一起。
Q24: package.json 中的 sideEffects 应该怎样配置?
答案:
sideEffects 告诉打包器哪些模块即使导出未被使用,执行本身也可能产生效果。包内模块都可安全删除时可写 false;存在全局 CSS、Polyfill、注册代码等副作用时,应使用文件模式保留:
{
"sideEffects": ["**/*.css", "./src/polyfills.ts"]
}
错误写成 false 可能让生产包丢失样式或初始化逻辑。它不是说函数一定纯,也不能替代压缩器的表达式级 DCE。库作者应让入口尽量无副作用,并在真实消费者构建中测试按需导入。
Q25: Webpack 中代码分割有哪几种方式?各自适用场景?
答案:
Webpack 提供三种代码分割方式:
1. 入口起点(Entry Points)
通过配置多个 entry 来生成多个 bundle:
entry: {
app: './src/index.ts',
admin: './src/admin.ts',
}
- 适用场景:多页应用(MPA),每个页面有独立入口
- 缺点:无法处理重复依赖,需配合
splitChunks使用
2. 动态导入(Dynamic Imports)
使用 import() 语法按需加载模块:
// 路由懒加载
const Home = lazy(() => import('./pages/Home'));
// 条件加载
if (needChart) {
const { Chart } = await import('chart.js');
}
- 适用场景:路由懒加载、条件加载、大型组件延迟加载
- 优点:最灵活,与框架的懒加载机制无缝配合
3. SplitChunks 插件
Webpack 内置插件,自动提取公共模块:
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: { test: /node_modules/, priority: 10 },
commons: { minChunks: 2, priority: 5 },
},
},
}
- 适用场景:提取公共依赖、第三方库分包、优化缓存
- 优点:自动化程度高,可细粒度控制分包规则
在实际项目中,三种方式通常组合使用:
- 用入口起点区分应用和管理后台
- 用动态导入实现路由懒加载
- 用 splitChunks 抽取公共模块和第三方库
Q26: 如何编写一个自定义 Webpack Loader?
答案:
编写一个自定义 Loader 需要遵循以下步骤:
第一步:创建 Loader 函数
import { validate } from 'schema-utils';
import type { LoaderDefinitionFunction } from 'webpack';
import type { Schema } from 'schema-utils/declarations/validate';
// 1. 定义 options 的 JSON Schema
const schema: Schema = {
type: 'object',
properties: {
methods: {
type: 'array',
items: { type: 'string' },
description: '要移除的 console 方法列表',
},
},
additionalProperties: false,
};
interface RemoveConsoleOptions {
methods?: string[];
}
// 2. 编写 Loader 函数(不能用箭头函数!)
const removeConsoleLoader: LoaderDefinitionFunction = function (source) {
// 3. 获取并校验 options
const options = this.getOptions() as RemoveConsoleOptions;
validate(schema, options, { name: 'RemoveConsoleLoader' });
// 4. 标记为可缓存
this.cacheable(true);
const methods = options.methods ?? ['log', 'debug', 'info'];
// 5. 执行转换
let result = source as string;
for (const method of methods) {
const regex = new RegExp(`console\\.${method}\\s*\\([^)]*\\);?`, 'g');
result = result.replace(regex, '');
}
// 6. 返回结果
return result;
};
export default removeConsoleLoader;
第二步:在 Webpack 配置中使用
import path from 'path';
import type { Configuration } from 'webpack';
const config: Configuration = {
module: {
rules: [
{
test: /\.ts$/,
use: [
'ts-loader',
{
loader: path.resolve(__dirname, 'loaders/remove-console-loader.ts'),
options: {
methods: ['log', 'debug'],
},
},
],
},
],
},
// 也可以通过 resolveLoader 简化路径
resolveLoader: {
modules: ['node_modules', path.resolve(__dirname, 'loaders')],
},
};
第三步:编写测试
import compiler from './compiler'; // 使用 webpack 测试辅助
describe('remove-console-loader', () => {
it('应该移除 console.log 语句', async () => {
const input = `
const a = 1;
console.log('debug info');
console.error('error');
export default a;
`;
const stats = await compiler('test-entry.ts', {
methods: ['log'],
});
const output = stats.toJson({ source: true }).modules?.[0]?.source;
expect(output).not.toContain('console.log');
expect(output).toContain('console.error'); // error 不应被移除
});
});
- 单一职责:每个 Loader 只做一件事
- 链式调用:通过管道组合多个 Loader
- 无状态:不要在 Loader 中保存状态
- 使用
this.getOptions()+schema-utils校验参数 - 合理使用缓存(
this.cacheable(true))
Q27: Rollup 和 Webpack 的区别是什么?各自适用什么场景?
答案:
两者都能处理模块图、代码分割和插件,但历史侧重点不同:
- Rollup 以 ESM、库产物和可组合插件接口见长,常用于 npm 库和工具链底层。
- Webpack 长期面向复杂 Web 应用,Loader/Plugin 生态、资源处理和存量工程能力成熟。
- 现代版本的能力大量重叠,产物质量和速度更取决于配置、插件与项目形态,不能简单说 Rollup 一定更小、Webpack 一定更慢。
应用通常优先选择更上层的 Vite、框架 CLI 或现有工程工具;库则比较 ESM/CJS 输出、类型、exports 和消费者 Tree Shaking。Vite 8 的生产构建已使用 Rolldown/Oxc,不应再描述为“固定由 Rollup 打包”。
迁移前用真实插件、动态导入、CSS、SSR、Source Map 和产物做 PoC,而不是只比较空项目。
Q28: 为什么构建很快,TypeScript 类型检查仍可能很慢?
答案:
esbuild、SWC、Oxc 等工具可以只做语法解析和转译,删除类型语法后快速生成 JavaScript;完整的 TypeScript 类型检查则要建立程序图、解析声明文件,并在模块间进行类型推导,两者不是同一项工作。
很多现代开发链路会拆分职责:
- 开发服务器负责快速转译和 HMR。
- 编辑器语言服务提供即时类型反馈。
- CI 或独立进程运行
tsc --noEmit,保证全项目类型正确。 - 大型仓库可用 Project References、增量构建、合理的
include/exclude和声明边界降低检查范围。
页面能运行不代表类型检查已经通过,也不应在每次请求转换中强行做全量类型检查。
Q29: 不同 Source Map 模式应该怎样选择?
答案:
选择要在重建速度、映射精度、产物暴露和调试体验之间取舍:
- 开发环境可使用基于 eval 或 cheap 的模式提高重建速度,但要确认是否需要列级映射和 Loader Source Map。
- 生产环境通常生成完整或 hidden Source Map,上传到错误监控平台并与 Release、资源 URL 对齐,不直接公开。
nosources可以隐藏源码内容,但仍要评估映射信息和平台能力。- 所有 Loader/编译器必须正确串联 Source Map,否则最终位置会偏移。
不能只背 Webpack 的 devtool 名称;关键是说明谁生成、谁消费、如何关联发布版本,以及源码是否对公网可访问。
Q30: Module Federation 的共享依赖为什么容易出现版本冲突?
答案:
Host 和 Remote 会把共享依赖放入 share scope,并根据提供版本、requiredVersion、singleton、加载时机等规则协商使用哪份实现。
React 这类要求单实例的库通常配置 singleton,但这并不自动保证兼容:某个 Remote 需要不兼容版本时,可能产生告警、使用错误版本或回退到自己的副本。设置 eager 还会改变加载和体积,不是通用修复。
治理方式是统一版本策略和运行时契约、在 CI 做 Host/Remote 组合测试、对远程发布保留兼容窗口和回滚,并监控 remoteEntry 与共享模块加载失败。
Q31: Vite 为什么要做依赖预构建?optimizeDeps 何时需要调整?
答案:
开发服务器以 ESM 提供模块。依赖预构建主要解决两件事:
- 把 CommonJS/UMD 依赖转换为开发环境可消费的 ESM。
- 把包含大量内部 ESM 文件的依赖合并,减少浏览器请求数量。
Vite 会自动扫描和缓存,大多数项目不需要配置。动态导入、插件生成入口、monorepo 链接包或扫描不到的依赖可能需要 optimizeDeps.include/exclude。
Vite 8 的预构建使用 Rolldown,旧的 optimizeDeps.esbuildOptions 仅保留兼容并已走向弃用,应迁移到 Rolldown 对应配置。修改前先观察重复重优化和整页 Reload 的日志,不要把整个依赖目录全部 include。
Q32: Autoprefixer 如何决定添加哪些浏览器前缀?
答案:
Autoprefixer 读取 Browserslist 目标和 Can I Use 数据,根据目标浏览器真正需要的兼容语法添加或移除前缀。它不是把所有可能前缀都写一遍,也不负责把任意现代 CSS 降级成旧浏览器等价实现。
因此配置重点是维护符合产品用户分布的 Browserslist,并定期更新兼容数据。过宽目标会增加产物和测试成本,过窄会造成真实用户样式失效。
对于 Grid 等存在历史实现差异的能力,还要检查插件选项和实际行为;新语法若需要结构转换,应使用对应 PostCSS 插件或提供渐进增强。
Q33: @babel/preset-env、语法转换和 Polyfill 是什么关系?
答案:
preset-env 根据目标环境选择需要的语法转换插件,例如把部分新语法改写为旧语法;但 Promise、Map、URL 等运行时 API 不能只靠语法改写,需要 Polyfill。
结合 core-js 时可按 useBuiltIns 和目标浏览器注入,但应用与库策略不同:
- 应用可按入口或使用情况引入运行时补丁。
- 公共库通常避免污染消费者全局,需评估 transform-runtime 或明确兼容要求。
- Browserslist、Babel、TypeScript target 和打包器目标必须协调。
不要同时由多套工具重复转译或注入 Polyfill,也不要把“编译通过”误认为运行环境已有对应 API。
Q34: Rspack 是什么?和 Webpack 有什么关系?
答案:
Rspack 是由字节跳动开源的、使用 Rust 编写的高性能 JavaScript 打包工具。它与 Webpack 的关系可以概括为:Rspack 是 Webpack 的 Rust 高性能替代品,兼容大部分 Webpack API。
核心关系:
- API 兼容:Rspack 实现了大部分 Webpack 配置选项(entry、output、module.rules、resolve、optimization 等),配置文件结构几乎一致
- 生态兼容:支持大部分 Webpack Loader(如 css-loader、less-loader),部分 Plugin 也可直接使用
- 理念一致:采用相同的 Bundle 模式,支持相同的代码分割策略(splitChunks)
- 性能飞跃:由于 Rust 的性能优势,Rspack 的构建速度比 Webpack 快 10-20 倍
关键区别:
// Webpack:需要 babel-loader 编译 TS
{ test: /\.tsx?$/, use: 'babel-loader' }
// Rspack:内置 SWC 编译,无需外部 Loader
{ test: /\.tsx?$/, use: 'builtin:swc-loader' }
// Webpack:需要 css-loader + style-loader
{ test: /\.css$/, use: ['style-loader', 'css-loader'] }
// Rspack:内置 CSS 处理
{ test: /\.css$/, type: 'css' }
Rspack 不是 Webpack 的 fork(分叉),而是从零用 Rust 实现的全新工具,只是在 API 层面保持了 Webpack 兼容。这使得现有 Webpack 项目可以低成本迁移,同时获得数量级的性能提升。
Q35: Webpack Loader 的 Pitch 阶段有什么作用?
答案:
Loader 链先从左到右执行 pitch,再从右到左执行普通转换。Pitch Loader 可以接收前后剩余请求,并在返回内容时短路后续 pitch 与普通加载流程,直接进入前面 Loader 的普通阶段。
它常用于注入运行时代码、拆分资源请求或实现缓存/代理逻辑。由于执行顺序容易混淆,业务 Loader 应尽量保持单一职责,并通过小型用例验证:
- pitch 与 normal 的实际顺序;
this.data在两个阶段间共享的数据;- 同步、异步回调和 Source Map;
- 缓存和依赖声明。
Q36: Vite 的虚拟模块是什么?插件怎样实现它?
答案:
虚拟模块是不一定对应磁盘文件、由插件动态提供内容的模块,常用于注入路由表、编译元数据或框架运行时代码。调用方可以像普通模块一样导入,例如 virtual:routes。
典型插件流程:
resolveId捕获公开模块名,返回带\0前缀的内部 id,避免被其他解析逻辑当成文件路径。load识别内部 id,返回生成的 JavaScript 和必要的 Source Map。- 若内容依赖外部文件,应把文件加入监听依赖,并在变化时正确失效模块或触发 HMR。
生成内容必须可预测、可缓存,不能把 Secrets 注入客户端模块;还要分别验证 SSR、开发和生产阶段。
Q37: 如何减小 Webpack 打包产物体积?
答案:
产物体积优化可以从以下方面入手:
| 策略 | 方法 | 效果 |
|---|---|---|
| 压缩 | TerserPlugin + CssMinimizerPlugin | 减小 30%~50% |
| Gzip/Brotli | CompressionPlugin | 再减小 60%~80% |
| Tree Shaking | sideEffects + ESM | 移除未使用代码 |
| 代码分割 | splitChunks + 动态 import | 按需加载 |
| Scope Hoisting | ModuleConcatenationPlugin | 减少模块包装 |
| 图片压缩 | image-minimizer-webpack-plugin | 图片体积减小 50%+ |
| 外部化 | externals + CDN | 大库不打入 bundle |
关键代码示例 -- externals 外部化大库:
const config: Configuration = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
lodash: '_',
},
};
<!-- 从 CDN 加载 -->
<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
关键代码示例 -- 动态 import 实现按需加载:
import { lazy, Suspense } from 'react';
// 路由级代码分割
const Home = lazy(() => import(/* webpackChunkName: "home" */ './pages/Home'));
const About = lazy(() => import(/* webpackChunkName: "about" */ './pages/About'));
const Dashboard = lazy(
() => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
{/* 路由组件 */}
</Suspense>
);
}
Q38: 什么情况下 Tree Shaking 会失效?如何解决?
答案:
Tree Shaking 失效的常见场景及解决方案:
| 失效场景 | 原因 | 解决方案 |
|---|---|---|
| 使用 CJS 模块 | require() 是动态的 | 使用 ESM 版本(如 lodash-es) |
| Babel 转换 ESM 为 CJS | @babel/preset-env 默认转换模块 | 设置 "modules": false |
| 模块有副作用 | 构建工具不敢移除有副作用的代码 | 配置 sideEffects 字段 |
| 类方法无法移除 | 方法挂在原型链上,无法单独移除 | 改用独立函数导出 |
import * 命名空间导入 | 部分工具无法分析属性访问 | 使用具名导入 import { fn } |
| 函数调用有副作用 | 构建工具无法确定函数是否纯净 | 使用 /*#__PURE__*/ 标注 |
一个综合的最佳实践配置:
{
"sideEffects": ["*.css", "*.scss"]
}
{
"presets": [["@babel/preset-env", { "modules": false }]]
}
import type { Configuration } from 'webpack';
const config: Configuration = {
mode: 'production',
optimization: {
usedExports: true,
minimize: true,
concatenateModules: true,
},
};
Q39: splitChunks 的配置策略是怎样的?如何设计合理的分包方案?
答案:
合理的分包方案需要平衡缓存效率和请求数量两个因素。
核心参数解读:
| 参数 | 作用 | 推荐值 |
|---|---|---|
chunks | 分割范围 | 'all'(同步+异步) |
minSize | 最小 chunk 体积 | 20000(20KB) |
maxSize | 最大 chunk 体积 | 250000(250KB) |
minChunks | 被引用次数阈值 | 1(vendors)/ 2(commons) |
priority | cacheGroup 优先级 | 数值越大优先级越高 |
推荐的分包方案:
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxSize: 250000,
cacheGroups: {
// 第一层:框架核心(极少变化,长期缓存)
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|vue)[\\/]/,
name: 'framework',
priority: 40,
enforce: true,
},
// 第二层:UI 库(较少变化)
ui: {
test: /[\\/]node_modules[\\/](antd|@ant-design|element-plus)[\\/]/,
name: 'ui-lib',
priority: 30,
},
// 第三层:其他第三方库
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 20,
},
// 第四层:业务公共代码
commons: {
minChunks: 2,
name: 'commons',
priority: 10,
reuseExistingChunk: true,
},
},
},
}
设计原则:
- 按变更频率分层:变化越少的代码越适合独立为 chunk,利用浏览器缓存
- 控制 chunk 数量:初始加载的 chunk 建议不超过 5 个,避免过多并行请求
- 设置合理的体积范围:单个 chunk 建议在 20KB ~ 250KB 之间
- 优先级层级清晰:确保更精确的 cacheGroup 优先级高于宽泛的匹配规则
Q40: 如何编写一个自定义 Webpack Plugin?
答案:
编写 Plugin 需要理解 Tapable 钩子系统,并选择合适的钩子注册回调。
完整示例:构建耗时统计插件
import type { Compiler, Stats } from 'webpack';
interface BuildTimePluginOptions {
/** 是否输出详细的各阶段耗时 */
verbose?: boolean;
/** 耗时超过此阈值(ms)则警告 */
warnThreshold?: number;
/** 自定义输出回调 */
onComplete?: (data: BuildTimeData) => void;
}
interface BuildTimeData {
totalTime: number;
phases: Record<string, number>;
timestamp: string;
}
class BuildTimePlugin {
private options: Required<BuildTimePluginOptions>;
private startTime: number = 0;
private phases: Map<string, number> = new Map();
constructor(options: BuildTimePluginOptions = {}) {
this.options = {
verbose: options.verbose ?? false,
warnThreshold: options.warnThreshold ?? 10000,
onComplete: options.onComplete ?? (() => {}),
};
}
apply(compiler: Compiler): void {
const pluginName = BuildTimePlugin.name;
// 编译开始
compiler.hooks.compile.tap(pluginName, () => {
this.startTime = Date.now();
this.phases.clear();
this.phases.set('compile_start', this.startTime);
});
// 模块构建完成
compiler.hooks.afterCompile.tap(pluginName, () => {
this.phases.set('after_compile', Date.now());
});
// 资源输出
compiler.hooks.emit.tapAsync(pluginName, (_compilation, callback) => {
this.phases.set('emit', Date.now());
callback();
});
// 构建完成
compiler.hooks.done.tap(pluginName, (stats: Stats) => {
const endTime = Date.now();
const totalTime = endTime - this.startTime;
// 计算各阶段耗时
const phaseTimings: Record<string, number> = {};
const entries = Array.from(this.phases.entries());
for (let i = 1; i < entries.length; i++) {
const phaseName = entries[i][0];
phaseTimings[phaseName] = entries[i][1] - entries[i - 1][1];
}
// 输出统计
console.log(`\n⏱ 构建总耗时:${totalTime}ms`);
if (this.options.verbose) {
console.log('各阶段耗时:');
for (const [phase, time] of Object.entries(phaseTimings)) {
console.log(` ${phase}: ${time}ms`);
}
}
if (totalTime > this.options.warnThreshold) {
console.warn(
`⚠️ 构建耗时 ${totalTime}ms 超过阈值 ${this.options.warnThreshold}ms`
);
}
// 触发回调
this.options.onComplete({
totalTime,
phases: phaseTimings,
timestamp: new Date().toISOString(),
});
});
}
}
export default BuildTimePlugin;
使用方式:
import BuildTimePlugin from './plugins/BuildTimePlugin';
export default {
plugins: [
new BuildTimePlugin({
verbose: true,
warnThreshold: 5000,
onComplete: (data) => {
// 可以将数据上报到监控系统
console.log('Build data:', JSON.stringify(data));
},
}),
],
};
Plugin 开发核心步骤总结:
- 创建一个类,定义
apply(compiler: Compiler)方法 - 在
apply中通过compiler.hooks.xxx.tap/tapAsync/tapPromise注册钩子 - 如果需要操作模块/资源,通过
compiler.hooks.compilation获取compilation对象 - 在回调中执行自定义逻辑,注意异步钩子需要调用
callback()或返回Promise
Q41: 如何使用 Rollup 打包一个 npm 库?(完整流程)
答案:
以打包一个 React 工具库为例,完整流程如下:
第一步:初始化项目并安装依赖
- npm
- Yarn
- pnpm
- Bun
npm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-terser rollup-plugin-dts typescript -D
yarn add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-terser rollup-plugin-dts typescript --dev
pnpm add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-terser rollup-plugin-dts typescript -D
bun add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-terser rollup-plugin-dts typescript --dev
第二步:配置 tsconfig.json
{
"compilerOptions": {
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "Node",
"declaration": true,
"declarationDir": "dist/types",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
第三步:编写库源码
export { useDebounce } from './hooks/useDebounce'
export { useThrottle } from './hooks/useThrottle'
export { formatDate } from './utils/date'
export type { DebounceOptions, ThrottleOptions } from './types'
第四步:配置 rollup.config.ts
import { defineConfig } from 'rollup'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import typescript from '@rollup/plugin-typescript'
import { terser } from 'rollup-plugin-terser'
import dts from 'rollup-plugin-dts'
import { readFileSync } from 'fs'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
const external = [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
/^react(\/.*)?$/, // 匹配 react 及其子路径
]
export default defineConfig([
// 主构建
{
input: 'src/index.ts',
output: [
{ file: 'dist/index.esm.js', format: 'esm', sourcemap: true },
{ file: 'dist/index.cjs.js', format: 'cjs', sourcemap: true, exports: 'named' },
],
external,
plugins: [
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.json', declaration: false }),
terser(),
],
},
// 类型声明
{
input: 'src/index.ts',
output: { file: 'dist/index.d.ts', format: 'esm' },
plugins: [dts()],
},
])
第五步:配置 package.json
{
"name": "my-react-hooks",
"version": "1.0.0",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
},
"sideEffects": false,
"files": ["dist"],
"peerDependencies": {
"react": ">=18.0.0"
},
"scripts": {
"build": "rollup -c rollup.config.ts --configPlugin typescript",
"prepublishOnly": "npm run build"
}
}
第六步:构建并发布
# 构建
npm run build
# 发布前检查产物
ls dist/
# index.esm.js index.cjs.js index.d.ts index.esm.js.map index.cjs.js.map
# 发布到 npm
npm publish
产物结构:
dist/
├── index.esm.js # ESM 格式(打包器使用)
├── index.esm.js.map # ESM sourcemap
├── index.cjs.js # CJS 格式(Node.js require 使用)
├── index.cjs.js.map # CJS sourcemap
└── index.d.ts # 合并的类型声明
Q42: SWC 和 Babel 的区别是什么?
答案:
SWC 和 Babel 都是 JavaScript/TypeScript 编译器(transpiler),但在实现和使用上有显著差异:
核心区别:
| 维度 | SWC | Babel |
|---|---|---|
| 语言 | Rust(编译为原生二进制 + WASM) | JavaScript |
| 速度 | 单线程快 20 倍,多线程快 70 倍 | 基准 |
| 配置 | .swcrc(JSON) | .babelrc / babel.config.js |
| 插件 | Rust/WASM 插件(生态较小) | JavaScript 插件(生态丰富) |
| 装饰器 | 支持最新提案 | 通过插件支持 |
| Polyfill | 不直接支持 | @babel/preset-env + core-js |
代码对比:
// Babel 配置
const babelConfig = {
presets: [
["@babel/preset-env", { targets: "> 0.25%, not dead" }],
"@babel/preset-typescript",
["@babel/preset-react", { runtime: "automatic" }],
],
plugins: [
["@babel/plugin-proposal-decorators", { version: "2023-05" }],
],
};
// 等效的 SWC 配置
const swcConfig = {
jsc: {
parser: {
syntax: "typescript",
tsx: true,
decorators: true,
},
transform: {
react: { runtime: "automatic" },
decoratorVersion: "2022-03",
},
target: "es2020",
},
};
什么时候选 SWC:
- 项目不依赖特殊的 Babel 插件
- 使用 Next.js(默认内置)
- 追求更快的编译速度
- 需要装饰器支持
什么时候留在 Babel:
- 项目依赖大量自定义 Babel 插件
- 需要精细的 Polyfill 控制(
useBuiltIns: "usage") - 项目已稳定运行,迁移成本大于收益
Q43: Webpack 的 runtime chunk 为什么会影响长期缓存?
答案:
Webpack Runtime 保存模块加载、Chunk 映射和异步加载等引导逻辑。若它被内联进业务入口,新增或移动 Chunk 可能改变 Runtime 内容,进而让没有业务变化的入口文件 hash 也发生变化。
常见做法是用 optimization.runtimeChunk 把 Runtime 单独拆出,并配合稳定的 moduleIds、chunkIds、contenthash 和合理的 splitChunks,让未变化的业务模块更有机会复用长期缓存。
但拆分 Runtime 会增加请求或依赖关系,收益要看协议与页面入口。还要避免 HTML 长期缓存到旧版本,否则可能出现入口与 Chunk 版本错配。
Q44: Module Federation 的运行时原理是怎样的?(加载流程)
答案:
Module Federation 的运行时加载分为以下关键步骤:
第一步:初始化共享作用域
Host 应用启动时调用 __webpack_init_sharing__('default') 创建共享作用域(Shared Scope),将自身的共享依赖注册进去。
第二步:加载远程容器入口
当代码执行到 import('remoteApp/Button') 时,Webpack 运行时通过动态创建 <script> 标签加载远程应用的 remoteEntry.js 文件。该文件执行后会在 window 上挂载一个容器对象(如 window.remoteApp)。
第三步:初始化远程容器
调用 container.init(shareScope) 将共享作用域传递给远程容器。远程容器将自己的共享依赖也注册到同一个作用域中,并进行版本协商——选择满足所有 requiredVersion 约束的最高版本。
第四步:获取远程模块
调用 container.get('./Button') 获取指定模块的工厂函数,执行工厂函数得到模块实例。
// 1. 初始化共享作用域
await __webpack_init_sharing__('default');
// 2. 加载远程入口(script 标签)
await loadScript('http://localhost:3001/remoteEntry.js');
// 3. 初始化远程容器
const container = window.remoteApp;
await container.init(__webpack_share_scopes__.default);
// 4. 获取远程模块
const factory = await container.get('./Button');
const ButtonModule = factory();
强调两个关键设计:
- 异步入口:Host 入口必须通过
import('./bootstrap')异步加载,确保共享作用域在任何模块使用前完成初始化 - 共享作用域:所有应用共享同一个
shareScope对象,运行时根据singleton、requiredVersion等配置自动协商,决定是复用已有实例还是加载新版本
Q45: 迁移过程中最常见的兼容性问题有哪些?如何解决?
答案:
最常见的六大兼容性问题及解决方案:
| 问题 | 原因 | 解决方案 |
|---|---|---|
require is not defined | CJS 依赖未转换 | optimizeDeps.include 强制预构建 |
require.context 不可用 | Webpack 专有 API | 替换为 import.meta.glob |
| 环境变量读取失败 | 前缀和访问方式变了 | REACT_APP_ → VITE_,process.env → import.meta.env |
| SVG 组件导入报错 | 缺少 SVGR 插件 | 安装 vite-plugin-svgr,使用 ?react 后缀 |
| 全局 SCSS 变量丢失 | 注入方式不同 | 配置 css.preprocessorOptions.scss.additionalData |
| 动态 import 路径报错 | Vite 对动态路径限制更严 | 使用 import.meta.glob 替代完全动态路径 |
其中最隐蔽的是 CJS 兼容性问题,因为很多常用的 npm 包仍然使用 CJS 格式(如 lodash、moment)。排查方法是观察浏览器控制台错误信息,将报错的包加入 optimizeDeps.include:
export default defineConfig({
optimizeDeps: {
include: [
'lodash',
'moment',
'classnames',
'some-lib > nested-cjs-dep', // 嵌套的 CJS 依赖也要显式声明
],
},
});
Q46: PostCSS 插件的执行顺序为什么重要?
答案:
PostCSS 插件按配置顺序处理同一棵 CSS AST,前一个插件产生的语法会成为后一个插件的输入。顺序错误可能导致后续插件识别不到语法、重复转换,或让压缩阶段破坏仍待处理的结构。
通常先处理自定义语法和设计令牌,再做兼容性转换,最后压缩;但实际顺序要看插件契约,不能机械套模板。排查时应逐个启用插件比较中间产物,确认输入/输出语法,并确保 Source Map 正确串联。
“都属于 PostCSS 插件”不代表可以任意交换位置,关键 CSS 应有快照或视觉回归测试。
Q47: 如何编写一个 Babel 插件?
答案:
Babel 插件本质上是一个函数,接收 Babel API 对象作为参数,返回一个包含 visitor 属性的对象。visitor 中的方法对应 AST 节点类型,当 Babel 遍历 AST 遇到匹配节点时自动调用。
编写步骤:
- 确定要转换的节点类型:用 AST Explorer 分析目标代码的 AST 结构
- 定义 Visitor 方法:在 visitor 中注册对应节点类型的处理函数
- 使用 path API 操作节点:
path.replaceWith()、path.remove()、path.insertBefore()等 - 使用 @babel/types 构建新节点:
t.identifier()、t.callExpression()等
完整示例——将可选链 ?. 转为安全的三元表达式:
import type { PluginObj } from "@babel/core";
import type * as BabelTypes from "@babel/types";
export default function optionalChainingPlugin({
types: t,
}: {
types: typeof BabelTypes;
}): PluginObj {
return {
name: "optional-chaining-simple",
visitor: {
OptionalMemberExpression(path) {
const { object, property } = path.node;
// obj?.prop → obj == null ? undefined : obj.prop
if (t.isIdentifier(object)) {
path.replaceWith(
t.conditionalExpression(
t.binaryExpression(
"==",
t.cloneNode(object),
t.nullLiteral()
),
t.identifier("undefined"),
t.memberExpression(t.cloneNode(object), property)
)
);
}
},
},
};
}
插件的使用方式:
export default {
plugins: [
// 方式 1:直接引用文件路径
"./babel-plugin-optional-chaining",
// 方式 2:带选项
["./babel-plugin-remove-console", { exclude: ["error"] }],
// 方式 3:npm 包名(自动添加 babel-plugin- 前缀)
"transform-runtime",
],
};
Q48: 如何保证前端构建产物可复现?
答案:
可复现构建指相同源码、配置和依赖在等价环境中得到相同内容。关键是消除未声明和不确定输入:
- 提交锁文件并使用冻结安装,固定 Node、包管理器和构建工具版本。
- 固定时区、Locale 等环境;不要把当前时间、随机数、绝对路径直接写入产物。
- 自定义 Loader/Plugin 读取文件或环境变量时显式声明为构建依赖。
- 对压缩、并行任务和模块 id 使用确定性配置。
- 在隔离环境重复构建,对文件清单和内容 hash 做比较。
可复现不等于产物永远不变。Git 提交、公开构建参数或版本号改变时,变化是预期的,关键是让所有输入可追踪。
Q49: HMR 为什么有时不能保留组件状态?
答案:
HMR 只能在框架插件识别到安全更新边界时保留状态。导出形态改变、组件签名不稳定、模块同时导出非组件值、Hook 调用顺序变化,或更新传播越过无法接收的边界,都可能触发组件重挂载甚至整页刷新。
排查时应区分:
- 模块 HMR:模块能否接收更新,依赖图是否正确传播。
- 框架 Fast Refresh:新旧导出是否仍被判定为同一组件类型。
- 状态位置:模块级单例、组件 state 和外部 Store 的生命周期不同。
保留状态只是开发体验优化,业务正确性不能依赖 HMR;初始化、清理和订阅仍要能承受正常挂载、卸载和完整刷新。
Q50: Vite 8 为什么改用 Rolldown 和 Oxc?
答案:
核心目标是减少长期存在的多工具差异,同时提升大型项目的构建速度:
- Rolldown:提供与 Rollup 生态和语义尽量兼容的高性能打包能力,承担生产构建,并参与依赖优化等开发阶段工作。
- Oxc:提供解析、转换和压缩等基础能力,减少过去 Babel、esbuild、Terser 等链路组合带来的语义和配置差异。
- 统一性:插件作者和应用开发者更容易在开发与生产之间获得一致行为,Vite 自身也能共享更多基础设施。
面试时不要再回答成“当前 Vite 生产固定使用 Rollup,esbuild 只负责压缩”。更准确的说法是:Vite 8 已转向 Rolldown/Oxc,但开发服务和生产构建仍服务于不同目标,插件兼容、产物差异和升级回归测试依然重要。
迁移时应检查旧的 Rollup/Vite 插件兼容性、废弃配置、构建产物和 Source Map,而不是只比较一次构建耗时。
Q51: Webpack 5 相比 Webpack 4 有哪些重要优化?
答案:
Webpack 5 是一个重大升级版本,主要优化如下:
| 特性 | Webpack 4 | Webpack 5 |
|---|---|---|
| 缓存 | 需要 hard-source-webpack-plugin | 内置 cache.type: 'filesystem' |
| 资源处理 | 需要 file-loader/url-loader | 内置 Asset Modules |
| Tree Shaking | 基础支持 | 嵌套 Tree Shaking、内部模块优化 |
| Content Hash | 基于模块结构 | 基于文件真实内容 |
| Node.js Polyfill | 自动注入 | 不再自动注入(减少体积) |
| 模块联邦 | 不支持 | ModuleFederationPlugin |
| 代码生成 | 仅 ES5 | 支持 ES6+(减少包装代码) |
| Top Level Await | 不支持 | 原生支持 |
持久化缓存是最重要的优化,二次构建提速 60%~90%:
// Webpack 5 只需要一行配置
const config: Configuration = {
cache: { type: 'filesystem' },
};
Node.js Polyfill 移除让很多项目体积减小了几十 KB。如果确实需要某个 polyfill,需要手动安装:
const config: Configuration = {
resolve: {
fallback: {
path: require.resolve('path-browserify'),
crypto: require.resolve('crypto-browserify'),
buffer: require.resolve('buffer/'),
stream: false, // 明确不需要
},
},
};
Module Federation 让微前端变得更简单,应用之间可以在运行时动态共享模块,无需重新打包部署。
Q52: package.json 的 exports 和 imports 字段分别解决什么问题?
答案:
exports 定义包对消费者公开的入口,可限制深层导入,并针对 import、require、类型声明和不同环境提供条件映射。imports 定义包内部以 # 开头的私有别名,只在当前包内部解析。
设计时要注意:
- 只暴露承诺长期兼容的子路径,并让 JavaScript 与类型声明映射一致。
- 条件顺序和工具支持会影响解析结果,要在 Node、TypeScript 和目标打包器中组合测试。
- 新增
exports可能让消费者原有深层导入立即失效,属于潜在破坏性变更。 - 它们解决入口和封装问题,不替代
sideEffects、Tree Shaking 或 TypeScript 路径别名。
Q53: prefetch 和 preload 的区别是什么?
答案:
Prefetch 和 Preload 都是资源加载的优化手段,但在加载时机、优先级和适用场景上有本质区别:
| 对比项 | prefetch | preload |
|---|---|---|
| HTML 语法 | <link rel="prefetch"> | <link rel="preload"> |
| 加载时机 | 浏览器空闲时 | 与当前页面资源并行 |
| 网络优先级 | 最低(Lowest) | 高(High) |
| 是否阻塞渲染 | 不阻塞 | 不阻塞,但会占用带宽 |
| 缓存行为 | 存入 HTTP Cache 或 prefetch cache | 存入内存缓存(memory cache) |
| 适用场景 | 下一页面可能用到的资源 | 当前页面一定需要的资源 |
| Webpack 注释 | /* webpackPrefetch: true */ | /* webpackPreload: true */ |
具体使用场景举例:
// 用户在首页,可能会进入文章详情页
const ArticleDetail = () => import(
/* webpackPrefetch: true */
'./pages/ArticleDetail'
);
// 用户在列表页,可能会打开编辑弹窗
const EditDialog = () => import(
/* webpackPrefetch: true */
'./components/EditDialog'
);
// 字体文件:当前页面渲染必需
// <link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
// 首屏图表组件:页面加载后立即需要展示
const HeroChart = () => import(
/* webpackPreload: true */
'./components/HeroChart'
);
- 不要把所有路由都设为 Preload:Preload 会争夺当前页面的网络带宽,滥用会适得其反
- Prefetch 不保证一定会加载:浏览器会根据网络状况、电量等因素决定是否执行
- Safari 对 Prefetch 支持有限:在 Safari 中
<link rel="prefetch">不会被执行
Q54: Compiler 和 Compilation 的区别是什么?
答案:
这是 Webpack 插件体系中两个最核心的对象,区分它们是理解 Plugin 开发的关键。
Compiler(编译器):
- 代表 Webpack 的完整配置环境,在 Webpack 启动时创建,贯穿整个生命周期
- 整个 Webpack 进程中只有一个 Compiler 实例
- 包含 Webpack 的所有配置信息(
options)、文件系统(inputFileSystem、outputFileSystem)、插件等 - 提供 Webpack 生命周期的顶级钩子(
compile、emit、done等)
Compilation(编译过程):
- 代表一次具体的编译过程,每次文件变化触发重新编译时都会创建新的 Compilation
- 包含当前编译的所有信息:模块(
modules)、依赖图、chunk(chunks)、输出资源(assets) - 在 watch 模式下,一个 Compiler 会创建多个 Compilation
import type { Compiler, Compilation } from 'webpack';
class DemoPlugin {
apply(compiler: Compiler): void {
// Compiler 级别:整个构建生命周期只触发一次(非 watch 模式)
compiler.hooks.done.tap('DemoPlugin', () => {
console.log('Compiler: 整个构建完成');
});
// Compilation 级别:每次编译都会触发
compiler.hooks.compilation.tap('DemoPlugin', (compilation: Compilation) => {
console.log('Compilation: 新的一次编译开始');
// 在 Compilation 上注册钩子,操作模块和资源
compilation.hooks.processAssets.tap(
{ name: 'DemoPlugin', stage: 0 },
(assets) => {
// 可以读取/修改所有输出资源
console.log('当前输出文件:', Object.keys(assets));
}
);
});
}
}
核心区别对比:
| 维度 | Compiler | Compilation |
|---|---|---|
| 创建时机 | Webpack 启动时创建一次 | 每次编译(包括 watch 触发)都新建 |
| 实例数量 | 全局唯一 | 可能有多个(watch 模式) |
| 包含信息 | 全局配置、文件系统、插件列表 | 模块、chunk、依赖图、输出资源 |
| 访问方式 | plugin.apply(compiler) | compiler.hooks.compilation.tap(cb) |
| 常用操作 | 注册生命周期钩子、读取配置 | 修改模块、操作资源、自定义分包 |
| 类比 | 工厂(基础设施不变) | 一次生产过程(原料/产品每次不同) |
不要在 Compiler 钩子中缓存 Compilation 的引用。由于每次重新编译都会创建新的 Compilation,使用旧的引用会导致内存泄漏和数据错误。
Q55: Rollup 的插件机制是怎样的?
答案:
Rollup 的插件机制基于 钩子系统(Hooks),插件通过实现特定的钩子函数来介入打包的各个阶段。
1. 插件的基本结构:
import type { Plugin } from 'rollup'
function myPlugin(options: Record<string, unknown> = {}): Plugin {
return {
name: 'my-plugin', // 必需:用于错误提示和调试
// Build Hooks — 构建阶段
resolveId(source: string, importer: string | undefined) { /* ... */ },
load(id: string) { /* ... */ },
transform(code: string, id: string) { /* ... */ },
// Output Generation Hooks — 输出阶段
renderChunk(code: string, chunk: RenderedChunk) { /* ... */ },
generateBundle(options: OutputOptions, bundle: OutputBundle) { /* ... */ },
}
}
2. 核心钩子说明:
| 钩子 | 阶段 | 作用 | 典型场景 |
|---|---|---|---|
options | Build | 修改输入配置 | 动态调整配置 |
buildStart | Build | 构建开始时调用 | 初始化、清理 |
resolveId | Build | 自定义模块路径解析 | 虚拟模块、路径别名 |
load | Build | 自定义模块内容加载 | 虚拟模块、内存文件 |
transform | Build | 转换模块代码 | 代码替换、注入 |
buildEnd | Build | 构建结束时调用 | 错误汇总 |
renderChunk | Output | 处理每个输出 chunk | 代码压缩、banner 注入 |
generateBundle | Output | 所有 chunk 生成后调用 | 添加额外文件、修改产物 |
writeBundle | Output | 产物写入磁盘后调用 | 后置处理、通知 |
3. 钩子的执行类型:
不同钩子有不同的执行方式,理解这一点对编写插件至关重要:
import type { Plugin } from 'rollup'
function examplePlugin(): Plugin {
return {
name: 'example',
// first 类型:多个插件都实现了 resolveId,取第一个非 null 返回值
resolveId(source: string) {
if (source === 'virtual:env') {
return '\0virtual:env' // 返回非 null,后续插件不再处理
}
return null // 返回 null,交给下一个插件处理
},
// sequential 类型:按顺序执行,前一个的输出作为后一个的输入
transform(code: string, id: string) {
// 多个插件的 transform 串联执行
// 插件 A 的输出 → 插件 B 的输入 → 插件 C 的输入
return code.replace('__DEV__', 'false')
},
// parallel 类型:并行执行,互不依赖
buildStart() {
console.log('Build started!')
// 所有插件的 buildStart 并行执行
},
}
}
4. 虚拟模块插件实战:
import type { Plugin } from 'rollup'
// 虚拟模块前缀约定:使用 \0 前缀防止其他插件处理
const VIRTUAL_ID = 'virtual:env'
const RESOLVED_ID = '\0virtual:env'
interface EnvOptions {
mode: 'development' | 'production'
}
export function virtualEnvPlugin(options: EnvOptions): Plugin {
return {
name: 'rollup-plugin-virtual-env',
// 解析:将虚拟模块 ID 映射为内部 ID
resolveId(source: string) {
if (source === VIRTUAL_ID) {
return RESOLVED_ID
}
},
// 加载:为虚拟模块返回代码内容
load(id: string) {
if (id === RESOLVED_ID) {
return `
export const MODE = ${JSON.stringify(options.mode)}
export const IS_DEV = ${options.mode === 'development'}
export const IS_PROD = ${options.mode === 'production'}
`
}
},
}
}
// 使用时:
// import { MODE, IS_DEV } from 'virtual:env'
- Rollup 插件是一个 返回钩子对象的函数
- 钩子分为 Build Hooks(构建阶段)和 Output Hooks(输出阶段)
- 最核心的三个钩子:
resolveId(解析路径)→load(加载内容)→transform(转换代码) - Vite 的插件机制基于 Rollup 扩展,学会 Rollup 插件约等于学会 Vite 插件
Q56: 在当前项目中怎样选择 esbuild、SWC、Oxc 和 Rolldown?
答案:
它们处在不同层次,不能只按“谁最快”替换:
- esbuild 适合快速转译、打包、压缩和自定义脚本,存量 Webpack 也可用相关 Loader。
- SWC 常用于框架编译、转译和压缩,例如部分 Next.js 工具链。
- Oxc 提供解析、转换、压缩、Lint 等 Rust 基础能力,并与 Rolldown/Vite 工具链协作。
- Rolldown 是 Bundler,Vite 8 已用它统一依赖优化和生产构建。
选择优先跟随上层框架的官方集成,避免自己拼装重复流水线。迁移必须检查装饰器、JSX、插件、目标浏览器、类型检查和 Source Map;这些工具的快速转译通常不等于 TypeScript 类型检查。
Q57: 生产环境应该如何处理 Source Map?
答案:
生产环境最佳实践是 生成 Source Map → 上传到错误监控平台 → 从部署产物中删除 .map 文件。
为什么不能完全不生成:没有 Source Map,线上错误堆栈只有压缩后的代码位置(如 a.js:1:28456),几乎无法定位问题。对于有一定用户量的产品,线上调试能力是必需的。
为什么不能直接暴露:Source Map 包含完整源码,暴露后等同于开源你的项目,且恶意用户可以借此发现安全漏洞。
推荐流程:
// 1. 构建时生成 hidden-source-map
// Webpack: devtool: 'hidden-source-map'
// Vite: build.sourcemap: 'hidden'
// 2. 上传 .map 文件到 Sentry(或其他平台)
async function uploadSourceMaps(): Promise<void> {
const release = process.env.RELEASE_VERSION;
// 创建 Release
await sentry.createRelease(release);
// 上传 Source Map
await sentry.uploadSourceMaps(release, {
include: ['./dist'],
urlPrefix: '~/static/js', // 与线上 JS 路径一致
});
// 完成 Release
await sentry.finalizeRelease(release);
}
// 3. 删除 .map 文件
async function cleanSourceMaps(): Promise<void> {
const mapFiles = await glob('dist/**/*.map');
await Promise.all(mapFiles.map((file) => fs.unlink(file)));
console.log(`已删除 ${mapFiles.length} 个 .map 文件`);
}
// 4. 部署到 CDN / 生产服务器(此时已不含 .map)
async function deploy(): Promise<void> {
await uploadToCDN('./dist');
}
Nginx 兜底防护(防止因配置遗漏导致 .map 被公开访问):
location ~* \.map$ {
deny all;
return 404;
}
四种策略对比:
| 策略 | 线上调试 | 源码安全 | 构建成本 | 推荐度 |
|---|---|---|---|---|
| 不生成 | 无法调试 | 完全安全 | 最低 | 不推荐 |
hidden-source-map + Sentry | 完整调试 | 安全 | 较高 | 强烈推荐 |
nosources-source-map | 行列映射 | 较安全 | 较高 | 推荐 |
source-map(公开) | 完整调试 | 不安全 | 较高 | 禁止 |
Q58: Module Federation 和微前端方案(如 qiankun)的区别?
答案:
两者的核心区别在于定位不同:
- Module Federation 是一个模块共享方案,解决的是"如何在运行时跨应用共享代码模块"
- qiankun 是一个微前端框架,解决的是"如何将多个独立应用集成为一个统一体验的大应用"
| 维度 | Module Federation | qiankun |
|---|---|---|
| 共享粒度 | 模块级(组件、工具函数) | 应用级(整个子应用) |
| JS 隔离 | 无隔离,共享全局作用域 | Proxy 沙箱,隔离全局变量 |
| CSS 隔离 | 无内置方案 | Shadow DOM / Scoped CSS |
| 依赖共享 | 内置 shared 配置,运行时协商 | 无内置机制,各子应用独立打包 |
| 技术栈 | 通常要求相同构建工具 | 完全技术栈无关 |
| 性能 | 更优,模块级按需加载 + 依赖复用 | 应用级加载,可能存在依赖冗余 |
| 生命周期 | 无(只是模块加载) | 完整的 mount/unmount/bootstrap |
选择建议:
function chooseArchitecture(project: ProjectInfo): string {
// 场景 1:同技术栈 + 共享组件
if (project.sameTechStack && project.needShareModules) {
return 'Module Federation';
}
// 场景 2:异构技术栈 + 强隔离需求
if (project.multiTechStack && project.needIsolation) {
return 'qiankun / wujie';
}
// 场景 3:两者结合
if (project.complexEnterprise) {
return 'qiankun(应用管理) + Module Federation(模块共享)';
}
return '评估具体需求后决定';
}
提到两者可以结合使用:用 qiankun 管理应用级别的加载、路由和隔离,用 Module Federation 在子应用之间共享公共组件库和工具模块。这种组合既有强隔离能力,又有高效的代码共享。
Q59: 迁移后的效果如何量化评估?
答案:
从开发体验和构建产物两个维度来量化:
开发体验指标(改善最明显):
interface MigrationMetrics {
// 1. 冷启动时间:从 npm run dev 到页面可交互的时间
coldStart: { webpack: '30-60s'; vite: '< 2s' };
// 2. HMR 时间:修改代码到页面更新的时间
hmrLatency: { webpack: '2-5s'; vite: '< 100ms' };
// 3. 依赖安装时间:devDependencies 数量和安装耗时
devDepsCount: { webpack: '40-60'; vite: '10-20' };
}
构建产物指标(改善温和):
| 指标 | 测量方法 | 预期改善 |
|---|---|---|
| 构建时间 | CI 流水线耗时 | 20-40% 提升 |
| 产物体积 | build 后 dist/ 总大小 | 5-15% 减少 |
| 首屏加载 | Lighthouse Performance | 基本持平 |
| chunk 数量 | 构建日志 | 视 manualChunks 配置而定 |
评估方法:
- 迁移前记录基准数据(冷启动、HMR、构建时间、产物体积)
- 迁移后在相同条件下重新测量
- 关注 CI/CD 流水线耗时变化
- 团队主观体验(DX 满意度调查)
迁移是否值得,主要看开发体验提升。生产构建的改善是锦上添花,但冷启动从 30s 降到 1s、HMR 从 3s 降到 50ms——这种量级的提升对开发效率和团队幸福感的影响是巨大的。
Q60: 如何编写一个 PostCSS 插件?
答案:
编写 PostCSS 插件的核心步骤:
- 创建插件函数:导出一个函数,接收配置选项,返回包含
postcssPlugin名称和访问者方法的对象 - 使用访问者模式:通过
Declaration、Rule、AtRule等方法遍历和修改 AST 节点 - 标记为插件:设置
函数名.postcss = true
import type { PluginCreator, Root, Declaration, Rule } from 'postcss';
interface MyPluginOptions {
/** 选项示例 */
enable?: boolean;
}
const myPlugin: PluginCreator<MyPluginOptions> = (opts = {}) => {
const { enable = true } = opts;
return {
postcssPlugin: 'postcss-my-plugin', // 必须:插件名称
// 处理开始时调用一次
Once(root: Root) {
// 可以在这里做全局分析
console.log('开始处理 CSS 文件');
},
// 每个属性声明都会触发
Declaration(decl: Declaration) {
if (!enable) return;
// 示例:将所有 color: red 改为 color: blue
if (decl.prop === 'color' && decl.value === 'red') {
decl.value = 'blue';
}
},
// 每个选择器规则都会触发
Rule(rule: Rule) {
// 示例:给所有选择器添加前缀
// rule.selector = `.prefix ${rule.selector}`;
},
// 处理结束时调用一次
OnceExit(root: Root) {
console.log('CSS 处理完成');
},
};
};
myPlugin.postcss = true; // 必须:标记为 PostCSS 插件
export default myPlugin;
常用的 AST 操作方法:
import type { Declaration, Rule, Root } from 'postcss';
// 修改属性值
function handleDecl(decl: Declaration): void {
decl.value = 'new-value'; // 修改值
decl.prop = 'new-prop'; // 修改属性名
decl.remove(); // 删除该声明
decl.replaceWith(decl.clone({ value: 'new' })); // 替换节点
}
// 修改规则
function handleRule(rule: Rule): void {
rule.selector = '.new-selector'; // 修改选择器
rule.append({ prop: 'color', value: 'red' }); // 添加新声明
rule.prepend({ prop: 'display', value: 'flex' }); // 在最前面添加
}
// 修改根节点
function handleRoot(root: Root): void {
root.walkDecls('color', (decl) => { // 遍历所有 color 声明
decl.value = 'blue';
});
root.walkRules(/^\.btn/, (rule) => { // 遍历匹配的规则
// 处理所有 .btn 开头的规则
});
}
- 使用 AST Explorer(选择 CSS / postcss)可以直观查看 CSS 的 AST 结构,帮助理解节点关系
- 插件应该是幂等的:多次运行产生相同结果
- 优先使用具体的访问者方法(如
Declaration)而非Once中手动walk,性能更好