跳到主要内容

构建工具热门面试题

使用说明

本篇共 60 道题。

构建工具题重点是模块图、开发/生产差异、产物和性能。回答不要只比较“谁快”,应说明快在哪里、牺牲什么以及如何验证。

Q1: 前端构建工具主要解决什么问题?

答案

它从入口解析模块依赖,转换浏览器暂不直接支持的语法和资源,做代码分割、优化与产物管理,并提供开发服务器、HMR 和诊断。

构建工具还承载环境变量、兼容目标和插件生态,因此选型不只是打包速度。

  • 开发阶段要解决语言转换、模块解析、HMR、类型检查和调试;生产阶段还要做依赖图优化、代码分割、压缩、缓存和兼容输出。
  • 面试时最好强调构建工具不是“把文件合在一起”,而是在源码、浏览器能力与部署环境之间建立可重复的交付流水线。

Q2: Webpack 的核心构建流程是什么?

答案

Webpack 的构建流程分为三大阶段:

1. 初始化阶段

  • 合并 CLI 参数、配置文件(webpack.config.ts)和默认配置
  • 创建 Compiler 对象(全局唯一),代表完整的 Webpack 环境
  • 遍历 plugins 数组,调用每个 Plugin 的 apply(compiler) 方法注册钩子
  • 注入 Webpack 内置插件(EntryPluginChunkPlugin 等)

2. 构建阶段(Make)

  • entry 配置的入口文件出发
  • 调用匹配的 Loader 链对模块进行转译(如 TS -> JS)
  • 使用 acorn 将转译后的代码解析为 AST(抽象语法树)
  • 遍历 AST,找出 importrequire 等依赖声明
  • 对每个依赖递归执行上述流程,最终构建出完整的模块依赖图(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 用于扩展功能

维度LoaderPlugin
职责将非 JS 文件转换为 Webpack 能处理的模块在构建流程的特定时机执行更广泛的任务
本质一个导出为函数的模块一个包含 apply 方法的类
作用范围仅作用于匹配的文件可以影响整个构建流程
配置module.rulesplugins
执行时机模块加载阶段贯穿整个构建生命周期

Loader 代码示例

markdown-loader.ts
// Loader 是一个函数,接收源代码,返回转换结果
import { marked } from 'marked';

export default function markdownLoader(source: string): string {
const html = marked(source);
// 返回 JS 模块代码
return `export default ${JSON.stringify(html)}`;
}

Plugin 代码示例

build-info-plugin.ts
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 不会重新编译所有模块,而是只对变化的模块及其受影响的依赖进行重新编译。编译完成后生成两个关键文件:

HMR 产物
// 更新清单(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 值:

WebSocket 消息
// 服务端推送
{ type: 'hash', data: 'abc123def456' }
{ type: 'ok' } // 编译成功

4. 浏览器端拉取更新

浏览器端注入的 HMR Runtime 收到 WebSocket 消息后,主动向服务端发起 HTTP 请求,拉取更新清单和更新后的模块代码:

HMR Runtime 工作流程(伪代码)
// 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. 模块替换与冒泡机制

框架的 HMR 集成

在实际开发中,React 和 Vue 框架通过各自的 HMR 方案自动处理模块替换,开发者不需要手动写 module.hot.accept()

  • React:使用 react-refresh + @pmmmwh/react-refresh-webpack-plugin,能在保留组件状态的前提下热替换组件
  • Vuevue-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 的静态结构进行分析。整个过程分为两个阶段:

  1. 标记阶段:从入口文件出发,递归分析所有 import 语句,构建模块依赖图。对每个模块的每个 export,检查是否被其他模块 import,未被引用的标记为"unused"
  2. 删除阶段:在压缩阶段(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 的核心区别:

维度PostCSSSass/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 模块。

解决的核心问题:

  1. 跨应用代码共享:传统方式(npm 包发布)需要构建时安装依赖、应用重新打包部署。Module Federation 让应用在运行时直接加载另一个应用暴露的模块,实现即时共享
  2. 依赖重复加载:多个微前端子应用可能各自打包了一份 React、lodash 等公共库。Module Federation 的 shared 机制让多个应用在运行时共享同一份依赖
  3. 独立部署的模块更新:组件库更新后,所有消费方应用自动获取最新版本,无需重新构建和部署

核心概念:

  • 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.extensionsresolve.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. 减少不必要的插件

  • 开发环境移除 TerserPluginCssMinimizerPlugin
  • 开发环境使用 eval-cheap-module-source-map 代替 source-map

6. 使用更快的工具链

  • esbuild-loader 替代 babel-loader(编译速度快 10~100 倍)
  • swc-loader 替代 ts-loader
使用 esbuild-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 应关注什么?

答案

迁移不是把配置名逐项翻译,首先要盘点两套工具的运行模型和隐式约定:

  1. 入口与 HTML:Vite 把 HTML 视为入口,公开目录、环境变量和资源 URL 规则不同。
  2. 模块兼容:检查 CommonJS、动态 require、Node Polyfill、路径别名和 monorepo 链接包。
  3. 插件迁移:Loader/Plugin 不一定有一一对应项,应确认是 Vite 插件、框架插件还是代码本身需要改造。
  4. 环境变量:客户端变量暴露规则变化,Secret 仍不能进入前端包。
  5. 产物与部署:重新验证分包、CSS、base 路径、旧浏览器、SSR、Source Map 和 CDN 缓存。
  6. 当前工具链:Vite 8 已使用 Rolldown/Oxc 工具链,不能继续按早期“开发 esbuild、生产 Rollup”的固定描述做判断。
  7. 验收:对比冷启动、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 函数

loaders/remove-console-loader.ts
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 配置中使用

webpack.config.ts
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')],
},
};

第三步:编写测试

__tests__/remove-console-loader.test.ts
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 的核心原则
  1. 单一职责:每个 Loader 只做一件事
  2. 链式调用:通过管道组合多个 Loader
  3. 无状态:不要在 Loader 中保存状态
  4. 使用 this.getOptions() + schema-utils 校验参数
  5. 合理使用缓存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,并根据提供版本、requiredVersionsingleton、加载时机等规则协商使用哪份实现。

React 这类要求单实例的库通常配置 singleton,但这并不自动保证兼容:某个 Remote 需要不兼容版本时,可能产生告警、使用错误版本或回退到自己的副本。设置 eager 还会改变加载和体积,不是通用修复。

治理方式是统一版本策略和运行时契约、在 CI 做 Host/Remote 组合测试、对远程发布保留兼容窗口和回滚,并监控 remoteEntry 与共享模块加载失败。

Q31: Vite 为什么要做依赖预构建?optimizeDeps 何时需要调整?

答案

开发服务器以 ESM 提供模块。依赖预构建主要解决两件事:

  1. 把 CommonJS/UMD 依赖转换为开发环境可消费的 ESM。
  2. 把包含大量内部 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

核心关系:

  1. API 兼容:Rspack 实现了大部分 Webpack 配置选项(entry、output、module.rules、resolve、optimization 等),配置文件结构几乎一致
  2. 生态兼容:支持大部分 Webpack Loader(如 css-loader、less-loader),部分 Plugin 也可直接使用
  3. 理念一致:采用相同的 Bundle 模式,支持相同的代码分割策略(splitChunks)
  4. 性能飞跃:由于 Rust 的性能优势,Rspack 的构建速度比 Webpack 快 10-20 倍

关键区别:

从 Webpack 迁移到 Rspack 的配置变化
// 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

典型插件流程:

  1. resolveId 捕获公开模块名,返回带 \0 前缀的内部 id,避免被其他解析逻辑当成文件路径。
  2. load 识别内部 id,返回生成的 JavaScript 和必要的 Source Map。
  3. 若内容依赖外部文件,应把文件加入监听依赖,并在变化时正确失效模块或触发 HMR。

生成内容必须可预测、可缓存,不能把 Secrets 注入客户端模块;还要分别验证 SSR、开发和生产阶段。

Q37: 如何减小 Webpack 打包产物体积?

答案

产物体积优化可以从以下方面入手:

策略方法效果
压缩TerserPlugin + CssMinimizerPlugin减小 30%~50%
Gzip/BrotliCompressionPlugin再减小 60%~80%
Tree ShakingsideEffects + ESM移除未使用代码
代码分割splitChunks + 动态 import按需加载
Scope HoistingModuleConcatenationPlugin减少模块包装
图片压缩image-minimizer-webpack-plugin图片体积减小 50%+
外部化externals + CDN大库不打入 bundle

关键代码示例 -- externals 外部化大库

webpack.config.ts
const config: Configuration = {
externals: {
react: 'React',
'react-dom': 'ReactDOM',
lodash: '_',
},
};
index.html
<!-- 从 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 实现按需加载

src/router.tsx
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__*/ 标注

一个综合的最佳实践配置:

package.json
{
"sideEffects": ["*.css", "*.scss"]
}
.babelrc
{
"presets": [["@babel/preset-env", { "modules": false }]]
}
webpack.config.ts
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)
prioritycacheGroup 优先级数值越大优先级越高

推荐的分包方案

生产级别的 splitChunks 配置
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,
},
},
},
}

设计原则

  1. 按变更频率分层:变化越少的代码越适合独立为 chunk,利用浏览器缓存
  2. 控制 chunk 数量:初始加载的 chunk 建议不超过 5 个,避免过多并行请求
  3. 设置合理的体积范围:单个 chunk 建议在 20KB ~ 250KB 之间
  4. 优先级层级清晰:确保更精确的 cacheGroup 优先级高于宽泛的匹配规则

Q40: 如何编写一个自定义 Webpack Plugin?

答案

编写 Plugin 需要理解 Tapable 钩子系统,并选择合适的钩子注册回调。

完整示例:构建耗时统计插件

plugins/BuildTimePlugin.ts
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;

使用方式

webpack.config.ts
import BuildTimePlugin from './plugins/BuildTimePlugin';

export default {
plugins: [
new BuildTimePlugin({
verbose: true,
warnThreshold: 5000,
onComplete: (data) => {
// 可以将数据上报到监控系统
console.log('Build data:', JSON.stringify(data));
},
}),
],
};

Plugin 开发核心步骤总结

  1. 创建一个类,定义 apply(compiler: Compiler) 方法
  2. apply 中通过 compiler.hooks.xxx.tap/tapAsync/tapPromise 注册钩子
  3. 如果需要操作模块/资源,通过 compiler.hooks.compilation 获取 compilation 对象
  4. 在回调中执行自定义逻辑,注意异步钩子需要调用 callback() 或返回 Promise

Q41: 如何使用 Rollup 打包一个 npm 库?(完整流程)

答案

以打包一个 React 工具库为例,完整流程如下:

第一步:初始化项目并安装依赖

npm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-terser rollup-plugin-dts typescript -D

第二步:配置 tsconfig.json

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"]
}

第三步:编写库源码

src/index.ts
export { useDebounce } from './hooks/useDebounce'
export { useThrottle } from './hooks/useThrottle'
export { formatDate } from './utils/date'
export type { DebounceOptions, ThrottleOptions } from './types'

第四步:配置 rollup.config.ts

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

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),但在实现和使用上有显著差异:

核心区别

维度SWCBabel
语言Rust(编译为原生二进制 + WASM)JavaScript
速度单线程快 20 倍,多线程快 70 倍基准
配置.swcrc(JSON).babelrc / babel.config.js
插件Rust/WASM 插件(生态较小)JavaScript 插件(生态丰富)
装饰器支持最新提案通过插件支持
Polyfill不直接支持@babel/preset-env + core-js

代码对比

babel-config.ts
// 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-config.ts
// 等效的 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 单独拆出,并配合稳定的 moduleIdschunkIdscontenthash 和合理的 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();
面试加分点

强调两个关键设计:

  1. 异步入口:Host 入口必须通过 import('./bootstrap') 异步加载,确保共享作用域在任何模块使用前完成初始化
  2. 共享作用域:所有应用共享同一个 shareScope 对象,运行时根据 singletonrequiredVersion 等配置自动协商,决定是复用已有实例还是加载新版本

Q45: 迁移过程中最常见的兼容性问题有哪些?如何解决?

答案

最常见的六大兼容性问题及解决方案:

问题原因解决方案
require is not definedCJS 依赖未转换optimizeDeps.include 强制预构建
require.context 不可用Webpack 专有 API替换为 import.meta.glob
环境变量读取失败前缀和访问方式变了REACT_APP_VITE_process.envimport.meta.env
SVG 组件导入报错缺少 SVGR 插件安装 vite-plugin-svgr,使用 ?react 后缀
全局 SCSS 变量丢失注入方式不同配置 css.preprocessorOptions.scss.additionalData
动态 import 路径报错Vite 对动态路径限制更严使用 import.meta.glob 替代完全动态路径

其中最隐蔽的是 CJS 兼容性问题,因为很多常用的 npm 包仍然使用 CJS 格式(如 lodashmoment)。排查方法是观察浏览器控制台错误信息,将报错的包加入 optimizeDeps.include

vite.config.ts
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 遇到匹配节点时自动调用。

编写步骤

  1. 确定要转换的节点类型:用 AST Explorer 分析目标代码的 AST 结构
  2. 定义 Visitor 方法:在 visitor 中注册对应节点类型的处理函数
  3. 使用 path API 操作节点path.replaceWith()path.remove()path.insertBefore()
  4. 使用 @babel/types 构建新节点t.identifier()t.callExpression()

完整示例——将可选链 ?. 转为安全的三元表达式

babel-plugin-optional-chaining.ts
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)
)
);
}
},
},
};
}

插件的使用方式

babel.config.ts
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 4Webpack 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.config.ts
// Webpack 5 只需要一行配置
const config: Configuration = {
cache: { type: 'filesystem' },
};

Node.js Polyfill 移除让很多项目体积减小了几十 KB。如果确实需要某个 polyfill,需要手动安装:

webpack.config.ts
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.jsonexportsimports 字段分别解决什么问题?

答案

exports 定义包对消费者公开的入口,可限制深层导入,并针对 importrequire、类型声明和不同环境提供条件映射。imports 定义包内部以 # 开头的私有别名,只在当前包内部解析。

设计时要注意:

  • 只暴露承诺长期兼容的子路径,并让 JavaScript 与类型声明映射一致。
  • 条件顺序和工具支持会影响解析结果,要在 Node、TypeScript 和目标打包器中组合测试。
  • 新增 exports 可能让消费者原有深层导入立即失效,属于潜在破坏性变更。
  • 它们解决入口和封装问题,不替代 sideEffects、Tree Shaking 或 TypeScript 路径别名。

Q53: prefetch 和 preload 的区别是什么?

答案

Prefetch 和 Preload 都是资源加载的优化手段,但在加载时机优先级适用场景上有本质区别:

对比项prefetchpreload
HTML 语法<link rel="prefetch"><link rel="preload">
加载时机浏览器空闲时与当前页面资源并行
网络优先级最低(Lowest)(High)
是否阻塞渲染不阻塞不阻塞,但会占用带宽
缓存行为存入 HTTP Cache 或 prefetch cache存入内存缓存(memory cache)
适用场景下一页面可能用到的资源当前页面一定需要的资源
Webpack 注释/* webpackPrefetch: true *//* webpackPreload: true */

具体使用场景举例

Prefetch — 用户可能的下一步操作
// 用户在首页,可能会进入文章详情页
const ArticleDetail = () => import(
/* webpackPrefetch: true */
'./pages/ArticleDetail'
);

// 用户在列表页,可能会打开编辑弹窗
const EditDialog = () => import(
/* webpackPrefetch: true */
'./components/EditDialog'
);
Preload — 当前页面必需的关键资源
// 字体文件:当前页面渲染必需
// <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)、文件系统(inputFileSystemoutputFileSystem)、插件等
  • 提供 Webpack 生命周期的顶级钩子(compileemitdone 等)

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));
}
);
});
}
}

核心区别对比

维度CompilerCompilation
创建时机Webpack 启动时创建一次每次编译(包括 watch 触发)都新建
实例数量全局唯一可能有多个(watch 模式)
包含信息全局配置、文件系统、插件列表模块、chunk、依赖图、输出资源
访问方式plugin.apply(compiler)compiler.hooks.compilation.tap(cb)
常用操作注册生命周期钩子、读取配置修改模块、操作资源、自定义分包
类比工厂(基础设施不变)一次生产过程(原料/产品每次不同)
常见误区

不要在 Compiler 钩子中缓存 Compilation 的引用。由于每次重新编译都会创建新的 Compilation,使用旧的引用会导致内存泄漏和数据错误。

Q55: Rollup 的插件机制是怎样的?

答案

Rollup 的插件机制基于 钩子系统(Hooks),插件通过实现特定的钩子函数来介入打包的各个阶段。

1. 插件的基本结构:

Rollup 插件结构
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. 核心钩子说明:

钩子阶段作用典型场景
optionsBuild修改输入配置动态调整配置
buildStartBuild构建开始时调用初始化、清理
resolveIdBuild自定义模块路径解析虚拟模块、路径别名
loadBuild自定义模块内容加载虚拟模块、内存文件
transformBuild转换模块代码代码替换、注入
buildEndBuild构建结束时调用错误汇总
renderChunkOutput处理每个输出 chunk代码压缩、banner 注入
generateBundleOutput所有 chunk 生成后调用添加额外文件、修改产物
writeBundleOutput产物写入磁盘后调用后置处理、通知

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. 虚拟模块插件实战:

plugins/rollup-plugin-virtual-env.ts
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'
面试要点总结
  1. Rollup 插件是一个 返回钩子对象的函数
  2. 钩子分为 Build Hooks(构建阶段)和 Output Hooks(输出阶段)
  3. 最核心的三个钩子:resolveId(解析路径)→ load(加载内容)→ transform(转换代码)
  4. 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 包含完整源码,暴露后等同于开源你的项目,且恶意用户可以借此发现安全漏洞。

推荐流程

CI/CD 脚本伪代码
// 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 被公开访问):

nginx.conf
location ~* \.map$ {
deny all;
return 404;
}

四种策略对比

策略线上调试源码安全构建成本推荐度
不生成无法调试完全安全最低不推荐
hidden-source-map + Sentry完整调试安全较高强烈推荐
nosources-source-map行列映射较安全较高推荐
source-map(公开)完整调试不安全较高禁止

Q58: Module Federation 和微前端方案(如 qiankun)的区别?

答案

两者的核心区别在于定位不同

  • Module Federation 是一个模块共享方案,解决的是"如何在运行时跨应用共享代码模块"
  • qiankun 是一个微前端框架,解决的是"如何将多个独立应用集成为一个统一体验的大应用"
维度Module Federationqiankun
共享粒度模块级(组件、工具函数)应用级(整个子应用)
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: 迁移后的效果如何量化评估?

答案

开发体验构建产物两个维度来量化:

开发体验指标(改善最明显):

performance-metrics.ts
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% 提升
产物体积builddist/ 总大小5-15% 减少
首屏加载Lighthouse Performance基本持平
chunk 数量构建日志manualChunks 配置而定

评估方法

  1. 迁移前记录基准数据(冷启动、HMR、构建时间、产物体积)
  2. 迁移后在相同条件下重新测量
  3. 关注 CI/CD 流水线耗时变化
  4. 团队主观体验(DX 满意度调查)
关键判断标准

迁移是否值得,主要看开发体验提升。生产构建的改善是锦上添花,但冷启动从 30s 降到 1s、HMR 从 3s 降到 50ms——这种量级的提升对开发效率和团队幸福感的影响是巨大的。

Q60: 如何编写一个 PostCSS 插件?

答案

编写 PostCSS 插件的核心步骤:

  1. 创建插件函数:导出一个函数,接收配置选项,返回包含 postcssPlugin 名称和访问者方法的对象
  2. 使用访问者模式:通过 DeclarationRuleAtRule 等方法遍历和修改 AST 节点
  3. 标记为插件:设置 函数名.postcss = true
完整的 PostCSS 插件模板
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 操作方法:

AST 节点操作 API
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,性能更好

相关链接