2026年3月底,我把做Vue3项目过程中反复用到、踩过坑、也在面试中被问过无数次的知识点重新过了一遍,整理成这篇总结。先说清定位:它不是按官方文档目录排下来的教程,而是偏向“开发里高频出现、面试里值得讲清楚、从Vue2迁移时容易绊倒”的硬核知识点串讲。无论你是刚准备从Vue2迁过来,还是已经上手Vue3一段时间想查漏补缺,顺着这条线捋下来,基本能把Vue3的技术地图拼完整。
1. 先看清Vue2与Vue3的核心差异,别只死记“setup替代data”
很多同学聊Vue2和Vue3的区别,第一反应就是“Vue2是Options API,Vue3是Composition API”。这话对,但过于表面。实际项目里真正影响你写代码方式的,是下面这两件事:逻辑组织方式变了,响应式底层也变了。
1.1 从Options API到Composition API,本质是逻辑聚合
Vue2时代我们习惯把一段业务逻辑拆散放进data、methods、computed、watch、生命周期里。比如一个搜索组件,它的loading状态、搜索关键词、防抖函数、结果数据、错误处理可能分散在十几个位置。代码量少时没什么感觉,一旦组件到了几百行,想改一个搜索功能就得在好几块地方来回跳。
Composition API解决的正是这个问题。它提供了一个setup函数,让你可以按“功能点”而不是“选项类型”组织代码。最直观的写法是配合<script setup>,一个搜索相关的逻辑可以这样收敛:
<script setup> import { ref, computed, watch } from 'vue' const keyword = ref('') const loading = ref(false) const results = ref([]) const search = async () => { loading.value = true try { // 请求数据... results.value = await fetchResults(keyword.value) } finally { loading.value = false } } const hasResults = computed(() => results.value.length > 0) watch(keyword, () => { debounce(search, 300) }) </script>这段代码里,关键词、加载状态、搜索动作、计算结果、副作用监听全部在同一个作用域里,一眼就能看清搜索功能的全貌。当组件变复杂,还可以把这套逻辑整体抽到useSearch.ts里,多个组件直接复用。我在实际项目里感受最深的是:维护三个月以上的老组件时,Composition API的“逻辑聚合”优势会被放大得非常明显。
createApp也是Vue2和Vue3差异里最常被提到的启动方式变化。Vue2用new Vue(),Vue3统一改成createApp().mount(),这背后是把全局配置和组件实例解耦的设计考虑,也让多个Vue实例共存时不再互相污染。面试时如果你能把这个设计意图讲出来,比单纯背api名要加分不少。
1.2 响应式重写:Object.defineProperty换成Proxy解决了哪些老问题
Vue2的响应式是基于Object.defineProperty对对象属性做拦截,这带来几个天生的麻烦:新增属性不响应、删除属性不响应、通过索引修改数组元素不响应。所以Vue2里才需要Vue.set、Vue.delete这类补丁方法,项目里一旦忘记调用,就是那种“数据改了页面不更新”的经典玄学。
Vue3把整个对象交给Proxy代理,新增属性、删除属性、数组索引变更都能被拦截到,Vue.set这类补丁API彻底退场。
实现机制上还有一个关键差异:Vue2是在初始化时递归遍历整个data对象做劫持,对象越深初始化越慢;Vue3的Proxy是“懒代理”,只有真正访问到深层对象时才去做响应式转换,整体初始化性能提升明显。
实际开发中对应到两个最常用的api:reactive和ref。reactive接收对象,返回的是Proxy包装后的响应式对象;ref则可以把任意值包成{ value: xxx }结构。很多人刚上手时好奇为什么非要有ref,因为Proxy只能代理对象,原始值必须包一层才能放进响应式系统。模板中使用ref会自动解包,写count而不是count.value,这个便利背后是编译器做的自动处理。
这里有个高频坑:reactive对象解构后会丢失响应性。
const state = reactive({ count: 0 }) // 这样拿到的 count 是普通数字,不再响应 const { count } = state正确的做法是解构时包一层toRefs:
const { count } = toRefs(state)我见过不少从Vue2转过来的同学在这个点上栽跟头。Vue2的data解构虽然不推荐,但直接解构后依然能变;Vue3用reactive就完全不行,必须在脑子里形成“解构即脱钩”的条件反射。
2. 面试必问的diff算法:静态提升、PatchFlag、最长递增子序列怎么讲才清楚
Vue3的diff优化是面试中最高频的问题之一,也几乎是衡量一个人是背了题还是真理解题的分水岭。别一上来就背“虚拟DOM”和“diff算法”几个词,真正有价值的点是Vue3在编译阶段做的大量静态分析优化。
2.1 编译期静态提升与PatchFlag:让Vue3一开始就知道哪里会变
Vue2的diff是运行时阶段的全量比对,新旧两个虚拟DOM树生成后从头到尾对比,只能靠“双端比较”这类算法去减少比较次数。Vue3的思路更聪明:既然模板本身就是静态写的,那能不能在编译阶段就分析出哪些节点是永远不变的?
于是有了静态提升。模板中不带任何动态绑定的节点会被提升到渲染函数外部,只创建一次,后续每次渲染直接复用同一个vnode对象:
<div> <span>永远不变</span> <span>{{ msg }}</span> </div>编译后简化形式大致是这样:
// 静态节点被提升,整个生命周期只创建一次 const hoisted = createVNode('span', null, '永远不变') function render() { return createVNode('div', null, [ hoisted, createVNode('span', null, toDisplayString(msg), 1 /* TEXT */) ]) }第二个关键优化是PatchFlag。编译时给动态节点打上标记,用数字标识哪部分会变化:文本变化是1,class变化是2,style变化是4,props变化是8,等等。更新时Vue3根据flag精确更新变化部分,不再傻乎乎对比整个节点树。你甚至可以理解成:Vue3在编译阶段就给每个动态节点写好了“体检报告”,运行时只需要照着报告去处理对应项目。
实际开发中还能感知到的一个小点:为什么v-for列表里哪怕什么都不绑,Vue3也会建议加key?因为编译期无法静态分析列表项,PatchFlag等优化手段在循环场景里会降级,key就成了帮助diff精准复用的最后一道保险。
2.2 最长递增子序列:列表怎么移动才最省
列表更新时,Vue3diff算法会先用key找到新旧节点的对应关系,再计算出最长递增子序列(Longest Increasing Subsequence,LIS)。这个概念听起来很冷门,实际含义非常朴素:
如果列表中已经有一部分节点的相对顺序是固定且正确的,那这批节点就应该原地不动,只移动其余节点。
举个例子,旧列表是[A, B, C, D],新列表是[B, E, C, D]。通过LIS算法算出B, C, D已经保持了正确相对顺序,那就只需要把A替换成E,再移动一下位置。节点移动次数被压到最少,DOM操作成本自然更低。
面试里讲到这一层,不需要把动态规划代码完整背下来,但一句“用贪心加二分来求LIS,再通过前驱数组回溯出具体节点序列”已经能证明你真的研究过源码。如果还能顺口提一句“LIS同样适用于Vue的移动端虚拟列表场景”,面试官对你的印象会再上一个台阶。
作为一个已经写了大量Vue3项目的人,我的实际体感是:大部分业务页面的更新性能瓶颈根本不在diff算法上,PatchFlag这类优化更多是让框架下限更高。但面试时能把这条链路讲清楚,说明你对框架的理解已经超出了“会用API”的层面。
3. 组件交互与组合式API:defineEmits、provide/inject、生命周期要怎么用
组件通信是Vue开发永远绕不开的话题。Vue3在写法上有不少变化,但底层的通信模型并没有发生颠覆性重构。最常见的仍然是父传子props、子传父emit、共享状态Pinia这三板斧。
3.1 defineEmits与defineProps的类型化写法
在<script setup>语法下,父子组件通信推荐直接使用编译宏defineProps和defineEmits,不需要手动引入:
<!-- 子组件 --> <script setup> const props = defineProps<{ title: string count?: number }>() const emit = defineEmits<{ 'update:count': [value: number] 'confirm': [data: { id: number }] }>() const handleClick = () => { emit('update:count', props.count + 1) } </script>这样写的好处是类型推断非常完整,父组件传错类型时开发期就能发现。和Vue2时代this.$emit('event-name')满天飞相比,defineEmits本质上做的事情是一样的,但它把事件名和参数类型暴露在编译阶段,让错误尽量提前暴露。
事件命名有个小建议:尽量用kebab-case在模板中监听,比如@update:count,但在defineEmits的数组或类型声明里统一用camelCase。Vue内部会做名称转换,保持这个习惯会少踩不少莫名其妙的坑。
Vue 3.4之后defineModel成为稳定特性,做v-model双向绑定更简洁了。之前需要手动声明modelValue加update:modelValue事件,现在一个defineModel()就搞定。如果要用Vue3的tabs、表格这类需要频繁绑值的组件封装,建议优先用。
3.2 非父子组件交互:provide/inject、事件总线、Pinia怎么选
非父子组件通信,按场景有不同的选择,我给一张对比表方便你直接用:
| 方案 | 适用场景 | 优点 | 注意点 |
|---|---|---|---|
| provide/inject | 祖先与后代组件,层级跨度大 | 无需逐层传递,代码简洁 | 如果注入的是reactive对象,最好用readonly包一层,避免子组件随意改动 |
| mitt事件总线 | 临时跨组件通知,组件数量少 | 轻量、无依赖 | 页面销毁时要off掉监听,否则容易内存泄漏 |
| Pinia | 全局数据共享,多页面多组件使用 | 状态可追踪、支持持久化、devtools友好 | 不要把所有临时UI状态都塞进去,store越轻越好 |
我个人的选择标准很简单:数据需要在路由级甚至应用级共享,直接上Pinia;只是某个大组件内部的深层传递,用provide/inject;临时事件通知,优先考虑组件本身结构能不能调整,实在绕不开再用mitt。
这里给个provide/inject设置响应式数据的小例子:
// 祖先组件 import { provide, ref, readonly } from 'vue' const user = ref({ name: '张三' }) provide('user', readonly(user))子组件注入时直接把user当成只读数据用,避免在多个后代组件里东改一下西改一下,最后排查问题头大。
3.3 生命周期与computed/watch的细节
Vue3组合式API的生命周期和Vue2名字有差异。对照关系见下表:
| Vue2选项 | Vue3 setup内 |
|---|---|
| beforeCreate | 不需要,直接写在setup里 |
| created | 不需要,setup本身就在创建阶段执行 |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeUnmount | onBeforeUnmount |
| unmounted | onUnmounted |
一个值得注意的细节:setup的执行时机早于beforeCreate。也就是说,在setup内部直接书写的代码天然就充当了原来的created位置,这也是为什么组合式API里不再需要created钩子。
computed是很多项目离不开的api。它有两个容易被忽略的特性:
- 惰性求值与缓存:只有依赖的响应式数据发生变化时才会重新计算。相比每次渲染都执行的方法,computed在性能上有天然优势。
- 不应写副作用:computed中应当只做纯计算,不要在里面改别的响应式数据或发起请求。副作用应该交给
watch或watchEffect。
watch与watchEffect的选择也常有困惑。watch是惰性的、需要明确指定监听源,适合“数据变化后做什么”的场景;watchEffect则是自动收集依赖,初始化时立即执行一次,适合“每次依赖变化都要同步执行的情况”。举个例子,监控路由变化:
import { useRoute } from 'vue-router' const route = useRoute() watch(() => route.fullPath, (newPath, oldPath) => { // 路由变化后的逻辑 })这类场景用watch就对了,因为不需要初始化时执行一次。
4. 工程化实战:从创建项目到组件库、第三方库接入的常见坑
知识点不能只停留在原理层,真正落地到项目里会遇到一堆工程化问题。下面这些是我在多个Vue3项目里反复处理过的场景,每一句都是实战出来的教训。
4.1 创建Vue3项目与src别名配置
2026年创建新项目,直接选Vite是默认共识,模板推荐vue-ts:
npm create vite@latest my-app -- --template vue-ts cd my-app npm install npm run dev项目跑起来第一件事,通常就是配src别名。因为Vue3项目目录一深,用../../引用组件会非常痛苦。Vite配置如下:
// vite.config.ts import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })这里有个坑:如果项目使用TypeScript,光配Vite不行,还要在tsconfig.json里加路径映射,否则写import xxx from '@/components/xxx'时编辑器会报红或无法跳转。
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }现实里我见过不少团队改完vite配置忘了改tsconfig,导致编辑器一直提示找不到模块。这个“双配置”的关联如果能在项目初始化时就写进脚手架,能省掉团队里至少一批的提问。
4.2 覆盖Element Plus样式、使用JSX、接入富文本与CodeMirror
Element Plus是Vue3后台系统里最常用的UI库,很多同学遇见的第一个问题是“为什么组件默认显示英文”。因为Element Plus默认语言是英文,需要手动配置中文locale:
import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' app.use(ElementPlus, { locale: zhCn })第二个高频需求是修改组件内部样式。比如修改el-tabs标签页选中态样式,由于组件内部DOM层级嵌套较深,直接写普通CSS往往不生效。在scoped样式中要用:deep()穿透:
<style scoped> :deep(.el-tabs__item.is-active) { font-weight: 700; color: #e45649; } </style>这里我想强调一个习惯:尽量把:deep选择器控制在一个明确的类名下,写成:deep(.my-tabs .el-tabs__item.is-active),避免样式污染到其他Tabs实例。
至于JSX,Vue3完全可以用。安装@vitejs/plugin-vue-jsx后在vite配置里启用,文件后缀改成.tsx或.jsx。在需要大量动态渲染、循环嵌套或者复杂条件判断的场景,JSX会比模板写起来更顺手。但要注意JSX里事件写法的差异:模板是@click,JSX里是onClick;指令特性v-if、v-for都不能直接用了,要换成JavaScript语法。我的建议是:默认模板,特殊场景JSX,不要全线切换,否则团队成员维护成本会很高。
富文本编辑器、代码编辑器这些第三方库,Vue3接入的逻辑基本都是:安装对应Vue3支持版本,按需引入样式,注意组件的v-model封装。CodeMirror 6的包拆得比较细,需要@codemirror/view、@codemirror/state加对应语言包组合使用;富文本领域我在项目里用过wangEditor、Quill和TipTap,小项目图省心选wangEditor,看重扩展和协作选TipTap。关键原则是:先确认第三方库是否有Vue3版本,不要拿Vue2时代的组件库硬塞进Vue3项目里。
知识图谱和3D渲染这类场景,Vue3本身不提供特殊能力,更多是在生命周期里正确初始化实例并在卸载时销毁。知识图谱用ECharts graph或G6,3D文件展示用Three.js或Cesium,都建议封装成独立组件,把实例生命周期收敛到onMounted和onUnmounted里,避免内存泄漏。开发类似“渲染UG 3D文件”的需求时,可以先用后端工具把模型转成Three.js能解析的glTF格式,前端再加载,这样可以绕开一堆浏览器兼容问题。
4.3 模板引用、组件暴露与IDE跳转问题
ref在Vue3里除了响应式数据,还承担着模板引用的职责:
<script setup> import { ref, onMounted } from 'vue' const myInput = ref(null) onMounted(() => { myInput.value?.focus() }) </script> <template> <input ref="myInput" /> </template>如果是引用子组件实例,默认情况下父组件拿到的实例不会暴露内部任何方法,必须用defineExpose声明:
<script setup> const open = () => { /* ... */ } defineExpose({ open }) </script>这一点和Vue2完全不同。Vue2中this.$refs.child可以直接调用子组件所有方法,Vue3为了隔离内部实现,默认选择“不暴露”。刚迁移的团队经常会问“为什么父组件调不到子组件的方法”,十有八九就是少了defineExpose。
还有一个让很多人头疼的IDE问题:在项目里点击一个组件引用却无法跳转。多数情况是前面说的tsconfig paths没配置,或者IDE的语言服务缓存出了问题。先检查tsconfig.json的paths,然后执行Volar对应的“重启TypeScript Server”命令,基本能解决九成问题。
另外有读者问过前端代码混淆的事。生产构建时webpack/Vite默认开启terser压缩,已经能起到一定混淆效果;再激进一点的可以配合javascript-obfuscator插件,把变量名替换成无意义字符。但我要提醒一句:混淆会显著降低报错定位效率,也让构建时间变长,普通业务系统做到默认压缩即可,别把防线全押在前端代码上。
5. 面试高频题与避坑经验:2026年依然逃不掉的几个细节
这一节我按自己多次面试和被面的经验,挑出了一批出现频率最高、也最能拉开分差的Vue3知识点,顺手整理成一个避坑速查表。
5.1 setup执行时机、SSE流式输出、路由实例这些点怎么答
先看setup创建阶段的问题:setup执行时机在beforeCreate之前,此时组件实例还没有完全创建完成,所以setup里不能访问this。这个答案几乎是送分题,但很多人会在这点上卡壳。因为Vue2迁移者习惯在created里拿this.xxx,到了setup里一写this.$route就懵。实际上现在都用组合式api替代:拿路由实例用useRoute(),拿store用对应的useStore。
SSE(Server-Sent Events)流式输出在Vue3里其实不是框架层面的问题,而是前端如何消费流式接口的问题。常见做法是用fetch配合ReadableStream逐段读取数据,再更新到响应式变量上,让页面产生类似打字机的效果。有一个容易忽略的点:流式场景数据更新频率很高,如果每次都直接改一个大对象的某个字段,触发整个组件更新,性能容易毛。我的做法是拆出独立组件接收流片段,或者把流数据放在ref里,更新时控制节流间隔。
关于路由访问,Vue Router 4里常用useRouter和useRoute,一个负责跳转,一个读取当前路由信息。与Vue2的this.$router相比,组合式API更利于在非组件模块(比如工具函数)里获取路由实例。
5.2 常见问题速查表
| 问题 | 原因 | 排查与解决 |
|---|---|---|
| 浏览器右上角最小化按钮有时点不掉 | 与Vue3无关,属于浏览器渲染异常或系统级问题 | 刷新页面或重置浏览器窗口设置,框架层面没有对应api |
| Element Plus组件显示英文 | Element Plus默认语言包是英文 | 在app.use时传入zhCn语言包 |
| scoped样式覆盖不了组件内部 | scoped会把选择器加属性限制,组件内部深层DOM不在当前属性作用域 | 使用:deep()穿透 |
| 引用跳转失效 | 往往是tsconfig paths没配,或语言服务缓存错误 | 检查paths并重启TypeScript Server |
| 后端返回的数据页面不更新 | 可能用reactive直接解构,或新增属性 | 改用ref/回头看响应式丢失场景,保持数据都走响应式api |
| 部署到服务器后刷新404 | Vue Router history模式需要服务端配合 | 配置nginx try_files fallback到index.html |
| defineExpose没写导致父组件拿不到方法 | Vue3默认不暴露子组件内部方法 | 在子组件显式调用defineExpose({ ... }) |
| 页面截图禁不掉 | 前端只能做基础防护,浏览器截图不受页面代码完全控制 | 可用CSS、Clipboard API等增加门槛,同时后端加水印溯源 |
这张表里的每一条,我都亲眼见过团队同事卡过。尤其是Element Plus英文和404问题,几乎每个新项目都要重复一遍。
整理这份知识点的过程中,我最大的体会是:Vue3相对Vue2并不仅是API层面的替换,而是一整套“从编译到运行时再到生态”的升级。真正掌握它的方式不是背一堆孤立的概念,而是把响应式原理、diff策略、组合式API设计、工程化选型串起来,形成自己的判断依据。比如遇到一个bug,你要能推导出“这里大概率是响应式丢了”而不是盲猜;做技术选型时,你要知道什么时候该用Pinia,什么时候一个provide/inject就够了。这套思考方式,比记住任何单一知识点都值钱。