vue源码解读(一):Observe

vue源码解读(一):Observe

Object

变化侦测

Vue.js会根据数据状态自动生成Dom,并将其渲染到页面之上。Vue.js的渲染是生命式的,我们通过模版来描述状态和Dom之间的联系。
通常,在运行时应用内部的状态是不断变化的,那么就需要不停的去渲染页面,但是这个过程中,状态发生了怎样的变化呢?
变化侦测就是用来解决这样的问题。它分为两种类型:”推” 和” 拉”。
其中AngularReact的变化侦测属于”拉”的类型。当数据发生改变的时候,会发送信号给框架,然后框架内部会进行一个暴力的对比,来找出哪些节点需要重新渲染,这就是Angular的脏检测。而React使用的则是虚拟Dom。
Vue.js使用的则是”推”类型。当一个状态发生改变之后,它会自动的发送通知给每一个依赖,让它们进行Dom更新。但是这也是有代价的,当一个状态的依赖越来越多的时候,这种推的过程的内存开销就会随之增大,所以在Vue2.0开始,引入来虚拟Dom,将粒度调整为中等粒度,每一个状态所绑定的依赖不再是具体的节点,而是组件。这样当状态发生变化之后,只需要通知对应的组件,由组件内部使用虚拟Dom进行比较。这样大大的降低来依赖数量,从而降低来依赖跟踪的内存消耗。

如何追踪变化

追踪一个对象的变化,有两个方法:Object.definePrototype()ES6的proxy。而由于ES6在各浏览器的支持度并不理想,所以在Vue2.0中,依旧使用的是Object.definePrototype()。但是在Vue3.0中,使用的就是proxy了。
根据Object.definePrototype()我们可以封装一个函数,来定义一个响应式数据对象。

function defineReactive(data,key,val){
    Object.defineProperty(data,key,{
        configurable:true,
        enumerable:true,
        get:function(){
            return val
        },
        set:function(newVal){
            if(val == newVal){
                return
            }
            val = newVal;
        }
    })
}
let obj = {
    a:10,
    b:20
}
defineReactive(obj,'a');
obj.a = 200;
console.log(obj.a);

只要调用函数defineReactive,传入参数data,key和val,即可对data进行监听。当获取data的key属性时,就会触发get;设置key属性时就会触发set。

收集依赖

所谓依赖,就是和目标对象的属性有一定的联系。而上面我们也通过一个栗子看到来,当我们获取一个对象的属性的时候,对象的访问器属性get就会被触发,那么我们可以不可以在这里进行依赖搜集?当get被触发的时候,将用到该属性的地方搜集起来,等到设置(修改属性值)的时候,即set被触发的时候,通知每一个依赖。
所以,最后我们得出结论:对象的监测,是在getter中收集依赖,在setter中触发依赖。
**

依赖保存在哪里

上一步我们已经知道如何搜集依赖,那么依赖搜集之后,我们将依赖放在哪里呢?
思考一下,如果以每一个key都对应一个数组,当key对应的值发生改变之后,遍历数组,触发依赖,那么是不是就可以来?
现在我们假设依赖是一个函数,保存在window.target上,那么:
修改上面的函数:

function defineReactive(data,key,val){
      let dep = [];//依赖
    Object.defineProperty(data,key,{
        configurable:true,
        enumerable:true,
        get:function(){
              dep.push(window.target);
            return val
        },
        set:function(newVal){
            if(val == newVal){
                return
            }
              for(let i = 0,len = dep.length; i < len; i++){
                    dep[i](newVal,val);
            }
            val = newVal;
        }
    })
}

这里我们新增数组dep作为存储搜集的依赖。然后当set被触发的时候,遍历dep,触发依赖。
然后我们观测以上代码,我们在变化侦测的方法中,进行依赖搜集的触发,这样写的化,代码的耦合度显得有点高,所以我们需要解耦,将dep抽离出来,单独封装成一个类:

export default class Dep {
    constructor(){
        this.subs = [];
    }
    addSub(sub){
        this.subs.push(sub)
    }
    removeSub(sub){
        let index = this.subs.indexOf(sub);
        if(index > 0){
            this.subs.splice(index,1);
        }
    }
    depend(){
        if(window.target){
            this.addSub(window.target)
        }
    }
    notify(){
        for (let index = 0; index < this.subs.length; index++) {
            const element = this.subs[index];
            element.update()
        }
    }
}

Dep.target = null
const targetStack = []

export function pushTarget (target) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

然后改造defineReactive:

import Dep from './dep'
function defineReactive(data,key,val){
      let dep = new Dep();//依赖
    Object.defineProperty(data,key,{
        configurable:true,
        enumerable:true,
        get:function(){
              dep.depend();
            return val
        },
        set:function(newVal){
            if(val == newVal){
                return
            }
              dep.notify();
            val = newVal;
        }
    })
}

依赖是谁

上面我们假设依赖是一个函数,window.target。那么它到底是什么?
我们知道,收集谁,就是当属性变化之后通知谁。
在Vue.js中,我们需要通知的地方,可能是模版、也可能是个watch。那么我们能不能将其抽离出来,封装成类,当属性收集的时候,只将类的实例收集起来,通知也只通知它一个,然后由它去通知其他地方。所以我们将这个类取个名字:Watcher。

什么是Watcher

在我的理解中,watcher就是一个中介,当属性发生变化的时候通知它,然后它再通知其他地方。

import { parsePath } from '../lib/parsePath';
import Dep, { pushTarget, popTarget } from './dep'

export default class Watcher {
    constructor(vm,expOrFn,cb){
        this.vm = vm;
        this.getter = parsrPath(expOrFn);
        this.cb = cb;
        this.value = this.get()
    }
    get(){
        pushTarget(this);
        // window.target = this;
        let value = this.getter.call(this.vm,this.vm);
        // window.target = undefined;
        popTarget();
        return value;
    }
    update(){
        const oldValue = this.value;
        this.value = this.get();
        this.cb.call(this.vm,this.value,oldValue)
    }
}

const bailRE = /^\w.s]/;
function parsePath(path){
    if(bailRE.test(path)){
        return
    }
    //使用.将path分割成数组,然后一层层的遍历,即可获取想读的数据
    const segments = path.split('.');
    return function(obj){
        for (let index = 0; index < segments.length; index++) {
            if(!obj) return;
            obj = obj[segments[index]];
        }
        return obj;
    }
}

在这段代码中,通过在get函数中,将this(即watcher实例)添加到Dep中,然后在读取属性值,就会触发getter,只要触发来getter就会触发依赖搜集逻辑。
将依赖注入到Dep中之后,只要属性值发生变化,就会让依赖列表中所有的依赖都执行update方法,也就是Watcher的update方法,而update方法会执行参数中的回调,将新老值传入回调中。

递归侦测所有的key

现在已经可以侦测到对象的属性值的变化,那么下一步就是将对象所有的属性都可以侦测到。那么我们就需要将其封装成一个Observer类。

class Observe {
    constructor(value) {
        this.value = value
        if (!Array.isArray(value)) {
            this.walk(value)
        }
    }
    /**
     * walk会将每一个对象的属性都转换成getter和setter的形式进行监听
     */
    walk(obj) {
        const keys = Object.keys(value)
        for (let i = 0; i < keys.length; i++) {
            defineReactive(obj, keys[i],obj[keys[i]])
        }
  }
}



function defineReactive(data, key, val) {
    if (typeof val === 'object') new Observe(val);//递归子属性
    let dep = new Dep() //依赖
    let childpOb = observe(val) //返回一个Observe实例
    Object.defineProperty(data, key, {
        configurable: true,
        enumerable: true,
        get: function() {
            //依赖搜集
            dep.depend()
            return val
        },
        set: function(newVal) {
            if (val == newVal) {
                return
            }
            val = newVal
            dep.notify()
        }
    })
}

我们定义类一个Observe类,它将一个正常的object转换为类一个被侦测的object。然后通过判断数据类型,只有Object类型的数据才会调用walk将每一个属性转换为getter/setter形式。

Object的问题

上面我们接受类Object的数据侦测的方式,是有getter/setter的方式实现的,那么正是由于这种追踪方式,使得一些属性变化不会被侦测到,如新增属性、删除属性。Vue.js的这种实现方式只能侦测一个对象的值的变化,而无法追踪新增属性和删除属性。所有才会导致上述问题。
但是在ES6之后,JavaScript提供类元编程的能力,我们可以通过proxy对Object进行元编程。具体请移步proxy

Array

数组的变化侦测

数组的变化侦测和Object的变化侦测有一点点的不同,下面我们举个🌰看看:

this.list.push(1)

在上面的代码中,我们向list数组中push了一个元素,但是这并没有触发getter/setter,所以用于Object的侦测方法已经不适合数组了。
在上面的代码中,我们还可以知道,我们通过push完成了数组的改变,那么如果我们在push的时候,获取通知,那么是不是就可以完成侦测?
所以,我们可以使用自定义的数组方法,通过覆盖数组原型的方法,来实现数组的变化侦测。如图:
image.png
我们在数组的原型前加了一层拦截器,拦截数组的操作方法,就可以实现对数组的变化侦测。

拦截器

那么拦截器是如何实现的呢?
首先,我们需要知道哪些方法可以改变数组,我们只需要在拦截器中重写这些方法覆盖数组原型对应的方法即可,没有必要对数组原型进行全方位的覆盖。
经过整理,我们知道可以改变数组自身内容的方法有:push、pop、shift、unshift、sort、reverse、slice七种方法。
所以,我们可以写出如下代码,对数组原型上的方法进行覆盖:

const originMethods = Array.prototype;
const arrayProto = Object.create(originMethods);
const methods = ['push','pop','shift','unshift','sort','reverse','slice'];

methods.forEach(method => {
  //缓存原始方法
    const originMethod = arrayProto[method];
  Object.definePrototype(arrayProto,method,{
      value:(...params) => {
      //todo
        return originMethod.apply(this,params);
    },
    enumerable:true,
    writeable:true,
    configerable:true
  })
})

在上述代码中,我们创造了arrayProto,它继承自Array.prototype,具备其一切的方法;接下来,我们通过遍历methods对其中每一个方法都进行重新封装,让其指向原型上的方法,这样我们使用拦截器拦截数组方法的操作时,使用的仍然是数组原型对象上的方法,而我们只需要在value的箭头函数中,做一些该做的事即可。

使用拦截器覆盖数组方法

有了拦截器之后,我们要做的就是使用拦截器覆盖Array.prototype,但是我们不能够进行全局覆盖,这样会污染全局的Array,而我们只希望拦截被侦测的数据,即只覆盖那些响应式的数组原型。
而将一个数据转换成响应式的,就需要使用Observe,所以我们在Observe中拦截即可。

具体请查阅源码:/src/core/observer#Observer

//判断是否支持__proto__
const hasProto = '_proto_' in {}
//如果支持_proto_属性,则使用protoAugment
function protoAugment(target, src, keys) {
    target._proto_ = src
}
//否则使用copyAugment
function copyAugment(target, src, keys) {
    // console.log(target,src,keys)
    for (let i = 0, len = keys.length; i < len; i++) {
        const key = keys[i]
        def(target, key, src[key])
    }
}

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor(value: any) {
    this.value = value;
    this.dep = new Dep();
    this.vmCount = 0;
    def(value, "__ob__", this);
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods);
      } else {
        copyAugment(value, arrayMethods, arrayKeys);
      }
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk(obj: Object) {
    const keys = Object.keys(obj);
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i]);
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray(items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i]);
    }
  }
}

上面我们通过上述代码,我们新增方法hasProto来判断浏览器是否支持proto属性,如果支持,则直接使用该属性设置对象的原型,否则使用递归复制到对象原型上。将拦截器设置在value.proto上之后,当用户使用这些方法之后,访问的不再是数组原型的方法,而是我们自定义的方法。
image.png

收集依赖

大家还记得我们前面提到的🌰吗?

this.list.push(1);

在这里,我们向数组中添加来一个元素。请注意,我们是在this对象上的list属性添加一个元素。所以,当我们访问list数组的时候,需要使用this.list。所以,我们就可以使用Object.definePrototype()来监听访问,当用户访问list时,一定会触发list的getter。
所以,数组的依赖收集也是在getter中进行。在拦截器中触发依赖。

依赖收集到哪里

在vue.js中依赖是被收集到Observe中的。

具体请查阅源码:/src/core/observer#defineReactive

function defineReactive(data, key, val) {
    if (typeof val === 'object') new Observe(val)
    let dep = new Dep() //依赖
    let childpOb = observe(val) //返回一个Observe实例
    Object.defineProperty(data, key, {
        configurable: true,
        enumerable: true,
        get: function() {
            console.log(val, 'get')
            //依赖搜集
            dep.depend()

            if (childpOb) {
                childpOb.dep.depend()
            }
            return val
        },
        set: function(newVal) {
            if (val == newVal) {
                return
            }
            console.log(newVal, 'set')
            // for(let i = 0; i < dep.length; i++){
            //     //遍历 依次触发依赖
            //     dep[i](newVal,val);
            // }
            val = newVal
            dep.notify()
        }
    })
}

/**
 * 尝试新建一个observe
 * 如果创建成功 返回新的observe实例
 * 否则返回已存在的observe实例
 */
function observe(value, asRootData) {
    if (!isObject(value)) return
    let ob
    //如果value已经是响应式的数据 则直接返回
    if (hasOwn(value, '_ob_') && value._ob_ instanceof Observe) {
        ob = value._ob_
    } else {
        ob = new Observe(value)
    }
    return ob
}

在上面我们创建来一个函数observe,用于新建Observe实例,如果value已经是响应式数据,即直接返回,否则创建一个新的Observe实例返回。避免来重复侦测value变化。而且,我们可以看到,在新建Observe实例时,vue.js是将实例挂载在value的_ob_属性上,所以细心的同学肯定也会发现我们平时开发时,一些响应式数据属性上总有一个_ob_属性,其实这就是Observe实例。

在拦截器中获取依赖

然后我们注意这一段代码:

this.dep = new Dep() //在此搜集依赖  保存在Observe
def(value, '_ob_', this) //将Observe挂载到 对象的_ob_属性上

这段代码将dep实例挂载这value上,然后将this设置在_ob_属性上,所以,我们后期就可以通过value的_ob_属性获取对应的依赖。

触发依赖

具体请查阅源码:/src/core/observer/array.js

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */

import { def } from '../util/index'

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

在上面的代码中,通过使用ob.dep.notify()来通知依赖数据发生来改变。

侦测数组中元素的变化

前面提到的侦测数组变化,指的是数组本身的变化,比如:新增、删除一个元素。那么,如果数组中某一元素是对象,对象的属性值发生变化也是要侦测的。也就是说响应式数据的子项也是要被侦听的。所以我们需要递归的遍历数组,将数组中的每一项都设置成响应式的。

......
if (Array.isArray(value)) {
  dependArray(value);
}
......

 /**
   * Observe a list of Array items.
   */
  observeArray(items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i]);
    }
  }

侦测新增元素的变化

if (inserted) ob.observeArray(inserted)

Array的问题

前面说过,Vue.js对数组实现侦测的方式是在数组原型上进行监听的,所以有一些方法改变数组是Vue.js是无法监测到的,如:this.list.length = 0、this.list[0] = 2,这样并不会触发re-render或者watcher。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!