尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3核心考点全解析:从响应式原理到组合式API实践

Vue3核心考点全解析:从响应式原理到组合式API实践 最近帮团队做技术面试一个月面了十几个前端候选人简历上几乎都写着熟练使用 Vue3但我一问到ref和reactive到底怎么选、为什么 Vue3 要用Proxy替换defineProperty、diff算法优化在哪里十个人里有八个答不到点子上。这让我挺有感触——很多人学了 Vue3 的皮没学到骨。这本 Vue3 核心考点解析不是照搬官方文档而是把我这几年从 Vue2 迁到 Vue3、在多个后台管理系统和可视化大屏项目里踩过的坑、复盘过的知识点、面试官真正爱问的底层逻辑全部摊开来聊一遍。内容既覆盖响应式原理、Composition API、diff 算法这些硬核考点也会穿插实际项目中遇到的 TS 报错、路由渲染失效、样式适配问题。无论是准备跳槽面试还是刚从 Vue2 转过来想彻底搞懂 Vue3这篇文章都能让你少走很多弯路。1. 为什么面试官揪着 Composition API 不放Options API 的痛点和 setup 的解题思路1.1 Options API 的代码组织问题同一业务逻辑被拆得四分五裂先看一个最常见的场景。你要实现一个用户登录模块用 Vue2 风格的 Options API 来写代码会散落在各个选项里data里要放username、password、loading、tokenmethods里要写login()、logout()、handleSubmit()computed里要加isLoggedInwatch里要监听token变化去刷新请求头。同一个登录业务逻辑被强行切成了四块。当组件越来越大你要理清一条业务链路就得在data、methods、computed、watch之间来回跳脑子要不停地上下文切换。这就是 Options API 最大的问题以选项类型组织代码而不是以业务逻辑组织代码。我在实际维护一个旧模块的时候深有体会——一个组件 900 多行光methods就有 40 个方法你想知道购物车结算这个功能涉及哪些状态和方法只能靠全局搜索效率极低。1.2 setup 和script setupComposition API 的入口Vue3 引入的setup就是来解决这个问题的。它把同一个业务逻辑相关的状态、计算属性、方法、监听器写在一起让代码按功能聚合而不是按选项类型聚合。日常开发我推荐直接用script setup语法糖它最大的好处是省去了export default { setup() {} }这层包装而且defineProps、defineEmits这些编译宏连导入都不用script setup langts import { ref, computed, watch } from vue const username ref() const password ref() const loading ref(false) const isLoggedIn computed(() !!localStorage.getItem(token)) async function login() { loading.value true // 调用登录接口 loading.value false } watch(isLoggedIn, (val) { // 登录态变化时做统一处理 }) /script这里要提醒一个容易踩的坑setup执行时机在beforeCreate之前所以里面的this是undefined。很多人从 Vue2 过来会习惯性地在setup里写this.$route、this.$store结果直接报错。在组合式 API 里要用useRoute()、useStore()这类 Hook 来替代。1.3 从 Mixin 到自定义 Hook逻辑复用方式的进化在 Vue2 时代跨组件复用逻辑靠mixin但混入用多了就会遇到三个很头疼的问题数据来源不透明组件里出现一个this.total你根本不知道它是当前组件定义的还是从哪个 mixin 里混进来的命名冲突两个 mixin 都定义了data里的同名属性后者覆盖前者排查起来非常痛苦状态不能隔离同一个 mixin 被多个组件使用时如果里面定义了响应式数据组件之间会互相影响。Vue3 的组合式 API 把复用变成了一个普通的函数也就是自定义 Hook。比如把上面登录逻辑抽成一个useUserLogin// hooks/useUserLogin.ts import { ref, computed } from vue export function useUserLogin() { const username ref() const password ref() const loading ref(false) const isLoggedIn computed(() !!localStorage.getItem(token)) async function login() { loading.value true try { // 调用登录接口 } finally { loading.value false } } return { username, password, loading, isLoggedIn, login } }每个组件调用一次useUserLogin()就会生成一组独立的状态既不会冲突又能清楚地知道数据来自哪个 Hook。这正是面试官想听的答案Composition API 的核心价值是更好的代码组织方式和更干净的逻辑复用机制。1.4 顺带补充Vue2 和 Vue3 的全局差异考点除了组织代码的方式从 Vue2 到 Vue3 还有一些全局性变化也常被问维度Vue2Vue3应用创建new Vue({ render })createApp().mount(#app)v-model 默认属性valueinputmodelValueupdate:modelValue过滤器 filter有移除用 computed 或方法替代全局 APIVue.component、Vue.useapp.component、app.use响应式原理Object.definePropertyProxy插槽语法slotheader、slot-scopev-slot:header、模板内解构自定义事件$on、$off、$once移除推荐用 mitt 或 provide/inject异步组件() import()defineAsyncComponent这两者的区别几乎是 Vue3 面试必问题。我的建议是不要死记硬背直接从Vue2 有什么痛点Vue3 怎么改的这个角度去理解面试时讲出来会更自然。2. 响应式系统不难懂Proxy、ref/reactive、diff 算法和 nextTick 的考点拆解2.1 Proxy 和 Object.defineProperty 的本质差异Vue2 的响应式是通过Object.defineProperty遍历对象的所有属性给每个属性加 getter/setter 来实现的。这带来三个先天缺陷监听不到新增或删除的属性所以要提供Vue.set()和Vue.delete()这种补丁 API数组的索引和 length 变更无法被侦测所以 Vue2 只能重写push、pop、shift等方法初始化时要递归遍历整个对象对象层级深了之后会有不小的性能开销。Vue3 直接用 ES6 的Proxy代理整个对象不管访问还是修改哪个属性都会经过代理层的拦截天然支持新增、删除属性也不需要再模拟数组方法。我面试时喜欢用这个类比来解释两者的区别Object.defineProperty相当于给屋子里的每件家具都单独装一个报警器装修成本高而且新搬进来的家具默认是不带报警器的得手动补装Proxy则是给整个房间装了一个监控探头房间里任何物品的变化都能被感知不需要关心物品是原有的还是后搬进来的。2.2 ref 和 reactive 怎么选为什么我推荐优先用 ref这两个 API 是 Vue3 新手最容易搞混的地方。简单说reactive接收一个对象返回一个响应式代理访问属性时不需要.valueref可以接收任意类型的值内部把值包装成一个RefImpl实例通过value属性访问。很多人刚开始会觉得reactive更好用毕竟没有.value的繁琐感。但实际项目里我强烈建议默认用ref尤其是在script setup里因为模板会自动解包写起来反而更顺手。reactive最大的坑在于解构会丢失响应性。下面的代码就是经典翻车现场const state reactive({ count: 0, name: vue3 }) // 注意解构出来的是普通值count 不再是响应式 const { count } state function add() { // 这里修改 count 并不会触发视图更新 count }如果你确实要从reactive对象里解构出响应式数据得用toRefsimport { reactive, toRefs } from vue const state reactive({ count: 0, name: vue3 }) const { count, name } toRefs(state) // 现在 count 是 Ref 类型模板里直接用 count脚本里用 count.value如果项目里全用ref就不会有这个问题因为ref本身就是引用。组件里定义基础数据用ref定义一组关联数据也可以用ref加对象字面量。我的习惯是除非遇到明确的性能优化场景否则统一ref团队协作时心智负担最小。说到性能优化场景有两个 API 需要认识shallowRef和markRaw。当你有一个很大的数据结构比如一屏图表配置项它不会频繁整体替换你就不想让 Vue 深层递归地把它变成响应式。此时用shallowRef只做浅层响应或者用markRaw标记一个对象永远不会变成响应式代理能显著减少代理初始化的开销。2.3 watch、watchEffect 和 computed三个相似又不同的响应式 API这几个 API 也是面试高频题。很多候选人能说出大概但讲不清楚适用边界。computed依赖响应式数据且具有缓存性。只有依赖变化时才重新计算适合派生状态watch需要显式指定监听的源默认不立即执行要立即执行需加immediate可以拿到变化前后的值适合执行异步操作或比较新旧值watchEffect不需要指定监听源会默认自动收集依赖并立即执行一次依赖变化时自动重跑适合副作用场景比如请求数据、手动操作 DOM。一个很典型的选择场景搜索框防抖请求。watch(keyword, async (val) { if (!val) return const res await fetchList({ keyword: val }) list.value res })这里的keyword被用户连续输入时每次变化都会触发请求所以通常要加防抖。还可以利用watchEffect的onCleanup在回调重跑前取消上一次的请求watchEffect(async (onCleanup) { let cancelled false onCleanup(() { cancelled true }) const res await fetchList({ keyword: keyword.value }) if (!cancelled) { list.value res } })另外注意一个细节watch监听ref时直接传count即可不需要写成() count.value但监听reactive对象里的某个属性时必须写成() state.count这种 getter 形式因为watch没法直接解析字符串路径。2.4 diff 算法到底优化了哪里patchFlag、静态提升和 block treeVue3 的diff算法是面试中压轴级别的问题。要讲清楚先得明白 Vue2 的问题Vue2 在更新时哪怕只有一个动态节点变化也要对新旧两颗虚拟 DOM 树的同层级所有节点进行全量对比。Vue3 做了编译时优化在模板编译阶段就标记出哪些部分是静态的、哪些部分是动态的。核心是这几个概念patchFlag编译时给动态节点打上的标记比如某个元素的text是动态的、props里某个属性是动态的都会在创建虚拟节点时附上对应 flag。运行时diff只需要对比有 flag 的部分不用整个节点重新对比静态提升完全静态的节点或属性在编译时被提升到渲染函数外部只在首次创建时生成一次后续更新时直接复用不再重新创建虚拟节点事件缓存Vue3 会把内联事件处理函数缓存起来例如clickcount会被缓存成同一个函数引用避免它在每次渲染时都变化也就减少了子组件的无谓更新block tree模板的根节点会被记录成一个 block把后代里的动态节点都收集到这个 block 上。更新时直接从动态节点列表对比跳过了大量静态子树。再加上移动节点时采用的最长递增子序列算法让 DOM 移动次数最小化。所以 Vue3 的diff比 Vue2 快不是某个单一优化点而是一整套编译时打标记 运行时跳过的组合拳。如果面试官继续追问那自定义渲染器有没有变化你可以补充Vue3 的运行时核心被抽成了runtime-core与具体渲染目标解耦所以diff算法本身是渲染器无关的这也是vue-router、pinia等库不依赖 DOM 也能正常运行的原因。2.5 nextTick 的原理数据变化后 DOM 为什么不会立刻更新这个问题我建议结合浏览器的事件循环来理解。Vue3 的响应式更新是异步批量的当你修改一个响应式数据Vue 不会立刻同步更新 DOM而是把更新任务放进一个队列里在同一个事件循环的微任务阶段统一执行。这样做的好处是哪怕一个流程里连续改了十次数据最终也只更新一次 DOM性能开销小很多。所以你在改完数据之后立刻去操作 DOM拿到的一定是旧值。这时候就要用nextTickimport { ref, nextTick } from vue const message ref(hello) async function updateMessage() { message.value world // 此时 DOM 还没更新 console.log(document.querySelector(.msg)?.textContent) // hello await nextTick() // DOM 已更新 console.log(document.querySelector(.msg)?.textContent) // world }nextTick在 Vue3 里返回的是一个 Promise所以可以直接await。它内部实现的逻辑也不复杂先判断当前环境是否支持Promise如果不支持就降级为MutationObserver或setTimeout核心目的就是把回调延迟到下一次 DOM 更新周期之后。面试时有个变体问题很常见nextTick和setTimeout(() {}, 0)有什么区别我的回答是nextTick会在当前微任务队列里尽量靠前执行而setTimeout宏任务要等所有微任务执行完才跑通常更晚。而且nextTick能保证 DOM 已被真正更新setTimeout则没有这种保证。3. 组件通信与生命周期高频考点和几个真实项目里踩过的坑3.1 生命周期改名与组合式 API 生命周期函数Vue3 对生命周期做了两个层面的变化。第一是选项式 API 的改名Vue2Vue3说明beforeCreate使用 setup()组合式 API 中不需要单独对应函数created使用 setup()同上beforeMountonBeforeMount挂载前mountedonMounted挂载后beforeUpdateonBeforeUpdate数据更新前updatedonUpdated数据更新后beforeDestroyonBeforeUnmount卸载前destroyedonUnmounted卸载后errorCapturedonErrorCaptured捕获后代组件错误第二是组合式 API 里的生命周期函数必须在setup执行阶段同步调用。什么意思你不能把onMounted包在setTimeout里也不能写在 Promise 的回调里因为 Vue 需要在初始化时把生命周期回调注册到当前组件实例上。如果异步调用此刻组件实例已经不是当前的上下文了回调根本注册不上自然也不会执行。举一个我之前写错的例子// 错误onMounted 被包在异步回调里不会生效 setTimeout(() { onMounted(() { console.log(mounted) }) }, 1000) // 正确同步调用 onMounted(() { console.log(mounted) })从代码组织角度我习惯把数据加载、事件监听、定时器启动都放在onMounted里然后在onBeforeUnmount清理。这样避免内存泄漏。提示生命周期函数在组合式 API 里更常见的命名是onMounted这种on开头形式不要和选项式的mounted混用。同一个组件里两者可以共存但逻辑会分散不建议。3.2 v-model 的多绑定与 defineModel 带来的体验提升Vue3 的v-model和 Vue2 最大的不同是它不再只限制于表单控件而是一种通用的双向绑定语法糖。默认情况下// 在子组件上使用 Child v-modeltitle / // 等价于 Child :modelValuetitle update:modelValuetitle $event /在子组件内部需要接收modelValue这个 prop并分发update:modelValue事件script setup langts defineProps{ modelValue: string }() const emit defineEmits{ update:modelValue: [value: string] }() function handleChange(e: Event) { emit(update:modelValue, (e.target as HTMLInputElement).value) } /scriptVue3.3 之后有了defineModel这个宏代码瞬间简洁很多script setup langts const modelValue defineModelstring() function handleChange(e: Event) { modelValue.value (e.target as HTMLInputElement).value } /scriptdefineModel会自动声明 prop、声明事件并绑好双向关系。它本质上是基于宏的语法糖编译时展开成对应的 prop 和 emit 逻辑。还有一个高频考点一个组件可以绑定多个 v-model这在封装复杂表单组件时特别有用。比如一个日期范围组件需要两个值DateRangePicker v-model:startDatestartDate v-model:endDateendDate /子组件里分别用defineModel(startDate)和defineModel(endDate)接收即可。这种写法比手动写一堆:startDate和update:startDate要清爽得多。3.3 一个高频坑props 赋值给 data 后父组件更新为什么失效这个问题我在真实项目里遇到不止一次。场景是这样的父组件请求详情数据后传给子组件子组件把props里的值保存到自己的data里做编辑结果发现父组件重新请求数据、传了新的 props子组件界面却还是旧值。原因在于子组件把 props 赋值给 data 时只是拷贝了一份初始值之后父组件更新 props子组件的 data 并不会自动同步。这种问题在 Vue2 里同样存在但在 Vue3 里由于响应式机制的变化更容易让新人摸不着头脑。解决方案有三种用 watch 同步监听 props 变化后更新内部数据const props defineProps{ userInfo: object }() const localData ref({ ...props.userInfo }) watch(() props.userInfo, (val) { localData.value { ...val } })用计算属性如果不需要在子组件里做临时编辑直接用 computed 派生强制重建组件用:key绑定 props 中的唯一标识父组件数据变化时 key 变化组件重新创建。比如列表详情场景DetailPanel :keycurrentId :user-infouserInfo /第三种方式最暴力也最省心但会丢失组件内部非响应式的临时状态要权衡使用。3.4 provide/inject 与事件总线的取舍在跨多层组件通信时逐级传 props 很啰嗦很多项目会考虑事件总线。这里要先明确一点Vue3 实例上已经移除了$on、$off、$once方法所以不能再直接用new Vue()做全局事件总线了社区通常用第三方库mitt来替代。这不意味着事件总线是推荐方案。事件总线最大的问题在于事件名是字符串项目大了以后很难追踪谁发、谁收调试成本高。更推荐的做法是provide/inject。它允许父组件向任意层级后代注入数据适合主题配置、用户信息、全局状态这类跨层共享但不需要全局响应的场景// 父组件 import { provide, ref } from vue const theme ref(dark) provide(appTheme, theme) // 孙组件 import { inject } from vue const theme injectRefstring(appTheme, ref(light))注意默认情况下provide/inject不是响应式的。如果你注入一个普通对象后代组件拿到的值不会随父组件更新。解决办法是注入ref、reactive对象或者用computed生成派生值。这是面试里很喜欢深挖的点很多人只听过provide能传值却不知道传的对象必须是响应式的才能联动更新。3.5 路由跳转后组件内容不渲染一次完整的排查实践有段时间项目里反馈一个问题从列表页 A 跳到详情页 B再从详情页 B 跳到另一个详情页 CURL 已经变了但页面内容没刷新像是卡在之前的详情。我当时按下面顺序排查确认路由配置查routes里的 path、name 是否拼错排除配置问题确认组件是否被 keep-alive 缓存如果外层包了keep-alive组件会被缓存onMounted不会重新触发而onActivated才会确认是不是同一个组件实例复用这是最终定位到的根因。从 B 详情跳到 C 详情两者用的是同一个详情组件文件只是路由参数不同。Vue 的RouterView默认会复用相同类型的组件实例组件的生命周期钩子不会重新执行所以数据自然没有刷新。解决方案是在路由组件上监听route变化重新拉取数据import { useRoute } from vue-router const route useRoute() watch(() route.params.id, async (newId) { const data await fetchDetail(newId) detail.value data }, { immediate: true })或者更简单粗暴在RouterView上绑一个:key让不同路径强制重建组件RouterView :key$route.fullPath /但绑fullPath会导致同一页面只是 query 变化也会整体重建组件性能不是最优一般只在页面少的小项目里用。大项目还是推荐用 watch 方式精确控制。3.6 组合式函数实战示例24小时分段选择器的实现思路开发可视化大屏项目时经常遇到需要选择当天多个时间段的需求比如 08:00-10:00、13:00-15:00 这种分段。用组合式 API 抽一个useTimeSegments会非常方便// hooks/useTimeSegments.ts import { ref, computed } from vue export function useTimeSegments(startHour 0, endHour 24) { const selected refArray{ start: number; end: number }([]) const hours computed(() Array.from({ length: endHour - startHour }, (_, i) startHour i) ) function addSegment(start: number, end: number) { selected.value.push({ start, end }) } function removeSegment(index: number) { selected.value.splice(index, 1) } function clearSegments() { selected.value [] } return { selected, hours, addSegment, removeSegment, clearSegments } }这个 Hook 本质上就是数据和方法聚合成一个独立模块组件里只需要调用一次模板里绑定hours渲染按钮再调addSegment/removeSegment即可。这类细节在面试里讲出来比干巴巴背概念更有说服力因为它体现了你理解了组合式 API 的实际应用价值。4. 工程化里的硬骨头vite 配置、TS 报错、JSX 和样式适配实测心得4.1 vite 和 webpack 的开发体验差距为什么新项目都用 vite很多从 Vue2 时代过来的人习惯了一改代码等两三秒热更新。第一次用 vite 的感受就是快得不真实。原因在于两者构建方式根本不同webpack在启动时要遍历项目的完整依赖关系构建出一个庞大的模块依赖图所有代码都要打包成 bundle然后才启动开发服务器vite开发模式底层用的是原生 ES Module浏览器直接请求模块文件vite 只做按需转换启动速度几乎不受项目规模影响。依赖预构建用 esbuild快到毫秒级。但在生产环境vite 默认用 rollup 打包这带来一个常见问题某些旧依赖库如果同时发布 CJS 和 ESM 格式可能会在构建时出现Failed to resolve entry之类的报错。解决办法一般是到vite.config.ts里加optimizeDeps.include或build.commonjsOptions.include把相关依赖显式声明进去。迁移到 vite 时最容易踩的另一个坑是路径别名和 TS 配置不对齐。比如你在vite.config.ts里配了alias但tsconfig里没加对应的pathsTS 编辑器就会飘红// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, ./src), }, }, })// tsconfig.json { compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }这两个配置必须同时存在否则 vite 能跑但 IDE 会一直报找不到模块的错误。4.2 Vue3 TS 的常见报错整理若依 ts 报错、mock 配置和全局类型声明在实际项目中Vue3 和 TypeScript 的组合带来的报错五花八门最常见的三类我总结如下第一类路径别名和 Node 全局变量报错。比如在vite.config.ts里使用path.resolve如果项目没有安装types/node就会报找不到 module path 或其相应的类型声明。解决办法是npm install types/node -D同时确保tsconfig.node.json包含vite.config.ts或将其加入 include。第二类mock 数据失效或类型报错。项目中通常用vite-plugin-mock来模拟接口。如果发现 mock 不生效先检查插件版本是否和 vite 版本兼容再看 mock 文件是否放在规范目录一般默认是mock目录并且导出方式是export default [{...}]。TS 环境下 mock 文件可能被当成后端脚本需要在tsconfig里单独配置types或排除掉。第三类window上挂自定义属性报错。比如上线排查问题方便你在window上挂了一个window.appVersionTS 直接报Property appVersion does not exist on type Window。需要做全局声明扩展// src/types/global.d.ts export {} declare global { interface Window { appVersion?: string } }这类报错看起来很小但新人遇到往往会卡很久。我有个习惯项目一创建就先建好types/global.d.ts目录把已知的全局变量、后端返回类型通用定义全部放进去后续少很多麻烦。4.3 JSX 在 Vue3 里的正确打开方式Vue3 本身支持用 JSX 编写组件但默认模板引擎不会去解析.tsx文件需要安装插件npm install vitejs/plugin-vue-jsx -D然后在vite.config.ts里注册import vueJsx from vitejs/plugin-vue-jsx export default defineConfig({ plugins: [vue({}), vueJsx()], })Vue3 的 JSX 写法和 React 的 JSX 很像但有几个差异容易踩坑条件渲染用三元表达式或逻辑与不要用v-if指令const renderButton () loading.value ? span加载中.../span : button onClick{handleClick}确定/button列表循环只有表达式没有指令用mapconst list [1, 2, 3] return ( div {list.map((item) ( span key{item}{item}/span ))} /div )v-modelJSX 里没有编译期语法糖要手动写return input value{name.value} onInput{(e) (name.value (e.target as HTMLInputElement).value)} /插槽通常用v-slots属性传入对象或直接在 JSX 中使用子元素当作默认插槽。如果团队里既有模板派又有 JSX 派建议统一规范简单组件用模板复杂渲染逻辑用 JSX。两者在同一个项目里共存没有问题只要风格一致即可。4.4 样式适配的两大实测问题postcss-pxtorem 对 echarts 无效tabs 标签页样式改不动这两个问题都来自我做可视化大屏项目的真实经历。首先是pxtorem对 echarts 不生效的问题。用postcss-pxtorem做移动端适配时它只处理 CSS 文件里写的px值但 echarts 图表里的字体大小、边距、坐标轴间距都是在 canvas 内部通过 JavaScript 绘制的根本不走 CSS所以pxtorem自然管不到。解决思路是拿到适配后的根字号在初始化 echarts 时动态计算数值const remSize parseFloat(getComputedStyle(document.documentElement).fontSize) const option { title: { textStyle: { fontSize: 14 / 20 * remSize, // 按照设计稿比例换算 }, }, }更省事的做法是监听resize时重新计算fontSize并重设setOption。注意 echarts 实例也要在容器尺寸变化时调用chart.resize()否则图表会被拉伸变形。这里有个小的经验大屏项目里窗口resize事件一定要做防抖不然图表会频繁重绘掉帧严重。其次是 element-plus 的tabs标签页样式改不动。原因是 element-plus 组件内部样式用了作用域外部普通选择器权重不够。在 Vue3 的 scoped 条件下需要用到:deep()来穿透作用域style scoped langscss .custom-tabs :deep(.el-tabs__item) { color: #999; } .custom-tabs :deep(.el-tabs__item.is-active) { color: #1a73e8; } .custom-tabs :deep(.el-tabs__active-bar) { background-color: #1a73e8; } /style:deep()的本质是让选择器不再被加上>
返回列表