服務端渲染與 Universal React App

隨着 Webpack 等前端構建工具的普及,客戶端渲染由於其構建方便,部署簡單等方面的優點,逐漸成爲了現代網站的主流渲染模式。而在剛剛發佈的 React v16.0 中,改進後更爲優秀的服務端渲染性能做爲六大更新點之一,被 React 官方重點說起。爲此筆者還專門作了一個小調查,分別詢問了二十位國內外(國內國外各十位)前端開發者,但願可以瞭解一下服務端渲染在使用 React 公司中所佔的比例。javascript

出人意料的是,十位國內的前端開發者中在生產環境使用服務端渲染的只有三位。而在國外的十位前端開發者中,使用服務端渲染的達到了驚人的八位。css

這讓人不由開始思考,同是 React 的深度使用者,爲何國內外前端開發者在服務端渲染這個 React 核心功能的使用率上有着如此巨大的差異?在通過又一番刨根問底地詢問後,真正的答案逐漸浮出水面,那就是可靠的 SEO(reliable SEO)。html

相比較而言,國外公司對於 SEO 的重視程度要遠高於國內公司,在這方面積累的經驗也要遠多於國內公司,前端頁面上須要服務端塞入的內容也毫不僅僅是用戶所看到的那些而已。因此對於國外的前端開發者來講,除去公司內部系統不談,全部的客戶端應用都須要作大量的 SEO 工做,服務端渲染也就瓜熟蒂落地成爲了一個必選項。這也從一個側面證實了國內外互聯網環境的一個巨大差別,即雖然國際上也有諸如 Google,Facebook,Amazon 這樣的巨頭公司,但放眼整個互聯網,這些巨頭公司所產生的黑洞效應並無國內 BAT 三家那樣如此得明顯,中小型公司依然有其生存的空間,搜索引擎所帶來的天然流量就足夠中小型公司能夠活得很好。在這樣的前提下,SEO 的重要性天然也就不言而喻了。前端

除去 SEO,服務端渲染對於前端應用的首屏加載速度也有着質的提高。特別是在 React v16.0 發佈以後,新版 React 的服務端渲染性能相較於老版提高了三倍之多,這讓已經在生產環境中使用服務端渲染的公司「免費」得到了一次網站加載速度提高的機會,同時也吸引了許多還未在生產環境中使用服務端渲染的開發者。java

客戶端渲染 vs. 服務端渲染 vs. 同構

在深刻服務端渲染的細節以前,讓咱們先明確幾個概念的具體含義。react

  • 客戶端渲染:頁面在 JavaScript,CSS 等資源文件加載完畢後開始渲染,路由爲客戶端路由,也就是咱們常常談到的 SPA(Single Page Application)。
  • 服務端渲染:頁面由服務端直接返回給瀏覽器,路由爲服務端路由,URL 的變動會刷新頁面,原理與 ASP,PHP 等傳統後端框架相似。
  • 同構:英文表述爲 Isomorphic 或 Universal,即編寫的 JavaScript 代碼可同時運行在瀏覽器及 Node.js 兩套環境中,用服務端渲染來提高首屏的加載速度,首屏以後的路由由客戶端控制,即在用戶到達首屏後,整個應用還是一個 SPA。

在明確了這三種渲染方案的具體含義後,咱們能夠發現,不管是客戶端渲染仍是服務端渲染,都有着其明顯的缺陷,而同構顯然是結合了兩者優勢以後的一種更好的解決方案。webpack

但想在客戶端寫出一套徹底符合同構要求的 React 代碼並非一件容易的事,與此同時還須要額外部署一套穩定的服務端渲染服務,這兩者相加起來的開發或遷移成本都足以擊潰許多想要嘗試服務端渲染的 React 開發者的信心。git

那麼今天就讓咱們來一塊兒總結下,符合同構要求的 React 代碼都有哪些須要注意的地方,以及如何搭建起一個基礎的服務端渲染服務。github

整體架構

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

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

小結

結合以前文章中提到的前端數據層的概念,服務端渲染服務實際上是一個很好的前端開發介入服務端開發的切入點,在完成了服務端渲染服務後,對數據接口作一些代理或整合也是很是值得去嘗試的工做。

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

相關文章
相關標籤/搜索