React 16是React历史上最重要的版本更新之一。它在2017年9月正式发布,引入了全新的Fiber架构,重写了核心的渲染引擎,同时带来了Error Boundaries、Portals、Fragment等一系列实用的新特性。React 16的发布标志着React进入了一个新的时代,不仅性能更好、体积更小,也为未来的功能扩展打下了基础。

很多开发者升级到React 16之后,只是享受到了更小的打包体积和更好的性能,并没有充分利用这些新特性。有些人甚至还在用React 15的写法写React 16的代码,完全浪费了新版本的优势。

我从React 16发布之后就一直在项目中使用,积累了一些实战经验。今天就来深入探讨React 16的新特性,分享一些进阶使用技巧,帮助你写出更优雅、更高效的React代码。

一、Fiber架构:React 16的灵魂

要理解React 16的新特性,首先要理解Fiber架构,因为几乎所有的新特性都是基于Fiber实现的。

什么是Fiber?

在React 15及之前的版本中,React使用的是"栈协调器"(Stack Reconciler)。它的工作方式是递归遍历组件树,同步地进行对比和更新。这种方式的问题是,一旦开始更新,就不能中断,必须一直执行完。如果组件树很大,更新过程可能会占用主线程很长时间,导致用户交互和动画卡顿,因为JavaScript是单线程的,更新的时候其他事情都得等着。

Fiber就是为了解决这个问题而设计的。Fiber是React 16全新的协调引擎,它把渲染更新过程拆分成了一个个小的任务单元(Fiber节点),每个任务单元执行完之后,React会检查有没有更高优先级的任务(比如用户输入、动画),如果有,就暂停当前的更新,先处理高优先级的任务,等空闲了再继续之前的更新。

这种"可中断、可恢复、有优先级"的渲染机制,让React能够把更新任务分散到多个帧中执行,保证高优先级的任务(比如用户交互)能及时响应,从而提升应用的流畅度。这就是React团队说的"时间切片"(Time Slicing)和"异步渲染"(Async Rendering)的基础。

Fiber带来的好处

对于普通开发者来说,Fiber带来的直接好处有:

  1. 更好的性能:特别是在大型应用中,Fiber的异步渲染机制能有效减少卡顿,提升应用的响应速度。
  2. 更小的打包体积:React 16的核心代码经过重写,打包体积比React 15小了很多,react + react-dom从原来的约43KB(gzip)降到了约34KB(gzip),减小了约20%。
  3. 更好的错误处理:Fiber架构让Error Boundaries成为可能,一个组件的错误不会导致整个应用崩溃。
  4. 为未来功能铺路:Fiber的架构让React团队能够更容易地添加新功能,比如后面会提到的Suspense、并发模式等。

需要注意的问题

虽然Fiber带来了很多好处,但是也有一些需要注意的地方。

首先,Fiber的渲染过程是可中断的,这意味着组件的生命周期函数可能会被调用多次。比如render函数、componentWillMount、componentWillReceiveProps这些在渲染阶段调用的生命周期函数,可能会因为渲染被中断和恢复而被调用多次。如果这些函数中有副作用(比如修改外部变量、发起请求),就可能导致不可预期的结果。

所以在React 16中,React团队开始不推荐使用componentWillMount、componentWillReceiveProps、componentWillUpdate这几个生命周期函数,因为它们在渲染阶段调用,可能被执行多次。取而代之的是getDerivedStateFromProps和getSnapshotBeforeUpdate这两个新的生命周期函数,它们被设计为纯函数,不会有副作用。

虽然在React 16中这些旧的生命周期函数还能用,但是建议大家逐步迁移到新的生命周期,避免未来的兼容性问题。

二、Error Boundaries:优雅地处理错误

Error Boundaries(错误边界)是React 16引入的一个非常实用的新特性。在React 15中,如果某个组件在渲染时抛出了错误,整个应用都会崩溃,用户看到的就是白屏。React 16的Error Boundaries可以让你捕获子组件树中的错误,并且渲染一个降级的UI,而不是让整个应用崩溃。

基本用法

Error Boundaries的实现很简单,只要一个组件实现了componentDidCatch生命周期函数,它就成为了一个Error Boundary。当它的子组件树中任何一个组件在渲染时抛出错误,这个错误都会被componentDidCatch捕获,你可以在里面记录错误信息,并且通过setState来渲染降级UI。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // 记录错误信息
    console.error('Error caught by boundary:', error, info);
    this.setState({ hasError: true });
  }

  render() {
    if (this.state.hasError) {
      // 渲染降级UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

使用的时候,把需要保护的组件包在ErrorBoundary里面:

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

这样,如果MyWidget或者它的子组件在渲染时抛出错误,ErrorBoundary会捕获错误,渲染"Something went wrong.",而不是让整个应用崩溃。

进阶技巧

  1. 分层使用Error Boundary:不要只在应用最外层放一个Error Boundary,而是应该根据组件的重要性分层使用。比如,页面的侧边栏、头部、主内容区可以各自有自己的Error Boundary,这样一个区域出问题不会影响其他区域。用户还能正常使用其他功能,体验会好很多。
  1. 提供有意义的降级UI:不要只显示"出错了",应该提供有意义的降级UI,比如错误说明、重试按钮、返回首页的链接等。让用户知道发生了什么,以及可以怎么做。
  1. 上报错误信息:在componentDidCatch中,除了设置状态,还应该把错误信息上报到错误监控系统,方便开发者及时发现和修复问题。componentDidCatch的第二个参数info包含了组件栈信息,能帮助定位错误发生在哪个组件。
  1. Error Boundary不能捕获的错误:需要注意的是,Error Boundary只能捕获子组件在渲染过程中、生命周期函数中、以及构造函数中抛出的错误。它不能捕获以下几种错误:事件处理函数中的错误(比如onClick里的错误)、异步代码中的错误(比如setTimeout、Promise回调里的错误)、服务端渲染的错误、Error Boundary自身抛出的错误。对于这些错误,还是需要用try/catch或者全局的错误处理来捕获。

实战经验

我在项目中一般会做两层Error Boundary:最外层一个全局的,捕获整个应用的错误,显示一个"应用出错了,请刷新页面"的页面;每个路由页面一个,捕获单个页面的错误,页面出错时显示错误提示和重试按钮,但是不影响导航栏和侧边栏。这样既能保证用户不会看到白屏,又能把错误的影响范围降到最小。

三、Portals:把组件渲染到任何地方

Portal(传送门)是React 16的另一个实用新特性。它允许你把组件的DOM渲染到当前组件的DOM树之外的任何地方,比如document.body,或者其他DOM节点中。

为什么需要Portal?

在React 15中,组件的DOM结构和组件树的结构是一致的,父组件的DOM一定包含子组件的DOM。这在大多数情况下没问题,但是在某些场景下会有问题。

最典型的场景就是模态框(Modal)、对话框、提示框(Tooltip)、下拉菜单这些需要"浮"在页面最上层的组件。如果这些组件嵌套在页面的某个DOM节点中,而那个父节点设置了overflow: hidden或者z-index比较小,那么模态框就可能被裁剪或者被其他元素遮挡,显示不正常。

以前的解决方案是用CSS的position: fixed,或者用jQuery把DOM节点移到body下,但是这些方法都不优雅,而且会破坏React的组件树结构。

Portal就是为了解决这个问题的。它让你可以把组件的DOM渲染到任何地方,但是组件的事件冒泡、Context传递、生命周期等仍然按照React组件树的结构来,不受DOM位置的影响。

基本用法

Portal的用法很简单,用ReactDOM.createPortal(child, container)即可,第一个参数是要渲染的React元素,第二个参数是目标DOM节点。

class Modal extends React.Component {
  render() {
    return ReactDOM.createPortal(
      <div className="modal">
        {this.props.children}
      </div>,
      document.body // 渲染到body下
    );
  }
}

这样,Modal组件的DOM会被渲染到document.body下,而不是父组件的DOM中,就不会被父元素的overflow或者z-index影响了。

进阶技巧

  1. 事件冒泡仍然按照React组件树:这是Portal一个很重要的特性。虽然Modal的DOM渲染到了body下,但是它的事件仍然会按照React组件树的结构向上冒泡。也就是说,如果Modal的父组件绑定了onClick事件,点击Modal的时候,父组件的onClick仍然会触发。这让事件处理变得很方便,不需要因为DOM位置的改变而调整事件绑定。
  1. 服务端渲染时的注意事项:如果你的应用用了服务端渲染(SSR),使用Portal的时候要注意,在服务端渲染时,Portal的内容不会被渲染到目标节点中,因为服务端没有DOM。需要在客户端hydration之后再渲染Portal的内容,或者用第三方库来处理SSR中的Portal。
  1. 动态创建目标节点:有时候你可能需要动态创建目标节点,比如每个Modal实例都有自己的容器。可以在componentDidMount中创建一个div,append到body中,然后在render中用这个div作为Portal的目标节点,在componentWillUnmount中移除这个div。这样可以避免多个Modal共用一个容器导致的问题。
  1. 配合CSS使用:Portal只是解决了DOM位置的问题,样式还是需要自己写。一般模态框需要设置position: fixed,加上遮罩层,处理滚动锁定等。可以配合CSS动画库(比如react-transition-group)来实现模态框的进出动画。

实战经验

我在项目中用Portal封装了一个通用的Modal组件,支持自定义目标节点、遮罩层、关闭按钮、进出动画等。所有的弹窗、对话框、确认框都用这个Modal组件来实现,再也没有出现过被父元素遮挡的问题。而且因为Portal的事件冒泡特性,事件处理也很方便,不需要做特殊处理。

除了模态框,Tooltip、下拉菜单、通知提示这些组件也非常适合用Portal来实现。如果你还在用老方法处理这些组件,建议试试Portal,代码会优雅很多。

四、Fragment:不再需要多余的包裹元素

Fragment是React 16.2引入的一个小但是很实用的特性。它允许你在不添加额外DOM节点的情况下,返回多个子元素。

问题背景

在React 15中,组件的render函数只能返回一个根元素。如果你想返回多个兄弟元素,就必须用一个div或者其他元素把它们包起来。比如:

render() {
  return (
    <div>
      <dt>Term</dt>
      <dd>Definition</dd>
    </div>
  );
}

但是这个包裹的div有时候是不需要的,甚至会破坏HTML结构。比如上面的例子,dl元素的直接子元素应该是dt和dd,多了一层div就不符合HTML规范了。还有在表格、列表等场景下,多余的包裹元素也会导致问题。

React 16一开始支持了返回数组的方式,可以返回一个元素数组:

render() {
  return [
    <dt key="dt">Term</dt>,
    <dd key="dd">Definition</dd>,
  ];
}

但是数组的方式需要给每个元素加key,而且语法看起来有点奇怪。于是React 16.2引入了Fragment,提供了更优雅的语法。

基本用法

Fragment的用法有两种,一种是用React.Fragment:

render() {
  return (
    <React.Fragment>
      <dt>Term</dt>
      <dd>Definition</dd>
    </React.Fragment>
  );
}

另一种是简写语法,用空标签:

render() {
  return (
    <>
      <dt>Term</dt>
      <dd>Definition</dd>
    </>
  );
}

Fragment不会在DOM中渲染任何额外的节点,dt和dd会直接作为父元素的子元素,HTML结构完全正确。

需要注意的地方

  1. 简写语法<>...</>需要Babel支持:如果你用的是Babel 7,默认就支持Fragment简写语法。如果是Babel 6,需要升级或者用React.Fragment的写法。
  1. Fragment可以带key:在列表渲染中,Fragment可以带key属性,但是简写语法<>不能带key,必须用React.Fragment。比如:
{items.map(item => (
  <React.Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </React.Fragment>
))}
  1. Fragment不会创建DOM节点,所以不能加className、style等属性:如果你需要给一组元素加样式,还是需要用一个div包起来。Fragment只是用来解决不需要额外DOM节点的场景。

实战经验

Fragment虽然是个小特性,但是用好了能让代码整洁很多。我现在写组件的时候,只要不需要额外的包裹元素,就用Fragment。特别是在写表格行、列表项、定义列表这些对HTML结构有要求的组件时,Fragment特别有用。

还有一个常见的使用场景是条件渲染。比如你想根据条件渲染两个兄弟元素,不需要额外的div,就可以用Fragment:

{isShow && (
  <>
    <Label />
    <Input />
  </>
)}

五、其他值得关注的新特性

除了上面详细讲的几个,React 16还有一些值得关注的新特性,这里简单介绍一下。

1. 支持自定义DOM属性

在React 15中,React会忽略不认识的DOM属性,如果你写了一个自定义的属性(比如data-*之外的自定义属性),React不会把它渲染到DOM上。React 16改进了这一点,对于不认识的属性,React会直接把它传递到DOM上,不再忽略。这对于使用一些自定义属性的第三方库或者Web Components来说很有用。

2. 更好的服务端渲染

React 16重写了服务端渲染,性能比React 15快了很多。根据React团队的benchmark,React 16的服务端渲染速度比React 15快了约3倍。而且React 16的服务端渲染支持流式输出,可以把HTML分块发送到浏览器,让用户更快看到页面内容。另外,React 16的客户端hydration也做了优化,不再要求服务端和客户端渲染的内容完全一致,减少了hydration错误。

3. setInterval返回的number可以作为key

这是一个小改进,但是很实用。以前用setInterval的返回值作为列表的key会有警告,因为key要求是string,而setInterval返回的是number。React 16支持number类型的key了,不再警告。

4. setState传null不再触发更新

在React 16中,如果setState传入null,React不会触发更新,也不会重新渲染。这对于在setState的回调中根据条件决定是否更新很有用。比如:

this.setState(state => {
  if (state.count === 0) {
    return null; // 不更新
  }
  return { count: state.count - 1 };
});

5. 新的生命周期函数

前面提到过,React 16引入了两个新的生命周期函数:getDerivedStateFromProps和getSnapshotBeforeUpdate,用来替代即将被废弃的componentWillReceiveProps和componentWillUpdate。

getDerivedStateFromProps是一个静态方法,在render之前调用,接收props和state,返回一个对象来更新state,或者返回null表示不需要更新。它被设计为纯函数,不能访问this,也不能有副作用。

getSnapshotBeforeUpdate在DOM更新之前调用,接收prevProps和prevState,返回的值会作为第三个参数传给componentDidUpdate。它适合用来在DOM更新前获取一些信息,比如滚动位置,然后在componentDidUpdate中使用。

虽然这两个生命周期函数在React 16.3才正式稳定,但是React 16已经有了相关的规划,建议大家提前了解,逐步迁移。

六、升级React 16的注意事项

如果你还在用React 15,想升级到React 16,有几个需要注意的地方。

1. 检查PropTypes和createClass

React 16把PropTypes和createClass从React核心中移出来了,变成了独立的包。如果你用了PropTypes,需要单独安装prop-types包;如果用了createClass,需要安装create-react-class包。不过这两个都是不推荐的用法,建议逐步迁移到函数组件和class组件,用TypeScript或者Flow来做类型检查。

2. 检查ReactDOM.render的返回值

在React 15中,ReactDOM.render返回的是根组件的实例。在React 16中,ReactDOM.render返回的是null,不再返回组件实例。如果你依赖了这个返回值,需要修改代码,用ref来获取组件实例。

3. 检查setState的回调

在React 16中,setState的回调(第二个参数)的执行时机有变化,它现在在componentDidMount/Update之后执行,而不是之前。如果你的代码依赖了回调的执行时机,需要检查一下。

4. 检查错误处理

React 16改变了错误处理的方式,未捕获的错误会导致整个应用卸载,而不是像React 15那样可能只影响部分组件。所以升级之后,建议加上Error Boundary来捕获错误,避免用户看到白屏。

5. 逐步升级,充分测试

React 16的API大部分是向后兼容的,升级的成本不高。但是还是建议逐步升级,先在测试环境充分测试,特别是生命周期函数、事件处理、服务端渲染这些部分。确认没有问题之后再发布到生产环境。

七、总结

React 16是一个里程碑式的版本,Fiber架构的引入让React的性能和可扩展性上了一个新台阶,Error Boundaries、Portals、Fragment等新特性也让我们写代码的时候更加得心应手。

很多人升级React 16只是为了更小的体积和更好的性能,但是忽略了这些新特性。充分利用这些新特性,能让你的代码更优雅、更健壮、更高效。希望这篇文章能帮助你更好地理解和使用React 16的新特性。

当然,React还在不断发展,未来还会有Suspense、并发模式、Hooks等更多令人兴奋的特性。作为前端开发者,我们要保持学习的热情,跟上技术的发展,用更好的技术写出更好的产品。

最后想说的是,技术只是工具,最终目的是为用户创造价值。不管用什么技术、什么框架,写出稳定、高效、用户体验好的应用,才是我们最重要的目标。