深入React v16新特性(一)

fengkk發表於2018-04-23

前言

React自發布v16版本以來已經有半年了,至今最新的是v16.3。從 v16 開始增加了較多新的API。相較於之前純淨的API設計,變化可以說是非常大了。可以看出 facebook 的 React 團隊已經解決了之前的大多數問題,現在開始為 React 設計新的 API 、增加新功能了。得益於重寫 React 底層為Fiber架構,v16 包含了許多實用的新特性,並且也有一些 “break-change”,之後的版本肯定更多。在官方部落格上的介紹雖然很全,但是由於翻譯不及時,查閱不變,而且有的地方難於理解。故在此通過一些程式碼做簡要介紹,也為自己查閱 API 做記錄。

倉庫中程式碼包含了本人的最佳實踐,閱讀文章之前, 希望你對 React 和 ES6 有一定了解。當然如果不會,也可以選擇性看看。如有例子不合適或謬誤,歡迎 提出意見

程式碼倉庫

程式碼已託管到 github倉庫 上,裡面有對應新特性子的 demo,歡迎 star !沒錯我就是來騙讚的

開始

由於準備長期更新,現在的是 v16 版。所以克隆到本地以後先 cd react16,再npm i, npm start 。訪問 localhost:9000 即可看到 demo(忽略我醜陋的 CSS )。左邊有幾個路由,分別對應了幾個新特性和相應的 demo。

ErrorBoundary (v16.0)

React v16 之前,如果渲染中出現錯誤,整個頁面會直接崩掉。如果對 React 足夠了解,可能會知道一個祕而不宣的API: unstable_handleError。此函式可用於捕獲頁面錯誤,然而由於文件沒記錄,知道的開發者也寥寥無幾。現在我們有了新的、官方的、穩定的 API: componentDidCatch。就像 try catch 一樣,可用於捕獲 render 過程中的錯誤,常用於捕獲錯誤並渲染不同的頁面,避免整個頁面崩潰。

演示

demo 中,第一個是自增按鈕,增加到5會丟擲渲染錯誤,如果此時處於不捕獲模式,頁面崩潰,只能重新整理頁面恢復正常。

深入React v16新特性(一)

相信這是每一個 React 頁面仔的噩夢吧(包括我),應該盡力避免此種情況發生。切換到捕獲模式後,元件啟用新的componentDidCatch API。此時發生錯誤不會崩,會顯示備用頁面。

深入React v16新特性(一)

程式碼

新特性的主要程式碼在 /src/ErrorBoundary/ErrorHandler.jsx 下。本來如果不用切換模式,一直捕獲錯誤的話,ErrorHander應該長成這樣:

import React from 'react'

class FakeHandler extends React.Component {
    state = {
        hasError: false
    }
    // 新的生命週期鉤子
    componentDidCatch(error, info) {
        this.setState({
            hasError: true
        })
    }
    // 重置狀態,與新 API 無關
    reset = () => {
        this.setState({
            hasError: false
        })
        this.props.reset()
    }
    render() {
        // 顯示備用頁面的核心程式碼,若有錯誤顯示備用頁面
        return this.state.hasError ? (
            <React.Fragment>
                <p>頁面渲染髮生錯誤,這是備用頁面,可開啟 console 檢視錯誤</p>
                <div>
                    <button onClick={this.reset}>點選此處重置</button>
                </div>
            </React.Fragment>
        ) : (
            React.Children.only(this.props.children)
        )
    }
}

複製程式碼

重點在 componentDidCatch 這一句,如果捕獲錯誤了把當前state.hasError設為truerender裡判斷下是否有錯誤再渲染,可以做到備用頁面的顯示,這也是常用的 componentDidCatch 處理手法,可以作為經典範例。

componentDidCatch可以接受兩個引數: 丟擲的錯誤error 和 錯誤資訊的 info,現在的info只包含了呼叫棧的資訊,感覺用處不大,因為發生錯誤時React總是會列印堆疊。可能以後會加入新資訊,拭目以待。

由於此方法可以放在任意元件內,因此可以在頁面不同地方定製化備用頁。

Note:此生命週期函式無法捕獲渲染外的錯誤,如以下錯誤無法捕獲,會正常渲染。

class A extends React.Component {
     render() {
        // 此錯誤無法被捕獲,渲染時元件正常返回 `<div></div>`
        setTimeout(() => {
            throw new Error('error')
        }, 1000)
        return (
            <div></div>
        )
    }
}
複製程式碼

倉庫中的程式碼由於需要切換捕獲模式以演示區別,因此出現了元件的繼承寫法,如果不熟悉,請好好體會。不過實際專案中遇到繼承的機會還是很少的,此種方法常用於覆蓋某元件的生命週期函式。

官網文件已有中文版,更多詳情請參閱 Error Boundaries

Portal (v16.0)

API為 ReactDOM.createPortal。可以簡單的理解為“傳送門”,即可以直接渲染在父元件以外的任意 DOM 節點,常用於彈出框、提示框等,並且支援事件冒泡,行為完全與子元件一致。demo 程式碼在src/Portal下。注意此方法並不能隨心所欲呼叫,只有在元件的 render 方法呼叫,並作為合法element的代替返回。

Note: 新的 API 掛載在 react-dom 下,並不是 React 包內。

程式碼示例:

import React from 'react'
import { createPortal } from 'react-dom'
class Dialog extends React.Component {
    render() {
        // 一定要 return
        return createPortal((
            <div></div>
        ), document.querySelector('#dialog'))
    }
}
複製程式碼

深入React v16新特性(一)

渲染的實際 DOM 如圖,即使整個應用都在 div#app 下,createPortal 依然能在之外的 div#poral 下渲染 Element。

非常簡單,但是要注意不能濫用,就像 ref 一樣,儘量把 react 能做的都交給 react 處理。淡然此 API 做彈出框的時候非常好用,對做基本彈窗元件的前端們簡直就是福音。更多請參考官方中文文件 Portals

Fragment(v16.0) & StrictMode(v16.3)

這兩個靜態元件均掛載在 React 包下,通過React.FragmentReact.StrictMode可訪問到。

Fragment靜態元件,v16.0 推出,用於將多個 React render 返回值包裹成一個具有頂級元素的element。之前如果我們需要返回多個元素,一定要在外面包一層<div></div>或其他的元素,React 還會將其渲染成真實 DOM;或直接返回一個相應的陣列(React v16.0支援),但是非常醜陋,並且必須附帶key屬性,即使用不到。

現在新的 Fragment 僅用於包裹,並不會生成對應 DOM 了,就像普通的jsx一樣,也不需要key屬性了,還是非常不錯的新功能。官方文件:Fragments

StrictMode 於 v16.3 推出。顧名思義,即嚴格模式,可用於在開發環境下提醒元件內使用不推薦寫法和即將廢棄的 API(該版本廢棄了三個生命週期鉤子)。與 Fragment 相同,並不會被渲染成真實 DOM。官方文件嚴格模式裡詳細介紹了會在哪些情況下發出警告。對於我們開發者來說,及時棄用不被推薦的寫法即可規避這些警告。

Fragment 和 StrictMode 程式碼示例在src/NewComponent下:

import React, {Fragment, StrictMode} from 'react'

const FragmentItem = props => new Array(5).fill(null).map((k, i) => (
    <Fragment key={i}>
        <p>這是第{i}項</p>
        <p>{i} * {i} = {i * i}</p>
    </Fragment> 
))

class OldLifecycleProvider extends React.Component {
    // 以下三個函式在 React v16.3 已不被推薦,未來的版本會廢棄。
    componentWillMount() {
        console.log('componentWillMount')
    }
    componentWillUpdate() {
        console.log('componentWillUpdate')
    }
    componentWillReceiveProps() {
        console.log('componentWillReceiveProps')
    }
    render() {
        return (
            <FragmentItem></FragmentItem>
        )
    }
}

export default class NewComponent extends React.Component {
    state = {
        propFlag: 2
    }
    // 使 OldLifecycleProvider 進入 componentWillReceiveProps 函式
    componentDidMount() {
        this.setState({
            propFlag: 1
        })
    }
    render() {
        return (
            <StrictMode>
                <OldLifecycleProvider propFlag={this.state.propFlag}></OldLifecycleProvider>
            </StrictMode>
        )
    }
}
複製程式碼

渲染層級為:NewComponent -> OldLifecycleProvider -> FragmentItem,可以看到在 React dev tool下依然可以看到多層結構(Fragment並沒有顯示,比較遺憾,希望 dev tool 新版本能修復這個問題),但渲染出的 DOM 層級還是扁平的,直接掛載在 div.view 下。

深入React v16新特性(一)

深入React v16新特性(一)

另外,由於故意在 StrictMode 下使用了三個即將廢棄的API,開啟 console ,可看到如下錯誤提醒:

深入React v16新特性(一)

Note: 專案可直接使用StrictMode,不必檢測是否為開發環境,因為只在開發環境起作用。

如果非常注重專案程式碼未來的可升級性,甚至可以在最頂層用 StrictMode 包裹。但其實除此之外,如果專案穩定,開啟此模式對開發人員沒有一點好處,甚至還有額外的遷移工作,因此不建議在已開始專案使用;但對程式碼重構有非常大的好處,可隨時提醒開發人員即將廢棄的 API 以便遷移。相信在 React 生態中會與 JS 的 'use strict' 一樣應用越來越廣泛。

createRef (v16.3)

v15 版本 ref

之前版本,如果想取得某個 Element 的 Ref,有兩種方式可選:

  • 字串形式: <input ref="input" /> => this.refs.input
  • 回撥函式形式: <input ref={input => (this.input = input)} /> => this.input

其中字串形式,由於存在種種問題 (issue八卦下:這哥們就是 redux 作者)而不被推薦,具體內容就是:

  1. 需要內部追蹤 ref 的 this 取值,會使 React 稍稍變慢;
  2. 有時候 this 與你想象的並不一致:
import React from 'react'

class Children extends React.Component {
    componentDidMount() {
        // <h1></h1>
        console.log('children ref', this.refs.titleRef)
    }
    render() {
        return (
            <div>
                {this.props.renderTitle()}
            </div>
        )
    }
}

export default class Parent extends React.Component {
    // 放入子元件渲染
    renderTitle = () => (
        <h1 ref='titleRef'>{this.props.title}</h1>
    )
    componentDidMount() {
        // undefined
        console.log('parent ref:', this.refs.titleRef)
    }
    render() {
        return (
            <Children renderTitle={this.renderTitle}></Children>
        )
    }
}
複製程式碼

因為字串形式的 ref 繫結的 this 是根據渲染時而定,而不是宣告時而定,有點像 js 中函式的 作用域this 的區別。但作為 React 元件,我們總是希望宣告時將 ref繫結在當前宣告的 Component 中,因此這也是個問題。

  1. 不可組合(其實沒看太懂,大意是如果一個庫將傳進來的 children 給了 ref,那麼開發者將無法傳遞另一個 ref 給 children。issue)。

因此現在常用函式形式,幾乎沒有確定,唯一的遺憾是需要新建函式;如果放入render裡,會影響效能;如果放在 class 下,又白白多了一個業務無關函式。但是現在我們有了新的 API:createRef。基本用法:

class A extends React.Component {
    inputRef = React.createRef()

    componentDidMount() {
        // 注意 current
        this.inputRef.current.focus()
    }

    render() {
        return (
            <input type="text" ref={this.inputRef}></input>
        )
    }
}

複製程式碼

通過 this.inputRef.current 即可獲取。this.inputRef 其實是個原型為 Object.prototype的物件,而且目前為止只有一個 current 鍵,對應的值是取得的 ref。看來 React 團隊已經預留好介面,接下來的版本會為 Ref 增加新功能了。

相較於字串形式,createRef 既在編碼中提前宣告需要獲取 Ref,又可以避免字串形式的種種硬傷;而像對於函式形式,可以少寫一個函式,但是不夠靈活,實際編碼中可能還是需要函式形式,這也是 React 文件中將函式形式列為高階技巧的原因。因此作為開發者,需要做到完全避免字串形式,儘量使用createRef,把函式形式列為備選;而在 v16.3 版本中,看到 createRef,無腦取 current 就行了。

ForwardRef (v16.3)

之前是沒有ForwardRef這種概念的,這是專門為高階元件獲取 Ref 而設計。官方文件(英文)Forwarding Refs 的例子摻雜了許多對高階元件(HOC)的介紹和理解,不夠純淨,不利於初步理解ForwardRef,本來挺簡單的一個概念被複雜化了,下面用簡單例子例子說明其基本用法:

import React from 'react'

// 高階元件,注意返回值用 `React.forwardRef` 包裹
// 裡面的無狀態元件接收第二個引數:ref
const paintRed = Component => React.forwardRef(
    // 此例中,ref 為 ForwardRef 中的 textRef
    (props, ref) => (
        <Component color='red' ref={ref} {...props}></Component>
    )
)

class Text extends React.Component {
    // 僅用於檢測是否取到 ref
    value = 1
    render() {
        const style = {
            color: this.props.color
        }
        return (
            <p style={style}>
                我是紅色的!
            </p>
        )
    }
}

const RedText = paintRed(Text)

export default class ForwardRef extends React.Component {
    textRef = React.createRef()
    componentDidMount() {
        // value = 1
        console.log(this.textRef.current.value)
    }
    render() {
        // 如果沒有 forwardRef,那麼這個ref只能得到 `RedText`,而不是裡面的 `Text`
        return (
            <RedText ref={this.textRef}></RedText>
        )
    }
}
複製程式碼

從此例子看出,forwardRef 主要針對高階元件編寫者,用法流程如下:

  1. 寫高階元件時,返回的無狀態元件用 forwardRef 包裹,並且可以傳遞第二個引數 ref;
  2. 無狀態元件中的返回值可將 ref 作為 props 傳入。

forwardRef裡的引數只能是無狀態元件,那如果高階元件返回值不是個無狀態函式,是個有生命週期函式的 class 呢?React 官方文件中已有這樣的例子,即在外面包一層無狀態元件,即:

const paintRed = Component => (() => {
    // 新增 `componentDidMount` 
    class WhatEver extends React.Component {
        static displayName = `PaintRed(${Component.displayName || Component.name || Unkown})`
        componentDidMount() {
            console.log('Mounted!')
        }
        render() {
            // textRef 即為最外層的 ref
            const { textRef, ...props } = this.props
            return (
                <Component color='red' ref={textRef} {...props}></Component>
            )
        }
    }
    const forwardRef = React.forwardRef(
        // 這裡再將 ref 的值作為普通 props 傳遞即可
        (props, ref) => (
            <WhatEver textRef={ref} {...props}></WhatEver>
        )
    )
    return forwardRef
})()
複製程式碼

眾所周知,React 的 props 有兩個是私有的:key 和 ref,這兩者是不能作為普通props傳遞給子元件的。然而從此例子可以看出,forwardRef 功能是:包裹的無狀態元件可以接收 ref 作為第二個引數,並且可以傳遞下去。此時 ref 依然是 props 裡面私有的,還是無法從 props 取出,依然沒有打破原來的設計。

如果不用 createRef,而是用原來的兩種形式,都是正常的。

這個 API 給我的感覺是用的不是很多,實際中一定要用高階元件的裡面的 ref 情況非常少,而且大部分都可以通過 react 普通 api 解決,但總算是解決了一個原來的盲點,因此只能算是聊勝於無的新功能。但其實文件中也提到了,大部分需要使用 forwardRef 的時候都可以用其他方式解決。如在上面的原始碼倉庫中,有個稍稍複雜的 forwardRef 的 demo,但其實還是可以不用 forwardRef 來實現相同功能,而且用的是新的生命週期函式實現,將在下次說新的生命週期鉤子時詳細講述。

相關文章