微信号:frontshow

介绍:关注前端发展,分享一线技术.不断学习,不断进步,登上前端之巅!

Pinterest的PWA实践

2018-01-04 22:58 易文英 译
Pinterest 在他们新的移动端网页体验中使用了 PWA。在这篇文章中,作者对 Pinterest 是如何通过保持 Javasrcipt 包的精简和通过 Service Workers 保持网络弹性来确保该网站在移动设备上的高速加载进行一些讲解。本文最初发布于 Medium,经原作者 Addy Osmani 授权由 InfoQ 中文站翻译并分享。

(可在手机上登录 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 硬件上得到的。在重复访问的情况下,结果甚至更好。

得益于 Service Worker 缓存了主要的 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);
    }
  })
];

在分块的过程中,他们也用了 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 });
    });
  }
}


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


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

{
  "presets": [
    ["env", {
      "targets": {
        "browsers": ["last 2 versions"]
      }
    }]
  ]
}

其实 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
  ],
});

如今,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);
]


应用外壳 (Application Shell) 的挑战


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 对本文的审读。

原文链接:

https://medium.com/dev-channel/a-pinterest-progressive-web-app-performance-case-study-3bd6ed2e6154


前端之巅


「前端之巅」是 InfoQ 旗下关注前端技术的垂直社群,加入前端之巅学习群请关注「前端之巅」公众号后回复 “ 加群 ”。投稿请发邮件到 editors@cn.infoq.com,注明 “ 前端之巅投稿 ”。

活动推荐:

随着手机配置越来越高,移动浏览器的功能越来越强大,前端可做的事情越来越多,想象力和空间越来越大。QCon 北京 2018,与淘宝高级技术专家寒冬、新浪微博技术专家聂永、百度资深前端工程师彭星等技术大咖探索前端技术实践,以及实践中的思考和经验参考。目前大会 8 折报名中,立减 1360 元。有任何问题可咨询购票经理 Hanna,电话:15110019061,微信:qcon-0410。


 
前端之巅 更多文章 2017 JavaScript生态圈调查报告 现在的JavaScript框架教程 利用Webpack插件进行前端code-splitting 前端每周清单: Safari支持Service Worker, 2017 前端大事件 浏览器user-agent简史
猜您喜欢 顶级项目孵化的故事系列——Kylin的心路历程 一周 Go World 新鲜事[Asta 优选] QQ空间直播秒开优化实践 文末送书 | 自定义View之添加银行卡动画 丝滑的页面切换 老池老友记