手写一个简易 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等特性 - 最终目标是掌握“如何从无到有搭建一个小型前端框架”
希望这篇文章能帮你打通前端框架的理解壁垒,不再只是“会用”,而是“懂原理”。
谢谢大家!欢迎留言交流 👇