前两篇该讲的模板语法、指令、组件基础都过了一遍跟着敲下来页面能写、接口能调但很多人就是卡在下一个台阶代码能跑问一句为什么就答不上来。这一篇VUE—从入门到飞起三不打算再重复v-if和v-for而是专挑那些真正决定你能不能从会写 Vue跨到敢改 Vue的内容来讲——reactive和ref到底是怎么收集依赖的、路由和权限怎么咬合、Pinia 与 Vuex 该选哪个、Vite 环境变量怎么配、打包上线之后布局为什么突然错乱、宝塔上怎么把 Spring Boot 和 Vue 一起部署出去。这一篇适合三类人已经写过一两个 Vue 3 项目、想弄明白原理的中级开发者正在准备面试、被nextTick和 diff 算法问懵的求职者以及手上有个前后端分离项目、卡在部署和打包环节的独立开发者。我会把怎么做和为什么这么做放在一起讲凡是参数和取舍都给出理由凡是坑都标出来能直接抄的地方就给你能跑的代码。1. 第三篇的定位从能写到敢改之间隔着什么前两篇的路径是典型的入门路线装 Node、配环境、创建项目、写组件、调接口。走完这条线你会发现一个现象——照着教程写没问题一旦需求稍微拐个弯比如要抽一个可复用的输入框组件、要做一个按钮级权限、要在弹窗里嵌个视频播放器就立刻不知道该动哪里了。原因不在语法语法就那么多原因在于对 Vue 的运行机制没有形成心理模型。我一般用盖房子打比方。入门阶段你在学怎么砌墙、怎么装门这些都学得会但真正决定房子能不能改的是承重墙在哪、水电怎么走。Vue 里的承重墙就是响应式系统水电就是组件间的数据流和生命周期。你只有知道ref改一下值之后是谁在什么时机通知视图更新才会明白为什么有些地方改了数据页面没动、为什么watch有时候不触发、为什么computed用不好会造成死循环。第三篇的取舍是这样的原理部分讲透但不钻牛角尖目标是你读完能自己用原生Proxy写出一套能跑的最小响应式而不是让你去吃透整个 Vue 源码仓库实战部分只挑高频场景路由权限、状态管理选型、工程化配置、打包部署这四件事覆盖了实际项目八成以上的卡壳时刻。至于音视频、地图、富文本、图表这些外挂能力我放在最后单独讲接入姿势因为这类第三方库和 Vue 的结合点特别容易出生命周期相关的 bug属于典型文档不写、但一定会踩的区域。顺便说一句热词里出现的vue播放m3u8、webrtc vue使用、用在vue里的腾讯地图、矩阵树图 vue、vue pc富文本编辑器这类需求本身都不难难的是和 Vue 的响应式、生命周期正确配合。如果你的项目里正有这些模块最后两节可以优先看。2. 脱离源码用原生 Proxy 手写一遍 reactive、ref、effect、computed这一节是整篇的重头戏。我建议你打开一个空的.js文件跟着敲不要复制粘贴敲一遍的感受完全不一样。2.1 为什么是 Proxy而不是继续用 definePropertyVue 2 用的是Object.defineProperty它的工作方式是在初始化时递归遍历对象的每一个属性把每个属性改写成带有getter和setter的访问器属性。这套方案有三个绕不过去的硬伤。第一新增和删除属性监听不到。因为所有监听都是在初始化那一刻挂上去的后来才加的键根本没有被处理过所以 Vue 2 里才需要Vue.set和Vue.delete这两个补丁 API。第二数组索引赋值和 length 修改监听不到因为defineProperty在那个年代对数组的处理代价太高Vue 2 只能去重写数组的七个方法push、pop、shift等来兜住。第三初始化时的递归开销大数据对象层级深、体量大时首屏会明显变慢。Proxy直接代理整个对象而不是逐个属性改写。它的get、set、deleteProperty、has、ownKeys这些拦截器覆盖了几乎所有的对象操作新增属性和删除属性天然被捕获数组索引赋值也能拿到。而且Proxy是惰性的——只有当某个嵌套对象真的被访问到时才需要递归去代理它这对大对象很友好。代价也有Proxy无法被降级到 ES5 环境这也是 Vue 3 放弃低版本浏览器支持的根本原因。选型上没什么可纠结的如果你的项目还要跑在很老的设备上那本来就不该用 Vue 3。2.2 先把依赖收集这件事想清楚响应式的本质只有两句话读数据的时候把谁在用记下来写数据的时候把用它的那些人叫回来重跑。前者叫依赖收集track后者叫派发更新trigger。数据结构上需要一个三级映射目标对象 - 属性名 - 依赖这个属性的副作用函数集合。外层用WeakMap原因是它不会阻止垃圾回收——对象被销毁后对应的依赖记录自动消失不会造成内存泄漏。这是很多人抄示例代码时忽略的一点。// 全局记录当前正在执行的副作用函数 let activeEffect null // 副作用函数栈处理嵌套 effect const effectStack [] // WeakMaptarget, Mapkey, Seteffect const targetMap new WeakMap() function track(target, key) { if (!activeEffect) return let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } let dep depsMap.get(key) if (!dep) { dep new Set() depsMap.set(key, dep) } dep.add(activeEffect) // 反向记录方便后续清理 activeEffect.deps.push(dep) } function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (!dep) return // 用新 Set 遍历避免执行过程中增删导致的死循环 new Set(dep).forEach(fn { if (fn ! activeEffect) { fn.scheduler ? fn.scheduler() : fn() } }) }这里有两个细节值得单独说。activeEffect.deps.push(dep)是为了后面做依赖清理因为一个副作用函数里可能有分支判断第一次走 A 分支用了变量a第二次走 B 分支用了b如果不清理a变化时还会把这个函数叫起来白白执行一次。trigger里用new Set(dep)复制一份再遍历是因为副作用函数执行时可能又触发新的收集直接遍历原集合会乱。2.3 reactive、effect 与清理逻辑reactive就是给Proxy包装一层并在get里做两件事收集依赖、深度代理。注意深度代理是懒的只有返回值是对象时才继续包。function reactive(target) { if (typeof target ! object || target null) return target return new Proxy(target, { get(t, key, receiver) { if (key __isReactive) return true track(t, key) const res Reflect.get(t, key, receiver) // 惰性深度代理 return typeof res object res ! null ? reactive(res) : res }, set(t, key, value, receiver) { const old t[key] const ok Reflect.set(t, key, value, receiver) // 值真的变了才派发且排除 NaN 自身比较为 false 的情况 const changed !Object.is(old, value) if (changed) trigger(t, key) return ok }, deleteProperty(t, key) { const had Object.prototype.hasOwnProperty.call(t, key) const ok Reflect.deleteProperty(t, key) if (had ok) trigger(t, key) return ok } }) }effect要做三件事执行前清理旧依赖、把自己压栈、执行完出栈恢复上一层的activeEffect。顺便把scheduler和lazy两个开关留出来后面computed要用。function cleanup(effectFn) { for (const dep of effectFn.deps) dep.delete(effectFn) effectFn.deps.length 0 } function effect(fn, options {}) { const effectFn () { cleanup(effectFn) activeEffect effectFn effectStack.push(effectFn) const res fn() effectStack.pop() activeEffect effectStack[effectStack.length - 1] return res } effectFn.deps [] effectFn.scheduler options.scheduler // lazy 为 true 时不立即执行交给 computed 手动控制 if (!options.lazy) effectFn() return effectFn }写到这里你可以先跑一遍验证effect里打印obj.count然后改obj.count看看有没有重新打印。这一步跑通了后面的ref和computed基本就是体力活了。2.4 ref 为什么要单独造一个类Proxy只能代理对象可现实里我们大量处理的是数字、字符串、布尔值这类原始值它们没法被代理。所以ref的做法是包一层对象把值放在_value上用get value/set value这对访问器来做拦截。这就是为什么在script里操作ref一定要写.value而在模板里能省略——模板编译时会做一层自动解包unref。function ref(value) { if (value value.__isRef) return value return { __isRef: true, get value() { track(this, value) return value }, set value(newVal) { if (Object.is(newVal, value)) return value newVal trigger(this, value) } } }真正的 Vue 3 里RefImpl还会做两件额外的事如果传进来的是对象会在内部转成reactive同时通过trackRefValue和triggerRefValue把依赖挂在dep字段上而不是WeakMap里。这些优化你不写也能跑但要知道它们存在。2.5 computed 的缓存靠什么实现computed最核心的关键词是惰性和缓存。它内部包了一个lazy的effect用一个dirty标记记录当前值是否过期第一次访问value时求值并把dirty置为false后续只要依赖没变就直接返回缓存值依赖变化时通过scheduler把dirty重新置为true但不立刻计算等下次访问再说。function computed(getter) { let value let dirty true const effectFn effect(getter, { lazy: true, scheduler() { dirty true trigger(obj, value) // 通知那些在 effect 里读 computed 的副作用 } }) const obj { __isRef: true, get value() { if (dirty) { value effectFn() dirty false } track(obj, value) return value } } return obj }到这里一个包含reactive、ref、effect、computed的最小响应式系统就齐了加起来不到一百行。跑通它给我的最大收获不是我懂了源码而是以后遇到响应式相关的怪问题我知道该往哪个方向查数据没更新视图先想依赖有没有收集到视图更新太频繁先想是不是非惰性的深度代理在作怪computed不刷新先想track(obj, value)这一步是不是被漏掉了。3. 手写版和真实源码之间差在这几个边界处理上自己写完再看 Vue 3 的reactivity包会有一种主干完全一样、细节全是坑的感觉。这几处差异是面试和实战里最容易被问到的我按重要性排一下。3.1 嵌套 effect 与栈的恢复我上面的实现用了effectStack这不是可选项。设想一个effect里调用了另一个effect内层执行完之后activeEffect必须恢复到外层函数否则后续所有的依赖收集都会挂到内层函数身上表现为外层数据变了不触发。在真实项目里这类嵌套经常出现在watch里再写computed、或者组件渲染 effect 里访问另一个组件的渲染 effect 的场景。3.2 数组、Set、Map 的代理与只触发一次Proxy拦截的是操作不是语义。数组调一次push实际上会先set索引再setlength如果不做处理就会触发两次更新。Vue 3 的baseHandlers里专门记录了length和索引的关系来处理这类情况同时提供了toRaw与markRaw让你在需要的时候绕开代理。实战经验给图表、地图、编辑器实例这类第三方重量级对象赋值时务必用markRaw包一下否则它内部一大堆属性会被递归代理轻则变慢重则因为内部逻辑依赖this指向而直接报错。我见过最典型的一次事故是给地图实例做了reactive结果地图拖动时疯狂触发渲染页面直接卡死。3.3 依赖存储结构的演进早期版本用的是我上面写的WeakMap - Map - SetVue 3.4 之后换成了双向链表 版本号的结构目的有两个一是避免副作用函数被重复收集二是用版本号快速判断依赖是否过期减少不必要的遍历。这说明一件事——原理层面的模型收集、派发、清理是稳定的但工程层面的性能优化一直在迭代。你不需要照着新版重写一遍但要知道自己写的是教科书版本不是生产版本。3.4 响应式丢失的四种典型场景这一条是纯实战清单我在项目里反复遇到现象根本原因处理方式解构后修改不生效取出的是值快照不是代理引用用toRefs或直接storeToRefs整个reactive对象被重新赋值变量指向新对象Proxy 被换掉用Object.assign合并或改用ref传给子组件的对象改属性不生效子组件收到的是被markRaw或原始对象检查是否toRaw过早computed里写了异步逻辑computed是同步派生值改成watch或watchEffect注意ref和reactive的选择有一个很实用的判断标准——需要整体替换的场景用ref只做属性读写的用reactive。表单对象这种经常要form {}重置的用ref会省掉很多麻烦。4. 路由不只是跳转参数传递、拦截器与权限是怎么串起来的热词里vue路由、vue路由参数、vue路由拦截器出现的频次很高说明这是新手到中级之间最容易含糊的一块。我按参数怎么传得干净拦截器怎么写得不乱权限怎么落到按钮上三个层次讲。4.1 参数传递的几种方式与对应坑query和params的区别老生常谈query拼在地址栏上、刷新不丢、能分享params在内存里、刷新就没了除非在路由定义里用:id占位。真正容易踩的是组件复用问题从/user/1跳到/user/2时Vue 会复用同一个组件实例onMounted不会重新执行数据还是旧的。处理方式有两种一是用watch监听route.params.id二是给router-view加:key$route.fullPath强制重建。我更推荐前者后者会带来额外的组件销毁重建开销页面里有视频播放器或者地图时尤其明显。还有一个更干净的做法是用props解耦在路由表里写props: true组件只管声明props: [id]不用去碰useRoute()。这样组件就成了一个纯粹的、可单测的输入输出单元后面要复用到别的页面时不用改一行代码。4.2 拦截器跳出登录校验写满每个页面的陷阱beforeEach是全局守卫最典型的用法是登录校验。但新手常犯的错是把逻辑写成一大坨if-else最后自己都读不懂。我的写法是把它拆成三层判断顺序不能乱router.beforeEach(async (to, from, next) { // 第一层白名单直接放行避免无意义开销 if (whiteList.includes(to.name)) return next() const token getToken() // 第二层无凭证记录目标地址后跳登录 if (!token) { return next({ name: login, query: { redirect: to.fullPath } }) } // 第三层有凭证但角色信息未加载拉取后动态注册路由 const userStore useUserStore() if (!userStore.roles.length) { try { await userStore.fetchProfile() userStore.routes.forEach(r router.addRoute(r)) // 关键replace 回去让新路由重新匹配一次 return next({ ...to, replace: true }) } catch (e) { await userStore.logout() return next({ name: login }) } } next() })这里有三处非常容易出事的地方。第一动态添加路由之后一定要next({ ...to, replace: true })否则新加的路由不会立刻生效用户会看到一个空白页第二redirect参数在登录成功后要消费掉别忘了做地址校验只允许站内路径防止被跳转到外部第三addRoute添加的路由在退出登录时必须移除否则切换账号后上一个账号的路由还在属于典型权限漏洞。做法是在logout时记录下addRoute的返回值它是一个移除函数逐个调用。4.3 按钮级权限指令比v-if更好用菜单级权限靠路由控制按钮级权限很多人用v-ifhasPerm(user:add)写代码里到处都是判断。更清爽的做法是自定义指令// 权限指令 export const permission { mounted(el, binding) { const store useUserStore() if (!store.permissions.includes(binding.value)) { el.parentNode el.parentNode.removeChild(el) } } }用v-permissionuser:add就行。注意这里用removeChild而不是display: none因为隐藏元素在开发者工具里仍然能看到某些安全审计会直接判为不通过。另外指令只处理静态权限如果权限是异步加载的要配合updated钩子或者等权限就绪后再渲染否则会出现刚进页面按钮闪一下又消失的现象。5. Pinia 还是 Vuex这次用项目实际需求来选vue pinia vs vuex是热词里的高频对比项我直接给结论新项目无脑用 Pinia老项目渐进迁移。理由不是 Pinia 更新而是它在几个关键维度上确实更省事。5.1 两者的核心差异Vuex 的核心概念有state、getters、mutations、actions、modules五件套。同步改状态必须走mutations异步走actions写一个功能要在两三处地方来回跳。Pinia 砍掉了mutations改状态直接在actions里改同时原生支持 TypeScript 类型推导不需要为类型定义写一堆辅助函数。维度Vuex 4Pinia同步修改必须提交 mutation直接赋值模块拆分modulesnamespaced每个 store 天生独立类型推导需要手动声明类型自动推导组合式写法不友好原生支持代码体积较大更小调试工具支持支持5.2 用 setup 语法写 store 的真实体验Pinia 支持两种写法我推荐 setup 风格因为它和组件写法一致心智负担最小而且天然支持组合式函数复用。export const useUserStore defineStore(user, () { const token ref() const profile ref(null) const permissions ref([]) const isLogin computed(() !!token.value) async function login(payload) { const { data } await api.login(payload) token.value data.token setToken(data.token) await fetchProfile() } async function fetchProfile() { const { data } await api.profile() profile.value data permissions.value data.permissions } function logout() { token.value profile.value null permissions.value [] removeToken() } return { token, profile, permissions, isLogin, login, fetchProfile, logout } })这里有个必踩的坑在组件里解构 store 会丢掉响应性。const { profile } useUserStore()拿到的是一次性快照后面profile更新了组件不刷新。正确姿势是用storeToRefsconst { profile, isLogin } storeToRefs(useUserStore()) // 方法不需要响应式直接解构也没问题 const { logout } useUserStore()我踩过这个坑之后养成一个习惯只要从 store 里解构出的是数据不是函数一律过一遍storeToRefs不给自己留判断的机会。另外Pinia 的 store 不需要注册到全局useXxxStore()在组件里按需调用即可但要注意它必须在setup作用域内调用写在普通的工具函数里会因为拿不到活跃的 pinia 实例而报错这种情况需要在main.js里把pinia实例导出并在工具函数里手动传入。6. 从 Vite 环境变量到 ESLint Prettier Vitest 的工程化收口6.1 环境变量.env.production到底什么时候生效Vite 的环境变量有几个必须记死的规则变量名必须以VITE_开头才会被注入到客户端代码里代码里通过import.meta.env.VITE_XXX访问而不是process.env。.env文件按优先级从高到低依次是.env.[mode].local、.env.[mode]、.env.local、.envmode由vite build --mode xxx指定。实际项目里最常见的需求是同一份代码部署到多套环境。我的做法是配合--mode加自定义脚本{ scripts: { dev: vite, build: vite build --mode production, build:test: vite build --mode staging, preview: vite preview } }注意环境变量是在构建时被静态替换的不是运行时读取的。也就是说你把打包产物扔到服务器上之后改环境变量文件是没有任何作用的必须重新打包。这一点和某些后端框架的配置方式完全不同很多从后端转过来的人在这里栽过。6.2 打包后的路径问题与base部署到子目录比如https://域名/admin/时如果不配base所有静态资源都会以根路径请求结果就是白屏加一堆 404。在vite.config.js里加base: /admin/即可。更稳的做法是用相对路径base: ./前提是你的路由必须是 hash 模式history 模式在子目录下用相对路径会出问题。6.3 三个工具的分工别搞混很多人把 ESLint 和 Prettier 当成一回事配置冲突之后互相打架。它们的分工是这样的ESLint 管代码质量和潜在错误未使用变量、v-for缺 key、props类型不对Prettier 管格式缩进、引号、换行。两者职责重叠的部分交给 PrettierESLint 里要关掉相关规则装eslint-config-prettier就是这个用途。Vitest 和 Vue 的结合点主要在组件测试我用得比较多的三类断言一是纯函数工具比如权限判断、日期格式化的输入输出二是 store 里的 action 逻辑用createPiniasetActivePinia在每个用例前重置三是组件的渲染快照和用户交互。我的经验是别追求覆盖率数字把utils、store、以及那些改一次出一次 bug的复杂组件测住收益远大于给每个 UI 组件都写一遍渲染测试。7. 打包上线才是真正的战场布局错乱、离线安装与宝塔部署7.1 开发环境正常打包后布局错乱到底错在哪vue打包后布局异常是被问得最多的问题之一我把自己和同事遇到过的原因归成五类按出现频率排序。第一类是单位的换算基准不一致。很多项目用postcss-px-to-viewport或者手写 rem 适配开发时在浏览器里看着正常打包后在真机上比例全乱。根因通常是插件的viewportWidth配置和设计稿宽度不一致或者插件被配在了devDependencies里但 CI 构建时用了--production安装导致插件压根没执行。这类问题的排查方法很简单把打包产物里的 CSS 捞出来看px有没有被转成vw一眼就能定位。第二类是 CSS 抽取顺序变化。开发时样式是通过style动态注入的顺序跟着模块加载走生产构建会把 CSS 抽成独立文件并按模块图合并顺序可能和开发环境不同。如果你的样式依赖后写的覆盖先写的这种隐式规则就会出现只在生产环境错位的现象。根治办法是不要依赖加载顺序该提升权重的地方老实提升或者用 CSS Modules /scoped把作用域隔开。第三类是浏览器兼容相关的降级。比如gap属性、aspect-ratio、:has()选择器在旧内核上不支持开发时用的是新版 Chrome 完全看不出来。这时候要看browserslist配置和目标用户的实际设备分布该加postcss-preset-env就加。第四类是图标字体或图片路径丢失。表现是布局塌陷但其实是资源 404 导致占位尺寸变成 0。检查base配置和资源引用方式即可。第五类是富文本内容里的内联样式被过滤。如果详情页的内容是富文本编辑器生成的 HTML经过 XSS 过滤时可能会把style属性或某些标签干掉页面在编辑时正常、展示时错乱。这条排查起来最费时间因为前端代码本身没毛病需要对着过滤规则逐条比对。7.2 内网或无网环境的离线安装有些项目跑在完全隔离的内网里装依赖全靠离线包。可行路径有三条我按推荐度排搭一个私有 npm 仓库Verdaccio 这类是最省事的把需要的包发上去内网机器指向私有源就行用npm pack打包成 tgz适合依赖数量少的情况npm install ./xxx.tgz直接装提前在联网机器上把node_modules和 npm 缓存目录一起拷过去适合一次性交付。离线安装最容易翻车的地方是间接依赖和postinstall脚本。有些包在安装时会下载二进制文件比如某些图像处理、canvas 相关的包离线环境下这步会失败。处理方式是提前设置好镜像地址指向内网服务或者找纯 JS 的替代实现。另外注意 Node 版本要一致node_modules里有平台相关的原生模块时跨系统拷贝必然出问题。7.3 宝塔上把 Spring Boot 和 Vue 一起部署前后端分离项目的标准部署结构是Nginx 托管前端静态文件同时把/api前缀反向代理到后端的 Spring Boot 端口。这样做的最大好处是同源直接绕过跨域问题前端代码里的请求地址写相对路径/api就行不用再配一堆代理规则。server { listen 80; server_name your-domain.com; # 前端history 模式必须加 try_files否则刷新 404 location / { root /www/wwwroot/vue-dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口转发 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 300s; } # 静态资源缓存 location ~* \.(js|css|png|jpg|svg|woff2)$ { root /www/wwwroot/vue-dist; expires 30d; add_header Cache-Control public, immutable; } }这里有几个坑值得单独标出来。try_files那行如果漏了用户在任何非根路径刷新页面都会 404这是 history 模式最常见的部署事故。proxy_pass结尾的斜杠决定了路径怎么拼接写http://127.0.0.1:8080/会把/api前缀去掉写http://127.0.0.1:8080则会保留你后端 Controller 上的RequestMapping前缀决定该写哪种两边对不上就是 404。另外带哈希的静态资源可以放心设长缓存但index.html千万别设缓存否则用户拿到旧的 HTML 去加载已经删掉的旧 JS直接白屏。我个人习惯是给前端产物加一个版本目录比如/www/wwwroot/vue-dist/20250612/Nginx 里用变量指向当前版本。回滚的时候只要改一行配置 reload比覆盖文件安全得多也不用担心正在访问的用户拿到半新半旧的资源。8.$attrs、透传与组件通信面试和实战里都躲不开的几条线热词里vue透传和vue $attrs放在一起出现说明这是很多人真正困惑的点。$attrs的规则其实只有一句话父组件传给子组件、但子组件没有在props里声明的属性全部收在$attrs里。在 Vue 3 中class和style会自动合并到根元素事件监听器click这类也被合并进了$attrs这就是v-on$listeners在 Vue 3 里消失的原因。8.1 封装第三方组件时的标准姿势假设你要基于一个 UI 库的输入框封装一个业务输入框希望父组件传的所有属性和事件都能透下去。写法就是inheritAttrs: false加上v-bind$attrsscript setup defineOptions({ inheritAttrs: false }) defineProps({ label: String }) /script template div classfield span v-iflabel classlabel{{ label }}/span input v-bind$attrs / /div /templateinheritAttrs: false必须加否则$attrs会同时自动落到根元素div上导致placeholder出现在div上、class被应用两次。这个错误的典型症状是样式莫名多了一层、或者点击事件触发了两次。8.2 多层透传时不要硬传如果属性要穿过三四层组件一层层v-bind$attrs会很啰嗦这时候该上provide/inject。但要注意provide传递的不是响应式的如果你希望注入的值能跟着变化要在provide里传ref或computed注入方拿到后自己解包。这一点和props的直觉不一样我见过不少人provide一个普通对象然后纳闷为什么不更新。8.3 面试里高频的几个点顺手过一遍v-model在 Vue 3 里的本质是modelValue属性加update:modelValue事件的语法糖想要多个双向绑定就用参数形式v-model:title。key的作用是给虚拟节点做身份标识让 diff 能正确复用节点列表里用索引当 key 在插入和排序场景下会导致状态错乱。nextTick基于微任务实现用来等 DOM 更新完成后再操作和 React 的setState回调是同一类需求的两种解法。至于vue和react的区别我的回答通常从更新机制切入Vue 通过响应式精确知道哪个组件需要重渲染粒度更细React 靠setState触发组件级重渲染再配合 diff引入 Fiber 是为了把渲染任务切片、可中断从而保证交互不卡顿。Vue 3 的编译器会做静态提升和 PatchFlag 标记把 diff 需要比较的属性直接编译进结果里本质是把运行时开销前移到编译期。这两条路线没有优劣只有适用场景的差异。9. 音视频、地图、富文本这些外挂能力怎么接进 Vue最后这一节专门解决热词里那一串具体需求它们的共性是库本身很强但和 Vue 的响应式、生命周期配合不好就会出各种诡异问题。9.1 m3u8 播放组件的正确封装vue播放m3u8的主流方案是hls.jsSafari 原生支持 HLS 可以不引。封装时的关键点有三个实例必须在onMounted之后创建因为要拿到真实的 DOM 容器onBeforeUnmount里必须调用hls.destroy()并清空src否则路由切换后后台还在拉流内存和带宽都白烧切换清晰度时不要重建整个组件调用hls.loadSource()重新加载即可。onMounted(() { if (Hls.isSupported()) { hls new Hls({ maxBufferLength: 30 }) hls.loadSource(props.src) hls.attachMedia(videoRef.value) } else if (videoRef.value.canPlayType(application/vnd.apple.mpegurl)) { videoRef.value.src props.src } }) onBeforeUnmount(() { hls hls.destroy() hls null if (videoRef.value) { videoRef.value.pause() videoRef.value.removeAttribute(src) videoRef.value.load() } })移动端要额外加playsinline和webkit-playsinline属性否则 iOS 上会自动全屏。自动播放基本会被浏览器策略拦截正确做法是静音 首次用户交互后播放不要试图绕过策略。9.2 WebRTC 与地图生命周期是唯一的敌人webrtc vue使用的核心坑在于资源释放。getUserMedia拿到的 track 必须逐一stop()RTCPeerConnection必须close()否则摄像头指示灯会一直亮着用户会以为你在偷拍他。把这些逻辑放在onBeforeUnmount里并且考虑页面隐藏visibilitychange时也做一次处理。地图比如腾讯地图的接入套路是异步加载脚本然后封装成一个组合式函数。有两个细节脚本加载要做单例判断window.TMap已存在就不要再插 script 标签否则同一页面多个地图组件会重复加载地图实例同样要markRaw处理并且在容器尺寸变化时调用resize()容器被v-show隐藏后重新显示时这一步几乎是必须的。9.3 富文本编辑器与图表PC 端富文本我用过的方案里关键是把编辑器的实例和v-model做双向桥接编辑器的change事件往外 emit 新内容外部值变化时调setHtml写进去但要注意比较一下内容是否相同再写否则会出现光标跳到开头的经典问题。内容提交到后端之前必须做 XSS 过滤展示端也要过滤一遍不能只信任一边。至于矩阵树图 vue这类图表需求用 ECharts 的 treemap 就够了。要注意的是图表实例必须挂载到markRaw或普通变量上不要放进reactive页面尺寸变化时监听resize并调用chart.resize()路由离开时dispose()。数据量大的时候记得开启progressive渲染否则下钻操作会有明显卡顿。vue 3 snippets和vscode配置这类工具链的问题我放在最后提一句Vue 3 项目里插件用 Volar现在叫 Vue - Official不要和 Vetur 同时开两者会互相抢.vue文件的语言服务表现为类型提示时有时无。装好之后配合vue-tsc做类型检查比在编辑器里靠肉眼找类型错误可靠得多。我自己从这套流程里得到的最大体会是Vue 从入门到飞起的分水岭从来不是记住了多少 API而是遇到问题时知道该往哪一层去看。页面不更新先看响应式路由不生效先看守卫执行顺序样式错乱先看构建产物接口 404 先看 Nginx 的斜杠。把这几个方向记住比背一整套面试题有用得多。第三篇到这里下一篇我会把项目实战里那些看起来能用、实际上经不起压力的写法拿出来逐条拆。