深入React v16新特性(一)

4,848 阅读12分钟

前言

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 页面仔的噩梦吧(包括我),应该尽力避免此种情况发生。切换到捕获模式后,组件启用新的componentDidCatch API。此时发生错误不会崩,会显示备用页面。

代码

新特性的主要代码在 /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'))
    }
}

渲染的实际 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 下。

另外,由于故意在 StrictMode 下使用了三个即将废弃的API,打开 console ,可看到如下错误提醒:

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 来实现相同功能,而且用的是新的生命周期函数实现,将在下次说新的生命周期钩子时详细讲述。