七天接手react專案 系列 —— 生命週期&受控和非受控元件&Dom 元素&Diffing 演算法

彭加李發表於2022-03-18

生命週期&受控和非受控元件&Dom 元素&Diffing 演算法

生命週期

首先回憶一下 vue 中的生命週期:

vue 對外提供了生命週期的鉤子函式,允許我們在 vue 的各個階段插入一些我們的邏輯,比如:createdmountedbeforeDestroy等。

vue-生命週期圖

react 中的生命週期是否也類似?請接著看:

每個元件都包含 “生命週期方法”,你可以重寫這些方法,以便於在執行過程中特定的階段執行這些方法 —— 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>

TipunmountComponentAtNode() 從 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() 方法也不會再執行。請看下圖:

react-生命週期圖2

呼叫 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 節點來處理 —— 官網-非受控元件

這裡我們能接收兩個資訊:

  1. 推薦使用受控元件
  2. 受控元件和非受控元件的區別在於:表單資料由誰來處理 —— 是 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>
        );
    }
}

Tipthis.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 也是可以的。

相關文章