Vue中计算属性computed的实现原理

基本介绍

话不多说,一个最基本的例子如下:

<div id="app">
    <p>{{fullName}}</p>
</div>
new Vue({
    data: {
        firstName: 'Xiao',
        lastName: 'Ming'
    },
    computed: {
        fullName: function () {
            return this.firstName + ' ' + this.lastName
        }
    }
})

Vue中我们不需要在template里面直接计算{{this.firstName + ' ' + this.lastName}},因为在模版中放入太多声明式的逻辑会让模板本身过重,尤其当在页面中使用大量复杂的逻辑表达式处理数据时,会对页面的可维护性造成很大的影响,而computed的设计初衷也正是用于解决此类问题。

对比侦听器watch

当然很多时候我们使用computed时往往会与Vue中另一个API也就是侦听器watch相比较,因为在某些方面它们是一致的,都是以Vue的依赖追踪机制为基础,当某个依赖数据发生变化时,所有依赖这个数据的相关数据或函数都会自动发生变化或调用。

虽然计算属性在大多数情况下更合适,但有时也需要一个自定义的侦听器。这就是为什么 Vue 通过 watch 选项提供了一个更通用的方法来响应数据的变化。当需要在数据变化时执行异步或开销较大的操作时,这个方式是最有用的。

从vue官方文档对watch的解释我们可以了解到,使用 watch 选项允许我们执行异步操作 (访问一个API)或高消耗性能的操作,限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态,而这些都是计算属性无法做到的。

下面还另外总结了几点关于computed和watch的差异:

  1. computed是计算一个新的属性,并将该属性挂载到vm(Vue实例)上,而watch是监听已经存在且已挂载到vm上的数据,所以用watch同样可以监听computed计算属性的变化(其它还有data、props)
  2. computed本质是一个惰性求值的观察者,具有缓存性,只有当依赖变化后,第一次访问 computed 属性,才会计算新的值,而watch则是当数据发生变化便会调用执行函数
  3. 从使用场景上说,computed适用一个数据被多个数据影响,而watch适用一个数据影响多个数据;

以上我们了解了computed和watch之间的一些差异和使用场景的区别,当然某些时候两者并没有那么明确严格的限制,最后还是要具体到不同的业务进行分析。

原理分析

言归正传,回到文章的主题computed身上,为了更深层次地了解计算属性的内在机制,接下来就让我们一步步探索Vue源码中关于它的实现原理吧。

在分析computed源码之前我们先得对Vue的响应式系统有一个基本的了解,Vue称其为非侵入性的响应式系统,数据模型仅仅是普通的JavaScript对象,而当你修改它们时,视图便会进行自动更新。

当你把一个普通的 JavaScript 对象传给 Vue 实例的 data 选项时,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter,这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 追踪依赖,在属性被访问和修改时通知变化,每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新。

Vue响应系统,其核心有三点:observe、watcher、dep:

  1. observe:遍历data中的属性,使用 Object.definePropertyget/set方法对其进行数据劫持
  2. dep:每个属性拥有自己的消息订阅器dep,用于存放所有订阅了该属性的观察者对象
  3. watcher:观察者(对象),通过dep实现对响应属性的监听,监听到结果后,主动触发自己的回调进行响应

对响应式系统有一个初步了解后,我们再来分析计算属性。
首先我们找到计算属性的初始化是在src/core/instance/state.js文件中的initState函数中完成的

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  // computed初始化
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

调用了initComputed函数(其前后也分别初始化了initData和initWatch)并传入两个参数vm实例和opt.computed开发者定义的computed选项,转到initComputed函数:

const computedWatcherOptions = { computed: true }

function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()

  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
      )
    }

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    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)
      }
    }
  }
}

从这段代码开始我们观察这几部分:

  1. 获取计算属性的定义userDef和getter求值函数
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get

定义一个计算属性有两种写法,一种是直接跟一个函数,另一种是添加set和get方法的对象形式,所以这里首先获取计算属性的定义userDef,再根据userDef的类型获取相应的getter求值函数。

  1. 计算属性的观察者watcher和消息订阅器dep
watchers[key] = new Watcher(
    vm,
    getter || noop,
    noop,
    computedWatcherOptions
)

这里的watchers也就是vm._computedWatchers对象的引用,存放了每个计算属性的观察者watcher实例(注:后文中提到的“计算属性的观察者”、“订阅者”和watcher均指代同一个意思但注意和Watcher构造函数区分),Watcher构造函数在实例化时传入了4个参数:vm实例、getter求值函数、noop空函数、computedWatcherOptions常量对象(在这里提供给Watcher一个标识{computed:true}项,表明这是一个计算属性而不是非计算属性的观察者,我们来到Watcher构造函数的定义:

class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    if (options) {
      this.computed = !!options.computed
    } 

    if (this.computed) {
      this.value = undefined
      this.dep = new Dep()
    } else {
      this.value = this.get()
    }
  }
  
  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      
    } finally {
      popTarget()
    }
    return value
  }
  
  update () {
    if (this.computed) {
      if (this.dep.subs.length === 0) {
        this.dirty = true
      } else {
        this.getAndInvoke(() => {
          this.dep.notify()
        })
      }
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

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

  depend () {
    if (this.dep && Dep.target) {
      this.dep.depend()
    }
  }
}

为了简洁突出重点,这里我手动去掉了我们暂时不需要关心的代码片段。
观察Watcher的constructor,结合刚才讲到的new Watcher传入的第四个参数{computed:true}知道,对于计算属性而言watcher会执行if条件成立的代码this.dep = new Dep(),而dep也就是创建了该属性的消息订阅器。

export default class Dep {
  static target: ?Watcher;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

// 当首次计算 computed 属性的值时,Dep 将会在计算期间对依赖进行收集
Dep.target = null
const targetStack = []

export function pushTarget (_target: Watcher) {
  // 在一次依赖收集期间,如果有其他依赖收集任务开始(比如:当前 computed 计算属性嵌套其他 computed 计算属性),
  // 那么将会把当前 target 暂存到 targetStack,先进行其他 target 的依赖收集,
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target
}

export function popTarget () {
  // 当嵌套的依赖收集任务完成后,将 target 恢复为上一层的 Watcher,并继续做依赖收集
  Dep.target = targetStack.pop()
}

dep同样精简了部分代码,我们观察Watcher和dep的关系,用一句话总结

watcher中实例化了dep并向dep.subs中添加了订阅者,dep通过notify遍历了dep.subs通知每个watcher更新。

  1. defineComputed定义计算属性
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)
  }
}

因为computed属性是直接挂载到实例对象中的,所以在定义之前需要判断对象中是否已经存在重名的属性,defineComputed传入了三个参数:vm实例、计算属性的key以及userDef计算属性的定义(对象或函数)。
然后继续找到defineComputed定义处:

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.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)
}

在这段代码的最后调用了原生Object.defineProperty方法,其中传入的第三个参数是属性描述符sharedPropertyDefinition,初始化为:

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

随后根据Object.defineProperty前面的代码可以看到sharedPropertyDefinition的get/set方法在经过userDef和 shouldCache等多重判断后被重写,当非服务端渲染时,sharedPropertyDefinition的get函数也就是createComputedGetter(key)的结果,我们找到createComputedGetter函数调用结果并最终改写sharedPropertyDefinition大致呈现如下:

sharedPropertyDefinition = {
    enumerable: true,
    configurable: true,
    get: function computedGetter () {
        const watcher = this._computedWatchers && this._computedWatchers[key]
        if (watcher) {
            watcher.depend()
            return watcher.evaluate()
        }
    },
    set: userDef.set || noop
}

当计算属性被调用时便会执行get访问函数,从而关联上观察者对象watcher。

分析完以上步骤,我们再来梳理下整个流程:

  1. 当组件初始化的时候,computeddata会分别建立各自的响应系统,Observer遍历data中每个属性设置get/set数据拦截
  2. 初始化computed会调用initComputed函数
  • 注册一个watcher实例,并在内实例化一个Dep消息订阅器用作后续收集依赖(比如渲染函数的watcher或者其他观察该计算属性变化的watcher)
  • 调用计算属性时会触发其Object.defineProperty的get访问器函数
  • 调用watcher.depend()方法向自身的消息订阅器depsubs中添加其他属性的watcher
  • 调用watcherevaluate方法(进而调用watcherget方法)让自身成为其他watcher的消息订阅器的订阅者,首先将watcher赋给Dep.target,然后执行getter求值函数,当访问求值函数里面的属性(比如来自dataprops或其他computed)时,会同样触发它们的get访问器函数从而将该计算属性的watcher添加到求值函数中属性的watcher的消息订阅器dep中,当这些操作完成,最后关闭Dep.target赋为null并返回求值函数结果。
  1. 当某个属性发生变化,触发set拦截函数,然后调用自身消息订阅器depnotify方法,遍历当前dep中保存着所有订阅者wathcersubs数组,并逐个调用watcherupdate方法,完成响应更新。

总结

1.初始化 data 和 computed,分别代理其 set 和 get 方法,对 data 中的所有属性生成唯一的 dep 实例

2.对 computed 中的 属性生成唯一的 watcher,并保存在 vm._computedWatchers 中

3.访问计算属性时,设置 Dep.target 指向 计算属性的 watcher,调用该属性具体方法

4.方法中访问 data 的属性,即会调用 data 属性的 get 方法,将 data 属性的 dep 加入到 计算属性的 watcher , 同时该 dep 中的 subs 添加这个 watcher

5.设置 data 的这个属性时,调用该属性代理的 set 方法,触发 dep 的 notify 方法

6.因为时 computed 属性,只是将 watcher 中的 dirty 设置为 true

7.最后,访问计算属性的 get 方法时,得知该属性的 watcher.dirty 为 true,则调用 watcher.evaluate() 方法获取新的值

综合以上:也可以解释了为什么有些时候当computed没有被访问(或者没有被模板依赖),当修改了this.data值后,通过vue-tools发现其computed中的值没有变化的原因,因为没有触发到其get方法。

参考文章:
https://segmentfault.com/a/1190000010408657
https://segmentfault.com/a/1190000016368913?utm_source=tag-newest

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 197,814评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,124评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 144,814评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,924评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,815评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,562评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,944评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,582评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,859评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,881评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,700评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,493评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,943评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,115评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,413评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,978评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,182评论 2 339

推荐阅读更多精彩内容

  • 计算属性适合用在模版渲染当中,某个值是依赖了其他响应式对象甚至是计算属性计算而来的。 侦听属性适用在观测某个值的变...
    LoveBugs_King阅读 1,513评论 0 0
  • 这方面的文章很多,但是我感觉很多写的比较抽象,本文会通过举例更详细的解释。(此文面向的Vue新手们,如果你是个大牛...
    Ivy_2016阅读 15,354评论 8 64
  • 前言 Vue.js 的核心包括一套“响应式系统”。 “响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码...
    NARUTO_86阅读 37,332评论 8 86
  • 回忆 watch的过程就是订阅数据,数据更新时执行回调函数。关于渲染,渲染Watcher本身就订阅了数据变化,us...
    LoveBugs_King阅读 720评论 0 0
  • Vue 依赖收集原理分析 Vue实例在初始化时,可以接受以下几类数据: 模板 初始化数据 传递给组件的属性值 co...
    wuww阅读 6,853评论 3 19