vue2源码探究之响应式原理

1,088 阅读9分钟

已经使用vue2两年多了,一直想要去了解它的内部的实现原理是如何的,以前总是看一些博客,零零散散的吸收一些知识点,碎片拼凑起来,总觉得还是没有对它形成一个系统化的了解,心里总是不踏实的。正好近期有一些空闲时间,就去详细看了《深入浅出Vue.js》这本书,再结合源码去比较细致的探究,终于觉得在脑海里形成了一个系统化认知,总算是踏实了。本篇文章主要分享了我在探究源码后,对响应式原理这一部分的一个认知,也算是一个总结吧。

响应式

通常,在运行时应用内部的状态会不断发生变化,当状态发生变化后,需要重新渲染,得到最新的视图。而响应式系统赋予了视图被重新渲染的能力,其核心组成部分就是状态的变化观测以及高效的DOM更新渲染,接下来,让我们一起从源码的角度上,来理解一下响应式的运作原理吧!

变化侦测

重新渲染的前提是状态发生了变化,那么,这时如何去确定这些状态的发生了变化,从而去发出变化通知呢?

变化侦测就是用来解决这个问题的。

在JS中侦测一个对象的变化,可以使用Object.defineProperty和Proxy,而由于ES6在浏览器的支持度并不理想(vue2.0在2016年10月1日发布),所以在vue2版本中还是选择使用Object.defineProperty来实现。

在官方文档中有这样一句话:由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。那么,这个限制是什么呢?vue2中对数组和对象变化侦测的实现方式有什么不一样呢?基于这两个疑问,我们结合源码一起来探究一下。

Object的变化侦测

首先,在涉及到数据响应式的这部分源码中,我们可以看到几个比较重要的类,它们分别是Observer、Dep、Watcher

职能介绍:

  • 如何收集依赖:即在哪里去做数据劫持,从而收集依赖?==> Observer

  • 依赖收集到哪里:每一个对象、每一个key的依赖都需要集中管理 ==> Dep

  • 依赖是谁:换句话说,属性发生变化后,通知到谁?使用到这个数据的地方可能有模版、用户定义的watch或者computed,所以定义一个集中处理这些情况的类,数据变化后通知到它,它再负责通知到其他地方 ==> watcher

Observer、Dep和Watcher之间的功能关系

  • Data通过Observer转换成了getter/setter的形式来追踪变化。

  • 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖管理(Dep)中。

  • 当数据发生了变化时,会触发setter,从而向Dep中的依赖(Watcher)发送通知。

  • Watcher接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发用户的某个回调函数等。

这里需要解释一下,外界是如何通过watcher去读数据的,有三种情况

  1. 组件挂载时,会创建watcher,传入组件更新函数

  2. 为计算属性computed创建watcher,传入计算属性的getter

  3. 会$watch创建watcher,传入监听的表达式,如'a.b.c'

watcher内部会去读取这些函数或者表达式,从而触发响应式数据的getter。可以看一下源码中的体现:

Array的变化侦测

我们知道,在vue2中,当我们直接改变数组下标的方式来设置数组项时是不具有响应式的vm.items[index] = newValue,那么是因为Object.defineProperty不能检测到变化吗?

针对这个问题,我们来做一下测试:

function defineReactive(obj, key, value) {
    Object.defineProperty(obj, key, {
        enumerable: true, 
        configurable: true, 
        get: function() {
            console.log(key,'---触发get---', value);
            return value;
        },
        set: function(newValue) {
            console.log(key,'---触发set---', newValue);
            value= newValue;
        }
    });
}
function Observer(value) {
    const keys = Object.keys(value)
    keys.forEach(k => {
        defineReactive(value, k, value[k]);
    });
}
const arr = ['a','b']
Observer(arr)
// 检测下标
arr[0]
arr[0]='aa'

可以看到输出结果,把数组的下标索引看作为一个key,Object.defineProperty是可以检测数组变化的,那么为什么不直接使用它来做数组的数据响应式?

嘶~!是个好问题!在vue的issue中,有人也提出了这样的问题,尤大的回答是:性能代价和获得的用户体验收益不成正比。很精简的回答,明白了但没完全明白!

思考一下,性能代价从何而来?如果使用Object.defineProperty来对数组属性实现监听,为什么会出现性能问题呢?

通常,除了通过下标的方式,我们一般是使用数组的7个方法来变更数组( push,pop,shift,unshift,splice,sort,reverse),当使用Object.defineProperty对数组的每个下标key实现getter/setter之后,再使用这七个方法来变更数组会发生什么,让我们来测试一下:

push

使用push增加数组项时,并为触发getter/setter,把数组下标当做key值,这和对象的表现形式一样,新增的key需要再次使用Object.defineProperty做一次拦截

unshift

使用unshift在数组开头增加数组项,触发了多次getter/setter,先读取数组中的每一项,再重新设值

pop

使用pop删除数组最后一项,当最后一项的下标key被拦截过,则会触发get

shift

使用shift删除数组第一项,当最后一项的下标key被拦截过,则会触发多次getter,一次setter

splice

使用splice修改某一项值,触发一次getter/setter;增加和删除项,触发了多次getter/setter

sort|reverse

使用sort和reverse排序,触发了多次getter/setter

总结:

对数组的每一个下标做Object.defineProperty拦截,当使用数组方法时,除了push,pop,其他方法都会触发多次getter/setter,考虑数组量级比较大时,递归遍历数组,为每一个下标做getter/setter,每一个下标key都对应创建一个Dep来管理依赖,使用数组方法改变数组时,多次触发getter收集依赖(watcher),setter通知依赖。

这里的创建Dep的内存开销、多次触发getter/setter、watcher的开销是有必要的吗?显然不是的,我们实际的业务场景中,对数组的更新,只需要知道数组本身发生了变化,从而去触发视图更新。所以当我们使用数组的方法来改变数组时,就可以知道数组已经发生了改变,此时去做拦截更新就可以了,放弃对数组下标进行getter/setter,避免额外的性能开销。

这里需要考虑的是,既然不使用Object.defineProperty拦截了,那怎么去触发数组的getter/setter呢?

那接下来,我们来源码中寻找一下答案

  • 对于需要响应式处理的数组,覆盖其数组原型上的7个方法。==> setter
import { def } from '../util/index'
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  // 原始方法
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    // 执行原始行为
    const result = original.apply(this, args)

    // 获取ob实例
    const ob = this.__ob__
    // 如果是新增元素的方法,需要取出新增的元素做响应式处理
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // 新加入的对象仍然需要响应式处理
    if (inserted) ob.observeArray(inserted)
    // notify change
    // 让内部的dep通知更新
    ob.dep.notify()
    return result
  })
})
  • 执行原始行为时,数组已经发生了变化,那么怎么去通知依赖发生了变化呢

看源码,我们可以发现,在创建Observer实例时,会在实例中创建这个观测对象所对应的依赖管理Dep实例,并为这个观测对象创建一个属性__ob__,指向当前的Observer实例,那么以后我们就可以在已经被观测到的对象中,通过__ob__去拿到Observer实例,从而获取这个对象的dep并通知变更

看一下Observer的constructor实现

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    // 创建对象一一对应的dep
    this.dep = new Dep()
    this.vmCount = 0
    // 在观测对象中绑定__ob__属性指向Observer实例
    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}
  • 既然对一个对象做了Dep管理,那么哪里去使用到对象的依赖收集? ==> getter

我们可以看到,对每一个key做侦测时,也会递归遍历对应的value,如果value为对象,那么就会进行侦测,即我们可以拿到ob实例,从而调用ob实例上的Dep去收集依赖

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 创建key一一对应的dep
  const dep = new Dep()
  
  ...为此处省略部分...

  // 递归遍历,当value为对象时,也需要观测
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // Dep.target就是当前的watcher
      if (Dep.target) {
        dep.depend()

        if (childOb) {
          // 添加依赖
          childOb.dep.depend()
          // 如果是数组,数组内部所有项都要做相同处理
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      ...为此处省略部分...
      childOb = !shallow && observe(newVal)
      // 变更通知
      dep.notify() 
    }
  })
}

$set、$delete

对于对象,由于Object.defineProperty不能检测对象上一个属性的新增和删除,所以针对这两种情况实现响应式,vue2提供了 $set来支持对象属性的新增, $delete支持属性的删除。通过判断一个对象是否被响应式处理过,可以直接访问对象上的__ob__属性,若存在,则可以拿到这个对象的依赖,并发出通知。

对于数组,从性能/体验的性价比考虑,放弃了Object.defineProperty对于数组下标做getter/setter,所以也对数组提供了 $set来支持下标的方式设置数组项,不同于对象的处理方式,数组是调用splice方法来对下标进行增删改,因为响应式处理后的数组splice方法,已经具有可以getter/setter的功能。

$set部分源码实现如下:

export function set (target: Array<any> | Object, key: any, val: any): any {
  
  // 数组:使用splice方法
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__ // Observer实例
  // 没有被响应式处理过,直接原生处理后返回
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val) // 对新增的key做响应式处理
  ob.dep.notify() // 通知依赖变更
  return val
}

$delete实现同$set,但不需要进行响应响应式处理了,只需要删除key即可,$delete部分源码实现如下:

export function del (target: Array<any> | Object, key: any) {
  
  // 数组:使用splice方法
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }

  const ob = (target: any).__ob__

  if (!hasOwn(target, key)) {
    return
  }
  delete target[key] //直接删除
  if (!ob) {
    return
  }
  ob.dep.notify() // 通知依赖变更
}

批量异步更新

具体实现

批量异步更新,主要利用了浏览器的事件循环机制,只要侦听到数据变化,就会开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。

如果同一个watcher被多次触发,只会被推入到队列中一次,避免不必要的计算和 DOM 操作,然后在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

$nextTick

当我们使用$nextTick API时,实际上也是使用的异步更新中的nextTick,将回调函数添加到回调的任务队列中。当我们在数据变化之后立即使用 $nextTick(callback),回调函数将在 DOM 更新完成后被调用。

nextTick源码部分

const callbacks = []
let pending = false

//  刷新回调
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  // 异步执行
  timerFunc = () => {
    p.then(flushCallbacks)
  }
  isUsingMicroTask = true
} ...此处省略其他执行环境判断代码...

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 将回调存入callbacks的数组中
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    // 启动异步执行
    pending = true
    timerFunc()
  }
}

DOM更新

渲染函数render

在上一步的执行异步更新阶段,会通过watcher.run()调用最终的更新函数vm._update(vnode: VNode, hydrating?: boolean),其中vnode由render函数执行后返回。

    updateComponent = () => {
      // 执行vm._render()渲染函数,返回VNode
      vm._update(vm._render(), hydrating)
    }

需要注意的是,在创建vnode时,如果为自定义组件标签,会使用vue.component创建该自定义组件的构造函数,并生成组件的钩子函数(这里面hooks在patch过程中触发)

来看一下模版编译后的render函数:

执行render函数,生成的vnode

虚拟DOM更新

vm._update()执行更新,调用patch函数进行新旧vnode的对比,得到最小的dom操作量,配合异步更新策略减少刷新频率,从而提升性能

patch过程(同层比较,深度优先)


  1. 首先进行树级比较,三种情况
  • newVnode不存在,oldVnode存在,删除
  • newVnode存在,oldVnode不存在,新增
  • 新旧vNode都存在,执行patchVnode,开始对比更新
  1. patchVnode对比更新,包括:文本更新,属性更新,子节点更新

  2. 子节点对比更新

在实际的业务场景中,不是所有子节点的位置都会发生变化,总有一些节点是没有发生移动的,对于这些位置不变或者说可以预测的,我们可以采用更快的查找方式==> 判断相同位置的节点是否为同一节点,如果刚好能够匹配到,就可以直接进行更新节点的操作,如果尝试失败了,再使用循环的方式。这种方式可以在很大程度上避免循环来查找节点,从而提升执行速度。

实际遍历过程:

  • 在新旧的子vnode中做首尾标记,当oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx时结束循环,首尾节点两两交叉比较寻找相同节点,都不符合时则使用在oldVnode数组中找与newStartIdx相同的节点

  • 当oldStartVnode/newStartVnode或者oldEndVnode/newEndVnode满足sameVnode时,直接执行patchVnode更新

  • 当oldStartVnode/newEndVnode满足条件时,说明节点位置发生移动,在进行patchVnode更新后,需要将oldStartVnode.elm移动到oldEndVnode.elm后面

  • 当oldEndVnode/newStartVnode满足条件时,说明节点位置发生移动,在进行patchVnode更新后,需要将oldEndVnode.elm移动到oldStartVnode.elm前面

  • 如果以上条件都不满足,即首尾没有找到相同节点
  • 如果newStartVnode存在key,则直接通过key值去查找old Vnode中的相同节点;

  • 没有找到,则在old Vnode中寻找与newStartVnode满足sameVnode节点;

  • 若存在vnodeToMove,则将vnodeToMove.elm节点移动到oldStartVnode.elm前面;

  • 若不存在,则创建新的DOM节点插入到oldStartVnode.elm前面

相同节点判断逻辑sameVnode:

// 对比两个节点是否相同
// 1.key
// 2.tag
// 3.isComment都为注释节点
// 4.data属性存在
// 5.input type
// 6.异步组件
function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

循环结束

  • 当oldStartIdx > oldEndIdx时,说明旧节点已经遍历结束,判断新节点数组中是否还剩下,批量创建并插入DOM中;

  • 当newStartIdx > newEndIdx时,新节点已经遍历结束,旧节点数组中还有剩下,从DOM中移除。

结束语

vue2通过模版来描述状态与视图的映射关系,先将模版编译为渲染函数,然后执行渲染函数生成虚拟节点,为了避免不必要的DOM操作,将虚拟节点与上一次的虚拟节点做对比,找出真正需要更新的节点来进行DOM操作。

之所以需要使用虚拟DOM的方式来更新视图,是因为vue的变化侦测可以在一定程度上知道具体哪些数据发生了变化,也就是说它可以在一定程度上知道哪些节点使用了这些状态,如果每一个节点都绑定了一个watcher来观察状态的变更,这就存在一定的内存开销,当状态被越多的节点使用,开销就越大。引入虚拟DOM,每个组件对应一个watcher,当数据变化后,通知到组件watcher,然后组件再去通过虚拟DOM对比,完成视图的更新,这是一个比较折中的方案!