Skip to content

Vue 响应式与双向绑定原理

作者:青见春山
发表于:2026-07-29
字数统计:6000 字
预计阅读21分钟

涵盖 Vue2 / Vue3 响应式原理、双向绑定机制、defineProperty 实现双向绑定 demo、以及 Vue3 用 Proxy 替代 defineProperty 的原因。原文件中的三处重复内容(响应式原理、双向绑定、为什么用 Proxy)合并到本文。

一、Vue2 响应式原理

Vue2 通过 Object.defineProperty 对 data 中的属性进行劫持,在属性的 getter 中进行依赖收集,在 setter 中进行依赖通知。每个属性都会对应一个 Dep 依赖管理器,用于存储依赖该数据的 watcher。当组件渲染时会生成 watcher,并在访问数据时触发 getter,将 watcher 收集到 Dep 中。当数据发生变化时触发 setter,Dep 会通知所有 watcher 执行更新,从而重新渲染视图。

二、Vue3 响应式原理

Vue3 使用 Proxy 对整个对象进行代理,通过拦截对象的 getsetdeleteProperty 等操作实现响应式。在 get 时进行依赖收集,在 set 时触发依赖更新,从而实现数据变化自动更新视图。相比 Vue2,Proxy 可以监听对象新增属性、删除属性以及数组变化,并且不需要递归遍历对象,性能更好。

三、双向数据绑定

标准面试回答(1 分钟)

Vue 的双向数据绑定是"数据劫持 + 发布订阅模式"的组合。核心机制分两层:数据层通过响应式系统监听数据变化,视图层通过指令(v-model)监听 DOM 事件,两者配合实现数据变化驱动视图、视图变化回写数据的双向同步。

Vue 2 版本:核心是 Object.defineProperty,在组件初始化时递归遍历 data 中的所有属性,用 defineProperty 劫持 getter 和 setter。每个属性对应一个 Dep 依赖收集器,getter 里收集用到这个数据的 Watcher(观察者),setter 里触发 Dep.notify,通知所有 Watcher 执行更新。视图这边,v-model 是语法糖,本质上是绑定了 value 属性和 input 事件——输入时触发事件更新 data,data 变化时通过响应式系统重新渲染视图。

Vue 3 版本:用 Proxy 替代了 defineProperty。Proxy 可以直接代理整个对象,不需要递归遍历,能监听到数组索引修改、对象新增属性等 defineProperty 做不到的操作,性能更好,也解决了 Vue 2 里 $set 这种 API 的痛点。

整体流程:初始化时建立响应式 → 编译模板时建立依赖关系 → 数据变化时派发更新 → 重新渲染 → 通过 v-model 监听用户输入回写数据

核心角色拆解

  • Observer(劫持):负责把 data 变成响应式
  • Dep(依赖收集):每个响应式属性对应一个,存储依赖它的 watcher
  • Watcher(观察者):订阅数据变化,触发更新(渲染 watcher、computed watcher、用户 watcher)
  • Compiler(编译):解析模板指令,把数据和 DOM 绑定

订阅者补充

订阅者是 Vue 中的一个概念,它是一个用于管理更新视图的对象。当数据发生变化时,Vue 会通知所有的订阅者进行更新。在 Vue 中,每一个挂载到视图上的组件,或者每一个 watcher,都可以被看成一个订阅者,他们订阅了某一个数据的变化,并等待数据发生变化时进行更新。订阅者是 Vue 实现双向数据绑定的关键组成部分,管理着数据和视图之间的关系,保证了数据的变化能够及时反应到视图上。

20 秒极简版

Vue 双向绑定是数据劫持加发布订阅模式。Vue 2 用 defineProperty 劫持 getter/setter,Vue 3 用 Proxy。v-model 本质是 value 绑定加 input 事件的语法糖,数据变化驱动视图,视图交互回写数据。

四、用 defineProperty 实现简单双向绑定

最经典的双向绑定 demo,使用 Object.defineProperty() 实现:

JavaScript
// 1. 创建一个数据对象
var Person = {}
var name = null

// 2. 使用 Object.defineProperty 劫持 name 属性的读写
Object.defineProperty(Person, 'name', {
    get: function () {
        return name  // 读取时返回私有变量
    },
    set: function (newV) {
        name = newV  // 设置时更新私有变量
        // 注意:这里可以触发视图更新,但代码中没有体现
    }
})

// 3. 监听输入框的 input 事件
let p = document.getElementById('ppp')
let ipt = document.getElementById('ipt')

ipt.addEventListener('input', function (e) {
    Person.name = e.target.value  // 视图变化 → 更新数据
    p.innerText = Person.name      // 数据变化 → 更新视图(手动触发)
})

核心机制:双向绑定的两个方向

  1. View → Model(视图到数据)

    JavaScript
    ipt.addEventListener('input', function (e) {
        Person.name = e.target.value  // 输入框变化,更新数据
    })
  2. Model → View(数据到视图)

    JavaScript
    set: function (newV) {
        name = newV
        // 当 Person.name 被赋值时,会触发 setter
        // 理想情况下,这里应该自动更新视图
        // 但当前代码需要手动更新
    }

原代码的问题与改进

原代码并未真正实现自动双向绑定,因为 setter 中缺少自动更新视图的代码:

JavaScript
// 改进版本:setter 中自动更新视图
var Person = {}
var name = null

Object.defineProperty(Person, 'name', {
    get: function () {
        return name
    },
    set: function (newV) {
        name = newV
        // 自动更新视图
        document.getElementById('ppp').innerText = newV
    }
})

let ipt = document.getElementById('ipt')
ipt.addEventListener('input', function (e) {
    Person.name = e.target.value  // 这里触发 setter,会自动更新 p 元素
})

完整手写实现

JavaScript
function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() {
            console.log(`读取 ${key}: ${val}`)
            return val
        },
        set(newVal) {
            if (newVal !== val) {
                console.log(`更新 ${key}: ${val} -> ${newVal}`)
                val = newVal
                // 触发视图更新
                updateView()
            }
        }
    })
}

// 使用
let data = {}
defineReactive(data, 'name', '张三')

// 绑定视图
let input = document.getElementById('input')
let span = document.getElementById('span')

input.addEventListener('input', (e) => {
    data.name = e.target.value  // 触发 setter
})

function updateView() {
    span.innerText = data.name  // 从 getter 获取最新值
}

五、defineProperty 的三大缺陷

  1. 无法监听数组索引修改和 length 变化:所以 Vue 2 对数组重写了 7 个变异方法(push、pop、shift、unshift、splice、sort、reverse)来 hack 实现
    JavaScript
    arr.push(1)  // 无法触发 setter
  2. 无法监听对象新增或删除属性:所以提供了 $set$delete API
    JavaScript
    delete Person.name  // 无法监听
    Person.age = 18     // 无法监听
  3. 初始化时需要递归遍历所有属性:性能开销大,嵌套对象越深开销越大

六、追问:高分手答

追问 1:v-model 在自定义组件上怎么工作?

在 Vue 3 中,自定义组件的 v-model 默认相当于绑定了 modelValue 属性,并监听 update:modelValue 事件。组件内部需要接收 props.modelValue,然后通过 emit('update:modelValue', newValue) 来触发更新。

如果需要绑定多个双向绑定的值,Vue 3 支持给 v-model 传参数,比如 v-model:title="pageTitle",组件内部就接收 title 属性,触发 update:title 事件。

Vue 2 稍有不同,需要通过 model 选项配置 prop 和 event,但原理一样。实际开发中封装表单组件、弹窗组件时经常用到。

追问 2:Watcher 和 Dep 的关系?

Dep 是每个响应式属性的"依赖收集器",相当于一个订阅中心。Watcher 是"观察者",比如一个组件对应一个渲染 Watcher,计算属性对应一个计算 Watcher。

关系是:当响应式属性被读取时(getter),会把当前的 Watcher 收集到它的 Dep 里;当属性被修改时(setter),Dep 会通知所有订阅的 Watcher 执行更新。

一对多的场景很常见——一个响应式属性可能被多个地方用到。比如 this.name 在模板里出现了三次,那这三个地方都属于同一个渲染 Watcher,所以 Dep 里只收集一个 Watcher。更典型的例子是:this.user.name 这个属性,可能被组件 A 的渲染 Watcher、组件 B 的渲染 Watcher、还有某个计算属性 Watcher 同时依赖,那这个属性的 Dep 里就维护了三个 Watcher,数据变化时三个地方都会更新。

追问 3:defineProperty 的缺陷和 Proxy 的改进?

主要有三个缺陷。第一,无法监听数组索引修改和 length 变化,所以 Vue 2 对数组重写了 7 个变异方法(push、pop 等)来 hack 实现。第二,无法监听对象新增或删除属性,所以提供了 $set$delete API。第三,初始化时需要递归遍历所有属性,性能开销大,嵌套对象越深开销越大。

Vue 3 的 Proxy 完美解决了这些:Proxy 代理整个对象,数组索引修改、length 变化、对象新增属性都能自动监听到;而且 Proxy 是懒代理,访问到嵌套对象时才递归代理,初始性能更好。另外 Proxy 支持 13 种拦截操作,比 defineProperty 灵活很多。

七、为什么 Vue3 用 Proxy 替代 defineProperty

核心结论

Vue 3 用 Proxy 替换 Object.defineProperty,核心原因是为了解决 defineProperty 在响应式系统中的三大先天缺陷:无法监听数组索引和 length 变化、无法监听对象属性的新增和删除、初始化时需要递归遍历所有属性导致性能问题。Proxy 通过代理整个对象,从根本上解决了这些问题,同时带来了更好的性能和更简洁的 API 设计。

监听能力对比

defineProperty 只能劫持对象的已有属性,对数组的索引修改(如 arr[0] = newValue)和 length 变化无法监听,所以 Vue 2 需要 hack 数组的 7 个变异方法(push、pop 等)来模拟响应式。对对象新增属性(obj.newProp = value)也监听不到,必须用 $set。Proxy 代理整个对象,所有操作(属性读取、赋值、删除、数组方法)都能拦截,不需要任何 hack。

初始化性能

defineProperty 需要在初始化时递归遍历对象的每一层属性,嵌套越深性能开销越大。Proxy 是懒代理——只在访问到某个嵌套对象时才进行代理,初始化性能更好。

语法简洁性

defineProperty 需要为每个属性单独定义 getter/setter,代码繁琐;Proxy 通过一个代理对象统一拦截所有操作,API 更清晰,还支持 13 种拦截操作(get、set、deleteProperty、has 等),扩展性更强。

数组的原生支持

Proxy 可以完美监听数组的所有操作,包括索引赋值、length 变化、push/pop 等原生方法,Vue 3 不再需要重写数组方法。

追问 1:Proxy 的懒代理具体是怎么实现的?

Vue 3 的响应式系统中,reactive 函数创建代理时,只会代理传入的根对象,不会递归代理嵌套对象。

当访问嵌套对象时(如 state.user.profile.name),会在 get 拦截器中判断:如果读取的值是对象,则对该对象调用 reactive 进行代理,然后返回代理后的对象。这就是"懒代理"——只有在真正访问到嵌套对象时才进行代理

对比 Vue 2 的 defineProperty:初始化 data 时,会递归遍历整个对象树,给每一层每个属性都加上 getter/setter。如果对象结构很深,或者有大量冗余数据,初始化性能开销很大。Vue 3 的懒代理在大部分场景下性能更好,尤其适合有深层嵌套但实际只访问部分路径的数据结构。

追问 2:Proxy 能监听所有数组方法吗?

能。Proxy 代理的是数组对象本身,对数组的任何属性访问或修改都会被拦截。

  • arr[100] = 1:触发 set 拦截器,可以检测到索引赋值
  • arr.length = 0:触发 set 拦截器,可以检测到 length 变化
  • arr.push(1):会触发多个操作,先触发 set 设置新索引,再触发 set 修改 length,都能被拦截

而 Vue 2 的 defineProperty 无法监听直接通过索引修改数组元素,也无法监听 length 变化,所以只能通过重写 pushpopshiftunshiftsplicesortreverse 这 7 个方法,在调用这些方法时手动触发更新。Proxy 完全不需要这些 hack,代码更干净,也消除了"为什么直接改数组索引不更新"这类新手困惑。

追问 3:Proxy 有什么缺点?

Proxy 主要有两个缺点:

第一,兼容性问题。Proxy 是 ES6 特性,无法被 polyfill 到 IE11 及更低版本浏览器。Vue 2 发布于 2016 年,当时还需要兼容 IE 浏览器,所以选择 defineProperty。Vue 3 放弃了对 IE 的支持,所以可以放心使用 Proxy。

第二,代理对象不能直接比较reactive 返回的是代理对象,和原始对象 === 比较会返回 false。这在某些场景下需要注意,比如需要判断某个对象是否已经存在于数组中。Vue 3 提供了 toRaw API 来获取原始对象。

另外,Proxy 的性能在极个别场景下可能略低于 defineProperty(因为每次操作都要经过代理层),但整体收益远大于损耗。Vue 3 通过编译优化和响应式系统的精细设计,实际性能远超 Vue 2。

八、Vue 2 与 Vue 3 的整体差异

核心升级

  1. 响应式系统:defineProperty → Proxy
  2. API 风格:选项式 API → 组合式 API(保留选项式)
  3. 性能优化:重写虚拟 DOM,静态提升、PatchFlags、Block Tree
  4. TypeScript:原生 TS 支持,类型推断更准确
  5. 内置组件:新增 Teleport、Fragments、Suspense
  6. 生命周期:beforeDestroy/destroyed → beforeUnmount/unmounted
  7. 工程化:官方推荐 Vite

易错点

  • 只说"Vue 3 性能更好"说不清为什么 → 要提 Proxy、静态提升、PatchFlags
  • 组合式 API 和 React Hooks 混淆 → Hooks 每次渲染重新执行,组合式 API 只执行一次
  • 忽略 Vue 3 的多根节点支持(Fragment)
  • 生命周期钩子名称说错(beforeUnmount / unmounted)
  • 忘记提及 Vite

九、关联文档

十、相关面试题

问题考察点
什么是双向数据绑定?理解 MVVM 架构
Object.defineProperty 和 Proxy 的区别版本差异、优缺点
如何实现数据驱动视图?观察者模式、发布订阅
Vue 的响应式原理源码理解
手写一个简单的双向绑定综合能力