Vue的响应式原理 -- 实现一个观察者Observer

186 阅读6分钟

前言提示:标题写不下了,除了实现 Observer,我们还实现了 Watcher,完整实现了数据变化=>视图更新,视图更新=>数据变化的双向数据绑定。

前面我们实现了一个指令解析器,现在我们来

实现一个观察者 Observer

class Observer {
    //根据上面的脑图,我们知道观察者需要完成两件事情:1.劫持监听所有属性 2.通知 Dep 数据变化让 Watcher 去更新视图
    //那么就先要把 data 传入进来
    constructor(data) {
    	this.observe(data);	// 定义 observe 方法来监听属性
    }
    observe(data) {
        if(data && typeof data === 'object') {
            Object.keys(data).forEach(key => {    //拿到 data 下的所有属性 [ person, msg, htmlStr ]	我们可以发现 person 是一个对象所以我们要对拿到的属性进行递归处理
            	this.defineReactive(data, key, data[key]);
            });
        }
    }
    defineReactive(data, key, value) {
    //我们要监听劫持 data 下的所有数据,而有的可能是对象形式存在的,对象里的所有属性也是我们需要的,所以这里先把拿到的属性递归遍历
        this.observe(value);
        //创建当前属性的发布者
        const dep = new Dep();
        //通过 Object.defineProperty() 劫持数据,这个方法可以在一个对象上定义一个新属性或者修改一个对象的现有属性,并返回此对象
        // Object.defineProperty(obj, prop, descriptor) 接收三个参数,obj 是要定义属性的对象,prop 是要定义或修改的属性的名称或Symbol,descriptor 是要定义或修改的属性描述符
        //我们需要用到这个方法里的 get 和 set 方法
        Object.defineProperty(data, key, {
            enumerable: true,	// 可遍历的	默认为: false 只有当 enumerable 为 true 时,该属性才会出现在对象的枚举属性中
            configurable: false,	// 不可配置的	默认为: false
            get() {
                //读取数据时,往 Dep 中添加观察者,收集依赖
                dep.target && dep.addSub(dep.target);
                return value;
            },
            set:(newVal) => {
                this.observe(newVal);   //重新监听新的数据 set 使用箭头函数来取消 this 的指向,让 this 指向 Observer
                if(newVal !== value) {
                  value = newVal;
                }
                //写入数据时,让 Dep 通知变化
                dep.notify();
            }
        })
    }
}

做到这样我们就已经完成了 Observer 的第一个功能:劫持监听所有属性,接下来我们要实现通过存放 watcher 的 Dep 上的一个 notify 方法,去提示指定的 watcher 去更新改变当前 watcher 所挂载的节点的数据 

创建一个 Dep 类

class Dep {
  // Dep 的作用有两个,1.收集依赖 2.通知订阅者(发布订阅)
  constructor() {
    this.subs = [];   // 创建一个空数组来存放 watcher
  }
  //收集观察者,或者说添加订阅者
  addSub(watcher) {
    this.subs.push(watcher);
  }
  //通知观察者去更新
  notify() {
    this.subs.forEach(w => w.update());
  }
}

创建一个 Watcher 类

class Watcher {
  // Watcher 的作用是观察数据有没发生变化,如果有,就去更新视图
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 先把旧值保存起来
    this.oldVal = this.getOldVal();
  }
  getOldVal() {
    // 标记当前观察者为发布者的目标,当前属性变化的时候, Dep 会给依赖当前属性的所有的 watcher 去发布内容
    Dep.target = this;
    //编译指令拿到值,例如 v-text = "msg" 就拿到 msg 的值,在这个过程中会经过 Observer 的监听,并把当前 watcher 存放到 Dep 的 subs 发布队列中
    const oldVal = compileUtil.getVal(this.expr, this.vm);
    // 取消标记
    Dep.target = null;
    return oldVal;
  }
  update() {
    //通过 compileUtil 上的 getVal 方法获取到被改变的数据,然后通过回调函数返回被改变的数据
    const newVal = compileUtil.getVal(this.expr, this.vm);
    if(newVal !== this.oldVal) {
      this.cb(newVal);
    }
  }
}

Dep 和 Watcher 都创建好了,我们要弄懂这几个之间的关系

  • Observer 用来劫持监听所有属性

  • Compile 用来解析编译指令

  •  Watcher 用来更新视图

        当 Compile 对指令进行解析编译的时候,对每个指令对应的data上的属性new了一个Watcher此时需要拿到data上的属性 new 了一个 Watcher 此时需要拿到 data 上的属性的数据,这时 Observer 监听到了,就通过 Dep 类把依赖于当前属性的 watcher 存放在 subs (发布订阅队列)中,然后完成把数据绑定到视图上的功能。 当数据发生变化时,Observer 监听到了属性的变化,就去提示 Dep 去通知订阅者(所有依赖于当前属性的 watcher )去通过 Compile 类上的编译方法拿到更新后的数据,然后去把更新后的数据重新渲染到视图上。

        在这个过程中我们需要弄懂几个时间点,

  • 什么时候挂载 Watcher 

  • 什么时候往 Dep 里存放 Watcher 

  • 什么时候调用 Dep 的 notify 方法去提示 Watcher

  1. 什么时候挂载 Watcher 
    解析编译页面的时候挂载 Watcher

  2. 什么时候往 Dep 里存放 Watcher
    解析编译页面的时候往 Dep 里存放 Watcher ,收集依赖

  3. 什么时候调用 Dep 的 notify 方法去提示 Watcher 使用 update 方法去获取被改变的数据并返回
    在收集到依赖后,当前属性的数据变化时,通知依赖当前属性的所有 Watcher 去更新视图,去获取被改变的数据并返回

  4. 怎么更新视图
    在拿到需要更新的数据后,通过 object.defineproperty 的 set 方法去改变属性

所以现在,我们要做的就是在解析编译页面的时候,通过创建 Watcher 实例来对每一个属性进行观察,我们重新回到指令解析方法的代码中,添加几行代码。

创建 Watcher 实例

const compileUtil = {
  getVal(expr, vm) {
    //[person,name]
    return expr.split(".").reduce((data, currentVal) => {
      // console.log(currentVal);
      return data[currentVal];
    }, vm.$data);
  },
  getContentVal(expr, vm) {
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getVal(args[1], vm);
    });
  },
  text(node, expr, vm) {
    let value;
    //expr:msg
    //const value = vm.$data[expr];
    //当value是person.name这样的内容的时候value值就不对了,所以还要进行处理
    if (expr.indexOf("{{") !== -1) {
      //处理双大括号 {{person.name}} -- {{person.ge}}
      value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {      	
        // watcher() 接收三个参数,当前vm实例,当前指令,回调函数返回当前指令的值	处理 v-text = person.fav
        new Watcher(vm, args[1], (newVal) => {
          this.updater.textUpdater(node, this.getContentVal(expr, vm));
        })
        return this.getVal(args[1], vm);
      });
    } else {
      const value = this.getVal(expr, vm);
    }
    this.updater.textUpdater(node, value);
  },
  html(node, expr, vm) {
    const value = this.getVal(expr, vm);
    new Watcher(vm, expr, (newVal) => {
      this.updater.htmlUpdater(node, newVal);
    });
    this.updater.htmlUpdater(node, value);
  },
  model(node, expr, vm) {
    const value = this.getVal(expr, vm);
    new Watcher(vm, expr, (newVal) => {
      this.updater.modelUpdater(node, newVal);
    });
    this.updater.modelUpdater(node, value);
  },
  on(node, expr, vm, eventName) {
    let fn = vm.$options.methods && vm.$options.methods[expr];
    node.addEventListener(eventName, fn.bind(vm), false);
  },
  //更新的函数
  updater: {
    textUpdater(node, value) {
      node.textContent = value;
    },
    htmlUpdater(node, value) {
      node.innerHTML = value;
    },
    modelUpdater(node, value) {
      node.value = value;
    },
  },
};

这样我们就完成了,数据更新=>视图变化,视图变化=>数据更新的双向数据绑定了

整体流程: 

在初始化页面的时候,通过 Compile 类对指令进行编译,并且在把数据渲染到页面上的时候,在当前节点 new 一个 Watcher ,这个 watcher 接收三个参数,第一个是当前实例 vm,第二个是当前属性 expr ,第三个是回调函数把当前属性的值返回。

同时,通过 Observer 类的 observe 方法对数据进行监听并通过 object.defineproperty 对属性进行劫持。 

在初始化页面的时候,通过 defineproperty 的 get 方法获取属性值时,在 dep 的 target 属性上标记当前 watcher(dep 收集依赖),并且把当前 watcher 通过 push 存放到 dep 数组里。

当数据发生变化的时候,Observer 类监听到了变化,在通过 defineproperty 的 set 方法对属性值进行更改的时候,调用 dep 的 notify 方法去通知 watcher 数据变化了(dep 通知更新),然后 watcher 通过 update 方法调用 Compile 类上的 compileUtil 子类的 getVal 方法拿到数据来完成数据的更新。

 完整 myVue.js 和对应 myindex.html 在码云开源:gitee.com/lin_si_wei/…