概况
最近对一个基于 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 的代码,虽然是实例化代码,但实际上这里做了两件事:
- new Vue,即创建了一个 Vue 实例。
- 调用实例的 $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
方法,定义构造函数后会调用 initMixin
,stateMixin
等方法,其中 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
的核心,props
、methods
、computed
、watch
这些常用的 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
(至于这是什么后面会详细说明),另外就是如上面提到的,进行名字校检防止冲突,例如如果有 data
的 key
跟 props
冲突了,就会报那个大家应该都很熟悉的 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
方法,这是渲染的核心主线逻辑,按顺序分别做了以下的事情:
- 判断是否有传入
render
方法,render
方法是把 Vue 模板转换成 VNode 的方法,在 Vue 内部,如果 new Vue 时有render
会优先使用,上面 new Vue 的示例就传入了render
方法,也是大家比较熟悉的把 App.vue 传入的逻辑。如果没有传入render
则会把render
赋值成创建一个空 VNode 节点的方法。 - 调用
beforeMount
的钩子。 - 定义好
updateComponent
方法,该方法负责执行实例的渲染和更新,内部会调用 Vue 实例的_update
,而_update
则传入了render
的调用结果,即计算好的 VNode。_update
方法的内最重要的就是调用了patch
,即把 VNode 转换成真实 DOM 的方法,转换过程跟「响应式」关联不大,因此这里不针对patch
展开太多。 - 创建一个
Watcher
实例,传入当前 Vue 的实例vm
,updateComponent
,还有一些 options,例如before
参数。 - 调用
mounted
钩子。
在梳理了 $mount
的过程后,可以梳理出一个清晰的 Vue 实例渲染主线,调用 new Vue 实例化 Vue,然后把 data
、props
等 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,完成了一个订阅 - 通知 - 响应的过程。
订阅者模式的基础实现
根据上面的例子可以总结出订阅者模式的基础特征:
- 一个观察目标对象通常会有观察者管理类,包括了添加、删除、通知观察者更新三个主要操作。
- 一个或多个观察者,接收观察目标的通知并作出处理。
也就是说,观察目标类,观察者管理类,观察者是订阅者模式中的三个基本要素。基于以上特征,这里实现了一个简单的订阅者模式示例,其中观察者集合类 ObserverList
作为一个工具类用于管理观察者,观察者目标类 Subject
调用 ObserverList
进行实际的观察者(Observer
)管理,以及在需要时发送更新通知给观察者,示例中的更新通知是更新随机数,观察者接受通知把最新的随机数输出。
到这里,Vue 实例化和渲染的基本逻辑已经梳理出来,下一篇文章会详细说明 Vue「响应式」的具体实现。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。