成长之:vue3响应式原理

2 阅读9分钟

vue3的响应式基于两个核心API(ref、reactive)和一个全局副作用管理机制。

一、副作用管理机制是响应式的基础

ref和reactive底层都基于副作用管理机制,也就是依赖收集和触发更新的机制。为了清晰展示脉络,我们会一起实现最核心的依赖收集track和触发更新trigger,以及一个简单的effect副作用函数。

1、为什么说副作用管理机制是响应式基础

【举个例子】
假如想要创建响应式数据count:
(1)依赖收集机制 - 记录响应式数据count对应的调用合集
(2)更新机制 - count变化时遍历合集把之前所有的调用再触发执行。

但是我们不能每一个调用都记录起来,就比如普通函数(未被effect包裹的函数)和生命周期里的调用不能记录,否则就乱套了。我们只记录比如watch、componenetsRender、computed等被effect包裹起来的调用。这个呢其实就是依赖收集机制了,它会适时记录好哪里的响应式数据的调用需要收集起来,哪里的不用。

现在知道为什么要有这个依赖收集机制了吧?那我们就开始吧~

2、track & trigger

系统里有一个全局的变量activeEffect,当副作用函数被调用时,activeEffect被设置为当前副作用函数,然后同步执行副作用函数。当执行完之后,activeEffect被清空。副作用函数执行过程中会对响应式数据的访问会为收集到对应集合中。当响应式数据被修改时,会触发trigger更新,trigger会获取对应响应式数据对应的依赖集合,然后遍历集合,执行集合里的副作用函数。

let activeEffect = null // 全局的变量reactiveEffect
const targetMap = new Map() // 依赖及其副作用函数集合

// 模拟的一个最简单的副作用函数
function effect(fn) {
    activeEffect = fn
    fn() // 依赖的收集在fn执行过程中,不再effect执行完成后
    activeEffect = null
}

// 依赖收集函数
function track(target,key){
    /*
        activeEffect的必要性: 
        1、activeEffect确保我们只收集被副作用函数effect包裹起来的响应式数据的调取会被收集;
        2、activeEffect为空则不对数据进行收集,而activeEffect只有在副作用函数执行的过程中数值不为空;
        3、effect 不可能存在 async await的情况,所以在js单线程运行机制下,也不可能存在activeEffect不为空同时有普通函数或生命钩子函数执行的情况
    */
    
    if(!activeEffect) return
    let desMap = targetMap.get(target)
    if(!desMap){
        targetMap.set(target,(desMap = new Map()))
    }
    let dep = desMap.get(key)
    if(!dep){
        desMap.set(key,(dep = new Set()))
    }
    dep.add(activeEffect)
}

// 触发更新
function trigger(target,key){
    const desMap = target.get(target)
    if(!desMap) return
    const dep = desMap.get(key)
    if(!dep || !dep.length) return
    dep.forEach(effect=> effect())
 }

二、手写reactive

reactive中,使用proxy来代理对象,监听拦截对象的所有操作。同时借助Reflect来实现对象的默认行为不被拦截操作所影响(例如:确保this指向正确、确保原型链正确)。对于proxy\reflect,以及为什么proxy要用Reflect,这个下文会进行阐述,这里我们先纯写reactive吧~

funtion reactive(target){
    if(type of target !== 'Object' || target === null ) return target
    const handler {
        get(obj,key,receiver) {
            //获取值
            const result = Reflect.get(obj,key,receiver)
            //依赖收集
            track(obj,key) 
            //判断result是否为对象,如果是对象需要递归代理(这里其实就是懒代理)
            let isObject = type of target === 'Object' || target !== null
             return isObject ?  reactive(result) : result
         },
         set(obj,key,value,receiver){
             //获取旧值
             const oldVal = obj[key]
             //设置数据
             const result = Reflect.set(obj,key,value,receiver)
             //如果新旧不一致则触发更新
             if(oldVal !== value) trigger(obj,value)
             
             return result
         },
         deleteProperty(obj,key){
             const hasKey = Object.property.hasOwnProperty.call(obj,key)
             const result = Reflect.deleterProperty(obj,key)
             if(hasKey) trigger(obj,key)
             return result
         }
    }
    return new Proxy(target,handler)
}
             
        
    

三、手写ref

ref的核心是创建一个带 .value、getter、setter的普通对象,在getter、setter中调用track和trigger。ref通常接收基本数据类型,但如果传入的是对象,内部调用reactive进行代理实现响应式。


function ref(val){
    // 如果已经是 ref,直接返回
    if(val && val._v_isRef) return val
    
    let rawVal = val
    // 对于对象,调用reactive代理
    if(type of val === 'object' && val !== null ) rawVal = reactive(val)
    
    const _ref = {
        _v_isRef: true,
        get value(){
            track(_ref,'value')
            return rawVal
        },
        set value(newVal){
            if(newVal !== rawVal){
                rawVal = newVal
                trigger(_ref,'value')
            }
        }
    }
    
    return _ref
}

四、可能派生的问题

1、依赖收集是为了监听数据变化,触发调用数据的地方重新更新。那怎么区分“普通函数的调用”、“template里的调用”、生命钩子函数的调用?这些地方的调用和watch、computed里面的调用有什么不同吗?

这个涉及响应式边界的问题,我们上面有提到只有被effect包裹的内容读取了响应式数据时才会被收集,这其实就是【只有运行在“响应式上下文”中的函数调用了响应式数据,才会被收集为依赖】。

template在编译阶段,也会被转换成渲染函数。vue内部会把渲染函数包装成一个effect,因此函数内部访问响应式数据会被收集为依赖。

watch、computed、watchEffect都是通过effect API创建的副作用函数,所以对于依赖收集在哪里调用本质上没什么不同,我们也不需要区分是哪里调用的,因为他们都运行在同一个机制(副作用管理机制-依赖收集机制)上。

它们确实不同,但不是依赖收集层面的不同,而是调度和行为层面的不同。watch和computed内部使用了复杂的scheduler来控制何时更新执行以及是否缓存

API是否会立即执行执行时机是否会有缓存回调参数
efect依赖变化后
computed是(最开始只计算,不执行回调)懒执行,访问 .value 时
watch否(排除immediate未true时)依赖变化后(可配flush)有(新值/旧值)

2、proxy是什么?干什么用的?

proxy时es6的一个编程特性,可用proxy来代理对象,监听拦截对象的基本操作,自定义对象的基本操作方法(读取、赋值、枚举、函数调用···)

(1)proxy的基本语法

const proxy = new Proxy(target,handler)

  • target: 被代理的对象
  • handler: 一个对象,里面是重写的被代理对象的基本操作,如:set、get、deleteProperty。当对proxy进行操作时会调用对应的方法

(2)proxy的常用方法

  • get(target,key,receiver)

    • 触发时机:读取proxy[key]时会触发get方法
    • target:原始对象
    • key:访问的属性
    • receiver:通常是proxy本身,用于保持正确的this指向
  • set(target,key,value,receiver)

    • 触发时机:proxy[key]赋值时会触发set方法,set方法会返回一个布尔值,表示赋值是否成功。
    • target:原始对象
    • key:访问的属性
    • value:设置的值
    • receiver:通常是proxy本身,用于保持正确的this指向

(3)为什么需要proxy?

因为只有通过proxy才能拦截任意一个对象属性的读取和修改、删除等操作。修改了vue2中使用的Object.defineProperty拦截对象,但只能监听已知对象的读取,不能监听新增属性、删除属性、数组索引修改、数组length变化的弊端,同时proxy懒代理避免了初始化深递归代理对象属性的性能损耗问题。这就是为什么我们要用proxy的原因。

3、Reflect是什么?

(1)Reflect简介

简单来说:Reflect是ES6提供的一个内置对象,它把原来对象的一些方法变成了函数化的API。

原生操作Reflect对应方法
obj[key]Reflect.get(obj,key,receiver)
obj[key]=valueReflect.set(obj,key,value,receiver)
delete obj[key]Reflect.deleteProperty(obj,key)
key in objReflect.has(obj,key)
Object.keys(obj)Reflect.ownKeys(obj)

Relfect 统一对象的函数式接口,不再依赖操作符或Object上的方法。且Reflect的方法会返回特定的返回值以说明当前操作的结果,而这些正是proxy所需要的,因此proxy常常会结合Reflect使用。

(2)Reflect的好处

  • 函数式,更安全。 有些操作比如 delete obj.prop 如果属性不存在、不可配置等,行为比较隐晦。而Reflect.deleteProperty(obj,'prop')会返回布尔值表示是否成功,而不是静默失败或者抛异常。
  • 统一的返回值 :proxy方法要求返回特定类型的值,而Reflect返回值正好满足这些要求
  • 更好的处理this指向

4、为什么proxy里要用Reflect?不用会怎么样?

(1)保证this指向正确,从而维持原型链的正确。
  • getter方法中,在不修改this指向的情况下,this指向默认指向访问该属性的对象(getter所依附的原始对象)
  • Reflect.get(tartget,key,receiver)允许手动指定getter内部的this为receiver,receiver通常为代理对象本身或继承自代理的对象,从而让getter能够访问到代理对象或继承者上的属性。
(5)处理边界问题

假设setter中不使用Reflect


const parent = {
    _value: 0,
    set value (v){
        this._value = v
    }
}

const obj = Object.create(parent)

const proxy = new Proxy(obj,{
    set(target,key,value){
        target[key]=value
        return true
    }
})

proxy.value = 123

  • 预期行为是:obj上没有value属性,赋值操作应该沿着原原型链找到parent的setter,并执行它。但事实是会在obj上创建一个新属性value,而不会触发parent上的setter。
  • 对于只读属性和不可配置属性的赋值:未使用Reflect的情况下,set操作直接赋值会静默失败,Reflect.set会返回布尔值表示是否成功
  • 而Reflect.set(target,key,value,receiver)不会有这样的问题,它会触发原型链上的setter,并将receiver作为setter内部的this

总的来说,proxy用来拦截对象的操作,而Reflect提供正确的默认实现。所以结合起来用既能自定义行为,也能完美恢复默认行为,且不会破环原有语义。

五、vue3响应式与vue2响应式的区别

vue2使用defineProperty递归遍历对象属性并转换为getter/setter,无法拦截属性的新增删除操作,也无法拦截数组的操作。vue3基于proxy可以代理整个对象,支持对象的新增删除属性、数组索引修改、数组length变化等。

vue2初始化时就递归遍历属性,但vue3初始化仅代理对象,直至代理对象的属性被读取属性才会被代理,节省了初始化性能的开销。

六、总结

vue3响应式简单来说就是:两个核心API(ref、reactive)和一个副作用管理机制。

副作用管理机制负责建立【“响应式数据”-使用位置(调用响应式数据的方法)】的映射关系,读取响应式数据时检查收集依赖建立映射关系(track方法);设置值时查找映射表,有记录就重新执行“调用响应式数据的方法”。

ref/reactive 负责定义响应式数据,在响应式读取时调用track依赖收集方法,在数据更新时调用trigger更新方法。