Pinterest PWA性能的案例研究
HerB28
7年前
<p><img src="https://simg.open-open.com/show/93fc6a554122fb50cce566418b528acb.png" alt="Pinterest PWA性能的案例研究" width="1000" height="562"></p> <p>( <em> 可在手机上登录 <a href="/misc/goto?guid=4959756482334337585" rel="nofollow,noindex">https://pinterest.com</a> 去体验下Pinterest新的移动端网站 </em> )</p> <h2>为什么Pinterest会选择用PWA?简单回顾下相关的历史</h2> <p>在最开始的时候,因为专注于国际市场的增长,Pinterest关注了移动端网页的开发,也由此有了Pinterest PWA。</p> <p>在分析了未经验证的移动端网页用户的相关数据后,Pinterest发现他们原来旧而慢的网络体验仅能将1%的用户转化为注册、登录或下载app作为本地应用使用的用户。如果能够提升这一转化率的话,无疑是一个巨大的机会,所以他们开始了对PWA的投资。</p> <h3>在一个季度内建立和推出PWA</h3> <p>用时超过 <strong>3个月</strong> ,Pinterest通过使用React、Redux和webpack重构了他们移动端网页的体验。移动端网页的重写也提高了他们几项核心业务指标。</p> <p>与旧移动端网页的体验相比,新移动端网页用户的使用时间增加了 <strong>40%</strong> ,用户生成的广告收益增加了 <strong>44%</strong> ,并且核心业务增长了 <strong>60%</strong> 。</p> <p><img src="https://simg.open-open.com/show/6f7ef48524c082e1adbe81fcb4ea7d43.png" alt="Pinterest PWA性能的案例研究" width="1000" height="562"></p> <p>与此同时,移动端网页的重写也改善了Pinterest网页的一些性能。</p> <h2>Pinterest PWA在3G普通移动硬件上的加载速度很快</h2> <p>Pinterest旧的移动端网页含有大量的需要占用很多CPU的JavaScript包,延长了Pin网页加载和取得互动 <a href="/misc/goto?guid=4959756482423509101" rel="nofollow,noindex">所需的时间</a> 。</p> <p>在可以进行任何互动之前,用户经常需要等 <strong>23秒</strong> :</p> <p><img src="https://simg.open-open.com/show/ef17cf9f8a1850b54a919452ab839f5e.png" alt="Pinterest PWA性能的案例研究" width="1000" height="647"></p> <p>( <em>Pinterest原有的移动端网站需要花费23s取得互动。这一过程中,他们会发送2.5MB以上的JavaScript,其中约有1.5MB用于主包,1MB用于懒加载。在主线程最终能够实现交互之前,需要花费几秒钟的时间来解析和编译</em> )</p> <p>他们新移动端网页的体验有了极大的提高。</p> <p>不仅是因为他们分散和减少了数百KB的JavaScript,将核心包体的大小从650KB降到了150KB,也是因为他们提高了网页的一些关键性能指标。 <a href="/misc/goto?guid=4959756482507952425" rel="nofollow,noindex">首次有效绘制</a> 时间由4.2s降低到了1.8s,并且 <a href="/misc/goto?guid=4959756482587615210" rel="nofollow,noindex">可交互时间</a> 由23s降低到了5.6s。</p> <p><img src="https://simg.open-open.com/show/052081a3e67137eeac7395a603c09632.png" alt="Pinterest PWA性能的案例研究" width="1000" height="631"></p> <p>以上的测试结果是在连接了缓慢3G网络的普通Android硬件上得到的。在重复访问的情况下,结果甚至更好。</p> <p>得益于 服务工作线程缓存了主要的JavaScript、CSS和静态UI资源,重复访问的时间被缩短到了3.9s:</p> <p><img src="https://simg.open-open.com/show/15b92a00733f937d8c34341f0627c342.png" alt="Pinterest PWA性能的案例研究" width="1000" height="631"></p> <p>尽管Pinterest有iOS和Android应用,但是只需在开始时下载约为150KB优化压缩(minified & gzipped)过的代码,就能够在网页应用上实现与本地应用相同的主页推送体验。对比于Android版应用的9.6MB和iOS版应用的56MB:</p> <p><img src="https://simg.open-open.com/show/2ca4ff71c46201d8e1570fc5cb641a2d.png" alt="Pinterest PWA性能的案例研究" width="1000" height="562"></p> <p>然而值得注意的是,与本地应用相比Pinterest PWA的优点并不局限于前期主页推送体验。PWA还会按新路由的需要来加载代码,而且额外代码的成本会被分摊到使用网页应用的整个过程中。随后的导航仍然不会像下载应用那样消耗大量的数据。</p> <p><img src="https://simg.open-open.com/show/989695d303b7e8694199e3bd2e167e41.png" alt="Pinterest PWA性能的案例研究" width="800" height="470"></p> <p>( <em>Pinterest的PWA分别在移动端的Firefox、Edge和Safari上的显示</em> )</p> <h2>基于路由的JavaScript分块(chunking)</h2> <p>在前期 <strong>仅加载用户需要的代码</strong> 降低了 <strong>网络传输和解析/编译JavaScript</strong> 的时间,从而提高了网页的加载速度和缩短了实现交互的时间。随后非关键资源可以根据需要进行懒加载。</p> <p>Pinterest开始将原有的高达几个MB的JavaScript包拆分成3种不同类型的webpack模块,效果还挺不错:</p> <p><img src="https://simg.open-open.com/show/47c028d8a5b3c177018bd7ffb4051061.png" alt="Pinterest PWA性能的案例研究" width="1000" height="373"></p> <ul> <li>一类是包含外部依赖性的 <strong>vendor</strong> 模块(react、redux、react-router等),大约73KB</li> <li>一类是包含渲染应用所需要的大部分代码的 <strong>入口</strong> 模块(entry chunk)(即常见的库,主要的页面外壳,我们的redux store),大约72KB</li> <li>一类是包含关于单个路由的代码的 <strong>异步</strong> 路由模块(async route chunk),大约13到18KB</li> </ul> <p>以下Network的瀑布记录,突出显示了渐进式地按需传送代码如何避免了整体(monolithic)传送包体的需求:</p> <p><img src="https://simg.open-open.com/show/46cbb8f16a4ad200e9d54982c0bfddac.png" alt="Pinterest PWA性能的案例研究" width="800" height="227"></p> <p>( <em>对于长期缓存,Pinterest也在每个文件名中包含了一个模块相关(chunk-specific)的哈希,通过chunkhash替换</em> )</p> <p>Pinterest用了webpack的 <a href="/misc/goto?guid=4959749507439025172" rel="nofollow,noindex">CommonsChunkPlugin</a> 插件来将他们的vendor包体拆分到可缓存的模块内:</p> <pre> <code class="language-javascript">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); } }) ];</code></pre> <p>( <em> 原代码见 <a href="/misc/goto?guid=4959756482696175891" rel="nofollow,noindex">sample-webpack.js</a> hosted with ❤ by <a href="/misc/goto?guid=4958184186834948584" rel="nofollow,noindex">GitHub</a> </em> )</p> <p>在分块的过程中,他们也用了 <a href="/misc/goto?guid=4959729725361071324" rel="nofollow,noindex">React Router</a> 来实现 <a href="/misc/goto?guid=4959756482846875706" rel="nofollow,noindex">代码拆分</a> :</p> <pre> <code class="language-javascript">// 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 }); }); } }</code></pre> <p>( <em> 原代码见 <a href="/misc/goto?guid=4959756482946795317" rel="nofollow,noindex">sample-codesplitting.js</a> hosted with ❤ by <a href="/misc/goto?guid=4958184186834948584" rel="nofollow,noindex">GitHub</a> </em> )</p> <h2>用babel-preset-env来只编译(transpile)目标浏览器所需的内容</h2> <p>Pinterest用了Babel的 <a href="/misc/goto?guid=4959756483032155433" rel="nofollow,noindex">babel-preset-env</a> 来仅编译(transpile)不受目标浏览器支持的ES2015+功能。Pinterest针对的是现代浏览器最新的两个版本,他们的.babelrc设置类似于:</p> <pre> <code class="language-javascript">{ "presets": [ ["env", { "targets": { "browsers": ["last 2 versions"] } }] ] }</code></pre> <p>( <em> 原代码见: <a href="/misc/goto?guid=4959756483117354930" rel="nofollow,noindex">.babelrc</a> hosted with ❤ by <a href="/misc/goto?guid=4958184186834948584" rel="nofollow,noindex">GitHub</a> </em> )</p> <p>其实Pinterest也可以对此作进一步的优化,按照实际需要有条件地提供polyfills(比如:Safari <a href="/misc/goto?guid=4958864220544422016" rel="nofollow,noindex">国际化的API</a> )。但是目前这还是这一优化仍在计划中。</p> <h3>使用Webpack Bundle Analyzer来分析改进空间</h3> <p><a href="/misc/goto?guid=4959756206237902848" rel="nofollow,noindex">Webpack Bundle Analyzer</a> 是一个很好的工具,可以帮助人切实地理解传送给客户的JavaScript包之间的依赖关系。</p> <p>如下图所示,在早期的Pinterest版本的输出中,有很多的紫色,粉色和蓝色的区域。这些都是被懒加载的路由 <strong>异步</strong> 模块。Webpack Bundle Analyzer可以帮助Pinterest将大多数的含有 <strong>重复代码</strong> 的模块可视化:</p> <p><img src="https://simg.open-open.com/show/edf3b7ba6325bdcc5ef76c8a74cbfa20.png" alt="Pinterest PWA性能的案例研究" width="1000" height="562"></p> <p>Webpack Bundle Analyzer可以将重复代码在不同模块之间的大小比例视觉化。</p> <p>在有了所有模块中有重复代码的信息之后,Pinterest就可以做出调用。 他们把异步模块中的重复代码移到了主要模块中。虽然这一改动增加了20%入口模块的大小,但是却将所有懒加载模块的大小减小了90%!</p> <p><img src="https://simg.open-open.com/show/498cc7e3e66482509da7f41de0d55e9d.png" alt="Pinterest PWA性能的案例研究" width="1000" height="562"></p> <h2>图像优化</h2> <p>大部分Pinterest PWA中内容的懒加载都是通过无限网格瀑布流插件 <a href="/misc/goto?guid=4959756483272842482" rel="nofollow,noindex">Masonry</a> 来处理的。它内置了对虚拟化的支持,并且仅装载(mounting)视口内的子项。</p> <p><img src="https://simg.open-open.com/show/8a12b12106c1df573b2bc766187d1ed9.png" alt="Pinterest PWA性能的案例研究" width="1000" height="456"></p> <p>Pinterest也在他们的PWA中使用了渐进式加载图片的技术。有主导颜色的占位符在最开始会被用于每一个Pin。而Pin的图像会以 <a href="/misc/goto?guid=4959756483350211501" rel="nofollow,noindex">Progressive JPEGs</a> 来提供,其质量会随着扫描次数的增加而增加:</p> <p><img src="https://simg.open-open.com/show/6645e7802586cee941e5a3123d4c44f2.png" alt="Pinterest PWA性能的案例研究" width="1000" height="400"></p> <h2>React性能的痛点</h2> <p>在Pinterest使用网格瀑布流 <a href="/misc/goto?guid=4959756483272842482" rel="nofollow,noindex">Masonry</a> 插件的同时,他们也面临着React带来的一些渲染性能的问题。装载和卸载大的组件树(像Pin)可能会很慢。一个Pin里面有很多的东西:</p> <p><img src="https://simg.open-open.com/show/50fc0a1ee18d4c3c9075daae900e00ca.png" alt="Pinterest PWA性能的案例研究" width="1000" height="541"></p> <p>尽管当时他们写Pinterest的时候用的是React 15.5.4, 但是他们寄希望于 <a href="/misc/goto?guid=4959756483445686562" rel="nofollow,noindex">React 16</a> (Fiber)将会大大减少卸载所用的时间。与此同时, <strong>虚拟化的网格</strong> 也会显著地减少组件卸载的时间。</p> <p>Pinterest还会限制Pin的插入,以便更快地测量/渲染第一个Pin,但是这也意味着设备CPU的工作量更大了。</p> <h2>导航转换</h2> <p>为了提高感知性能,Pinterest也更新了导航栏图标的选定状态,将其独立于路由之外。这就确保了当导航从一个路由转到另一个路由的时候,用户并不会因为网络的阻塞而感到缓慢。用户在等待数据到达时可以快速地获得可视化界面。</p> <p><img src="https://simg.open-open.com/show/e6628f58603102a5d50a67484477a653.png" alt="Pinterest PWA性能的案例研究" width="1000" height="553"></p> <h2>使用Redux的体验</h2> <p>Pinterest在他们所有的API数据中均使用了 <a href="/misc/goto?guid=4959754510265256670" rel="nofollow,noindex">normalizr</a> (normalizr会根据一种模式来规范化嵌套的JSON)。从Redux DevTools就可以看出:</p> <p><img src="https://simg.open-open.com/show/d7c21e0bf5c76208fdf6ec8bde3c62e3.png" alt="Pinterest PWA性能的案例研究" width="800" height="475"></p> <p>这样做的缺点是逆规范化(denormalization)会变得很慢,在渲染的阶段最终他们很大程度上是依赖于 <a href="/misc/goto?guid=4959756483555699151" rel="nofollow,noindex">reselect</a> 的selector模式来记忆(memoizing)逆规范化。他们也尽可能的在最低程度上进行逆规范处理,以确保单个的更新不会导致大规模的重新渲染。</p> <p>举个例子来说,他们的网格项目列表只是由Pin ID与逆规范化自身的Pin组件组成的。如果任何给定的Pin有了改变,则完整的网格不必重新渲染。但是有得就有失,这样Pinterest PWA就有了很多Redux用户,虽然这一点尚未对性能产生显著的影响。</p> <h2>用Service Worker来缓存资源</h2> <p>Pinterest用了Workbox库来生成和管理他们的Service worker:</p> <pre> <code class="language-javascript">/* 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 ], });</code></pre> <p>( <em> 原代码见: <a href="/misc/goto?guid=4959756483643937078" rel="nofollow,noindex">sample-sw-caching.js</a> hosted with ❤ by <a href="/misc/goto?guid=4958184186834948584" rel="nofollow,noindex">GitHub)</a> </em> )</p> <p>如今,Pinterest使用缓存优先策略(cache-first strategy)来缓存任何JavaScript或者CSS的包,并且也会缓存其用户的界面(应用程序的外壳)。</p> <p><img src="https://simg.open-open.com/show/9a94bc96da4f7318b1a7f3ae1a149603.png" alt="Pinterest PWA性能的案例研究" width="800" height="401"></p> <p>( <em> 在缓存资源优先的设置中,如果请求与缓存条目相匹配,则以缓存的资源为准。否则,则尝试从网络获取资源。如果网络请求成功,则对缓存进行更新。要了解更多有关使用Service Worker的缓存策略,请阅读 <a href="/misc/goto?guid=4959756483742931866" rel="nofollow,noindex">Jake Archibald的Offline Cookbook</a> 。 </em> )</p> <p>他们也为应用程序外壳(webpack运行时,vendor和entry模块)加载的初始包定义了预缓存。</p> <p>因为Pinterest是一个具有全球影响力的网站,能够支持多种语言,所以他们还会生成 <strong>适用于每个语言区域的Service Worker配置</strong> ,以便其预缓存不同语言区域的软件包。Pinterest也使用了webpack的命名模块来预缓存顶级(top-level)异步路由包。</p> <p>这项工作是在几个较小的迭代中逐步推出完成的。</p> <ul> <li>第一步:Pinterest的Service Worker仅 <strong>缓存运行时需要懒加载的脚本</strong> 。充分利用 <a href="/misc/goto?guid=4959756206403154158" rel="nofollow,noindex">V8的代码缓存</a> ,跳过了一些在重复视图解析/编译所需的成本,使得加载能够快速的进行。从有Service Worker存在的Cache Storage获得的脚本能够很快地进行代码缓存,因为浏览器很可能知道当重复访问时用户最终会重复使用这些资源。</li> </ul> <p><img src="https://simg.open-open.com/show/932400e6bc77c7262b45a31625bd58ce.png" alt="Pinterest PWA性能的案例研究" width="1000" height="322"></p> <ul> <li>在这之后,Pinterest推进到 <strong>预缓存其vendor和入口模块</strong> 。</li> <li>接下来,Pinterest开始 <strong>预缓存一些使用最多的路由</strong> (比如主页,锁定收藏的网页,搜索页等)</li> <li>最后,他们开始为每个地域生成一个Service Worker,这样的话就能够缓存不同地域的语言包。这不仅是为了保证重复加载的性能,也是为了保证绝大多数的用户可以享受基本的离线渲染功能。</li> </ul> <pre> <code class="language-javascript">/* 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); ]</code></pre> <p>( <em> 原代码见: <a href="/misc/goto?guid=4959756483846747968" rel="nofollow,noindex">sample-sw-generation.js</a> hosted with ❤ by <a href="/misc/goto?guid=4958184186834948584" rel="nofollow,noindex">GitHub</a> </em> )</p> <h3>应用外壳的挑战</h3> <p>Pinterest发现实施他们应用的外壳有些难。因为桌面时代(desktop-era)会假定多少数据能够通过有线连接发送出去,而其应用外壳的初始有效负载量很大包含有很多无关紧要的信息,比如用户的测试组,用户信息,上下文信息等。</p> <p>他们不得不问自己:“我们是否应该把这些内容缓存在应用程序的外壳中?或者选择在渲染任何内容之前忍受阻塞网络请求对性能的影响。”</p> <p><img src="https://simg.open-open.com/show/3af9783136e813c3aa44800a42c4a902.png" alt="Pinterest PWA性能的案例研究" width="1000" height="562"></p> <p>最终,他们选择这些内容缓存到应用外壳中,这就需要对什么时候应该让应用外壳失效(注销、从设置更新用户信息等)进行一定的管理。每一个请求的响应有一个‘appVersion’,如果应用程序的版本发生了变化,他们会先取消注册Service Worker,转而注册新的请求,然后在下一次路由更改时重新加载整个页面。</p> <h2>用Lighthouse进行审查</h2> <p>Pinterest用了 <a href="/misc/goto?guid=4959756483951562213" rel="nofollow,noindex">Lighthouse</a> 对其性能的提升进行一次性的验证,以确保相关性能改进的方向是正确的。观察类似于持续互动时间这类的指标是很有用的。</p> <p><img src="https://simg.open-open.com/show/87207389e8490505dc921d6a162c1bf6.png" alt="Pinterest PWA性能的案例研究" width="1000" height="483"></p> <p>下一年,他们希望用Lighthouse作为回归机制(regression mechanism)来验证页面的加载速度是否仍然快速。</p> <h2>未来</h2> <p>Pinterest刚刚部署了对web推送通知的支持,并且也在致力于提高未经身份验证(注销)时的用户体验。</p> <p><img src="https://simg.open-open.com/show/e291eb0c21bfe2db24ed14c185e11c54.png" alt="Pinterest PWA性能的案例研究" width="1000" height="204"></p> <p>他们有兴趣探索对于< <a href="/misc/goto?guid=4959756484031950514" rel="nofollow,noindex">link rel = preload</a> >的支持,用其来预加载关键包和减少在首次加载时传送给用户的无用JavaScript。请继续期待他们未来更好的用户体验!</p> <p> </p> <p> </p> <p>来自:http://www.infoq.com/cn/articles/pinterest-progressive-web-app-performance-case-study</p> <p> </p>