BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

Pinterest PWA性能的案例研究

| 作者 Addy Osmani 关注 0 他的粉丝 ,译者 易文英 关注 0 他的粉丝 发布于 2018年1月17日. 估计阅读时间: 26 分钟 | QCon上海2018 关注大数据平台技术选型、搭建、系统迁移和优化的经验。


可在手机上登录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

感谢徐川对本文的审校。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT