1. 引言
如果你在使用 React 16,可以嘗試 Function Component 風格,享受更大的靈活性。但在嘗試之前,最好先閱讀本文,對 Function Component 的思維模式有一個初步認識,防止因思維模式不同步造成的困擾。
2. 精讀
什么是 Function Component?
Function Component 就是以 Function 的形式創建的 React 組件:
function App() {
return (
<div>
<p>App</p>
</div>
);
}
也就是,一個返回了 JSX 或 createElement
的 Function 就可以當作 React 組件,這種形式的組件就是 Function Component。
所以我已經學會 Function Component 了嗎?
別急,故事才剛剛開始。
什么是 Hooks?
Hooks 是輔助 Function Component 的工具。比如 useState
就是一種 Hook,它可以用來管理狀態:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
useState
返回的結果是數組,數組的第一項是 值,第二項是 賦值函數,useState
函數的第一個參數就是 默認值,也支持回調函數。更詳細的介紹可以參考 Hooks 規則解讀。
先賦值再 setTimeout 打印
我們再將 useState
與 setTimeout
結合使用,看看有什么發現。
創建一個按鈕,點擊后讓計數器自增,但是延時 3 秒后再打印出來:
function Counter() {
const [count, setCount] = useState(0);
const log = () => {
setCount(count + 1);
setTimeout(() => {
console.log(count);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
如果我們 在三秒內連續點擊三次,那么 count
的值最終會變成 3
,而隨之而來的輸出結果是。。?
0
1
2
嗯,好像對,但總覺得有點怪?
使用 Class Component 方式實現一遍呢?
敲黑板了,回到我們熟悉的 Class Component 模式,實現一遍上面的功能:
class Counter extends Component {
state = { count: 0 };
log = () => {
this.setState({
count: this.state.count + 1
});
setTimeout(() => {
console.log(this.state.count);
}, 3000);
};
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={this.log}>Click me</button>
</div>
);
}
}
嗯,結果應該等價吧?3 秒內快速點擊三次按鈕,這次的結果是:
3
3
3
怎么和 Function Component 結果不一樣?
這是用好 Function Component 必須邁過的第一道坎,請確認完全理解下面這段話:
首先對 Class Component 進行解釋:
- 首先 state 是 Immutable 的,
setState
后一定會生成一個全新的 state 引用。 - 但 Class Component 通過
this.state
方式讀取 state,這導致了每次代碼執行都會拿到最新的 state 引用,所以快速點擊三次的結果是3 3 3
。
那么對 Function Component 而言:
useState
產生的數據也是 Immutable 的,通過數組第二個參數 Set 一個新值后,原來的值會形成一個新的引用在下次渲染時。- 但由於對 state 的讀取沒有通過
this.
的方式,使得 每次setTimeout
都讀取了當時渲染閉包環境的數據,雖然最新的值跟着最新的渲染變了,但舊的渲染里,狀態依然是舊值。
為了更容易理解,我們來模擬三次 Function Component 模式下點擊按鈕時的狀態:
第一次點擊,共渲染了 2 次,setTimeout
生效在第 1
次渲染,此時狀態為:
function Counter() {
const [0, setCount] = useState(0);
const log = () => {
setCount(0 + 1);
setTimeout(() => {
console.log(0);
}, 3000);
};
return ...
}
第二次點擊,共渲染了 3 次,setTimeout
生效在第 2
次渲染,此時狀態為:
function Counter() {
const [1, setCount] = useState(0);
const log = () => {
setCount(1 + 1);
setTimeout(() => {
console.log(1);
}, 3000);
};
return ...
}
第三次點擊,共渲染了 4 次,setTimeout
生效在第 3
次渲染,此時狀態為:
function Counter() {
const [2, setCount] = useState(0);
const log = () => {
setCount(2 + 1);
setTimeout(() => {
console.log(2);
}, 3000);
};
return ...
}
可以看到,每一個渲染都是一個獨立的閉包,在獨立的三次渲染中,count
在每次渲染中的值分別是 0 1 2
,所以無論 setTimeout
延時多久,打印出來的結果永遠是 0 1 2
。
理解了這一點,我們就能繼續了。
如何讓 Function Component 也打印 3 3 3
?
所以這是不是代表 Function Component 無法覆蓋 Class Component 的功能呢?完全不是,我希望你讀完本文后,不僅能解決這個問題,更能理解為什么用 Function Component 實現的代碼更佳合理、優雅。
第一種方案是借助一個新 Hook - useRef
的能力:
function Counter() {
const count = useRef(0);
const log = () => {
count.current++;
setTimeout(() => {
console.log(count.current);
}, 3000);
};
return (
<div>
<p>You clicked {count.current} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
這種方案的打印結果就是 3 3 3
。
想要理解為什么,首先要理解 useRef
的功能:通過 useRef
創建的對象,其值只有一份,而且在所有 Rerender 之間共享。
所以我們對 count.current
賦值或讀取,讀到的永遠是其最新值,而與渲染閉包無關,因此如果快速點擊三下,必定會返回 3 3 3
的結果。
但這種方案有個問題,就是使用 useRef
替代了 useState
創建值,那么很自然的問題就是,如何不改變原始值的寫法,達到同樣的效果呢?
如何不改造原始值也打印 3 3 3
?
一種最簡單的做法,就是新建一個 useRef
的值給 setTimeout
使用,而程序其余部分還是用原始的 count
:
function Counter() {
const [count, setCount] = useState(0);
const currentCount = useRef(count);
useEffect(() => {
currentCount.current = count;
});
const log = () => {
setCount(count + 1);
setTimeout(() => {
console.log(currentCount.current);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
通過這個例子,我們引出了一個新的,也是 最重要的 Hook - useEffect
,請務必深入理解這個函數。
useEffect
是處理副作用的,其執行時機在 每次 Render 渲染完畢后,換句話說就是每次渲染都會執行,只是實際在真實 DOM 操作完畢后。
我們可以利用這個特性,在每次渲染完畢后,將 count
此時最新的值賦給 currentCount.current
,這樣就使 currentCount
的值自動同步了 count
的最新值。
為了確保大家准確理解 useEffect
,筆者再啰嗦一下,將其執行周期拆解到每次渲染中。假設你在三秒內快速點擊了三次按鈕,那么你需要在大腦中模擬出下面這三次渲染都發生了什么:
第一次點擊,共渲染了 2 次,useEffect
生效在第 2
次渲染:
function Counter() {
const [1, setCount] = useState(0);
const currentCount = useRef(0);
useEffect(() => {
currentCount.current = 1; // 第二次渲染完畢后執行一次
});
const log = () => {
setCount(1 + 1);
setTimeout(() => {
console.log(currentCount.current);
}, 3000);
};
return ...
}
第二次點擊,共渲染了 3 次,useEffect
生效在第 3
次渲染:
function Counter() {
const [2, setCount] = useState(0);
const currentCount = useRef(0);
useEffect(() => {
currentCount.current = 2; // 第三次渲染完畢后執行一次
});
const log = () => {
setCount(2 + 1);
setTimeout(() => {
console.log(currentCount.current);
}, 3000);
};
return ...
}
第三次點擊,共渲染了 4 次,useEffect
生效在第 4
次渲染:
function Counter() {
const [3, setCount] = useState(0);
const currentCount = useRef(0);
useEffect(() => {
currentCount.current = 3; // 第四次渲染完畢后執行一次
});
const log = () => {
setCount(3 + 1);
setTimeout(() => {
console.log(currentCount.current);
}, 3000);
};
return ...
}
注意對比與上面章節展開的 setTimeout
渲染時有什么不同。
要注意的是,useEffect
也隨着每次渲染而不同的,同一個組件不同渲染之間,useEffect
內閉包環境完全獨立。對於本次的例子,useEffect
共執行了 四次,經歷了如下四次賦值最終變成 3
:
currentCount.current = 0; // 第 1 次渲染
currentCount.current = 1; // 第 2 次渲染
currentCount.current = 2; // 第 3 次渲染
currentCount.current = 3; // 第 4 次渲染
請確保理解了這句話再繼續往下閱讀:
setTimeout
的例子,三次點擊觸發了四次渲染,但setTimeout
分別生效在第 1、2、3 次渲染中,因此值是0 1 2
。useEffect
的例子中,三次點擊也觸發了四次渲染,但useEffect
分別生效在第 1、2、3、4 次渲染中,最終使currentCount
的值變成3
。
用自定義 Hook 包裝 useRef
是不是覺得每次都寫一堆 useEffect
同步數據到 useRef
很煩?是的,想要簡化,就需要引出一個新的概念:自定義 Hooks。
首先介紹一下,自定義 Hooks 允許創建自定義 Hook,只要函數名遵循以 use
開頭,且返回非 JSX 元素,就是 Hooks 啦!自定義 Hooks 內還可以調用包括內置 Hooks 在內的所有自定義 Hooks。
也就是我們可以將 useEffect
寫到自定義 Hook 里:
function useCurrentValue(value) {
const ref = useRef(0);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
這里又引出一個新的概念,就是 useEffect
的第二個參數,dependences。dependences 這個參數定義了 useEffect
的依賴,在新的渲染中,只要所有依賴項的引用都不發生變化,useEffect
就不會被執行,且當依賴項為 []
時,useEffect
僅在初始化執行一次,后續的 Rerender 永遠也不會被執行。
這個例子中,我們告訴 React:僅當 value
的值變化了,再將其最新值同步給 ref.current
。
那么這個自定義 Hook 就可以在任何 Function Component 調用了:
function Counter() {
const [count, setCount] = useState(0);
const currentCount = useCurrentValue(count);
const log = () => {
setCount(count + 1);
setTimeout(() => {
console.log(currentCount.current);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
封裝以后代碼清爽了很多,而且最重要的是將邏輯封裝起來,我們只要理解 useCurrentValue
這個 Hook 可以產生一個值,其最新值永遠與入參同步。
看到這里,也許有的小伙伴已經按捺不住迸發的靈感了:將 useEffect
第二個參數設置為空數組,這個自定義 Hook 就代表了 didMount
生命周期!
是的,但筆者建議大家 不要再想生命周期的事情,這樣會阻礙你更好的理解 Function Component。因為下一個話題,就是要告訴你:永遠要對 useEffect
的依賴誠實,被依賴的參數一定要填上去,否則會產生非常難以察覺與修復的 BUG。
將 setTimeout
換成 setInterval
會怎樣
我們回到起點,將第一個 setTimeout
Demo 中換成 setInterval
,看看會如何:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
這個例子將引發學習 Function Component 的第二個攔路虎,理解了它,才深入理解了 Function Component 的渲染原理。
首先介紹一下引入的新概念,useEffect
函數的返回值。它的返回值是一個函數,這個函數在 useEffect
即將重新執行時,會先執行上一次 Rerender useEffect
第一個回調的返回函數,再執行下一次渲染的 useEffect
第一個回調。
以兩次連續渲染為例介紹,展開后的效果是這樣的:
第一次渲染:
function Counter() {
useEffect(() => {
// 第一次渲染完畢后執行
// 最終執行順序:1
return () => {
// 由於沒有填寫依賴項,所以第二次渲染 useEffect 會再次執行,在執行前,第一次渲染中這個地方的回調函數會首先被調用
// 最終執行順序:2
}
});
return ...
}
第二次渲染:
function Counter() {
useEffect(() => {
// 第二次渲染完畢后執行
// 最終執行順序:3
return () => {
// 依此類推
}
});
return ...
}
然而本 Demo 將 useEffect
的第二個參數設置為了 []
,那么其返回函數只會在這個組件被銷毀時執行。
讀懂了前面的例子,應該能想到,這個 Demo 希望利用 []
依賴,將 useEffect
當作 didMount
使用,再結合 setInterval
每次時 count
自增,這樣期望將 count
的值每秒自增 1。
然而結果是:
1
1
1
...
理解了 setTimeout
例子的讀者應該可以自行推導出原因:setInterval
永遠在第一次 Render 的閉包中,count
的值永遠是 0
,也就是等價於:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(0 + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
然而罪魁禍首就是 沒有對依賴誠實 導致的。例子中 useEffect
明明依賴了 count
,依賴項卻非要寫 []
,所以產生了很難理解的錯誤。
所以改正的辦法就是 對依賴誠實。
永遠對依賴項誠實
一旦我們對依賴誠實了,就可以得到正確的效果:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
return <h1>{count}</h1>;
}
我們將 count
作為了 useEffect
的依賴項,就得到了正確的結果:
1
2
3
...
既然漏寫依賴的風險這么大,自然也有保護措施,那就是 eslint-plugin-react-hooks 這個插件,會自動訂正你的代碼中的依賴,想不對依賴誠實都不行!
然而對這個例子而言,代碼依然存在 BUG:每次計數器都會重新實例化,如果換成其他費事操作,性能成本將不可接受。
如何不在每次渲染時重新實例化 setInterval
?
最簡單的辦法,就是利用 useState
的第二種賦值用法,不直接依賴 count
,而是以函數回調方式進行賦值:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
這這寫法真正做到了:
- 不依賴
count
,所以對依賴誠實。 - 依賴項為
[]
,只有初始化會對setInterval
進行實例化。
而之所以輸出還是正確的 1 2 3 ...
,原因是 setCount
的回調函數中,c
值永遠指向最新的 count
值,因此沒有邏輯漏洞。
但是聰明的同學仔細一想,就會發現一個新問題:如果存在兩個以上變量需要使用時,這招就沒有用武之地了。
同時使用兩個以上變量時?
如果同時需要對 count
與 step
兩個變量做累加,那 useEffect
的依賴必然要寫上一種某一個值,頻繁實例化的問題就又出現了:
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [step]);
return <h1>{count}</h1>;
}
這個例子中,由於 setCount
只能拿到最新的 count
值,而為了每次都拿到最新的 step
值,就必須將 step
申明到 useEffect
依賴中,導致 setInterval
被頻繁實例化。
這個問題自然也困擾了 React 團隊,所以他們拿出了一個新的 Hook 解決問題:useReducer
。
什么是 useReducer
先別聯想到 Redux。只考慮上面的場景,看看為什么 React 團隊要將 useReducer
列為內置 Hooks 之一。
先介紹一下 useReducer
的用法:
const [state, dispatch] = useReducer(reducer, initialState);
useReducer
返回的結構與 useState
很像,只是數組第二項是 dispatch
,而接收的參數也有兩個,初始值放在第二位,第一位就是 reducer
。
reducer
定義了如何對數據進行變換,比如一個簡單的 reducer
如下:
function reducer(state, action) {
switch (action.type) {
case "increment":
return {
...state,
count: state.count + 1
};
default:
return state;
}
}
這樣就可以通過調用 dispatch({ type: 'increment' })
的方式實現 count
自增了。
那么回到這個例子,我們只需要稍微改寫一下用法即可:
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return <h1>{count}</h1>;
}
function reducer(state, action) {
switch (action.type) {
case "tick":
return {
...state,
count: state.count + state.step
};
}
}
可以看到,我們通過 reducer
的 tick
類型完成了對 count
的累加,而在 useEffect
的函數中,竟然完全繞過了 count
、step
這兩個變量。所以 useReducer
也被稱為解決此類問題的 “黑魔法”。
其實不管被怎么稱呼也好,其本質是讓函數與數據解耦,函數只管發出指令,而不需要關心使用的數據被更新時,需要重新初始化自身。
仔細的讀者會發現這個例子還是有一個依賴的,那就是 dispatch
,然而 dispatch
引用永遠也不會變,因此可以忽略它的影響。這也體現了無論如何都要對依賴保持誠實。
這也引發了另一個注意項:盡量將函數寫在 useEffect
內部。
將函數寫在 useEffect
內部
為了避免遺漏依賴,必須將函數寫在 useEffect
內部,這樣 eslint-plugin-react-hooks 才能通過靜態分析補齊依賴項:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
function getFetchUrl() {
return "https://v?query=" + count;
}
getFetchUrl();
}, [count]);
return <h1>{count}</h1>;
}
getFetchUrl
這個函數依賴了 count
,而如果將這個函數定義在 useEffect
外部,無論是機器還是人眼都難以看出 useEffect
的依賴項包含 count
。
然而這就引發了一個新問題:將所有函數都寫在 useEffect
內部豈不是非常難以維護?
如何將函數抽到 useEffect
外部?
為了解決這個問題,我們要引入一個新的 Hook:useCallback
,它就是解決將函數抽到 useEffect
外部的問題。
我們先看 useCallback
的用法:
function Counter() {
const [count, setCount] = useState(0);
const getFetchUrl = useCallback(() => {
return "https://v?query=" + count;
}, [count]);
useEffect(() => {
getFetchUrl();
}, [getFetchUrl]);
return <h1>{count}</h1>;
}
可以看到,useCallback
也有第二個參數 - 依賴項,我們將 getFetchUrl
函數的依賴項通過 useCallback
打包到新的 getFetchUrl
函數中,那么 useEffect
就只需要依賴 getFetchUrl
這個函數,就實現了對 count
的間接依賴。
換句話說,我們利用了 useCallback
將 getFetchUrl
函數抽到了 useEffect
外部。
為什么 useCallback
比 componentDidUpdate
更好用
回憶一下 Class Component 的模式,我們是如何在函數參數變化時進行重新取數的:
class Parent extends Component {
state = {
count: 0,
step: 0
};
fetchData = () => {
const url =
"https://v?query=" + this.state.count + "&step=" + this.state.step;
};
render() {
return <Child fetchData={this.fetchData} count={count} step={step} />;
}
}
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
componentDidUpdate(prevProps) {
if (
this.props.count !== prevProps.count &&
this.props.step !== prevProps.step // 別漏了!
) {
this.props.fetchData();
}
}
render() {
// ...
}
}
上面的代碼經常用 Class Component 的人應該很熟悉,然而暴露的問題可不小。
我們需要理解 props.count
props.step
被 props.fetchData
函數使用了,因此在 componentDidUpdate
時,判斷這兩個參數發生了變化就觸發重新取數。
然而問題是,這種理解成本是不是過高了?如果父級函數 fetchData
不是我寫的,在不讀源碼的情況下,我怎么知道它依賴了 props.count
與 props.step
呢?更嚴重的是,如果某一天 fetchData
多依賴了 params
這個參數,下游函數將需要全部在 componentDidUpdate
覆蓋到這個邏輯,否則 params
變化時將不會重新取數。可以想象,這種方式維護成本巨大,甚至可以說幾乎無法維護。
換成 Function Component 的思維吧!試着用上剛才提到的 useCallback
解決問題:
function Parent() {
const [ count, setCount ] = useState(0);
const [ step, setStep ] = useState(0);
const fetchData = useCallback(() => {
const url = 'https://v/search?query=' + count + "&step=" + step;
}, [count, step])
return (
<Child fetchData={fetchData} />
)
}
function Child(props) {
useEffect(() => {
props.fetchData()
}, [props.fetchData])
return (
// ...
)
}
可以看出來,當 fetchData
的依賴變化后,按下保存鍵,eslint-plugin-react-hooks 會自動補上更新后的依賴,而下游的代碼不需要做任何改變,下游只需要關心依賴了 fetchData
這個函數即可,至於這個函數依賴了什么,已經封裝在 useCallback
后打包透傳下來了。
不僅解決了維護性問題,而且對於 只要參數變化,就重新執行某邏輯,是特別適合用 useEffect
做的,使用這種思維思考問題會讓你的代碼更 “智能”,而使用分裂的生命周期進行思考,會讓你的代碼四分五裂,而且容易漏掉各種時機。
useEffect
對業務的抽象非常方便,筆者舉幾個例子:
- 依賴項是查詢參數,那么
useEffect
內可以進行取數請求,那么只要查詢參數變化了,列表就會自動取數刷新。注意我們將取數時機從觸發端改成了接收端。 - 當列表更新后,重新注冊一遍拖拽響應事件。也是同理,依賴參數是列表,只要列表變化,拖拽響應就會重新初始化,這樣我們可以放心的修改列表,而不用擔心拖拽事件失效。
- 只要數據流某個數據變化,頁面標題就同步修改。同理,也不需要在每次數據變化時修改標題,而是通過
useEffect
“監聽” 數據的變化,這是一種 “控制反轉” 的思維。
說了這么多,其本質還是利用了 useCallback
將函數獨立抽離到 useEffect
外部。
那么進一步思考,可以將函數抽離到整個組件的外部嗎?
這也是可以的,需要靈活運用自定義 Hooks 實現。
將函數抽到組件外部
以上面的 fetchData
函數為例,如果要抽到整個組件的外部,就不是利用 useCallback
做到了,而是利用自定義 Hooks 來做:
function useFetch(count, step) {
return useCallback(() => {
const url = "https://v/search?query=" + count + "&step=" + step;
}, [count, step]);
}
可以看到,我們將 useCallback
打包搬到了自定義 Hook useFetch
中,那么函數中只需要一行代碼就能實現一樣的效果了:
function Parent() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(0);
const [other, setOther] = useState(0);
const fetch = useFetch(count, step); // 封裝了 useFetch
useEffect(() => {
fetch();
}, [fetch]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>setCount {count}</button>
<button onClick={() => setStep(c => c + 1)}>setStep {step}</button>
<button onClick={() => setOther(c => c + 1)}>setOther {other}</button>
</div>
);
}
隨着使用越來越方便,我們可以將精力放到性能上。觀察可以發現,count
與 step
都會頻繁變化,每次變化就會導致 useFetch
中 useCallback
依賴的變化,進而導致重新生成函數。然而實際上這種函數是沒必要每次都重新生成的,反復生成函數會造成大量性能損耗。
換一個例子就可以看得更清楚:
function Parent() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(0);
const [other, setOther] = useState(0);
const drag = useDraggable(count, step); // 封裝了拖拽函數
}
假設我們使用 Sortablejs 對某個區域進行拖拽監聽,這個函數每次都重復執行的性能損耗非常大,然而這個函數內部可能因為僅僅要上報一些日志,所以依賴了沒有實際被使用的 count
step
變量:
function useDraggable(count, step) {
return useCallback(() => {
// 上報日志
report(count, step);
// 對區域進行初始化,非常耗時
// ... 省略耗時代碼
}, [count, step]);
}
這種情況,函數的依賴就特別不合理。雖然依賴變化應該觸發函數重新執行,但如果函數重新執行的成本非常高,而依賴只是可有可無的點綴,得不償失。
利用 Ref 保證耗時函數依賴不變
一種辦法是通過將依賴轉化為 Ref:
function useFetch(count, step) {
const countRef = useRef(count);
const stepRef = useRef(step);
useEffect(() => {
countRef.current = count;
stepRef.current = step;
});
return useCallback(() => {
const url =
"https://v/search?query=" + countRef.current + "&step=" + stepRef.current;
}, [countRef, stepRef]); // 依賴不會變,卻能每次拿到最新的值
}
這種方式比較取巧,將需要更新的區域與耗時區域分離,再將需更新的內容通過 Ref 提供給耗時的區域,實現性能優化。
然而這樣做對函數的改動成本比較高,有一種更通用的做法解決此類問題。
通用的自定義 Hooks 解決函數重新實例化問題
我們可以利用 useRef
創造一個自定義 Hook 代替 useCallback
,使其依賴的值變化時,回調不會重新執行,卻能拿到最新的值!
這個神奇的 Hook 寫法如下:
function useEventCallback(fn, dependencies) {
const ref = useRef(null);
useEffect(() => {
ref.current = fn;
}, [fn, ...dependencies]);
return useCallback(() => {
const fn = ref.current;
return fn();
}, [ref]);
}
再次體會到自定義 Hook 的無所不能。
首先看這一段:
useEffect(() => {
ref.current = fn;
}, [fn, ...dependencies]);
當 fn
回調函數變化時, ref.current
重新指向最新的 fn
這個邏輯中規中矩。重點是,當依賴 dependencies
變化時,也重新為 ref.current
賦值,此時 fn
內部的 dependencies
值是最新的,而下一段代碼:
return useCallback(() => {
const fn = ref.current;
return fn();
}, [ref]);
又僅執行一次(ref 引用不會改變),所以每次都可以返回 dependencies
是最新的 fn
,並且 fn
還不會重新執行。
假設我們對 useEventCallback
傳入的回調函數稱為 X,則這段代碼的含義,就是使每次渲染的閉包中,回調函數 X 總是拿到的總是最新 Rerender 閉包中的那個,所以依賴的值永遠是最新的,而且函數不會重新初始化。
React 官方不推薦使用此范式,因此對於這種場景,利用
useReducer
,將函數通過dispatch
中調用。 還記得嗎?dispatch
是一種可以繞過依賴的黑魔法,我們在 “什么是 useReducer” 小節提到過。
隨着對 Function Component 的使用,你也漸漸關心到函數的性能了,這很棒。那么下一個重點自然是關注 Render 的性能。
用 memo 做 PureRender
在 Fucntion Component 中,Class Component 的 PureComponent
等價的概念是 React.memo
,我們介紹一下 memo
的用法:
const Child = memo((props) => {
useEffect(() => {
props.fetchData()
}, [props.fetchData])
return (
// ...
)
})
使用 memo
包裹的組件,會在自身重渲染時,對每一個 props
項進行淺對比,如果引用沒有變化,就不會觸發重渲染。所以 memo
是一種很棒的性能優化工具。
下面就介紹一個看似比 memo
難用,但真正理解后會發現,其實比 memo
更好用的渲染優化函數:useMemo
。
用 useMemo 做局部 PureRender
相比 React.memo
這個異類,React.useMemo
可是正經的官方 Hook:
const Child = (props) => {
useEffect(() => {
props.fetchData()
}, [props.fetchData])
return useMemo(() => (
// ...
), [props.fetchData])
}
可以看到,我們利用 useMemo
包裹渲染代碼,這樣即便函數 Child
因為 props
的變化重新執行了,只要渲染函數用到的 props.fetchData
沒有變,就不會重新渲染。
這里發現了 useMemo
的第一個好處:更細粒度的優化渲染。
所謂更細粒度的優化渲染,是指函數 Child
整體可能用到了 A
、B
兩個 props
,而渲染僅用到了 B
,那么使用 memo
方案時,A
的變化會導致重渲染,而使用 useMemo
的方案則不會。
而 useMemo
的好處還不止這些,這里先留下伏筆。我們先看一個新問題:當參數越來越多時,使用 props
將函數、值在組件間傳遞非常冗長:
function Parent() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(0);
const fetchData = useFetch(count, step);
return <Child fetchData={fetchData} setCount={setCount} setStep={setStep} />;
}
雖然 Child
可以通過 memo
或 useMemo
進行優化,**但當程序復雜時,可能存在多個函數在所有 Function Component 間共享的情況 **,此時就需要新 Hook: useContext
來拯救了。
使用 Context 做批量透傳
在 Function Component 中,可以使用 React.createContext
創建一個 Context:
const Store = createContext(null);
其中 null
是初始值,一般置為 null
也沒關系。接下來還有兩步,分別是在根節點使用 Store.Provider
注入,與在子節點使用官方 Hook useContext
拿到注入的數據:
在根節點使用 Store.Provider
注入:
function Parent() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(0);
const fetchData = useFetch(count, step);
return (
<Store.Provider value={{ setCount, setStep, fetchData }}>
<Child />
</Store.Provider>
);
}
在子節點使用 useContext
拿到注入的數據(也就是拿到 Store.Provider
的 value
):
const Child = memo((props) => {
const { setCount } = useContext(Store)
function onClick() {
setCount(count => count + 1)
}
return (
// ...
)
})
這樣就不需要在每個函數間進行參數透傳了,公共函數可以都放在 Context 里。
但是當函數多了,Provider
的 value
會變得很臃腫,我們可以結合之前講到的 useReducer
解決這個問題。
使用 useReducer
為 Context 傳遞內容瘦身
使用 useReducer
,所有回調函數都通過調用 dispatch
完成,那么 Context 只要傳遞 dispatch
一個函數就好了:
const Store = createContext(null);
function Parent() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 });
return (
<Store.Provider value={dispatch}>
<Child />
</Store.Provider>
);
}
這下無論是根節點的 Provider
,還是子元素調用都清爽很多:
const Child = useMemo((props) => {
const dispatch = useContext(Store)
function onClick() {
dispatch({
type: 'countInc'
})
}
return (
// ...
)
})
你也許很快就想到,將 state
也通過 Provider
注入進去豈不更妙?是的,但此處請務必注意潛在性能問題。
將 state
也放到 Context 中
稍稍改造下,將 state
也放到 Context 中,這下賦值與取值都非常方便了!
const Store = createContext(null);
function Parent() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 });
return (
<Store.Provider value={{ state, dispatch }}>
<Count />
<Step />
</Store.Provider>
);
}
對 Count
Step
這兩個子元素而言,可需要謹慎一些,假如我們這么實現這兩個子元素:
const Count = memo(() => {
const { state, dispatch } = useContext(Store);
return (
<button onClick={() => dispatch("incCount")}>incCount {state.count}</button>
);
});
const Step = memo(() => {
const { state, dispatch } = useContext(Store);
return (
<button onClick={() => dispatch("incStep")}>incStep {state.step}</button>
);
});
其結果是:無論點擊 incCount
還是 incStep
,都會同時觸發這兩個組件的 Rerender。
其問題在於:memo
只能擋在最外層的,而通過 useContext
的數據注入發生在函數內部,會 繞過 memo
。
當觸發 dispatch
導致 state
變化時,所有使用了 state
的組件內部都會強制重新刷新,此時想要對渲染次數做優化,只有拿出 useMemo
了!
useMemo
配合 useContext
使用 useContext
的組件,如果自身不使用 props
,就可以完全使用 useMemo
代替 memo
做性能優化:
const Count = () => {
const { state, dispatch } = useContext(Store);
return useMemo(
() => (
<button onClick={() => dispatch("incCount")}>
incCount {state.count}
</button>
),
[state.count, dispatch]
);
};
const Step = () => {
const { state, dispatch } = useContext(Store);
return useMemo(
() => (
<button onClick={() => dispatch("incStep")}>incStep {state.step}</button>
),
[state.step, dispatch]
);
};
對這個例子來說,點擊對應的按鈕,只有使用到的組件才會重渲染,效果符合預期。 結合 eslint-plugin-react-hooks 插件使用,連 useMemo
的第二個參數依賴都是自動補全的。
讀到這里,不知道你是否聯想到了 Redux 的 Connect
?
我們來對比一下 Connect
與 useMemo
,會發現驚人的相似之處。
一個普通的 Redux 組件:
const mapStateToProps = state => (count: state.count);
const mapDispatchToProps = dispatch => dispatch;
@Connect(mapStateToProps, mapDispatchToProps)
class Count extends React.PureComponent {
render() {
return (
<button onClick={() => this.props.dispatch("incCount")}>
incCount {this.props.count}
</button>
);
}
}
一個普通的 Function Component 組件:
const Count = () => {
const { state, dispatch } = useContext(Store);
return useMemo(
() => (
<button onClick={() => dispatch("incCount")}>
incCount {state.count}
</button>
),
[state.count, dispatch]
);
};
這兩段代碼的效果完全一樣,Function Component 除了更簡潔之外,還有一個更大的優勢:全自動的依賴推導。
Hooks 誕生的一個原因,就是為了便於靜態分析依賴,簡化 Immutable 數據流的使用成本。
我們看 Connect
的場景:
由於不知道子組件使用了哪些數據,因此需要在 mapStateToProps
提前寫好,而當需要使用數據流內新變量時,組件里是無法訪問的,我們要回到 mapStateToProps
加上這個依賴,再回到組件中使用它。
而 useContext
+ useMemo
的場景:
由於注入的 state
是全量的,Render 函數中想用什么都可直接用,在按保存鍵時,eslint-plugin-react-hooks 會通過靜態分析,在 useMemo
第二個參數自動補上代碼里使用到的外部變量,比如 state.count
、dispatch
。
另外可以發現,Context 很像 Redux,那么 Class Component 模式下的異步中間件實現的異步取數怎么利用 useReducer
做呢?答案是:做不到。
當然不是說 Function Component 無法實現異步取數,而是用的工具錯了。
使用自定義 Hook 處理副作用
比如上面拋出的異步取數場景,在 Function Component 的最佳做法是封裝成一個自定義 Hook:
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData
});
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
dispatch({ type: "FETCH_INIT" });
try {
const result = await axios(url);
if (!didCancel) {
dispatch({ type: "FETCH_SUCCESS", payload: result.data });
}
} catch (error) {
if (!didCancel) {
dispatch({ type: "FETCH_FAILURE" });
}
}
};
fetchData();
return () => {
didCancel = true;
};
}, [url]);
const doFetch = url => setUrl(url);
return { ...state, doFetch };
};
可以看到,自定義 Hook 擁有完整生命周期,我們可以將取數過程封裝起來,只暴露狀態 - 是否在加載中:isLoading
是否取數失敗:isError
數據:data
。
在組件中使用起來非常方便:
function App() {
const { data, isLoading, isError } = useDataApi("https://v", {
showLog: true
});
}
如果這個值需要存儲到數據流,在所有組件之間共享,我們可以結合 useEffect
與 useReducer
:
function App(props) {
const { dispatch } = useContext(Store);
const { data, isLoading, isError } = useDataApi("https://v", {
showLog: true
});
useEffect(() => {
dispatch({
type: "updateLoading",
data,
isLoading,
isError
});
}, [dispatch, data, isLoading, isError]);
}
到此,Function Component 的入門概念就講完了,最后附帶一個彩蛋:Function Component 的 DefaultProps 怎么處理?
Function Component 的 DefaultProps 怎么處理?
這個問題看似簡單,實則不然。我們至少有兩種方式對 Function Component 的 DefaultProps 進行賦值,下面一一說明。
首先對於 Class Component,DefaultProps 基本上只有一種大家都認可的寫法:
class Button extends React.PureComponent {
defaultProps = { type: "primary", onChange: () => {} };
}
然而在 Function Component 就五花八門了。
利用 ES6 特性在參數定義階段賦值
function Button({ type = "primary", onChange = () => {} }) {}
這種方法看似很優雅,其實有一個重大隱患:沒有命中的 props
在每次渲染引用都不同。
看這種場景:
const Child = memo(({ type = { a: 1 } }) => {
useEffect(() => {
console.log("type", type);
}, [type]);
return <div>Child</div>;
});
只要 type
的引用不變,useEffect
就不會頻繁的執行。現在通過父元素刷新導致 Child
跟着刷新,我們發現,每次渲染都會打印出日志,也就意味着每次渲染時,type
的引用是不同的。
有一種不太優雅的方式可以解決:
const defaultType = { a: 1 };
const Child = ({ type = defaultType }) => {
useEffect(() => {
console.log("type", type);
}, [type]);
return <div>Child</div>;
};
此時不斷刷新父元素,只會打印出一次日志,因為 type
的引用是相同的。
我們使用 DefaultProps 的本意必然是希望默認值的引用相同, 如果不想單獨維護變量的引用,還可以借用 React 內置的 defaultProps
方法解決。
利用 React 內置方案
React 內置方案能較好的解決引用頻繁變動的問題:
const Child = ({ type }) => {
useEffect(() => {
console.log("type", type);
}, [type]);
return <div>Child</div>;
};
Child.defaultProps = {
type: { a: 1 }
};
上面的例子中,不斷刷新父元素,只會打印出一次日志。
因此建議對於 Function Component 的參數默認值,建議使用 React 內置方案解決,因為純函數的方案不利於保持引用不變。
最后補充一個父組件 “坑” 子組件的經典案例。
不要坑了子組件
我們做一個點擊累加的按鈕作為父組件,那么父組件每次點擊后都會刷新:
function App() {
const [count, forceUpdate] = useState(0);
const schema = { b: 1 };
return (
<div>
<Child schema={schema} />
<div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
</div>
);
}
另外我們將 schema = { b: 1 }
傳遞給子組件,這個就是埋的一個大坑。
子組件的代碼如下:
const Child = memo(props => {
useEffect(() => {
console.log("schema", props.schema);
}, [props.schema]);
return <div>Child</div>;
});
只要父級 props.schema
變化就會打印日志。結果自然是,父組件每次刷新,子組件都會打印日志,也就是 子組件 [props.schema]
完全失效了,因為引用一直在變化。
其實 子組件關心的是值,而不是引用,所以一種解法是改寫子組件的依賴:
const Child = memo(props => {
useEffect(() => {
console.log("schema", props.schema);
}, [JSON.stringify(props.schema)]);
return <div>Child</div>;
});
這樣可以保證子組件只渲染一次。
可是真正罪魁禍首是父組件,我們需要利用 Ref 優化一下父組件:
function App() {
const [count, forceUpdate] = useState(0);
const schema = useRef({ b: 1 });
return (
<div>
<Child schema={schema.current} />
<div onClick={() => forceUpdate(count + 1)}>Count {count}</div>
</div>
);
}
這樣 schema
的引用能一直保持不變。如果你完整讀完了本文,應該可以充分理解第一個例子的 schema
在每個渲染快照中都是一個新的引用,而 Ref 的例子中,schema
在每個渲染快照中都只有一個唯一的引用。
3. 總結
所以使用 Function Component 你入門了嗎?
本次精讀留下的思考題是:Function Component 開發過程中還有哪些容易犯錯誤的細節?
如果你想參與討論,請 點擊這里,每周都有新的主題,周末或周一發布。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號

special Sponsors
版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)