Vue源码笔记之响应式系统

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_41694291/article/details/100175402

什么是响应式系统?

核心知识铺垫

get/set:get/set是javascript提供的访问和修改对象属性的底层方法。比如当我们在使用let temp = message.title访问message的title属性时,在底层实际上就是在调用message的title属性对应的get方法。同理,调用message.title = "abc"修改message的title属性值时,就是在调用title的set方法。这两个方法一般是封装在javascript引擎内的,但是javascript向开发者提供了Object.defineProperty接口来修改这两个方法,如:

let message = {
  title: "old"
};
let value = message.title;  //我们使用默认的get方法先得到之前的title值
Object.defineProperty(message, "title", {
  get: function(){
    console.log("你现在调用了message.title的get方法!");
    return value;
  },
  set: function( newVal ){
    console.log("你现在正在修改message.title的值");
    value = newVal;
  }
})

现在message.title的get/set被我们人为修改了,当我们再次执行let temp = message.title时,控制台就会输出get方法中的提示消息;而执行message.title = "abc"时,就会执行set方法中的提示消息。通过修改这两个方法,我们就可以对数据的变化进行监听,Vue的响应式系统正是基于该接口实现的。
注意:这是目前2.x版本Vue的实现原理,Vue的作者表示3.0版本将使用ES6提供的proxy(代理)来实现。两者的原理类似,但是proxy的性能更好,功能更强大。

响应式系统概述

Vue的响应式系统是一个精心搭建的数据监控系统,它负责监测项目中的数据变化,然后通知对该数据“感兴趣”的订阅者进行相关操作。

数据的监测分为两类,一类是对对象的监测,另一类是对数组的监测。对对象监测的基本原理是修改对象每个属性的get/set方法,使其变为响应式属性;对数组监测的基本原理是拦截数组的七个原型方法(如push、pop等),并将数组的对象或数组成员继续进行响应式转化。

我们先来理解“数据”、“感兴趣”以及“订阅者”这三个关键词。

这里指的数据,就是options中的data配置项,它通过以下两种方式定义:

//单文件中使用Vue
var app = new Vue({
  el: "#app",
  data: { ... },
  template: "",
  ...
})

//单文件组件
<template>
  ...
</template>
<script>
  name: "",
  data(){
    return {
      ...
	}
  }
</script>

通常我们把与数据操作相关的逻辑称为业务逻辑,而这些数据如何展现在页面上则是由视图层负责。MVVM的核心思想正是让开发者把目光聚焦于业务逻辑,关注如何进行数据操作,而不是考虑视图如何更新(虽然Vue不是严格的MVVM框架,但这里的思想是一致的)。

那么什么叫“感兴趣”呢?我们举个例子来说明,假设有下面一个组件:

//CustormForm.vue
<template>
  <h1>{{ title }}</h1>
</template>

<script>
  name: "custom-form",
  data() {
	title: "表单",
  },
  watch:{
    "form.title": function(newVal, oldVal){
      console.log("标题发生了变化:" + newVal);
	}
  }
</script>

现在组件中有一个被监听的数据title。我们在模板中以{{ title }}的形式将它的值绑定在模板里,这样在渲染该组件时,Vue就会把title的值替换进去,模板会被渲染为下面的样子:

<h2>表单</h2>

如果之后title的值发生了变化,Vue的响应式系统就会监听到这个变化,然后更新上面的视图。因为这里视图的渲染和更新需要依赖title的值,而在Vue中是借助一个watcher(订阅者)来根据数据变化进行视图的渲染和更新的,因此我们说这个watcher对title“感兴趣”。类似的,上面我们在watch中对title注册了一个回调函数,它会在title的值变化后,向控制台输出新的title值。而Vue也是通过生成一个watcher来负责根据数据变化执行上述回调函数的,因此这个watcher也对title的变化“感兴趣”。

简单来说,只要是关注某个数据的变化,就是对该数据“感兴趣”。

扫描二维码关注公众号,回复: 7637977 查看本文章

那什么是订阅者呢?

一个订阅者就是一个在数据变化后可以执行一定操作的javascript对象(也就是上面提到的watcher)。具体来说,在上面的例子中,当title发生变化时,会有一个对象负责重新渲染视图,同时会有另一个对象负责向控制台输出新的值。这两个对象都是订阅者。它们会被注册到title的依赖者列表中,当title变化时,就会调用它们的update方法进行对应的操作。

简单来说,订阅者就是数据变化时执行操作的对象。

响应式系统的简介就到这里,下面我们来看响应式系统的实现。现在假如我们有如下的Vue实例:

let app = new Vue({
  el: "#app",
  template: "<div>{{message.title}}</div>",
  data: {
    message: {
      title: ""
    },
    author: []
  }
})

我们以上面的例子为基础,分别介绍Vue对对象和数组的监听原理。

1. 对象监测篇

对象监测系统的结构设计

对象的监测系统基于三个核心类:Observer(观察者)、Dep(依赖)和Watcher(订阅者)。

从作用上来看:

  1. Observer负责监听数据的变化,并在数据变化时通知Dep
  2. Dep负责收集对该数据“感兴趣”的订阅者,并在收到Observer的数据变化通知时调用Watcher提供的update方法
  3. Watcher就是订阅者,它向Dep提供update接口,内部封装了在数据变化后需要执行的操作,可能是更新视图或执行回调

从结构上来看:

  1. Observer以对象的__ob__属性存在。比如一个对象:
data(){
  return {
    message: {title: ""}
  }
}

执行new Observer(message)就可以把message变成响应式的对象,执行之后的message对象将变成(这里省略了__proto__属性,它与响应式系统无关):

message: {
  title: "",
  __ob__: Observer {value: {}, dep: Dep, vmCount: 0},
  get str: ƒ reactiveGetter(),
  set str: ƒ reactiveSetter(newVal)
}

message新增了一个属性__ob__,它是一个Observer实例。具有该属性也表明message已经被封装为了一个响应式的对象(即它的属性具备在值发生变化时自动触发某些事件的能力)。get和set实际上是由它的父对象(在这里就是data)执行Object.defineProperty时为它定义的,get用于收集对message的变化“感兴趣”的订阅者,set用于触发响应。

  1. Dep作为__ob__的属性dep存在,或者以闭包的形式存在。继续展开上面的message对象,它的结构是这样的:
message: {
  title: "",
  __ob__: {
    dep: Dep {id: 1, subs: Array(0)},
    value: {__ob__: Observer},
    vmCount: 0
  },
  get str: ƒ reactiveGetter()
  set str: ƒ reactiveSetter(newVal)
}

上面是作为属性存在的Dep,用于收集对当前数据“感兴趣”的watcher,另外还有一种作为闭包存在的Dep,后面会讲到。

  1. Watcher作为Dep的subs数组元素存在。继续展开上面的对象:
message: {
  title: "",
  __ob__: {
    dep: {
      id: 1,
      subs: [watcher1, watcher2, ...]
    },
    value: {__ob__: Observer},
    vmCount: 0
  },
  get str: ƒ reactiveGetter()
  set str: ƒ reactiveSetter(newVal)
}

每个watcher定义了在数据发生变化时该进行什么样的操作,它向外提供了一个update方法来执行该操作。

监测对象变化的大体思路是:在new Observer(data)时修改data每个属性的get/set(如果该属性是对象,就递归下去),并生成一个与该属性对应的Dep实例。一旦某个watcher通过get获取该属性的值,它就会被收集到对应的Dep实例的subs数组中。当属性值变化时,就会触发属性的set,Dep实例会依次调用subs中收集的watcher提供的update方法执行操作。

注意:响应式系统在进行响应式转化的时候是递归的,因此如果对象的某个属性是对象,那么它的每个属性也都会被递归地转化为响应式属性。有人可能疑惑,基本数据类型的属性在Vue中不是响应式的吗?当然不是。因为Vue构造响应式系统时最基本的操作是将一个对象的属性(而不是对象本身)转化为响应式的。也就是说,只要是以某个对象的属性存在的,它就是响应式的(无论它是什么类型的属性,data参数里声明的变量,都是以data的属性存在的)。同样这也意味着,作为响应式系统入口的根对象data本身不是响应式的(实际上Vue不允许直接修改该对象)。

对象监测系统的实现

上面说到,Vue的整个响应式系统是以data对象为入口的。我们回顾一下Vue实例的初始化过程:

//摘自src/core/instance/index.js
function Vue(options){
  ...
  this._init(options);
}
initMixin(Vue);
...

//摘自src/core/instance/init.js
function initMixin(){
  Vue.prototype._init = function(options){
    const vm = this; 
    ...
    initState(vm);
    ...
  }
}

//摘自src/core/instance/state.js
function initState(vm){
  vm._watchers = []
  const opts = vm.$options
  ...
  if(opts.data){
    initData(vm);
  }
}
function initData(vm){
  let data = vm.$options.data;
  ...
  observe(data, true/* as root data */);
}

从observe函数开始,我们就正式进入到响应式系统的模块了。这个observe函数引自src/core/observer/index.js,负责将一个普通对象的属性转化为响应式的,这里我们要进行转化的就是根数据对象data。

首先我们来看observe函数所在文件的大致结构,它是响应式系统的入口文件。

//摘自src/core/observer/index.js
import Dep from './dep'
import VNode from '../vdom/vnode'
import { arrayMethods } from './array'
...
//定义Observer类
export class Observer {
  value: any;
  dep: Dep;
  vmCount: number;
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    //如果是数组,需要使用observeArray进行监测
    if (Array.isArray(value)) {
      //向数组原型添加拦截器,如果浏览器不支持__proto__,则直接添加到数组上
      //这里会在数组篇讨论
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
    //如果是对象,则使用walk遍历所有属性
      this.walk(value)
    }
  }
  //用for循环遍历所有属性,使用defineReactive将其转化为响应式属性,
  //这个defineReactive是对象观测的核心方法
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  //观测数组,第二篇讨论
  observeArray(){
    ...
  }
}
  ...
//观测对象的方法,负责将value转化为响应式对象
function observe (value, asRootData){
  //为了方便理解,这里对源码进行了提取
  let ob;
  //当前对象有__ob__属性,说明已经是响应式对象了,直接返回__ob__属性即可
  if(hasOwn(value, __ob__)){
    ob = value.__ob__;
  } else if(/*value具备转化为响应式对象的条件*/){
    ob = new Observer(value);
  }
  ...
  return ob;
}
//负责将对象属性转化为响应式属性,这个函数接下来将详细分析
function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
){
  ...
}

我们知道,响应式系统的监测对象就是当前Vue实例的data对象(这个监测是递归的,也就是说data内的任何后代属性都会被监测),而对data的观测是以observe(data)为入口的。代码中value(此时,局部变量value就指代data)转化为响应式的流程如下:

  1. 执行observe(value)。首先检查当前对象是否已经是响应式对象(防止对一个对象重复监测),如果不是,就执行new Observer(value)。
  2. 调用Observer的构造函数。在构造函数内,根据value是普通对象还是数组执行不同的操作。这里的data是一个对象,因此将执行this.walk(value),遍历它的属性。
  3. 调用walk(value),将value的属性枚举出来,分别调用defineReactive(obj, key),将它的每个属性转化为响应式属性。
  4. 调用defineReactive(obj, key),进行get/set转化,通过修改该属性取值和设值的行为,将其转化为一个可观测的属性。

下面我们详细来看defineReactive函数的实现:

function defineReactive (
  obj: Object,   //当前属性所属的对象
  key: string,   //需要转化为响应式的属性
  val: any,      //当前的属性值
  customSetter?: ?Function,  //自定义的setter
  shallow?: boolean //是否为浅观测,即如果当前属性是对象,是否要递归监测子属性
) {
  //闭包形式存在的Dep实例,用于收集对当前属性“感兴趣”的watcher
  const dep = new Dep()
  ...
  //如果是深度监测,就递归调用observe函数监测该属性的子属性
  let childOb = !shallow && observe(val)
  //修改该属性的get/set,响应式系统的核心
  Object.defineProperty(obj, key, {
    enumerable: true,  //规定该属性可遍历
    configurable: true,  //规定该属性可配置(可修改)
    get: function reactiveGetter () {
      //先取出该属性的值
      const value = getter ? getter.call(obj) : val
      //当某个watcher取当前属性的值时,就会将自身赋值给Dep类的
      //静态属性target,这样在get中就可以把该watcher添加到dep中
      if (Dep.target) {
        dep.depend()
        //如果当前属性还有子属性,那么子属性的dep中也要添加当前watcher,
        //因为当前属性变化意味着其内存地址发生了变化,显然它的子属性也会变化
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      //这里的getter是修改前的get方法,先获取该属性的原始值
      const value = getter ? getter.call(obj) : val
      //只在属性值变化时才发出通知,当值不发生变化时(如a.b = a.b)是不需要触发响应式操作的
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      //允许开发者提供自定义的setter
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      //如果setter不存在,说明当前属性不允许修改,直接return
      if (getter && !setter) return
      //调用setter修改属性,或直接通过赋值修改属性
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      //将该属性新的值转化为响应式的
      childOb = !shallow && observe(newVal)
      //数据发生了变化,通知dep去触发watcher
      dep.notify()
    }
  })
}

让我们来仔细分析一下上面的代码。

  1. 在函数内部定义一个dep对象,它是一个Dep实例,用于收集对当前属性感兴趣的watcher。这个dep并不属于任何对象,它是以闭包的形式存在的。当前函数执行完毕后,在该函数外无法访问到这个dep,但是我们在函数内部为当前属性定义的get/set却可以访问该dep。因为我们定义的get/set是一直有效的,因此这个dep也不会被释放(闭包的基本原理),于是该dep现在就和当前属性一一对应了。
  2. 递归观测当前属性。比如data.message是一个对象,那么我们不仅要将message转化为响应式的,message的每一个属性也都要转化为响应式的,以此类推,这样才能实现对data的完全观测。这个递归过程直到需要转化的属性是非对象或数组时才会结束。
  3. 使用Object.defineProperty修改当前属性的get/set。get方法用于向dep中添加对当前属性感兴趣的watcher;set方法用于在属性值变化时通知dep,由dep去触发这些watcher的update方法。

这里的第三步我们还需要详细展开。首先我们需要先了解一下Dep和Watcher的结构。

Dep:

class Dep {
  //静态属性,watcher在访问某个属性时,会临时把自身保存在该静态属性中,
  //然后在该属性的get方法中,通过dep.depend()将这个watcher添加到依赖列表中
  static target: ?Watcher;
  id: number;   //dep实例的唯一id
  subs: Array<Watcher>;    //依赖者列表
  //构造函数,只是简单的初始化
  constructor () {
    this.id = uid++
    this.subs = []
  }
  //向依赖数组subs中添加watcher的方法
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  //从依赖数组中移除watcher,取消对某个属性的监听时会用到
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }
  //将当前dep注册到目标watcher的depIds中,防止重复注册,同时,
  //watcher的addDep方法会将自身添加到dep的subs中,这里的Dep.target
  //是一个watcher实例,调用的addDep是一个watcher的方法
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
  //属性值变化时通知dep的接口
  notify () {
    ...
    //依次调用依赖者列表中每个watcher的update方法,
    //这里就是对数据变化进行响应的分发接口
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

我们看到,dep的作用就是收集watcher,并在数据变化时依次触发这些watcher的update。

然后我们看一下watcher的结构:

class Watcher {
  vm: Component;
  expression: string;
  cb: Function;
  id: number;
  deep: boolean;
  user: boolean;
  lazy: boolean;
  sync: boolean;
  dirty: boolean;
  active: boolean;
  deps: Array<Dep>;
  newDeps: Array<Dep>;
  depIds: SimpleSet;
  newDepIds: SimpleSet;
  before: ?Function;
  getter: Function;
  value: any;

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {  //渲染类watcher,在模板中绑定数据时就是这类watcher
      vm._watcher = this
    }
    vm._watchers.push(this) //将watcher注册到当前Vue实例的列表中
    ...
    //调用watcher的get方法为value赋值
    this.value = this.lazy
      ? undefined
      : this.get()
  }
  
  get () {
    //将当前的watcher添加为Dep.target,这样在取目标属性值时就会触发
    //依赖收集,dep就会把Dep.target(即当前watcher)添加到依赖者列表
    pushTarget(this)
    let value
    const vm = this.vm
    //这里是简写,源码中还包括取值失败时的操作,以及对属性的深度观测
    //这里会触发目标属性的get方法,由于已经在上面将当前watcher赋值
    //给Dep.target,这样dep就可以把当前watcher收集进依赖列表
    value = this.getter.call(vm, vm)
    ...
    popTarget()    //释放对Dep.target的占用
    this.cleanupDeps()  //释放失效的依赖
    return value
  }

  //将当前watcher添加到传过来的dep的subs数组中,完整依赖收集,同时将
  //该dep的id添加到自身相关的dep列表中,防止重复触发依赖收集
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
  //清除已经失效的dep
  cleanupDeps () {
    ...
  }
  //watcher提供的更新方法,它负责定义数据变化时的操作
  update () {
    if (this.lazy) {   //懒更新,给当前watcher打上标志
      this.dirty = true
    } else if (this.sync) { //同步更新,直接调用run重新渲染视图或执行回调
      this.run()
    } else {  //异步更新,将当前watcher推入循环队列,等主线程操作完成再触发回调
      queueWatcher(this)
    }
  }
  //数据变化时需要执行的实际操作
  run () {
    ...
    //run方法最重要的就是执行传入的回调函数,可能是虚拟DOM的patch方法,
    //也可能是通过$watch定义的回调函数。当是前者时,当前的watcher就是
    //一个renderWatcher(渲染类watcher),通过虚拟DOM提供的patch
    //方法进行视图更新
    this.cb.call(this.vm, value, oldValue);
    ...
  }
  //取目标属性值
  evaluate () {
    this.value = this.get()
    this.dirty = false
  }
  ...
  //注销当前watcher
  teardown () {
    if (this.active) {
      if (!this.vm._isBeingDestroyed) {
        remove(this.vm._watchers, this) //从实例的_watchers中移除当前watcher
      }
      let i = this.deps.length
      while (i--) {
        this.deps[i].removeSub(this)//从与它相关的dep中移除当前watcher
      }
      this.active = false
    }
  }
}

现在我们可以重新来理解defineReactive的第三步所做的事了。我们把defineReactive的核心部分再精简一下:

function defineReactive (obj, key, val, customSetter, shallow) {
  const dep = new Dep()
  ...
  Object.defineProperty(obj, key, {
    ...
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        ...
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      ...
      val = newVal
      dep.notify()
    }
  })
}

我们来看上面的结构,首先通过闭包为当前属性生成一个dep,然后修改该属性的get/set。在之后的某个时间(如开发者通过$watch方法对该属性进行了监听),那么Vue就会执行new Watcher(vm)来生成一个watcher负责执行开发者传入的回调。该watcher在初始化时会取当前属性的值,这会触发该属性的get,由于watcher在取值之前已经把自己添加到Dep.target中,因此该属性对应的dep就会通过depend方法,将该watcher保存到自己的依赖者列表(subs)中(之所以选择Dep的静态属性来临时保存watcher,是因为dep实例和watcher实例都可以访问到它)。这样就完成了依赖收集。

随后,如果该属性的值发生了变化,就会触发它的set方法。而在set方法中,Vue通过dep.notify()将这个变化通知给dep。这个方法的行为就是遍历当前dep的依赖者列表subs,依次调用subs中每个watcher的update方法。最终watcher的update方法就会执行开发者传入的回调函数。就这样,属性值的变化最终导致回调函数的自动调用。如果这个回调函数是更新视图用的,那么数据的变化将引起视图的自动更新。

如果当前的watcher是渲染类watcher(也就是更新视图用的watcher),那么它的回调将是虚拟DOM提供的patch方法,只要调用这个patch方法,就可以对虚拟DOM进行修补,根据修补结果进行视图更新。这样数据的变化就导致了视图的自动更新,这是响应式系统在Vue中最大的价值所在。patch的实现会在虚拟DOM部分进行探讨。

提示:了解过Vue的同学应该知道,Vue无法监听到对象属性的添加和删除。如我们在代码中手动为message对象添加了一个属性message.date = “”,或者手动删除了一个属性delete message.title,Vue无法知道我们进行了这样的操作。原因是为对象添加和删除属性既不会触发对象的get/set,也不会触发该属性的get/set(因为实际上我们没有操作该属性的“值”,只有对属性值的操作才会触发get/set),因此这些操作导致的数据变化无法被Vue监测到。官网建议对于需要用到的属性,即使没有初始值,也需要提前传入data并赋予一个空值,以进行响应式转化。而要删除属性时,可以使用Vue提供的响应式方法$delete,如this.$delete(message, “title”)。

2. 数组监测篇

数组监测系统的原理

在Vue的响应式里,普通对象和数组(实际上也是对象)是区分对待的。对于普通对象,上一篇已经详细介绍,这里不再赘述;对于数组来说,Vue会通过设置拦截器来拦截数组原型对象上的七个方法push、pop、shift、unshift、splice、sort、reverse。如果开发者用到了这七个方法,实际上调用的是Vue的同名方法,而不是真正的数组原型方法。不过,Vue不会改变这些原型方法的默认行为,它只是向方法中添加了触发响应的逻辑。同时Vue会调用observeArray方法,来遍历数组成员,将其中对象成员的属性转化为响应式的。注意,这里只是将对象成员的属性转化为响应式,不包括该对象成员自身,举个例子:

<template>
  <div>
    <div>{{ arr[0] }}</div>
    <div>{{ arr[0].name }</div>
  <div>
</template>

<script>
  data(){
    return {
      arr: [{name: "夕山雨"}],
    }
  },
  mounted(){
    this.arr[0].name = "Carter";
    this.arr[0] = {name: "MrGoblet"};
  }
</script>

上面的例子中,我们在该实例的mounted生命周期钩子函数中分别修改了arr[0].name和arr[0]的值。然后我们会看到,前一个语句会触发视图的更新,但是后一个语句并不会导致视图更新。同样的,如果数组成员是普通成员,通过index修改它的值,也不会触发视图更新。这就是说,只要是通过index来修改数组元素的值,如arr[0] = xx,都无法触发视图更新。出现这种情况的原因,是因为Vue区分对待数组和普通对象,并且在对待数组时,没有通过Object.defineProperty来转化数组元素(实际上经过测试,这种转化是可行的,个人补充中会谈到)。下面我们就分两个阶段来看数组的监控过程,分别是:原型方法拦截和遍历数组元素进行响应式转化。

一、原型方法遍历

我们知道,数组原型上的push、pop、shift、unshift、splice、sort、reverse这七个方法都会导致数组的元素变化,如果我们可以在开发者调用这些方法时拦截它,并在里面添加自己的逻辑(这里指的就是触发响应式系统),那么不就可以实现对数组元素的监听了吗?这就是Vue监测数组的基本原理了。

举个例子:

let originalPush = Array.prototype.push; //先保存默认的push方法
Array.prototype.push = function(...args){
  console.log("你现在调用的是被我拦截过的push方法");
  //执行Array原型上原本的push方法,这样就不会改变push本身的行为
  originalPush.apply(this, args); 
}

let arr = [];
arr.push("name");

现在Array.prototype上的push方法已经被替换为我们自己定义的push方法了,之后调用arr.push时,实际上调用的都是我们自己的push方法,因此语句arr.push(“name”)就会在控制台输出提示信息。我们自己定义的这个push方法就被称为原始push方法的拦截器。Vue就是在这里注入了响应式系统的逻辑,实现对数组的监听。

现在我们就来看一下源码是如何对这七个方法进行拦截的。

const arrayProto = Array.prototype  //将Array的原型保存在局部变量中
//原型式继承,得到一个以Array.prototype为原型的空对象
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [ //需要拦截的方法,原型方法中只有这七个会修改数组
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
//遍历上述数组,分别为每个方法设置拦截器
methodsToPatch.forEach(function (method) {
  //保存原方法。拦截器不能改变原型方法的基本功能,因此后面要调用这个原始方法
  const original = arrayProto[method]
  //为Array.prototype添加自定义拦截器,mutator就是用于拦截原始方法的拦截器
  def(arrayMethods, method, function mutator (...args) {
    //调用原始方法,保证该方法的基本功能不变
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    //push、unshift和splice可能向数组添加新元素,
    //需要将这些新添加的元素转化为响应式
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    //将新添加的元素纳入响应式系统
    if (inserted) ob.observeArray(inserted)
    //通知dep数据发生了改变
    ob.dep.notify()
    return result
  })
})

经过上述步骤,我们得到了如下一个对象arrayMethods:
在这里插入图片描述
arrayMethods有7个实例方法,也就是我们定义的拦截器,它的原型对象正是数组的原型对象。假如我们把arrayMethods替换为一个数组的原型对象,那么根据原型链的查找规则,arrayMethods中的七个实例方法将覆盖它的原型(也就是数组真正的原型)上的七个同名方法。而除了这七个方法以外的其他方法都可以借助原型链正常访问,也就是说我们成功的拦截了这七个方法,并保证了其他方法不受影响。

Vue中并没有直接把上述arrayMethods替换到Array.prototype(即Array.prototype = arrayMethods,可能是为了避免对原生类型Array进行操作,因为这样会影响到所有的数组实例),而是直接替换当前数组实例的__proto__(它默认指向构造函数Array的原型对象,但是修改__proto__的指向只会影响到当前的数组实例),如果当前浏览器不支持__proto__,Vue会将这七个拦截器直接添加到数组实例上,作为实例方法存在。代码如下:

if (Array.isArray(value)) {
      if (hasProto) {  //浏览器支持__proto__
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)  //观测数组
    }
//数组实例的__proto__存在时,将它指向我们上面的对象
function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}
//浏览器不支持__proto__时,直接把拦截器复制到当前数组中,
//同样可以覆盖原型上的同名方法
function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

现在每当我们调用数组的这七个原型方法时,调用的实际上就是Vue的拦截器。那么Vue是如何在调用这七个方法时触发响应式的呢?非常简单,就是拦截器中的一行代码:

ob.dep.notify()

因为对当前数组元素的依赖都已经被收集到了该数组对应的dep中,那么我们只需要触发dep的notify方法,就可以通知订阅者执行回调或者更新视图。无论我们通过这七个方法的哪一个修改了数组,Vue都知道数组已经被更新,需要触发响应式系统。

现在当使用这七个原型方法修改数组时已经能触发响应式了,但是数组的某个元素如果是个对象,而视图中绑定的是这个对象的属性呢?比如:

//template
<div>{{ arr[0].name }}</div>

//mounted
this.arr[0].name = "123";

显然当我们修改这些属性时,不会触发数组的原型方法(因为我们不是在修改数组元素本身,而是它的某个属性)。同时,因为在对data进行递归转化时,一旦遇到数组就会执行专门针对数组的处理方法,所以数组的对象成员此时并不是响应式的。但是我们当然希望上述语句能够触发视图更新,所以Vue又专门写了observeArray这个函数,遍历数组元素,将它的对象成员的属性也转化为响应式的。observeArray的实现如下:

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

非常简单,遍历数组成员, 使用observe函数(对象篇提到过)将每个成员转化为响应式(注意,这里转化为响应式指的是成员的属性,而不是成员自身,如同我们将data对象转化为响应式时,并没有把data自身纳入响应式系统,而是它的所有属性)。这样,this.arr[0].name = xx这样的语句就会触发视图更新了。

现在我们来看看源码中是如何分别对待对象和数组的:

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number;

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {  //判断当前value是不是数组
      if (hasProto) {   //对数组来说,先拦截原型上的七个方法,
      					//如果支持__proto__,就替换原型对象,
      					//否则直接覆盖到数组上,作为实例方法
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      //遍历并观测数组的对象成员,将对象成员的属性转化为响应式
      this.observeArray(value)
    } else {
    //这里是对象的处理逻辑
      this.walk(value)
    }
  }
  ...
}

现在只要我们是通过调用数组的那七个原型方法修改了数组,视图就会自动更新,同时数组的对象成员的属性也都是响应式的,可以触发视图更新。但是arr[0] = xx这样的操作却不是响应式的(因为observeArray只将这些对象成员的属性转化为响应式,但不包括该成员本身,这里指的数组成员,是声明在data里的,而不是后来通过arr[1000]添加进去的)。

下面将补充介绍一下我在学习响应式系统时关于该问题的一点思考。

个人补充

Vue的响应式其实到上面就已经介绍完了。但是在学习Vue响应式系统的过程中,我发现了一个可以优化的地方,在这里与大家分享。

上文数组部分我们讲到,Vue(截止到目前的2.6.10版本)无法对通过index修改数组元素的值触发响应式,如:

//template
<div>{{ arr[0] }}</div>
//mounted
this.arr[0] = xx

这是因为Vue在执行new Observer来为当前的value进行响应式转化时,一旦发现value是一个数组,就会进行专门处理,首先是原型方法拦截,很显然上述语句不属于该情况。然后是执行observeArray对数组元素进行响应式转化。我们可能觉得,这个函数不是应该可以响应我们上面的赋值语句吗?事实并不是这样的。

我们一直在强调,Vue的响应式系统最基本的操作是将对象的属性转化为响应式,而不是对象本身,因此Vue只能转化作为对象属性存在的数据。

这也就意味着,根对象和数组元素(Vue认为它没有作为对象的属性存在)是无法转化为响应式的。实际上从Vue对数组的处理中也可以看出,Vue是把数组的每个成员又当成了根对象(如果它是对象的话)来对待,这样,Vue就无法拦截数组成员的get/set,因此无法触发响应式。

造成上述差异的根本原因是,Vue并没有把数组成员当成它的属性来对待,而且这在大多数其他语言里是显而易见的。但在JavaScript中,数组也是对象!所以在阅读源码时我就在想,难道数组真的不能像普通对象一样处理吗?其实是可以的!

我们来看Vue遇到对象时调用的walk函数:

walk (obj: Object) {
    const keys = Object.keys(obj) //遍历对象属性
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])  //将属性转化为响应式
    }
  }

关键就是这个Object.keys(obj)。常规来看,它是个对象的专有方法,如下:

let obj = {
  name: "夕山雨",
  age: 24
}

Object.keys(obj) // =>["name", "age"]

那么如果将它用在一个数组上会怎么样呢:

let arr = ["夕山雨", "24"]

Object.keys(arr)  // => ["0", "1"]

实际上它会返回数组每个成员的索引值。也就是说,上面的数组在javascript引擎看来是这样的:

arr // => {"0": "夕山雨", "1": "24"}

现在我们就知道,实际上完全没有必要区别对待数组,直接把数组放在walk函数里,和普通对象进行同样的处理就可以了。也就是改成下面这样:

  if (Array.isArray(value)) {
    //如果是数组,仍然可以拦截七个原型方法
    if (hasProto) {
      protoAugment(value, arrayMethods)
    } else {
      copyAugment(value, arrayMethods, arrayKeys)
    }
  } 
//对数组和普通对象,都使用walk方法来遍历
this.walk(value)

上面的修改带来了一个很大的好处,那就是如果你在data中定义了一个数组,并给了若干个初始元素,那么使用index修改这几个元素时,将触发响应式系统。如:

<template>
  <div>{{ arr[0] }}</div>
</template>

<script>
  data(){
    return {
      arr: ["夕山雨", "123"]
    }
  },
  mounted(){
    this.arr[0] = "456";
  }
</script>

你会看到页面上显示“456”,也就是说上述本来不会触发视图更新的语句成功触发了视图更新(由于官方一直都告诉开发者,在Vue中通过index修改数组元素不会触发响应式,而大家也接受了这个说法,所以社区成员认为这样的优化对2.x版本的Vue来说没有必要,并且即将到来的3.0版本基于proxy的实现不存在这个问题)。

当然了,上面的优化只对最初就存在于data的数组成员有效。对于后添加的成员,对数组来说就相当于新属性,由于javascript的限制,只能通过$set来转化为响应式的。

总结

阅读Vue的响应式系统相关的代码,最大的收获就是明白了它的工作原理,以及它为什么不能响应某些数据变化。这样,以后使用Vue时,就可以不光知其然,还可以知其所以然。当然,发现响应式系统的一个可优化点也是个很大的意外收获(虽然提的issue不到半小时就被关闭了…),它将激励我更加主动地思考。

下一篇文章将讨论Vue的编译器。它的用途就是将template模板编译为渲染函数,而得到渲染函数的目的,就是通过嵌套调用生成虚拟DOM树。由于编译器的实现涉及的细节特别多,下文可能只会介绍它的实现原理,而不会完全展开分析(否则可能比本文还要长…)。所以敬请期待!

文章链接

Vue源码笔记之项目架构
Vue源码笔记之初始化
Vue源码笔记之响应式系统
Vue源码笔记之编译器
Vue源码笔记之虚拟DOM

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/100175402