Hooks API 在 Vue 中的實現分析

做者: 長峯javascript

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

後來看到尤大在 Vue 3.0 最近進展 的視頻中也提到了 Hooks API,並寫了一個在 Vue 中使用 Hooks 的 POC。看來 Hooks 仍是挺重要的,因而立刻找到官方的 React Hooks 文檔與發佈會的視頻 -- 又一輪的惡補。前端

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

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

Hooks解決了什麼問題

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

按照 Dan 的說法,React 項目的開發中遇到了如下幾個痛點:react

  1. 跨組件代碼,難以複用。
  2. 大組件,難以維護。
  3. 組件樹層級,每每嵌套很深。
  4. 類組件,不容易理解。

固然 Vue 項目也是同樣,這些問題其實也是相關聯的。git

組件化的開發方式,咱們將頁面拆分紅不一樣的組件,按自上而下的數據流,層層嵌套。代碼結構的最小顆粒是組件。github

若是某些組件太大,咱們就繼續拆分紅更小的組件,而後在父組件中調用它。 若是多組件之間有很多通用邏輯,咱們就用 mixin 或 構建組件的繼承體系。web

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

跨組件邏輯複用更加棘手!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, 也可微信搜索小程序「美菜產品技術團隊」,乾貨滿滿且每週更新,想學習技術的你不要錯過哦。

相關文章
相關標籤/搜索