06-vue
面向大厂 P6 前端面试的体系化答案。题目来源:美团、腾讯 WXG、字节真实面经。 默认使用 Vue 3 + 语法,只在需要对比时给出 Vue 2 写法。 每题结构:【核心答案】(30 秒口述版)→ …
Vue
面向大厂 P6 前端面试的体系化答案。题目来源:美团、腾讯 WXG、字节真实面经。 默认使用 Vue 3 +
<script setup>语法,只在需要对比时给出 Vue 2 写法。 每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理 + 可运行代码)→ 【追问 & 加分点】。
目录
- 1. 数据驱动的理解:Vue2 与 Vue3 响应式原理对比、MVVM
- 2. 路由 hash 模式与 history 模式
- 3. 列表渲染中 key 的作用与下标 key 的问题
- 4. 绑定了响应式数据,为什么还会渲染异常
- 5. Pinia 比 Vuex 好在哪里
- 6. Vue3 Diff 中的最长递增子序列(LIS)
- 7. 场景题:A 改筛选项,B/C 更新变卡怎么排查优化
- 8. Vite 的特点,对比 Webpack
1. 数据驱动的理解:Vue2 与 Vue3 响应式原理对比、MVVM
【核心答案】
数据驱动是指:开发者只维护数据(状态),视图由框架根据数据自动推导生成;不需要手动 document.getElementById(...).innerHTML = ... 去同步 DOM。用一个公式概括就是 UI = f(state)——我们只负责改 state,f 由框架的渲染系统实现。
实现数据驱动的关键是响应式系统,它要解决三件事:
- 依赖收集——知道"哪段渲染逻辑读了哪些数据";
- 变更侦测——知道"数据什么时候被改了";
- 派发更新——数据变了,精准地重新执行依赖它的那些渲染逻辑。
Vue2 用 Object.defineProperty:初始化时递归遍历 data 的每个属性,把它改写成 getter/setter。getter 里做依赖收集(把当前渲染 Watcher 收进这个属性的 Dep),setter 里做派发更新(通知 Dep 里的所有 Watcher 重新求值)。局限:只能劫持已存在的属性,新增/删除属性侦测不到(要用 Vue.set / $set);数组下标赋值和 length 修改侦测不到(Vue 通过重写 7 个数组变更方法绕过);初始化时必须递归深度遍历整个 data,大对象有明显性能开销;不支持 Map/Set。
Vue3 用 Proxy + Reflect:代理的是整个对象而不是单个属性,能拦截 get/set/has/deleteProperty/ownKeys 等 13 种操作。所以新增属性、删除属性、数组下标赋值、length 变化、in 操作、for...in 全都能侦测到;而且是惰性代理——只有真正被访问到的嵌套对象才会被转成响应式,初始化开销从 O(全部属性) 降到 O(访问到的属性);还原生支持 Map/Set/WeakMap/WeakSet。局限:不支持 IE11(Proxy 无法 polyfill);基本类型仍需要 ref 包一层用 .value 访问;解构会丢失响应性;代理对象与原始对象不是同一个引用,做 === 比较和传给第三方库时要注意。
MVVM:Model(数据/业务状态)↔ ViewModel(Vue 实例:响应式系统 + 模板编译 + 渲染器)↔ View(模板/DOM)。ViewModel 承担双向绑定的职责——Model 变了自动更新 View(响应式 + 虚拟 DOM diff),View 上的用户输入通过事件回写 Model(v-model 语法糖)。Vue 严格说是 MVVM 的"实践者"而非教科书实现,因为它也允许 ref 直接操作 DOM。
【展开】
1.1 Vue2:Object.defineProperty 实现
// ============ Vue2 响应式的最小可运行实现 ============
// Dep:一个属性对应一个 Dep,负责收集依赖它的 Watcher
class Dep {
static target = null; // 全局唯一,指向"当前正在求值的 Watcher"
constructor() {
this.subs = [];
}
depend() {
if (Dep.target) this.subs.push(Dep.target); // 实际实现里会去重
}
notify() {
this.subs.slice().forEach((w) => w.update());
}
}
// Watcher:一个"会读取数据的副作用"(渲染函数、computed、watch 回调)
class Watcher {
constructor(getter, cb) {
this.getter = getter;
this.cb = cb;
this.get(); // 立即求值一次,触发依赖收集
}
get() {
Dep.target = this; // 把自己挂到全局,让 getter 能拿到
const value = this.getter();
Dep.target = null; // 收集完立刻清空,避免误收集
return value;
}
update() {
this.cb(this.get());
}
}
// defineReactive:把一个属性改写成 getter/setter
function defineReactive(obj, key, val) {
observe(val); // 递归:嵌套对象也要变成响应式(初始化就要走完整棵树)
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
dep.depend(); // ① 依赖收集
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal); // 新赋的值如果是对象,也要变成响应式
dep.notify(); // ② 派发更新
},
});
}
function observe(obj) {
if (obj === null || typeof obj !== 'object') return;
// ⚠️ 必须在初始化时就遍历所有 key —— 这是 Vue2 性能开销和「新增属性不响应」的根源
Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key]));
}
// ---------- 验证 ----------
const data = { count: 0, user: { name: 'vue2' } };
observe(data);
new Watcher(() => data.count, (v) => console.log('count 变为', v));
data.count = 1; // ✅ 输出:count 变为 1
data.newKey = 'x'; // ❌ 无任何反应 —— newKey 从未被 defineProperty 处理过Vue2 数组的特殊处理:Object.defineProperty 理论上能劫持数组下标,但 Vue2 出于性能考虑(数组可能极长,为每个下标定义 getter/setter 成本高)没有这么做,而是重写了数组原型上的 7 个变更方法:
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach((method) => {
const original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
// push/unshift/splice 新增进来的元素也要变成响应式
let inserted;
if (method === 'push' || method === 'unshift') inserted = args;
else if (method === 'splice') inserted = args.slice(2);
if (inserted) ob.observeArray(inserted);
ob.dep.notify(); // 手动通知
return result;
},
});
});所以 Vue2 里这两种写法不会触发更新,必须用 $set / splice:
this.list[0] = newValue; // ❌ 不触发
this.list.length = 0; // ❌ 不触发
this.$set(this.list, 0, val); // ✅
this.list.splice(0, 1, val); // ✅
this.list.splice(0); // ✅ 清空数组的正确写法1.2 Vue3:Proxy + Reflect 实现
// ============ Vue3 响应式的最小可运行实现 ============
// 依赖存储结构:WeakMap<target, Map<key, Set<effect>>>
// 用 WeakMap 是为了让 target 被回收时依赖也自动回收,避免内存泄漏
const targetMap = new WeakMap();
let activeEffect = null;
const effectStack = []; // 支持嵌套 effect(组件嵌套渲染)
function effect(fn) {
const run = () => {
try {
effectStack.push(run);
activeEffect = run;
return fn();
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1] || null;
}
};
run();
return run;
}
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); // Set 天然去重
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
// 复制一份再遍历,避免遍历过程中 effect 重新收集依赖造成无限循环
if (dep) [...dep].forEach((e) => e !== activeEffect && e());
}
const reactiveMap = new WeakMap(); // 缓存:同一个对象多次 reactive 返回同一个代理
function reactive(target) {
if (target === null || typeof target !== 'object') return target;
if (reactiveMap.has(target)) return reactiveMap.get(target);
const proxy = new Proxy(target, {
get(obj, key, receiver) {
// Reflect 保证 this 指向代理对象,这样 getter 里访问的其他属性也能被收集
const res = Reflect.get(obj, key, receiver);
track(obj, key);
// ★ 惰性代理:只有真正读到这个嵌套对象时,才把它转成响应式
// 对比 Vue2 初始化就递归整棵树,这是 Vue3 初始化性能提升的关键
return typeof res === 'object' && res !== null ? reactive(res) : res;
},
set(obj, key, value, receiver) {
const hadKey = Object.prototype.hasOwnProperty.call(obj, key);
const oldValue = obj[key];
const res = Reflect.set(obj, key, value, receiver);
if (!hadKey) {
trigger(obj, key); // ✅ 新增属性也能触发!Vue2 做不到
} else if (!Object.is(oldValue, value)) {
trigger(obj, key);
}
return res;
},
deleteProperty(obj, key) { // ✅ delete 也能触发!Vue2 需要 $delete
const hadKey = Object.prototype.hasOwnProperty.call(obj, key);
const res = Reflect.deleteProperty(obj, key);
if (hadKey && res) trigger(obj, key);
return res;
},
has(obj, key) { // ✅ 拦截 in 操作符
track(obj, key);
return Reflect.has(obj, key);
},
ownKeys(obj) { // ✅ 拦截 for...in / Object.keys
track(obj, Array.isArray(obj) ? 'length' : Symbol('iterate'));
return Reflect.ownKeys(obj);
},
});
reactiveMap.set(target, proxy);
return proxy;
}
// ---------- 验证 ----------
const state = reactive({ count: 0, list: [1, 2] });
effect(() => console.log('render:', state.count, state.list.length));
state.count = 1; // ✅ 触发
state.newKey = 'x'; // ✅ 触发(Vue2 不行)
state.list[0] = 99; // ✅ 触发(Vue2 不行)
state.list.push(3); // ✅ 触发,无需重写数组方法
delete state.count; // ✅ 触发(Vue2 不行)为什么必须用 Reflect 而不是直接 obj[key]:
const obj = {
_name: 'vue',
get name() {
return this._name; // ← 这里的 this
},
};
// 用 obj[key]:getter 里的 this 是原始对象 obj,读 _name 不会经过 Proxy → 依赖收集不到 _name
// 用 Reflect.get(obj, key, receiver):receiver 是 Proxy,getter 里的 this 指向 Proxy → _name 也被收集1.3 ref 与 reactive 的关系
Proxy 只能代理对象,基本类型无法代理,所以 Vue3 用 ref 通过类的 get value / set value 访问器来处理:
class RefImpl {
constructor(value) {
this._rawValue = value;
// 对象类型的 ref,内部还是用 reactive 包一层
this._value = typeof value === 'object' && value !== null ? reactive(value) : value;
this.dep = new Set();
this.__v_isRef = true; // 标记位,模板自动解包和 unref 都靠它
}
get value() {
trackRefValue(this);
return this._value;
}
set value(newVal) {
if (Object.is(newVal, this._rawValue)) return;
this._rawValue = newVal;
this._value = typeof newVal === 'object' && newVal !== null ? reactive(newVal) : newVal;
triggerRefValue(this);
}
}ref | reactive | |
|---|---|---|
| 支持类型 | 任意(基本类型 + 对象) | 仅对象 / 数组 / Map / Set |
| 访问方式 | .value(模板中自动解包) | 直接访问属性 |
| 整体替换 | ✅ state.value = newObj 保持响应性 | ❌ 重新赋值会丢失响应性 |
| 解构 | 解构出 .value 后丢失响应性 | 解构丢失响应性,需 toRefs |
| 实现 | 类 + 访问器属性 | Proxy |
实践建议:优先用 ref,理由是心智统一(一律 .value)、可以整体替换、且能直接作为函数返回值传递而不丢响应性。
1.4 MVVM 与数据驱动的完整链路
Model (响应式数据)
↕
┌──── ViewModel (Vue 实例) ────┐
│ 响应式系统:Proxy + track/trigger │
│ 编译器:template → render 函数 │
│ 渲染器:vnode → patch → 真实 DOM │
└──────────────────────────────┘
↕
View (DOM)
完整更新链路:
① 组件初始化:setup() 建立响应式数据
② 挂载:创建 ReactiveEffect(渲染 effect),执行 render 函数
→ 读取响应式数据 → 触发 getter → track 收集:这个组件的渲染 effect 依赖了这些属性
→ 得到 vnode 树 → patch → 创建真实 DOM
③ 数据变更:setter → trigger → 找到依赖这个属性的所有 effect
④ 调度:不立即执行,而是 queueJob 推入微任务队列去重(同一个组件的多次修改只渲染一次)
⑤ 微任务 flush:重新执行渲染 effect → 生成新 vnode → 与旧 vnode diff → 最小化更新真实 DOM【追问 & 加分点】
- Vue3.4+ 的响应式重构:3.4 把依赖收集从"Set 集合 + 全量遍历"改成了双向链表 + 版本号(
globalVersion),computed 增加了脏值检查(_dirtyLevel),避免了大量不必要的重新计算;3.5 进一步简化了实现。能提一句说明你在跟进版本演进。 - Vue2 的 Watcher 粒度:Vue2 是"组件级 Watcher + 模板编译期无优化",Vue3 是"组件级 effect + 编译期靶向优化"。Vue3 的编译器会给 vnode 打
patchFlag(如TEXT、CLASS、PROPS),并把动态节点收集进dynamicChildren(Block Tree),运行时 diff 时可以跳过所有静态节点,直接遍历动态节点数组。这是 Vue3 比 Vue2 快的主因之一,比 Proxy 本身的贡献大得多。 - 为什么 Proxy 无法被 polyfill:
Object.defineProperty只能模拟已知属性的拦截,无法模拟对"未知属性"和"新增属性"的拦截,这是语言层面的能力差异。所以 Vue3 直接放弃了 IE11。 shallowRef/shallowReactive/markRaw/toRaw:性能优化的逃生舱。大型不可变数据(比如后端返回的几千条列表、地图实例、第三方 SDK 实例)用shallowRef或markRaw可以避免深层代理的巨大开销。===陷阱:reactive(obj) === obj为false。把响应式对象传给需要引用比较的第三方库(如 ECharts 实例、地图实例)前应该toRaw(),或者一开始就用markRaw()。
2. 路由 hash 模式与 history 模式
【核心答案】
hash 模式(createWebHashHistory):URL 形如 https://a.com/#/user/1。# 及其后面的部分是"片段标识符",浏览器不会把它发送给服务器,改变 hash 也不会触发页面刷新,只会触发 hashchange 事件。所以它天然不需要任何服务端配置,随便丢到静态文件服务器上就能用。缺点是 URL 不美观、SEO 不友好(虽然现代爬虫能执行 JS,但 hash 部分不参与服务端渲染和分享预览)、无法用锚点定位。
history 模式(createWebHistory):URL 形如 https://a.com/user/1,和普通后端路由一模一样。依赖 HTML5 的 history.pushState / replaceState,可以在不刷新页面的前提下改变 URL 路径,并通过 popstate 事件监听前进后退。必须配置服务端:因为用户直接访问 https://a.com/user/1 或刷新页面时,请求真的会打到服务器,而服务器上并没有 /user/1 这个文件,会返回 404。解决办法是配置一个 fallback:所有未匹配到静态资源的路径都返回 index.html,交给前端路由接管。
怎么选:只要有服务端配置权限,一律用 history 模式(URL 干净、SEO 友好、便于分享和埋点分析、支持 SSR)。用 hash 的场景是:纯静态托管且无法配置 rewrite(某些内网 CDN、GitHub Pages 的部分场景)、部署在别人的域名子路径下且不可控、Hybrid 内嵌 WebView 且路径规则受限、以及一些微前端子应用为了避免与主应用路由冲突而使用 hash。
【展开】
2.1 两种模式的实现原理
// ============ hash 模式的最小实现 ============
class HashHistory {
constructor() {
this.listeners = [];
// hashchange 是浏览器原生事件,改 hash 一定会触发
window.addEventListener('hashchange', () => {
this.notify(this.current);
});
// 首次进入时如果没有 hash,补一个默认值
if (!window.location.hash) window.location.hash = '#/';
}
get current() {
return window.location.hash.slice(1) || '/';
}
push(path) {
window.location.hash = path; // 会触发 hashchange,会写入历史记录
}
replace(path) {
// 用 replaceState 改 hash,不新增历史记录(不触发 hashchange,需手动 notify)
const href = window.location.href.replace(/#.*$/, '') + '#' + path;
window.location.replace(href);
}
notify(path) {
this.listeners.forEach((fn) => fn(path));
}
}
// ============ history 模式的最小实现 ============
class HTML5History {
constructor(base = '') {
this.base = base;
this.listeners = [];
// ⚠️ popstate 只在「浏览器前进/后退」或 history.go/back/forward 时触发,
// 调用 pushState/replaceState 本身不会触发 popstate,所以要手动 notify
window.addEventListener('popstate', () => this.notify(this.current));
}
get current() {
return window.location.pathname.slice(this.base.length) + window.location.search;
}
push(path) {
// pushState(state, unused, url):改变 URL 但不发起任何网络请求、不刷新页面
window.history.pushState({ key: Date.now() }, '', this.base + path);
this.notify(path); // 手动通知,因为 pushState 不触发 popstate
}
replace(path) {
window.history.replaceState({ key: Date.now() }, '', this.base + path);
this.notify(path);
}
notify(path) {
this.listeners.forEach((fn) => fn(path));
}
}2.2 对比表
| 维度 | hash 模式 | history 模式 |
|---|---|---|
| URL 形态 | a.com/#/user/1 | a.com/user/1 |
| 依赖 API | location.hash + hashchange | pushState/replaceState + popstate |
| 是否发给服务器 | ❌ # 后内容不会发送 | ✅ 完整路径发送 |
| 刷新/直接访问 | 永远能拿到 index.html | 需服务端 fallback,否则 404 |
| 服务端配置 | 不需要 | 必须配置 rewrite |
| SEO | 差(路径信息不进请求) | 好 |
| SSR 支持 | ❌ 服务端拿不到 hash | ✅ |
| 浏览器兼容 | 全兼容(IE8+) | IE10+ |
锚点定位(#section) | 冲突,不能用 | 正常可用 |
| 微前端 | 常用于子应用隔离 | 需要 base 规划 |
2.3 服务端配置示例
Nginx(最常用):
server {
listen 80;
server_name a.com;
root /var/www/dist;
location / {
# 依次尝试:当前路径的文件 → 当前路径的目录 → 兜底返回 index.html
# 这一行是 history 模式能工作的全部秘密
try_files $uri $uri/ /index.html;
}
# 静态资源单独处理:带 hash 的文件名可以长缓存
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# ⚠️ index.html 绝对不能长缓存,否则发版后用户拿到旧的 html 引用旧的 js
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}Apache:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>Node / Express:
const history = require('connect-history-api-fallback');
app.use(history());
app.use(express.static('dist'));Vite 开发服务器:内置了 fallback,本地开发不会 404,所以很多人本地一切正常、上线才发现刷新 404——这是最典型的 history 模式事故。
Vue Router 配置:
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router';
const router = createRouter({
// 部署在子路径 https://a.com/admin/ 时,base 要传 '/admin/'
// Vite 项目可以直接用 import.meta.env.BASE_URL(对应 vite.config 的 base)
history: createWebHistory(import.meta.env.BASE_URL),
// history: createWebHashHistory(), // hash 模式
routes: [
{ path: '/user/:id', component: () => import('@/views/User.vue') },
// 404 兜底路由:注意 history 模式下服务端已经 fallback 到 index.html,
// 真正的 404 判断在前端做
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFound.vue') },
],
});2.4 history 模式的两个额外坑
- 真 404 与假 404 混淆:因为服务端把所有路径都 fallback 到 index.html,一个不存在的页面会返回 HTTP 200 + 前端渲染的 404 页面。对 SEO 不友好(搜索引擎认为这是正常页面)。严谨的做法是在服务端对已知路由白名单外的路径返回真正的 404 状态码,或者用 SSR。
- 子路径部署:
vite.config.js的base、createWebHistory(base)、Nginx 的location三处必须一致,任何一处不一致都会导致资源 404 或路由匹配不上。
【追问 & 加分点】
- 为什么
pushState不触发popstate? 规范设计如此:popstate表示"用户在会话历史中导航",而pushState是"程序添加历史记录",不是用户导航。所以路由库必须在 push/replace 后手动触发自己的更新逻辑。 pushState的同源限制:只能 push 同源的 URL,跨域会抛SecurityError。这保证了不能伪造地址栏欺骗用户。- memory 模式(
createMemoryHistory):不操作 URL,历史记录只存在内存里。用于 SSR 服务端渲染和单元测试、以及非浏览器环境(如小程序、Electron 的某些场景)。 - hash 模式下的锚点冲突:
#已经被路由占用,想做页面内锚点跳转只能自己实现scrollIntoView。Vue Router 的scrollBehavior可以统一处理。 - 微前端场景:主应用用 history、子应用用 hash 是一种常见的低成本隔离方案;qiankun 等框架也支持子应用用 history +
activeRule前缀,但需要主子应用协商 base。
3. 列表渲染中 key 的作用与下标 key 的问题
【核心答案】
key 的作用:给 vnode 一个稳定的身份标识,让 diff 算法能判断"新旧两个 vnode 是不是同一个节点"。Vue 的 isSameVNodeType 判定是 n1.type === n2.type && n1.key === n2.key——类型和 key 都相同才复用(patch),否则卸载旧的、挂载新的。有了稳定 key,列表增删改序时 Vue 才能复用已有 DOM 并做最少的移动,而不是逐个更新内容。
用数组下标做 key 的后果,要分两种情况回答(这是这道题的关键,很多人只答一半):
- 如果列表项是无状态的纯展示节点(内容完全由 props/数据决定,没有组件本地 state、没有非受控输入、没有过渡动画),那么用 index 作 key 只会导致性能变差,不会导致渲染结果错误。因为 Vue 最终会把每个位置的内容更新成正确的值——只是本可以"移动一个 DOM"的操作,退化成了"逐个更新 N 个 DOM 的文本和属性"。最终 DOM 内容是对的。
- 如果列表项持有"不由数据驱动的状态",就会真正渲染错乱。这些状态包括:子组件的
ref/reactive本地 state、非受控的<input>用户输入值、<input type="checkbox">的 checked、焦点与光标位置、滚动位置、<transition-group>的过渡动画、<video>播放进度、第三方插件挂在 DOM 上的实例。因为 Vue 复用了"位置相同"的 DOM/组件实例,而这些状态是跟着实例走的,于是就出现"输入的内容跑到了别的行""勾选状态错位"这类 bug。
除了在列表前方插入,下标 key 出问题的场景还有:删除中间项(后面所有项整体前移,下标全错位)、排序(下标与数据的映射彻底重排)、过滤/搜索(筛选后同一下标对应完全不同的数据)、在中间插入、列表项交换位置、以及 <transition-group> 的进出场动画(会看到"内容突变"而不是"元素移动")。本质上是同一个原因:index 表示的是"位置",而 key 需要表示的是"身份";只要位置与身份的对应关系发生变化,下标 key 就失效。
【展开】
3.1 Vue3 的 patchKeyedChildren 五步
旧: [A, B, C, D, E, F, G]
新: [A, B, E, C, D, H, F, G]
① 前序同步(i 从头往后,遇到不同就停)
A、B 相同 → patch,i = 2
② 后序同步(e1/e2 从尾往前,遇到不同就停)
G、F 相同 → patch,e1 = 4(E), e2 = 5(H)
③ 旧的走完了、新的还有剩 → 全部挂载(mount)
④ 新的走完了、旧的还有剩 → 全部卸载(unmount)
⑤ 都有剩余(未知乱序序列):i..e1 是旧的剩余 [C,D,E],i..e2 是新的剩余 [E,C,D,H]
a. 为新的剩余段建立 keyToNewIndexMap: { E:2, C:3, D:4, H:5 }
b. 建立 newIndexToOldIndexMap = [0,0,0,0](长度=新剩余段长度,0 表示"新增节点")
c. 遍历旧的剩余段,用 key 去 map 里找对应的新位置:
C → 新位置 3,记录 newIndexToOldIndexMap[3-i] = 旧下标+1
D → 新位置 4,记录 ...
E → 新位置 2,记录 ...
同时用 maxNewIndexSoFar 判断是否出现"逆序" → moved = true
旧的剩余里在新的 map 中找不到的 → unmount
d. moved 为 true 时,对 newIndexToOldIndexMap 求最长递增子序列(LIS)
LIS 中的节点 = 相对顺序本来就对的,不需要移动
e. 从后往前遍历新的剩余段:
- newIndexToOldIndexMap[i] === 0 → 新节点,mount
- 下标不在 LIS 中 → move(insertBefore 到锚点前)
- 下标在 LIS 中 → 什么都不做
(从后往前是为了保证锚点节点已经就位)3.2 isSameVNodeType —— key 到底怎么用的
// runtime-core/src/vnode.ts
export function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key;
}
// runtime-core/src/renderer.ts —— patch 的入口判断
const patch = (n1, n2, container, anchor) => {
if (n1 && !isSameVNodeType(n1, n2)) {
// 类型或 key 不同 → 不复用,直接卸载旧的
unmount(n1);
n1 = null; // 后续走 mount 分支,创建全新的 DOM / 组件实例
}
// ... 相同则走 patchElement / patchComponent,复用 DOM 和组件实例
};注意 key 为 undefined 时:undefined === undefined 为 true,所以没写 key 的列表项在同一位置上会被认为是"同一个节点",行为等价于用 index 做 key(Vue 会在 dev 模式给出警告)。所以"不写 key"和"写 index 作 key"的问题是一样的。
3.3 情况一:无状态纯展示 —— 只是性能差
<script setup>
import { ref } from 'vue';
const list = ref([
{ id: 1, name: 'B' },
{ id: 2, name: 'C' },
]);
const prepend = () => list.value.unshift({ id: 3, name: 'A' });
</script>
<template>
<button @click="prepend">头部插入</button>
<!-- 纯展示,li 内部没有任何本地状态 -->
<li v-for="(item, index) in list" :key="index">{{ item.name }}</li>
</template>diff 过程:
旧 vnode: [key=0 文本'B', key=1 文本'C']
新 vnode: [key=0 文本'A', key=1 文本'B', key=2 文本'C']
前序同步:key=0 相同 → patch,文本 'B' → 'A' (1 次文本更新)
key=1 相同 → patch,文本 'C' → 'B' (1 次文本更新)
后序同步:无
旧的走完 → 挂载 key=2 的新节点 'C' (1 次 DOM 创建 + 插入)
总计:2 次文本更新 + 1 次插入用 :key="item.id" 的话:
前序同步:key=3 vs key=1,不同,立即停止(i=0)
后序同步:key=2 vs key=2 相同 → patch(无变化)
key=1 vs key=1 相同 → patch(无变化)
旧的走完(e1 < i)→ 挂载 key=3 的新节点 'A' 到头部
总计:1 次插入,B 和 C 的 DOM 完全没被碰过最终页面显示的都是 A、B、C,结果是正确的——所以纯展示场景下 index 作 key 确实只是性能问题。列表越长、每项越复杂(比如每项是一个有几十个 DOM 的卡片),差距越大:N 项的列表头部插入,index key 是 O(N) 次 patch,id key 是 O(1) 次插入。
3.4 情况二:有本地状态 —— 真正错乱
<script setup>
import { ref } from 'vue';
const list = ref([
{ id: 1, name: '写周报' },
{ id: 2, name: '改 bug' },
]);
const prepend = () => list.value.unshift({ id: 3, name: '开会' });
</script>
<template>
<button @click="prepend">头部插入</button>
<div v-for="(item, index) in list" :key="index">
<span>{{ item.name }}</span>
<!-- ① 非受控 input:用户输入的值存在真实 DOM 上,Vue 不管理它 -->
<input placeholder="备注" />
<!-- ② 有本地 state 的子组件 -->
<TodoItem :todo="item" />
<!-- ③ checkbox 的 checked 状态 -->
<input type="checkbox" />
</div>
</template>复现步骤:在"写周报"这行的备注框里输入「周五前」,勾选它的 checkbox,然后点"头部插入"。
实际结果:新插入的"开会"这一行,备注框里显示「周五前」,checkbox 是勾上的;而"写周报"那一行反而是空的。
原因:index=0 这个 key 在新旧 vnode 中都存在,isSameVNodeType 返回 true,Vue 复用了原来那一行的整个 DOM 子树和 TodoItem 组件实例,只把 <span> 的文本从"写周报"改成了"开会"。而 <input> 里用户输入的值是浏览器存在 DOM 节点上的,TodoItem 的本地 state 存在组件实例上,两者都跟着"位置"被复用了。
这就是"渲染结果错乱"和"性能变差"的分界线:Vue 保证的是"由数据驱动的部分最终一定正确",但它保证不了"不由数据驱动的状态"(DOM 自身状态、组件实例状态)跟着正确的数据项走——那正是 key 应该负责的事。
受控 vs 非受控的差别(这里值得展开一句):如果写成 <input v-model="item.remark">,即受控,那么每次 patch 时 Vue 会把 item.remark 的值写回 input.value,最终显示是对的——这时 index key 又退化为"只是性能问题"。但只要有一帧的中间态、或者输入法正在组合输入(composition)、或者光标位置,用户依然能感知到异常。所以结论是:受控输入能"纠正"值,但纠正不了焦点、光标、输入法状态;非受控输入连值都纠正不了。
3.5 下标 key 出问题的完整场景清单
| 场景 | 旧 → 新 | 用 index key 的后果 |
|---|---|---|
| 头部/中间插入 | [B,C] → [A,B,C] | 插入点之后所有项的 DOM 被"就地复用+改内容",本地状态整体后移错位 |
| 删除中间项 | [A,B,C] → [A,C] | key=1 从 B 复用给 C,B 的本地状态跑到 C 这行;末尾多删一个 DOM |
| 删除头部项 | [A,B,C] → [B,C] | 全部整体前移,所有行的状态错位一格 |
| 排序 | [A,B,C] → [C,B,A] | DOM 完全原地不动,只换文本;展开态/勾选态全乱;有动画则看不到移动只看到内容闪变 |
| 交换两项 | [A,B] → [B,A] | 两个 DOM 内容互换,但 DOM 节点本身没动,两行的本地状态互换失败 |
| 过滤 / 搜索 | [A,B,C,D] → [B,D] | key=0 从 A 复用给 B、key=1 从 B 复用给 D,状态全错 |
| 异步数据到达后整体替换 | [] → [X,Y,Z] | 这个反而没问题(旧列表为空,全是新建) |
<transition-group> | 任意重排 | 元素被就地复用,Vue 认为"没有元素移动",move 动画完全不触发 |
| 拖拽排序(sortablejs 等) | 任意 | 库直接操作 DOM + Vue 就地复用,两边打架,出现幽灵节点、顺序回弹 |
3.6 正确做法
<!-- ✅ 最优:后端返回的稳定唯一 id -->
<li v-for="item in list" :key="item.id">...</li>
<!-- ✅ 无 id 时:用业务上唯一的字段组合 -->
<li v-for="item in list" :key="`${item.type}_${item.code}`">...</li>
<!-- ✅ 数据本身真的没有唯一标识:在数据进入响应式状态的那一刻生成并固化 -->
<script setup>
const list = ref([]);
async function load() {
const raw = await fetchList();
// 在这里生成一次,之后这个 _uid 就跟着数据项走,不会变
list.value = raw.map((it) => ({ ...it, _uid: crypto.randomUUID() }));
}
</script>
<!-- ❌ 绝对不要:每次渲染都生成新 key,导致整个列表每次都销毁重建 -->
<li v-for="item in list" :key="Math.random()">...</li>
<!-- ⚠️ 仅在同时满足这四点时,index 才是安全的:
① 列表静态不增删 ② 永不排序/过滤 ③ 项内无任何本地状态 ④ 无过渡动画 -->
<li v-for="(tab, index) in STATIC_TABS" :key="index">{{ tab }}</li>【追问 & 加分点】
- key 的比较是严格相等还是宽松相等? Vue 用
===。所以:key="1"(数字)和:key="'1'"(字符串)是不同的 key。从后端拿到的 id 类型不统一时会踩坑。 - key 只需在兄弟节点间唯一,不需要全局唯一。嵌套列表的内层可以重复外层的 key。
- key 的另一个用途:强制重建组件。
<UserProfile :key="userId" />,userId 变化时组件被完整卸载重建,所有内部状态重置。这比在组件内写一堆watch(() => props.userId, resetAll)干净得多。同理,路由/user/1→/user/2时组件默认会被复用(onMounted不重跑),给<router-view :key="$route.fullPath">可以强制重建。 v-for与v-if不能用在同一元素:Vue3 中v-if优先级高于v-for(Vue2 相反),意味着v-if里访问不到v-for的变量,会直接报错。正确做法是用<template v-for>包一层,或者用computed先过滤。v-for遍历对象时的默认 key:遍历对象时第三个参数才是 index,第二个是 key 名,容易搞错:v-for="(value, key, index) in obj"。- Vue2 与 Vue3 diff 的区别:Vue2 是双端 diff(新旧各两个指针,四种比对:头头、尾尾、头尾、尾头),Vue3 是"双端预处理 + 中间乱序段 LIS"。Vue3 的移动次数是理论最优(见第 6 题)。
4. 绑定了响应式数据,为什么还会渲染异常
【核心答案】
"绑定了响应式数据"只保证了数据本身是可追踪的,但从"数据变了"到"屏幕上正确显示"中间还有依赖收集、触发时机、调度、diff、DOM 复用等一整条链路,任何一环出问题都会表现为"渲染异常"。常见来源分四类:
第一类:数据根本没变成响应式,或响应性在传递中丢失。 Vue2 的新增属性和数组下标赋值侦测不到;Vue3 里解构 reactive 对象、把 ref 的 .value 传出去、直接给 reactive 变量整体重新赋值、shallowRef/shallowReactive 的深层修改、markRaw/Object.freeze 的对象,都会导致数据改了但 effect 没被触发。
第二类:依赖没被正确收集。 渲染函数里没有实际读取那个属性(比如属性只在 v-if 的另一分支里用到)、异步回调中读取(此时 activeEffect 已经是 null)、以及在 watch 的 getter 中访问了未被追踪的路径。
第三类:时机问题。 Vue 的更新是异步批量的,改完数据立刻读 DOM 拿到的是旧值,需要 nextTick;watch 默认在组件更新前触发(flush: 'pre'),要读更新后的 DOM 得用 flush: 'post';onMounted 里访问异步渲染的子组件 DOM 也可能拿不到。
第四类:即使数据、依赖、时机全对,仍可能异常的来源。 这一类是"严格按响应式规范操作依然会踩"的:key 使用不当导致的 DOM/组件实例误复用(第 3 题);组件被复用而不是重建(路由参数变化、v-if 切换同类型组件)导致 onMounted 不重跑;<keep-alive> 缓存的组件状态残留;第三方库直接操作 DOM 与 Vue 的虚拟 DOM 产生冲突;SSR 的 hydration mismatch;v-html 内容不受响应式管理;以及 v-if/v-show 与 CSS 的交互。
【展开】
4.1 第一类:响应性丢失
import { reactive, ref, toRefs, toRef, shallowRef, triggerRef } from 'vue';
// ---------- ① 解构 reactive 对象 ----------
const state = reactive({ count: 0, name: 'vue' });
let { count } = state;
count++; // ❌ count 只是一个普通数字副本,改它跟 state 没关系
// ✅ 方案 A:toRefs 把每个属性转成 ref,保持连接
const { count: countRef } = toRefs(state);
countRef.value++; // ✅ 等价于 state.count++
// ✅ 方案 B:单个属性用 toRef
const nameRef = toRef(state, 'name');
// ✅ 方案 C:干脆用 ref,心智更统一
const count2 = ref(0);
// ---------- ② 整体替换 reactive 对象 ----------
let list = reactive([]);
async function load() {
const data = await fetchList();
list = reactive(data); // ❌ 只是让局部变量 list 指向了一个新代理,
// 模板里绑定的还是旧的那个代理对象
}
// ✅ 方案 A:改用 ref,整体替换天然支持
const list2 = ref([]);
list2.value = await fetchList();
// ✅ 方案 B:保持引用不变,原地修改
list.length = 0;
list.push(...(await fetchList()));
// ✅ 方案 C:对象用 Object.assign
const form = reactive({ a: 1, b: 2 });
Object.assign(form, { a: 10, b: 20 });
// ---------- ③ 把 ref 的值传出去 ----------
const size = ref(10);
function useSize(s) { /* s 是一个普通数字,改它无效 */ }
useSize(size.value); // ❌ 丢失响应性
useSize(size); // ✅ 传 ref 本身
// ---------- ④ shallowRef / shallowReactive 的深层修改 ----------
const big = shallowRef({ list: [1, 2, 3] });
big.value.list.push(4); // ❌ 不触发(只有 .value 整体替换才触发)
triggerRef(big); // ✅ 手动触发
big.value = { ...big.value, list: [...big.value.list, 4] }; // ✅ 或整体替换
// ---------- ⑤ markRaw / Object.freeze ----------
import { markRaw } from 'vue';
const chart = markRaw(new ECharts(dom)); // 永远不会被代理(这是有意为之,避免性能灾难)
const frozen = reactive(Object.freeze({ a: 1 })); // set 会静默失败(严格模式下报错)Vue2 特有的两个坑(对比时讲):
// Vue2
this.obj.newKey = 1; // ❌ 不触发
this.$set(this.obj, 'newKey', 1); // ✅
this.obj = { ...this.obj, newKey: 1 }; // ✅ 整体替换也可以
this.arr[0] = 'x'; // ❌ 不触发
this.arr.length = 0; // ❌ 不触发
this.$set(this.arr, 0, 'x'); // ✅
this.arr.splice(0, 1, 'x'); // ✅
delete this.obj.key; // ❌ 不触发
this.$delete(this.obj, 'key'); // ✅Vue3 全部解决——这是 Proxy 相对 defineProperty 最直观的收益。
4.2 第二类:依赖未被收集
<script setup>
import { ref, reactive, watchEffect } from 'vue';
const show = ref(false);
const state = reactive({ a: 1, b: 2 });
// ❌ 异步回调中的读取不会被收集:effect 执行完 activeEffect 就被清空了
watchEffect(() => {
setTimeout(() => {
console.log(state.a); // 这次读取时 activeEffect 是 null,收集不到
}, 0);
});
// ✅ 在同步阶段读取
watchEffect(() => {
const a = state.a; // 同步读取 → 被收集
setTimeout(() => console.log(a), 0);
});
// ❌ 条件分支导致的"依赖缺失":show 为 false 时,渲染函数根本没读 state.b,
// 所以 state.b 变化时不触发重渲染。这通常不是 bug(因为没显示),
// 但如果你依赖"b 变化 → 触发某个副作用",就会失效
</script>
<template>
<div v-if="show">{{ state.b }}</div>
</template>// watch 的 getter 写法坑
const obj = reactive({ nested: { count: 0 } });
watch(obj.nested, (v) => {}); // ⚠️ 传的是响应式对象 → 隐式 deep,能触发但拿不到 oldValue
watch(() => obj.nested, (v) => {}); // ❌ getter 返回的引用没变 → 不触发
watch(() => obj.nested.count, (v) => {}); // ✅ 精确追踪某个属性
watch(() => obj.nested, (v) => {}, { deep: true }); // ✅ 深度监听4.3 第三类:异步更新时机
<script setup>
import { ref, nextTick, watch, onMounted } from 'vue';
const count = ref(0);
const el = ref(null);
async function update() {
count.value = 1;
console.log(el.value.textContent); // ❌ 输出 '0':DOM 还没更新
await nextTick();
console.log(el.value.textContent); // ✅ 输出 '1'
}
// watch 的 flush 时机
watch(count, () => {
console.log('pre :', el.value.textContent); // 默认 flush:'pre',组件更新前 → 旧 DOM
});
watch(count, () => {
console.log('post :', el.value.textContent); // flush:'post',组件更新后 → 新 DOM
}, { flush: 'post' });
watch(count, () => {
console.log('sync :', el.value.textContent); // flush:'sync',同步触发,慎用(高频改动会卡)
}, { flush: 'sync' });
</script>
<template><div ref="el">{{ count }}</div></template>为什么 Vue 要异步更新:同一个 tick 内多次修改同一个响应式数据(比如 for 循环里改 1000 次),如果同步更新就会渲染 1000 次。Vue 的调度器把渲染 job 推入队列并按组件 uid 去重排序,在微任务(Promise.then)中统一 flush,1000 次修改只渲染 1 次。
// runtime-core/src/scheduler.ts(简化)
const queue = [];
let isFlushPending = false;
const resolvedPromise = Promise.resolve();
export function queueJob(job) {
// 去重:同一个组件的渲染 job 在一个 tick 内只入队一次
if (!queue.includes(job, isFlushing ? flushIndex + 1 : flushIndex)) {
queue.push(job);
queueFlush();
}
}
function queueFlush() {
if (isFlushPending) return;
isFlushPending = true;
resolvedPromise.then(flushJobs); // 微任务,本轮宏任务结束后立即执行
}
function flushJobs() {
// 按 job.id(组件 uid)升序:保证父组件先于子组件更新
queue.sort((a, b) => a.id - b.id);
// ... 依次执行
}nextTick 就是把回调挂在同一个 Promise 链的后面,所以它一定在 flush 之后执行。
4.4 第四类:严格按规范操作仍会出现异常的来源
这一类最能拉开面试分数,因为它超出了"响应式"本身。
① key 不当导致的 DOM/实例误复用 —— 详见第 3 题。响应式数据完全正确,但 diff 把状态复用错了位置。
② 组件被复用而不是重建
// 路由 /user/1 → /user/2,组件是同一个,Vue 会复用实例:
// onMounted 不会重跑,setup 不会重跑,写在 onMounted 里的数据请求不会发起
// ✅ 方案 A:watch 路由参数
watch(() => route.params.id, fetchUser, { immediate: true });
// ✅ 方案 B:强制重建
// <router-view :key="$route.fullPath" /><!-- v-if / v-else 切换同类型元素时,Vue 会复用 DOM -->
<input v-if="isEmail" placeholder="邮箱" />
<input v-else placeholder="用户名" />
<!-- ⚠️ 切换时用户已输入的内容不会清空(DOM 被复用了),只有 placeholder 变了 -->
<!-- ✅ 加不同的 key 强制重建 -->
<input v-if="isEmail" key="email" placeholder="邮箱" />
<input v-else key="username" placeholder="用户名" />③ <keep-alive> 的状态残留
<keep-alive>
<component :is="current" />
</keep-alive>
<!-- 组件被缓存,切回来时 onMounted 不重跑,只触发 onActivated -->
<!-- 依赖 onMounted 做数据刷新的逻辑会失效;组件内的滚动位置、表单填写会保留 -->
<!-- ✅ 需要每次激活都刷新的逻辑放 onActivated;需要清理的放 onDeactivated -->④ 第三方库直接操作 DOM,与虚拟 DOM 冲突
// ECharts / 地图 / sortablejs / 富文本编辑器 直接改了 Vue 管理的 DOM
// 下次 patch 时 Vue 按照自己的 vnode 树去更新,会覆盖或错乱第三方库的改动
// ✅ 用 v-once / v-html 隔离,或者给第三方库一个 Vue 完全不管的容器节点
// ✅ 库实例本身一定要 markRaw / shallowRef,否则被深度代理会导致性能崩溃甚至死循环
const chart = shallowRef(null);
onMounted(() => { chart.value = markRaw(echarts.init(el.value)); });⑤ SSR hydration mismatch
// 服务端渲染出的 HTML 与客户端首次渲染的结果不一致时,
// Vue3 会在 dev 报 "Hydration node mismatch",prod 则可能静默地把服务端 HTML 丢弃重新渲染,
// 表现为闪烁、事件绑定丢失、状态被重置
// 常见原因:new Date()、Math.random()、访问 window/localStorage、
// 依赖用户 UA 的条件渲染、时区差异
// ✅ 用 <ClientOnly> 包裹,或把不确定的内容放到 onMounted 之后再渲染⑥ 异步组件与 Suspense 的时序
// defineAsyncComponent 加载的组件在解析完成前是占位的,
// 父组件 onMounted 里通过 ref 拿子组件实例会拿到 null⑦ 同一个 DOM 上多个指令/绑定的优先级
<!-- v-model 与 :value 同时写,后者会被 v-model 覆盖,表现为"绑定不生效" -->
<input v-model="a" :value="b" />
<!-- style/class 的对象绑定与静态属性会合并,但同名 CSS 属性的覆盖顺序容易反直觉 -->⑧ 计算属性有副作用 / 依赖了非响应式源
// ❌ computed 依赖了 Date.now() 这种非响应式的值 → 缓存住之后永远不更新
const label = computed(() => `${state.name} @ ${Date.now()}`);
// ❌ computed 里改了别的响应式数据 → 可能触发无限更新循环4.5 排查这类问题的通用步骤
① 数据层:console.log / Vue Devtools 看数据本身有没有变
没变 → 是业务逻辑或响应性丢失(第一类)
变了 → 往下走
② 触发层:给该属性加一个 watch 或 watchEffect,看有没有被触发
没触发 → 依赖没收集到(第二类),或响应性丢失
触发了 → 往下走
③ 渲染层:用 Vue Devtools 的 "Highlight updates" / onRenderTriggered 看组件有没有重渲染
没重渲染 → 渲染函数里没有真正读这个属性
重渲染了但 DOM 不对 → 往下走
④ DOM 层:检查 key、组件复用、keep-alive、第三方库、v-html
→ 大概率是第四类
⑤ 时机层:把读 DOM 的代码放进 nextTick 或 flush:'post' 再试【追问 & 加分点】
onRenderTracked/onRenderTriggered:两个调试钩子,能精确告诉你"这次渲染是被哪个属性的哪种操作触发的",排查"为什么它重渲染了"和"为什么它没重渲染"极其有效。能主动提到这两个 API 是很强的加分点。- Vue3 的
reactive对 Map/Set 有专门的 handler(collectionHandlers),因为 Map/Set 的方法内部访问的是原始对象的内部槽,直接代理会抛错,需要重写get/set/has/add/delete/forEach/迭代器。 toRaw与相等性:watch的新旧值在深层对象场景下可能是同一个引用(因为 Vue 没有做深拷贝),所以oldValue === newValue。需要对比时要自己浅拷贝。- Vue 3.5 的 props 解构:
const { foo } = defineProps()在 3.5 后由编译器转换为props.foo,解构后仍保持响应性。这是一个"以前的坑现在不再是坑"的知识点,答的时候要标明版本。 v-memo:手动缓存子树,依赖数组不变就整个跳过 patch。用错了会导致"数据变了但视图不更新"——这是一个"自己制造渲染异常"的典型来源。
5. Pinia 比 Vuex 好在哪里
【核心答案】
Pinia 是 Vue 官方推荐的状态管理库(Vuex 已进入维护状态),它的优势可以概括为五点:
- TypeScript 类型推导完美。Vuex 的
commit('setUser', payload)/dispatch('fetchUser')是字符串驱动的,无法静态推导类型,重构时改一个 mutation 名字全靠搜索。Pinia 的 action 就是普通方法,store.fetchUser(1)完整的参数和返回值类型推导、跳转定义、重命名重构全都开箱可用。 - API 大幅简化。去掉了
mutations(Vuex 里 mutation 只能同步、action 只能异步再 commit,这个约束是为了 devtools 记录变更,但 Pinia 用其他方式实现了同样的追踪),只剩state/getters/actions;action 里同步异步都可以写。 - 没有嵌套模块,天然扁平。Vuex 的 modules +
namespaced+rootState+rootGetters心智负担重,路径字符串'user/profile/setName'又长又易错。Pinia 每个 store 都是独立的、可以互相 import 使用,组合关系用代码表达而不是配置表达。 - 对 Tree-shaking 和代码分割友好。Vuex 是一个大的全局单例 store,所有模块都会打进主包。Pinia 的每个 store 是一个独立模块,没用到就不会被打包,可以按路由懒加载。
- 和 Composition API 同构。
defineStore的 setup 写法就是写一个setup()函数,可以直接用ref/computed/watch/ 生命周期 / 其他组合式函数,学习成本几乎为零。
作为实习生怎么理解用 Pinia 做状态管理的价值(这是面试官真正想听的部分):
状态管理解决的核心问题是 "跨组件、跨路由的共享状态该放在哪"。不用状态管理时,共享状态只能提升到共同祖先然后逐层透传(prop drilling),或者用 provide/inject、事件总线。这三种方式的问题是:透传导致中间层组件被迫接收和转发它并不关心的 props,组件复用性和可维护性都变差;事件总线让数据流向变得不可追踪,出了问题不知道是谁改的。
Pinia 的价值在于:把"谁能读""谁能改""怎么改"集中在一个地方定义。读是 store.xxx,改是 store.someAction(),所有的修改路径都是显式的、可被 devtools 记录的、可被单元测试覆盖的。它带来的不是"能实现",而是"改动时不容易出错"——这是工程价值而不是功能价值。
同时也要能说出边界:不是所有状态都该进 store。组件内部的 UI 状态(弹窗开关、表单临时输入、hover 态)就该留在组件里;服务端数据的缓存和请求状态,用 TanStack Query / VueUse 的 useAsyncState 往往比塞进 store 更合适。滥用全局状态会让组件失去独立性,反而更难维护。判断标准:这个状态是否需要被多个不相关的组件共享,或者需要跨路由存活?是,才放 store。
【展开】
5.1 代码对比:同一个功能的两种写法
// ============ Vuex 4 ============
// store/modules/user.js
export default {
namespaced: true,
state: () => ({ info: null, loading: false }),
getters: {
// 参数签名固定为 (state, getters, rootState, rootGetters),类型推导困难
displayName: (state) => state.info?.nickname || state.info?.name || '游客',
},
mutations: {
// mutation 必须同步,且只能通过字符串 commit
SET_INFO(state, info) { state.info = info; },
SET_LOADING(state, v) { state.loading = v; },
},
actions: {
async fetchUser({ commit, rootGetters }, id) {
commit('SET_LOADING', true); // 字符串,拼错了运行时才报错
try {
const info = await api.getUser(id);
commit('SET_INFO', info);
} finally {
commit('SET_LOADING', false);
}
},
},
};
// 组件中使用
import { useStore } from 'vuex';
import { computed } from 'vue';
const store = useStore();
const name = computed(() => store.getters['user/displayName']); // 长字符串路径,无类型
store.dispatch('user/fetchUser', 1); // 无参数类型检查// ============ Pinia(setup 写法,推荐) ============
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useAppStore } from './app'; // store 之间直接 import,无需 rootState
export const useUserStore = defineStore('user', () => {
// ---- state:就是 ref ----
const info = ref<UserInfo | null>(null);
const loading = ref(false);
// ---- getters:就是 computed ----
const displayName = computed(() => info.value?.nickname || info.value?.name || '游客');
const isVip = computed(() => (info.value?.level ?? 0) >= 3);
// ---- actions:就是普通函数,同步异步随意 ----
async function fetchUser(id: number) {
loading.value = true;
try {
info.value = await api.getUser(id);
// 跨 store 调用:直接用,像调普通函数
useAppStore().setTitle(info.value.name);
} finally {
loading.value = false;
}
}
function logout() {
info.value = null;
}
// 想暴露什么就 return 什么,没 return 的就是私有的(这一点 Vuex 做不到)
return { info, loading, displayName, isVip, fetchUser, logout };
});
// 组件中使用
import { storeToRefs } from 'pinia';
const userStore = useUserStore();
// ⚠️ 直接解构会丢失响应性(store 本质是 reactive 对象),必须用 storeToRefs
const { info, displayName, loading } = storeToRefs(userStore);
// action 是函数,可以直接解构(函数不需要响应性)
const { fetchUser, logout } = userStore;
fetchUser(1); // ✅ 完整类型提示:参数是 number,返回 Promise<void>5.2 Pinia 的几个实用能力
const store = useUserStore();
// ---- ① 直接改 state(Vuex 必须走 mutation) ----
store.info = { name: 'a' };
// ---- ② $patch 批量修改:多次修改只触发一次 devtools 记录和一次渲染 ----
store.$patch({ loading: false, count: store.count + 1 });
// 函数式 $patch:适合数组等复杂修改
store.$patch((state) => {
state.list.push(newItem);
state.hasChanged = true;
});
// ---- ③ $reset:重置到初始状态(仅 option 写法开箱可用,setup 写法需自己实现) ----
store.$reset();
// ---- ④ $subscribe:订阅 state 变化,常用于持久化 ----
store.$subscribe((mutation, state) => {
localStorage.setItem('user', JSON.stringify(state));
}, { detached: true }); // detached: 组件卸载后订阅依然有效
// ---- ⑤ $onAction:订阅 action 调用,常用于埋点、错误上报 ----
store.$onAction(({ name, args, after, onError }) => {
const start = Date.now();
after((result) => report('action_success', { name, cost: Date.now() - start }));
onError((err) => report('action_error', { name, err }));
});
// ---- ⑥ 插件机制:几行代码实现全局持久化 ----
pinia.use(({ store }) => {
const saved = localStorage.getItem(store.$id);
if (saved) store.$patch(JSON.parse(saved));
store.$subscribe((_, state) => {
localStorage.setItem(store.$id, JSON.stringify(state));
});
});5.3 对比总表
| 维度 | Vuex 4 | Pinia |
|---|---|---|
| 核心概念 | state / getters / mutations / actions / modules | state / getters / actions |
| TS 支持 | 需要大量手写类型声明,仍不完美 | 完整自动推导 |
| 修改 state | 必须 commit mutation | 直接赋值 / $patch / action |
| 异步 | 只能在 action | action 中同步异步都可以 |
| 模块化 | 嵌套 modules + namespaced 字符串路径 | 多个扁平 store,互相 import |
| 包体积 | ~10KB | ~1.5KB |
| Tree-shaking | 差(单例聚合) | 好(独立模块) |
| Devtools | 支持 | 支持(时间旅行、action 追踪) |
| SSR | 需要额外处理 | 一等公民支持 |
| Vue2 支持 | ✅ | ✅(需 @vue/composition-api) |
| 官方状态 | 维护模式,不再新增特性 | 官方推荐 |
5.4 状态该放哪里 —— 一个可以直接讲的决策树
这个状态需要被多个组件共享吗?
├─ 否 → 留在组件内(ref / reactive)
└─ 是 → 是父子/祖孙关系,且层级 ≤ 2 吗?
├─ 是 → props + emit
└─ 否 → 是"跨整个应用的配置类数据"吗(主题、语言、权限)?
├─ 是 → provide/inject 或 Pinia 都可以,Pinia 更易调试
└─ 否 → 是"服务端数据的缓存"吗?
├─ 是 → 优先 TanStack Query / useAsyncState
│ (它们自带缓存失效、重试、去重、乐观更新)
└─ 否 → Pinia【追问 & 加分点】
- Pinia 为什么去掉了 mutations? Vuex 里 mutation 的存在意义是"让 devtools 能追踪每一次状态变更的时间点"。Pinia 通过响应式系统的
$subscribe和 action 包装($onAction)实现了同样的追踪能力,所以 mutation 变成了纯粹的样板代码,被移除了。这个"为什么"比"是什么"更能体现理解深度。 storeToRefs的必要性:store 实例本质是一个reactive对象(准确说是reactive包裹的effectScope产物),解构等于取出了普通值,响应性丢失。storeToRefs只把 state 和 getters 转成 ref,跳过 action(函数不需要)。- Pinia 在 setup 写法下
$reset不可用:因为它无法知道初始状态。解决办法是自己在 store 里写一个reset()函数,或者用插件保存初始快照。 - 在组件外使用 store 的时机问题:
useUserStore()必须在 pinia 实例被app.use(pinia)之后调用。在路由守卫、axios 拦截器等模块顶层直接调用会报 "getActivePinia() was called but there was no active Pinia",必须挪到函数内部延迟调用。 - SSR 下的状态污染:服务端是长期运行的进程,如果 store 是模块级单例,不同用户的请求会共享状态。Pinia 通过每个请求
createPinia()新实例解决,这是它 SSR 友好的具体含义。
6. Vue3 Diff 中的最长递增子序列(LIS)
【核心答案】
用在 diff 的哪一步:在 patchKeyedChildren 的第 5 步——处理"中间未知乱序序列"时。前面已经做完了前序同步、后序同步、纯新增、纯删除四种简单情况,剩下的是新旧列表中间那段发生了乱序的部分。Vue 先建立一个 newIndexToOldIndexMap(下标是新列表中的相对位置,值是对应的旧下标+1,0 表示这是全新节点),然后对这个数组求最长递增子序列。
为什么用 LIS 能最小化移动:newIndexToOldIndexMap 中的一个递增子序列,意味着这些节点在旧列表中的相对顺序和在新列表中的相对顺序是一致的——也就是说,它们彼此之间不需要移动。要让整个序列有序,最少的移动次数就是"总节点数 − 保持不动的最大集合"。所以求出最长的那个递增子序列,把它当作"锚"固定不动,只移动剩下的节点,移动次数就是理论最小值。
为什么 Vue 的实现是贪心 + 二分:朴素的 LIS 动态规划是 O(n²),diff 是运行时热路径,不能接受。贪心策略是"维护一个数组 result,让长度为 i 的递增子序列的结尾元素尽可能小",新元素比结尾大就 push,否则用二分查找找到第一个 ≥ 它的位置替换掉。这样求长度是 O(n log n)。但贪心过程中 result 里存的下标可能不是一个真实的子序列(后面的替换会破坏前面的对应关系),所以 Vue 额外维护了一个前驱数组 p,记录每个元素被放进 result 时它前一个元素的下标,最后从末尾回溯还原出真正的 LIS。
【展开】
6.1 在 diff 中的完整位置
// runtime-core/src/renderer.ts —— patchKeyedChildren 第 5 步(简化并加注释)
const patchKeyedChildren = (c1, c2, container, parentAnchor) => {
let i = 0;
let e1 = c1.length - 1; // 旧列表尾指针
let e2 = c2.length - 1; // 新列表尾指针
// ① 前序同步:sync from start
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[i], c2[i])) patch(c1[i], c2[i], container);
else break;
i++;
}
// ② 后序同步:sync from end
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[e1], c2[e2])) patch(c1[e1], c2[e2], container);
else break;
e1--; e2--;
}
// ③ 旧的走完,新的有剩 → 全部挂载
if (i > e1) {
if (i <= e2) {
const anchor = e2 + 1 < c2.length ? c2[e2 + 1].el : parentAnchor;
while (i <= e2) patch(null, c2[i++], container, anchor);
}
}
// ④ 新的走完,旧的有剩 → 全部卸载
else if (i > e2) {
while (i <= e1) unmount(c1[i++]);
}
// ⑤ 中间乱序序列 ★ LIS 登场 ★
else {
const s1 = i, s2 = i;
// 5.1 为新列表的乱序段建立 key → newIndex 的映射
const keyToNewIndexMap = new Map();
for (i = s2; i <= e2; i++) {
keyToNewIndexMap.set(c2[i].key, i);
}
// 5.2 遍历旧列表的乱序段,找到能复用的节点并 patch,找不到的卸载
let patched = 0;
const toBePatched = e2 - s2 + 1; // 新列表乱序段的长度
let moved = false;
let maxNewIndexSoFar = 0;
// 核心数据结构:下标 = 新列表中的相对位置,值 = 旧列表中的下标 + 1
// 之所以 +1,是为了让 0 有独立含义:"这是一个全新节点,没有对应的旧节点"
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) { unmount(prevChild); continue; }
const newIndex = keyToNewIndexMap.get(prevChild.key);
if (newIndex === undefined) {
unmount(prevChild); // 新列表里没有这个 key → 删除
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1;
// 只要出现一次"新位置不是递增的",就说明发生了乱序,需要移动
if (newIndex >= maxNewIndexSoFar) maxNewIndexSoFar = newIndex;
else moved = true;
patch(prevChild, c2[newIndex], container); // 复用并更新
patched++;
}
}
// 5.3 求最长递增子序列(只在确实发生了移动时才求,省掉一次 O(n log n))
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
let j = increasingNewIndexSequence.length - 1;
// 5.4 从后往前遍历新列表的乱序段
// 倒序的原因:insertBefore 需要一个"已经就位的后继节点"作为锚点,
// 从后往前能保证当前节点的后一个节点必定已经处理完毕
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex];
const anchor = nextIndex + 1 < c2.length ? c2[nextIndex + 1].el : parentAnchor;
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, anchor); // 全新节点 → 挂载
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor); // 不在 LIS 中 → 必须移动
} else {
j--; // 在 LIS 中 → 保持不动
}
}
}
}
};6.2 一个完整的手算例子
旧: [A, B, C, D, E, F, G]
新: [A, B, E, C, D, H, F, G]
① 前序同步:A、B 相同 → i = 2
② 后序同步:G、G 相同;F、F 相同 → e1 = 4, e2 = 5
此时旧的乱序段 = [C, D, E](下标 2..4)
新的乱序段 = [E, C, D, H](下标 2..5)
③ keyToNewIndexMap = { E:2, C:3, D:4, H:5 }
④ toBePatched = 4,newIndexToOldIndexMap 初始 = [0, 0, 0, 0]
遍历旧乱序段:
C(旧下标2)→ 新下标3 → map[3-2=1] = 2+1 = 3;maxNewIndexSoFar = 3
D(旧下标3)→ 新下标4 → map[4-2=2] = 3+1 = 4;maxNewIndexSoFar = 4
E(旧下标4)→ 新下标2 → map[2-2=0] = 4+1 = 5;2 < 4 → moved = true
最终 newIndexToOldIndexMap = [5, 3, 4, 0]
↑E ↑C ↑D ↑H(全新)
⑤ getSequence([5,3,4,0]) → 忽略 0,最长递增子序列是 [3,4],对应下标 [1, 2]
含义:新乱序段中下标 1(C) 和 2(D) 的相对顺序与旧列表一致 → 不用动
⑥ 从后往前遍历 i = 3,2,1,0:
i=3 (H):map[3]===0 → 挂载新节点 H,锚点是 F
i=2 (D):在 LIS 中 → 不动,j--
i=1 (C):在 LIS 中 → 不动,j--
i=0 (E):不在 LIS 中 → 移动 E 到 C 前面
结论:只做了 1 次挂载 + 1 次移动。
如果不用 LIS,朴素做法要移动 E、C、D 三个节点。6.3 getSequence:贪心 + 二分 + 前驱回溯
/**
* 求最长递增子序列,返回的是「下标数组」而不是值数组
* 时间复杂度 O(n log n),空间 O(n)
* @param {number[]} arr 即 newIndexToOldIndexMap,0 表示新增节点,需跳过
* @returns {number[]} 组成最长递增子序列的元素在 arr 中的下标(升序)
*/
function getSequence(arr) {
// p[i] 记录:当 arr[i] 被放入 result 时,它在 result 中的前一个元素的「下标」
// 用它做最后的回溯,解决"贪心过程中 result 内容可能被替换、不再是真实子序列"的问题
const p = arr.slice();
// result 存放的是「下标」,且满足:arr[result[0]] < arr[result[1]] < ...
// 贪心不变式:长度为 k+1 的所有递增子序列中,result[k] 对应的结尾值是最小的
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
// 0 表示"这是一个全新节点,在旧列表中没有对应",不参与 LIS 计算
if (arrI !== 0) {
j = result[result.length - 1]; // 当前最长子序列的结尾元素的下标
if (arr[j] < arrI) {
// 情况一:比当前结尾还大 → 直接接到末尾,子序列长度 +1
p[i] = j; // 记录前驱
result.push(i);
continue;
}
// 情况二:不比结尾大 → 二分查找 result 中第一个 >= arrI 的位置,替换掉它
// 替换的意义:让长度为 u+1 的子序列有一个更小的结尾,
// 为后续元素留出更多"接上去"的空间。这不改变当前长度,但让未来更可能变长。
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1; // 无符号右移 1 位 = 除以 2 取整,比 Math.floor 快
if (arr[result[c]] < arrI) u = c + 1;
else v = c;
}
// 循环结束时 u === v,指向第一个 arr[result[u]] >= arrI 的位置
if (arrI < arr[result[u]]) {
if (u > 0) p[i] = result[u - 1]; // 记录前驱:它前面那个位置对应的下标
result[u] = i; // 替换
}
}
}
// ★ 回溯:此时 result 的「长度」一定正确,但「内容」可能因为替换而不是一个真实的子序列。
// 例如 arr = [3,1,2]:遍历后 result = [1,2](值 1,2),恰好正确;
// 但 arr = [1,4,2,5,3] 这类情况,末尾之前的元素可能被更小的值覆盖过。
// 所以从最后一个元素开始,沿着 p 数组一路往前串,才能得到真正的 LIS。
u = result.length;
v = result[u - 1]; // 最后一个元素的下标一定是正确的
while (u-- > 0) {
result[u] = v;
v = p[v]; // 跳到前驱
}
return result;
}
// ---------- 验证 ----------
console.log(getSequence([5, 3, 4, 0])); // [1, 2] → 对应值 3, 4
console.log(getSequence([2, 1, 5, 3, 6, 4, 8, 9, 7]));
// [1, 3, 5, 6, 7] → 对应值 1, 3, 4, 8, 9,长度 5
console.log(getSequence([1, 2, 3, 4])); // [0, 1, 2, 3] 全部递增
console.log(getSequence([4, 3, 2, 1])); // [3] 长度为 16.4 为什么必须回溯 —— 一个强反例
arr = [2, 3, 1, 5](已在 Node 中实测)
不带回溯的贪心过程(result 存下标,括号里是对应的值):
i=0, 值2:result = [0] → 值序列 [2]
i=1, 值3:3 > 2,push → result = [0,1],值 [2,3],p[1]=0
i=2, 值1:1 < 3,二分找到位置0,替换 → result = [2,1],值 [1,3]
i=3, 值5:5 > 3,push → result = [2,1,3],值 [1,3,5],p[3]=1
此时 result = [2,1,3]:值序列 [1,3,5] 看着递增,但下标序列是 2 → 1 → 3——
下标 2 排在下标 1 前面,意味着"arr[2] 在 arr[1] 之前",这在原数组里根本不成立。
result 已经不是一个合法的子序列了(diff 里直接拿它当"不动集合"会移错节点)。
回溯修复:从末尾(一定正确)出发沿 p 还原:
v = result[2] = 3 → result[2] = 3
v = p[3] = 1 → result[1] = 1
v = p[1] = 0 → result[0] = 0
最终 result = [0,1,3],值 [2,3,5] ✅ 下标、值都递增,才是真正的 LIS。
出错的机理:值 1(下标 2)把 result 的位置 0 替换掉了——它属于"另一条潜在子序列"
(以 1 开头的那条),但 result 后面的元素(下标 1 的值 3)是接在旧的 2 后面的。
一旦发生这种"跨链替换",result 前面的元素就不再是末尾元素的合法前驱。
回溯靠的是 p 数组:每个元素入场时记下"我当时真正接在谁后面",所以能还原出真实链条。面试话术:可以直接说"贪心保证的是长度正确,回溯保证的是内容正确",这一句话就能说明白 p 数组的存在意义。
6.5 为什么 LIS 就是最优解
这是一个可以形式化的结论:
- 设新乱序段有
n个可复用节点(排除全新节点),要把它们从旧的相对顺序调整到新的相对顺序。 - 每次
insertBefore操作可以把一个节点移动到任意位置。 - 保持不动的节点集合必须满足:它们在旧列表中的相对顺序 = 在新列表中的相对顺序,即它们在
newIndexToOldIndexMap中构成一个递增子序列。 - 移动次数 =
n − |不动集合|。要最小化移动次数,就要最大化不动集合,即求最长递增子序列。
所以 LIS 给出的移动次数是理论下界,不存在更优的方案。
【追问 & 加分点】
moved标志的优化意义:如果整个乱序段中新下标是单调递增的(maxNewIndexSoFar从未回退),说明只有增删没有移动,此时完全不需要求 LIS,直接跳过这个 O(n log n)。真实业务中"只增删不排序"是最常见的场景,这个短路很有价值。- 为什么
newIndexToOldIndexMap要存"旧下标 + 1"? 因为需要用0表示"这是全新节点"这个特殊语义,而旧下标本身可以是 0。用-1也可以,但那样getSequence里就要多一个判断,且new Array(n).fill(0)比fill(-1)更符合 JS 引擎的优化路径。 - React 为什么不用 LIS? React 的
placeChild用lastPlacedIndex做单向判定,只处理"右移",实现更简单但移动次数不是最优([A,B,C,D] → [D,A,B,C]React 移动 3 次,Vue 移动 1 次)。这是两个框架在"实现复杂度 vs 运行时最优"上的不同取舍。能对比着答是明显的加分。 - LIS 本身是经典算法题(LeetCode 300),面试官可能直接让你手写。要能同时给出 O(n²) 的 DP 版本和 O(n log n) 的贪心+二分版本,并说清二分版本的
tails数组"不是答案本身,只是长度的载体"。 >> 1vsMath.floor(/2):位运算更快,但要注意>> 1对超过 2³¹ 的数会溢出。列表长度不可能到这个量级,所以安全。
7. 场景题:A 改筛选项,B/C 更新变卡怎么排查优化
原题(字节一面):页面上有 A、B、C 三个组件,A 是筛选器,B 和 C 展示数据。用户在 A 里改变筛选项之后,B、C 的更新明显变卡。请从 Vue 的更新链路分析可能的原因,并给出排查步骤和优化手段。
【核心答案】
先把更新链路讲清楚,再定位每一环可能的瓶颈:
用户在 A 中改动筛选项
→ ① 修改响应式数据(filter)
→ ② trigger:找到所有依赖 filter 的 effect(B、C 的渲染 effect、相关 computed、watch)
→ ③ 调度:queueJob 推入微任务队列,按组件 uid 排序去重
→ ④ 微任务 flush:执行 B、C 的渲染 effect
→ 重新执行 render 函数(含 computed 重算、模板中的方法调用、数据格式化)
→ 生成新的 vnode 树
→ ⑤ patch:新旧 vnode diff(大列表走 patchKeyedChildren,可能触发 LIS)
→ ⑥ DOM 操作:创建/更新/移动真实节点
→ ⑦ 浏览器:样式计算 → 布局 → 绘制 → 合成卡顿一定发生在其中某一环,常见的五个瓶颈:
- 触发次数过多:筛选项是输入框且没有防抖,每敲一个字符触发一次全链路;或者用了
flush: 'sync'的 watch,导致同步渲染无法批量合并。 - 计算量过大(第 ④ 环):模板里直接写了
{{ formatList(list) }}这类方法调用(每次渲染都重算,无缓存)、或者 computed 的依赖设计不当导致本可以缓存的结果被反复重算、或者在 render 中做了filter + sort + map的重计算。 - 渲染节点数过多(第 ⑤⑥ 环):B/C 是几千行的大列表或大表格,一次全量 diff + DOM 操作,主线程直接被打满。
- 响应式开销过大(第 ①②环):把大数组用
reactive深度代理,每个元素每个属性都建立依赖,trigger 时要遍历巨量的 dep 集合。 - 无关组件被连带更新:把筛选状态放在了公共父组件上,导致父组件重渲染带着所有子组件一起走一遍 diff;或者 props 传了每次都新建的对象/数组,让子组件的 bailout 失效。
排查步骤:Performance 录制 → 看火焰图判断时间花在 JS 还是 Rendering → 是 JS 就用 Vue Devtools 的 Performance 面板 + onRenderTriggered 定位是哪个组件、被什么触发的 → 是 Rendering 就看 DOM 节点数和布局抖动。
优化手段按性价比排序:防抖/节流 → computed 替代模板方法 → 精确的响应式粒度(shallowRef / markRaw)→ v-memo / 稳定 key → 虚拟列表或分页 → useTransition 式的分片(Vue 中用 requestIdleCallback 或分批渲染)→ Web Worker 承担纯计算。
【展开】
7.1 定位问题的完整流程
第一步:Chrome Performance 录制一次操作
录制 → 改一次筛选项 → 停止
看火焰图的时间分布:
├─ 大块的黄色(Scripting)→ JS 计算或 Vue 渲染逻辑慢 → 走第二步
├─ 大块的紫色(Rendering / Layout)→ DOM 节点太多或布局抖动 → 走第四步
└─ 大块的绿色(Painting)→ 复杂样式/大量图片/滤镜 → 检查 CSS
展开 Scripting 的调用栈,通常会看到:
componentUpdateFn → renderComponentRoot → patch → patchKeyedChildren
哪一层耗时最长,问题就在哪一层第二步:Vue Devtools 定位组件
- 打开 Performance 面板录制,能看到每个组件的 render 耗时排序。
- 打开设置里的 Highlight updates,可以直观看到哪些组件被重渲染了——如果改一个筛选项导致半个页面都在闪,说明更新范围失控。
第三步:用生命周期钩子精确定位触发源
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue';
// 开发环境专用:告诉你这次渲染是被「哪个对象的哪个属性的哪种操作」触发的
onRenderTriggered((e) => {
console.log('触发重渲染:', {
type: e.type, // 'set' | 'add' | 'delete' | 'clear'
key: e.key, // 哪个属性
target: e.target, // 哪个对象
oldValue: e.oldValue,
newValue: e.newValue,
});
});
// 告诉你这个组件收集了哪些依赖(用来发现"收集了不该收集的东西")
onRenderTracked((e) => console.log('收集依赖:', e.key));
</script>如果发现 B 组件被一个和它毫无关系的属性触发了重渲染,那就是响应式粒度或组件拆分的问题。
第四步:判断是不是 DOM 数量问题
// 在控制台直接数
document.querySelectorAll('*').length;
// 超过 3000~5000 个节点,单次全量 diff 就会明显卡;超过 10000 基本必卡7.2 优化手段逐个展开
① 减少触发次数:防抖 / 节流
<script setup>
import { ref, watch } from 'vue';
import { useDebounceFn, refDebounced } from '@vueuse/core';
const keyword = ref(''); // 输入框绑定这个,保证输入流畅
const filter = ref({ keyword: '', category: '' });
// 方案 A:防抖函数
const applyFilter = useDebounceFn((kw) => {
filter.value = { ...filter.value, keyword: kw };
}, 300);
watch(keyword, applyFilter);
// 方案 B:VueUse 的 refDebounced,把"输入值"和"生效值"分离
const debouncedKeyword = refDebounced(keyword, 300);
// B、C 组件依赖 debouncedKeyword 而不是 keyword
</script>
<template>
<!-- 输入框绑定 keyword:输入永远流畅,不受下游渲染影响 -->
<input v-model="keyword" />
<ListB :keyword="debouncedKeyword" />
<ChartC :keyword="debouncedKeyword" />
</template>要点:把"输入的值"和"用于计算的值"拆成两个状态,这是"紧急更新 / 非紧急更新"思想在 Vue 中的落地。
② 用 computed 缓存,而不是模板中调用方法
<script setup>
import { ref, computed } from 'vue';
const rawList = ref([]); // 10000 条
const filter = ref({ keyword: '', status: 'all', sortBy: 'time' });
// ❌ 模板里写 {{ getFiltered() }}:每次组件重渲染都重新执行一遍 filter+sort,无缓存
function getFiltered() {
return rawList.value.filter(/*...*/).sort(/*...*/);
}
// ✅ computed 有缓存:只有 rawList 或 filter 变化时才重算
// 并且分层拆分,让不同粒度的变化只触发必要的那一层重算
const filtered = computed(() =>
rawList.value.filter((it) => it.name.includes(filter.value.keyword))
);
const sorted = computed(() =>
// 注意:sort 会原地修改数组,必须先浅拷贝,否则会修改上游 computed 的结果
[...filtered.value].sort((a, b) => a[filter.value.sortBy] - b[filter.value.sortBy])
);
// 这样改 sortBy 时,filtered 不会重算(它不依赖 sortBy),只有 sorted 重算
</script>分层 computed 是这道题里很容易被忽略但很加分的点:把大计算拆成依赖不同的多层,让每次变化只重算必要的那一层。
③ 控制响应式粒度
import { shallowRef, markRaw, triggerRef } from 'vue';
// ❌ 10000 条数据、每条 20 个字段的数组用 reactive
// → 初始化时惰性代理还好,但一旦渲染时全部访问过,就建立了 200000 个依赖
// → 任何一次 trigger 都要遍历庞大的 dep 集合
const list = ref(bigArray);
// ✅ 列表数据通常是"整体替换"的(重新请求后全量覆盖),不需要深层响应式
const list = shallowRef(bigArray);
list.value = newBigArray; // 触发更新
// 如果确实需要原地改某一项后触发:
list.value[0].name = 'x';
triggerRef(list); // 手动触发
// ✅ 第三方实例(图表、地图、编辑器)一定要 markRaw
const chartInstance = markRaw(echarts.init(el));
// 不 markRaw 的后果:ECharts 实例内部有大量循环引用和上万个属性,
// 被深度代理后每次访问都走 Proxy,性能可能下降一个数量级,甚至栈溢出④ v-memo:跳过整棵子树的 patch
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<!-- 只有 item.id 或 item.selected 变化时,这棵子树才重新 patch -->
<!-- 其他任何变化(包括父组件重渲染)都直接复用上次的 vnode,完全跳过 diff -->
<ExpensiveRow :data="item" />
</div>
</template>慎用警告:v-memo 是"手动控制更新"的逃生舱,依赖数组漏写一个字段就会导致"数据变了但视图不更新",且这种 bug 极难排查。只在明确的性能热点(超长列表 + 每项渲染昂贵)上用,并且要写注释说明为什么。
⑤ 虚拟列表:最有效的手段
如果 B 是一个几千行的列表,前面所有优化加起来都不如虚拟列表——它把渲染的 DOM 节点数从 N 降到"可视区域数量 + buffer"(通常 20~50 个),是数量级的改善。
<script setup>
// 生产环境直接用成熟方案:vue-virtual-scroller / @tanstack/vue-virtual / VueUse 的 useVirtualList
import { useVirtualList } from '@vueuse/core';
import { computed } from 'vue';
const props = defineProps(['list']);
const { list: visibleList, containerProps, wrapperProps } = useVirtualList(
computed(() => props.list),
{ itemHeight: 48, overscan: 10 }
);
</script>
<template>
<div v-bind="containerProps" style="height: 600px">
<div v-bind="wrapperProps">
<!-- 无论总数据是 1 万还是 100 万,这里始终只渲染约 20 个节点 -->
<div v-for="{ data, index } in visibleList" :key="data.id" style="height:48px">
{{ data.name }}
</div>
</div>
</div>
</template>如果产品形态允许,分页比虚拟列表更简单可靠——先问"这个列表真的必须一次展示全部吗"。
⑥ 拆分组件,缩小更新范围
<!-- ❌ 筛选状态放在父组件,父组件重渲染带着 B、C 一起走 patch -->
<script setup>
const filter = ref({});
</script>
<template>
<FilterA v-model="filter" />
<ListB :filter="filter" />
<ChartC :filter="filter" />
</template>
<!-- ✅ 改进一:状态放 Pinia / provide,A 改动不引起父组件重渲染 -->
<!-- ✅ 改进二:B 和 C 分别只订阅自己需要的字段 -->
<script setup>
// B 只关心 keyword,不关心 sortBy
const keyword = computed(() => filterStore.keyword);
// C 只关心 dateRange
const dateRange = computed(() => filterStore.dateRange);
// 这样改 sortBy 时,B 完全不会被触发
</script>关键原理:Vue 的更新粒度是组件级——一个组件内任何一个被依赖的数据变了,整个组件的 render 函数就会重新执行一遍。所以"组件拆得合理"本身就是最重要的性能优化。同时,Vue3 的编译器优化(patchFlag + Block Tree)能让重渲染只 patch 动态节点,但它无法避免 render 函数本身被重新执行——如果 render 里有昂贵计算,编译优化救不了你。
⑦ 昂贵的纯计算下放到 Web Worker
// 如果筛选/聚合本身要几百毫秒(比如前端做 10 万条数据的多维聚合)
// 那么无论怎么优化 Vue,主线程都会被阻塞 → 唯一出路是移出主线程
import { useWebWorkerFn } from '@vueuse/core';
const { workerFn, workerStatus } = useWebWorkerFn((list, filter) => {
// 这段代码在 Worker 线程执行,不阻塞主线程
return list.filter(/* 复杂逻辑 */).reduce(/* 聚合 */);
});
watch(filter, async () => {
loading.value = true;
result.value = await workerFn(rawList.value, filter.value);
loading.value = false;
});⑧ 分批渲染(时间切片的 Vue 版)
// 必须一次性渲染大量节点、又不能用虚拟列表时(比如要支持 Ctrl+F 全文搜索)
const renderedCount = ref(0);
function renderInChunks(total, chunk = 50) {
function step() {
renderedCount.value = Math.min(renderedCount.value + chunk, total);
if (renderedCount.value < total) requestAnimationFrame(step);
}
step();
}
// 模板中 v-for="item in list.slice(0, renderedCount)"7.3 优化手段的选择顺序
① 先问产品:这个列表必须一次展示全部吗?→ 能分页就分页(成本最低,效果最好)
② 减少触发:防抖/节流,把输入值与生效值分离
③ 减少计算:模板方法 → computed;computed 分层拆分
④ 减少响应式开销:大数组 shallowRef,第三方实例 markRaw
⑤ 减少渲染节点:虚拟列表(数量级改善)
⑥ 减少更新范围:拆组件、精确订阅、稳定 key、v-memo
⑦ 移出主线程:Web Worker
⑧ 兜底:分批渲染 + loading 骨架,至少让用户感知不到"卡死"面试时的表达要点:先讲清链路(说明你理解原理),再讲排查步骤(说明你有工程方法),最后才给手段(说明你有实战经验)。直接甩一堆"用虚拟列表、用防抖"的候选人,和能说清"我会先录一次 Performance,看时间花在 Scripting 还是 Rendering"的候选人,差距非常明显。
【追问 & 加分点】
- Vue 的更新粒度是组件级,那"Vue3 比 Vue2 快"体现在哪? 体现在同一个组件重渲染时的 patch 成本:Vue3 编译器把动态节点收集到
dynamicChildren(Block Tree),patch 时只遍历动态节点数组,静态节点完全跳过;同时用patchFlag标记"只有 class 变了"这类信息,避免全量 props 对比。但如果瓶颈在 render 函数本身的 JS 计算,这些优化帮不上忙。 - Vue 没有
useTransition:Vue 的更新是微任务批处理,不可中断,没有 React 的时间切片和优先级。所以在 Vue 里对付"渲染本身昂贵",手段只能是"减少要渲染的东西"(虚拟列表)或"分批"(rAF 分片),而不是"让出主线程"。这个框架间的差异能说清楚是高分回答。 computed的缓存失效场景:computed 内部读了非响应式数据(Date.now()、外部变量)时缓存会"过期不更新";依赖了一个每次都新建的对象时缓存"永不命中"。Object.freeze作为优化手段:Vue2 时代常用Object.freeze(bigList)跳过 observe,Vue3 用shallowRef/markRaw更精确。- 不要过早优化:如果列表只有 100 条,上虚拟列表反而增加了滚动计算和复杂度。先测量,再优化。
8. Vite 的特点,对比 Webpack
【核心答案】
Vite 的核心思路是"开发和生产用两套不同的策略":
- 开发环境(dev):不打包。启动一个 dev server,直接把源码以原生 ES Module 的形式交给浏览器,浏览器发起
import请求时才按需编译这一个文件并返回。所以启动时间与项目大小几乎无关(不需要先构建整个依赖图),永远是毫秒级;HMR 也只需要重新编译改动的那个模块,与项目规模无关。 - 依赖预构建(pre-bundling):
node_modules里的第三方依赖用 esbuild(Go 编写,比 JS 工具快 10~100 倍)预打包成 ESM 格式,产物缓存在node_modules/.vite。目的有两个:把 CommonJS/UMD 格式的依赖转成浏览器能用的 ESM;把 lodash-es 这种有几百个内部模块的包合并成一个文件,避免开发时浏览器发起成百上千个请求。 - 生产环境(prod):用 Rollup 打包。因为生产环境需要 Tree-shaking、代码分割、按需加载、资源优化,而"不打包"会造成大量网络请求(尤其在 HTTP/1.1 下)。
Webpack 的模式是"开发和生产用同一套流程":无论 dev 还是 prod,都要先从入口出发递归解析整个依赖图、把每个模块用 loader 编译一遍、打包成 bundle,dev server 再把 bundle 提供出去。项目越大,冷启动越慢(大型项目几十秒到几分钟),改一行代码的 HMR 也要重新走部分构建流程。
本质差异:Webpack 诞生时浏览器还不支持 ES Module,"打包"是必需品;Vite 建立在"现代浏览器原生支持 ESM"这个前提上,把开发环境的打包这一步直接省掉了。这是环境变化带来的架构机会,而不是单纯的实现优化。
【展开】
8.1 开发环境流程对比
┌──────────── Webpack Dev ────────────┐
entry (main.js)
→ 递归解析所有 import,构建完整依赖图
→ 每个模块跑 loader 链(babel-loader / vue-loader / css-loader...)
→ 打包成一个(或几个)bundle
→ dev server 提供 bundle
⏱ 项目有 1000 个模块 → 启动前要处理完 1000 个模块
⏱ 改 1 个文件 → 重新编译该模块 + 更新依赖它的 chunk
┌──────────── Vite Dev ────────────┐
启动 dev server(毫秒级,几乎不做事)
→ 只做一件事:esbuild 预构建 node_modules 依赖(首次约几百毫秒,之后走缓存)
浏览器请求 index.html
→ 遇到 <script type="module" src="/src/main.js">
→ 请求 /src/main.js
→ Vite 拦截请求,用 esbuild 编译这一个文件(去 TS 类型、转 JSX),返回
→ 浏览器解析返回内容里的 import,继续请求下一个文件……
⏱ 项目有 1000 个模块 → 只编译当前页面实际用到的那几十个
⏱ 改 1 个文件 → 只重新编译这 1 个文件,通过 WebSocket 通知浏览器重新 import 它8.2 关键技术点
① 裸模块导入(bare import)的路径重写
浏览器的原生 ESM 只认识相对路径和绝对路径,不认识 import vue from 'vue' 这种"裸导入"。Vite 在中间层做了重写:
// 你写的
import { createApp } from 'vue';
import _ from 'lodash-es';
// Vite 返回给浏览器的
import { createApp } from '/node_modules/.vite/deps/vue.js?v=8f3a2b1c';
import _ from '/node_modules/.vite/deps/lodash-es.js?v=8f3a2b1c';
// ↑ 版本 hash,用于强缓存失效② 依赖预构建(esbuild)
// vite.config.js
export default {
optimizeDeps: {
// 强制预构建某些依赖(比如动态 import 的、Vite 扫不到的)
include: ['some-cjs-package'],
// 排除某些依赖(比如本地 link 的、需要热更新的包)
exclude: ['@my-org/local-lib'],
},
};预构建解决的两个问题:
- 格式问题:npm 上仍有大量包只提供 CommonJS/UMD 格式,浏览器的 ESM 加载器不认识
require/module.exports,必须转换。 - 性能问题:
lodash-es有 600+ 个内部模块,不预构建的话浏览器要发 600+ 个请求。预构建后合并成 1 个文件,1 个请求。
为什么开发环境用 esbuild,生产环境不用? esbuild 极快(Go 编写、并行、无中间 AST 序列化开销),但它的代码分割和 CSS 处理还不够成熟,一些高级的 Tree-shaking 和 chunk 优化能力不如 Rollup。生产构建对产物质量的要求远高于速度,所以用 Rollup。
③ HMR 的精确性
Vite HMR:
文件变更 → Vite 让该模块的缓存失效 →
沿 importers 链向上找到最近的「HMR 边界」(比如一个 .vue 组件) →
通过 WebSocket 发送 { type: 'update', path: '/src/Foo.vue' } →
浏览器重新 import 这一个模块(带新的 timestamp query 绕过缓存)→ 局部替换
⏱ 与项目规模完全无关
Webpack HMR:
文件变更 → 重新编译该模块 → 重新生成受影响的 chunk →
生成 hot-update.json + hot-update.js → 浏览器拉取并应用
⏱ 受 chunk 大小和依赖图复杂度影响④ 生产构建用 Rollup
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
target: 'es2015',
rollupOptions: {
output: {
// 手动分包:把变动少的第三方库单独打包,利用浏览器长期缓存
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
echarts: ['echarts'],
},
},
},
// 大于 4KB 的资源不内联为 base64
assetsInlineLimit: 4096,
// 生产默认用 esbuild 压缩(比 terser 快 20~40 倍,压缩率略低 1~2%)
minify: 'esbuild',
},
server: {
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
},
},
});8.3 全面对比表
| 维度 | Webpack | Vite |
|---|---|---|
| dev 启动 | 全量打包,秒级到分钟级,随项目增长线性劣化 | 不打包,毫秒级,与项目规模无关 |
| dev 编译时机 | 启动时全部编译 | 请求时按需编译单文件 |
| dev 编译器 | babel-loader / ts-loader(JS 编写) | esbuild(Go,快 10~100 倍) |
| HMR 速度 | 受项目规模影响 | 恒定,与规模无关 |
| 依赖处理 | 与源码一起打包 | esbuild 预构建 + 强缓存 |
| prod 打包 | webpack 自身 | Rollup |
| 配置复杂度 | 高(loader + plugin 生态庞杂) | 低(约定优于配置,开箱支持 TS/JSX/CSS 模块/PostCSS) |
| 生态成熟度 | 极其成熟,插件覆盖一切场景 | 快速增长,兼容部分 Rollup 插件 |
| 老浏览器支持 | 天然支持 | 需 @vitejs/plugin-legacy |
| 首次页面加载(dev) | 快(bundle 已就绪) | 稍慢(首次要瀑布式请求大量模块) |
| SSR | 需大量配置 | 一等公民支持 |
| 适用场景 | 复杂的老项目、微前端、需要极致定制的构建流程 | 新项目、中大型 SPA、组件库、SSR |
8.4 Vite 的取舍与已知问题
要能讲出代价,不能只夸优点:
- dev 与 prod 的行为不一致:dev 走 ESM 不打包,prod 走 Rollup 打包。极少数情况下会出现"开发正常、构建后报错",比如某些 CommonJS 依赖的循环引用、
process.env的处理差异、副作用模块的执行顺序。 - 首次页面加载可能慢:模块非常多的大页面,dev 环境要瀑布式发起几百个请求。Vite 5+ 用了并行请求优化和
warmup配置改善,但这个问题在超大项目上依然存在。 - 需要现代浏览器:dev 环境必须支持原生 ESM(Chrome 61+)。生产要兼容老浏览器需要
@vitejs/plugin-legacy(会额外产出一份 legacy bundle)。 - 插件生态不如 Webpack:某些特定需求(复杂的 DLL、特殊的 loader 链、老旧的埋点/国际化工具)可能没有现成的 Vite 插件。
- Monorepo 中的 link 包处理:本地 link 的包默认不会被预构建(因为不在 node_modules 的扫描范围内),CommonJS 格式的 link 包需要手动配置
optimizeDeps.include。
8.5 生态演进(体现你在跟进)
- Rolldown:Rust 编写的 Rollup 替代品,由 Vite 团队(VoidZero)开发,目标是统一 dev 和 prod 的构建器,同时解决 Rollup 的性能瓶颈和 dev/prod 不一致问题。Vite 7 开始逐步集成。
- Turbopack(Vercel,Rust):Next.js 的构建器,走的是"增量计算"路线,与 Vite 的"不打包"是不同的技术路径。
- Rspack(字节,Rust):Webpack 的 Rust 重写,主打兼容 Webpack 配置和 loader 生态,是大型老项目提速的低成本方案——不用改配置,只换构建器。这个点在字节面试里尤其值得提。
- esbuild / SWC / Oxc:底层编译工具链的"去 JS 化"是整个前端工程化的主线趋势。
【追问 & 加分点】
- "Vite 生产环境不用 esbuild 打包是因为快不够重要吗":不是。是因为 esbuild 的代码分割(code splitting)和 CSS 代码分割能力尚未稳定,Tree-shaking 也不如 Rollup 彻底。生产产物的体积和加载性能直接影响用户,值得多花几十秒构建时间。这个取舍很能体现工程判断。
- HTTP/2 与"不打包":Vite dev 的大量请求在 HTTP/1.1 下会受 6 个并发连接的限制,HTTP/2 的多路复用缓解了这个问题。但生产环境依然要打包——因为每个请求仍有往返延迟和头部开销,且不打包就无法做 Tree-shaking。
import.meta.glob:Vite 特有的批量导入语法,编译时展开成一组静态 import 或动态 import,常用于路由自动注册、组件自动引入。Webpack 的对应物是require.context。- Vite 的 SSR 支持:
ssrLoadModule让服务端也能享受按需编译和 HMR,Nuxt 3、SvelteKit、Remix(部分版本)都构建在 Vite 之上。 - 什么时候不该迁移到 Vite:已有大量自定义 Webpack loader/plugin 的老项目,迁移成本可能高于收益,此时 Rspack 是更务实的选择。能说出这一点比无脑吹 Vite 更成熟。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。