生命週期&受控和非受控元件&Dom 元素&Diffing 演算法
生命週期
首先回憶一下 vue 中的生命週期:
vue 對外提供了生命週期的鉤子函式,允許我們在 vue 的各個階段插入一些我們的邏輯,比如:created
、mounted
、beforeDestroy
等。
react 中的生命週期是否也類似?請接著看:
每個元件都包含 “生命週期方法”,你可以重寫這些方法,以便於在執行過程中特定的階段執行這些方法 —— react 官網-元件的生命週期
請看一張 react 的生命週期圖譜:
從這張圖我們知道:
- 既然沒有勾選”展示不常用的生命週期“,這裡顯示的 5 個方法就是常用的生命週期方法。
- 元件的生命週期可以分三個階段:掛載、更新、解除安裝
- 掛載時的順序是:
constructor()
、render()
、componentDidMount()
Tip:
- componentDidMount() 會在元件掛載後(插入 DOM 樹中)立即呼叫。常做定時器、網路請求
componentDidUpdate()
會在更新後會被立即呼叫。首次渲染不會執行此方法componentWillUnmount()
會在元件解除安裝及銷燬之前直接呼叫。在此方法中執行必要的清理操作,例如,清除 timer,取消網路請求或清除在componentDidMount()
中建立的訂閱等
掛載和解除安裝
以 Clock 元件為例:
當 Clock 元件第一次被渲染到 DOM 中的時候,就為其設定一個計時器。這在 React 中被稱為“掛載(mount)”。
同時,當 DOM 中 Clock 元件被刪除的時候,應該清除計時器。這在 React 中被稱為“解除安裝(unmount)”。
請看實現:
class Clock extends React.Component {
state = { date: new Date() }
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
1000
)
}
// 元件解除安裝前會被呼叫
componentWillUnmount() {
clearInterval(this.timerID) // {1}
}
tick() {
this.setState({
date: new Date()
});
}
handleUnmount = () => {
// 從 DOM 中解除安裝元件
ReactDOM.unmountComponentAtNode(document.getElementById('root'))
}
render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
<button onClick={this.handleUnmount}>解除安裝</button>
</div>
);
}
}
頁面顯示:
Hello, world!
It is 11:34:16.
解除安裝
時間每秒都會更新,點選按鈕”解除安裝“,頁面將不再有任何資訊,對應的 html 為 <div id="root"></div>
Tip:unmountComponentAtNode() 從 DOM 中解除安裝元件,會將其事件處理器(event handlers)和 state 一併清除。
注:倘若將 clearInterval(this.timerID)
(行{1})註釋,點選”解除安裝“將報錯如下:
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method.
警告:無法對解除安裝的元件執行 React 狀態更新。 這是一個空操作,但它表明您的應用程式中存在記憶體洩漏。 要修復,請取消 componentWillUnmount 方法中的所有訂閱和非同步任務。
不要將定時器放入 render()
倘若將上面例子中的定時器放在 render() 中。就像這樣:
render() {
console.log(1)
// 定時器
this.timerID = setInterval(
() => this.tick(),
1000
)
return (
// ...不變
);
}
之前 render() 每秒執行一次,現在很快就會執行過萬,因為每次執行都會生成一個定時器。
過時的生命週期方法
以下生命週期方法標記為“過時”。這些方法仍然有效,但不建議在新程式碼中使用它們 —— 官網-過時的生命週期方法
-
componentWillMount
,現在改名為UNSAFE_componentWillMount()
,在掛載之前被呼叫 -
componentWillReceiveProps
,現在改名為UNSAFE_componentWillReceiveProps()
,在已掛載的元件接收新的 props 之前被呼叫。第一次傳的不算,以後傳的才算,有人說應該叫componentWillReceiveNewProps
-
componentWillUpdate
,現在改名為UNSAFE_componentWillUpdate()
,當元件收到新的 props 或 state 時,會在渲染之前呼叫。
倘若用了重新命名之前的方法,控制檯會有詳細的警告資訊。請看示例:
class Clock extends React.Component {
componentWillMount() {
}
UNSAFE_componentWillReceiveProps() {
}
}
控制檯輸出:
Warning: componentWillMount has been renamed, and is not recommended for use. See https://reactjs.org/link/unsafe-component-lifecycles for details.
* Move code with side effects to componentDidMount, and set initial state in the constructor.
* Rename componentWillMount to UNSAFE_componentWillMount to suppress this warning in non-strict mode. In React 18.x, only the UNSAFE_ name will work. To rename all deprecated lifecycles to their new names, you can run `npx react-codemod rename-unsafe-lifecycles` in your project source folder.
Please update the following components: Clock
Tip:既然這幾個方法不建議使用,所以不打算深入研究
UNSAFE_ 不是指安全性
這裡的 “unsafe” 不是指安全性,而是表示使用這些生命週期的程式碼在 React 的未來版本中更有可能出現 bug,尤其是在啟用非同步渲染之後 —— 官網-非同步渲染之更新
shouldComponentUpdate
shouldComponentUpdate()
預設返回 true。用法如下:
class Clock extends React.Component {
state = { date: new Date() }
componentDidMount() {
this.timerID = setInterval(
() => this.tick(),
1000
)
}
tick() {
this.setState({
date: new Date()
});
}
render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
// 返回 false
shouldComponentUpdate() {
return false
}
}
Clock 的時間不會再變化。render()
方法也不會再執行。請看下圖:
呼叫 setState()
,如果 shouldComponentUpdate()
返回 false 則中斷,不再執行 render()
。
Tip:此方法僅作為效能優化的方式而存在。不要企圖依靠此方法來“阻止”渲染,因為這可能會產生 bug —— 官網-shouldComponentUpdate()
forceUpdate
根據上圖說明,呼叫 forceUpdate()
將致使元件呼叫 render()
方法,此操作會跳過該元件的 shouldComponentUpdate()
。
通常應該避免使用 forceUpdate()
新增生命週期方法
相對舊的生命週期,新增如下兩個方法,但都屬於不常見的情形,所以不做詳細研究。
getDerivedStateFromProps
getDerivedStateFromProps()
會在呼叫 render 方法之前呼叫,並且在初始掛載及後續更新時都會被呼叫。它應返回一個物件來更新 state,如果返回 null 則不更新任何內容。
此方法適用於罕見的用例,即 state 的值在任何時候都取決於 props。
getDerivedStateFromProps
的存在只有一個目的:讓元件在 props 變化時更新 state —— 官網-什麼時候使用派生 state
getSnapshotBeforeUpdate
getSnapshotBeforeUpdate()
在最近一次渲染輸出(提交到 DOM 節點)之前呼叫。它使得元件能在發生更改之前從 DOM 中捕獲一些資訊(例如,滾動位置)。
此用法並不常見,但它可能出現在 UI 處理中,如需要以特殊方式處理滾動位置的聊天執行緒等。
在函式元件中使用生命週期
我們可以在函式元件中使用 useEffect 來模擬常見的生命週期鉤子:componentDidMount()
、componentDidUpdate()
、componentWillUnmount()
。
體驗 useEffect
首先我們執行一個例子:
function MyButton() {
const [count, setCount] = React.useState(0)
const add = () => {
setCount(count + 1)
}
const unMount = () => {
ReactDOM.unmountComponentAtNode(document.getElementById('root'))
}
// React.useEffect() 將寫在此處 {1}
return (
<div>
<button onClick={add}>{count}</button> <button onClick={unMount}>解除安裝</button>
</div>
);
}
ReactDOM.render(
<MyButton />,
document.getElementById('root')
)
頁面顯示兩個按鈕:
0 解除安裝
第一個按鈕顯示一個數字,每點選一次就會自增 1,點選第二個按鈕,此元件就會被解除安裝。
我們接下來在行{1}處新增 React.useEffect()
相關程式碼。請看示例:
// 相當於 componentDidMount()、componentDidUpdate()
React.useEffect(() => {
console.log('a')
})
頁面渲染後就會輸出 a,之後每點選第一個按鈕都會輸出 a,點選解除安裝沒有輸出。
可以給 useEffect 傳遞第二個引數,它是 effect 所依賴的值陣列 —— 官網-effect 的條件執行
倘若給 useEffect 第二個引數傳遞一個空陣列,表明沒有依賴值:
// 相當於 componentDidMount()
React.useEffect(() => {
console.log('a')
}, [])
頁面渲染後就會輸出 a,但點選第一個按鈕就不會再有輸出。
通常,元件解除安裝時需要清除 effect 建立的諸如訂閱或計時器 ID 等資源。要實現這一點,useEffect 函式需返回一個清除函式 —— 官網-清除 effect
倘若給 useEffect 函式返回一個函式。請看示例:
React.useEffect(() => {
console.log('a')
return () => {
console.log('b')
}
}, [])
頁面渲染後就會輸出 a
,但點選第一個按鈕就不會再有輸出,點選解除安裝輸出 b
。
優化函式元件 Clock 中的定時器
在函式元件中使用 state中我們寫過這麼一個例子:
function Clock() {
const [name] = React.useState('pjl')
const [date, setDate] = React.useState(new Date())
setInterval(() => {
console.log('setInterval')
setDate(new Date())
}, 1000)
return (
<div>
<h1>Hello, world! {name}</h1>
<h2>It is {date.toLocaleTimeString()}.</h2>
</div>
);
}
十秒就會輸出一千多次 setInterval
。定時器應該只執行一次,放在 componentDidMount
生命鉤子中比較合適。以下是優化後的增強版:
function Clock() {
// console.log('Clock')
const [name] = React.useState('pjl')
const [date, setDate] = React.useState(new Date())
React.useEffect(() => {
console.log('useEffect')
const timerId = setInterval(() => {
// console.log('setInterval')
setDate(new Date())
}, 1000)
return () => {
clearInterval(timerId)
}
}, [name])
const unMount = () => {
ReactDOM.unmountComponentAtNode(document.getElementById('root'))
}
return (
<div>
<h1>Hello, world! {name}</h1>
<h2>It is {date.toLocaleTimeString()}.</h2>
<button onClick={unMount}>解除安裝</button>
</div>
);
}
受控元件和非受控元件
在大多數情況下,我們推薦使用 受控元件 來處理表單資料。在一個受控元件中,表單資料是由 React 元件來管理的。另一種替代方案是使用非受控元件,這時表單資料將交由 DOM 節點來處理 —— 官網-非受控元件
這裡我們能接收兩個資訊:
- 推薦使用受控元件
- 受控元件和非受控元件的區別在於:表單資料由誰來處理 —— 是 react 元件管理,還是 dom 來處理。
受控元件
將表單寫為受控元件:
class NameForm extends React.Component {
state = { value: '' }
// 值若改變,則將其更新到 state 中
handleChange = event => {
this.setState({ value: event.target.value });
}
// 提交表單
handleSubmit = event => {
console.log('提交的名字: ' + this.state.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
名字:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="提交" />
</form>
);
}
}
ReactDOM.render(
<NameForm />,
document.getElementById('root')
);
頁面顯示
名字:[ 輸入框 ] 提交
在輸入框中輸入”123“,點選”提交“按鈕,控制檯將輸出 ”提交的名字: 123“。
非受控元件
重寫 NameForm 元件,改為功能相同的非受控元件:
class NameForm extends React.Component {
input = React.createRef()
handleSubmit = event => {
console.log('提交的名字: ' + this.input.current.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
名字:
<input type="text" ref={this.input} />
</label>
<input type="submit" value="提交" />
</form>
);
}
}
勿過度使用 Refs —— 官網
Tip:倘若發生事件的元素,是你要操作的元素時,可以通過 event.target 取得 dom。
高階函式和函式柯里化優化受控元件
按照受控元件中的寫法,如果我們定義多個 input,我們就得寫多個 handleXxxx
處理方法。就像這樣:
class NameForm extends React.Component {
state = { name: '', age: '' }
// 2 個 input 對應 2 個處理方法
handleName = event => {
this.setState({ name: event.target.value });
}
handleAge = event => {
this.setState({ age: event.target.value });
}
handleSubmit = event => {
console.log({ name: this.state.name, age: this.state.age });
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
名字:
<input type="text" value={this.state.name} onChange={this.handleName} />
</label>
<label>
年齡:
<input type="text" value={this.state.age} onChange={this.handleAge} />
</label>
<input type="submit" value="提交" />
</form>
);
}
}
如果我們有10個,豈不是要寫10個處理方法!我們可以用高階函式
和函式柯里化
來對其優化。請看實現:
class NameForm extends React.Component {
state = { name: '', age: '' }
// saveFormField 既是`高階函式`,也使用了`函式柯里化`
saveFormField = (stateName) => {
return (event) => {
this.setState({ [stateName]: event.target.value }) // {1}
}
}
handleSubmit = event => {
console.log({ name: this.state.name, age: this.state.age });
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
名字:
<input type="text" value={this.state.name} onChange={this.saveFormField('name')} />
</label>
<label>
年齡:
<input type="text" value={this.state.age} onChange={this.saveFormField('age')} />
</label>
<input type="submit" value="提交" />
</form>
);
}
}
Tip:this.setState({ [stateName]: event.target.value })
使用的語法是 可計算屬性名。
高階函式
高階函式是處理函式的函式,只要滿足其中一個條件即可:
- 引數是函式
- 返回函式
js 內建的高階函式有:Array.forEach、setInterval、Promise等。
函式柯里化
通過函式呼叫繼續返回函式,實現多次接收引數最後統一處理的函式編碼形式。
最二的一個示例是將:
function sum(a,b,c){
return a + b + c
}
改成 sum(1)(2)(3)
的形式。就像這樣:
const sum = (a) => {
return (b) => {
return (c) => {
return a + b + c
}
}
}
// 6
console.log(sum(1)(2)(3))
DOM 元素
React 實現了一套獨立於瀏覽器的 DOM 系統,兼顧了效能和跨瀏覽器的相容性。我們藉此機會完善了瀏覽器 DOM 實現的一些特殊情況 ——官網-DOM 元素。
在 React 中,所有的 DOM 特性和屬性(包括事件處理)都應該是小駝峰命名的方式。例如,與 HTML 中的 tabindex 屬性對應的 React 的屬性是 tabIndex。
注:例外的情況是 aria-* 以及 data-* 屬性,一律使用小寫字母命名。比如, 你依然可以用 aria-label 作為 aria-label。
React 與 HTML 之間有很多屬性存在差異,下面以 onChange 為例。
Tip:比如 react 中用 htmlFor 代替 for,其他更多介紹請看 DOM 元素。
onChange
onChange 事件與預期行為一致:每當表單欄位變化時,該事件都會被觸發。我們故意沒有使用瀏覽器已有的預設行為,是因為 onChange 在瀏覽器中的行為和名稱不對應,並且 React 依靠了該事件實時處理使用者輸入 —— 官網-onChange
change 事件並不是每次元素的 value 改變時都會觸發 —— mdn-change 事件
原生 html 中 change 事件是這樣的:
<body>
名字:<input name="name" />
<script>
document.querySelector('input').
addEventListener('change', e => console.log(e.target.value))
</script>
</body>
在輸入框中輸入 123
,點選他處讓 input 失去焦點,控制檯輸出 123
。
在上面受控元件 NameForm 中增加一行:
class NameForm extends React.Component {
state = { value: '' }
handleChange = event => {
+ console.log(event.target.value)
this.setState({ value: event.target.value });
}
}
在輸入框中輸入 123
,控制檯依次輸出:
1
12
123
每當表單欄位變化時,該事件都會被觸發。事件名和行為相對應。
Diffing 演算法
根節點
當對比兩棵樹時,React 首先比較兩棵樹的根節點 —— 官網-Diffing 演算法
對比不同型別的元素
當根節點為不同型別的元素時,React 會拆卸原有的樹並且建立起新的樹
舉個例子,當一個元素從 <a>
變成 <img>
,從 <Article>
變成 <Comment>
,或從 <Button>
變成 <div>
都會觸發一個完整的重建流程
當解除安裝一棵樹時,對應的 DOM 節點也會被銷燬。元件例項將執行 componentWillUnmount()
方法。
在根節點以下的元件也會被解除安裝,它們的狀態會被銷燬。比如,當比對以下更變時:
<div>
<Counter />
</div>
<span>
<Counter />
</span>
React 會銷燬 Counter 元件並且重新裝載一個新的元件。
對比同型別的元素
當對比兩個相同型別的 React 元素時,React 會保留 DOM 節點,僅比對及更新有改變的屬性
比如:
<div className="before" title="stuff" />
<div className="after" title="stuff" />
通過對比這兩個元素,React 知道只需要修改 DOM 元素上的 className 屬性。
在處理完當前節點之後,React 繼續對子節點進行遞迴。
對比同型別的元件元素
當一個元件更新時,元件例項會保持不變,因此可以在不同的渲染時保持 state 一致。React 將更新該元件例項的 props 以保證與最新的元素保持一致,並且呼叫該例項的 componentDidUpdate()
方法。
下一步,呼叫 render() 方法,diff 演算法將在之前的結果以及新的結果中進行遞迴
對子節點進行遞迴
預設情況下,當遞迴 DOM 節點的子元素時,React 會同時遍歷兩個子元素的列表
在子元素列表末尾新增元素時,更新開銷比較小。比如:
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
React 會先匹配兩個 <li>first</li>
對應的樹,然後匹配第二個元素 <li>second</li>
對應的樹,最後插入第三個元素的 <li>third</li>
樹。
如果只是簡單的將新增元素插入到表頭,那麼更新開銷會比較大。比如:
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
React 並不會意識到應該保留 <li>Duke</li>
和 <li>Villanova</li>
,而是會重建每一個子元素。這種情況會帶來效能問題。
Keys
為了解決上述問題(新增元素插入表頭開銷大),React 引入了 key 屬性。以下示例在新增 key 之後,使得樹的轉換效率得以提高:
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
現在 React 知道只有帶著 '2014' key 的元素是新元素,帶著 '2015' 以及 '2016' key 的元素僅僅移動了。
正確使用 key
倘若用元素在陣列中的下標作為 key,有時不僅會造成上面所說的效能問題,有時還會造成程式的錯誤。請看示例:
function Demo() {
const [todos, setTodos] = React.useState(['a', 'b'])
const unshift = () => {
setTodos([++seed, ...todos])
}
return (
<div>
<ul>
{
todos.map((item, index) => {
return <li key={index} data-index={index}> {item} <input type="text" /></li>
})
}
</ul>
<button onClick={unshift}>頭部插入</button>
</div>
)
}
頁面顯示:
a [ /* input 輸入框 */ ]
b [ /* input 輸入框 */ ]
頭部插入
在第一個輸入框中輸入 a,在第二個輸入框中輸入 b,然後點選按鈕“頭部插入”,介面錯亂如下:
1 [a ]
a [b ]
b [ ]
頭部插入
倘若將 key 改成唯一值,使用相同的操作,介面就正常:
{
todos.map((item, index) => {
return <li key={item} data-index={index}> {item} <input type="text" /></li>
})
}
1 [ ]
a [a ]
b [b ]
頭部插入
在 Codepen 有兩個例子,分別為 展示使用下標作為 key 時導致的問題
,以及不使用下標作為 key 的例子的版本,修復了重新排列,排序,以及在列表頭插入的問題
—— 官網-Keys
Tip:如果僅做簡單展示,用元素在陣列中的下標作為 key 也是可以的。