原文:https://dev.to/voluntadpear/comparing-react-hooks-with-vue-composition-api-4b32
Vue 最近提出了 Composition API RFC,一種新的書寫 Vue 組件的 API;該 API 受到 React Hooks 的啟發,但有一些有趣的差異,也就是本文要探討的內容。該 RFC 始自於在社區某些部分受到 大量非議 的之前一個叫做 Function-based Component API 的版本 -- 人們擔心 Vue 開始變得更復雜而不像大家最初喜歡它時那樣是個簡單的庫了。
Vue 核心團隊解決了圍繞首個 RFC 的困惑並在新的版本中提出了一些引人關注的調整,也對提案改變的背后動機提供了進一步的見解。如果你對向 Vue 核心團隊給出一些關於新提案反饋方面感興趣,可以參與到 https://github.com/vuejs/rfcs/pull/78 中。
注意: Vue Composition API 仍在不斷改進,會收到特性改變的影響。在 Vue 3.0 到來之前不要把 Vue Composition API 視為 100% 確定的。
React Hooks 允許你 "勾入" 諸如組件狀態和副作用處理等 React 功能中。Hooks 只能用在函數組件中,並允許我們在不需要創建類的情況下將狀態、副作用處理和更多東西帶入組件中。自從 2018 年被引入,社區對其一見傾心。
React 核心團隊奉上的采納策略是不反對類組件,所以你可以升級 React 版本、在新組件中開始嘗試 Hooks,並保持既有組件不做任何更改。
那么,開始學習 React Hooks 和 Vue Composition API 不同的方面並記錄某些我們會遇到的區別吧 ⏯
React Hooks
例子:
import React, { useState, useEffect } from "react"; const NoteForm = ({ onNoteSent }) => { const [currentNote, setCurrentNote] = useState(""); useEffect(() => { console.log(`Current note: ${currentNote}`); }); return ( <form onSubmit={e => { onNoteSent(currentNote); setCurrentNote(""); e.preventDefault(); }} > <label> <span>Note: </span> <input value={currentNote} onChange={e => { const val = e.target.value && e.target.value.toUpperCase()[0]; const validNotes = ["A", "B", "C", "D", "E", "F", "G"]; setCurrentNote(validNotes.includes(val) ? val : ""); }} /> </label> <button type="submit">Send</button> </form> ); };
useState
和 useEffect
是 React Hooks 中的一些例子,使得函數組件中也能增加狀態和運行副作用;稍后我們還會看到其他 hooks,甚至能自定義一個。這些 hooks 打開了代碼復用性和擴展性的新大門。
Vue Composition API
例子:
<template> <form @submit="handleSubmit"> <label> <span>Note:</span> <input v-model="currentNote" @input="handleNoteInput"> </label> <button type="submit">Send</button> </form> </template> <script> import { ref, watch } from "vue"; export default { props: ["divRef"], setup(props, context) { const currentNote = ref(""); const handleNoteInput = e => { const val = e.target.value && e.target.value.toUpperCase()[0]; const validNotes = ["A", "B", "C", "D", "E", "F", "G"]; currentNote.value = validNotes.includes(val) ? val : ""; }; const handleSubmit = e => { context.emit("note-sent", currentNote.value); currentNote.value = ""; e.preventDefault(); }; return { currentNote, handleNoteInput, handleSubmit, }; } }; </script>
Vue Composition API 圍繞一個新的組件選項 setup
而創建。setup()
為 Vue 組件提供了狀態、計算值、watcher 和生命周期鈎子。
這個新的 API 並沒有讓原來的 API(現在被稱作 "Options-based API")消失。提案的當前迭代甚至允許開發者 結合使用新舊兩種 APIs。
注意:可以在 Vue 2.x 中通過 @vue/composition-api 插件嘗試新 API。
代碼的執行
Vue Composition API 的 setup()
晚於 beforeCreate
鈎子(在 Vue 中,“鈎子”就是一個生命周期方法)而早於 created
鈎子被調用。這是我們可以分辨 React Hooks 和 Vue Composition API 的首個區別, React hooks 會在組件每次渲染時候運行,而 Vue setup()
只在組件創建時運行一次。因為前者可以多次運行,所以 render 方法必須遵守 某些規則,其中之一是:
不要在循環內部、條件語句中或嵌套函數里調用 Hooks
直接貼一段 React 文檔中的代碼來展示這一點:
function Form() { // 1. 使用 name 狀態變量 const [name, setName] = useState('Mary'); // 2. 使用一個持久化表單的副作用 if (name !== '') { useEffect(function persistForm() { localStorage.setItem('formData', name); }); } // 3. 使用 surname 狀態變量 const [surname, setSurname] = useState('Poppins'); // 4. 使用一個更新 title 的副作用 useEffect(function updateTitle() { document.title = `${name} ${surname}`; }); // ... }
React 在內部保持了對我們用於組件中所有 hooks 的跟蹤。在本例中,我們用了四個 hooks。注意第一個 useEffect
調用是如何條件性的完成的,由於首次渲染中 name
會被默認值 'Mary'
賦值,條件會被評估為 true
,React 也會知道需要按順序的保持對所有四個 hooks 的跟蹤。但如若在另一次渲染中 name
為空會發生什么?在那種情況下,React 將不知道第二個 useState
hook 該返回什么 ????(譯注:React 默認靠 hook 調用的順序為其匹配對應的狀態,連續兩個 useState 會造成后面的 hook 提前執行)。要避免類似的問題,強烈推薦在處理 React Hooks 時使用一個 eslint-plugin-react-hooks 插件,它也默認包含在了 Create React App 中。
那么如果我們想要在 name
為空時也運行對應的副作用呢?可以簡單的將條件判斷語句移入 useEffect
回調內部:
useEffect(function persistForm() { if (name !== '') { localStorage.setItem('formData', name); } });
回過頭看看 Vue,和上例等價的寫法大概是這樣:
export default { setup() { // 1. 使用 name 狀態變量 const name = ref("Mary"); // 2. 使用一個 watcher 以持久化表單 if(name.value !== '') { watch(function persistForm() => { localStorage.setItem('formData', name.value); }); } // 3. 使用 surname 狀態變量 const surname = ref("Poppins"); // 4. 使用一個 watcher 以更新 title watch(function updateTitle() { document.title = `${name.value} ${surname.value}`; }); } }
因為 setup()
只會運行一次,我們是可以將 Composition API 中不同的函數 (reactive
、ref
、computed
、watch
、生命周期鈎子等) 作為循環或條件語句的一部分的。
但是,if
語句同樣只運行一次,所以它在 name
改變時也同樣無法作出反應,除非我們將其包含在 watch
回調的內部:
watch(function persistForm() => { if(name.value !== '') { localStorage.setItem('formData', name.value); } });
聲明狀態
useState
是 React Hooks 聲明狀態的主要途徑。你可以向調用中傳入一個初始值作為參數;並且如果初始值的計算代價比較昂貴,也可以將其表達為一個函數,這樣就只會在初次渲染時才會被執行了。
const [name, setName] = useState("Mary"); const [age, setAge] = useState(25); console.log(`${name} is ${age} years old.`);
useState()
返回一個數組,第一項是 state,第二項是一個 setter 函數。通常可以使用 Array destructuring 語法得到它們。
useReducer
是個有用的替代選擇,其常見形式是接受一個 Redux 樣式的 reducer 函數和一個初始狀態:
const initialState = {count: 0}; function reducer(state, action) { switch (action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; default: throw new Error(); } } const [state, dispatch] = useReducer(reducer, initialState);
比如調用了一次 dispatch({type: 'increment'});
后,state
就會變為 {count: 1}
。
useReducer
還有一種 延遲初始化 的形式,傳入一個 init 函數作為第三個參數。
Vue 則由於其天然的反應式特性,有着不同的做法。存在兩個主要的函數來聲明狀態:ref
和 reactive
。
ref()
返回一個反應式對象,其內部值可通過其 value
屬性被訪問到。可以將其用於基本類型,也可以用於對象,在后者的情況下是深層反應式的。
const name = ref("Mary"); const age = ref(25); watch(() => { console.log(`${name.value} is ${age.value} years old.`); });
另一方面,reactive()
只將一個對象作為其輸入並返回一個對其的反應式代理。注意其反應性也影響到了所有嵌套的屬性。
const state = reactive({ name: "Mary", age: 25, }); watch(() => { console.log(`${state.name} is ${state.age} years old.`); });
有時腦子里要有根弦,那就是使用 ref
時需要記得用 value
屬性訪問其包含的值(除非在 template 中,Vue 允許你省略它)。而用 reactive
時,要注意如果使用了對象解構(destructure),會失去其反應性(譯注:因為是對整個對象做的代理)。所以你需要定義一個指向對象的引用,並通過其訪問狀態屬性。
Composition API 提供了兩個助手函數以處理 refs 和 reactive 對象。
如果必要的話,isRef()
可被用來條件性地獲取 value
屬性(比如 isRef(myVar) ? myVar.value : myVar
)。
toRefs()
則將反應式對象轉換為普通對象,該對象上的所有屬性都自動轉換為 ref。這對於從自定義組合式函數中返回對象時特別有用(這也允許了調用側正常使用結構的情況下還能保持反應性)。
function useFeatureX() { const state = reactive({ foo: 1, bar: 2 }) return toRefs(state) } const {foo, bar} = useFeatureX();
RFC 中用 一整個章節 比較了 ref
和 reactive
,在其結尾總結了使用這兩個函數時可能的處理方式:
像你在正常的 JavaScript 中聲明基本類型變量和對象變量那樣去使用
ref
和reactive
即可。在這種方式下,推薦使用一個 IDE 支持的類型系統。只要用到
reactive
的時候,要記住從 composition 函數中返回反應式對象時得使用toRefs()
。這樣做減少了過多使用ref
時的開銷,但並不會消減熟悉該概念的必要。
如何跟蹤依賴
React 中的 useEffect
hook 允許我們在每次渲染之后運行某些副作用(如請求數據或使用 storage 等 Web APIs),並視需要在下次執行回調之前或當組件卸載時運行一些清理工作。默認情況下,所有用 useEffect
注冊的函數都會在每次渲染之后運行,但我們可以定義真實依賴的狀態和屬性,以使 React 在相關依賴沒有改變的情況下(如由 state 中的其他部分引起的渲染)跳過某些 useEffect
hook 執行。回到之前 Form
的例子,我們可以傳遞一個依賴項的數組作為 useEffect
hook 的第二個參數:
function Form() { const [name, setName] = useState('Mary'); const [surname, setSurname] = useState('Poppins'); useEffect(function persistForm() { localStorage.setItem('formData', name); }, [name]); // ... }
這樣一來,只有當 name
改變時才會更新 localStorage
。使用 React Hooks 時一個常見的 bug 來源就是忘記在依賴項數組中詳盡地聲明所有依賴項;這可能讓 useEffect
回調以依賴和引用了上一次渲染的陳舊數據而非最新數據從而無法被更新而告終。幸運的是,eslint-plugin-react-hooks
也包含了一條 lint 提示關於丟失依賴項的規則。
useCallback
和 useMemo
也使用依賴項數組參數,以分別決定其是否應該返回緩存過的( memoized)與上一次執行相同的版本的回調或值。
在 Vue Composition API 的情況下,可以使用 watch()
執行副作用以響應狀態或屬性的改變。多虧了 Vue 的反應式系統,依賴會被自動跟蹤,注冊過的函數也會在依賴改變時被反應性的調用。回到例子中:
export default { setup() { const name = ref("Mary"); const lastName = ref("Poppins"); watch(function persistForm() => { localStorage.setItem('formData', name.value); }); } }
在 watcher 首次運行后,name
會作為一個依賴項被跟蹤,而稍后當其值改變時,watcher 會再次運行。
訪問組件生命周期
Hooks 在處理 React 組件的生命周期、副作用和狀態管理時表現出了心理模式上的完全轉變。React 社區中的一位活躍分子 Ryan Florence,曾表示從類組件切換到 hooks 有一個心理轉換過程,並且 React 文檔中也指出:
如果你熟悉 React 類生命周期方法,那么可以將
useEffect
Hook 視為componentDidMount
、componentDidUpdate
及componentWillUnmount
的合集
但其實也有可能控制 useEffect
何時運行,並讓我們更接近生命周期中運行副作用的心理模式:
useEffect(() => { console.log("這段只在初次渲染后運行"); return () => { console.log("這里會在組件將要卸載時運行"); }; }, []);
但要再次強調的是,使用 React Hooks 時停止從生命周期方法的角度思考,而是考慮副作用依賴什么狀態,才是更符合習慣的。順便一提的是,Svelte 的創建者 Rich Harris 發表了他在 NYC React meetup 上演講的 some insightful slides,其間他探究了 React 為了將來的新特性(比如 concurrent mode)可用所做的妥協以及 Svelte 何其的區別。這將幫助你理解從思考副作用發生在組件生命周期何處到 作為渲染本身一部分的副作用 的轉變。來自 React 核心團隊的 Sebastian Markbåge 寫的 further expands here 也解釋了 React 前進的方向和為類似 Svelte 或 Vue 式的反應性系統作出的妥協。
另一方面的 Vue Component API,讓我們通過 onMounted
、onUpdated
和 onBeforeUnmount
等仍可以訪問 生命周期鈎子 (Vue 世界中對生命周期方法的等價稱呼):
setup() { onMounted(() => { console.log(`這段只在初次渲染后運行`); }); onBeforeUnmount(() => { console.log(`這里會在組件將要卸載時運行`); }); }
故而在 Vue 的情況下的心理模式轉變更多在停止通過組件選項(data
、computed
, watch
、methods
、生命周期鈎子等)管理代碼這點上,要轉向用不同函數處理對應的特性。RFC 包含一個通過選項 vs. 通過邏輯關注點管理代碼的 示例和對照大全。
自定義代碼
React 團隊意圖聚焦於 Hooks 上的一方面,是比之於先前社區中采納的諸如 Higher-Order Components 或 Render Props 等替代方式,提供給開發者編寫可復用代碼的更佳方式。Custom Hooks 正是他們帶來的答案。
Custom Hooks 就是普通的 JavaScript 函數,在其內部利用了 React Hooks。它遵守的一個約定是其命名應該以 use
開頭,以明示這是被用作一個 hook 的。
export function useDebugState(label, initialValue) { const [value, setValue] = useState(initialValue); useEffect(() => { console.log(`${label}: `, value); }, [label, value]); return [value, setValue]; }
這個 Custom Hook 的小例子可被作為一個 useState
的替代品使用,用於當 value 改變時向控制台打印日志:
const [name, setName] = useDebugState("Name", "Mary");
在 Vue 中,組合式函數(Composition Functions)與 Hooks 在邏輯提取和重用的目標上是一致的。事實上就是,我們能在 Vue 中實現一個類似的 useDebugState
組合式函數。
export function useDebugState(label, initialValue) { const state = ref(initialValue); watch(() => { console.log(`${label}: `, state.value); }); return state; } // 在其他某處: const name = useDebugState("Name", "Mary");
注意:根據約定,組合式函數也像 React Hooks 一樣使用 use
作為前綴以明示作用,並且表面該函數用於 setup()
中
Refs
React 的 useRef
和 Vue 的 ref
都允許你引用一個子組件(如果是 React 則是一個類組件或是被 React.forwardRef
包裝的組件)或要附加到的 DOM 元素。
React:
const MyComponent = () => { const divRef = useRef(null); useEffect(() => { console.log("div: ", divRef.current) }, [divRef]); return ( <div ref={divRef}> <p>My div</p> </div> ) }
Vue:
export default { setup() { const divRef = ref(null); onMounted(() => { console.log("div: ", divRef.value); }); return () => ( <div ref={divRef}> <p>My div</p> </div> ) } }
注意 Vue 2.x 且用 @vue/composition-api
插件的情況下,不支持 在 setup()
返回的渲染函數中通過 JSX 分配模版 refs, 但根據 當前的 RFC,以上語法在 Vue 3.0 中是合法的。
React 中的 useRef
Hook 不止對於取得 DOM 元素的訪問有用。亦可用在你想保持在渲染函數中但並不是 state 一部分的(也就是它們的改變觸發不了重新渲染)任何類型的可變值(mutable value)上。可將這些可變值視為類組件中的 "實例變量" 。這是一個例子:
const timerRef = useRef(null); useEffect(() => { timerRef.current = setInterval(() => { setSecondsPassed(prevSecond => prevSecond + 1); }, 1000); return () => { clearInterval(timerRef.current); }; }, []); return ( <button onClick={() => { clearInterval(timerRef.current); }} > 停止 timer </button> )
在 Vue Composition API 中,如我們在幾乎所有文中之前的例子中所見,ref
可被用於定義反應式狀態。使用 Composition API 的時候,模版 refs 和反應式 refs 是一致的。
附加的函數
由於 React Hooks 在每次渲染時都會運行,所以沒有需要有一個等價於 Vue 中 computed
函數的方法。你可以自由地聲明一個變量,其值基於狀態或屬性,並將指向每次渲染后的最新值:
const [name, setName] = useState("Mary"); const [age, setAge] = useState(25); const description = `${name} is ${age} years old`;
在 Vue 的情況下,setup()
只運行一次。因此需要定義計算屬性,其應該觀察某些狀態更改並作出相應的更新(但只是當其依賴項之一改變的時候):
const name = ref("Mary"); const age = ref(25); const description = computed(() => `${name.value} is ${age.value} years old`);
照例,記住 refs 是容器,而值要通過訪問 value
屬性獲得 :p
如果計算一個值開銷比較昂貴又如何呢?你不會想在組件每次渲染時都計算它。React 包含了針對這點的 useMemo
hook:
function fibNaive(n) { if (n <= 1) return n; return fibNaive(n - 1) + fibNaive(n - 2); } const Fibonacci = () => { const [nth, setNth] = useState(1); const nthFibonacci = useMemo(() => fibNaive(nth), [nth]); return ( <p> <label> Number: <input type="number" value={nth} onChange={e => setNth(e.target.value)} /> </label> <p>nth Fibonacci number: {nthFibonacci}</p> </p> ); };
useMemo
同樣期望一個依賴項數組以獲知其在何時應該計算一個新值。React 建議你使用 useMemo
作為一個性能優化手段而非一個直到任何一個依賴項改變之前的緩存值。
作為一個補充說明:Kent C. Dodds 有一篇非常棒的文章 "useMemo 和 useCallback" 說明了很多 useMemo
和 useCallback
非必要的場景。
Vue 的 computed
執行自動的依賴追蹤,所以它不需要一個依賴項數組。
useCallback
類似於 useMemo
,但它是用來緩存一個回調函數的。事實上 useCallback(fn, deps)
等價於 useMemo(() => fn, deps)
。其理想用例是當我們需要在多次渲染間保持引用相等性時,比如將回調傳遞給一個用 React.memo
定義的已優化子組件,而我們想要避免其不必要的重復渲染時。
鑒於 Vue Composition API 的天然特性,並沒有等同於 useCallback
的函數。setup()
中的任何回調函數都只會定義一次。
Context 和 provide/inject
React 中的 useContext
hook,可以作為一種讀取特定上下文當前值的新方式。返回的值通常由最靠近的一層 <MyContext.Provider>
祖先樹的 value
屬性確定。其等價於一個類中的 static contextType = MyContext
,或是 <MyContext.Consumer>
組件。
// context 對象 const ThemeContext = React.createContext('light'); // provider <ThemeContext.Provider value="dark"> // consumer const theme = useContext(ThemeContext);
Vue 有一個類似的 API 叫做 provide/inject。它在 Vue 2.x 中作為組件選項存在,而在 Composition API 中增加了一對用在 setup()
中的 provide
和 inject
函數:
// key to provide const ThemeSymbol = Symbol(); // provider provide(ThemeSymbol, ref("dark")); // consumer const value = inject(ThemeSymbol);
注意,如果你想保持反應性,必須明確提供一個 ref
/reactive
作為值。
在渲染上下文中暴露值
在 React 的情況下,因為所有 hooks 代碼都在組件定義中,且你將在同一個函數中返回要渲染的 React 元素,所以你對作用域中的任何值擁有完全訪問能力,就像在任何 JavaScript 代碼中的一樣:
const Fibonacci = () => { const [nth, setNth] = useState(1); const nthFibonacci = useMemo(() => fibNaive(nth), [nth]); return ( <p> <label> Number: <input type="number" value={nth} onChange={e => setNth(e.target.value)} /> </label> <p>nth Fibonacci number: {nthFibonacci}</p> </p> ); };
而在 Vue 的情況下,你要在 template
或 render
選項中定義模板;如果你使用單文件組件,就要從 setup()
中返回一個包含了你想輸出到模板中的所有值的對象。由於要暴露的值很可能過多,你的返回語句也容易變得冗長,這一點在 RFC 的 Verbosity of the Return Statement 章節 中有所提及:
<template> <p> <label> Number: <input type="number" v-model="nth" /> </label> <p>nth Fibonacci number: {{nthFibonacci}}</p> </p> </template> <script> export default { setup() { const nth = ref(1); const nthFibonacci = computed(() => fibNaive(nth.value)); return { nth, nthFibonacci }; // 譯注:這里可能有很多 } }; </script>
要達到 React 同樣簡潔表現的一種方式是從 setup()
自身中返回一個渲染函數:
export default { setup() { const nth = ref(1); const nthFibonacci = computed(() => fibNaive(nth.value)); return () => ( <p> <label> Number: <input type="number" vModel={nth} /> </label> <p>nth Fibonacci number: {nthFibonacci}</p> </p> ); } };
不過,模板在 Vue 中是更流行的一種做法,所以暴露一個包含值的對象,是你使用 Vue Composition API 時必然會多多遭遇的情況。
總結
每個框架都有驚喜時刻。自從 React Hooks 在 2018 年被引入,社區利用它們傑作頻出,並且自定義 Hooks 的可擴展性也催生了 許多開源貢獻 ,讓我們可以輕易的加入自己的項目中。Vue 受 React Hooks 啟發並將其調整為適用於其框架的方式,這也成為這些不同的技術如何擁抱變化並分享靈感和解決方案的成功案例。我對 Vue 3 的到來已經急不可耐,迫切想看到它的解鎖能帶來的可能性了。