共计 8989 个字符,预计需要花费 23 分钟才能阅读完成。
Vue 的外围之一就是响应式零碎,通过侦测数据的变动,来驱动更新视图。
实现可响应对象的形式
通过可响应对象,实现对数据的侦测,从而告知外界数据变动。实现可响应对象的形式:
- getter 和 setter
- defineProperty
- Proxy
对于前两个 API 的应用形式不多赘述,繁多的拜访器 getter/setter
性能绝对简略,而作为 Vue2.x 实现可响应对象的 API – defineProperty
,
API 自身存在较多问题。
Vue2.x 中,实现数据的可响应,须要对 Object
和 Array
两种类型采纳不同的解决形式。Object
类型通过 Object.defineProperty
将属性转换成 getter/setter
,这个过程须要递归侦测所有的对象 key
,来实现深度的侦测。
为了感知 Array
的变动,对 Array
原型上几个扭转数组本身的内容的办法做了拦挡,尽管实现了对数组的可响应,但同样存在一些问题,或者说不够不便的状况。
同时,defineProperty
通过递归实现 getter/setter
也存在肯定的性能问题。
更好的实现形式是通过 ES6
提供的 Proxy API
。
Proxy API 的一些细节
Proxy API 具备更加弱小的性能,
相比旧的 defineProperty
API,Proxy
能够代理数组,并且 API 提供了多个 traps
,能够实现诸多性能。
这里次要说两个 trap: get
、set
,以及其中的一些比拟容易被疏忽的细节。
细节一:trap 默认行为
let data = {foo: 'foo'}
let p = new Proxy(data, {get(target, key, receiver) {return target[key]
},
set(target, key, value, receiver) {console.log('set value')
target[key] = value // ?
}
})
p.foo = 123
// set value
通过 proxy
返回的对象 p
代理了对原始数据的操作,当对 p
设置时,便能够侦测到变动。然而这么写实际上是有问题,
当代理的对象数据是数组时,会报错。
let data = [1,2,3]
let p = new Proxy(data, {get(target, key, receiver) {return target[key]
},
set(target, key, value, receiver) {console.log('set value')
target[key] = value
}
})
p.push(4) // VM438:12 Uncaught TypeError: 'set' on proxy: trap returned falsish for property '3'
将代码更改为:
let data = [1,2,3]
let p = new Proxy(data, {get(target, key, receiver) {return target[key]
},
set(target, key, value, receiver) {console.log('set value')
target[key] = value
return true
}
})
p.push(4)
// set value // 打印 2 次
实际上,当代理对象是数组,通过 push
操作,并不只是操作以后数据,push
操作还触发数组自身其余属性更改。参考 vue 实战视频解说:进入学习
let data = [1,2,3]
let p = new Proxy(data, {get(target, key, receiver) {console.log('get value:', key)
return target[key]
},
set(target, key, value, receiver) {console.log('set value:', key, value)
target[key] = value
return true
}
})
p.push(1)
// get value: push
// get value: length
// set value: 3 1
// set value: length 4
先看 set
操作,从打印输出能够看出,push
操作除了给数组的第 3
位下标设置值 1
,还给数组的 length
值更改为 4
。同时这个操作还触发了 get 去获取 push
和 length
两个属性。
咱们能够 通过 Reflect
来返回 trap 相应的默认行为,对于 set 操作绝对简略,然而一些比较复杂的默认行为解决起来绝对繁琐得多,Reflect
的作用就显现出来了。
let data = [1,2,3]
let p = new Proxy(data, {get(target, key, receiver) {console.log('get value:', key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {console.log('set value:', key, value)
return Reflect.set(target, key, value, receiver)
}
})
p.push(1)
// get value: push
// get value: length
// set value: 3 1
// set value: length 4
相比本人解决 set
的默认行为,Reflect
就不便得多。
细节二:屡次触发 set / get
从后面的例子中能够看出,当代理对象是数组时,push
操作会触发屡次 set
执行,同时,也引发 get
操作,这点十分重要,vue3 就很好的应用了这点。
咱们能够从另一个例子来看这个操作:
let data = [1,2,3]
let p = new Proxy(data, {get(target, key, receiver) {console.log('get value:', key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {console.log('set value:', key, value)
return Reflect.set(target, key, value, receiver)
}
})
p.unshift('a')
// get value: unshift
// get value: length
// get value: 2
// set value: 3 3
// get value: 1
// set value: 2 2
// get value: 0
// set value: 1 1
// set value: 0 a
// set value: length 4
能够看到,在对数组做 unshift
操作时,会屡次触发 get
和 set
。
仔细观察输入,不难看出,get
先拿数组最末位下标,开拓新的下标 3
寄存原有的末位数值,而后再将原数值都往后挪,将 0
下标设置为了 unshift
的值 a
,由此引发了屡次 set
操作。
而这对于 告诉内部操作 显然是不利,咱们假如 set
中的 console
是触发外界渲染的 render
函数,那么这个 unshift
操作会引发 屡次 render
。
咱们前面会讲述如何解决相应的这个问题,持续。
细节三:proxy 只能代理一层
let data = {foo: 'foo', bar: { key: 1}, ary: ['a', 'b'] }
let p = new Proxy(data, {get(target, key, receiver) {console.log('get value:', key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {console.log('set value:', key, value)
return Reflect.set(target, key, value, receiver)
}
})
p.bar.key = 2
// get value: bar
执行代码,能够看到并没有触发 set
的输入,反而是触发了 get
,因为 set
的过程中拜访了 bar
这个属性。
由此可见,proxy
代理的对象只能代理到第一层,而对象外部的深度侦测,是须要开发者本人实现的。同样的,对于对象外部的数组也是一样。
p.ary.push('c')
// get value: ary
同样只走了 get
操作,set
并不能感知到。
咱们留神到 get/set
还有一个参数:receiver
,对于 receiver
,其实接管的是一个代理对象:
let data = {a: {b: {c: 1} } }
let p = new Proxy(data, {get(target, key, receiver) {console.log(receiver)
const res = Reflect.get(target, key, receiver)
return res
},
set(target, key, value, receiver) {return Reflect.set(target, key, value, receiver)
}
})
// Proxy {a: {…}}
这里 receiver
输入的是以后代理对象,留神,这是一个曾经代理后的对象。
let data = {a: {b: {c: 1} } }
let p = new Proxy(data, {get(target, key, receiver) {const res = Reflect.get(target, key, receiver)
console.log(res)
return res
},
set(target, key, value, receiver) {return Reflect.set(target, key, value, receiver)
}
})
// {b: {c: 1} }
当咱们尝试输入 Reflect.get
返回的值,会发现,当代理的对象是多层构造时,Reflect.get
会返回对象的内层构造。
记住这一点,Vue3 实现深度的 proxy,便是很好的应用了这点。
解决 proxy 中的细节问题
后面提到了应用 Proxy
来侦测数据变动,有几个细节问题,包含:
- 应用
Reflect
来返回trap
默认行为 - 对于
set
操作,可能会引发代理对象的属性更改,导致set
执行屡次 proxy
只能代理对象中的一层,对于对象外部的操作set
未能感知,然而get
会被执行
接下来,咱们将 先本人尝试解决这些问题,前面再剖析 Vue3 是如何解决这些细节的。
setTimeout 解决反复 trigger
function reactive(data, cb) {
let timer = null
return new Proxy(data, {get(target, key, receiver) {return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {clearTimeout(timer)
timer = setTimeout(() => {cb && cb()
}, 0);
return Reflect.set(target, key, value, receiver)
}
})
}
let ary = [1, 2]
let p = reactive(ary, () => {console.log('trigger')
})
p.push(3)
// trigger
程序输入后果为一个:trigger
这里实现了 reactive
函数,接管两个参数,第一个是被代理的数据 data
,还有一个回调函数 cb
,
咱们这里先简略的在 cb
中打印 trigger 操作,来模仿告诉内部数据的变动。
解决反复的 cb
调用有很多中形式,比如通过标记,来决定是否调用。而这里是应用了定时器 setTimeout
,
每次调用 cb
之前,都革除定时器,来实现相似于 debounce
的操作,同样能够解决反复的 callback
问题。
解决数据深度侦测
目前还有一个问题,那便是深度的数据侦测,咱们能够应用递归代理的形式来实现:
function reactive(data, cb) {
let res = null
let timer = null
res = data instanceof Array ? []: {}
for (let key in data) {if (typeof data[key] === 'object') {res[key] = reactive(data[key], cb)
} else {res[key] = data[key]
}
}
return new Proxy(res, {get(target, key) {return Reflect.get(target, key)
},
set(target, key, val) {let res = Reflect.set(target, key, val)
clearTimeout(timer)
timer = setTimeout(() => {cb && cb()
}, 0)
return res
}
})
}
let data = {foo: 'foo', bar: [1, 2] }
let p = reactive(data, () => {console.log('trigger')
})
p.bar.push(3)
// trigger
对代理的对象进行遍历,对每个 key
都做一次 proxy
,这是递归实现的形式。
同时,联合后面提到的 timer
防止反复 set 的问题。
这里咱们能够输入代理后的对象 p
:
能够看到深度代理后的对象,都携带 proxy
的标记。
到这里,咱们解决了应用 proxy
实现侦测的系列细节问题,尽管这些解决形式能够解决问题,但仿佛并不够优雅,尤其是递归 proxy
是一个性能隐患,
当数据对象比拟大时,递归的 proxy 会耗费比拟大的性能,并且有些数据并非须要侦测,咱们须要对数据侦测做更细的管制。
接下来咱们就看下 Vue3 是如何应用 Proxy
实现数据侦测的。
Vue3 中的 reactivity
Vue3 我的项目构造采纳了 lerna 做 monorepo
格调的代码治理,目前比拟多的开源我的项目切换到了 monorepo 的模式,
比较显著的特色是我的项目中会有个 packages/
的文件夹。
Vue3 对性能做了很好的模块划分,同时应用 TS。咱们间接在 packages 中找到响应式数据的模块:
其中,reactive.ts
文件提供了 reactive
函数,该函数是实现响应式的外围。
同时这个函数也挂载在了全局的 Vue 对象上。
这里对源代码做一点水平的简化:
const rawToReactive = new WeakMap()
const reactiveToRaw = new WeakMap()
// utils
function isObject(val) {return typeof val === 'object'}
function hasOwn(val, key) {
const hasOwnProperty = Object.prototype.hasOwnProperty
return hasOwnProperty.call(val, key)
}
// traps
function createGetter() {return function get(target, key, receiver) {const res = Reflect.get(target, key, receiver)
return isObject(res) ? reactive(res) : res
}
}
function set(target, key, val, receiver) {const hadKey = hasOwn(target, key)
const oldValue = target[key]
val = reactiveToRaw.get(val) || val
const result = Reflect.set(target, key, val, receiver)
if (!hadKey) {console.log('trigger ...')
} else if(val !== oldValue) {console.log('trigger ...')
}
return result
}
// handler
const mutableHandlers = {get: createGetter(),
set: set,
}
// entry
function reactive(target) {
return createReactiveObject(
target,
rawToReactive,
reactiveToRaw,
mutableHandlers,
)
}
function createReactiveObject(target, toProxy, toRaw, baseHandlers) {let observed = toProxy.get(target)
// 原数据曾经有相应的可响应数据, 返回可响应数据
if (observed !== void 0) {return observed}
// 原数据曾经是可响应数据
if (toRaw.has(target)) {return target}
observed = new Proxy(target, baseHandlers)
toProxy.set(target, observed)
toRaw.set(observed, target)
return observed
}
rawToReactive
和 reactiveToRaw
是两个弱援用的 Map
构造,这两个 Map
用来保留 原始数据
和 可响应数据
,在函数 createReactiveObject
中,toProxy
和 toRaw
传入的便是这两个 Map
。
咱们能够通过它们,找到任何代理过的数据是否存在,以及通过代理数据找到原始的数据。
除了保留了代理的数据和原始数据,createReactiveObject
函数仅仅是返回了 new Proxy
代理后的对象。
重点在 new Proxy
中传入的 handler 参数 baseHandlers
。
还记得后面提到的 Proxy
实现数据侦测的细节问题吧,咱们尝试输出:
let data = {foo: 'foo', ary: [1, 2] }
let r = reactive(data)
r.ary.push(3)
打印后果:
能够看到打印输出了一次 trigger ...
问题一:如何做到深度的侦测数据的?
深度侦测数据是通过 createGetter
函数实现的,后面提到,当对多层级的对象操作时,set
并不能感知到,然而 get 会触发 ,
于此同时,利用 Reflect.get()
返回的“多层级对象中内层”,再对“内层数据”做一次代理。
function createGetter() {return function get(target, key, receiver) {const res = Reflect.get(target, key, receiver)
return isObject(res) ? reactive(res) : res
}
}
能够看到这里 判断了 Reflect 返回的数据是否还是对象 ,如果是对象,则再走一次 proxy
, 从而取得了对对象外部的侦测。
并且,每一次的 proxy
数据,都会保留在 Map
中,拜访时会间接从中查找,从而进步性能。
当咱们打印代理后的对象时:
能够看到这个代理后的对象内层并没有代理的标记,这里仅仅是代理外层对象。
输入其中一个存储代理数据的 rawToReactive
:
对于内层 ary: [1, 2]
的代理,曾经被存储在了 rawToReactive
中。
由此实现了深度的数据侦测。
问题二:如何防止屡次 trigger?
function hasOwn(val, key) {
const hasOwnProperty = Object.prototype.hasOwnProperty
return hasOwnProperty.call(val, key)
}
function set(target, key, val, receiver) {console.log(target, key, val)
const hadKey = hasOwn(target, key)
const oldValue = target[key]
val = reactiveToRaw.get(val) || val
const result = Reflect.set(target, key, val, receiver)
if (!hadKey) {console.log('trigger ... is a add OperationType')
} else if(val !== oldValue) {console.log('trigger ... is a set OperationType')
}
return result
}
对于屡次 trigger
的问题,vue 解决得很奇妙。
在 set
函数中 hasOwn
前打印 console.log(target, key, val)
。
输出:
let data = ['a', 'b']
let r = reactive(data)
r.push('c')
输入后果:
r.push('c')
会触发 set
执行两次,一次是值自身 'c'
,一次是 length
属性设置。
设置值 'c'
时,传入的新增索引 key
为 2
,target
是原始的代理对象 ['a', 'c']
,hasOwn(target, key)
显然返回 false
,这是一个新增的操作,此时能够执行 trigger ... is a add OperationType
。
当传入 key
为 length
时,hasOwn(target, key)
,length
是本身属性,返回 true
,此时判断 val !== oldValue
, val
是 3
, 而 oldValue
即为 target['length']
也是 3
,此时不执行 trigger
输入语句。
所以通过 判断 key 是否为 target 本身属性,以及设置 val 是否跟 target[key]相等 能够确定 trigger
的类型,并且防止多余的 trigger
。
总结
实际上本文次要集中解说 Vue3 中是如何应用 Proxy
来侦测数据的。
而在剖析源码之前,须要讲清楚 Proxy
自身的一些个性,所以讲了很多 Proxy
的前置常识。同时,咱们也通过本人的形式来解决这些问题。
最初,咱们比照了 Vue3 中,
是如何解决这些细节的。能够看出,Vue3 并非简略的通过 Proxy
来递归侦测数据,
而是通过 get
操作来实现外部数据的代理,并且联合 WeakMap
来对数据保留,这将大大提高响应式数据的性能。
有趣味的小伙伴能够针对 递归 Proxy 和 这种 Vue3 的这种实现形式做相应的 benchmark,
这两者的性能差距比拟大。
文章还是对 reactive
做了很大水平的简化,实际上要解决的细节要简单得多