这是用户在 2024-3-20 9:34 为 https://medium.com/@paularmstrong/twitter-lite-and-high-performance-react-progressive-web-apps-at-sc... 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?

Get unlimited access to the best of Medium for less than $1/week.

Twitter Lite and High Performance React Progressive Web Apps at Scale
Twitter Lite和高性能的React渐进式Web应用在规模上的应用

Paul Armstrong
13 min readApr 11, 2017

A look into removing common and uncommon performance bottlenecks in one of the worlds largest React.js PWAs, Twitter Lite.
探索如何消除世界上最大的React.js PWA之一——Twitter Lite中常见和不常见的性能瓶颈。

Support the author by reading this at paularmstrong.dev

Creating a fast web application involves many cycles of measuring where time is wasted, understanding why it’s happening, and applying potential solutions. Unfortunately, there’s never just one quick fix. Performance is a continuous game of watching and measuring for areas to improve. With Twitter Lite, we made small improvements across many areas: from initial load times, to React component rendering (and prevention re-rendering), to image loading, and much more. Most changes tend to be small, but they add up, and the end result is that we have one of the largest and fastest progressive web applications.
创建一个快速的Web应用程序涉及到多个周期的测量时间浪费的地方,理解为什么会发生这种情况,并应用潜在的解决方案。不幸的是,没有一个快速解决方案。性能是一个持续的游戏,需要不断观察和测量以寻找改进的领域。通过Twitter Lite,我们在许多方面进行了小的改进:从初始加载时间到React组件渲染(以及防止重新渲染),到图像加载等等。大多数改变往往是小的,但它们累积起来,最终结果是我们拥有了最大和最快的渐进式Web应用程序之一。

Before Reading On: 阅读前:

If you’re just starting to measure and work toward increasing the performance of your web application, I highly recommend learning how to read flame graphs, if you don’t know how already.

Each section below includes example screenshots of timeline recordings from Chrome’s Developer Tools. To make things more clear, I’ve highlighted each pair of examples with what’s bad (left image) versus what’s good (right image).

Special note regarding timelines and flame graphs: Since we target a very large range of mobile devices, we typically record these in a simulated environment: 5x slower CPU and 3G network connection. This is not only more realistic, but makes problems much more apparent. These may also be further skewed if we’re using React v15.4.0’s component profiling. Actual values on desktop performance timelines will tend to be much faster than what’s illustrated here.
关于时间轴和火焰图的特别说明:由于我们的目标是非常广泛的移动设备,我们通常在模拟环境中记录这些数据:CPU速度减慢5倍,网络连接为3G。这不仅更加真实,而且使问题更加明显。如果我们使用React v15.4.0的组件分析功能,这些数据可能会进一步偏离。在桌面性能时间轴上的实际值往往比这里所示的要快得多。

Optimizing for the Browser

Use Route-Based Code Splitting

Webpack is powerful but difficult to learn. For some time, we had issues with the CommonsChunkPlugin and the way it worked with some of our circular code dependencies. Because of that, we ended up with only 3 JavaScript asset files, totaling over 1MB in size (420KB gzip transfer size).

Loading a single, or even just a few very large JavaScript files in order to run a site is a huge bottleneck for mobile users to see and interact with a website. Not only does the amount of time it takes for your scripts to transfer over a network increase with their size, but the time it takes for the browser to parse increases as well.

After much wrangling, we were finally able to break up common areas by routes into separate chunks (example below). The day finally came when this code review dropped into our inboxes:

Adds granular, route-based code-splitting. Faster initial and HomeTimeline render is traded for greater overall app size, which is spread over 40 on-demand chunks and amortized over the length of the session. —

Timelines from before (left) and after (right) code splitting. Click or tap to zoom.

Our original setup (above left) took over 5 seconds to load our main bundle, while after splitting out code by routes and common chunks (above right), it takes barely 3 seconds (on a simulated 3G network).

This was done early on in our performance focus sprints, but this single change made a huge difference when running Google’s Lighthouse web application auditing tool:

We also ran the site before (left) and after (right) through Google’s “Lighthouse” web application auditing tool.

Avoid Functions that Cause Jank

Over many iterations of our infinite scrolling timelines, we used different ways to calculate your scroll position and direction to determine if we needed to ask the API for more Tweets to display. Up until recently, we were using react-waypoint, which worked well for us. However, in chasing the best possible performance for one of the main underlying components of our application, it just wasn’t fast enough.

Waypoints work by calculating many different heights, widths, and positions of elements in order to determine your current scroll position, how far from each end you are, and which direction you’re going. All of this information is useful, but since it’s done on every scroll event it comes at a cost: making those calculations causes jank–and lots of it.

But first, we have to understand what the developer tools mean when they tell us that there is “jank”.

Most devices today refresh their screens 60 times a second. If there’s an animation or transition running, or the user is scrolling the pages, the browser needs to match the device’s refresh rate and put up 1 new picture, or frame, for each of those screen refreshes.

Each of those frames has a budget of just over 16ms (1 second / 60 = 16.66ms). In reality, however, the browser has housekeeping work to do, so all of your work needs to be completed inside 10ms. When you fail to meet this budget the frame rate drops, and the content judders on screen. This is often referred to as jank, and it negatively impacts the user’s experience. — Paul Lewis on Rendering Performance
每个帧的预算只有略高于16毫秒(1秒/60=16.66毫秒)。然而,在现实中,浏览器还有一些内部工作要做,所以你的所有工作都需要在10毫秒内完成。当你无法满足这个预算时,帧率会下降,内容在屏幕上会出现抖动。这通常被称为卡顿,对用户的体验产生负面影响。- 保罗·刘易斯关于渲染性能的观点

Over time, we developed a new infinite scrolling component called VirtualScroller. With this new component, we know exactly what slice of Tweets are being rendered into a timeline at any given time, avoiding the need to make expensive calculations as to where we are visually.

It may not look like much, but before (left) while scrolling, we would cause render jank by trying to calculate the height of various elements. After (right) we cause no jank and reduce the stuttering while scrolling timelines at fast speeds. Click or tap to zoom.

By avoiding function calls that cause extra jank, scrolling a timeline of Tweets looks and feels more seamless, giving us a much more rich, almost native experience. While it can always be better, this change makes a noticeable improvement to the smoothness of scrolling timelines. It was a good reminder that every little bit counts when looking at performance.

Use Smaller Images 使用较小的图片

We first started pushing to use less bandwidth on Twitter Lite by working with multiple teams to get new and smaller sizes of images available from our CDNs. It turns out, that by reducing the size of the images we were rendering to be only what we absolutely needed (both in terms of dimensions and quality), we found that not only did we reduce bandwidth usage, but that we were also able to increase performance in the browser, especially while scrolling through image-heavy timelines of Tweets.
我们首先开始通过与多个团队合作,从我们的CDN中获取新的、更小的图像尺寸,以减少在Twitter Lite上使用的带宽。事实证明,通过减小我们渲染的图像尺寸,使其仅包含我们绝对需要的内容(无论是尺寸还是质量),我们不仅减少了带宽的使用,而且还能提高浏览器的性能,特别是在浏览图像密集的推文时间轴时。

In order to determine how much better smaller images are for performance, we can look at the Raster timeline in Chrome Developer Tools. Before we reduced the size of images, it could take 300ms or more just to decode a single image, as shown in the timeline recording below on the left. This is the processing time after an image has been downloaded, but before it can be displayed on the page.

When you’re scrolling a page and aiming for the 60 frame-per-second rendering standard, we want to keep as much processing as possible under 16.667ms (1 frame). It’s taking us nearly 18 frames just to get a single image rendered into the viewport, which is too many. One other thing to note in the timeline: you can see that the Main timeline is mostly blocked from continuing until this image has finished decoding (as shown by the whitespace). This means we’ve got quite a performance bottleneck here!

Large images (left) can block the main thread from continuing for 18 frames. Small images (right) take only about 1 frame. Click or tap to zoom.

Now, after we’ve reduced the size of our images (above, right), we’re looking at just over a single frame to decode our largest images.

Optimizing React 优化React

Make use of the shouldComponentUpdate method
利用 shouldComponentUpdate method

A common tip for optimizing the performance of React applications is to use the shouldComponentUpdate method. We try to do this wherever possible, but sometimes things slip through the cracks.
优化React应用程序性能的常见技巧是使用 shouldComponentUpdate 方法。我们尽可能地在各个地方使用它,但有时会有一些遗漏。

Liking the first Tweet caused both it and the entire Conversation below it to re-render!

Here’s an example of a component that was always updating: When clicking the heart icon to like a Tweet in the home timeline, any Conversation component on screen would also re-render. In the animated example, you should see green boxes highlighting where the browser has to re-paint because we’re making the entire Conversation component below the Tweet we’re acting on update.
这是一个始终在更新的组件的示例:当在主页时间线上点击心形图标来喜欢一条推文时,屏幕上的任何 Conversation 组件也会重新渲染。在动画示例中,您应该看到绿色框突出显示浏览器需要重新绘制的位置,因为我们正在更新位于我们操作的推文下方的整个 Conversation 组件。

Below, you’ll see two flame graphs of this action. Without shouldComponentUpdate (left), we can see its entire tree updated and re-rendered, just to change the color of a heart somewhere else on the screen. After adding shouldComponentUpdate (right), we prevent the entire tree from updating and prevent wasting more than one-tenth of a second running unnecessary processing.
下面,您将看到这个操作的两个火焰图。没有 shouldComponentUpdate (左边),我们可以看到整个树被更新和重新渲染,只是为了改变屏幕上其他地方的一个心形的颜色。添加 shouldComponentUpdate (右边)后,我们阻止整个树的更新,避免浪费超过十分之一秒的不必要处理。

Before (left), when liking an unrelated Tweet, entire Conversations would update and re-render. After adding shouldComponentUpdate logic (right), you can see that the component and its children are prevented from wasting CPU cycles. Click or tap to zoom.

Defer Unnecessary Work until componentDidMount

This change may seem like a bit of a no-brainer, but it’s easy to forget about the little things when developing a large application like Twitter Lite.
这个改变可能看起来很简单,但在开发像Twitter Lite这样的大型应用程序时,很容易忽略一些小细节。

We found that we had a lot of places in our code where we were doing expensive calculations for the sake of analytics during the componentWillMount React lifecycle method. Every time we did this, we blocked rendering of components a little more. 20ms here, 90ms there, it all adds up quickly. Originally, we were trying to record which tweets were being rendered to our data analytics service in componentWillMount, before they were actually rendered (timeline below, left).

By deferring non-essential code paths from `componentWillMount` to `componentDidMount`, we saved a lot of time to render Tweets to the screen. Click or tap to zoom.

By moving that calculation and network call to the React component’s componentDidMount method, we unblocked the main thread and reduced unwanted jank when rendering our components (above right).
通过将计算和网络调用移动到React组件的 componentDidMount 方法中,我们解除了主线程的阻塞,并减少了在渲染组件时产生的不必要的卡顿(如上图右侧)。

Avoid dangerouslySetInnerHTML

In Twitter Lite, we use SVG icons, as they’re the most portable and scalable option available to us. Unfortunately, in older versions of React, most SVG attributes were not supported when creating elements from components. So, when we first started writing the application, we were forced to use dangerouslySetInnerHTML in order to use SVG icons as React components.
在Twitter Lite中,我们使用SVG图标,因为它们是我们可用的最便携和可伸缩的选项。不幸的是,在旧版本的React中,大多数SVG属性在从组件创建元素时不受支持。因此,当我们开始编写应用程序时,我们被迫使用 dangerouslySetInnerHTML 来使用SVG图标作为React组件。

For example, our original HeartIcon looked something like this:

Not only is it discouraged to use dangerouslySetInnerHTML, but it turns out that it’s actually a source of slowness when mounting and rendering.
不仅不鼓励使用 dangerouslySetInnerHTML ,而且事实证明,在挂载和渲染时它实际上会导致速度变慢。

Before (left), you’ll see it takes roughly 20ms to mount 4 SVG icons, while after (right) it takes around 8. Click or tap to zoom.

Analyzing the flame graphs above, our original code (left) shows that it takes about 20ms on a slow device to mount the actions at the bottom of a Tweet containing four SVG icons. While this may not seem like much on its own, knowing that we need to render many of these at once, all while scrolling a timeline of infinite Tweets, we realized that this is a huge waste of time.

Since React v15 added support for most SVG attributes, we went ahead and looked to see what would happen if we avoided dangerouslySetInnerHTML. Checking the patched flame graph (above right), we get about an average of 60% savings each time we need to mount and render one of these sets of icons!
自从React v15添加了对大多数SVG属性的支持,我们继续前进并查看了如果避免使用 dangerouslySetInnerHTML 会发生什么。检查修补后的火焰图(右上方),每次需要挂载和渲染这些图标集合时,我们平均节省了约60%的时间!

Now, our SVG icons are simple stateless components, don’t use “dangerous” functions, and mount an average of 60% faster. They look like this:

Defer Rendering When Mounting & Unmounting Many Components

On slower devices, we noticed that it could take a long time for our main navigation bar to appear to respond to taps, often leading us to tap multiple times, thinking that perhaps the first tap didn’t register.

Notice in the image below how the Home icon takes nearly 2 seconds to update and show that it was tapped:

Without deferring rendering, the navigation bar takes time to respond.

No, that wasn’t just the GIF running a slow frame rate. It actually was that slow. But, all of the data for the Home screen was already loaded, so why is it taking so long to show anything?

It turns out that mounting and unmounting large trees of components (like timelines of Tweets) is very expensive in React.

At the very least, we wanted to remove the perception of the navigation bar not reacting to user input. For that, we created a small higher-order-component:

Our HigherOrderComponent, as written by Katie Sievert.
我们的HigherOrderComponent,由Katie Sievert编写。

Once applied to our HomeTimeline, we saw near-instant responses of the navigation bar, leading to a perceived improvement overall.

const DeferredTimeline = deferComponentRender(HomeTimeline);
render(<DeferredTimeline />);
After deferring rendering, the navigation bar responds instantly.

Optimizing Redux 优化Redux

Avoid Storing State Too Often

While controlled components seem to be the recommended approach, making inputs controlled means that they have to update and re-render for every keypress.

While this is not very taxing on a 3GHz desktop computer, a small mobile device with very limited CPU will notice significant lag while typing–especially when deleting many characters from the input.

In order to persist the value of composing Tweets, as well as calculating the number of characters remaining, we were using a controlled component and also passing the current value of the input to our Redux state at each keypress.

Below (left), on a typical Android 5 device, every keypress leading to a change could cause nearly 200ms of overhead. Compound this by a fast typist, and we ended up in a really bad state, with users often reporting that their character insertion point was moving all over the place, resulting in jumbled sentences.
在一个典型的Android 5设备上(左侧),每次按键导致的变化都可能引起近200毫秒的额外开销。如果是一个快速打字者,这种情况会进一步恶化,用户经常报告他们的字符插入点会在各个位置移动,导致句子混乱。

Comparisons of the amount of time it takes to update after each keypress while dispatching the change to Redux and when not. Click or tap to zoom.

By removing the draft Tweet state from updating the main Redux state on every keypress and keeping things local in the React component’s state, we were able to reduce the overhead by over 50% (above, right).

Batch Actions into a Single Dispatch

In Twitter Lite, we’re using redux with react-redux to subscribe our components to data state changes. We’ve optimized our data into separate areas of a larger store with Normalizr and combineReducers. This all works wonderfully to prevent duplication of data and keep our stores small. However, each time we get new data, we have to dispatch multiple actions in order to add it to the appropriate stores.
在Twitter Lite中,我们使用redux和react-redux来订阅我们的组件的数据状态变化。我们使用Normalizr和combineReducers将我们的数据优化为较大存储区的不同区域。这一切都很好地防止了数据的重复,并保持了我们的存储区的小型化。然而,每次获取新数据时,我们都必须分发多个动作以将其添加到适当的存储区。

With the way that react-redux works, this means that every action dispatched will cause our connected components (called Containers) to recalculate changes and possibly re-render.

While we use a custom middleware, there are other batch middleware available. Choose the one that’s right for you, or write your own.

The best way to illustrate the benefits of batching actions is by using the Chrome React Perf Extension. After the initial load, we pre-cache and calculate unread DMs in the background. When that happens we add a lot of various entities (conversations, users, message entries, etc). Without batching (below left), you can see that we end up with double the number of times we render each component (~16) versus with batching (~8) (below right).
批量处理操作的好处最好的方式是使用Chrome React Perf扩展。在初始加载后,我们在后台预缓存并计算未读的直接消息。当这种情况发生时,我们会添加许多不同的实体(对话、用户、消息条目等)。没有批量处理(左下方),您可以看到我们渲染每个组件的次数是批量处理的两倍(约16次),而使用批量处理的情况下只有约8次(右下方)。

A comparison using the React Perf extension for Chrome without batch-dispatch in Redux (left) vs with batch-dispatch (right). Click or tap to zoom.
使用React Perf扩展在Redux中没有批量分发的情况下进行比较(左侧)与使用批量分发的情况(右侧)。点击或触摸进行放大。

Service Workers 服务工作者

While Service Workers aren’t available in all browsers yet, they’re an invaluable part of Twitter Lite. When available, we use ours for push notifications, to pre-cache application assets, and more. Unfortunately, being a fairly new technology, there’s still a lot to learn around performance.
虽然服务工作者在所有浏览器中尚不可用,但它们是 Twitter Lite 的宝贵组成部分。一旦可用,我们将使用它们进行推送通知、预缓存应用程序资产等。不幸的是,作为一项相对较新的技术,性能方面仍有很多需要学习的地方。

Pre-Cache Assets 预缓存资源

Like most products, Twitter Lite is by no means done. We’re still actively developing it, adding features, fixing bugs, and making it faster. This means we frequently need to deploy new versions of our JavaScript assets.
就像大多数产品一样,Twitter Lite绝不是完美的。我们仍在积极开发中,添加功能,修复错误,并使其更快。这意味着我们经常需要部署新版本的JavaScript资源。

Unfortunately, this can be a burden when users come back to the application and need to re-download a bunch of script files just to view a single Tweet.

In ServiceWorker-enabled browsers, we get the benefit of being able to have the worker automatically update, download, and cache any changed files in the background, on its own, before you come back.

So what does this mean for the user? Near instant subsequent application loads, even after we’ve deployed a new version!

Network asset load times without ServiceWorker pre-caching (left) vs with pre-caching (right). Click or tap to zoom.

As illustrated above (left) without ServiceWorker pre-caching, every asset for the current view is forced to load from the network when returning to the application. It takes about 6 seconds on a good 3G network to finish loading. However, when the assets are pre-cached by the ServiceWorker (above right), the same 3G network takes less than 1.5 seconds before the page is finished loading. A 75% improvement!

Delay ServiceWorker Registration

In many applications, it’s safe to register a ServiceWorker immediately on page load:


While we try to send as much data to the browser as possible to render a complete-looking page, in Twitter Lite this isn’t always possible. We may not have sent enough data, or the page you’re landing on may not support data pre-filled from the server. Because of this and various other limitations, we need to make some API requests immediately after the initial page load.
尽管我们尽可能向浏览器发送尽量多的数据以呈现完整的页面,但在Twitter Lite中,这并非总是可能的。我们可能没有发送足够的数据,或者您所访问的页面可能不支持从服务器预填充数据。由于这个原因和其他各种限制,我们需要在初始页面加载后立即进行一些API请求。

Normally, this isn’t a problem. However, if the browser hasn’t installed the current version of our ServiceWorker yet, we need to tell it to install–and with that comes about 50 requests to pre-cache various JS, CSS, and image assets.
通常情况下,这不是一个问题。然而,如果浏览器尚未安装我们的ServiceWorker的最新版本,我们需要告诉它进行安装 - 这将导致大约50个请求来预缓存各种JS、CSS和图像资源。

When we were using the simple approach of registering our ServiceWorker immediately, we could see the network contention happening within the browser, maxing out our parallel request limit (below left).

Notice how when registering your service worker immediately, it can block all other network requests (left). Deferring the service worker (right) allows your initial page load to make required network requests without getting blocked by the concurrent connection limit of the browser. Click or tap to zoom.

By delaying the ServiceWorker registration until we’ve finished loading extra API requests, CSS and image assets, we allow the page to finish rendering and be responsive, as illustrated in the after screenshot (above right).

Overall, this is a list of just some of the many improvements that we’ve made over time to Twitter Lite. There are certainly more to come and we hope to continue sharing the problems we find and the ways that we work to overcome them. For more about what’s going on in real-time and more React & PWA insights, follow me and the team on Twitter.
总的来说,这是我们随着时间推移对Twitter Lite进行的一些改进的列表。肯定还有更多的改进即将到来,我们希望继续分享我们发现的问题以及我们努力克服问题的方法。想要了解更多关于实时情况和React&PWA的见解,请在Twitter上关注我和团队。

Recommended from Medium 推荐自Medium

Lists 列表

See more recommendations