Vue源码分析基础之响应式原理

Posted 天地会珠海分舵

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue源码分析基础之响应式原理相关的知识,希望对你有一定的参考价值。

1. 简述

1.1. 响应式简述

所谓响应式(Reactive),放在一个前端框架中,指的就是框架能够主动观察(Observe)状态数据的变化(即Vue中的data),并收集所有依赖(Depend)该数据的监控(Watch)回调代码,在数据发生改动时,主动执行该监控回调以更新目标数据或者更新视图。

相应地,在Vue版本2.6.14的源码中,存在以下三个主要的类来专门实现响应式:

  • Observer: 观察者,主要的作用是结合observe辅助方法,通过javascript原生的defineProperty方法,为目标数据对象的每个属性添加一个getter和setter,以实现依赖的收集和数据的劫持。其中getter的目的主要是为了收集依赖,某处代码如果有引用该数据对应子属性,则会自动触发getter,此时即可以进行依赖的收集;而setter主要是为了实现数据的劫持,在该数据被修改时,相应的setter将会触发,此时将会通知相应的依赖项进行执行。
  • Dep: 依赖,主要的作用是通过观察者模式,将所有持有对应数据依赖回调的监控对象(Watcher)给保存起来到一个数组中,当依赖数据发生改变时,通知对应的监控对象来执行相应的依赖回调。
  • Watcher:监控者持有依赖目标数据的监控回调。当对应的依赖数据发生变化时,将触发该watcher对应的依赖回调以实现响应式。

1.2. 目标简述

当然,除了这三个类,还有很多辅助函数和代码来协助完成整个响应式系统,我们下面会逐一分析。但总的来说,我们要实现的目的就是如下代码所示:

const data = 
  count: 
    total: 0,
    free: 0,
  ,
;

observe(data); // 开始劫持数据,为data的每个属性设置getter 和 setter
new Watcher(data, "count.total", (newVal, oldVal) => 
  console.log(
    "监控到count.total发生了改变,开始更新UI..."
  );
);

data.count = 10

也就是说,本文主要是围绕以下以下两个目标进行分析阐述的:

  • 调用observe开始为每个属性创建Observer,对目标对象的每个子属性进行数据劫持
  • 执行new Watcher(data, key, callback)开始对目标对象中对应key的属性进行监控,因为该属性已经被数据劫持了,所以一旦该属性发生改变,则会自动调用依赖回调callback。

同时,为了降低分析的复杂度,我们这里不会考虑目标对象的子属性是数组的形式。

2. 数据劫持

要实现响应式,我们首先需要做的就是要截获数据的访问和修改,也就是所谓的数据劫持。

2.1. 从defineProperty到defineReactive实现截获对象某个属性的数据劫持

在Javascript中,我们可以使用defineProperty方法来给一个对象添加一个属性,并通过实现其getter和setter来监控该属性的访问和修改。

比如,下面的代码通过defineProperty方法给data对象添加了一个count属性,并实现了getter和setter

const data =  ;

let val = 0;
Object.defineProperty(data, "count", 
  // getter
  get() 
    ...// 数据访问劫持,在返回数据之前先做些依赖收集相关的事情
    return val;
  ,
  // setter
  set(newVal) 
    val = newVal;
    ... // 数据修改劫持,在修改数据之后通知依赖count的所有watcher进行数据更新
  ,
);

以上代码通过getter和setter,实现了对data对象的count属性的数据劫持。在访问data.count时,getter会先进行依赖收集相关的逻辑(我们下面会谈到),然后才返回一个指定的数据;而在修改data.count时,setter会在将该数据修改后,通知所有依赖该数据的回调进行执行。

这里唯一美中不足的就是,如果我们data需要监控多个属性,那么我们就需要定义多个不同的val来为不同的属性服务。所以这里我们最好是将这部分代码封装成一个新的方法,同时利用闭包的特性,使得val无需进行全局暴露即可以正常的给getter和setter 使用。

function defineReactive(data, key, val = data[key]) 
  Object.defineProperty(data, key, 
    // getter
    get() 
      ...// 数据访问劫持,在返回数据之前先做些依赖收集相关的事情
      return val;
    ,
    // setter
    set(newVal) 
      val = newVal;
      ... // 劫持修改,在修改数据之后通知依赖count的所有watcher进行数据更新
    ,
  );


const data = ;
defineReactive(data, "count", 0);

2.2. Observer实现劫持对象每个嵌套属性

通过defineReactive,我们可以很方便的指定对象及对象的某个属性来对其实现数据劫持,但是这里每个属性都需要调用一遍的话太过于麻烦,所以我们希望通过进一步的封装,只提供一个对象作为参数,即可对其下的所有属性自动实现数据劫持。

这里就是我们引入Observer这个类的地方

export class Observer 
  constructor (value) 
      ...
      def(value, '__ob__', this)
      this.walk(value)
  

  walk (obj) 
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) 
      defineReactive(obj, keys[i])
    
  

将一些在当前看来无关紧要的代码去掉后,整个Observer类看上去就非常简单,接受一个value对象,然后在构造函数阶段就直接调用walk方法,将value对象下面的每个属性都通过defineReactive方法来实现数据劫持。

另外,为了防止对某个属性重复进行劫持,这里我们会通过def方法把当前Obserer的示例以__ob__的属性名加入到正在处理对象(或者子属性)中。其中def是对defineProperty的进一步封装:

/**
 * Define a property.
 */
function def (obj, key, val, enumerable) 
  Object.defineProperty(obj, key, 
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  );

同时,为了方便调用,vue框架还实现一个叫做observe的辅助函数。

function observe (value) 
  if (!isObject(value)) 
    return
  
  var ob;
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) 
    ob = value.__ob__;
   else 
    ob = new Observer(value);
  
  return ob

如果目标对象(或者递归中的子属性)已经被劫持,即已经有__ob__属性,则直接返回该保存的Observer实例对象。如果没有被劫持,则调用Observer构造函数触发walk成员方法来对下一层属性进行数据劫持。

但是,通过Observer的walk成员函数,我们只能实现劫持目标对象下一层的子属性,如果子属性还有子属性呢?比如:

data: 
  count: 
    total: 0,
    lost: 0
  

这个时候我们就要考虑引入递归了,即在每次对子属性进行数据劫持之前,先对Observer构造函数进行一次递归调用,这样我们就能遍历整个对象的所有层级,并对任意嵌套属性进行数据劫持。

所以这里的递归调用我们很容易就能想到放到walk方法下面,因为我们就是从这里开始循环劫持对象的下一层属性的。

export class Observer 
  constructor (value) 
      ...
      this.walk(value)
  

  walk (obj) 
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) 
      observe(obj[keys[i]]) //等同于new Observer(obj[keys[i]]);
      defineReactive(obj, keys[i])
    
  

但vue的做法是将递归放入到了defineReactive方法中,因为考虑到defineReactive今后还需要用到该子属性对应的Observer实例对象。因此,此时defineReactive的代码大致如下:

function defineReactive(data, key, val = data[key]) 
  let childOb = observe(val)
  Object.defineProperty(data, "count", 
    // getter
    get() 
      ...// 数据访问劫持,在返回数据之前先做些依赖收集相关的事情
      return val;
    ,
    // setter
    set(newVal) 
      val = newVal;
      ... // 劫持修改,在修改数据之后通知依赖count的所有watcher进行数据更新
    ,
  );

这样一来,整个递归就不是在Observer对象自身单个方法上面完成了,而是跨越了多个方法,大致流程如下

  • observe(data)拉开了对value对象实现数据劫持的序幕,方法内部的new Observer(data)就是这一切的始点。
  • 跟着Observer构造函数开始调用walk方法来遍历data对象的下一层子属性,比如count子属性,并为每个子属性defineReactive方法以实现该属性的数据劫持
  • 在defineReactive开始时,会先针对该子属性执行一次observe,如果observe方法发现该子属性是个对象,也就是该子属性还有子属性,则会跳到这里的第一点开始以子属性作为新的data(比如上面的data下的count属性),开始递归
  • 递归完成后,继续上一层子属性的数据劫持操作
  • 最终实现对目标对象的所有层级嵌套属性的数据劫持

至此,我们实现了为对象的所有属性增加一个getter和setter以实现对数据的劫持,即能够捕捉到对属性的访问和修改。

跟着要做的事情,就是在getter中收集依赖,在setter中触发依赖,如此一来我们截获访问和修改才有意义。

3. 依赖收集

所谓依赖收集,指的就是将依赖某个属性的代码给保存起来,以便在今后截获到对这个属性的修改时,将触发所有依赖这个属性的依赖回调代码。

3.1. 收集到的依赖回调怎么保存?

这听上去就是个很典型的发布订阅/观察者模式的应用场景。

所以这里很自然的就可以定义一个叫做Dep的类,实现订阅(addSub)和通知(notify)。

class Dep 
  constructor() 
    this.subs = [];
  
  addSub(依赖回调) 
    this.subs.push(依赖回调);
  
  notify() 
    this.subs.forEach(依赖回调 => 依赖回调())
  

只是在vue中,我们不是直接将依赖回调给保存到subs数组中,而是先将依赖回调保存到Watcher实例中,然后将该实例保存到subs数组中。

3.2. Watcher和依赖回调及依赖属性的关系

所以这里的Watcher应该持有以下一些基本数据,从而将目标对象所依赖的属性和依赖回调给关联起来:

  • 目标对象。比如data,但因为vue中实现了数据代理,所以data中的数据同样也会直接出现在vm中,所以下面的代码可以直接用vm代替data。
  • 依赖属性的访问路径。比如依赖的是data下面的count.totoal,那么访问路径就是'count.total'
  • 依赖回调

如此一来,Watcher类大概可以实现成下面这个样子

class Watcher 
    constructor(vm,expr,cb) // vm,因为实现了数据代理,所以相当于data;expr,即访问路径,如'count.total';cb: 依赖回调
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
    
    update()
      //todo: 调用依赖回调cb 
    

而上面的Dep类则可以相应的改成

class Dep 
  constructor() 
    this.subs = [];
  
  addSub(watcher) 
    this.subs.push(watcher);
  
  notify() 
    this.subs.forEach(watcher => watcher.update())
  

3.3. 依赖收集

在vue中,依赖收集阶段其实并不是直接通过调用addSub来把watcher加入到subs中的,而是通过增加一个叫做depend的方法。

class Dep 
  constructor() 
    this.subs = []
  
  depend() 
    this.addSub(Dep.target)
  
  addSub(sub) 
    this.subs.push(sub)
  
  notify() 
    this.subs.forEach(watcher => watcher.update())
  

而该depend的方法在哪里调用呢?就在defineReactive的getter中,因为谁引用了该属性,谁就依赖了该属性。

所以defineReactive方法稍微修改下getter,为每个属性添加一个dep对象,用来存储所有依赖这个属性的watcher。同时,在getter中开始触发依赖的收集。

function defineReactive(data, key, val = data[key]) 
  const dep = new Dep()
  let childOb = observe(val)
  Object.defineProperty(data, key, 
    // getter
    get() 
      if (Dep.target) 
        dep.depend()
      
      return val
    ,
    // setter
    set(newVal) 
      val = newVal;
      ... // 劫持修改,在修改数据之后通知依赖count的所有watcher进行数据更新
    ,
  );

那么上面Dep中depend方法以及这里getter中用到的Dep.target究竟是什么呢?事实上,它就是一个全局的Watcher实例,这主要会在Watcher这个类中进行设置。

在Watcher类的构造函数中,会调用成员方法get来访问所依赖的属性,从而引发该属性的getter执行。

class Watcher 
    constructor(vm,expr,cb) // vm,因为实现了数据代理,所以相当于data;expr,即访问路径,如'count.total';cb: 依赖回调
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        this.value = this.get() // 访问目标属性以触发getter从而发起依赖收集流程
    
    update()
      //todo: 调用依赖回调cb 
    
    get() 
      Dep.target = this
      const value = 读取vm中的expr路径的属性 // 从而触发对应属性的getter
      Dep.target = null 
      return value
    

因为上面代码在访问依赖属性之前先把Dep.target设置成当前的watcher实例本身,所以defineReactive的getter中就会认为当前正处于依赖收集阶段,所以就会继续调用dep.depend方法,从而将该watcher实例加入到该属性的dep实例所维护的subs数组中。

完了后上面的代码会继续走,将Dep.target设置成null,从而结束依赖收集阶段。

也就是说,只有Watcher中的get方法会触发getter中的dep.depend,即只有在new Watcher(vm, key,...) 会引发依赖收集。

const data = 
  count:  total: 100 

new Watcher(vm, 'count.total, () => console.log('total 发生改变') 

而一般的数据访问则不会进行依赖收集,比如下面vue组件methods配置项中的代码:

... 
methods: 
  getPrice() 
    const price = this.data.count.total * 10
  

这段代码虽然有访问data下count.total属性,但是因为没有设置Dep.target,所以在defineReactive的getter中会直接忽视,不会进入到依赖收集阶段来。

至此,依赖收集完成。稍微总结下整个流程:

  • new Watcher(vm, key, callback)将在Watcher的构造函数中调用get成员方法,该方法先是将Dep.target设置成自身实例。
  • 紧跟着get方法会去访问vm中对应key的属性,从而引发defineReactive的getter,里面发现已经设置了Dep.target,所以会调用Dep.depend来将该target加入到defineReactive闭包维护的对应属性的dep对象的subs数组中。
  • Watcher的get方法继续执行,并将Dep.target设置成null,从而结束依赖收集

4. 依赖回调/派发更新

4.1. setter开始派发更新

从前面的分析中我们可以看到,依赖收集是从defineReactive的getter中开始的,即一旦有访问对象某个属性,且设置了Dep.target,则开始依赖收集。

那么派发更新又是从哪里开始的呢?很明显,是从setter开始的。我们先更新下defineReactive的setter:

function defineReactive(data, key, val = data[key]) 
  const dep = new Dep()
  let childOb = observe(value)
  Object.defineProperty(data, key, 
    // getter
    get: function reactiveGetter () 
      if (Dep.target) 
        dep.depend()
      
      return val
    ,
    // setter
    set(newVal) 
      if (newVal === val) return
      val = newVal
      observe(newVal)
      dep.notify()
    

setter做了以下事情:

  • 如果该属性修改的新值和保存的旧值是一样的,什么都不做,直接返回
  • 否则更新成新值
  • 以防新传进来的新值是个对象,对新值调用observe来将其观察起来,即实现数据劫持
  • 调用该属性对应的依赖对象dep的notify方法来派发更新

4.2. 通知所有依赖该属性的watcher进行更新

下面我们看下Dep类的notify方法:

class Dep 
  constructor() 
    this.subs = []
  
  ...
  notify() 
    this.subs.forEach(watcher => watcher.update())
  

很简单,收到派发更新的调用后,循环调用每个依赖该属性的watcher的update方法。

4.3. Watcher获取属性新值和旧值作为参数调用依赖回调

接着我们看下Watcher的update方法大概会怎么实现:

class Watcher 
    constructor(vm,expr,cb) //vm,因为实现了数据代理,所以相当于data;expr,即访问路径,如'count.total';cb: 依赖回调
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        this.getters = parsePath(this.expr)
        this.value = this.get()
    
    update()
      const oldValue = this.value
      this.value = this.getters.call(this.vm, this.vm); //读取vm中的expr路径的属性,即取得新值
      this.cb.call(this.vm, this.value, oldValue)
    
    get() 
      window.target = this //开始依赖收集
      const value = this.getters.call(this.vm, this.vm) //读取vm中的expr路径的属性, 从而触发对应属性的getter,
      window.target = null //结束依赖收集
      return value
    

首先这里得先介绍下parsePath这个高阶函数:

 function parsePath(path) 
    path = path.split('.')
    return function (obj) 
      path.forEach((key) => 
        obj = obj[key]
      )
      return obj
    
  

该方法接受一个对象的访问路径,比如'count.total',然后返回一个函数。该函数将接收一个对象,并返回外层函数所提供的访问路径的属性。比如:

const data = 
  count: 
    total: 18,
    free: 10,
  ,
;
const getter = parsePath('count.data')
//那么可以直接通过getter获取指定对象对应路径中的值
const totalCount = getter(data) // 返回18

了解这个方法的用途之后,Watcher的update方法就很好理解了:

 update()
      const oldValue = this.value
      this.value = this.getters.call(this.vm, this.vm); //读取vm中的expr路径的属性,即取得新值
      this.cb.call(this.vm, this.value, oldValue)
    
  • 获得保存的依赖属性旧值
  • 通过高阶函数parsePath返回的getters获取到当前该属性的新值
  • 设置依赖回调的上下文问vm,并且用新值和旧值作为参数调用依赖回调

新值和旧值作为依赖回调的参数,可以回看下文章最开始的Watcher示例以加深理解:

...
observe(data);
new Watcher(data, "count.total", (newVal, oldVal) => 
  console.log(
    "监控到count.total发生了改变,开始更新UI..."
  );
);

至此,Vue响应性原理的学习就算告一段落了。虽然vue源码的具体实现会有不同,但是原理上应该是相差不远。所以有了这些基础后,一是让我们对vue的响应式原理有了进一步的理解,二是让我们带着这些知识查看vue源码时可以更加得心应手。

5. 参考和致谢

https://youtu.be/MmdYWQC57-Y?list=PLmOn9nNkQxJFbDF2ZZgaSlMiurxt9saFx

https://juejin.cn/post/6932659815424458760

vue源码阅读解析(超详细) - 知乎

我是@天地会珠海分舵,「青葱日历」和「三日清单」 作者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!

以上是关于Vue源码分析基础之响应式原理的主要内容,如果未能解决你的问题,请参考以下文章

Vue源码之响应式原理(个人向)

技术专栏 | Vue源码分析之 Watcher 和 Dep

Vue源码之计算属性watcher

Vue源码之计算属性watcher

vue系列---响应式原理实现及Observer源码解析

前端攻城狮该了解的 Vue.2x 响应式原理