Vue2响应式原理-高级

222 阅读6分钟

前言

思考两个问题,在Vue2中的data的数据,如何在视图中展示?当data变化时,如何使视图同步更新展示?这里涉及两个点:数据视图

一、思考

要完成上面的功能,需要解决两个问题:

  1. 监听data数据的变化。
    • 此时数据的变化无非就是获取对象属性对对象属性赋值
    • 可以使用Object.defineProperty做数据劫持,监听对象属性的改变。
  2. 当数据改变时,通知视图刷新。
    • 使用一种监听机制,那就是观察者模式

一句话总结:Object.defineProperty数据劫持测试数据变化+观察者模式进行依赖收集和视图更新。

二、过程

1.任务拆解

  1. Vue类传入两个内容,一个是要展示的数据,一个是数据要挂载的视图
  2. 数据处理:
    • 拿到数据,遍历对象的每个属性,使用Object.defineProperty将它们变成getter/setter模式,意思是可以劫持到属性被获取和赋值。
    • 在数据的getter过程,可以将那些获取数据的主体(依赖)保存下来。
    • 在数据的setter过程,可以通知那些依赖去同步更新视图
    • 意味着数据中每个属性都对应一个Dep(发布者),才能细粒度的保存到每一次getter操作(观察者)。
  3. 视图处理:
    • 使用编译器(compiler)编译模板(template模块),解析指令(v-text/v-model)/插值表达式({{}})。
    • 在编译过程订阅数据的变化(watcher),同时绑定更新函数(update)
    • 展示视图。

image.png

一句话总结:首先做到在页面上展示变量,然后知道当数据改变时怎么更新展示,最后根据展示类型做不同的展示方案。所以需要知道哪个地方使用了变量,怎么通知更新。

2.实现

  1. 接收初始化参数。
  2. data中的属性注入到Vue中,并且转成getter/setter形式,方便通过vm[key]的方式访问。
  3. 调用Observer监听data中所有属性的变化(变成响应式数据)。
  4. 调用Dep收集每个属性的依赖。
  5. 调用Watcher订阅通知。
  6. 调用compiler解析指令(v-text/v-model)/差值表达式({{}})。

2.1 使用方式

明确输入输出和展示效果。将数据项视图传给Vue类。

<div id="app">
    <h1>表达式</h1>
    <h1>{{message}}</h1>
    <h1>{{age}}</h1>
    v-text:message <h1 v-text="message"></h1>
    v-model:message <input type="text" v-model="message" />
    v-model:count <input type="text" v-model="count" />
</div>
<script>
    let vm = new Vue({
      el: '#app',
      data: {
        message: 'why',
        age: 18,
        count: 'count11',
        person: {
          name: 7
        }
      }
    })
    // 给属性重新赋值为对象,也是响应式的
    vm.message = 'hhh'
    // 给Vue实例新增属性,不是响应式的(Object.defineProperty的弊端)
    // 解决方案:
    // 1. Vue.set(vm.someObject, 'b', 2)
    // 2. this.$set(this.someObject,'b',2)
    vm.testPro = 'ttt'
</script>

2.2 创建Vue类

  1. 接收初始化选项,选项中包括数据项视图内容
  2. 使用_proxyData函数将属性注入到Vue实例中,方便之后能直接通过vm[key]获取到值。因为现在数据都被包裹到data中,取值不方便。
// 0.创建Vue构造函数
class Vue {
  constructor(options) {
    // 1.接收传递过来的选项,并保存
    this.$options = options || {};
    // 获取选项参数中的data
    this.$data = options.data || {};
    // el传过来的可能是字符串'#app',也可能是一整个节点数据
    this.$el =
      typeof options.el === "string"
        ? document.querySelector(options.el)
        : options.el;
    // 2.把data转换成getter/setter,并注入到Vue实例中
    this._proxyData(this.$data);

    // 3.调用Observer对象,监听数据的变化(把$data数据变成响应式)
    new Observer(this.$data);

    // 4.调用compiler解析指令/差值表达式
    new Compiler(this);
  }
  _proxyData(data) {
    Object.keys(data).forEach((key) => {
      // 第一个参数为this,将属性代理到Vue实例上
      Object.defineProperty(this, key, {
        enumerable: true,
        configurable: true,
        get() {
          return data[key];
        },
        set(newValue) {
          if (data[key] === newValue) {
            return;
          }
          data[key] = newValue;
        },
      });
    });
  }
}

2.3 创建Observer类

使用Object.defineProperty劫持所有data的属性,监听它们的变化(变成响应式数据)。属性改变时通知订阅者。

class Observer {
  constructor(data) {
    this.walk(data);
  }
  walk(data) {
    if (!data || typeof data !== "object") {
      return;
    }
    Object.keys(data).forEach((key) => {
      this.defineReactive(data, key, data[key]);
    });
  }
  // 把数据变成响应式
  defineReactive(obj, key, value) {
    let that = this;
    // 在使用前先创建Dep类,每个属性对应一个Dep对象
    let dep = new Dep();
    // 将对象中的属性也变成响应式
    this.walk(value);
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() {
        // 添加一个观察者watcher
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      set(newValue) {
        if (newValue === value) {
          return;
        }
        value = newValue;
        // 新增加的数据也应该是响应式的
        that.walk(newValue);
        // 发送通知,更新视图
        dep.notify();
      },
    });
  }
}

2.4 创建Compiler

编译模板,把变量变成数据,绑定更新函数。添加订阅者,收到通知就执行更新函数。

class Compiler {
  constructor(vm) {
    // 保存视图
    this.el = vm.$el;
    // 保存Vue实例
    this.vm = vm;
    this.compile(this.el);
  }
  // 编译模板
  compile(el) {
    // 获取el的所有子节点
    let childNodes = el.childNodes;
    Array.from(childNodes).forEach((node) => {
      if (this.isTextNode(node)) {
        // 处理文本节点
        this.compileText(node);
      } else if (this.isElementNode(node)) {
        // 处理元素节点
        this.compileElement(node);
      }
      // 判断node是否还有子节点
      if (node.childNodes && node.childNodes.length) {
        this.compile(node);
      }
    });
  }
  // 编译文本节点,处理差值表达式
  compileText(node) {
    // 匹配{{variable}}
    let reg = new RegExp(/\{\{(.+)\}\}/);
    let value = node.textContent;
    if (value.match(reg)) {
      let key = reg.exec(value)[1];
      // 使用Vue实例上的值替换掉节点的内容
      node.textContent = value.replace(reg, this.vm[key]);
      // 订阅数据的变化
      new Watcher(this.vm, key, (newValue) => {
        node.textContent = newValue;
      });
    }
  }
  // 编译元素节点,处理指令
  compileElement(node) {
    Array.from(node.attributes).forEach((attr) => {
      // 获取属性的名字
      let attrName = attr.name;
      // 判断是否是指令
      if (this.isDirective(attrName)) {
        // 有可能是, v-text:text,v-model:model
        attrName = attrName.substr("2");
        // 获取指令中的值,v-text:msg,v-model:count
        let key = attr.value;
        this.update(node, key, attrName);
      }
    });
  }
  // 更新展示
  update(node, key, attrName) {
    let updateFn = this[`${attrName}Updater`];
    // 需要改变this的指向
    updateFn && updateFn.call(this, node, this.vm[key], key);
  }
  // 处理v-text指令
  textUpdater(node, value, key) {
    node.textContent = value;
    // 订阅数据的变化
    new Watcher(this.vm, key, (newValue) => {
      node.textContent = newValue;
    });
  }
  // 处理v-model指令
  modelUpdater(node, value, key) {
    node.value = value;
    // 订阅数据的变化
    new Watcher(this.vm, key, (newValue) => {
      node.value = newValue;
    });
    // 数据双向绑定
    node.addEventListener("input", () => {
      this.vm[key] = node.value;
    });
  }
  // 判断元素的属性是否为指令
  isDirective(attrName) {
    return attrName.startsWith("v-");
  }
  // 判断节点是否为文本节点
  isTextNode(node) {
    return node.nodeType === 3;
  }
  // 判断节点是否为元素节点
  isElementNode(node) {
    return node.nodeType === 1;
  }
}

2.5 创建Dep

收集依赖。

// 发布者,作用是收集依赖(watcher),在getter中收集依赖,在setter中通知依赖
class Dep {
  constructor() {
    this.sbus = [];
  }
  // 保存订阅者
  addSub(sub) {
    this.sbus.push(sub);
  }
  // 通知订阅者
  notify() {
    this.sbus.forEach((sub) => {
      // 执行更新
      sub.update();
    });
  }
}

2.6 创建Watcher

订阅Observer属性变化的消息,触发Compiler更新函数。

// 订阅者,作用是数据变化后触发依赖更新视图
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    // data中属性名称
    this.key = key;
    // 回调函数,负责更新视图
    this.cb = cb;
    Dep.target = this;
    // 获取更新前的旧值
    // 这里的vm[key]被Observer中的setter捕获,所以下一步需要对Dep.target初始化
    this.oldValue = vm[key];
    // 初始化
    Dep.target = null;
  }
  // 更新操作
  update() {
    let newValue = this.vm[this.key];
    if (newValue === this.oldValue) {
      return;
    }

    this.cb(newValue);
  }
}

2.7 双向数据绑定

// 在Compiler类中的modelUpdater函数实现数据双向绑定
node.addEventListener("input", () => {
  this.vm[key] = node.value;
});

三、总结

  1. 将要展示的数据和要挂载的节点传给Vue类,在Vue内部通过_proxyData函数将数据转换成getter/setter形式,同时把数据注入到Vue实例中,可以通过实例访问。
  2. 使用Observer类把数据变成响应式的,具体使用Object.defineProperty劫持。对象中属性也要变成响应式的,新增数据也变成响应式的。其中使用Dep类在数据getter中收集依赖,在setter中通知依赖更新视图。
  3. 使用Compiler类编译模板并展示视图。递归遍历所有节点包括子节点,判断节点类型,当前案例需要处理的是文本节点({{}})和元素节点(元素节点上会有v-textv-model指令)。
    • 如果是文本节点,需要处理插值表达式的情况({{}}),所以需要使用正则判断是否是差值表达式。如果是表达式,则需要在Vue实例中拿到变量的值,并替换掉这个节点的内容(node.textContent)为变量的值。此时还需要订阅数据变化,方便变量值改变时更新视图。
    • 如果是元素节点,需要拿到节点上的属性,遍历判断是不是指令(简单判断),是指令则执行对应的更新函数。v-text指令则替换整个节点的内容(node.textContent),同时订阅数据变化,方便变量值改变时更新视图。v-model指令则改变节点的值(node.value),同时订阅数据变化,方便变量值改变时更新视图。
  4. 使用Dep类充当发布者,在数据getter中收集依赖,在setter中通知依赖更新视图。
  5. 使用Watcher类充当订阅者/观察者,在数据变更时触发更新视图。
  6. 在可以使用v-model的节点上,监听input操作,回调为更新Vue实例的值为节点的值,完成数据双向绑定。此时,数据赋值触发setter,则Dep类会发送通知让所有依赖更新视图。

总的来看就是把要做的事情拆开来,一步步完成。就像如何把一头大象放进冰箱,打开冰箱,把大象塞进去,关上冰箱。

附录

  1. 视频:最全最新Vue、Vuejs教程,从入门到精通