基于 Vue 的思想实现一个简易 MVVM
原文从 MV* 模式、观察者/发布订阅、Vue 运行机制和一个自定义
b-*指令系统展开。本文保留这条学习路线,并把抓取后损坏的代码整理成一个有边界、可运行的教学实现。它用于理解原理,不是 Vue 源码,也不建议替代 Vue 处理生产项目。
一、MV* 设计模式的演变
如果把 GUI 应用的关注点拆开,通常可以看到:
- View:展示界面并接收用户交互;
- Model:业务数据和领域状态;
- Logic:处理输入、业务规则和状态同步。
MVC、MVP、MVVM 都是在尝试拆分这些职责。它们不是前端框架必须严格遵循的语法,而是帮助组织代码的概念模型。
MVC
经典 MVC 中,View 接收用户操作,Controller 协调输入和业务,Model 保存状态并通知相关 View:

优点:
- 职责分离;
- Model 可以被多个视图使用;
- Controller 和业务逻辑有机会复用。
局限:
- Controller 可能不断膨胀;
- View 与 Model 的依赖关系在不同实现中差异很大;
- 经典 GUI MVC 不能直接等同于服务端 MVC。
服务端 MVC 和 JSP Model 1/2
JSP Model 1 把页面、控制逻辑和数据访问混在 JSP/JavaBean 中,职责较为混乱:

JSP Model 2 把请求处理、模型和视图分开,更接近服务端 MVC:

服务端 MVC 和浏览器中的响应式 UI 不是同一个运行环境:HTTP 请求通常是无状态的,页面是否保留状态还涉及 Cookie、Session、缓存和客户端脚本。

MVP
MVP 让 View 尽量变成被动视图,由 Presenter 负责协调 Model 和 View:

这样可以让 View 更容易测试和复用,但 Presenter 也可能变得很厚。
MVVM
MVVM 在 View 和 Model 之间加入 ViewModel。ViewModel 通常包含状态、派生状态、事件处理和数据绑定逻辑:

“数据双向绑定”常被用来描述 MVVM,但需要准确理解:
Model → View:响应式渲染
View → Model:用户事件/输入处理
这不是任意对象修改都会自动互相同步。Vue 的 v-model 也是属性绑定加事件监听的语法糖;组件之间仍然应遵循 props 下行、emits 上行的单向数据流。
二、观察者模式和发布/订阅模式
1. 观察者模式
观察者模式中,Subject 维护 Observer 列表。Observer 主动注册,Subject 状态变化后直接调用观察者的更新方法:

subject.subscribe(observer)
subject.notify(change)
Subject 通常知道观察者集合,因此耦合关系比较直接。Vue 2 的 Dep/Watcher、Vue 3 的依赖集合和 effect,都是“依赖收集—触发更新”思想的具体实现,但不能把它们简单等同于业务层的 Subject/Observer 类。
2. 发布/订阅模式
发布者和订阅者通过事件通道或 broker 传递消息,发布者通常不直接持有订阅者对象:

channel.subscribe('user:updated', handler)
channel.publish('user:updated', user)
两者的常见区别:
| 维度 | 观察者模式 | 发布/订阅模式 |
|---|---|---|
| 中间层 | 通常没有独立 broker | 有事件通道或 broker |
| 依赖 | Subject 直接维护 Observer | Publisher 和 Subscriber 可解耦 |
| 调试 | 依赖关系较容易沿对象查找 | 事件名可能形成隐式依赖 |
| 适用 | 响应式依赖、对象状态通知 | 跨模块事件、消息分发 |
“发布订阅一定是双向通信”并不准确。通信方向取决于是否建立反向频道;模式本身既不保证双向,也不自动解决事件顺序、异常、取消订阅和内存泄漏。
三、Vue 的运行机制简述
原文以 Vue 2 Runtime + Compiler 为主要背景。Vue 3.5 的编译器、响应式和 renderer 已经重写,但总体链路仍可以这样理解:
组件选项 / <script setup> / template
↓
编译器生成 render function
↓
首次执行 render 生成 VNode
↓
renderer mount 到宿主环境
↓
响应式状态变化 → 重新调度组件更新 → patch

1. Vue 2 的初始化和编译
Vue 2 Runtime + Compiler 构建中,如果组件提供 template 而没有 render,会经历大致流程:
parse:把模板解析为 AST;optimize:标记静态节点;generate:生成 render function 字符串;$mount:创建渲染 watcher 并首次执行 render;- patch:把 VNode 创建、更新为真实 DOM。
Vue 2 的响应式主要使用 Object.defineProperty、Dep 和 Watcher。首次执行 render 时读取响应式数据,getter 收集当前渲染 watcher;数据 setter 触发后,相关 watcher 入队并进行批量更新。
2. Vue 3 的编译和渲染
Vue 3 的 template 编译可以抽象为:
parse → transform → codegen
运行时还会利用:
- 静态提升;
- Patch Flags;
- Block Tree;
- keyed children 的高效更新;
- 组件更新 job 的调度和去重。
因此,不能把 Vue 的性能收益概括成“Virtual DOM 一定比 DOM 快”或“Diff 总是产生最小 DOM 操作”。实际成本取决于组件边界、编译优化、列表 key、响应式范围和真实设备。
3. Vue 3 的响应式
Vue 3 主要使用 Proxy/Reflect 拦截对象操作,配合 track/trigger 和 ReactiveEffect:

读取 reactive 对象属性
↓ track(target, key)
建立 effect 与属性的依赖
↓
写入、删除或修改集合
↓ trigger(target, key)
调度组件、computed 或 watcher
Proxy 改善了新增/删除属性、数组和 Map/Set 等操作的覆盖范围,但不是没有成本,也无法在 IE11 中被完整 polyfill。ref、computed、scheduler 和集合 instrumentation 仍然是响应式系统的重要部分。
四、基于 Vue 机制实现一个简易 MVVM
下面实现一个有限范围的 b-* 指令系统,支持:
b-model="input.message":输入框双向绑定;b-text="input.message":安全地更新textContent;b-on-click="increment":绑定实例方法;{{ path }}:简单文本插值;- 通过 Mediator 按数据路径发布变化;
destroy()清理事件和订阅。
它有意不实现 Vue 的完整模板语法、组件、条件渲染、列表、表达式解析器和安全沙箱。
1. HTML 使用方式
<div id="app">
<label>
消息:
<input b-model="input.message">
</label>
<p b-text="input.message"></p>
<p>插值:{{ input.message }}</p>
<button b-on-click="increment">计数:{{ count }}</button>
</div>
<script src="./mini-mvvm.js"></script>
<script>
const vm = new MiniMVVM({
el: '#app',
data: {
input: { message: 'Hello MVVM!' },
count: 0,
},
methods: {
increment() {
this.count += 1
},
},
})
</script>

2. Mediator
class Mediator {
constructor() {
this.channels = new Map()
}
subscribe(channel, callback) {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set())
}
const subscribers = this.channels.get(channel)
subscribers.add(callback)
return () => {
subscribers.delete(callback)
if (subscribers.size === 0) this.channels.delete(channel)
}
}
publish(channel, payload) {
const subscribers = this.channels.get(channel)
if (!subscribers) return
for (const callback of [...subscribers]) {
callback(payload)
}
}
}
原文使用数字 uid 取消订阅,这种方式可以工作,但返回 unsubscribe 函数更不容易误删,也能让组件在销毁时清理自身监听器。
3. 路径读取和设置
function getPath(object, path) {
return path.split('.').reduce((value, key) => value?.[key], object)
}
function setPath(object, path, nextValue) {
const keys = path.split('.')
const lastKey = keys.pop()
const target = keys.reduce((value, key) => value[key], object)
if (!target || !lastKey) {
throw new Error(`无法设置路径:${path}`)
}
target[lastKey] = nextValue
}
任意用户输入都不应被当成 JavaScript 表达式执行。这里仅支持点分隔属性路径,是为了避免自行实现一个不安全的 eval 模板表达式解析器。
4. Object.defineProperty 数据劫持
function observe(object, basePath, notify, seen = new WeakSet()) {
if (!object || typeof object !== 'object' || seen.has(object)) return
seen.add(object)
for (const key of Object.keys(object)) {
let value = object[key]
const path = basePath ? `${basePath}.${key}` : key
observe(value, path, notify, seen)
Object.defineProperty(object, key, {
enumerable: true,
configurable: true,
get() {
return value
},
set(nextValue) {
if (Object.is(nextValue, value)) return
value = nextValue
observe(value, path, notify, seen)
notify(path, nextValue)
},
})
}
}
这段代码是 Vue 2 响应式思想的极简练习,不是完整实现:
- 只能为初始化时已经存在的普通对象属性定义 getter/setter;
- 新增/删除属性不会自动被捕获,需要显式 API 或重新处理;
- 数组索引和
length需要额外处理,Vue 2 通过改写部分变更方法弥补; - Map/Set 需要独立的集合操作拦截;
- 循环引用、不可扩展对象、非枚举属性和原型链还有更多边界。
原文说 Object.defineProperty “无法深层监听对象”不够准确:只要递归遍历已知嵌套对象,它可以深层观察已存在的属性;真正的限制是新增/删除属性、数组和集合等操作覆盖不完整,以及初始化递归成本较高。

5. 编译视图指令
class Compiler {
constructor(root, vm) {
this.root = root
this.vm = vm
this.cleanups = []
}
compile() {
this.walk(this.root)
return () => {
for (const cleanup of this.cleanups) cleanup()
this.cleanups = []
}
}
walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
this.compileText(node)
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
for (const attribute of [...node.attributes]) {
const { name, value } = attribute
if (name === 'b-text') {
this.bindText(node, value)
node.removeAttribute(name)
} else if (name === 'b-model') {
this.bindModel(node, value)
node.removeAttribute(name)
} else if (name.startsWith('b-on-')) {
this.bindEvent(node, name.slice(5), value)
node.removeAttribute(name)
}
}
for (const child of [...node.childNodes]) this.walk(child)
}
compileText(node) {
const template = node.textContent
if (!template || !/{{\s*([\w.]+)\s*}}/.test(template)) return
const update = () => {
node.textContent = template.replace(
/{{\s*([\w.]+)\s*}}/g,
(_, path) => String(getPath(this.vm.$data, path) ?? ''),
)
}
this.bindPath(update, template)
}
bindText(node, path) {
this.bindPath(() => {
node.textContent = String(getPath(this.vm.$data, path) ?? '')
}, path)
}
bindModel(node, path) {
const update = () => {
node.value = String(getPath(this.vm.$data, path) ?? '')
}
const input = event => {
setPath(this.vm.$data, path, event.target.value)
}
node.addEventListener('input', input)
this.cleanups.push(() => node.removeEventListener('input', input))
this.bindPath(update, path)
}
bindEvent(node, eventName, methodName) {
const method = this.vm.$methods[methodName]
if (typeof method !== 'function') {
throw new Error(`方法不存在:${methodName}`)
}
const handler = event => method.call(this.vm, event)
node.addEventListener(eventName, handler)
this.cleanups.push(() => node.removeEventListener(eventName, handler))
}
bindPath(update, template) {
update()
const paths = [...template.matchAll(/\b[\w]+(?:\.[\w]+)*/g)]
.map(match => match[0])
.filter(path => getPath(this.vm.$data, path) !== undefined)
for (const path of new Set(paths)) {
const unsubscribe = this.vm.$mediator.subscribe(path, update)
this.cleanups.push(unsubscribe)
}
}
}
这个 Compiler 只支持教学所需的有限语法。生产模板编译器必须处理 HTML 解析、指令参数、表达式作用域、组件、插槽、事件修饰符、XSS 和编译缓存,不能通过简单正则替代。
6. ViewModel
class MiniMVVM {
constructor(options) {
this.$data = options.data || {}
this.$methods = options.methods || {}
this.$mediator = new Mediator()
this.$destroyed = false
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, '', (path, value) => {
this.$mediator.publish(path, value)
})
const root = typeof options.el === 'string'
? document.querySelector(options.el)
: options.el
if (!root) throw new Error('找不到挂载元素')
this.$compiler = new Compiler(root, this)
this.$stopCompiler = this.$compiler.compile()
}
$destroy() {
if (this.$destroyed) return
this.$destroyed = true
this.$stopCompiler?.()
}
}

在这个练习中,流程是:
初始化 data
↓
defineProperty 劫持已知属性
↓
Compiler 解析 b-* 指令和插值
↓
View 事件修改 Model
↓
setter 发布路径变化
↓
Mediator 通知绑定该路径的视图
它和 Vue 的关键差异是:
- 没有基于读取行为的精确依赖收集;
- 没有组件实例、VNode、renderer 和 scheduler;
- 没有异步批量更新;
- 只支持简单点路径,不支持模板表达式;
- 不处理数组、Map/Set、动态属性和组件生命周期。
五、Vue 3 写法的现代对应关系
在真实 Vue 3 项目中,不应继续维护自定义 b-* 编译器,而可以这样写:
<script setup lang="ts">
import { computed, reactive, watchEffect } from 'vue'
const state = reactive({
message: 'Hello Vue',
count: 0,
})
const label = computed(() => `${state.message} (${state.count})`)
function increment() {
state.count++
}
watchEffect(() => {
console.log('当前状态:', state.message, state.count)
})
</script>
<template>
<input v-model="state.message">
<p>{{ label }}</p>
<button @click="increment">增加</button>
</template>
这里:
reactive/ref提供响应式状态;computed提供缓存的派生状态;watchEffect承担自动追踪的副作用;- 模板编译器处理插值、
v-model和事件; - renderer 将 VNode patch 到真实 DOM。
组件通信仍然推荐 props/emits:
<!-- Parent.vue -->
<Child :value="state.message" @update:value="state.message = $event" />
Vue 3 中也可以使用:
<Child v-model="state.message" />
v-model 不是任意 Model 和 View 自动双向修改,而是编译为 modelValue prop 和 update:modelValue 事件;多个模型可以使用 v-model:title 等参数。
六、常见错误和边界
- Vue、React、Angular 都是严格 MVVM 实现:不严谨。它们都借鉴了数据驱动、组件化或 MV* 思想,但具体数据流和渲染模型不同;
- 双向绑定就是数据自动互相修改:不准确。Vue 的 View → Model 仍通过事件或
v-model约定; - Observer 和 Pub/Sub 完全相同:不准确,前者通常有直接依赖,后者通过消息通道解耦;
Object.defineProperty无法递归监听:不准确,递归已知属性可以做到,但新增属性、数组和集合支持不完整;- Proxy 解决所有响应式问题:不准确。Proxy 扩大拦截范围,但仍需要依赖收集、effect、scheduler 和集合处理;
- 用
innerHTML更新用户输入安全:错误。动态 HTML 必须经过可信来源或清洗,普通文本使用textContent; - 自定义 MVVM 代码可以直接替代 Vue:错误。教学实现缺少模板安全、组件系统、异步调度、SSR 和完整 renderer;
- Vue 一定只做最小 DOM 更新:错误。优化取决于编译信息、key、组件更新范围和具体 renderer。
七、总结
MVVM 的学习价值在于理解 View、Model 和同步逻辑如何解耦。一个简易实现通常需要:
- 保存 Model;
- 劫持或代理状态变化;
- 解析有限的视图绑定;
- 处理 View → Model 的输入事件;
- 在 Model 变化后通知对应视图;
- 在销毁时清理事件和订阅。
Vue 2 使用 defineProperty、Dep、Watcher 和模板编译;Vue 3 使用 Proxy、ReactiveEffect、scheduler、编译器优化和 renderer。理解这些机制可以帮助我们正确使用 ref、reactive、computed、watch、v-model 和组件通信,但不要把教学版 MVVM 的代码直接当作 Vue 源码或生产框架。
参考资料
- Vue 3 深入响应式原理
- Vue 3 响应式 API
- Vue 3 模板语法
- Vue 3
v-model - Vue 3 渲染机制
- Vue 3 自定义 renderer
- Vue 2 响应式原理
- MDN
Object.defineProperty - MDN
Proxy - Martin Fowler:GUI Architectures
原文作者:子弈。原文关于 MV* 历史、观察者/发布订阅、Vue 运行机制、数据劫持、视图编译和简易 MVVM 的主线予以保留;课程推广、抓取噪声、损坏的代码块、不安全的 innerHTML 说明及错误的响应式绝对化表述已清理或修正。