不好意思!耽误你的十分钟,让 MVVM 原理还给你
原文用一个简化的 Vue 2 风格 MVVM 解释数据劫持、数据代理、发布订阅、编译和双向绑定。这个学习路径仍然有价值,但自写代码不能等同于 Vue 2.7.16 的完整实现,更不能直接代表 Vue 3.5。
版本说明:Vue 2.7 使用 getter/setter、Observer、Dep、Watcher 和异步 scheduler;Vue 3.5 的对象/数组响应式主要使用 Proxy,同时
ref.value仍然通过 getter/setter 追踪依赖。Vue 2 已于 2023 年 12 月 31 日结束官方维护,新项目优先使用 Vue 3。
一、MVVM 到底解决了什么问题
MVVM 可以作为一种架构理解:
- Model:数据和业务状态;
- View:用户看到的界面;
- ViewModel:把状态变化同步到视图,并把用户输入转换成状态更新的中间层。
“数据驱动视图”并不意味着 Vue 会替开发者任意双向修改数据。更准确的描述是:
Model -> View:响应式依赖变化后更新渲染结果
View -> Model:输入事件、emit 或 v-model 协议更新状态
React、Vue 和 Angular 的更新模型不能简单统称为同一种“数据劫持 + 发布订阅”。本文的代码只用来解释一种 Vue 2 风格的教学实现。
二、Object.defineProperty 的基础
Object.defineProperty 可以定义数据属性或访问器属性。访问器属性中的 get 在读取时执行,set 在赋值时执行:
const obj = {}
let music = '发如雪'
Object.defineProperty(obj, 'music', {
configurable: true,
enumerable: true,
get() {
console.log('读取 music')
return music
},
set(nextValue) {
console.log('修改 music:', nextValue)
music = nextValue
},
})
console.log(obj.music) // 发如雪
obj.music = '夜曲'
console.log(obj.music) // 夜曲
这里使用的是 accessor descriptor,所以不能同时配置 value/writable。writable 只属于数据属性,不能用来解释上面 set() 的赋值行为。
Object.defineProperty 是 ES5 能力。Vue 2 的响应式实现需要它,因此 Vue 2 不支持 IE8 及以下;“IE8+”容易造成误解,应写成“IE9+ 时代的 Vue 2 浏览器支持范围”。
三、一个教学版的响应式核心
下面的实现只覆盖普通对象、简单路径和文本/输入框,用于观察依赖收集过程。它不是 Vue 2 源码,没有完整处理数组方法、Vue.set、依赖清理、计算属性 watcher、组件更新队列和 SSR。
3.1 Dep 和 Watcher
let activeWatcher = null
class Dep {
constructor() {
this.subscribers = new Set()
}
depend(watcher) {
if (watcher) this.subscribers.add(watcher)
}
notify() {
this.subscribers.forEach(watcher => watcher.update())
}
}
class Watcher {
constructor(vm, getter, callback) {
this.vm = vm
this.getter = getter
this.callback = callback
this.value = this.get()
}
get() {
const previous = activeWatcher
activeWatcher = this
try {
return this.getter(this.vm)
} finally {
activeWatcher = previous
}
}
update() {
const nextValue = this.get()
if (!Object.is(nextValue, this.value)) {
const previous = this.value
this.value = nextValue
this.callback(nextValue, previous)
}
}
}
真实 Vue 2 的 Watcher 还会维护新旧依赖集合、清理失效依赖并由 scheduler 去重和异步刷新。教学版直接使用 Set 去重,但仍然比“每次读取都 push 一个订阅”更接近真实语义。
3.2 观察对象
function observe(value) {
if (!value || typeof value !== 'object') return
Object.keys(value).forEach(key => {
defineReactive(value, key, value[key])
})
}
function defineReactive(target, key, initialValue) {
const dep = new Dep()
let value = initialValue
observe(value)
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
get() {
dep.depend(activeWatcher)
return value
},
set(nextValue) {
if (Object.is(nextValue, value)) return
value = nextValue
observe(nextValue)
dep.notify()
},
})
}
每个响应式属性都应有自己的 Dep。不能把一个对象的所有 key 共用一个 Dep,否则修改 a 会错误地通知只依赖 b 的 watcher。
3.3 数据代理
function proxy(vm, data) {
Object.keys(data).forEach(key => {
Object.defineProperty(vm, key, {
configurable: true,
enumerable: true,
get() {
return vm.$data[key]
},
set(value) {
vm.$data[key] = value
},
})
})
}
数据代理的作用是让 vm.message 转发到 vm.$data.message。Vue 2 真实实现还会避开 $、_ 等保留键,并检查与 props、methods 的命名冲突。
四、一个可以运行的简化 MVVM
4.1 解析路径
function getPath(target, path) {
return path.split('.').reduce((value, key) => value?.[key], target)
}
function setPath(target, path, nextValue) {
const keys = path.split('.')
const lastKey = keys.pop()
const owner = keys.reduce((value, key) => value[key], target)
owner[lastKey] = nextValue
}
4.2 编译文本和 v-model
class Compile {
constructor(root, vm) {
this.root = typeof root === 'string'
? document.querySelector(root)
: root
this.vm = vm
if (!this.root) throw new Error('找不到挂载节点')
this.walk(this.root)
}
walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
this.bindText(node)
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const modelExpression = node.getAttribute('v-model')?.trim()
if (modelExpression && 'value' in node) {
const updateInput = () => {
node.value = String(getPath(this.vm, modelExpression) ?? '')
}
new Watcher(
this.vm,
vm => getPath(vm, modelExpression),
updateInput,
)
node.addEventListener('input', event => {
setPath(this.vm, modelExpression, event.target.value)
})
}
Array.from(node.childNodes).forEach(child => this.walk(child))
}
bindText(node) {
const template = node.textContent
const expressionPattern = /\{\{\s*(.*?)\s*\}\}/g
const expressions = []
let match
while ((match = expressionPattern.exec(template)) !== null) {
expressions.push(match[1])
}
if (expressions.length === 0) return
const render = () => {
node.textContent = template.replace(
expressionPattern,
(_, expression) => String(getPath(this.vm, expression) ?? ''),
)
}
// 每个占位符初始化一次 watcher;不要在每次 render 中重复 new Watcher。
expressions.forEach(expression => {
new Watcher(
this.vm,
vm => getPath(vm, expression),
render,
)
})
render()
}
}
class Mvvm {
constructor(options = {}) {
this.$options = options
this.$data = options.data || {}
observe(this.$data)
proxy(this, this.$data)
if (options.el) {
new Compile(options.el, this)
}
options.mounted?.call(this)
}
}
配套 HTML:
<div id="app">
<h1>{{ song }}</h1>
<p>《{{ album.name }}》是{{ singer }}的专辑</p>
<p>主打歌为{{ album.theme }}</p>
<input v-model="singer" />
</div>
<script src="mvvm.js"></script>
<script>
const vm = new Mvvm({
el: '#app',
data: {
song: '发如雪',
album: {
name: '十一月的萧邦',
theme: '夜曲',
},
singer: '周杰伦',
},
mounted() {
console.log('mounted')
},
})
</script>
这个示例只实现了单向文本更新和简单 v-model。它没有 Vue 模板表达式解析、事件指令、修饰符、数组响应式和组件系统,不应直接称为“手写 Vue”。
五、从数据变化到视图更新
以 vm.singer = '新名字' 为例:
执行 vm.singer = ...
↓
代理 setter 转发到 $data.singer
↓
响应式属性 setter 调用 dep.notify()
↓
Watcher 重新读取 getter
↓
Watcher 的 callback 更新文本节点或 input
真实 Vue 2 的流程还会把 watcher 放入 scheduler 队列,按 id 排序、去重,并在 next tick 批量刷新。也就是说,Vue 2 的 Dep.notify() 不是简单地同步执行所有渲染工作。
六、数组和 Vue 2 的限制
Object.defineProperty 本身可以劫持已经存在的数组下标,但 Vue 2 的 Observer 并不是为每个数组下标都定义 getter。Vue 2 对数组采用七个变异方法的拦截:
push、pop、shift、unshift、splice、sort、reverse
因此 Vue 2 中下面两种写法不会自动触发更新:
this.items[index] = nextValue
this.items.length = 0
可以使用:
this.$set(this.items, index, nextValue)
this.items.splice(index, 1, nextValue)
Vue 3 的 Proxy 可以直接追踪对象新增/删除、数组下标和 length,但它仍有数组 instrumentation 来处理迭代、身份查询和批量更新。两代实现不能混为同一段源码。
七、computed 和生命周期应该怎样理解
原文尝试手写 computed 和 mounted,这对理解概念有帮助,但真实 computed 并不是简单地给实例定义一个 getter:
- Vue 2 computed 使用 lazy watcher,并缓存上次结果;
- 依赖变化时会让 computed 失效,下一次访问再求值;
- computed 还会把依赖转发给外层渲染 watcher;
- Vue 3 使用
ComputedRefImpl、Dep 和调度机制; mounted只在客户端组件挂载后执行,SSR 不执行需要 DOM 的 mounted。
如果只是学习 JavaScript,可以写一个最小 getter:
const state = { a: 1, b: 2 }
const view = {
get sum() {
return state.a + state.b
},
}
但不要把它描述成 Vue computed 的完整实现。
八、Vue 3.5 的响应式方式
Vue 3 新项目通常这样写:
<script setup lang="ts">
import { computed, ref } from 'vue'
const singer = ref('周杰伦')
const count = ref(1)
const double = computed(() => count.value * 2)
function updateSinger() {
singer.value = '新名字'
}
</script>
<template>
<p>{{ singer }}</p>
<p>{{ double }}</p>
<button @click="updateSinger">修改</button>
</template>
reactive() 适合对象、数组和 Map/Set:
import { reactive, toRefs } from 'vue'
const state = reactive({
user: { name: 'Vue' },
items: [],
})
const { user, items } = toRefs(state)
setup() 和 <script setup> 不使用 Options API 的 this,而是使用闭包中的 ref、reactive、props、emit 和 composable。Vue 3 的对象响应式主要依赖 Proxy,但 ref.value 仍然是 getter/setter 形式的响应式引用。
九、依赖收集的真实边界
教学版经常写成:
Dep = 一个数组
Watcher = 一个回调
get = addSub
set = notify
Vue 2.7 的真实实现还包括依赖去重、依赖清理、target stack、数组依赖、computed watcher、user watcher、渲染 watcher 和 scheduler。Vue 3.5 则使用 targetMap、Dep、Link、ReactiveEffect 和 batch 调度。简化模型用于建立直觉,源码结论必须以对应版本为准。
十、原文调试图片
以下图片保留原文用于说明递归观察和数据代理的调试截图,已经本地化:



图片只用于辅助理解,不能作为当前源码的版本证明。
十一、总结
- MVVM 是组织 Model、View 和 ViewModel 的一种方式;
- Vue 2.7 的响应式主线可以概括为 getter/setter、Observer、Dep、Watcher 和 scheduler;
- 一个教学版 MVVM 可以用
defineProperty + Dep + Watcher帮助理解依赖收集; - 教学代码必须明确边界,不能把简化实现冒充 Vue 源码;
- Vue 3.5 的对象和数组主要使用 Proxy,
ref.value仍有 getter/setter; - Vue 2 数组下标、新属性和 length 修改存在响应式限制,Vue 3 的 Proxy 改善了这些边界;
- 新项目应优先学习 Vue 3 Composition API,不要把 Vue 2 的
this._data、指令和 watcher 代码直接迁移过去。