前言
之前寫了深入React v16 新特性(一),如果之前沒看過的可以先閱讀,裡面先介紹的 v16 比較簡單基礎的 API,程式碼倉庫在這篇文章裡有。本篇內容有:
- 生命週期函式的改變
- 深入 React v16 的底層 fiber 架構以解釋其原因
- 新的
Context
API,以及使用其實現簡易的react-redux
React v16 底層 fiber
,Facebook 花了近一年的時間,幾乎重寫了整個 React 的底層架構就是為了引入 fiber
。那 fiber
是個啥?在 Google 翻譯下就是“纖維”的意思。可以這樣理解:fiber
是次於“程式”的一個概念,即更細粒度控制程式。
具體來看:如果你想渲染一個元件,由於 js 是單執行緒的,必須要全部渲染完畢,如圖所示:
最後一直渲染完畢 js 才對其他動作作出響應;如果這個元件樹非常大,渲染耗時非常長,那麼在這段時間內瀏覽器就處於假死狀態,無法對使用者任何反應(點選事件等)作出反應,體驗非常差,因為有時候並不需要渲染全部元件出來,於是 fiber 便應運而生。
fiber 會在react渲染時將任務分為幾個碎片,每完成一個更新就會將控制權交給 react 協調控制部分,如果此時有優先順序更高的任務就會優先完成那一部分:
詳情請見程墨老師的這篇文章,對底層有更詳細的描述,我們這主要講對我們開發者的影響。
一個元件渲染(更新)時分為 render 前和 render 後,fiber 的協調控制時機就在 render 時,如果此時有更優先的任務,react 會將此元件已做的計算全部捨棄(對,就是完全不要),完成那個任務後才會從頭渲染這個元件。上面三個生命週期會在渲染(更新)前呼叫,如果是純函式還好,但如果是有副作用的函式,有可能會被呼叫兩次,這是違反開發者意願的,對帶有副作用的函式處理必須慎之又慎。另外也是為了接下來的非同步渲染,現在 React 的做法是完全擯棄這三個函式以免不必要的副作用,用有返回值的函式來代替。
前面講到的 StrictMode 就是故意呼叫兩次即將廢棄的方法來檢測副作用的。
生命週期變化(v16.3)
從 v16.3 開始,原來的三個生命週期 API componentWillMount
、componentWillUpdate
、componentWillReceiveProps
將被廢棄,取而代之的是兩個全新的生命週期:
- static getDerivedStateFromProps
- getSnapshotBeforeUpdate
static getDerivedStateFromProps
用法
直譯過來很好理解:“從 props 中獲取 state”,通俗易懂。該方法接收兩個引數:nextProps
、prevState
,返回值為表示更新的 state,可以返回 null 表示沒有更新(React 新功能,setState(null)
表示沒有更新)。此方法會在 props 改變時(包括第一次渲染和 props 改變)就被呼叫,返回 state 合併在當前 state 中。
與其他生命週期函式顯著不同的一點是,這是一個靜態方法,這意味著你不能通過 this
訪問元件例項,也就是你不能直接訪問到this.state
、this.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
。
說實話,實際開發中需要用到 componentWillUpdate
和 componentDidUpdate
的時候真不多,因為在每次更新的時候,作為開發者,都會知道是哪裡的 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)和不使用 componentWillUpdate
和 getSnapshotBeforeUpdate
的方法(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>
)
複製程式碼
其實用法也挺簡單的,次序如下:
- 新建 Context 物件,並指定預設值
- 在要設定 Context 值的地方用
Context.Provider
包裹,並通過props.value
設定值 - 在用到的地方使用
Context.Consumer
包裹,並且裡面通過函式渲染,函式的第一個引數就是設定的 Context 值
需要注意的是在 Context.Consumer
中,children 必須為函式,這樣才能將 Context 值顯式傳遞下來,用法比較簡單,還支援組合(巢狀)。自從有了這個 API,React 官方也不再不推薦使用 context 了。
實現 react-redux
要在 React 中使用 redux,想必大家都用的是react-redux
庫。最重要,也是最常用的的 API 就是 Provider
和 connect
了,現在我們一起用新 API 實現一個擁有核心功能的 react-redux
庫。所有示例程式碼都在原倉庫,如果你還不會 redux,可以跳過。
實現目標
由於我們最常用的就是 Provider
和 connect
了,不用太複雜,實現這兩個核心功能,一個簡單的 react-redux 應用就跑起來了。
API 回顧
Provider
用於最頂層元件,只需傳入一個 redux 的 store
,用於給所有子元件傳遞 context。store
中有幾個我們比較關心的方法:
getState
:getState()
返回整個 state 內容;dispatch
:dispatch 一個 action 可以修改 store 中的 state;subscribe
:訂閱 state 的變化,返回一個可以取消訂閱的函式。
connect
接收多個引數,我們這裡只關心主要的兩個:
mapStateToProps
:一個函式,傳入整個 state,返回其中關心的 state;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 更新,因此這裡有兩種方法:
- 儲存一個 state,更新時
setState
通知 React; - 直接通過
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 沒有優化效能,應該寫個 shouldComponentUpdate
,connect
高階元件沒有命名等,但是並不影響核心功能。