深入理解 Vue 响应式系统
理解 Vue 响应式原理,到 computed、vuex 原理
前言
众所周知,一说到 vue 的响应式系统,就能马上想到 Object.defineProperty
、数据劫持、getter/setter
这些内容,是的,毕竟 官方文档 已经把基本原理介绍的很清楚了
当你把一个普通的 JavaScript 对象传入 Vue 实例作为data
选项,Vue 将遍历此对象所有的属性,并使用Object.defineProperty
把这些属性全部转为 getter/setter。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在属性被访问和修改时通知变更。
了解了基本工作原理,但是我仍然有两个问题
- 内部是如何实现追踪依赖和通知变更的?
- 了解了
data
选项如何实现响应式,那么computed
的原理是什么? - vuex 的
state
也是响应式的,其内部原理又是什么?
工作流程
引用官方文档一段内容
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
再上官网一张图
官方文档解释实在是太清晰了,我觉得我再多说一句都是废话了
到这里就可以知道了,响应式系统内部是依靠 Watcher
来实现的,那接下来就来看看 Watcher
的实现?不,现在直接看还有点懵,我们先按文档介绍的过程来逐步分析
- 遍历
data
选项的所有属性,把这些属性转为getter/settter
,即所谓的数据劫持 - 每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。
- 当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
实现细节
数据劫持
Vue 将 data 的属性转为 getter/setter
的工作,是在初始化一个 Vue 实例的时候完成的,接下来快速过一遍初始化的过程,找到我们要看的地方。
初始化 Vue 实例 _init
传入 options 后,调用 this._init
并传入 options,initMixin
的作用就是在 Vue 的原型对象扩展了 _init
方法
function Vue (options) {
// ... 省略
this._init(options)
}
initMixin(Vue)
来看 initMixin,做了三件事
- 处理options,这里只需知道,传入的 options,被合并到了
vm.$options
里了 - 初始化和调用生命周期函数,
initState
就是对状态进行初始化的 - 挂载组件实例,对!就是文档说的那个组件实例,可以猜测这里和
Watcher
有关系
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function () {
// 处理 options
vm._isVue = true
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
// 初始化工作和调用生命周期函数
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)
}
}
}
初始化状态 initState
可以看到,传入的options合并到 vm.$options
后,所有选项就都从 $options 获取了
initState 依次对 props
, methods
, data
, computed
, watch
进行初始化,这些都是我们熟悉的 API 了,initData
就是对 data
选项进行数据劫持的地方了
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)
}
}
初始化 data 选项
传入 data 选项也要经历检查和初始化
- 获取 data 对象,如果是函数,则获取函数返回的结果,data必须为 plain object
- 将data的属性代理到 Vue 实例上,这就是能直接通过
vm.a
访问 data 的属性的原因 详见文档 - observe 观察数据!马不停蹄看下来!!
function initData (vm: Component) {
let data = vm.$options.data
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
if (!isPlainObject(data)) {
data = {}
}
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
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
observe(data, true /* asRootData */)
}
observe
observe 函数主要工作是:对于可扩展的对象或数组,在其内部生成一个Observer实例并返回,包括以下几点
- 对传入的 values (可以是
data
选项,或者子对象、子数组,普通属性)进行类型判断,非对象或VNode实例,则返回空, - 根据
__ob__
属性判断是否已设置观察者,有则直接返回 - 判断类型为数组或对象,且可扩展的,使用
new
创建 Observer 实例,传入当前 value,返回该实例对!象
那么,问题来了__ob__
是什么?哪里来的?Observer
实例对象又是什么?有什么用?
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) {
return
}
let ob: Observer | void
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
class Observer
这是 Observer 的源码
def(value, '__ob__', this)
就是将当前的 Observer 实例挂到value.__ob__
上,每个对象(或数组)会对应一个 Observer 实例,保存在自身的__ob__
属性上,可以方便获取到- Observer 会
new
一个Dep
实例,并保存到this.dep
上,这个的作用稍后讲 - 如果value是数组,则对数组的 变异方法进行包裹 ,然后调用
observeArray
遍历数组调用observe
- 如果value是对象,则遍历对象的属性,调用
defineReactive
,定义响应式属性
值得注意的是
Vue 不会对数组的索引调用 defineReactive,要知道Object.definePrototype
是可以对数组索引值的变动的,但由于性能代价和用户收益不成正比,所以这里直接跳过了
Observer 实例会保存一个 Dep 实例,Dep 是 dependent 的缩写,表示依赖,一个对象对应一个 dep,就是一个依赖
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
this.dep = new Dep()
this.vmCount = 0
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)
}
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
defineReactive
设置响应式属性,就是把属性转为 getter/setter
代码有点多,分步来看,先看整体,参数有点多,目前只需用到前两个
export function defineReactive (obj, key, val, customSetter, shallow) {
// ...
}
接下来是函数内部
创建一个 Dep 实例,由于后面的getter/setter 有访问到这个变量,会形成一个闭包,上面说到 dep 是依赖,也就是说,一个属性就是一个依赖
const dep = new Dep()
判断是否可设置 getter/setter
,因此使用 Object.frezze
可以阻止修改现有的属性,也意味着响应系统无法再追踪变化。
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
预存
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
上面说到 observe 会创建并返回一个 Observer 实例对象,那这里就是对子对象递归设置观察者,并获取到观察者实例,下面会有用
let childOb = !shallow && observe(val)
定义存取描述符 getter/setter,里面做了这几件事
- 上面预存是为了这里正常完成对属性值的读写
- setter 中判断如果新值和旧值相等,则不会触发更新,newVal !== newVal 是考虑 NaN 的情况
- setter 中,
childOb = !shallow && observe(newVal)
如果修改的值是对象或数组,则需要对其进行 observe - 然后接下来就是依赖收集 和 通知变更 了
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
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
// 考虑到 NaN !== NaN 的情况
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
依赖收集
这里说的依赖,就是把当前属性作为一个依赖(dep)收集起来,如果这个属性的值变化了,就去通知订阅了这个依赖的订阅者
我们把依赖收集的代码抽出来看
- dep 是上面说到的 Dep 实例,
dep.denpend()
就是收集依赖了 - childOb 是一个子对象的 Observer 实例,我们知道,它内部也保存了一个 Dep 实例,可以递归收集依赖
那么问题来了,先把问题抛出来,后面来逐个解决
- 依赖收集到哪去,订阅者是谁?
- Dep.target 是什么?到目前为止还没看过这个变量,这里应该怎么收集依赖?
- 对于子对象,又递归收集了一遍依赖,是否会重复?
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
通知变更
哪个属性值被修改了,就由它自身去发出通知
同样把问题先抛出来
- 如何通知变更
dep.notify()
小结
回看上面的分析步骤,第一步,遍历 data 选项的所有属性,转为 getter/setter 已经完成,留下的问题就是如何收集依赖和通知变更,所有的问题都指向 Dep.target
、Dep
、以及一开始说到的 Watcher
, 这三者是什么关系,又是如何互相配合的?
创建 Watcher 实例
接下来看第二步
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据属性记录为依赖。
回看 _init
方法最后一步是挂载组件
// 挂载组件
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
组件挂载,即经历了编译渲染成为真实DOM,这个过程会去读取 data
选项的属性值,这个时候就会触发 getter
,然后收集依赖
$mount
定义在 src/platforms/web/entry-runtime-with-compiler.js
这里主要是将 template
转为 render
,然后调用 mount
mount
其实是 src/platforms/web/runtime/index.js
上定义的 $mount
方法,这样做是为了方便复用,不同的平台都基于这个来封装,这个 $mount
直接调用了 mountComponent
接下来看看 mountComponent
方法
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
// ...
callHook(vm, 'beforeMount')
// ...
const updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
mountComponent
就是用来挂载组件实例的,首先调用了 beforeMount
,然后 new Watcher
,最后调用 mounted
,因此,组件实例挂载前会创建一个 Watcher
实例,并完成挂载
那么来看看 new Watcher
传入什么参数
vm
: vue 实例updateComponent
: 这个方法调用了 _update, 传入了 render 方法执行后的结果,因此这个方法是用于挂载和更新组件实例的,执行render方法就会触发依赖收集noop
: 不执行操作before
: 方法内判断组件实例已挂载且未销毁的情况下,调用beforeUpdate
,可以猜测,组件实例更新前会调用这个方法
再来看看 Watcher
的源码
export default class Watcher {
constructor (vm, expOrFn, cb, options) {
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
// ...
}
this.value = this.lazy ? undefined : this.get()
}
}
这里省略了大部分的源码,上面组件挂载前 new Watcher
之后,主要是执行了上面这部分代码,可以看到这里把传入的 updateComponent
保存在 this.getter
,最后调用了 this.get
, 下面来看看 get
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (err) {
} finally {
// ...
popTarget()
this.cleanupDeps()
}
}
上面代码中,主要有以下几个操作
- 执行
this.getter()
,即上面的updateComponent
updateComponent
是用来挂载和更新组件的,保存在 Watcher 实例里,由 Watcher 来管理执行更新的时机,这也印证了上面说到的: 当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。 那么, Watcher 如何被准确通知到呢?答案是依赖Dep.target
来确定数据对应的 Watcher 实例
- 执行
this.getter
的前后分别pushTarget(this)
和popTarget()
,这里的 this 其实就是当前的 Watcher 实例
这两个方法是全局方法,是用于修改全局变量Dep.target
的,依赖收集的时候,会判断是否有Dep.target
,有才会进行依赖收集,而Dep.target
就是每次执行 getter 方法时的Watcher 实例
(组件实例对应的是 updateComponent 方法)
而执行updateComponent
方法又会执行 vm._render(),这个时候就会触发数据的getter
,然后开始收集依赖。那么,收集依赖的过程是怎样的呢? 答案是依赖数据内部各自持有的 Dep 实例
```javascript Dep.target = null const targetStack = []
export function pushTarget (target: ?Watcher) { targetStack.push(target) Dep.target = target console.log('targetStack:', targetStack) }
export function popTarget () { targetStack.pop() Dep.target = targetStack[targetStack.length - 1] } ```
- 最后执行了
this.cleanupDeps
结合 updateComponent
和 before
可以看出,组件的挂载和更新,是在 Watcher 实例里面执行的,这也对应了开头说的第三个步骤
这个的作用需等到 依赖收集讲完再来看~
======== 未完待续 =========