服務端渲染與 Universal React App

誠身發表於2017-11-10

隨著 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 程式碼都有哪些需要注意的地方,以及如何搭建起一個基礎的服務端渲染服務。

總體架構

為了方便各位理解同構的具體實現過程,筆者基於 reactreact-routerredux 以及 webpack3 實現了一個簡單的腳手架專案,支援客戶端渲染和服務端渲染兩種開發方式,供各位參考。

architecture

  1. 服務端預先獲取編譯好的客戶端程式碼及其他資源。
  2. 服務端接收到使用者的 HTTP 請求後,觸發服務端的路由分發,將當前請求送至服務端渲染模組處理。
  3. 服務端渲染模組根據當前請求的 URL 初始化 memory history 及 redux store。
  4. 根據路由獲取渲染當前頁面所需要的非同步請求(thunk)並獲取資料。
  5. 呼叫 renderToString 方法渲染 HTML 內容並將初始化完畢的 redux store 塞入 HTML 中,供客戶端渲染時使用。
  6. 客戶端收到服務端返回的已渲染完畢的 HTML 內容並開始同步載入客戶端 JavaScript,CSS,圖片等其他資源。
  7. 之後的流程與客戶端渲染完全相同,客戶端初始化 redux store,路由找到當前頁面的元件,觸發元件的生命週期函式,再次獲取資料。唯一不同的是 redux store 的初始狀態將由服務端在 HTML 中塞入的資料提供,以保證客戶端渲染時可以得到與服務端渲染相同的結果。受益於 Virtual DOM 的 diff 演算法,這裡並不會觸發一次冗餘的客戶端渲染。

在瞭解了同構的大致思路後,接下來再讓我們對同構中需要注意的點逐一進行分析,與各位一起探討同構的最佳實踐。

客戶端與服務端構建過程不同

因為執行環境與渲染目的的不同,共用一套程式碼的客戶端與服務端在構建方面有著許多的不同之處。

入口(entry)不同

客戶端的入口為 ReactDOM.render 所在的檔案,即將根元件掛載在 DOM 節點上。而服務端因為沒有 DOM 的存在,只需要拿到需要渲染的 react 元件即可。為此我們需要在客戶端抽離出獨立的 createAppcreateStore 的方法。

// 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__ || 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')),
}];複製程式碼

服務端核心的頁面渲染模組:

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 時的計算量並不大,即使使用者量很大,也仍然是一件值得去做的事情。

小結

結合之前文章中提到的前端資料層的概念,服務端渲染服務其實是一個很好的前端開發介入服務端開發的切入點,在完成了服務端渲染服務後,對資料介面做一些代理或整合也是非常值得去嘗試的工作。

一個程式碼庫之所以複雜,很多時候就是因為分層架構沒有做好而導致其中某一個模組過於臃腫,集中了大部分的業務複雜度,但其他模組又根本幫不上忙。想要做好前端資料層的工作,只把眼光侷限在客戶端是遠遠不夠的,將業務複雜度均分到客戶端及服務端,並讓兩方分別承擔各自適合的工作,可能會是一種更好的解法。


相關文章