Treebo网站的React与Preact PWA性能分析报告(上)

Posted 奇舞周刊

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Treebo网站的React与Preact PWA性能分析报告(上)相关的知识,希望对你有一定的参考价值。

编者按:本文由eJayYoung在众成翻译平台上翻译。

Treebo网站的React与Preact PWA性能分析报告(上)

Treebo是一家印度家喻户晓的经济型连锁酒店,在旅游业中占据了价值200亿美元的市场。他们最近开发了一个新的渐进式应用(PWA)作为默认的移动端体验,最开始使用React,但最后在生产环境转向了Preact

对比之前的移动端可以看到,新版本在首屏渲染时间上提升了 70%,初始交互时间减少了 31%。大部分用户在3G环境下使用自己的移动设备只需不到4s即可浏览完整内容。使用WebPageTest模拟印度超慢的3G网络也只需要不到5s。

Treebo网站的React与Preact PWA性能分析报告(上)

从React迁移到Preact也使初始交互时间缩短了15%。你可以打开Treebo.com完整体验一下,但是今天我们想深入探讨分析这个PWA的过程中的一些技术实现。

Treebo网站的React与Preact PWA性能分析报告(上)

这就是Treebo 新版的PWA

1. 性能优化之旅

1.1 老版移动端

老版的Treebo移动端是基于Django框架搭建的。用户在跳转页面时必须等待服务端请求。这个版本的首屏渲染时间为1.5s,首屏完整渲染时间为5.9s,初始交互时间为6.5s。

Treebo网站的React与Preact PWA性能分析报告(上)

1.2 基础的React单页应用

它们第一次迭代重构Treebo是用React和简单的webpack来构建一个单页应用。

你可以看下之前写的代码。这导致生成了简单(巨大)的javascript和CSS包(bundles)。

/* 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'),

这次版本的首屏渲染时间为4.8s,初始交互时间大约5.6s,完整的首屏图片加载时间在7.2s。

Treebo网站的React与Preact PWA性能分析报告(上)

1.3服务端渲染(SSR)

接着,他们着手优化首屏渲染时间,所以他们尝试了服务端渲染。有一点值得注意,服务端渲染并不是没有副作用。它优化的同时也会消耗其他性能。

使用服务端渲染,你服务端给浏览器的返回就是你即将重绘页面的html,这样浏览器可以不需要等待所有Javascript加载和执行才能渲染页面。

Treebo使用React的renderToString()将组件渲染为一段HTML字符串,并在应用初始化的时候注入state。

// 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 " style="border: 0px;">${assets.main.js}"></script>

     </body>

     </html>

 `;

在Treebo的例子中,使用服务端渲染,首屏渲染时间减少到1.1s,首屏完整渲染时间减少到2.4s - 这提高了用户在页面加载速度的感知,他们可以更提前获取内容,而且在测试中显示在SEO也略微改善。但是缺点就是在初始交互时间有糟糕的影响。

Treebo网站的React与Preact PWA性能分析报告(上)

尽管用户可以看到网站内容,但是当初始化加载javascript时主线程被阻塞了,并且就堵在那里。

使用SSR,浏览器需要比之前请求处理更大的HTMl负载,并且接着请求,解析/编译,执行Javascript。虽然这样高效的做了更多工作。

但这意味着第一次交互时间需要6.6s,反而不如之前了。

SSR也可以通过锁定下游设备的主线程来缩短TTI。(译者注:Transmission Time Interval传输时间间隔)

1.4 基于路由的代码分割和按需加载

接下来Treebo要做的就是按需加载,可以减少初始交互时间。

按需加载目的在于给一个路由页面的交互提供其所需要的最少代码,通过code-splitting将路由分割成按需加载的“块”。这样让加载的资源更接近于开发者写的模块粒度。

他们在这块的做法是,把他们的第三方依赖库,Webpack runtime manifests,和他们的路由分割成单独的块。(译者注:需要理解webpack 的 runtime 和 manifest,可以点进来看看)

// 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>

// vendor.js


 import 'redux-pack';

 import 'redux-segment';

 import 'redux-thunk';

 import 'redux';

 // import other external dependencies

// webpack.js


 entry: {

   main: './client/index.js',

   vendor: './client/vendor.js',

 },

 new webpack.optimize.CommonsChunkPlugin({

   names: ['vendor', 'webpackManifest'],

   minChunks: Infinity,

 }),

// 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>

// webpack.js


 //extract css from all the split chunks into main.hash.css

 new ExtractTextPlugin({

   filename: 'css/[name].[contenthash:8].css',

   allChunks: true,

 }),

这直接将初始交互时间减少到4.8s了。帅呆了!

唯一不够理想的是需要在初始化的bundles被执行完才会开始下载当前页面的Javascript。

但它至少在体验上提升了不少。对于按需加载,代码分割和这次体验的提升,他们做了一些更隐性的改进。他们通过webpack 的import方法调用React Router声明支持的getComponent来异步加载到各个模块中。(译者注:想了解getComponent可以点进来)

Treebo网站的React与Preact PWA性能分析报告(上)

1.5 PRPL性能模式

按需加载对于代码更颗粒化的运行和缓存是非常赞的第一步。Treebo想再优化,并在PRPL 模式上找到了灵感。

PRPL是一种用于结构化和提供 Progressive Web App (PWA) 的模式,该模式强调应用交付和启动的性能。

它代表:

  • 推送 - 为初始网址路由推送关键资源。

  • 渲染 - 渲染初始路由。

  • 预缓存 - 预缓存剩余路由。

  • 延迟加载 - 延迟加载并按需创建剩余路由。

Treebo网站的React与Preact PWA性能分析报告(上)

Jimmy Moon做的一份PRPL的结构图

“推送”部分推荐给服务器/浏览器组合设计一个离散的结构,以便在优化缓存的同时,支持HTTP/2传递给浏览器首屏光速渲染所需的资源。这些资源的传递可以通过<link ref="preload">或者HTTP/2 Push来高效完成。

Treebo选择使用<link rel=”preload” />加载当前路由模块。当初始模块执行完后,webpack回调获取当前路由,当前路由模块已经在缓存中了,这样就减少初始交互时间。所以现在初始交互时间在4.6s时就开始了。

Treebo网站的React与Preact PWA性能分析报告(上)

使用preload唯一不好的就是它并没有支持跨浏览器。目前,Safari已经支持link rel preload特性。我希望今年它会持续落实。目前Firefox也正在落实进行中。

1.6 HTML流

使用renderToString()的缺点之一是它是异步的,这会成为React项目中服务端渲染的性能瓶颈。服务器直到全部HTML被创建后才会发送

请求。当web服务器输出网站内容时,浏览器会在全部请求完成之前渲染页面给用户。类似react-dom-stream这样的项目可以对此有所帮助。

为了提高他们的app感知性能,并引入一种渐进式渲染的感觉,Treebo使用了HTML流。他们会优先输出那些带有link rel preload的头部标签,这样可以预加载CSS和Javascript。然后再执行服务端渲染,并把剩下的资源发送给浏览器。

这样做的好处是资源比之前更早开始下载,将首屏渲染时间降低到0.9s,初始交互时间降低到4.4s。app始终保持在4.9/5秒的节点才开始交互。

Treebo网站的React与Preact PWA性能分析报告(上)

缺点是它在客户端和服务器之间连接会保持一段时间,如果遇到稍长点的延迟时间,可能会出现问题。 针对HTML流,Treebo将传输内容定义成预加载模块,主内容模块和将要加载的模块。 所有这些都被插入到页面中。 就像这样:

// 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 " style="border: 0px;">${assets.webpackManifest.js}"></script>

             <script " style="border: 0px;">${assets.vendor.js}"></script>

             <script " style="border: 0px;">${assets.main.js}"></script>

             </body>

         </html>

     `;

 },

// 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();

 };

对于所有不同的脚本标签,预加载模块已经获取到它们的rel=preload声明。将要加载的模块则获取了服务端返回的html和其他包含state的内容,或者正在使用已经加载的Javascript。

1.7 内联对应路径CSS

CSS样式表会阻塞页面的渲染。页面会在浏览器发起请求,接收,下载,并且解析你的样式表之前保持空白。通过减少浏览器需要加载的CSS数量,并把对应路径样式内联到页面中,这样就减少了一个HTTP请求,页面就可以更快的渲染。

Treebo在当前路由支持了内联对应路径的样式,并在DOMContentLoaded时使用loadCSS异步加载剩余的CSS。

这消除了<link>标签对对应路径页面渲染的阻塞,并加入了少量的核心CSS,将首屏渲染时间减少至0.4s。

// 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);

     },

 };

// 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 " style="border: 0px;">${assets.webpackManifest.js}"></script>

                <script " style="border: 0px;">${assets.vendor.js}"></script>

                <script " style="border: 0px;">${assets.main.js}"></script>

                <script>${scripts.loadRemainingCSS(route)}</script>

            </body>

        </html>

     `;

 },

// 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,

     }),

 //...

// 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 " style="border: 0px;">${assets.webpackManifest.js}"></script>

                 <script " style="border: 0px;">${assets.vendor.js}"></script>

                 <script " style="border: 0px;">${assets.main.js}"></script>

                 <script>${scripts.loadRemainingCSS(route)}</script>

             </body>

         </html>

     `;

 },

// 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,

     }),

     //...

缺点就是首屏渲染时间稍微增加到4.6s,因为内联样式使加载资源更大,并且在Javascript执行之前解析也需要时间。

Treebo网站的React与Preact PWA性能分析报告(上)

1.8 离线静态资源缓存

Service Worker是一种可编程网络代理,让你能够控制页面所发送网络请求的处理方式。

Treebo添加了Service Worker以支持静态资源以及自定义离线页面的缓存。下面我可以看到Service Worker的注册和他们如何使用sw-precache-webpack-plugin来缓存资源。

// 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)

             })

         });`,

 };

// 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>

// server.js


 //serve it at the root level scope

 app.use('/serviceWorker.js', express.static('build/client/serviceWorker.js'));

// webpack.js


 new SWPrecacheWebpackPlugin({

     cacheId: 'app-name',

     filename: 'serviceWorker.js',

     staticFileGlobsIgnorePatterns: [/\.map$/, /manifest/i],

     dontCacheBustUrlsMatching: /./,

     minify: true,

 }),

Treebo网站的React与Preact PWA性能分析报告(上)

缓存静态资源(比如CSS和Javascript包)意味着页面在反复访问时可以立即从硬盘缓存中加载,而不是需要每次都请求服务器。关于硬盘缓存命中率,硬盘定义的缓存头可以产生同样的效果,但是Service Worker给我们提供了离线支持。

Treebo网站的React与Preact PWA性能分析报告(上)

在缓存Javascript时,Service Worker使用了缓存API(如我们在JavaScript 性能入门一文中提到的),使得Treebo在V8的代码缓存中也有不俗的优先选择,这样Treebo在反复访问时的启动节省了一点时间。

接下来,Treebo想尝试减少他们第三方插件包的大小和JS的执行时间,于是他们在生产环境将React换成了Preact。

1.9 Preact替换React

Preact是一个跟React同样使用ES2015 API,精简到3KB的替代方案。它旨在提供高性能渲染,并且与React生态系统的其余部分(如Redux)配合使用(preact-compat)。

Preact精简的部分在于删除了合成事件(Synthetic Events)和PropType验证。 另外它还包含:

  • 虚拟DOM(Virtual DOM)和真实DOM的对比

  • 支持class和for的props

  • 在render方法中传入了(props, state)

  • 使用标准浏览器事件

  • 完全支持异步渲染

  • SubTree默认无效

在很多PWA应用中,替换成Preact可以让应用减小JS包的大小,并且缩短了Javascript初始化时间。最近发布的PWA,例如Lyft, Uber和 Housing.com都在生产环境使用了Preact。

注意:如果你的项目是React开发的,并且你想换成Preact? 理想情况下,您应该使用preact和preact-compat来进行开发,生产和测试。 这可以让你在早期发现任何交互操作性错误。 如果你只想在Webpack中仅使用别名preact和preact-compat生成构建(例如,如果你最开始使用Enzyme),请确保在部署到服务器之前彻底测试一切正常工作。

在Treebo的案例中,转换成Preact让他们的第三方包大小直接从140kb降到100kb。当然,全都是gzip之后的。这让Treebo成功的在目标移动设备将初始交互时间从4.6s降低到3.9s。

Treebo网站的React与Preact PWA性能分析报告(上)

你可以在你的Webpack里面配置alias,react对应preact-compat,react-dom也对应preact-compat。

// webpack.js


 resolve: {

     alias: {

          react: 'preact-compat',

         'react-dom': 'preact-compat',

     },

 },

这种方法的缺点是,需要兼容其他配套方案,这样Preact才能在他们想使用的React生态的各部分中同样工作

如果你正在使用React,Preact对于95%的案例来说都是最合适的选择;对于另外那5%,你可能需要给那些尚未考虑的边缘案例提交bug。

注意:由于WebPageTest目前还不支持测试印度真实的Moto G4s,性能测试是在“孟买 - EC2 - Chrome - 仿真摩托罗拉G(第4代) - 3GSlow - 手机”设置下运行的。 如果你想看看这些记录,可以在这里找到它们。

1.10 加载占位图

“加载占位图本质上是内容逐渐加载的一个空白页面。”

Treebo想使用预览组件(类似给每个组件添加加载占位图)来加载占位。这个方法的本质就是给所有基础组件(文本,图片等)添加一个预览组件,这样一旦组件所需的数据源还没加载出来,就会显示组件对应的预览组件。

例如,你正在上面这个列表中看到的酒店名称,城市名称,价格等内容,他们使用排版组件类似,添加两个额外的prop,previewpreviewStyle来实现。

// Text.js


 <Text

     preview={!hotel.name}

     previewStyle={{width: 80%}}

 >

     {hotel.name}

 </Text>

基本上,如果hotel.name不存在,则组件会将背景更改为灰色,并根据传递的previewStyle设置宽度和其他样式(如果没有预览样式传递,则默认为100%)。

// 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;

         }

     }

 }

// 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;

Treebo喜欢这种方法是因为,切换到预览模式的逻辑与实际展示的数据无关,这样看起来更灵活。当你在浏览“包含xx所有税”部分时,它就只是静态文字,在开始时可能正常显示,但是当api调用时,价格仍在加载,就会让用户感觉很困惑。

所以为了在剩下的ui中把静态文字“包含xx所有税”展示在预览模式,他们使用价格本身作为逻辑判断。

// TextPreview.js


 <Text preview={!price.sellingPrice}>

     Incl. of all taxes

 </Text>

这样当价格还在加载时,你会获取到预览的界面,一旦api接口返回成功,你就可以看到展示的数据了。


奇舞周刊

——————————————————

领略前端技术 阅读奇舞周刊


长按二维码,关注奇舞周刊


以上是关于Treebo网站的React与Preact PWA性能分析报告(上)的主要内容,如果未能解决你的问题,请参考以下文章

React 与 Preact PWA 性能分析报告

前端每周清单: iOS 11 Viewport 解析,Preact PWA 性能优化案例

Preact PWA,配置 maximumFileSizeToCacheInBytes 来改变这个限制

如何将 React 路由器与 Preact 一起使用

将 React 开发工具与 Preact 一起使用

Preact:React.js替代品?