【VUE】源码分析 - computed计算属性的实现原理

tip:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。

GitHub - vuejs/vue: Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web. Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web. - GitHub - vuejs/vue: Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.https://github.com/vuejs/vue 本系列博客 ( 不定期更新 ):

【VUE】源码分析 - computed计算属性的实现原理_依然范特西fantasy的博客-CSDN博客

【VUE】源码分析 - watch侦听器的实现原理_依然范特西fantasy的博客-CSDN博客

【VUE】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客

前言

在分析computed属性之前,我们已经介绍过了数据劫持的基本原理、侦听器的基本原理。而computed属性或多或少与它们存在某些联系。实现原理本质上,依旧是一个收集依赖,触发依赖的过程。本篇博客假设你对数据劫持充分了解,对Watcher类有了初步的认识。如果你对这些还不太熟悉,建议戳上方链接,学习前置知识后再来阅读本文。

步入正题

来自官方文档的介绍:在模板中放入太多的逻辑会让模板过重且难以维护。对于任何复杂逻辑,你都应当使用计算属性。

简单来说,就是模板语法中应该只关注结果,而不应该存在过多的逻辑。如果某些地方牵涉到过多过复杂的逻辑,那么就应该使用计算属性,将相关逻辑存放在其中,而模板中只需要使用此计算属性即可。

我们知道,在模板中使用响应式数据,那么当这些响应式数据发生改变的时候,就会通知到相应的模板节点,从而重新渲染最新的结果,展示到页面上。

那如果使用的是计算属性computed,它又是如何实现监听到相关数据的变化的呢?在多处地方使用同一个计算属性,它是否会触发多次逻辑运算呢?而这些,就是在本章需要讨论的内容。

computed的初始化

先来看computed的初始化函数:

const computedWatcherOptions = { lazy: true }

function initComputed(vm, computed) {
  const watchers = (vm._computedWatchers = Object.create(null));

  for (const key in computed) {
    const userDef = computed[key];
    const getter = typeof userDef === "function" ? userDef : userDef.get;
    if (process.env.NODE_ENV !== "production" && getter == null) {
      warn(`Getter is missing for computed property "${key}".`, vm);
    }

    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    );

    if (!(key in vm)) {
      defineComputed(vm, key, userDef);
    } else if (process.env.NODE_ENV !== "production") {
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm);
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(
          `The computed property "${key}" is already defined as a prop.`,
          vm
        );
      } else if (vm.$options.methods && key in vm.$options.methods) {
        warn(
          `The computed property "${key}" is already defined as a method.`,
          vm
        );
      }
    }
  }
}

首先,会在当前实例上定一个新的属性:_computedWatchers,用来存放处理好的computed。然后会对computed做一个for - in 循环,遍历其每一个属性(或方法)。而此处的computed,就是我们自己定义的computed属性。

而在循环体内部,会先获取到computed的getter函数,对应的就是函数形式的函数体,或者对象形式的get方法:

computed:{
  fantasyName(){
    return this.fantasy.firstName + this.fantasy.lastName
  },
  fantasyAge:{
    get(){
      return this.fantasy.age 
    },
    set(val){
      this.fantasy.age = val
    }
  }
}

上述就是两种不同的computed声明形式。而对于fantasyName来说,getter即为其函数体;而对于fantasyAge来说,getter对应的就是get方法。

接着往下走,还是我们熟悉的Watcher类。这也就是为什么我之前说的,Wathcer类服务了三种不同的"角色",当前实例,watch,computed。而此处也是computed的核心所在。我们先介绍完init函数,然后再单独介绍computed的Watcher。

循环体的最后,也就是对属性名的检测:computed属性的初始化顺序是放在props,methods,data之后的,如果与上述三种属性有重名属性,则报错。(此处有一个defineComputed函数,大家先简单理解为"最终定义"即可。在后续会做单独介绍)

至于为什么不允许重名呢,大家可以回想一下,vue为了操作的便利,将这些会用到的属性都给代理到了当前实例身上。也就是说,你可以使用 this.fantasy 去访问一个props、methods、data、computed。那么在这种情况下,重名肯定就会存在冲突。

而关于vue对于属性的解析顺序,先在这里做一个简单介绍,但暂时先不展开:

props > methods > data > computed > watch

大家只需要知道这个顺序即可。

computed的Watcher

介绍完computed初始化函数,我们着重分析其中的Watcher的实现。

在创建Watcher实例的时候,一共会传递4个参数:

const computedWatcherOptions = { lazy: true }

watchers[key] = new Watcher(
    vm,
    getter || noop,
    noop,
    computedWatcherOptions
);

第一个参数未当前vue实例,第二个参数就是在之前取得的getter函数,若不存在,则取noop。而noop呢,就是提前定义好的"空函数",即 function noop(){}。第三个参数,依旧使用noop,第四个参数为选项,在此定义了 lazy为true。

如果大家对watch侦听器的Watcher类还有印象的话,应该还记得,在watch中,第三个参数传递的是回调函数,而第二个参数传递的就是表达式或者包含表达式的函数。而在computed中,并不需要使用到回调,因此,回调函数传递的就是一个空函数。而表达式,则是在computed自定义的函数体或者get函数(getter在之前有介绍,忘记的往前翻一翻)。

为什么computed定义的函数,不是作为回调,而是作为表达式呢?

首先需要明确的一点:computed的作用,并不是作为依赖数据项改变而触发的某种行为(这是侦听器做的事情);computed要做的事情,是将依赖数据做某些逻辑处理之后,传递给下一个"使用者",该使用者可以为模板,也可以为另一个computed,或者侦听器。

那么在此背景下,回调就并不是我们所在意的东西,甚至其压根就没有。我们需要关注的,就只是在经过computed这一层"拦截处理"之后,如何将依赖项下发的通知,再次传递到目标的。

一个简单的例子:

{{fantasy.firstName}}
{{fantasyName}}
data(){ return { fantasy:{ firstName:'fantasy', lastName:"II" } } } computed:{ fantasyName(){ return this.fantasy.firstName + this.fantasy.lastName } }

我们知道,如果在模板中使用了fantasy.firstName,那么就会将fantasy以及firstName的dep都绑定为此节点的依赖项,当其发生变化之时,就会通知到该节点。那么对与第二个(使用了computed属性)节点,并没有直接用到fantasy相关的数据,那么正常的通过get绑定依赖,肯定是无法实现的。而computed的Watcher,就是做了这么一件事情:作为中间层,帮助上层使用者(节点)绑定上下层依赖(响应式数据)。

将上层依赖绑定至computed

其实现依旧是在get方法(省略了watch相关的部分)上:

  get () {
    pushTarget(this)

    let value = this.getter.call(vm, vm)

    popTarget()    
    return value
  }

这个方法已经讲过很多次,在此就不做赘述了。忘记了的戳这里回顾。

但是呢,他与watch还是存在区别的:

    this.dirty = this.lazy
    this.value = this.lazy
      ? undefined
      : this.get()

不知道大家是否还有印象,在创建Wathcer实例的时候,computed会传递lazy为true的属性。这个属性有什么用呢?我们先不着急,一步一步地看。首先在这个位置,我们可以明确的是,computed的Wathcer,会一并在初始化的时候将dirty属性设为true,并且并不会在初始化一开始就去通过get获取到value。当然,在get中实现的添加依赖,也就暂时是还未执行的。

以上,就完成了computed的Watcher创建:初始化了一个没有调用get方法,同时也暂未添加上依赖 (这里的依赖指的是computed和相关响应式数据) 的Watcher实例。并且现在该实例的lazy属性为true,dirty属性为true。至于为什么这么做呢,通过接下来的分析你就会明白了。

定义computed

在最开始介绍computed初始化函数的结尾处,存在一个defineComputed是我们还没有介绍的。那么我们现在就来关注一下这个函数:

function defineComputed(
  target: any,
  key: string,
  userDef: Object | Function
) {
  if (typeof userDef === "function") {
    sharedPropertyDefinition.get = createComputedGetter(key);
    sharedPropertyDefinition.set = noop;
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop;
    sharedPropertyDefinition.set = userDef.set || noop;
  }
  if (
    process.env.NODE_ENV !== "production" &&
    sharedPropertyDefinition.set === noop
  ) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      );
    };
  }
  Object.defineProperty(target, key, sharedPropertyDefinition);
}

还是一步一步来。首先,userDef (即我们自己定义的computed属性)可能为function,也可能为一个带get函数,set函数,或其他属性的对象。

如果是简单的函数形式,那么将get定义为createComputedGetter ( 先简单理解为vue重新包装的get,后续会再做介绍 )。

如果不是函数形式,则为复杂的"选项"形式。用户可以定义get,set,或cache属性。如果用于定义了get,并且并没有将cache显式的定义为false,则还是一样的使用createComputedGetter。如果cache定义为false,那么使用另一个函数:createGetterInvoker。如果没有定义get,则设定为空函数。

如果用户定义了set,则computed属性的set即为用户自定义的set。否则,将会设定为一个调用会报错的set函数。

最后,再将重新包装好的computed的key,采用defineProperty的方式,定义到当前实例身上。而defineProperty默认会将没有设定的属性描述,设置为false。也就是当前属性的enumerable和configuable都为false。

以上就完成了对computed属性的定义过程。那么其中的set,又是如何处理的呢?

computed的set

我们从简单的看起:

function createGetterInvoker(fn) {
  return function computedGetter () {
    return fn.call(this, this)
  }
}

createGetterInvoker,也就是当computed为对象的形式,并且设置了cache为false的时候,使用的set。可以看到,其就是简单的调用执行了而已,并没有做额外的逻辑处理 ( 这里的指代的就是缓存操作等 )

而另一个呢,就是会缓存的get。这也是整个computed中最重要的部分之一

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

首先是取得当前computed属性对应的watcher实例,大家应该还有印象,在computed初始化的时候,就创建了_computedWatchers属性,用来存放watcher实例。

然后,在get的内部,判断当前watcher实例的dirty属性,是否为true。而我们之前提到的:

在初始化computed的watcher实例的时候,是初始化了一个没有调用get方法,同时也暂未添加上依赖的Watcher实例。并且该实例的lazy属性为true,dirty属性为true。

如果dirty为true,则会触发实例身上的evaluate方法,否则就只是返回实例的value。为什么要这么做呢?

相信大家或多或少都会对计算属性的这一个性质有所了解:缓存。而此处,正是computed属性缓存的实现。

computed的缓存机制

如果我没有猜错的话,大家对于计算属性的缓存或许是这么理解的:computed去比较所依赖项的值,是否和上一次相比发生了变化,如果是,则重新计算;如果不是,则返回上一次的值。

但其实这种理解是有偏差的。vue是这么实现的:

1,如果值发生了变化,则通知computed,将dirty设置为true,但是此时并不会做计算,只是加上一个标记:"我不干净了,我需要重新算过"。

2,等待某个时刻上层使用者 ( 节点,或其他computed,或watch属性 ) 使用computed属性,也就是调用get方法的时候 ( 这个调用时机大家暂时先不用关注,后序会做介绍 ) ,发现了此计算属性的dirty属性为true,这个时候才会重新去做一次逻辑运算。并且,在第一次运算之后,就会马上将dirty属性重新变为false,也就是 "这个computed我给它处理过了,它干净了,后面的兄弟放心享用"。

3,等待再有上层使用者在此使用该属性的时候,发现了dirty已经是false了,则直接获取其value值,而不会再次做计算

这么做的好处,对于computed的watcher实例来说,它不需要去做时机的判断,不关心自己什么时候更新,只需要暴露一个dirty属性即可;而对于上层使用者来说呢,也不需要去关心数据的变化形式如何,只需要在dirty的时候,调用一次计算方法即可。并且,每一次依赖数据的更新 ( 指的是computed属性的下层依赖数据 ),只需要重新计算一次。

介绍完缓存机制,我们再具体的来看代码是如何实现的。我们这里介绍的是某次依赖数据发生改变之后的更新流程。因为我们知道初始化的时候dirty一定为true,也就必然会执行一次更新操作。当一次更新发生时,下层依赖数据通知该实例update:

 update () {
    if (this.lazy) {
      this.dirty = true
    } else {
      queueWatcher(this)
    }
  }

  不知道大家对update方法是否还有印象。对于watch来说,update就是将对应的watcher实例添加至回调队列中,等待执行更新;而对于设置了lazy属性的watcher实例( 对应的也就是computed的watcher实例 )来说,就只需要将dirty设置为true即可。

此时,dirty为true,且我们先假设此时上层使用者能自己接收到更新通知 ( 在之后做介绍 ) 。也就是改变发生时,使用者一定会去重新调用 computed的get方法:


    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }

观察到watcher的dirty为true,调用evaluate方法:

evaluate () {
    this.value = this.get()
    this.dirty = false
  }

evaluate方法内部会调用get方法,重新计算,并给value赋值,计算完成之后将dirty设置为false。

最后,computed的get函数返回value的值。

当接下来的使用者调用该computed之时,发现dirty为false,则直接跳过计算,返回value。

以上,就是整个update循环的基本流程。到这里呢,我们对于computed的流程已经了解的差不多了,但是我们遗留了另一个很重要的点还没介绍。

如何传递依赖

在之前,我们都是 "假设" 上层使用者能感应到下层数据的变化。那么在代码中,上层使用者,中间层computed,下层响应式数据,三者之间就是如何完成通知的传递的呢?

首先对于中间层computed和下层响应式数据,其实现在之前已经有提到过了:get。也就是当使用者去调用computed的时候,依赖就会顺利添加上了。

还是这一段代码:

if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }

evaluate内部会调用get,而get内部,就是一个求值,并且添加相关依赖的过程。比如:

fantasyName(){
    return this.fantasy.firstName + this.fantasy.lastName
  }

上面的computed属性,在求值的时候,就会将fantasy、firstName、lastName的依赖都给添加上。

再比如:

fantasyName(){
    this.a
    this.b
    this.c
    this.e
    return this.fantasy.firstName + this.fantasy.lastName
  }

即使最终结果没有用到abcde中任意一个,但是依赖同样还是会添加上。因为在函数内部使用了,就会触发响应式数据的get,同时也就会添加依赖。

添加之后呢,对于computed的watcher实例身上的deps属性就会存放着这些依赖 ( 关于deps属性,在之前已经介绍过,不做展开。忘记的戳这里 )。

而上层使用者在调用computed的时候,同样也会将自身设置为Dep.target,然后触发computed的watcher实例的depend方法:

depend() {
    let i = this.deps.length;
    while (i--) {
      this.deps[i].depend();
    }
  }

很简单,就是让watcher实例中的deps,再重新添加上上层使用者为sub。这样也就实现了在之后的更新中,也就会直接绕过computed,直接通知到使用者,"我变了,你要给点反应"。这个时候,使用者也就会重新通过get获取computed最新的值。

这里比较难,画了一张流程图帮助大家理解:

【VUE】源码分析 - computed计算属性的实现原理_第1张图片

 而关于为什么会先通知computed,在通知使用者呢。因为在get内部的先后顺序为,先调用computed属性的watcher实例的get方法,将computed添加为数据的subs,再调用实例的depend方法,将使用者变为数据的subs。而subs为一个数组,会维护其插入的顺序,在触发的时候再依次调用即可。

以上,就是整个computed的源码分析。我们介绍了其依赖的绑定原理,作为中间数据对上下层信息传递的实现原理,以及computed的缓存原理。

到此为止,三个Watcher的使用者,我们已经介绍了两个(computed,watch)。而对于组件实例,因为牵扯的内容比较多,在短期内不会进行分析,至少要等到 vnode 之后,才能更好的理解。大家只需要先简单的理解为:组件实例的更新也是通过Watcher来实现的,同样是一个触发依赖,响应变化的过程。只不过接收到依赖通知之后,并不一定就会真实的更新,而是会做一次diff算法,才最终决定是否需要更新到页面。

文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。

你可能感兴趣的:(vue,vue.js,javascript,前端)