原文地址:https://github.com/lcxfs1991/blog/issues/10)
本文 starter kit:steamer-reactjavascript
就是爲了「性能」!!!
按照經驗來講,直出,可以減小20% - 50%不等的首屏時間,所以儘管增長必定維護成本,前端們仍是前赴後繼地在搞直出。css
除此以外,有些特定的業務作直出可以彌補先後端分離帶來的SEO問題。像此次選取的騰訊新聞,大多數頁面首屏其實都是直出的(但確定不是React直出)。html
剛提到的首屏時間,只是單純內容的渲染,另外還有首屏可交互時間,即除了內容渲染之餘,還可以讓用戶可以對首屏的內容進行交互,如點擊、滾動等等。如今市面上有關React的性能報告,尤爲是那些截了Chrome渲染映像的,都歸到首屏時間。前端
我並不是騰訊新聞的業務相關方,能夠比較大膽地做爲例子使用java
騰訊新聞頁面更爲豐富,能夠作更多場景的實踐node
驗證全套脫胎手Q家校羣react的優化策略、實踐方案和開發工具react
因爲只是實驗,數據都是拉取騰訊新聞現網提供的,而樣式簡單地仿照了一下,作得略粗糙,請見諒。webpack
作此次實踐閱讀了很多文章,文章提到過的內容我這裏就再也不贅述了,後文主要是作補充。
此次同構直出實踐,咱們使用的是脫胎於手Q家校羣的react start kit,名曰steamer-react。目前能夠試用。它有2個分支,一個是react分支,目前只是提供純前端的boilerplate。另外一個是react-isomorphic,同時包括前端和後臺的boilerplate。有什麼問題能夠給我提issue。git
文章:github
此次咱們選取的是騰訊新聞的列表頁、詳情頁和評論頁。平時咱們瀏覽騰訊新聞的時候,都會發現從列表頁進詳情頁,或者從詳情頁進入評論頁,都須要跳轉,就像steamer-react中,訪問index.html頁同樣。這樣對於用戶體驗欠佳,所以我作了另一版,spa.html,使用react + react-router作了一版無跳轉的單頁面應用。
列表頁
詳情頁
評論頁
但是單頁面應用在SEO的優化方面,處於略勢,所以對於新聞類業務來講,須要作直出來彌補。下面咱們逐步來拆解React同構直出的步驟。
AlloyTeam團隊目前以Koa爲基礎搭建了玄武直出平臺,目前很多手Q基礎的web業務也有接入,包括早前作過同構優化的手Q家校羣列表頁。是次實踐,在steamer-react下面新建了一個node文件夾,存放後臺服務。後臺服務包括返回數據的api,還有直出的controller層。controller層仿照玄武的寫法,對於騰訊內的同事,作適當修改即可以快速接入玄武直出平臺,對於騰訊外的,也能夠做有用的參照,嵌入本身的業務也不費什麼功夫。
那直出的controller層具體怎麼寫呢?
直出controller層和數據返回的api都一概寫在controller.js裏面,而後去require存放在node/asset/下面具體直出邏輯文件,而後將yield出來的值直接吐出來:
exports.spa = function* () { let dir = path.dirname(path.resolve()), appPath = path.join(dir, '/pub/node/index.js'); if (fs.existsSync(appPath)) { // 若asset中無此文件,則輸出其它值 var ReactRender = require(appPath); yield ReactRender(this.request, this.response); // 給ReactRender函數傳入request和response this.body = this.response.body; } else { this.body = "spa list"; } };
而ReactRender函數,大概長這樣,其實就是一個generator function,具體拉取數據和React同構渲染的邏輯都寫在這裏面。
module.exports = function* (req, res) { // some code }
你直接寫好的邏輯,有很多可能node並不識別,例如import, window對象等,這些須要構建去處理,後文會有論述。
其實整個直出過程很是簡單。基本就是三部曲,拉數據、存數據和吐內容。
拉數據這裏封裝了一個requestSync的庫,能夠直接經過yield對request庫作同步的寫法:
// requestSync.js var request = require('request'); exports.requestSync = function(option) { return function(callback) { request(option, function (error, response, body) { callback(error, response); }); }; } ; // 拉數據邏輯 var response = yield requestSync.requestSync({ uri: CGI_PATH['GET_TOP_NEWS'] + urlParam, method: 'GET' });
// 在編譯的時候,你可能會發現require('request')
報錯,這是由於你缺乏了一些babel插件。但也有另一個辦法讓你去尋找一個不知名的babel插件。我改用plugin('requestSync')而不是require。由於require會直接去讀取node_modules包的內容,plugin並不會編譯,它會保留原樣,等Koa讀取的時候再實時運行。plugin實質是定義在global全局變量裏的一個函數,而後將它nodeUtils在controller.js中require進來,就能達到保留原樣的效果。
// 直出邏輯 var requestSync = plugin('requestSync'); // nodeUtils.js global.plugin = function(pkg) { return require('./' + pkg); } // controller.js var nodeUtils = require('../common/nodeUtils');
因爲咱們採用redux作統一數據的處理,所以咱們須要將數據存一份到store裏,以便後面吐內容。
const store = configureStore(); store.dispatch({ type: 'xxx action', data: response.body, param:{ } });
若是咱們沒有使用react-router,咱們直接將store存給最主要的React Component,而後就能夠開始直出了,像這樣:
import { renderToString } from 'react-dom/server'; var Root = React.createFactory(require('Root').default); ren html = renderToString(Root(store.getState()));
但若是咱們使用了react-router,咱們就須要引用react-router比較底層的match來作路徑匹配和內容吐出。
import { match, RouterContext } from 'react-router'; import { routeConfig } from 'routes'; match({ routes: routeConfig, location: req.url }, (error, redirectLocation, renderProps) => { if (renderProps) { reactHtml = renderToString( <Provider store={store}> <RouterContext {...renderProps} /> </Provider> ); } else { res.body = "404"; } });
客戶端也須要作相似的寫法,且咱們不採用hashHistory,而是browserHistory
let history = syncHistoryWithStore(browserHistory, store); const { pathname, search, hash } = window.location; const location = `${pathname}${search}${hash}`; match({ routes: routeConfig, location: location }, () => { render( <Provider store={store}> // Redux相關 <div> <Router routes={routeConfig} history={history} /> // Router 相關 </div> </Provider>, document.getElementById('pages') ) });
在吐內容(html)的同時,請記得將store也吐一份到<script>
標籤裏,由於客戶端的js中也須要用到。
在首次吐出內容以後,你會發現還不能立刻進行交互,須要客戶端再次執行一行Root.js裏面的代碼,纔可以將可交互的事件綁定。
前端的代碼改動不大,不過前端這裏主要完成最後關鍵的一步,事件掛載。
後臺渲染完後,給客戶端吐出html字符串,這時尚未任何事件的綁定,須要客戶端的代碼進行事件掛載,這裏須要注意2點:
保持dom結構一致
不然會報錯或者觸發從新渲染
將部份事件放到componentDitMount中觸發
服務端的生命週期只走到componentWillMount,而客戶端則會有完整的生命週期,所以部份事件能夠挪到componentDidMount中處理。例如此次實踐作的列表頁有一個個人收藏功能,這裏的數據存儲用到localstorage。這個服務端沒法渲染,所以會選擇在componentDidMount的時候再去觸發讀取localstorage數據的action。
兼顧後臺沒有的對象
除了以上提到的,前端部份的代碼主要注意的是一些後臺沒有的對象,例如window。能夠經過構建手段注入全局變量去替換或者在服務端渲染的時候不執行部份代碼。
react-isomorphic比react的分支多了一個webpack.node.js,用於設置直出的相關構建內容。一些須要留意的配置以下:
target: 'node', // 構建輸出node能夠識別的內容 node: { __filename: true, __dirname: true }, { test: /\.js?$/, loader: 'babel', query: { cacheDirectory: '/webpack_cache/', plugins: [ 'transform-decorators-legacy', [ "transform-runtime", { "polyfill": false, "regenerator": true // 識別regenerator } ] ], presets: [ 'es2015-loose', 'react', ] }, exclude: /node_modules/, }, { test: /\.css$/, loader: "ignore-loader", // ignore-loader對css/scss輸出空內容 }, plugins: [ new webpack.BannerPlugin("module.exports = ", {entryOnly : true, raw: true}), // react/node/asset/下的文件生產到/react/pub/node/以後,須要在最前面注入module.exports, // 這樣Koa才能正常引用 ]
以下面兩圖,是直出先後的Chrome映像對比圖,直出要比非直出快400ms,近40%的性能提高。除了直出以外,還採用了react-router,使頁面能夠無縫切換,大大提升了用戶的體驗。你可能還會擔憂這麼多頁面的邏輯放在一個js bundle會讓js很大,若是js bundle膨脹到必定程度,你能夠考慮使用webpack和react-router的特性進行拆包。
可能你會驚詫於習慣寫長文的我竟然只寫這麼少,但React同構下出真的就是這麼簡單,而藉助脫胎於手Q家校羣,驗證於騰訊新聞的steamer-react start kit,你會更事半功倍。
若有錯誤,懇請斧正。