13

手写Vue源码(五) - 依赖收集

 3 years ago
source link: https://zhuanlan.zhihu.com/p/343502936
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

手写Vue源码(五) - 依赖收集

Enjoy what you are doing!

源码地址:传送门

Vue为用户提供了一个特别方便的功能:数据更新时自动更新DOM。本文将详细介绍Vue源码中该特性实现的核心思路,深入理解Vue数据和视图的更新关系。

如何追踪变化

这是Vue官方数据变化引发视图更新的图解:

v2-a3e8b118640109774b33e02b1af95a4b_720w.jpg

用文字描述的话,其流程如下:

  1. 组挂载,执行render方法生成虚拟DOM。此时在模板中用到的数据,会从vm实例上进行取值
  2. 取值会触发data选项中定义属性的get方法
  3. get方法会将渲染页面的watcher作为依赖收集到dep
  4. 当修改模板中用到的data中定义的属性时,会通知dep中收集的watcher执行update方法来更新视图
  5. 重新利用最新的数据来执行render方法生成虚拟DOM。此时不会再收集重复的渲染watcher

渲染watcher就是用来更新视图的watcher,具体的执行过程在组件初渲染中有详细介绍,它的主要作用如下:
1. 执行vm._render方法生成虚拟节点
2. 执行vm._update方法将虚拟节点处理为真实节点挂载到页面中

需要注意的是,数组并没有为每个索引添加set/get方法,而是重写了数组的原型。所以当通过调用原型方法修改数组时,会通知watcher来更新视图,保证页面更新。

收集watcher并且在数据更新后通知watcher更新DOM的功能主要是通过Dep来实现的,其代码如下:

let id = 0;

class Dep {
  constructor () {
    // dep的唯一标识
    this.id = id++;
    this.subs = [];
  }

  addSub (watcher) {
    this.subs.push(watcher);
  }

  // 通过watcher来收集dep
  depend () {
    Dep.target.addDep(this);
  }

  // 执行所有收集watcher的update方法
  notify () {
    this.subs.forEach(sub => {
      sub.update();
    });
  }
}

Dep会将watcher收集到内部数组subs中,之后通过notify方法进行统一执行。

代码中还会维护一个栈,来保存所有正在执行的watcher,执行完毕后watcher出栈。

const stack = [];
// 当前正在执行的watcher
Dep.target = null;

export function pushTarget (watcher) {
  stack.push(watcher);
  Dep.target = watcher;
}

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

目前代码并没有用到栈,在之后实现计算属性时,会利用栈中存储的渲染watcher来更新视图

通过上面的代码,就可以通过dep来实现对watcher的收集和通知。

Watcher

本文中讲到的watcher只是起到渲染视图的作用,所以将其称为渲染watcher。在之后涉及到watchcomputed之后,还会有它们各自相对应的watcher

Watcher的主要功能:

  • 收集dep,用于之后实现computed的更新
  • 通过get方法来更新视图
let id = 0;

class Watcher {
  constructor (vm, exprOrFn, cb, options) {
    // 唯一标识
    this.id = id++;
    this.vm = vm;
    this.exprOrFn = exprOrFn;
    this.cb = cb;
    this.options = options;
    this.deps = [];
    this.depsId = new Set(); // 利用Set来进行去重
    if (typeof exprOrFn === 'function') {
      this.getter = this.exprOrFn;
    }
    this.get();
  }

  // 在watcher中对dep进行去重,然后收集起来,并且再让收集的dep收集watcher本身(this)。这样便完成了dep和watcher的相互收集
  addDep (dep) {
    // 用空间换时间,使用Set来存储deps id进行去重
    if (!this.depsId.has(dep.id)) {
      this.deps.push(dep);
      this.depsId.add(dep.id);
      // 重复的dep无法进入,每个dep只能收集一次对应watcher
      dep.addSub(this);
    }
  }

  get () {
    // 更新视图之前将watcher入栈
    pushTarget(this);
    this.getter();
    // 视图更新后,watcher出栈
    popTarget();
  }

  // 更新视图
  update () {
    this.get();
  }
}

Watcher接收的参数如下:

  • vm: Vue组件实例
  • exprOrFn: 表达式或者函数
  • cb: 回调函数
  • options: 执行watcher的一些选项

首先,在组件初次挂载时,会实例化Watcher,在Watcher内部会执行传入的exprOrFn渲染页面:

Vue.prototype.$mount = function (el) {
  // some code ...
  mountComponent(vm);
};

export function mountComponent (vm) {
  callHook(vm, 'beforeMount');

  function updateComponent () {
    vm._update(vm._render());
  }

  // 在实例化时,会执行updateComponent来更新视图
  new Watcher(vm, updateComponent, () => {}, { render: true });
  callHook(vm, 'mounted');
}

data选项中的值发生更新后,会通过dep.notify来调用watcherupdate,而watcherupdate方法会调用exprOrFn即我们之前传入的updateComponent 方法,从而更新视图。

依赖收集时分别对对象和数组进行了不同的操作:

  • 对象:在对象每一个属性的get方法中,利用属性对应的dep来收集当前正在执行的watcher
  • 数组:在Observer中,为所有data中的对象和数组都添加了__ob__属性,可以获取Observer实例。并且为Observer实例设置了dep 属性,可以直接通过array.__ob__.depend()来收集依赖。

设置值时:

  • 对象:通过被修改属性的set方法,调用dep.notify来执行收集的watcherupdate方法
  • 数组:通过调用数组方法来修改数组,在对应的数组方法更新完数组后,还会执行数组对应的array.__ob__.notify来通知视图更新

依赖收集的具体代码如下:

为每一个Observer添加dep属性:

class Observer {
  constructor (value) {
    this.value = value;
    this.dep = new Dep(); // data中对象和数组创建dep
    // 为data中的每一个对象和数组都添加__ob__属性,方便直接可以通过data中的属性来直接调用Observer实例上的属性和方法
    defineProperty(this.value, '__ob__', this);
    if (Array.isArray(value)) {
      Object.setPrototypeOf(value, arrayProtoCopy);
      this.observeArray(value);
    } else {
      this.walk();
    }
  }

  // some code ...
}

observe中将Observer实例返回,并且对已经执行过Observer的数据不再处理:

function observe (data) {
  // 如果是对象,会遍历对象中的每一个元素
  if (typeof data === 'object' && data !== null) {
    // 已经观测过的数据会有__ob__属性,将不再处理,返回undefined
    if (data.__ob__) {
      return;
    }
    // 返回Observer实例
    return new Observer(data);
  }
}

data中每个对象的属性都会在get方法中收集依赖,在set方法中通知视图更新。也会为data中的对象和数组在Observer实例中创建的dep收集watcher

function defineReactive (target, key) {

  let value = target[key];
  // 继续对value进行监听,如果value还是对象的话,会继续new Observer,执行defineProperty来为其设置get/set方法
  // 否则会在observe方法中什么都不做
  const childOb = observe(value);
  const dep = new Dep();
  Object.defineProperty(target, key, {
    get () {
      if (Dep.target) { // 每个属性都收集watcher
        // 为对象的每一个属性收集依赖
        dep.depend();
        if (childOb) {
          // 收集数组的依赖,在数组更新的时候,会调用notify方法,通知数组更新
          // 这里是定义在Observer中的另一个新的dep
          childOb.dep.depend();
          // 对于数组中依旧有数组的情况,需要对其再进行依赖收集
          dependArray(value);
        }
      }
      return value;
    },
    set (newValue) {
      if (newValue !== value) {
        observe(newValue);
        value = newValue;
        dep.notify();
      }
    }
  });
}

对于数组,要递归为数组中每一项继续收集watcher。这样即使当数据为arr:[[1,2,3]]时,也可以在内层数组调用数组方法更新时通知视图更新:

// src/observer/array.js
export function dependArray (data) {
  if (Array.isArray(data)) {
    for (let i = 0; i < data.length; i++) {
      const item = data[i];
      // item也可能是对象,会对对象再次进行依赖收集,此时和defineReactive中收集的dep不是同一个
      item.__ob__?.dep.depend();
      dependArray(item);
    }
  }
}

当调用修改原数组的方法时,通过vm.array.__ob__.dep.notify来通知视图更新:

// some code ...
methods.forEach(method => {
  arrayProtoCopy[method] = function (...args) {
    const result = arrayProto[method].apply(this, args);
    // data中的数组会调用这里定义的方法,this指向该数组
    const ob = this.__ob__;
    // some code ...
    ob.dep.notify();
    return result;
  };
});

调用如concat等数组方法时,并不会修改原数组,需要我们手动将原数组赋值为更改后的新数组,这样就会触发defineReactive中原数组对应的set方法,从而更新视图。

// 会触发array属性的set方法,调用dep.notify通知视图更新
vm.array = newArray

Observer中定义的dep,与defineReactive中的dep不同,是一个新的dep,会收集数组和对象依赖的watcher。在之后便可以很方便的通过vm.data.__ob__ 来获取到Observer实例,进行而调用dep中的dependnotify方法。

$set 和 $delete

现在数据更新,视图也会自动更新。但是删除和新增对象属性以及通过索引修改数组并不会更新视图,为了应对这些情况,我们为代码设计了$set$delete方法。

其用法如下:

// Vue.set( target, propertyName/index, value )
// 为对象新增属性
this.$set(this.someObject, 'b', 2)
// 通过索引来修改数组
this.$set(this.someArray, 1, 2)
// Vue.delete( target, propertyName/index)
this.$delete(this.someObject, 'a')

下面是其代码实现:

由于新增属性时,value是自己传入的,需要重构defineReactive函数。这里对于重构过程不再赘述,具体可以参考源代码。

function set (target, key, value) {
  if (Array.isArray(target)) {// 数组直接调用splice方法
    target.splice(key, 0, value);
    return value;
  }
  if (typeof target === 'object' && target != null) { // 对象
    const ob = target.__ob__;
    // 通过Object.defineProperty为对象新加的属性,添加其对应的set/get方法,并进行依赖收集
    defineReactive(target, key, value);
    // 对象更新后通知视图更新
    ob.dep.notify();
    return value;
  }
}

function del (target, key) {
  if (Array.isArray(target)) {
    // 代用splice删除元素
    target.splice(key, 1);
    return;
  }
  if (typeof target === 'object' && target != null) { // 对象
    const ob = target.__ob__;
    delete target.key;
    // 删除对象属性后通知视图更新
    ob.dep.notify();
  }
}

对于数组,其实只是调用了splice方法进行元素的添加和删除。

如果是对象,$set方法会通过defineReactive为对象新增属性,并保证属性具有响应性,而$delete 会帮用户将对象中的对应属性删除。最终,$set$delete都会利用之前在Observer中设置的dep属性通知视图更新。

在实现对应的方法后,为了方便用户使用,将其设置到Vue的原型上:

// src/state.js
export function stateMixin (Vue) {
  Vue.prototype.$set = set;
  Vue.prototype.$delete = del;
}
import { stateMixin } from './state';

function Vue (options) {
  this._init(options);
}

// some code ...

// 添加原型方法$set $delete
stateMixin(Vue);
export default Vue;

这样用户便可以从组件实例中方便的调用$set$delete方法来保证数据的响应性

依赖收集的核心其实就是:

  • 获取数据的值时将视图更新函数放到一个数组中
  • 设置数据的值时依次执行数组中的函数来更新视图

这里可以回头再看一下Vue官方文档中"数据更改追踪 "的流程图,相信你会有不一样的理解!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK