vue3.0快发布了,也带来了很多新的特性,如新的监测设计,PWA,TS支持等,本节一起了解下新的监测原理。
旧的响应式原理
vue2利用Object.defineProperty来劫持data数据的getter和setter操作。这使得data在被访问或赋值时,动态更新绑定的template模块。
对象:会递归得去循环vue得每一个属性,(这也是浪费性能的地方)会给每个属性增加getter和setter,当属性发生变化的时候会更新视图。
数组:重写了数组的方法,当调用数组方法时会触发更新,也会对数组中的每一项进行监控。
缺点:对象只监控自带的属性,新增的属性不监控,也就不生效。若是后续需要这个自带属性,就要再初始化的时候给它一个undefined值,后续再改这个值
数组的索引发生变化或者数组的长度发生变化不会触发实体更新。可以监控引用数组中引用类型值,若是一个普通值并不会监控,例如:[1, 2, {a: 3}] ,只能监控a
Proxy消除了之前 Vue2.x 中基于 Object.defineProperty 的实现所存在的这些限制:无法监听 属性的添加和删除、数组索引和长度的变更,并可以支持 Map、Set、WeakMap 和 WeakSet!
Proxy及使用
MDN 上是这么描述的——Proxy
对象用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。
其实就是在对目标对象的操作之前提供了拦截,可以对外界的操作进行过滤和改写,修改某些操作的默认行为,这样我们可以不直接操作对象本身,而是通过操作对象的代理对象来间接来操作对象,达到预期的目的~
一个例子:
let obj = {
name:{name:'hhh'},
arr: ['吃','喝','玩']
}
//proxy兼容性差 可以代理13种方法 get set
//defineProperty 只对特定 的属性进行拦截
let handler = {
get (target,key) { //target就是obj key就是要取obj里面的哪个属性
console.log('收集依赖')
return target[key]
},
set (target,key,value) {
console.log('触发更新')
target[key] = value
}
}
let proxy = new Proxy(obj,handler)
//通过代理后的对象取值和设置值
proxy.arr //收集依赖
proxy.name = '123' //触发更新
定义了一个对象obj,通过代理后的对象(上面的proxy)来操作原对象。当取值的时候会走get方法,返回对应的值,当设置值的时候会走set方法,触发更新。
但这是老的写法,新的写法是使用Reflect。
Reflect是内置对象,为操作对象而提供的新API,将Object对象的属于语言内部的方法放到Reflect对象上,即从Reflect对象上拿Object对象内部方法。 如果出错将返回false
简单改写上面这个例子:
let handler = {
get (target,key) { //target就是obj key就是要取obj里面的哪个属性
console.log('收集依赖')
// return target[key]
//Reflect 反射 这个方法里面包含了很多api
return Reflect.get(target,key)
},
set (target,key,value) {
console.log('触发更新')
// target[key] = value //这种写法设置时如果不成功也不会报错 比如这个对象默认不可配置
Reflect.set(target,key,value)
}
}
let proxy = new Proxy(obj,handler)
//通过代理后的对象取值和设置值
proxy.arr //收集依赖
proxy.name.name //收集依赖(只有一个)
proxy.name = '123' //触发更新
效果依旧和上面一样。
但是有一个问题,这个对象是多层对象,它并不会取到里面的那个name的值。
这是因为之前Object.defineProperty方法是一开始就会对这个多层对象进行递归处理,所以可以拿到,而Proxy不会。它是懒代理。如果对这个对象里面的值进行代理就取不到值。就像上面我们只对name进行了代理,但并没有对name.name进行代理,所以他就取不到这个值,需要代理之后才能取到。
改进如下:
let obj = {
name:{name:'hhh'},
arr: ['吃','喝','玩']
}
//proxy兼容性差 可以代理13种方法 get set
//defineProperty 只对特定 的属性进行拦截
let handler = {
get (target,key) { //target就是obj key就是要取obj里面的哪个属性
console.log('收集依赖')
if(typeof target[key] === 'object' && target[key] !== null){
//递归代理,只有取到对应值的时候才会代理
return new Proxy(target[key],handler)
}
// return target[key]
//Reflect 反射 这个方法里面包含了很多api
return Reflect.get(target,key)
},
set (target,key,value) {
console.log('触发更新')
// target[key] = value //这种写法设置时如果不成功也不会报错 比如这个对象默认不可配置
Reflect.set(target,key,value)
}
}
let proxy = new Proxy(obj,handler)
proxy.arr //收集依赖
proxy.name.name //收集依赖(2个)
接下来看看数组的代理过程:
let obj = {
name:{name:'hhh'},
arr: ['吃','喝','玩']
}
//proxy兼容性差 可以代理13种方法 get set
//defineProperty 只对特定 的属性进行拦截
let handler = {
get (target,key) { //target就是obj key就是要取obj里面的哪个属性
console.log('收集依赖')
if(typeof target[key] === 'object' && target[key] !== null){
//递归代理,只有取到对应值的时候才会代理
return new Proxy(target[key],handler)
}
// return target[key]
//Reflect 反射 这个方法里面包含了很多api
return Reflect.get(target,key)
},
set (target,key,value) {
console.log('触发更新')
// target[key] = value //这种写法设置时如果不成功也不会报错 比如这个对象默认不可配置
return Reflect.set(target,key,value)
}
}
let proxy = new Proxy(obj,handler)
//通过代理后的对象取值和设置值
// proxy.name.name = '123' //设置值,取一次,设置一次
proxy.arr.push(456)
//输出
收集依赖 (proxy.arr)
收集依赖 (proxy.arr.push)
收集依赖 (proxy.arr.length)
触发更新 写入新值
触发更新 长度改变
proxy.arr[0]=456
//输出
收集依赖 (proxy.arr)
触发更新 写入新值
这里面它会走两次触发更新的操作,因为第一次需要修改数组的长度,第二次再把元素放进数组里。所以我们需要判断一下它是新增操作还是修改操作
判断新旧属性:
set (target,key,value) {
let oldValue = target[key]
console.log(key, oldValue, value)
if(!oldValue){
console.log('新增属性')
}else if(oldValue !== value){
console.log('修改属性')
}
return Reflect.set(target,key,value)
}
首先拿到它的旧值,如果这个值不存在就是新增,如果存在但不相等就是修改操作
vue3的响应式设计
Vue 3.0 的想法是引入灵感来自于 React Hook 的 Function-based API,作为主要的组件声明方式。
意思就是所有组件的初始状态、computed、watch、methods 都要在一个叫做 setup 的方法中定义,抛弃(暂时会继续兼容)原有的基于对象的组件声明方式。
组件装载
vue3.0用effect副作用钩子来代替vue2.0watcher。我们都知道在vue2.0中,有渲染watcher专门负责数据变化后的从新渲染视图。vue3.0改用effect来代替watcher达到同样的效果。
先简单介绍一下mountComponent流程,后面的文章会详细介绍mount阶段的
// 初始化组件
const mountComponent: MountComponentFn = (
...
) => {
/* 第一步: 创建component 实例 */
const instance: ComponentInternalInstance = (initialVNode.component = createComponentInstance(
...
))
/* 第二步 : TODO:初始化 初始化组件,建立proxy , 根据字符窜模版得到 */
setupComponent(instance)
/* 第三步:建立一个渲染effect,执行effect */
setupRenderEffect(
instance, // 组件实例
initialVNode, //vnode
container, // 容器元素
...
)
}
整个mountComponent的主要分为了三步,我们这里分别介绍一下每个步骤干了什么:
① 第一步: 创建component 实例 。
② 第二步:初始化组件,建立proxy ,根据字符窜模版得到render函数。生命周期钩子函数处理等等
③ 第三步:建立一个渲染effect,执行effect。
从如上方法中我们可以看到,在setupComponent已经构建了响应式对象,但是还没有初始化收集依赖。
setupRenderEffect 构建渲染effect
const setupRenderEffect: SetupRenderEffectFn = (
...
) => {
/* 创建一个渲染 effect */
instance.update = effect(function componentEffect() {
//...省去的内容后面会讲到
},{ scheduler: queueJob })
}
setupRenderEffect的作用
① 创建一个effect,并把它赋值给组件实例的update方法,作为渲染更新视图用。
② componentEffect作为回调函数形式传递给effect作为第一个参数
effect做了些什么
export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
const effect = createReactiveEffect(fn, options)
/* 如果不是懒加载 立即执行 effect函数 */
if (!options.lazy) {
effect()
}
return effect
}
effect作用如下
① 首先调用。createReactiveEffect.
② 如果不是懒加载 立即执行 由createReactiveEffect创建出来的ReactiveEffect函数。
接着看createReactiveEffect(这就是vue2.x中的Watcher)
function createReactiveEffect<T = any>(
fn: (...args: any[]) => T, /**回调函数 */
options: ReactiveEffectOptions
): ReactiveEffect<T> {
const effect = function reactiveEffect(...args: unknown[]): unknown {
try {
enableTracking()
effectStack.push(effect) //往effect数组中里放入当前 effect
activeEffect = effect //TODO: effect 赋值给当前的 activeEffect
return fn(...args) //TODO: fn 为effect传进来 componentEffect
} finally {
effectStack.pop() //完成依赖收集后从effect数组删掉这个 effect
resetTracking()
/* 将activeEffect还原到之前的effect */
activeEffect = effectStack[effectStack.length - 1]
}
} as ReactiveEffect
/* 配置一下初始化参数 */
effect.id = uid++
effect._isEffect = true
effect.active = true
effect.raw = fn
effect.deps = [] /* TODO:用于收集相关依赖 */
effect.options = options
return effect
}
createReactiveEffect的作用主要是配置了一些初始化的参数,然后包装了之前传进来的fn重要的一点是把当前的effect赋值给了activeEffect,这一点非常重要,和收集依赖有着直接的关系.
整个响应式初始化阶段进行总结
① setupComponent创建组件,调用composition-api,处理options(构建响应式)得到Observer对象。
② 创建一个渲染effect,里面包装了真正的渲染方法componentEffect,添加一些effect初始化属性。
③ 然后立即执行effect,然后将当前渲染effect赋值给activeEffect
数据响应
vue3新的响应式书写方式(老的也兼容)
setup() {
const state = {
count: 0,
double: computed(() => state.count * 2)
}
function increment() {
state.count++
}
onMounted(() => {
console.log(state.count)
})
watch(() => {
document.title = `count ${state.count}`
})
return {
state,
increment
}
}
感觉setup这块就有点像 react hooks 理解成一个带有数据的逻辑复用模块,不再以vue组件为单位的代码复用了
和React钩子不同,setup()函数仅被调用一次。
所以新的响应书数据两种声明方式:
- Ref
前提:声明一个类型 Ref
ref()函数源码:
function ref(raw: unknown) {
if (isRef(raw)) {
return raw
}
// convert 内容:判断 raw是不是对象,是的话 调用reactive把raw响应化
raw = convert(raw)
const r = {
_isRef: true,
get value() {
// track 理解为依赖收集
track(r, OperationTypes.GET, '')
return raw
},
set value(newVal) {
raw = convert(newVal)
// trigger 理解为触发监听,就是触发页面更新好了
trigger(r, OperationTypes.SET, '')
}
}
return r as Ref
}
上面的convert函数内容为
const convert = val => isObject(val) ? reactive(val) : val
可以看得出 ref类型 只会包装最外面一层,内部的对象最终还是调用reactive,生成Proxy对象进行响应式代理。
疑问:可能有人想问,为什么不都用proxy, 内部对象都用proxy,最外层还要搞个 Ref类型,多此一举吗?
理由可能比较简单,那就是proxy代理的都是对象,对于基本数据类型,函数传递或对象结构是,会丢失原始数据的引用。
官方解释:
However, the problem with going reactive-only is that the consumer of a composition function must keep the reference to the returned object at all times in order to retain reactivity. The object cannot be destructured or spread:
- Reactive
源码如下:
注:target一定是一个对象,不然会报警告
function reactive(target) {
// 如果target是一个只读响应式数据
if (readonlyToRaw.has(target)) {
return target
}
// 如果是被用户标记的只读数据,那通过readonly函数去封装
if (readonlyValues.has(target)) {
return readonly(target)
}
// go ----> step2
return createReactiveObject(
target,
rawToReactive,
reactiveToRaw,
mutableHandlers, // 注意传递
mutableCollectionHandlers
)
}
createReactiveObject函数如下:
function createReactiveObject(
target: unknown,
toProxy: WeakMap<any, any>,
toRaw: WeakMap<any, any>,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>
) {
// 判断target不是对象就 警告 并退出
if (!isObject(target)) {
if (__DEV__) {
console.warn(`value cannot be made reactive: ${String(target)}`)
}
return target
}
// 通过原始数据 -> 响应数据的映射,获取响应数据
let observed = toProxy.get(target)
if (observed !== void 0) {
return observed
}
// 如果原始数据本身就是个响应数据了,直接返回自身
if (toRaw.has(target)) {
return target
}
// 如果是不可观察的对象,则直接返回原对象
if (!canObserve(target)) {
return target
}
// 集合数据与(对象/数组) 两种数据的代理处理方式不同
const handlers = collectionTypes.has(target.constructor)
? collectionHandlers
: baseHandlers
// 声明一个代理对象 ----> step3
observed = new Proxy(target, handlers)
// 两个weakMap 存target observed
toProxy.set(target, observed)
toRaw.set(observed, target)
if (!targetMap.has(target)) {
targetMap.set(target, new Map())
}
return observed
}
通过上面源码创建proxy对象的大致流程是这样的:
①首先判断目标对象有没有被proxy响应式代理过,如果是那么直接返回对象。
②然后通过判断目标对象是否是[ Set, Map, WeakMap, WeakSet ]数据类型来选择是用collectionHandlers , 还是baseHandlers->就是reactive传进来的mutableHandlers作为proxy的hander对象。
③最后通过真正使用new proxy来创建一个observed ,然后通过rawToReactive reactiveToRaw 保存 target和observed键值对。
拦截器
在baseHandles基类处理对象中可以看到对set/get的拦截处理
(我们以对象类型为例,集合类型的handlers稍复杂点)
handlers如下,new Proxy(target, handles)的 handles就是下面这个对象
export const mutableHandlers = {
get: createGetter(false),
set,
deleteProperty,
has,
ownKeys
}
依赖收集
打开createGetter(false)看实现思路如下:
思路:当我们代理get获取到res时,判断res 是否是对象,如果是那么 继续reactive(res),可以说是一个递归
reactive(target) ->
createReactiveObject(target,handlers) ->
new Proxy(target, handlers) ->
createGetter(readonly) ->
get() -> res ->
isObject(res) ? reactive(res) : res
function createGetter(isReadonly: boolean) {
// isReadonly 用来区分是否是只读响应式数据
// receiver即是被创建出来的代理对象
return function get(target: object, key: string | symbol, receiver: object) {
// 获取原始数据的响应值
const res = Reflect.get(target, key, receiver)
if (isSymbol(key) && builtInSymbols.has(key)) {
return res
}
if (isRef(res)) {
return res.value
}
// 收集依赖
track(target, OperationTypes.GET, key)
// 这里判断上面获取的res 是否是对象,如果是对象 则调用reactive并且传递的是获取到的res,
// 则形成了递归
return isObject(res)
? isReadonly
? // need to lazy access readonly and reactive here to avoid
// circular dependency
readonly(res)
: reactive(res)
: res
}
}
在vue2.0的时候。响应式是在初始化的时候就深层次递归处理了
但是与vue2.0不同的是,即便是深度响应式我们也只能在获取上一级get之后才能触发下一级的深度响应式。
这样做好处是:
- 初始化的时候不用递归去处理对象,造成了不必要的性能开销。
- 有一些没有用上的state,这里就不需要在深层次响应式处理。
先来看看track源码:
/* target 对象本身 ,key属性值 type 为 'GET' */
export function track(target: object, type: TrackOpTypes, key: unknown) {
/* 当打印或者获取属性的时候 console.log(this.a) 是没有activeEffect的 当前返回值为0 */
let depsMap = targetMap.get(target)
if (!depsMap) {
/* target -map-> depsMap */
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
/* key : dep dep观察者 */
depsMap.set(key, (dep = new Set()))
}
/* 当前activeEffect */
if (!dep.has(activeEffect)) {
/* dep添加 activeEffect */
dep.add(activeEffect)
/* 每个 activeEffect的deps 存放当前的dep */
activeEffect.deps.push(dep)
}
}
里面主要引入了两个概念 targetMap和 depsMap
track作用大致是,首先根据 proxy对象,获取存放deps的depsMap,然后通过访问的属性名key获取对应的dep,然后将当前激活的effect存入当前dep收集依赖。
主要作用
①找到与当前proxy 和 key对应的dep。
②dep与当前activeEffect建立联系,收集依赖。
为了方便理解,targetMap 和 depsMap的关系,下面我们用一个例子来说明:
例子:
<div id="app" >
<span>{{ state.a }}</span>
<span>{{ state.b }}</span>
<div>
<script>
const { createApp, reactive } = Vue
/* 子组件 */
const Children ={
template="<div> <span>{{ state.c }}</span> </div>",
setup(){
const state = reactive({
c:1
})
return {
state
}
}
}
/* 父组件 */
createApp({
component:{
Children
}
setup(){
const state = reactive({
a:1,
b:2
})
return {
state
}
}
})mount('#app')
渲染effect函数如何触发get
前面说过,创建一个渲染renderEffect,然后把赋值给activeEffect,最后执行renderEffect ,在这个期间是怎么做依赖收集的呢,让我们一起来看看,update函数中做了什么,我们回到之前讲的componentEffect逻辑上来
function componentEffect() {
if (!instance.isMounted) {
let vnodeHook: VNodeHook | null | undefined
const { el, props } = initialVNode
const { bm, m, a, parent } = instance
/* TODO: 触发instance.render函数,形成树结构 */
const subTree = (instance.subTree = renderComponentRoot(instance))
if (bm) {
//触发 beforeMount声明周期钩子
invokeArrayFns(bm)
}
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
isSVG
)
/* 触发声明周期 mounted钩子 */
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
instance.isMounted = true
} else {
// 更新组件逻辑
// ......
}
}
代码大致首先会通过renderComponentRoot方法形成树结构,这里要注意的是,我们在最初mountComponent的setupComponent方法中,已经通过编译方法compile编译了template模版的内容,state.a state.b等抽象语法树,最终返回的render函数在这个阶段会被触发,在render函数中在模版中的表达式 state.a state.b 点语法会被替换成data中真实的属性,这时候就进行了真正的依赖收集,触发了get方法。接下来就是触发生命周期 beforeMount ,然后对整个树结构重新patch,patch完毕后,调用mounted钩子.
依赖收集流程总结
① 首先执行renderEffect ,赋值给activeEffect ,调用renderComponentRoot方法,然后触发render函数。
② 根据render函数,解析经过compile,语法树处理过后的模版表达式,访问真实的data属性,触发get。
③ get方法首先经过之前不同的reactive,通过track方法进行依赖收集。
④ track方法通过当前proxy对象target,和访问的属性名key来找到对应的dep。
⑤ 将dep与当前的activeEffect建立起联系。将activeEffect压入dep数组中,(此时的dep中已经含有当前组件的渲染effect,这就是响应式的根本原因)如果我们触发set,就能在数组中找到对应的effect,依次执行。
set 派发更新
set的一个主要作用去触发监听,使试图更新,需要注意的是控制什么时候才是视图需要真的更新
function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object
): boolean {
// 拿到新值的原始数据
value = toRaw(value)
// 获取旧值
const oldValue = (target as any)[key]
// 如果旧值是Ref类型,新值不是,那么直接更新值,并返回
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
const hadKey = hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
// 如果是原始数据原型链上的数据操作,不做任何触发监听函数的行为。
if (target === toRaw(receiver)) {
// 更新的两种条件
// 1. 不存在key,即当前操作是在新增属性
// 2. 旧值和新值不等
if (!hadKey) {
trigger(target, OperationTypes.ADD, key)
} else if (hasChanged(value, oldValue)) {
trigger(target, OperationTypes.SET, key)
}
}
return result
}
疑问:对于数据的set操作会出发多次traps?
这里有个前提了解:就是我们日常修改数组,比如 let a = [1], a.push(2),
这个push操作,我们是实际上是对a做了2个属性的修改,1,set length 1; 2. set value 2
所以我们的set traps会出发多次
思路:通过属性值和value控制,比如当 set key是 length的时候,我们可以判断当前数组 已经有此属性,所以不需要出发更新,当新设置的值和老值一样是也不需要更新。
疑问:set的源码里面有 有一个 target === toRaw(receiver)条件下才继续操作 trigger更新视图?
这里就暴露出一个东西,即存在 target !== toRaw(receiver)
Receiver: 最初被调用的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上或以其他方式被间接地调用(因此不一定是 proxy 本身)
其实源码有注释
// don’t trigger if target is something up in the prototype chain of original
即如果我们的操作是操作原始数据原型链上的数据操作,target 就不等于 toRaw(receiver)
什么情况下 target !== toRaw(receiver)
例如:
const child = new Proxy(
{},
{ // 其他 traps 省略
set(target, key, value, receiver) {
Reflect.set(target, key, value, receiver)
console.log('child', receiver)
return true
}
}
)
const parent = new Proxy(
{ a: 10 },
{ // 其他 traps 省略
set(target, key, value, receiver) {
Reflect.set(target, key, value, receiver)
console.log('parent', receiver)
return true
}
}
)
Object.setPrototypeOf(child, parent) // child.__proto__ === parent true
child.a = 4
// 结果
// parent Proxy {a: 4}
// Proxy {a: 4}
从结果可以看出,理论上 parent的set应该不会触发,但实际是触发了,此时
target: {a: 10}
receiver: Proxy {a: 4}
// 在vue3中
toRaw(receiver): {a: 4}
set的流程大致是这样的
① 首先通过toRaw判断当前的proxy对象和建立响应式存入reactiveToRaw的proxy对象是否相等。
② 判断target有没有当前key,如果存在的话,改变属性,执行trigger(target, TriggerOpTypes.SET, key, value, oldValue)。
③ 如果当前key不存在,说明是赋值新属性,执行trigger(target, TriggerOpTypes.ADD, key, value)
接着看下trigger
/* 根据value值的改变,从effect和computer拿出对应的callback ,然后依次执行 */
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
/* 获取depssMap */
const depsMap = targetMap.get(target)
/* 没有经过依赖收集的 ,直接返回 */
if (!depsMap) {
return
}
const effects = new Set<ReactiveEffect>() /* effect钩子队列 */
const computedRunners = new Set<ReactiveEffect>() /* 计算属性队列 */
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || !shouldTrack) {
if (effect.options.computed) { /* 处理computed逻辑 */
computedRunners.add(effect) /* 储存对应的dep */
} else {
effects.add(effect) /* 储存对应的dep */
}
}
})
}
}
add(depsMap.get(key))
const run = (effect: ReactiveEffect) => {
if (effect.options.scheduler) { /* 放进 scheduler 调度*/
effect.options.scheduler(effect)
} else {
effect() /* 不存在调度情况,直接执行effect */
}
}
//TODO: 必须首先运行计算属性的更新,以便计算的getter
//在任何依赖于它们的正常更新effect运行之前,都可能失效。
computedRunners.forEach(run) /* 依次执行computedRunners 回调*/
effects.forEach(run) /* 依次执行 effect 回调( TODO: 里面包括渲染effect )*/
}
trigger的核心逻辑
① 首先从targetMap中,根据当前proxy找到与之对应的depsMap。
② 根据key找到depsMap中对应的deps,然后通过add方法分离出对应的effect回调函数和computed回调函数。
③ 依次执行computedRunners 和 effects 队列里面的回调函数,如果发现需要调度处理,放进scheduler事件调度