隨著 Webpack 等前端構建工具的普及,客戶端渲染因為其構建方便,部署簡單等方面的優勢,逐漸成為了現代網站的主流渲染模式。而在剛剛發布的 React v16.0 中,改進后更為優秀的服務端渲染性能作為六大更新點之一,被 React 官方重點提及。為此筆者還專門做了一個小調查,分別詢問了二十位國內外(國內國外各十位)前端開發者,希望能夠了解一下服務端渲染在使用 React 公司中所占的比例。
出人意料的是,十位國內的前端開發者中在生產環境使用服務端渲染的只有三位。而在國外的十位前端開發者中,使用服務端渲染的達到了驚人的八位。
這讓人不禁開始思考,同是 React 的深度使用者,為什么國內外前端開發者在服務端渲染這個 React 核心功能的使用率上有著如此巨大的差別?在經過又一番刨根問底地詢問后,真正的答案逐漸浮出水面,那就是可靠的 SEO(reliable SEO)。
相比較而言,國外公司對于 SEO 的重視程度要遠高于國內公司,在這方面積累的經驗也要遠多于國內公司,前端頁面上需要服務端塞入的內容也絕不僅僅是用戶所看到的那些而已。所以對于國外的前端開發者來說,除去公司內部系統不談,所有的客戶端應用都需要做大量的 SEO 工作,服務端渲染也就順理成章地成為了一個必選項。這也從一個側面證明了國內外互聯網環境的一個巨大差異,即雖然國際上也有諸如 Google,Facebook,Amazon 這樣的巨頭公司,但放眼整個互聯網,這些巨頭公司所產生的黑洞效應并沒有國內 BAT 三家那樣如此得明顯,中小型公司依然有其生存的空間,搜索引擎所帶來的自然流量就足夠中小型公司可以活得很好。在這樣的前提下,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
,react-router
,redux
以及 webpack3
實現了一個簡單的腳手架項目,支持客戶端渲染和服務端渲染兩種開發方式,供各位參考。
- 服務端預先獲取編譯好的客戶端代碼及其他資源。
- 服務端接收到用戶的 HTTP 請求后,觸發服務端的路由分發,將當前請求送至服務端渲染模塊處理。
- 服務端渲染模塊根據當前請求的 URL 初始化 memory history 及 redux store。
- 根據路由獲取渲染當前頁面所需要的異步請求(thunk)并獲取數據。
- 調用 renderToString 方法渲染 HTML 內容并將初始化完畢的 redux store 塞入 HTML 中,供客戶端渲染時使用。
- 客戶端收到服務端返回的已渲染完畢的 HTML 內容并開始同步加載客戶端 JavaScript,CSS,圖片等其他資源。
- 之后的流程與客戶端渲染完全相同,客戶端初始化 redux store,路由找到當前頁面的組件,觸發組件的生命周期函數,再次獲取數據。唯一不同的是 redux store 的初始狀態將由服務端在 HTML 中塞入的數據提供,以保證客戶端渲染時可以得到與服務端渲染相同的結果。受益于 Virtual DOM 的 diff 算法,這里并不會觸發一次冗余的客戶端渲染。
在了解了同構的大致思路后,接下來再讓我們對同構中需要注意的點逐一進行分析,與各位一起探討同構的最佳實踐。
客戶端與服務端構建過程不同
因為運行環境與渲染目的的不同,共用一套代碼的客戶端與服務端在構建方面有著許多的不同之處。
入口(entry)不同
客戶端的入口為 ReactDOM.render
所在的文件,即將根組件掛載在 DOM 節點上。而服務端因為沒有 DOM 的存在,只需要拿到需要渲染的 react 組件即可。為此我們需要在客戶端抽離出獨立的 createApp
及 createStore
的方法。
// createApp.jsimport 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.jsimport { 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 = {}) {// enhancerslet composeEnhancers = compose;if (typeof window !== 'undefined') {// eslint-disable-next-line no-underscore-danglecomposeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;}// middlewaresconst 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')),
}];復制代碼
服務端核心的頁面渲染模塊:
const ReactDOM = require('react-dom/server');
const { matchRoutes } = require('react-router-config');
const { Helmet } = require('react-helmet');
const serialize = require('serialize-javascript');
const createHistory = require('history/createMemoryHistory').default;
const get = require('lodash/get');
const head = require('lodash/head');
const { getClientInstance } = require('../client');// Initializes the store with the starting url = require( request.
function configureStore(req, client) {console.info('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 = serialize(context.store.getState(), {isJSON: true});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.error(`SSR error: ${err}`);});
}module.exports = serverRender;復制代碼
在客戶端,我們可以直接在 componentDidMount
中調用這些 action:
const mapDispatchToProps = {getUser: action.getUser,getMessage: homeAction.getMessage,
};componentDidMount() {this.props.getMessage();this.props.getUser();
}復制代碼
在分離了服務端與客戶端 dispatch 異步請求的方式后,我們還可以針對性地對服務端的 thunk 做進一步的優化,即只請求首屏渲染需要的數據,剩下的數據交給客戶端在 js 加載完畢后再請求。
但這里又引出了另一個問題,比如在上面的例子中,getUser 和 getMessage 這兩個異步請求分別在服務端與客戶端各請求了一次,即我們在很短的時間內重復請求了同一個接口兩次,這是可以避免的嗎?
這樣的數據獲取方式在純服務端渲染時自然是冗余的,但在同構的架構下,其實是無法避免的。因為我們并不知道用戶在訪問客戶端的某個頁面時,是從服務端路由來的(即首屏),還是從客戶端路由(首屏之后的后續路由)來的。也就是說如果我們不在組件的 componentDidMount
中去獲取異步數據的話,一旦用戶到達了某個頁面,再點擊頁面中的某個元素跳轉至另一頁面時,是不會觸發服務端的數據獲取的,因為這時走的實際上是客戶端路由。
服務端渲染還能做些什么
除去 SEO 與首屏加速,在額外部署了一套服務端渲染服務后,我們當然希望它能為我們分擔更多的事情,那么究竟有哪些事情放在服務端去做是更為合適的呢?筆者總結了以下幾點。
初始化應用狀態
除去獲取當前頁面的數據,在做了同構之后,客戶端還可以將獲取應用全局狀態的一些請求也交由服務端去做,如獲取當前時區,語言,設備信息,用戶等通用的全局數據。這樣客戶端在初始化 redux store 時就可以直接獲取到上述數據,從而加快其他頁面的渲染速度。與此同時,在分離了這部分業務邏輯到服務端之后,客戶端的業務邏輯也會變得更加清晰。當然,如果你想做一個純粹的 Universal App,也可以把初始化應用狀態封裝成一個方法,讓服務端與客戶端都可以自由地去調用它。
更早的路由處理
相較于客戶端,服務端可以更早地對當前 URL 進行一些業務邏輯上的判斷。比如 404
時,服務端可以直接將另一個 error.html
的模板發送至客戶端,用戶也就可以在第一時間收到相應的反饋,而不需要等到所有 JavaScript 等客戶端資源加載完畢之后,才看到由客戶端渲染的 404
頁面。
Node.js 中間層
有了服務端渲染這一層后,服務端還可以幫助客戶端向 Cookie 中注入一些后端 API 中沒有的數據,甚至做一些接口聚合,數據格式化的工作。這時,我們所寫的 Node.js 服務端就不再是一個單純的渲染服務了,而是進化為了一個 Node.js 中間層,可以幫助客戶端完成許多在客戶端做不到或很難做到的事情。
要不要做同構
在分析了同構的具體實現細節并了解了同構的好處之后,我們也需要知道這一切的好處并不是沒有代價的,同構或者說服務端渲染最大的瓶頸就是服務端的性能。
在用戶規模大到一定程度之后,客戶端渲染本身就是一個完美的分布式系統,我們可以充分地利用用戶的電腦去運行 JavaScript 中那些復雜的運算,而服務端渲染卻將這些工作全部攬了回來并加到了網站自己的服務器上。
所以,考慮到投入產出比,同構可能并不適用于前端需要大量計算(如包含大量圖表的頁面)且用戶量非常巨大的應用,卻非常適用于大部分的內容展示型網站,比如知乎就是一個很好的例子。以知乎為例,服務端渲染與客戶端渲染的成本幾乎是相同的,重點都在于獲取用戶時間線上的數據,這時多頁面的服務端渲染可以很好地加快首屏渲染的速度,又因為運行 renderToString
時的計算量并不大,即使用戶量很大,也仍然是一件值得去做的事情。
小結
結合之前文章中提到的前端數據層的概念,服務端渲染服務其實是一個很好的前端開發介入服務端開發的切入點,在完成了服務端渲染服務后,對數據接口做一些代理或整合也是非常值得去嘗試的工作。
一個代碼庫之所以復雜,很多時候就是因為分層架構沒有做好而導致其中某一個模塊過于臃腫,集中了大部分的業務復雜度,但其他模塊又根本幫不上忙。想要做好前端數據層的工作,只把眼光局限在客戶端是遠遠不夠的,將業務復雜度均分到客戶端及服務端,并讓兩方分別承擔各自適合的工作,可能會是一種更好的解法。