66 KiB
Vite 面试题(含详细解答)
基于本项目的 Vue 3 + Vite + 自定义插件实战经验整理。
1. Vite 和 Webpack 在项目开发中有哪些区别?
这是一道高频面试题,关键不是背概念,而是从开发体验、构建机制、配置复杂度、生态兼容性四个维度说清楚。
1.1 核心差异:开发阶段的模块处理方式
Webpack 是一个 Bundle-based 的构建工具。开发时它会从入口文件出发,递归解析所有依赖,把所有模块打包成一个(或多个)bundle,然后启动 dev server。每次代码变更,它需要重新打包受影响的模块并刷新 bundle,这就是为什么大项目中 Webpack 的 HMR 有时会明显变慢。
Vite 在开发阶段不走打包流程,而是利用浏览器原生支持的 ES Module (ESM)。它直接把 .vue、.ts、.jsx 等源码文件按需编译后通过 ESM 发给浏览器。浏览器通过 <script type="module"> 发起一个个 import 请求,Vite 的 dev server 拦截这些请求并实时编译返回。这种「按需编译」的方式使得冷启动和热更新都非常快,几乎与项目规模无关。
Webpack 开发流程:
入口 → 递归打包所有依赖 → bundle → dev server → 浏览器
Vite 开发流程:
浏览器 import → dev server 拦截 → 按需编译单个文件 → 返回浏览器
1.2 冷启动速度
| Webpack | Vite | |
|---|---|---|
| 冷启动 | 预先打包整个应用,大型项目可能需要几十秒甚至几分钟 | 只编译浏览器当前请求的文件,通常 < 1s |
| 原理 | Bundle-first | ESM on-demand |
1.3 热更新(HMR)
- Webpack:文件变更后,需要重新构建受影响的模块链并替换 bundle,项目越大越慢。
- Vite:只失效变更文件及其直接依赖链上的模块,通过 WebSocket 推送更新,浏览器重新 import 即可。因为本身就是按需加载的,HMR 粒度天然更细。
1.4 底层依赖
| 阶段 | Webpack | Vite |
|---|---|---|
| 开发构建 | Webpack 自己(JS 实现) | esbuild(Go 实现,快 10-100 倍)做预构建 |
| 生产打包 | Webpack / Terser | Rollup(成熟的 ES module 打包器) |
为什么 Vite 不用 esbuild 打包生产环境? esbuild 虽然极快,但在旧版本js代码支持,代码分割、Tree Shaking 的精细控制方面不如 Rollup 成熟。Vite 选择 Rollup 作为生产构建引擎是务实的选择。
1.4.1 依赖预构建的两大作用(关键技术)
Vite 的依赖预构建解决两个核心问题:
① 格式兼容 — CJS/UMD → ESM
很多第三方依赖(特别是老牌 npm 包)发布的是 CommonJS 或 UMD 格式。浏览器原生只支持 ESM,无法直接加载 CJS 模块。esbuild 在预构建阶段将这些包统一转换为浏览器可识别的 ESM 格式。
// node_modules 中的 CJS 包
module.exports = function sum(a, b) { return a + b }
// 预构建后(node_modules/.vite/deps/sum.js)
export default function sum(a, b) { return a + b }
② 性能优化 — 合并细碎模块
有些包内部由成百上千个独立文件组成(典型如 lodash-es,每个工具函数一个文件)。如果不做合并,浏览器将发起数百个级联的 HTTP 请求,形成严重的请求瀑布:
浏览器 import lodash-es → 请求 /node_modules/lodash-es/add.js → 请求 /node_modules/lodash-es/_baseAdd.js → 请求 /node_modules/lodash-es/_createMathOperation.js → ...(数百个请求层层依赖)
Vite 将这些细碎模块预构建为单个或少数几个文件,把成百上千次请求合并成数次,大幅减少浏览器请求带来的性能开销。预构建结果缓存在 node_modules/.vite/deps/,只在依赖变更时重新执行。
1.5 配置复杂度
Webpack:你需要配置 entry、output、loader(如 css-loader、style-loader、babel-loader)、plugin(如 HtmlWebpackPlugin、MiniCssExtractPlugin)。一个中等规模的项目 webpack.config.js 动辄几百行。
Vite:开箱即用。.vue、.ts、.jsx、CSS、静态资源都不需要额外配置。一个典型的 Vite 配置:
// vite.config.js — 本项目实际配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()] // 一行搞定 Vue SFC 支持
})
1.6 生态与兼容性
- Webpack:生态极其庞大,loader 和 plugin 覆盖几乎所有场景。如果你需要兼容 IE11 或做一些非常规的构建操作,Webpack 是更安全的选择。
- Vite:插件生态快速增长中,但一些老旧/小众的 Webpack loader 可能没有直接对应。不过 Vite 的 Rollup 插件兼容层可以复用大量 Rollup 插件。
1.7 总结
| 维度 | Webpack | Vite |
|---|---|---|
| 开发模式 | Bundle-first | ESM on-demand |
| 冷启动 | 慢(全量打包) | 快(按需编译) |
| HMR | 随项目规模递减 | 几乎恒定 |
| 预构建 | 无 | esbuild(Go) |
| 生产打包 | Webpack + Terser | Rollup |
| 配置复杂度 | 高 | 低 |
| 生态成熟度 | 极高 | 快速增长 |
| 浏览器要求 | 无限制 | 需支持 ESM(现代浏览器) |
一句话总结:Vite 用「开发时不打包 + esbuild 预构建 + Rollup 生产打包」的组合拳,在开发体验上对 Webpack 形成了代际优势;Webpack 则在兼容性和极端定制场景上仍有不可替代的地位。
2. Vite 的核心工作原理是什么?
2.1 开发阶段
- 启动 dev server:Vite 启动一个基于 connect 的 HTTP 服务器。
- 预构建依赖:用 esbuild 把
node_modules中的 CommonJS/UMD 依赖转换为 ESM,并合并细碎模块(如 lodash-es 的几百个文件)以减少浏览器请求数。 - 源码按需编译:浏览器请求
src/main.js→ Vite 拦截 → 用 esbuild 编译 JS/TS → 返回 ESM 格式。 .vue文件处理:由@vitejs/plugin-vue拦截.vue请求,将其拆分为 template、script、style 三部分分别处理。- HMR:文件变更时,Vite 通过 WebSocket 通知浏览器,浏览器重新请求变更的模块。
2.2 生产构建
直接使用 Rollup 打包,因为 Rollup 在 tree-shaking、代码分割、输出稳定性方面是最好的 ESM 打包器。
3. Vite 的预构建(Pre-bundling)做了什么?为什么需要它?
3.1 做了什么
Vite 使用 esbuild 对 node_modules 中的依赖进行预构建:
- CJS → ESM:将 CommonJS/UMD 格式的包转为 ESM。
- 合并碎片模块:有些包(如
lodash-es)由数百个独立文件组成,Vite 将它们合并为单个模块,大幅减少 HTTP 请求。
3.2 为什么需要
- CJS 兼容:浏览器只认识 ESM,而 npm 上有大量 CJS 包。
- 请求瀑布:如果不合并,
lodash-es的一个 import 可能触发几百个级联请求,拖慢页面加载。
3.3 缓存策略
预构建结果缓存在 node_modules/.vite/deps/,只有 package.json 中的依赖列表变更时才会重新执行,开发阶段几乎无感。
4. 为什么 Vite 开发时用 esbuild,生产打包用 Rollup?
| esbuild | Rollup | |
|---|---|---|
| 优势 | 极快(Go 语言,原生并发) | 成熟的 tree-shaking、精细的代码分割控制 |
| 劣势 | 代码分割能力弱,对 ESM 输出的精细控制不够 | 相对较慢 |
| 适用阶段 | 开发——需要快速编译和预构建 | 生产——需要最优的输出质量 |
Vite 不是绑死在一个工具上,而是「谁适合干什么就让它干什么」。
5. Vite 的 HMR(热模块替换)是如何工作的?
- 文件监听:Vite 使用 chokidar 监听文件变更。
- 模块失效:文件变更后,Vite 以该文件为起点,沿着模块依赖图找到所有受影响的模块。
- 边界判定:找到最近的「HMR 边界」(通常是接受热更新的组件/模块),只重载边界内的模块。
- WebSocket 推送:Vite 通过 WebSocket 向浏览器发送更新信息(变更文件路径 + 更新类型)。
- 浏览器处理:客户端 runtime 收到消息后,重新 import 变更的模块并执行框架特定的更新逻辑(如 Vue 的
hot.accept)。
对于 Vue SFC,@vitejs/plugin-vue 为每个 .vue 文件注入 HMR 边界代码,所以修改一个组件只更新它自己,不影响页面状态。
6. Vite 中如何处理环境变量?
Vite 通过 .env 文件加载环境变量,支持四种优先级:
| 文件 | 用途 |
|---|---|
.env |
所有环境共享 |
.env.local |
本地覆盖(应加入 .gitignore) |
.env.[mode] |
特定模式(如 .env.production) |
.env.[mode].local |
特定模式的本地覆盖 |
在代码中通过 import.meta.env 访问:
// 所有变量,VITE_ 前缀的才会暴露给客户端
console.log(import.meta.env.VITE_API_BASE_URL)
// 内置变量
import.meta.env.MODE // 'development' | 'production'
import.meta.env.DEV // boolean
import.meta.env.PROD // boolean
import.meta.env.BASE_URL // 部署基础路径
安全性:只有 VITE_ 前缀的变量会暴露给客户端,这防止了服务端密钥泄露到浏览器。
7. Vite 如何做代码分割(Code Splitting)?
Vite 使用 Rollup 的生产构建能力,代码分割主要通过以下方式:
8.1 动态 import(自动分割)
任何动态 import 都会在打包时自动产生独立的 chunk,无需任何配置:
// 源码
import('./sum.js').then(({ sum }) => {
console.log(sum(1, 2))})
// 打包产物
// dist/assets/sum-xxxx.js ← 自动分离为独立 chunk
打包产物文件名中的 xxxx 是内容哈希,用于浏览器长期缓存。当文件内容不变时,哈希不变,浏览器直接命中缓存。
8.2 手动配置分割策略 — 对象语法
如果你的项目里有一个通用模块需要单独拆包(比如一段多处引用的工具函数),可以通过 manualChunks 手动指定:
// src/test.js
export const sayHello = () => {
console.log('hello')}
// vite.config.js
export default defineConfig({
build: { rollupOptions: { output: { manualChunks: { // key: chunk 名称,value: 模块路径
'test-vendor': './src/test.js' } } } }})
8.3 手动配置分割策略 — 函数语法(更灵活)
对象语法只能匹配精确路径,函数语法可以基于模块 ID 做任意逻辑判断:
// vite.config.js
export default defineConfig({
build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('test')) { return 'test-vendor' // 所有路径含 'test' 的模块合并为一个 chunk } } } } }})
函数语法更适合实际项目:你可以按 node_modules 来源拆分 vendor,按业务域拆分 page chunks,或者按体积阈值动态决定是否拆分。
8. Vite 的 CSS 处理有什么特点?
- 原生 CSS 支持:直接
import './style.css',Vite 自动注入到页面。 - CSS Modules:文件名以
.module.css结尾,自动启用 CSS Modules。 - PostCSS:项目根目录放置
postcss.config.js即可,Vite 会自动应用。 - CSS 代码分割:生产构建时,每个异步 chunk 的 CSS 会被提取为独立文件,按需加载。
@import内联与 rebase:@import和url()路径会自动重写,避免开发与生产路径不一致。- 预处理器:安装
sass或less即可直接使用.scss、.less文件,无需额外配置 loader。
9. Vite 的静态资源处理策略是怎样的?
- 小资源(< 4KB):自动转为 base64 内联,减少 HTTP 请求。
- 大资源:复制到
dist/assets/,文件名包含 hash 用于缓存。 public目录:此目录下的资源不会被处理,直接复制到dist/根目录,适合robots.txt、favicon.ico等。new URL:支持通过new URL('./img.png', import.meta.url)动态引用资源。
10. 如何调试 Vite 的自定义插件?
-
添加
name:每个插件必须有唯一的name,方便定位问题。 -
在项目中引入 vite-plugin-inspect
-
使用 console.log:在插件的各个钩子中添加日志,观察执行时机和参数。
启用 debug 模式:DEBUG=vite:* vite ```5. **使用 Vite 的 `apply` 选项**:可以限制插件只在开发或生产环境生效: ```js { name: 'dev-only-plugin', apply: 'serve' // 或 'build',分别限制开发/生产 }
11. Vite 项目从开发到部署的完整流程是怎样的?
# 开发
npm run dev # vite — 启动 dev server,ESM 按需编译 + HMR
# 构建
npm run build # vite build — Rollup 打包,输出到 dist/
# 本地预览生产构建
npm run preview # vite preview — 在本地启动静态服务器预览 dist/
典型部署:将 dist/ 目录部署到 CDN 或静态服务器(Nginx、Vercel、Netlify 等)。
对于 SPA 应用,需要配置 fallback 到 index.html:
location / {
try_files $uri $uri/ /index.html;}
12. 有没有了解微内核设计?Vite 的插件化设计思想是怎样完成个性化打包构建需求的?
12.1 什么是微内核架构
微内核(Microkernel)是一种架构模式,核心思想是:内核只提供最精简的基础能力,所有扩展功能通过插件机制加载。操作系统领域最经典的例子是 Minix 和 macOS 的 XNU 内核。
在前端构建工具领域,这个思想同样适用:
┌──────────────────────────────────────────┐
│ 插件层(Plugin Layer) ││ Vue SFC │ JSX │ TS │ CSS │ ... │ ├──────────────────────────────────────────┤
│ 微内核(Minimal Core) ││ dev server │ HMR │ module graph │
│ esbuild预构建 │ 中间件机制 │└──────────────────────────────────────────┘
12.2 Vite 的微内核设计
Vite 的核心(内核)只做几件事:
| 内核能力 | 说明 |
|---|---|
| Dev Server | 基于 connect 的 HTTP 服务器,拦截浏览器请求 |
| 模块图谱 | 追踪所有模块的依赖关系,驱动 HMR |
| esbuild 预构建 | CJS → ESM 转换 + 细碎模块合并 |
| 中间件/钩子系统 | 提供插件接入点,但不做具体编译 |
Vue SFC 编译、JSX 转换、TypeScript 类型检查、CSS 预处理 —— 这些全部由插件完成,不在内核里。 这就是微内核思想:内核保持精简稳定,功能由插件按需扩展。
12.3 插件机制如何实现个性化构建
Vite 的插件体系基于 Rollup 插件接口 并做了增强,通过一系列生命周期钩子让开发者在构建各阶段注入自定义逻辑。
钩子执行顺序(简化):
config → configResolved → options → buildStart
→ resolveId → load → transform → moduleParsed → buildEnd → outputOptions → renderChunk → generateBundle → writeBundle```
**① 通用钩子(Rollup 兼容层)**
```js
{
name: 'my-plugin', resolveId(id) {}, // 模块解析(虚拟模块入口)
load(id) {}, // 模块加载(返回自定义内容)
transform(src, id) {} // 代码转换(最常用)
}
② Vite 独有钩子
{
name: 'my-vite-plugin', config(config) {}, // 修改用户配置
configureServer(server) {}, // dev server 注入中间件
handleHotUpdate(ctx) {} // 自定义 HMR 行为
}
enforce 控制执行顺序: pre → 核心插件 → 普通(默认) → post → Vite 内置插件
12.4 一个完整的个性化构建案例
回到本项目的 helloPlugin,它实现了一个个性化需求——「生产环境自动移除 console.log」:
// vite.config.js
function helloPlugin() {
return { name: 'helloPlugin', // 接入 transform 钩子,拿到每个模块的源码
transform(src, id) { if (!id.includes('/src/')) return null // 只处理业务代码
if (process.env.NODE_ENV === 'production') { const result = src.replace(/console\.log\([^)]*\);?/g, '') return { code: result, map: null } } return null // 不处理则透传
} }}
这个插件展示了插件化的核心价值:不需要修改 Vite 源码,不需要 fork 项目,只需写一个函数接入钩子,就能定制打包行为。 这种「内核不动、插件扩展」的模式,正是微内核架构在前端工程化中的最佳实践。
12.5 总结:Vite 的插件化设计思想怎样完成个性化打包构建需求?
一个结构化的回答应包含以下几点:
- 架构层面:Vite 采用了微内核设计。它的核心非常轻量,仅包含一个 ESM 开发服务器和一套插件加载机制。所有高层功能,如框架支持、CSS 预处理、特定文件加载等,都由独立的插件实现。
- 接口层面:Vite 提供了一套兼容 Rollup 的、统一的插件接口。这套接口通过暴露一系列生命周期钩子 (Hooks),允许开发者在构建过程的各个关键节点(如配置解析、模块加载、代码转换、HTML 生成等)注入自定义逻辑。
- 实践层面:开发者可以编写一个插件,通过选择合适的钩子来完成特定任务。例如,使用
transform钩子可以支持一种新的语言或文件格式;使用configureServer钩子可以在开发时添加自定义的服务器中间件;使用transformIndexHtml则可以动态修改主页面的内容。
13. 站在前端架构角度说说 Bundleless 原理
14.1 什么是 Bundleless
传统的 Bundle 模式(Webpack 为代表)是「先打包再启动」:
源码 → 打包器(Bundle)→ 一个/多个 bundle 文件 → 浏览器加载
Bundleless 模式(Vite 为代表)跳过了打包这一步:
源码 → 开发服务器按需编译 → 浏览器通过 ESM 直接加载
14.2 为什么 Bundleless 现在才成为主流
Bundleless 不是新概念——ESM 规范早在 2015 年就发布了。之前无法普及的原因:
| 时间 | 瓶颈 | 现状 |
|---|---|---|
| 2015-2018 | 浏览器 ESM 支持率低(IE 占主流) | IE 已淘汰,现代浏览器全覆盖 |
| 2015-2019 | npm 包几乎全是 CJS 格式 | 新包逐渐提供 ESM 入口,且有 esbuild 做转换 |
| 2015-2019 | HTTP/1.1 并发连接有限(6个/域名) | HTTP/2 多路复用,数百个请求不再是瓶颈 |
三个条件同时满足——浏览器 ESM 支持、npm 生态转换能力、HTTP/2 普及——Bundleless 才真正可行。
14.3 Bundleless 的架构优势
① 按需编译,冷启动 O(1)
传统打包:启动时间 ∝ 项目模块数。Bundleless:只编译浏览器当前请求的文件,启动几乎恒定。
② HMR 粒度天然匹配 ESM 边界
每个文件就是一个 ESM 模块,热更新不需要重新打包 bundle,只需要让浏览器重新 import 变更的那个文件。
③ 开发与生产的明确分工
开发阶段(Bundleless) 生产阶段(Bundled)
───────────────────── ─────────────────────
不打包,按需编译 Rollup 全量打包
esbuild 做预构建 Tree-shaking ESM 原生加载 代码分割 + 压缩
追求开发体验 追求加载性能```
这两个阶段的目标本就不同:开发要快,生产要小。Bundleless 把这个分工理清了。
### 14.4 Bundleless 的工程代价
从架构角度,不是所有场景都适合 Bundleless:
| 场景 | Bundleless 是否适用 | 原因 |
|---|---|---|
| 现代浏览器开发 | ✅ 完美 | ESM + HTTP/2 |
| 需要兼容 IE | ❌ 不行 | IE 不支持 ESM |
| 巨型 node_modules | ⚠️ 需预构建 | 否则请求数爆炸 |
| 非 JS 资源(CSS/图片) | ⚠️ 需额外处理 | ESM 只管 JS |
这也是为什么 Vite 不是纯 Bundleless——它用预构建和 Rollup 生产打包弥补了 Bundleless 的短板。**务实的设计比纯粹的理念更重要。**
扩展思考:如果你来设计一个类似的工具,会怎么做?
这通常是大厂面试的进阶追问。可以从以下角度组织回答:
- 利用浏览器原生能力:现代浏览器已经支持 ESM、ES2020+ 语法,开发阶段不需要向下兼容。
- 语言选择:高频编译用 Go/Rust 实现的工具(如 esbuild、SWC),生产打包用成熟的 JS 生态工具。
- 按需编译:改变「先打包再启动」的思路,变成「请求时编译」。
- 插件兼容:设计插件接口时尽量对齐已有标准(如 Rollup 插件格式),降低迁移成本。
14. Webpack 的 Loader 和 Plugin 有什么区别?各自的工作原理是什么?
14.1 核心区别
| 维度 | Loader | Plugin |
|---|---|---|
| 职责 | 模块内容转换——把非 JS 文件转为 JS 模块 | 构建流程扩展——在打包各阶段注入自定义逻辑 |
| 作用对象 | 单个文件(模块) | 整个构建流程 |
| 本质 | 一个函数,接收源码,返回转换后的代码 | 一个类(或包含 apply 方法的对象) |
| 执行时机 | 模块加载时,在文件被添加到依赖图之前 | 通过钩子在编译各阶段触发 |
| 典型例子 | css-loader、babel-loader、vue-loader |
HtmlWebpackPlugin、MiniCssExtractPlugin |
一句话:Loader 管"翻译"(把各种文件翻译成 JS),Plugin 管"编排"(控制打包流程的各个环节)。
14.2 Loader 的工作原理
Loader 是一个 Node.js 函数,它接收源文件内容,返回转换后的结果:
// 一个最简单的 loader:把文件内容转为大写
module.exports = function (source) {
return source.toUpperCase()
}
Loader 支持链式调用,从右到左(或从下到上)依次执行:
处理顺序:css-loader → style-loader
写法:use: ['style-loader', 'css-loader']
// ←────────── 从右到左 ──────────
每个 loader 必须做三件事之一:返回转换后的代码、返回 this.callback()(支持 source map)、或通过 this.async() 转为异步。
常见 Loader 分类:
| 类型 | Loader 示例 | 作用 |
|---|---|---|
| 编译类 | babel-loader |
ES6+ → ES5 |
| 样式类 | css-loader |
解析 CSS 中的 @import 和 url() |
| 文件类 | file-loader |
把文件复制到输出目录并返回 URL |
| 模板类 | vue-loader |
解析 .vue 单文件组件 |
14.3 Plugin 的工作原理
Plugin 基于 Webpack 的 Tapable 事件机制。Webpack 在编译的各个阶段广播事件,Plugin 通过监听这些事件介入构建:
class MyPlugin {
apply(compiler) {
// compiler.hooks 提供了数十个生命周期钩子
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
// compilation: 本次编译的上下文,可访问所有模块和产物
// 在这里修改产物、添加文件等
callback()
})
}
}
Webpack 的编译生命周期(关键钩子):
environment → afterEnvironment → entryOption → afterPlugins → afterResolvers
→ initialize → run → compile → compilation → make → finishMake
→ afterCompile → emit → afterEmit → done
Plugin 能做的典型事情:
HtmlWebpackPlugin:在emit阶段生成 HTML 文件并自动注入打包后的 JS/CSS 引用MiniCssExtractPlugin:在afterCompile阶段把 CSS 从 JS bundle 中提取为独立文件DefinePlugin:在compilation阶段做编译时常量替换
14.4 面试怎么答
- 先说清楚二者本质区别:Loader 是文件级转换器,Plugin 是流程级控制器。
- 各举一个熟悉的例子说明(如
css-loader和HtmlWebpackPlugin)。 - 如果需要,简述 Loader 的链式调用和 Plugin 的 Tapable 钩子机制。
15. Tree Shaking 的原理是什么?为什么必须基于 ESM?
15.1 什么是 Tree Shaking
Tree Shaking(摇树优化)指消除没有被使用的代码(dead code elimination)。名字来源于:把应用比作一棵树,用到的代码是绿叶,没用到的代码是枯叶;摇晃这棵树,枯叶就会掉下来。
源代码 Tree Shaking 后
┌───────────────────┐ ┌───────────────────┐
│ export const a │ │ export const a │
│ export const b ✗ │ ──→ │ │
│ export const c │ │ export const c │
│ function d() ✗ │ │ │
└───────────────────┘ └───────────────────┘
15.2 为什么必须基于 ESM
Tree Shaking 的前提是编译时能确定模块的导入导出关系。这要求模块语法必须是静态的:
| 模块系统 | 导入语法 | 能否做 Tree Shaking | 原因 |
|---|---|---|---|
| ESM | import/export |
✅ 能 | 静态声明——编译时就能确定依赖关系 |
| CommonJS | require() |
❌ 不能 | 动态调用——require() 可以放在 if/for/函数里,运行时才知道 |
// ESM — 静态,编译时即可分析
import { sum } from './math' // 语法层面必须写在顶层
// CJS — 动态,运行时才能确定
const modulePath = condition ? './a' : './b'
const result = require(modulePath) // require 就是一个函数调用
15.3 实现原理
以 Rollup/Webpack 为例,Tree Shaking 分三步:
- 标记(Mark):从入口开始,遍历所有模块的
import/export,标记哪些导出被使用了。 - 清除(Sweep):删除没有被标记的导出语句及其关联的代码。
- 副作用处理:如果一个模块有顶层副作用(如
console.log()、修改全局变量),即使没有导出被使用,整个模块也不会被摇掉。
// package.json 中声明副作用可以优化 Tree Shaking
{
"sideEffects": false // 告诉打包器:所有模块都无副作用,放心摇
// 或指定具体文件
"sideEffects": ["*.css", "*.global.js"]
}
15.4 Webpack 和 Rollup 的差异
| Webpack | Rollup | |
|---|---|---|
| 实现方式 | 通过 usedExports + TerserPlugin |
原生支持,更彻底 |
| 产物 | 更"扁"——模块合并后再压缩 | 更"净"——尽可能保留 ESM 结构 |
| 生产效果 | 良好,但可能保留一些薄弱的引用 | 极简,几乎零冗余 |
这也是 Vite 选择 Rollup 做生产打包的原因之一:Rollup 的 Tree Shaking 天然更干净。
16. 什么是 Source Map?不同模式如何选择?
16.1 为什么需要 Source Map
构建工具会把源码编译、混淆、压缩成一个(或几个)bundle。浏览器中报错时,错误堆栈指向的是打包后的代码——行号、变量名都和源码对不上。Source Map 是一个映射文件,把打包产物的每一行/列映射回源码的对应位置。
源码 (main.js) 打包产物 (bundle.js) Source Map (.map)
┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ 1: const a = 1 │ │ 1: !function(){ │ │ bundle.js L1:C10 │
│ 2: console.log │ ───→ │ 2: const a=1, │ ───→ │ → main.js L1:C7 │
│ 3: (a + 2) │ │ 3: console │ │ bundle.js L3:C5 │
│ │ │ 4: .log(a+2)│ │ → main.js L2:C9 │
└────────────────┘ └─────────────────┘ └──────────────────┘
17. 前端模块化的发展历程:从 IIFE 到 ESM
17.1 为什么需要模块化
前端代码从"一个 HTML 里写几百行 JS"到"几万行代码分布在几十个文件",如果不做模块化,会遇到:
- 全局命名冲突:两个文件定义了同名变量,互相覆盖
- 依赖管理混乱:
<script>标签的加载顺序必须正确,手动维护极其脆弱
17.2 演进脉络
IIFE → CommonJS (2009, Node.js) → AMD (2011, 浏览器)
→ UMD (2011, 兼容) → ES Module (2015, 标准) → 今天
① IIFE(立即执行函数表达式)
// 用闭包隔离作用域,通过参数传入依赖
var myModule = (function ($) {
var privateVar = 'secret'
return {
doSomething: function () { $('body').text('hello') }
}
})(jQuery)
- ✅ 解决了全局命名冲突
- ❌ 依赖顺序仍需手动管理,没有标准的加载机制
② CommonJS(Node.js 原生支持)
// math.js
module.exports = { sum: (a, b) => a + b }
// app.js
const { sum } = require('./math')
- ✅ 同步加载,适合服务端(文件在本地磁盘)
- ❌ 不适合浏览器——
require()是同步的,在浏览器中会阻塞渲染
③ AMD(Asynchronous Module Definition)
// 专门为浏览器设计,异步加载
define(['jquery', './math'], function ($, math) {
return { value: math.sum(1, 2) }
})
- ✅ 异步加载,不阻塞页面
- ❌ 写法繁琐,嵌套多了可读性差
④ UMD(Universal Module Definition)
一套兼容 CommonJS + AMD + 全局变量的样板代码,让一个模块同时运行在 Node.js 和浏览器中。今天的很多 npm 包仍然发布 UMD 格式。
⑤ ES Module(ES2015,今天的标准)
// math.js
export const sum = (a, b) => a + b
// app.js
import { sum } from './math'
- ✅ 静态语法——编译时即可做 Tree Shaking、代码分析
- ✅ 浏览器原生支持——
<script type="module">直接可用 - ✅ 标准化——不依赖任何第三方库
17.3 总结对比
| 模块系统 | 加载方式 | 适用环境 | 静态分析 | 当前状态 |
|---|---|---|---|---|
| IIFE | 手动 | 浏览器 | ❌ | 历史遗留 |
| CommonJS | 同步 | Node.js | ❌ | Node.js 仍广泛用 |
| AMD | 异步 | 浏览器 | ❌ | 基本淘汰 |
| UMD | 兼容 | 通用 | ❌ | npm 包仍大量使用 |
| ES Module | 静态声明 | 所有环境 | ✅ | 现在的标准 |
18. Babel 的工作原理(解析 → 转换 → 生成)及常用配置
18.1 Babel 做了什么
Babel 是一个 JavaScript 编译器,它的核心任务是把新语法写的代码转换成旧环境能运行的代码:
// 输入:ES6+ 箭头函数 + const
const double = (n) => n * 2
// 输出:ES5
var double = function (n) { return n * 2 }
18.2 三阶段工作流
源码(字符串)
↓
【① Parse 解析】
↓
AST(抽象语法树)
↓
【② Transform 转换】← Plugin 在这一步介入
↓
新的 AST
↓
【③ Generate 生成】
↓
目标代码(字符串)
① Parse(解析):把字符串代码解析为 AST。Babel 使用 @babel/parser(巴比伦),这一阶段分为词法分析(分词)和语法分析(构建语法树)。
② Transform(转换):遍历 AST,对感兴趣的节点做增删改。这是 Babel 插件工作的核心阶段。每个 Plugin 就是一个 visitor——告诉 Babel"遇到 XX 类型的 AST 节点时做什么"。
// 一个极简 Babel 插件:把所有标识符 name 反转
const babel = require('@babel/core')
const reversePlugin = {
visitor: {
Identifier(path) {
path.node.name = path.node.name.split('').reverse().join('')
}
}
}
babel.transformSync('const hello = "world"', { plugins: [reversePlugin] })
// → "const olleh = "world";"
③ Generate(生成):把转换后的 AST 重新生成为字符串代码,同时生成 Source Map。
19. 如何设计一个前端项目的 CI/CD 流水线?
19.1 典型流水线阶段
代码推送 → Lint 检查 → 单元测试 → 构建 → 部署
Push Lint Test Build Deploy
─────────────────────────────────────────────────→
← 任何阶段失败,阻止后续,通知开发者
19.2 各阶段详解
① Lint & 格式检查
# .github/workflows/ci.yml (GitHub Actions 示例)
- name: Lint
run: |
npx eslint src/ --ext .js,.ts,.vue
npx prettier --check src/
npx stylelint "src/**/*.{css,scss}"
- ESLint:代码逻辑规则检查(未使用变量、禁止 console 等)
- Prettier:代码格式一致性检查
- Stylelint:CSS/SCSS 代码规范
② 类型检查(TypeScript)
npx tsc --noEmit # 只做类型检查,不产出 JS 文件
即使 Vite/esbuild 在构建时不检查类型(只做语法转换),CI 中也必须独立跑一次 tsc --noEmit,防止类型错误流入生产。
③ 单元测试 & 覆盖率
npx vitest run --coverage
设置覆盖率门槛,低于阈值阻断流水线:
// vitest.config.ts
{
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80
}
}
}
④ 构建
npm run build # 触发 vite build / webpack build
构建产物通常上传为 artifact,供后续部署阶段使用。
⑤ 部署
根据环境不同选择策略:
| 环境 | 策略 |
|---|---|
| 开发环境 | 合并到 dev 分支后自动部署 |
| 预发环境 | 合并到 release 分支后自动部署 |
| 生产环境 | 打 tag 后手动审批 + 部署 |
⑥ 通知
- 失败 → 企业微信/钉钉/Slack 通知 + @相关人员
- 成功 → 更新部署日志,通知(可选)
19.3 进阶实践
- 缓存依赖:CI 中缓存
node_modules,大幅缩短安装时间 - 增量构建:使用 Nx/Turborepo 等工具,只构建受影响的子项目
- 构建产物对比:上传前对比产物大小,异常增长时报警
- E2E 测试:用 Playwright/Cypress 在部署后做端到端验证
20. Monorepo vs Multirepo:前端项目的代码组织方式
20.1 两个概念
| Monorepo | Multirepo | |
|---|---|---|
| 定义 | 多个项目放在一个仓库中 | 每个项目独立仓库 |
| 依赖管理 | 共享 node_modules,统一版本 |
各自独立的依赖 |
| 工具 | pnpm workspaces / Turborepo / Nx | 各自独立的 package.json |
| 典型用户 | Vue/Vite/React/Babel | 传统企业项目 |
20.2 Monorepo 的核心优势
① 代码共享零摩擦
# Multirepo:改一个共享组件,要走 3-4 步
1. 改 shared-utils 仓库 → 发 npm 包
2. 项目 A 升级 shared-utils 版本
3. 项目 B 升级 shared-utils 版本
4. 如果 A 和 B 不兼容新 API → 继续修
# Monorepo:改完即生效
packages/
shared-utils/ ← 改了这里
project-a/ ← 自动看到变更
project-b/ ← 自动看到变更
② 原子化提交:一个 feature 同时改三个包,一个 commit 搞定,不会出现"忘记升级依赖"的问题。
③ 统一工具链:一个 ESLint 配置、一套构建脚本、一致的 CI 流程,所有子项目共享。
20.3 Monorepo 的挑战
| 挑战 | 解决方案 |
|---|---|
| 仓库体积大 | Git 浅克隆、部分检出 |
| 构建速度 | pnpm workspace + Turborepo 并行构建 |
| 版本管理混乱 | Changesets / Lerna-Lite 管理发布 |
| 权限控制困难 | CODEOWNERS 文件 + 分支保护规则 |
pnpm 的幽灵依赖隔离是杀手特性:在 pnpm 下,package A 不能直接 import package B 的依赖(除非 B 显式声明了 peerDependencies 或 A 自己也安装了),彻底避免了"本地能跑、CI 炸"的问题。
21. npm/pnpm/yarn 的依赖管理机制对比
21.1 安装机制的底层差异
| npm | yarn | pnpm | |
|---|---|---|---|
| 安装方式 | 平铺 + 嵌套(v3 后改进) | 平铺(Plug'n'Play 可选) | 内容寻址存储 + 硬链接 |
node_modules 结构 |
扁平化(有幽灵依赖) | 扁平化(有幽灵依赖) | 非扁平化(严格隔离) |
| 磁盘效率 | 低(每个项目独立复制) | 中(有缓存但仍复制) | 高(全局 store,跨项目共享) |
node_modules 体积 |
~500 MB(中型项目) | ~400 MB | ~150 MB |
21.2 pnpm 的非扁平 node_modules
这是 pnpm 最大的设计差异:
npm/yarn 的 node_modules(扁平化)
node_modules/
react/ ← 你安装的
loose-envify/ ← react 的依赖被提升到顶层,你可以 import
object-assign/ ← 同上
pnpm 的 node_modules(非扁平)
node_modules/
.pnpm/ ← 全局 store 的硬链接
react/ ← 符号链接 → .pnpm/react@18.2.0/node_modules/react/
// loose-envify 不在你的 node_modules 顶层!
// npm/yarn 下能跑,pnpm 下报错
import looseEnvify from 'loose-envify' // ❌ 你没安装它,react 才依赖它
幽灵依赖(Phantom Dependency) 指的就是这种"能访问但未声明"的依赖。pnpm 从根本上杜绝了它。
21.3 内容寻址存储(Content-Addressable Storage)
pnpm 的全局 store(~/.pnpm-store)以文件内容的 hash 作为文件名存储:
~/.pnpm-store/v3/
files/
1a/2b3c...hash... ← react@18.2.0 的 index.js(内容哈希)
9f/8e7d...hash... ← 同一个文件即使被 100 个项目引用,也只存一份
项目 node_modules 里的文件都是这个 store 的硬链接(hard link),不占额外磁盘空间。
21.4 lock 文件的作用
| 文件 | 包管理器 | 作用 |
|---|---|---|
package-lock.json |
npm | 锁定整个依赖树的精确版本(包括子依赖) |
yarn.lock |
yarn | 同上 |
pnpm-lock.yaml |
pnpm | 同上,但格式更紧凑 |
为什么需要 lock 文件? 因为 package.json 中的版本号通常是范围(如 ^1.2.3),不同时间安装可能得到不同版本。lock 文件把整棵依赖树的版本固化下来,确保"所有人、所有环境安装的依赖完全一致"。
lock 文件必须提交到 Git。
21.5 实战建议
| 场景 | 推荐 |
|---|---|
| 个人项目 | npm(零配置) |
| Monorepo | pnpm |
| 团队协作,重视稳定性 | pnpm |
| 遗留项目(已有 yarn.lock) | yarn |
| CI/CD 速度要求高 | pnpm |
22. 浏览器缓存策略与前端构建的配合
22.1 两种缓存的角色
| 缓存类型 | 控制字段 | 行为 |
|---|---|---|
| 强缓存 | Cache-Control、Expires |
不发起请求,直接从浏览器缓存读取(200 from disk) |
| 协商缓存 | ETag/If-None-Match、Last-Modified |
发起请求,服务器返回 304 告知"没变,用缓存" |
22.2 前端构建产物命名策略
构建工具的内容哈希(Content Hash) 是实现缓存策略的基石:
dist/
index.html ← 不缓存(或协商缓存)
assets/
index-a3f8b2.js ← 强缓存(一年)
vendor-7c1d9e.js ← 强缓存(一年)
logo-4b2e1f.png ← 强缓存(一年)
核心原则:
文件内容不变 → 文件名(hash)不变 → 浏览器命中缓存 → 不需要下载
文件内容变了 → 文件名(hash)变了 → 浏览器视为新文件 → 必须下载
22.3 Nginx 配置示例
# 带 hash 的静态资源:强缓存,长期有效
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# index.html:协商缓存,确保更新能及时生效
location / {
add_header Cache-Control "no-cache"; # 每次都验证,304 返回
}
为什么 index.html 不能做强缓存? 因为每次构建后,HTML 中引用的 JS/CSS 文件名(hash)会变。如果 HTML 被强缓存,浏览器拿到的是旧 HTML,里面引用的还是旧的 hash 文件名——但旧文件可能已经从服务器删除了,导致白屏。
22.4 模块的缓存策略
| 文件类型 | 策略 | Nginx 配置 |
|---|---|---|
index.html |
协商缓存(no-cache) | Cache-Control: no-cache |
*.js / *.css(带 hash) |
强缓存一年 | Cache-Control: max-age=31536000, immutable |
| 静态图片/字体 | 强缓存一年 | 同上 |
robots.txt |
短期强缓存 | Cache-Control: max-age=86400 |
22.5 更新的原子性问题
情况:用户打开页面 → 此时 HTML 引用 index-a3f8b2.js
→ 你部署了新版本 → HTML 引用 index-9c4b1d.js
→ 用户点击路由跳转 → 懒加载触发 import()
→ 请求动态 chunk 时,可能已经在服务端被替换了
解决方案:
- 保留最近 N 个版本的构建产物(不立即删除旧文件)
- Nginx 配置 5 分钟的重试窗口
- 或者在 HTML 中内联版本检查逻辑
23. 前端性能优化 — 构建层面能做哪些事?
23.1 构建优化全景图
构建产物优化
├── 代码层面
│ ├── Tree Shaking(摇掉无用代码)
│ ├── 代码分割(按路由/组件懒加载)
│ ├── 压缩混淆(Terser/esbuild)
│ └── 常量替换(DefinePlugin)
├── 资源层面
│ ├── CSS 提取 & 压缩
│ ├── 图片压缩 & WebP 转换
│ ├── 小资源 base64 内联
│ ├── 字体子集化(只保留用到的字符)
│ └── SVG 优化(svgo)
├── 依赖层面
│ ├── 依赖分析(找出大包替代品)
│ ├── external(CDN 外置大型库)
│ └── 按需引入(如 lodash-es → import { debounce })
└── 加载策略
├── preload/prefetch(提前加载关键资源)
├── 异步加载(dynamic import)
└── module/nomodule(差异化构建)
23.2 代码层面的具体做法
① Tree Shaking 前置条件
// ❌ 副作用写法——整个模块不会被摇掉
import './polyfills' // 无显式导出,有副作用
// ✅ 在 package.json 中声明
{ "sideEffects": ["./src/polyfills.js"] }
② 路由级代码分割(Vue 3 + Vite)
// router/index.js
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue') // 独立 chunk
},
{
path: '/settings',
component: () => import('@/views/Settings.vue') // 独立 chunk
}
]
每个路由页面单独打包,首屏只加载首页的代码。
③ 第三方库的按需引入
// ❌ 打包整个 lodash(~70KB gzip)
import _ from 'lodash'
// ✅ 只引入用到的函数(~2KB gzip)
import debounce from 'lodash-es/debounce'
// ✅ 或者用 tree-shakable 的替代品
import { debounce } from 'radash' // 天然支持 tree-shaking
23.3 依赖分析工具
| 工具 | 作用 |
|---|---|
rollup-plugin-visualizer(Vite/Rollup) |
生成可视化 treemap,一眼看出哪个包大 |
webpack-bundle-analyzer(Webpack) |
同上 |
vite-plugin-inspect |
查看 Vite 的模块转换过程 |
npm why <package> / pnpm why |
查看某个依赖为什么被安装了 |
23.4 关键指标目标
| 指标 | 目标值 | 说明 |
|---|---|---|
| 首屏 JS 总体积 | < 200KB gzip | 包括框架 + 业务代码 |
| 首屏 CSS | < 50KB gzip | 关键 CSS 应内联 |
| 单个 chunk | < 100KB gzip | 大包考虑拆分 |
| 图片总大小 | < 500KB | 使用 WebP,懒加载 |
| Time to Interactive | < 3.5s(3G) | 用户可交互时间 |
23.5 Vite 中的构建优化配置
// vite.config.js
export default defineConfig({
build: {
target: 'es2015', // 不转译现代语法,减小体积
cssCodeSplit: true, // CSS 按 chunk 拆分
chunkSizeWarningLimit: 500, // 提高 chunk 大小警告阈值
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue')) return 'vue-vendor'
if (id.includes('echarts')) return 'echarts-vendor'
return 'vendor'
}
}
}
}
})
24. SSR(服务端渲染)的原理是什么?与 CSR/SSG 有什么区别?
24.1 三种渲染模式对比
| CSR(客户端渲染) | SSR(服务端渲染) | SSG(静态站点生成) | |
|---|---|---|---|
| 渲染地点 | 浏览器 | 服务端 | 构建时(服务端) |
| 首屏速度 | 慢(先下载 JS,再渲染) | 快(直接返回 HTML) | 最快(纯静态 HTML) |
| SEO | ❌ 差(爬虫看到空壳) | ✅ 好(HTML 已包含内容) | ✅ 最好 |
| 服务器压力 | 低(只提供静态文件) | 高(每次请求都要渲染) | 低(CDN 即可) |
| 适用场景 | 后台管理系统、需要登录的 SPA | 内容型网站、电商、需要 SEO 的 SPA | 博客、文档站、营销页 |
| 代表框架 | 纯 Vue/React | Nuxt.js / Next.js | Next.js / Astro / VitePress |
24.2 SSR 的核心流程
浏览器请求 /page
↓
服务端收到请求
↓
运行 Vue/React 组件 → 生成完整 HTML 字符串
↓
返回 HTML(页面内容已包含在内)
↓
浏览器渲染 HTML(用户看到内容了!)
↓
同时下载 JS bundle
↓
Hydration(水合):Vue/React 接管 DOM,页面变为可交互
Hydration 是什么? 服务端渲染的 HTML 是"死的"(纯 HTML,没有事件绑定)。浏览器加载 JS 后,Vue/React 需要把这棵静态 DOM 树"激活"——绑定事件、建立响应式系统。这个过程叫 Hydration(水合)。
// Nuxt 3 中一个 SSR 页面的生命周期
// 1. 服务端执行 → 生成 HTML
// 2. 发送到浏览器
// 3. 浏览器执行 Hydration
<script setup>
// 这段代码在服务端和客户端各执行一次
const { data } = await useFetch('/api/posts') // 服务端 fetch 一次,数据嵌入 HTML
</script>
24.3 SSR 的工程化挑战
| 挑战 | 说明 | 解决方案 |
|---|---|---|
| 服务端无 DOM | window/document 在 Node.js 中不存在 |
用 import.meta.client / process.client 守卫 |
| 数据同步 | 服务端获取的数据要传递给客户端,避免重复请求 | Nuxt 的 useFetch / Next 的 getServerSideProps |
| 内存泄漏 | 服务端每个请求都创建 Vue 实例,容易泄漏 | 使用工厂函数创建实例,避免单例 |
| 缓存策略 | 每次请求都渲染太慢 | 页面级缓存(Redis)、组件级缓存 |
| 部署复杂度 | 需要 Node.js 服务器,而不是纯静态文件 | Serverless(Vercel/Netlify)、容器化 |
24.4 什么时候选哪种模式
需要 SEO + 实时数据? → SSR(电商、新闻、社区)
需要 SEO + 内容不常变?→ SSG(博客、文档、官网)
不需要 SEO? → CSR(后台管理系统、工具型应用)
混合场景? → ISR / 混合渲染(部分页面 SSR,部分 SSG)
25. 如何做前端项目的依赖治理?
25.1 依赖治理的三个维度
依赖治理
├── 安全维度 — 漏洞修复(npm audit)
├── 版本维度 — 升级策略 & 兼容性管理
└── 体积维度 — 依赖瘦身 & 重复检测
25.2 安全漏洞修复
# 审计当前项目依赖
npm audit # 查看漏洞列表
npm audit fix # 自动修复(仅 semver 兼容的补丁版本)
npm audit fix --force # 强制修复(可能包含 breaking change)
# pnpm
pnpm audit
生产环境的漏洞处理流程:
1. npm audit → 发现高危/严重漏洞
2. 查看是否影响运行时(devDependencies 的漏洞可能无需紧急处理)
3. 尝试 npm audit fix(不破坏项目的前提下自动升级)
4. 如果 fix 失败 → 手动升级单个包 → 跑测试 → 部署
5. 建立定期审计机制(CI 中跑 npm audit --audit-level=high)
25.3 版本升级策略
| 策略 | 做法 | 风险 | 适用场景 |
|---|---|---|---|
| 激进升级 | 经常升级到最新版 | 高 | 个人项目 |
| 保守升级 | 只在需要新功能/修复时升级 | 低 | 生产项目 |
| 定期批处理 | 每月/每季度集中升级一次 | 中 | 推荐 |
| 工具辅助 | Renovate / Dependabot 自动提 PR | 中 | 团队协作 |
Renovate 配置示例:
// renovate.json
{
"extends": ["config:base"],
"schedule": ["before 8am on Monday"], // 每周一早上自动提 PR
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true // patch 自动合并
},
{
"matchDepTypes": ["devDependencies"],
"automerge": true // 开发依赖自动合并
}
]
}
25.4 依赖瘦身
① 找出重复/冗余依赖
npx depcheck # 找出来安装但未使用的包
npx npm-dedupe # 去重 npm 依赖树
pnpm dedupe # pnpm 去重
② 分析依赖体积
# 可视化分析
npx vite-bundle-visualizer # Vite 项目
npx webpack-bundle-analyzer # Webpack 项目
# 命令行快速查看
npx cost-of-modules # 每个包的大小
③ 替代重型依赖
| 重包 | 轻量替代 | 体积差异 |
|---|---|---|
moment |
dayjs |
72KB → 2KB |
lodash |
lodash-es + 按需引入 |
全量 70KB → 按需 2-3KB |
axios |
ky / ofetch |
13KB → 3KB |
antd |
按需引入 + tree-shaking | 视情况定 |
25.5 CI 中的依赖质量门禁
# GitHub Actions 示例
- name: Dependency Check
run: |
npm audit --audit-level=high # 高危漏洞阻断 CI
npx depcheck --ignores="eslint,prettier" # 检查未使用依赖
npx bundlesize # 检查产物大小是否超标
26. dev server proxy 的原理是什么?跨域问题有哪些工程化解决方案?
26.1 为什么会有跨域问题
浏览器的同源策略(Same-Origin Policy):协议、域名、端口三者必须完全相同才能自由通信。开发时,Vite dev server 跑在 localhost:5173,后端 API 跑在 localhost:3000——端口不同,跨域了。
开发阶段 生产阶段
localhost:5173 ──跨域──→ api.example.com 前端域名: app.example.com
(前端) (后端) 后端域名: api.example.com
不同子域名也跨域!
26.2 三种工程化方案
方案一:dev server proxy(开发环境首选)
// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
原理:浏览器请求 localhost:5173/api/users → Vite dev server 收到 → 转发给 localhost:3000/users → 拿到响应 → 返回浏览器。整个过程对浏览器透明,浏览器只和同源的 localhost:5173 通信,不存在跨域。
方案二:CORS(服务端配置)
// 后端 Express 示例
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'https://my-app.com')
res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE')
res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.header('Access-Control-Allow-Credentials', 'true')
next()
})
- ✅ 标准方案,任何环境都可用
- ⚠️ 需要后端配合,不能配置
*时还要处理携带 cookie 的场景
方案三:Nginx 反向代理(生产环境推荐)
server {
listen 80;
server_name app.example.com;
# 前端静态文件
location / {
root /var/www/dist;
try_files $uri /index.html;
}
# API 代理到后端
location /api/ {
proxy_pass http://api-server:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
- ✅ 前端和后端在同一个域名下,完全不存在跨域
- ✅ 生产环境的最佳实践
26.3 方案选择指南
| 阶段 | 推荐方案 | 原因 |
|---|---|---|
| 本地开发 | dev server proxy | 零配置、即时生效、真实模拟 |
| 生产环境 | Nginx 反向代理 | 同域部署,无跨域,性能好 |
| 跨域 API | CORS + 白名单 | 前后端分开部署时的标准方案 |
| 临时调试 | 浏览器插件关掉 CORS | ⚠️ 仅限本地调试,绝不用于生产 |
27. 微前端有哪些实现方案?Module Federation 解决了什么问题?
27.1 什么是微前端
微前端(Micro Frontends)是把一个大型前端应用拆分为多个独立的小型应用,每个小型应用可以由不同团队独立开发、测试、部署。
┌─────────────────────────────────────────────┐
│ 基座应用(Shell) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ 子应用 A │ │ 子应用 B │ │ 子应用 C │ │
│ │ (React) │ │ (Vue) │ │ (Angular) │ │
│ │ 团队 A │ │ 团队 B │ │ 团队 C │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
└─────────────────────────────────────────────┘
27.2 主流方案对比
| 方案 | 原理 | 技术栈限制 | 学习成本 | 适用场景 |
|---|---|---|---|---|
| qiankun | 基于 single-spa,JS 沙箱 + CSS 隔离 | 无限制 | 中 | 遗留系统迁移、多技术栈 |
| micro-app | Web Component + Iframe 思想 | 无限制 | 低 | 快速接入、简单场景 |
| wujie(无界) | Web Component + Iframe + 代理 | 无限制 | 低 | 隔离性要求高的场景 |
| Module Federation | Webpack 5 原生,运行时共享模块 | Webpack 5 | 中高 | 新技术栈、同技术栈 |
| Emp | 基于 Module Federation 的微前端框架 | Webpack 5 | 中 | Module Federation 增强 |
27.3 Module Federation 的核心原理
Module Federation 是 Webpack 5 内置的微前端方案,核心思想是运行时共享模块:
// 子应用 A(remote) — webpack.config.js
new ModuleFederationPlugin({
name: 'appA',
filename: 'remoteEntry.js',
exposes: {
'./Header': './src/components/Header.vue', // 暴露组件
'./utils': './src/utils/index.ts' // 暴露工具函数
},
shared: ['vue', 'vue-router'] // 共享依赖,避免重复加载
})
// 基座应用(host) — webpack.config.js
new ModuleFederationPlugin({
name: 'host',
remotes: {
appA: 'appA@http://localhost:3001/remoteEntry.js' // 声明远程模块
}
})
// 基座中使用子应用的组件
import Header from 'appA/Header' // 就像本地模块一样 import!
关键优势:
| 对比维度 | qiankun 类方案 | Module Federation |
|---|---|---|
| 依赖加载 | 每个子应用打包完整的依赖 | 共享依赖(如 Vue 只加载一次) |
| 组件共享 | 需要额外封装或全局注册 | 原生 import,类型安全 |
| 构建方式 | 子应用独立构建部署 | 可以独立构建,也可以联合构建 |
| 运行时性能 | 子应用加载有一定开销 | 几乎零开销 |
27.4 微前端的工程化挑战
| 挑战 | 说明 | 应对 |
|---|---|---|
| CSS 冲突 | 多个子应用的全局样式互相覆盖 | CSS Modules / Shadow DOM / qiankun 沙箱 |
| JS 全局污染 | 子应用修改了 window.xxx,影响其他子应用 |
JS 沙箱(qiankun 的 Proxy 沙箱) |
| 通信机制 | 子应用之间需要共享数据和事件 | 全局 EventBus / 基座下发的 props |
| 路由管理 | 多个子应用的路由需要统一分配和调度 | 基座统一管理 / 各自的子路由 |
| 版本管理 | 基座和子应用版本如何协调 | 基座保持向后兼容,子应用可独立升级 |
28. 前端如何搭建性能与错误监控体系?
28.1 监控体系全景
前端监控体系
├── 性能监控
│ ├── Core Web Vitals(LCP / FID / CLS)
│ ├── 自定义指标(首屏时间、API 响应时间)
│ └── 资源加载(慢资源、资源失败)
├── 错误监控
│ ├── JS 运行时错误(window.onerror)
│ ├── Promise 未捕获异常(unhandledrejection)
│ ├── 框架错误边界(Vue errorHandler / React ErrorBoundary)
│ └── 接口错误(API 超时、5xx、网络异常)
└── 行为监控(辅助排查)
├── 用户操作路径(点击、路由跳转)
└── 录制回放(Sentry Replay / rrweb)
28.2 JS 错误捕获的三种方式
// ① 全局运行时错误
window.addEventListener('error', (event) => {
reportToSentry({
type: 'js-error',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack
})
})
// ② Promise 未捕获异常
window.addEventListener('unhandledrejection', (event) => {
reportToSentry({
type: 'promise-rejection',
reason: event.reason
})
})
// ③ 框架级错误边界
// Vue 3
app.config.errorHandler = (err, instance, info) => {
reportToSentry({
type: 'vue-error',
message: err.message,
stack: err.stack,
component: instance?.$options?.name,
info
})
}
28.3 性能指标采集
// Core Web Vitals 采集
import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals'
onLCP(metric => reportMetric('LCP', metric.value)) // Largest Contentful Paint
onFID(metric => reportMetric('FID', metric.value)) // First Input Delay
onCLS(metric => reportMetric('CLS', metric.value)) // Cumulative Layout Shift
onINP(metric => reportMetric('INP', metric.value)) // Interaction to Next Paint
// 自定义首屏时间(Vue 项目)
onMounted(() => {
nextTick(() => {
const fmp = performance.now()
reportMetric('FMP', fmp)
})
})
// 资源加载监控
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.duration > 3000) { // 资源加载超过 3 秒
reportSlowResource({ name: entry.name, duration: entry.duration })
}
})
})
observer.observe({ entryTypes: ['resource'] })
28.4 常见工具对比
| 工具 | 类型 | 优势 | 适用场景 |
|---|---|---|---|
| Sentry | 自建/云 | 错误聚合 + 堆栈解析 + 源码映射 | 标准选择,错误监控首选 |
| 阿里 ARMS | 云服务 | 性能 + 错误一体化,国内用户友好 | 国内业务、阿里云用户 |
| 腾讯 TAM | 云服务 | 免费额度大,与小程序集成好 | 微信生态、小程序 |
| LogRocket | 云服务 | 录制回放,可复现用户操作 | 复杂 Bug 排查 |
| 自建方案 | 自建 | 可定制、数据安全 | 隐私敏感场景 |
28.5 监控的最佳实践
① 错误分级
- fatal:页面白屏、无法交互 → 立即告警
- error:功能异常但页面可用 → 记录 + 定时汇总
- warning:非预期但可降级 → 日志留存
② Source Map 管理
- 生产环境不发 .map 到 CDN
- .map 只上传到 Sentry 等监控平台
- Sentry 会自动根据堆栈找到原始源码位置
③ 采样策略
- 错误监控:100% 上报(错误不应该漏)
- 性能监控:10-30% 采样(节省成本)
- 用户行为:视隐私合规要求决定
④ 告警规则
- 5 分钟内错误数 > 100 → 紧急通知
- 核心 API 成功率 < 99% → 警告
- LCP 超过 4s 的比例 > 20% → 性能预警