React16.8中加入了Hooks,讓React函式式元件再一次昇華,那麼到底什麼是Hooks?
動機
React官網和2018年的React conf上都提到了動機這個東西,那麼出現hooks的動機是什麼?是什麼推動了hooks的出現?先來看一下Hooks的動機。
1.在元件間複用狀態邏輯很難
React沒有提供可複用性行為“附加”到元件的途徑,在寫類元件的時候,一個類是一個閉包並且state在元件間傳遞並不怎麼友好,雖然可以使用props和高階元件來解決,但是這樣會元件的結構更麻煩。如果你在 React DevTools 中觀察過 React 應用,你會發現由 providers,consumers,高階元件,render props 等其他抽象層組成的元件會形成“巢狀地獄”。
2. 複雜元件變得難以理解
React中的類元件是很重的,比如說我就想實現一個非常簡單的功能,必須要帶一堆鉤子函式,讓一個簡單的元件變得很複雜。而且由於不同的生命週期在不同的階段呼叫,導致我們會在相應的地方作一些處理,有可能把一些完全不相干的程式碼因為執行週期相同必須放在同一個生命週期中,很容易引發bug。
3. 難以理解的class
文件上說這點主要是學習class是一個難點。因為我自己寫es6 class有一段時間了,所以class對我自己來說還是可以的,並且this理解的還可以。
什麼是Hooks?
那麼什麼是Hook,Hook顧名思義就是鉤子的意思。在函式元件中把React的狀態和生命週期等這些特性鉤入進入,這就是React的Hook。
特指表明React的Hook作用是把類元件的一些特性鉤入函式元件中,因在類元件中是不可以使Hook的。
Hooks的使用規則
Hook就是javascript函式,但是使用有兩個規則:
- 只能在函式的最外層呼叫hook。不要在迴圈、條件判斷或者子函式中呼叫。(這個關係到了hooks的執行機制,會在下面hook中說到)
- 只能在React的函式元件中呼叫Hook。不要在其他javascript函式中呼叫(自定義hooks中也可以呼叫)
使用Hooks的好處
- 使用hooks,如果業務變更,就不需要把函式元件修改成類元件。
- 告別了繁雜的this和合並了難以記憶的生命週期。
- 支援包裝自己的Hooks(自定義Hooks),是基於純命令式的api。
- 更好的完成狀態之間的共享,解決原來class元件內部封裝的問題,也解決了高階元件愛你和函式元件的巢狀過深。一個元件一個自己的state,一個元件內可以公用。
內建的Hook
React一共內建了9種Hook。
- useState
- usEffect
- useContext
- useReducer
- useCallback
- useMemo
- useRef
- useImperativeHandle
- useLayoutEffect
useState
以前的函式式元件被成為純函式元件或者無狀態元件,是隻能接受父元件傳來的props並且只能做展示功能,不能使用state也沒有生命週期。
現在State Hook 可以讓函式式元件使用狀態。
useState是React的一個Hook,它是一個方法,可以傳入值作為state的預設值,返回一個陣列,陣列的第一項是對應的狀態(預設值會賦予狀態),陣列的第二項是更新狀態的函式。
import React, { useState } from "react";
const Greeting = () => {
const states = useState(0);
const count = states[0];
const setCount = states[1];
return (
<>
<h1> {count} </h1>
<button onClick={() => {setCount(count + 1)}}> + </button>
</>
)
}
export default Greeting;
複製程式碼
每次取陣列的第幾項太麻煩,所以官方建議使用ES6陣列的解構賦值的方式。
const [count, setCount] = useState(1);
複製程式碼
看起來是不是簡便多了。更新程式碼
import React, { useState } from "react";
const Greeting = () => {
const [count, setCount] = useState(0);
return (
<>
<h1> {count} </h1>
<button onClick={() => {setCount(count + 1)}}> + </button>
</>
)
}
export default Greeting;
複製程式碼
我們發現,一般函式呼叫完成之後,其中的變數都會被回收,而上面程式碼和圖上可以看出每次都是在count
的基上相加,並沒有消失,為什麼呢? 先埋下疑問點,在Hook的執行機制會提到。
使用多次useState
在一個元件中我們不可能只有一個state,useState允許在一個元件中使多次,並且每次使用都是一個全新的狀態。
import React, { useState } from "react";
const Greeting = () => {
const [count, setCount] = useState(0); //第一次使用
const [istrue, setIstrue] = useState(true); //第二次使用
return (
<>
{istrue ? <h1> {count} </h1> : void 0}
<button onClick={ () => {setIstrue(!istrue)}}>change</button>
<button onClick={() => {setCount(count + 1)}}> + </button>
</>
)
}
export default Greeting;
複製程式碼
上面程式碼使用兩次useState,完美的完成了功能。
那麼現在又有疑問了,React是怎麼區別多次呼叫的hooks的呢?先埋下疑問點,在Hook的執行機制的時候會談到(所有的Hook都是這)。
useEffect
既然React Hooks給了函式式元件(或者說是純函式元件)那麼強大的功能(拋棄類元件),那麼元件中總是要會執行副作用操作,純函式元件保持了函式渲染的純度,那麼要怎麼執行副作用呢?
React Hooks 提供了 Effect Hook,可以在函式元件中執行副作用操作,並且是在函式渲染DOM完成後執行副作用操作。
import React, {useEffect} from "react";
複製程式碼
useEffect這個方法傳入一個函式作為引數,在函式裡面執行副作用程式碼,並且useEffec的第一個引數還支援返回值為一個函式,這個函式執行相當於元件更新和解除安裝。
import React, {useState, useEffect} from "react";
const EffectComponent = () => {
useEffect(() => {
console.log("useEffect Hook");
})
return null;
}
export default EffectComponent
複製程式碼
與類元件生命週期的比較
我們都知道在類元件中可以在componentDidMount
和componentDidUpdate
中執行副作用,那麼在函式元件中useEffect的引數函式就具有類元件的這兩個生命週期的用途,如果useEffec的第一個引數有返回值為函式的話,函式的返回值相當於componentWillUnmount
。可以說useEffect把這三個API合成了一個。
最常見的做法就是就是在函式引數中寫事件註冊,在函式的返回函式中寫事件銷燬。
import React, {useState, useEffect} from "react";
const EffectComponent = () => {
const [width, setWidth] = useState(window.innerWidth);
const resizeHandle = () => {
setWidth(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", resizeHandle);
return () => {
window.removeEventListener("resize", resizeHandle)
}
})
return (
<h1>{width}</h1>
);
}
export default EffectComponent
複製程式碼
useEffect的執行時機
從上面我們知道了useEffect可以說是類元件中三種生命週期的結合,但是它的執行時機是什麼樣的呢?從一個小Demo來說
import React, {useState, useEffect} from "react";
const EffectComponent = () => {
const [count, setCount] = useState(1);
useEffect(() => {
console.log("定義事件介面")
return () => {
console.log("登出事件介面")
}
})
return (
<>
{console.log("渲染")}
<h1>{count}</h1>
<button onClick={() => {setCount(count + 1)}}> + </button>
</>
);
}
export default EffectComponent
複製程式碼
在開始的時候有提到,useEffec執行副作時機在渲染後,確實是這樣。細心的你會發現,當我點選+號的時候,怎麼會出現登出事件介面
?
useEffec函式中的返回函式不是在元件解除安裝的時候被呼叫嗎?
我個人的理解是useEffec函式引數中返回函式所代表的銷燬是useEffect自己的銷燬,每次重新執行函式元件都會重新生成新的Effec。假如沒有銷燬,由於useEffect的函式引數會在首次渲染和更新的時候呼叫,這就有了一致命的缺點:如果我是定義的事件,每次更新都會執行,那麼豈不是在事件還沒有移除掉又定義了一次,所以useEffect加入了這個功能。
我們來驗證一下上述論述是否正確。
import React, {useState, useEffect} from "react";
const EffectComponent = () => {
const [width, setWidth] = useState(window.innerWidth);
const [count, setCount] = useState(1);
const resizeHandle = () => {
setWidth(window.innerWidth);
console.log(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", resizeHandle);
return () => {
// window.removeEventListener("resize", resizeHandle)
}
})
return (
<>
<h1>{count}</h1>
<button onClick={() => {setCount(count + 1)}}>+</button>
</>
);
}
export default EffectComponent
複製程式碼
上面程式碼我把useEffect 中return的事件移除註釋掉,同時在事件處理函式中列印一下視窗寬度。
可以看出當我第一次觸發視窗事件的時候,直接列印了三次。useEffect的第二個引數
當useEffect的第二個引數不寫的話(上面都沒寫),任何更新都會觸發useEffect。那麼下面說一下useEffect的第二個引數。
useEffect的第二個引數是一個陣列,表示以來什麼state和props來執行副作用。
陣列為空的時候,useEffect就相當於componentDidMoubt
和componentWillUnmount
這兩個生命週期,只在首次渲染和解除安裝的時候執行。
當陣列中值是狀態的時候,就會只監聽這一個狀態的變化。當然陣列中可以多個值,監聽存放state的變化。
const EffectComponent = () => {
const [count, setCount] = useState(1);
const [num, setNum] = useState(2);
useEffect(() => {
console.log("count狀態更新");
return () => {
console.log("useEffect解除安裝")
}
},[count])
return (
<>
<h1>{count}</h1>
<button onClick={() => {setCount(count + 1)}}>+</button>
<h1>{num}</h1>
<button onClick={() => {setNum(num + 1)}}>+</button>
</>
);
}
複製程式碼
寫多個useEffect
當我們在寫類元件的時候,通常會把定義事件寫在componentDidMount
中,如果只是一個事件處理,專案不大還好,那如果專案很大,所有的事件處理都定義在一個生命週期中,難道就不亂嗎?亂是肯定的,而且還容易出bug。
React Hook 允許函式式元件中定義多個useEffect(和useState類似),多個useEffect互相不受干擾。
const EffectComponent = () => {
const [count, setCount] = useState(1);
const [num, setNum] = useState(2);
useEffect(() => {
console.log("count狀態更新");
return () => {
console.log("count解除安裝")
}
},[count])
useEffect(() => {
console.log("num狀態更新");
return () => {
console.log("num解除安裝")
}
},[num])
return (
<>
<h1>{count}</h1>
<button onClick={() => {setCount(count + 1)}}>+</button>
<h1>{num}</h1>
<button onClick={() => {setNum(num + 1)}}>+</button>
</>
);
}
複製程式碼
useEffect在函式元件中的作用非常大,好好利用必成神器。
useContext
React16中更新了Context API,Context主要用於爺孫元件的傳值問題,新的Context API使用訂閱釋出者模式方式實現在爺孫元件中傳值。 在我的部落格中我寫了一篇簡單的使用方法Context API,不瞭解的可以參考一下。
React Hooks出現之後也對Context API出了響應的Hook useContext
。同樣也是解傳值的問題。
useContext Hook接受一個context物件(由createContext建立的物件)作為引數,並返回Context.Consumer。例如:
const stateContext = createContext('default');
複製程式碼
- 正確: useContext(stateContext)
- 錯誤: useContext(stateContext.Consumer)
- 錯誤: useContext(stateContext.Provider)
使用方式
比如說有一個簡單的ContextComponent元件
const ContextComponent = () => {
return (
<>
<h1>{value}</h1>
</>
);
}
複製程式碼
通過Context API給這個元件發資訊。
export default () => (
<stateContext.Provider
value={"Hello React"}
>
<ContextComponent/>
</stateContext.Provider>
)
複製程式碼
使用useContext()
const value = useContext(stateContext);
複製程式碼
使用useContext,必須在函式式元件中,否則會報錯。
可以看出,使用useContext仍然需要在上層元件中使用<MyContext.Provider>來為下層元件提供context。
useReducer
看到useReducer
,肯定會想到Redux,沒錯它和Redux的工作方式是一樣的。useReducer的出現是useState的替代方案,能夠讓我們更好的管理狀態。
useReducer一共可以接受三個引數並返回當前的state與其配套的dispatch。
第一個引數
useReducer的第一個引數就是形如(state,action) => newState
這樣的reducer,沒錯就是reducer,和redux完全相同。我們來定義一個簡單的reducer。
const reducer = (state, action) => {
switch(action.type){
case "ADD_TODO":
return [
...state,
action.todo
];
default:
return state;
}
}
複製程式碼
上面是一個簡單的reducer,細心的你會發現,state引數難道不需要指定一下預設值嗎?不需要,React不需要使用指定state = initialState
,有時候初始值需要依賴於props,所以初始值在useReducer上指定,也許已經猜到第二個引數是什麼了?
第二個引數
useReducer的第二個引數和Redux的createStore也相同,指定狀態的預設值。例如:
useReducer(reducer,[{
id: Date.now(),
value: "Hello react"
}])
複製程式碼
第三個引數
useReducer的第三個引數接受一個函式作為引數,並把第二個引數當作函式的引數執行。主要作用是初始值的惰性求值,把一些對狀態的邏輯抽離出來,有利於重置state。
定義一個init函式
function init(initialCount) {
return [
...initialCount,
];
}
複製程式碼
useReducer使用
useReducer(reducer,[
{
id: Date.now(),
value: "Hello react"
}
],init)
複製程式碼
useReducer的返回值
useReducer的返回值為一個陣列,陣列的第一項為當前state,第二項為與當前state對應的dispatch,可以使用ES6的解構賦值拿到這兩個
const [state,dispatch] = useReducer(reducer,[
{
id: Date.now(),
value: "Hello react"
}
],init)
複製程式碼
淺比較渲染
如果 Reducer Hook 的返回值與當前 state 相同,React 將跳過子元件的渲染及副作用的執行。
這種方react使用Objec.is比較演算法來比較state,因此這是一個淺比較,來測驗一下。
我們先在reducer中新增一個改變的Todo值的case。
case "CHANGE_TODO":return state[action.id] = 'change' && state;
複製程式碼
修改一下return,給下層元件傳一個change屬性
const change = (id) => {
dispatch({
type: "CHANGE_TODO",
id,
})
}
return (
<>
<button onClick={() => {dispatch({type: "ADD_TODO",todo:{id:Date.now(),value:"Hello Hook"}})}}> Add </button>
{state.map((todo,index) => (
<Todo key={index} todo={todo.value} change={()=>{change(todo.id)}}/>
))}
</>
)
複製程式碼
給Todo元件新增一點選事件,當點選觸發上層元件傳來的方法,使元件值修改.
let Todo = ({todo,change}) => {
return (
console.log("render"),
<li onClick={change}>{todo}</li>
);
}
複製程式碼
從圖片上可以看出,無論我怎麼點選li都不會發生改變。
那麼我們來改變一下reducer,讓它返回一個全新的陣列。
case "CHANGE_TODO":
return state.map((todo,index) =>{
if(todo.id === action.id){
todo.value="change";
}
return todo;
} )
複製程式碼
當返回一個新的陣列的時候,點選li都發生了改變,預設有了shouldComponentUpdate
的功能。
useCallback
useCallback可以認為是對依賴項的監聽,把接受一個回撥函式和依賴項陣列,返回一個該回撥函式的memoized(記憶)版本,該回撥函式僅在某個依賴項改變時才會更新。
一個簡單的小例子
const CallbackComponent = () => {
let [count, setCount] = useState(1);
let [num, setNum] = useState(1);
const memoized = useCallback( () => {
return num;
},[count])
console.log("記憶:",memoized());
console.log("原始:",num);
return (
<>
<button onClick={() => {setCount(count + 1)}}> count+ </button>
<button onClick={() => {setNum(num + 1)}}> num+ </button>
</>
)
}
複製程式碼
如果沒有傳入依賴項陣列,那麼記憶函式在每次渲染的時候都會更新。
useMemo
useMemo和useCallback很像,唯一不同的就是
useCallback(fn, deps) 相當於 useMemo(() => fn, deps
這裡就不過多介紹了。
useRef
React16出現了可用Object.createRef
建立ref的方法,因此也出了這樣一個Hook。
使用語法:
const refContainer = useRef(initialValue);
useRef返回一個可變的ref物件,useRef接受一個引數繫結在返回的ref物件的current屬性上,返回的ref物件在整個生命週期中保持不變。
栗子:
const RefComponent = () => {
let inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
})
return (
<input type="text" ref={inputRef}/>
)
}
複製程式碼
上面例子在input上繫結一個ref,使得input在渲染後自動焦點聚焦。
useImperativeHandle
useImperativeHandle 可以讓你在使用 ref 時自定義暴露給父元件的例項值。
就是說:當我們使用父元件把ref傳遞給子元件的時候,這個Hook允許在子元件中把自定義例項附加到父元件傳過來的ref上,有利於父元件控制子元件。
使用方式
useImperativeHandle(ref, createHandle, [deps])
一個栗子:
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.value="Hello";
}
}));
return <input ref={inputRef} />;
}
FancyInput = forwardRef(FancyInput);
export default () => {
let ref = useRef(null);
useEffect(() => {
console.log(ref);
ref.current.focus();
})
return (
<>
<FancyInput ref={ref}/>
</>
)
}
複製程式碼
上面是一個父子元件中ref傳遞的例子,使用到了forwardRef(這是一個高階函式,主要用於ref在父子元件中的傳遞),使用useImperativeHandle把第二個引數的返回值繫結到父元件傳來的ref上。
useLayoutEffect
這個鉤子函式和useEffect相同,都是用來執行副作用。但是它會在所有的DOM變更之後同步呼叫effect。useLayoutEffect和useEffect最大的區別就是一個是同步一個是非同步。
從這個Hook的名字上也可以看出,它主要用來讀取DOM佈局並觸發同步渲染,在瀏覽器執行繪製之前,useLayoutEffect 內部的更新計劃將被同步重新整理。
官網建議還是儘可能的是使用標準的useEffec以避免阻塞視覺更新。
Hook的執行機制
上面一共埋了2個疑問點。
第一個:函式呼叫完之後會把函式中的變數清除,但ReactHook是怎麼複用狀態呢?
React 保持對當先渲染中的元件的追蹤,每個元件內部都有一個「記憶單元格」列表。它們只不過是我們用來儲存一些資料的 JavaScript 物件。當你用 useState() 呼叫一個Hook的時候,它會讀取當前的單元格(或在首次渲染時將其初始化),然後把指標移動到下一個。這就是多個 useState() 呼叫會得到各自獨立的本地 state 的原因。
之所以不叫createState,而是叫useState,因為 state 只在元件首次渲染的時候被建立。在下一次重新渲染時,useState 返回給我們當前的 state。
const [count, setCount] = useState(1);
setCount(2);
//第一次渲染
//建立state,
//設定count的值為2
//第二次渲染
//useState(1)中的引數忽略,並把count賦予2
複製程式碼
React是怎麼區分多次呼叫的hooks的呢,怎麼知道這個hook就是這個作用呢?
React 靠的是 Hook 呼叫的順序。在一個函式元件中每次呼叫Hooks的順序是相同。藉助官網的一個例子:
// ------------
// 首次渲染
// ------------
useState('Mary') // 1. 使用 'Mary' 初始化變數名為 name 的 state
useEffect(persistForm) // 2. 新增 effect 以儲存 form 操作
useState('Poppins') // 3. 使用 'Poppins' 初始化變數名為 surname 的 state
useEffect(updateTitle) // 4. 新增 effect 以更新標題
// -------------
// 二次渲染
// -------------
useState('Mary') // 1. 讀取變數名為 name 的 state(引數被忽略)
useEffect(persistForm) // 2. 替換儲存 form 的 effect
useState('Poppins') // 3. 讀取變數名為 surname 的 state(引數被忽略)
useEffect(updateTitle) // 4. 替換更新標題的 effect
// ...
複製程式碼
在上面hook規則的時候提到Hook一定要寫在函式元件的對外層,不要寫在判斷、迴圈中,正是因為要保證Hook的呼叫順序相同。
如果有一個Hook寫在了判斷語句中
if (name !== '') {
useEffect(function persistForm() {
localStorage.setItem('formData', name);
});
}
複製程式碼
藉助上面例子,如果說name是一個表單需要提交的值,在第一次渲染中,name不存在為true,所以第一次Hook的執行順序為
useState('Mary') // 1. 使用 'Mary' 初始化變數名為 name 的 state
useEffect(persistForm) // 2. 新增 effect 以儲存 form 操作
useState('Poppins') // 3. 使用 'Poppins' 初始化變數名為 surname 的 state
useEffect(updateTitle) // 4. 新增 effect 以更新標題
複製程式碼
在第二次渲染中,如果有表單中有資訊填入,那麼name就不等於空,Hook的渲染順序如下:
useState('Mary') // 1. 讀取變數名為 name 的 state(引數被忽略)
// useEffect(persistForm) // ? 此 Hook 被忽略!
useState('Poppins') // ? 2 (之前為 3)。讀取變數名為 surname 的 state 失敗
useEffect(updateTitle) // ? 3 (之前為 4)。替換更新標題的 effect 失敗
複製程式碼
這樣就會引發Bug的出現。因此在寫Hook的時候一定要在函式元件的最外層寫,不要寫在判斷,迴圈中。
自定義Hook
自定義hooks可以說成是一種約定而不是功能。當一個函式以use
開頭並且在函式內部呼叫其他hooks,那麼這個函式就可以成為自定義hooks,比如說useSomething
。
自定義Hooks可以封裝狀態,能夠更好的實現狀態共享。
我們來封裝一個數字加減的Hook
const useCount = (num) => {
let [count, setCount] = useState(num);
return [count,()=>setCount(count + 1), () => setCount(count - 1)]
};
複製程式碼
這個自定義Hook內部使用useState定義一個狀態,返回一個陣列,陣列中有狀態的值、狀態++的函式,狀態--的函式。
const CustomComp = () => {
let [count, addCount, redCount] = useCount(1);
return (
<>
<h1>{count}</h1>
<button onClick={addCount}> + </button>
<button onClick={redCount}> - </button>
</>
)
}
複製程式碼
主函式中使用解構賦值的方式接受這三個值使用,這是一種非常簡單的自定義Hook。如果專案大的話使用自定義Hook會抽離可以抽離公共程式碼,極大的減少我們的程式碼量,提高開發效率。
總結
Hooks的學習就總結到這裡。在學習的過程中總結知識,並推廣給志同道合的同伴,這無疑是我努力學好它的動力。學習React不算太長,但在學習過程中處處都是對React中運用函數語言程式設計和軟體工程的驚歎,前端的路還有很長,我只不過才半腳踏入門,努力向自己的目標前進。