React function 元件和 React classes 有什麼不同?
以前,一個標準答案是說 classes 提供更多的功能(例如 state)。有了 Hooks,便不是這樣了。
可能你聽過其中一個效能更好。哪一個?許多這樣的效能基準都存在缺陷,所以我會小心地從中得出結論。效能主要取決於程式碼而不是選擇一個 function 或者 一個 class。在我們觀察中,即使優化策略有所不同,但效能的差距其實微乎其微。
另一方面我們不推薦重寫你寫好的元件,除非你有其他原因且不介意成為早期試驗者。Hooks 仍然很新(就像 2014 年的 React),並且一些“最佳實踐”還未寫進教程。
React function 和 classes 是否存在本質上的區別?當然,它們 —— 在心智模型中。在這篇文章裡,我會看看它們之間的最大區別。這在2015年的 function components 中介紹過,但它經常被忽視了:
Function 元件捕獲 render 後的值。
讓我們來分析下這是什麼意思。
注意:這片文章不做 classes 或者 functions 的價值衡量,我只描述兩種程式設計模型在 React 中的區別。更多關於採用 functions 的問題,請參閱 Hooks 常見問題解答。
思考這個元件:
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
複製程式碼
它展示一個帶有模擬網路請求的 setTimeout
且之後會在確認彈窗中現實出來的按鈕。例如,如果 props.user
為 'Dan'
,它會在三秒後顯示'Followed Dan'
,非常簡單。
(請注意,上面例子中無論我是否使用箭頭還是普通函式,function handleClick()
肯定是一樣效果的。)
那我們把它寫成 class 會怎麼樣呢?直接翻譯後可能看起來像這樣:
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
handleClick = () => {
setTimeout(this.showMessage, 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
複製程式碼
一般會認為這兩段程式碼是等效的,大家經常在這些模式中隨意的重構,而不會注意到它們的含義:
但是,這兩段程式碼略微不同。 好好看看它們,有看出不同了嗎?就個人而言,我花了好一會才發現。
前面有劇透,如果你想自己找到的話,這是一個線上demo。文章接下來的部分來分析這個差異及為什麼會這樣。
在我們繼續之前,我想強調下,我所描述的差異與 React Hooks 自身無關,上面的例子甚至不需要用 Hooks!
這完全是關於 functions 和 classes 在 React 中的區別的,如果你打算在 React 應用中更常用 functions,你可能想去弄懂它。
我們將通過 React 應用中常見的一個 bug 來說明這區別。
使用當前的條目選擇器和之前兩個 ProfilePage
實現來開啟這個 sandbox 例子 —— 每個渲染一個 Follow 按鈕。
按照這種操作順序使用兩個按鈕:
- 點選 其中一個按鈕。
- 在 3 秒中內改變選擇條目。
- 看下彈出的文字。
你會注意到一個特殊的區別:
-
當為 function 的
ProfilePage
時,點選 Follow Dan 的條目然後切換成 Sophie 的,仍然彈出'Followed Dan'
。 -
當為 class 的
ProfilePage
時,它會彈出'Followed Sophie'
:
這個例子中,第一種行為是正確的。如果你關注一個人,然後切換到另外一個人的條目,我的元件不應該困惑於我要關注的是誰。class 的實現明顯是個錯誤。
所以為什麼我們的 class 例子會以這種方式執行?
讓我們仔細看看 class 中 showMessage
方法:
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
複製程式碼
這個 class 方法讀取了 this.props.user
,Props 在 React 中是不可變的。 但是,this
是,且已經改變了。
實際上,這就是在 class 中有 this
的目的,React 本身會隨著時間的推移而變異,以便你在可以渲染和生命週期中獲取到新版本。
所以如果我們元件在處於請求狀態時重渲染,this.props
會發生改變。 showMessage
方法從“太新”的 props
中獲取 user
。
這暴露了一個 UI 層性質上的有趣現象。如果我們說 UI 在概念上是當前應用程式狀態的函式,則事件處理程式是渲染結果的一部分 —— 就像視覺輸出一樣。我們的事件處理程式“屬於”具有特定 props 和 state 的特定 render。
但是,排程一個回掉讀取 this.props
的 timeout 會中斷該聯絡。我們的 showMessage
回撥沒有“繫結”到任何特定 render 上,因此它“丟失”了正確的 props,而讀取了 this
切斷這種聯絡。
可以說 function 元件不存在這個問題。我們要這麼解決這個問題?
我們想以某種方式 “修復” 有正確 props 的 render
與獲取它們的 showMessage
回撥之間的聯絡。沿著這種方式 props
會跟丟。
一種方法是在事件早期就讀取 this.props
,然後顯示地將它們傳遞到 timeout 處理程式:
class ProfilePage extends React.Component {
showMessage = (user) => {
alert('Followed ' + user);
};
handleClick = () => {
const {user} = this.props;
setTimeout(() => this.showMessage(user), 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
複製程式碼
這樣可行。但是,這種方法使程式碼明顯更加冗餘,且隨著時間推移容易出錯。如果我們需要超過一個 prop 怎麼辦?如果我們也需要獲取 state 怎麼辦?如果 showMessage
呼叫其他方法,且這個方法讀取 this.props.something
或 this.state.something
,我們會再次遇到同樣的問題。所以我們不得不將 this.props
和 this.state
做為引數傳給每個呼叫了 showMessage
的方法。
這樣做通常會破壞通常由 class 提供的人體工程學,也難以記住或強制執行,這就是大家經常出現 bugs 的原因。
類似的,把 alert
放入 handleClick
中也無法解決這個難題。我們希望以允許拆分更多方法的方式構造程式碼,同時我們還要讀取與該呼叫相關 render 的對應 props 和 state。這個問題甚至不是 React 獨有的 —— 你可以在任何將資料放入像 this
可變物件的 UI 庫中重現它。
或許,我們可以在 constructor 裡 bind 方法?
class ProfilePage extends React.Component {
constructor(props) {
super(props);
this.showMessage = this.showMessage.bind(this);
this.handleClick = this.handleClick.bind(this);
}
showMessage() {
alert('Followed ' + this.props.user);
}
handleClick() {
setTimeout(this.showMessage, 3000);
}
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
複製程式碼
不, 這樣無法修復任何東西。記住,這個問題是由於我們太遲去讀取 this.props
了 —— 不是我們這麼用語法的問題!然而,這個問題如果我們完全依靠 JavaScript 閉包就可以解決。
通常會避開使用閉包,因為很難知道隨著時間推移可能會發生變異的值。但在 React,props 和 state 是不可以變的!(或者至少,這是一個強烈推薦。)這去除了閉包的一個殺手鐗。
這意味著如果你封鎖一個特定 render 的 props 或 state,你總是可以獲取相同的它們:
class ProfilePage extends React.Component {
render() {
// 捕獲 props!
const props = this.props;
// 注意: 我們在 *render 裡面*
// 這不是 class 方法。
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return <button onClick={handleClick}>Follow</button>;
}
}
複製程式碼
你在 render 時已經“捕獲”到 props 了:
這樣,它內部的任何程式碼(包括 showMessage
)都可以保證看到這個特定 render 的 props,React 不會再“動我們的乳酪”了。
我們在裡邊新增多少個輔助方法都可以,並且它們全都使用被捕獲的 props 和 state,救回了閉包。
上面的例子沒有錯但看起來奇怪。如果在 render 中定義函式而不是使用 class 的方法,那還要 class 做什麼?
事實上,我們可以去掉 class 這個“殼”來簡化程式碼:
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
複製程式碼
就像上面那樣,props
仍然被捕獲了 —— React 用引數形式傳遞它們。不像 this
,props
物件本身沒有被 React 改變。
如果在 function 定義時解構 props
就更明顯的:
function ProfilePage({ user }) {
const showMessage = () => {
alert('Followed ' + user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
複製程式碼
當父元件用不同 props 渲染 ProfilePage
時,React 會再次呼叫 ProfilePage
方法。但我們點選了的事件程式“屬於”具有自己的 user
值的上一個 render 和讀取它的 showMessage
回撥,它們都完好無損。
這就是為什麼,在這個 demo 的 function 版本中,在 Sophie 的條目時點選 Follow 之後切換成 Sunil 會彈出 'Followed Sophie'
:
這反應是正確的。(雖然你也可能想關注 Sunil!)
現在我們明白了 functions 與 classes 在 React 中的最大不同了:
Function 元件捕獲 渲染後的值。
使用 Hooks,同樣的原則也適用於 state。思考這個例子:
function MessageThread() {
const [message, setMessage] = useState('');
const showMessage = () => {
alert('You said: ' + message);
};
const handleSendClick = () => {
setTimeout(showMessage, 3000);
};
const handleMessageChange = (e) => {
setMessage(e.target.value);
};
return (
<>
<input value={message} onChange={handleMessageChange} />
<button onClick={handleSendClick}>Send</button>
</>
);
}
複製程式碼
([這是一個 線上 demo。])
雖然這不是一個好的訊息應用 UI,它實現了同樣的東西:如果我傳送一個特定的資訊,這個元件不應該困惑於傳送哪個訊息。這個 function 元件的訊息
捕獲了 state 且“屬於”返回被瀏覽器點選事件呼叫的 render。所以這個訊息
被設定為當我點選”傳送“時 input 裡的值。
所以我們知道 React 中的 functions 會預設捕獲 props 和 state。但如果我們希望讀取的是最新的 props 或者 state,它們不屬於特定的 render 要怎麼辦?如果我們想在未來裡讀取到它們怎麼辦?
在 classes 中,你可以讀取 this.props
或 this.state
,因為 this
本身是可變的,React 會改變它。在 function 元件中,你也可以有一個共享於所有元件 renders 的可變值,它叫做 “ref”:
function MyComponent() {
const ref = useRef(null);
// 你可以讀寫 `ref.current`。
// ...
}
複製程式碼
但是,你需要自己管理它。
ref 和例項欄位扮演相同的角色,它是進入可變命令世界的逃脫倉。你可能熟悉 “DOM refs”,但這個原理要通俗的多,它只是一個你可以往裡面放東西的箱子。
即便在視覺上,this.someting
看起來像 something.current
的映象。它們代表了相同的概念。
預設情況下,在 function 元件中 React 不會建立最新 props 或 state 的 refs。在許多情況下是不需要它們的,且分配它們會是浪費的工作。但是,如果你願意,可以手動跟蹤值:
function MessageThread() {
const [message, setMessage] = useState('');
const latestMessage = useRef('');
const showMessage = () => {
alert('You said: ' + latestMessage.current);
};
const handleSendClick = () => {
setTimeout(showMessage, 3000);
};
const handleMessageChange = (e) => {
setMessage(e.target.value);
latestMessage.current = e.target.value;
};
複製程式碼
如果我們讀取 showMessage
的 message
,我們會看到我們第幾傳送按鈕時的訊息。但當我們讀取 latestMessage.current
,我們獲取到的是最新的值 —— 即使我們在按下傳送按鈕後繼續輸入。
你可以比較這兩個 demos 看看區別。ref 是一種“選擇退出”渲染一致的方法,在某些情況下可以很方便。
通常你應該避免在渲染期間讀取或設定 refs,因為它們是可變的。我們想保持渲染的可預測性。但是,如果我們想獲取到特定 prop 或 state 最新的值,手動更新 ref 會很麻煩。我們可以用 effect 自動化它:
function MessageThread() {
const [message, setMessage] = useState('');
// 保持 track 是最新值
const latestMessage = useRef('');
useEffect(() => {
latestMessage.current = message;
});
const showMessage = () => {
alert('You said: ' + latestMessage.current);
};
複製程式碼
(這是一個demo。)
我們在 effect 裡面賦值來實現 ref 值只在 DOM 被更新時才改變。這確保我們的變異不會破壞像 Time Slicing 和 Suspense 等中斷渲染的功能。
很少會像這樣去使用 ref,捕獲 props 或 state 預設下是更好的。然而,在處理像定時器和訂閱這樣的棘手地 APIs 時是很方便的。記住你可以跟蹤任何這樣的值 —— prop、state 變數、整個 props 物件,甚至是一個 function。
這種模式也可以用來做優化 —— 例如在 useCallback
標示頻繁改變時。但是,使用一個 reducer 通常是一個更好的解決方案。(這個會在以後的部落格文章中寫!)
這片文章裡,我們看到在 classes 中的普遍破壞模式,及閉包是如何幫助我們修復它的。但是,你可能注意到了當你試著通過指定依賴陣列來優化 Hooks 時,你可能會遇到過時閉包帶來的 bugs。這意味著閉包是問題?我也不這麼認為。
正如我們之前所見,閉包確實幫助我們修復了難以注意到的細微問題。同樣地,它們使編寫在併發模式下的程式碼正常工作變得更簡單。這可能是因為元件內部的邏輯在渲染後封鎖正確的 props 和 state。
在目前為止看到的所有情況中,“過時閉包”問題發生是由於 “functions 不發生變化” 或 “props 總是相同”的錯誤假設。事實並非如此,我希望這篇文章有助於澄清。
Functions 鎖住它們的 props 和 state —— 所有它們是什麼很重要。這不是一個 bug,而是一個 function 元件的特性。例如,Functions 不應該從 userEffect
或 useCallback
的“依賴陣列”中被排除。(上面提到常用的適當修復不管是 useReducer
或是 useRef
的解決方案 —— 我們很快會在文件中說明如何在它們之間做選擇)
在我們用 functions 寫大多數 React 程式碼時,我們需要適配我們的關於 優化程式碼 和 什麼值會一直改變的情況。
到目前為止我用 hooks 找到的最好的心理規則是 “程式碼的任何值似乎可以在任意時間改變”。
Functions 也不例外。這需要一些時間才能在 React 學習材料裡面變成普遍的知識,從 class 心態過來的需要一些適應,但我希望這篇文章可以幫助你用新的眼光看待它。
React functions 總會捕獲它們的值 —— 且現在我們知道為什麼了。
它們是一個完全不同的神奇寶貝。
翻譯原文How Are Function Components Different from Classes?(2019-03-03)