Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(上)

概况

最近对一个基于 Vue 项目的 Sentry Issue 进行治理时,发现了大量 Issue 都是 Vue 内部逻辑引起的,为了更好地去解决问题,因此也复习了一遍 Vue2 的原理。

相比起 Vue3 更清晰的项目结构和实现,Vue2 中各个部分的实现存在较多的耦合,也导致其逻辑梳理起来较为复杂。其中「响应式」的部分是最为复杂也是最重要的一环,实际项目中大部分的 Issue 也与其相关,如 Vue2 官网中所述的那样:

“Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题”。

在系统地梳理「响应式」工作原理的过程中,也参考了不少现有的文章,大部分都是围绕“依赖收集”、“派发更新”或者“Watcher”,“Dep”这些响应式相关的概念逻辑展开讲述,当然这些概念和逻辑是必不可少的要展开讲述的内容,但是如果单纯围绕这些内容展开来编写一篇文章,对于理解「响应式」在整个 Vue 中的工作过程可能会感到困惑。因此,本文会换一个角度,从 Vue 使用的过程展开说明「响应式」的工作原理,即从「实例化」、「渲染」、「数据更新」三条线讲述「响应式」的工作过程,分别对应的是如何定义响应式数据、如何触发响应式逻辑执行,以及如何触发响应式数据更新。

在介绍了「响应式」的工作原理之后,也会基于工作原理解决一些常见的数据更新相关的问题。

从实例化到渲染

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

以上是一段大家应该都很熟悉的代码,即 Vue Cli 创建的示例项目实例化 Vue 的代码,虽然是实例化代码,但实际上这里做了两件事:

  1. new Vue,即创建了一个 Vue 实例。
  2. 调用实例的 $mount 方法,即挂载 Vue 的渲染结果到 #app 这个节点上。

这里是 Vue 中两条重要的工作线,接下来看看在 Vue 内部这两个操作具体做了什么,当然会着重于「响应式」相关部分。

实例化过程

// 精简了非 production 的逻辑
function Vue (options) {
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

首先是定义了 Vue 的构造函数,构造函数内会调用 _init 方法,定义构造函数后会调用 initMixinstateMixin 等方法,其中 initMixin 内会定义构造函数内的 _init 方法,因此先关注一下 initMixin

// 精简了非 production 逻辑
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    vm._uid = uid++
    vm._isVue = true
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

initMixin 方法内部会给 Vue 的原型扩展大量方法,其中初始的就是 _init 方法,包括生命周期、渲染函数(把模板构造成 render 函数,render 函数负责输出虚拟节点)、data/props、调用 created hook 等,对数据进行响应式封装的逻辑也是从这里开始的。

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 */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initState 是负责处理 data 的核心,propsmethodscomputedwatch 这些常用的 Vue 的 options,也是在这里进行处理,主要的处理内容包括做一些检查,例如有名字冲突,比如比较常见的 warning:"Method xxx has already been defined as a prop.",就是在这个阶段做的检查,另外最重要的就是对数据进行响应式封装,接下来会以最常用也是最直观的 data 作为例子。

// 精简了非 production 逻辑
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  const keys = Object.keys(data)
  const props = vm.$options.props
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (props && hasOwn(props, key)) {
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  observe(data, true /* asRootData */)
}

上图是 initData 的主体逻辑,主要的作用是对 data 的内容进行格式检查,比如必须是一个 isPlainObject(至于这是什么后面会详细说明),另外就是如上面提到的,进行名字校检防止冲突,例如如果有 datakeyprops 冲突了,就会报那个大家应该都很熟悉的 warning:"The data property xxx is already declared as a prop. Use prop default value instead.",最后就是真正的响应式逻辑 observe 方法。

到这里,实例化的主线已经梳理出来了,可以看到 new Vue 之后 Vue 的处理步骤,以及 data 这类 options 是如何走到数据响应式处理的。

调用 $mount,挂载实例

在 Vue 的示例中,实例化之后会调用 $mount 把渲染出来的 DOM 挂载到页面上,$mount 实际上是触发渲染的入口。

// 精简了非 production 逻辑
export function mountComponent (vm: Component, el: ?Element, hydrating?: boolean): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

$mount 首先会调用 mountComponent 方法,这是渲染的核心主线逻辑,按顺序分别做了以下的事情:

  1. 判断是否有传入 render 方法,render 方法是把 Vue 模板转换成 VNode 的方法,在 Vue 内部,如果 new Vue 时有 render 会优先使用,上面 new Vue 的示例就传入了 render 方法,也是大家比较熟悉的把 App.vue 传入的逻辑。如果没有传入 render 则会把 render 赋值成创建一个空 VNode 节点的方法。
  2. 调用 beforeMount 的钩子。
  3. 定义好 updateComponent 方法,该方法负责执行实例的渲染和更新,内部会调用 Vue 实例的 _update,而 _update 则传入了 render 的调用结果,即计算好的 VNode。_update 方法的内最重要的就是调用了 patch,即把 VNode 转换成真实 DOM 的方法,转换过程跟「响应式」关联不大,因此这里不针对 patch 展开太多。
  4. 创建一个 Watcher 实例,传入当前 Vue 的实例 vmupdateComponent,还有一些 options,例如 before 参数。
  5. 调用 mounted 钩子。

在梳理了 $mount 的过程后,可以梳理出一个清晰的 Vue 实例渲染主线,调用 new Vue 实例化 Vue,然后把 dataprops 等 options 进行校检和「响应式」封装,接着调用 $mount 开始进行渲染,首先创建一个 Watcher 对象跟 Vue 实例关联起来,并通过传入 updateComponent 方法维护实例的渲染和更新,render 作为 updateComponent,负责把模板转换成虚拟节点 VNode,后面的 patch 方法则把 VNode 转换成真实 DOM,最后挂载到页面上。而在这个过程中,实例化时定义好响应式数据,渲染时调用响应式数据的更新逻辑,最终实现整个更新逻辑。

订阅者模式

在上面的整个更新逻辑中,核心的「响应式」逻辑,应用了订阅者模式这种设计模式,在说明 Vue 具体是如何基于订阅者模式实现「响应式」之前,先来介绍一下订阅者模式。

什么是订阅者模式?

“一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知”。这是对订阅者的简单描述,在 JavaScript 中,订阅者模式是最常用的模式之一,例如经常用到的 DOM 事件监听也是一种订阅者模式,比如:

document.body.addEventListener('click', () => {
    console.log('clicked1');
});
document.body.addEventListener('click', () => {
    console.log('clicked2');
});

body 作为观察目标,订阅了 click 事件,当 body 被点击时就会向订阅者发出通知,订阅者依次输出 clicked1 和 clicked2,完成了一个订阅 - 通知 - 响应的过程。

订阅者模式的基础实现

根据上面的例子可以总结出订阅者模式的基础特征:

  1. 一个观察目标对象通常会有观察者管理类,包括了添加、删除、通知观察者更新三个主要操作。
  2. 一个或多个观察者,接收观察目标的通知并作出处理。

也就是说,观察目标类观察者管理类观察者是订阅者模式中的三个基本要素。基于以上特征,这里实现了一个简单的订阅者模式示例,其中观察者集合类 ObserverList 作为一个工具类用于管理观察者,观察者目标类 Subject 调用 ObserverList 进行实际的观察者(Observer)管理,以及在需要时发送更新通知给观察者,示例中的更新通知是更新随机数,观察者接受通知把最新的随机数输出。可以尝试打开控制台,点击示例中的按钮留意输出。

到这里,Vue 实例化和渲染的基本逻辑已经梳理出来,下一篇文章会详细说明 Vue「响应式」的具体实现。

本文由 Kayo Lee 发表,本文链接:https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-one.html

评论列表

回复

你正在以游客身份访问网站,请输入你的昵称和 E-mail