Vue 的双向绑定原理与实现
本文保留原文“从一个简化的 MVVM 了解 Vue 双向绑定”的主线,同时把 Vue 2.7.16 和 Vue 3.5.39 分开说明。
Vue 2.7 是 Vue 2 的最终 minor 版本,Vue 2 已于 2023 年 12 月 31 日结束官方维护;下面保留它主要是为了维护旧项目和理解历史源码。新项目应优先使用 Vue 3。
先给结论:Vue 的响应式更新本身主要是 状态到视图的单向更新;
v-model再监听用户输入,把视图产生的新值写回状态,才形成开发者通常所说的“双向绑定”。不要把“响应式”“双向绑定”和“数据流方向”当成同一个概念。
一、什么是双向绑定
1.1 双向绑定与单向绑定
双向绑定通常指:
Model(状态) ──────────────> View(视图)
▲ │
└──── 用户输入/事件 ──────────┘
- Model → View:状态变化,框架更新 DOM;
- View → Model:用户输入触发事件,应用把新值写回状态。
单向数据流则强调状态由一个明确的数据源向下流动:
状态/props ──> 组件 ──> DOM
▲
└── emit/action/事件通知变化
React、Vue 都可以实现表单的双向体验;Vue 并不是所有数据都双向流动。Vue 组件的 props 默认是单向下行,子组件应该通过事件通知父组件,而不是直接修改 prop。
1.2 原生元素上的 v-model
下面的写法:
<template>
<input v-model="message" />
<p>{{ message }}</p>
</template>
在文本输入框上可以近似理解为:
<input
:value="message"
@input="message = $event.target.value"
/>
这只是便于理解的展开,不是所有表单元素都使用完全相同的 property 和事件:
| 元素 | 绑定的 DOM property | 默认事件 |
|---|---|---|
文本 input、textarea | value | input |
checkbox | checked | change |
radio | checked | change |
select | value | change |
Vue 始终把 JavaScript 状态作为真实来源,不会把 HTML 上初始的 value、checked、selected 当作之后的状态来源。
1.3 .lazy、.trim 和 .number
<input v-model.lazy="message" />
<input v-model.trim="name" />
<input v-model.number="age" />
.lazy:从input后同步改为change后同步;.trim:去掉输入字符串两端空白;.number:尝试使用parseFloat()转换为数字,转换失败时保留原字符串,空输入仍可能是空字符串。
type="number" 的输入在 Vue 3 中会自动采用 number 行为,但需要清晰表达意图时仍可以显式写 .number。中文、日文等 IME 输入处于 composition 阶段时,v-model 不会在每一个中间字符都更新;如果业务必须监听组合输入,应自行组合 value 和 input 事件。
二、Vue 2.7.16 的响应式原理
2.1 Object.defineProperty + Dep + Watcher
Vue 2.7.16 的响应式系统仍然使用 getter/setter,而不是 Proxy:
初始化 data
↓
Observer 递归遍历对象
↓
Object.defineProperty 为属性定义 getter/setter
↓
渲染或 watch 读取属性,Dep 收集 Watcher
↓
setter 被调用,Dep.notify()
↓
Watcher.update() 进入调度队列
↓
重新渲染或执行用户回调
原文把它概括为“数据劫持 + 发布订阅”,这个说法可以保留,但需要知道它是教学层面的概括:Vue 2 还包含依赖去重、Watcher 栈、异步调度、数组方法改写和组件更新队列等机制。

2.2 Observer 与 defineReactive
下面是 Vue 2.7.16 源码思想的简化版,不是完整源码:
let uid = 0
class Dep {
static target = null
constructor() {
this.id = uid++
this.subs = []
}
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify() {
const subs = this.subs.slice()
for (const watcher of subs) {
watcher.update()
}
}
}
function defineReactive(obj, key, value) {
const dep = new Dep()
let childOb = observe(value)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
dep.depend()
// 对象和数组还需要收集子 Observer 的依赖
if (childOb) {
childOb.dep.depend()
}
}
return value
},
set(newValue) {
if (Object.is(newValue, value)) {
return
}
value = newValue
childOb = observe(newValue)
dep.notify()
},
})
}
function observe(value) {
if (!value || typeof value !== 'object') {
return
}
// 真实实现还会处理已有 Observer、不可扩展对象、数组等情况
const ob = new Observer(value)
return ob
}
class Observer {
constructor(value) {
this.value = value
this.dep = new Dep()
if (Array.isArray(value)) {
// Vue 2 会改写 push、pop、splice 等七个变异方法
augmentArrayMethods(value)
for (const item of value) {
observe(item)
}
} else {
for (const key of Object.keys(value)) {
defineReactive(value, key, value[key])
}
}
}
}
真正的 Vue 2.7.16 源码还会:
- 使用
__ob__标记已经被观察的对象; - 为数组调用
dependArray,收集数组元素中嵌套对象的依赖; - 通过
Vue.set/this.$set和Vue.delete/this.$delete处理新增、删除属性; - 忽略非对象、不可扩展对象和某些特殊对象;
- 对被观察的数组改写变异方法,并在方法执行后通知依赖。
源码参考:Vue 2.7.16 observer/index.ts。
2.3 Vue 2 的响应式限制
Object.defineProperty 只能拦截已经定义的属性,因此 Vue 2 不能自动发现:
this.user.age = 18
this.items[2] = 'new value'
this.items.length = 0
推荐写法:
this.$set(this.user, 'age', 18)
this.$set(this.items, 2, 'new value')
this.items.splice(0, this.items.length)
Vue 2.7 仍保留这些 API。Vue 3 使用 Proxy 后,普通对象新增和删除属性可以被追踪,但依然应该遵循清晰的状态建模,不要随意改变不属于组件的数据结构。
2.4 Dep 与 Watcher 的多对多关系
一个响应式属性 ──> 一个 Dep ──> 多个 Watcher
一个渲染表达式 ──> 一个 Watcher ──> 多个 Dep
例如模板中出现两次 name:
<p>{{ name }}</p>
<span v-text="name"></span>
它们可能分别创建渲染 Watcher,但都会订阅 name 对应的 Dep。一个表达式 user.profile.name 则会在读取过程中访问多个响应式属性。
2.5 Dep.target 与依赖收集
渲染 Watcher 求值前,Vue 会把它放入全局的 Dep.target;getter 被读取时,Dep 就知道当前应该收集哪个 Watcher:
const targetStack = []
function pushTarget(watcher) {
targetStack.push(watcher)
Dep.target = watcher
}
function popTarget() {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
class Watcher {
constructor(vm, getter, cb, options = {}) {
this.vm = vm
this.getter = getter
this.cb = cb
this.deps = new Set()
this.lazy = !!options.lazy
this.dirty = this.lazy
this.value = this.lazy ? undefined : this.get()
}
addDep(dep) {
if (!this.deps.has(dep)) {
this.deps.add(dep)
dep.subs.push(this)
}
}
get() {
pushTarget(this)
let value
try {
value = this.getter.call(this.vm)
} finally {
popTarget()
}
return value
}
update() {
if (this.lazy) {
this.dirty = true
return
}
queueWatcher(this)
}
evaluate() {
this.value = this.get()
this.dirty = false
return this.value
}
}
真实 Watcher 还需要 addDep、depIds 去重、深度遍历、用户 Watcher 错误处理和销毁逻辑。Vue 2 的 computed 使用 lazy Watcher;渲染 Watcher 和 user watcher 则会通过 scheduler 合并更新。
三、Vue 3.5.39 的响应式原理
3.1 Proxy 替代 getter/setter
Vue 3 使用 Proxy:
import { reactive, ref } from 'vue'
const state = reactive({
count: 0,
user: {
name: 'Ada',
},
})
state.count++
state.user.name = 'Grace'
const count = ref(0)
count.value++
reactive() 返回一个 Proxy;嵌套对象在访问时也会被转换为响应式代理。ref() 用 .value 保存值,如果值是对象,内部也会采用响应式转换。
Proxy 可以拦截属性读取、写入、删除、in、ownKeys 等操作,因此 Vue 3 不再需要 Vue 2 的 $set。但 reactive() 仍然只适合对象、数组、Map、Set 等对象类型;基本类型应使用 ref()。
Vue 2.7 的 Composition API 是回移植版本,仍基于 getter/setter。下面这个差异很容易被忽略:
// Vue 2.7:reactive 会直接转换原对象
reactive(object) === object // true
// Vue 3:reactive 返回代理对象
reactive(object) === object // false
3.2 track、trigger 和依赖表
Vue 3 响应式可以抽象为:
WeakMap<rawTarget, Map<key, Dep>>
读取时:
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)
}
写入时,Vue 会根据操作类型判断是否真的发生了变化,再触发相关 effect:
function trigger(target, key, type, newValue) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const effects = new Set(depsMap.get(key))
for (const effect of effects) {
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
这是便于理解的伪代码。Vue 3.5.39 的真实实现已经使用 Dep、Link、版本号和 Subscriber 等机制优化依赖关系;不要把上面的 Set 伪代码当成当前版本逐行源码。
源码参考:
3.3 响应式更新不是同步 DOM 更新
修改状态后,Vue 通常会把组件更新放入队列并批量执行:
import { nextTick, ref } from 'vue'
const count = ref(0)
async function increment() {
count.value++
console.log(document.querySelector('#count')?.textContent)
// 这里可能还是旧 DOM
await nextTick()
console.log(document.querySelector('#count')?.textContent)
// 这里可以读取本次更新后的 DOM
}
Vue 2 的 nextTick 会优先尝试 Promise、MutationObserver、setImmediate,最后回退到 setTimeout;Vue 3 的调度器以 Promise 微任务队列为主要实现。两者的公共语义都是“等待当前更新批次完成”,不要把它理解成固定的 0ms 定时器。
四、v-model 的实现方式
4.1 原生元素
原生 v-model 会由编译器根据元素类型生成合适的 prop 和事件:
<input v-model="text" />
<textarea v-model="text" />
<input type="checkbox" v-model="checked" />
<select v-model="selected">...</select>
Vue 3 的模板编译器会生成对应的 vModelText、vModelCheckbox、vModelSelect 等运行时指令。它还要处理 IME、checkbox 数组、radio、动态 value 和 .lazy/.trim/.number 等细节,所以“v-model 等于一行 @input”只是最简单文本输入框的近似。

4.2 Vue 2 组件上的 v-model
Vue 2 默认协议是:
<CustomInput v-model="title" />
近似展开为:
<CustomInput
:value="title"
@input="title = $event"
/>
子组件:
<script>
export default {
props: {
value: String,
},
methods: {
updateValue(event) {
this.$emit('input', event.target.value)
},
},
}
</script>
<template>
<input :value="value" @input="updateValue" />
</template>
Vue 2 可以使用 model 选项修改默认的 prop 和事件名:
export default {
model: {
prop: 'checked',
event: 'change',
},
props: {
checked: Boolean,
},
}
4.3 Vue 3 组件上的 v-model
Vue 3 默认协议是:
<CustomInput v-model="title" />
近似展开为:
<CustomInput
:modelValue="title"
@update:modelValue="title = $event"
/>
手写子组件:
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
Vue 3.4+ 推荐使用 defineModel:
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
多个模型:
<UserName
v-model:first-name="firstName"
v-model:last-name="lastName"
/>
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>
五、一个简化的 MVVM 实现
下面的实现用于帮助理解原文中的 Observer、Compile、Dep 和 Watcher 如何连接起来。它只支持少量语法,不是 Vue 的替代品,也没有处理安全、组件、列表、异步调度、表达式解析和完整错误边界。
5.1 数据代理
function proxy(vm, sourceKey, key) {
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get() {
return vm[sourceKey][key]
},
set(value) {
vm[sourceKey][key] = value
},
})
}
function proxyData(vm, data) {
for (const key of Object.keys(data)) {
proxy(vm, '$data', key)
}
}
这样可以通过 vm.message 访问 vm.$data.message,但数据代理本身不负责依赖收集;依赖收集发生在被观察的数据属性 getter 中。
5.2 Dep 和 Watcher
let activeWatcher = null
const watcherStack = []
class Dep {
constructor() {
this.subs = new Set()
}
depend() {
if (activeWatcher) {
this.subs.add(activeWatcher)
}
}
notify() {
for (const watcher of this.subs) {
watcher.update()
}
}
}
class Watcher {
constructor(vm, getter, callback) {
this.vm = vm
this.getter = getter
this.callback = callback
this.value = this.get()
}
get() {
watcherStack.push(this)
activeWatcher = this
try {
return this.getter.call(this.vm)
} finally {
watcherStack.pop()
activeWatcher = watcherStack[watcherStack.length - 1]
}
}
update() {
const oldValue = this.value
const newValue = this.get()
if (!Object.is(oldValue, newValue)) {
this.value = newValue
this.callback(newValue, oldValue)
}
}
}
5.3 递归观察数据
function observe(value) {
if (!value || typeof value !== 'object') {
return
}
for (const key of Object.keys(value)) {
defineReactive(value, key, value[key])
}
}
function defineReactive(object, key, initialValue) {
const dep = new Dep()
let value = initialValue
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(nextValue)
dep.notify()
},
})
}
完整实现还需要处理数组变异方法、对象依赖、循环引用和重复 observe。上面的代码只能演示最核心的 getter 收集、setter 通知。
5.4 表达式读取与写入
function parsePath(path) {
const segments = path.split('.')
return object => {
let value = object
for (const segment of segments) {
if (value == null) {
return undefined
}
value = value[segment]
}
return value
}
}
function setPath(object, path, value) {
const segments = path.split('.')
const lastKey = segments.pop()
let target = object
for (const segment of segments) {
if (target[segment] == null) {
target[segment] = {}
}
target = target[segment]
}
target[lastKey] = value
}
真实 Vue 的模板表达式不只是 split('.'):它支持运算、条件、函数调用、可选链等由编译器解析的语法。原文中“Vue 的 parsePath 不支持 abc['bbc']”只适合这个简化的路径读取函数,不能直接概括成 Vue 模板的全部能力。
原文还保留了几张调试截图,展示简化编译器从模板读取指令、解析表达式和更新节点的过程。截图中的实现是教学代码,不是 Vue 2.7.16 的完整源码:



5.5 编译文本和 v-model
const interpolationRE = /\{\{\s*([^}]+?)\s*\}\}/g
function getTextValue(vm, template) {
return template.replace(interpolationRE, (_, expression) => {
const getter = parsePath(expression.trim())
const value = getter(vm)
return value == null ? '' : String(value)
})
}
function compileText(node, vm) {
const template = node.textContent
if (!interpolationRE.test(template)) {
return
}
interpolationRE.lastIndex = 0
const expressions = [...template.matchAll(interpolationRE)]
.map(match => match[1].trim())
const getter = () => {
// 为了让所有表达式参与依赖收集,逐个读取
expressions.forEach(expression => parsePath(expression)(vm))
return getTextValue(vm, template)
}
const update = () => {
node.textContent = getter()
}
new Watcher(vm, getter, update)
update()
}
function compileModel(node, vm, expression) {
const getter = parsePath(expression)
const update = value => {
node.value = value == null ? '' : String(value)
}
new Watcher(vm, getter, update)
update(getter(vm))
node.addEventListener('input', event => {
setPath(vm, expression, event.target.value)
})
}
v-html 可以把字符串当 HTML 插入,但不要把用户可控内容直接交给 innerHTML 或 v-html。实际应用应优先使用文本插值,并在确实需要 HTML 时进行可信来源控制和清洗。
5.6 Compile 的基本流程
class Compile {
constructor(root, vm) {
this.root = typeof root === 'string'
? document.querySelector(root)
: root
this.vm = vm
if (!this.root) {
return
}
const fragment = document.createDocumentFragment()
while (this.root.firstChild) {
fragment.appendChild(this.root.firstChild)
}
this.compile(fragment)
this.root.appendChild(fragment)
}
compile(node) {
for (const child of [...node.childNodes]) {
if (child.nodeType === Node.ELEMENT_NODE) {
this.compileElement(child)
this.compile(child)
} else if (child.nodeType === Node.TEXT_NODE) {
compileText(child, this.vm)
}
}
}
compileElement(node) {
for (const attribute of [...node.attributes]) {
const { name, value } = attribute
if (name === 'v-model') {
compileModel(node, this.vm, value)
} else if (name.startsWith('@')) {
const method = this.vm[value]
|| this.vm.$options.methods?.[value]
|| this.vm.$options[value]
if (typeof method === 'function') {
node.addEventListener(name.slice(1), method.bind(this.vm))
}
}
}
}
}
原文使用 DocumentFragment 批量把节点移到内存中,这个思路可以用来理解早期 MVVM 编译器。但现代 Vue SFC 通常在构建阶段编译模板,运行时执行的是渲染函数和 VNode patch,不会按这个简化版本逐个查找所有 DOM 指令。
原文中的事件指令、普通指令、class 更新和 watcher 更新截图如下:




5.7 MVVM 入口
class MVVM {
constructor(options) {
this.$options = options
this.$data = options.data || {}
proxyData(this, this.$data)
observe(this.$data)
if (options.el) {
new Compile(options.el, this)
}
}
}
const vm = new MVVM({
el: '#app',
data: {
message: 'hello',
},
methods: {
show() {
window.alert(this.message)
},
},
})
示例模板:
<div id="app">
<input v-model="message" />
<p>{{ message }}</p>
<button @click="show">显示</button>
</div>
执行链路是:
new MVVM
↓
proxyData + observe
↓
Compile 扫描 {{ message }} 和 v-model
↓
创建 Watcher,读取 message,触发 getter 收集 Dep
↓
input 事件写入 message
↓
setter 调用 dep.notify()
↓
Watcher 更新文本和 input value
六、Vue 模板编译与 VNode
6.1 Vue 2 的模板编译
Vue 2 的编译流程通常概括为:
template 字符串
↓ parse
AST
↓ optimize
标记静态节点
↓ generate
render 函数代码
↓
VNode
Vue 2 的运行时编译器会解析标签、文本、指令和表达式。生产构建一般在构建阶段完成编译,运行时构建才会包含模板编译器。


6.2 Vue 3 的编译器优化
Vue 3 仍然生成 VNode,但采用“编译器感知的 Virtual DOM”:
- 静态节点提升(hoist static);
- 静态子树缓存;
- patch flags 标记只会变化的 class、style、text 等;
- block tree 收集动态后代;
- SSR hydration 使用 block 和 patch flag 走更快路径;
v-once、v-memo可在适合的场景减少重复更新。
例如:
<div class="static">固定内容</div>
<div :class="className">{{ message }}</div>
编译器可以告诉运行时第二个节点主要需要更新 class 和文本,而不必每次完整比较所有属性。patch flag 的判断会使用位运算,但这只是实现细节,不能简单说“Vue 3 的 diff 只靠位运算”。

七、响应式更新队列
7.1 为什么要异步批量更新
vm.message = 'one'
vm.message = 'two'
vm.message = 'three'
Vue 不需要为同一事件循环中的三次修改都执行完整渲染。它会对待执行的 watcher/job 去重,在更新批次中统一刷新。
this.message = 'updated'
console.log(this.$el.textContent) // 可能还是旧值
this.$nextTick(() => {
console.log(this.$el.textContent) // 更新后的 DOM
})
“数据已变但 DOM 还没变”不是响应式失效,而是批量更新尚未 flush。需要访问更新后的 DOM 时使用 nextTick。
7.2 Vue 2 与 Vue 3 调度差异
| 版本 | 依赖/更新模型 | 等待 DOM 更新 |
|---|---|---|
| Vue 2.7 | Watcher、Dep、scheduler、queueWatcher | vm.$nextTick 或 Vue.nextTick |
| Vue 3.5 | ReactiveEffect/Subscriber、scheduler、job queue | nextTick() |
Vue 3 的 watch 还可以通过 flush: 'pre' | 'post' | 'sync' 选择回调时机;watchPostEffect 适合需要在 DOM 更新后执行的副作用。sync 会失去批量调度优势,应该谨慎使用。
八、关于“数据劫持”的准确说法
“劫持”是历史文章中的形象说法,更准确的表述是:
- Vue 2:通过
Object.defineProperty定义 getter/setter,并由 Dep/Watcher 追踪依赖; - Vue 3:通过 Proxy/ref 的读取和写入拦截,由依赖表触发 effect;
- Vue 2.7:虽然有 Composition API 和
<script setup>回移植,但底层仍不是 Vue 3 的 Proxy 响应式; - Vue 3 的响应式对象是代理,原始对象与代理对象不是同一个引用;
- 非所有状态都应该做深层响应式,性能敏感的大型不可变数据可以考虑
shallowRef、markRaw,但必须明确这些 API 会减少自动追踪。
九、总结
- “双向绑定”可以理解为 状态到视图的响应式更新 + 视图事件写回状态;
- Vue 2.7.16 使用
Object.defineProperty、Observer、Dep、Watcher 和异步更新队列; - Vue 2 需要用
$set/splice处理部分新增属性和数组索引变化; - Vue 3.5.39 使用 Proxy、ref 和新的依赖/调度实现,普通新增属性不再需要
$set; - Vue 2 组件
v-model默认是value/input,Vue 3 默认是modelValue/update:modelValue; - Vue 3.4+ 可使用
defineModel,Vue 2.7 的 Composition API 仍基于 Vue 2 getter/setter; - 简化 MVVM 中的 Observer、Compile、Dep、Watcher 适合学习原理,不能当作完整 Vue 实现;
- Vue 2 和 Vue 3 都会批量更新 DOM,DOM 时序问题使用
nextTick解决; - 模板编译会为运行时提供静态提升、patch flags 和 block tree 等优化信息。
官方参考
Vue 2.7.16
Vue 3.5.39
原文出处
作者:uu_Q
来源:稀土掘金
本文保留原文的 MVVM、数据代理、模板编译、Observer/Dep/Watcher 和 v-model 示例,并补充 Vue 2.7.16、Vue 3.5.39 的实现差异。