ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue双向绑定与响应式系统:从v-model到reactive/computed原理

Vue双向绑定与响应式系统:从v-model到reactive/computed原理 聊到 Vue 的双向绑定十个新手里有九个第一反应是 v-model。但这概念一旦往深了问比如依赖收集是怎么触发的、为什么 Vue 2 改数组下标不生效、computed 为什么能缓存很多人立刻就卡壳。原因其实不复杂v-model 只是语法糖真正支撑数据驱动视图的是一整套“读取时收集依赖、写入时触发更新”的响应式机制。这篇文章我想把这条链路掰开揉碎从最早的发布订阅模型讲起手写一个足够上路的 reactive、ref、effect、computed 迷你实现再回头对照 Vue 2 与 Vue 3 的实现差异。适合三类人看准备啃源码但被各种模块绕晕的面前端被连环追问响应式原理的以及写过不少 Vue 但遇到性能问题只能靠“把 reactive 换成 ref”碰运气的。先交代一句下面的代码不是 Vue 源码而是为了讲清楚“最小可用原理”写出来的教学版。理解了这个骨架再去看 Vue 源码里的 effect.ts、reactive.ts会觉得每个函数都眼熟。1. 先搞清楚双向绑定到底绑的是什么1.1 双向绑定的完整链路拆解“双向”这个说法很容易误导人好像有一条魔法管道让数据和视图互通。实际上 Vue 里不存在什么“双向魔法”它本质上是两条单向数据流的组合数据变化 - 视图更新这是响应式系统的职责。数据变成了响应式对象被渲染函数读取时建立依赖被修改时触发视图重新渲染。视图交互 - 数据变化这是事件绑定的职责。用户在 input 里输入文字触发 input 事件事件处理器把新值写回数据。两条单向流拼在一起从使用者的视角看就是“改数据页面变改页面数据变”所以叫双向绑定。我经常用一个 Excel 公式的类比去解释这条链路你在 A1 单元格输入 100B1 单元格写的是A1*2改 A1B1 会自动变。Excel 底层做的事情是B1 公式执行时记住了“我引用了 A1”A1 变化时会去通知所有引用它的单元格重新计算。Vue 的响应式系统和这个模型几乎一模一样只不过把单元格换成了 DOM把公式换成了渲染函数。所以当你面试被问“双向绑定原理”时不要只答 v-model要把这条链路说出来数据劫持、依赖收集、派发更新、视图重渲染。v-model 只是其中很小的一段。1.2 为什么 v-model 不等于双向绑定机制v-model 是 Vue 给开发者提供的一个语法糖。以原生 input 为例下面这两段代码是等价的input v-modelusername /input :valueusername inputusername $event.target.value /看出来了吗v-model 做的事情就是“绑 value 监听 input 事件再写回数据”。也就是说即使根本没有响应式系统单靠手动事件监听也能实现“双向绑定”。jQuery 时代很多人不也这么干么val() 读值on(input) 赋值。那响应式系统的价值在哪在于第一段:当username被其他地方修改时input 的 value 会自动跟着变。这才是真正的数据驱动。所以严谨地说v-model 解决的是“视图到数据”那半条链路而“数据到视图”那条路是依赖收集和派发更新来铺的。另外注意Vue 3 里 v-model 在自定义组件上的等价形式也变了。Vue 2 是:valueinputVue 3 改成了:modelValueupdate:modelValue并且支持一个组件上写多个 v-model比如v-model:title、v-model:content。核心思想不变只是名字更语义化。后面我会专门讲组件上的 v-model。2. 依赖收集与派发更新响应式系统的引擎2.1 把响应式理解成“订阅登记”为什么 Vue 能在数据变化时精准地只更新用到了这个数据的组件靠的就是依赖收集。我一直觉得搞懂 Vue 响应式最关键的一步不是看 Proxy 怎么劫持而是先搞懂“谁在依赖谁”这套模型。把响应式数据想象成一家外卖店把组件渲染函数想象成顾客。顾客第一次点餐时店里会记下他的电话track依赖收集。以后店里有新品到货数据变更店员挨个打电话通知“你关注的菜可以点啦”trigger派发更新。到代码层面这个“电话本”就是依赖表。每个响应式属性都维护一个依赖集合集合里存的是“谁在用我”。这个“谁”在 Vue 3 里叫effect副作用函数渲染函数、computed、watch 的回调都是 effect在 Vue 2 里叫Watcher。整个链路可以浓缩成四个动作读取属性把当前的 effect 登记到属性的依赖列表里。修改属性取出这个属性的依赖列表挨个重新执行。重新执行 effecteffect 内部会再次读取属性再次登记形成闭环。视图更新渲染 effect 重新执行 组件重新渲染。我最初看源码时犯过一个错误以为依赖是渲染函数“绑定”上去的。其实不是依赖是在 render 函数执行过程中因为访问了响应式属性被 getter 拦截后动态挂上去的。换句话说渲染函数用到了哪些数据就自动登记哪些数据没用到的数据改了也不会触发重渲染。2.2 一次渲染如何建立依赖关系把模板编译成 render 函数之后组件渲染的第一步是执行 render 函数。我们用一个简化的渲染函数来模拟function render() { // 假设模板是 div{{ count }} {{ name }}/div div.textContent count: ${state.count}, name: ${state.name} }render 执行时访问了state.count和state.name这两个属性的 get 拦截器就会把“当前正在运行的 effect也就是 render 函数”记到它俩各自的依赖列表里。以后执行state.countcount 的 set 拦截器跑一遍依赖列表发现有一个 effect 在用自己就把这个 effect 扔进调度队列浏览器空闲时重新执行 render。render 一跑又从 get 拦截器走一遍依赖关系被重新确认。这个过程每天都发生在每一个 Vue 应用里肉眼看不见但 DevTools 的 Performance 面板能录到。这里有个很值得细品的细节依赖关系是运行时动态建立的。如果模板里写了 v-if那么条件为 false 时被隐藏分支里的数据根本不会被读取依赖也就不会挂上。等切回 true分支里的数据重新被读取依赖关系才建立。这就是为什么有人会遇到“明明数据变了视图却不刷新”多半是数据在条件分支之外或者压根没被 render 函数访问到。3. Vue 2 到 Vue 3从 Object.defineProperty 到 Proxy 的进化3.1 Vue 2 响应式方案的历史包袱Vue 2 的响应式核心是Object.defineProperty给对象的每个属性单独设置 getter 和 setter。这套方案在 2013 年那个时代是很聪明的但它的限制也清清楚楚写在设计里只能劫持已经存在的属性。对象新增属性时没有 getter/setter直接就不是响应式的。删除属性同样无法感知。数组的多数操作无法被 defineProperty 拦截。直接arr[0] xxx、arr.length 0都没反应。于是 Vue 2 提供了一堆“补救 API”Vue.set(obj, key, value)和Vue.delete(obj, key)就是手动给新属性加上响应式数组方法push、pop、shift、unshift、splice、sort、reverse被整体重写内部先执行原生方法再手动触发依赖更新。这些补丁能干但容易忘。尤其是从后端接口拿数据后直接this.obj.newField x页面纹丝不动排查半天才反应过来要 set。我踩过这个坑不止一次后来干脆养成了习惯Vue 2 项目里所有动态字段一律先声明的默认值或者统一走Vue.set。至于为什么 Vue 2 不把每个数组索引都用 defineProperty 包一遍官方当年的解释是性能和收益不成正比。对一个一万项的数组来说给每一项都加 getter/setter初始化开销是肉眼可见的卡。3.2 Proxy 解决了哪些历史问题Vue 3 把响应式核心换成了 Proxy这不是换了个 API 那么简单是整个“劫持模型”变了。Object.defineProperty是给对象内部的某个 key 打补丁所以新增 key 它管不了Proxy是直接在对象外面套了一个拦截层你对这个对象的任何操作都要先经过代理的拦截器不管这个 key 是原本就有的还是后来新增的。用 Proxy 之后之前的所有坑基本消失新增属性、删除属性都能被set和deleteProperty拦截到不再需要Vue.set。in操作符、for...in遍历可以被has、ownKeys拦截。数组索引赋值、修改 length都能被 set 拦截到原生数组也不需要重写方法了。代码层面的差别是这样的。Vue 2 劫持一个对象等于给每个房间装监控// Vue 2 思路简化遍历 key逐个 defineProperty Object.keys(obj).forEach((key) { let value obj[key] Object.defineProperty(obj, key, { get() { // 收集依赖 return value }, set(newValue) { value newValue // 触发更新 }, }) })Vue 3 思路简化在房子门口装一道安检闸机// Vue 3 思路简化 const proxy new Proxy(target, { get(target, key, receiver) { // 收集依赖支持任意 key return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { // 新 key 老 key 一视同仁 const result Reflect.set(target, key, value, receiver) // 触发更新 return result }, })但 Proxy 也不是银弹。它代理的是整个对象深度还是需要递归。Vue 3 的reactive在 get 拦截器里做了惰性代理只有当某个属性被访问且值是对象时才对这个子对象递归包一层 Proxy。这样初始化时不用深度遍历整棵对象树首次性能反而比 Vue 2 好很多。很多人不知道Proxy 的receiver参数是有说法的它指向实际调用代理方法的对象。在处理原型链和 getter 里的this时有坑Vue 源码里就有针对 receiver 的toRaw处理。这种细节我们写业务代码基本碰不到但面试官会拿它区分“看过文档”和“读过源码”。4. 手写迷你响应式系统reactive、ref、effect、computed4.1 先搭最底层的 track 和 trigger我不建议一上来就贴完整源码那样容易劝退。咱们先写最小的依赖收集模型再一步步加功能。先准备一个“依赖存储桶”。Vue 3 用三层结构targetMapWeakMap-depsMapMap-depSet。为什么要三层因为我们需要存“哪个对象的哪个属性被哪些 effect 依赖”。对象是一层key 是一层effect 集合是一层。const targetMap new WeakMap() let activeEffect null const effectStack []这里有两个关键变量activeEffect表示当前正在运行的 effecteffectStack是为了处理嵌套 effect。如果你只用一个全局变量嵌套场景下内层 effect 执行完外层 effect 就找不回来了依赖会挂错地方。这是初学者手写响应式最容易漏的细节。track 的职责就是“登记依赖”function track(target, key) { if (!activeEffect) return // 拿到 target 对应的 depsMap let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } // 拿到 key 对应的 dep 集合 let dep depsMap.get(key) if (!dep) { dep new Set() depsMap.set(key, dep) } // 把当前 effect 加进去 dep.add(activeEffect) }trigger 的职责是“派发更新”function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (!dep) return // 把依赖这个 key 的 effect 挨个重新执行 dep.forEach((effect) { if (effect.scheduler) { effect.scheduler() } else { effect() } }) }scheduler是 Vue 3 里很重要的一环。默认情况下 trigger 是同步执行 effect 的但如果每次都立即执行一个事件里改了 10 次数据渲染函数就要重跑 10 次。Vue 把 effect 传给调度器调度器把 effect 放进微任务队列同一轮里多个触发合并成一次执行这就是异步批量更新的雏形。WeakMap的选择也是刻意的。如果这里用普通 Map 保存 target 的依赖即使业务代码里已经把对象释放了Map 还强引用着它垃圾回收永远不回收它内存就泄漏了。WeakMap 的 key 是弱引用target 失去引用后整条依赖链跟着被回收。4.2 手写 reactive 和 ref有了 track 和 triggerreactive 就是一个 Proxy 壳子function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const res Reflect.get(target, key, receiver) // 读取时收集依赖 track(target, key) // 惰性递归如果取出来的还是对象继续包一层 if (res ! null typeof res object) { return reactive(res) } return res }, set(target, key, value, receiver) { const oldValue target[key] const res Reflect.set(target, key, value, receiver) if (oldValue ! value) { // 写入时派发更新 trigger(target, key) } return res }, deleteProperty(target, key) { const res Reflect.deleteProperty(target, key) // 删除属性同样要触发更新 trigger(target, key) return res }, }) }注意 set 里为什么要比较旧值因为响应式系统最忌讳“无意义的重复渲染”。如果赋的是同一个值说明业务层面没有任何变化触发更新只会白白浪费一次渲染。Vue 3 源码里对 NaN 这种特殊情况还有额外判断但核心思想就是“变了才通知”。ref 的底层其实比 reactive 还简单。它把一个值包装成{ value: 任意值 }然后对这个包装对象走响应式function ref(value) { 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) } } } return new RefImpl(value) }所以 Vue 3 模板里ref明明是个值类型却不用ref.value.xxx因为模板编译时自动帮你解包了。而在 JS 逻辑里必须写.value也是因为本质是一个属性叫 value 的对象。4.3 effect 和 computed 的实现要义effect 是响应式系统的执行单元。它的作用是执行传入的函数执行过程中如果访问了响应式属性就把自己登记为依赖。function effect(fn, options {}) { const effectFn () { try { // 入栈 effectStack.push(effectFn) activeEffect effectFn // 执行函数收集依赖 return fn() } finally { // 出栈恢复外层 effect effectStack.pop() activeEffect effectStack[effectStack.length - 1] } } if (options.scheduler) { effectFn.scheduler options.scheduler } effectFn() return effectFn }这里effectStack是关键它不是可有可无的锦上添花。举一个场景一个 effect 内部调用了另一个 effectcomputed 就是这种情况内层 effect 执行时会把activeEffect换成自己执行完如果不恢复外层的依赖收集就全挂到内层 effect 上了整个依赖图就乱了。computed 的实现则是在 effect 基础上加一个“懒执行 缓存”的逻辑function computed(getter) { let value let dirty true // 用 effect 收集依赖 const runner effect(getter, { scheduler() { // 依赖变化时不立即重算只标记“脏了” dirty true // 通知外层依赖 computed 的地方 trigger(computedRef, value) }, }) const computedRef { get value() { // 只有 dirty 时才重新计算 if (dirty) { value runner() dirty false } // 读取 computed 时也要收集外层依赖 track(computedRef, value) return value }, } return computedRef }computed 这个实现细节是面试里很容易打出区分度的地方它为什么快不是因为它算得快而是它有一套 dirty 标志位。如果依赖的数据没变再读取 computed 时直接返回缓存值不重跑 getter只有依赖的数据变了scheduler 被触发dirty 变回 true下次读取才重算。这个“懒计算 脏标记”的模式在很多框架状态管理里都能看到影子。4.4 手写版的局限与 Vue 源码里真正的处理上面这个教程版能跑但离 Vue 3 真正源码还有不少距离。源码里至少还处理了这些边缘情况依赖清理。每次 effect 执行前会先清除与 dep 的关系执行后再重新收集避免分支切换时旧依赖一直挂着导致不必要的更新。嵌套对象代理的缓存。同一个对象重复reactive应该返回同一个代理实例而不是每访问一次包一个新代理否则对象身份丢失、无限递归。对Map、Set、WeakMap等集合类型的代理。Vue 3 对 Map 的size、get、set等方法有特殊处理因为 Proxy 无法直接拦截方法内部的属性读写。只读代理和浅层代理。readonly和shallowReactive在源码里是通过不同的 baseHandlers 实现的。如果读者是进了 Vue 源码之后绕晕的我建议优先读packages/reactivity/src/effect.ts它是整个响应式系统的心脏。等把 track 与 trigger 看懂了再回头看baseHandlers.ts里的createGetter和createSetter基本就顺了。5. v-model 不是双向绑定的全部模板层的语法糖机制5.1 v-model 在原生元素和组件上的翻译规则把 v-model 写在原生 input 上Vue 编译器会把它拆成:value和input。但具体拆法视元素类型而定文本框、多行文本value 属性 input 事件。复选框、单选框checked 属性 change 事件值可能不是字符串而是布尔值或数组。下拉选择框value 属性 change 事件。Vue 3 在自定义组件上的 v-model 规范是modelValueupdate:modelValue。子组件里这样写// 子组件 defineProps([modelValue]) const emit defineEmits([update:modelValue])模板里使用 props.modelValue 展示需要变化时 emit(update:modelValue, newValue)。Vue 3 还支持多个 v-model比如日期范围选择器里的开始时间和结束时间DateRangePicker v-model:startstartDate v-model:endendDate /等价于DateRangePicker :startstartDate update:startstartDate $event :endendDate update:endendDate $event /这个能力在 Vue 2 时代做不到当时如果要传两个双向绑定值只能用一个 v-model 加一个 .sync非常割裂。Vue 3 统一之后组件 API 的设计层面就顺畅多了。5.2 为什么不能直接修改 prop哪怕收到了 v-model这个问题我被问过太多次而且很多面试者答不全。组件收到 prop 后能不能直接props.modelValue 新值不能。抛开框架层面单从设计模式上说原因想一想就清楚了props.modelValue本质是父组件某个响应式数据的一份“快照”。如果子组件直接改 prop相当于在不知道父组件意图的情况下私自篡改了父组件的数据源。这个数据源可能同时被多个子组件引用其中一个改了其他几个显示的旧数据也得跟着变但其他子组件根本不会收到通知因为它们没有通过 emit 走那条正规链路。响应式系统的“单向数据流”原则就是为了避免这种不可控的分叉状态数据归属和修改行为必须明确。子组件不拥有这份数据只能通过事件向上申请修改修改权始终在数据所有者手里。所以 v-model 的完整理解应该是父组件通过 modelValue 把数据传给子组件子组件展示子组件要把数据改掉时通过 update:modelValue 事件把新值发回父组件父组件再更新自己的数据。整条数据流是一个干净的环路。5.3 v-model 与 .sync 的异同Vue 2 遗留问题Vue 2 里v-model和.sync一直是很像但又不完全一样的两套东西。面试里也经常被放在一起问。Vue 2 的 v-model 默认翻译成valueinput.sync默认翻译成组件名属性 update:组件名事件相当于“可传参版本的 v-model”。后者更灵活可以给任意 prop 做双向同步但只能有一个 key。Vue 3 把.sync的功能直接合并进 v-model 之后.sync被废弃了。现在 Vue 3 的 v-model 就是加强版既支持默认的 modelValue也支持带参数绑定任意 prop。如果项目里还有 Vue 2 老代码在维护遇到表单类组件比如弹窗、表格、筛选器我建议都统一走.sync因为语义更清晰。但新项目直接用 Vue 3 的多 v-model 就好不需要再纠结。6. 面试和线上问题双向绑定最常见的坑与排查6.1 爆栈现场effect 内部修改自己依赖的属性写自定义 effect 或者 watch 的时候一个经典死循环我已经见过无数人踩了effect(() { // 读取 count然后又修改 count state.count state.count 1 })执行流程是这样的effect 里读取 count把自己登记成依赖然后给 count 赋新值set 拦截器触发 trigger把这个 effect 重新执行重新执行时又读又写再触发 trigger……无限循环直到浏览器爆栈。没加调度器的情况下这几乎是秒炸。Vue 源码里对这种场景有防线吗有它是通过依赖清理和更新队列来避免同一个 effect 在同一轮循环里重复执行的。但我们自己写 watch 和自定义 hook 时一定要有这个意识回调函数里尽量避免修改自己监听的数据源。如果业务上确实需要至少要加个标志位或者节流控制。6.2 数组和新增属性旧时代的大坑新时代的注意点Vue 2 里两个高频问题我要再拎出来提醒一下曾经维护老项目的人直接array[0] x视图不更新因为索引不是响应式的。直接obj.newKey value视图不更新因为 defineProperty 根本没劫持过这个 key。Vue 3 的 Proxy 把这两个问题都解决了但新坑也随之而来如果你给一个响应式对象整体赋了一个包含深层嵌套的大对象内部依然可能经历一次较大的递归代理。数据量不大无所谓但如果是从接口拉回来的几十兆嵌套数据直接放进 reactive初始化时的代理过程也会有一定耗时。遇到这种情况可以评估一下该数据是否真的需要响应式不需要的话用markRaw或者shallowReactive把它们排除在响应式追踪之外。另外一个容易忽略的性能点是reactive 是深层代理而模板渲染通常只读取几层没必要深度追踪的第三方实例比如地图实例、富文本编辑器实例、播放器实例一旦被包进 reactive每次内部状态变化都可能触发 Vue 的依赖逻辑造成诡异的卡顿。我见过一个项目把视频播放器的实例塞进 reactive 里页面越来越卡后来用markRaw包了一下立刻恢复流畅。6.3 排查响应式失效的标准思路遇到“数据变了但视图没更新”的问题先不要急着怀疑框架有 bug按下面的顺序排查数据是不是真的变了console.log 在修改前后各打一次确认引用是否相同。修改的是不是同一个响应式对象如果是把整个对象重新赋值给 ref / reactive 容器要确认赋值方式是否正确。模板里依赖的是不是同一个属性注意作用域问题比如解构出来的变量已经失去了响应式追踪。是否在 effect / computed 之外修改如果数据在非响应式上下文里被改可能根本没触发依赖关系。依赖有没有被清理掉比如用了 v-if组件销毁重挂缓存依赖被清空了。响应式失效九成都是前三类问题。解构这个问题我多说一句const { value } refObj之后再改value是不会触发视图更新的因为解构出来的是普通变量的值拷贝不是代理对象。Vue 3 里要用storeToRefs或者始终refObj.value去操作。6.4 面试应答思路速查常见问题推荐回答要点双向绑定原理是什么分两段数据到视图靠响应式依赖收集视图到数据靠事件监听。v-model 只是语法糖依赖收集怎么工作读取时通过 getter 触发 track把当前 effect 记录到目标属性的依赖集合中派发更新怎么工作修改时通过 setter 触发 trigger取出依赖集合逐个重新执行 effect为什么 Vue 2 新增属性不响应Object.defineProperty 是在对象初始化时逐 key 劫持没有 key 就没有 getter/setterVue 3 Proxy 解决了什么支持新增属性、删除属性、数组索引、for...in 等完整对象语义的拦截computed 为什么能缓存dirty 标志位机制依赖没变时直接返回缓存值不重跑 getterv-model 在组件上怎么传值Vue 3 中是 modelValue 属性 update:modelValue 事件多个值可用 v-model:xxxeffect 与 watch 的区别effect 是响应式系统的基础执行单元watch 是内置在 effect 之上的封装且默认懒执行我发现面试时回答这些问题的关键不是背概念而是能画出“渲染函数读取属性 - get 拦截 - 收集 effect - 修改属性 - set 拦截 - 执行 effect - 重新渲染”这条完整链路。只要这个链路清晰任考官从哪个环节切入追问都不会被卡住。最后说一个我自己的习惯。遇到双向绑定相关的问题我从来不去死背文档而是先在控制台里把 effect 和 reactive 的小样例跑一遍看依赖实际挂在了哪个对象、哪个 key 上。你能把 track 和 trigger 的执行时机讲清楚说明你是真的懂了而不是背了一堆名词。如果你也在啃 Vue 源码建议从 effect.ts 开始而不是从 Vue 的入口文件开始因为 effect 是整个响应式系统的心脏后面看 computed、ref、watch、render 的源码全都是绕着这颗心脏转的。这是我在这个机制上踩坑近两年最想告诉你的一句话。
返回列表