1617 lines
66 KiB
Markdown
1617 lines
66 KiB
Markdown
# 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 | 随项目规模递减 | 几乎恒定 |
|
||
| 预构建 | 无 | esbuild(Go) |
|
||
| 生产打包 | 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 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`:
|
||
|
||
```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 管"翻译"(把各种文件翻译成 JS),Plugin 管"编排"(控制打包流程的各个环节)。
|
||
|
||
### 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)
|
||
```
|
||
|
||
- ✅ 解决了全局命名冲突
|
||
- ❌ 依赖顺序仍需手动管理,没有标准的加载机制
|
||
|
||
**② CommonJS(Node.js 原生支持)**
|
||
|
||
```js
|
||
// math.js
|
||
module.exports = { sum: (a, b) => a + b }
|
||
// app.js
|
||
const { sum } = require('./math')
|
||
```
|
||
|
||
- ✅ 同步加载,适合服务端(文件在本地磁盘)
|
||
- ❌ 不适合浏览器——`require()` 是同步的,在浏览器中会阻塞渲染
|
||
|
||
**③ AMD(Asynchronous Module Definition)**
|
||
|
||
```js
|
||
// 专门为浏览器设计,异步加载
|
||
define(['jquery', './math'], function ($, math) {
|
||
return { value: math.sum(1, 2) }
|
||
})
|
||
```
|
||
|
||
- ✅ 异步加载,不阻塞页面
|
||
- ❌ 写法繁琐,嵌套多了可读性差
|
||
|
||
**④ UMD(Universal Module Definition)**
|
||
|
||
一套兼容 CommonJS + AMD + 全局变量的样板代码,让一个模块同时运行在 Node.js 和浏览器中。今天的很多 npm 包仍然发布 UMD 格式。
|
||
|
||
**⑤ ES Module(ES2015,今天的标准)**
|
||
|
||
```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)
|
||
├── 依赖层面
|
||
│ ├── 依赖分析(找出大包替代品)
|
||
│ ├── external(CDN 外置大型库)
|
||
│ └── 按需引入(如 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.5s(3G) | 用户可交互时间 |
|
||
|
||
### 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 服务器,而不是纯静态文件 | Serverless(Vercel/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-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 内置的微前端方案,核心思想是**运行时共享模块**:
|
||
|
||
```js
|
||
// 子应用 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 错误捕获的三种方式
|
||
|
||
```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% → 性能预警
|
||
```
|
||
|
||
--- |