Vue2响应式原理笔记自用

58 阅读6分钟

数据劫持

Vue2的响应式原理的根本: Object.defineProperty 而由于我们需要一个变量来赋值getter和setter,通常会使用闭包。 于是引出第一个部分 defineReactive函数

//import Dep from './dep.js';
import observe from './observe.js';

export default function defineReactive(data, key, val) {
    //const dep = new Dep()
    // console.log('我是defineReactive', key);
    if (arguments.length == 2) {
        val = data[key];
    }

    // 子元素要进行observe,至此形成了递归。这个递归不是函数自己调用自己,而是多个函数、类循环调用
    let childOb = observe(val);

    Object.defineProperty(data, key, {
        // 可枚举
        enumerable: true,
        // 可以被配置,比如可以被delete
        configurable: true,
        // getter
        get() {
            console.log('你试图访问' + key + '属性');
            // 如果现在处于依赖收集阶段
            return val;
        },
        // setter
        set(newValue) {
            console.log('你试图改变' + key + '属性', newValue);
            if (val === newValue) {
                return;
            }
            val = newValue;
            // 当设置了新值,这个新值也要被observe
            childOb = observe(newValue);
            //发布订阅模式,通知dep
            //dep.notify()
        }
    });
};

这个函数可以劫持到对象里的一个属性,那么要劫持多个属性,应当怎么办呢?于是引出了第二个部分,Observer类

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

const obj = { a: 1, b: 2 }
new Observer(obj)

我们知道,劫持一个对象的多个属性,要遍历到每一层的属性,而上面的Obeserver类只能遍历第一层的属性。 为了遍历深层的属性,我们就需要用到递归遍历,这就引出了observe方法。

import Observer from './Observer.js';
export default function (value) {
    // 如果value不是对象,什么都不做
    if (typeof value != 'object') return;
    // 定义ob
    var ob;
    if (typeof value.__ob__ !== 'undefined') {
        ob = value.__ob__;
    } else {
        ob = new Observer(value);
    }
    return ob;
}

可以看到 observe方法内接收了value,然后以此创建一个observer类实例,在observer类实例中,又会遍历属性调用defineReactive方法(进入下一层),而在defineReactive方法内,又会调用observe方法。其逻辑如下图:

image.png

执行observe(obj)
├── new Observer(obj),并执行this.walk()遍历obj的属性,执行defineReactive()
    ├── defineReactive(obj, a)
        ├── 执行observe(obj.a) 发现obj.a不是对象,直接返回
        ├── 执行defineReactive(obj, a) 的剩余代码
    ├── defineReactive(obj, b) 
	    ├── 执行observe(obj.b) 发现obj.b是对象
	        ├── 执行 new Observer(obj.b),遍历obj.b的属性,执行defineReactive()
                    ├── 执行defineReactive(obj.b, c)
                        ├── 执行observe(obj.b.c) 发现obj.b.c不是对象,直接返回
                        ├── 执行defineReactive(obj.b, c)的剩余代码
            ├── 执行defineReactive(obj, b)的剩余代码
代码执行结束

到目前为止,我们就完成了对象的递归劫持,但是数组是无法劫持到的,我们需要方法劫持数组,正因为我们可以通过Array原型上的方法来改变数组的内容,所以ojbect那种通过getter/setter的实现方式就行不通了。vue2采用的方法是,创建一个arrayMethods对象,让其继承Array.prototype,方便调用原型链上的方法,然后重写以下几个方法:push, pop, shift, unshift, sort, reverse, splice, 然后每次调用时,都会执行这个对象里的方法。我们可以将这个对象理解成一个拦截器,覆盖Array.prototype

//array.js
import def from "./def";

const arrayPrototype = Array.prototype;

// 以Array.prototype为原型创建arrayMethod
export const arrayMethods = Object.create(arrayPrototype);

// 要被改写的7个数组方法
const methodsNeedChange = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse",
];

// 批量操作这些方法
methodsNeedChange.forEach((methodName) => {
  // 备份原来的方法
  const original = arrayPrototype[methodName];

  // 定义新的方法
  def(
    arrayMethods,
    methodName,
    function () {
      console.log("array数据已经被劫持");

      // 恢复原来的功能(数组方法)
      const result = original.apply(this, arguments);
      // 把类数组对象变成数组
      const args = [...arguments];

      // 把这个数组身上的__ob__取出来
      // 在拦截器中获取Observer的实例
      const ob = this.__ob__;

      // 有三种方法 push、unshift、splice能插入新项,要劫持(侦测)这些数据(插入新项)
      let inserted = [];
      switch (methodName) {
        case "push":
        case "unshift":
          inserted = args;
          break;
        case "splice":
          inserted = args.slice(2);
          break;
      }

      // 查看有没有新插入的项inserted,有的话就劫持
      if (inserted) {
        ob.observeArray(inserted);
      }

      return result;
    },
    false
  );
});

Observer.js __ob__的作用可以用来标记当前value是否已经被Observer转换成了响应式数据了
而且可以通过value.__ob__来访问Observer的实例

import def from "./def";
import defineReactive from "./defineReactive";
import observe from "./observe";
import {arrayMethods} from './array'
/**
 * 将一个正常的object转换为每个层级的属性都是响应式(可以被侦测)的object
 * Observer 类会附加到每一个被侦测的object上
 * 一旦被附加,Observer会将object所有属性转换成getter/setter的形式
 * 来收集属性的依赖,并且当属性发生变化时会通知这些依赖
 */
export default class Observer {
  // 构造器
  constructor(value) {
  
    // 给实例添加__ob__属性,值是当前Observer的实例,不可枚举
    def(value, "__ob__", this, false);
    // __ob__的作用可以用来标记当前value是否已经被Observer转换成了响应式数据了;而且可以通过value.__ob__来访问Observer的实例
    
    // console.log("Observer构造器", value);
    // 判断是数组还是对象
    if (Array.isArray(value)) {
      // 是数组,就将这个数组的原型指向arrayMethods
      Object.setPrototypeOf(value, arrayMethods);
      // 早期实现是这样
      // value.__proto__ = arrayMethods;
      
      // observe数组
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }
  // 对象的遍历方式 遍历value的每一个key
  walk(value) {
    for (let key in value) {
      defineReactive(value, key);
    }
  }
  // 数组的遍历方式
  observeArray(arr) {
    for (let i = 0, l = arr.length; i < l; i++) {
      // 逐项进行observe
      observe(arr[i]);
    }
  }
}

有些同学可能会想,只要在setter中调用一下渲染函数来重新渲染页面,不就能完成在数据变化时更新页面了吗?确实可以,但是这样做的代价就是:任何一个数据的变化,都会导致这个页面的重新渲染,代价未免太大了吧。我们想做的效果是:数据变化时,只更新与这个数据有关的DOM结构,那就涉及到下文的内容了:依赖

收集依赖和派发更新

链接订阅发布模式,占个坑 在Vue中,我们定义一个Watcher类,这个类的作用是:每个Watcher实例订阅一个或多个数据,这些数据也被称为watcher的依赖,当依赖发生变化,watcher实例会调用依赖对应的回调函数,比如更新页面操作。

class Watcher {
  constructor(data, expression, cb) {
    // data: 数据对象,如obj
    // expression:表达式,如b.c,根据data和expression就可以获取watcher依赖的数据
    // cb:依赖变化时触发的回调
    this.data = data
    this.expression = expression
    this.cb = cb
    // 初始化watcher实例时订阅数据
    this.value = this.get()
  }
  
  get() {
    const value = parsePath(this.data, this.expression)
    return value
  }
  
  // 当收到数据变化的消息时执行该方法,从而调用cb
  update() {
    this.value = parsePath(this.data, this.expression) // 对存储的数据进行更新
    cb()
  }
}

function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}

由发布订阅模式我们可以推出,我们还需要在每个数据上建立一个数组,存放watcher,我们可以通过闭包在defineReactive中建立一个数组,这样每个数据就能有独立的存放watcher的数组,我们将这个数组命名为dep。

依赖收集

在Watcher类中,在定义初始化watcher实例时我们会调用this.get方法,这个方法会访问数据,也就是说这个get方法会触发getter,而我们当前的需求是将watcher存入到dep数组中,显然在getter中进行这一操作是没问题的。同时,我们也可以将Dep抽象为一个类。基于上述,我们就是在getter中把watcher实例存放到dep数组中,那么怎么获取watcher实例呢?如果我们给window的某个属性绑定上watcher实例,试想,有一个对象obj: { a: 1, b: 2 }我们先实例化了一个watcher1watcher1依赖obj.a,那么window.target就是watcher1。之后我们访问了obj.b,会发生什么呢?访问obj.b会触发obj.bgettergetter会调用dep.depend(),那么obj.bdep就会收集window.target, 也就是watcher1,这就导致watcher1依赖了obj.b,但事实并非如此。为解决这个问题,我们做如下修改:

// Watcher的get方法
get() {
  window.target = this
  const value = parsePath(this.data, this.expression)
  window.target = null // 新增,求值完毕后重置window.target
  return value
}

// Dep的depend方法
depend() {
  if (Dep.target) { // 新增
    this.addSub(Dep.target)
  }
}

想一个这样的场景:我们有两个嵌套的父子组件,渲染父组件时会新建一个父组件的watcher,渲染过程中发现还有子组件,就会开始渲染子组件,也会新建一个子组件的watcher。在我们的实现中,新建父组件watcher时,window.target会指向父组件watcher,之后新建子组件watcherwindow.target将被子组件watcher覆盖,子组件渲染完毕,回到父组件watcher时,window.target变成了null,这就会出现问题,因此,我们用一个栈结构来保存watcher

const targetStack = []

function pushTarget(_target) {
  targetStack.push(window.target)
  window.target = _target
}

function popTarget() {
  window.target = targetStack.pop()
}
get() {
  pushTarget(this) // 修改
  const value = parsePath(this.data, this.expression)
  popTarget() // 修改
  return value
}

总代码

// 调用该方法来检测数据
function observe(data) {
  if (typeof data !== 'object') return
  new Observer(data)
}

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

// 数据拦截
function defineReactive(data, key, value = data[key]) {
  const dep = new Dep()
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      dep.depend()
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue)
      dep.notify()
    }
  })
}

// 依赖
class Dep {
  constructor() {
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.addSub(Dep.target)
    }
  }

  notify() {
    const subs = [...this.subs]
    subs.forEach((s) => s.update())
  }

  addSub(sub) {
    this.subs.push(sub)
  }
}

Dep.target = null

const TargetStack = []

function pushTarget(_target) {
  TargetStack.push(Dep.target)
  Dep.target = _target
}

function popTarget() {
  Dep.target = TargetStack.pop()
}

// watcher
class Watcher {
  constructor(data, expression, cb) {
    this.data = data
    this.expression = expression
    this.cb = cb
    this.value = this.get()
  }

  get() {
    pushTarget(this)
    const value = parsePath(this.data, this.expression)
    popTarget()
    return value
  }

  update() {
    const oldValue = this.value
    this.value = parsePath(this.data, this.expression)
    this.cb.call(this.data, this.value, oldValue)
  }
}

// 工具函数
function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}

// for test
let obj = {
  a: 1,
  b: {
    m: {
      n: 4
    }
  }
}

observe(obj)

let w1 = new Watcher(obj, 'a', (val, oldVal) => {
  console.log(`obj.a 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
})

重新总结一下

  • 首先是数据劫持部分,数据劫持首先最核心的是object.defineProperty,通过这个函数可以完成单个对象属性的劫持,然后定义了Observer类,完成对象属性遍历劫持,再通过定义observe函数,判断传入的是否是对象,是则继续初始化observer类遍历。
  • 然后是依赖收集和派发更新部分,我们定义一个Watcher类,每个watcher实例订阅一个或多个数据,并保存数据变化时触发的回调。每一个数据应当有一个数组,存放订阅该数据的watcher,我们定义这个数组为dep, 由于我们劫持了数据,而且watcher实例会在初始化时进行订阅数据,那么就会触发数据的getter,所以我们在getter中完成dep对watcher的收集。那么在数据更新时,我们就可以在数据的setter里遍历dep数组,调用每个watcher的回调函数,也就是通知视图层进行渲染,实现通知订阅者。

图片.png 关于对数组的处理部分,我们定义了一个代理原型,在这个代理原型上,我们重写了数组七个方法。原因是obj.defineProperty是没有能力对超出原数组长度的元素进行劫持的,同时对数组每个元素进行劫持性能消耗又是巨大的,所以我们只在这七个方法上对数组元素进行劫持,同时,我们需要对如push、unshift这些新增的元素进行劫持。

图片.png