在 React 和 Vue 中嚐鮮 Hooks
在美國當地時間 10 月 26 日舉辦的 React Conf 2018
上,React 官方宣佈 React v16.7.0-alpha 將引入名為 Hooks
的新特性,在開發社群引發震動。
而就在同月 28 日左右,作為 “摸著鷹醬過河” 優秀而典型代表的 Vue.js 社群,其創始人 Evan You 就在自己的 github 上釋出了 vue-hooks
工具庫,其簡介為 “實驗性的 React hooks 在 Vue 的實現”。
到底是怎樣的一個新特性,讓大家如此關注、迅速行動呢?本文將嘗試做出簡單介紹和比較,看看其中的熱鬧,並一窺其門道。
I. 新鮮的 React Hooks
在 React v16.7.0-alpha 版本中,React 正式引入了新特性 Hooks
,其定義為:
Hooks 是一種新特性,致力於讓你不用寫類也能用到 state 和其他 React 特性
在琢磨這個定義之前,先直觀感受下官網中給出的第一個例子:
import { useState } from 'react'; function Example() { // Declare a new state variable, which we'll call "count" const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
換成之前的寫法,則等價於:
class Example extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> </div> ); } }
猜也差不多猜得到,useState() 這個新的內建方法抽象了原有的 this.setState() 邏輯。
為什麼又有新 API ?
自從 React 誕生後,其建立元件的方式從 ES5 時期宣告式的 createClass
,到支援原生 ES6 class 的 OOP 語法,再到發展出 HOC 或 render props 的函式式寫法,官方和社群一直在探索更方便合理的 React 元件化之路。
隨之而來的一些問題是:
-
元件往往變得巢狀過多
-
各種寫法的元件隨著邏輯的增長,都變得難以理解
-
尤其是基於類寫法的元件中,
this
關鍵字曖昧模糊,人和機器讀起來都比較懵 -
難以在不同的元件直接複用基於 state 的邏輯
-
人們不滿足於只用函式式元件做簡單的展示元件,也想把 state 和生命週期等引入其中
Hooks 就是官方為解決類似的問題的一次最新的努力。
II. 幾種可用的 Hooks
對開頭的官方定義稍加解釋就是:Hooks 是一種函式,該函式允許你 “勾住(hook into)” React 元件的 state 和生命週期。可以使用內建或自定義的 Hooks 在不同元件之間複用、甚至在同一組件中多次複用基於 state 的邏輯。
Hooks 在類內部不起作用,官方也並不建議馬上開始重寫現有的元件類,但可以在新元件中開始使用。
Hooks 主要分為以下幾種:
-
基礎 Hooks
-
useState
-
useEffect
-
useContext
-
其他內建 Hooks
-
useReducer
-
useCallback
-
useMemo
-
useRef
-
useImperativeMethods
-
useMutationEffect
-
useLayoutEffect
-
自定義 Hooks
2.1 State Hook
文章開頭的計數器例子就是一種 State Hook 的應用:
import { useState } from 'react'; function Example() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
這種最常用的 Hook 也展示了 Hooks 的通用邏輯:
-
呼叫
useState
方法,返回一個數組 -
這裡使用了
“array destructuring”
語法 -
陣列首個值相當於定義了
this.state.count
,命名隨意 -
陣列第二個值用來更新以上值,命名隨意,相當於
this.setState({count: })
-
useState
方法唯一的引數,就是所定義值的初始值
多次呼叫 Hooks
當需要用到多個狀態值時,不同於在 state 中都定義到一個物件中的做法,可以多次使用 useState() 方法:
const [age, setAge] = useState(42); const [fruit, setFruit] = useState('banana'); const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
應該注意到,同樣有別於傳統 state 的是,呼叫相應更新函式後,只會用新值替換舊值,而非合併兩者。
2.2 Effect Hook
所謂的 “Effect” 對應的概念叫做 “side effect”。指的是狀態改變時,相關的遠端資料非同步請求、事件繫結、改變 DOM 等;因為此類操作要麼會引發其他元件的變化,要麼在渲染週期中並不能立刻完成,所以就稱其為“副作用”。
傳統做法中,一般在 componentDidMount、componentDidUpdate、componentWillUnmount 等生命週期中分別管理這些副作用,邏輯分散而複雜。
在 Hooks 中的方案是使用 useEffect
方法,這相當於告訴 React 在每次更新變化到 DOM 後,就呼叫這些副作用;React 將在每次(包括首次) render()
後執行這些邏輯。
同樣看一個示例:
function FriendStatusWithCounter(props) { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); const [isOnline, setIsOnline] = useState(null); useEffect(() => { ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }; }); function handleStatusChange(status) { setIsOnline(status.isOnline); } // ... }
可以看出:
-
useEffect
一般可以搭配useState
使用 -
useEffect
接受一個函式作為首個引數,在裡面書寫副作用程式碼,比如繫結事件 -
若該函式返回一個函式,則返回的這個函式就作為相應副作用的 “cleanup”,比如解綁事件
-
同樣可以用多個
useEffect
分組不同的副作用,使邏輯更清晰;而非像原來一樣都方針同一個生命週期中
跳過副作用以優化效能
副作用往往都帶來一些效能消耗,傳統上我們可能這樣避免不必要的執行:
componentDidUpdate(prevProps, prevState) { if (prevState.count !== this.state.count) { document.title = `You clicked ${this.state.count} times`; } }
useEffect
中的做法則是傳入第二個可選引數:一個數組;陣列中的變數用來告訴 React,在重新渲染過程中,只有在其變化時,對應的副作用才應該被執行。
useEffect(() => { document.title = `You clicked ${count} times`; }, [count]);
2.3 自定義 Hooks
傳統上使用 HOC 或 render props 的寫法實現邏輯共享;而定義自己的 Hooks,可以將元件中的邏輯抽取到可服用的函式中去。
比如將之前例子中的 isOnline 狀態值邏輯抽取出來:
import { useState, useEffect } from 'react'; function useFriendStatus(friendID) { const [isOnline, setIsOnline] = useState(null); function handleStatusChange(status) { setIsOnline(status.isOnline); } useEffect(() => { ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange); }; }); return isOnline; }
在元件中呼叫:
function FriendStatus(props) { const isOnline = useFriendStatus(props.friend.id); if (isOnline === null) { return 'Loading...'; } return isOnline ? 'Online' : 'Offline'; }
在另一個元件中呼叫:
function FriendListItem(props) { const isOnline = useFriendStatus(props.friend.id); return ( <li style={{ color: isOnline ? 'green' : 'black' }}> {props.friend.name} </li> ); }
如上所示:
-
自定義 Hook 函式的引數是自由定義的
-
因為只是個純函式,所以不同元件都可以各自呼叫
-
使用
use
字首不是硬性要求,但確實是推薦使用的約定 -
不同元件只共享狀態邏輯,而不共享任何狀態
2.4 呼叫 Hooks 的兩個原則
-
只在 top level 呼叫 Hooks,而不能在迴圈、條件或巢狀函式中使用
-
只在 React 函式元件或自定義 Hooks 中呼叫,而不能在普通 JS 函式中
可以使用官方提供的 eslint 外掛保證以上原則: https://www.npmjs.com/package/eslint-plugin-react-hooks
III. Vue.js 社群的追趕
vue-hooks
庫: https://github.com/yyx990803/vue-hooks
目前該庫也宣告為實驗性質,並不推薦在正式產品中使用。
3.1 “React-style” 的 Hooks
vue-hooks
支援以下 Hooks,嗯呢,看著相當眼熟:
-
useState
-
useEffect
-
useRef
以及一個輔助方法:
-
withHooks
結合 Vue.js 中的 render()
,可以寫出非常函式式的 “React-like” 程式碼:
import Vue from "vue" import { withHooks, useState, useEffect } from "vue-hooks" // a custom hook... function useWindowWidth() { const [width, setWidth] = useState(window.innerWidth) const handleResize = () => { setWidth(window.innerWidth) }; useEffect(() => { window.addEventListener("resize", handleResize) return () => { window.removeEventListener("resize", handleResize) } }, []) return width } const Foo = withHooks(h => { // state const [count, setCount] = useState(0) // effect useEffect(() => { document.title = "count is " + count }) // custom hook const width = useWindowWidth() return h("div", [ h("span", `count is: ${count}`), h( "button", { on: { click: () => setCount(count + 1) } }, "+" ), h("div", `window width is: ${width}`) ]) }) new Vue({ el: "#app", render(h) { return h("div", [h(Foo), h(Foo)]) } })
3.2 “Vue-style” 的 Hooks
vue-hooks
也支援以下 Hooks,這些就非常接地氣了:
-
useData
-
useMounted
-
useDestroyed
-
useUpdated
-
useWatch
-
useComputed
以及一個 mixin 外掛:
-
hooks
這樣在不提供 Vue 例項的顯式 data
屬性的情況下,也實現了一種更函式式的開發體驗:
import { hooks, useData, useMounted, useWatch, useComputed } from 'vue-hooks' Vue.use(hooks) new Vue({ el: "#app", hooks() { const data = useData({ count: 0 }) const double = useComputed(() => data.count * 2) useWatch(() => data.count, (val, prevVal) => { console.log(`count is: ${val}`) }) useMounted(() => { console.log('mounted!') }) return { data, double } } })
3.3 實現淺析
vue-hooks
的原始碼目前只有不到 200 行, 非常簡明扼要的實現了以上提到的 Hooks 和方法等。
首先大體看一下:
let currentInstance = null let isMounting = false let callIndex = 0 //... export function useState(initial) { //... } export function useEffect(rawEffect, deps) { //... } export function useRef(initial) { //... } export function useData(initial) { //... } export function useMounted(fn) { //... } export function useDestroyed(fn) { //... } export function useUpdated(fn, deps) { //... } export function useWatch(getter, cb, options) { //... } export function useComputed(getter) { //... } 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.$props) currentInstance = null return ret } } } export function hooks (Vue) { Vue.mixin({ beforeCreate() { const { hooks, data } = this.$options if (hooks) { this._effectStore = {} this._refsStore = {} this._computedStore = {} this.$options.data = function () { const ret = data ? data.call(this) : {} ret._state = {} return ret } } }, beforeMount() { const { hooks, render } = this.$options if (hooks && render) { this.$options.render = function(h) { callIndex = 0 currentInstance = this isMounting = !this._vnode const hookProps = hooks(this.$props) Object.assign(this._self, hookProps) const ret = render.call(this, h) currentInstance = null return ret } } } }) }
基本的結構非常清楚,可以看出:
-
withHooks
返回一個包裝過的 Vue 例項配置 -
hooks
以 mixin 的形式發揮作用,注入兩個生命週期 -
用模組區域性變數 currentInstance 記錄了 Hooks 生效的 Vue 例項
其次值得注意的是處理副作用的 useEffect
:
export function useEffect(rawEffect, deps) { //... if (isMounting) { const cleanup = () => { const { current } = cleanup if (current) { current() cleanup.current = null } } const effect = () => { const { current } = effect if (current) { cleanup.current = current() effect.current = null } } effect.current = rawEffect currentInstance._effectStore[id] = { effect, cleanup, deps } currentInstance.$on('hook:mounted', effect) currentInstance.$on('hook:destroyed', cleanup) if (!deps || deps.lenght > 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() effect.current = rawEffect } } }
其核心大致軌跡如下:
-
宣告 effect 函式和 cleanup 函式
-
將呼叫 Hook 時傳入的 rawEffect 賦值到 effect.current 屬性上
-
effect() 執行後,將 rawEffect 執行後的返回值賦值到 cleanup.current 上
-
在 Vue 本身就支援的幾個
hook:xxx
生命週期鉤子事件中,呼叫 effect 或 cleanup
//vue/src/core/instance/lifecycle.js Vue.prototype.$destroy = function () { //... callHook(vm, 'destroyed') //... } //... export function callHook (vm, hook) { //... if (vm._hasHookEvent) { vm.$emit('hook:' + hook) } //... }
這樣再去看這兩個 Hook 就敞亮多了:
export function useMounted(fn) { useEffect(fn, []) } export function useDestroyed(fn) { useEffect(() => fn, []) }
另外常用的 useData
也是利用了 Vue 例項的 $set
方法,清晰易懂:
export function useData(initial) { //... if (isMounting) { currentInstance.$set(state, id, initial) } return state[id] }
同樣利用例項方法的:
export function useWatch(getter, cb, options) { //... if (isMounting) { currentInstance.$watch(getter, cb, options) } }
其餘幾個 Hooks 的實現大同小異,就不逐一展開說明了。
IV. 總結
-
React Hooks 是簡化元件定義、複用狀態邏輯的一種最新嘗試
-
vue-hooks 很好的實現了相同的功能,並且結合 Vue 例項的特點提供了適用的 Hooks
V. 參考資料
-
https://reactjs.org/docs/hooks-intro.html
-
https://github.com/yyx990803/vue-hooks/blob/master/README.md
-
https://www.zhihu.com/question/300049718/answer/518641446
-
https://mp.weixin.qq.com/s/GgJqG82blfNnNWqRWvSbQA