Airbnb 爱彼迎房源详情页中的 React 性能优化

Airbnb 爱彼迎房源详情页中的 React 性能优化

在一些容易被忽视但又非常重要的场景,可能会有许多严重影响性能却很容易解决的问题。

本文最早于 2017 年 12 月 5 日发表(原文链接),主要介绍了 Airbnb web 端访问量最大的页面之一——房源详情页的 react 性能优化过程,其中用到的方法、工具和经验心得。
作者:Joe Lencioni,Airbnb web 基础架构工程师
译者:Yvan Zhong,Airbnb 中国实习工程师
校对:Lawrence Lin,Airbnb 中国全栈工程师

我们使用 React RouterHypernova 开发支持服务端渲染的单页面应用。第一个应用场景是 airbnb.com 的核心预订流程。在今年年初(注:本文原作于 2017 年),我们完成了首页和房源搜索结果页面的迁移并取得了很好的效果。下一步计划是把房源详情页面加入到单页面应用中。

airbnb.com房源详情页面: https://www.airbnb.com/rooms/8357

这就是我们的房源详情页面。在整个搜索过程中,用户可能会多次访问该页面,查看不同的房源。这个页面是 airbnb.com 上访问量最大,最重要的页面之一,因此我们希望弄清楚所有影响性能的细节!

每个页面都不可避免地会有一些交互操作,如滚动、点击、输入。作为单页面应用迁移工作的一部分,我希望排查房源详情页面中所有由交互操作引起的性能问题。我们希望页面可以快速启动并保持流畅,让用户拥有更好的体验。

通过分析,修复和再次分析的过程,这个关键页面的交互性能得到了显著改善,可以给用户带来更加流畅的预定体验。在这篇文章中,您将了解到我用来分析这个页面的技术以及优化页面的工具,并能从火焰图中看到改变带来的影响。

方法

对页面的分析是通过Chrome的性能工具记录的:

  1. 打开隐身窗口(这样我的浏览器插件不会干扰到分析)
  2. 在本地开发环境中访问要分析的页面,并在查询字段中使用 ?react_perf(以启用 React 的User Timing annotations),同时禁用一些会减慢页面速度的 dev-only 功能,比如 axe-core
  3. 单击录制按钮⚫️
  4. 与页面交互(例如滚动,单击,输入)
  5. 再次单击录制按钮🔴并分析结果

通常情况下,我提倡在移动硬件上进行性能分析,例如 Moto C Plus,或者将 CPU 限制设置为6x减速,以了解较慢设备上的体验。然而,因为这个页面的性能问题已经足够严重,所以在我配置很高的笔记本电脑上,即使不设置 throttle,也可以明显观察到性能问题的存在。

初始渲染

当我开始优化这个页面时,我注意到控制台有一个警告:💀

webpack-internal:///36:36 Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server:
(client) ut-placeholder-label screen-reader-only"
(server) ut-placeholder-label" data-reactid="628"

这是服务端渲染和客户端渲染结果不匹配导致的错误信息,该问题会使Web浏览器执行那些使用服务端渲染后本不需要执行的工作,因此只要发生这种情况,React就会给出这样的警告✋。

不幸的是,错误信息并不能非常清楚地表明发生问题的确切位置或可能原因,不过确实能给我们一些线索。 🔎我注意到一些看起来像 CSS 类的文本,于是我在终端中输入:

~/airbnb ❯❯❯ ag ut-placeholder-label
app/assets/javascripts/components/o2/PlaceholderLabel.jsx
85:        'input-placeholder-label': true,

app/assets/stylesheets/p1/search/_SearchForm.scss
77:    .input-placeholder-label {
321:.input-placeholder-label,

spec/javascripts/components/o2/PlaceholderLabel_spec.jsx
25:    const placeholderContainer = wrapper.find('.input-placeholder-label');

很快就把搜索范围缩小到了 o2/PlaceHolderLabel.jsx,它是位于评论区顶部用于搜索的组件。🔍

从代码中我发现,我们检测了一些浏览器功能来确保 Placeholder 在老版本浏览器(如 Internet Explorer)中可见,但如果当前浏览器中不支持,则会以不同方式渲染输入框。由于在服务端渲染阶段并不能对浏览器执行检测,导致服务端总会渲染一些额外的内容。

这不仅影响了性能,还会导致每次都有多余的标签被渲染出来。为了解决这个问题,我使用 react state 来渲染这部分内容,并把它放在 componentDidMount 中,直到客户端渲染时才会执行。 🥂

再次运行 profiler,可以看到 <SummaryContainer> 在 initial mount 后立刻更新。

重新渲染 Redux 连接的 SummaryContainer,耗时 101.63 毫秒

在更新时,最终会重新渲染一个 <BreadcrumbList>,两个 <ListingTitles> 和一个 <SummaryIconRow>。但是,它们都没有任何变化,因此我们可以对这三个组件使用React.PureComponent 来大幅减少不必要的渲染操作。

export default class SummaryIconRow extends React.Component {
 ...
}

改为:

export default class SummaryIconRow extends React.PureComponent {
 ...
}

接下来,我们可以看到 <BookIt> 在页面加载时也发生了重新渲染。根据火焰🔥图,大部分时间是用在了渲染 <GuestPickerTrigger> 和 <GuestCountFilter> 上。

重新渲染 BookIt,耗时 103.15 毫秒

有趣的是,除非客户需要输入,否则这些组件是不可见的👻。

解决此问题的方法是直到组件被使用时才进行渲染。这提高了初始渲染和重新渲染的速度。 🐎如果我们再深入一点,使用更多的 PureComponents,可以让渲染速度更快。

重新渲染 BookIt,耗时 8.52 毫秒

上下滚动

在做一些优化平滑滚动动画的工作时,我注意到页面在滚动时非常不稳定。 📜当动画没有达到 60 fps(每秒帧数),甚至没有达到 120 fps 时,用户就能感觉到卡顿了滚动是一种特殊的动画,它直接关联到手指的运动,所以在性能比较差时会比其他动画更敏感。

稍微分析一下,我发现我们在滚动事件处理机制(scroll event handlers)中对React组件进行了大量不必要的重新渲染!这真的非常糟糕:

在没做任何修复之前,Airbnb 房源详情页的滚动性能确实很差

将这些树中的三个组件(<Amenity>,<BookItPriceHeader>和<StickyNavigationController>)改成 React.PureComponent,能解决大部分问题,大大降低了重新渲染的开销。虽然我们还没有达到 60 fps,但我们更加接近了:

经过一些修复后,Airbnb 房源详情页的滚动性能略有提升

此外,还有一些可以优化的部分。🚗稍微展开一下火焰图,我们可以看到我们仍然花费了大量时间重新渲染 <StickyNavigationController>。而且,如果我们仔细看组件堆栈信息,可以发现有四个相似的模块:

重新渲染 StickyNavigationController,耗时 58.80 毫秒

<StickyNavigationController> 是房源页面的一部分,固定在顶部。当你在各模块之间滚动时,它会突出显示你当前所在的模块。火焰图中的四个块分别对应于吸顶导航的四个链接。当我们在各个模块之间滚动时,会突出显示不同的链接,因此其中一些需要重新渲染。这是它在浏览器中的效果。

我们可以看到一共有四个链接,但在各个部分之间切换时只有两个需要更新外观。在火焰图中,我们发现每次四个链接都会重新渲染。发生这种情况的原因是 <NavigationAnchors> 组件每次渲染时都创建了一个新函数,并将它作为 prop 传递给 <NavigationAnchor>,这会使 pure 组件失去优化的能力。

const anchors = React.Children.map(children, (child, index) => {     
  return React.cloneElement(child, {
    selected: activeAnchorIndex === index,
    onPress(event) { onAnchorPress(index, event); },
  });
});

我们可以通过确保 <NavigationAnchor> 每次被 <NavigationAnchors> 渲染时总是接收到相同的函数来修复这个问题:

const anchors = React.Children.map(children, (child, index) => {     
  return React.cloneElement(child, {
    selected: activeAnchorIndex === index,
    index,
    onPress: this.handlePress,
  });
});

在 <NavigationAnchor> 中:

class NavigationAnchor extends React.Component {
  constructor(props) {
    super(props);
    this.handlePress = this.handlePress.bind(this);
  }

  handlePress(event) {
    this.props.onPress(this.props.index, event);
  }

  render() {
    ...
  }
}

优化后再运行 profiler,可以看到只重新渲染了两个链接,工作量变成了之前的一半🌗!而且,如果我们使用更多的链接,需要渲染的工作量也不会增加。

重新渲染 StickyNavigationController,耗时 32.85 毫秒

FlexportDounan Shi 一直在研究 Reflective Bind,它使用 Babel 插件来执行这一类的优化。这个项目还处于起步阶段,尚不足以正式发布,但我非常期待它的未来。

观察性能工具的主面板,我注意到我们有一个非常可疑的 _handleScroll 块,每次滚动事件都会占用 19ms。如果我们想要达到 60 fps,就只能有 16ms 的渲染时间,这明显超出太多了。 🌯

_handleScroll 耗时18.45毫秒

罪魁祸首似乎在 onLeaveWithTracking 内部。通过搜索代码,我将其跟踪到<EngagementWrapper>。再仔细看看调用堆栈,我注意到大部分时间都花在了 React 的 setState 中,但奇怪的是我们实际上并没有看到任何重新渲染。嗯...

深入研究一下 <EngagementWrapper>,我注意到我们正在使用 React state 来跟踪实例上的一些信息。

this.state = { inViewport: false };

但是,我们从未在渲染路径(render path)中使用 inViewport,并且永远不需要在 inViewport 改变时触发重新渲染,也就是说我们付出了不必要的性能开销。 💸将 React state 的所有类似用法转换为简单的实例变量,有助于加快滚动动画的速度。

this.inViewport = false;
滚动事件处理程序耗时 1.16 毫秒

我还注意到 <AboutThisListingContainer> 的重新渲染导致了 <Amenities> 组件昂贵的💰、不必要的重新渲染。

在 AboutThisListingContainer 中重新渲染耗时 32.24 毫秒

最终确认重新渲染是由用于帮助我们进行实验的 withExperiments 高阶组件引起的。这个 HOC 总是将新创建的对象作为 prop 传递给它包装的组件——失去了对其路径中的任何优化。

render() {
  ...
  const finalExperiments = {
    ...experiments,
    ...this.state.experiments,
  };
  return (
    <WrappedComponent
      {...otherProps}
      experiments={finalExperiments}
    />
  );
}

我通过引入 reselect 修复了这个问题,它会缓存上次的结果,在连续渲染之间可以保持引用相等性。

const getExperiments = createSelector(
  ({ experimentsFromProps }) => experimentsFromProps,
  ({ experimentsFromState }) => experimentsFromState,
  (experimentsFromProps, experimentsFromState) => ({
    ...experimentsFromProps,
    ...experimentsFromState,
  }),
);
...
render() {
  ...
  const finalExperiments = getExperiments({
    experimentsFromProps: experiments,
    experimentsFromState: this.state.experiments,
  });
  return (
    <WrappedComponent
      {...otherProps}
      experiments={finalExperiments}
    />
  );
}

问题的第二部分是类似的。我们使用了 getFilteredAmenities 函数,该函数将数组作为其第一个参数,并返回该数组的过滤版本,类似于:

function getFilteredAmenities(amenities) {
 return amenities.filter(shouldDisplayAmenity);
}

虽然看起来没什么问题,但每次运行时,即使结果相同也会创建一个新的数组实例,任何 pure components 只要把该数组当做参数,就不能得到优化。我通过引入reselect来缓存这个过滤器,从而解决了这个问题。到这里就没有火焰图了,因为整个重新渲染完全消失了! 👻

除此以外可能还有更多优化机会(例如CSS containment),但滚动性能已经有了比较大的改善!

修复后的 Airbnb 房源页面的滚动性能

点击操作

继续与页面进行更多的交互,我明显感觉到在点击评论中的“Helpful”按钮时存在延迟✈️。

我的直觉是单击此按钮会导致页面上的所有评论都会被重新渲染。看一下火焰图,和我预计的一样:

重新渲染 ReviewsContent,耗时 42.38 毫秒

在几个地方使用 React.PureComponent 之后,页面更新变得更加高效了。

重新渲染 ReviewsContent,耗时 12.38 毫秒

输入操作

再回到服务端/客户端不匹配这一老问题,我注意到在输入框里打字时反应很迟钝。

分析后发现,每次按键操作都会导致整个评论区头部以及每条评论全部被重新渲染! 😱这是在逗我吗?

重新渲染 Redux 连接的 ReviewsContainer,耗时 61.32 毫秒

为了解决这个问题,我将评论区头部的一部分提取出来作为组件,这样我就可以将其作为 React.PureComponent,然后再把这几个 React.PureComponents 分散在树上。这使得每次按键操作仅重新渲染需要重新渲染的组件:输入框。

重新渲染 ReviewsHeader,耗时 3.18 毫秒

我们学到了什么?

  1. 我们希望页面快速启动并保持流畅。
  2. 这意味着我们需要关注的不仅仅是用户发起请求到页面可交互的时间(Time to Interactive),还需要分析页面上的交互动作,例如滚动,点击和输入。
  3. React.PureComponent 和 reselect 是 React 应用优化过程中非常有用的两个工具。
  4. 当实例变量完全满足你的需求时,就应该避免使用 React state。
  5. 虽然 React 功能强大,但也很容易写出影响性能的代码。
  6. 培养分析、优化、再次分析的习惯。