[Vue源码学习]2-深入响应式原理(上)

349 阅读8分钟

本节目的:

  • 加深响应式原理的定义理解
  • 了解Vue实现响应式原理的关键步骤(依赖收集 / 派发更新)

简介

首先先说说响应式编程。在计算机编程中,响应式编程或反应式编程(英语:Reactive programming)是一种面向数据流和变化传播的编程范式。

这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。以这种数据流为核心思想的工具库有很多,比较出名的有如:RxJS

image

而在前端领域,响应式编程是为了简化交互式用户界面的创建和实时系统动画的绘制而提出来的一种方法。例如,在MVVM模型中,响应式编程允许将相关View Model的变化自动反映到View上,反之亦然

image
(Input 更新,视图更新)

前端框架中的响应式

前端中对于这个特性的描述,还有一个更加熟悉的名字叫“双向绑定”,我们分别来看看,在前端开发中,各类框架是如何基于JS语言实现响应式原理的

Angular「脏检查」

image

angular2对常用的dom事件,xhr事件等做了封装, 在里面触发进入angular的digest流程。在digest流程里面, 会从rootscope开始遍历, 检查所有的watcher。也称为脏检查(dirty check)

ng只有在指定事件触发后,才进入$digest cycle:

  • DOM事件,譬如用户输入文本,点击按钮等。(ng-click)
  • XHR响应事件 ($http)
  • 浏览器Location变更事件 ($location)
  • Timer事件(timeout,interval)

通过执行digest()或apply()来完成视图的响应式更新

React「单向数据流」

image

在react中,框架通过暴露setState API给使用者,来获取数据的变动情况,用户通过调用API来改变目标数值,并不属于典型的响应式更新框架

Vue「数据劫持 + 发布订阅」

image

Vue框架通过Object.definePropertyAPI实现数据劫持功能,结合自实现的 Dep和Watcher类 实现发布订阅功能

我们这里举个例子,回顾一下Object.defineProperty API

function defineReactive (obj, key, val) {
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: () => {
            console.log('数据被读取');
            return val;
        },
        set: newVal => {
            if (val === newVal) {
                return;
            }
            val = newVal;
            console.log("数据被改变");
        }
    })
}

let data = {
    text: 'hello world',
};

// 对data上的text属性进行绑定
defineReactive(data, 'text', data.text);

console.log(data.text); // 控制台输出 数据被读取
data.text = 'hello Vue'; // 控制台输出 数据被改变

PS:Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因

Vue内的响应式原理

手动实现思路

结合我们上一期讲到的实例化过程,如果在此基础上,我们再实现一遍Vue的响应式,我们需要做什么工作呢?

按照数据劫持的原理,我们可以了解到数据何时被更新,然后在数据被set的时候,重新走一遍初始化Vue实例的逻辑,可以完成这个需求。但是这也有几个问题:

如果data内的变量声明了但是没有在render中被用到,被set的时候其实是不需要刷新的,所以如何收集变量的使用关系也是一个需要关注的点

如果这个页面只有hello world,那是没有明显问题的,但是如果这个页面数据变化频繁,一次操作涉及到10个变量的变化,那是不是会渲染10次呢,很可能不需要,所以如何控制更新频率是一个需要关注的点

作为一款成熟的框架,考虑性能问题是必须的,如果随着开发维护,框架越来越慢,肯定会被众人吐槽,然后被弃用,这期响应式分享的部分,如果能够让大家了解如何解决上面的两个性能问题,那也就到位了

框架中的基本概念

Vue中提供了丰富的API供开发者使用,其中有两个 与响应式相关的API:watch和computed,他们的执行时机也和变量的值有关。所以在设计的时候必须得抽离出一个更高层级的思维,来统一管理响应式部分的逻辑

在Vue内,用了Dep、Watcher、Observer这三个类来构建了这一套系统,我们来统一过一下基本概念

Observer 「响应式」

在Vue中,定义了Observer类来管理上述将data内数据 响应式化 的逻辑,里面主要是集成了一些对于复杂类型做响应式支持的逻辑

我们可以用如下代码来描述,将this.data也就是我们在Vue代码中定义的data属性全部进行「响应式」绑定

class Observer {
    constructor() {
        // 响应式绑定数据通过方法
            observe(this.data);
    }
}
export function observe (data) {
    const keys = Object.keys(data);
    for (let i = 0; i < keys.length; i++) {
       // 将data中我们定义的每个属性进行响应式绑定
       defineReactive(obj, keys[i]);
    }
}

Dep 「依赖管理」

上面提到的第一个问题就是,我们怎么知道这个变量被使用了。我首先想到的就是引用计数法,用到了计数器+1,如果数值变化时引用数不为0,那就去触发重新render

在Vue内也是这个思路,但是对于引用方并不是只记录了个数字,而是记录了相关联的回调函数,是不是有点像Vue内的一个API,对就是watch

那对于这些回掉函数的处理Vue也抽象了一个类用于管理这部分逻辑,叫Dep,个人推测是Dependency的意思,数据的依赖管理中心的意思

这些被Dep记录的回调函数,在Vue内被抽象成了一个类,之所以叫Watcher,感觉和watch功能比较像是一个原因

Watcher 「回调处理」

为什么单个回调函数的执行逻辑,还要抽成一个类呢。主要是watch这个api提供的功能有点多,包括了sync,immediate等配置项,并且Watcher内也记录了依赖的Deps,用于在组件销毁的时候通知依赖自己的Deps。内容也比较多,为了处理方便,就抽离了一个类了

从提供更高的灵活属性考虑出发,拆分出来也便于维护

小结

下面举个代码案例,可以一窥Observer、Dep和Watcher的关系

// HTML
<div>
    <p>{{message}}</p>
</div>

// JS
new Vue({
    data: {
        text: 'hello world',
        message: 'hello vue',
    },
    watch: {
        message: function (val, oldVal) {
            console.log('new: %s, old: %s', val, oldVal)
        },
    },
    computed: {
        messageT() {
            return this.message + '!';
        }
    }
})

图示如下:一个data属性可能有多个Watcher依赖,但是只有一个Dep来管理它的依赖,但是一个Watcher可能被多个Dep调用,如果messageT的执行函数里读取了两个属性,那这个回调函数会同时被两个Dep管理

所以Dep和Watcher的关系是:多对多

image

Observer、Dep、Watcher整体的关系就像这样,是不是瞬间豁然开朗了

image

源码分析

下面我们就从源码角度出发,分析Vue的实现逻辑

实例化读取data属性「依赖收集」

首先先来分析实例化过程,在Vue做首次渲染的时候,会在render函数内读取的对应的数据,从而会让被劫持的数据触发getter内的逻辑,我们看看里面getter内有啥内容

src/core/observer/index.js

Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    // ...
  })

我们先只关注两件事:

  • Dep.target存在时,把当前Watcher放到这个属性的dep内
  • 然后返回对应的obj[key]的value值

我们发现,和我们预期中的行为一致,在属性被读取是,记录对应的回调函数也就是Watcher到Dep内,诶那

  • Watcher在哪呢
  • Dep.target是啥

首先,我们回忆一下实例化的这个地方,是不是有印象了

src/core/instance/lifecycle.js

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
    // ...
    } else {
        updateComponent = () => {
          debugger
          vm._update(vm._render(), hydrating)
        }
    }
    // ...
    new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
    // ...
}

实际上,我们在执行实例化读取操作的时候,是在这个需要做处理的回调Render Watcher内执行的,比如这个updateComponent,数据变化了,我在执行一遍它,达到re-render的效果

那这不是代表着computed、watch api内的函数都会默认执行一遍,这和实际表现不一样啊。其实并不是,在实例化的时候,这几个API是有单独的解析逻辑的,下面会详细介绍

所以实例化过程,就在Render Watcher内

在接下来说一下Dep.target,我们都知道浏览器环境是单线程的,所以同一时刻只可能有一个Watcher在执行,那这个Dep.target就是这个当前执行的Watcher:

  • 在Watcher执行的开始时把前一个Watcher入栈,并把当前Watcher赋值给Dep.target
  • 执行完了出栈,并把上一个Watcher赋值给Dep.target

这样就让全局唯一的Watcher,有了存放的地方

当我们实例化完毕,依赖收集也在Render Watcher内顺带完成了

Computed / Watch API初始化「创建对应类型Watcher」

刚刚在实例化的时候提到,Vue对于API的处理,有特殊待遇,直接安排了两个函数做支持,来详细看看

Computed

src/core/instance/state.js

const computedWatcherOptions = { lazy: 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 (!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)
      }
    }
  }}

这个流程里注意两件事:

  • 注册了一个Watcher,但是并不是render watcher,并且options里lazy为true(即不立即执行get)
  • 注册了响应式逻辑,拦截getter逻辑,类似对data属性进行拦截的defineReactive函数(当属性被get时,收集computed内依赖的变量)

我们可以注意到,在defineComputed的getter内,并没有新建一个Dep实例,来单独收集这个computed属性的使用方,而是在getter返回前调用了watcher.depend()把收集的任务使用方告知了computed内用到的各个的data属性,这一段代码看的头皮发麻,估计是为了简化代码逻辑吧..

Watch

我们再来看看watch的逻辑

src/core/instance/state.js

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }}

代码栈比较深,从initWatch => createWatcher => $watch => new Watcher。执行完毕以后,对应的回调watcher就被依赖进了对应data属性的Dep内,当data变化时,执行对应的cb

data数据变化「派发更新」

那如果收集的相关data属性,进行了数值的变更,就进入到了我们讨论的派发更新过程了,这里面我们要注意框架更新对于频率的控制

setter

首先来看看setter内有什么内容

src/core/observer/index.js

Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      // ...
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })

我们可以发现函数,先把上次的value取出来,再判断和当前newValue是否一致,一样就别改了。这里有个地方挺有意思的 (newVal !== newVal && value !== value) 这里我猜测是用来判断 NaN === NaN 的这个场景,避免每次赋值NaN都刷新

然后shallow是判断是否为内部指令,如果不是的话,就把新value设施之响应式,然后执行Dep的notify方法,也就是派发更新

src/core/observer/dep.js

class Dep {
  // ...
  notify () {
  // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }}

watcher

按照我们先前分析的思路来看,这里的逻辑还是比较简单的,遍历需要通知的Watcher,执行他们,也就是运行update方法

src/core/observer/watcher.js

class Watcher {
  // ...
  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
  // ...
}

这里有三个可能的逻辑走向:

  • Lazy
  • Sync
  • 加入队列

lazy这边是标示是否为computed API的watcher,如果是的话,仅仅把dirty设置为true,并不去执行回调,原因是computed依赖的data数值变化了,并不代表,在下一次render中computed会被用到,所以在render读取computed属性的时候,再去调用watcher.evaluate(),避免不必要的计算

sync代表同步的watch API,这里直接阻塞执行,拿到返回值

第三个就是一般的回调处理的执行逻辑,会被加入一个队列中,我们来看看具体实现:

src/core/observer/scheduler.js

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

flushSchedulerQueue 是 Vue 在做派发更新的时候的一个优化的点,它把这些 watcher 先添加到一个队列里,然后在 nextTick 后执行。这里有几个点,也就是我们开局第二个问题的答案:

  • 首先用 has 对象保证同一个 Watcher 只添加一次
  • 接着对 flushing 的判断。watcher没开始更新,就加入队列,开始的话就动态找到应该插入的位置,这里需要找位置的原因后面会讲到
  • 最后通过 waiting 保证对 nextTick(flushSchedulerQueue) 的调用逻辑只有一次

nextTick 的实现下一次分享会仔细分析,目前就可以理解它是在下一个 tick,也就是异步的去执行 flushSchedulerQueue

flushSchedulerQueue

flushSchedulerQueue函数内部主要是做了一些优化watcher执行的事,这里不贴代码了,说一下就行

首先是对watcher队列做了一次从小到大的排序 queue.sort((a, b) => a.id - b.id)

这么做主要有以下要确保以下几点:

  1. 组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。
  2. 用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。避免render watcher多次执行
  3. 如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行(跳过不需要执行的watcher)

排序之后,就是对watcher的遍历了,这里也有两个亮点,分享下:

  • 首先是在每次循环时取最新的watcher arr的length,因为数组可能在更新watcher的过程中添加进新的watcher
  • 其次是如果watch api内又改变了自己的data属性,那么就会进入死循环,这里也用一个对象做了计数,在开发环境下,超过100次就终止,并报错

下一步才是开始执行watcher的回调,获取当前值,然后执行完了以后在做一些收尾的工作,比如数组,对象重置这种

响应式的可优化点

其实也是Object.defineProperty这个API的缺点

对象新属性添加「无法触发响应式更新」

我们都知道直接给data内的对象类型数据添加新的属性值,不会触发对象的响应式更新,这是Vue2内一个比较另新用户迷惑的点,Vue为了解决这个问题,提供了以全局的API $set

$set函数会判断新增key的对象是否为响应式对象,如果是的话直接会触发dep.notify,达到手动告知框架触发响应式的目的,这个问题在Vue3内通过es6的Proxy API支持上了

数组变动可能「无法触发响应式更新」

数组也是同理,Vue为了做一些力所能及的工作,把常见的几个Array方法做了一层拦截,比如

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

手动去弥补了需要使用$set才能触发响应式更新的工作,ok那么到这里本期的分享内容就告一段落了

思维导图

第一层:数据变化通知Watcher回调触发渲染

image

第二层:数据变化触发了组件属性管理Dep的update,执行了所有收集的Watcher回调

image

看看你在第几层了

总结

本期文章主要从依赖收集、派发更新和几个响应式相关的API触发,分析了完整的Vue响应式系统的构成何其设计思想

本质和我们开始源码分析前的推测很类似,没想到的主要是怎么能够优雅的构建系统的同时把几个API的实现也带上,这里通过一个较高层的设计,解决了这个问题,这是我们之后业务开发的时候可以注意并运用于实际的一种设计思想

下一期会把响应式的其他内容补充完毕,并开始分析Vue的组件系统

附录

优秀文章