渐进式React

时间:2022-06-19
本文章向大家介绍渐进式React,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

可以说 React 为Web开发者带来了全新的开发模式,而在各类新功能下,如何达到性能最优仍是我们需要关心的。今天做一次精读尝试,原文地址在文末,话不多说,先呈上一份性能清单:

1. 测量组件级渲染性能

  • Chrome DevTools Performance 面板
  • React DevTools profiler 面板

2. 避免非必要的组件重复渲染

  • 尽量使用 shouldComponentUpdate
  • Class 组件使用 PureComponent
  • 功能组件使用 React.memo
  • 记住 Redux selectors(比如使用 reselect
  • 虚拟化超长列表(比如使用 react-window

3. 使用 Lighthouse 测量App级性能

4. 提升APP级性能

  • 如果没有使用服务端渲染,则使用 React.lazy 分割组件
  • 如果使用了服务端渲染,则使用 loadable-components 之类的库来分割组件
  • 使用 service worker 来缓存需要的文件,Workbox 可以帮到你
  • 如果使用了服务端渲染,使用流式传输(使用 renderToNodeStream 或 renderToStaticNodeStream)
  • 无法使用 SSR?使用 react-snap 等方案进行预渲染(Pre-render)
  • 如果用到 CSS-in-JS 库,将关键路径样式解析出来
  • 保障应用可用性,考虑使用 React A11y 或 react-axe 等库
  • 如果用户需要通过设备主屏幕访问站点,增加 web app manifest

对于 React 应用,我们主要关注两个性能维度:组件渲染性能页面加载性能,由于 React 的核心在于组件设计,那先从组件性能讲起。

测量组件级性能

React 熟为人知的“Virtual DOM”,是建立在高效调和(reconciliation)算法基础上的,其基于一定约定假设,将虚拟 DOM Diff 时间复杂度从O(n3)降为O(n)。虽然这些 React 内部实现不要求大家都理解,在小型应用中性能也不足以成为瓶颈,但性能优化本来就是量变到质变的过程,因此让我们从测量组件性能工具做起。

使用 Chrome 开发者工具测量性能

React 使用 User Timing API 收集各生命周期耗时,为避免测量本身带来的性能影响,性能采集仅在开发模式有效。

说实话,这类火焰图在视觉上有很强直观性,但缺少的有效调试信息,因此 React Devtools 提供了更为强大的能力。

使用 React DevTools Profiler 分析性能

React 16.5 开始使用 Profiler API 收集组件渲染耗时,以独立Tab形式呈现在 React DevTools 中。它的使用类似于 Chrome DevTools Performance,通过录制来决定收集数据范围。

React DevTools Profiler 示例

相比 Chrome DevTools Performance 中呈现的 Timing 信息,React DevTools Profiler 提供了更多辅助定位性能瓶颈的组件级信息,这里简单说下几个亮点:

  1. 以 commit 维度记录信息。熟悉 React 内部原理的同学知道,React 生命周期中有个 Commit 阶段,React DevTools Profiler 会以每次 commit 维度记录渲染相关信息,在右侧进行展示。
  2. 具体组件状态信息。左侧的火焰图对应了组件层级结构,以不同颜色区分组件渲染次数,高亮重复渲染的组件。点击组件后,右侧会展示组件具体渲染次数,以及当时的 state 与 props。
  3. 简单的统计能力。除了火焰图,工具还有排名(Ranked)和交互(Interactions)两个维度统计,帮助更快的定位组件瓶颈。

总体上 Profiler 工具使用简单,没什么门槛,接下来介绍优化组件渲染的相关技术。

避免非必要的组件重复渲染

去除无用的重复渲染,方案因场景各异:

使用 shouldComponentUpdate

shouldComponentUpdate(nextProps, nextState) {
  // 仅在确定条件下返回 true
}

Class 组件使用 PureComponent

import React, { PureComponent } from 'react';

class AvatarComponent extends PureComponent {

}

功能组件使用 memo

import React, { memo } from 'react';

const AvatarComponent = memo(props => {

});

记住 Redux selectors(比如使用 reselect

虚拟化超长列表(比如使用 react-window

测量 App 级性能

除了 DOM 级的渲染性能,还有更高层面的应用加载性能需要关注。这方面的性能工具属 Lighthouse 最有名了,我们可以通过 Node CLIChrome 扩展和 Chrome DevTools 的 Audits 面板用到它。

Lighthouse 根据一系列性能规则,对目标页面进行检查,最终生成一份性能报告,给出未达标指标的改进建议。在 React 项目中,随着路由和组件的膨胀,很容易触发 Lighthouse 对 JavaScript 传输体积的检查规则(Avoid enormous network payloads)。在实践中,已有成熟的方案供我们使用——代码分割。

代码分割

进行代码分割的一个方法是动态导入(dynamic imports):

 import('lodash.sortby')
    .then(module => module.default)
    .then(module => doSomethingCool(module))

这里的 import 语法像是函数调用,允许异步加载模块并通过 Promise 返回。上面代码动态获取了 lodash sortby 方法,紧接着被后续代码使用。

虽然动态导入目前仍处于 stage 3 阶段,Chrome and Safari 已经率先支持了,WebpackRollupParcel 也做好了支持。

回到 React,组件级别的代码分割已经被良好地抽象,比如 React.lazy:

import React, { lazy } from 'react';

const AvatarComponent = lazy(() => import('./AvatarComponent'));

然而这么做可能会导致用户可感知的加载延迟。对此,可以将 Suspense 组件配合 React.lazy 一起使用,“暂停”部分组件的渲染,通过渲染 Loading 组件,对仍在加载的组件进行降级处理:

import React, { lazy, Suspense } from 'react';
import LoadingComponent from './LoadingComponent';

const AvatarComponent = lazy(() => import('./AvatarComponent'));

const PageComponent = () => (
  <Suspense fallback={LoadingComponent}>
    <AvatarComponent />
  </Suspense>
)

Suspense 还不支持 SSR,如果要在服务端渲染使用代码分割,可以使用 loadable-components 这样的库。另外如果需要在滚动场景做异步加载的同学,可以了解下 react-loadable-visibility

缓存

Service Worker 就不重新介绍了,概括起来就是一个运行在浏览器后台的可编程代理,让我们对网络缓存更加可控。一个具体的使用场景是,通过控制缓存策略,来提升用户二次访问时的页面加载体验。

这里主要是安利 Workbox 这个工具包,它能让我们更简单地使用 Service Worker,具体细节不做展开,在 PWA 的浪潮中,你的站点值得拥有。

流式 SSR

为了加快页面呈现,服务端渲染概念已经被大家接受和使用。为了最大限度复用服务端返回的 HTML,React 还提供了 hydrate() API。这时优化的目光投向了 TTI,流式渲染也应运而生,相对之前的 renderToString API 返回 HTML 字符串,renderToNodeStream 会返回 Node Readable 字节流。这样浏览器就能源源不断地获取到页面块,hydrate API 也很好地支持了流式处理,真的很强大。

关于 SSR 更多信息,可以查看本专栏的《Web渲染那些事儿》

SSR 不行?预渲染来顶

其实服务端渲染是个笼统的概念,由于现代页面大多都是动态的,因此每个请求可能都要在服务器上处理一遍。然而纯服务端渲染与纯客户端渲染之间,是存在中间地带的。虽然页面是通过组件模式进行开发,但页面内容可能是静态的,只要生成一次就行,这就是预渲染(Prerendering)或静态渲染的由来。

这里介绍一个基于 Puppeteer 的预渲染方案 react-snap,它能让你更简单地进行预渲染页面。

提取关键 CSS-in-JS 样式

出于各种原因,有些开发者会使用 emotionstyled-components 等 CSS-in-JS 库,但如果不注意,会导致样式都在运行时解析,也就是导致页面会闪过无样式的瞬间。如果在移动设备或弱网络场景下,体验就很糟糕。上面提到的 SSR 更是如此,因为在客户端JS加载之前,SSR 返回的无样式 DOM 已经开始渲染了。

优化的做法就是将这些关键样式提取出来,好在 emotionstyled-components 都原生支持将样式提取到可读流中,流式 SSR 也不用担心闪屏情况了。

杂项

接下几项关于提升开发者体验,并助于减少繁琐的编码。

编写更少代码 = 传输更少代码 = 更快的网页加载

原子 CSS

原子样式的理念是定义单一作用的 class,以达到灵活组合样式的目的。看个简单的例子:

<button class="bg-blue">Click Me</button>

bg-blue 定义了蓝色背景色,作用在 button 上可令其应用这条规则。如果要给它加个 padding,可以设置单独负责 padding 的 class:

<button class="bg-blue pa2">Click Me</button>

虽然会多写几个 css class,但可以不用再去编辑复杂的 CSS 文件了,如果你不想自己维护一套样式规范,可以直接用开源的 Tachyons 方案。组件级别还有 tachyons-components 这样的方案,个人觉得还不太成熟,这里不做展开。

整体来看原子 CSS 比较适用于样式风格简单统一的场景,让开发者聚焦 JS 部分,随时修改样式而不用关心样式继承方面的影响,另一个好处是 CSS 可以长期缓存,基本不需要更新。

出于性能考虑,页面首次加载会被统一样式的 CSS 阻塞,看了下gzip后有10KB大小,还是看场景应用吧。

Hooks

Hooks 允许以功能组件实现以前只有 class 组件才能实现的功能,比如对 state 的操作:

import { useState } from 'react';

function AvatarComponent() {
  const [name, setName] = useState('Houssein');

  return (
    <React.Fragment>
      <div>
        <p>This is a picture of {name}</p>
        <img src="avatar.png" />
      </div>

      <button onClick={() => setName('a banana')}>
        Fix name
      </button>
    </React.Fragment>
  );
}

除了 React 提供的 useStateuseEffect,可以自定义 hooks 来复用跨组件的逻辑。在此之前要实现该功能,会用到 recompose 这个库,Hooks 出现后就可以退出历史舞台了。(真实情况是 recompose 的作者加入了 React Team,并推出了 Hooks)

虽然 Hooks 的定位是解决代码架构问题,但确实也在加载性能方面做出了贡献。虽然 Hooks 功能相关代码为 React 增加了1.5KB(gzip后),但 Hooks 代码比 class 组件代码更易压缩,因此可以减小一些 JS 包大小。

总结

像 React 这样拥有广泛开发者的开源项目,有两样事可以期待:

  1. 优化其 API,令构建应用更加容易
  2. 开源社区贡献第三方库,令构建应用更加容易

“令构建应用更加容易”可以指很多方面,让开发者做的更少、页面性能更高是其中之一。

延伸阅读

progressive react

React as a UI Runtime

Debugging React performance with React 16 and Chrome Devtools

Introducing the React Profiler

make loadable-components work with SSR

《Web渲染那些事儿》