这一次,彻底搞懂vue双向绑定原理

·  阅读 7935

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。

本文实现了一个自定义vue,逐步实现了数据的双向绑定,即数据驱动视图,视图驱动数据

文末有总结

创建vue类

  • 1,vue最少需要两个参数:模板和数据data

  • 2,创建Compiler类,用于解析模板中的vue指令,将所需的data渲染到模板中,最后挂载到指定跟节点上。

  • 3,创建Observer类,用于对data的每个属性都进行get/set拦截。在Compiler类解析模板的时候会触发get拦截,这时候就可以获取到该data所被依赖的所有dom节点,当修改该data的值时,会触发set,就可以将收集的所有依赖dom依次修改,实现了数据驱动视图。

class MyVue {
  // 1,接收两个参数:模板(根节点),和数据对象
  constructor(options) {
    // 保存模板,和数据对象
    if (this.isElement(options.el)) {
      this.$el = options.el;
    } else {
      this.$el = document.querySelector(options.el);
    }
    this.$data = options.data;
    if (this.$el) {
      // 2,拦截data所有属性的get/set
      new Observer(this.$data);
      // 3,解析模板中的vue指令
      new Compiler(this)
    }
  }
  // 判断是否是一个dom元素
  isElement(node) {
    return node.nodeType === 1;
  }
}
复制代码

解析模板

实现数据首次渲染到页面

Compiler

1,node2fragment 函数将模板元素提取到内存中,方便将数据渲染到模板后,再一次性挂载到页面中

2,模板提取到内存后,使用 buildTemplate 函数遍历该模板元素

  • 元素节点

    • 使用 buildElement 函数检查元素上以v-开头的属性
  • 文本节点

    • 用 buildText 函数检查文本中有无 {{}} 内容

3,创建 CompilerUtil 类,用于处理vue指令和 {{}},完成数据的渲染

4,到此就完成了首次数据渲染,接下来需要实现:数据改变时,自动更新视图。

class Compiler {
  constructor(vm) {
    this.vm = vm;
    // 1.将网页上的元素放到内存中
    let fragment = this.node2fragment(this.vm.$el);
    // 2.利用指定的数据编译内存中的元素
    this.buildTemplate(fragment);
    // 3.将编译好的内容重新渲染会网页上
    this.vm.$el.appendChild(fragment);
  }
  node2fragment(app) {
    // 1.创建一个空的文档碎片对象
    let fragment = document.createDocumentFragment();
    // 2.编译循环取到每一个元素
    let node = app.firstChild;
    while (node) {
      // 注意点: 只要将元素添加到了文档碎片对象中, 那么这个元素就会自动从网页上消失
      fragment.appendChild(node);
      node = app.firstChild;
    }
    // 3.返回存储了所有元素的文档碎片对象
    return fragment;
  }
  buildTemplate(fragment) {
    let nodeList = [...fragment.childNodes];
    nodeList.forEach(node => {
      // 需要判断当前遍历到的节点是一个元素还是一个文本
      if (this.vm.isElement(node)) {
        // 元素节点
        this.buildElement(node);
        // 处理子元素
        this.buildTemplate(node);
      } else {
        // 文本节点
        this.buildText(node);
      }
    })
  }
  buildElement(node) {
    let attrs = [...node.attributes];
    attrs.forEach(attr => {
      // v-model="name" => {name:v-model  value:name}
      let { name, value } = attr;
      // v-model / v-html / v-text / v-xxx
      if (name.startsWith('v-')) {
        // v-model -> [v, model]
        let [_, directive] = name.split('-');
        CompilerUtil[directive](node, value, this.vm);
      }
    })
  }
  buildText(node) {
    let content = node.textContent;
    let reg = /\{\{.+?\}\}/gi;
    if (reg.test(content)) {
      CompilerUtil['content'](node, content, this.vm);
    }
  }
}
复制代码

工具类CompilerUtil

let CompilerUtil = {
  getValue(vm, value) {
    // 解析this.data.aaa.bbb.ccc这种属性
    return value.split('.').reduce((data, currentKey) => {
      return data[currentKey.trim()];
    }, vm.$data);
  },
  getContent(vm, value) {
    // 解析{{}}中的变量
    let reg = /\{\{(.+?)\}\}/gi;
    let val = value.replace(reg, (...args) => {
      return this.getValue(vm, args[1]);
    });
    return val;
  },
  // 解析v-model指令
  model: function (node, value, vm) {
    // 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
    new Watcher(vm, value, (newValue, oldValue) => {
      node.value = newValue;
    });
    let val = this.getValue(vm, value);
    node.value = val;
  },
  // 解析v-html指令
  html: function (node, value, vm) {
    // 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
    new Watcher(vm, value, (newValue, oldValue) => {
      node.innerHTML = newValue;
    });
    let val = this.getValue(vm, value);
    node.innerHTML = val;
  },
  // 解析v-text指令
  text: function (node, value, vm) {
    // 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
    new Watcher(vm, value, (newValue, oldValue) => {
      node.innerText = newValue;
    });
    let val = this.getValue(vm, value);
    node.innerText = val;
  },
  // 解析{{}}中的变量
  content: function (node, value, vm) {
    let reg = /\{\{(.+?)\}\}/gi;
    let val = value.replace(reg, (...args) => {
      // 在触发getter之前,为dom创建Wather,并为Watcher.target赋值
      new Watcher(vm, args[1], (newValue, oldValue) => {
        node.textContent = this.getContent(vm, value);
      });
      return this.getValue(vm, args[1]);
    });
    node.textContent = val;
  }
}
复制代码

实现数据驱动视图

Observer

1,使用 defineRecative 函数对 data 做 Object.defineProperty 处理,使得可以拦截 data 中的每个数据的get/set。

class Observer {
  constructor(data) {
    this.observer(data);
  }
  observer(obj) {
    if (obj && typeof obj === 'object') {
      // 遍历取出传入对象的所有属性, 给遍历到的属性都增加get/set方法
      for (let key in obj) {
        this.defineRecative(obj, key, obj[key])
      }
    }
  }
  // obj: 需要操作的对象
  // attr: 需要新增get/set方法的属性
  // value: 需要新增get/set方法属性的取值
  defineRecative(obj, attr, value) {
    // 如果属性的取值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
    this.observer(value);
    Object.defineProperty(obj, attr, {
      get() {
        return value;
      },
      set: (newValue) => {
        if (value !== newValue) {
          // 如果给属性赋值的新值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
          this.observer(newValue);
          value = newValue;
          console.log('监听到数据的变化');
        }
      }
    })
  }
}
复制代码

2,接下来将考虑:如何在监听到data值改变后,更新视图内容呢?

  • 使用观察者设计模式,创建Dep和Wather类。

使用观察者设计模式,创建Dep和Wather类

1,在解析模板,收集data中各个属性在模板中被引用的dom节点集合,当该数据改变时,更新依赖了该数据的dom节点集合,就实现了数据驱动页面更新。

2,创建Dep类和Watcher类

  • Dep:用于收集某个data属性依赖的dom节点集合,并提供更新方法

  • Watcher:每个dom节点的包裹对象

    • attr:该dom使用的data属性
    • cb:修改该dom内容的回调函数,在对象创建的时候会接收

3,到这里感觉思路是没问题了,已经是胜券在握了。那Dep和Watcher该怎么使用呢?

  • 为data的每个属性添加一个dep数组,用来收集依赖的dom节点。

  • 因为vue实例初始化的时候会解析模板,会触发data数据的getter,所以在此收集dom。

  • 具体如何收集呢?

    • 在CompilerUtil类解析v-model,{{}}等命令时,会触发getter。

    • 我们在触发之前创建Wather对象,该对象在初始化的时候调用getOldValue,首先为Dep添加一个静态属性target,值为该dom节点。

    • 再调用CompilerUtil.getValue,获取该data的当前值,此时就以及触发了getter。然后我们在getter函数里面获取该静态变量Dep.target,并添加到对应的依赖数组dep中了,就完成了一次收集。

    • 因为每次触发getter之前都对该静态变量赋值,所以不存在收集错依赖的情况。

class Dep {
  constructor() {
    // 这个数组就是专门用于管理某个属性所有的观察者对象的
    this.subs = [];
  }
  // 订阅观察的方法
  addSub(watcher) {
    this.subs.push(watcher);
  }
  // 发布订阅的方法
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}

复制代码
class Watcher {
  constructor(vm, attr, cb) {
    this.vm = vm;
    // 该dom使用的data属性
    this.attr = attr;
    // 修改该dom内容的回调函数
    this.cb = cb;
    // 在创建观察者对象的时候就去获取当前的旧值
    this.oldValue = this.getOldValue();
  }
  getOldValue() {
    Dep.target = this;
    let oldValue = CompilerUtil.getValue(this.vm, this.attr);
    Dep.target = null;
    return oldValue;
  }
  // 定义一个更新的方法, 用于判断新值和旧值是否相同
  update() {
    let newValue = CompilerUtil.getValue(this.vm, this.attr);
    if (this.oldValue !== newValue) {
      this.cb(newValue, this.oldValue);
    }
  }
}
复制代码

4,修改get/set方法

defineRecative(obj, attr, value) {
  this.observer(value);
  // 1,创建了属于当前属性的依赖收集对象
  let dep = new Dep();
  Object.defineProperty(obj, attr, {
    get() {
      // 2,在这里收集依赖
      Dep.target && dep.addSub(Dep.target);
      return value;
    },
    set: (newValue) => {
      if (value !== newValue) {
        // 如果给属性赋值的新值又是一个对象, 那么也需要给这个对象的所有属性添加get/set方法
        this.observer(newValue);
        value = newValue;
        // 通知到视图更新
        dep.notify();
        console.log('监听到数据的变化');
      }
    }
  })
}

复制代码

5,到这里就实现了数据绑定时,视图自动更新。

实现视图驱动数据

其实就是监听输入框的input、change事件。修改CompilerUtil的model方法。具体代码如下

model: function (node, value, vm) {
    new Watcher(vm, value, (newValue, oldValue)=>{
        node.value = newValue;
    });
    let val = this.getValue(vm, value);
    node.value = val;
	// 看这里
    node.addEventListener('input', (e)=>{
        let newValue = e.target.value;
        this.setValue(vm, value, newValue);
    })
},
复制代码

总结

vue双向绑定原理

vue接收一个模板和data参数。1,首先将data中的数据进行递归遍历,对每个属性执行Object.defineProperty,定义get和set函数。并为每个属性添加一个dep数组。当get执行时,会为调用的dom节点创建一个watcher存放在该数组中。当set执行时,重新赋值,并调用dep数组的notify方法,通知所有使用了该属性watcher,并更新对应dom的内容。2,将模板加载到内存中,递归模板中的元素,检测到元素有v-开头的命令或者双大括号的指令,就会从data中取对应的值去修改模板内容,这个时候就将该dom元素添加到了该属性的dep数组中。这就实现了数据驱动视图。在处理v-model指令的时候,为该dom添加input事件(或change),输入时就去修改对应的属性的值,实现了页面驱动数据。3,将模板与数据进行绑定后,将模板添加到真实dom树中。

如何将watcher放在dep数组中?

在解析模板的时候,会根据v-指令获取对应data属性值,这个时候就会调用属性的get方法,我们先创建Watcher实例,并在其内部获取该属性值,作为旧值存放在watcher内部,我们在获取该值之前,在Watcher原型对象上添加属性Watcher.target = this;然后取值,将讲Watcher.target = null;这样get在被调用的时候就可以根据Watcher.target获取到watcher实例对象。

methods的原理

创建vue实例的时候,接收methods参数

在解析模板的时候遇到v-on的指令。会对该dom元素添加对应事件的监听,并使用call方法将vue绑定为该方法的this:vm.$methods[value].call(vm, e);

computed的原理

创建vue实例的时候,接收computed参数

初始化vue实例的时候,为computed的key进行Object.defineProperty处理,并添加get属性。

点赞收藏关注不迷路

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改