面試官:Vue3響應式系統都不會寫,還敢說精通?

前端胖頭魚發表於2022-04-27

也許你我素未謀面,但很可能相見恨晚,我是前端胖頭魚

前言

都說今年是最慘工作年,大廠裁員,小廠跟風,簡歷投了幾百封回信的寥寥無幾,金三銀四怕是成了銅三鐵四,冷冷清清,悽悽慘慘。

但是今天的主角,小帥同學卻在逆風環境中給了面試官當頭一喝,秀了他一身,優秀如他,到底經歷了一場怎樣的面試?

文中的例子和程式碼都可以點選這裡檢視

1.# 題目亮相

面試官: 我看你簡歷寫的精通Vue3,並研究過其原始碼? 小夥子很狂啊!那我們就現場秀一段如何?

說罷,面試官現場給了一道題...

<div id="app"></div>

<script>
  const $app = document.querySelector('#app')

  let state = {
    text: 'hello fatfish'
  }

  function effect() {
    $app.innerText = state.text
  }

  effect()

  setTimeout(() => {
    // 1秒後希望app的內容變成hello Vue3
    state.text = 'hello Vue3'
  }, 1000)
</script>

小帥竊喜?: 這個簡單,只要攔截state物件,在對text進行取值時,收集effect函式依賴,然後text設定值時,把收集的effect函式執行一波就可以。

面試官: 口嗨我也會,別逼逼了,趕緊寫起來...

2 版本1:跑起來了,卻不通用,卒

2.1# 原始碼實現

小帥很快就寫出了第一版,核心只有兩步:

  1. 第一步:收集依賴(effect函式),在讀取key時,將effect函式儲存起來
  2. 第二步:設定值時,將依賴(effect函式)執行
const $app = document.querySelector('#app')

const bucket = new Set()
   
const state = new Proxy({ text: 'hello fatfish' }, {
  get (target, key) {
    const value = target[ key ]
    // 第一步:收集依賴,在讀取key時,將effect函式儲存起來
    bucket.add(effect)
    console.log(`get ${key}: ${value}`)
    return value
  },
  set (target, key, newValue) {
    console.log(`set ${key}: ${newValue}`)

    target[ key ] = newValue
    // 第二步:設定值時,將依賴執行
    bucket.forEach((fn) => fn())
  }
})

function effect() {
  console.log('執行了effect')
  $app.innerText = state.text
}

effect()

setTimeout(() => {
  state.text = 'hello Vue3'
}, 1000)

效果預覽

點選預覽,噠噠噠,看起來很簡單哦,瞬間就完成啦!

2.2# 面試官點評

面試官: 功能是實現了,但是我不太滿意,你這裡收集依賴是寫死的函式名字effect,只要稍微變化一下題目,就不行了。

<div id="container">
  <div id="app1"></div>
  <div id="app2"></div>
</div>

const $app1 = document.querySelector('#app1')
const $app2 = document.querySelector('#app2')

const state = { 
  text: 'hello fatfish', 
  text2: 'hello fatfish2' 
}
// 改變app1的值
function effect1() {
  console.log('執行了effect')
  $app1.innerText = state.text
}
// 改變app2的值
function effect2() {
  console.log('執行了effect2')
  $app2.innerText = state.text2
}
// 1秒鐘之後兩個div的值要分別改變
setTimeout(() => {
  state.text = 'hello Vue3'
  state.text2 = 'hello Vue3-2'
}, 1000)

3# 版本2: 支援多屬性響應式修改和主動註冊

3.1# 原始碼實現

小帥心想: "大意了,我應該把effect依賴函式通過某種機制,主動註冊到桶中,這樣無論你是匿名函式亦或者是具名函式都一視同仁"

機靈的他馬上就想到了答案。


const bucket = new Set()

let activeEffect
// 變化點:
// 通過effect函式來主動收集依賴
const effect = function (fn) {
  // 每執行一次,將當前fn賦值給activeEffect,這樣在fn中觸發讀取操作時,就可以被收集進bucket中了
  activeEffect = fn
  // 主動執行一次很重要,必不可少
  fn()
}
const state = new Proxy({ text: 'hello fatfish', text2: 'hello fatfish2' }, {
  get (target, key) {
    const value = target[ key ]
    // 變化點:由版本1的effect變成了activeEffect,從而不再依賴具體的函式名字
    bucket.add(activeEffect)
    console.log(`get ${key}: ${value}`)
    return value
  },
  set (target, key, newValue) {
    console.log(`set ${key}: ${newValue}`)

    target[ key ] = newValue
    bucket.forEach((fn) => fn())
  }
})

effect(function effect1 () {
  console.log('執行了effect1')
  $app1.innerText = state.text
})

effect(function effect2() {
  console.log('執行了effect2')
  $app2.innerText = state.text2
})

setTimeout(() => {
  state.text = 'hello Vue3'
  state.text2 = 'hello Vue3-2'
}, 1000)

效果預覽
可以看到,此時app1和app2在1秒後都變成了對應值,目標達成。

點選檢視

3.2# 面試官點評

面試官:小夥子非常不錯,思路靈活,變通很快嘛!不過你有沒有想過一個問題?

state上增加一個之前不存在的屬性,你的bucket卻會把收集的依賴執行一次,是不是有點浪費?

能否做到effect中依賴了state的什麼值,其值改變了回撥才會被執行?

4# 版本3:推倒重來,再次設計"桶"資料結構

4.1# 重新設計資料結構

小帥: 心裡有點沒底了,簡歷上寫精通Vue,深入研究過Vue原始碼真TM巨坑啊!

面試還得繼續,苦思冥想之後終於明白了第二個版本的問題所在:

沒有在effect函式與被操作的目標欄位之間建立明確的聯絡

const state = new Proxy({ text: 'hello fatfish' }, {
  get (target, key) {
    const value = target[ key ]
    // 無論`state`上啥屬性被讀取了,都會執行`get`然後被收集進`bucket`
    bucket.add(effect)
    
    return value
  },
  set (target, key, newValue) {
    target[ key ] = newValue
    // 無論`state`上啥值被修改了,都會觸發`set`,進而收集的依賴被執行。
    bucket.forEach((fn) => fn())
  }
})

1. 新的對映關係

該如何設計bucket中儲存的值呢?我們們先來看看關鍵程式碼


effect(function effectFn () {
  $app.innerText = state.text
})

這段程式碼中有幾個角色:

  1. 被操作(讀取)的代理物件state
  2. 被操作的(讀取)的欄位名text
  3. 使用effect函式註冊的effectFn函式

那麼他們之間的關係可以用一顆樹來表述

state
    |__key
       |__effectFn

2. 場景1:有兩個effectFn讀取同一個物件的屬性值

effect(function effectFn1 () {
  // 讀取text
  state.text
})

effect(function effectFn2 () {
  // 讀取text
  state.text
})

那麼按照上面樹形結構,現在表示如下:
text屬性應該要和effectFn1effectFn2建立聯絡

state
    |__text
       |__effectFn1
       |__effectFn2

3. 場景2:effectFn中讀取了同一個物件的多個不同屬性

effect(function effectFn1 () {
  // 讀取text1和text2
  state.text
  state.text2
})

texttext2屬性應該要和effectFn1建立聯絡

state
    |__text
       |__effectFn1
    |__text2
       |__effectFn1 

4. 場景3:不同的effectFn中讀取了不同物件的不同屬性

 effect(function effectFn1 () {
   // 讀取text1
   state1.text1
 })
 
 effect(function effectFn2 () {
   // 讀取text2
   state2.text2
 })
 

對應的關係表示如下:

state1
     |__text1
        |__effectFn1

state2
     |__text2
        |__effectFn2

看到這裡,相信聰明的你一定明白了,當我們改變了state2.text2的值時,只有effectFn2函式會被重新執行,而effectFn1卻不會。當然了新增一個以往不存在的屬性時,effectFn1和effectFn2都不會被執行。

5. 畫一個資料結構圖來理解一下儲存關係:

4.2# 原始碼實現

6: 新版原始碼實現


const $app = document.querySelector('#app')
// 重新定義bucket資料型別為WeakMap
const bucket = new WeakMap()
let activeEffect
const effect = function (fn) {
  activeEffect = fn
  fn()
}
const state = new Proxy({ name: 'fatfish', age: 100 }, {
  get (target, key) {
    const value = target[ key ]
    // activeEffect無值意味著沒有執行effect函式,無法收集依賴,直接return掉
    if (!activeEffect) {
      return
    }
    // 每個target在bucket中都是一個Map型別: key => effects
    let depsMap = bucket.get(target)
    // 第一次攔截,depsMap不存在,先建立聯絡
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    // 根據當前讀取的key,嘗試讀取key的effects函式
    let deps = depsMap.get(key)

    if (!deps) {
      // deps本質是個Set結構,即一個key可以存在多個effect函式,被多個effect所依賴
      depsMap.set(key, (deps = new Set()))
    }
    // 將啟用的effectFn存進桶中
    deps.add(activeEffect)

    console.log(`get ${key}: ${value}`)
    return value
  },
  set (target, key, newValue) {
    console.log(`set ${key}: ${newValue}`)
    // 設定屬性值
    target[ key ] = newValue
    // 讀取depsMap 其結構是 key => effects
    const depsMap = bucket.get(target)

    if (!depsMap) {
      return
    }
    // 真正讀取依賴當前屬性值key的effects
    const effects = depsMap.get(key)
    // 挨個執行即可
    effects && effects.forEach((fn) => fn())
  }
})

effect(() => {
  console.log('執行了effect')
  $app.innerText = `hello ${ state.name }, are you ${state.age} years old?`
})

setTimeout(() => {
  state.name = 'Vue3'
  state.age = 18
}, 1000)

效果預覽

點選檢視

可以看到我們給state新增了一個屬性text但是effect並不會被執行,修改了name屬性為juejin之後才被執行了,而檢視層也更新了。

4.3# 面試官點評

牛逼,牛逼,差點給我整懵逼,小弟佩服!

不過能不能再進一步,你這隻能對state一個物件進行響應式處理,能不能再封裝一下,像Vue3裡面使用reactive一樣使用?

5# 版本4:reactive抽象,有點Vue3的味道了

5.1# 原始碼實現

小帥心想:你一定是不想讓我面試通過,故意刁難我,不過你是面試官你最大。搞就搞。

前面我們已經實現了基本的響應式功能,不過為了通用化,我們可以進一步封裝。

const bucket = new WeakMap()
// 重新定義bucket資料型別為WeakMap
let activeEffect
const effect = function (fn) {
  activeEffect = fn
  fn()
}
// track表示追蹤的意思
function track (target, key) {
  // activeEffect無值意味著沒有執行effect函式,無法收集依賴,直接return掉
  if (!activeEffect) {
    return
  }
  // 每個target在bucket中都是一個Map型別: key => effects
  let depsMap = bucket.get(target)
  // 第一次攔截,depsMap不存在,先建立聯絡
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 根據當前讀取的key,嘗試讀取key的effects函式  
  let deps = depsMap.get(key)

  if (!deps) {
    // deps本質是個Set結構,即一個key可以存在多個effect函式,被多個effect所依賴
    depsMap.set(key, (deps = new Set()))
  }
  // 將啟用的effectFn存進桶中
  deps.add(activeEffect)
}
// trigger執行依賴
function trigger (target, key) {
  // 讀取depsMap 其結構是 key => effects
  const depsMap = bucket.get(target)

  if (!depsMap) {
    return
  }
  // 真正讀取依賴當前屬性值key的effects
  const effects = depsMap.get(key)
  // 挨個執行即可
  effects && effects.forEach((fn) => fn())
}
// 統一對外暴露響應式函式
function reactive (state) {
  return new Proxy(state, {
    get (target, key) {
      const value = target[ key ]

      track(target, key)
      console.log(`get ${key}: ${value}`)
      return value
    },
    set (target, key, newValue) {
      console.log(`set ${key}: ${newValue}`)
      // 設定屬性值
      target[ key ] = newValue

      trigger(target, key)
    }
  })
}

有了上面的封裝我們們使用起來就真的有點Vue3的感覺啦!

const $app = document.querySelector('#app')

const nameObj = reactive({
  name: 'fatfish'
})
const ageObj = reactive({
  age: 100
})

effect(() => {
  console.log('執行了effect')
  $app.innerText = `hello ${ nameObj.name }, are you ${ageObj.age} years old?`
})

setTimeout(() => {
  nameObj.name = 'Vue3'
}, 1000)

setTimeout(() => {
  ageObj.age = 18
}, 2000)

效果預覽

點選預覽

可以看到我們們通過reactive定義了兩個響應式資料,在1秒後修改了nameObj的值,檢視也馬上更新了,2秒後修改了ageObj的值,檢視也馬上更新了。這下夠通用了吧!完美

5.2# 面試官點評

面試官: 你特別優秀,不過...

小帥:不過你妹啊! 我就面個試,你要我造個Vue3不成?

面試官: 好好好,哈哈哈!這輪面試通過了,接下來二面面試官會繼續讓你實現更全面的響應式系統,好好準備把!

小帥: 心裡一萬個草泥馬飛過...

6.# 下集預告

雖然小帥已經得到了一面面試官的認可,但是就目前實現的響應式系統來說還是不夠完善,問題依舊不少比如:

  1. effect巢狀執行會有啥問題?
  2. 會不會出現迴圈依賴,死迴圈等問題?
  3. ...

這些問題請看小帥在二面如何解決,敬請期待。

最後

希望能一直給大家分享實用、基礎、進階的知識點,一起早早下班,快樂摸魚。

期待你在掘金關注我:前端胖頭魚,也可以在公眾號裡找到我:前端胖頭魚

參考

Vue.js設計與實現

相關文章