写点什么

Pinterest PWA 性能的案例研究

2018 年 1 月 16 日


可在手机上登录 https://pinterest.com 去体验下 Pinterest 新的移动端网站

为什么 Pinterest 会选择用 PWA?简单回顾下相关的历史

在最开始的时候,因为专注于国际市场的增长,Pinterest 关注了移动端网页的开发,也由此有了 Pinterest PWA。

在分析了未经验证的移动端网页用户的相关数据后,Pinterest 发现他们原来旧而慢的网络体验仅能将 1% 的用户转化为注册、登录或下载 app 作为本地应用使用的用户。如果能够提升这一转化率的话,无疑是一个巨大的机会,所以他们开始了对 PWA 的投资。

在一个季度内建立和推出 PWA

用时超过3 个月,Pinterest 通过使用 React、Redux 和 webpack 重构了他们移动端网页的体验。移动端网页的重写也提高了他们几项核心业务指标。

与旧移动端网页的体验相比,新移动端网页用户的使用时间增加了40%,用户生成的广告收益增加了44%,并且核心业务增长了60%

与此同时,移动端网页的重写也改善了Pinterest 网页的一些性能。

Pinterest PWA 在 3G 普通移动硬件上的加载速度很快

Pinterest 旧的移动端网页含有大量的需要占用很多 CPU 的 JavaScript 包,延长了 Pin 网页加载和取得互动所需的时间

在可以进行任何互动之前,用户经常需要等 23 秒


Pinterest 原有的移动端网站需要花费 23s 取得互动。这一过程中,他们会发送 2.5MB 以上的 JavaScript,其中约有 1.5MB 用于主包,1MB 用于懒加载。在主线程最终能够实现交互之前,需要花费几秒钟的时间来解析和编译

他们新移动端网页的体验有了极大的提高。

不仅是因为他们分散和减少了数百 KB 的 JavaScript,将核心包体的大小从 650KB 降到了 150KB,也是因为他们提高了网页的一些关键性能指标。首次有效绘制时间由 4.2s 降低到了 1.8s,并且可交互时间由 23s 降低到了 5.6s。

以上的测试结果是在连接了缓慢 3G 网络的普通 Android 硬件上得到的。在重复访问的情况下,结果甚至更好。

得益于 服务工作线程缓存了主要的 JavaScript、CSS 和静态 UI 资源,重复访问的时间被缩短到了 3.9s:

尽管 Pinterest 有 iOS 和 Android 应用,但是只需在开始时下载约为 150KB 优化压缩(minified & gzipped)过的代码,就能够在网页应用上实现与本地应用相同的主页推送体验。对比于 Android 版应用的 9.6MB 和 iOS 版应用的 56MB:

然而值得注意的是,与本地应用相比 Pinterest PWA 的优点并不局限于前期主页推送体验。PWA 还会按新路由的需要来加载代码,而且额外代码的成本会被分摊到使用网页应用的整个过程中。随后的导航仍然不会像下载应用那样消耗大量的数据。


Pinterest 的 PWA 分别在移动端的 Firefox、Edge 和 Safari 上的显示

基于路由的 JavaScript 分块(chunking)

在前期仅加载用户需要的代码降低了网络传输和解析 / 编译 JavaScript的时间,从而提高了网页的加载速度和缩短了实现交互的时间。随后非关键资源可以根据需要进行懒加载。

Pinterest 开始将原有的高达几个 MB 的 JavaScript 包拆分成 3 种不同类型的 webpack 模块,效果还挺不错:

  • 一类是包含外部依赖性的vendor模块(react、redux、react-router 等),大约 73KB
  • 一类是包含渲染应用所需要的大部分代码的入口模块(entry chunk)(即常见的库,主要的页面外壳,我们的 redux store),大约 72KB
  • 一类是包含关于单个路由的代码的异步路由模块(async route chunk),大约 13 到 18KB

以下 Network 的瀑布记录,突出显示了渐进式地按需传送代码如何避免了整体(monolithic)传送包体的需求:


(对于长期缓存,Pinterest 也在每个文件名中包含了一个模块相关 (chunk-specific) 的哈希,通过 chunkhash 替换

Pinterest 用了 webpack 的 CommonsChunkPlugin 插件来将他们的 vendor 包体拆分到可缓存的模块内:

复制代码
const bundles = {
'vendor-mweb': [
'app/mobile/polyfills.js',
'intl',
'normalizr',
'react-dom',
'react-redux',
'react-router-dom',
'react',
'redux'
],
'entryChunk-webpack': 'app/mobile/runtime.js',
'entryChunk-mobile': 'app/mobile/index.js'
};
const chunkPlugins = [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor-mweb',
minChunks: Infinity,
chunks: ['entryChunk-mobile']
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'entryChunk-webpack',
minChunks: Infinity,
chunks: ['vendor-mweb']
}),
new webpack.optimize.CommonsChunkPlugin({
children: true,
name: 'entryChunk-mobile',
minChunks: (module, count) => {
return module.resource && (isCommonLib(resource) || count >= 3);
}
})
];

(原代码见 sample-webpack.js hosted with ❤ by GitHub )

在分块的过程中,他们也用了 React Router 来实现代码拆分

复制代码
// Create a loader
const Closeup = () => import(/* webpackChunkName: "CloseupPage" */ 'app/mobile/routes/CloseupPage');
// Register it to the route
route('/pin/:pinId', routes.Closeup, { name: 'Closeup' }),
// Render a react-router-v4 Route with the route bundle loader
<Route exact key="matched-route" path={path} render={matchProps =>
<PageRoute
bundleLoader={loader}
routeName={name}
{...matchProps}
{...props}
/>}
/>
// Async load the route bundle
class PageRoute extends PureComponent {
render() {
const { bundleLoader, ...props } = this.props;
return <Loader loader={bundleLoader} {...props} />;
}
}
// Load it and render
class Loader extends PureComponent {
componentWillMount() {
this.props.loader().then(module => {
this.setState({ LoadedComponent: module.default });
});
}
}

原代码见 sample-codesplitting.js hosted with ❤ by GitHub

用 babel-preset-env 来只编译(transpile)目标浏览器所需的内容

Pinterest 用了 Babel 的 babel-preset-env 来仅编译(transpile)不受目标浏览器支持的 ES2015+ 功能。Pinterest 针对的是现代浏览器最新的两个版本,他们的.babelrc 设置类似于:

复制代码
{
"presets": [
["env", {
"targets": {
"browsers": ["last 2 versions"]
}
}]
]
}

(原代码见:.babelrc hosted with ❤ by GitHub )

其实 Pinterest 也可以对此作进一步的优化,按照实际需要有条件地提供 polyfills(比如:Safari国际化的API )。但是目前这还是这一优化仍在计划中。

使用Webpack Bundle Analyzer 来分析改进空间

Webpack Bundle Analyzer 是一个很好的工具,可以帮助人切实地理解传送给客户的 JavaScript 包之间的依赖关系。

如下图所示,在早期的 Pinterest 版本的输出中,有很多的紫色,粉色和蓝色的区域。这些都是被懒加载的路由异步模块。Webpack Bundle Analyzer 可以帮助 Pinterest 将大多数的含有重复代码的模块可视化:

Webpack Bundle Analyzer 可以将重复代码在不同模块之间的大小比例视觉化。

在有了所有模块中有重复代码的信息之后,Pinterest 就可以做出调用。他们把异步模块中的重复代码移到了主要模块中。虽然这一改动增加了 20% 入口模块的大小,但是却将所有懒加载模块的大小减小了 90%!

图像优化

大部分Pinterest PWA 中内容的懒加载都是通过无限网格瀑布流插件 Masonry 来处理的。它内置了对虚拟化的支持,并且仅装载(mounting)视口内的子项。

Pinterest 也在他们的 PWA 中使用了渐进式加载图片的技术。有主导颜色的占位符在最开始会被用于每一个 Pin。而 Pin 的图像会以 Progressive JPEGs 来提供,其质量会随着扫描次数的增加而增加:

React 性能的痛点

在 Pinterest 使用网格瀑布流 Masonry 插件的同时,他们也面临着 React 带来的一些渲染性能的问题。装载和卸载大的组件树(像 Pin)可能会很慢。一个 Pin 里面有很多的东西:

尽管当时他们写Pinterest 的时候用的是React 15.5.4, 但是他们寄希望于 React 16 (Fiber) 将会大大减少卸载所用的时间。与此同时,虚拟化的网格也会显著地减少组件卸载的时间。

Pinterest 还会限制 Pin 的插入,以便更快地测量 / 渲染第一个 Pin,但是这也意味着设备 CPU 的工作量更大了。

导航转换

为了提高感知性能,Pinterest 也更新了导航栏图标的选定状态,将其独立于路由之外。这就确保了当导航从一个路由转到另一个路由的时候,用户并不会因为网络的阻塞而感到缓慢。用户在等待数据到达时可以快速地获得可视化界面。

使用Redux 的体验

Pinterest 在他们所有的 API 数据中均使用了 normalizr (normalizr 会根据一种模式来规范化嵌套的 JSON)。从 Redux DevTools 就可以看出:

这样做的缺点是逆规范化(denormalization) 会变得很慢,在渲染的阶段最终他们很大程度上是依赖于 reselect 的 selector 模式来记忆(memoizing)逆规范化。他们也尽可能的在最低程度上进行逆规范处理,以确保单个的更新不会导致大规模的重新渲染。

举个例子来说,他们的网格项目列表只是由 Pin ID 与逆规范化自身的 Pin 组件组成的。如果任何给定的 Pin 有了改变,则完整的网格不必重新渲染。但是有得就有失,这样 Pinterest PWA 就有了很多 Redux 用户,虽然这一点尚未对性能产生显著的影响。

用 Service Worker 来缓存资源

Pinterest 用了 Workbox 库来生成和管理他们的 Service worker:

复制代码
/* global $VERSION, $Cache, importScripts, WorkboxSW */
importScripts('https://unpkg.com/workbox-sw@1.1.0/build/importScripts/workbox-sw.prod.v1.1.0.js');
// Add app shell to the webpack-generated precache list
$Cache.precache.push({ url: 'sw-shell.html', revision: $VERSION });
// Register precache list with Workbox
const workbox = new WorkboxSW({ handleFetch: true, skipWaiting: true, clientClaim: true });
workbox.precache($Cache.precache);
// Runtime cache all js
workbox.router.registerRoute(/webapp\/js\/.*\.js/, workbox.strategies.cacheFirst());
// Prefer app-shell for full-page loads
workbox.router.registerNavigationRoute('sw-shell.html', {
blacklist: [
// bunch of non-app routes
],
});

(原代码见: sample-sw-caching.js hosted with ❤ by GitHub)

如今,Pinterest 使用缓存优先策略(cache-first strategy)来缓存任何 JavaScript 或者 CSS 的包,并且也会缓存其用户的界面(应用程序的外壳)。


在缓存资源优先的设置中,如果请求与缓存条目相匹配,则以缓存的资源为准。否则,则尝试从网络获取资源。如果网络请求成功,则对缓存进行更新。要了解更多有关使用 Service Worker 的缓存策略,请阅读 Jake Archibald 的 Offline Cookbook

他们也为应用程序外壳(webpack 运行时,vendor 和 entry 模块)加载的初始包定义了预缓存。

因为 Pinterest 是一个具有全球影响力的网站,能够支持多种语言,所以他们还会生成适用于每个语言区域的 Service Worker 配置,以便其预缓存不同语言区域的软件包。Pinterest 也使用了 webpack 的命名模块来预缓存顶级(top-level)异步路由包。

这项工作是在几个较小的迭代中逐步推出完成的。

  • 第一步:Pinterest 的 Service Worker 仅缓存运行时需要懒加载的脚本。充分利用 V8 的代码缓存,跳过了一些在重复视图解析 / 编译所需的成本,使得加载能够快速的进行。从有 Service Worker 存在的 Cache Storage 获得的脚本能够很快地进行代码缓存,因为浏览器很可能知道当重复访问时用户最终会重复使用这些资源。

  • 在这之后,Pinterest 推进到预缓存其 vendor 和入口模块
  • 接下来,Pinterest 开始预缓存一些使用最多的路由(比如主页,锁定收藏的网页,搜索页等)
  • 最后,他们开始为每个地域生成一个 Service Worker,这样的话就能够缓存不同地域的语言包。这不仅是为了保证重复加载的性能,也是为了保证绝大多数的用户可以享受基本的离线渲染功能。
复制代码
/* Create a service worker for every locale to precache the locale bundle */
const ServiceWorkerConfigs = locales.reduce((configs, locale) => {
return Object.assign(configs, {
[`mobile-${locale}`]: Object.assign({}, BaseConfig, {
template: path.join(__dirname, 'swTemplates/mobileBase.js'),
cache: {
template: path.join(__dirname, 'swTemplates/mobileCache.js'),
precache: [
'vendor-mweb-.*\\.js$',
'entryChunk-mobile-.*\\.js$',
'entryChunk-webpack-.*\\.js$',
`locale-${locale}-mobile.*js$`,
'pjs-HomePage.*\\.js$',
'pjs-SearchPage.*\\.js$',
'pjs-CloseupPage.*\\.js$'
]
}
})
});
}, {});
// Add to webpack
plugins: [
new ServiceWorkerPlugin(BaseConfig, ServiceWorkerConfigs);
]

原代码见: sample-sw-generation.js hosted with ❤ by GitHub

应用外壳的挑战

Pinterest 发现实施他们应用的外壳有些难。因为桌面时代(desktop-era)会假定多少数据能够通过有线连接发送出去,而其应用外壳的初始有效负载量很大包含有很多无关紧要的信息,比如用户的测试组,用户信息,上下文信息等。

他们不得不问自己:“我们是否应该把这些内容缓存在应用程序的外壳中?或者选择在渲染任何内容之前忍受阻塞网络请求对性能的影响。”

最终,他们选择这些内容缓存到应用外壳中,这就需要对什么时候应该让应用外壳失效(注销、从设置更新用户信息等)进行一定的管理。每一个请求的响应有一个‘appVersion’,如果应用程序的版本发生了变化,他们会先取消注册Service Worker,转而注册新的请求,然后在下一次路由更改时重新加载整个页面。

用Lighthouse 进行审查

Pinterest 用了 Lighthouse 对其性能的提升进行一次性的验证,以确保相关性能改进的方向是正确的。观察类似于持续互动时间这类的指标是很有用的。

下一年,他们希望用Lighthouse 作为回归机制(regression mechanism)来验证页面的加载速度是否仍然快速。

未来

Pinterest 刚刚部署了对 web 推送通知的支持,并且也在致力于提高未经身份验证(注销)时的用户体验。

他们有兴趣探索对于< link rel = preload > 的支持,用其来预加载关键包和减少在首次加载时传送给用户的无用 JavaScript。请继续期待他们未来更好的用户体验!

在此祝贺 Pinterest 的 Zack Argyle、YenWei Liu、 Luna Ruan、Victoria Kwong、 Imad Elyafi、 Langtian Lang、Becky Stoneman 和 Ben Finkel 推出了他们的 Progressive Web App ,也感谢他们对于本文的贡献。也感谢 Jeffrey Posnick 和 Zouhir 对本文的审读。

原文链接: A Pinterest Progressive Web App Performance Case Study

感谢徐川对本文的审校。

2018 年 1 月 16 日 17:291543

评论

发布
暂无评论
发现更多内容

架构师0期作业-20200606

caibird1984

极客大学架构师训练营

快速复制文件rsync、tar

唯爱

String五连杀!

Bruce Duan

Java string equals

PostgreSQL权限控制

唯爱

UML 练习 食堂就餐卡

Mr.hou

极客大学架构师训练营

怎样才能像月「睡后收入」 20 万的独立开发者一样挣钱?

非著名程序员

程序员 独立开发者 程序人生 提升认知

ARTS-第一周

爱睡的猫

架构师训练营第一周总结

Linuxer

极客大学架构师训练营

就餐卡系统设计

hiqian

极客大学架构师训练营

作业二:根据当周学习情况,完成一篇学习总结

LN

Char和编码

拾贝

Java

S型曲线 - 第二曲线

石云升

创新 增长 S型理论 第二曲线 破坏式创新

作业一:食堂就餐卡系统设计

LN

架构师训练营第一周总结

极客大学架构师训练营

【架构师训练营】第 1 周作业1—食堂就餐卡系统设计

花生无翼

极客大学架构师训练营

架构师训练营第一周总结

一剑

食堂就餐卡系统设计

戴维斯

食堂就餐卡系统设计

TiK

极客时间 - 架构师训练营 - week1 - 课堂笔记

毛聪

极客大学架构师训练营

架构师训练营 - 第一周 - 学习总结

韩挺

架构师训练营 - 第一周 - 食堂就餐卡系统设计

韩挺

源码分析 | 手写mybait-spring核心功能(干货好文一次学会工厂bean、类代理、bean注册的使用)

小傅哥

spring 源码 源码分析 小傅哥 mybatis

游戏夜读 | vim一份极简手册

game1night

数据结构与算法之数组链表

shirley

数组 链表

标题

lai

第一周学习总结:架构方法

晓雷

深圳各大知名办公园区引进 GoWork 智能楼宇管理系统,开启商业地产行业的春天

Geek_116789

「架构师训练营」第 1 周作业 - 食堂就餐卡系统设计

旭东(Frank)

架构 极客大学架构师训练营 作业

Week1 总结

TiK

极客大学架构师训练营

食堂就餐卡系统架构设计文档

竹森先生

极客大学 架构设计 极客大学架构师训练营

架构师训练营 作业 -- 第一周

傅晶

极客大学架构师训练营

演讲经验交流会|ArchSummit 上海站

演讲经验交流会|ArchSummit 上海站

Pinterest PWA性能的案例研究-InfoQ