技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

实现双向绑定:Proxy 比 defineProperty 有哪些优劣?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/74-实现双向绑定Proxy比defineproperty优劣如何
本文目录8 个章节
  1. 前言:先区分响应式和双向绑定
  2. 一、什么是数据劫持
  3. 二、Vue 2 风格的 Observer、Dep 和 Watcher
  4. 三、Object.defineProperty 的优缺点
  5. 四、Proxy 的特点
  6. 五、Vue 2.7 与 Vue 3.5 的对应关系
  7. 六、总结
  8. 参考资料

实现双向绑定:Proxy 比 defineProperty 有哪些优劣?

原文主要讨论数据劫持、Object.definePropertyProxy、Observer/Dep/Watcher 和数组监听。本文尽量保留这条学习路线,并补充 Vue 2.7、Vue 3.5 的现状。示例是为了理解机制的教学代码,不是 Vue 源码的完整实现。

前言:先区分响应式和双向绑定

“双向绑定”常被用来描述表单场景,但它不是响应式系统的同义词:

状态 → 视图:响应式渲染
视图 → 状态:事件处理或 v-model 约定

Vue 的响应式系统负责在状态变化后调度视图更新;v-model 再把输入事件和状态赋值组合成方便的语法。组件之间仍然推荐 props 下行、emits 上行的单向数据流。

Vue 的核心可以从三个角度理解:

  • 响应式系统:如何追踪读取、检测写入并触发 effect;
  • 模板编译:如何把模板转换为 render function;
  • 渲染器:如何把 VNode 挂载、更新到 DOM 或其他宿主环境。

不同框架也有不同的状态更新模型。React 默认强调显式触发 state 更新,Angular、Vue 提供更强的模板绑定能力;但这些框架都可以实现单向或双向的数据流,不能简单概括为“某个框架只能单向、另一个只能双向”。

本文讨论两类 JavaScript 属性拦截方式:

  • Vue 2.7 主要使用 Object.defineProperty 将已知属性转换为 getter/setter;
  • Vue 3.5 使用 Proxy 处理 reactive 对象,使用 getter/setter 处理 ref 的 .value

严格说,Proxy 是“代理”,不必把所有拦截都称为“数据劫持”。Object.observe 曾经是提案,但已经废弃,不能作为现代方案。

属性拦截、响应式和视图更新的关系(已本地化)

一、什么是数据劫持

当对象属性被读取或写入时,通过 getter/setter 或 Proxy trap 执行额外逻辑,就可以收集依赖、校验数据或通知视图。

1. Object.defineProperty 的最小示例

const data = { name: '' }

function say(name) {
  if (name === '古天乐') {
    console.log('给大家推荐一款超好玩的游戏')
  } else if (name === '渣渣辉') {
    console.log('戏我演过很多,但游戏我只玩贪玩蓝月')
  } else {
    console.log('来做我的兄弟')
  }
}

Object.keys(data).forEach(key => {
  let value = data[key]

  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log('get')
      return value
    },
    set(newValue) {
      if (Object.is(newValue, value)) return
      value = newValue
      console.log(`大家好,我系${newValue}`)
      say(newValue)
    },
  })
})

data.name = '渣渣辉'
// 大家好,我系渣渣辉
// 戏我演过很多,但游戏我只玩贪玩蓝月

原文的 getter 没有返回旧值,会导致读取 data.name 得到 undefined;这里补上了闭包中的 value。实际 Vue 还要处理嵌套对象、数组、依赖收集、批量调度和组件更新。

2. 数据劫持能解决什么问题

如果 setter 能够知道具体是哪个对象、哪个属性被修改,就可以直接通知相关依赖,而不必每次都对整棵状态树做全量比较。这是精确依赖更新的一个重要基础。

但“可以精确知道属性变化”不代表一定比 Diff 更快:

  • 依赖收集本身有运行时成本;
  • 大量细粒度 effect 仍需要调度和去重;
  • 渲染器最终还要处理 VNode、属性、布局和绘制;
  • 数组和集合操作可能一次触发多个底层读写。

二、Vue 2 风格的 Observer、Dep 和 Watcher

一个简化的 Vue 2 响应式链路是:

Observer:把对象属性转换为 getter/setter
Dep:保存某个属性对应的订阅者
Watcher:读取数据时收集依赖,收到通知后重新执行
Compile:把模板指令转换为具体的 DOM 更新

Observer、Compile、Dep、Watcher 的关系(已本地化)

1. Dep:依赖管理器

class Dep {
  constructor() {
    this.subscribers = new Set()
  }

  depend() {
    if (activeWatcher) activeWatcher.addDep(this)
  }

  addSub(watcher) {
    this.subscribers.add(watcher)
  }

  notify() {
    for (const watcher of [...this.subscribers]) {
      watcher.update()
    }
  }
}

let activeWatcher = null

Vue 2 源码使用 Dep.target 表示当前正在收集依赖的 watcher。真实实现还要用栈处理嵌套计算、清理旧依赖和批量调度,这里用一个变量只展示核心概念。

2. Observer:转换对象属性

function observe(value) {
  if (!value || typeof value !== 'object') return
  for (const key of Object.keys(value)) {
    defineReactive(value, key, value[key])
  }
  return value
}

function defineReactive(object, key, initialValue) {
  let value = initialValue
  const dep = new Dep()

  observe(value)

  Object.defineProperty(object, key, {
    enumerable: true,
    configurable: true,
    get() {
      dep.depend()
      return value
    },
    set(nextValue) {
      if (Object.is(nextValue, value)) return
      value = nextValue
      observe(value)
      dep.notify()
    },
  })
}

这段代码只处理初始化时已经存在的普通对象属性。Vue 2 还需要:

  • 为数组改写部分变更方法;
  • 处理嵌套对象的 child observer 依赖;
  • 通过 Vue.set/this.$setVue.delete 弥补新增、删除属性;
  • 对 watcher 进行队列化、去重和异步刷新。

3. Watcher:读取时收集依赖

function getPath(object, path) {
  return path.split('.').reduce((value, key) => value?.[key], object)
}

class Watcher {
  constructor(vm, getter, callback) {
    this.vm = vm
    this.getter = getter
    this.callback = callback
    this.deps = new Set()
    this.value = this.get()
  }

  addDep(dep) {
    if (this.deps.has(dep)) return
    this.deps.add(dep)
    dep.addSub(this)
  }

  get() {
    const previous = activeWatcher
    activeWatcher = this
    try {
      return this.getter.call(this.vm)
    } finally {
      activeWatcher = previous
    }
  }

  update() {
    const nextValue = this.get()
    if (Object.is(nextValue, this.value)) return

    const oldValue = this.value
    this.value = nextValue
    this.callback.call(this.vm, nextValue, oldValue)
  }

  teardown() {
    for (const dep of this.deps) {
      dep.subscribers.delete(this)
    }
    this.deps.clear()
  }
}

读取 this.vm._data.user.name 时,会依次触发对象属性 getter,当前 watcher 因此订阅相关依赖。真实 Vue 2 watcher 还区分渲染 watcher、computed watcher、user watcher,并在更新时进入 scheduler 队列。

4. 简化的 Vue 实例

class MiniVue {
  constructor(options = {}) {
    this.$data = typeof options.data === 'function'
      ? options.data()
      : (options.data || {})
    this._watchers = []

    for (const key of Object.keys(this.$data)) {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get: () => this.$data[key],
        set: value => {
          this.$data[key] = value
        },
      })
    }

    observe(this.$data)
  }

  $watch(path, callback) {
    const watcher = new Watcher(
      this,
      () => getPath(this.$data, path),
      callback,
    )
    this._watchers.push(watcher)
    return () => {
      watcher.teardown()
      const index = this._watchers.indexOf(watcher)
      if (index >= 0) this._watchers.splice(index, 1)
    }
  }
}

const vm = new MiniVue({
  data: { user: { name: 'Vue' } },
})

vm.$watch('user.name', (value, oldValue) => {
  console.log(`${oldValue} -> ${value}`)
})

vm.user.name = 'Vue 3'

这里还没有实现模板编译和 DOM 更新。完整的双向绑定需要 Compiler 将 v-model、插值和事件绑定到 watcher,再在输入事件中修改 Model。

三、Object.defineProperty 的优缺点

优点

  • Vue 2 时代浏览器支持范围广;
  • 可以在不改变调用方式的情况下拦截已有属性的读写;
  • setter 能够精确知道某个属性的新值;
  • 与 Dep/Watcher 结合后可以实现依赖收集。

局限

1. 只能转换已知属性

Object.defineProperty 是针对单个属性设置描述符。初始化后直接新增属性不会自动拥有 getter/setter:

const state = { user: { name: 'Vue' } }
observe(state)
state.user.age = 3 // 这个新增属性不会被上面的简化 observe 捕获

Vue 2 应使用:

this.$set(this.user, 'age', 3)
// 或 Vue.set(this.user, 'age', 3)

Vue 3 的 Proxy 可以拦截新增和删除属性,但响应式对象仍应遵循清晰的状态设计。

2. 数组需要额外处理

Vue 2 能够检测以下七种数组变更方法:

push、pop、shift、unshift、splice、sort、reverse

它通过替换响应式数组的部分原型方法,在调用原生方法后通知依赖;这不是 defineProperty 自动监听了数组的所有变化。以下写法在 Vue 2 中仍需要特殊处理:

vm.items[index] = nextValue
vm.items.length = 0

可以使用 splice$set

vm.items.splice(index, 1, nextValue)
vm.$set(vm.items, index, nextValue)

示意性的数组方法改写如下:

const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayMethods = Object.create(Array.prototype)

for (const method of methods) {
  const original = Array.prototype[method]
  Object.defineProperty(arrayMethods, method, {
    value(...args) {
      const result = original.apply(this, args)
      console.log(`数组方法 ${method} 被调用`)
      // 真实框架会在这里通知依赖并批量调度更新
      return result
    },
    enumerable: false,
    writable: true,
    configurable: true,
  })
}

const list = [1, 2]
Object.setPrototypeOf(list, arrayMethods)
list.push(3)

3. 初始化遍历成本和覆盖范围

为了转换嵌套对象,Vue 2 需要递归遍历已知属性。属性很多或对象很深时,初始化会有成本;不可扩展对象、原型链属性、Map/Set 和某些外部对象也需要额外处理。

因此,“defineProperty 无法深度监听对象”并不严谨:递归遍历后,它可以监听已存在的嵌套属性;准确的说法是它对新增/删除属性、数组索引和集合的覆盖不完整。

四、Proxy 的特点

Proxy 在目标对象外部建立代理,可以拦截 getsethasdeletePropertyownKeys 等多种操作。它返回的是一个代理对象,不是目标对象的复制品:

const raw = { count: 0 }
const proxy = new Proxy(raw, {})

proxy !== raw // true
proxy.count = 1
raw.count // 1

1. 拦截对象而不是逐个声明属性

const input = document.querySelector('#input')
const output = document.querySelector('#output')

const state = new Proxy({ text: '' }, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    const updated = Reflect.set(target, key, value, receiver)

    if (key === 'text') {
      input.value = String(value)
      output.textContent = String(value)
    }

    return updated
  },
})

input.addEventListener('input', event => {
  state.text = event.target.value
})

这里展示的是“输入事件修改状态、setter 更新 DOM”的最小双向绑定。生产实现不应把 DOM 操作直接写进 Proxy,而应通过 effect、组件渲染器和 scheduler 解耦。

简易双向绑定示例(已本地化)

2. 对数组和集合有更完整的拦截能力

Proxy 可以观察数组索引、length 以及 push 等方法内部触发的属性操作,也可以为 Map/Set 的 getsetadddeleteclear 建立专门的依赖规则:

const list = new Proxy([], {
  set(target, key, value, receiver) {
    const oldLength = target.length
    const result = Reflect.set(target, key, value, receiver)
    console.log('changed:', key, value)

    if (target.length !== oldLength) {
      console.log('length changed:', target.length)
    }

    return result
  },
})

list.push('Vue')
list[2] = 'Proxy'

一次数组方法可能触发多个 trap,因此真实 Vue 不会在每个 trap 中立即完整重渲染,而是把组件更新和 watcher 放入 scheduler 队列进行去重、批量处理。

3. Proxy 的局限

  • Proxy 不能拦截普通局部变量的读写,只能代理对象操作;
  • 不能用 polyfill 完整模拟旧浏览器缺少的 Proxy;
  • 代理对象和原对象的 identity 不同,和第三方库交互时要注意;
  • 必须遵守 Proxy invariant,某些不可配置属性不能随意伪造结果;
  • 代理并不会自动产生依赖关系,仍需要 track/trigger 或其他订阅机制;
  • 代理层本身也有运行时开销,不能简单宣称“Proxy 永远更快”。

五、Vue 2.7 与 Vue 3.5 的对应关系

方面Vue 2.7Vue 3.5
普通对象Object.definePropertyProxy/Reflect
基础值通常通过实例属性代理ref() 使用 .value getter/setter
依赖模型Dep、WatcherReactiveEffect、依赖集合、scheduler
新增属性$set/Vue.setProxy 可拦截 add
数组改写部分变更方法Proxy + 数组操作 instrumentation
Map/Set支持有限有专门的集合响应式处理
组件更新render watcher 入队组件 render effect 入队
浏览器兼容可支持更老环境需要 Proxy,不能兼容 IE11

Vue 3 的 reactive() 主要返回 Proxy,ref() 主要通过 getter/setter 追踪 .value。因此不要把“Vue 3 完全不使用 getter/setter”作为结论。

一个更接近 Vue 3 的响应式伪代码是:

const subscribers = new WeakMap()
let activeEffect

function track(target, key) {
  if (!activeEffect) return
  // WeakMap<target, Map<key, Set<effect>>>,这里只省略初始化细节
}

function trigger(target, key) {
  // 找到 target/key 对应的 effects,并交给 scheduler
}

function reactive(target) {
  return new Proxy(target, {
    get(object, key, receiver) {
      const value = Reflect.get(object, key, receiver)
      track(object, key)
      return value
    },
    set(object, key, value, receiver) {
      const oldValue = object[key]
      const result = Reflect.set(object, key, value, receiver)
      if (!Object.is(oldValue, value)) trigger(object, key)
      return result
    },
  })
}

伪代码省略了嵌套代理、删除、迭代依赖、数组、Map/Set、effect 清理和 scheduler。Vue 官方文档中的 track/trigger 示例同样是为了说明核心概念,不能直接替代框架实现。

六、总结

  1. 响应式是状态变化通知依赖;双向绑定通常还包含 View → Model 的输入事件;
  2. Vue 2.7 以 defineProperty + Dep/Watcher 为主要实现,并通过数组方法改写和 $set 弥补限制;
  3. Vue 3.5 以 Proxy 处理 reactive 对象,以 getter/setter 处理 ref,并通过 effect 和 scheduler 驱动更新;
  4. Proxy 能拦截新增、删除、数组索引和集合操作,但不会自动解决依赖收集、批量更新和兼容性问题;
  5. 不要把 Proxy 称为 defineProperty 的“全方位无条件加强版”,应结合目标浏览器、数据类型和更新模型选择方案。

参考资料

原文作者:寻找海蓝 96。原文关于数据劫持、Observer/Dep/Watcher、数组方法改写和 Proxy 示例的主线予以保留;抓取格式、失效推广内容、错误的 getter、错误的“八种数组方法”和过度绝对化表述已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS