Vue3 響應式原理剖析

image

簡述

Vue3 發佈後,有一個重要的有關於響應式機制的改動,html

Vue2 的時候,採用的是 Object.defineProperty 方式,重構數據的 setget 方法,來達到監聽數據變動的方法,vue

可是在 Vue3 發佈後,就再也不使用 Object.defineProperty 了,而是使用了 ES6 中的 Proxy 來對數據進行一個封裝,起到一箇中間代理的做用來監聽數據的變動,對於 Proxy 不瞭解的小夥伴能夠看這裏:Proxyreact

下面主要是對 Vue3 的響應式機制進行一個簡單的實現,主要包含兩個:refreactivegit

  • ref:是對基礎數據進行封裝監聽,例如:Boolean、Numberes6

  • reactive:是對複雜數據進行封裝監聽,例如:github

{
    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 的實現邏輯了。typescript

接下來能夠了解一下源碼是怎麼樣的:segmentfault

ref 在源碼中會對傳入的數據進行類型判斷,若是判斷爲對象數據類型會使用 reactive 去進行響應式分裝的,否者會使用 RefImplget,set 方法去監聽,這點相似於 Vue2 的 Object.definePropert。數組

在源碼上爲 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,
      num: { count: 1 },
    })
    
    // 計算屬性
    let double = computed(()=>obj.age*2)
    
    // 反作用,依賴函數
    effect(()=>{
      console.log('數據變了',obj.age)
      root.innerHTML = `<h1>${obj.name}今年${obj.age}歲了,雙倍${double.value}, Num: ${obj.num.count}</h1>`
    })

    btn.addEventListener('click',()=>{
      // obj.age+=1;
      obj.num.count += 1; // 測試 reactive 遞歸封裝 Proxy 的特色
    },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)
    // 當使用到內部屬性的時候,再進行 Proxy 封裝,
	if (typeof res === 'object') {
	  return reactive(res);
	}
    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){
  if (typeof target === 'object') {
    /*
    if (target instanceof Array) {
      // 若是是一個數組,那麼取出來數組中的每個元素
      // 判斷每個元素是否又是一個對象,若是又是一個對象,那麼也須要包裝成 Proxy
	  target.forEach((item, index) => {
	    if (typeof item === 'object') {
          target[index] = reactive(item);
        }
      });
    } else {
	  // 若是是一個對象,那麼取出對象屬性的值
	  // 判斷對象屬性的值是否又是一個對象,若是又是一個對象,那麼也須要包裝成 Proxy
	  for (let key in target) {
	    const item = target[key];
		if (typeof item === 'object') {
		  target[key] = reactive(item);
		}
	  }
	}
	*/
    // target變成響應式
    const observerd = new Proxy(target, baseHandler);
    return observerd;
  } else {
	console.warn('請傳入 Object');
	return target;
  }
}

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{
      /**
       * 這裏計算屬性取值的時候,會調用計算屬性的 fn 獲得返回值,若是沒有 if (!effect.computed) 這個條件,
       * 那麼計算屬性中所依賴的屬性好比:age 就會綁定上 fn 這個依賴,而不是綁定上 root.innerHTML 這個依賴
       * 會致使更新 age 值,沒法刷新視圖,由於對於這種狀況:
       * effect(()=>{
       *   root.innerHTML = `<h1>雙倍${double.value}</h1>`
       * })
       * 沒有取值 obj.age,只作了 double.value 的取值的話,就沒法讓計算屬性中的 age 綁定正確的更新函數了
       * 固然 vue3 源碼中也並非這樣作的,這裏只是簡單了一下,待更新中...
       */
      if (!effect.computed) 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 實現,裏面還有一個特殊的計算屬性的響應式實現,基本流程作了什麼,都在註釋上進行標識了。

有一點注意的是: reactive 會進行嵌套封裝 Proxy ,但它又不是一次性的,須要用到內部屬性的時候會去給內部屬性也封裝 Proxy,這樣返回的數據進行變動的時候,也能進行代理。

// 源碼
if (isObject(res)) {
  // Convert returned value into a proxy as well. we do the isObject check
  // here to avoid invalid value warning. Also need to lazy access readonly
  // and reactive here to avoid circular dependency.
  return isReadonly ? readonly(res) : reactive(res)
}
複製代碼

除了 reactiveref 外,還有兩個相似的 Api:shallowreactiveshallowref,這兩個和前兩個的區別就是不執行嵌套對象的深度響應式轉換,只封裝第一層 Proxy。

代碼中使用到了 ES6 的 Proxy 和 Reflect,不懂的小夥伴還得須要去了解一下這幾個知識點滴~

reactive.js 源碼

參考文獻

相關文章
相關標籤/搜索