Mini Vue · 单文件 HTML 原理教程

目标是把 Vue 2 的核心机制全部放进一个页面里:读得懂、跑得动、看得见依赖收集和更新链路。

一页学完:Observer / Dep / Watcher / Compiler / v-model

0. 整体架构

这个 mini-vue 专注于“最小可解释闭环”:数据变化如何一步步驱动 DOM 更新。

先记住一句话:模板里用到的数据,会被记录;数据一变,只更新用到它的地方。

  • 入口 Vue:初始化 data、代理属性、启动编译。
  • Observer:用 Object.defineProperty 拦截 getter / setter。
  • Dep + Watcher:建立“谁依赖了谁”,并在变更时定点通知。
  • Compiler:识别 {{ }}v-model,把数据和 DOM 绑定起来。
  • 初始化:先把 data 变成响应式,再扫描模板,把绑定关系建好。
  • 读数据:触发 getter,完成“依赖收集”。
  • 写数据:触发 setter,通知相关 Watcher 更新 DOM。

1. proxyData:让 data 挂到实例上

vm.$data.message 简化成 vm.message,符合 Vue 的使用习惯。

这一章不负责更新视图,只负责“访问体验”:把 data 的键代理到 vm,后面模板和业务代码都能直接用 vm.xxx

class Vue {
  constructor(options) {
    // 保存配置和挂载点
    this.$options = options || {};
    this.$el = typeof options.el === "string" ? document.querySelector(options.el) : options.el;
    // 原始 data 对象
    this.$data = options.data || {};
    // 1) 先做 data 代理:vm.xxx -> vm.$data.xxx
    this.proxyData(this.$data);
    // 2) 再把 data 变成响应式对象
    new Observer(this.$data);
    // 3) 最后编译模板,建立数据和 DOM 的绑定
    new Compiler(this);
  }

  proxyData(data) {
    Object.keys(data).forEach((key) => {
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        // 访问 vm.xxx 时,转发到 vm.$data.xxx
        get: () => data[key],
        set: (newValue) => {
          // 简单防抖:值变化才写入
          if (data[key] !== newValue) {
            data[key] = newValue;
          }
        }
      });
    });
  }
}
  • 读取 vm.message 时,本质还是读 vm.$data.message
  • 写入 vm.message 时,本质还是写 vm.$data.message
  • 作用是统一入口:后面 Compiler / Watcher 都按同一套 key 工作。

2. Observer:把普通对象转成响应式对象

每个属性都有独立 dep。getter 负责依赖收集,setter 负责通知更新。

你可以把 defineReactive 理解为给每个字段安装“传感器”:谁读取了、谁修改了,都能被捕获。

class Observer {
  constructor(data) {
    // 从根对象开始递归响应式化
    this.walk(data, "");
  }

  walk(data, basePath) {
    if (!data || typeof data !== "object") return;
    Object.keys(data).forEach((key) => {
      const path = basePath ? basePath + "." + key : key;
      this.defineReactive(data, key, data[key], path);
    });
  }

  defineReactive(obj, key, val, path) {
    // 每个字段各自维护一个依赖集合
    const dep = new Dep(path);
    // 如果字段本身是对象,继续递归处理
    this.walk(val, path);

    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: () => {
        // 有活跃 watcher 时,把它收集进 dep
        if (Dep.target) {
          dep.addSub(Dep.target);
        }
        logger.push("read", "读取 " + path + " -> " + stringifyValue(val));
        return val;
      },
      set: (newValue) => {
        if (newValue === val) return;
        const oldValue = val;
        val = newValue;
        // 新值可能是对象,继续递归响应式化
        this.walk(newValue, path);
        logger.push("write", "写入 " + path + ": " + stringifyValue(oldValue) + " -> " + stringifyValue(newValue));
        // 通知依赖该字段的 watcher 执行更新
        dep.notify();
      }
    });
  }
}
  • 递归处理嵌套对象,所以 person.name 这类路径也能响应。
  • getter 里只做一件事:如果当前有 Watcher,就把它收集进 dep。
  • setter 里做两件事:更新值 + 调用 dep.notify()

3. Dep / Watcher:依赖收集 + 精准更新

创建 Watcher 时将其挂到 Dep.target,读取数据触发 getter 并完成订阅。

Dep 是“发布中心”,Watcher 是“订阅者”。哪个模板片段用到某个字段,就会有对应 Watcher 订阅它。

class Dep {
  constructor(path) {
    this.path = path;
    // Set 去重,避免重复订阅
    this.subs = new Set();
  }

  addSub(watcher) {
    if (!watcher || typeof watcher.update !== "function") return;
    if (!this.subs.has(watcher)) {
      this.subs.add(watcher);
      logger.push("collect", watcher.label + " 订阅 -> " + this.path);
    }
  }

  notify() {
    logger.push("notify", this.path + " 触发更新,订阅数量: " + this.subs.size);
    this.subs.forEach((watcher) => watcher.update());
  }
}
Dep.target = null;

class Watcher {
  constructor(vm, key, cb, source) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;
    this.source = source || "unknown";
    this.id = ++Watcher.uid;
    this.label = "Watcher#" + this.id + "(" + this.key + ")";

    // 依赖收集窗口开始:告诉 getter 当前是谁在读取
    Dep.target = this;
    // 读取一次 key,触发 getter,让 dep 记住我
    this.oldValue = this.getValue(vm, key);
    // 依赖收集结束
    Dep.target = null;

    logger.push("watcher", this.label + " 初始化,值: " + stringifyValue(this.oldValue));
  }

  update() {
    const newValue = this.getValue(this.vm, this.key);
    // 仅在值变化时执行回调,减少无效更新
    if (newValue !== this.oldValue) {
      const oldValue = this.oldValue;
      this.oldValue = newValue;
      logger.push("update", this.label + " 更新: " + stringifyValue(oldValue) + " -> " + stringifyValue(newValue));
      this.cb(newValue);
    }
  }

  getValue(vm, key) {
    return key.split(".").reduce((data, currentKey) => {
      if (data == null) return undefined;
      return data[currentKey.trim()];
    }, vm.$data);
  }
}
Watcher.uid = 0;
  • 关键点:Dep.target 让 getter 知道“当前是谁在读我”。
  • 数据变化后,dep 只通知订阅了该字段的 Watcher,不会全量重渲染。
  • Watcher 比较新旧值,不变就不触发回调,避免无效更新。

4. Compiler:处理插值和指令

文本节点匹配 {{ key }},元素节点匹配 v-model,两者都创建 Watcher。

Compiler 的目标是把“模板语法”变成“可执行绑定”。编译完成后,数据和 DOM 就建立了双向联系。

class Compiler {
  constructor(vm) {
    this.vm = vm;
    this.el = vm.$el;
    this.compile(this.el);
  }

  compile(el) {
    // 扫描当前层所有子节点,分别处理文本和元素
    const childNodes = Array.from(el.childNodes);
    childNodes.forEach((node) => {
      if (this.isTextNode(node)) {
        this.compileText(node);
      } else if (this.isElementNode(node)) {
        this.compileElement(node);
      }

      if (node.childNodes && node.childNodes.length) {
        // 递归处理下一层
        this.compile(node);
      }
    });
  }

  compileElement(node) {
    Array.from(node.attributes).forEach((attr) => {
      const attrName = attr.name;
      if (!this.isDirective(attrName)) return;
      // v-model="message" -> directiveName=model, key=message
      const directiveName = attrName.slice(2);
      const key = attr.value.trim();
      this.update(node, key, directiveName);
    });
  }

  modelUpdater(node, vm, key) {
    const updateFn = (value) => {
      node.value = value == null ? "" : value;
    };
    // 数据 -> 视图:数据变化时自动更新 input.value
    new Watcher(vm, key, updateFn, "v-model");
    updateFn(this.getValue(vm, key));

    node.addEventListener("input", () => {
      // 视图 -> 数据:用户输入回写到 data
      logger.push("input", "input 事件回写 " + key + " = " + stringifyValue(node.value));
      this.setValue(vm, key, node.value);
    });
  }

  compileText(node) {
    const text = node.textContent;
    if (!/\{\{([^}]+)\}\}/.test(text)) return;
    node.originalTextContent = text;

    const reg = /\{\{([^}]+)\}\}/g;
    const keys = [];
    text.replace(reg, (_, key) => {
      const cleaned = key.trim();
      if (!keys.includes(cleaned)) keys.push(cleaned);
      return _;
    });

    keys.forEach((key) => {
      // 每个插值 key 创建一个 watcher,任一变化就重算文本
      new Watcher(this.vm, key, () => this.updateTextNode(node), "text");
    });
    this.updateTextNode(node);
  }
}
  • {{ message }} 属于“数据 -> 视图”:数据变了自动改文本。
  • v-model 额外加了“视图 -> 数据”:用户输入时回写 data。
  • 所以 v-model 本质是两个单向绑定的组合。