一份來自Treebo 的 React 與 Preact PWA 性能分析報告

soryokurin 7年前發布 | 37K 次閱讀 React CSS PWA

Treebo 是一家印度家喻戶曉的經濟型連鎖酒店,在旅游業中占據了價值200億美元的市場。他們 最近 開發了一個新的漸進式應用(PWA)作為默認的移動端體驗,最開始使用 React ,但最后在生產環境轉向了 Preact

對比之前的移動端可以看到,新版本 在首屏渲染時間上提升了 70%, 初始交互時間 減少了 31% 。大部分用戶在3G環境下使用自己的移動設備只需不到4s即可瀏覽完整內容。使用WebPageTest模擬印度超慢的3G網絡也只需要不到5s。

從React遷移到Preact也使初始交互時間縮短了15%。你可以打開 Treebo.com 完整體驗一下,但是今天我們想深入探討分析這個PWA的過程中的一些技術實現。

這就是Treebo 新版的PWA

性能優化之旅

老版移動端

老版的Treebo移動端是基于Django框架搭建的。用戶在跳轉頁面時必須等待服務端請求。這個版本的首屏渲染時間為1.5s,首屏完整渲染時間為5.9s,初始交互時間為6.5s。

基礎的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。

服務端渲染(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 src="${assets.main.js}">`</script>
     </body>
     </html>
 `;

在Treebo的例子中,使用服務端渲染,首屏渲染時間減少到1.1s,首屏完整渲染時間減少到2.4s - 這提高了用戶在頁面加載速度的感知,他們可以更提前獲取內容,而且在測試中顯示在SEO也略微改善。但是缺點就是在初始交互時間有糟糕的影響。

盡管用戶可以看到網站內容,但是當初始化加載javascript時主線程被阻塞了,并且就堵在那里。

使用SSR,瀏覽器需要比之前請求處理更大的HTMl負載,并且接著請求,解析/編譯,執行Javascript。雖然這樣高效的做了更多工作。

但這意味著第一次交互時間需要6.6s,反而不如之前了。

SSR也可以通過鎖定下游設備的主線程來縮短TTI。(譯者注: Transmission Time Interval 傳輸時間間隔)

基于路由的代碼分割和按需加載

接下來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可以點進來 )

PRPL性能模式

按需加載對于代碼更顆粒化的運行和緩存是非常贊的第一步。Treebo想再優化,并在 PRPL 模式 上找到了靈感。

PRPL是一種用于結構化和提供 Progressive Web App (PWA) 的模式,該模式強調應用交付和啟動的性能。

它代表:

  • 推送- 為初始網址路由推送關鍵資源。

  • 渲染- 渲染初始路由。

  • 預緩存- 預緩存剩余路由。

  • 延遲加載- 延遲加載并按需創建剩余路由。

Jimmy Moon做的一份PRPL的結構圖

“推送”部分推薦給服務器/瀏覽器組合設計一個離散的結構,以便在優化緩存的同時,支持HTTP/2傳遞給瀏覽器首屏光速渲染所需的資源。這些資源的傳遞可以通過 <link ref="preload"> 或者 HTTP/2 Push 來高效完成。

Treebo選擇使用 <link rel=”preload” /> 加載當前路由模塊。當初始模塊執行完后,webpack回調獲取當前路由,當前路由模塊已經在緩存中了,這樣就減少初始交互時間。所以現在初始交互時間在4.6s時就開始了。

使用preload唯一不好的就是它并沒有支持跨瀏覽器。目前,Safari已經支持link rel preload特性。我希望今年它會持續落實。目前Firefox也正在落實進行中。

HTML流

使用 renderToString() 的缺點之一是它是異步的,這會成為React項目中服務端渲染的性能瓶頸。服務器直到全部HTML被創建后才會發送 請求。當web服務器輸出網站內容時,瀏覽器會在全部請求完成之前渲染頁面給用戶。類似 react-dom-stream 這樣的項目可以對此有所幫助。

為了提高他們的app感知性能,并引入一種漸進式渲染的感覺,Treebo使用了 HTML流 。他們會優先輸出那些帶有link rel preload的頭部標簽,這樣可以預加載CSS和Javascript。然后再執行服務端渲染,并把剩下的資源發送給瀏覽器。

這樣做的好處是資源比之前更早開始下載,將首屏渲染時間降低到0.9s,初始交互時間降低到4.4s。app始終保持在4.9/5秒的節點才開始交互。

缺點是它在客戶端和服務器之間連接會保持一段時間,如果遇到稍長點的延遲時間,可能會出現問題。 針對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 src="${assets.webpackManifest.js}">`</script>
             `<script src="${assets.vendor.js}">`</script>
             `<script src="${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。

內聯對應路徑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 src="${assets.webpackManifest.js}">`</script>
                `<script src="${assets.vendor.js}">`</script>
                `<script src="${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 src="${assets.webpackManifest.js}">`</script>
                 `<script src="${assets.vendor.js}">`</script>
                 `<script src="${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執行之前解析也需要時間。

離線靜態資源緩存

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,
 }),

緩存靜態資源(比如CSS和Javascript包)意味著頁面在反復訪問時可以立即從硬盤緩存中加載,而不是需要每次都請求服務器。關于硬盤緩存命中率,硬盤定義的緩存頭可以產生同樣的效果,但是Service Worker給我們提供了離線支持。

在緩存Javascript時,Service Worker使用了緩存API(如我們在 JavaScript 性能入門 一文中提到的),使得Treebo在V8的代碼緩存中也有不俗的優先選擇,這樣Treebo在反復訪問時的啟動節省了一點時間。

接下來,Treebo想嘗試減少他們第三方插件包的大小和JS的執行時間,于是他們在生產環境將React換成了 Preact

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

你可以在你的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 - 手機”設置下運行的。 如果你想看看這些記錄,可以在 這里 找到它們。

加載占位圖

“加載占位圖本質上是內容逐漸加載的一個空白頁面。”

~Luke Wroblewski

Treebo想使用預覽組件(類似給每個組件添加加載占位圖)來加載占位。這個方法的本質就是給所有基礎組件(文本,圖片等)添加一個預覽組件,這樣一旦組件所需的數據源還沒加載出來,就會顯示組件對應的預覽組件。

例如,你正在上面這個列表中看到的酒店名稱,城市名稱,價格等內容,他們使用排版組件類似 ,添加兩個額外的prop, preview 和 previewStyle 來實現。

// 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接口返回成功,你就可以看到展示的數據了。

Webpack-bundle-analyzer

在這一點,Treebo想做打包分析,這樣可以找出一些低頻使用的包來優化。

注意:如果你在移動端使用了類似React的庫,經常優化你引入的第三方庫,是非常重要的。不這樣做可能會導致性能問題。考慮如何更好的打包你的第三方庫,這樣路由只會加載頁面所需要的庫

Treebo使用 webpack-bundle-analyzer 來跟蹤他們包的大小變化,并在每個路由塊中監視其中包含的模塊。他們也用它來發現可以優化減小包大小的地方,例如去掉moment.js的locales,復用深依賴。

使用webpack優化moment.js

Treebo在他們的日期操作重度依賴 moment.js 。當你引入了moment.js,并用webpack把它打包,你的包會包含所有moment.js,而它默認的語言包gizp之后都有約61.95kb。這嚴重增加了最終第三方庫打包完的包大小。

為了優化moment.js的大小,有 兩個webpack插件 可以用: IgnorePlugin , ContextReplacementPlugin

當Treebo不再需要任何語言包,他們選擇了IgnorePlugin來移除所有語言文件。

new webpack.IgnorePlugin(/^.\/locale$/, /moment$/)

去除了語言包后,moment.js打包后大小在gizp后降低到約16.48kb。

作為移除moment.js語言包的邊際影響力的最大改善,就是第三方包大小直接從179kb降到119kb。對于首屏加載時一個關鍵的包,60kb算是大幅度的下降。所有這些都意味著第一次交互時間的大幅度下降。你可以在 這里 閱讀更多關于優化moment.js。

復用深依賴

Treebo最開始使用“qs”模塊來進行查詢字符串操作。在webpack-bundle-analyzer分析的結果中,他們發現“react-router”中包含的“history”模塊中包含了“query-string”模塊。

因為這兩個不同的模塊都做了相同的操作,在他們源代碼中使用當前版本的“query-string”(就是當前安裝的)來替換“qs”,又讓他們的包gizp后減少2.72kb(也就是“qs”模塊的大小)。

Treebo是一個很好的開源參與者。他們使用來大量的開源軟件。作為回報,他們也把自己大部分的Webpack配置開源,包含了很多他們在生產環境的配置,可以作為一個模版。你可以在這里找到: https://github.com/lakshyaranganath/pwa

他們也承諾會盡量保持更新。隨著不斷完善,您可以把它們作為另一個PWA實現參考。

結尾和未來

Treebo知道,沒有什么應用是完美的,他們積極探索多種方法,不斷改進他們向用戶提供的經驗。其中一些:

懶加載圖片

有些人可能從之前的網絡瀑布圖中了解到,網站圖像下載是跟JS下載來競爭帶寬。

由于瀏覽器解析img標簽后立即觸發圖片下載,在JS下載過程中它們共享帶寬。 一個簡單的解決方案是當它們進入用戶視圖時懶加載圖片,這也可以減少我們的交互時間。

Lighthouse在視圖外圖片審查高亮了這些問題:

雙重引用

Treebo也意識到,雖然他們是異步加載應用的剩余CSS(在加載內聯對應路徑CSS之后),隨著他們的應用發展,從長遠來看,這種方法對用戶是不可行的。更多的迭代和頁面意味著更多的CSS和下載,這些都將導致帶寬占用和浪費。

借鑒 loadCSSbabel-plugin-dual-import 的實現方法,Treebo在各自的JS模塊中,并行異步執行import(‘chunkpath’)方法,再通過自定義實現的importCss(‘chunkname’)方法返回CSS模塊,以此改變加載CSS的方法。

// 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>`;
 },
// 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);
     });
 };
// 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));
     }}
 />

通過這種新方法,路由跳轉會進行兩個并行的異步請求,一個給JS,另一個給CSS,而不像之前所有的CSS都在DOMContentLoaded時被加載。對于用戶只會下載當前訪問頁面所需的CSS來說,這樣更可行。

A/B 測試

Treebo目前正在實施AB測試方法,包含服務器端渲染和代碼分割,以便在服務器端和客戶端渲染期間拉下用戶所需要的版本。 (Treebo將發布一篇關于他們如何解決這個問題的博文)。

預加載

理想中,為了避免對關鍵資源下載的流量爭用,Treebo不希望在頁面初始加載所有應用分割的模塊,對于移動端用戶,在下次訪問時,如果沒使用service-worker來緩存,也確實浪費寶貴的流量。如果我們看看Treebo在持續交互方面做的怎樣,仍然有許多空間可以改善:

這是他們正在嘗試改進的領域。 一個例子是在按鈕的波紋動畫期間預加載下一個路由模塊。 點擊時, Treebo使用webpack 動態import() 回調來加載下一個路由模塊,并用setTimeout延遲路由跳轉。 他們還希望確保下一個路由模塊足夠小,以便在緩慢的3g網絡上給定的400ms之內能加載完。

 

來自:http://www.zcfy.cc/article/a-react-and-preact-progressive-web-app-performance-case-study-treebo-4250.html

 

 本文由用戶 soryokurin 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!