深入响应式原理(第四章)
响应式对象:
什么是响应式对象?当我们随手写一个对象的时候:
var obj = {
a:10,
b:20
}
其实这不是响应式对象,因为它没有能力来判断我们是否对对象进行操作。我们举个例子,让我们更加的对响应式对象有一个了解。尤大曾经在它演讲的源码解析中举过一个这样的例子。
我们有一个对象obj
。他有两个属性a/b
。它们的值都是数字,我们有一个要求,每次属性b
输出的时候都是a
值的两倍。即:console.log(obj.b)
。那么我们该如何做到呢?
var obj = {
a:10,
b:20
}
其实这里有一个隐含的问题,那就是a
属性的值是可以随时变化的,我们是不知道的。那么如果让属性a
的改变影响到属性b
呢?此时我们就需要了解一个函数:Object.defineProperty()
。
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
参数:
obj
:要定义或修改属性的对象key
:要定义或修改属性的名称descriptor
:要定义或修改的属性描述符。
descriptor
-
configurable
当且仅当该属性的
configurable
键值为true
时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。 默认为false
。 -
enumerable
当且仅当该属性的
enumerable
键值为true
时,该属性才会出现在对象的枚举属性中。 默认为false
。 -
value
该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。 默认为
undefined
。 -
writable
当且仅当该属性的
writable
键值为true
时,属性的值,也就是上面的value
,才能被赋值运算符
改变。 默认为false
。
存取描述符还具有以下可选键值:
-
get
属性的 getter 函数,如果没有 getter,则为
undefined
。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入this
对象(由于继承关系,这里的this
并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。 默认为undefined
。 -
set
属性的 setter 函数,如果没有 setter,则为
undefined
。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的this
对象。 默认为undefined
。
举例:
var obj = {
a:10
}
Object.defineProperty(obj,'a',{
get(){
console.log('你正在访问')
},
set(){
console.log('你正在修改')
}
})
console.log(obj.a)
obj.a = 20
//结果:
你正在访问
undefined
你正在修改
你看可以发现,当我们去访问或者修改的时候,obj
就知道它被访问或者修改了。那么我们如何让属性b
每次输出的时候都是属性a
的两倍呢?
var obj = {
a:10,
b:20
}
function ReactDefineProperty(o){
const obj = o
Object.defineProperty(o,'a',{
set(value){
obj.b = value*2
}
})
}
ReactDefineProperty(obj)
obj.a = 20
console.log(obj.b)
obj.a = 5
console.log(obj.b)
obj.a = 1
console.log(obj.b)
//结果
40
10
2
其实代码并不难,你会发现:Object.defineProperty
将称为响应式原理的关键。
initState()
initState()
函数用来处理我们传入的data/props/methods
。该函数的调用在beforeCreate
钩子函数之后,在created
钩子函数之前。该函数的代码如下:
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)//处理props
if (opts.methods) initMethods(vm, opts.methods)//处理methods
if (opts.data) {//处理data
initData(vm)
} else {//如果我们没有从传入data,那么就使用一个空对象作为默认的data
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)//处理computed
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)//处理watch
}
}
initState()
函数对数据做的初始化顺序为:props -> methods -> data -> computed -> watch
我们主要研究的是data/props
内部的实现原理。
initData(获取data并判断是否有命名冲突)
该函数的代码如下:
function initData (vm: Component) {
let data = vm.$options.data//获取data
//获取data
data = vm._data = typeof data === 'function'
? getData(data, vm)//如果data是一个函数,那么执行getDate()来拿到内部的data数据
: data || {}
//判断类型
if (!isPlainObject(data)) {//对data进行类型校验
data = {}
process.env.NODE_ENV !== 'production' && warn(
'data functions should return an object:\n' +
'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
vm
)
}
// proxy data on instance
const keys = Object.keys(data)//获取data中的key
const props = vm.$options.props//获取props
const methods = vm.$options.methods
let i = keys.length
//判断属性冲突
while (i--) {//该代码就是这个遍历,看是否methods/props/data中是否有命名冲突
const key = keys[i]
if (process.env.NODE_ENV !== 'production') {
if (methods && hasOwn(methods, key)) {
warn(
`Method "${key}" has already been defined as a data property.`,
vm
)
}
}
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' && warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)//将我们的data代理到vm._data_上
}
}
// observe data
observe(data, true /* asRootData */)
}
initData
函数的作用也是对data
进行一些边界情况的处理兼容,最核心是调用observe()
函数。但是在此之前我们要知道initData
函数做了哪些事情。首先获取data
,因为我们传入的data
不一定是对象,所以要处理获取data
。然后将data
中的key
和props/methods
中的key
进行比较,看是否有命名冲突。当这些工作全部都准备好之后开始执行observe()
函数。
observe()
export function observe (value: any, asRootData: ?boolean): Observer | void {
//对data进一步边缘处理
if (!isObject(value) || value instanceof VNode) {//如果data不是一个对象,或者说是一个vnode。那么直接返回,不予进行响应式处理
return
}
let ob: Observer | void
//判断是否已经处理过了。
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
//判断data是否有__ob__属性,因为这个属性只有经过observe处理过后的对象才具有。
ob = value.__ob__
} else if (
shouldObserve &&//是否要进行observe()处理,默认为true
!isServerRendering() &&//非服务端渲染
(Array.isArray(value) || isPlainObject(value)) &&//要么是数组,要么是对象
Object.isExtensible(value) &&//对象是可扩展的
!value._isVue//不是Vue构造函数实例
) {
//我们传给Observer()是data对象
ob = new Observer(value)
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
observe
函数的核心是调用new Observer()
构造函数,但是在此之前对传入的参数进行了处理。首先如果data
不是对象或者是一个vnode
,那么则不予处理。判断我们要observe
的对象是否已经被observe
过了。如果没有那么就进行条件验证:shouldObserve/ !isServerRendering()/(Array.isArray(value) || isPlainObject(value))/ Object.isExtensible(value)/ !value._isVue
。当这些条件满足之后调用new Observer()
函数。
-> new Observer()
export class Observer {
value: any;
dep: Dep;
vmCount: number;
constructor (value: any) {
//这里的this指向的是observer实例,也即是说每一个data中的属性对象都会生成一个对应的observer实例对象
//为对象添加标记。这里并没有特指data,因为data中如果还有子对象的话也会走这一步的
this.value = value
this.dep = new Dep()//为该属性对象添加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函数
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])
}
}
}
这里有一点需要注意,为属性对象创建的ob
我们在哪里可以获取到呢?在data.__ob__
中可以获取,那和组件又有什么关系呢?我们可以通过vm.$data
获取到data
,然后通过data.__ob__
获取到该组件对应的ob
。每一个组件都有一个单独的data
,每一个data
都有一个ob
,通过data.__ob__
可以访问。不仅每一个data
都有一个__ob__
,甚至data
中的属性如果是一个对象,那么这个对象也有一个__0b__
。
对于对象的监听,我们先假设监听的是一个没有引用类型数据的对象,例如:{name:"xz",age:13}
。然后再假设监听的是一个有引用类型的数据{name:"xz",friends:{name:'hky'}}
。
我们向Observer
构造函数传入的是data
对象,但是返回的ob
实例是另一个对象,那么就让我们看看Observer
函数到底做了什么事情。
首先将我们的data
挂载到ob.value
上。然后添加ob.dep
属性。def(value,'__data__',this)
。这行代码的运行结果是在我们传入的data
对象上添加一个__ob__
属性,而该属性的值就是我们的ob
实例。如果你想想就会发现ob/data
这两个对象相互引用,形成闭环。这个是题外话。
其实observe
函数的参数是一个对象,不一定是根data
。原因是当我们的根data
有子对象的时候,也会将子对象进行observe
处理,也就是说,会深度遍历子属性,将每一个子对象添加一个__ob__
属性。如果我们传入的是对象,则会调用walk()
函数。
->->walk() 获取keys
walk (obj: Object) {
//为每一个key都进行响应式处理
const keys = Object.keys(obj)//获取value中的key
for (let i = 0; i < keys.length; i++) {//然后将key逐个进行defineReactive处理
defineReactive(obj, keys[i])
}
}
walk()
函数的作用就是获取所有的key
,然后将每一个key
都进行defineReactive()
处理。
->->->defineReactive()
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
//为每一个key都创建一个dep用来存放访问该属性的依赖
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)//获取到key的描述信息
if (property && property.configurable === false) {//如果该属性是不可配置的,那么将返回
return
}
// cater for pre-defined getter/setters
const getter = property && property.get//获取该属性原生定义的get属性
const setter = property && property.set//获取该属性原生定义的set属性
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]//获取该属性的值
}
let childOb = !shallow && observe(val)//如果子属性是一个对象的话,那么对子属性进行observe处理
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
//......
},
set: function reactiveSetter (newVal) {
//......
}
})
}
其实defineReactive
函数的核心就是调用Object.defineproperty()
。只不过在此之前做了其他的事情。通过Object.defineProperty
设置set/get
函数。当我们对该属性进行访问的时候,调用get
函数。当我们对该属性进行修改的时候,调用set
函数。get
的作用是进行依赖收集,而set
的作用是值的派发等。
这里我们需要知道每一个属性是怎么存储自己的dep的,首先每一个属性都有一个getter/setter
属性,并且每一个getter都可以通过闭包来访问到这个属性对应的dep
。
至此initState
对于data
的监听就到此为止。
渲染watcher:
每一个组件实例都对应一个Watcher实例,它会在组件渲染的过程中把接触到的数据属性记录为依赖。之后当依赖项的setter
触发时会通知每个组件相绑定的Watcher从而使它关联的组件重新渲染。
什么意思呢,Watcher
的生成是以组件为单位的。我们来举个例子:
组件1 ----> watcher_1
组件2 ----> watcher_2
组件3 ----> watcher_3
data = {
name:'xz', //dep_1
age:22//dep_2
}
组件1 使用了data.name/data.age
组件2 使用了 data.name
组件3 使用了 data.age
//它们之间的关系为:
watcher_1 = {dep_1,dep_2}
watcher_2 = {dep_1}
watcher_3 = {dep_2}
dep_1 = {watcher_1,watcher_2}
dep_2 = {watcher_1,watcher_3}
而watcher_*
就是我们要找的依赖。同时它也代表的是组件实例。接下来我们就具体的来看Watcher
实例。
export default class Watcher {
constructor (vm,expOrFn,cb) {
this.vm = vm//这里的vm指的是我们的组件实例
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
// options
if (options) {
//......
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
//....
}
this.value = this.lazy
? undefined
: this.get()
}
//方法暂时省略
}
watcher
类的代码有点长我们来讲主要部分。当我们创建一个组件的时候就会对应的创建一个和该组件相绑定的watcher
实例,用来存储它访问了哪些属性。在constructor
中,Watcher
做了大量的操作,定义了很多变量。它将updateComponent
赋值给了this.getter
。因为后面要用,所以暂时不做讲解。最主要的代码时执行了this.get()
。
get
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
get()
函数首先就调用了pushTarget()
函数。该函数的作用是什么呢?让我们来看一下pushTarget
的实现过程。
pushTarget
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
该函数位于./src/core/observe/dep.js
。传给pushTarget
的this
就是组件的watcher
实例对象。当我们创建完一个组件,然后就把组件的watcher
实例挂载到Dep.target
上,这个Dep.target
是在dep.js
中定义的一个相当于全局变量的一个存放watcher
实例的容器。
执行完pushTarget
后我们回到get
中来,继续执行this.getter.call(vm,vm)
。刚才我们说了this.getter()
就是updateComponent
函数。在第三章的时候我们讲过updateComponent
函数的作用是通过执行render
函数生成vnode
。现在我们想一想,既然会执行render
函数,那么一定会触发属性的getter
函数。为什么这么说呢?假如我们手写render
函数。那么我们需要将我们的变量写道render
函数的某些对象中,其实这也是访问对象属性的一种。如果render
函数是通过解析模板生成的,那么它会生成一种with(){}
结构的字符串,然后在变为render
函数的过程中会访问到对象属性,进而触发属性的get
方法。
在这里我们要注意一下targetStack.push(target)
。这个方法,为什么要注意它呢?有没有想过为什么要有这个方法?首先targetStack
是一个栈,用来存watcher
实例对象的,那为什么要设计一个栈的?原因是为组件嵌套做准备,假如有两个组件为父子组件,当父组件在渲染的时候内部又有一个子组件,那么我们前面说了。每当一个组件创建的时候(并不一定是创建完才生成watcher
),就会生成一个watcher
。此时的Dep.target
就是父组件的watcher
。那么现在我们要创建子组件了,好我现在有一个子组件的watcher
。那么我挂不挂载到Dep.target
上呢?有人说挂载,那么假如我们子组件创建完了,现在调用render
函数,其中我们访问了父组件数据中的属性,那么问题就来我们为该属性添加watcher
。此时发现添加的是子组件的watcher
。这就很难办了,所以我们要将父组件和子组件的watcher
都压入栈中,栈顶是当前在渲染的组件的watcher
。然后将Dep.target
赋值为栈顶的watcher
。当子组件创建完成,然后就pop()
操作,将该组件的watcher
弹出栈,然后将操作后的栈的栈顶的watcher
赋值给Dep.target
。这就保证了当前的watcher
和访问属性是需要添加的watcher
是一致的。
也就是说,最终我们都会发现触发属性的get
方法。那么我们就言归正传的去讨论get
函数。
依赖收集:
依赖收集主要是get
函数所做的事情,当我们在一个地方引用一个属性的时候,其实就会调用get
函数。例如:
var obj = {
name:'xz'
}
console.log(obj.name)
当我们通过console.log()
函数打印出obj.name
时,其实就是访问obj.name
,如果name
属性有get
函数的话就会调用该函数。
defineReactive()
函数将data
中的所有属性都添加了get
,当我们在任意地方进行访问的时候都会调用该函数。那么接下来我们将详细讲解get
函数。
get
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
}
当执行get
函数的时候,那么就一定能确定一件事。那就是在代码的某处该属性正在被访问。那么此时问题就来了,该属性是在什么时候被访问的呢?我们前面讲了,当我们执行渲染函数的时候被访问的,当执行渲染函数的时候,就一定有组件被创建,那么就一定会生成对应的watcher
实例。因为执行了Watcher
中的pushTarget
函数,那么此时的Dep.target
就是watcher
实例。也就是说大概率的情况下if(Dep.target)
是成立的。然后执行dep.depend()
函数。
dep.depend()
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
该函数最终调用的是watcher.addDep(this)
。这里的this
指向的是属性的dep
。当一个组件涉及到了某个属性,那么就把代表这个属性的dep
放到watcher
这个篮子当中。
watcher.addDep
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
这是一个dep ---> watcher
的过程,但是内部调用了dep.addSub(this)
。这里的this
指的是组件的wathcer
。此时也将watcher
放到dep
中,是一个watcher ---> dep
的过程。
现在依赖的收集算是完成了。我们需要回到组件创建时调用的updateComponent()
函数了。
traverse(value)
代码稍后再讲。我们继续向下运行,接下来运行的是popTarget()
函数,其实该函数的作用就是我们前面讲的,当一个组件在快创建完成的时候(即依赖收集完毕后),此时就将组件的watcher
实例从全局的存储watcher
的栈的栈顶移除。接下来执行this.cleanupDeps()
函数。
cleanupDeps
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}
为什么要删除旧的deps
呢?其实后面有讲解,因为当我们更新数据的时候会引发vue
重新渲染vdom
。而再渲染的过程中会再一次的访问属性,而访问属性的过程中会再一次的触发属性的get
函数,进而又一次的收集依赖,但是我们以前有了旧依赖怎么办,那么我们就必须删除旧依赖然后保存新依赖。然后把新依赖放入到旧依赖中,等待下一次的更新。
派发更新
派发更新是属性中set
函数需要做的事情。当我们去修改数据的时候,就会触发set
函数,进而通知订阅的watcher
去实时的改变原有的值。
set
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val//求值
if (newVal === value || (newVal !== newVal && value !== value)) {
//和以前的值进行对比,如果相同没有必要修改和派发通知
return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (getter && !setter) return
if (setter) {//如果有原生的setter,先执行原生setter
setter.call(obj, newVal)
} else {
//设置新值
val = newVal
}
//假如我们赋的新值也是一个对象,那么将该对象变成一个响应式的对象
childOb = !shallow && observe(newVal)
dep.notify()//派发更新
}
set
函数的作用是派发更新,其核心代码是dep.notify()
。但是在调用之前做了一些数据处理。
首先是或通过getter
求原有值,然后将newVal
和原有的值进行对比,如果没有变化就没有必要修改和派发更新。对于没有setter
的属性直接返回,不进行派发更新,假如存在setter
。执行原生setter
。没有的话就将val
赋值为新值。假如我们的新值也是一个对象,那么将该对象也变成一个响应式的对象。
最后执行dep.notify()
。
dep.notify
notify () {
const subs = this.subs.slice()
if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
该函数为更新做了一些简单的处理,首先是获取到该属性对应的dep
数组。然后通过for
循环,挨个执行update()
。因为subs
中存放的是组件的watcher
实例,也就是说循环的作用是调用每一个watcher
的update
函数。
watcher.update
update () {
if (this.lazy) {//false
this.dirty = true
} else if (this.sync) {//false
this.run()
} else {
queueWatcher(this)
}
}
在函数update
中最终调用的是queueWatcher(this)
函数,而this
就是组件的watcher
实例对象。
-->queueWatcher:将watcher放到更新队列中
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
//放到下一个事件循环中
nextTick(flushSchedulerQueue)
}
}
}
该函数的主要最用是将我们的watcher
放到一个叫queue
的更新队列里面。queue
数组相当于一个全局队列,当有watcher
需要更新的时候,就会将该watcher
放入到队列里准备下一步的更新操作。
这里有一个if
判断。if(has[id] == null)
。为什么要有这个判断呢?当一个watcher
里有多个dep
的时候,假如该依赖有多个属性更新了数据,那么就会导致同一个watcher
被多次添加到queue
队列里导致重复。所以通过该判断就可以使我们的queue
队列中只存在唯一的watcher
。
当我们将需要更新的watcher
放入到queue
中后。其实是通过flushSchedulerQueue
函数来遍历的。
-->->flushSchedulerQueue
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id)//从小到大排watcher对象,也就是从父到子
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
// in dev build, check and stop circular updates.
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
}
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
if (devtools && config.devtools) {
devtools.emit('flush')
}
}
首先将flushing
置为true
。表示正在刷新watcher
中。然后将queue
队列中的watcher
进行排序,为什么要排序呢?原因如下:
1. 组件的更新是从父到子的(因为父组件总是在子组件前被创建)
2.组件的user watcher运行在其render watcher之前的(因为user watcher的创建是在render watcher之前)
3.如果在父组件运行watcher回调的时候,改组件被销毁,那么就可以跳过watcher。
把队列中的watcher
从前到后从父到子的排列起来。然后开始循环遍历,如果watcher
有before()
属性,那么先执行watcher.before()
函数。然后将id
从has[id]
中移除,表示已经处理了该watcher
。然后再执行watcher.run()
函数。后续一些代码是关于无限循环更新的情况。我们后续再讲,先来看watcher.run()
函数。
-->->->watcher.run()
run () {
if (this.active) {
const value = this.get()
if (
value !== this.value ||
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
->->->->get()
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}
我们通过调用watcher.get()
函数来获取新值,get()
函数内部会调用watcher.getter()
。该函数就是我们传入的updateComponent
函数,用来渲染vdom
。而在进行虚拟DOM的生成过程中就会访问到我们定义的属性,进而触发getter
函数,那么我们就会再一次的收集依赖,此时我们生成的虚拟DOM要访问的值就是最新的值。这一点仔细想想就会很快的明白。
接下来执行的是popTarget()
。其实popTarget
的作用在上面get
函数中有讲解,其实道理也是大同小异。因为当我们进行二次更新的时候一定会导致二次渲染vdom
。而渲染的时候又会从新收集依赖,而假如有嵌套组件,它要知道哪个watcher
正在收集依赖,为了保证watcher
与deps
的正确对应,所以才会将watcher
进栈然后出栈。当依赖收集完之后,使用watcher.cleanupDeps
将旧deps
删除,保存新deps
。
Vue是如何派发更新视图的?
首先,如果要想更新视图,那么必须要生成vdom
。而再Vue
生成Vdom
的过程中会触发属性的get
函数,从而去收集依赖。收集完后,此时组件的watcher
中有一个或多个dep
。当我们更新属性值的时候就会触发属性的set
函数,而set
函数会去调用dep.notify
。dep.notify
调用watcher.update --> queueWatcher
。将我们更新的watcher
放到当前更新阶段的队列当中,然后通过flushSchedulerQueue
来逐个处理更新队列中的watcher
。处理的方式就是逐个的调用watcher.run
。而这个watcher.run
至关重要,原因在于它调用了一个函数:this.get()
。在get
函数内部执行了this.getter.call(vm,vm)
。就是这个代码,是派发更新最重要的一句代码。正是这个代码的执行让我们的更新进入了高潮部分。首先getter
函数是什么?该函数是在创建watcher
时产生的。this.getter = expOrFn
。而expOrFn
就是updateComponent
函数。那么这个函数是干什么用的呢?它是用来调vm._update(vm._render(),vm.$createElement)
。我们知道,一旦调用vm._render()
。那么就会生成vdom
。而在生成vdom
的过程中又会触发属性的get
函数,进而收集依赖,但是我们前面已经收集过依赖了怎么办,那就用cleanupDeps
函数将以前的依赖删除掉,保存新的依赖,因为我们更新了数据,所以新的依赖的值就是newVal
。
其实其本质就是我们给属性赋新值后,通过一系列的操作让vue通过生成vdom
再访问一次属性。而这次属性的值已经发生了变化。可能有些依赖不会再用到了。
怎么将watcher和组件一一对应起来
这也许是一个小问题,可能根据个人原因,提出了这个问题。我们直入主题,Vue
是怎么将watcher
和组件对应起来的呢?我们在lifecycle.js
中只能找到这样的代码:
export function mountComponent (){
//......
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
//......
}
它只new
了一个watcher
并没有去保存,也没有进行额外的操作,那么它是怎么知道哪个组件对应哪个watcher
呢?其实我认为关键就在于它传入的第二个参数updateComponent
函数。该函数是用来渲染模板生成vdom
的。每一个组件都有一个属于自己的updateComponent
。毕竟要渲染vdom
。也就是说每一个watcher
接收的updateComponent
都不一样。每一个updateComponent
都会渲染它对应的组件的vdom
。这样就通过updateComponent
将我们的组件区别开来。而每当我们创建一个watcher
的时候,其内部保存的updateComponent
也各不一样,这样就可以通过updateComponent
来将我们的watcher
区分开来。当我们更新数据的时候,通过重新调用updateComponent
就可以将对应组件的vdom
重新渲染,然后就访问到了新值。
Vue数据更新流程
我们前面讲了关于派发更新到完成视图渲染的过程,但是如果我们仔细地回想我们会发现这里有一个断层,那就是从queueWatcher(this)
到flushSchedulerQueue()
函数地过程,原因是因为我们跳过了一个叫nextTick
地过程,而这个nextTick
就和派发更新有关,那么这一节我们就将要来说说这个中间层干了什么让我们可以从queueWatcher
转换到flushSchedulerQueue()
。我们下来看一下派发更新地一个过程:
dep.notify ---> watcher.update ---> queueWatcher(watcher)
在queueWatcher
函数里面我们调用了nextTick(flushSchedulerQueue)
。那么我们先看传入到nextTick
函数中的参数flushSchedulerQueue
。
flushSchedulerQueue
对于该函数地源码上面有,我们不做解析,我们只探讨该函数在宏观上地作用是什么。其实通过源码分析我们可以看出,该函数地作用是将我们修改属性值后所涉及的watcher
放入队列中,然后逐步地去重新执行updateComponent
来渲染vnode
。让组件此时访问的值是最新的。也就是说flushSchedulerQueue
是用来重新渲染更新的。那么为什么在中间要加上一个nextTick
呢?
nextTick
nextTick
在Vue
中主要是以两种形式出现:vm.$nextTick()
和Vue.nextTick()
但是这两种函数内部最终调用的还是nextTick
函数。nextTick()
函数在next-tick.js
文件当中。在讲nextTick
函数之前,我们来讲讲该文件做了什么事情。它定义了一个函数timerFunc
。这个函数其实将是让任务异步化,不让它同步执行。我们都知道,异步任务有两种:宏任务和微任务两种。而对于timerFunc
其实是不固定的,要看浏览器支持的版本。我们来看具体代码:
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
从代码上我们能看出其实Vue
对timerFunc
函数进行了适配,如果支持promise
。那么在timerFunc
内部使用Promise.resolve().then()
来处理flushCallbacks
。如果不支持,那么就会适配其他选项,例如setImmeditate/setTimeout
等。此时的timerFunc
函数中的任务其实是放在宏队列中执行的。Promise.resolve().then()
是放在微队列执行的。
接下来我们来看nextTick
函数。
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
cb
参数就是我们的flushSchedulerQueue
。是一个watcher
更新队列。当改变一个值的时候,可能需要多个watcher
需要重新更新,那么该队列中可能就不止一个watcher
了。此时如果我们直接调用flushSchedulerQueue()
函数,那么就会直接进行渲染。那么渲染的过程其实就是同步的。但是在执行flushSchedulerQueue()
函数的时候是进行了nextTick
处理。其实nextTick
代码可以简化为:
export function nextTick (cb?: Function, ctx?: Object) {
callbacks.push(() => {
cb.call(ctx)
})
timerFunc()
}
可能里面有很多重要的细节被我省略了,在这里我们只展现出函数的执行过程,来宏观的分析异步更新的过程。首先把一个包含flushSchedulerQueue
函数的匿名函数添加到callbacks
。然后执行timerFunc()
函数。
timerFunc
对于timerFunc
函数的定义,前面讲述的有,所以我们不在赘述。在这里我们就以setTimeout()
这种情况来具体分析。
-
setTimeout
:如果我们使用
setTimeout()
来进行渲染更新,我们会发现,当我们修改属性值的时候它会做一系列的派发通知操作,但是执行到setTimeout(flushSchedulerQueue)
的时候就停止了。当所有的Vue
代码执行完后再回来去执行宏队列中的flushSchedulerQueue
函数。也就是说DOM
更新其实是异步的。也就是说它把我们更新的任务放到了宏队列中了。我们来举个例子:<template> <div @click="change"> {{meaage}} </div> </template> <script> export default { name:'App', data(){ return { message:'Hello World' } }, methods:{ change(){ this.message = 'Hello Vue' console.log(this.$refs.message.innnerText) } } } </script>
我们再子组件中定义了一个
change
函数。那么此时结果是什么?答案是Hello World
。可能会有人有疑问,为什么不是Hello Vue
。其实原因很简单,那就是当我们this.message = Hello Vue
。的时候会触发属性的setter
函数。进而触发其他的例如dep.notify
函数去通知watcher
更新,但是它执行的时候遇到了nextTick()
中的setTimeout()
函数,那么此时就把回调函数放到宏队列后就不管了。继续执行我们的后续代码console.log(this.$refs.message.innnerText)
。请问此时更新了吗,没有!因为更新的函数还在我们的宏队列中排队呢。所以我们访问的值还是更新前的。直到所有change
的任务执行完了,那么才开始从宏队列中拿出我们的更新函数来更新DOM
。这就是为什么说Vue
的DOM
更新是异步的。那么问题来了,我们如何去在
change
中拿到更新后的数据呢?答案是使用vm.$nextTick()
函数。假如我们更改一下函数:methods:{ change(){ this.message = 'Hello Vue' console.log(this.$refs.message.innnerText) this.$nextTick(function(){ console.log(this.$refs.message.innnerText) }) } } //结果: 'Hello World' 'Hello Vue'
为什么是这样呢?我们前面说过
vm.$nextTick
内部调用的也是nextTick()
函数。所以当我们修改message
值的时候,更新的函数被放到了宏队列当中,当执行到this.$nextTick(function(){ console.log(this.$refs.message.innnerText) })
的时候
functioin(){}
函数也会被放到宏队列当中,但是是在更新函数之后。第一次访问因为更新函数没有执行然后输出'Hello World'
。当我们执行完change
后就会从宏队列中取出更新函数执行,此时DOM
被更新了。当更新完后再从宏队列中拿出function(){}
执行,而此时的message已经是更新后的,所以输出Hello Vue
。 -
Promise.resolve().then()
:nextTick()
其实还有该函数,他的特点在于它是把我们的渲染函数放到微队列当中的。数组的响应式处理
该源码分析出自Vue技术内幕。因为该文章将的很好,所以将此引用。建议大家去读其发表的相关文章。
回到 Observer
类的 constructor
函数,找到如下代码:
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
在 if
条件语句中,使用 Array.isArray
函数检测被观测的值 value
是否是数组,如果是数组则会执行 if
语句块内的代码,从而实现对数组的观测。处理数组的方式与纯对象不同,我们知道数组是一个特殊的数据结构,它有很多实例方法,并且有些方法会改变数组自身的值,我们称其为变异方法,这些方法有:push
、pop
、shift
、unshift
、splice
、sort
以及 reverse
等。这个时候我们就要考虑一件事,即当用户调用这些变异方法改变数组时需要触发依赖。换句话说我们需要知道开发者何时调用了这些变异方法,只有这样我们才有可能在这些方法被调用时做出反应。那么Vue
是怎么处理这些变异方法的呢?
拦截数组变异方法的思路
那么怎么样才能知道开发者何时调用了数组的变异方法呢?其实很简单,我们来思考这样一个问题,如下代码中 sayHello
函数用来打印字符串 'hello'
:
function sayHello () {
console.log('hello')
}
但是我们有这样一个需求,在不改动 sayHello
函数源码的情况下,在打印字符串 'hello'
之前先输出字符串 'Hi'
。这时候我们可以这样做:
const originalSayHello = sayHello
sayHello = function () {
console.log('Hi')
originalSayHello()
}
有点类似于AOP编程
看,这样就完美地实现了我们的需求,首先使用 originalSayHello
变量缓存原来的 sayHello
函数,然后重新定义 sayHello
函数,并在新定义的 sayHello
函数中调用缓存下来的 originalSayHello
。这样我们就保证了在不改变 sayHello
函数行为的前提下对其进行了功能扩展。
这其实是一个很通用也很常见的技巧,而 Vue
正是通过这个技巧实现了对数据变异方法的拦截,即保持数组变异方法原有功能不变的前提下对其进行功能扩展。
数组本身也是一个对象,所以它实例的 __proto__
属性指向的就是数组构造函数的原型,即 arr.__proto__ === Array.prototype
为真。我们的一个思路是通过设置 __proto__
属性的值为一个新的对象,且该新对象的原型是数组构造函数原来的原型对象 。
我们知道数组本身也是一个对象,既然是对象那么当然可以访问其 __proto__
属性,数组实例的 __proto__
属性指向了 arrayMethods
对象,同时 arrayMethods
对象的 __proto__
属性指向了真正的数组原型对象。并且 arrayMethods
对象上定义了与数组变异方法同名的函数,这样当通过数组实例调用变异方法时,首先执行的是 arrayMethods
上的同名函数,这样就能够实现对数组变异方法的拦截。
const arrayProto = Array.prototype//获取数组的原型
export const arrayMethods = Object.create(arrayProto)//创建arrayMethods对象,该对象的原型是arrayProto
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
const arrayMethods = Object.create(Array.prototype) // 实现 arrayMethods.__proto__ === Array.prototype
const arrayProto = Array.prototype // 缓存 Array.prototype
mutationMethods.forEach(method => {
arrayMethods[method] = function (...args) {
const result = arrayProto[method].apply(this, args)
console.log(`执行了代理原型的 ${method} 函数`)
return result
}
})
如上代码所示,我们通过 Object.create(Array.prototype)
创建了 arrayMethods
对象,这样就保证了 arrayMethods.__proto__ === Array.prototype
。然后通过一个循环在 arrayMethods
对象上定义了与数组变异方法同名的函数,并在这些函数内调用了真正数组原型上的相应方法。我们可以测试一下,如下代码:
const arr = []
arr.__proto__ = arrayMethods
arr.push(1)
可以发现控制台中打印了一句话:执行了代理原型的 push 函数
。很完美,但是这实际上是存在问题的,因为 __proto__
属性是在 IE11+
才开始支持,所以如果是低版本的 IE
怎么办?比如 IE9/10
,所以出于兼容考虑,我们需要做能力检测,如果当前环境支持 __proto__
时我们就采用上述方式来实现对数组变异方法的拦截,如果当前环境不支持 __proto__
那我们就需要另想办法了,接下来我们就介绍一下兼容的处理方案。
实际上兼容的方案有很多,其中一个比较好的方案是直接在数组实例上定义与变异方法同名的函数,如下代码:
const arr = []
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
arrayKeys.forEach(method => {
arr[method] = arrayMethods[method]
})
上面代码中,我们通过 Object.getOwnPropertyNames
函数获取所有属于 arrayMethods
对象自身的键,然后通过一个循环在数组实例上定义与变异方法同名的函数,这样当我们尝试调用 arr.push()
时,首先执行的是定义在数组实例上的 push
函数,也就是 arrayMethods.push
函数。这样我们就实现了兼容版本的拦截。不过细心的同学可能已经注意到了,上面这种直接在数组实例上定义的属性是可枚举的,所以更好的做法是使用 Object.defineProperty
:
arrayKeys.forEach(method => {
Object.defineProperty(arr, method, {
enumerable: false,
writable: true,
configurable: true,
value: arrayMethods[method]
})
})
这样就完美了 。
拦截数组变异方法在 Vue 中的实现
我们已经了解了拦截数组变异方法的思路,接下来我们就可以具体的看一下 Vue
源码是如何实现的。在这个过程中我们会讲解数组是如何通过变异方法触发依赖(观察者
)的。
我们回到 Observer
类的 constructor
函数:
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
this.walk(value)
}
}
首先大家注意一点:无论是对象还是数组,都将通过 def
函数为其定义 __ob__
属性。接着我们来看一下 if
语句块的内容,如果被观测的值是一个数组,那么 if
语句块内的代码将被执行,即如下代码:
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
首先定义了 augment
常量,这个常量的值根据 hasProto
的真假而定,如果 hasProto
为真则 augment
的值为 protoAugment
,否则值为 copyAugment
。那么 hasProto
是什么呢?其实 hasProto
是一个布尔值,它用来检测当前环境是否可以使用 __proto__
属性,如果 hasProto
为真则当前环境支持 __proto__
属性,否则意味着当前环境不能够使用 __proto__
属性。
如果当前环境支持使用 __proto__
属性,那么 augment
的值是 protoAugment
,其中 protoAugment
就定义在 Observer
类的下方。源码如下:
/**
* Augment an target Object or Array by intercepting
* the prototype chain using __proto__
*/
function protoAugment (target, src: Object, keys: any) {
/* eslint-disable no-proto */
target.__proto__ = src
/* eslint-enable no-proto */
}
那么 protoAugment
函数的作用是什么呢?相信大家已经猜到了,正如我们在讲解拦截数据变异方法的思路中所说的那样,可以通过设置数组实例的 __proto__
属性,让其指向一个代理原型,从而做到拦截。我们看一下 protoAugment
函数是如何被调用的:
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
当 hasProto
为真时,augment
引用的就是 protoAugment
函数,所以调用 augment
函数等价于调用 protoAugment
函数,可以看到传递给 protoAugment
函数的参数有三个。第一个参数是 value
,其实就是数组实例本身;第二个参数是 arrayMethods
,这里的 arrayMethods
与我们在拦截数据变异方法的思路中所讲解的 arrayMethods
是一样的,它就是代理原型;第三个参数是 arrayKeys
,我们可以在 src/core/observer/array.js
文件中找到这样一行代码:
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
其实 arrayKeys
是一个包含了所有定义在 arrayMethods
对象上的 key
,其实也就是所有我们要拦截的数组变异方法的名字:
arrayKeys = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
但实际上 protoAugment
函数虽然接收三个参数,但它并没有使用第三个参数。可能有的同学会问为什么 protoAugment
函数没有使用第三个参数却依然声明了第三个参数呢?原因是为了让 flow
更好地工作。
我们回到 protoAugment
函数,如下:
/**
* Augment an target Object or Array by intercepting
* the prototype chain using __proto__
*/
function protoAugment (target, src: Object, keys: any) {
/* eslint-disable no-proto */
target.__proto__ = src
/* eslint-enable no-proto */
}
该函数的函数体只有一行代码:target.__proto__ = src
。这行代码用来将数组实例的原型指向代理原型(arrayMethods
)。下面我们具体看一下 arrayMethods
是如何实现的。打开 src/core/observer/array.js
文件:
/*
* not type checking this file because flow doesn't play well with
* dynamically accessing methods on Array prototype
*/
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)
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
ob.dep.notify()
return result
})
})
如上是 src/core/observer/array.js
文件的全部代码,该文件只做了一件事情,那就是导出 arrayMethods
对象:
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
可以发现,arrayMethods
对象的原型是真正的数组构造函数的原型。接着定义了 methodsToPatch
常量:
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch
常量是一个数组,包含了所有需要拦截的数组变异方法的名字。再往下是一个 forEach
循环,用来遍历 methodsToPatch
数组。该循环的主要目的就是使用 def
函数在 arrayMethods
对象上定义与数组变异方法同名的函数,从而做到拦截的目的,如下是简化后的代码:
//这个是数组拦截的整个核心
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)//改变了数据
const ob = this.__ob__
// 省略中间部分...
// notify change
ob.dep.notify()//通知重新渲染页面
return result
})
})
上面的代码中,首先缓存了数组原本的变异方法:
const original = arrayProto[method]
然后使用 def
函数在 arrayMethods
上定义与数组变异方法同名的函数,在函数体内优先调用了缓存下来的数组变异方法:
const result = original.apply(this, args)
并将数组原本变异方法的返回值赋值给 result
常量,并且我们发现函数体的最后一行代码将 result
作为返回值返回。这就保证了拦截函数的功能与数组原本变异方法的功能是一致的。
关键要注意这两句代码:
const ob = this.__ob__
// 省略中间部分...
// notify change
ob.dep.notify()
定义了 ob
常量,它是 this.__ob__
的引用,其中 this
其实就是数组实例本身,我们知道无论是数组还是对象,都将会被定义一个 __ob__
属性,并且 __ob__.dep
中收集了所有该对象(或数组)的依赖(观察者)。所以上面两句代码的目的其实很简单,当调用数组变异方法时,必然修改了数组,所以这个时候需要将该数组的所有依赖(观察者)全部拿出来执行,即:ob.dep.notify()
。
注意上面的讲解中我们省略了中间部分,那么这部分代码的作用是什么呢?如下:
def(arrayMethods, method, function mutator (...args) {
// 省略...
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// 省略...
})
首先我们需要思考一下数组变异方法对数组的影响是什么?无非是 增加元素、删除元素 以及 变更元素顺序。有的同学可能会说还有 替换元素,实际上替换可以理解为删除和增加的复合操作。那么在这些变更中,我们需要重点关注的是 增加元素 的操作,即 push
、unshift
和 splice
,这三个变异方法都可以为数组添加新的元素,那么为什么要重点关注呢?原因很简单,因为新增加的元素是非响应式的,所以我们需要获取到这些新元素,并将其变为响应式数据才行,而这就是上面代码的目的。下面我们看一下具体实现,首先定义了 inserted
变量,这个变量用来保存那些被新添加进来的数组元素:let inserted
。接着是一个 switch
语句,在 switch
语句中,当遇到 push
和 unshift
操作时,那么新增的元素实际上就是传递给这两个方法的参数,所以可以直接将 inserted
的值设置为 args
:inserted = args
。当遇到 splice
操作时,我们知道 splice
函数从第三个参数开始到最后一个参数都是数组的新增元素,所以直接使用 args.slice(2)
作为 inserted
的值即可。最后 inserted
变量中所保存的就是新增的数组元素,我们只需要调用 observeArray
函数对其进行观测即可:
if (inserted) ob.observeArray(inserted)
以上是在当前环境支持 __proto__
属性的情况,如果不支持则 augment
的值为 copyAugment
函数,copyAugment
定义在 protoAugment
函数的下方:
/**
* Augment an target Object or Array by defining
* hidden properties.
*/
/* istanbul ignore next */
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
copyAugment
函数接收的参数与 protoAugment
函数相同,不同的是 copyAugment
使用到了全部三个参数。在拦截数组变异方法的思路一节中我们讲解了在当前环境不支持 __proto__
属性的时候如何做兼容处理,实际上这就是 copyAugment
函数的作用。
我们知道 copyAugment
函数的第三个参数 keys
就是定义在 arrayMethods
对象上的所有函数的键,即所有要拦截的数组变异方法的名称。这样通过 for
循环对其进行遍历,并使用 def
函数在数组实例上定义与数组变异方法同名的且不可枚举的函数,这样就实现了拦截操作。
总之无论是 protoAugment
函数还是 copyAugment
函数,他们的目的只有一个:把数组实例与代理原型或与代理原型中定义的函数联系起来,从而拦截数组变异方法。下面我们再回到 Observer
类的 constructor
函数中,看如下代码:
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
// 省略...
}
可以发现在 augment
函数调用语句之后,还以该数组实例作为参数调用了 Observer
实例对象的 observeArray
方法:
this.observeArray(value)
这句话的作用是什么呢?或者说 observeArray
方法的作用是什么呢?我们知道,当被观测的数据(value
)是数组时,会执行 if
语句块的代码,并调用 augment
函数从而拦截数组的变异方法,这样当我们尝试通过这些变异方法修改数组时是会触发相应的依赖(观察者
)的,比如下面的代码:
const ins = new Vue({
data: {
arr: [1, 2]
}
})
ins.arr.push(3) // 能够触发响应
但是如果数组中嵌套了其他的数组或对象,那么嵌套的数组或对象却不是响应的:
const ins = new Vue({
data: {
arr: [
[1, 2]
]
}
})
ins.arr.push(1) // 能够触发响应
ins.arr[0].push(3) // 不能触发响应
上面的代码中,直接调用 arr
数组的 push
方法是能够触发响应的,但调用 arr
数组内嵌套数组的 push
方法是不能触发响应的。为了使嵌套的数组或对象同样是响应式数据,我们需要递归的观测那些类型为数组或对象的数组元素,而这就是 observeArray
方法的作用,如下是 observeArray
方法的全部代码:
/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
可以发现 observeArray
方法的实现很简单,只需要对数组进行遍历,并对数组元素逐个应用 observe
工厂函数即可,这样就会递归观测数组元素了。
自己的总结:
上面对数组的讲解其实很全面了,但是这里我想添加一下自己的想法。我们以一个实例为切入点来讲解数组的响应式原理。
data(){
return {
messsage:['xz']
}
}
这是一个组件数据源,我们的message
是一个数组,里面只有一个元素'xz'
。那么我们从源码执行的角度来看一下它是如何进行响应式处理的。我们先来看前期的响应式流程:
initState() --> initData() --> observe() --> new Observer() .... --> definePrototype()
当执行到defineReactive()
函数的时候,会执行下面这行代码:
let childOb = !shallow && observe(val)
其实每一个属性的属性值都会走这个程序,但是因为不是对象或者数组,所以它们不产生__ob__
这个属性。所以原始值是无关紧要的。但是假如我们传入的值是一个对象或者数组就不一样了。例如message
。它的值是一个数组,所以当执行到该代码的时候会深入的向下走。再次进入observe
函数之后,又会执行Observer
构造函数,此时value
是一个数组,所以会执行this.observeArray(value)
。来到我们的observeArray()
函数的时候。
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
其实该函数就是把数组中的每一个元素都observe
一遍。就以我们的'xz'
为例,此时进入到observe
后基本上没有做什么。那么此时就有人问了。对象属性是使用getter
来收集依赖的,那么数组是怎么收集依赖的呢?其实很简单,也是用getter
来收集依赖。
let arr = [1,2]
Object.defineProperty(arr,'0',{
get(){
console.log('你访问了数组的第一个元素')
}
})
arr[0]
node .\promise.js
你访问了数组的第一个元素
可能有同学就不明白了,为什么?我们来举一个例子,假如说我们在项目的某个地方引用了message
这个数组中的值,那么它一定是怎么引用的?一定是类似于这样:this.message[0]
。那么此时问题就来了,既然你这样引用,那么本身就已经触发了message
这个属性的getter
函数。因为message
本身也有自己的dep
。这里要明白,只要是属性,那么它一定会有自己的getter/setter/dep
。不论它是原始值还是引用值。为什么呢?因为有可能我会这样:this.message = 123
。此时把它的和值变为123
。那么此时我们的Vue
也需要更新呀,所以它自身就有一个dep
。当message
进行Observer
之后会有一个属性,为__ob__
。该属性的值就是observer
实例(对象也有)。该实例内部有一个dep
的属性,而这个属性是用来存放message
数组的依赖的。这个dep
也就是我们上面说的那个它自身的dep
。具体代码如下:
this.value = value
this.dep = new Dep()//为message属性的observer实例创建一个dep,该dep用来存放引用message数组的watchers
this.vmCount = 0
def(value, '__ob__', this)//将observer实例挂载到message属性身上,属性名为`__ob__`。
此时问题就来了,我们现在知道了依赖应该放在哪里,那么我们如何收集数组的依赖。例如:this.message[0]
。我们如何收集这个依赖呢?当我们this.message[0]
的时候,那么就访问到了this.messhae
的getter
,让我们来看看该getter
做了什么?
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
}
很显然,首先获取到我们的value
。此时的值是一个数组。假如现在Dep.target === watcher_1
。然后调用dep.depend
。这个是收集调用this.message
的依赖,其实和调用this.message[0]
是有一点小的差别,就像我们调用this.a.b.c
。其实中间收集到了三个dep
。分别是this.a/this.a.b/this.a.b.c
的。但后它判断的是childOb
。我们知道,只有值是数组或者对象的时候才会有childOb
这个属性值。很明显,我们的值有这个属性,额然后调用childOb.dep.depend()
。我们前面说了observer
实例对象有dep
这个属性,该属性是用来存储访问该数组值的watcher
,例如:watcher_1
。然后后面的操作和本章早期讲的响应式一样,就是调用updateComponent
函数进行重新更新,然后就可以访问到我们的this.message[0]
的值。
当我们想要更新数组中的值的时候,我们应该怎么办呢?我们不能使用this.message[0] = 123
的方式,为什么?有人说,这样明明可以触发setter
函数的呀。其实这样想就出错了。其实很简单,如果触发了setter
函数,请问是触发了谁的setter
函数,是this.message
的?还是xz
的?首先排除xz
。因为不是对象,所以根本没有setter
函数,那么是this.message
的吗?很显然也不是,只有this.message = 123
这样才会触发setter
函数,很显然我们没有这样做,那么问题就很显而易见了,虽然我们更新了数据,但是没有通知Vue
重新渲染更新,所以我们看到的仍然是旧的数据,那么问题就来了,现在我们的数据已经改变了,怎么才能触发更新呢?答案就是使用官方给我们提供的方法:Vue.set(this.message,0,123)
。该方法不仅能够修改数据,还能够让Vue
重新更新视图。而至于Vue.set()
是怎么做到的。我们前面其实已经讲过了。这里不在赘述。其实如果我们想用变异方法也可以直接修改,它仍然后重新渲染。原理是什么上面也介绍的有。
但是我们来看一些具体的细节,走一个流程。流程如下:
Vue.set() -> set() -> target.splice(key:0,1,val)
。注意,此时我们调用splice()
不是原生的,而是经过Vue
处理后的splice()
编译方法。接着:
original.apply()
。这里有一行代码:const ob = this.__ob__
。这里的this
就是我们的target
即this.message
。获取其__ob__
属性。然后它定义了一个inserted
变量,用来存储我们替换的元素。然后它会执行ob.observeArray(inserted)
如果我们添加的是原始数据的话,其实没有任何作用。然后直接调用ob.de p.notify
进行通知更新。但是如果我们把元素替换成对象或者数组的话,那么就会需要将对象或者数组进行响应式处理。
其实Vue
这样做无非就是让我们的页面展示真正的数据,假如我们this.message[0] = 123
的话,内部的数据变了了吗,变了。但是我们的视图没有更新,此时需要用一系列的操作让我们的视图也更新为最新的数据。