盤點 React 16.0 ~ 16.5 主要更新及其應用
大約一年前,React 團隊釋出了 React 16.0。時至今日,已更新到 16.5 。這其中有不少激動人心的特性(如 Fiber 架構的引入、新的周期函式、全新 Context API、Fragment、Error Boundary、Portal 等)都值得開發者跟進學習。本文就以 React 更新日誌 為引,選取幾個重要且用於工作的更新,和大家一起學習。所有示例程式碼在 react-upgrade-examples, 配合文章一起食用更佳~ ?
目錄
6. 其他
7. 總結
生命週期函式的更新
隨著 React 16.0 釋出, React 採用了新的核心架構 Fiber,在新的架構中它將更新分為兩個階段:Render Parse 和 Commit Parse, 也由此引入了 getDerivedStateFromProps
getSnapshotBeforeUpdate
及 componentDidCatch
等三個生命週期函式。同時,也將 componentWillMount
、componentWillReceiveProps
和 componentWillUpdate
標記為不安全的方法。
new lifecycle
新增
static getDerivedStateFromProps(nextProps, prevState)
getSnapshotBeforeUpdate(prevProps, prevState)
componentDidCatch(error, info)
標記為不安全
componentWillMount(nextProps, nextState)
componentWillReceiveProps(nextProps)
componentWillUpdate(nextProps, nextState)
static getDerivedStateFromProps(nextProps, prevState)
根據 getDerivedStateFromProps(nextProps, prevState)
的函式簽名可知: 其作用是根據傳遞的 props
來更新 state
。它的一大特點是 無副作用 : 由於處在 Render Phase 階段,所以在每次的更新都要觸發, 故在設計 API 時採用了靜態方法,其好處是單純 —— 無法訪問例項、無法通過 ref
props
的操作來產生副作用,這時應該將操作 props
的方法移到 componentDidUpdate
中,減少觸發次數。
例:
state = { isLogin: false }
static getDerivedStateFromProps(nextProps, prevState) {
if(nextProps.isLogin !== prevState.isLogin){
return {
isLogin: nextProps.isLogin
}
}
return null
}
componentDidUpdate(prevProps, prevState){
if(!prevState.isLogin && prevProps.isLogin) this.handleClose()
}
但在使用時要非常小心,因為它不像 componentWillReceiveProps
一樣,只在父元件重新渲染時才觸發,本身呼叫 setState
也會觸發。官方提供了 3 條 checklist, 這裡搬運一下:
- 如果改變
props
的同時,有副作用的產生(如非同步請求資料,動畫效果),這時應該使用componentDidUpdate
- 如果想要根據
props
計算屬性,應該考慮將結果 memoization 化,參見 memoization - 如果想要根據
props
變化來重置某些狀態,應該考慮使用受控元件
配合 componentDidUpdate
周期函式,getDerivedStateFromProps
是為了替代 componentWillReceiveProps
而出現的。它將原本 componentWillReceiveProps
功能進行劃分 —— 更新 state
和 操作/呼叫 props
,很大程度避免了職責不清而導致過多的渲染, 從而影響應該效能。
getSnapshotBeforeUpdate(prevProps, prevState)
根據 getSnapshotBeforeUpdate(prevProps, prevState)
的函式簽名可知,其在元件更新之前獲取一個 snapshot —— 可以將計算得的值或從 DOM 得到的資訊傳遞到 componentDidUpdate(prevProps, prevState, snapshot)
周期函式的第三個引數,常常用於 scroll 位置的定位。摘自官方的示例:
class ScrollingList extends React.Component {
constructor(props) {
super(props)
// 取得dom 節點
this.listRef = React.createRef()
}
getSnapshotBeforeUpdate(prevProps, prevState) {
// 根據新新增的元素來計算得到所需要滾動的位置
if (prevProps.list.length < this.props.list.length) {
const list = this.listRef.current
return list.scrollHeight - list.scrollTop
}
return null
}
componentDidUpdate(prevProps, prevState, snapshot) {
// 根據 snapshot 計算得到偏移量,得到最終滾動位置
if (snapshot !== null) {
const list = this.listRef.current
list.scrollTop = list.scrollHeight - snapshot
}
}
render() {
return <div ref={this.listRef}>{/* ...contents... */}</div>
}
}
componentDidCatch(error, info)
在 16.0 以前,錯誤捕獲使用 unstable_handleError
或者採用第三方庫如 react-error-overlay 來捕獲,前者捕獲的資訊十分有限,後者為非官方支援。而在 16.0 中,增加了 componentDidCatch
周期函式來讓開發者可以自主處理錯誤資訊,諸如展示,上報錯誤等,使用者可以建立自己的Error Boundary
來捕獲錯誤。例:
···
componentDidCatch(error, info) {
// Display fallback UI
this.setState({ hasError: true });
// You can also log the error to an error reporting service
logErrorToMyService(error, info);
}
···
此外,使用者還可以採用第三方錯誤追蹤服務,如 Sentry、Bugsnag 等,保證了錯誤處理效率的同時也極大降級了中小型專案錯誤追蹤的成本。
圖片bugsnag
標記為不安全 componentWillMount
、componentWillReceiveProps
、componentWillUpdate
componentWillMount
componentWillMount
可被開發者用於獲取首屏資料或事務訂閱。
開發者為了快速得到資料,將首屏請求放在 componentWillMount
中。實際上在執行 componentWillMount
時第一次渲染已開始。把首屏請求放在componentWillMount
的與否都不能解決首屏渲染無非同步資料的問題。而官方的建議是將首屏放在 constructor
或 componentDidMount
中。
此外事件訂閱也被常在 componentWillMount
用到,並在 componentWillUnmount
中取消掉相應的事件訂閱。但事實上 React 並不能夠保證在 componentWillMount
被呼叫後,同一組件的 componentWillUnmount
也一定會被呼叫。另一方面,在未來 React 開啟非同步渲染模式後,在 · 被呼叫之後,元件的渲染也很有可能會被其他的事務所打斷,導致 componentWillUnmount
不會被呼叫。而 componentDidMount
就不存在這個問題,在 componentDidMount
被呼叫後,componentWillUnmount
一定會隨後被呼叫到,並根據具體程式碼清除掉元件中存在的事件訂閱。
對此的升級方案是把 componentWillMount
改為 componentDidMount
即可。
componentWillReceiveProps
、componentWillUpdate
componentWillReceiveProps
被標記為不安全的原因見前文所述,其主要原因是操作 props 引起的 re-render。與之類似的 componentWillUpdate
被標記為不安全也是同樣的原因。除此之外,對 DOM 的更新操作也可能導致重新渲染。
對於 componentWillReceiveProps
的升級方案是使用 getDerivedStateFromProps
和 componentDidUpdate
來代替。對於 componentWillUpdate
的升級方案是使用 componentDidUpdate
代替。如涉及大量的計算,可在 getSnapshotBeforeUpdate
完成計算,再在 componentDidUpdate
一次完成更新。
通過框架級別的 API 來約束甚至限制開發者寫出更易維護的 Javascript 程式碼,最大限度的避免了反模式的開發方式。
全新的 Context API
在 React 16.3 之前,Context API 一直被官方置為不推薦使用(don’t use context),究其原因是因為老的 Context API 作為一個實驗性的產品,破壞了 React 的分形結構。同時在使用的過程中,如果在穿透元件的過程中,某個元件的 shouldComponentUpdate
返回了 false
, 則 Context API 就不能穿透了。其帶來的不確定性也就導致被不推薦使用。隨著 React 16.3 的釋出,全新 Context API 成了一等 API,可以很容易穿透元件而無副作用,官方示例程式碼:
// Context lets us pass a value deep into the component tree
// without explicitly threading it through every component.
// Create a context for the current theme (with "light" as the default).
const ThemeContext = React.createContext('light')
class App extends React.Component {
render() {
// Use a Provider to pass the current theme to the tree below.
// Any component can read it, no matter how deep it is.
// In this example, we're passing "dark" as the current value.
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
)
}
}
// A component in the middle doesn't have to
// pass the theme down explicitly anymore.
function Toolbar(props) {
return (
<div>
<ThemedButton />
</div>
)
}
function ThemedButton(props) {
// Use a Consumer to read the current theme context.
// React will find the closest theme Provider above and use its value.
// In this example, the current theme is "dark".
return (
<ThemeContext.Consumer>{theme => <Button {...props} theme={theme} />}</ThemeContext.Consumer>
)
}
其過程大概如下:
- 通過
React.createContext
建立 Context 物件 - 在父元件上,使用
<ThemeContext.Provider/>
來提供 Provider - 在需要消費的地方,使用
<ThemeContext.Consumer/>
以函式呼叫的方式{theme => <Button {...props} theme={theme} />}
獲得 Context 物件的值。
Context API 與 Redux
在狀態的管理上,全新的 Context API 完全可以取代部分 Redux 應用,示例程式碼:
const initialState = {
theme: 'dark',
color: 'blue',
}
const GlobalStore = React.createContext()
class GlobalStoreProvider extends React.Component {
render() {
return (
<GlobalStore.Provider value={{ ...initialState }}>{this.props.children}</GlobalStore.Provider>
)
}
}
class App extends React.Component {
render() {
return (
<GlobalStoreProvider>
<GlobalStore.Consumer>
{context => (
<div>
<div>{context.theme}</div>
<div>{context.color}</div>
</div>
)}
</GlobalStore.Consumer>
</GlobalStoreProvider>
)
}
}
全新的 Context API 帶來的穿透元件的能力對於需要全域性狀態共享的場景十分有用,無需進入額外的依賴就能對狀態進行管理,程式碼簡潔明瞭。
React Strict Mode
React StrictMode 可以在開發階段發現應用存在的潛在問題,提醒開發者解決相關問題,提供應用的健壯性。其主要能檢測到 4 個問題:
- 識別被標誌位不安全的生命週期函式
- 對棄用的 API 進行警告
- 探測某些產生副作用的方法
- 檢測是否採用了老的 Context API
使用起來也很簡單,只要在需要被檢測的元件上包裹一層 React StrictMode
,示例程式碼 React-StictMode:
class App extends React.Component {
render() {
return (
<div>
<React.StrictMode>
<ComponentA />
</React.StrictMode>
</div>
)
}
}
若出現錯誤,則在控制檯輸出具體錯誤資訊:
React Strict Mode
Portal
由 ReactDOM 提供的 createPortal
方法,允許將元件渲染到其他 DOM 節點上。這對大型應用或者獨立於應用本身的渲染很有幫助。其函式簽名為ReactDOM.createPortal(child, container)
, child
引數為任意的可渲染的 React Component,如 element
、sting
、fragment
等,container
則為要掛載的 DOM 節點.
以一個簡單的 Modal 為例, 程式碼見 Portal Modal :
import React from 'react'
import ReactDOM from 'react-dom'
const modalRoot = document.querySelector('#modal')
export default class Modal extends React.Component {
constructor(props) {
super(props)
this.el = document.createElement('div')
}
componentDidMount() {
modalRoot.appendChild(this.el)
}
componentWillUnmount() {
modalRoot.removeChild(this.el)
}
handleClose = () => [this.props.onClose && this.props.onClose()]
render() {
const { visible } = this.props
if (!visible) return null
return ReactDOM.createPortal(
<div>
{this.props.children}
<span onClick={this.handleClose}>[x]</span>
</div>,
this.el
)
}
}
具體過程就是使用了 props
傳遞 children
後, 使用 ReactDOM.createPortal
, 將 container 渲染在其他 DOM 節點上的過程。
Refs
雖然 React 使用 Virtual DOM 來更新檢視,但某些時刻我們還要操作真正的 DOM ,這時 ref
屬性就派上用場了。
React.createRef
React 16 使用了 React.createRef
取得 Ref 物件,這和之前的方式還是有不小的差別,例:
// before React 16
···
componentDidMount() {
// the refs object container the myRef
const el = this.refs.myRef
// you can also using ReactDOM.findDOMNode
// const el = ReactDOM.findDOMNode(this.refs.myRef)
}
render() {
return <div ref="myRef" />
}
···
···
// React 16+
constructor(props) {
super(props)
this.myRef = React.createRef()
}
render() {
return <div ref={this.myRef} />
}
···
React.forwardRef
另外一個新特性是 Ref 的轉發, 它的目的是讓父元件可以訪問到子元件的 Ref,從而操作子元件的 DOM。React.forwardRef
接收一個函式,函式引數有 props
和 ref
。看一個簡單的例子,程式碼見 Refs:
const TextInput = React.forwardRef((props, ref) => (
<input type="text" placeholder="Hello forwardRef" ref={ref} />
))
const inputRef = React.createRef()
class App extends Component {
constructor(props) {
super(props)
this.myRef = React.createRef()
}
handleSubmit = event => {
event.preventDefault()
alert('input value is:' + inputRef.current.value)
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<TextInput ref={inputRef} />
<button type="submit">Submit</button>
</form>
)
}
}
這個例子使用了 React.forwardRef
將 props
和 ref
傳遞給子元件,直接就可以在父元件直接呼叫。
Fragment
在向 DOM 樹批量新增元素時,一個好的實踐是建立一個document.createDocumentFragment
,先將元素批量新增到 DocumentFragment
上,再把 DocumentFragment
新增到 DOM 樹,減少了 DOM 操作次數的同時也不會建立一個新元素。
和 DocumentFragment
類似,React 也存在 Fragment
的概念,用途很類似。在 React 16 之前,Fragment 的建立是通過擴充套件包 react-addons-create-fragment
建立,而 React 16 中則通過 <React.Fragment></React.Fragment>
直接建立 'Fragment'。例如:
render() {
return (
<React.Fragment>
<ChildA />
<ChildB />
<ChildC />
</React.Fragment>
)
}
如此,我們不需要單獨包裹一層無用的元素(如使用<div></div>
包裹),減少層級巢狀。此外,還一種精簡的寫法:
render() {
return (
<>
<ChildA />
<ChildB />
<ChildC />
</>
)
}
其他
ReactDOM
的 render
函式可以陣列形式返回 React Component
render(){
return [
<ComponentA key='A' />,
<ComponentB key='B' />,
]
}
移除內建的react-with-addons.js
, 所有的外掛都獨立出來
之前常用的react-addons-(css-)transition-group
,react-addons-create-fragment
,react-addons-pure-render-mixin
、react-addons-perf
等,除部分被內建,其餘全部都獨立為一個專案,使用時要注意。
總結
窺一斑而見全豹,React 16.0 ~ 16.5 的升級給了開發者一個更為純粹的開發流程。API 層面的更改、架構的更替、工具類的拆分都在為構建更易維護的 JavaScript 應用而努力。擁抱變化,順應時勢。
由於筆者能力有限,文中難免有疏漏,還望讀者不吝賜教。
以上。