Files
vite-plugin-demo/docs/vite.md
2026-06-27 00:27:46 +08:00

1617 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 格式。
```js
// 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 配置:
```js
// 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 | 随项目规模递减 | 几乎恒定 |
| 预构建 | 无 | esbuildGo |
| 生产打包 | Webpack + Terser | Rollup |
| 配置复杂度 | 高 | 低 |
| 生态成熟度 | 极高 | 快速增长 |
| 浏览器要求 | 无限制 | 需支持 ESM现代浏览器 |
**一句话总结**Vite 用「开发时不打包 + esbuild 预构建 + Rollup 生产打包」的组合拳,在开发体验上对 Webpack 形成了代际优势Webpack 则在兼容性和极端定制场景上仍有不可替代的地位。
---
## 2. Vite 的核心工作原理是什么?
### 2.1 开发阶段
1. **启动 dev server**Vite 启动一个基于 [connect](https://github.com/senchalabs/connect) 的 HTTP 服务器。
2. **预构建依赖**:用 esbuild 把 `node_modules` 中的 CommonJS/UMD 依赖转换为 ESM并合并细碎模块如 lodash-es 的几百个文件)以减少浏览器请求数。
3. **源码按需编译**:浏览器请求 `src/main.js` → Vite 拦截 → 用 esbuild 编译 JS/TS → 返回 ESM 格式。
4. **`.vue` 文件处理**:由 `@vitejs/plugin-vue` 拦截 `.vue` 请求,将其拆分为 template、script、style 三部分分别处理。
5. **HMR**文件变更时Vite 通过 WebSocket 通知浏览器,浏览器重新请求变更的模块。
### 2.2 生产构建
直接使用 Rollup 打包,因为 Rollup 在 tree-shaking、代码分割、输出稳定性方面是最好的 ESM 打包器。
---
## 3. Vite 的预构建Pre-bundling做了什么为什么需要它
### 3.1 做了什么
Vite 使用 **esbuild**`node_modules` 中的依赖进行预构建:
1. **CJS → ESM**:将 CommonJS/UMD 格式的包转为 ESM。
2. **合并碎片模块**:有些包(如 `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热模块替换是如何工作的
1. **文件监听**Vite 使用 [chokidar](https://github.com/paulmillr/chokidar) 监听文件变更。
2. **模块失效**文件变更后Vite 以该文件为起点,沿着模块依赖图找到所有受影响的模块。
3. **边界判定**找到最近的「HMR 边界」(通常是接受热更新的组件/模块),只重载边界内的模块。
4. **WebSocket 推送**Vite 通过 WebSocket 向浏览器发送更新信息(变更文件路径 + 更新类型)。
5. **浏览器处理**:客户端 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` 访问:
```js
// 所有变量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无需任何配置
```js
// 源码
import('./sum.js').then(({ sum }) => {
console.log(sum(1, 2))})
// 打包产物
// dist/assets/sum-xxxx.js ← 自动分离为独立 chunk
```
打包产物文件名中的 `xxxx` 是内容哈希,用于浏览器长期缓存。当文件内容不变时,哈希不变,浏览器直接命中缓存。
### 8.2 手动配置分割策略 — 对象语法
如果你的项目里有一个通用模块需要单独拆包(比如一段多处引用的工具函数),可以通过 `manualChunks` 手动指定:
```js
// src/test.js
export const sayHello = () => {
console.log('hello')}
```
```js
// vite.config.js
export default defineConfig({
build: { rollupOptions: { output: { manualChunks: { // key: chunk 名称value: 模块路径
'test-vendor': './src/test.js' } } } }})
```
### 8.3 手动配置分割策略 — 函数语法(更灵活)
对象语法只能匹配精确路径,函数语法可以基于模块 ID 做任意逻辑判断:
```js
// 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 处理有什么特点?
1. **原生 CSS 支持**:直接 `import './style.css'`Vite 自动注入到页面。
2. **CSS Modules**:文件名以 `.module.css` 结尾,自动启用 CSS Modules。
3. **PostCSS**:项目根目录放置 `postcss.config.js` 即可Vite 会自动应用。
4. **CSS 代码分割**:生产构建时,每个异步 chunk 的 CSS 会被提取为独立文件,按需加载。
5. **`@import` 内联与 rebase**`@import``url()` 路径会自动重写,避免开发与生产路径不一致。
6. **预处理器**:安装 `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 的自定义插件?
1. **添加 `name`**:每个插件必须有唯一的 `name`,方便定位问题。
2. **在项目中引入 vite-plugin-inspect**
3. **使用 console.log**:在插件的各个钩子中添加日志,观察执行时机和参数。
**启用 debug 模式**
```bash
DEBUG=vite:* vite
```5. **使用 Vite 的 `apply` 选项**:可以限制插件只在开发或生产环境生效:
```js
{
name: 'dev-only-plugin', apply: 'serve' // 或 'build',分别限制开发/生产
}
```
---
## 11. Vite 项目从开发到部署的完整流程是怎样的?
```bash
# 开发
npm run dev # vite — 启动 dev serverESM 按需编译 + HMR
# 构建
npm run build # vite build — Rollup 打包,输出到 dist/
# 本地预览生产构建
npm run preview # vite preview — 在本地启动静态服务器预览 dist/
```
典型部署:将 `dist/` 目录部署到 CDN 或静态服务器Nginx、Vercel、Netlify 等)。
对于 SPA 应用,需要配置 fallback 到 `index.html`
```nginx
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 独有钩子**
```js
{
name: 'my-vite-plugin', config(config) {}, // 修改用户配置
configureServer(server) {}, // dev server 注入中间件
handleHotUpdate(ctx) {} // 自定义 HMR 行为
}
```
**enforce 控制执行顺序:** `pre` → 核心插件 → 普通(默认) → `post` → Vite 内置插件
### 12.4 一个完整的个性化构建案例
回到本项目的 `helloPlugin`,它实现了一个个性化需求——「生产环境自动移除 console.log」
```js
// 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 的插件化设计思想怎样完成个性化打包构建需求?
一个结构化的回答应包含以下几点:
1. **架构层面**Vite 采用了**微内核设计**。它的核心非常轻量,仅包含一个 ESM 开发服务器和一套插件加载机制。所有高层功能如框架支持、CSS 预处理、特定文件加载等,都由独立的插件实现。
2. **接口层面**Vite 提供了一套**兼容 Rollup 的、统一的插件接口**。这套接口通过暴露一系列生命周期钩子 (**Hooks**)允许开发者在构建过程的各个关键节点如配置解析、模块加载、代码转换、HTML 生成等)注入自定义逻辑。
3. **实践层面**:开发者可以编写一个插件,通过选择合适的钩子来完成特定任务。例如,使用 `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 的短板。**务实的设计比纯粹的理念更重要。**
```
---
## 扩展思考:如果你来设计一个类似的工具,会怎么做?
这通常是大厂面试的进阶追问。可以从以下角度组织回答:
1. **利用浏览器原生能力**:现代浏览器已经支持 ESM、ES2020+ 语法,开发阶段不需要向下兼容。
2. **语言选择**:高频编译用 Go/Rust 实现的工具(如 esbuild、SWC生产打包用成熟的 JS 生态工具。
3. **按需编译**:改变「先打包再启动」的思路,变成「请求时编译」。
4. **插件兼容**:设计插件接口时尽量对齐已有标准(如 Rollup 插件格式),降低迁移成本。
---
## 14. Webpack 的 Loader 和 Plugin 有什么区别?各自的工作原理是什么?
### 14.1 核心区别
| 维度 | Loader | Plugin |
| -------- | ------------------------------------------- | ------------------------------------------- |
| 职责 | **模块内容转换**——把非 JS 文件转为 JS 模块 | **构建流程扩展**——在打包各阶段注入自定义逻辑 |
| 作用对象 | 单个文件(模块) | 整个构建流程 |
| 本质 | 一个**函数**,接收源码,返回转换后的代码 | 一个**类**(或包含 `apply` 方法的对象) |
| 执行时机 | 模块加载时,在文件被添加到依赖图之前 | 通过钩子在编译各阶段触发 |
| 典型例子 | `css-loader`、`babel-loader`、`vue-loader` | `HtmlWebpackPlugin`、`MiniCssExtractPlugin` |
**一句话**Loader 管"翻译"(把各种文件翻译成 JSPlugin 管"编排"(控制打包流程的各个环节)。
### 14.2 Loader 的工作原理
Loader 是一个 Node.js 函数,它接收源文件内容,返回转换后的结果:
```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 通过监听这些事件介入构建:
```js
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 面试怎么答
1. 先说清楚二者本质区别Loader 是**文件级转换器**Plugin 是**流程级控制器**。
2. 各举一个熟悉的例子说明(如 `css-loader` 和 `HtmlWebpackPlugin`)。
3. 如果需要,简述 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/函数里,运行时才知道 |
```js
// ESM — 静态,编译时即可分析
import { sum } from './math' // 语法层面必须写在顶层
// CJS — 动态,运行时才能确定
const modulePath = condition ? './a' : './b'
const result = require(modulePath) // require 就是一个函数调用
```
### 15.3 实现原理
以 Rollup/Webpack 为例Tree Shaking 分三步:
1. **标记Mark**:从入口开始,遍历所有模块的 `import/export`,标记哪些导出被使用了。
2. **清除Sweep**:删除没有被标记的导出语句及其关联的代码。
3. **副作用处理**:如果一个模块有顶层副作用(如 `console.log()`、修改全局变量),即使没有导出被使用,整个模块也不会被摇掉。
```json
// 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立即执行函数表达式**
```js
// 用闭包隔离作用域,通过参数传入依赖
var myModule = (function ($) {
var privateVar = 'secret'
return {
doSomething: function () { $('body').text('hello') }
}
})(jQuery)
```
- ✅ 解决了全局命名冲突
- ❌ 依赖顺序仍需手动管理,没有标准的加载机制
**② CommonJSNode.js 原生支持)**
```js
// math.js
module.exports = { sum: (a, b) => a + b }
// app.js
const { sum } = require('./math')
```
- ✅ 同步加载,适合服务端(文件在本地磁盘)
- ❌ 不适合浏览器——`require()` 是同步的,在浏览器中会阻塞渲染
**③ AMDAsynchronous Module Definition**
```js
// 专门为浏览器设计,异步加载
define(['jquery', './math'], function ($, math) {
return { value: math.sum(1, 2) }
})
```
- ✅ 异步加载,不阻塞页面
- ❌ 写法繁琐,嵌套多了可读性差
**④ UMDUniversal Module Definition**
一套兼容 CommonJS + AMD + 全局变量的样板代码,让一个模块同时运行在 Node.js 和浏览器中。今天的很多 npm 包仍然发布 UMD 格式。
**⑤ ES ModuleES2015今天的标准**
```js
// 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 编译器,它的核心任务是把**新语法写的代码**转换成**旧环境能运行的代码**
```js
// 输入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 节点时做什么"。
```js
// 一个极简 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 & 格式检查**
```yaml
# .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**
```bash
npx tsc --noEmit # 只做类型检查,不产出 JS 文件
```
即使 Vite/esbuild 在构建时不检查类型只做语法转换CI 中也必须独立跑一次 `tsc --noEmit`,防止类型错误流入生产。
**③ 单元测试 & 覆盖率**
```bash
npx vitest run --coverage
```
设置覆盖率门槛,低于阈值阻断流水线:
```json
// vitest.config.ts
{
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80
}
}
}
```
**④ 构建**
```bash
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 的核心优势
**① 代码共享零摩擦**
```bash
# 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 顶层!
```
```js
// 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 配置示例
```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
├── 依赖层面
│ ├── 依赖分析(找出大包替代品)
│ ├── externalCDN 外置大型库)
│ └── 按需引入(如 lodash-es → import { debounce }
└── 加载策略
├── preload/prefetch提前加载关键资源
├── 异步加载dynamic import
└── module/nomodule差异化构建
```
### 23.2 代码层面的具体做法
**① Tree Shaking 前置条件**
```js
// ❌ 副作用写法——整个模块不会被摇掉
import './polyfills' // 无显式导出,有副作用
// ✅ 在 package.json 中声明
{ "sideEffects": ["./src/polyfills.js"] }
```
**② 路由级代码分割Vue 3 + Vite**
```js
// router/index.js
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue') // 独立 chunk
},
{
path: '/settings',
component: () => import('@/views/Settings.vue') // 独立 chunk
}
]
```
每个路由页面单独打包,首屏只加载首页的代码。
**③ 第三方库的按需引入**
```js
// ❌ 打包整个 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.5s3G | 用户可交互时间 |
### 23.5 Vite 中的构建优化配置
```js
// 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水合
```js
// 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 服务器,而不是纯静态文件 | ServerlessVercel/Netlify、容器化 |
### 24.4 什么时候选哪种模式
```
需要 SEO + 实时数据? → SSR电商、新闻、社区
需要 SEO + 内容不常变?→ SSG博客、文档、官网
不需要 SEO → CSR后台管理系统、工具型应用
混合场景? → ISR / 混合渲染(部分页面 SSR部分 SSG
```
---
## 25. 如何做前端项目的依赖治理?
### 25.1 依赖治理的三个维度
```
依赖治理
├── 安全维度 — 漏洞修复npm audit
├── 版本维度 — 升级策略 & 兼容性管理
└── 体积维度 — 依赖瘦身 & 重复检测
```
### 25.2 安全漏洞修复
```bash
# 审计当前项目依赖
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 配置示例:**
```json
// renovate.json
{
"extends": ["config:base"],
"schedule": ["before 8am on Monday"], // 每周一早上自动提 PR
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true // patch 自动合并
},
{
"matchDepTypes": ["devDependencies"],
"automerge": true // 开发依赖自动合并
}
]
}
```
### 25.4 依赖瘦身
**① 找出重复/冗余依赖**
```bash
npx depcheck # 找出来安装但未使用的包
npx npm-dedupe # 去重 npm 依赖树
pnpm dedupe # pnpm 去重
```
**② 分析依赖体积**
```bash
# 可视化分析
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 中的依赖质量门禁
```yaml
# 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开发环境首选**
```js
// 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服务端配置**
```js
// 后端 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 反向代理(生产环境推荐)**
```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-spaJS 沙箱 + 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 内置的微前端方案,核心思想是**运行时共享模块**
```js
// 子应用 Aremote — 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 VitalsLCP / FID / CLS
│ ├── 自定义指标首屏时间、API 响应时间)
│ └── 资源加载(慢资源、资源失败)
├── 错误监控
│ ├── JS 运行时错误window.onerror
│ ├── Promise 未捕获异常unhandledrejection
│ ├── 框架错误边界Vue errorHandler / React ErrorBoundary
│ └── 接口错误API 超时、5xx、网络异常
└── 行为监控(辅助排查)
├── 用户操作路径(点击、路由跳转)
└── 录制回放Sentry Replay / rrweb
```
### 28.2 JS 错误捕获的三种方式
```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 性能指标采集
```js
// 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% → 性能预警
```
---