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

之前分享過幾篇關于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.gif

走上優化之路

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

結果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:

結果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:

結果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.

結果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());

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

使用Service worker
不使用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">

最終優化的結果如圖:

最終結果

總結

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

Happy Coding!

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容