Hooks API 在 Vue 中的實現分析

WirelessSprucetec發表於2019-01-25

作者: 長峰

初次聽到 React Hooks,是在其剛釋出的那幾天,網上鋪天蓋地的文章介紹它。看字面意思是 「React 鉤子」,就想當然地理解應該是修改 React 元件的鉤子吧。React 延伸的概念非常多,高階元件、函式式、Render Props、Context、等等。又來了一個新概念,前端開發已經夠複雜了!近兩年一直用 Vue,覺得 React 相關的諸多特性,在 Vue 中也都有類似的解決方案,所以就沒有立即去了解它。

後來看到尤大在 Vue 3.0 最近進展 的視訊中也提到了 Hooks API,並寫了一個在 Vue 中使用 Hooks 的 POC。看來 Hooks 還是挺重要的,於是馬上找到官方的 React Hooks 文件與釋出會的視訊 -- 又一輪的惡補。

看了相關資料,覺得 Hooks 的應用前景還是挺誘人的,解決了目前前端開發中的諸多痛點。不過 React Hooks 目前還在 alpha 階段,不太完善,內建 Hooks 也不豐富。而 Vue 只有個 Hooks POC,Vue3.0 很可能會加上,但需要再等幾個月。所以暫不建議在正式程式碼中使用。

本篇文章著重解釋一下我對 Hooks 的理解,以及 Hooks API 在 Vue 中的原始碼實現。也說明一下 Hooks 是個中立的概念,可以在任何框架中使用,非 React 所獨有 :)。

Hooks解決了什麼問題

在開始之前,我們先複述一下 Hooks 會幫我們解決什麼問題。

按照 Dan 的說法,React 專案的開發中遇到了以下幾個痛點:

  1. 跨元件程式碼,難以複用。
  2. 大元件,難以維護。
  3. 元件樹層級,往往巢狀很深。
  4. 類元件,不容易理解。

當然 Vue 專案也是一樣,這些問題其實也是相關聯的。

元件化的開發方式,我們將頁面拆分成不同的元件,按自上而下的資料流,層層巢狀。程式碼結構的最小顆粒是元件。

如果某些元件太大,我們就繼續拆分成更小的元件,然後在父元件中呼叫它。 如果多元件之間有不少通用邏輯,我們就用 mixin 或 構建元件的繼承體系。

問題是,元件拆分,會使我們很容易不小心就把元件的層級搞得很深,增加系統複雜度不說,效能也可能受到影響。並且,有些元件的互動邏輯確實比較複雜,拆分不得,系統長期迭代下來,累積的程式碼量很大,相關聯的邏輯分散在元件不同的生命週期中,難以維護。

跨元件邏輯複用更加棘手!mixin 是一個雙刃劍(參考: mixin 是有害的);元件繼承也不可取,雖然在強型別的面嚮物件語言(如:Java/C#)中,繼承用著很好,但在 JavaScript 中總感到力不從心,也使得程式碼晦澀難懂;抽取 util 包也是一個慣用的做法,但,如果要抽取的公用邏輯需關聯元件的本地狀態呢,如果相關聯的公用邏輯需要分散在元件的不同生命週期中呢,就搞不定了!這時候,我們往往就妥協了 -- 大元件/重複邏輯產生了。

類元件也讓人愛恨交織,一直以來我們也提倡用物件導向的方式抽象程式碼結構,在沒有更好的解決方案之前,確實是個不錯的選擇。但我個人覺得在 JavaScript 中,特別是在基於 React/Vue 元件體系的開發中,並不很合適。我們經常需要奇技淫巧的手段使 JavaScript 型別能夠支援 super、私有成員,並小心地處理函式中 this 的指向。得益於 JavaScript 的靈活性與強大的表現力,總能夠找到正確書寫程式碼的方式。問題是,這樣的程式碼怎麼維護呢,我們希望程式碼簡潔明瞭,符合慣例寫法,而非晦澀難懂、雷區遍佈。單說 this,我們知道 JavaScript 的基於靜態作用域的,即從原始碼上看,就能夠推斷變數的作用域,但 this 卻是個例外,它是基於動態作用域的,就是說 this 的值是由呼叫者決定的。同一個方法,用不同的方式呼叫,其 this 指向完全不一樣,使得我們不得不大量使用 bind,以保證 this 的指向。

關於 JavaScript 中的 this 用法,感興趣的同學,可參考:詳解 this

如何解決這些痛點呢 -- Hooks !

Hooks 是什麼

wikipedia 上關於 hooks 的定義是:

The term hooking covers a range of techniques used to alter or augment the behavior of an operating system, of applications, or of other software components by intercepting function calls or messages or events passed between software components. Code that handles such intercepted function calls, events or messages is called a hook.

翻譯成中文含義是:Hooks 包含了一系列技術,用於改變或增強作業系統、應用程式、軟體元件的行為。這些技術通過攔截軟體執行過程中的函式呼叫、訊息、事件來實現。

就是說通過 Hooks,我們能夠後期改變或增強已有系統的執行時行為。那麼,對應到 React/Vue 元件系統,則 Hooks 是可以改變或增強元件執行時行為的程式碼模組。

通過閱讀 React Hooks 的技術文件,的確如此。React 提供了兩個重要的內建 Hooks :

  • useState -- 為元件新增本地響應式狀態。
  • useEffect -- 為元件新增狀態更新後,需要執行的副作用邏輯。

React 還提供了其它一些,元件特性相關的內建 Hooks,如 useContext、useReducer、useMemo、useRef 等等。未來應該會出現更多的內建 Hooks,切入元件執行時的方方面面。我們也可以基於這些內建 Hooks,實現自定義 Hooks。

React 中強調 Hooks 只能在函式式元件中使用。函式式元件本質上是一個單純的渲染函式,無狀態、資料來源於外部。那麼如何給元件新增本地狀態,以及各種生命週期相關的業務邏輯呢?答案是:通過 Hooks。React 團隊希望未來「函式式元件 + Hooks」成為開發元件的主要方式,那麼 Hooks 應該有能力侵入元件生命週期的每個環節,以便為元件新增狀態與行為。雖然目前 React 提供的 Hooks 還不夠豐富,後續會逐漸完善。

綜上所述,我們發現,Hooks 可以使我們模組化開發的粒度更細,更函式式。元件的功能變成了由 Hooks 一點點地裝配起來。這樣的特性,也解決了上面提到的4個痛點:程式碼複用、大元件、元件樹過深、類元件問題。

關於 React Hooks 的背景及諸多示例,請參考:Introducing Hooks

對於 Vue ,除了 useState、useEffect、useRef 與 React Hooks API 一致外,還可以實現 useComputed、useMounted、useUpdated、useWatch 等內建 Hooks,以便能夠更細緻地為元件新增功能。

Hooks API 的 Vue 實現

這裡分析一下 尤大的Hooks POC of Vue 的原始碼實現,以便加深對 Hooks API 的理解。

withHooks

我們知道 React Hooks 只能在函式式元件中使用,Vue 中也要這樣定義。

withHooks 用於包裝一個 Vue 版的「函式式元件」,在這個函式式元件中,您可以使用 Hooks API。

withHooks 使用示例:

import { withHooks, useData, useComputed } from "vue-hooks"

const Foo = withHooks(h => {
  const data = useData({
    count: 0
  })
  const double = useComputed(() => data.count * 2)
  return h('div', [
    h('div', `count is ${data.count}`),
    h('div', `double count is ${double}`),
    h('button', { on: { click: () => {
      data.count++
    }}}, 'count++')
  ])
})
複製程式碼

程式碼中 withHooks 包裝了一個函式式元件(渲染函式),通過 Hooks 為元件新增了一個本地狀態 data,及一個計算屬性 double。

注意:程式碼中的 useData 與 useState 類似,下文會解釋。

withHooks 實現細節:

let currentInstance = null
let isMounting = false
let callIndex = 0

function ensureCurrentInstance() {
  if (!currentInstance) {
    throw new Error(
      `invalid hooks call: hooks can only be called in a function passed to withHooks.`
    )
  }
}

export function withHooks(render) {
  return {
    data() {
      return {
        _state: {}
      }
    },
    created() {
      this._effectStore = {}
      this._refsStore = {}
      this._computedStore = {}
    },
    render(h) {
      callIndex = 0
      currentInstance = this
      isMounting = !this._vnode
      const ret = render(h, this.$attrs, this.$props)
      currentInstance = null
      return ret
    }
  }
}
複製程式碼

程式碼中:

withHooks 為元件新增了一個私有本地狀態 _state,用於儲存 useState、useData 所關聯的狀態值。

在 created 中,為元件注入了一些支援 Hooks ( useEffect、useRef、useComputed ) 所需要的儲存類物件。

重點是程式碼中的 render 函式:

  • callIndex,為 Hooks 相關的儲存物件提供 key。這裡每次渲染,都重置為 0,是為了能夠根據呼叫次序匹配對應的 Hooks。這樣處理也限制了 Hooks 只能在頂級程式碼中呼叫。
  • currentInstance,結合 ensureCurrentInstance 函式,用於確保 Hooks 只能在函式式元件中使用。
  • isMounting,用於標識元件的掛載狀態

useState

useState 用於為元件新增一個響應式的本地狀態,及該狀態相關的更新器。

方法簽名為:

const [state, setState] = useState(initialState);

setState 用於更新狀態:

setState(newState);

useState 使用示例:

import { withHooks, useState } from "vue-hooks"
const Foo = withHooks(h => {
  const [count, setCount] = useState(0)
  return h("div", [
    h("span", `count is: ${count}`),
    h("button", { on: { click: () => setCount(count + 1) } }, "+" )
  ])
})
複製程式碼

程式碼中,通過 useState 為元件新增了一個本地狀態 count 與更新狀態值用的函式 setCount。

useState 實現細節:

export function useState(initial) {
  ensureCurrentInstance()
  const id = ++callIndex
  // 獲取元件例項的本地狀態。
  const state = currentInstance.$data._state
  // 本地狀態更新器,以自增id為鍵值,儲存到本地狀態中。
  const updater = newValue => {
    state[id] = newValue
  }
  if (isMounting) {
    // 通過$set保證其是響應式狀態。
    currentInstance.$set(state, id, initial)
  }
  // 返回響應式狀態與更新器。
  return [state[id], updater]
}
複製程式碼

以上程式碼,很清晰地描述了 useState 是在元件中建立了一個本地響應式狀態,並生成了一個狀態更新器。

需要注意的是:

  • 函式 ensureCurrentInstance 是為了確保 useState 必須在 render 中執行,也就是限制了必須在函式式元件中執行。
  • 以 callIndex 生成的自增 id 作為儲存狀態值的 key。說明 useState 需要依賴第一次渲染時的呼叫順序來匹配過去的 state(每次渲染 callIndex 都要重置為0)。這也限制了 useState 必須在頂層程式碼中使用。
  • 其它 hooks 也必須遵循以上兩點。

useEffect

useEffect 用於新增元件狀態更新後,需要執行的副作用邏輯。

方法簽名:

void useEffect(rawEffect, deps)

useEffect 指定的副作用邏輯,會在元件掛載後執行一次、在每次元件渲染後根據指定的依賴有選擇地執行、並在元件解除安裝時執行清理邏輯(如果指定了的話)。

呼叫示例 1:

import { withHooks, useState, useEffect } from "vue-hooks"

const Foo = withHooks(h => {
  const [count, setCount] = useState(0)
  useEffect(() => {
    document.title = "count is " + count
  })
  return h("div", [
    h("span", `count is: ${count}`),
    h("button", { on: { click: () => setCount(count + 1) } }, "+" )
  ])
})
複製程式碼

程式碼中,通過 useEffect 使每當 count 的狀態值變化時,都會重置 document.title。

注意:這裡沒有指定 useEffect 的第二個引數 deps,表示只要元件重新渲染都會執行 useEffect 指定的邏輯,不限制必須是 count 變化時。

useEffect 詳細的引數說明,請參考:Using the Effect Hook

呼叫示例 2:

import { withHooks, useState, useEffect } from "vue-hooks"

const Foo = withHooks(h => {
  const [width, setWidth] = useState(window.innerWidth)
  const handleResize = () => {
    setWidth(window.innerWidth)
  };
  useEffect(() => {
    window.addEventListener("resize", handleResize)
    return () => {
      window.removeEventListener("resize", handleResize)
    }
  }, [])

  return h("div", [
    h("div", `window width is: ${width}`)
  ])
})
複製程式碼

程式碼中,通過 useEffect 控制在視窗改變時重新獲取其寬度。

useEffect 第一個引數的返回值,如果是函式的話,則定義其為清理邏輯。清理邏輯會在元件需要重新執行 useEffect 邏輯之前,或元件被銷燬時執行。

這裡在 useEffect 邏輯中,為 window 物件新增了 resize 事件,那麼就需要在元件銷燬時或需要重新執行該副作用邏輯時,先把 resize 事件登出掉,以避免不必要的事件處理。

注意,這裡 useEffect 的第二個引數的值是 [],表明無依賴項,副作用邏輯只在元件 mounted 時執行一次,這樣處理也符合這裡的上下文場景。

useEffect 實現細節:

export function useEffect(rawEffect, deps) {
  ensureCurrentInstance()
  const id = ++callIndex
  if (isMounting) {
    // 元件掛載前,重新包裝「清理邏輯」與「副作用邏輯」。
    const cleanup = () => {
      const { current } = cleanup
      if (current) {
        current()
        // 清理邏輯執行完,則重置回 null;
        // 如果副作用邏輯二次執行,cleanup.current 會被重新賦值。
        cleanup.current = null
      }
    }
    const effect = () => {
      const { current } = effect
      if (current) {
        // rawEffect 的返回值,如果是一個函式的話,則定義為 useEffect副作用 的清理函式。
        cleanup.current = current()
        // rawEffect 執行完,則重置為 null;
        // 如果相關的 deps 發生變化,需要二次執行 rawEffect 時 effect.current 會被重新賦值。
        effect.current = null
      }
    }
    effect.current = rawEffect
    // 在元件例項上,儲存 useEffect 相關輔助成員。
    currentInstance._effectStore[id] = {
      effect,
      cleanup,
      deps
    }
    // 元件例項 mounted 時,執行 useEffect 邏輯。
    currentInstance.$on('hook:mounted', effect)
    // 元件例項 destroyed 時,執行 useEffect 相關清理邏輯。
    currentInstance.$on('hook:destroyed', cleanup)
    // 若未指定依賴項或存在明確的依賴項時,元件例項 updated 後,執行 useEffect 邏輯。
    // 若指定依賴項為 [], 則 useEffect 只會在 mounted 時執行一次。
    if (!deps || deps.length > 0) {
      currentInstance.$on('hook:updated', effect)
    }
  } else {
    const record = currentInstance._effectStore[id]
    const { effect, cleanup, deps: prevDeps = [] } = record
    record.deps = deps
    if (!deps || deps.some((d, i) => d !== prevDeps[i])) {
      // 若依賴的狀態值有變動時,在副作用重新執行前,執行清理邏輯。
      cleanup()
      // useEffect 執行完畢後,會將 current 的屬性置為 null. 這裡為 effect.current 重新賦值,
      // 是為了在 updated 後執行 rawEffect 邏輯。
      effect.current = rawEffect
    }
  }
}
複製程式碼

可以看到,useEffect 的實現比較精巧,涉及到了元件的三個生命週期:mounted、updated、destroyed,副作用邏輯的執行細節由引數 deps 控制:

  • mounted 時,固定地執行一次。
  • 如果 deps 未指定,則每次 updated 後都執行一次。
  • 如果 deps 為空陣列,則 updated 後不執行。
  • 如果 deps 指定了依賴項,則當相應的依賴項的值改變時,執行一次。

通過引數,我們可以為 useEffect 指定 3 種資訊:

  • rawEffect - 副作用邏輯內容。
  • 清理邏輯 - 通過 rawEffect 的返回值定義。
  • 依賴 - 定義何時需要重複執行副作用邏輯。

其中,清理邏輯,會在 2 種情況下執行:

  • rawEffect 需要重複執行之前,清理上次執行所帶來的副作用。
  • 元件銷燬時。

useRef

相當於為元件新增一個本地變數(非元件狀態)。

方法簽名:

const refContainer = useRef(initialValue)

useRef 實現細節:

export function useRef(initial) {
  ensureCurrentInstance()
  const id = ++callIndex
  const { _refsStore: refs } = currentInstance
  return isMounting ?
    (refs[id] = {
      current: initial
    }) :
    refs[id]
}
複製程式碼

程式碼中,useRef 指定的初始值,連同元件本身的 refs 定義,被儲存到了內部物件 _refsStore 中。在元件的渲染函式中,隨時可拿到 ref 物件:refContainer,獲取或修改其中的 current 屬性。

useData

useData 與 useState 類似,不同的是,useData 不提供更新器。

useData 實現細節:

export function useData(initial) {
  const id = ++callIndex
  const state = currentInstance.$data._state
  if (isMounting) {
    currentInstance.$set(state, id, initial)
  }
  return state[id]
}
複製程式碼

useMounted

新增需要在 mounted 事件中執行的邏輯。

useMounted 實現細節:

export function useMounted(fn) {
  useEffect(fn, [])
}
複製程式碼

這個就比較簡單了,上文中提到,如果 useEffect 的引數 deps 指定為空陣列的話,fn 就不在 updated 後執行了 -- 即僅在 mounted 時執行一次.

useDestroyed

新增需要在 destroyed 階段執行的邏輯。

useDestroyed 實現細節:

export function useDestroyed(fn) {
  useEffect(() => fn, [])
}
複製程式碼

上文中提到 useEffect 第一個引數的返回值,如果是函式的話,會在 destroyed 階段作為清理邏輯執行。

這裡,通過設定引數 deps 的值為空陣列,並把 fn 指定為 useEffect 的副作用邏輯的返回值,避免了 fn 在元件更新時被執行,使 fn 僅在 destroyed 階段執行。

useUpdated

新增需要在元件更新後執行的邏輯。

useUpdated 實現細節:

export function useUpdated(fn, deps) {
  const isMount = useRef(true)  // 通過 useRef 生成一個識別符號。
  useEffect(() => {
    if (isMount.current) {
      isMount.current = false // 跳過 mounted.
    } else {
      return fn()
    }
  }, deps)
}
複製程式碼

也是通過 useEffect 實現,通過 useRef 宣告一個標誌變數,避免 useEffect 的副作用邏輯在 mounted 中執行。

useWatch

為元件新增 watch.

useWatch 實現細節:

export function useWatch(getter, cb, options) {
  ensureCurrentInstance()
  if (isMounting) {
    currentInstance.$watch(getter, cb, options)
  }
}
複製程式碼

直接通過元件例項的 $watch 方法實現。

useComputed

為元件新增 computed 屬性。

useComputed 實現細節:

export function useComputed(getter) {
  ensureCurrentInstance()
  const id = ++callIndex
  const store = currentInstance._computedStore
  if (isMounting) {
    store[id] = getter()
    currentInstance.$watch(
      getter,
      val => { store[id] = val },
      { sync: true }
    )
  }
  return store[id]
}
複製程式碼

這裡把計算屬性的值儲存在了內部物件 _computedStore 中。本質上,也是通過元件例項的 $watch 實現。

完整程式碼及示例

請參考:POC of vue-hooks

結論

熟悉了 Hooks 出現的背景、Hooks 定義、以及在 React/Vue 中的實現後,基本上可以得出以下結論:

  • Hooks 如果廣泛應用的話,將會大幅地改變了我們開發元件的方式。
  • 通過 Hooks,使我們能夠切入元件生命週期的各個環節,為函式式的純元件裝配狀態與行為。模組化粒度更細了,程式碼複用度高,也更高內聚鬆耦合了。
  • Hooks API 是個中立的概念,也可以在 Vue、或其它元件系統中使用,如:React's Hooks API implemented for web components
  • 以「純元件 + Hooks」的方式開發元件,我們基本上告別了捉摸不定的 this,程式碼更函式式了。未來也方便更進一步地使用函式式的柯里化、組合、惰性計算等諸多優勢,編寫更簡潔健壯的程式碼。
  • 通過 Hooks,使我們能夠根據業務邏輯的相關性組織程式碼模組,擺脫了類元件格式的限制。
  • Hooks 還處於早期階段,但是給我們開發元件提供了一個很好的思路,大家可以在 react-16.7.0-alpha.0 中體驗。

原文連結: tech.meicai.cn/detail/82, 也可微信搜尋小程式「美菜產品技術團隊」,乾貨滿滿且每週更新,想學習技術的你不要錯過哦。

相關文章