Airbnb 近期对房源展示页面的性能优化

1,355 阅读4分钟
原文链接: medium.com

This ends up re-rendering a <BreadcrumbList>, two<ListingTitles>, and a <SummaryIconRow> when it updates. However, none of these have any differences, so we can make this operation significantly cheaper by using React.PureComponent on these three components. This was about as straightforward as changing this:

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

into this:

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

Up next, we can see that <BookIt> also goes through a re-render on the initial pageload. According to the flame 🔥 chart, most of the time is spent rendering <GuestPickerTrigger> and <GuestCountFilter>.

103.15 ms spent re-rendering BookIt

The funny thing here is that these components aren’t even visible 👻 unless the guest input is focused.

The fix for this is to not render these components when they are not needed. This speeds up the initial render as well as any re-renders that may end up happening. 🐎 If we go a little further and drop in some more PureComponents, we can make this area even faster.

8.52 ms spent re-rendering BookIt

Scrolling around

While doing some work to modernize a smooth scrolling animation we sometimes use on the listing page, I noticed the page felt very janky when scrolling. 📜 People usually get an uncomfortable and unsatisfying feeling when animations aren’t hitting a smooth 60 fps (Frames Per Second), and maybe even when they aren’t hitting 120 fps. Scrolling is a special kind of animation that is directly connected to your finger movements, so it is even more sensitive to bad performance than other animations.

After a little profiling, I discovered that we were doing a lot of unnecessary re-rendering of React components inside our scroll event handlers! This is what really bad jank looks like:

Really bad scrolling performance on Airbnb listing pages before any fixes

I was able to resolve most of this problem by converting three components in these trees to use React.PureComponent: <Amenity>, <BookItPriceHeader>, and <StickyNavigationController>. This dramatically reduced the cost of these re-renders. While we aren't quite at 60 fps (Frames Per Second) yet, we are much closer:

Slightly improved scrolling performance of Airbnb listing pages after some fixes

However, there is still more opportunity to improve. Zooming 🚗 into the flame chart a little, we can see that we still spend a lot of time re-rendering <StickyNavigationController>. And, if we look down component stack, we notice that there are four similar looking chunks of this:

58.80 ms spent re-rendering StickyNavigationController

The <StickyNavigationController> is the part of the listing page that sticks to the top of the viewport. As you scroll between sections, it highlights the section that you are currently inside of. Each of the chunks in the flame 🚒 chart corresponds to one of the four links that we render in the sticky navigation. And, when we scroll between sections, we highlight a different link, so some of it needs to re-render. Here's what it looks like in the browser.

Now, I noticed that we have four links here, but only two change appearance when transitioning between sections. But still, in our flame chart, we see that all four links re-render every time. This was happening because our <NavigationAnchors> component was creating a new function in render and passing it down to <NavigationAnchor> as a prop every time, which de-optimizes pure components.

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

We can fix this by ensuring that the <NavigationAnchor> always receives the same function every time it is rendered by <NavigationAnchors>:

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

And then in <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() {
    ...
  }
}

Profiling after this change, we see that only two links are re-rendered! That's half 🌗 the work! And, if we use more than four links here, the amount of work that needs to be done won’t increase much anymore.

32.85 ms spent re-rendering StickyNavigationController

Dounan Shi at Flexport has been working on Reflective Bind, which uses a Babel plugin to perform this type of optimization for you. It’s still pretty early so it might not be ready for production just yet, but I’m pretty excited about the possibilities here.

Looking down at the Main panel in the Performance recording, I notice that we have a very suspicious-looking _handleScroll block that eats up 19ms on every scroll event. Since we only have 16ms if we want to hit 60 fps, this is way too much. 🌯

18.45 ms spent in _handleScroll

The culprit seems to be somewhere inside of onLeaveWithTracking. Through some code searching, I track this down to the <EngagementWrapper>. And looking a little closer at these call stacks, I notice that most of the time spent is actually inside of React's setState, but the weird thing is that we aren't actually seeing any re-renders happening here. Hmm...

Digging into <EngagementWrapper> a little more, I notice that we are using React state 🗺 to track some information on the instance.

this.state = { inViewport: false };

However, we never use this state in the render path at all and never need these state changes to cause re-renders, so we end up paying an extra cost. 💸 Converting all of these uses of React state to be simple instance variables really helps us speed up these scrolling animations.

this.inViewport = false;
1.16ms spent in scroll event handler

I also noticed that the <AboutThisListingContainer> was re-rendering, which caused an expensive 💰 and unnecessary re-render of the <Amenities> component.

32.24 ms spent in AboutThisListingContainer re-render

This ended up being partly caused by our withExperiments higher-order component which we use to help us run experiments. This HOC was written in a way that it always passes down a newly created object as a prop to the component it wraps—deoptimizing anything in its path.

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

I fixed this by bringing in reselect for this work, which memoizes the previous result so that it will remain referentially equal between successive renders.

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}
    />
  );
}

The second part of the problem was similar. In this code path we were using a function called getFilteredAmenities which took an array as its first argument and returned a filtered version of that array, similar to:

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

Although this looks innocent enough, this will create a new instance of the array every time it is run, even if it produces the same result, which will deoptimize any pure components receiving this array as a prop. I fixed this as well by bringing in reselect to memoize the filtering. I don’t have a flame chart for this one because the entire re-render completely disappeared! 👻

There’s probably still some more opportunity here (e.g. CSS containment), but scrolling performance is already looking much better!

Improved scrolling performance on Airbnb listing pages after these fixes

Clicking on things

Interacting with the page a little more, I felt some noticeable lag ✈️ when clicking on the “Helpful” button on a review.

My hunch was that clicking this button was causing all of the reviews on the page to be re-rendered. Looking at the flame chart, I wasn’t too far off: