React Portals与Error Boundaries

全文共 2357 个字

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特性取消调了,如果需要进行升级的可以去 这里 下载并使用升级工具。