Vue双向绑定原理

Posted pengnima

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue双向绑定原理相关的知识,希望对你有一定的参考价值。

参考文章:https://www.cnblogs.com/kidney/p/6052935.html?utm_source=gold_browser_extension

最简单的 双向绑定

//极简双向绑定
let $input = document.querySelector("input");
let $p = document.querySelector("p");

// 数据
let obj = {};

//通过输入事件改变 数据
$input.addEventListener("input", e => {
  obj.txt = e.target.value;
});

//通过 数据拦截 改变 视图
Object.defineProperty(obj, "txt", {
  set(val) {
    obj._txt = val;
    $p.innerText = val;
    $input.value = val;
  },
});

真正的双向绑定的最终实现

<div id="app">
  <input type="text" v-model="text" />
  {{ text }}
</div>

<script>
  var vm = new Vue({
    el: "#app",
    data: {
      text: "你好,世界",
    },
  });
</script>

将上面划分成几个子任务:

  1. 输入框 以及 文本节点与 data 中的数据绑定。 DOM 节点绑定
  2. 输入框内容变化时,data 中数据同步变化。 view -> model
  3. data 中的数据变化时,文本节点的内容同步变化。 model -> view

任务 一 思路:

循环#app 下的所有子节点,将其挂载到文档片段中,并进行数据的绑定初始化,最后将文档片段返回到 #app 中

1. 创建一个 Vue 的构造函数

/**
 * 创建一个Vue的 构造函数
 * @param {Object} options
 */
function Vue(options) {
  this.data = options.data; //将用户声明的data挂载到实例上
  let id = options.el; // 静态私有属性,内部自己调用

  // 任务2
  // 劫持监听 data 中的属性
  observe(this.data, this);

  // 全部挂载到文档片段,在返回到 #app中,第二个参数 就是 Vue的实例,因为 data 已经挂载到实例上了
  let fragment = nodeToFragment(document.querySelector(id), this);

  document.querySelector(id).appendChild(fragment);
}

2. DomcumentFragment (文档片段)

当需要添加多个 dom 元素时,如果先将这些元素添加到 DocumentFragment 中,再统一将 DocumentFragment 添加到页面,会减少页面渲染 dom 的次数,效率会明显提升。

DocumentFragment (文档片段)可以看作节点容器,它可以包含多个子节点,当我们将它插入到 DOM 中时,只有它的子节点会插入目标节点,所以把它看作一组节点的容器。
使用 DocumentFragment 处理节点速度和性能远远优于直接操作 DOM
Vue 进行编译时,就是将挂载目标的所有子节点劫持(真的是劫持,通过 append 方法,DOM 中的节点会被自动删除)到 DocumentFragment 中,经过一番处理后,再将 DocumentFragment 整体返回插入挂载目标

/**
 * 挂载到文档片段中
 * @param node ‘#app’
 */
function nodeToFragment(node, vm) {
  let fragment = document.createDocumentFragment();

  let child = null;
  //循环#app的子孩子,直到没有子孩子,退出循环
  while ((child = node.firstChild)) {
    compile(child, vm); // 调用 数据绑定初始化函数
    fragment.appendChild(child); //把 挂载在 #app 上的子节点全部劫到文档片段中
  }

  return fragment;
}

3. 数据绑定的初始化

/**
 * 数据绑定初始化
 * @param {htmlDocument} node
 * @param {*} vm
 */
function compile(node, vm) {
  let reg = /{{(.*)}}/;

  //如果节点类型为 元素
  if (node.nodeType == 1) {
    // 遍历元素的属性,看看是否有 v-model
    for (const attr of node.attributes) {
      if (attr.nodeName == "v-model") {
        let name = attr.nodeValue;

        // 任务 2
        // 监听 输入事件,并赋值,由于vm 的msg有被数据劫持,所以可以如此
        node.addEventListener("input", function (e) {
          vm[name] = e.target.value;
        });

        node.value = vm[name]; // 将 vm 里data的 对应的数据 的值给该 node

        // vm.data 被任务2的 vm 替换
        //node.value = vm.data[attr.nodeValue]; // 将 vm 里data的 对应的数据 的值给该 node
        node.removeAttribute("v-model"); //为了不让在前端看到该属性,赋值完后移除该属性
      }
    }

    // 元素节点的 子节点中如果有 其他类型的,递归
    for (const txtNode of node.childNodes) {
      compile(txtNode, vm);
    }
  }

  //如果节点类型为 文本
  if (node.nodeType == 3) {
    // 如果有 mustache 语法
    if (reg.test(node.nodeValue)) {
      let name = RegExp.$1; // 获取 正则匹配到的值,并去除两边的空白
      name = name.trim();
      node.nodeValue = vm[name]; //这里节点是文本节点,所以要用 nodeValue
    }
  }
}

任务 二 思路:

向输入框输入数据时,首先触发 input 事件(或者 keyup、change 事件),在相应的事件处理程序中,我们获取输入框的 value 并赋值给 vm 实例的 msg 属性
利用 defineProperty 将 data 中的 msg 设置为 vm 的访问器属性,因此给 vm.msg 赋值,就会触发 set 方法。
在 set 方法中主要做两件事,第一是更新属性的值,第二是通知监听者修改数据

/**
 * 劫持函数
 * @param {Vue实例}} vm
 * @param {属性名} key
 * @param {旧值} oldVal
 */
function defineDataProperty(vm, key, oldVal) {
  Object.defineProperty(vm, key, {
    get() {
      return oldVal;
    },
    set(newVal) {
      if (oldVal === newVal) return;
      oldVal = newVal;
      console.log(oldVal);
    },
  });
}

/**
 * 劫持vm.data中所有的属性
 * @param {vm.data} data
 * @param {Vue实例} vm
 */
function observe(data, vm) {
  Object.keys(data).forEach(key => {
    // 【注】要劫持的是 vm.data里的属性,但是实际确实让 vm 来控制。
    // 即: vm.msg  等价于 vm.data.msg
    defineDataProperty(vm, key, data[key]);
  });
}
  • 为什么明明是 vm.data.msg 里的数据,却可以用 vm.msg 来操作?
    因为在进行 数据劫持,defineProperty 的时候,第一参数是 vm,第二参数为 msg,然后会创建一个 vm 的 msg 属性,所以可以看到 vm 和 vm.data 都有一个 msg 属性。(如果第一参数是 vm.data,那么就没 vm 什么事了)

  • 有了 数据劫持,就可以在 compile 函数中,给 nodeType 为元素的节点添加 监听 input 事件,当 value 改变时,去改变 vm.msg ,这样就可以 view -> model。数据层的数据就改变了


任务 三 思路:

vm 的 msg 属性发生了变化,但是 其他文本节点也没发生变化
So,这里用 发布订阅者模式,定义 一对多的关系。让多个订阅者同时监听某一个对象,该对象发生改变时,就让发布者通知所有订阅者

// 发布订阅者模式
class Dep {
  constructor() {
    this.subs = [];
  }

  static target = null;

  add(sub) {
    this.subs.push(sub);
  }
  notify() {
    this.subs.forEach(v => {
      v.update();
    });
  }
}

class Watcher {
  constructor(vm, node, name) {
    Dep.target = this;
    this.node = node; // 绑定的文本节点
    this.name = name; // 绑定的data属性名
    this.vm = vm; // Vue实例

    this.update(); // 触发 vm 的属性的 get()访问器
    Dep.target = null;
  }
  update() {
    // 更新
    this.node.nodeValue = this.vm[this.name];
  }
}

每个属性都需要有一个 发布者, html 文档中的每个对应的文本节点都需要有一个 订阅者

  • 每次 defineProperty 之前就 new 一个发布者
  • compile 时候,每次遇到一个 文本节点,就 new 一个订阅者,之后构造函数中会马上将该订阅者与 dep 绑定

    如何绑定? new 订阅者时,会将自身赋值给 Dep.target,且会触发 vm 的 get 访问器,该访问器会 dep.add(Dep.target)


总结

主要原理是通过: 数据劫持 + 发布订阅者模式

  1. 先对数据进行劫持 Object.defineDataProperty且 new 一个发布者
    • 数据劫持第一个参数是 vm 实例,第二个参数是 key(data 数据里的 key)
    • get 访问器中,如果 Dep.target 有值,那么将其 添加至 dep.subs 中(Dep.target 会在 new 订阅者时有值)
    • set 访问器中,dep.notify()
  2. 解析文档中的元素、文本节点,(内部细节:依次遍历首位子元素,分析节点做处理,并将其转移至文档片段中,最后文档片段在移动到‘#app‘里)
    • 当遇到文本节点时,就 new 一个订阅者
    • 当遇到元素节点,遍历循环看是否有 v-model 属性,有的话,给其添加一个 input 事件,该事件将 值 value 赋值给了 vm 的属性,这样会触发 vm 属性的 set 访问器,会通知其他订阅者修改数据






以上是关于Vue双向绑定原理的主要内容,如果未能解决你的问题,请参考以下文章

Vue2从入门到精通详解Vue数据双向绑定原理及手动实现双向绑定

剖析Vue原理&实现双向绑定MVVM

剖析Vue原理&实现双向绑定MVVM

VUE底层原理之数据双向绑定

vue的双向绑定原理及实现

vue的双向绑定原理及实现