什麼是生命週期方法?新的React16+生命週期方法是怎樣的?你該如何直觀地理解它們,以及爲什麼它們很有用?
生命週期方法到底是什麼?
React組件都有自己的階段。
如果要你“構建一個Hello World組件”,我相信你會這麼做:
class HelloWorld extends React.Component {
render() {
return <h1> Hello World </h1>
}
}
在客戶端渲染這個組件時,你最終可能會看到如下的視圖:
在呈現這個視圖之前,這個組件經歷了幾個階段。這些階段通常稱爲組件生命週期。
對於人類而言,我們會經歷小孩、成人、老人階段。而對於React組件而言,我們有掛載、更新和卸載階段。
巧合的是,掛載一個組件就像將一個新生嬰兒帶到這個世界。這是組件第一次擁有了生命。組件正是在這個階段被創建,然後被插入到DOM中。
這是組件經歷的第一個階段——掛載階段。
但它並不會就這樣結束了。React組件會“成長”,或者說組件會經歷更新階段。
如果React組件不經歷更新階段,它們將保持被創建時的狀態。
大部分組件會被更新——無論是通過修改state還是props,也就是經歷更新階段。
組件經歷的最後一個階段是卸載階段。
在這個階段,組件會“死亡”。用React術語來描述,就是指從DOM中移除組件。
這些就是你需要了解的有關組件生命週期的一切。
對了,React組件還需要經歷另一個階段。有時候代碼會無法運行或者某處出現了錯誤,這個時候組件正在經歷錯誤處理階段,就像人類去看醫生。
現在,你瞭解了React組件的四個基本階段或者說生命週期。
1.掛載——組件在這個階段被創建然後被插入到DOM中;
2.更新——React組件“成長”;
3.卸載——最後階段;
4.錯誤處理——有時候代碼無法運行或某處出現了錯誤。
注意:React組件可能不會經歷所有階段。一個組件有可能在掛載後立即就被卸載——沒有更新或錯誤處理。
瞭解各個階段及其相關的生命週期方法
瞭解組件經歷的各個階段只是整個等式的一部分,另一部分是瞭解每個階段所對應的方法。
這些方法就是衆所周知的組件生命週期方法。
讓我們來看看這4個階段所對應的方法。
我們先來看一下掛載階段的方法。
掛載生命週期方法
掛載階段是指從組件被創建到被插入DOM的階段。
這個階段會調用以下幾個方法(按順序描述)。
1. constructor()
這是給組件“帶來生命”時調用的第一個方法。
在將組件掛載到DOM之前會調用constructor方法。
通常,你會在constructor方法中初始化state和綁定事件處理程序。
這是一個簡單的例子:
const MyComponent extends React.Component {
constructor(props) {
super(props)
this.state = {
points: 0
}
this.handlePoints = this.handlePoints.bind(this)
}
}
我相信你已經很熟悉這個方法了,所以我不打算進一步再做解釋。
需要注意的是,這是第一個被調用的方法——在組件被掛載到DOM之前。
2. static getDerivedStateFromProps()
在解釋這個生命週期方法之前,我先說明如何使用這個方法。
這個方法的基本結構如下所示:
const MyComponent extends React.Component {
...
static getDerivedStateFromProps() {
//do stuff here
}
}
這個方法以props和state作爲參數:
...
static getDerivedStateFromProps(props, state) {
//do stuff here
}
...
你可以返回一個用於更新組件狀態的對象:
...
static getDerivedStateFromProps(props, state) {
return {
points: 200 // update state with this
}
}
...
或者返回null,不進行更新:
...
static getDerivedStateFromProps(props, state) {
return null
}
...
你可能會想,這個生命週期方法很重要嗎?它是很少使用的生命週期方法之一,但它在某些情況下會派上用場。
請記住,這個方法在組件被初始掛載到DOM之前調用。
下面是一個簡單的例子:
假設有一個簡單的組件,用於呈現足球隊的得分。
得分被保存在組件的state對象中:
class App extends Component {
state = {
points: 10
}
render() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
You've scored {this.state.points} points.
</p>
</header>
</div>
);
}
}
結果如下所示:
源代碼可以在GitHub上獲得:
https://github.com/ohansemmanuel/points
假設你像下面這樣在static getDerivedStateFromProps方法中放入其他分數,那麼呈現的分數是多少?
class App extends Component {
state = {
points: 10
}
// *******
// NB: Not the recommended way to use this method. Just an example. Unconditionally overriding state here is generally considered a bad idea
// ********
static getDerivedStateFromProps(props, state) {
return {
points: 1000
}
}
render() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
You've scored {this.state.points} points.
</p>
</header>
</div>
);
}
}
現在我們有了static getDerivedStateFromProps組件生命週期方法。在將組件掛載到DOM之前這個方法會被調用。通過返回一個對象,我們可以在組件被渲染之前更新它的狀態。
我們將看到:
1000來自static getDerivedStateFromProps方法的狀態更新。
當然,這個例子主要是出於演示的目的,static getDerivedStateFromProps方法不應該被這麼用。我這麼做只是爲了讓你先了解這些基礎知識。
我們可以使用這個生命週期方法來更新狀態,但並不意味着必須這樣做。static getDerivedStateFromProps方法有它特定的應用場景。
那麼什麼時候應該使用static getDerivedStateFromProps方法呢?
方法名getDerivedStateFromProps包含五個不同的單詞:“Get Fromived State From Props”。
顧名思義,這個方法允許組件基於props的變更來更新其內部狀態。
此外,以這種方式獲得的組件狀態被稱爲派生狀態。
根據經驗,應該謹慎使用派生狀態,因爲如果你不確定自己在做什麼,很可能會嚮應用程序引入潛在的錯誤。
3. render()
在調用static getDerivedStateFromProps方法之後,下一個生命週期方法是render:
class MyComponent extends React.Component {
// render is the only required method for a class component
render() {
return <h1> Hurray! </h1>
}
}
如果要渲染DOM中的元素,可以在render方法中編寫代碼,即返回一些JSX。
你還可以返回純字符串和數字,如下所示:
class MyComponent extends React.Component {
render() {
return "Hurray"
}
}
或者返回數組和片段,如下所示:
class MyComponent extends React.Component {
render() {
return [
<div key="1">Hello</div>,
<div key="2" >World</div>
];
}
}
class MyComponent extends React.Component {
render() {
return <React.Fragment>
<div>Hello</div>
<div>World</div>
</React.Fragment>
}
}
如果你不想渲染任何內容,可以在render方法中返回一個布爾值或null:
class MyComponent extends React.Component {
render() {
return null
}
}
class MyComponent extends React.Component {
// guess what's returned here?
render() {
return (2 + 2 === 5) && <div>Hello World</div>;
}
}
你還可以從render方法返回一個portal:
class MyComponent extends React.Component {
render() {
return createPortal(this.props.children, document.querySelector("body"));
}
}
關於render方法的一個重要注意事項是,不要在函數中調用setState或者與外部API發生交互。
4. componentDidMount()
在調用render後,組件被掛載到DOM,並調用componentDidMount方法。
在將組件被掛載到DOM之後會立即調用這個函數。
有時候你需要在組件掛載後立即從組件樹中獲取DOM節點,這個時候就可以調用這個組件生命週期方法。
例如,你可能有一個模態窗口,並希望在特定DOM元素中渲染模態窗口的內容,你可以這麼做:
class ModalContent extends React.Component {
el = document.createElement("section");
componentDidMount() {
document.querySelector("body).appendChild(this.el);
}
// using a portal, the content of the modal will be rendered in the DOM element attached to the DOM in the componentDidMount method.
}
如果你希望在組件被掛載到DOM後立即發出網絡請求,可以在這個方法裏進行:
componentDidMount() {
this.fetchListOfTweets() // where fetchListOfTweets initiates a netowrk request to fetch a certain list of tweets.
}
你還可以設置訂閱,例如計時器:
// e.g requestAnimationFrame
componentDidMount() {
window.requestAnimationFrame(this._updateCountdown);
}
// e.g event listeners
componentDidMount() {
el.addEventListener()
}
只需要確保在卸載組件時取消訂閱,我們將在討論componentWillUnmount生命週期方法時介紹更詳細的內容。
掛載階段基本上就是這樣了,現在讓我們來看看組件經歷的下一個階段——更新階段。
更新生命週期方法
每當更改React組件的state或props時,組件都會被重新渲染。簡單地說,就是組件被更新。這就是組件生命週期的更新階段。
那麼在更新組件時會調用哪些生命週期方法?
1. static getDerivedStateFromProps()
首先,還會調用static getDerivedStateFromProps方法。這是第一個被調用的方法。因爲之前已經介紹過這個方法,所以這裏不再解釋。
需要注意的是,在掛載和更新階段都會調用這個方法。
2. shouldComponentUpdate()
在調用static getDerivedStateFromProps方法之後,接下來會調用nextComponentUpdate方法。
默認情況下,或者在大多數情況下,在state或props發生變更時會重新渲染組件。不過,你也可以控制這種行爲。
你可以在這個方法中返回一個布爾值——true或false,用於控制是否重新渲染組件。
這個生命週期方法主要用於優化性能。不過,如果state和props沒有發生變更,不希望組件重新渲染,你也可以使用內置的PureComponent。
3. render()
在調用shouldComponentUpdate方法後,會立即調用render——具體取決於shouldComponentUpdate返回的值,默認爲true。
4. getSnapshotBeforeUpdate()
在調用render方法之後,接下來會調用getSnapshotBeforeUpdatelifcycle方法。
你不一定會用到這個生命週期方法,但在某些特殊情況下它可能會派上用場,特別是當你需要在DOM更新後從中獲取一些信息。
這裏需要注意的是,getSnapshotBeforeUpdate方法從DOM獲得的值將引用DOM更新之前的值,即使之前調用了render方法。
我們以使用git作爲類比。
在編寫代碼時,你會在將代碼推送到代碼庫之前暫存它們。
假設在將變更推送到DOM之前調用了render函數來暫存變更。因此,在實際更新DOM之前,getSnapshotBeforeUpdate獲得的信息指向了DOM更新之前的信息。
對DOM的更新可能是異步的,但getSnapshotBeforeUpdate生命週期方法在更新DOM之前立即被調用。
如果你還是不太明白,我再舉一個例子。
聊天應用程序是這個生命週期方法的一個典型應用場景。
我已經爲之前的示例應用程序添加了聊天窗格。
可以看到右側的窗格嗎?
聊天窗格的實現非常簡單,你可能已經想到了。在App組件中有一個帶有Chats組件的無序列表:
<ul className="chat-thread">
<Chats chatList={this.state.chatList} />
</ul>
Chats組件用於渲染聊天列表,爲此,它需要一個chatList prop。基本上它就是一個數組,一個包含3個字符串的數組:[“Hey”, “Hello”, “Hi”]。
Chats組件的實現如下:
class Chats extends Component {
render() {
return (
<React.Fragment>
{this.props.chatList.map((chat, i) => (
<li key={i} className="chat-bubble">
{chat}
</li>
))}
</React.Fragment>
);
}
}
它只是通過映射chatList prop並渲染出一個列表項,而該列表項的樣式看起來像氣泡。
還有一個東西,在聊天窗格頂部有一個“Add Chat”按鈕。
看到聊天窗格頂部的按鈕了嗎?
單擊這個按鈕將會添加新的聊天文本“Hello”,如下所示:
與大多數聊天應用程序一樣,這裏有一個問題:每當消息數量超過聊天窗口的高度時,預期的行爲應該是自動向下滾動聊天窗格,以便看到最新的聊天消息。大現在的情況並非如此。
讓我們看看如何使用getSnapshotBeforeUpdate生命週期方法來解決這個問題。
在調用getSnapshotBeforeUpdate方法時,需要將之前的props和state作爲參數傳給它。
我們可以使用prevProps和prevState參數,如下所示:
getSnapshotBeforeUpdate(prevProps, prevState) {
}
你可以讓這個方法返回一個值或null:
getSnapshotBeforeUpdate(prevProps, prevState) {
return value || null // where 'value' is a valid JavaScript value
}
無論這個方法返回什麼值,都會被傳給另一個生命週期方法。
getSnapshotBeforeUpdate生命週期方法本身不會起什麼作用,它需要與componentDidUpdate生命週期方法結合在一起使用。
你先記住這個,讓我們來看一下componentDidUpdate生命週期方法。
5. componentDidUpdate()
在調用getSnapshotBeforeUpdate之後會調用這個生命週期方法。與getSnapshotBeforeUpdate方法一樣,它接收之前的props和state作爲參數:
componentDidUpdate(prevProps, prevState) {
}
但這並不是全部。
無論從getSnapshotBeforeUpdate生命週期方法返回什麼值,返回值都將被作爲第三個參數傳給componentDidUpdate方法。
我們姑且把返回值叫作snapshot,所以:
componentDidUpdate(prevProps, prevState, snapshot) {
}
有了這些,接下來讓我們來解決聊天自動滾動位置的問題。
要解決這個問題,我需要提醒(或教導)你一些DOM幾何學知識。
下面是保持聊天窗格滾動位置所需的代碼:
getSnapshotBeforeUpdate(prevProps, prevState) {
if (this.state.chatList > prevState.chatList) {
const chatThreadRef = this.chatThreadRef.current;
return chatThreadRef.scrollHeight - chatThreadRef.scrollTop;
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot !== null) {
const chatThreadRef = this.chatThreadRef.current;
chatThreadRef.scrollTop = chatThreadRef.scrollHeight - snapshot;
}
}
這是聊天窗口:
下圖突出顯示了保存聊天消息的實際區域(無序列表ul)。
我們在ul中添加了React Ref:
<ul className="chat-thread" ref={this.chatThreadRef}>
...
</ul>
首先,因爲getSnapshotBeforeUpdate可以通過任意數量的props或state更新來觸發更新,我們將通過一個條件來判斷是否有新的聊天消息:
getSnapshotBeforeUpdate(prevProps, prevState) {
if (this.state.chatList > prevState.chatList) {
// write logic here
}
}
getSnapshotBeforeUpdate必須返回一個值。如果沒有添加新聊天消息,就返回null:
getSnapshotBeforeUpdate(prevProps, prevState) {
if (this.state.chatList > prevState.chatList) {
// write logic here
}
return null
}
現在看一下getSnapshotBeforeUpdate方法的完整代碼:
getSnapshotBeforeUpdate(prevProps, prevState) {
if (this.state.chatList > prevState.chatList) {
const chatThreadRef = this.chatThreadRef.current;
return chatThreadRef.scrollHeight - chatThreadRef.scrollTop;
}
return null;
}
我們先考慮一種情況,即所有聊天消息的高度不超過聊天窗格的高度。
表達式chatThreadRef.scrollHeight - chatThreadRef.scrollTop等同於chatThreadRef.scrollHeight - 0。
這個表達式的值將等於聊天窗格的scrollHeight——在將新消息插入DOM之前的高度。
之前我們已經解釋過,從getSnapshotBeforeUpdate方法返回的值將作爲第三個參數傳給componentDidUpdate方法,也就是snapshot:
componentDidUpdate(prevProps, prevState, snapshot) {
}
這個值是更新DOM之前的scrollHeight。
componentDidUpdate方法有以下這些代碼,但它們有什麼作用呢?
componentDidUpdate(prevProps, prevState, snapshot) {
if (snapshot !== null) {
const chatThreadRef = this.chatThreadRef.current;
chatThreadRef.scrollTop = chatThreadRef.scrollHeight - snapshot;
}
}
實際上,我們以編程方式從上到下垂直滾動窗格,距離等於chatThreadRef.scrollHeight - snapshot;。
由於snapshot是指更新前的scrollHeight,上述的表達式將返回新聊天消息的高度,以及由於更新而導致的任何其他相關高度。請看下圖:
當整個聊天窗格高度被消息佔滿(並且已經向上滾動一點)時,getSnapshotBeforeUpdate方法返回的snapshot值將等於聊天窗格的實際高度。
componentDidUpdate將scrollTop值設置爲額外消息高度的總和,這正是我們想要的。
卸載生命週期方法
在組件卸載階段會調用下面這個方法。
componentWillUnmount()
在卸載和銷燬組件之前會調用componentWillUnmount生命週期方法。這是進行資源清理最理想的地方,例如清除計時器、取消網絡請求或清理在componentDidMount()中創建的任何訂閱,如下所示:
// e.g add event listener
componentDidMount() {
el.addEventListener()
}
// e.g remove event listener
componentWillUnmount() {
el.removeEventListener()
}
錯誤處理生命週期方法
有時候組件會出現問題,會拋出錯誤。當後代組件(即組件下面的組件)拋出錯誤時,將調用下面的方法。
讓我們實現一個簡單的組件來捕獲演示應用程序中的錯誤。爲此,我們將創建一個叫作ErrorBoundary的新組件。
這是最基本的實現:
import React, { Component } from 'react';
class ErrorBoundary extends Component {
state = {};
render() {
return null;
}
}
export default ErrorBoundary;
static getDerivedStateFromError()
當後代組件拋出錯誤時,首先會調用這個方法,並將拋出的錯誤作爲參數。
無論這個方法返回什麼值,都將用於更新組件的狀態。
讓ErrorBoundary組件使用這個生命週期方法:
import React, { Component } from "react";
class ErrorBoundary extends Component {
state = {};
static getDerivedStateFromError(error) {
console.log(`Error log from getDerivedStateFromError: ${error}`);
return { hasError: true };
}
render() {
return null;
}
}
export default ErrorBoundary;
現在,只要後代組件拋出錯誤,錯誤就會被記錄到控制檯,並且getDerivedStateFromError方法會返回一個對象,這個對象將用於更新ErrorBoundary組件的狀態。
componentDidCatch()
在後代組件拋出錯誤之後,也會調用componentDidCatch方法。除了拋出的錯誤之外,還會有另一個參數,這個參數包含了有關錯誤的更多信息:
componentDidCatch(error, info) {
}
在這個方法中,你可以將收到的error或info發送到外部日誌記錄服務。與getDerivedStateFromError不同,componentDidCatch允許包含會產生副作用的代碼:
componentDidCatch(error, info) {
logToExternalService(error, info) // this is allowed.
//Where logToExternalService may make an API call.
}
讓ErrorBoundary組件使用這個生命週期方法:
import React, { Component } from "react";
class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
console.log(`Error log from getDerivedStateFromError: ${error}`);
return { hasError: true };
}
componentDidCatch(error, info) {
console.log(`Error log from componentDidCatch: ${error}`);
console.log(info);
}
render() {
return null
}
}
export default ErrorBoundary;
此外,由於ErrorBoundary只能捕捉後代組件拋出的錯誤,因此我們將讓組件渲染傳進來的Children,或者在出現錯誤時呈現默認的錯誤UI:
...
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}