Object.defineProperty 和 Vue3 的 Proxy 有什么区别,各自的缺陷是什么?★★★Object.defineProperty 是劫持对象已有属性的 getter 和 setter,所以必须在初始化时递归遍历整个对象把每个属性都改造一遍。这带来几个绕不过去的缺陷:
新增和删除属性侦听不到,因为劫持的是「已存在的属性」,这就是为什么 Vue2 要提供 this.$set 和 this.$delete。数组的下标赋值和长度修改侦听不到,所以 Vue2 只能重写数组的七个变更方法(push、pop、shift、unshift、splice、sort、reverse)来触发更新,而 arr[0] = 1 和 arr.length = 0 依然无效。初始化开销大,深层嵌套的大对象要一次性全部递归改造,属性再多也躲不掉。
Proxy 是代理整个对象,拦截的是对象层面的操作,有十三种拦截方法(get、set、has、deleteProperty、ownKeys 等)。所以上面那些问题天然就没有:新增删除属性能侦听、数组下标和 length 能侦听、不需要 $set、也不需要重写数组方法。
而且它是懒代理:只有真正访问到某个嵌套对象时才把它转成响应式,不需要一开始就递归到底,初始化性能好得多。这一点在大数据量的表单和列表上差别很明显。
Proxy 的代价是兼容性,它没法用 polyfill 模拟,所以 Vue3 直接放弃了 IE11。另外要注意几个使用细节:reactive 返回的是代理对象,原始对象和代理对象不是同一个引用,用 === 比较会不相等;把响应式对象传给第三方库时,对方拿到的是代理,某些依赖对象身份的库会出问题,这时候要用 toRaw 取原始对象。
核心是发布订阅,两个阶段:读取时建立「数据 → 副作用」的映射(依赖收集),写入时按映射通知(派发更新)。
Vue2 的角色是 Observer、Dep、Watcher。每个响应式属性对应一个 Dep。组件渲染时会创建一个渲染 Watcher 并把它挂到全局的 Dep.target 上,然后执行 render 函数——渲染过程中读到哪个属性,那个属性的 getter 就被触发,于是把当前的 Dep.target 收集进自己的 Dep。之后属性被修改,setter 触发 dep.notify(),通知所有订阅的 Watcher 重新执行。
Vue3 换成了 effect 加 track 和 trigger。用一个全局的 activeEffect 代替 Dep.target,依赖关系存在一个三层结构里:WeakMap<target, Map<key, Set<effect>>>——对象映射到「属性到副作用集合」的表。get 时 track 把 activeEffect 加进对应的 Set,set 时 trigger 把 Set 里的 effect 全部拿出来执行。
用 WeakMap 是个细节考点:key 是原始对象且为弱引用,对象没人用了整条依赖记录能被自动回收,不会泄漏。
两代设计的本质区别在粒度和解耦。Vue2 把 Dep 塞在每个属性的闭包里,属性和依赖是绑死的;Vue3 把依赖关系抽出来单独存,effect 成了一个独立可复用的概念——所以 computed、watch、渲染更新全都是 effect 的不同封装,甚至可以脱离组件单独用响应式,这就是 @vue/reactivity 能独立发包的原因。
还有一个关键点是组件级更新:一个组件对应一个渲染 effect,所以某个数据变化只会让用到它的那个组件重新 render,不会波及父子。这跟 React 默认自上而下重渲染的模型是根本差异。
ref 和 reactive 有什么区别,为什么 ref 要写 .value?★★★reactive 用 Proxy 代理对象,所以它只能接对象和数组,传基本类型没有意义——Proxy 没法代理一个数字。
ref 的实现是包一层对象,内部是一个带 value 访问器属性的类(RefImpl),在 get value 里 track、set value 里 trigger。所以它能处理基本类型。如果传进去的是对象,内部会转成 reactive。
.value 存在的根本原因是 JavaScript 没法劫持基本类型变量的读写。let count = 0 这样的变量赋值,语言层面没有任何钩子能拦截,只能靠「把值放进对象的属性里」才能通过属性访问器捕获。所以 .value 不是设计冗余,而是语言限制下的必然选择。
由此也能解释 reactive 的一个大坑:解构和重新赋值会丢失响应性。
let state = reactive({ count: 0 })
const { count } = state // count 只是拿到了值 0,跟代理断开了
state = reactive({ count: 5 }) // 整个替换,原来的代理没人用了,视图不更新
ref 不怕重新赋值(改的是 .value,ref 对象本身没换),而 reactive 的解构问题要用 toRefs 解决——它把每个属性转成 ref,保留和源对象的连接。
实践上的取舍:我倾向统一用 ref,理由是心智负担小,不用记「这个是对象所以用 reactive、那个是数字所以用 ref」,也不会踩解构的坑,代价只是多写 .value(模板里会自动解包,不用写)。reactive 适合一组强关联的状态放在一起、且确定不会整体替换的场景。
computed 的缓存是怎么实现的,跟 watch 有什么本质区别?★★★computed 靠一个 dirty 标志位做缓存。它内部是一个 lazy 的 effect:第一次访问时执行计算函数,把结果存起来并把 dirty 置为 false;再次访问时如果 dirty 还是 false,直接返回缓存值,压根不执行计算函数。只有它依赖的响应式数据发生变化时,才把 dirty 置回 true,下次访问才重算。
所以模板里一个 computed 被引用十次,计算只发生一次。这就是「computed 有缓存、method 没有」的机制层面解释——method 每次渲染都会实打实执行一遍。
跟 watch 的本质区别在意图:computed 是声明「这个值由那些值算出来」,关注的是结果,必须有返回值、应该是纯函数、不该有副作用;watch 是声明「那个值变了我要做点什么」,关注的是过程,专门用来做副作用——发请求、写 localStorage、手动操作 DOM。
判断该用哪个有个简单标准:如果你在 watch 里做的事情只是给另一个响应式变量赋值,那它应该是 computed。这是很常见的写法退化,用 watch 同步派生状态会导致多一次更新、还可能出现中间态不一致。
另外 computed 还有两个容易被追问的点。一是它可以有 setter,写成 { get, set } 的形式,常用于给 v-model 绑一个需要转换的值。二是 computed 也是懒的,如果模板和代码里都没人访问它,它永远不会执行,所以不要指望在 computed 里做初始化。
nextTick 的原理是什么,为什么改了数据 DOM 不是立刻更新?★★★因为 Vue 的更新是异步批量的。改一个响应式数据,Vue 不会立刻重新渲染,而是把对应的更新任务推进一个队列并去重,然后在当前同步代码执行完之后,通过微任务一次性处理完整个队列。
这么做的原因很实际:一个方法里连续改了十个数据,如果每次都同步渲染就是十次渲染,绝大部分是白做的。批量之后只渲染一次,而且同一个组件被标记多次也只更新一次。
nextTick 做的事就是把你的回调注册到这个更新队列的后面,等 DOM 更新完再执行。它的实现是一个 Promise 微任务(Vue2 里做过降级处理,依次尝试 Promise、MutationObserver、setImmediate、setTimeout;Vue3 直接用 Promise.then)。
用微任务而不是宏任务是关键设计:微任务在当前事件循环的末尾、浏览器渲染之前执行,所以用户永远不会看到中间状态,也不会多一帧的延迟。如果用 setTimeout,更新就被推到下一个宏任务,可能出现闪烁。
实践场景:改了数据之后要拿到新 DOM 的尺寸位置、要让某个新出现的输入框自动聚焦、要滚动到新增的列表项,都得放进 nextTick 或者 await nextTick()。
有个容易混淆的追问:watch 的回调默认在 DOM 更新之前执行(flush: 'pre'),所以在 watch 里直接读 DOM 拿到的是旧的。要读新 DOM 就配 flush: 'post',效果等价于在回调里包一层 nextTick。
三个阶段:parse → transform → generate。
parse 把模板字符串解析成 AST(抽象语法树)。用有限状态机逐字符扫描,识别标签、属性、指令、插值表达式,构造出一棵描述模板结构的对象树。
transform 遍历 AST 做各种转换和静态分析,这是 Vue3 优化的主战场:标记哪些节点是静态的(可以提升到渲染函数外面)、给动态节点打上 PatchFlag(说明是 class 动态还是文本动态)、收集动态节点组成 Block、处理 v-if 和 v-for 这些结构性指令、缓存事件处理函数。
generate 把处理后的 AST 拼接成渲染函数的代码字符串,也就是一堆 createVNode 调用。
关键在于这个过程发生在什么时候,这决定了两个构建版本的差异。用了构建工具的项目里,.vue 文件的模板在打包时就编译成渲染函数了,运行时只需要执行函数,所以可以用运行时版本(runtime-only),体积更小。如果直接在 HTML 里写模板、或者用 template 选项传字符串,就必须在浏览器里现场编译,需要完整版(runtime + compiler),体积大三成左右,而且每次启动都有编译开销。
所以实践上:正常项目一定用运行时版本,模板全写在 .vue 文件里。只有需要动态渲染用户输入的模板这种特殊场景才需要完整版——而那种场景本身有 XSS 风险,通常应该换个设计。
另外可以顺着讲一句趋势:Vue3 把大量工作从运行时挪到了编译期,因为模板是静态可分析的,编译器能提前知道哪些永远不会变。这个方向被证明比优化运行时算法更有效,Svelte 走得更极端,直接编译成命令式的 DOM 操作、完全不要虚拟 DOM。
不一定,单看性能它反而多了一层开销。这题就是考你敢不敢说实话、以及能不能说清它真正的价值。
手写的、精准的原生 DOM 操作永远是最快的——你确切知道该改哪个节点的哪个属性,一次到位。虚拟 DOM 要先创建 vnode 对象、再做 diff 比对、最后才打补丁,这些 JS 计算都是纯粹的额外成本。
它的价值在另外三件事上。
一是保证性能下限,而不是提升上限。直接操作 DOM 的项目里,很容易写出「改一个数据就重新渲染整个列表」这种糟糕实现,或者在循环里反复读写布局属性触发多次重排。虚拟 DOM 把 diff 和批量更新交给框架,让普通开发者写出的代码性能也不至于太差。
二是换来了声明式的开发方式。你只描述「界面应该长什么样」,不用手写「从当前状态变到目标状态需要哪几步 DOM 操作」。这个心智负担的降低是巨大的,可维护性完全不是一个量级。
三是抽象出了跨平台能力。vnode 只是一个描述界面的 JS 对象,渲染成什么由 renderer 决定。所以同一套逻辑可以渲染成 DOM、渲染成原生组件(Weex)、渲染成小程序模板、渲染成 canvas,甚至渲染成字符串(SSR)。这是虚拟 DOM 最被低估的价值。
另外要知道 Vue3 大幅削减了 diff 的必要性:通过编译期的静态提升和 PatchFlag,很多节点压根不参与 diff,等于把「运行时全量比对」变成了「只比对可能变的部分」。所以现代框架的思路已经是尽量在编译期把工作做掉,Svelte 更极端,直接不用虚拟 DOM。
先说共同前提:两棵树完整比对的复杂度是 O(n³),不可接受。所以所有框架都做了同样的三个假设降到 O(n):只做同层比较不跨层移动;类型不同直接整棵替换不再深入;同层子节点靠 key 来判断是不是同一个。
Vue2 的双端比较:新旧子节点数组各设头尾两个指针,按四种情况尝试匹配——旧头对新头、旧尾对新尾、旧头对新尾、旧尾对新头。命中就处理并移动指针,四种都不命中才去建立 key 到索引的映射表查找。这个策略对头尾操作特别高效:数组反转、在头部插入、尾部追加,都能用极少的移动完成。
Vue3 的做法是先处理前后相同的部分(预处理头尾),剩下中间乱序的部分建立映射表,然后关键一步:求最长递增子序列。
为什么求 LIS?因为递增子序列里的节点相对顺序本来就是对的,可以原地不动,只需要移动不在这个序列里的节点。这样 DOM 移动次数被压到理论最少。举个例子,旧序列 a b c d e,新序列 a c d b e,LIS 找出 c d 保持不动,只移动 b 一个节点;而双端比较在这种中间乱序的情况下会产生更多次移动。
DOM 移动是真实的浏览器操作,比 JS 计算贵得多,所以多花一点 JS 时间算 LIS,换取更少的 DOM 移动,整体是划算的。这个取舍思路本身就是很好的答题材料。
顺带说 Vue3 的另一个优化:结合编译期的 Block Tree,动态节点被收集进一个扁平数组,diff 时只遍历这个数组而不是递归整棵树。所以静态内容再多也不影响 diff 成本,这比改进算法本身收益更大。
key 的作用是什么,为什么不建议用数组下标做 key?★★key 是给 diff 用的身份标识。没有 key 时,同层节点只能按位置一一对应,Vue 会认为「位置相同就是同一个节点」,于是尽量就地复用并修改内容(这是 Vue 默认的策略,叫就地更新)。
用下标做 key 等于没加 key,因为下标本身会随增删变化。问题在列表项有内部状态的时候会暴露出来:
假设列表是三个带勾选框的项 A B C,用户勾了 B,然后删掉 A。此时 B 的下标从 1 变成 0,C 从 2 变成 1。diff 按下标比对,发现下标 0 的内容从 A 变成了 B,就复用原来 A 的那个 DOM 和组件实例,只把文字改成 B——而勾选状态存在组件实例里,没被更新。结果就是:删掉 A 之后,看起来是 B 被勾选变成了「B 显示未勾选,C 显示勾选」这类错乱。
受影响的不只是勾选框:输入框里已输入的内容、展开收起状态、动画进度、组件内部的定时器,都会出现串位。而且这类 bug 有个特点——纯展示的列表完全正常,一旦加了交互就出问题,很容易漏测。
所以规则是:用数据本身的唯一标识做 key,通常是后端返回的 id。没有 id 的话,用能唯一确定这条数据的字段组合,实在没有就在拿到数据时生成一个稳定的本地 id(注意不能在 render 里用 Math.random() 生成,那样每次渲染 key 都变,会导致整个列表反复销毁重建,性能更差)。
反过来说,纯静态、永不增删排序的列表,用下标是没问题的,这一点可以补上,显示你理解的是原理不是教条。另外 key 还有个用法是强制重建组件——给组件绑一个会变的 key,key 一变 Vue 就销毁旧实例创建新的,用来重置组件内部状态,比写一堆 reset 逻辑干净。
核心思路是把运行时的活尽量挪到编译期,因为模板是静态可分析的,编译器能提前看出哪些东西永远不会变。
静态提升(hoistStatic):完全没有动态内容的节点,把它的 vnode 创建提到 render 函数外面,只创建一次然后每次渲染复用同一个对象。Vue2 里这些静态节点每次渲染都要重新创建一遍 vnode,纯属浪费。
PatchFlag(补丁标记):编译器给动态节点打上标记,说明它到底哪一部分是动态的——只有 class 动态、只有文本动态、还是有动态 props。运行时 diff 看到标记就只比对那一项,不用把所有属性都过一遍。这把「全量属性 diff」变成了「定点更新」。
Block Tree:这个是最关键的。编译器把模板划分成 block,每个 block 用一个扁平数组收集自己内部所有的动态节点。更新时不再递归遍历整棵 vnode 树去找哪里变了,而是直接遍历这个动态节点数组。所以一个页面里有一千个静态节点、三个动态节点,diff 的成本只跟那三个有关。v-if 和 v-for 会创建新的 block,因为它们会改变结构的稳定性。
缓存事件处理函数(cacheHandlers):模板里写 @click="() => foo(item)" 这种内联箭头函数,每次渲染都是新函数,会导致子组件 props 变化而多余更新。编译器把它缓存起来复用。
此外还有 SSR 优化,把大段静态内容直接编译成字符串拼接而不是逐个创建 vnode。
这些加起来的效果是:性能不再随模板体积线性下降,而只跟动态内容的数量相关。这也是为什么 Vue3 官方数据里更新性能提升那么明显。要理解的是这背后的趋势——现代前端框架都在往编译期优化走,因为运行时能做的优化已经接近极限了。
按组件间的关系来分类记,比死记方式列表清楚得多。
父子之间:props 往下传,emit 往上抛,这是首选,数据流最清晰。父组件要主动调子组件的方法用 ref(Vue3 的 <script setup> 里子组件必须 defineExpose 才能被访问到)。v-model 是 props 加 emit 的语法糖。
祖先与后代(跨多层):provide 和 inject。它解决的是「层层透传 props」的麻烦——中间那些组件压根不关心这个数据,却被迫接收再传下去。
透传属性:attrs 拿到父组件传来但没被 props 声明的属性,配合 v-bind="$attrs" 可以做一层薄封装(比如封装一个带默认样式的 input,把所有原生属性透传下去)。
任意组件之间:状态管理(Pinia),或者事件总线。这里要注意 Vue3 移除了实例上的 $on / $off / $emit,所以 Vue2 那种 new Vue() 当事件总线的写法失效了,需要用 mitt 这类第三方库。
选择原则是我会强调的重点:优先用最「近」的方式。能 props 和 emit 就不要上 provide,能 provide 就不要上全局 store。因为通信方式越全局,数据流向就越难追踪,出问题时越难定位是谁改了状态。
事件总线要慎用,它的问题是数据流完全不可追踪(谁发的、谁收的,全靠字符串事件名对上),而且必须记得在组件卸载时注销,否则组件反复创建会重复注册,一个事件触发多次回调。我一般只在「简单的一次性通知」场景用它,复杂状态一律走 store。
provide / inject 的原理是什么,怎么保持响应性?★★原理是沿着组件实例的父子链向上查找。每个组件实例有一个 provides 对象,默认原型指向父组件的 provides。所以 inject 时只需要在这条原型链上查找,天然就能拿到任意层级祖先提供的值,而且不需要遍历。自己调用 provide 时才会创建一个新的对象并把父级的作为原型,从而实现「就近覆盖」——离得近的祖先提供的值会遮住更远的。
响应性是最容易踩的坑:provide 本身不会自动让传下去的值变成响应式。如果你 provide('count', 0) 传一个普通值,后代拿到的就是一个死值,之后祖先改了后代也不会更新。
正确做法是传响应式对象:传 ref 或者 reactive 对象,这样后代通过它读取时依赖收集正常建立,值变了后代会重新渲染。
// 祖先
const count = ref(0)
provide('count', count) // 传 ref 本身,不要传 count.value
// 后代
const count = inject('count') // 拿到的是同一个 ref
另一个实践建议是不要让后代直接修改注入的值,那样数据从哪儿被改的完全无法追踪。更好的做法是把修改方法一起 provide 下去,后代调用方法,改动仍然发生在祖先组件内部,数据流向可控。要强制约束的话可以用 readonly() 包一层再 provide。
适用场景:主要是组件库内部的跨层通信,比如 Form 和深层嵌套的 FormItem 共享校验规则、Tabs 和 TabPane 联动。业务代码里跨层共享状态我更倾向直接用 store,因为 provide 的来源比较隐式,一个组件被注入了什么在代码里看不出来,跟 mixin 有类似的可读性问题。
还有个细节:inject 必须在 setup 同步执行期间调用,因为它依赖当前组件实例的上下文,放到异步回调里会拿不到。同样也可以提供默认值 inject('key', defaultValue),避免祖先没提供时报警告。
Vue3 的 Composition API 写法是 onBeforeMount、onMounted、onBeforeUpdate、onUpdated、onBeforeUnmount、onUnmounted,另外还有 onErrorCaptured、onActivated、onDeactivated。Vue2 的 beforeCreate 和 created 在 Vue3 里被 setup 本身取代了(setup 的执行时机就在这两者之间)。destroyed 在 Vue3 里改名成了 unmounted。
父子顺序的关键是:创建是父先开始、子先完成;销毁也是父先开始、子先完成。完整顺序是
父 setup → 父 beforeMount → 子 setup → 子 beforeMount
→ 子 mounted → 父 mounted
为什么子的 mounted 在父之前?因为父组件挂载的前提是它的子树都已经挂载完成——父要等所有孩子都就位,自己才算真正挂载好。理解这个因果关系比背顺序有用。
由此得出两条实践:需要操作 DOM 的代码放 onMounted,因为在这之前 DOM 还不存在;父组件在 onMounted 里可以确定所有子组件都已就绪,所以依赖子组件 ref 的操作要放这里,放 setup 里拿到的 ref 还是 null。
还有个高频追问:请求应该放哪里?放 setup(相当于 created)比放 onMounted 更早,能和渲染并行,首屏更快。onMounted 只在「必须等 DOM 就绪」时才用。另外 SSR 环境下 onMounted 不会执行,只在客户端跑,这决定了哪些逻辑能放进去。
v-model 的本质是什么,Vue2 和 Vue3 有什么差异?★★v-model 是语法糖,等价于一个 prop 加一个事件,本质是把「单向数据流」包装成看起来像双向绑定。
在原生表单元素上,它会根据元素类型展开成不同的东西:input 和 textarea 是 value 加 input 事件,checkbox 和 radio 是 checked 加 change,select 是 value 加 change。
在自定义组件上,Vue2 默认是 value prop 加 input 事件,想改名要用 model 选项,而且一个组件只能有一个 v-model,需要多个双向绑定只能用 .sync 修饰符。
Vue3 改成了 modelValue prop 加 update:modelValue 事件,并且支持多个 v-model,写成 v-model:title 就对应 title 和 update:title。同时 .sync 被移除了,因为它的能力被 v-model 参数完全覆盖。这个统一是很好的简化——Vue2 里 v-model 和 .sync 做的是同一件事却是两套语法。
Vue3.4 之后还提供了 defineModel 宏,直接拿到一个可读写的 ref,不用手写 props 和 emit,这是现在写自定义组件双向绑定最简洁的方式。
实践中的常见坑:不能直接修改 v-model 传进来的 prop,那违反单向数据流会报警告。需要在组件内部操作时,要么 emit 出去让父组件改,要么用一个带 getter 和 setter 的 computed 做中转——后者在写表单组件时特别常用,因为可以在 setter 里做格式化和校验。
插槽的本质是把父组件写的模板内容编译成函数,传给子组件去调用。
普通插槽的内容在父组件的作用域里编译,所以里面用的数据是父组件的。子组件拿到的是一个已经编译好的 vnode 生成函数,它只负责决定在哪个位置、什么条件下把这些内容渲染出来。这解释了两个现象:一是插槽内容能访问父组件的数据但访问不到子组件的;二是子组件可以通过 v-if 控制插槽内容渲染不渲染,甚至渲染多次。
作用域插槽(scoped slot)的关键在于:它编译出来的不是普通函数,而是一个接受参数的函数。子组件在渲染插槽时把自己的数据作为参数传进去调用,父组件通过 v-slot="slotProps" 接收这个参数。
<!-- 子组件内部 -->
<slot :item="row" :index="i" />
<!-- 父组件使用 -->
<MyTable v-slot="{ item, index }">
{{ index }} - {{ item.name }}
</MyTable>
所以数据流向没有被打破:还是子组件把数据「传出来」,只是传递的载体从事件变成了函数参数。理解成回调函数就很清楚了——父组件提供一个渲染回调,子组件在合适的时机带着自己的数据调用它。
这个机制的价值在于逻辑和视图的分离:一个表格组件可以负责分页、排序、加载这些逻辑,而每一列具体长什么样完全交给使用方决定。所有高质量的 UI 组件库都大量依赖作用域插槽,这也是 Vue 里实现「无渲染组件」的基础。
Vue3 的变化是所有插槽都统一成了函数(Vue2 里普通插槽是 vnode 数组、作用域插槽才是函数),这样编译和更新逻辑统一了,而且插槽内容变化时只会更新子组件而不会触发父组件重渲染,性能更好。语法上 slot-scope 和 slot 都被 v-slot 统一取代。
keep-alive 是怎么实现缓存的,activated 什么时候触发?★★keep-alive 是一个抽象组件,它自己不渲染任何 DOM,作用是把包裹的组件实例缓存起来,切换时不销毁。
实现上它内部维护一个缓存对象和一个 key 列表,渲染时先看缓存里有没有对应 key 的实例:有就直接复用那个 vnode 和组件实例,没有就正常创建并存进缓存。关键在于组件「消失」时它做的不是销毁,而是把真实 DOM 移到一个隐藏容器里(Vue3 里是通过特殊的 activate 和 deactivate 钩子把 DOM 搬走或搬回),所以组件实例、内部状态、滚动位置全都留着。
因为组件没有被销毁,onUnmounted 就不会触发,取而代之的是onDeactivated(被缓存起来时)和 onActivated(从缓存恢复时)。首次进入的顺序是 onMounted 然后 onActivated;之后每次回来只有 onActivated,onMounted 不会再执行。
这就带来一个必须注意的实践点:「每次进入页面都要刷新的数据」不能放 onMounted,因为被缓存后它只执行一次,用户返回列表页看到的还是旧数据。这类逻辑要放 onActivated。反过来,只需初始化一次的重活放 onMounted 正好。
缓存策略上,include 和 exclude 按组件名筛选,max 限制最大缓存数量——超出后按 LRU 淘汰最久未使用的。这个 max 在实际项目里很重要,无限缓存会让内存持续增长,尤其是缓存了带大列表或大图的页面。
还有个常见需求是「从详情页返回列表页要缓存,但从首页进入列表页要刷新」,这个靠 keep-alive 本身做不到,需要配合路由守卫判断来源,或者动态改 include。这类需求也是场景题里的常见考点。
解决的是 Options API 在复杂组件里逻辑被打散的问题。一个组件如果有三块功能,用 Options API 写就会变成:三块的数据混在 data 里、三块的计算属性混在 computed 里、三块的方法混在 methods 里。想看懂其中一块功能,得在文件里上下反复跳。组件越大越痛苦。
Composition API 让同一个功能的所有代码写在一起,还能抽成独立的组合函数(useXxx)复用。
跟 mixin 对比,优势有三点,都是 mixin 的硬伤。命名冲突:多个 mixin 定义了同名的 data 或 method 会互相覆盖,而且是静默覆盖,出问题很难查;组合函数的返回值由你自己命名解构,冲突当场就能改。来源不清:组件里用到一个 this.someValue,你不知道它是哪个 mixin 带来的,得逐个翻;组合函数是显式调用显式接收,来源一目了然。无法传参和组合:mixin 是静态混入,不能根据条件传不同参数;组合函数就是普通函数,可以传参、可以互相调用、可以有返回值。
要说清的是它不是用来取代 Options API 的。简单的展示型组件用 Options API 写反而更短更直观,Vue3 也一直保留支持。所以选择标准是复杂度:逻辑简单就 Options,有多块可复用逻辑或者组件超过一两百行就上 Composition。
另外一个实际收益是类型推导。Options API 里 this 上挂的东西全靠 TS 的类型体操去推,经常不准;组合函数就是普通的函数调用,类型自然流转,TS 体验好得多。这是很多团队转向 Composition API 的直接原因。
watch 和 watchEffect 有什么区别,怎么选?★★watch 是显式指定要监听什么,回调能拿到新值和旧值,默认不会立即执行。watchEffect 是自动收集依赖——它立刻执行一遍传入的函数,执行过程中读到的所有响应式数据都成为它的依赖,之后任何一个变化就重新执行。
几个具体差异:是否立即执行,watchEffect 一定会先跑一次,watch 要加 immediate: true 才会;能否拿旧值,只有 watch 能;依赖是否明确,watch 明确,watchEffect 隐式。
选择标准我是这样:需要旧值、或者只想在特定数据变化时才动、或者依赖关系必须清晰可控,用 watch;逻辑同时依赖好几个数据、且「任意一个变了就重跑」的语义正好合适,用 watchEffect,能省掉手动列依赖的麻烦。
watchEffect 的坑要清楚。一是依赖收集是运行时的,如果函数里有 if 分支,某个数据在这次执行时没被读到,它就不算依赖,之后它变化也不会触发——这会导致「有时响应有时不响应」的诡异现象。二是容易意外引入依赖,函数里顺手读了个不相关的响应式数据,就多了一个触发源,可能造成多余执行甚至循环。三是异步操作之后读到的数据不会被收集,因为依赖收集只在同步执行期间进行,await 之后读的响应式数据是不算依赖的,这个很容易踩。
另外两个配套知识点:watch 监听对象要加 deep: true 才能感知内部属性变化,但深度监听有性能成本(要递归遍历),更好的做法是精确监听到具体属性,用 getter 写法 watch(() => obj.a.b, cb)。还有 onCleanup 参数,用来在下次执行前清理上一次的副作用,做搜索防抖和取消请求时很有用。
setup 里没有 this,props 为什么不能直接解构?★★★setup 的执行时机在组件实例创建的过程中,比 beforeCreate 还早,那时候实例还没初始化完,this 压根没有可用的内容。更重要的是设计上有意去掉了它:Composition API 的思路是所有东西通过参数传入、通过返回值传出,不再依赖 this 这个隐式的、什么都往上挂的容器。这也让 TS 的类型推导变得直接——不用再去推 this 上有什么。
需要访问实例上的东西时,各自有明确的替代:props 和 emit 从 setup 的参数拿,attrs 和 slots 从第二个参数拿,路由用 useRouter() 和 useRoute(),store 用 useStore()。这些 useXxx 底层是通过 getCurrentInstance 拿到当前实例,所以它们必须在 setup 的同步执行期间调用,放到异步回调或者事件处理函数里会拿不到实例——这是很常见的报错原因。
props 不能直接解构是因为 props 是一个响应式代理对象,响应性建立在「通过这个对象访问属性」这件事上。const { title } = props 只是把当前那一刻的值取出来赋给一个普通变量,从此和 props 断开连接,父组件更新时这个变量不会变。
解法有几个:直接用 props.title;需要单独一个响应式引用就用 toRef(props, 'title');要把所有属性都转成 ref 用 toRefs(props)。
值得补一句时效性的:Vue 3.5 之后有了「响应式 props 解构」,在 <script setup> 里解构 props 会被编译器处理成保持响应性的形式,这个限制在新版本里被消除了。但要知道它是编译期做的转换,只在 script setup 环境下生效,普通 setup 函数里直接解构依然会丢响应性。
按影响程度而不是按 API 清单来说,才能体现理解深度。
一、响应式实现换了底层。Object.defineProperty 改成 Proxy:新增删除属性能侦听(不再需要 $set)、数组下标和 length 能侦听(不再需要重写七个数组方法)、懒代理(只有访问到嵌套对象时才转换,初始化性能大幅提升)。代价是放弃 IE11。
二、编译期优化,这是性能提升的主要来源,很多人只答响应式就漏了更重要的部分。静态提升把不变的 vnode 提到渲染函数外只创建一次;PatchFlag 标记每个动态节点具体哪部分会变,diff 时只比对那一项;Block Tree 把动态节点收集进扁平数组,更新时不再递归遍历整棵 vnode 树。效果是性能不再随模板体积线性下降,只和动态内容数量相关。
三、Composition API。解决的是 Options API 在复杂组件里同一功能的代码被拆散到 data/computed/methods 各处的问题,同时带来了比 mixin 更好的逻辑复用(无命名冲突、来源明确、可传参组合)和大幅改善的 TypeScript 推导。后者是很多团队升级的直接动机。
四、diff 算法调整:从双端比较改成「预处理头尾 + 中间部分求最长递增子序列」,把 DOM 移动次数压到理论最少。
五、架构层面:源码用 TS 重写并按模块拆包(@vue/reactivity 可以独立使用,脱离 Vue 做响应式);更好的 tree shaking(没用到的功能不打进包);支持多根节点(Fragment)、Teleport(把内容渲染到 DOM 树的其他位置,解决弹窗层级问题)、Suspense(异步组件的加载态)。
六、破坏性变更要知道,这是迁移的主要成本:$on/$off/$emit 事件 API 被移除(事件总线要换 mitt)、filters 移除、$children 移除、v-model 语义改变(modelValue 加 update:modelValue,.sync 被取代)、全局 API 改成应用实例(createApp 而不是 new Vue)、Vuex 换 Pinia。
一句话总结的话:Vue3 的性能提升主要来自编译期做得更多而不是运行时算法更聪明,开发体验提升主要来自 Composition API 加 TS。这个判断比背 API 列表有价值。
v-if 和 v-show 有什么区别,常用指令还有哪些坑?★★v-if 是真正的条件渲染:为 false 时组件压根不创建(或者被销毁),DOM 里没有这个节点,事件监听和子组件也一并销毁重建。v-show 只是切换 CSS 的 display,节点始终存在。
所以选择标准是切换频率:频繁切换用 v-show(避免反复销毁重建的开销),条件很少改变或者初始很可能不渲染用 v-if(省掉初始渲染成本)。
几个实际影响要说清。v-if 切换会重置组件内部状态——输入框内容、勾选、滚动位置全丢,这有时是你想要的(当作重置手段),有时是 bug 来源。v-show 对组件的生命周期没有影响,所以隐藏的组件里的定时器、请求、动画还在跑,这是常见的性能问题和资源浪费。v-show 不能用在 template 上(没有真实节点可以设 display)。
v-if 和 v-for 不要写在同一个元素上:Vue2 里 v-for 优先级更高,意味着每一项都要先渲染再判断,白做很多工作;Vue3 里 v-if 优先级更高,导致它访问不到 v-for 的变量直接报错。正确做法是用 computed 先过滤数据,或者外面套一层 template。
v-for 的 key 前面单独讲过,这里补一点:key 要用数据的唯一标识,不能用 index(增删排序时导致状态串位),也不能用随机值(每次渲染都变,导致整个列表销毁重建,性能反而更差还会丢状态)。
v-html 有 XSS 风险,渲染用户输入的内容必须先用 DOMPurify 净化,不要自己写正则过滤。
事件修饰符里比较实用的:.stop 阻止冒泡、.prevent 阻止默认行为、.once 只触发一次、.passive 提升滚动性能(告诉浏览器不会 preventDefault,可以立即滚动)。.native 在 Vue3 里被移除了。.lazy 和 .number 在表单场景很有用,前者改成 change 时才同步(避免每次输入都触发校验和请求),后者自动转数字(避免拿到字符串导致的计算错误)。
要分清四层,因为它们能捕获的范围完全不同,缺任何一层都会漏。
一、app.config.errorHandler:Vue 的全局错误处理,能捕获组件渲染、生命周期钩子、事件处理函数、watcher 回调里的错误,并且会带上出错的组件实例和具体的钩子名,这对定位非常有用。这是最主要的一层。
二、onErrorCaptured(Vue2 是 errorCaptured):组件级的错误边界,能捕获子孙组件抛出的错误。返回 false 可以阻止错误继续向上传播。用它可以实现局部降级——某个模块崩了就显示一个占位提示,而不是整页白掉。这个能力在首页这种由多个独立模块拼起来的页面上很有价值。
三、window.onerror 和 unhandledrejection:Vue 管不到的地方要靠这两个。异步代码里的错误(setTimeout 回调、Promise 链)、以及Vue 之外的代码(第三方 SDK、原生事件监听)都不走 Vue 的 errorHandler。unhandledrejection 特别重要——没有 catch 的 Promise 拒绝是最常见的静默失败来源。
四、资源加载失败:图片、脚本、样式加载失败不触发 window.onerror,要用捕获阶段的 error 事件监听(window.addEventListener('error', fn, true))。这一层很多人不知道,导致「CDN 挂了但监控毫无反应」。
上报要注意几件事。必须带上 sourcemap 能解析的信息——线上代码是压缩的,堆栈是天书,要把 sourcemap 上传到监控平台(但不要部署到公网,否则源码就泄露了)。要带用户操作路径(面包屑:最近几次点击和路由跳转),否则只有一行报错很难复现。要做去重和限流,一个死循环里的报错能瞬间发出几万条上报,把自己的监控服务打挂。
还有两类要单独处理:接口错误不应该走全局错误处理,要在请求层统一拦截并区分业务异常和系统异常(业务异常给用户友好提示,系统异常上报并提示稍后重试);跨域脚本的错误会变成没有细节的 Script error,要给 script 标签加 crossorigin 属性并让 CDN 返回正确的 CORS 头,否则第三方脚本的问题永远查不出来。
分两个方向:让包更小和让关键内容更早出现。
包体积方面,最有效的是路由懒加载,用动态 import() 把每个路由拆成独立 chunk,首屏只加载当前路由的代码。然后是按需引入组件库,用 unplugin-vue-components 这类插件自动按需引入,避免整包引入几百 KB 的 UI 库。检查重复依赖和体积大头,用 rollup-plugin-visualizer 出可视化报告,常见的意外大头是 moment、lodash 全量引入、以及多个库各自打包了同一个依赖的不同版本。压缩用 gzip 或 brotli,配合服务端配置,通常能再降百分之六七十。
加载策略方面,静态资源上 CDN 并配好长期缓存(文件名带 hash);把不常变的第三方库单独拆成 vendor chunk,这样业务代码更新时用户的 vendor 缓存还能用;用 <link rel="preload"> 提前加载关键资源,prefetch 预取用户很可能马上访问的路由。
感知优化同样重要:加骨架屏或者 loading,避免白屏;图片懒加载加合适的尺寸,首屏图片用 preload;长列表用虚拟滚动。
渲染层面,如果首屏内容对 SEO 或者秒开有硬要求,就要上 SSR 或者预渲染——纯 SPA 必须等 JS 下载执行完才能看到内容,这是架构层面的限制,优化不掉。
最后强调先测量再优化:用 Lighthouse 和真实用户监控看 LCP、FCP、TTI 这些指标,找到瓶颈在哪一段。很多时候瓶颈压根不是包体积,而是某个慢接口卡住了首屏渲染,那优化打包就完全是白费功夫。
hash 模式把路径放在 # 后面。关键特性是 # 后面的内容不会发送给服务器,所以不管前端路由怎么变,服务器收到的请求路径始终是同一个。路由变化通过 hashchange 事件监听。好处是不需要任何服务端配置,部署最简单,兼容性也最好。缺点是 URL 里带个 # 不美观,而且对 SEO 不友好。
history 模式用 HTML5 的 pushState 和 replaceState 改变 URL 而不触发页面刷新,配合 popstate 事件监听前进后退。URL 是干净的正常路径。
它需要服务端配合的原因很直接:用户直接访问或刷新一个子路由时,这个请求是真的发到服务器上的。比如访问 /user/detail,服务器上压根没有这个目录和文件,默认就返回 404。而 SPA 的实际情况是——所有路由都应该返回同一个 index.html,然后由前端 JS 接管路由解析。
所以服务端要配一条兜底规则:找不到对应静态资源时统一返回 index.html。Nginx 里是 try_files $uri $uri/ /index.html;。
这里有个配套的坑:兜底规则会让真正的 404 也返回首页,用户访问一个不存在的路径看到的是首页而不是 404 页。所以前端路由要配一个 * 通配路由指向自己的 404 组件。另外要注意兜底规则别把 API 路径也吃掉了,一般会把 /api 前缀排除在外。
选择上:能配服务端就用 history,URL 干净、SEO 友好;部署环境改不了配置(比如放在别人的静态托管上、或者嵌在某个子路径里)就用 hash。另外要注意如果项目不是部署在域名根目录,还得正确设置 base。
守卫分三层:全局守卫(beforeEach、beforeResolve、afterEach)、路由独享守卫(beforeEnter)、组件内守卫(beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave)。
完整顺序是:离开组件的 beforeRouteLeave → 全局 beforeEach → 重用组件的 beforeRouteUpdate → 路由的 beforeEnter → 解析异步组件 → 进入组件的 beforeRouteEnter → 全局 beforeResolve → 导航确认 → 全局 afterEach → DOM 更新。
顺序的规律是从外到内、先离开再进入,记住这个逻辑比背列表可靠。afterEach 没有 next 参数因为导航已经确定了,改不了了,所以它只适合做埋点、改标题、关 loading 这类事。
权限控制的做法分两种粒度。路由级:在 beforeEach 里判断,没登录跳登录页,登录了但没权限跳 403。要注意必须避免死循环——跳转的目标路由本身要放过,不然登录页也被拦就无限重定向了。菜单和按钮级:登录后拿到用户的权限列表,动态生成路由表(router.addRoute)和菜单,按钮级用自定义指令或者组件包一层控制显示。
几个实践要点。动态路由要处理刷新丢失:addRoute 加的路由存在内存里,刷新就没了,所以要在守卫里判断「权限数据还没拉取过」时先拉取并重新注册,注册完用 next({ ...to, replace: true }) 重新进一次,否则会因为路由还没注册好而白屏。
最后必须说清一点:前端权限控制只是体验优化,不是安全措施。所有校验必须在后端做,因为前端代码用户完全可控,藏起来的按钮照样可以直接调接口。这一点答出来能明显区分对安全有没有概念。
mutation?★★最直观的区别:Pinia 没有 mutation,只有 state、getters、actions;没有嵌套模块的概念,而是多个扁平的 store 互相引用;不需要 this.$store.commit('xxx') 这样的字符串调用,直接调方法。
去掉 mutation 的原因是它的收益不值那个成本。Vuex 里区分 mutation(同步改状态)和 action(异步逻辑)的初衷是为了让状态变更可追踪,devtools 能记录每一次变更。但代价是同一件事要写两遍——action 里处理异步然后 commit 一个 mutation,大量样板代码。而且 TypeScript 对 commit('someType', payload) 这种字符串调用几乎无法做类型推导。
Pinia 的判断是:devtools 的追踪能力可以通过其他方式实现(它确实也支持时间旅行调试),没必要用一层强制的 API 约束来换。所以 action 里既能写异步也能直接改 state,代码量少一半,类型推导还完整。
其他几个实际差异:Pinia 是为 Composition API 设计的,可以用 setup 语法定义 store,写起来跟写组合函数一样自然;天然的代码分割,每个 store 独立,没用到的 store 不会打进包;不需要处理命名空间,Vuex 的嵌套模块加命名空间是很容易出错的一块。
还有一点值得提:Pinia 就是 Vuex 5 的实现,是官方团队做的,Vuex 已经进入维护状态。所以新项目没有理由再选 Vuex。
最后补一个常被追问的:什么时候才需要状态管理库?不是所有项目都需要。只有一个页面用的状态就放组件里,父子传递用 props 和 emit,跨少数几层用 provide/inject。真正需要 store 的是多个不相邻的组件共享、且需要持久存在的状态,典型是用户信息、登录态、全局配置、购物车。为了「规范」把所有状态都塞进 store 反而增加复杂度。
SSR 解决纯 SPA 的两个硬伤。首屏白屏时间长:SPA 返回的 HTML 基本是空的,必须等 JS 下载、解析、执行、请求数据之后才能看到内容,弱网和低端机上体感很差。SEO 不友好:爬虫拿到的是空页面,虽然主流搜索引擎现在能执行 JS,但抓取的优先级和完整度都不如直出的 HTML。
SSR 的做法是在服务端把组件渲染成 HTML 字符串直接返回,浏览器拿到就能立刻显示内容,同时把数据序列化进 HTML。用户能更快看到内容(FCP 和 LCP 明显改善)。
水合是承接这一步的关键:服务端返回的 HTML 只是静态结构,没有任何事件监听,点击按钮没反应。所以客户端 JS 加载完之后,Vue 会接管这些已有的 DOM——遍历一遍,把组件实例、响应式状态和事件监听绑定到对应节点上,让静态页面「活」起来。这个过程就叫水合。
关键在于它复用现有 DOM 而不是重新创建,所以要求服务端和客户端渲染出的结构必须一致。不一致就会出现「水合失败」的警告,Vue 只能丢弃服务端的 DOM 重新渲染,等于白做了一遍。
常见的不一致原因值得记:用了只在客户端存在的东西(window、localStorage、navigator);渲染了随机值或时间(Math.random()、new Date(),两端算出来必然不同);HTML 结构不合法(比如 <div> 放进了 <p> 里,浏览器会自动纠正结构导致对不上)。解法是把这类逻辑放 onMounted 里,或者用 ClientOnly 之类的组件包起来。
要说清 SSR 的代价:需要一个 Node 服务,运维复杂度和服务器成本都上去了;服务端有并发压力,渲染是 CPU 密集的;onMounted 之前的生命周期在服务端也会执行,代码里到处要判断运行环境;调试更麻烦。所以不要默认「SSR 更好」——中后台系统压根不需要 SEO,也不在乎首屏那几百毫秒,上 SSR 纯属自找麻烦。真需要的是内容型站点和电商这类依赖搜索流量、且首屏速度直接影响转化的场景。
另外现在还有折中方案值得提一句:SSG(构建时生成静态 HTML,适合内容不常变的站点)和预渲染(只把首页等少数页面预先渲染出来),成本比 SSR 低得多,很多场景够用了。
没有匹配的题目,换个关键词试试。
模块 05 · 共 27 题 · 题目与答案分离,建议先自答再展开