做出Uber移動網頁版還不夠 極致性能打造才見真章
之前分享過幾篇關於 React 技術棧的原創文章:
- [React組件設計分解思考](React 組件設計和分解思考)
- [解析Twitter前端架構 學習複雜場景數據設計](解析Twitter前端架構 學習複雜場景數據設計)
- [React Conf 2017 乾貨總結1: React + ES next = ?](React Conf 2017 乾貨總結1: React + ES next = ?)
- [React+Redux打造「NEWS EARLY」單頁應用 一個項目理解最前沿技術棧真諦](React+Redux打造「NEWS EARLY」單頁應用 一個項目理解最前沿技術棧真諦)
- [一個react+redux工程實例](一個react+redux工程實例)
- ......
今天進一步剖析一個實際案例:Uber APP 移動網頁版。
如果你對 React 技術棧沒有多大興趣,或者不是很了解,也沒有關係。因為讀下來,你會發現,這篇文章的真諦其實在於性能優化上。
本文靈感和主體內容翻譯自 Narendra N Shetty 的文章(https://hackernoon.com/how-i-built-a-super-fast-uber-clone-for-mobile-web-863680d2100f),同時進行了大量擴充以及深挖。
出發點和產品雛形
很早以來,相信大家都會認同一個觀點:移動端流量超越 PC 端是不爭的事實。對於前端開發者來說,移動端web的開發同樣非常有趣,也充滿挑戰。
這不,Uber 最近發布了最新版本 APP,全新樣式,體驗超棒。於是,筆者決定使用 React 來從零開始構建一個新的屬於自己的 Uber 。
開發期間,筆者花費了很多時間在基礎組件和樣式搭建上。這環節中,主要應用了 Uber 官方開放的React地圖庫(uber/react-map-gl),並在地圖上「目的地」和「起始點」之間採用 svg-overlay 和 html-overlay 去繪製路線。
最終的基本交互可以參考下面Gif圖:
走上優化之路
現在,我們有基本的產品形態了。目前面臨的問題在於提高產品的各方面性能體驗。我使用了Chrome Lighthouse去檢驗產品的性能表現。最終得到的結果為:
wow...
第一次繪製時間就已經接近2秒,後面的時間慘不忍睹就不要看了吧。
想像一下,一個用戶拿出手機,企圖叫車。主屏時間的繪製就超過了 19189.9ms,這是極其不能忍受的。
接下來,什麼也不說了,擼起袖子,想辦法去優化吧。
優化方法1-代碼分離(Code Splitting)
我最開始想到並使用的方法就是:Code Splitting(代碼分離),正好我們可以藉助 webpack來實現這項技術。
什麼是webpack code splitting呢? 您可以參考這裡(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-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-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-Caching(緩存)
截止到此,我們已經從最初的 19189.9ms 已經優化到 546ms,我們當然繼續可以在客戶端進行靜態文件緩存來使得載入時間變得更短。
筆者使用了sw-toolbox(GoogleChrome/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 worke r的出現是正是為了使得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 的兼容性,可以參考這裡(http://caniuse.com/#search=preload)。
對於不支持 preload 的瀏覽器,筆者使用了 prefetch 來處理。
但與 preload 不同,prefetch 的作用是告訴瀏覽器載入下一頁面可能會用到的資源,注意,是下一頁面,而不是當前頁面。因此該方法的載入優先順序非常低。
這些新標準其實很有意思,裡面的內容遠不止這些。有興趣的同學可以自行了解,也歡迎與我討論。
回到正題,我在 head 標籤中使用:
<link rel="preload" ... as="script">
最終優化的結果如圖:
總結
其實,使用 React+Webpack 做出一個 Uber 已經不是重點了。真正激動人心的是整套流程的優化之路。我們使用了大量成熟的、未成熟(新技術),希望對讀者有所啟發!
Happy Coding!
PS: 作者[Github倉庫](https://github.com/HOUCe),歡迎通過代碼各種形式交流。
推薦閱讀: