深入React v16新特性(二)

fengkk發表於2019-03-04

前言

之前寫了深入React v16 新特性(一),如果之前沒看過的可以先閱讀,裡面先介紹的 v16 比較簡單基礎的 API,程式碼倉庫在這篇文章裡有。本篇內容有:

  • 生命週期函式的改變
  • 深入 React v16 的底層 fiber 架構以解釋其原因
  • 新的 Context API,以及使用其實現簡易的 react-redux

React v16 底層 fiber

,Facebook 花了近一年的時間,幾乎重寫了整個 React 的底層架構就是為了引入 fiber。那 fiber 是個啥?在 Google 翻譯下就是“纖維”的意思。可以這樣理解:fiber 是次於“程式”的一個概念,即更細粒度控制程式。 具體來看:如果你想渲染一個元件,由於 js 是單執行緒的,必須要全部渲染完畢,如圖所示:

深入React v16新特性(二)

最後一直渲染完畢 js 才對其他動作作出響應;如果這個元件樹非常大,渲染耗時非常長,那麼在這段時間內瀏覽器就處於假死狀態,無法對使用者任何反應(點選事件等)作出反應,體驗非常差,因為有時候並不需要渲染全部元件出來,於是 fiber 便應運而生。

fiber 會在react渲染時將任務分為幾個碎片,每完成一個更新就會將控制權交給 react 協調控制部分,如果此時有優先順序更高的任務就會優先完成那一部分:

深入React v16新特性(二)

詳情請見程墨老師的這篇文章,對底層有更詳細的描述,我們這主要講對我們開發者的影響。

一個元件渲染(更新)時分為 render 前和 render 後,fiber 的協調控制時機就在 render 時,如果此時有更優先的任務,react 會將此元件已做的計算全部捨棄(對,就是完全不要),完成那個任務後才會從頭渲染這個元件。上面三個生命週期會在渲染(更新)前呼叫,如果是純函式還好,但如果是有副作用的函式,有可能會被呼叫兩次,這是違反開發者意願的,對帶有副作用的函式處理必須慎之又慎。另外也是為了接下來的非同步渲染,現在 React 的做法是完全擯棄這三個函式以免不必要的副作用,用有返回值的函式來代替。

前面講到的 StrictMode 就是故意呼叫兩次即將廢棄的方法來檢測副作用的。

生命週期變化(v16.3)

從 v16.3 開始,原來的三個生命週期 API componentWillMountcomponentWillUpdatecomponentWillReceiveProps 將被廢棄,取而代之的是兩個全新的生命週期:

  • static getDerivedStateFromProps
  • getSnapshotBeforeUpdate

static getDerivedStateFromProps用法

直譯過來很好理解:“從 props 中獲取 state”,通俗易懂。該方法接收兩個引數:nextPropsprevState,返回值為表示更新的 state,可以返回 null 表示沒有更新(React 新功能,setState(null) 表示沒有更新)。此方法會在 props 改變時(包括第一次渲染和 props 改變)就被呼叫,返回 state 合併在當前 state 中。

與其他生命週期函式顯著不同的一點是,這是一個靜態方法,這意味著你不能通過 this 訪問元件例項,也就是你不能直接訪問到this.statethis.props等一系列方法。如果一定要在裡面用this,你也只能取到 Component 而非例項。開啟程式碼倉庫,在src/GetDerivedStateFromProps下有相關例子,現有原來的實現方法和現在的方法,放一起做比對: Old_Consumer.jsx:

export default class Consumer extends React.Component {
    state = {
        // 從 props 獲取預設 state
        result: this.getResult(this.props.value)
    }
    componentWillReceiveProps(nextProps) {
        // 常用正規化
        if (nextProps.value !== this.props.value) {
            this.setState({
                // 更新 state
                result: this.getResult(nextProps.value)
            })
        }
    }
    
    // props 到 state 的資料對映
    getResult = value => value * value
    
    handleChange = (e) => {
        this.props.eraseResult()
        this.setState({
            result: e.target.value
        })
    }
    
    render() {
        return (
            <input type='text'
                onChange={ this.handleChange }
                value={ this.state.result }></input>
        )
    }
}

複製程式碼

New_Consumer.jsx:

export default class Consumer extends React.Component {
    state = {
        result: 0,
        // 必須儲存 props.value 到 state 的副本,以便 getDerivedStateFromProps 取到 
        value: 0
    }
    // 新的方法,接收 nextProps 和 prevState
    static getDerivedStateFromProps(nextProps, prevState) {
        // prevState.value 相當於當前元件的 this.props.value,是存在 state 的副本
        if (prevState.value !== nextProps.value) {
            // 返回新的state(只需返回更新的部分,與 `setState` 相同)
            return {
                // 相當於上面的 getResult,但只有一處
                result: nextProps.value * nextProps.value,
                // 又一次儲存副本
                value: nextProps.value
            }
        }
        // 返回 null 表示不更新,此函式最後一定需要返回值
        return null
    }
    // 以下都相同
    handleChange = e => {
        this.props.eraseResult()
        this.setState({
            result: e.target.value
        })
    }
    render() {
        return (
            <input type="text"
                onChange={this.handleChange}
                value={this.state.result}></input>
        )
    }
}
複製程式碼

getDerivedStateFromProps 會在元件第一次渲染的時候更新,因此只用在預設 state 中指定資料結構便於閱讀就行了,也正因為如此,從 props 到 state 的資料對映過程只有一處,寫在新生命週期方法中,而不像之前版本的中有個 getResult

這次改變非常大膽,徹底貫徹了 v = f(s) (v = view,s = state) 的 React 設計理念。之前我們可以通過 componentWillReveiveProps 來監聽 props 改變從而改變 state 來實現更新,算是偽 props 更新,更像是 v = f(s, 0.5p) (p = props)。當然 state 非常有必要根據 props 不同而不同,但是這樣實現不夠純粹,更純粹的方法就是新的方法,即 v = f(s(p)),即如果開發者足夠關心 props 的某一個屬性,必須將其存入 state 中。但如此設計是不是過於冒進?這樣會使 state 多了冗餘資料,反倒使 state 不夠純了;如果新 API 設計為例項方法,能取到 this.props 就跟原來沒兩樣了,使設計理念貫徹不夠徹底;我也不知道答案,既然作為新的 API,React 團隊已經給了我們答案,先按他們的做吧。

新生命週期實現 forwardRef 效果

上一期在 forwardRef 小節末尾留了個坑,程式碼倉庫中 forwardRef 下已有相關的 MockWithoutForwardRef.jsx 元件,實現了相同的功能(高階函式等),不同的是本可以放在例項屬性上而不是 state 中的屬性改寫後必須放在 state 上,不過語義來看無可厚非,確實該這麼做。理解上面例子後,這個例子也是一樣的,不做贅述。

getStapshotBeforeUpdate用法

這是一個例項方法,直譯過來就是 “更新前獲取快照”,可以認為是替代 componentWillUpdate 的作用。此函式會在 render 後和提交給 DOM 前呼叫,接收兩個引數 prevProps, prevState,此時你可以獲取到之前的所有狀態和更新後的所有狀態;函式的任何返回值都會作為第三個引數傳遞給 componentDidUpdate

說實話,實際開發中需要用到 componentWillUpdatecomponentDidUpdate 的時候真不多,因為在每次更新的時候,作為開發者,都會知道是哪裡的 state 改變而更新的,常常將本應寫在這兩個函式的程式碼寫在邏輯上的 setState 前後了,這樣也無可厚非。由於例子極少,我便直接參照官網例子寫個小 demo ,程式碼在原倉庫下。現在貼出主要部分程式碼:

// list` 條目增加,渲染的 `li` 也增加。但是滾動條位置不變
export default class List extends React.Component {
    listRef = null
    // 新的生命週期
    getSnapshotBeforeUpdate(prevProps, prevState) {
        // 如果 `props.list` 增加,將原來的 scrollHeight 存入 listRef
        if (prevProps.list.length < this.props.list.length) {
            return this.listRef.scrollHeight
        }
        return null
    } 
    // snapshot 就是 `getSnapshotBeforeUpdate` 的返回值
    componentDidUpdate(prevProps, prevState, snapshot) {
        if (snapshot !== null) {
            // scrollTop 增加新的 scrollHeight 和原來 scrollHeight 的差值,以保持滾動條位置不變
            this.listRef.scrollTop += this.listRef.scrollHeight - snapshot
        }
    } 
    
    setListRef = ref => (this.listRef = ref)

    render() {
        return (
            <ul ref={this.setListRef} style={{ height: 200, overflowY: 'scroll' }}>
                {this.props.list.map((n, i) => (
                    <li key={i}>{n}</li>
                ))}
            </ul>
        )
    }
}

複製程式碼

加上註釋很明白了,其實用法也很簡單;原始碼倉庫下統一目錄還有兩種寫法,分別是用 componentWillUpdate 的方法(A)和不使用 componentWillUpdategetSnapshotBeforeUpdate 的方法(B)。不得不說,方法(B)的實現真的醜陋,更難讀,還是用其他兩種方法比較優雅。程式碼就不貼了,感興趣的可以看看。

新的 Context API

用法

直接上程式碼:

// React.createContext 是新的 API,接收一個任意值作為預設值,並返回一個 Context 物件
// Context 物件有兩個屬性:Provider 和 Consumer,均為 React 的 Component
const ColorContext = React.createContext('red')
const { Provider, Consumer } = ColorContext
// 將 Context.Provider 包裹在你想應用 Context 的元件上,可指定 `value` 值,否則將使用預設值
const Wrapper = () => (
    <Provider value='red'>
        <Text></Text>
    </Provider>
)

// 當要用到 Context 裡的值時,用 Context.Consumer 包裹
// 裡面的 chlidren 只能是一個render 函式,並且函式的第一個引數就是 Context 的值
const Text = () => (
    <Consumer>
        {color => (
            <p style={{ color }}>This is Red!</p>
        )}
    </Consumer>
)
複製程式碼

其實用法也挺簡單的,次序如下:

  1. 新建 Context 物件,並指定預設值
  2. 在要設定 Context 值的地方用 Context.Provider 包裹,並通過 props.value 設定值
  3. 在用到的地方使用 Context.Consumer 包裹,並且裡面通過函式渲染,函式的第一個引數就是設定的 Context 值

需要注意的是在 Context.Consumer 中,children 必須為函式,這樣才能將 Context 值顯式傳遞下來,用法比較簡單,還支援組合(巢狀)。自從有了這個 API,React 官方也不再不推薦使用 context 了。

實現 react-redux

要在 React 中使用 redux,想必大家都用的是react-redux 庫。最重要,也是最常用的的 API 就是 Providerconnect 了,現在我們一起用新 API 實現一個擁有核心功能的 react-redux 庫。所有示例程式碼都在原倉庫,如果你還不會 redux,可以跳過。

實現目標

由於我們最常用的就是 Providerconnect 了,不用太複雜,實現這兩個核心功能,一個簡單的 react-redux 應用就跑起來了。

API 回顧

Provider 用於最頂層元件,只需傳入一個 redux 的 store,用於給所有子元件傳遞 context。store 中有幾個我們比較關心的方法:

  1. getStategetState() 返回整個 state 內容;
  2. dispatch:dispatch 一個 action 可以修改 store 中的 state;
  3. subscribe:訂閱 state 的變化,返回一個可以取消訂閱的函式。

connect 接收多個引數,我們這裡只關心主要的兩個:

  1. mapStateToProps:一個函式,傳入整個 state,返回其中關心的 state;
  2. mapDispatchToProps:也是函式,用於將 action 與 dispatch 連線起來,並轉為元件的 props。

目標達成例子

由於 redux 樣板程式碼太多,這裡不一一貼出,只貼目的碼:

const App = () => (
    <Provider store={store}>
        <Text></Text>
    </Provider>
)
// 裝飾器語法,開發中連線的元件往往是 class
class Text extends React.Component {
    render() {
        // incCount 是 action,count 是 state 上的值
        return (
            <button onClick={this.props.incCount}>{this.props.count}</button>
        )
    }
}
複製程式碼

開始

我們發現一個共同點:Context 和 react-redux 都有 Provider,那至少有這一部分:

// mock-react-redux.jsx

import React from 'react'
// 本例不需要預設值
const {Provider: ContextProvider, Consumer: ContextCosumer} = React.createContext()

class Provider extends React.Component {
    render() {
        // 將 store 放入 context 中
        return (
            <ContextProvider value={this.props.store}>
                {React.Children.only(this.props.children)}
            </ContextProvider>
        )
    }
}

// 連線時就是在 Consumer 中,並取出 store
const connect = (mapStateToProps = state => state, mapDispatchToProps = () => {}) => Component => props => (
    <ContextConsumer>
        {store => (
            <Component
                {...(mapStateToProps(store.getState()))}
                {...mapDispatchToProps(store.dispatch)}
                {...props}>
            </Component>
        )}
    </ContextConsumer>
)
複製程式碼

如果對高階元件足夠熟悉,這是第一感覺。完成後我們確實發現能正常渲染,但是當我們點選時,發現檢視上並不能及時更新。如果在 redux 中打斷點發現 store 確實會改變,然而 React 並沒有更新。這是因為我們並沒有 setState 通知 React 更新,因此這裡有兩種方法:

  1. 儲存一個 state,更新時 setState 通知 React;
  2. 直接通過 this.forceUpdate() 強制更新。

forceUpdate 總有一種怪怪的味道,在能使用 state 的情況下就最好不要用這個 API,因此我們使用 setState,將 store 裡的 state 作為 Provider 的 state;監聽 redux 的 store,一旦發生改變就通知 Provider。

// 修改 Provider
class Provider extends React.Component {
    state = this.props.store.getState()
    listener = null
    componentDidMount() {
        const { subscribe, getState } = this.props.store 
        this.listener = subscribe(() => {
            // setState 通知 React 更新檢視
            this.setState(getState())
        })
    }
    componentWillUnmount() {
        // 別忘了解除監聽,否則可能引起記憶體洩露
        this.listener()
    }
    render() {
        // 這裡要做相應修改,Consumer 內只關心 state 和 dispatch
        return (
            <ContextProvider value={{ state: this.state, dispatch: this.props.store.dispatch }}>
                {React.Children.only(this.props.children)}
            </ContextProvider>
        )
    }
}

// connect 相應修改
const connect = (mapStateToProps = state => state, mapDispatchToProps = () => {}) => Component => props => (
    <ContextConsumer>
        {({ state, dispatch }) => (
            <Component
                {...(mapStateToProps(state))}
                {...mapDispatchToProps(dispatch)}
                {...props}>
            </Component>
        )}
    </ContextConsumer>
)
複製程式碼

這樣就完成了一個簡單的 react-redux。可以直接放在已有應用並替換 react-redux 包,可以正常工作。當然有很多不足,比如 Provider 沒有優化效能,應該寫個 shouldComponentUpdateconnect 高階元件沒有命名等,但是並不影響核心功能。

相關文章