news 2026/3/2 7:58:32

手写一个简易的 MVVM 框架:数据劫持、模板编译与发布订阅的整合

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写一个简易的 MVVM 框架:数据劫持、模板编译与发布订阅的整合

手写一个简易 MVVM 框架:数据劫持、模板编译与发布订阅的整合

各位开发者朋友,大家好!今天我们来一起手写一个简易但完整的 MVVM 框架。这个框架虽然不复杂,但它融合了前端开发中最核心的三大技术点:

  1. 数据劫持(响应式原理)
  2. 模板编译(视图更新机制)
  3. 发布-订阅模式(状态同步机制)

我们将从零开始构建它,让你真正理解 Vue.js 这类框架底层是如何工作的。文章会以讲座形式展开,逻辑清晰、代码详实、语言自然,适合有一定 JavaScript 基础的同学阅读。


一、什么是 MVVM?

MVVM 是 Model-View-ViewModel 的缩写,是一种用于构建用户界面的设计模式:

层级职责
Model数据层,通常是 JS 对象或 API 返回的数据
ViewUI 层,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; } } }

工作流程总结:

  1. 创建 Watcher 时,设置Dep.target = this
  2. 执行this.get()→ 触发defineReactive.get()→ 添加当前 Watcher 到 Dep 的 subs 列表
  3. 后续数据变更 →dep.notify()→ 所有 Watcher 执行update()
  4. 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 框架,其核心在于:

  1. 数据劫持(observe):通过Object.defineProperty实现响应式;
  2. 模板编译(Compile):识别并处理{{xxx}}v-bind:
  3. 发布订阅(Watcher + Dep):建立数据与视图之间的通信链路。

这套机制正是 Vue.js 的前身,也是现代前端框架(React Hooks、Svelte 等)背后的通用思想。

推荐学习路径:

  • 先理解本文内容,再看 Vue 源码(尤其是observercompilerwatcher
  • 尝试自己加上v-modelcomputedfilter等特性
  • 最终目标是掌握“如何从无到有搭建一个小型前端框架”

希望这篇文章能帮你打通前端框架的理解壁垒,不再只是“会用”,而是“懂原理”。

谢谢大家!欢迎留言交流

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/27 7:38:43

SPA 应用中的路由切换内存泄漏:未注销的 Scroll 监听与全局变量

SPA 应用中的路由切换内存泄漏&#xff1a;未注销的 Scroll 监听与全局变量大家好&#xff0c;我是你们的技术讲师。今天我们来深入探讨一个在现代前端开发中非常常见却又容易被忽视的问题——单页应用&#xff08;SPA&#xff09;中的内存泄漏问题&#xff0c;特别是由 未注销…

作者头像 李华
网站建设 2026/3/1 5:31:03

游泳池漆专用施工涂料如何选?专业视角解析耐水抗氯性能

说到游泳池漆哪个牌子好&#xff0c;很多工程方都会头疼。去年我亲自跟进一个市政泳池项目&#xff0c;施工队试了三种漆都出现脱落。后来改用海瑞的水池蓝池底漆&#xff0c;效果确实稳定。 环保安全与施工便捷的双重优势 游泳池漆哪个牌子好&#xff0c;首先要看环保指标。海…

作者头像 李华
网站建设 2026/3/1 16:40:43

中国RFID设备十大企业综合实力解析

&#xff08;注&#xff1a;以下排名基于技术研发、市场份额、行业应用等维度综合评估&#xff09;行业领军企业远望谷技术深耕物联网识别领域20年&#xff0c;其超高频读写设备在铁路物流管理市占率达38%&#xff0c;自主研发的$ \lambda \frac{c}{f} $抗干扰算法显著提升多标…

作者头像 李华
网站建设 2026/2/27 10:00:04

C#静态成员总结 常量与只读字段总结 类的继承总结

&#x1f4dd; C# 静态成员总结&#x1f3af; 核心区别表格特性静态成员/方法非静态成员/方法关键字static无关键字属于谁属于类本身属于类的实例对象调用方式类名.成员名对象.成员名内存位置内存中只有一份每个对象都有独立副本何时创建类加载时&#xff08;程序启动&#xff…

作者头像 李华
网站建设 2026/2/27 12:50:12

都说东莞有好的AI销售厂家,实际情况真如此吗?

都说东莞有好的AI销售厂家&#xff0c;事实究竟如何&#xff1f;某行业实践验证&#xff0c;优质AI销售方案可使企业销售效率提升超30%。接下来&#xff0c;我们深入剖析东莞AI销售厂家的现状。现状与挑战当前&#xff0c;东莞AI销售厂家发展迅速&#xff0c;众多企业投身其中。…

作者头像 李华