ReactPortals與ErrorBoundaries

溜達向日葵發表於2018-07-31

Portals

在React 16.x 新增了一個名為“Protals”的特性,直接按照字面意思翻譯實在不靠譜。在描述這個特性時,我們還是用官方的英文單詞來指定它。Portals的作用簡單的說就是為了便於開發“彈窗”、“對話方塊”、“浮動卡片”、“提示窗”等脫離標準文件流的元件而設定的,用於替換之前的unstable_renderSubtreeIntoContainer。 

15.x之前的時代實現”彈窗”

過去沒有這個特性的時候,我們使用React繪製“彈窗”之前無非就三種方法:

1.將彈窗作為一個子元素在元件中直接使用,然後賦予彈窗 {position: fixed ,z-index:99}這樣的樣式,讓他漂浮在整個頁面應用的最上層並相對與整個瀏覽器視窗定位。如果你認為fixed能實現所有要求,那麼最好把下面的這個頁面程式碼複製到本地執行看看:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>Fixed</title>
</head>
<body>
<div class="top-div">
    <div class="fixed-div">Do I look fixed to you?</div>
</div>
</body>
<style>
    .top-div {
        width: 300px;
        height: 300px;
        background: coral;
        transform: translate(100px, 100px);
        animation: diagonal-loop 1s infinite alternate;
    }
    .fixed-div {
        position: fixed;
        background: rgba(0, 0, 0, 0.7);
        width: 100%;
        height: 100%;
        top: 100px;
        left: 100px;
        padding: 10px;
        color: white;
    }
    @keyframes diagonal-loop {
        0% {
            transform: translate(100px, 100px);
        }
        100% {
            transform: translate(200px, 200px);
        }
    }
</style>
</html>

除此之外,這種方式處理事件的冒泡也會導致一些問題。

2.使用unstable_renderSubtreeIntoContainer方法將彈窗元件新增到body中。官方文件明確告訴你了,這玩意是有坑的,使用起來也到處是雷區。

3.最後一種方式是使用Redux來全域性控制,可以在React中的模式對話方塊一文了解使用Redux實現對話方塊的內容。雖然能解決前面2個問題,但是使用 Redux 除了多引入一些包之外,這也不是一種很“自然”的實現方式。

Protals的使用

Protals元件的使用方式和普通的React元件並沒有太大差異,只不過要用一個新的方法將其包裹起來:

/**
* @param child 需要展示在Protals中的元件,如<div>child</div>
* @param container 元件放置的容器,就是一個Element物件。例如 document.getElementById(`pop`);
*/
ReactDOM.createPortal(child, container)

通常情況下,我們需要為某個元件增加子元素都會直接寫在render()方法中:

render() {
  return (
    <div>
      {this.props.children}
    </div>
  );
}

而如果是一個 Protals 特性的元件,我們通過下面的過程建立它:

render() {
  return ReactDOM.createPortal(
    this.props.children,
    domNode,
  );
}

Protals的事件傳遞

Protals特性的元件渲染成真實DOM後結構上和虛擬DOM不完全一致,但是其事件流還是像普通的React元件一樣可以在父元件中接收並加以處理。所以我們依然可以按照冒泡的方式處理Protals元件的事件。

看個程式碼的例子,我們定義兩個元件——AppPop

App是整個頁面的框架,負責將Pop彈窗中輸入的內容顯示到頁面中。React 會將彈窗直接新增為<body>的子元素。

class App extends React.Component {
    //constructor 
    clickHandle() {
        this.setState({popShow: true})
    }
    submitHandle(value) {
        this.setState({message: value, popShow: false})
    }
    cancelHandle() {
        this.setState({popShow: false})
    }
    render() {
        return (
            <div>
                <p>Input Message : {this.state.message}</p>
                <button onClick={this.clickHandle}>Click</button>
                {this.state.popShow && 
                <Modal>
                    <Pop onSubmit={this.submitHandle} onCancel={this.cancelHandle}/>
                </Modal>}
            </div>
        )
    }
}
class Pop extends React.Component {
    //constructor
    submitHandle() {
        this.props.onSubmit(this.el.value)
    }
    render() {
        const {onCancel} = this.props
        return createPortal(
            <div>
                <div><span onClick={onCancel}>X</span></div>
                <textarea ref={ref=>this.el=ref}/>
                <div>
                    <button onClick={this.submitHandle}>submit</button>
                    <button onClick={onCancel}>cancel</button>
                </div>
            </div>,
            document.getElementById(`body`))
    }
}

以上只是示例,已實現的原始碼在:https://github.com/chkui/ReactProtalExample。你可以執行下面這幾步執行,並在瀏覽器輸入http://localhost:8080/看到效果。

$ git clone https://github.com/chkui/ReactProtalExample.git
$ npm install #按照node_module
$ npm start #執行webpack

觀察程式碼我們會發現:實現這個彈窗的效果僅僅需要在舊的React元件編碼的方式上增加一層createPortal 方法包裝即可。其他的處理方式沒有任何變化。但是出現彈窗後,觀察真實的DOM結構,你會發現彈窗是出現在<body />標籤下,脫離了React的樹形結構:

<body id="body">
    <div id="root">
      <div class="app">
         <p class="message">Input Message : Input</p>
         <button class="button">Click</button>
      </div>
    </div>
    <div class="modal"> <!-- 彈窗的DOM -->
      <div class="mask"></div>
      <div class="pop">
         <div class="title"><span class="close">X</span></div>
         <textarea class="text" placeholder="input message"></textarea>
         <div class="pop-bottom">
             <button class="button pop-btn">submit</button>
             <button class="button pop-btn">cancel</button>
         </div>
      </div>
    </div>
</body>

Error Boundaries

在 16.x 版本之前,React並沒有對異常有什麼處理(15.x 增加的 unstable_handleError 滿地是坑),都是讓使用React的開發人員按照標準JavaScript的方式自行處理可能會出現的異常,這會導致某些由底層渲染過程引起的異常很難定位。此外,由於一個React元件常常伴隨多個生命週期方法(lifecycle methods),如果要全面的去處理異常,會導致程式碼結構越來越差。

為了解決這些坑,最新版本的React提供了一個優雅處理渲染過程異常的機制—— Error Boundaries 。同時,隨著 Error Boundaries 的推出,React也調整了一些異常處理的的行為和日誌輸出的內容。

Error Boundaries特點

特點1:通過一個生命週期方法捕獲子元件的所有異常:

/**
*@param error: 被丟擲的異常
*@param info: 包含異常堆疊列表的物件
**/
componentDidCatch(error, info)

特點2:只能捕獲子元件的異常,而不能捕獲自身出現的異常。

特點3:只能捕獲渲染方法,和生命週日方法中出現的異常。而事件方法中的異常、非同步程式碼中的異常(例如setTimeoout、一些網路請求方法)、服務端渲染時出現的異常以及componentDidCatch方法中出現的異常是無法被捕獲的。如果需要捕獲這些異常,只能使用JavaScripttry/catch語法。

異常處理行為變更

16.x 之後的React的異常處理較之前有一些變動。當元件在使用的過程中出現某個異常沒有被任何 componentDidCatch 方法捕獲,那麼 React 將會解除安裝掉整個 虛擬Dom樹。這樣的結果是任何未處理的異常都導致使用者看到一個空白頁面。官方的原文——“As of React 16, errors that were not caught by any error boundary will result in unmounting of the whole React component tree”。

這樣的目的是儘可能保證頁面完整性,避免由於頁面的錯誤而導致業務邏輯錯誤。所以React升級到16.x版本後,至少在最頂層的根節點元件實現 componentDidCatch 方法並附加一個 錯誤提示的簡單元件。如果根節點的元件需要處理的事物太複雜,最好多加一層包裝元件僅處理異常。

有了 componentDidCatch 之後,我們可以更細粒度的按照模組或者業務來控制異常。還可以專門設定一個伺服器介面來收集頁面在客戶端執行時出現的異常。

優化異常堆疊

新版本的React優化了異常輸出,能夠更清晰的跟蹤到出錯的位置。異常日誌輸出的內容將會比之前的React豐富很多,除了輸出JavaScript的異常資訊,還會清晰的定位到錯誤出現的元件:

如果你的專案使用最新版本的 create-react-app 建立的,那麼這一項功能已經存在了。如果沒使用 Create React App,那麼可以通過一個 Babel 的外掛新增這個功能:

$ npm install --save-dev babel-plugin-transform-react-jsx-source

然後在對應的配置(.babelrcwebpack的plugins等)中新增:

{
  "plugins": ["transform-react-jsx-source"]
}

切記這項功能僅僅用於開發或測試環境,切勿用於生產環境。某些瀏覽器可能不支援 Function.name  的屬性,可能無法正確顯示元件名稱(例如所有版本的IE)。可以通過使用一些 polyfill 來解決這個問題,比如這個 function-name工具 。

程式碼例項

最後是一個程式碼的例子。請按照以下步驟到github上clone下來執行。

$ git clone https://github.com/chkui/ErrorBoundariesExample.git #下載程式碼
$ npm install #安裝node_module
$ npm start #安裝完後webpakc啟動

例子值得關注的就幾個點。

1.通過 webpack 的方式引入了babel的原始碼對映外掛用以定位異常出現的位置。

module: {
        rules: [{
            test: /.js$/,
            use: [{
                loader: `babel-loader`,
                options: {
                    presets: [`es2015`, `stage-0`, `react`],
                    plugins: [`transform-react-jsx-source`], //新增外掛
                }
            }],
            exclude: /node_modules/
        }]
    },

2.定義了四個元件——AppParentChildErrorTip,分別是入口元件、父元件、子元件和捕獲到異常時用來提示的元件。

class App extends React.Component {
    //constructor
    componentDidCatch(error, info) {
        this.setState({error: true}) //處理子元件的異常
    }
    render() {
        return (<div className="app">
                <h2>Example</h2>
                {this.state.error ? (<ErrorTip />) : (<Parent/>)}
            </div>)
    }
}
class Parent extends React.Component {
    //constructor
    clickHandle() {
        try {
            throw new Error(`event error`)
        } catch (e) {
            this.setState({myError: true})
        }
    }
    childErrorClickHandle(){
        this.setState({childError:true})
    }
    componentWillUpdate(nextProps, nextState) {
        if (nextState.myError) {
            throw new Error(`Error`)
        }
    }
    componentDidCatch(error, info) {
        this.setState({catchError: true})
    }
    render() {
        return (
            <div className="box">
                <p>Parent</p>
                <button onClick={this.clickHandle}>throw parent error</button>
                <button onClick={this.childErrorClickHandle}>throw child error</button>
                {this.state.catchError ? (<ErrorTip/>):(<Child error={this.state.childError}/>)}
            </div>
        )
    }
}
class Child extends React.Component{
    //constructor
    componentWillReceiveProps(nextProps){
        if(nextProps.error){throw new Error(`child error`)}
    }
    render(){
        return (<div className="box">
            <p>Child</p>
        </div>)
    }
}

Child丟擲的異常會被Parent元件處理、Parent元件丟擲的異常會被App元件處理,元件無法捕獲自生出現的異常。

最後,由於16.x版本提供了componentDidCatch的功能,所以將15.x的unstable_handleError特性取消調了,如果需要進行升級的可以去 這裡 下載並使用升級工具。


相關文章