服務端渲染與 Universal React App

隨著 Webpack 等客戶端構建工具的普及,純粹的客戶端渲染因為其節約成本,部署簡單等方面的優勢,逐漸成為了現代網站的主流渲染模式。而在剛剛發布的 React v16.0 中,改進后更為優秀的服務端渲染性能作為六大更新點之一,被 React 官方重點提及。為此筆者還專門做了一個小調查,分別詢問了二十位國內外(國內國外各十位)前端開發者,希望能夠了解一下服務端渲染在使用 React 公司中所占的比例。
出人意料的是,在十位國內的前端開發者中,生產環境使用服務端渲染的只有三位。而在國外的前端開發者中,使用服務端渲染的達到了驚人的八位。
筆者不禁開始思考,同是作為 React 的深度使用者,為什么國內外前端開發者在服務端渲染這個 React 核心功能的使用率上有著如此巨大的差別?在經過又一番刨根問底地詢問后,真正的答案逐漸浮出水面,那就是可更靠的 SEO(搜索引擎優化)。
相比較而言,國外公司對于 SEO 的重視程度要遠遠高于國內公司,在這方面積累的經驗也要遠遠多于國內公司,前端頁面上需要服務端塞入的內容也絕不僅僅是用戶所看到的那些而已。所以對于國外的前端開發者來說,除去公司的內部系統不談,所有客戶端應用都需要做大量的 SEO 工作,服務端渲染也就順理成章地成為了一個必選項。
除去 SEO,服務端渲染對于前端應用的首屏加載速度也有著質的提升。特別是在 React v16.0 發布之后,新版 React 的服務端渲染性能相較于老版又提升了三倍之多,這讓已經在生產環境中使用服務端渲染的公司“免費”獲得了一次網站加載速度提升的機會,也吸引了許多還未在生產環境中使用服務端渲染的開發者。
客戶端渲染 vs 服務端渲染 vs 同構
在深入服務端渲染的細節之前,讓我們先明確幾個概念的具體含義。
- 客戶端渲染: 頁面在 JavaScript,CSS 等資源文件加載完畢后開始渲染,路由為客戶端路由,也就是我們經常談到的 SPA(Single Page Application)。
- 服務端渲染: 頁面由服務端直接返回給瀏覽器,路由為服務端路由,URL 的變更會刷新頁面,原理與 ASP,PHP 等傳統后端框架類似。
- 同構: 英文表述為 Isomorphic 或 Universal,即編寫的 JavaScript 代碼可同時運行于瀏覽器及 Node.js 兩套環境中,用服務端渲染來提升首屏的加載速度,首屏之后的路由由客戶端控制,即在用戶到達首屏后,整個應用仍是一個 SPA。
在明確了這三種渲染方案的具體含義后,我們可以發現,不論是客戶端渲染,還是服務端渲染,都有著其明顯的缺陷,而同構顯然是結合了二者優點之后的一種更好的解決方案。
但想在客戶端寫出一套完全符合同構要求的 React 代碼并不是一件容易的事,與此同時還需要額外部署一套穩定的服務端渲染服務,這二者相加起來的開發或遷移成本都足以擊潰許多想要嘗試服務端渲染的 React 開發者的信心。
那么今天,就讓我們來一起總結下符合同構要求的 React 代碼都有哪些需要注意的地方,以及如何搭建起一個基礎的服務端渲染服務。
總體架構
為了方便各位理解同構的具體實現過程,筆者基于 react v16.0,react-router v4,redux 以及 webpack3 實現了一個簡單的 腳手架項目 ,支持客戶端渲染和服務端渲染兩種開發方式,供各位參考。

渲染流程圖
- 服務端預先獲取編譯好的客戶端代碼及其他資源。
- 在服務端接收到用戶的 HTTP 請求后,觸發服務端的路由分發,將當前請求送至服務端渲染模塊處理。
- 服務端渲染模塊根據當前請求的 URL 初始化 memory history 及 redux store。
- 根據路由獲取渲染當前頁面所需要的異步請求(thunk)并獲取數據。
- 調用 renderToString 方法渲染 HTML 內容并將已包含數據的 redux store 塞入 HTML 中,供客戶端渲染時使用。
- 此時,客戶端將直接收到服務端返回的已渲染完畢的當前頁面并開始同步加載客戶端 JavaScript,CSS,圖片等其他資源。
- 之后的流程與客戶端渲染完全相同,客戶端初始化 redux store,路由找到當前頁面的組件,觸發組件的生命周期函數,再次獲取數據并渲染。唯一不同的是 redux store 的初始狀態將由服務端在 HTML 中塞入的數據提供,以保證客戶端渲染時可以得到與服務端渲染相同的結果。
在了解了同構的大致思路后,接下來我們再對同構中需要注意的點逐一進行分析,與各位一起探討同構的最佳實踐。
客戶端與服務端構建腳本不同
因為運行環境與渲染目的的不同,共用一套代碼的客戶端與服務端在構建方面有著許多的不同之處。
入口(entry)不同
客戶端的入口為 ReactDOM.render 所在的文件,即將 react 組件掛載在 DOM 節點上。而服務端因為沒有 DOM 的存在,只需要拿到將要渲染的 react 組件即可。為此我們需要在客戶端抽離出獨立的 createApp 及 createStore 的方法。
createApp.js
import React from 'react';
import { Provider } from 'react-redux';
import Router from './router';
const createApp = (store, history) => (
<Provider store={store}>
<Router history={history} />
</Provider>
);
export default createApp;
createStore.js
import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
import { routerReducer, routerMiddleware } from 'react-router-redux';
import reduxThunk from 'redux-thunk';
import reducers from './reducers';
import routes from './router/routes';
function createAppStore(history, preloadedState = {}) {
// enhancers
let composeEnhancers = compose;
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-underscore-dangle
composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
}
// middlewares
const routeMiddleware = routerMiddleware(history);
const middlewares = [
routeMiddleware,
reduxThunk,
];
const store = createStore(
combineReducers({
...reducers,
router: routerReducer,
}),
preloadedState,
composeEnhancers(applyMiddleware(...middlewares)),
);
return {
store,
history,
routes,
};
}
export default createAppStore;
并在 app 文件夾中將這兩個方法一起輸出出去:
import createApp from './createApp';
import createStore from './createStore';
export default {
createApp,
createStore,
};
出口(output)不同
為了最大程度地提升用戶體驗,在客戶端渲染時我們將根據路由對代碼進行分拆,但在服務端渲染時,確定某段代碼與當前路由之間的關系是一件非常繁瑣的事情,所以我們選擇將所有客戶端代碼打包成一個完整的 js 文件供服務端使用。
理想的打包結果如下:
├── build
│ └── v1.0.0
│ ├── assets
│ │ ├── 0.0.257727f5.js
│ │ ├── 0.0.257727f5.js.map
│ │ ├── 1.1.c3d038b9.js
│ │ ├── 1.1.c3d038b9.js.map
│ │ ├── 2.2.b11f6092.js
│ │ ├── 2.2.b11f6092.js.map
│ │ ├── 3.3.04ff628a.js
│ │ ├── 3.3.04ff628a.js.map
│ │ ├── client.fe149af4.js
│ │ ├── client.fe149af4.js.map
│ │ ├── css
│ │ │ ├── style.db658e13004910514f8f.css
│ │ │ └── style.db658e13004910514f8f.css.map
│ │ ├── images
│ │ │ └── 5d5d9eef.svg
│ │ ├── vendor.db658e13.js
│ │ └── vendor.db658e13.js.map
│ ├── favicon.ico
│ ├── index.html
│ ├── manifest.json
│ └── server (服務端需要的資源將被打包至這里)
│ ├── assets
│ │ ├── server.4b6bcd12.js
│ │ └── server.4b6bcd12.js.map
│ └── manifest.json
使用的插件(plugin)不同
與客戶端不同,除去 JavaScript 之外,服務端并不需要任何其他的資源,如 HTML 及 CSS 等,所以在構建服務端 JavaScript 時,諸如 HtmlWebpackPlugin 等客戶端所特有的插件就可以省去了,具體細節各位可以參考項目中的 webpack.config.js 。
數據獲取方式不同
異步數據獲取一直都是服務端渲染做得不夠優雅的一個地方,其主要的問題在于無法直接復用客戶端的數據獲取方法。如在 redux 的前提下,服務端沒有辦法像客戶端那樣直接在組件的componentDidMount 中調用 action 去獲取數據。
為了解決這一問題,我們針對每一個 view 為其抽象出了一個 thunk 文件,并將其綁定在客戶端的路由文件中。這樣我們就可以在服務端通過 react-router-config 提供的 matchRoutes 方法找到當前頁面的 thunk,并在 renderToString 之前 dispatch 這些異步方法,將數據更新至 redux store 中,以保證 renderToString 的渲染結果是包含異步數據的。
// thunk.js
import homeAction from '../home/action';
import action from './action';
const thunk = store => ([
store.dispatch(homeAction.getMessage()),
store.dispatch(action.getUser()),
]);
export default thunk;
// createAsyncThunk.js
import get from 'lodash/get';
import isArrayLikeObject from 'lodash/isArrayLikeObject';
function promisify(value) {
if (typeof value.then === 'function') {
return value;
}
if (isArrayLikeObject(value)) {
return Promise.all(value);
}
return value;
}
function createAsyncThunk(thunk) {
return store => (
thunk()
.then(component => get(component, 'default', component))
.then(component => component(store))
.then(component => promisify(component))
);
}
export default createAsyncThunk;
// routes.js
const routes = [{
path: '/',
exact: true,
component: AsyncHome,
thunk: createAsyncThunk(() => import('../../views/home/thunk')),
}, {
path: '/user',
component: AsyncUser,
thunk: createAsyncThunk(() => import('../../views/user/thunk')),
}];
服務端核心的頁面渲染模塊:
import ReactDOM from 'react-dom/server';
import { matchRoutes } from 'react-router-config';
import { Helmet } from 'react-helmet';
import createHistory from 'history/createMemoryHistory';
import get from 'lodash/get';
import map from 'lodash/map';
import head from 'lodash/head';
import { getClientInstance } from '../client';
// Initializes the store with the starting url from request.
function configureStore(req, client) {
console.log('server path', req.originalUrl);
const history = createHistory({ initialEntries: [req.originalUrl] });
const preloadedState = {};
return client.app.createStore(history, preloadedState);
}
// This essentially starts passing down the "context"
// object to the Promise "then" chain.
function setContextForThenable(context) {
return () => context;
}
// Prepares the HTML string and the appropriate headers
// and subequently string replaces them into their placeholders
function renderToHtml(context) {
const { client, store, history } = context;
const appObject = client.app.createApp(store, history);
const appString = ReactDOM.renderToString(appObject);
const helmet = Helmet.renderStatic();
const initialState = JSON.stringify(store.getState()).replace(/</g, '\ ');
context.renderedHtml = client
.html()
.replace(/<!--appContent-->/g, appString)
.replace(/<!--appState-->/g, `<script>window.__INITIAL_STATE__ = ${initialState}</script>`)
.replace(/<\/head>/g, [
helmet.title.toString(),
helmet.meta.toString(),
helmet.link.toString(),
'</head>',
].join('\n'))
.replace(/<html>/g, `<html ${helmet.htmlAttributes.toString()}>`)
.replace(/<body>/g, `<body ${helmet.bodyAttributes.toString()}>`);
return context;
}
// SSR Main method
// Note: Each function in the promise chain beyond the thenable context
// should return the context or modified context.
function serverRender(req, res) {
const client = getClientInstance(res.locals.clientFolders);
const { store, history, routes } = configureStore(req, client);
const branch = matchRoutes(routes, req.originalUrl);
const thunk = get(head(branch), 'route.thunk', () => {});
Promise.resolve(null)
.then(() => (thunk(store)))
.then(setContextForThenable({ client, store, history }))
.then(renderToHtml)
.then((context) => {
res.send(context.renderedHtml);
return context;
})
.catch((err) => {
console.log(`SSR error: ${err}`);
});
}
export default serverRender;
在客戶端,我們可以直接在 componentDidMount 中調用這些 action:
const mapDispatchToProps = {
getUser: action.getUser,
getMessage: homeAction.getMessage,
};
componentDidMount() {
this.props.getMessage();
this.props.getUser();
}
在分離了服務端與客戶端 dispatch 異步請求的方式后,我們還可以針對性地對服務端的 thunk 做進一步的優化,即只請求首屏渲染所需要的數據,剩下的數據交給客戶端在 js 加載完畢后再請求。
但這里又引出了另一個問題,比如在上面的例子中,getUser 和 getMessage 這兩個異步請求分別在服務端與客戶端各請求了一次,即我們在很短的時間內重復請求了同一個接口兩次,這又是為什么呢?
原因其實也很簡單,就是在同構的前提下,我們并不知道用戶訪問客戶端的某個頁面時,是從服務端路由來的,還是從客戶端路由來的,也就是說如果我們不在組件的 componentDidMount 中去獲取異步數據的話,一旦用戶到達了某個頁面,再點擊頁面中的某個元素跳轉至另一頁面時,是不會觸發服務端的數據獲取的,因為這時走的實際上是客戶端路由。
再近一步來說,我們能不能直接復用服務端所使用的頁面 thunk.js 文件呢?答案當然是可以的,除去在 componentDidMount 時獲取數據,我們還可以在客戶端路由切換時直接調用 thunk.js 去更新 redux store。在這一點上,相較于 react-router,另一個路由庫 redux-first-router 做得要更好一些,與 redux 的契合度也更高一些。在配置完 routesMap 之后,redux-first-router 會在路由改變時自動 dispatch thunk 以獲取數據,我們甚至不需要將這些異步的 action 綁定到 view 上去。
服務端渲染還能做些什么
除去 SEO 與首屏加速,在額外部署了一套服務端渲染服務后,我們當然希望它能為我們分擔更多的事情,那么究竟有哪些事情放在服務端去做是更為合適的呢?筆者總結了以下幾點。
初始化應用狀態
除去獲取當前頁面的數據,在做了同構之后,客戶端還可以將獲取應用全局狀態的一些請求也交給服務端去做,如獲取當前時區,語言,設備信息,用戶等通用的全局數據。這樣客戶端在初始化 redux store 時就可以直接獲取到上述數據,從而加快其他頁面的渲染速度,同時在分離了這部分業務邏輯到服務端之后,客戶端的業務邏輯也將變得更為簡單與清晰。當然,如果你想做一個純粹的 Universal App,也可以把初始化應用狀態封裝成一個方法,讓服務端與客戶端都可以自由地去調用它。
更早的路由處理
相較于客戶端,服務端可以更早地對當前 URL 進行一些業務邏輯上的判斷。比如 404 時,服務端可以直接將另一個 error.html 的模板直接發送至客戶端,用戶也就可以在第一時間收到相應的反饋,而不需要等到所有 JavaScript 等加載完畢之后,才看到由客戶端渲染的 404 頁面。又或是 302 等其他一些特殊情況,服務端都可以更早地做出應對。
其他
有了服務端渲染這一層后,服務端還可以幫助客戶端向 Cookie 里注入一些后端 API 中沒有的數據,甚至做一些接口聚合,數據格式化的工作。這時,我們所寫的 Node.js 服務端就不再是一個單純的渲染服務了,而是進化為了一個 Node.js 中間層,可以幫助客戶端完成許多在客戶端做不到或很難做到的事情。
要不要做同構
在分析了同構的具體實現細節并了解了同構的好處之后,我們也需要知道這一切的好處并不是沒有代價的,而同構或者說服務端渲染最大的瓶頸就是性能及容量。
在用戶規模大到一定程度之后,客戶端渲染本身就是一個完美的分布式系統,我們可以充分地利用用戶的電腦去運行 JavaScript 中那些復雜的運算,而服務端渲染卻將這些工作全部攬了回來并加到了網站自己的服務器上。
所以,考慮到投入產出比,同構可能并不適用于前端需要大量計算(如包含大量的圖表或儀表盤的頁面等)且用戶量非常巨大的應用,卻非常適用于大部分的內容展示型網站,比如知乎就是一個很好的例子。以知乎為例,服務端渲染與客戶端渲染的成本幾乎是相同的,重點都在于獲取用戶時間線上的數據,這時多頁面的服務端渲染可以很好地加快首屏渲染的速度,又因為運行 renderToString 時的計算量并不大,即使用戶量很大,也仍然是一件值得去做的事情。
小結
結合上一篇專欄中提到的前端數據層的概念,服務端渲染服務其實是一個很好的前端開發介入服務端開發的切入點,在完成了服務端渲染服務后,對數據接口做一些代理或整合也是非常值得去嘗試的事情。
一個軟件之所以復雜,很多時候就是因為分層架構沒有做好,導致其中某一個模塊過于臃腫,集中了大部分的業務復雜度,而其他模塊又根本幫不上忙。所以想做好前端數據層的工作,只把眼光局限在客戶端是遠遠不夠的,將業務復雜度均分到客戶端及服務端,并讓兩方分別承擔各自適合的工作,可能會是一種更好的解法。
參考資料
- React v16.0 服務端渲染腳手架: AlanWei/react-boilerplate-2017
- 與 redux 結合更友好且支持服務端渲染的路由庫: faceyspacey/redux-first-router
- 前端數據層分析: 寫在2017的前端數據層不完全指北
- 前后端渲染之爭:精讀前后端渲染之爭
- React 同構實踐:React 同構實踐與思考
來自:https://zhuanlan.zhihu.com/p/30580569