手写一个简易 MVVM 框架:数据劫持、模板编译与发布订阅的整合
各位开发者朋友,大家好!今天我们来一起手写一个简易但完整的 MVVM 框架。这个框架虽然不复杂,但它融合了前端开发中最核心的三大技术点:
- 数据劫持(响应式原理)
- 模板编译(视图更新机制)
- 发布-订阅模式(状态同步机制)
我们将从零开始构建它,让你真正理解 Vue.js 这类框架底层是如何工作的。文章会以讲座形式展开,逻辑清晰、代码详实、语言自然,适合有一定 JavaScript 基础的同学阅读。
一、什么是 MVVM?
MVVM 是 Model-View-ViewModel 的缩写,是一种用于构建用户界面的设计模式:
| 层级 | 职责 |
|---|---|
| Model | 数据层,通常是 JS 对象或 API 返回的数据 |
| View | UI 层,HTML + CSS 构成的页面结构 |
| ViewModel | 连接 Model 和 View 的桥梁,负责数据绑定和事件处理 |
在我们的框架中,ViewModel 就是我们要实现的核心对象 —— 它监听数据变化,并自动更新 DOM。
二、整体架构设计
我们先定义一个简单的入口类MVVM,它包含以下关键功能:
class MVVM { constructor(options) { this.$options = options; this.$data = options.data; // 1. 数据劫持:让 data 变成响应式的 observe(this.$data); // 2. 编译模板:将 {{xxx}} 替换为实际值 new Compile(this.$options.el, this); } }接下来我们分步实现这三个模块:observe(数据劫持)、Compile(模板编译)、Watcher(发布订阅)。
三、第一步:数据劫持(observe)
目标:让this.$data中的所有属性变成“可观察”的,一旦修改就能触发更新。
核心思想:
使用Object.defineProperty劫持每个属性的 getter/setter,当访问或修改时通知订阅者。
function observe(data) { if (!data || typeof data !== 'object') return; Object.keys(data).forEach(key => { defineReactive(data, key, data[key]); }); } function defineReactive(obj, key, val) { const dep = new Dep(); // 每个属性对应一个 Dep 实例 Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { // 如果有 watcher 正在读取该属性,则添加到依赖列表 if (Dep.target) { dep.addSub(Dep.target); } return val; }, set(newVal) { if (newVal === val) return; val = newVal; // 数据变更后通知所有订阅者(watcher) dep.notify(); } }); }这里引入了一个新的概念:Dep(依赖收集器)
class Dep { constructor() { this.subs = []; // 存储所有订阅者(watcher) } addSub(sub) { this.subs.push(sub); } notify() { this.subs.forEach(sub => sub.update()); } }注意:Dep.target是一个全局变量,用来临时保存当前正在执行的 Watcher(后续详解)。
这一步完成后,任何对
$data的访问都会被拦截,且赋值时能触发更新!
四、第二步:模板编译(Compile)
目标:解析 HTML 中的插值表达式{{xxx}},并将其替换为真实数据。
比如:
<div id="app"> <p>{{name}}</p> <p>{{age}}</p> </div>我们要把它变成:
<div id="app"> <p>张三</p> <p>25</p> </div>编译过程分为两步:
Step 1:遍历 DOM 节点,找到所有{{xxx}}表达式
class Compile { constructor(el, vm) { this.el = document.querySelector(el); this.vm = vm; // 把真实 DOM 移动到 fragment 中提高性能 this.fragment = this.nodeToFragment(this.el); // 编译 fragment 中的内容 this.compileElement(this.fragment); // 最终把 fragment 插入原容器 this.el.appendChild(this.fragment); } nodeToFragment(el) { const fragment = document.createDocumentFragment(); let child; while ((child = el.firstChild)) { fragment.appendChild(child); } return fragment; } compileElement(node) { if (node.nodeType === 1) { // 元素节点,如 <p>、<div> this.compileAttrs(node); } else if (node.nodeType === 3) { // 文本节点,如 "Hello {{name}}" this.compileText(node); } // 递归子节点 Array.from(node.childNodes).forEach(child => { this.compileElement(child); }); } compileAttrs(node) { const attrs = node.attributes; Array.from(attrs).forEach(attr => { const attrName = attr.name; const exp = attr.value; if (attrName.startsWith('v-bind:')) { const key = attrName.slice(7); // v-bind:name -> name this.bindAttr(node, key, exp); } }); } compileText(node) { const text = node.textContent.trim(); const reg = /{{(.+?)}}/g; // 匹配 {{xxx}} if (reg.test(text)) { node.textContent = text.replace(reg, (_, key) => { // 创建 watcher 监听这个 key 的变化 new Watcher(this.vm, key, (newVal) => { node.textContent = text.replace(reg, (_, k) => newVal); }); return this.vm.$data[key]; }); } } bindAttr(node, key, exp) { new Watcher(this.vm, exp, (newVal) => { node.setAttribute(key, newVal); }); node.setAttribute(key, this.vm.$data[exp]); } }关键点说明:
- 使用
document.createDocumentFragment()避免频繁 DOM 操作。 compileText处理文本节点中的{{xxx}},并创建 Watcher。bindAttr支持v-bind:绑定属性(例如<img src="{{url}}" />)。
现在,只要你在data中改了某个字段,对应的 DOM 就会自动刷新!
五、第三步:发布订阅(Watcher)
这是整个框架最精妙的部分 —— Watcher 是连接数据和视图的纽带。
Watcher 类定义如下:
class Watcher { constructor(vm, exp, cb) { this.vm = vm; this.exp = exp; this.cb = cb; // 当前 watcher 被 push 到 Dep.target 上 Dep.target = this; // 触发一次 getter 获取初始值(同时触发 dep.addSub) this.value = this.get(); Dep.target = null; } get() { return this.vm.$data[this.exp]; // 触发 defineReactive 中的 getter } update() { const newVal = this.get(); if (newVal !== this.value) { this.cb(newVal); // 更新回调函数 this.value = newVal; } } }工作流程总结:
- 创建 Watcher 时,设置
Dep.target = this - 执行
this.get()→ 触发defineReactive.get()→ 添加当前 Watcher 到 Dep 的 subs 列表 - 后续数据变更 →
dep.notify()→ 所有 Watcher 执行update() - 在
update()中调用用户传入的回调函数(如更新 DOM)
这就是经典的观察者模式(Observer Pattern)!
六、完整示例演示
让我们用一个完整的例子验证整个框架是否正常工作:
<!DOCTYPE html> <html> <head> <title>MVVM Demo</title> </head> <body> <div id="app"> <h1>{{title}}</h1> <p>姓名:<span v-bind:text="name"></span></p> <p>年龄:<span>{{age}}</span></p> <button onclick="app.change()">改变数据</button> </div> <script> class MVVM { constructor(options) { this.$options = options; this.$data = options.data; observe(this.$data); new Compile(this.$options.el, this); } change() { this.$data.name = "李四"; this.$data.age += 1; } } const app = new MVVM({ el: '#app', data: { title: '我的应用', name: '张三', age: 25 } }); window.app = app; </script> </body> </html>运行效果:
- 页面显示:“我的应用”、“张三”、“25”
- 点击按钮后:
- 名字变为 “李四”
- 年龄加 1,变为 26
- 自动更新 DOM,无需手动操作!
整个过程完全由框架内部完成,你只需要关心业务数据!
七、对比传统做法 vs MVVM 框架
| 方式 | 缺点 | MVVM 解决方案 |
|---|---|---|
手动操作 DOM(如document.getElementById(...).innerHTML = xxx) | 易出错、难以维护 | 数据驱动视图,减少手动 DOM 操作 |
| 事件监听 + DOM 更新分离 | 逻辑混乱、耦合度高 | Watcher 统一管理数据变更与视图同步 |
| 不支持双向绑定 | 需要额外逻辑处理输入框同步 | 可扩展为双向绑定(只需加 input 监听) |
八、优化建议 & 扩展方向
目前框架已经具备基础能力,但可以进一步增强:
| 功能 | 实现思路 |
|---|---|
| 双向绑定(v-model) | 监听 input 输入事件,同步到 data;data 变化也同步回 input |
| 计算属性(computed) | 将 computed 字段作为 Watcher,缓存结果避免重复计算 |
| 生命周期钩子 | 如 mounted、updated,提供 hook 函数供用户自定义行为 |
| 指令系统(v-if / v-for) | 扩展 Compile 类,支持更多语法糖 |
| 异步更新队列 | 避免多次 set 引起的频繁渲染,合并成一次批量更新 |
这些都可以基于现有结构轻松扩展!
九、总结
今天我们亲手打造了一个简易但完整的 MVVM 框架,其核心在于:
- 数据劫持(observe):通过
Object.defineProperty实现响应式; - 模板编译(Compile):识别并处理
{{xxx}}和v-bind:; - 发布订阅(Watcher + Dep):建立数据与视图之间的通信链路。
这套机制正是 Vue.js 的前身,也是现代前端框架(React Hooks、Svelte 等)背后的通用思想。
推荐学习路径:
- 先理解本文内容,再看 Vue 源码(尤其是
observer、compiler、watcher) - 尝试自己加上
v-model、computed、filter等特性 - 最终目标是掌握“如何从无到有搭建一个小型前端框架”
希望这篇文章能帮你打通前端框架的理解壁垒,不再只是“会用”,而是“懂原理”。
谢谢大家!欢迎留言交流