Vue3 响应式原理 :Proxyの防脱发指南

54 阅读7分钟

Vue2 缺陷

  • 通过Object.defineProperty对对象的每一个属性进行数据拦截(getter,setter)

  • Object.defineProperty 只拦截其他操作,对象/数组操作方法不提供

    • 所以vue给出了vue.get/vue.delete 方法
  • vue不对每个数组元素单独拦截get/set,而是直接重写了数组的原型方法以检测数组变化。原因如下:

    • 考虑到使用索引直接访问数组的情况比较少
    • 而且操作数组的方法更为常用,所以直接放弃了第一个方法
    • 对于对象则是直接操作的更多,所以对对象遍历执行defineProperty

通过observer() 递归 生成响应式内容

let  obj = {
    a:1,
    b:2,
    c :{
        n:3
    },
    d:['1','2','3']
}

function observer(target){
   
        for(let key in target){
            defineReactive(target,key,target[key])
        }
}

function defineReactive(target,key,value) {
    if(typeof value === 'object' && value !==null){
        observer(value)
    }
    Object.defineProperty(target,key,{
        get(){ // 取值
            return value;
            
        },
        set(newVal){ // 设置值
          if(newVal !== value){
                value = newVal
                updateView () //触发虚拟DOM到DIFF,渲染的流程
          }
        }
    })
}

observer(obj)

Proxy 代理

代替了 Vue2 的 Object.defineProperty

proxy 简介

包装一个对象,拦截诸如读写和其他的操作,自行处理他们 let proxy = new Proxy(target,handler )

类似于中间件,对proxy的操作会先经有handler替换

[[Get]]get读取属性
[[Set]]set写入属性
[[HasProperty]]hasin 操作符
[[Delete]]deletePropertydelete 操作符
[[Call]]apply函数调用
[[Construct]]constructnew 操作符
[[GetPrototypeOf]]getPrototypeOfObject.getPrototypeOf
[[SetPrototypeOf]]setPrototypeOfObject.setPrototypeOf
[[IsExtensible]]isExtensibleObject.isExtensible
[[PreventExtensions]]preventExtensionsObject.preventExtensions
[[DefineOwnProperty]]definePropertyObject.defineProperty, Object.defineProperties
[[GetOwnProperty]]getOwnPropertyDescriptorObject.getOwnPropertyDescriptor, for..in, Object.keys/values/entr…
[[OwnPropertyKeys]]ownKeysObject.getOwnPropertyNames, Object.getOwnPropertySymbols, for..in, Object.keys/values/entr…
  • 上述方法受到JS不变量限制:内部方法和捕捉器必须返回一些规定的值Invariant是强制执行的,否则触发 TypeError
  • 可以使用可撤销的代理 let {proxy, revoke} = Proxy.revocable(target, hander)
  • 上述多种操作拦截器可以实现对对象操作方法/直接访问的拦截解决vue2的问题

Reflect

Reflect 是一个内建方法,用于简化 Proxy 的创建以及传递正确的接收方(receiver)

  • Reflect 和 Proxy 的 handler 有一样的函数名

解决了如下的问题:

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop]; // (*) target = user
  }
});

let admin = { __proto__ : userProxy, _name : "Admin" };
// 期望输出:Adminalert(admin.name); // 输出:Guest (?!?) 

target[prop]不传递正确的this,在admin中查找name失败,在原型(userProxy) 上get name, 根据handler,return target[prop],此处的target是user,所以访问 user.get , 访问this._name,此时this是user,所以返回Guest

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) { // receiver = admin
 return  Reflect . get (target, prop, receiver); // (*)
    // 可以写成 return Reflect.get(...arguments)
  }
});


let admin = {
  __proto__: userProxy,
  _name: "Admin"
};

alert (admin. name ); // Admin

使用Reflect 修复。在userProxy return 处传递了receiver,调用user.get的时候,this就是 userProxy 的get 方法入参receiver,也即 admin,所以获取到admin._name = "Admin"

简而言之,Reflect

  1. 提供一种方便的将捕捉器转发给对象的方法Reflect.<method>
  2. 在转发给原对象的时候保留调用方正确的this (Receiver)

创建响应式的对象

reactive -- 对象

ref -- 基本类型/对象

readonly -- 只读的对象,创建过程

对于基本类型 Ref

  1. 通过RefImpl定义属性拦截器,创建响应式对象
  2. 依赖存储在RefImpl主动的一个set中

所以是包裹了一层RefImpl来实现响应式,所以用ref需要写.value。如果传入的是对象,第一层有value,后面有observer递归调用reative(创建Proxy),不用再加.value了

对于对象 Ref / reactive

  1. 创建Proxy

  2. Proxy保存在weakmap中

    1. 方便查找
    2. 方便释放内存
    3. 防止重复创建
  3. weakMap中还有各个属性的Map

  4. map下保存各个属性的effect set

  • 通过target标记:target.__v_reactive = observed 来标记已经是响应式的对象,以防止重复代理
  • 在定义的时候没有递归创建Proxy,而是在getter中创建,也即访问的时候才会去创建嵌套对象的Proxy,这是一种性能优化

依赖收集和触发

依赖收集

Proxy的 setter 被重写,访问 setter 时回会触发 tarck(),tarck() 会将读取该值的副作用函数添加到 targetMap 中

如何获取当前的副作用函数?全局变量 activeEffect 保存当前effect ,track() 中闭包访问 activeEffect 。

  1. activeEffect 存入 set : deps中
  2. shouldTrack 判断当前是否需要收集依赖
  3. deps 存入关于 key 的 weakMap targetMap 中
targetMap: 
    state -> {
    count -> [effect1,effect2],
    name -> [effect1,effect2]
    }

实际上收集的是副作用函数的effect 方法生成的 ReactiveEffect 类的实例 _effect

依赖 cleanup

执行renderEffect的时候需要先清除之前收集的rendereffect,防止执行没有渲染的rendereffect

effect 函数

一个用来注册副作用函数的函数

  • 接收一个回调函数,这个回调函数就是被注册的副作用函数,他会在合适的时候被调用(vue挂载,state更新)
  • 一个对象
export interface ReactiveEffectOptions {
  lazy?: boolean //  是否延迟触发 effect
  computed?: boolean // 是否为计算属性
  scheduler?: (job: ReactiveEffect) => void // 调度函数
  onTrack?: (event: DebuggerEvent) => void // 追踪时触发
  onTrigger?: (event: DebuggerEvent) => void // 触发回调时触发
  onStop?: () => void // 停止监听时触发
}

依赖触发

Proxy的 setter 被重写, 访问getter的时候会触发 trigger() ,trigger() 会读取副作用函数set,触发依赖身上的所有依赖函数

export function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)

  if (!deps) return

  deps.forEach(effect => {
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect()
    }
  })
}

image.png

计算属性API

computedAPI 也是一个依赖收集的过程

为什么不直接使用函数呢?

基本流程

  1. 传入的参数作为一个getter
  2. 判断 dirty 是否需要重新计算,默认为true,首次访问会执行 getter
  3. 执行runner() 获取计算结果 value
  4. 将dirty设置为false
  5. 使用track收集依赖
  6. 返回 value 每次计算的结果

当依赖被触发

  1. ref/reactive setter trigger到 scheduler
  2. scheduler 将 dirty 设置为 true ,然后再去触发 runner (computed)
  • 延时计算:只有当我们去访问computed计算属性的时候,coputed getter函数才真正计算
  • 缓存:当dirty为false,会使用上次的value,这就是为什么使用computed比直接使用function会更好,因为缓存

优先级更高

相比普通函数,computed runner 的执行优先级相比 ref reactive 会更高

Watch

和effect很像,他们有什么区别吗?

watch(count,(count,prevCount)=>{})
watch(()=>count,(count,prevCount)=>{})
watch([count1,count2],([count1,count2],[prevCount1,prevCount2])=>{})

基本流程

  1. 标准化 source

    1. ref:创建一个访问ref.value 的getter
    2. reactive :创建一个访问reactive的getter并deep设置为true
    3. 如果是一个函数:当做getter。监听基本类型需要用函数形式
    4. 否则报错
    5. deep 会递归监听
  2. 构造 applyCb 回调函数

    1. newValue,oldValue,onInvalidate
  3. 创建 scheduler 时序执行函数

    1. 执行方式 flush 属性
    2. 同步sync watch cb同步执行
    3. 异步pre 通过queueJob在组件更新之前执行,如果组件未挂载则同步执行,保证是在组件挂载之前执行(preview)
    4. 未设置 通过queuePostRenderEffect在组件更新之后执行
  4. 创建 effect 副作用函数

    1. 是一个computed Effect 所以是优先执行的
    2. 配置immediate则会立刻执行
  5. 返回监听器销毁函数

    1. 执行stop方法使失活
    2. 清空依赖

回调函数调度方式

flush:sync

同步执行

flush:pre/null

watcher回调函数异步执行。维护一些内部的队列帮助调度

  • queueJob
  • queuePostRenderEffect
  • queue 异步任务队列
  • queuePostFlushCb 异步任务执行完的回调队列

及时多次执行queueFlush,也可以通过标志位防止flushJobs重复执行

  • isFlushPending
  • isFlushing

Vue3 中 nextTick 的实现

Promise.resolve().then( 在此执行flushJobs )

排序

queueJob 执行前,需要按升序排序

  • 在queue中,组件更新id是父组件小于子组件,为了保证父组件先于子组件更新,需要在queueJob执行之前将其按升序排序
  • 当父组件在更新过程中被卸载,其子组件的更新也应该停止

WatchEffect

于watch的不同

  • 监听的是一个普通函数,内部访问了响应式对象,不需要返回响应式对象
  • 没有回调函数,副作用函数中的响应式变量发生变化后重新执行副作用函数
  • 发生变化的时候马上执行watchEffect

provide inject

创建组件的时候组件会将当前provides引用指向父级提供的provides

创建的时候,他将provides内容添加到父级提供的provides上

因此会将上一级的同名内容被覆盖

笔者才疏学浅,各位读者多多担待,不吝赐教。部分插图来自网络,侵删。