大前端百科全书vue专题之双向数据绑定原理

398 阅读8分钟

大前端百科全书,前端界的百科全书,记录前端各相关领域知识点,方便后续查阅及面试准备

关键词:发布订阅、观察者、数据劫持、依赖收集、Object.defineProperty、Proxy、Observer、Compiler、Watcher、Dep……

  • vue的双向绑定的原理是什么?
  • Vue是如何收集依赖的?
  • 说一下Vue template到render的过程

vue的双向绑定的原理是什么?

vue.js是采用数据劫持结合发布者-订阅者模式的方式,通过ES5提供的Object.defineProperty()方法来劫持(监视)各个属性的setter,getter,在数据变动的时发布消息给订阅者,触发相应的监听回调。并且,由于是在不同的数据上触发同步,可以精确的将变更发送发送给绑定的视图,而不是对所有的数据都执行一次检测。

具体的步骤:

  1. 需要observer的数据对象进行递归遍历,包括子属性对象的属性,都加上getter和setter这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化

  2. compile解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图

  3. Watcher订阅者是Observer和compile之间通信桥梁,主要做的事情是:

    • 在自身实例化时往属性订阅器(dep)里面添加自己
    • 自身必须有一个update()的方法
    • 待属性变动dep.notify()通知的时候,能调用自身的update()方法,并触发compile中绑定的回调,则功成身退
  4. MVVM作为数据绑定的入口,整合observer、compile和watcher三者,通过observer来监听自己的model数据变化,通过compile来编译模板指令,最终利用watcher搭起的observer和compile之间的通信桥梁,达到数据变化 --> 试图更新;视图交互变化(input) --> 数据model变更的双向绑定效果

版本比较

  • vue是基于依赖收集的双向绑定
  • 3.0版本之前使用Object.definePropetry
  • 3.0新版使用Proxy
  • 1.基于数据劫持/依赖收集 的双向绑定的优点

    • 不需要显示的调用,Vue利用数据劫持+发布订阅,可以直接通知变化并且驱动视图
    • 直接得到精确的变化数据,劫持了属性setter,当属性值改变,我们可以精确的获取变化的内容newValue,不需要额外的diff操作
  • 2.Object.defineProperty的缺点

    • 不能监听数组:因为数组没有getter和setter,因为数组长度不确定,如果太长性能负担太大
    • 只能监听属性,而不是整个对象,需要遍历循环属性
    • 只能监听属性变化,不能监听属性的删减
  • 3.proxy的好处

    • 可以监听数组
    • 监听整个对象不是属性
    • 13种来截方法,强大很多
    • 返回新对象而不是直接修改原对象,更符合immutable;
  • 4.proxy的缺点

    • 兼容性不好,而且无法用polyfill磨平;

Vue是如何收集依赖的?

在初始化vue每个组件时,会对组件的data进行初始化,就会将由普通对象变成响应式对象,在这个过程中便会进行依赖收集的相关逻辑

function defieneReactive(obj, key, val){
    const dep = new Dep();
    //...
    Object.defineProperty(obj, key,{
        //...
        get: function reactiveGetter(){
            if(Dep.target){
                dep.depend();
                //...
            }
            return val
        }
    })
}

上面的代码主要说明: const dep=new Dep() 实例化一个Dep实例,然后能在get函数中通过dep.depend() 进行依赖收集

Dep

Dep是整个依赖收集的核心

class Dep {
  static target;
  subs;

  constructor () {
    ...
    this.subs = [];
  }
  addSub (sub) { // 添加
    this.subs.push(sub)
  }
  removeSub (sub) { // 移除
    remove(this.sub, sub)
  }
  depend () { // target添加
    if(Dep.target){
      Dep.target.addDep(this)
    }
  }
  notify () { // 响应
    const subs = this.subds.slice();
    for(let i = 0;i < subs.length; i++){
      subs[i].update()
    }
  }
}

Dep是一个class,里面有一个静态属性static, 指向全局唯一的Watcher,保证了同一时间全局只有一个watcher被计算,另一个属性subs则是一个watcher数组,所以dep实际上就是对watcher的管理

watcher

class Watcher {
  getter;
  ...
  constructor (vm, expression){
    ...
    this.getter = expression;
    this.get();
  }
  get () {
    pushTarget(this);
    value = this.getter.call(vm, vm)
    ...
    return value
  }
  addDep (dep){
        ...
    dep.addSub(this)
  }
  ...
}
function pushTarget (_target) {
  Dep.target = _target
}

watcher是一个class,定义了一些方法,其中和依赖收集相关的函数是get、addDep

过程

在实例化Vue时,依赖收集的相关过程

初始化状态 initState,这中间便会通过defineReactive将数据变成响应式对象,其中的getter部分便是用来收集的。

初始化最终会走mount过程,其中会实例化watcher,进入watcher中,便会执行this.get()方法

updateComponent = ()=>{
    vm._update(vm._render())
}

new Watcher(vm,updateComponent)

get方法中的pushTarget实际上就是把Dep.target赋值为当前的watcher,this.getter.call(vm,vm),这里的getter会执行vm._render()方法,在这个过程中便会触发数据对象的getter

那么每个对象值的getter都持有一个dep,在触发getter的时候会调用dep.depend()方法,也就是会执行Dep.target.addDep(this)

刚才Dep.target已经被赋值为watcher,于是就执行addDep方法,然后走到dep.addSub()方法,便将当前的watcher订阅到这个数据持有的dep的subs中,这个是为了后续数据变化的时候能通知到哪些subs做准备

所以在vm._render()过程中,会触发所有的数据的getter,这样已经完成了一个依赖收集的过程

说一下Vue template到render的过程

过程解析

vue的模板编译过程如下:template - ast - render函数

vue在模板编译中执行compileToFunctions将template转化成render函数

compileToFunctions的主要核心点:

  1. 调用parse方法将template转化为ast树(抽象语法树)

parse的目的:是把template转化为ast树,它是一种用js对象的形式来描述整个模板。

解析过程:利用正则表达式顺序解析模板,当解析到开始标签、闭合标签、文本的时候都会分别执行对应的回调函数,来达到构成ast树

ast元素节点(type)总共三种类型:普通元素--1,表达式--2,纯文本--3

  1. 对静态节点做优化

这个过程主要分析出哪些是静态节点,在其做一个标记,为后面的更新渲染可以直接跳过,静态节点做优化

深度遍历AST, 查看每个子树的节点元素是否为静态节点或者静态节点根。如果为静态节点,他们生成的dom永远不会改变,这对运行时模板更新起到优化作用

  1. 生成代码 generate

将 ast 抽象语法树编译成render字符串并将静态部分放到staticRender中,最后通过new Function(render)生成render函数

实现双向数据绑定

截屏2021-10-03 下午4.29.09.png

index.js作用

  • 数据劫持 _proxyData,把data的数据同步到vm上:this.data.name => this.name
  • new Observer 数据劫持
  • compiler编译模板,生成render function字符串:with(code)……

observer.js

  • 递归遍历,数据劫持 defineReactive,创建 dep = new Dep()
  • Object.property get属性的时候,添加到dep实例的subs上:dep.addSub(Dep.target)
  • Object.property set属性的时候,调用dep.notify通知subs的所有Watcher进行update => patch方法,dom更新

compiler.js

  • 编译template
  • for循环遍历childNodes(文本、元素节点)
  • 创建观察者 new Watcher(),将实例挂载到 Dep.target

watcher.js

  • Dep.target = this,把观察者watcher放在Dep.target上
  • update方法,更新dom,后面经过patch方法

dep.js 订阅者

  • addSub,将watcher push进subs中
  • notify,通知subs中的所有watcher,调用update方法

demo代码

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app">
      <p>msg:{{ msg }}</p>
      <p>age:{{ age }}</p>
      <div v-text="msg"></div>
      <input v-model="msg" type="text" />
    </div>
    <script type="module">
      import Vue from "./js/vue.js";

      let vm = new Vue({
        el: "#app",
        data: {
          msg: "123",
          age: 21,
        },
      });
      window.vm = vm
    </script>
  </body>
</html>

vue.js

import Observer from "./observer.js";
import Compiler from "./compiler.js";

export default class Vue {
  constructor(options) {
    this.$options = options || {};
    this.$el =
      typeof options.el === "string"
        ? document.querySelector(options.el)
        : options.el;
    this.$data = options.data || {};
    // 看到这里为什么做了两个重复性的操作呢?
    // 重复性两次把 data 的属性转为响应式
    // 在obsever.js 中是把 data 的所有属性 加到 data 自身 变为响应式 转成 getter setter方式
    // 在vue.js 中 也把 data的 的所有属性 加到 Vue 上,是为了以后方面操作可以用 Vue 的实例直接访问到 或者在 Vue 中使用 this 访问
    this._proxyData(this.$data);
    // 使用 Obsever 把data中的数据转为响应式
    new Observer(this.$data);
    // 编译模板
    new Compiler(this);
  }
  // 把data 中的属性注册到 Vue
  _proxyData(data) {
    Object.keys(data).forEach((key) => {
      // 进行数据劫持
      // 把每个data的属性 到添加到 Vue 转化为 getter setter方法
      Object.defineProperty(this, key, {
        // 设置可以枚举
        enumerable: true,
        // 设置可以配置
        configurable: true,
        // 获取数据
        get() {
          return data[key];
        },
        // 设置数据
        set(newValue) {
          // 判断新值和旧值是否相等
          if (newValue === data[key]) return;
          // 设置新值
          data[key] = newValue;
        },
      });
    });
  }
}

observer.js

import Dep from "./dep.js";

export default class Observer {
  constructor(data) {
    // 用来遍历 data
    this.walk(data);
  }
  // 遍历 data 转为响应式
  walk(data) {
    // 判断 data是否为空 和 对象
    if (!data || typeof data !== "object") return;
    // 遍历 data
    Object.keys(data).forEach((key) => {
      // 转为响应式
      this.defineReactive(data, key, data[key]);
    });
  }
  // 转为响应式
  // 要注意的 和vue.js 写的不同的是
  // vue.js中是将 属性给了 Vue 转为 getter setter
  // 这里是 将data中的属性转为getter setter
  defineReactive(obj, key, value) {
    // 如果是对象类型的 也调用walk 变成响应式,不是对象类型的直接在walk会被return
    this.walk(value);
    const _this = this;
    // 创建 Dep 对象
    let dep = new Dep();
    Object.defineProperty(obj, key, {
      // 设置可枚举
      enumerable: true,
      // 设置可配置
      configurable: true,
      // 获取值
      get() {
        // 在这里添加观察者对象 Dep.target 表示观察者
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 设置值
      set(newValue) {
        // 判断旧值和新值是否相等
        if (newValue === value) return;
        // 设置新值
        value = newValue;
        // 赋值的话如果是newValue是对象,对象里面的属性也应该设置为响应式的
        _this.walk(newValue);
        // 触发通知 更新视图
        dep.notify();
      },
    });
  }
}

compiler.js

import Watcher from "./watcher.js";

export default class Compiler {
  // vm 指 Vue 实例
  constructor(vm) {
    // 拿到 vm
    this.vm = vm;
    // 拿到 el
    this.el = vm.$el;
    // 编译模板
    this.compile(this.el);
  }
  // 编译模板
  compile(el) {
    // 获取子节点 如果使用 forEach 遍历就把伪数组转为真的数组
    let childNodes = [...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);
      }
    });
  }
  // 判断是否是 文本 节点
  isTextNode(node) {
    return node.nodeType === 3;
  }
  // 编译文本节点(简单的实现)
  compileText(node) {
    // 核心思想利用把正则表达式把{{}}去掉找到里面的变量
    // 再去Vue找这个变量赋值给node.textContent
    let reg = /\{\{(.+?)\}\}/;
    // 获取节点的文本内容
    let val = node.textContent;
    // 判断是否有 {{}}
    if (reg.test(val)) {
      // 获取分组一  也就是 {{}} 里面的内容 去除前后空格
      let key = RegExp.$1.trim();
      // 进行替换再赋值给node
      node.textContent = val.replace(reg, this.vm[key]);
      // 创建观察者
      new Watcher(this.vm, key, newValue => {
        node.textContent = newValue;
      });
    }
  }
  // 判断是否是元素节点
  isElementNode(node) {
    return node.nodeType === 1;
  }
  // 判断元素的属性是否是 vue 指令
  isDirective(attr) {
    return attr.startsWith("v-");
  }
  // 编译元素节点这里只处理指令
  compileElement(node) {
    // 获取到元素节点上面的所有属性进行遍历
    ![...node.attributes].forEach((attr) => {
      // 获取属性名
      let attrName = attr.name;
      // 判断是否是 v- 开头的指令
      if (this.isDirective(attrName)) {
        // 除去 v- 方便操作
        attrName = attrName.substr(2);
        // 获取 指令的值就是  v-text = "msg"  中msg
        // msg 作为 key 去Vue 找这个变量
        let key = attr.value;
        // 指令操作 执行指令方法
        // vue指令很多为了避免大量个 if判断这里就写个 uapdate 方法
        this.update(node, key, attrName);
      }
    });
  }
  // 添加指令方法 并且执行
  update(node, key, attrName) {
    // 比如添加 textUpdater 就是用来处理 v-text 方法
    // 我们应该就内置一个 textUpdater 方法进行调用
    // 加个后缀加什么无所谓但是要定义相应的方法
    let updateFn = this[attrName + "Updater"];
    // 如果存在这个内置方法 就可以调用了
    updateFn && updateFn(node, key, this.vm[key], this);
  }
  // 提前写好 相应的指定方法比如这个 v-text
  // 使用的时候 和 Vue 的一样
  textUpdater(node, key, value, context) {
    node.textContent = value;
    // 创建观察者2
    new Watcher(context.vm, key, (newValue) => {
      node.textContent = newValue;
    });
  }
  // v-model
  modelUpdater(node, key, value, context) {
    node.value = value;
    // 创建观察者
    new Watcher(context.vm, key, (newValue) => {
      node.value = newValue;
    });
    // 这里实现双向绑定 监听input 事件修改 data中的属性
    node.addEventListener("input", () => {
      console.log('+++++++++', node.value)
      context.vm[key] = node.value;
    });
  }
}

watcher.js

import Dep from "./dep.js";

/**
 * 数据更新后 收到通知之后 调用 update 进行更新
 */
export default class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    // key是data中的key
    this.key = key;
    // 回调函数,更新视图的具体方法
    this.cb = cb;
    // 把观察者存放在 Dep.target
    Dep.target = this;
    // 旧数据 更新视图的时候要进行比较
    // 还有一点就是 vm[key] 这个时候就触发了 get 方法
    // 之前在 get 把 观察者 通过dep.addSub(Dep.target) 添加到了 dep.subs中
    this.oldValue = vm[key];
    // Dep.target 就不用存在了 因为上面的操作已经存好了
    Dep.target = null;
  }
  // 观察者中的必备方法 用来更新视图
  update() {
    // 获取新值
    let newValue = this.vm[this.key];
    // 比较旧值和新值
    if (newValue === this.oldValue) return;
    // 调用具体的更新方法
    this.cb(newValue);
  }
}

dep.js

export default class Dep {
  constructor() {
    // 存储观察者
    this.subs = [];
  }
  // 添加观察者
  addSub(sub) {
    // 判断观察者是否存在和是否用友 update 方法
    if (sub && sub.update) {
      this.subs.push(sub);
    }
  }
  // 通知方法
  notify() {
    // 触发每个观察者的更新方法
    this.subs.forEach((sub) => {
      sub.update();
    });
  }
}