做出Uber移動網頁版還不夠 極致效能打造才見真章

LucasHC發表於2019-02-25

之前分享過幾篇關於React技術棧的原創文章:

今天進一步剖析一個實際案例:Uber APP 移動網頁版。

如果你對React技術棧沒有多大興趣,或者不是很瞭解,也沒有關係。因為讀下來,你會發現,這篇文章的真諦其實在於效能優化上。

本文靈感和主體內容翻譯自Narendra N Shetty的文章:How I built a super fast Uber clone for mobile web,同時進行了大量擴充以及深挖。

出發點和產品雛形

很早以來,相信大家都會認同一個觀點:移動端流量超越PC端是不爭的事實。對於前端開發者來說,移動端web的開發同樣非常有趣,也充滿挑戰。

這不,Uber最近釋出了最新版本APP,全新樣式,體驗超棒。於是,筆者決定使用React來從零開始構建一個新的屬於自己的Uber。

開發期間,筆者花費了很多時間在基礎元件和樣式搭建上。這環節中,主要應用了Uber官方開放的React地相簿,並在地圖上“目的地”和“起始點”之間採用svg-overlay和html-overlay去繪製路線。

最終的基本互動可以參考下面Gif圖:

做出Uber移動網頁版還不夠 極致效能打造才見真章
uber.gif

走上優化之路

現在,我們有基本的產品形態了。目前面臨的問題在於提高產品的各方面效能體驗。我使用了Chrome Lighthouse去檢驗產品的效能表現。最終得到的結果為:

做出Uber移動網頁版還不夠 極致效能打造才見真章
結果1.png

wow...
第一次繪製時間就已經接近2秒,後面的時間慘不忍睹就不要看了吧。
想象一下,一個使用者拿出手機,企圖叫車。主屏時間的繪製就超過了19189.9ms,這是極其不能忍受的。

接下來,什麼也不說了,擼起袖子,想辦法去優化吧。

優化方法1-程式碼分離(Code Splitting)

我最開始想到並使用的方法就是:Code Splitting(程式碼分離),正好我們可以藉助webpack來實現這項技術。
什麼是webpack code splitting呢? 您可以參考這裡,如果英語閱讀吃力,可以參考下面引文:

code splitting就是指將檔案分割為塊(chunk),webpack使我們可以定義一些分割點(split point),根據這些分割點對檔案進行分塊,並實現按需載入。

因為筆者使用了React技術棧,並採用了react-router,所以程式碼的劃分(split)就可以按照路由和載入時機進行。具體操作可以使用react-router的getComponent api來實現:

<Route path="home" name="home" getComponent={(nextState, cb) => {
    require.ensure([], (require) => {
        cb(null, require('../components/Home').default);
    }, 'HomeView');
}}> 複製程式碼

只有當對應路由被請求時,相應的元件才會被載入呈現。

同時,筆者使用了webpack的CommonChunkPlugin外掛提取第三方程式碼。這是出於什麼考慮呢?

細心的讀者可能會發現上面的code splitting也許會存在一個問題:
按需(按路由)引入資源後,這些資源可能存在大量重複程式碼。尤其是我們使用的第三方資源。
想明白這個問題,這時候,你應該就會明白CommonChunkPlugin這個外掛的意義了。關於這個外掛配置方法有多種,這裡我們採用了:有選擇性的提取(物件方式傳參):

{
    'entry': {
        'app': './src/index.js',
        'vendor': [
            'react',
            'react-redux',
            'redux',
            'react-router',
            'redux-thunk'
        ]
    },
    'output': {
        'path': path.resolve(__dirname, './dist'),
        'publicPath': '/',
        'filename': 'static/js/[name].[hash].js',
        'chunkFilename': 'static/js/[name].[hash].js'
    },
    'plugins': [
        new webpack.optimize.CommonsChunkPlugin({
            name: ['vendor'], // 公共塊的塊名稱
            minChunks: Infinity, // 最小被引用次數,最小是2。傳遞Infinity只是建立公共塊,但不移動模組。 
            filename: 'static/js/[name].[hash].js', // 公共塊的檔名
        }),
    ]
}複製程式碼

這樣子,我們把公共程式碼(react、react-redux、redux、react-router、redux-thunk)專門抽取到vendor模組中。

通過上述方法,筆者欣喜地發現:
First meaningful paint時間由19189.9ms縮短到4584.3ms:

做出Uber移動網頁版還不夠 極致效能打造才見真章
結果2

這無疑是激動人心的。

優化方法2-Server side rendering(服務端直出)

也許你一直在聽說過“服務端渲染”或者“服務端直出”這樣的名詞。但是從未實踐過,也從來沒有了解過他的意義。好吧,這裡我先描述一下,到底什麼是服務端直出。

服務端直出,其實簡單總結為伺服器在接到來自瀏覽器第一次請求時,便返回一個“初步最終”HTML文件。這個HTML文件已經進行了資料拼接。這樣使用者能以最快的時間看到首屏的效果,當然這個效果是“閹割版”的,非最終版本。

這種方式主要是針對“前後分離”的傳統模式。傳統模式中,伺服器返回HTML文件,之後瀏覽器解析文件標籤,拉取CSS,之後拉取JS檔案。JS檔案載入完成之後,執行JS內容,併傳送請求獲取資料。最終,將資料渲染在頁面上。

由此,Server side rendering方式將JS請求資料的過程放在了伺服器上,甚至對於資料與HTML結合處理也可以在伺服器上做。

這樣一來,主要就是加快了首屏渲染時間。當然,使用服務端渲染,還能夠優化前端渲染難以克服的SEO問題。

理論理解起來很簡單,難處就在於伺服器端環境的前端指令碼如何處理,如何與客戶端保持一致。

在這個專案中,我使用了Express作為nodeJS框架,結合react-router完成:

server.use((req, res)=> {
    match({
    'routes': routes,
    'location': req.url
    }, (error, redirectLocation, renderProps) => {
        if (error) {
            res.status(500).send(error.message);
        } 
        else if (redirectLocation) {
            res.redirect(302, redirectLocation.pathname + redirectLocation.search);
        } 
        else if (renderProps) {
            // Create a new Redux store instance
            const store = configureStore();

            // Render the component to a string
            const html = renderToString(<Provider store={store}><RouterContext {...renderProps} /></Provider>);

            const preloadedState = store.getState();

            fs.readFile('./dist/index.html', 'utf8', function (err, file) {
                if (err) {
                    return console.log(err);
                }
                let document = file.replace(/<div id="app"><\/div>/, `<div id="app">${html}</div>`);
                document = document.replace(/'preloadedState'/, `'${JSON.stringify(preloadedState)}'`);
                res.setHeader('Cache-Control', 'public, max-age=31536000');
                res.setHeader("Expires", new Date(Date.now() + 2592000000).toUTCString());
                res.send(document);
            });
        } 
        else {
            res.status(404).send('Not found')
        }
    });
});複製程式碼

通過上述方法,我們欣喜地發現:
First meaningful paint時間已經縮短到921.5ms:

做出Uber移動網頁版還不夠 極致效能打造才見真章
結果3

這無疑是令人振奮的。

優化方法3-Compressed static assets(壓縮靜態檔案)

壓縮檔案,當然是一個容易想到而且行之有效的措施。為此,我使用了webpack的CompressionPlugin外掛:

{
    'plugins': [
        new CompressionPlugin({
            test: /\.js$|\.css$|\.html$/
        })
    ]
}複製程式碼

同時,使用express-static-gzip來對服務端進行配置:

server.use('/static', expressStaticGzip('./dist/static', {
    'maxAge': 31536000,
    setHeaders: function(res, path, stat) {
    res.setHeader("Expires", new Date(Date.now() + 2592000000).toUTCString());
        return res;
    }
}));複製程式碼

express-static-gzip是一個處於express.static之上的中介軟體。如果對於指定路徑的檔案沒有找到壓縮版本,就使用為壓縮版本進行返回。

經過此處理,我們縮短了400ms時間,OK,現在First meaningful paint時間為546.6ms.

做出Uber移動網頁版還不夠 極致效能打造才見真章
結果4

優化方法4-Caching(快取)

截止到此,我們已經從最初的19189.9ms已經優化到546ms,我們當然繼續可以在客戶端進行靜態檔案快取來使得載入時間變得更短。

筆者使用了sw-toolbox搭配service workers進行。

sw-toolbox:A collection of service worker tools for offlining runtime requests.
Service Worker Toolbox provides some simple helpers for use in creating your own service workers. Specifically, it provides common caching strategies for dynamic content, such as API calls, third-party resources, and large or infrequently used local resources that you don't want precached.

簡單翻譯下:
Service Worker實現常見執行時快取模式,例如動態內容、API呼叫以及第三方資源,實現方法就像編寫README一樣簡單。

也許到這裡你一頭霧水,沒關係,我們從最初開始,瞭解一下什麼是service worker:

在2014年,W3C公佈了service worker的草案,service worker提供了很多新的能力,使得web app擁有與native app相同的離線體驗、訊息推送體驗。
service worker是一段指令碼,與web worker一樣,也是在後臺執行。
作為一個獨立的執行緒,執行環境與普通指令碼不同,所以不能直接參與web互動行為。native app可以做到離線使用、訊息推送、後臺自動更新,service worker的出現是正是為了使得web app也可以具有類似的能力。

而sw-toolbox,顧名思義,就是service worker一個toolbox,具體我們看程式碼:

toolbox.router.get('(.*).js', toolbox.fastest, {
    'origin':/.herokuapp.com|localhost|maps.googleapis.com/,
    'mode':'cors',
    'cache': {
        'name': `js-assets-${VERSION}`,
        'maxEntries': 50,
        'maxAgeSeconds': 2592e3
    }
});複製程式碼

上面程式碼的意思是,我們對於get型別的請求,當請求內容為js指令碼時,應用toolbox.fastest handler處理。
toolbox.fastest指示:對於這個請求,我們既從快取中獲取,也同時通過正常的請求network獲取。這兩種方式哪個返回快,就應用哪一個。
另外,toolbox.router.get的第三個參數列示配置項。

考慮周到的讀者可能會想,上面是對於支援Service worker的瀏覽器,那麼對於不支援的瀏覽器呢?我們乾脆設定:

res.setHeader("Expires", new Date(Date.now() + 2592000000).toUTCString());複製程式碼

通過這樣處理,我們來直觀感受一下頁面載入瀑布流:

做出Uber移動網頁版還不夠 極致效能打造才見真章
使用Service worker

做出Uber移動網頁版還不夠 極致效能打造才見真章
不使用Service worker

優化方法5-Preload and then load(預載入/延後載入)

如果你還沒聽說過“Preload”,不要緊。我們這就來了解一下:

Preload作為一個新的web標準,旨在提高效能和為web開發人員提供更細粒度的載入控制。Preload使開發者能夠自定義資源的載入邏輯,且無需忍受基於指令碼的資源載入器帶來的效能損失。

換成你能聽明白的話來說:
preload建議允許始終預載入某些資源,瀏覽器必須請求preload標記的資源。

這樣子,究竟有什麼意義呢?
舉個例子:比如一些隱藏在CSS和Javascript中的資源。
當瀏覽器發現自己需要這些資源時已經為時已晚,所以大多數情況,這些資源的載入都會對頁面渲染造成延遲。

preload的出現就是為了優化這個過程。
對於preload的相容性,可以參考這裡。

對於不支援preload的瀏覽器,筆者使用了prefetch來處理。
但於preload不同,prefetch的作用是告訴瀏覽器載入下一頁面可能會用到的資源,注意,是下一頁面,而不是當前頁面。因此該方法的載入優先順序非常低。

這些新標準其實很有意思,裡面的內容遠不止這些。有興趣的同學可以自行了解,也歡迎與我討論。

回到正題,我在head標籤中使用:

<link rel="preload" ... as="script">複製程式碼

最終優化的結果如圖:

做出Uber移動網頁版還不夠 極致效能打造才見真章
最終結果

總結

其實,使用React+Webpack做出一個Uber已經不是重點了。真正激動人心的是整套流程的優化之路。我們使用了大量成熟的、未成熟(新技術),希望對讀者有所啟發!

Happy Coding!

PS: 作者Github倉庫,歡迎通過程式碼各種形式交流。

相關文章