話外:隨著 react 新特性 HOOKS 的提出,最新很多人開始討論 react 裡面的函式元件和 class 元件到底有什麼不同?這裡是 Dan Abramov 的一篇文章 How Are Function Components Different From Classes, 下面是對這篇文章的翻譯,大致意思大致表達出來了,不足的地方,請大家多多指教 :)
React 函式元件和 React 類有何不同?
有一段時間,規範的答案是類提供了更多的功能(如狀態)。有了 Hooks,就不再那麼正確了。
也許你聽說其中一個的效能更好。那是哪一個呢?許多基於此類的論證過程都存在缺陷,因此我會謹慎地從中得出結論。效能主要取決於程式碼做了什麼,而不是您選擇的是函式還是類。在我們的觀察中,雖然優化策略略有不同,但效能差異可以忽略不計。
在任何一種情況下,除非您有其他原因或者不介意成為第一個使用它的人,否則我們不建議您使用HOOKS重寫現有元件。 Hooks 仍然是新的(就像 2014 年的 React 一樣),並且一些“最佳實踐”還沒有在教程中提到。
那我們該怎麼辦呢? React 函式和類之間是否有任何根本的區別?當然,還有在心理模型中。在這篇文章中,我將看看它們之間的最大區別。函式元件自 2015 年推出以來,它就一直存在,但卻經常被忽視:
函式元件捕獲已渲染的值。
讓我們來看看這是什麼意思。
注意:這篇文章不是重在評價類或者函式。我只闡述在 react 中的這兩個語法模型之間的區別。關於更廣泛地採用函式式元件的問題,請參考 Hooks FQA
思考一下這個元件:
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
複製程式碼
它顯示一個按鈕,使用 setTimeout
模擬網路請求,然後顯示確認警告彈窗。例如,如果 props.user
為 'Dan'
,那麼呼叫這個函式3s之後將會顯示 'Followed Dan'
,這很好理解。
(注意在上面這個例子中使用箭頭函式或者是函式宣告都是可以的,function handleClick()
會以完全相同的方式工作)
我們如何把它寫成一個類呢?簡單的轉換可能看起來像是這樣:
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
handleClick = () => {
setTimeout(this.showMessage, 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
複製程式碼
通常會認為這兩個程式碼片段是等效的。人們經常隨意的使用這些模式進行重構,而不會注意到它們的含義。
但是,這兩個程式碼片段還是有些細微的差別。仔細的看看他們,你看出差異了嗎?就個人而言,我花了一段時間才看到這一點。提示一下,如果你想自己搞清楚,這是一個線上例子, 本文的其餘部分解釋了這個差異及其重要性。
在我們開始之前,我想強調的是,我所描述的差異與 React Hooks 本身無關。 上面的例子甚至沒有使用Hooks!
它只是 React 中函式和類之間的區別。如果您計劃在 React 應用程式中更頻繁地使用函式式元件,那你可能想要了解它。
我們將用 React 應用程式中常見的 bug 來說明它們之間的區別。
使用一個即時 profile 選擇器和上面的兩個 ProfilePage 實現開啟這個示例沙箱 - 每個都實現了一個 Follow 按鈕。
用兩個按鈕嘗試以下操作序列:
- 點選其中一個 Follow 按鈕。
- 在3秒之內更改所選的配置。
- 閱讀警告文字
你將會注意到他們結果的差異:
- 在基於函式的
ProfilePage
上,點選跟隨 Dan 的配置,然後改變配置為 Sophie 的,3s 後的警告任然是'Followed Dan'
。 - 在基於類的
ProfilePage
上,則將會警告'Followed Sophie'
在這個例子中,第一個的行為是正確的。如果我跟隨了一個人然後導航到另一個人的配置,我的元件不應該對我到底跟隨了誰而感到困惑。這個類的實現顯然是錯誤的。
(儘管你可能想去關注 sophie)
為什麼我們使用類的結果是這樣的呢?
讓我們仔細觀察我們類中的 showMessage
方法。
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
複製程式碼
這個類方法讀取 this.props.user
,Props 在 React 中是不可變的所以它永遠不會改變,但是 this
總是可變的。
實際上,這就是類中存在this的全部意義。React 本身會隨著時間而改變,以便您可以在 render
和生命週期函式中讀取新版本。
因此,如果我們的元件在請求執行時更新。this.props
將會改變。showMessage 方法從“最新”的 props
中讀取 user
。
這是揭示了關於使用者介面本質的一個有趣的現象。如果我們說UI在概念上是當前應用程式狀態的函式,那麼事件處理函式就是渲染輸出的一部分,就像視覺化輸出一樣。我們的事件處理程式“屬於”具有特定的 props
和 state
的特定的 render
。
但是當定時器的回撥函式讀取 this.props
時 打破了這種規則。我們的 showMessage
回撥函式沒有繫結到任何特定的 render
,所以它失去了正確的 props
, 從 this
裡讀取 props 切斷了這種關聯。
假設說函式元件不存在,我們該怎麼解決這個問題呢?
我們希望以某種方式在渲染之後“修復” props 與讀取它們的 showMessage
回撥之間的連線。因為Props
在傳遞的過程中失去了正確的意義。
一個方法是在事件處理函式初始就讀取 this.props
,然後精確的將它傳遞到 setTimeout
的回撥函式中。
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 怎麼辦?如果我們還需要訪問狀態怎麼辦?如果 showMessage 呼叫另一個方法,並且該方法讀取 this.props.something
或 this.state.something
,我們將再次遇到完全相同的問題。所以我們必須將 this.props 和 this.state 作為引數傳遞給 showMessage
呼叫的每個方法。
這麼做不僅不符合通常我們對類的認知,同時也極其難以記錄並施行,最後程式碼就會不可避免的出現 bug。
同樣,在 handleClick 中 alert
程式碼並不能解決更大的問題。我們想要使用一種可以拆分為多個方法的方式來構造程式碼,同時也要讀取被呼叫時與之對應的引數和狀態,這個問題甚至不是 React 獨有的 - 您可以將資料放入像 this
的這樣的可變物件一樣來在任何UI庫中來重現它。
可能,我們可以在建構函式中繫結方法?
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
是不可變的(至少這是被強烈推薦的)。這消除了使用閉包時的主要的問題。
這意味著如果你從特定渲染中遮蔽 props
或 state
,你可以認為它們保持完全相同:
class ProfilePage extends React.Component {
render() {
// Capture the props!
const props = this.props;
// Note: we are *inside render*.
// These aren't class methods.
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return <button onClick={handleClick}>Follow</button>;
}
}
複製程式碼
你可以在渲染的時候獲取 props
。
這樣,在這個特定的 render 函式內部的任何程式碼(包括 showMessage)都可以保證取到正確的 props
。React 不會再“動我們的乳酪”。
然後我們可以在裡面新增我們想要的任意數量的輔助函式,它們都會使用被捕獲的 props
和 state
。這多虧了閉包的幫助!
上面的例子是正確的但看起來有些奇怪, 如果在 render 中定義函式而不是使用類的方式,那使用類有什麼意義呢?
實際上,我們可以通過移除包裹在他外層的 class “殼”來簡化程式碼:
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
複製程式碼
就像上面這樣,prop
依舊被捕獲到了。React 將他們像引數一樣進行傳遞進來,不同於 this
, 這個 props
物件本身永遠不會被React改變。
如果你在函式定義的時候對 props
解構賦值,效果會更加明顯
function ProfilePage({ user }) {
const showMessage = () => {
alert('Followed ' + user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
複製程式碼
當父元件使用不同的 props
渲染 ProfilePage
時,React 會再一次呼叫 ProfilePage
函式。但是我們已經點選的事件處理程式“屬於”前一個 render
函式,他自己的 showMessage
回撥函式讀取的 user
值沒有任何改變。
這就是為什麼在這個例子的函式版本中,點選跟隨 sophie's 配置之後改變配置為 sunil 時會顯示 ‘Followed Sophie’
,
現在我們理解了在 React 中函式和類之間最大的區別了:
函式元件獲取已經渲染過的值(Function components capture the rendered values.)
使用 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>
</>
);
}
複製程式碼
(這裡是線上例子)
雖然這不是一個非常好的訊息應用 UI,但它說明了同樣的觀點:如果我傳送特定訊息,元件不應該對實際傳送的訊息感到困惑。此函式元件的 message
捕獲到的是渲染的資料,然後被瀏覽器呼叫的單擊事件處理函式返回。因此當我點選傳送的時候 message
會被設定成我輸入的東西。
所以我們知道 React 裡面的函式會預設捕獲 props
和 state
。但如果我們想讀取不屬於特定 render 的最新 props
和 state
時該怎麼辦呢?就是如果我們想從未來讀取它們該怎麼辦?
在類裡面,你可以通過讀取 this.props
和 this.state
來做到,因為他們本身是可變的。由 React 來改變。在函式元件裡,你同樣有一個可以被所有元件渲染共享的可變值
,他被叫做 "ref"。
function MyComponent() {
const ref = useRef(null);
// You can read or write `ref.current`.
// ...
}
複製程式碼
但是你必須自己去管理它。
ref 與例項欄位扮演同樣的角色。它是進入可變命令世界的逃脫艙。您可能熟悉 “DOM refs”,但這個的概念更為通用。它只是一個你可以把東西放進去的盒子。
即使在視覺上,this.something
看起來就像是 something.current
一樣,它們表達的概念是相同的。
在函式元件裡 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
,當我們按下 send 按鈕的時候我們會看見這個 message,但當我們讀取 latestMessage.current
時,我們會得到最新的值,即使是在我們按下send按鈕之後繼續輸入的值。
你可以通過這兩個例子來比較他們的不同。ref 是一種“選擇性退出”渲染一致性的方案,在某些情況下可以很方便。
通常情況下,如果想要保持渲染的可預測性,您應該避免在渲染的時候讀取或者設定refs,因為他們是可變的。但是如果我們想要獲得特定的 props
和 state
的最新值,手動更新 ref 可能很煩人。我們可以通過使用 effect 自動更新它:
function MessageThread() {
const [message, setMessage] = useState('');
// Keep track of the latest value.
const latestMessage = useRef('');
useEffect(() => {
latestMessage.current = message;
});
const showMessage = () => {
alert('You said: ' + latestMessage.current);
};
複製程式碼
這是一個例子
我們在 effect 裡面做了賦值,所以 ref 的值只會在 DOM 更新之後才會改變,這確保我們的變化不會破壞依賴於可中斷渲染的功能。比如 Time Slicing and Suspense。
通常我們不需要經常使用 ref。預設獲取 props
和 state
通常更好。但是,在處理 intervals 和 subscriptions 等命令式 API 時它會很方便。請記住,您可以跟蹤任何值,比如:prop 和 state 變數、整個 prop 物件、甚至是函式。
此模式的優化也很方便 - 例如,當 useCallback 標識經常更改時。但是,使用 reducer 通常是更好的解決方案。 (未來部落格文章的主題!)
在這篇文章中,我們研究了類中常見的 bug,以及如何使用閉包來幫助我們修復它。但是,您可能已經注意到,當您試圖通過指定依賴項陣列來優化 Hooks 時,可能會遇到閉包還未來得及更新導致的 bug。這是否意味著閉包是問題所在呢?我不這麼認為。
正如我們在上面看到的,閉包實際上幫助我們解決了難以注意到的細微問題。類似地,它們使編寫在併發模式下正確工作的程式碼變得容易得多。這是可能的,因為元件內部的邏輯在渲染它時遮蔽了正確的 props 和 state。
到目前為止,我所見過的所有情況下,“陳舊的閉包”問題都是由於錯誤地假設“函式不會改變”或 “ props 總是相同的”而發生的。事實並非如此,我希望這篇文章有助於澄清這一點。
函式遮蔽了他們更新的 props
和 state
——因此它們的標識也同樣重要。這不是一個 bug,而是函式元件的一個特性。例如,對於 useEffect 或 useCallback,函式不應該被排除在“相關陣列”之外。(正確的修復通常是 useReducer 或上面的 useRef 解決方案——我們很快將在文件中解釋如何在兩者之間進行選擇)。
當我們用函式編寫大多數 React 程式碼時,我們需要調整我們關於優化程式碼的直覺以及哪些值會隨時間變化
就像 Fredrik 寫的那樣:
到目前為止,我所發現的關於 hook 的最好的心理預期是“程式碼裡好像任何值都可以隨時更改”。
函式也不例外。這需要一些時間才能成為 React 學習材料的常識。它需要從 class 的思維方式進行一些調整。但我希望這篇文章可以幫助你以新的眼光看待它。
React 函式總是捕獲它們的值 - 現在我們知道原因了。