50行代码的MVVM,感受闭包的艺术
Category(分类): Vue Status: 已校订(Vue 2 风格教学实验)
版本说明:原文把示例称为“MVVM/双向绑定”,但它实际只实现了 Data → View 的浅层响应式文本更新,没有输入事件或
v-model,也不是 Vue 源码的 50 行复刻。下面先保留原作者的“50 行”核心代码及分析,再紧邻说明局限;安全改良版作为追加内容,不替代原实验。
我们先看原运行结果:

name 和 age 初次渲染出来;2 秒后,脚本修改 name,页面随之更新。这里是代码修改 Model 后触发 View 更新,不是用户操作 View 后写回 Model。
原作者的“50 行”核心代码(历史教学实验)
class Vue {
constructor(opt) {
this.opt = opt
this.observe(opt.data)
const root = document.querySelector(opt.el)
this.compile(root)
}
observe(data) {
Object.keys(data).forEach((key) => {
const obv = new Observer()
data[`_${key}`] = data[key]
Object.defineProperty(data, key, {
get() {
Observer.target && obv.addSubNode(Observer.target)
return data[`_${key}`]
},
set(newVal) {
obv.update(newVal)
data[`_${key}`] = newVal
},
})
})
}
compile(node) {
;[].forEach.call(node.childNodes, (child) => {
if (!child.firstElementChild && /\{\{(.*)\}\}/.test(child.innerHTML)) {
const key = RegExp.$1.trim()
child.innerHTML = child.innerHTML.replace(
new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'gm'),
this.opt.data[key],
)
Observer.target = child
this.opt.data[key]
Observer.target = null
} else if (child.firstElementChild) {
this.compile(child)
}
})
}
}
class Observer {
constructor() {
this.subNode = []
}
addSubNode(node) {
this.subNode.push(node)
}
update(newVal) {
this.subNode.forEach((node) => {
node.innerHTML = newVal
})
}
}
原作者的执行思路是:
observe()遍历data的已有 key,每个 key 创建一个Observer;getter 读取时把当前 DOM 节点登记进去,setter 写入时通知这些节点。compile()从根节点递归扫描 DOM,找到{{ key }},先替换初始值,再借助Observer.target和一次属性读取完成登记。- 2 秒后的赋值直接触发 setter,
Observer.update()同步修改节点。
闭包确实存在,但原文“for 循环每次执行都形成闭包”“内层作用域中堆地址暴露”的说法不准确。这里是 forEach 回调每次调用形成独立词法环境,getter/setter 捕获当次的 key 与 obv;闭包是函数和其创建时词法环境中外部变量引用的组合。
这段代码还不是完整双向绑定:它没有任何 View → Data 的输入事件。另有几个重要局限:
data['_' + key]会污染用户数据;若用户本来就有_name,还会与name的内部存储冲突。遍历期间新增下划线属性也使数据形态变得意外。- 描述符未显式设置
enumerable、configurable,二者默认false,与普通对象原属性行为不同。 innerHTML会把数据当 HTML 解析,既可能破坏原节点的子结构,也可能在数据不可信时造成 XSS;mustache 本应使用纯文本语义。- 正则是贪婪匹配,一个文本节点多个插值、复杂表达式、嵌套对象、数组、新增字段等都未正确支持。
Observer.target没有栈和异常恢复,订阅不去重、不清理;更新也没有 Vue 的 VNode patch 与异步调度。- “后续 event loop 修改 key”不是更新成立的原因;同步赋值同样会立即触发这个 demo 的 setter。
原作者完整页面演示
下面保留原实验的完整运行形态,只清理抓取粘连和格式;其限制与上文相同,请勿作为生产实现:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>50 行 MVVM 历史实验</title>
</head>
<body>
<div id="app">
<h3>姓名</h3>
<p>{{ name }}</p>
<h3>年龄</h3>
<p>{{ age }}</p>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const opt = {
el: '#app',
data: { name: '检索中...', age: 30 },
}
new Vue(opt)
setTimeout(() => {
opt.data.name = '王永峰'
}, 2000)
})
class Vue {
constructor(opt) {
this.opt = opt
this.observe(opt.data)
this.compile(document.querySelector(opt.el))
}
observe(data) {
Object.keys(data).forEach((key) => {
const obv = new Observer()
data[`_${key}`] = data[key]
Object.defineProperty(data, key, {
get() {
Observer.target && obv.addSubNode(Observer.target)
return data[`_${key}`]
},
set(newVal) {
obv.update(newVal)
data[`_${key}`] = newVal
},
})
})
}
compile(node) {
;[].forEach.call(node.childNodes, (child) => {
if (!child.firstElementChild && /\{\{(.*)\}\}/.test(child.innerHTML)) {
const key = RegExp.$1.trim()
child.innerHTML = child.innerHTML.replace(
new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'gm'),
this.opt.data[key],
)
Observer.target = child
this.opt.data[key]
Observer.target = null
} else if (child.firstElementChild) {
this.compile(child)
}
})
}
}
class Observer {
constructor() {
this.subNode = []
}
addSubNode(node) {
this.subNode.push(node)
}
update(newVal) {
this.subNode.forEach((node) => {
node.innerHTML = newVal
})
}
}
</script>
</body>
</html>
原文完整演示的 GitHub 地址 可用于对照,但不要把它视为 Vue 源码。
追加:安全改良版最小实验
下面仍采用 Vue 2 风格的 Object.defineProperty,但使用闭包保存属性值,不再创建可能与用户字段冲突的 _name;更新专用文本节点而不是使用有 XSS 风险、且会破坏子节点的 innerHTML。
class MiniReactiveView {
constructor(options) {
this.options = options
this.observe(options.data)
const root = document.querySelector(options.el)
if (!root) throw new Error(`找不到根节点:${options.el}`)
this.compile(root)
}
observe(data) {
Object.keys(data).forEach((key) => {
const dep = new Dep()
let value = data[key]
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) dep.add(Dep.target)
return value
},
set(newValue) {
if (Object.is(newValue, value)) return
value = newValue
dep.notify(newValue)
},
})
})
}
compile(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
const textNodes = []
while (walker.nextNode()) textNodes.push(walker.currentNode)
textNodes.forEach((node) => {
const source = node.textContent ?? ''
const matches = [...source.matchAll(/\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g)]
if (!matches.length) return
const render = () => {
node.textContent = source.replace(
/\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g,
(_, key) => String(this.options.data[key] ?? ''),
)
}
matches.forEach(([, key]) => {
Dep.target = render
try {
this.options.data[key]
} finally {
Dep.target = null
}
})
render()
})
}
}
class Dep {
static target = null
constructor() {
this.subscribers = new Set()
}
add(subscriber) {
this.subscribers.add(subscriber)
}
notify() {
this.subscribers.forEach((subscriber) => subscriber())
}
}
完整页面:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>最小响应式文本更新实验</title>
</head>
<body>
<div id="app">
<h3>姓名</h3>
<p>当前姓名:{{ name }}</p>
<h3>年龄</h3>
<p>{{ age }} 岁</p>
</div>
<script>
// 此处放入上面的 MiniReactiveView 与 Dep
const options = {
el: '#app',
data: { name: '检索中...', age: 30 },
}
new MiniReactiveView(options)
setTimeout(() => {
options.data.name = '王永峰'
}, 2000)
</script>
</body>
</html>
改良版重点分析:闭包到底发生在哪里
Object.keys(data).forEach((key) => { ... }) 的 callback 每次调用都有自己的词法环境。定义在其中的 getter/setter 会捕获当次调用的 value 和 dep,所以函数离开后,这些状态仍能被对应属性访问。
这才是闭包的准确含义:函数与其创建时所处词法环境中外部变量引用的组合。不是“for 循环每执行一次就自动形成闭包”,也不是“内层作用域的堆地址暴露”。
如果改用传统 for 并以 var 声明循环变量,多个闭包可能共享同一个绑定;let/const 则提供块级或每次迭代绑定。本例使用 forEach callback,因此每次回调自然拥有独立参数 key。
依赖收集与更新链路
observe()为每个已有 key 创建一个Dep,getter/setter 通过闭包持有它。compile()扫描 Text 节点,找到简单的{{ key }}插值。- 收集时暂时把
render放到Dep.target,读取data[key]触发 getter,将其登记到对应 Dep。 - 外部代码赋值触发 setter,setter 通知 Dep,
render重新计算该文本节点。
这里的更新是 demo 的 setter 同步调用,不是 event loop 主动发现了变量变化。真实 Vue 会批处理组件更新,DOM 通常在下一次 tick 完成。
为什么它不是完整双向绑定
示例没有 input/change 监听,所以没有 View → Data。若只针对文本输入,可以补一个最小事件回路:
<input id="name-input">
const input = document.querySelector('#name-input')
input.value = options.data.name
input.addEventListener('input', (event) => {
options.data.name = event.target.value
})
这只是某一种控件的写回方式。Vue 的 v-model 会根据文本框、复选框、单选框、select 和组件选择不同属性/事件;Vue 3 组件 v-model 默认展开为:
<Child
:model-value="name"
@update:model-value="name = $event"
/>
因此应区分:响应式负责状态读取跟踪和变化通知,v-model 负责将表单/组件事件写回状态。
示例局限
这个几十行实验有意省略了生产框架必须处理的内容:
- 只观测初始化时已有的顶层属性,不支持嵌套对象、数组和新增/删除字段;
- 插值只支持简单标识符,不支持 Vue 模板表达式;
- 没有依赖栈、失效依赖清理、组件卸载清理或调度队列;
- 同一属性被多个节点读取虽可订阅,但没有 Vue 的组件/VNode patch;
- 直接操作 DOM,不包含编译器、生命周期、computed、watch、methods 或组件系统;
- 只实现 Data → View;即使增加上面的 input 监听,也只覆盖最简单文本输入。
使用 textContent 很重要:Vue 的 mustache 默认输出纯文本;不可信字符串不能直接交给 innerHTML 或 v-html,否则可能造成 XSS。
Vue 2 与 Vue 3 对照
| 维度 | 本文 / Vue 2 风格 | Vue 3 当前机制 |
|---|---|---|
| 对象拦截 | 遍历 key,使用 Object.defineProperty | reactive() 返回 Proxy |
| 原始值 | 示例直接包装对象属性 | ref() 用 .value getter/setter |
| 依赖 | 单一 Dep.target + callback 集合 | track / trigger + ReactiveEffect |
| 更新时机 | demo 同步更新 Text 节点 | Vue 批处理组件 DOM 更新 |
| 双向写回 | 未实现;需手写 DOM 事件 | v-model 组合属性与事件 |
Vue 3 的 Proxy 能拦截对象新增、删除、枚举等更多操作;ref 则仍通过 .value accessor 跟踪读写。闭包仍广泛存在于 JavaScript 实现中,但“闭包”不等于“Vue 响应式”。
结论
原示例最值得保留的是:getter 在读取时登记依赖,setter 在写入时通知依赖,以及 getter/setter 对每个字段私有 dep/value 的闭包捕获。准确名称应是“Vue 2 风格的浅层响应式文本更新实验”,而不是完整 MVVM、完整双向绑定或 Vue 源码实现。
官方参考
- Vue 3:深入响应式系统
- Vue 3:表单输入绑定
- Vue 3:模板语法与纯文本插值
- Vue 3:安全
- Vue 2:深入响应式原理
- MDN:闭包
- MDN:
innerHTML - MDN:
Object.defineProperty
作者:maodayeyeye 原文链接:https://juejin.cn/post/6844903619808985095 来源:稀土掘金。著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。