Vue项目中的JavaScript核心技能:从响应式到异步编程
Vue 项目写多了很多朋友会有一种感觉框架语法越用越熟但一旦遇到复杂交互、性能问题或者需要排查底层 bug 时真正卡住你的往往不是 Vue API而是 JavaScript 的基础功力。我自己在多个 Vue 项目里摸爬滚打之后越来越确认这件事——Vue 只是把 DOM 操作、组件化、响应式这些脏活累活封装好了但数据怎么组织、异步怎么调度、副作用怎么控制这些底层的 JS 能力才是决定一个前端工程师天花板的钥匙。这篇文章不会讲太多花里胡哨的东西我按自己在真实项目里踩坑和沉淀的顺序把 Vue 开发中最高频的 JS 核心知识点串一遍。内容包括 Vue 3 响应式原理中的 Proxy 与依赖收集、日常几乎每天都在用的数组与对象操作、异步编程里 Event Loop 和 nextTick 的关系、组件通信和路由状态管理中的 JS 思维以及 M3U8 播放、复制粘贴、地图加载这些实际场景里用到的第三方库集成技巧。文章末尾我会再补一段关于手写响应式 API 的代码实战和面试中常见的 Vue 与 JS 交叉问题无论你是刚入门还是已经在写业务代码的前端相信都能从中找到点有价值的东西。1. Vue 响应式原理从 Proxy 到手写 reactive1.1 Vue 3 为什么选择 Proxy在 Vue 2 时代响应式系统依赖的是Object.defineProperty它需要提前遍历对象的每一个属性把它们一个个变成 getter/setter。这意味着对象上新增一个属性Vue 是感知不到的所以才有了Vue.set和this.$set这种不自然的 API。而且对于数组Vue 2 只能拦截push、pop、splice等 7 个变更方法直接通过下标改数组同样不会被侦测到。Vue 3 全面转向Proxy之后这些历史包袱基本被扔掉了。Proxy可以直接代理整个对象而不是某个属性所以新增属性、删除属性、数组下标操作都能被拦截到。我在项目里用到的一个很典型的例子是动态表单场景表单字段是后端接口返回的字段数量不固定用户在填写过程中还会动态增加字段。Vue 2 里每次新增字段都要小心处理响应式丢失的问题Vue 3 里直接给响应式对象加属性就行视图会自动更新。简单看一下 Proxy 的基本用法const target { name: vue, version: 3 }; const proxy new Proxy(target, { get(target, key, receiver) { console.log(读取了 ${String(key)}); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { console.log(设置了 ${String(key)} ${value}); return Reflect.set(target, key, value, receiver); } }); proxy.name; // 输出读取了 name proxy.version 4; // 输出设置了 version 4这里有一个细节值得强调在 getter/setter 内部为什么要用Reflect.get和Reflect.set而不是直接target[key]核心原因在于this绑定。当对象存在继承关系时直接用target[key]会让 getter 里的this指向target而Reflect.get(target, key, receiver)会把正确的receiver传进去保证 getter 里的this指向代理对象本身。这个区别在真实项目里处理 class 继承和 getter 链时很容易踩坑我建议所有打算深入研究 Vue 源码的人都把 Reflect 这一块吃透。1.2 手写一个简版 reactive理解依赖收集和触发更新理解 Proxy 还只是第一步Vue 响应式的精髓在于依赖收集track和触发更新trigger。这两个词的底层逻辑其实非常朴素当你在组件模板或者 computed 里读取了一个响应式数据这个读取行为就应该被记录下来告诉系统这里用到了这个数据当数据被修改时系统再去通知所有依赖它的地方重新执行。我用一个极度简化的例子来还原这个过程。首先准备一个effect函数它负责注册一个副作用比如渲染函数、计算属性等执行时会自动收集当前依赖然后实现track和trigger用 WeakMap 存储依赖关系。// 用一个全局变量临时存当前正在执行的 effect let activeEffect null; // 依赖存储结构target - key - effects 集合 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); } function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const dep depsMap.get(key); if (dep) { dep.forEach(effect effect()); } } function reactive(target) { return new Proxy(target, { get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { const result Reflect.set(target, key, value, receiver); trigger(target, key); return result; } }); } function effect(fn) { activeEffect fn; fn(); activeEffect null; } // 使用示例 const state reactive({ count: 0 }); effect(() { console.log(count 变化了, state.count); }); state.count 1; // 输出count 变化了1这个例子虽然只有几十行但它把 Vue 响应式的最小闭环完整呈现出来了读取时收集依赖修改时触发更新。我在项目中用过这个思路去排查一个很隐蔽的问题——某个表格数据没刷新最后发现是同事在组件外部用普通对象保存了一份数据引用脱离了响应式系统。理解了依赖收集机制以后你会很自然地意识到响应式数据必须通过 reactive 或 ref 创建拷贝或导出响应式对象时也要格外小心。2. 数组、对象和字符串Vue 项目里最常用的 JS 基本功2.1 数组方法 map、filter、reduce 的应用场景我在大多数 Vue 项目里真正每天大量接触的不是复杂算法而是数组的各种转换。列表数据从接口返回以后往往需要做一层加工才能直接渲染到模板上这时候map、filter、reduce就成了最顺手的工具。举一个我实际做过的订单管理页的例子。后端返回的订单列表里状态字段是一个数字枚举1 代表待支付、2 代表已支付、3 代表已取消而且订单金额单位是分。直接在模板里做判断会让模板非常臃肿我一般会用map在拿到数据后立刻格式化成视图模型const orders rawOrders.map(item ({ ...item, statusText: { 1: 待支付, 2: 已支付, 3: 已取消 }[item.status] || 未知, amountText: (item.amount / 100).toFixed(2) }));filter最常见的用法是配合搜索和筛选。比如一个商品列表用户在前端输入关键字我希望在前端做过滤而不用每次请求接口const filteredProducts allProducts.filter(product { const keyword searchText.value.trim().toLowerCase(); return product.name.toLowerCase().includes(keyword) || product.category.toLowerCase().includes(keyword); });reduce则适合做统计和分组。比如一个报表页面需要按分类汇总销售额用reduce一行就能搞定const salesByCategory salesList.reduce((acc, item) { acc[item.category] (acc[item.category] || 0) item.amount; return acc; }, {});这里还有一个容易被忽略的点这些数组方法都返回新数组不会修改原数组这对于 Vue 的响应式更新非常友好。因为 Vue 可以检测到引用变化从而精准地触发视图更新。如果你习惯在业务代码里用push、splice修改原数组也不是不行但让数据不可变immutable的思维能帮你避免很多难以定位的响应式更新 bug。2.2 解构赋值与展开运算符的妙用解构赋值是我在 Vue 项目里使用频率最高、但又最容易写花哨的一个特性。最基础也最实用的是从接口返回值里取字段const { code, data, message } response;但真正让代码变优雅的场景通常是组件传参和状态管理里。比如一个表单配置项较多我会把 props 通过展开运算符透传给子组件template BaseSelect v-modelform.city v-bindselectConfig / /template这里的v-bindselectConfig实际上就是在 JSX 层面的展开运算符把selectConfig对象里的所有键值对铺平成一个个 prop。反过来在组件内部接收 props 时也可以把特定的参数解构出来const props defineProps({ visible: Boolean, title: String, data: Object });需要提醒的是解构赋值配合函数默认值能省掉很多冗余的空值判断。比如从userInfo里取用户昵称和头像const { nickname 未登录用户, avatar defaultAvatar } userInfo;这种写法在处理接口返回缺失字段时特别给力我几乎在每个项目里都会用。展开运算符还有一个高级用法就是用来合并对象并实现浅拷贝。比如在 Vue 3 中更新响应式对象时经常可以这样写form.value { ...form.value, ...newData };这种模式比一个个字段赋值更清晰也更能体现 JS 函数式风格。但注意它只能做浅拷贝如果对象里嵌套着对象内部引用仍然是同一个这点在接下来深拷贝部分我会专门展开。2.3 字符串包含判断、URL 校验等实用操作字符串操作在业务中占的比重也不小。我在需求里经常遇到判断某个字符串是否包含某个关键字这种场景。过去大家习惯用indexOf现在用 ES6 的includes会直观很多if (fileName.includes(.m3u8)) { // 按 HLS 流处理 }另外在实现搜索筛选、路由权限判断时startsWith和endsWith也很常用if (route.path.startsWith(/admin)) { // 进入管理后台逻辑 } if (fileUrl.endsWith(.pdf)) { // 预览 PDF }还有一个高频需求是校验 URL 是否有效。网上有人直接写一大段正则但我在项目里其实更喜欢用new URL()的方式去做既简单又可靠function isValidUrl(str) { try { new URL(str); return true; } catch { return false; } }这种方式能处理绝大多数协议、域名、端口等格式问题比手写正则稳定得多。讲真正则表达式虽然强大但在不需要精确定位复杂结构时用内置 API 能省掉好多麻烦。说到省市区三级联动这也是很多后台管理系统必备的功能。通常我们会引入一份省市区编码数据文件JS 层面就是按层级筛选const provinces regionData.filter(item item.level 1); const cities regionData.filter(item item.level 2 item.parentCode selectedProvince); const districts regionData.filter(item item.level 3 item.parentCode selectedCity);这种用 flat 数组存储、通过 filter 按父级筛选的思路比嵌套树结构在更新和查找时更灵活推荐大家在类似场景里使用。3. 异步编程Event Loop、Promise 与 async/await3.1 异步请求的状态管理Vue 项目里逃不开接口请求。最基本的模式是页面加载时请求数据加载完成前显示 loading失败时提示错误。用 async/await 实现这套逻辑非常自然const loading ref(false); const list ref([]); const error ref(); async function fetchList() { loading.value true; error.value ; try { const { data } await request.get(/api/list); list.value data; } catch (e) { error.value e.message || 请求失败; } finally { loading.value false; } }这里try/catch/finally三个块的分工是很多初学者容易搞混的try 里放主流程catch 捕获异常finally 无论成功失败都会执行很适合用来复位 loading 状态。在多个接口并行请求的场景下Promise.all也是常客比如同时拉取用户信息和菜单权限const [userInfo, menuList] await Promise.all([ request.get(/api/user/info), request.get(/api/user/menus) ]);项目中还有一种容易踩坑的情况就是Promise.all里某个请求失败会导致整个 Promise reject后续逻辑全部中断。如果业务上希望部分成功也继续处理可以给每个请求单独做 catch或者用Promise.allSettled。我在一个数据大屏项目里就遇到过类似问题其中一个数据源偶尔超时结果整个大屏都白屏了后来改用allSettled并对未成功的数据显示占位值问题立刻解决。3.2 Event Loop 与 nextTick很多 Vue 开发者会好奇为什么在修改响应式数据后立刻读取 DOM 拿不到最新值必须用nextTick。这个问题的根源就是 JavaScript 的事件循环机制。Vue 的响应式更新是异步批量执行的。当你修改了响应式数据Vue 并不会立刻同步地更新 DOM而是把本次更新的回调任务丢到微任务队列里等当前同步代码执行完后再统一处理。换句话说数据变了但 DOM 还在等微任务队列被清空的那一刻。事件循环的核心概念我用一句话总结JavaScript 主线程先执行同步代码遇到微任务Promise.then、MutationObserver、Vue 的 nextTick 回调等会放进微任务队列遇到宏任务setTimeout、setInterval、I/O 等会放进宏任务队列当前同步代码执行完毕浏览器会清空微任务队列然后取一个宏任务执行再清空微任务循环往复。来看一段我用来说明 Vue 更新时机的代码const count ref(0); async function update() { count.value 1; console.log(同步读 DOM, document.querySelector(#num).textContent); await nextTick(); console.log(nextTick 后读 DOM, document.querySelector(#num).textContent); }结果是第一次打印的是旧值第二次打印的是新值。原因是第一次打印时DOM 更新任务还在微任务队列里没执行。理解了这一点你就能明白为什么有时候拿到的 DOM 不是最新状态以及用nextTick或者把它转成 Promise 用await就能解决。超时任务和微任务的先后顺序在面试里几乎是必考题。我常给朋友举的例子是console.log(script start); setTimeout(() console.log(timeout)); Promise.resolve().then(() console.log(promise)); console.log(script end); // 输出顺序script start - script end - promise - timeout原因是 Promise 回调是微任务setTimeout 是宏任务微任务永远比宏任务先执行。3.3 异步错误处理与竞态控制异步代码里还有一个高频坑位竞态条件。典型场景是搜索框输入关键字后请求接口由于用户输入速度快网络响应顺序可能和发送顺序不一致后发出的请求先返回导致页面显示的数据不是最终关键字对应的结果。解决办法很多我常用的是用一个递增的序号来忽略过期响应let requestId 0; async function search(keyword) { const id requestId; try { const { data } await request.get(/api/search, { params: { keyword } }); if (id requestId) { result.value data; } } catch (e) { if (id requestId) { error.value e.message; } } }这种递增序号 判断是否是最后一次请求的模式我在多个项目中验证过非常可靠。另外如果用的是 axios还可以利用AbortController取消过期请求从根源上减少无效请求对服务器和浏览器资源的浪费。4. 组件通信、路由与状态管理的 JS 化思维4.1 provide/inject 与透传Vue 3 里provide和inject是跨层级组件通信的重要方式尤其适合多个嵌套子组件需要共享同一份数据的情况。官方文档把它称为依赖注入但在真实项目里我更习惯把它当作一种JS 对象透传来看待。比如一个复杂的表单容器组件它内部有多层子组件需要拿到同一个校验实例。用 props 一层层传递不仅繁琐而且中间层级如果不需要这些数据代码会非常别扭。用 provide/inject 就干净很多// 父组件 provide(formContext, { form, rules, validate }); // 任意层级子组件 const formContext inject(formContext);需要注意的一点是如果你希望注入的数据是响应式的provide 时最好传入 ref 或 reactive 对象。我见过不少同事直接 provide 一个普通对象然后在子组件里改了数据父组件毫无反应就是因为数据没有经过响应式包装。与透传fallthrough attributes概念相关的还有组件上声明了inheritAttrs: false之后$attrs里可以拿到父组件传来的所有未被 props 声明的属性。这个机制对封装第三方 UI 组件库时特别有用我只需要在自定义组件里声明业务相关的 props剩下 attrs比如 class、style、事件监听器会自动落到内部的某个元素上完全不需要手动一个个转发省了很多样板代码。4.2 路由参数的 JS 处理路由参数是 Vue 项目里绕不开的主题。拿 Vue Router 4 Composition API 来说我们常用useRoute和useRouterconst route useRoute(); const router useRouter(); // 读取参数 const id route.params.id; const type route.query.type; // 跳转 router.push({ name: detail, params: { id: 123 }, query: { from: list } });这里容易踩坑的是 params 和 query 的丢弃行为。params如果传的是对象、数组等复杂结构刷新页面后会丢失因为 URL 无法完整表示这些值。所以我在项目里的原则是能进 query 的进 query必须用 params 的长参数就手动做一次序列化比如用encodeURIComponent(JSON.stringify(...))然后在详情页解码。还有一个高频需求是监听路由变化来做页面初始化。比如同一个组件在多个路由下复用切换参数时组件实例不会重建此时需要用watch监听route.paramswatch( () route.params.id, (newId, oldId) { if (newId ! oldId) { fetchDetail(newId); } } );如果不这样处理组件复用时数据很可能会残留上一个页面的内容这种 bug 在列表跳详情再返回的场景里非常常见很多人排查了很久才发现是路由参数变了但组件没有刷新。4.3 状态管理中常用的 JS 模式不管是 Vuex 还是 Pinia底层的很多东西都是 JS 对象操作。比如 Vuex 里的mapState、mapGetters本质上是帮你把 store 里的某个状态解构到组件的计算属性里和对象解构的思想一脉相承。Pinia 的defineStore在 Composition API 下标配一套 setup 风格写法之后状态就是一个ref或reactive对象调用 actions 就是调用普通 JS 函数。我自己的开发偏好是尽量把纯状态逻辑从组件里抽出来放到 store 或者单独的 JS 模块中。比如一个复杂筛选页筛选条件、当前页码、总条数、加载状态、列表数据这些都应该放进 store而组件只负责调用 actions 和渲染。这样拆分以后组件代码会薄很多测试和维护也都更容易。另外状态管理的命名规范也很重要。我习惯用动词短语表示 actionfetchUserList、submitOrder用名词短语表示 stateuserList、orderDetail看起来普通但大型项目里保持一致性能极大降低沟通成本。5. 工具函数与第三方库集成实践5.1 防抖与节流Vue 项目里与用户高频交互的场景搜索框输入、窗口大小调整、滚动加载几乎都离不开防抖和节流。两者看似相似其实侧重点不同防抖debounce触发后延迟执行如果延迟期间又被触发则重新计时。适合搜索输入、表单校验。节流throttle固定时间间隔内只执行一次。适合滚动监听、拖拽事件。我在项目里通常直接用 lodash 的debounce和throttle但如果不想引依赖手写也没有多难function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; } function throttle(fn, interval 200) { let lastTime 0; return function(...args) { const now Date.now(); if (now - lastTime interval) { lastTime now; fn.apply(this, args); } }; }注意在 Vue 组件里使用防抖节流函数时尽量不要在模板里直接每次都生成一个新函数比如这样写inputdebounce(fn, 300)每次渲染都会创建新的 debounce 实例防抖效果会失效。正确做法是在 setup 中提前把 debounce 后的函数保存下来const handleSearch debounce((keyword) { search(keyword); }, 300);5.2 深拷贝与浅拷贝深拷贝在业务中出现的频率也很高。最常见的是在编辑弹窗中为了避免直接改动父组件传入的对象会先把 props 里的数据拷贝一份再做编辑。浅拷贝只拷贝一层内部嵌套对象引用不变深拷贝会递归拷贝所有层级。现在的浏览器普遍支持structuredClone可以一行实现深拷贝而且原生性能也不错const copy structuredClone(originalObj);但在 Vue 项目里使用structuredClone要特别小心它无法拷贝函数和 Proxy 对象。而 Vue 的响应式对象内部就是 Proxy直接 structuredClone 一个 reactive 对象很可能会报错或者得到不可预期的结果。稳妥的做法是先把响应式对象通过toRaw取出原始对象或者用JSON.parse(JSON.stringify())在函数和特殊类型不影响的情况下做深拷贝const copy JSON.parse(JSON.stringify(toRaw(formObj)));当然JSON.parse(JSON.stringify())也有它的硬伤会丢失undefined、函数、Date会被转成字符串、循环引用会直接爆炸。所以在做深拷贝前先想清楚这份数据里会不会有这些特殊类型再决定用哪个方案。5.3 复制粘贴、M3U8 播放与地图加载这几个关键词看起来分散但它们都是真实项目里最常见的第三方集成场景。先说复制粘贴。现代浏览器推荐用 Clipboard APIasync function copyText(text) { try { await navigator.clipboard.writeText(text); // 提示复制成功 } catch (e) { // 降级方案创建隐藏 textarea 执行 execCommand const textarea document.createElement(textarea); textarea.value text; textarea.style.position fixed; textarea.style.opacity 0; document.body.appendChild(textarea); textarea.select(); document.execCommand(copy); document.body.removeChild(textarea); } }Clipboard API 在 HTTPS 环境下才能使用且需要用户授权所以在不支持的环境里降级到document.execCommand(copy)是常规操作。我通常还会在点击按钮时先判断navigator.clipboard是否存在。再说 M3U8 视频播放。M3U8 是 HLS 协议的索引文件格式浏览器原生不支持播放需要借助 hls.js 或 video.js 来解析。在 Vue 3 里集成 hls.js最关键的是生命周期管理创建播放器实例后必须在组件卸载时销毁它否则会造成内存泄漏和重复事件绑定。import Hls from hls.js; const videoRef ref(null); let hls null; onMounted(() { const video videoRef.value; if (Hls.isSupported()) { hls new Hls(); hls.loadSource(url); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () video.play()); } }); onBeforeUnmount(() { if (hls) { hls.destroy(); hls null; } });地图加载也是常见需求。以腾讯地图为例通常是先加载 JS SDK然后在onMounted里初始化地图实例。加载 SDK 的方式很多我最喜欢的是在index.html里通过 script 标签引入并且用全局回调函数包装避免在组件里重复判断脚本是否加载完成。地图实例同样需要在组件卸载时清除否则页面切换多次后会变得卡顿。关于省市区三级联动数据如果用 JS 数组存全量数据文件体积可能会不小建议在首次使用或按需加载。项目中如果引入了地图 SDK也可以顺便用地图的行政区划接口来获取省市区数据省去维护数据文件的麻烦。当然这种方式需要网络请求离线场景下还是本地 JSON 数据更可靠。6. 常见问题排查与工程技巧6.1 打包后布局异常Vue 项目本地开发一切正常但npm run build部署上线后布局乱七八糟这种情况我遇到太多次了。原因通常不是 CSS 写错了而是静态资源路径和 CSS 引用路径的问题。最常见的配置是把publicPath改成相对路径// vue.config.js 或 vite.config.js module.exports { publicPath: ./ };这样打包后的 CSS、JS、图片资源都会引用相对路径部署到任意子目录下就不会 404。但要注意改成相对路径后如果使用了 vue-router 的 history 模式路由会出现问题因为 history 模式依赖服务端对所有路径做重定向。这种情况要么把 publicPath 改回绝对路径要么把路由模式切换成 hash 模式需要按照实际部署环境来权衡。还有一种布局异常是 CSS 样式被覆盖或者没有生效。尤其在组件库和自定义样式混用时编译后的样式顺序会影响优先级。排查时先打开浏览器的开发者工具看元素的计算样式确认是哪条规则被覆盖了再决定是否要提升选择器优先级或者用 scoped 样式。6.2 事件监听泄露与生命周期处理在组件里直接给window、document等全局对象添加事件监听最容易出现内存泄漏。比如鼠标位置监听、键盘快捷键监听如果不在组件卸载时移除组件销毁后监听器仍然存留会造成性能下降和不预期的交互。正确的做法是在onMounted里添加在onBeforeUnmount里移除function handleKeydown(e) { if (e.key Escape) { closeModal(); } } onMounted(() { window.addEventListener(keydown, handleKeydown); }); onBeforeUnmount(() { window.removeEventListener(keydown, handleKeydown); });这里有个容易忽视的点removeEventListener的第二个参数必须和addEventListener传入的是同一个函数引用。如果你在onMounted里直接用箭头函数那么移除时没办法拿到同一个引用就移除不掉了。所以提前把函数提取出来是一种更规范的做法。另外一个生命周期相关的常见坑是异步请求返回时组件已经卸载。比如用户在请求未完成时切换了页面此时如果再用请求结果去更新响应式数据Vue 会在开发环境给出警告而且可能造成不必要的分派更新。通常我会用一个isActive标记在卸载前把它置为 falselet isActive true; onBeforeUnmount(() { isActive false; }); // 请求回调里 if (isActive) { result.value data; }6.3 视频播放组件卸载后仍在播放的声音问题之前做一个直播模块时遇到过一个问题页面跳转了直播的视频画面没了但声音还在响。排查后发现是 video 元素没有在组件卸载时暂停hls.js 的播放流也没有销毁。解决方案就是在onBeforeUnmount里主动video.pause()并销毁播放器实例。这种问题在 SPA 单页应用里特别隐晦因为页面切换后 DOM 虽然被移除了但浏览器还在后台持有媒体资源。如果项目里还有音频播放、WebRTC 视频流同样需要在卸载时主动停止。养成一个习惯组件里凡是占据了外部资源的定时器、事件监听、播放器、地图实例、网络连接都要在卸载阶段释放这样你的应用才经得起长时间运行。7. 面试与进阶Vue 中 JS 的深水区7.1 Vue 和 React 的区别从 JS 角度理解Vue 和 React 的对比是面试高频题也是团队选型时总会聊到的话题。从 JS 底层角度看我最喜欢从响应式 vs 不可变数据这个切入点来解释。Vue 的响应式系统是拦截对象读写 依赖收集。你用 reactive 或 ref 定义数据框架帮你在读写时收集依赖、触发更新。这种模型对开发者很友好因为代码写起来像普通对象一样自然模板里变量一变视图就自动更新。React 则是显式更新 不可变数据。它没有 Proxy 拦截这套逻辑组件状态更新时必须通过setState传入新值而且强调传入的是新的对象引用不能再原来的对象上修改。这样 React 在比较数据是否变化时只需要比较引用即可渲染流程更容易预测。从代码写法来看Vue 用模板语法把 HTML、CSS、JS 尽量拆开React 用 JSX 把结构和逻辑混在同一个函数里。两者没有绝对的好坏更多是团队习惯和项目场景的选择。我在大项目里两个都用过体感是 Vue 上手更快、模板直观React 生态更自由、组合模式更灵活。7.2 手写一个包含 reactive、ref、effect、computed 的 mini 响应式系统这一节是很多 Vue 面试题的核心也是理解 Vue 源码最好的练习。我们在第 1 节已经实现了一个简版 reactive 和 effect现在把它扩展成包含 ref 和 computed 的完整示例。核心思路是ref内部其实也用 reactive只是把基本类型包装在一个{ value }结构里computed则是一个特殊的 effect它会缓存计算结果只有依赖变化时才重新计算。class RefImpl { constructor(value) { this._value value; } get value() { track(this, value); return this._value; } set value(newValue) { if (newValue ! this._value) { this._value newValue; trigger(this, value); } } } function ref(value) { return new RefImpl(value); } function computed(getter) { let cachedValue; let dirty true; const runner effect(getter, { lazy: true, scheduler() { dirty true; trigger(obj, value); } }); const obj { get value() { if (dirty) { cachedValue runner(); dirty false; } track(obj, value); return cachedValue; } }; return obj; }当然这仍然是一个简化版本Vue 源码里还处理了嵌套对象、数组、Set、Map 等复杂数据结构以及Reflect的 receiver 传递、清理依赖等边界情况。但如果你能把这个 mini 版本彻底理解面试官问的手写 reactive、ref、effect、computed基本就有了完整的骨架。我建议你在本地把这段代码运行一遍打印一下依赖收集和触发更新的日志直观感受一下什么时候触发track什么时候触发trigger。这个过程比背任何八股都更有价值。7.3 进阶学习路径建议如果 Vue 面试中这部分是硬骨头说明 JS 基础大概率还差一口气。我的建议是先不要急着追框架特性把 JavaScript 核心机制补起来。具体路径大概是第一把this指向、闭包、作用域链这些基础概念重新梳理一遍这是理解一切高级写法的前提。第二彻底吃透 Promise 和 async/await尤其是微任务队列和事件循环的配合Vue 的 nextTick、路由和状态管理背后都在用这套机制。第三练习手写常见的工具函数深拷贝、防抖节流、发布订阅、事件总线等这些能帮你建立函数式思维。第四阅读 Vue 3 源码中vue/reactivity模块不要从头读到尾而是带着问题去读比如为什么要用 WeakMap 而不是 Map、为什么 Ref 需要 .value 访问这样收获最大。从工程能力来说我还建议大家平时多关注项目里的真实问题。比如我在文章里提到的 M3U8 播放器销毁、路由参数监听、异步竞态处理这些问题拆开看都不复杂但组合在一起就非常考验 JS 底子和架构思维。多写、多排查、多总结比任何教程都有效。说到底Vue 是一个工具JavaScript 才是你真正用工具做事的双手。把双手练结实了不管框架怎么变你都有底气快速上手。希望这篇文章能帮你在 Vue 项目里把 JS 用得越来越顺手。