0%

Component Injected 属性

这些属性通过调用 app.use(router) 注入到每个子组件中。

阅读全文 »

【面试速答版】

Q1: “Vue Router 4 相比 3 有哪些主要变化?”

创建从 new VueRouter() 变为 createRouter({ history: createWebHistory() })。全面拥抱 Composition API——useRouteruseRoute。导航守卫 next() 变为可选,不 return 即放行,不再有”忘记调用 next 页面卡死”的 bug。移除了 <transition> + <keep-alive> 的旧嵌套用法,改用 <router-view v-slot> 灵活控制。TypeScript 类型全面增强。动态路由 API 更完善(addRoute/removeRoute)。

阅读全文 »

【面试速答版】

Q1: “Vue3 到底好在哪?你有 Vue2 迁移到 Vue3 的经验吗?”

Vue3 是 Vue2 的架构重构而非简单升级。四个维度:响应式——Proxy 替代 Object.defineProperty,解决了数组/新增属性监听问题;API 范式——Composition API + composables 替代 mixin,解决逻辑复用冲突;性能——编译时 PatchFlag + 静态提升 + Block Tree,渲染性能提升 1.3-2 倍;生态——Pinia 替代 Vuex(去掉了烦人的 mutations),Vite 替代 Webpack。迁移经验:Options API 和 Composition API 可以混用,建议渐进式迁移——先升级构建工具和依赖库,再逐组件改造。

Q2: “Vue2 和 Vue3 有哪些破坏性变更?迁移时需要特别注意什么?”

必知变更:v-model 默认 prop 从 value + input 变为 modelValue + update:modelValue.sync 修饰符废弃,用多 v-model 替代;$on/$off/$once 移除(EventBus 不再内置,推荐 mitt);filters 移除(用 computed 或方法代替);$listeners 合并到 $attrsv-if 优先级高于 v-for(Vue2 相反);生命周期 beforeDestroybeforeUnmountdestroyedunmounted迁移建议:先安装 @vue/compat 兼容模式,逐一修复控制台的废弃警告,再移除兼容层。

Q3: “Vue2 项目如何迁移到 Vue3?必须全部重写吗?”

不需要全部重写。Options API 在 Vue3 中完全兼容,Composition API 可以混用。推荐的迁移步骤:① 升级构建工具到 Vite 或 Webpack 5 并安装 Vue3;② 安装 @vue/compat(Vue3 的 Vue2 兼容模式);③ 一条条修复兼容警告(先解决 filters 和 v-model,再处理生命周期改名和 EventBus);④ 移除 @vue/compat,使用原生 Vue3;⑤ 新组件用 <script setup> 写,旧组件逐步改造。核心原则:基础设施先升级,业务代码逐步改,不急着重写所有组件

【深入理解版】

1. 这个知识点要解决什么问题?

Vue2 发布于 2016 年,随着前端发展,它的几个设计逐渐暴露出问题:

  • Object.defineProperty 的局限——无法监听数组下标修改、新增/删除属性,需要 hack(重写 7 个数组方法)和 Vue.set/Vue.delete
  • Options API 在复杂组件中的维护问题——200 行的组件,功能逻辑分散在 data/methods/computed/watch 四个区域。
  • mixin 的缺陷——命名冲突、来源不明、隐式依赖。
  • Tree-shaking 不支持——即使不用过渡动画、指令,打包时也被包含。
  • 全局 API 污染——Vue.mixin 影响所有实例。

Vue3 在 2020 年发布,2022 年成为默认版本,重写了核心模块但保持了 90% 以上的 API 兼容。

2. 核心差异对比

2.1 创建实例

1
2
3
4
5
6
7
8
// Vue2 — 全局 API 可能导致冲突
Vue.mixin({ created() { /* 影响所有实例 */ } })
new Vue({ el: '#app', render: h => h(App) })

// Vue3 — 应用实例隔离
const app = createApp(App)
app.mixin({ created() { /* 仅影响当前应用 */ } })
app.mount('#app')

2.2 响应式系统

1
2
3
4
5
6
7
8
9
10
// Vue2 — 需要额外 API 处理新增/删除
this.obj.newProp = 'hello' // ❌ 不是响应式
Vue.set(this.obj, 'newProp', 'hello') // ✅
this.arr[0] = 'x' // ❌ 不是响应式
this.arr.splice(0, 1, 'x') // ✅

// Vue3 — Proxy 原生支持
state.newProp = 'hello' // ✅
state.arr[0] = 'x' // ✅
delete state.obj.prop // ✅

2.3 生命周期对照

Vue2Vue3说明
beforeCreate / createdsetup()setup 在 beforeCreate 之前执行
beforeMountonBeforeMount设名
mountedonMountedhooks 化
beforeUpdateonBeforeUpdatehooks 化
updatedonUpdatedhooks 化
beforeDestroyonBeforeUnmount更名,语义更清晰
destroyedonUnmounted更名
errorCapturedonErrorCapturedhooks 化

2.4 模板语法

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- Vue2 — 单根节点 -->
<template>
<div>
<header />
<main />
</div>
</template>

<!-- Vue3 — 多根节点(Fragments)-->
<template>
<header />
<main />
</template>
1
2
3
4
5
6
7
8
9
<!-- Vue2 v-model -->
<Child v-model="title" />

<!-- Vue3 v-model(默认 prop 变化)-->
<Child v-model="title" />
<!-- 等价于 :modelValue="title" @update:modelValue="val => title = val" -->

<!-- Vue3 多 v-model -->
<Child v-model:title="title" v-model:content="content" />

3. 迁移中的常见坑

坑 1:v-model 默认行为变化

Vue2 的 v-model="count" 对应 value prop 和 input 事件。Vue3 改为 modelValue prop 和 update:modelValue 事件。如果自定义组件升级到 Vue3 但没有改 props 声明,双向绑定功能会失效。

坑 2:filters 移除

1
2
3
4
5
6
7
8
9
10
<!-- Vue2 -->
<div>{{ price | currency }}</div>

<!-- Vue3 — 改为方法或 computed -->
<script setup>
const currency = (val) => `¥${val.toFixed(2)}`
</script>
<template>
<div>{{ currency(price) }}</div>
</template>

坑 3:EventBus 移除

Vue2 中 new Vue() 作为事件总线 + $on/$off。Vue3 中不再支持。推荐:状态共享用 Pinia,跨组件事件通信用 mitt(200 字节)。

坑 4:$listeners 合并到 $attrs

Vue2 中 $attrs 只包含非 props 的 attribute,$listeners 包含所有监听器。Vue3 中两者合并到 $attrs,且 classstyle 也包含在 $attrs 中。

4. 迁移策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Step 1: 升级构建环境
将 Webpack 升级到 v5 或迁移到 Vite
安装 Vue3、Vue Router 4、Pinia(替代 Vuex)

Step 2: 安装 @vue/compat(Vue3 的 Vue2 兼容模式)
在项目中启用兼容模式,它会自动检测废弃用法并给出警告

Step 3: 逐条修复兼容警告
优先级从上到下:
├─ filters → 改为 computed/method
├─ v-model 调整(value → modelValue)
├─ $listeners → $attrs
├─ 移除 $on/$off/$once
├─ 生命周期改名(beforeDestroy → beforeUnmount)
├─ .sync → 多 v-model
└─ v-if / v-for 优先级问题

Step 4: 移除 @vue/compat,使用原生 Vue3

Step 5: 逐步将组件迁移到 Composition API + <script setup>
新组件直接用新语法,旧组件保持 Options API 也可以

5. 对比总结

对比维度Vue2 (2016)Vue3 (2020)
响应式核心Object.definePropertyProxy
API 范式Options API+ Composition API
逻辑复用mixincomposables
TypeScript困难一等公民
渲染性能全量递归 diffPatchFlag + 静态提升
包体积~30KB(不可摇树)~13KB(可摇树)
多根节点不支持Fragment 支持
状态管理Vuex(需 mutations)Pinia(无 mutations)
构建工具Webpack(默认)Vite(官方推荐)

6. 现代最佳实践(2024-2025)

  1. 新项目直接选 Vue3 + Vite + TypeScript + Pinia + Vue Router 4
  2. 渐进式迁移——不必一次性重写所有组件。Options API 依然受支持,可以和 Composition API 混用。
  3. 放弃 mixin——所有 mixin 逻辑改为 composables(useXxx 函数)。
  4. 放弃 EventBus——状态共享用 Pinia,事件通信用 mitt。
  5. 利用 @vue/compat 做平滑迁移——不要直接在旧项目上装 Vue3 原生版。

7. 常见疑问解答

Q:Vue3 中为什么把 beforeDestroy 改成 beforeUnmount?

A:语义更准确。destroy 在英文中暗示”彻底销毁”,而 React 也有类似生命周期方法。Vue 团队认为 unmount(卸载)更准确地描述了”组件从 DOM 树中移除”这个行为。同理 destroyedunmounted

Q:我能在 Vue2 项目中使用 Composition API 吗?

A:可以——安装 @vue/composition-api 插件后,Vue2 项目也能用 refreactivecomputedwatchonMounted 等。但有一些限制:① defineComponent 是零等的;② <script setup> 不可用(这是编译器特性,Vue2 编译器不支持);③ onUnmounted 在 Vue2 中映射为 destroyed。这只是一种过渡方案,最终还是需要迁移到 Vue3。

Q:@vue/compat 兼容模式会拖慢性能吗?

A:会有一点性能损耗,因为兼容层需要额外做 Vue2 API 的适配转换。但它只应该作为迁移过程的工具——你逐条修复废弃警告,修复一条就少一条兼容代码。当所有 warnings 都修完后,就可以移除 @vue/compat,使用原生 Vue3。不要长期在生产环境使用兼容模式

关联知识点索引

  • 所有 Vue3 文档(本目录)
  • 所有 Vue2 文档(../Vue2/

【面试速答版】

Q1: “Vue3 对比 Vue2 有哪些核心变化?”

三大核心变化:响应式系统——用 Proxy 替代 Object.defineProperty,解决了 Vue2 无法监听数组下标修改、新增/删除属性、Map/Set 的问题,且性能更好。Composition API——把组件逻辑按功能组织而非按选项(data/methods/computed)分割,解决了 Vue2 mixin 的命名冲突和来源不明问题。编译器优化——引入 PatchFlag(编译时标记动态节点类型)、静态提升(静态节点提升到 render 外复用)、树摇(按需引入运行时 API),渲染性能提升约 1.3-2 倍。此外还新增了 Fragments(多根节点)、Teleport(传送门——把组件渲染到指定 DOM 位置)、Suspense(异步依赖统一管理)等特性。

Q2: “Composition API 解决了什么痛点?Vue2 的 Options API 有什么问题?”

Vue2 中一个 200 行的组件,逻辑分散在 data、methods、computed、watch 四个选项中——搜索功能相关的代码分散在四个地方,分页功能也是。你想理解”搜索”整个功能,要在文件内来回跳转。这就是”逻辑分散”问题。逻辑复用靠 mixin——但 mixin 有命名冲突(两个 mixin 都定义了 handleSearch)、来源不明(模板里的 handleSearch 是哪个 mixin 提供的?)、隐式依赖(mixin 依赖宿主组件的某个 data,但没有任何类型提示)。Composition API 让你按功能组织:搜索的逻辑写在一起,分页的逻辑写在一起,然后通过 composables(组合函数)把逻辑提取为可复用的 useSearchusePagination,没有冲突、来源清晰。

Q3: “setup 和 script setup 是什么?ref 和 reactive 有什么区别?”

setup 是 Composition API 的入口函数,在组件创建前执行。<script setup> 是它的语法糖,编译后等价于 setup 函数,模板中直接使用顶层变量不需要 return。refreactive 都是创建响应式数据的方式:ref 通过 .value 访问/修改,适用于基础类型和需要重新赋值的变量;reactive 直接操作属性,适用于深层嵌套的对象。在模板中 ref 自动解包(不需要 .value),在 reactive 中也会自动解包。建议优先用 ref——赋值时不会丢失响应式,且解构时用 toRefs 即可。

【深入理解版】

1. 这个知识点要解决什么问题?

Vue2 当年(2016)的设计在现在看来有一些历史局限。用 Vue2 写过 300 行以上的组件可能都有这种体验:一个表格组件里同时有搜索、分页、筛选、导出四个功能,每个功能的数据在 data 中初始化、方法在 methods 中定义、计算属性在 computed 中。当你想理解”搜索”这个功能时,你需要在 created(初始化请求)、methods(搜索方法)、computed(搜索结果)、watch(搜索条件变化)之间来回跳转。这就是 Options API 的逻辑分散问题

除此之外:Vue2 的响应式用 Object.defineProperty 在初始化时递归遍历 data 所有属性,导致两个问题——新增/删除属性无法监听(需要 Vue.set/Vue.delete)、数组下标修改无法监听。Vue3 用 Proxy 重写了响应式系统,同时引入了 Composition API 解决逻辑组织问题。

2. 核心原理/执行过程

2.1 createApp 替代 new Vue()

1
2
3
4
5
6
7
8
// Vue2 — 全局 API 污染
Vue.mixin({ created() { /* 全局混入 */ } }) // 影响所有 Vue 实例
new Vue({ el: '#app', render: h => h(App) })

// Vue3 — 应用实例隔离
const app = createApp(App)
app.mixin({ created() { /* 仅影响这个应用 */ } })
app.mount('#app')

createApp 返回一个应用实例,所有全局 API(mixincomponentdirective)都挂在这个实例上,不影响其他 Vue 应用。这在微前端场景下很重要——两个子应用可以各有独立的 Vue 配置。

2.2 Composition API 的执行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup> 编译阶段

编译为 setup() 函数,在组件创建前执行(beforeCreate 之前)

setup 执行:
ref(0) → 创建响应式状态
reactive({}) → 创建响应式对象
computed(...) → 注册计算属性
watch(...) → 注册监听器
onMounted(...) → 注册生命周期钩子

这些响应式变量和方法暴露给模板

模板渲染 → 访问响应式变量触发 get → 自动收集依赖

setup 执行时组件实例还没创建,所以 setup 中不能访问 thisthis 是 undefined)。如果想在 setup 中获取当前组件实例,可以用 getCurrentInstance()(但很少需要这样做)。

2.3 ref 和 reactive 的底层关系

1
2
3
4
5
6
7
8
import { ref, reactive } from 'vue'

const count = ref(0)
// ref 内部等价于:
// const count = reactive({ value: 0 })

const state = reactive({ count: 0 })
// reactive 直接返回一个 Proxy 对象

ref 的本质是:如果传入基础类型(string/number/boolean),把它包在一个 { value: ... } 对象中,然后让这个对象变成响应式的。如果传入对象,内部调用 reactive 处理。所以 ref(0)reactive({ value: 0 }) 的本质是相同的。

模板中 ref 自动解包

1
2
3
4
5
6
7
<template>
<p>{{ count }}</p> <!-- 不需要 count.value -->
<button @click="count++">+1</button>
</template>
<script setup>
const count = ref(0)
</script>

但不是所有地方都解包

1
2
3
4
5
6
7
const count = ref(0)
const arr = reactive([count])
console.log(arr[0]) // RefImpl 对象,不是 0!
console.log(arr[0].value) // 0

const state = reactive({ count })
console.log(state.count) // 0 ✅ reactive 中自动解包

3. 实际应用场景

场景1:useSearch composable(逻辑复用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// composables/useSearch.js
import { ref, watch } from 'vue'
import { debounce } from 'lodash-es'

export function useSearch(fetchApi) {
const keyword = ref('')
const results = ref([])
const loading = ref(false)

// 防抖搜索:用户停止输入 300ms 后自动搜索
const search = debounce(async (kw) => {
loading.value = true
results.value = await fetchApi(kw)
loading.value = false
}, 300)

// watch keyword 变化时触发搜索
watch(keyword, (val) => {
if (val) search(val)
else results.value = []
})

return { keyword, results, loading }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup>
import { useSearch } from './composables/useSearch'

const { keyword, results, loading } = useSearch(
(kw) => fetch(`/api/search?q=${kw}`).then(r => r.json())
)
</script>

<template>
<input v-model="keyword" placeholder="搜索..." />
<div v-if="loading">搜索中...</div>
<ul v-else>
<li v-for="item in results">{{ item.title }}</li>
</ul>
</template>

这个 composable 封装了搜索的完整逻辑(防抖、请求、loading),任何需要搜索功能的组件直接引入使用,没有 mixin 的冲突问题。

场景2:Teleport 传送弹窗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- Modal.vue -->
<script setup>
defineProps({ visible: Boolean })
</script>

<template>
<!-- Teleport 把弹窗渲染到 body 下,避免被父组件的 overflow:hidden 裁剪 -->
<Teleport to="body">
<div v-if="visible" class="modal-overlay">
<div class="modal-content">
<slot />
</div>
</div>
</Teleport>
</template>

<style scoped>
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
}
</style>

没有 Teleport 时,如果 Modal 在父组件中层级很深,父组件的 overflow: hiddentransformz-index 都可能影响弹窗的显示。<Teleport to="body"> 把模板内容渲染到 document.body 下,但逻辑上仍然属于当前组件的生命周期。

4. 常见误区 & 实际项目中的坑

误区1:reactive 解构后丢失响应式

1
2
3
4
5
6
7
8
9
const state = reactive({ count: 0, name: 'foo' })

// ❌ 解构后 count 和 name 是普通值
const { count, name } = state
count++ // 不会触发更新

// ✅ 用 toRefs 保持响应式
const { count, name } = toRefs(state)
count.value++ // ✅ 触发更新

toRefs 把 reactive 对象的每个属性转为 ref。这在从 composable 返回多个响应式值时特别有用——调用方可以解构而不丢失响应式。

误区2:watchEffect 中的异步操作导致依赖追踪不完整

1
2
3
4
5
6
watchEffect(async () => {
const res = await fetch(`/api/user/${state.id}`)
// 上一行的 state.id 会被追踪(同步阶段)
state.data = await res.json()
// 这里的 state.data 不会被追踪(异步阶段——await 之后脱离了同步追踪)
})

watchEffect 只在同步阶段收集依赖。await 之后的代码已经不在同步执行上下文中了,不会被视为依赖。解法:用 watch 显式指定依赖,或在同步阶段读取所有需要的响应式变量。

坑:ref 在普通对象和数组中不会自动解包

1
2
3
4
5
6
const count = ref(0)
const obj = { count } // 不会解包,obj.count 是 RefImpl
const arr = reactive([count]) // 不会解包,arr[0] 是 RefImpl

// reactive 中会自动解包
const state = reactive({ count }) // state.count 是 number

5. 与相关知识的关联 & 对比

对比维度Options API (Vue2)Composition API (Vue3)
逻辑组织按选项分割(data/methods/computed)按功能聚合(composables)
逻辑复用mixin(命名冲突、来源不明)composables(显式导入、无冲突)
Tree-shaking不支持支持(按需 import)
TypeScript弱(this 上下文推断困难)
学习曲线

6. 现代最佳实践(2024-2025)

  1. **新项目一律用 <script setup>**,不使用 options API。
  2. **优先用 ref 而不是 reactive**——ref 在赋值时不会丢失响应式,解构用 toRefs。
  3. 逻辑复用一律用 composablesuseXxx 函数),不再使用 mixin。
  4. 异步依赖优先用 Suspense,避免手动维护 loading 状态。
  5. 新项目标准技术栈:Vue3 + Vite + TypeScript + Pinia + Vue Router 4

7. 常见疑问解答

Q:setup 函数中为什么不能访问 this?

A:因为 setup() 在组件实例创建之前执行。此时组件实例还没有被初始化,data、methods、computed 等都还没创建。this 指向的是 undefined(严格模式下)。设计意图是让 setup 与组件的其他选项解耦——你在 setup 中定义的变量和方法不依赖组件的 this 上下文,更容易提取为复用的 composable。

Q:<script setup> 编译后到底变成了什么?

A:它编译为一个 setup() 函数,所有顶层的 import 和变量声明都成为这个函数的局部变量,模板中可以直接使用。不需要 return 语句,编译器会自动处理。此外 <script setup> 还让编译器能更准确地分析模板中绑定的变量来源——比手写 setup() 函数能多做一些编译优化。

Q:ref 的 .value 自动解包规则到底是什么?

A:三个规则:① 在模板中自动解包——{{ count }} 就是 count.value;② 在 reactive 中自动解包——reactive({ count })state.count 就是 number;③ 在普通对象和 reactive 数组中不会解包——obj.countarr[0] 都需要 .value

关联知识点索引

  • 响应式原理.md — ref/reactive 的 Proxy 实现细节
  • 组件通信.md — provide/inject 在 Composition API 中的用法
  • 模板编译与渲染.md<script setup> 的编译优化原理

【面试速答版】

Q1: “Vue3 的 diff 算法相比 Vue2 有哪些优化?”

两大优化:PatchFlag + Block Tree。Vue3 在编译阶段给每个 VNode 打上 PatchFlag(标记哪些属性是动态的),运行时 patch 只比较带标记的属性,静态节点完全跳过。同时通过 Block Tree 把所有动态节点收集到一个扁平数组中,运行时直接遍历这个数组而不递归整棵 VNode 树。列表 diff 方面,Vue3 用快速 diff(基于最长递增子序列)替代了 Vue2 的双端 diff,预处理时先跳过前缀/后缀的相同节点,然后对中间未知序列用 LIS 计算最少移动次数,性能更优。

Q2: “什么是 Block Tree?它是如何工作的?”

Block Tree 是 Vue3 在 VNode 树之上的一层”动态节点索引”。render 函数执行时,_openBlock() + _createBlock() 把所有带 PatchFlag 的动态节点扁平化收集dynamicChildren 数组中。patch 时只遍历 dynamicChildren,跳过整棵静态子树。这样一来,即使组件有 100 个节点,实际只需要比较其中 3-5 个动态节点。

Q3: “v-for 的 key 有什么用?Vue3 中 key 的优化点是什么?”

key 帮助 diff 算法”识别”同一个节点在不同渲染中是否可复用。没有 key 时,列表的 diff 退化为”逐个位置比较”——头部插入新元素会导致后面所有元素被”更新”而非”移动”。有正确的 key 时,Vue3 的快速 diff 通过最长递增子序列计算出最少移动次数。Vue3 中 key 不仅可以用在 v-for 中,也可以用在 <template v-for> 上(Vue2 不支持)。始终使用唯一且稳定的 key(如后端 id),不要用 index。

【深入理解版】

1. 这个知识点要解决什么问题?

当响应式数据变化时,Vue 需要更新真实 DOM。最粗暴的方式是把整个 DOM 树销毁重建——但这样性能极差。diff 算法的目标就是:用最小的 DOM 操作来完成新旧状态的迁移

Vue2 的 diff 是全量递归——从根节点开始,逐层比较新旧两棵 VNode 树的所有节点。即使 90% 的节点是静态的(从未变化),每次也要全部比较一遍。Vue3 的目标是:只比较那些可能变化的部分

2. 核心原理/执行过程

2.1 整体 patch 流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
响应式数据变化

组件重新执行 render → 生成新的 VNode 树 + dynamicChildren

patch(prevVNode, nextVNode)

判断是否是同一节点(tag + key)
├─ 不是 → 卸载旧节点,挂载新节点
└─ 是 → patchElement
├─ 根据 PatchFlag 更新属性/事件/样式
└─ patchChildren
├─ 文本 → 直接替换
├─ 静态 → 跳过
└─ 动态 → patchKeyedChildren(快速 diff)

2.2 Block Tree 与动态节点收集

Vue3 在 VNode 上增加了两种数据结构:patchFlag 标记动态类型,dynamicChildren 收集动态子节点。

1
2
3
4
5
6
7
8
9
<template>
<div>
<span class="static">固定文字</span>
<span :class="cls">{{ msg }}</span>
<div>
<span>完全静态</span>
</div>
</div>
</template>

编译后的 render 函数(简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function render(_ctx, _cache) {
return (_openBlock(), _createBlock('div', null, [
_createVNode('span', { class: 'static' }, '固定文字'),
// 上一行:没有 PatchFlag,也没有被收集到 dynamicChildren

_createVNode('span', { class: _ctx.cls }, _ctx.msg, 3 /* TEXT, CLASS */),
// 上一行:PatchFlag = 3,被收集到 dynamicChildren

_createVNode('div', null, [
_createVNode('span', null, '完全静态')
])
// 外层 div 的 children 有静态内容,但没有任何动态标记
// 这个子 div 整体也不会被收集到 dynamicChildren
]))
}

patch 时:

1
2
// dynamicChildren 只有一条:[span(PatchFlag=3)]
// patch 只需要比较这一个节点,其他全部跳过

如果这个组件有 100 个节点,其中 95 个是静态的,那么 dynamicChildren 可能只有 5 个节点。diff 只需要比较 5 个节点——这就是 Vue3 比 Vue2 快的原因。

2.3 快速 diff(patchKeyedChildren)

当新旧两个子节点列表都是动态的(如 v-for 列表),Vue3 使用快速 diff 算法。过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
旧列表:[a, b, c, d, e]
新列表:[a, b, e, d, c, f]

步骤1: 从头开始比较,找到相同的前缀
a === a → 跳过
b === b → 跳过

旧剩余: [c, d, e]
新剩余: [e, d, c, f]

步骤2: 从尾开始比较,找到相同的后缀
旧尾: e
新尾: f → 不同,停止

旧剩余: [c, d, e]
新剩余: [e, d, c, f]

步骤3: 对剩余部分,用 key 建立映射表,找最长递增子序列
剩余新列表的 key 序列: [e, d, c, f]
映射到旧列表索引: [4, 3, 2, -1](f 是新增)
最长递增子序列: [2, 3] → 对应 c 和 d → 不用移动
e 需要移动到 d 后面
f 是新增,插入到最后

结果是:c 和 d 不动,e 移动位置,f 新增。用最少操作完成了列表更新。

为什么最长递增子序列能算出最少移动? 递增序列中的元素说明它们在旧列表和新列表中的相对顺序一致——不需要移动。剩下的元素才需要移动或新增删除。找最长递增子序列 = 找到最多”不用动”的元素。

3. 实际应用场景

场景1:理解 key 的重要性

1
2
3
4
5
<!-- ❌ 不推荐:用 index 做 key -->
<li v-for="(item, index) in list" :key="index">{{ item.name }}</li>

<!-- ✅ 推荐:用唯一 id -->
<li v-for="item in list" :key="item.id">{{ item.name }}</li>

list 头部插入新元素时,用 index 做 key 会导致所有已有 li 的 key 都变了(索引全部后移),diff 认为它们都是新节点——所有 li 都会重新创建。用唯一 id 时,key 不变,diff 能识别出哪些是已有节点(只需移动位置而不是重建)。

场景2:理解 Block Tree 的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div>
<!-- 下面这个静态列表完全不会被 diff -->
<ul>
<li>固定项 1</li>
<li>固定项 2</li>
<li>固定项 3</li>
</ul>

<!-- 只有这个动态文本需要 diff -->
<p>{{ message }}</p>
</div>
</template>

即使这个模板看起来有很多节点,Vue3 的 Block Tree 确保只有 <p>{{ message }}</p> 被收集到 dynamicChildren 中。静态的 <ul> 列表在整个组件生命周期中都不会被 diff。

4. 常见误区 & 实际项目中的坑

误区1:认为加了一定 key 就一定性能好

key 帮助 diff 准确识别节点,但如果你只有一个静态列表(没有新增/删除/排序),加不加 key 没有区别。另外用随机值作为 key(:key="Math.random()")会导致每次渲染所有节点都被重新创建——性能极差,且会丢失组件状态。

误区2:v-for 中 index 作为 key “也可以”

1
2
3
4
5
<!-- 问题:输入框内容错乱 -->
<div v-for="(item, i) in list" :key="i">
<input v-model="item.name" />
</div>
<!-- 头部插入新项后,每个输入框的内容会错位 -->

:key="i",在列表头部插入后,原来索引 0 的输入框现在对应了新数据,React 和 Vue 都会认为”这个 DOM 节点还是之前的”——输入框里残留的旧内容不会清除。

坑:不要在 v-for 中创建内联对象作为 props

1
<Child v-for="item in items" :key="item.id" :config="{ name: item.name }" />

每次渲染都会创建新的 { name: item.name } 对象,导致 Child 每次都会收到”新的” props 引用,触发不必要的更新。如果 Child 能稳定复用,建议提取为方法或 computed。

5. 与相关知识的关联 & 对比

对比维度Vue2 diffVue3 diff
整体策略全量递归比较Block Tree + 动态节点收集
静态节点每次重新创建并比较静态提升 + 完全跳过
属性 diff全量比较所有属性PatchFlag 按需比较
列表 diff双端 diff(4 指针)快速 diff(LIS)
key 的作用sameVnode 判断基本一致

6. 现代最佳实践(2024-2025)

  1. 始终用唯一且稳定的 key(后端 id 或 uuid),不要用 index。
  2. 避免在 v-for 循环中创建内联对象/函数——每次渲染都创建新引用,导致不必要的更新。
  3. 如果列表完全静态,不需要加 key,Block Tree 会直接跳过整个列表。
  4. **大规模静态内容用 v-once**——渲染一次后永不更新。
  5. 合理拆分组件——组件是独立的 patch 单元,经常变化的部分单独拆分可缩小 diff 范围。

7. 常见疑问解答

Q:Vue3 的快速 diff 和 Vue2 的双端 diff 有什么区别?

A:双端 diff 用 4 个指针(oldStart、oldEnd、newStart、newEnd)两两比较,匹配成功的节点移动到对应位置。快速 diff 先预处理前缀/后缀的相同节点跳过,然后对中间未知序列用 key → index 映射 + 最长递增子序列计算最少移动次数。快速 diff 代码更简洁(Vue3 核心约 100 行,Vue2 约 300 行),且在大多数场景下效率更高。两者理论上都是 O(n) 复杂度,但快速 diff 的常数更小。

Q:Block Tree 中的 dynamicChildren 是扁平的动态节点数组,嵌套组件中的动态节点怎么处理?

A:每个组件实例是一个独立的 patch 单元。父组件的 Block Tree 只收集父组件模板中直接定义的动态节点,不穿透到子组件内部。子组件内部的动态节点由子组件自己的 Block Tree 处理。所以 dynamicChildren 是”当前组件模板这一层”的动态节点,不是整个组件树的。

七、Vapor Mode——无虚拟 DOM 的未来

7.1 什么是 Vapor Mode

Vapor Mode 是 Vue 团队正在开发的一种新的编译策略(非默认,是可选的编译目标)。它的核心思路是:在编译阶段将模板直接编译为原生 DOM 操作,彻底移除虚拟 DOM

1
2
3
4
5
传统 Vue 3 运行流程:
模板 → 编译为 render 函数 → 执行 render → 生成 VNode → diff → patch DOM

Vapor Mode 运行流程:
模板 → 编译为 DOM 操作函数 → 直接调用 DOM API → 更新真实 DOM

7.2 为什么需要 Vapor Mode

虚拟 DOM 虽然有 diff 性能优势,但也有不可忽视的开销:

1
2
3
4
5
虚拟 DOM 的开销:
├── 运行时要额外创建 VNode 对象(内存分配 + GC 压力)
├── 即使只更新一个属性,也要执行整棵树的 diff 遍历
├── 对于纯静态内容,每次都创建相同的 VNode 再直接 patch
└── Block Tree 虽能跳过静态节点,但 VNode 仍然存在

Vapor Mode 直接跳过 VNode 创建和 diff 阶段,将模板编译为等价的命令式 DOM 操作:

1
2
3
4
5
<!-- 模板 -->
<div class="card">
<p>{{ title }}</p>
<p>静态描述</p>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Virtual DOM 模式编译产物
function render(ctx, cache) {
return (_openBlock(), _createBlock('div', { class: 'card' }, [
_createVNode('p', null, ctx.title, 1 /* TEXT */),
_createVNode('p', null, '静态描述'),
]))
}

// Vapor Mode 编译产物(示意)
function render(ctx) {
const div = document.createElement('div');
div.className = 'card';

const p1 = document.createElement('p');
effect(() => { p1.textContent = ctx.title; }); // 只有这一行是动态的

const p2 = document.createElement('p');
p2.textContent = '静态描述'; // 只创建一次,永不更新

div.append(p1, p2);
return div;
}

7.3 Virtual DOM vs Vapor Mode 对比

维度Virtual DOM 模式Vapor Mode
VNode 创建每次更新都创建
diff 开销有(即使 Block Tree 也有)
内存占用高(VNode 对象 + 响应式系统)低(仅响应式系统)
首屏渲染需要执行 render + diff直接创建 DOM
更新粒度组件级别(组件内比完后应用更新)变量级别(精确到具体 DOM 属性)
动态内容 > 静态内容优秀优秀
静态内容 > 动态内容有浪费(VNode 创建)极优(静态内容只执行一次)
缓存友好性低(每次都创建新对象)
bundle 大小完整 Vue 运行时(含 diff 算法)小(不含 virtual DOM 相关代码)

7.4 Vapor Mode 的更新粒度

Vapor Mode 中每个动态绑定被直接编译为响应式副作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<p :style="{ color: theme }">{{ msg }}</p>
</template>

<!-- Vapor Mode 编译产物(伪代码) -->
import { effect, template, setText, setStyle } from 'vue/vapor';

const tpl = template('<p></p>'); // 只创建一次模板

function render(ctx) {
const el = tpl(); // 克隆模板

effect(() => { setText(el, ctx.msg); }); // msg 变化只更新 textContent
effect(() => { setStyle(el, 'color', ctx.theme); }); // theme 变化只更新 color

return el;
}

msg 变化时,只有 setText(el, ctx.msg) 被执行——不需要 diff,不需要知道其他节点

7.5 Vapor Mode 的局限性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
├── 不是 "drop-in" 替代
│ Vapor Mode 是一个编译目标,不是默认的运行时策略。
│ 目前的规划是:Vapor Mode 编译的组件可以和 Virtual DOM 组件混用。

├── 不直接支持部分功能
│ ├── `<Teleport>`、`<Suspense>` 等内置组件(需额外运行时支持)
│ ├── `<KeepAlive>`(依赖 VNode 缓存)
│ ├── functional components 的某些用法
│ └── 动态组件 `<component :is="...">`(需要运行时解析)

├── 编译产物更大
│ 每个模板被直接编译为 DOM 操作代码,会有些体积膨胀。
│ 但不需要包含运行时 diff 算法,gzip 后整体可能更小。

├── 与库 / 插件的兼容性
│ 第三方组件库可能依赖 VNode 或 render 函数的某些特性,
│ 需要适配才能完全在 Vapor Mode 下工作。

7.6 混合模式(Vapor + Virtual DOM)

Vue 团队的计划不是”二选一”,而是两者共存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────────┐
│ 应用容器 │
│ ┌──────────────────────────────────┐ │
│ │ Virtual DOM 组件(兼容层) │ │
│ │ (使用现有组件库、插件) │ │
│ └────────────┬─────────────────────┘ │
│ │ props / slots │
│ ┌────────────▼─────────────────────┐ │
│ │ Vapor 组件(高性能内层) │ │
│ │ (核心业务、大列表、频繁更新) │ │
│ └──────────────────────────────────┘ │
│ │
│ 两者通过统一的跨组件通信机制互操作 │
└─────────────────────────────────────────┘

在同一个应用中,大部分组件继续使用 Virtual DOM 模式(兼容性优先),对性能敏感的组件(大列表、频繁更新的图表、动画)使用 Vapor Mode。

7.7 当前状态与启用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Vue 3.4:Vapor Mode 进入实验阶段(RFC + 原型实现)
Vue 3.5(预计):可选的 Vapor Mode 编译器
Vue 3.6+(预计):稳定版 Vapor Mode

启用方式(未来):
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
plugins: [
vue({
vapor: true, // 整个应用开启 Vapor Mode
// 或按文件粒度
}),
],
});

// 或按组件选择(通过文件扩展名或配置)
// Button.vapor.vue → 使用 Vapor Mode
// Button.vue → 使用 Virtual DOM

7.8 面试题

Q1: Vapor Mode 和虚拟 DOM 哪个更快

1
2
3
4
5
6
7
8
9
不能简单说哪个"更快",取决于场景:

静态内容多 → Vapor Mode 快(静态只创建一次,无需 VNode)
动态更新频繁 → 两者接近(Vapor Mode 精确到变量,Virtual DOM 到组件)
首次渲染 → Vapor Mode 快(跳过 VNode 创建 + diff)
内存敏感(移动端)→ Vapor Mode 优(无 VNode 对象开销)

Vapor Mode 消除的是虚拟 DOM 的"固定开销"(VNode 分配 + diff),
在静态内容越多的场景下优势越明显。

Q2: Vapor Mode 会替代虚拟 DOM 吗

1
2
3
4
5
6
7
不会完全替代。Vapor Mode 和 Virtual DOM 是互补关系:

Vapor Mode 适合:新项目、性能敏感组件、移动端、静态内容多的页面
Virtual DOM 适合:需要兼容第三方库、使用 Teleport/Suspense/KeepAlive、
动态组件、依赖 render 函数的场景

Vue 团队的目标是让两者无缝共存。

Q3: Vapor Mode 对现有项目的影响

1
2
3
4
5
对现有项目基本无影响——Vapor Mode 是一个可选的编译目标,
默认还是 Virtual DOM 模式。升级 Vue 版本不会自动切换。

如果需要使用 Vapor Mode,需要对依赖库进行兼容性检查
(element-plus / ant-design-vue 等是否适配)。

关联知识点索引

  • 模板编译与渲染.md — PatchFlag、Block Tree 的编译过程
  • 响应式原理.md — 响应式变化如何触发 patch
  • Vue 官方 Vapor Mode RFC

一、Next.js 是什么

Next.js 是一个基于 React 的全栈 Web 应用框架,由 Vercel 开发和维护。它提供了服务端渲染(SSR)、静态生成(SSG)、API 路由、文件系统路由等开箱即用的能力,是目前 React 生态中最流行的生产级框架。

阅读全文 »

一、Nuxt.js 是什么

Nuxt.js 是一个基于 Vue.js 的通用应用框架,它封装了 Vue、Vue Router、Vite/Webpack 等服务端渲染所需的工具链,提供了一套约定优于配置的开发模式,支持 SSR(服务端渲染)、SSG(静态生成)和 SPA 三种渲染模式。

阅读全文 »

在公司实习时,参与的第一个任务是负责从头搭建某医疗机构的 h5 页面端。h5 端和 pc 端的页面仅供浏览信息使用,不像 app 端有用户注册登录及一系列交互操作的页面,因此整体难度不高。因为上司没有指明要使用何种框架,所以我照搬了公司用来测试前端实习生水平的一个伪项目所用的框架——Nuxt。本文用来梳理在搭建这个 Nuxt 项目时需要了解的一些知识点。

阅读全文 »