一份来自Treebo 的 React 与 Preact PWA 性能分析报告

soryokurin 2年前
   <p><img src="https://simg.open-open.com/show/0d4013c77d207f2f7dbad03eb0287ecd.png"></p>    <p><a href="/misc/goto?guid=4959754789351568027" rel="nofollow,noindex">Treebo</a> 是一家印度家喻户晓的经济型连锁酒店,在旅游业中占据了价值200亿美元的市场。他们 <a href="/misc/goto?guid=4959754789445416591" rel="nofollow,noindex">最近</a> 开发了一个新的渐进式应用(PWA)作为默认的移动端体验,最开始使用 <a href="/misc/goto?guid=4959754789524334902" rel="nofollow,noindex">React</a> ,但最后在生产环境转向了 <a href="/misc/goto?guid=4959754789610508665" rel="nofollow,noindex">Preact</a> 。</p>    <p>对比之前的移动端可以看到,新版本 <strong> 在首屏渲染时间上提升了 70%, <a href="/misc/goto?guid=4959754789694499414" rel="nofollow,noindex">初始交互时间</a> 减少了 31% </strong> 。大部分用户在3G环境下使用自己的移动设备只需不到4s即可浏览完整内容。使用WebPageTest模拟印度超慢的3G网络也只需要不到5s。</p>    <p><img src="https://simg.open-open.com/show/920e04c065d4f8d7ea655614daac3314.png"></p>    <p>从React迁移到Preact也使初始交互时间缩短了15%。你可以打开 <a href="/misc/goto?guid=4959754789351568027" rel="nofollow,noindex">Treebo.com</a> 完整体验一下,但是今天我们想深入探讨分析这个PWA的过程中的一些技术实现。</p>    <p><img src="https://simg.open-open.com/show/d659633f7deb5d94d19b46f7c4f8911c.png"></p>    <p>这就是Treebo 新版的PWA</p>    <h3>性能优化之旅</h3>    <p>老版移动端</p>    <p>老版的Treebo移动端是基于Django框架搭建的。用户在跳转页面时必须等待服务端请求。这个版本的首屏渲染时间为1.5s,首屏完整渲染时间为5.9s,初始交互时间为6.5s。</p>    <p><img src="https://simg.open-open.com/show/dc2656ea1f7aafa3c42fdd38b085fac7.png"></p>    <p>基础的React单页应用</p>    <p>它们第一次迭代重构Treebo是用React和简单的 <a href="/misc/goto?guid=4958999009084926641" rel="nofollow,noindex">webpack</a> 来构建一个 <strong>单页应用</strong> 。</p>    <p>你可以看下之前写的代码。这导致生成了简单(巨大)的Javascript和CSS包(bundles)。</p>    <pre>  <code class="language-javascript">/* webpack.js */     entry: {       main: './client/index.js',   },   output: {       path: path.resolve('./build/client'),       filename: 'js/[name].[chunkhash:8].js',   },   module: {       rules: [           { test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },           { test: /\.css$/, loader: ExtractTextPlugin.extract({ fallback: ['style-loader'], use: ['css-loader'] }) },       ],   }   new ExtractTextPlugin('css/[name].[contenthash:8].css'),</code></pre>    <p>这次版本的首屏渲染时间为4.8s,初始交互时间大约5.6s,完整的首屏图片加载时间在7.2s。</p>    <p><img src="https://simg.open-open.com/show/1a7774c874dded8b9036f472660baa3b.png"></p>    <p>服务端渲染(SSR)</p>    <p>接着,他们着手优化首屏渲染时间,所以他们尝试了 <strong>服务端渲染</strong> 。 有一点值得注意,服务端渲染并不是没有副作用。它优化的同时也会消耗其他性能 。</p>    <p>使用 <a href="/misc/goto?guid=4959754789811730152" rel="nofollow,noindex">服务端渲染</a> ,你服务端给浏览器的返回就是你即将重绘页面的HTML,这样浏览器可以不需要等待所有Javascript加载和执行才能渲染页面。</p>    <p>Treebo使用React的 <a href="/misc/goto?guid=4959754789912881107" rel="nofollow,noindex">renderToString()</a> 将组件渲染为一段HTML字符串,并在应用初始化的时候注入state。</p>    <pre>  <code class="language-javascript">// reactMiddleware.js   const serverRenderedHtml = async (req, res, renderProps) => {       const store = configureStore();       //call, wait, and set api responses into redux store's state (ghub.io/redux-connect)       await loadOnServer({ ...renderProps, store });       //render the html template       const template = html(           renderToString(           <Provider store={store} key="provider">               <ReduxAsyncConnect {...renderProps} />           </Provider>,           ),           store.getState(),       );       res.send(template);   };   const html = (app, initialState) => `       <!doctype html>       <html lang="en">           <head>              <link rel="stylesheet" href="${assets.main.css}">           </head>       <body>           <div id="root">${app}</div>           `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`           `<script src="${assets.main.js}">`</script>       </body>       </html>   `;</code></pre>    <p>在Treebo的例子中,使用服务端渲染,首屏渲染时间减少到1.1s,首屏完整渲染时间减少到2.4s - 这提高了用户在页面加载速度的感知,他们可以更提前获取内容,而且在测试中显示在SEO也略微改善。但是缺点就是在初始交互时间有糟糕的影响。</p>    <p><img src="https://simg.open-open.com/show/46da310493c2a36d330d2dfa9739fcc0.png"></p>    <p>尽管用户可以看到网站内容,但是当初始化加载javascript时主线程被阻塞了,并且就堵在那里。</p>    <p>使用SSR,浏览器需要比之前请求处理更大的HTMl负载,并且接着请求,解析/编译,执行Javascript。虽然这样高效的做了更多工作。</p>    <p>但这意味着第一次交互时间需要6.6s,反而不如之前了。</p>    <p>SSR也可以通过锁定下游设备的主线程来缩短TTI。(译者注: <a href="/misc/goto?guid=4959754790005902570" rel="nofollow,noindex">Transmission Time Interval</a> 传输时间间隔)</p>    <p>基于路由的代码分割和按需加载</p>    <p>接下来Treebo要做的就是 <strong>按需加载</strong> ,可以减少初始交互时间。</p>    <p><a href="/misc/goto?guid=4959754790087639202" rel="nofollow,noindex">按需加载</a> 目的在于给一个路由页面的交互提供其所需要的最少代码,通过 <a href="/misc/goto?guid=4959749507871813473" rel="nofollow,noindex">code-splitting</a> 将路由分割成按需加载的“块”。这样让加载的资源更接近于开发者写的模块粒度。</p>    <p>他们在这块的做法是,把他们的第三方依赖库,Webpack runtime manifests,和他们的路由分割成单独的块。(译者注:需要理解 <a href="/misc/goto?guid=4959754790204340127" rel="nofollow,noindex">webpack 的 runtime 和 manifest,可以点进来看看</a> )</p>    <pre>  <code class="language-javascript">// reactMiddleware.js     //add the webpackManifest and vendor script files to your html   <body>   <div id="root">${app}</div>   `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`   `<script src="${assets.webpackManifest.js}">`</script>   `<script src="${assets.vendor.js}">`</script>   `<script src="${assets.main.js}">`</script>   </body></code></pre>    <pre>  <code class="language-javascript">// vendor.js     import 'redux-pack';   import 'redux-segment';   import 'redux-thunk';   import 'redux';   // import other external dependencies</code></pre>    <pre>  <code class="language-javascript">// webpack.js     entry: {   main: './client/index.js',   vendor: './client/vendor.js',   },   new webpack.optimize.CommonsChunkPlugin({   names: ['vendor', 'webpackManifest'],   minChunks: Infinity,   }),</code></pre>    <pre>  <code class="language-javascript">// routes.js     <Route       name="landing"       path="/"       getComponent={       (_, cb) => import('./views/LandingPage/LandingPage' /* webpackChunkName: 'landing' */)       .then((module) => cb(null, module.default))       .catch((error) => cb(error, null))       }   >   </Route></code></pre>    <pre>  <code class="language-javascript">// webpack.js     //extract css from all the split chunks into main.hash.css   new ExtractTextPlugin({   filename: 'css/[name].[contenthash:8].css',   allChunks: true,   }),</code></pre>    <p>这直接将初始交互时间减少到4.8s了。帅呆了!</p>    <p>唯一不够理想的是需要在初始化的bundles被执行完才会开始下载当前页面的Javascript。</p>    <p>但它至少在体验上提升了不少。对于按需加载,代码分割和这次体验的提升,他们做了一些更隐性的改进。他们通过webpack 的import方法调用React Router声明支持的getComponent来异步加载到各个模块中。(译者注: <a href="/misc/goto?guid=4959754790302005934" rel="nofollow,noindex">想了解getComponent可以点进来</a> )</p>    <p><img src="https://simg.open-open.com/show/30ee7c577d67fed04aa520984f24337b.png"></p>    <p>PRPL性能模式</p>    <p>按需加载对于代码更颗粒化的运行和缓存是非常赞的第一步。Treebo想再优化,并在 <a href="/misc/goto?guid=4959746498587996883" rel="nofollow,noindex"> <strong>PRPL 模式</strong> </a> 上找到了灵感。</p>    <p>PRPL是一种用于结构化和提供 Progressive Web App (PWA) 的模式,该模式强调应用交付和启动的性能。</p>    <p>它代表:</p>    <ul>     <li> <p>推送- 为初始网址路由推送关键资源。</p> </li>     <li> <p>渲染- 渲染初始路由。</p> </li>     <li> <p>预缓存- 预缓存剩余路由。</p> </li>     <li> <p>延迟加载- 延迟加载并按需创建剩余路由。</p> </li>    </ul>    <p><img src="https://simg.open-open.com/show/a90e44f638657759f14e4464de2d779f.png"></p>    <p>Jimmy Moon做的一份PRPL的结构图</p>    <p>“推送”部分推荐给服务器/浏览器组合设计一个离散的结构,以便在优化缓存的同时,支持HTTP/2传递给浏览器首屏光速渲染所需的资源。这些资源的传递可以通过 <a href="/misc/goto?guid=4959754790406016535" rel="nofollow,noindex"> <link ref="preload"> </a> 或者 <a href="/misc/goto?guid=4959754790502099207" rel="nofollow,noindex">HTTP/2 Push</a> 来高效完成。</p>    <p>Treebo选择使用 <link rel=”preload” /> 加载当前路由模块。当初始模块执行完后,webpack回调获取当前路由,当前路由模块已经在缓存中了,这样就减少初始交互时间。所以现在初始交互时间在4.6s时就开始了。</p>    <p><img src="https://simg.open-open.com/show/25b38c4bc7aee57d3926e444cffed9cd.png"></p>    <p>使用preload唯一不好的就是它并没有支持跨浏览器。目前,Safari已经支持link rel preload特性。我希望今年它会持续落实。目前Firefox也正在落实进行中。</p>    <p>HTML流</p>    <p>使用 renderToString() 的缺点之一是它是异步的,这会成为React项目中服务端渲染的性能瓶颈。服务器直到全部HTML被创建后才会发送 请求。当web服务器输出网站内容时,浏览器会在全部请求完成之前渲染页面给用户。类似 <a href="/misc/goto?guid=4959754790594457975" rel="nofollow,noindex">react-dom-stream</a> 这样的项目可以对此有所帮助。</p>    <p>为了提高他们的app感知性能,并引入一种渐进式渲染的感觉,Treebo使用了 <strong>HTML流</strong> 。他们会优先输出那些带有link rel preload的头部标签,这样可以预加载CSS和Javascript。然后再执行服务端渲染,并把剩下的资源发送给浏览器。</p>    <p>这样做的好处是资源比之前更早开始下载,将首屏渲染时间降低到0.9s,初始交互时间降低到4.4s。app始终保持在4.9/5秒的节点才开始交互。</p>    <p><img src="https://simg.open-open.com/show/a2e1daada91db72f29de768d56b3303a.png"></p>    <p>缺点是它在客户端和服务器之间连接会保持一段时间,如果遇到稍长点的延迟时间,可能会出现问题。 针对HTML流,Treebo将传输内容定义成预加载模块,主内容模块和将要加载的模块。 所有这些都被插入到页面中。 就像这样:</p>    <pre>  <code class="language-javascript">// html.js     earlyChunk(route) {       return `           <!doctype html>           <html lang="en">           <head>               <link rel="stylesheet" href="${assets.main.css}">               <link rel="preload" as="script" href="${assets.webpackManifest.js}">               <link rel="preload" as="script" href="${assets.vendor.js}">               <link rel="preload" as="script" href="${assets.main.js}">               ${!assets[route.name] ? '' : `<link rel="preload" as="script" href="${assets[route.name].js}">`}           </head>`;   },   lateChunk(app, head, initialState) {       return `           <body>               <div id="root">${app}</div>               `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`               `<script src="${assets.webpackManifest.js}">`</script>               `<script src="${assets.vendor.js}">`</script>               `<script src="${assets.main.js}">`</script>               </body>           </html>       `;   },</code></pre>    <pre>  <code class="language-javascript">// reactMiddleware.js     const serverRenderedChunks = async (req, res, renderProps) => {       const route = renderProps.routes[renderProps.routes.length - 1];       const store = configureStore();       //set the content type since you're streaming the response       res.set('Content-Type', 'text/html');       //flush the head with css & js resource tags first so the download starts immediately       const earlyChunk = html.earlyChunk(route);       res.write(earlyChunk);       res.flush();       //call & wait for api's response, set them into state       await loadOnServer({ ...renderProps, store });       //flush the rest of the body once app the server side rendered       const lateChunk = html.lateChunk(           renderToString(           <Provider store={store} key="provider">               <ReduxAsyncConnect {...renderProps} />           </Provider>,           ),           Helmet.renderStatic(),           store.getState(),           route,       );       res.write(lateChunk);       res.flush();       //let client know the response has ended       res.end();   };</code></pre>    <p>对于所有不同的脚本标签,预加载模块已经获取到它们的 rel=preload 声明。将要加载的模块则获取了服务端返回的html和其他包含state的内容,或者正在使用已经加载的Javascript。</p>    <p>内联对应路径CSS</p>    <p>CSS样式表会阻塞页面的渲染。页面会在浏览器发起请求,接收,下载,并且解析你的样式表之前保持空白。通过减少浏览器需要加载的CSS数量,并把 <a href="/misc/goto?guid=4959754790683723755" rel="nofollow,noindex">对应路径样式</a> 内联到页面中,这样就减少了一个HTTP请求,页面就可以更快的渲染。</p>    <p>Treebo在当前路由支持了 <strong>内联对应路径的样式</strong> ,并在DOMContentLoaded时使用 <a href="/misc/goto?guid=4958870010451469010" rel="nofollow,noindex">loadCSS</a> 异步加载剩余的CSS。</p>    <p>这消除了 <link> 标签对对应路径页面渲染的阻塞,并加入了少量的核心CSS,将首屏渲染时间减少至0.4s。</p>    <pre>  <code class="language-javascript">// fragments.js     import assetsManifest from '../../build/client/assetsManifest.json';   //read the styles into an assets object during server startup   export const assets = Object.keys(assetsManifest)       .reduce((o, entry) => ({           ...o,           [entry]: {               ...assetsManifest[entry],               styles: assetsManifest[entry].css ?    fs.readFileSync(`build/client/css/${assetsManifest[entry].css.split('/').pop()}`, 'utf8') : undefined,           },       }), {});       export const scripts = {           //loadCSS by filamentgroup           loadCSS: 'var loadCSS=function(e,n,t){func...',           loadRemainingCSS(route) {               return Object.keys(assetsManifest)                   .filter((entry) => assetsManifest[entry].css && entry !== route.name && entry !== 'main')                   .reduce((s, entry) => `${s}loadCSS("${assetsManifest[entry].css}");`, this.loadCSS);       },   };</code></pre>    <pre>  <code class="language-javascript">// html.js    //use the assets object to inline styles into your lateChunk template generation logic during runtime   lateChunk(route) {       return `                  <style>${assets.main.styles}</style>                  <style>${assets[route.name].styles}</style>              </head>              <body>                  <div id="root">${app}</div>                  `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`                  `<script src="${assets.webpackManifest.js}">`</script>                  `<script src="${assets.vendor.js}">`</script>                  `<script src="${assets.main.js}">`</script>                  `<script>${scripts.loadRemainingCSS(route)}</script>`              </body>          </html>       `;   },</code></pre>    <pre>  <code class="language-javascript">// webpack.client.js    //replace ExtractTextPlugin with ExtractCssChunks from 'extract-css-chunks-webpack-plugin'   module: {       rules: isProd ? [           { test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },           { test: /\.css$/, loader: ExtractCssChunks.extract({ use: [{ loader: 'css-loader', options: { importLoaders: 1 } }, 'postcss-loader'] }) },   //...   plugins: [       new ExtractCssChunks('css/[name].[contenthash:8].css'),       //this generates a css chunk alongside the js chunk for each dynamic import() call (route-split path in our case) for eg,       //main.hash.js, main.hash.css       //landing.hash.js, landing.hash.css       //cities.hash.js, cities.hash.css       //the landing.hash.css and cities.hash.css will contain the css rules for their respective chunks       //but will also contain shared rules between them like button, grid, typography css and so on       //to extract these shared rules to the main.hash.css use the CommonsChunkPlugin       //bonus: this also extracts the common js code shared between landing.hash.js and cities.hash.js into main.hash.js       new webpack.optimize.CommonsChunkPlugin({           children: true,           minChunks: 2,       }),       //use the assets-webpack-plugin to get a manifest of all the generated files       new AssetsPlugin({           filename: 'assetsManifest.json',           path: path.resolve('./build/client'),           prettyPrint: true,       }),   //...</code></pre>    <pre>  <code class="language-javascript">// html.js    //use the assets object to inline styles into your lateChunk template generation logic during runtime   lateChunk(route) {       return `                   <style>${assets.main.styles}</style>                   <style>${assets[route.name].styles}</style>               </head>               <body>                   <div id="root">${app}</div>                   `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`                   `<script src="${assets.webpackManifest.js}">`</script>                   `<script src="${assets.vendor.js}">`</script>                   `<script src="${assets.main.js}">`</script>                   `<script>${scripts.loadRemainingCSS(route)}</script>`               </body>           </html>       `;   },</code></pre>    <pre>  <code class="language-javascript">// webpack.client.js  //replace ExtractTextPlugin with ExtractCssChunks from 'extract-css-chunks-webpack-plugin'   module: {       rules: isProd ? [           { test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },           { test: /\.css$/, loader: ExtractCssChunks.extract({ use: [{ loader: 'css-loader', options: { importLoaders: 1 } }, 'postcss-loader'] }) },           //...   plugins: [       new ExtractCssChunks('css/[name].[contenthash:8].css'),       //this generates a css chunk alongside the js chunk for each dynamic import() call (route-split path in our case) for eg,       //main.hash.js, main.hash.css       //landing.hash.js, landing.hash.css       //cities.hash.js, cities.hash.css       //the landing.hash.css and cities.hash.css will contain the css rules for their respective chunks       //but will also contain shared rules between them like button, grid, typography css and so on       //to extract these shared rules to the main.hash.css use the CommonsChunkPlugin       //bonus: this also extracts the common js code shared between landing.hash.js and cities.hash.js into main.hash.js       new webpack.optimize.CommonsChunkPlugin({           children: true,           minChunks: 2,       }),       //use the assets-webpack-plugin to get a manifest of all the generated files       new AssetsPlugin({           filename: 'assetsManifest.json',           path: path.resolve('./build/client'),           prettyPrint: true,       }),       //...</code></pre>    <p>缺点就是首屏渲染时间稍微增加到4.6s,因为内联样式使加载资源更大,并且在Javascript执行之前解析也需要时间。</p>    <p><img src="https://simg.open-open.com/show/80e479d4a4f3e4c4c5fdc78b187b37d4.png"></p>    <p>离线静态资源缓存</p>    <p><a href="/misc/goto?guid=4959733392273143433" rel="nofollow,noindex">Service Worker</a> 是一种可编程网络代理,让你能够控制页面所发送网络请求的处理方式。</p>    <p>Treebo添加了Service Worker以支持静态资源以及自定义离线页面的缓存。下面我可以看到Service Worker的注册和他们如何使用 <a href="/misc/goto?guid=4959754790830034369" rel="nofollow,noindex">sw-precache-webpack-plugin</a> 来缓存资源。</p>    <pre>  <code class="language-javascript">// fragments.js   // register the service worker after the onload event to prevent   // bandwidth resource contention during the main and vendor js downloads   export const scripts = {       serviceWorker:           `"serviceWorker" in window.navigator && window.addEventListener("load", function() {               window.navigator.serviceWorker.register("/serviceWorker.js")               .then(function(r) {               console.log("ServiceWorker registration successful with scope: ", r.scope)               }).catch(function(e) {               console.error("ServiceWorker registration failed: ", e)               })           });`,   };</code></pre>    <pre>  <code class="language-javascript">// html.js     `<script src="${assets.webpackManifest.js}">`</script>   `<script src="${assets.vendor.js}">`</script>   `<script src="${assets.main.js}">`</script>   `<script>${scripts.loadRemainingCSS(route)}</script>`   //add the serviceWorker script to your html template   `<script>${scripts.serviceWorker}</script>`</code></pre>    <pre>  <code class="language-javascript">// server.js     //serve it at the root level scope   app.use('/serviceWorker.js', express.static('build/client/serviceWorker.js'));</code></pre>    <pre>  <code class="language-javascript">// webpack.js     new SWPrecacheWebpackPlugin({       cacheId: 'app-name',       filename: 'serviceWorker.js',       staticFileGlobsIgnorePatterns: [/\.map$/, /manifest/i],       dontCacheBustUrlsMatching: /./,       minify: true,   }),</code></pre>    <p><img src="https://simg.open-open.com/show/17fee5da82a8fd121368dc2f9ec1d16a.png"></p>    <p>缓存静态资源(比如CSS和Javascript包)意味着页面在反复访问时可以立即从硬盘缓存中加载,而不是需要每次都请求服务器。关于硬盘缓存命中率,硬盘定义的缓存头可以产生同样的效果,但是Service Worker给我们提供了离线支持。</p>    <p><img src="https://simg.open-open.com/show/69e1d834c834b4799914ef71878b39c1.png"></p>    <p>在缓存Javascript时,Service Worker使用了缓存API(如我们在 <a href="/misc/goto?guid=4959754790918838803" rel="nofollow,noindex">JavaScript 性能入门</a> 一文中提到的),使得Treebo在V8的代码缓存中也有不俗的优先选择,这样Treebo在反复访问时的启动节省了一点时间。</p>    <p>接下来,Treebo想尝试减少他们第三方插件包的大小和JS的执行时间,于是他们在生产环境将React换成了 <strong>Preact</strong> 。</p>    <p>Preact替换React</p>    <p><a href="/misc/goto?guid=4959754789610508665" rel="nofollow,noindex">Preact</a> 是一个跟React同样使用ES2015 API,精简到3KB的替代方案。它旨在提供高性能渲染,并且与React生态系统的其余部分(如Redux)配合使用(preact-compat)。</p>    <p>Preact精简的部分在于删除了合成事件(Synthetic Events)和PropType验证。 另外它还包含:</p>    <ul>     <li> <p>虚拟DOM(Virtual DOM)和真实DOM的对比</p> </li>     <li> <p>支持class和for的props</p> </li>     <li> <p>在render方法中传入了(props, state)</p> </li>     <li> <p>使用标准浏览器事件</p> </li>     <li> <p>完全支持异步渲染</p> </li>     <li> <p>SubTree默认无效</p> </li>    </ul>    <p>在很多PWA应用中,替换成Preact可以让应用减小JS包的大小,并且缩短了Javascript初始化时间。最近发布的PWA,例如Lyft, Uber和 Housing.com都在生产环境使用了Preact。</p>    <p>注意:如果你的项目是React开发的,并且你想换成Preact? 理想情况下,您应该使用preact和preact-compat来进行开发,生产和测试。 这可以让你在早期发现任何交互操作性错误。 如果你只想在Webpack中仅使用别名preact和preact-compat生成构建(例如,如果你最开始使用Enzyme),请确保在部署到服务器之前彻底测试一切正常工作。</p>    <p>在Treebo的案例中,转换成Preact让他们的第三方包大小直接从140kb降到100kb。当然,全都是gzip之后的。这让Treebo成功的在目标移动设备将初始交互时间从 <strong>4.6s降低到3.9s</strong> 。</p>    <p><img src="https://simg.open-open.com/show/64d5c5acd5e90af16b916c3e3852b0d0.png"></p>    <p>你可以在你的Webpack里面配置alias,react对应 <a href="/misc/goto?guid=4959754791020122054" rel="nofollow,noindex">preact-compat</a> ,react-dom也对应preact-compat。</p>    <pre>  <code class="language-javascript">// webpack.js     resolve: {       alias: {            react: 'preact-compat',           'react-dom': 'preact-compat',       },   },</code></pre>    <p>这种方法的缺点是,需要兼容其他配套方案,这样Preact才能在他们想使用的React生态的各部分中同样工作</p>    <p>如果你正在使用React,Preact对于95%的案例来说都是最合适的选择;对于另外那5%,你可能需要给那些尚未考虑的边缘案例提交bug。</p>    <p>注意:由于WebPageTest目前还不支持测试印度真实的Moto G4s,性能测试是在“孟买 - EC2 - Chrome - 仿真摩托罗拉G(第4代) - 3GSlow - 手机”设置下运行的。 如果你想看看这些记录,可以在 <a href="/misc/goto?guid=4959754791100809162" rel="nofollow,noindex">这里</a> 找到它们。</p>    <p>加载占位图</p>    <p>“加载占位图本质上是内容逐渐加载的一个空白页面。”</p>    <p>~Luke Wroblewski</p>    <p><img src="https://simg.open-open.com/show/da12b3fb5a308eac2b6d3cc65f826e2a.png"></p>    <p>Treebo想使用预览组件(类似给每个组件添加加载占位图)来加载占位。这个方法的本质就是给所有基础组件(文本,图片等)添加一个预览组件,这样一旦组件所需的数据源还没加载出来,就会显示组件对应的预览组件。</p>    <p>例如,你正在上面这个列表中看到的酒店名称,城市名称,价格等内容,他们使用排版组件类似 ,添加两个额外的prop, preview 和 previewStyle 来实现。</p>    <pre>  <code class="language-javascript">// Text.js     <Text       preview={!hotel.name}       previewStyle={{width: 80%}}   >       {hotel.name}   </Text></code></pre>    <p>基本上,如果hotel.name不存在,则组件会将背景更改为灰色,并根据传递的previewStyle设置宽度和其他样式(如果没有预览样式传递,则默认为100%)。</p>    <pre>  <code class="language-javascript">// text.css   .text {       font-size: 1.2rem;       color: var(--color-secondary);       &--preview {           opacity: 0.1;           height: 13px;           width: 100%;           background: var(--color-secondary);       }       @media (--medium-screen) {           font-size: 1.4rem;           &--preview {               height: 16px;           }       }   }</code></pre>    <pre>  <code class="language-javascript">// Text.js     import React, { PropTypes } from 'react';   import cn from 'classnames';   const Text = ({       className,       tag,       preview,       previewStyle,       children,       ...props   }) =>       React.createElement(tag, {           style: preview ? previewStyle : {},           className: cn('text', {               'text--preview': preview,           }, className),           ...props,       }, children);   Text.propTypes = {       className: PropTypes.string,       tag: PropTypes.string.isRequired,       preview: PropTypes.bool.isRequired,       previewStyle: PropTypes.object,       children: PropTypes.node,   };   Text.defaultProps = {       tag: 'p',       preview: false,   };   export default Text;</code></pre>    <p>Treebo喜欢这种方法是因为,切换到预览模式的逻辑与实际展示的数据无关,这样看起来更灵活。当你在浏览“包含xx所有税”部分时,它就只是静态文字,在开始时可能正常显示,但是当api调用时,价格仍在加载,就会让用户感觉很困惑。</p>    <p>所以为了在剩下的ui中把静态文字“包含xx所有税”展示在预览模式,他们使用价格本身作为逻辑判断。</p>    <pre>  <code class="language-javascript">// TextPreview.js     <Text preview={!price.sellingPrice}>       Incl. of all taxes   </Text></code></pre>    <p>这样当价格还在加载时,你会获取到预览的界面,一旦api接口返回成功,你就可以看到展示的数据了。</p>    <h3>Webpack-bundle-analyzer</h3>    <p>在这一点,Treebo想做打包分析,这样可以找出一些低频使用的包来优化。</p>    <p>注意:如果你在移动端使用了类似React的库,经常优化你引入的第三方库,是非常重要的。不这样做可能会导致性能问题。考虑如何更好的打包你的第三方库,这样路由只会加载页面所需要的库</p>    <p>Treebo使用 <a href="/misc/goto?guid=4959754791203288989" rel="nofollow,noindex">webpack-bundle-analyzer</a> 来跟踪他们包的大小变化,并在每个路由块中监视其中包含的模块。他们也用它来发现可以优化减小包大小的地方,例如去掉moment.js的locales,复用深依赖。</p>    <p>使用webpack优化moment.js</p>    <p>Treebo在他们的日期操作重度依赖 <a href="/misc/goto?guid=4959749540781533256" rel="nofollow,noindex">moment.js</a> 。当你引入了moment.js,并用webpack把它打包,你的包会包含所有moment.js,而它默认的语言包gizp之后都有约61.95kb。这严重增加了最终第三方库打包完的包大小。</p>    <p><img src="https://simg.open-open.com/show/697c2c9e58eae42b7e5e08c1a7822e93.png"></p>    <p>为了优化moment.js的大小,有 <a href="/misc/goto?guid=4959754791365036540" rel="nofollow,noindex">两个webpack插件</a> 可以用: <a href="/misc/goto?guid=4959754791483500686" rel="nofollow,noindex">IgnorePlugin</a> , <a href="/misc/goto?guid=4959754791572064010" rel="nofollow,noindex">ContextReplacementPlugin</a></p>    <p>当Treebo不再需要任何语言包,他们选择了IgnorePlugin来移除所有语言文件。</p>    <p>new webpack.IgnorePlugin(/^.\/locale$/, /moment$/)</p>    <p>去除了语言包后,moment.js打包后大小在gizp后降低到约16.48kb。</p>    <p><img src="https://simg.open-open.com/show/0285ed40bf1028a7cef45fe6d860ad82.png"></p>    <p>作为移除moment.js语言包的边际影响力的最大改善,就是第三方包大小直接从179kb降到119kb。对于首屏加载时一个关键的包,60kb算是大幅度的下降。所有这些都意味着第一次交互时间的大幅度下降。你可以在 <a href="/misc/goto?guid=4959754791365036540" rel="nofollow,noindex">这里</a> 阅读更多关于优化moment.js。</p>    <p>复用深依赖</p>    <p>Treebo最开始使用“qs”模块来进行查询字符串操作。在webpack-bundle-analyzer分析的结果中,他们发现“react-router”中包含的“history”模块中包含了“query-string”模块。</p>    <p><img src="https://simg.open-open.com/show/e7cf95c75c9affe0816081c8f48ba119.png"></p>    <p>因为这两个不同的模块都做了相同的操作,在他们源代码中使用当前版本的“query-string”(就是当前安装的)来替换“qs”,又让他们的包gizp后减少2.72kb(也就是“qs”模块的大小)。</p>    <p>Treebo是一个很好的开源参与者。他们使用来大量的开源软件。作为回报,他们也把自己大部分的Webpack配置开源,包含了很多他们在生产环境的配置,可以作为一个模版。你可以在这里找到: <a href="/misc/goto?guid=4959754791690473569" rel="nofollow,noindex">https://github.com/lakshyaranganath/pwa</a></p>    <p><img src="https://simg.open-open.com/show/87ea52a730f1147d495073f7ee967dfa.jpg"></p>    <p>他们也承诺会尽量保持更新。随着不断完善,您可以把它们作为另一个PWA实现参考。</p>    <h3>结尾和未来</h3>    <p>Treebo知道,没有什么应用是完美的,他们积极探索多种方法,不断改进他们向用户提供的经验。其中一些:</p>    <p>懒加载图片</p>    <p>有些人可能从之前的网络瀑布图中了解到,网站图像下载是跟JS下载来竞争带宽。</p>    <p><img src="https://simg.open-open.com/show/684135b5af491f0aee48d2d5e78c5cc8.png"></p>    <p>由于浏览器解析img标签后立即触发图片下载,在JS下载过程中它们共享带宽。 一个简单的解决方案是当它们进入用户视图时懒加载图片,这也可以减少我们的交互时间。</p>    <p>Lighthouse在视图外图片审查高亮了这些问题:</p>    <p><img src="https://simg.open-open.com/show/0123d586e91a5b00ce7d1f84cedb2807.png"></p>    <p>双重引用</p>    <p>Treebo也意识到,虽然他们是异步加载应用的剩余CSS(在加载内联对应路径CSS之后),随着他们的应用发展,从长远来看,这种方法对用户是不可行的。更多的迭代和页面意味着更多的CSS和下载,这些都将导致带宽占用和浪费。</p>    <p>借鉴 <a href="/misc/goto?guid=4958870010451469010" rel="nofollow,noindex">loadCSS</a> 和 <a href="/misc/goto?guid=4959754791814052729" rel="nofollow,noindex">babel-plugin-dual-import</a> 的实现方法,Treebo在各自的JS模块中,并行异步执行import(‘chunkpath’)方法,再通过自定义实现的importCss(‘chunkname’)方法返回CSS模块,以此改变加载CSS的方法。</p>    <pre>  <code class="language-javascript">// html.js     import assetsManifest from '../../build/client/assetsManifest.json';     lateChunk(app, head, initialState, route) {       return `               <style>${assets.main.styles}</style>               // inline the current route's css and assign an id to it               ${!assets[route.name] ? '' : `<style id="${route.name}.css">${assets[route.name].styles}</style>`}           </head>           <body>               <div id="root">${app}</div>               `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`               `<script>window.__ASSETS_MANIFEST__ = ${JSON.stringify(assetsManifest)}</script>`               `<script src="${assets.webpackManifest.js}">`</script>               `<script src="${assets.vendor.js}">`</script>               `<script src="${assets.main.js}">`</script>           </body>       </html>`;   },</code></pre>    <pre>  <code class="language-javascript">// importCSS.js     export default (chunkName) => {       if (!__BROWSER__) {           return Promise.resolve();       } else if (!(chunkName in window.__ASSETS_MANIFEST__)) {           return Promise.reject(`chunk not found: ${chunkName}`);       } else if (!window.__ASSETS_MANIFEST__[chunkName].css) {           return Promise.resolve(`chunk css does not exist: ${chunkName}`);       } else if (document.getElementById(`${chunkName}.css`)) {           return Promise.resolve(`css chunk already loaded: ${chunkName}`);       }         const head = document.getElementsByTagName('head')[0];       const link = document.createElement('link');       link.href = window.__ASSETS_MANIFEST__[chunkName].css;       link.id = `${chunkName}.css`;       link.rel = 'stylesheet';         return new Promise((resolve, reject) => {           let timeout;           link.onload = () => {               link.onload = null;               link.onerror = null;               clearTimeout(timeout);               resolve(`css chunk loaded: ${chunkName}`);           };           link.onerror = () => {               link.onload = null;               link.onerror = null;               clearTimeout(timeout);               reject(new Error(`could not load css chunk: ${chunkName}`));           };           timeout = setTimeout(link.onerror, 30000);           head.appendChild(link);       });   };</code></pre>    <pre>  <code class="language-javascript">// routes.js     <IndexRoute       name="landing"       getComponent={(_, cb) => {           Promise.all([               import('./views/LandingPage/LandingPage' /* webpackChunkName: 'landing' */),               importCss('landing'),           ]).then(([module]) => cb(null, module.default));       }}   />   <Route       name="search"       path="/search/"       getComponent={(_, cb) => {           Promise.all([               import('./views/SearchResultsPage/SearchResultsPage' /* webpackChunkName: 'search' */),               importCss('search'),           ]).then(([module]) => cb(null, module.default));       }}   /></code></pre>    <p>通过这种新方法,路由跳转会进行两个并行的异步请求,一个给JS,另一个给CSS,而不像之前所有的CSS都在DOMContentLoaded时被加载。对于用户只会下载当前访问页面所需的CSS来说,这样更可行。</p>    <p>A/B 测试</p>    <p>Treebo目前正在实施AB测试方法,包含服务器端渲染和代码分割,以便在服务器端和客户端渲染期间拉下用户所需要的版本。 (Treebo将发布一篇关于他们如何解决这个问题的博文)。</p>    <p>预加载</p>    <p>理想中,为了避免对关键资源下载的流量争用,Treebo不希望在页面初始加载所有应用分割的模块,对于移动端用户,在下次访问时,如果没使用service-worker来缓存,也确实浪费宝贵的流量。如果我们看看Treebo在持续交互方面做的怎样,仍然有许多空间可以改善:</p>    <p><img src="https://simg.open-open.com/show/dd40d58e8a3f35913f7ed4f95ddc60bb.png"></p>    <p>这是他们正在尝试改进的领域。 一个例子是在按钮的波纹动画期间预加载下一个路由模块。 点击时, Treebo使用webpack <a href="/misc/goto?guid=4959754791952959616" rel="nofollow,noindex">动态import()</a> 回调来加载下一个路由模块,并用setTimeout延迟路由跳转。 他们还希望确保下一个路由模块足够小,以便在缓慢的3g网络上给定的400ms之内能加载完。</p>    <h3> </h3>    <p>来自:http://www.zcfy.cc/article/a-react-and-preact-progressive-web-app-performance-case-study-treebo-4250.html</p>    <p> </p>