In some overlooked but very important scenarios, there can be a number of problems that severely affect performance but are easily resolved.
This article was originally published on December 5, 2017 (
The original link), mainly introduces the react performance optimization process of the Listing details page, one of the most visited pages on the Web side of Airbnb, and the methods, tools and experiences used in the process.
The author:
Joe Lencioni, Airbnb Web infrastructure engineer
Yvan Zhong is an intern engineer at Airbnb China
Proofreading: Lawrence Lin, Airbnb China Full Stack Engineer
We use React Router and Hypernova to develop single-page applications that support server-side rendering. The first scenario is airbnb.com’s core booking process. At the beginning of this year, we completed the migration of the home page and the housing search results page and achieved good results. The next step is to add the listing details page to the single-page app.
This is our listing details page. Throughout the search process, users may visit the page multiple times to view different listings. This page is one of the most visited and important airbnb.com pages, so we want to figure out all the details that affect performance!
Every page inevitably has some interaction, such as scrolling, clicking, typing. As part of the single-page application migration, I wanted to troubleshoot any performance issues in the listing details page caused by interaction. We want the page to launch quickly and stay smooth for a better user experience.
Through the process of analysis, repair, and re-analysis, the interactive performance of this key page has been significantly improved, resulting in a smoother booking experience for users. In this article, you’ll learn about the techniques I used to analyze the page and the tools I used to optimize the page, and you’ll see the impact of the changes in the fire diagram.
methods
Page analysis is recorded using Chrome’s Performance tools:
- Open invisible window (so my browser plugin doesn’t interfere with analysis)
- Access the page to be analyzed in the local development environment and use? React_perf (to enable User Timing Annotations for React), while disabling dev-only features that slow down pages, such as axe-core)
- Click the record button ⚫️
- Interact with the page (e.g., scroll, click, type)
- Click the record button again 🔴 and analyze the results
In general, I advocate performance analysis on mobile hardware, such as Moto C Plus, or setting the CPU limit to 6x deceleration to understand the experience on slower devices. However, because the performance issues with this page were severe enough, they were evident on my highly configured laptop, even without throttle.
The initial rendering
When I started optimizing the page, I noticed that the console had a warning: 💀
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"Copy the code
This is an error message caused by a mismatch between server rendering and client rendering. The problem causes Web browsers to perform work that they wouldn’t otherwise have done with server rendering, so React warns whenever this happens ✋.
Unfortunately, error messages are not very clear about the exact location or probable cause of the problem, but they do give us some clues. 🔎 I noticed some text that looked like a CSS class, so I typed in the terminal:
~ / reality ❯ ❯ ❯ 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');Copy the code
JSX is the search component at the top of the comments area. 🔍
From the code I found that we checked some browser features to make sure Placeholder was visible in older browsers (like Internet Explorer), but rendered the input box differently if it wasn’t supported in the current browser. Because the browser cannot be detected during the server-side rendering phase, the server always renders extra content.
This not only affects performance, but also results in extra tags being rendered every time. To fix this, I use React State to render this part and place it in componentDidMount until the client renders. 🥂
Running the profiler again, you can see that <SummaryContainer> is updated immediately after initial mount.
When updated, a <BreadcrumbList>, two <ListingTitles> and a <SummaryIconRow> are eventually rerendered. However, none of them has changed at all, so we can use the React.PureComponent for these three components to drastically reduce unnecessary rendering operations.
export default class SummaryIconRow extends React.Component {
...
}
Copy the code
To:
export default class SummaryIconRow extends React.PureComponent {
...
}
Copy the code
Next, we can see that <BookIt> is also re-rendered when the page loads. According to the flame 🔥 diagram, most of the time is spent rendering <GuestPickerTrigger> and <GuestCountFilter>.
Interestingly, these components are not visible unless the customer requires input 👻.
The solution to this problem is not to render until the component is in use. This improves the speed of initial rendering and re-rendering. 🐎 If we dig a little deeper and use more PureComponents, we can make rendering faster.
Scroll up and down
While doing some work on optimizing smooth scrolling animations, I noticed that the page was very unstable when scrolling. 📜 When the animation doesn’t reach 60 FPS (frames per second), or even 120 FPS, the user feels stuck. Scrolling is a special kind of animation that is directly related to finger movement, so it can be more sensitive than other animations when performance is poor.
After a bit of analysis, I found that we did a lot of unnecessary re-rendering of the React component in the Scroll Event Handlers! This is really bad:
Will the trees of the three components (< Amenity >, < BookItPriceHeader > and < StickyNavigationController >) to React. PureComponent, can solve most problems, greatly reduces the overhead to rendering. We’re not at 60 FPS yet, but we’re getting closer:
In addition, there are some parts that can be optimized. 🚗 spread a little bit about the flame figure, we can see we still spend a lot of time to render the < StickyNavigationController >. Furthermore, if we look closely at the component stack information, we can see that there are four similar modules:
< StickyNavigationController > is part of the property page, fixed at the top. As you scroll between modules, it highlights the module you are currently in. The four blocks in the flame map correspond to the four links in the top navigation. As we scroll between modules, different links are highlighted, so some of them need to be re-rendered. This is what it looks like in the browser.
We can see that there are four links, but only two need to update the appearance when switching between sections. In the fire diagram, we see that four links are re-rendered each time. The reason for this is that the <NavigationAnchors> component creates a new function every time it renders and passes it as a prop to the <NavigationAnchor>, which makes the Pure component unoptimized.
const anchors = React.Children.map(children, (child, index) => { return React.cloneElement(child, { selected: activeAnchorIndex === index, onPress(event) { onAnchorPress(index, event); }}); });Copy the code
We can fix this by ensuring that <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,
});
});
Copy the code
In the < 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() { ... }}Copy the code
Run the profiler after optimization, and you can see that only two links are rerendered, reducing the work to half 🌗! Also, if we use more links, the amount of rendering needed doesn’t increase.
Dounan Shi of Flexport has been working on Reflective Bind, which uses the Babel plug-in to perform such optimizations. The project is still in its infancy, not ready for official release, but I’m looking forward to its future.
Looking at the main panel of the performance tool, I noticed that we have a very suspicious _handleScroll block that takes up 19ms per scroll event. If we wanted to get to 60 FPS, we would only have 16ms render time, which is obviously too much. 🌯
The culprit seems to be within onLeaveWithTracking. By searching the code, I trace it to <EngagementWrapper>. Taking a closer look at the call stack, I noticed that most of the time was spent in React setState, but strangely we didn’t actually see any re-rendering. B: well…
Digging deeper into <EngagementWrapper>, I noticed that we were using React State to track some information on the instance.
this.state = { inViewport: false };
Copy the code
However, we never used inViewport in the render Path, and we never needed to trigger rerendering when the inViewport changed, which meant we incurred unnecessary performance overhead. 💸 helps speed up scrolling animations by converting all similar uses of React State into simple instance variables.
this.inViewport = false;
Copy the code
I also noticed that the < AboutThisListingContainer > render again led to the < Amenities > component expensive 💰, unnecessary to rendering.
It was eventually confirmed that the re-rendering was caused by the withExperiments higher-order component used to help us with our experiment. This HOC always passes the newly created object as a prop to the component it wraps — losing any optimization in its path.
render() { ... const finalExperiments = { ... experiments, ... this.state.experiments, }; return ( <WrappedComponent {... otherProps} experiments={finalExperiments} /> ); }Copy the code
I fixed this by introducing Reselect, which caches the last result and maintains reference equality 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} /> ); }Copy the code
The second part of the question is similar. The getFilteredAmenities function is applied, which takes array as its first parameter and returns the filtered version of the array, so it is like:
function getFilteredAmenities(amenities) {
return amenities.filter(shouldDisplayAmenity);
}
Copy the code
While this looks fine, a new array instance is created every time it runs, even if the result is the same, and any Pure Components that take that array as an argument cannot be optimized. I solved this problem by introducing ResELECT to cache this filter. At this point there is no fire map because the whole re-render has completely disappeared! 👻
There may be more optimization opportunities out there (CSS Containment, for example), but scrolling has improved a lot!
Click on the operation
Moving on to more interaction with the page, I clearly felt a delay in clicking the “Helpful” button in the comments ✈️.
My hunch is that clicking this button will cause all comments on the page to be re-rendered. Take a look at the fire chart, as I expected:
After using the React.PureComponent in several places, page updates became more efficient.
Input operation
Going back to the old server/client mismatch, I noticed that I was slow to type in the input box.
Analysis revealed that each keystroke caused the entire comment header and each comment to be re-rendered! 😱 Are you kidding me?
To solve this problem, I extracted a portion of the comment header as a component so I could use it as a React.PureComponent, and then scattered the react. PureComponents on the tree. This allows each keystroke to re-render only the component that needs to be re-rendered: the input box.
What have we learned?
- We want the page to start up quickly and stay smooth.
- This means that we need to focus not only on the Time to Interactive between the user initiating the request and the page, but also on the interaction actions on the page, such as scrolling, clicking, and typing.
- PureComponent and ResELECT are two very useful tools for optimizing React applications.
- Avoid React state when instance variables are exactly what you want.
- While React is powerful, it’s also easy to write code that affects performance.
- Develop the habit of analyzing, optimizing, and re-analyzing.