Vue3 响应式原理剖析

  • 简述
  • ref 迷你版
  • reactive 迷你版
  • 参考文献

简述

Vue3 发布后,有一个重要的有关于响应式机制的改动,

Vue2 的时候,采用的是 Object.defineProperty 方式,重构数据的 setget 方法,来达到监听数据变更的方法,

但是在 Vue3 发布后,就不再使用 Object.defineProperty 了,而是使用了 ES6 中的 Proxy 来对数据进行一个封装,起到一个中间代理的作用来监听数据的变更,对于 Proxy 不了解的小伙伴可以看这里:Proxy

下面主要是对 Vue3 的响应式机制进行一个简单的实现,主要包含两个:refreactive

  • ref:是对基础数据进行封装监听,例如:Boolean、Number

  • reactive:是对复杂数据进行封装监听,例如:

{
    key1: 'Benson',
    key2: {
        key3: 1007,
        key4: [1, 2, 3]
    }
}

ref 迷你版

let activeEffect // 用于保存当先需要依赖的函数

// mini 依赖中心
class Dep {
  constructor(){
    this.subs = new Set(); // 使用 Set 避免重复收集依赖
  }
  depend(){
    // 收集依赖
    if(activeEffect){
      this.subs.add(activeEffect)
    }
  }
  notofy(){
    // 数据变化,触发effect执行
    this.subs.forEach(effect=>effect())
  }
}

function effect(fn){
  activeEffect = fn; // 保存当前响应式依赖函数
  fn(); // 执行依赖函数
}

const dep = new Dep() // vue3 中就变成一个大的 map

// ref 大概的原理在这了,待会后面可以看代码
function ref(val){
  let _value = val
  // 拦截.value操作
  let state = {
    get value(){
      // 获取值,收集依赖 track
      dep.depend()
      return _value
    },
    set value(newCount){
      // 修改,通知dep,执行有这个依赖的effect函数
      // 源码这里会做判断,是否真的值发生了变化
      _value = newCount
      // trigger
      dep.notofy()
    }
  }
  return state
}

const state = ref(0)

effect(()=>{
  // 这个函数内部,依赖state的变化
  console.log(state.value)
})

setInterval(()=>{
  state.value++; // 这里进行响应式数据的值改变,触发 set 方法
},1000)

上面的案例就是对 ref 的一个简单实现了,其实已经能够很好的表示 Vue3 在源码中对 ref 的实现逻辑了。

接下来可以了解一下源码是怎么样的:

ref 在源码中会对传入的数据进行类型判断,不符合的数据类型会使用 reactive 去进行响应式分装的,在源码上为 ref 定义了一个 interface

// 生成一个唯一key
declare const RefSymbol: unique symbol

export interface Ref<T = any> {
  /**
   * value值,存放真正的数据的地方
   */
  value: T
  /**
   * Type differentiator only.
   * We need this to be in public d.ts but don't want it to show up in IDE
   * autocomplete, so we use a private Symbol instead.
   * 用此唯一 key,来做 Ref 接口的一个描述符, 让 isRef 函数做类型判断
   */
  [RefSymbol]: true
  /**
   * @internal
   */
  _shallow?: boolean
}

接下来看看 ref 方法:

// 对于 ref 进行多次重载
export function ref<T extends object>(
  value: T
): T extends Ref ? T : Ref<UnwrapRef<T>>
export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T = any>(): Ref<T | undefined>
export function ref(value?: unknown) {
  return createRef(value)
}

// 看一般情况 ref(123),使用最后一个

function createRef(rawValue: unknown, shallow = false) {
  // 判断是否已经是响应式 ref 数据了
  if (isRef(rawValue)) {
    return rawValue
  }
  // 创建响应式数据
  return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
  private _value: T

  public readonly __v_isRef = true

  constructor(private _rawValue: T, public readonly _shallow = false) {
    // 转化数据为响应式数据
    this._value = _shallow ? _rawValue : convert(_rawValue)
  }

  get value() {
    // track 的代码在 effect中,能猜到此处就是监听函数收集依赖的方法
    track(toRaw(this), TrackOpTypes.GET, 'value')
    // 返回数据
    return this._value
  }

  set value(newVal) {
    // 如果数据发生变化
    if (hasChanged(toRaw(newVal), this._rawValue)) {
      // 更新数据
      this._rawValue = newVal
      // 转化数据为响应式数据
      this._value = this._shallow ? newVal : convert(newVal)
      // 能猜到此处就是触发监听函数执行的方法
      trigger(toRaw(this), TriggerOpTypes.SET, 'value', newVal)
    }
  }
}

// 数据类型不合适使用 ref,将采用 reactive
const convert = <T extends unknown>(val: T): T =>
  /**
   * isObject() 从 @vue/shared 中引入,判断一个数据是否为对象
   * 如果传递的值是个对象(包含数组/Map/Set/WeakMap/WeakSet),则使用 reactive 执行,否则返回原数据
   */
  isObject(val) ? reactive(val) : val

// 从@vue/shared中引入,判断一个数据是否为对象
// Record<any, any>代表了任意类型key,任意类型value的类型
// 为什么 val is Record<any, any> 而不是 val is object 呢?可以看下这个回答:
// https://stackoverflow.com/questions/52245366/in-typescript-is-there-a-difference-between-types-object-and-recordany-any
export const isObject = (val: unknown): val is Record<any, any> =>
  val !== null && typeof val === 'object'

以上就是 Vue3 中对 ref 的简单阅读,至于 ref 里面的各个内部方法具体逻辑,可以了解一下前面的简单例子就能大概知道了,如果要仔细了解的话,就自行一步一步去查看源码了哈~

ref 源码

reactive 迷你版

Vue3 对于比较复杂的数据,就会采用 reactive 进行响应式的封装,下面来看看如何实现一个简易版的响应式逻辑:

<!--index.html-->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>

  <div id="app"></div>
  <div id="btn">click</div>
  <script src="./vue.js"></script>
  <script>
    const root = document.getElementById('app')
    const btn = document.getElementById('btn')
    <!--响应式封装-->
    let obj = reactive({
      name: 'Benson',
      age: 24
    })

    <!--计算属性-->
    let double = computed(()=>obj.age*2)

    <!--副作用,依赖函数-->
    effect(()=>{
      console.log('数据变了',obj.age)
      root.innerHTML = `<h1>${obj.name}今年${obj.age}岁了,双倍${double.value}</h1>`
    })

    btn.addEventListener('click',()=>{
      obj.age+=1
    },false)
  </script>
</body>
</html>

在 index.html 中,对一个对象进行 reactive 响应式封装,并且还生成一个对 obj.age 的计算属性,这里其实计算属性就是一个特殊的依赖函数(副作用函数)

effect 副作用函数传入的方法会在响应式数据发生变化后执行。副作用函数执行之后,计算属性根据取值操作,也就是 get 方法会触发,这时候,就会触发 computed 的传入的 Effect 执行获取到最新值,这一点可以留意一下,下面的简易版实现逻辑:

<!--vue.js-->
const effectStack = [] // 这里存储当前响应式数据的依赖函数
let targetMap = new WeakMap() // 存储所有reactive,所有key对应的依赖
// {
//   target1: {
//     key1: [effect]
//   }
// }
// target1 其实就是使用响应式源对象作为 key,对象中的属性作为 key1 ,然后该属性对应着哪一些副作用函数整合到 [effect] 中


function track(target,key){
  // 收集依赖
  // reactive可能有多个,一个又有N个属性key
  const effect = effectStack[effectStack.length-1]
  if(effect){
    let depMap = targetMap.get(target)
    if(!depMap){
      depMap = new Map() // 类似对象类型,里面放着响应数据的属性 key 和对应 dep
      targetMap.set(target, depMap)
    }
    let dep = depMap.get(key)
    if(!dep){
      dep = new Set() // 这里使用了 Set 很重要,这里的 Set 能够防止重复保存依赖函数
      depMap.set(key,dep)
    }
    // 添加依赖
    dep.add(effect)
    effect.deps.push(dep)
  }
}

function trigger(target,key,info){
  // 触发更新
  let depMap = targetMap.get(target)
  if(!depMap){
    return 
  }
  const effects = new Set()
  const computedRunners = new Set()

  if(key){
    let deps = depMap.get(key)
    deps.forEach(effect=>{
      if(effect.computed){
        computedRunners.add(effect)
      }else{
        effects.add(effect)
      }
    })
  }
  // 计算属性传入的 `fn` 会依赖 `reactive` 对象的属性 A
  // 所以这个 `fn` 也会在属性 A 依赖集合 `deps` 进行存储,属性 A
  // 发生了变化也会执行这个 `fn`
  computedRunners.forEach(computed=>computed())
  // 这里会执行一般的函数,这里就是主要就是执行:root.innerHTML 更新视图
  effects.forEach(effect=>effect())
}

function effect(fn,options={}){
  // {lazy:false,computed:false}
  // 副作用
  // computed是一个特殊的effect
  let e = createReactiveEffect(fn,options)

  if(!options.lazy){
    // lazy决定是不是首次就执行effect
    e()
  }
  return e
}

const baseHandler = {
  get(target,key){
    const res = Reflect.get(target, key); // reflect更合理的
    // 收集依赖
    track(target,key)
    return res
  },
  set(target,key,val){
    const info = {oldValue:target[key], newValue:val}
    Reflect.set(target, key, val); // Reflect.set
    // 触发更新
    trigger(target,key,info)
  }
}
function reactive(target){
  // target变成响应式
  const observerd = new Proxy(target,baseHandler)
  return observerd
}

function createReactiveEffect(fn,options){
  const effect = function _effect(...args){
  /* 这里的 _effect 和 fn 都会因为在 run 函数中保存在 effectStack,
   * 然后执行 fn 触发数据的 get 方法,保存在 targetMap 对应响应式数据属性 key 的 dep 中,
   * 所以 _effect 和 fn 都会一直处于闭包状态,而不会消失,
   * 这时候,设置响应式数据的 set 方法时,就会触发执行 _effect 方法,
   * 并且重新执行 run 和里面的 fn,这时候 fn执行时,
   * 又会触发响应数据的 get 方法,触发收集依赖函数,
   * 此时就是因为收集依赖的是 new Set(),一所不会导致重复收集相同的依赖,流程就是这样了
   */
    return run(_effect,fn,args) 
  }
  // 为了后续清理 以及缓存
  effect.deps = []
  effect.computed = options.computed
  effect.lazy = options.lazy
  return effect
}
function run(effect,fn,args){
  if(effectStack.indexOf(effect)===-1){
    try{
      effectStack.push(effect)
      return fn(...args)
    }finally{
      effectStack.pop()
    }
  }
}
function computed(fn){
  // 特殊的effect
  const runner = effect(fn, {computed:true,lazy:true})
  return{
    effect:runner,
    get value(){
      return runner() // 这里计算属性取值的时候,会执行这个 runner 从而得到最新的值,这个值是依赖于计算属性传入的 fn 而来的
    }
  }
}

上诉案例就是简单的 reactive 实现,里面还有一个特殊的计算属性的响应式实现,基本流程做了什么,都在注释上进行标识了。

代码中使用到了 ES6 的 Proxy 和 Reflect,不懂的小伙伴还得需要去了解一下这几个知识点滴~

reactive.js 源码

参考文献

原文链接:juejin.im

上一篇:逐行阅读Vue3响应式系统核心源码(reactive和effect)
下一篇:提高效率小技巧:自定义终端命令或脚本文件

相关推荐

官方社区

扫码加入 JavaScript 社区