為什麼做直出
就是為了“效能”!!! 按照經驗來說,直出,能夠減少20% - 50%不等的首屏時間,因此儘管增加一定維護成本,前端們還是前赴後繼地在搞直出。
除此之外,有些特定的業務做直出能夠彌補前後端分離帶來的SEO問題。像這次選取的騰訊新聞,大多數頁面首屏其實都是直出的(但肯定不是React直出)。
效能指標
剛提到的首屏時間,只是單純內容的渲染,另外還有首屏可互動時間,即除了內容渲染之餘,還能夠讓使用者能夠對首屏的內容進行互動,如點選、滾動等等。現在市面上有關React的效能報告,尤其是那些截了Chrome渲染映像的,都歸到首屏時間。
為什麼選擇騰訊新聞
- 我並非騰訊新聞的業務相關方,可以比較大膽地作為例子使用
- 騰訊新聞頁面更為豐富,可以做更多場景的實踐
- 驗證全套脫胎手Q家校群react的優化策略、實踐方案和開發工具
由於只是實驗,資料都是拉取騰訊新聞現網提供的,而樣式簡單地仿照了一下,做得略粗糙,請見諒。
參考的資料和使用的工具
做這次實踐閱讀了不少文章,文章提到過的內容我這裡就不再贅述了,後文主要是做補充。 這次同構直出實踐,我們使用的是脫胎於手Q家校群的react start kit,名曰steamer-react。目前可以試用。它有2個分支,一個是react分支,目前只是提供純前端的boilerplate。另一個是react-isomorphic,同時包括前端和後臺的boilerplate。有什麼問題可以給我提issue。
文章:
- React+Redux 同構應用開發
- React 同構實踐與思考
- React同構直出優化總結
- ReactJS 服務端同構實踐QQ音樂web團隊
- How to Implement Node + React Isomorphic JavaScript & Why it Matters
- 效能優化三部曲之三——Node直出讓你的網頁秒開
分析場景
這次我們選取的是騰訊新聞的列表頁、詳情頁和評論頁。平時我們瀏覽騰訊新聞的時候,都會發現從列表頁進詳情頁,或者從詳情頁進入評論頁,都需要跳轉,就像steamer-react中,訪問index.html頁一樣。這樣對於使用者體驗欠佳,因此我做了另外一版,spa.html,使用react + react-router做了一版無跳轉的單頁面應用。
- 列表頁
- 詳情頁
- 評論頁
可是單頁面應用在SEO的優化方面,處於略勢,因此對於新聞類業務來說,需要做直出來彌補。下面我們逐步來拆解React同構直出的步驟。
用Koa搭建後臺
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();
yield 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,你會更事半功倍。
如有錯誤,懇請斧正。