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

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

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

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

我们将从零开始构建它,让你真正理解 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;
    }
  }
}

💡 工作流程总结:

  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 等特性
  • 最终目标是掌握“如何从无到有搭建一个小型前端框架”

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

谢谢大家!欢迎留言交流 👇

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注