記一次React應用同構(SSR)

前言

React應用同構能夠達到更好的用戶體驗以及SEO,可是過程也較爲繁雜,代碼難以解耦,至於項目是否須要同構我認爲就看對應的場景及投入和產出了,本文不對此作贅述css

關於同構(Isomorphic),網上說法頗多,我我的認爲是一份代碼,服務端進行渲染,拼接html及初始化數據,瀏覽器端拿到html及js後在瀏覽器插入初始化的數據和掛載相關監聽事件html

關於isomorphic,也有人認爲應該爲Universal Rendering(看自知乎),本文將均使用"同構"一詞。
前端

此文將記錄本人React應用———一個我的博客進行同構的實踐、SSR效果,以及過程當中所遇到的問題及解決方案,此連接爲同構後上線的我的博客周立涵的博客

node

在參考大量的文章以後作出了這次改寫,React的生態確實繁榮,方案頗多,這次實踐採用的方案是同構,同時寫了兩套webpack,即一套React代碼,兩套webpack配置(分別針對瀏覽器端與服務端),服務端使用express進行渲染,夾帶瀏覽器端js代碼返回前端本質上仍是一套代碼

因爲是首次改寫同構,有建議之處還請大佬指出

此文將按如下順序進展:react

  • 同構效果對比
  • React同構原理
  • 同構過程遇到的問題
  • 實踐中的步驟或問題
    • 服務端webpack配置
    • server/index.js結構編寫
    • 服務端renderToString,瀏覽器端hydrate
    • 定製主題的Ant Design處理
    • react-router處理
    • Ajax數據請求處理
    • DOM處理

同構先後的效果對比

爲單純對比性能,排除網絡環境影響,測試在本地開發環境下進行
同構前
webpack


FMP指標截圖:

具體指標:

FP FCP FMP DCL L
1766.5ms 1766.5ms 1950.1ms 1706.1ms 4400.2.5ms

同構後


FMP指標截圖:
具體指標:

FP FCP FMP DCL L
403.8ms 403.8ms 403.8ms 1343.5ms 2442.5ms

同構先後FMP下降了1546.3ms!!!
同時也肉眼可見的首屏速度有顯著的提高ios

在不一樣環境下測試的結果會有差別,但基本每次測試,同構後都比同構前的FMP下降了1000+ms!git

React 同構原理

React 同構簡單地說便是在服務端renderToString拼接HTML字符串,請求初始化數據,返回瀏覽器HTML及相關靜態文件(如js、css等等),在瀏覽器端執行React代碼,插入初始化數據及掛載相關的監聽事件(hydrate)

此處強烈推薦此文,很是清晰地闡述了SSRServer-Side Rendering with React, Redux, and React-Router,下圖也摘自文中
github

應用目錄結構

本博客應用使用了React + React Router + Ant Design + CSS Module
同構前的React應用結構以下web

│  .babelrc
│  package-lock.json
│  package.json
│  postcss.config.js
│  webpack.common.js
│  webpack.dev.js  //開發環境webpack
│  webpack.prod.js  //生產環境webpack
│              
├─public
│      favicon.ico
│      index.html   
└─src
    │  App.css
    │  App.jsx
    │  App.module.css
    │  index.css
    │  index.js  //瀏覽器端入口文件
    │  
    ├─components  //組件目錄
    │          
    ├─container  //應用視圖目錄
    │          
    └─source  //項目靜態資源
複製代碼

同構前經過src/index.js進行瀏覽器端的掛載

配合express進行同構後目錄以下

│  .babelrc
│  package-lock.json
│  package.json
│  postcss.config.js
│  server.js  //編譯後的服務端代碼
│  webpack.common.js
│  webpack.dev.js      
│  webpack.prod.js    //瀏覽器端webpack
│  webpack.server.js  //服務端webpack
│      
├─dist  //編譯後的客戶端代碼
│          
│          
└─src  //項目源碼
    ├─browser  //瀏覽器端入口文件
    │      index.js
    │      
    ├─server  //服務端入口文件
    │      index.js
    │      
    └─shared  //共享目錄
        │  App.css
        │  App.jsx
        │  App.module.css
        │  index.css
        │  routesConfig.js
        │  
        ├─components
        │          
        ├─container
        │          
        └─source
複製代碼

同構後,瀏覽器訪問express接口,express返回html字符串及編譯後的客戶端代碼(dist目錄下的靜態資源),瀏覽器端掛載事件及初始化數據
目錄結構的文件關係能夠以下表示:

由於每次訪問端口都依賴於靜態資源,因此每次修改代碼查看效果都須要webpack從新編譯,開發過程當中相對麻煩。
目前的想法是經過配置webpackDevServe在本地服務器上打通資源訪問,以支持熱更新,後續再研究

本應用中將dist做爲靜態資源目錄,其中包含webpack打包後的js、css,lessc編譯後的css文件等等

同構過程遇到的問題

綜上,本項目使用了express + React + React Router + Ant Design + CSS Module,再加上一些簡單的邏輯代碼(涉及DOM的懶加載、數據請求)
我同構的步驟及解決的問題以下:

  1. 服務端webpack配置——ESM、JSX、CSS Module
  2. server/index.js結構編寫
  3. 服務端renderTostring,瀏覽器端hydrate
  4. 抽取自定義樣式的Ant Design爲單獨的CSS文件
  5. react-router處理——使先後端都可路由
  6. Ajax數據請求處理——使後端返回已初始化數據的html
  7. DOM處理——Node端運行js無DOM(document)、Window(window, navigator等)、Storage(localStorage, sessionStorage等)對象

接下來將按照順序闡述步驟原因,及採用了什麼方案

1.服務端webpack配置

js能夠跑在瀏覽器和node上,可是也存在些許的環境差別(好比是否有DOM),因此須要針對服務端(即node)進行webpack的配置
本項目主要針對瞭如下內容進行配置

webpack target屬性——說明打包後的js運行環境爲node而不是瀏覽器
ESM——node(v10.15.3)原生不支持import及export
JSX
CSS Module——使得在node端完成對className的hash

前三項內容能夠說是必須項,最後一項得看對應項目技術棧

對於說明打包後的js運行環境爲node,直接在webpack配置中設置target屬性便可:

...
    entry: "./src/server/index.js",
    target: "node",
    ...
複製代碼

對於ESM、JSX及CSS Module,node端同瀏覽器端同樣根據須要配置webpack module

2.server/index.js結構編寫

咱們經過訪問express的API,返回對應的html,在此寫出server/index.js的基本結構

import express from "express";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import React from "react";
import App from "../shared/App";

const app = express();
app.use(
  express.static("dist", {
    index: false
  })
);//指定端口使用的靜態資源目錄,dist目錄下放的是打包後的客戶端靜態資源;index爲false表示關閉index.**文件索引

app.get("路徑",  (req,res)=>{
    /** * 客戶端訪問接口,接口返回html **/
    const reactComhtml = ...;//react元素獲取html字符串,reactComhtml變量值見下文
    const theHtml = ...;//拼接成完整的html,theHtml變量值見下文
    res.setHeader("Content-Type", "text/html");//設置響應頭
    res.send(theHtml); //返回html
});
複製代碼

3.服務端renderToString,瀏覽器端hydrate

這裏介紹兩個API,renderToStringhydrate
renderToString在服務端將React元素渲染爲初始的HTML

// server/index.js
  const reactComHtml = ReactDOMServer.renderToString(
    <StaticRouter> <App reqPathname={req.path} /> </StaticRouter> ); const theHtml = ` <html> <head> ... </head> <body> <div id="App">${reactComHtml}</div> //拼接上html字符串 <script src="{...靜態資源目錄}/main.js" charset="utf-8"></script> //攜帶webpack打包後的瀏覽器端靜態資源,好比js文件 </body> </html> `; 複製代碼

hydrate在瀏覽器端對存在元素掛載監聽事件。React但願服務端渲染的內容與瀏覽器渲染的內容一致。同時,React能夠對文字內容的不一致進行修補,但咱們最好不要人爲地引入不一致,並且這一修補機制並不能保證徹底正確。在開發環境下,若發現內容不一致,React會發出警告,好比Warning: Expected server HTML to contain a matching <div> in <div>.。固然,形成這一警告的緣由也有不少,好比本應用中使用了react-markdown組件,對markdown文本進行了處理,使得服務端與客戶端部份內容不一致,開發環境下就拋出了警告,關於hydrate更多可查看官網說明hydrate

// brpwser/index.js
const app = document.getElementById("App");
app? ReactDOM.hydrate(
      <BrowserRouter> <App /> </BrowserRouter>,app) : false;
複製代碼

由此,服務端能夠輸出初始化的HTML(此處還未填充初始化數據),瀏覽器掛載事件供用戶進行交互操做,以此`提高了SEO`和`極大地縮短了首屏白屏時間`

4.定製主題的Ant Design處理

本項目使用了Ant Design,而且配置了babel-plugin-import進行按需加載,因此若進行定製主題,則須要將babel-plugin-import的style配置改成true,可是這就致使了antd樣式沒有做爲單獨的CSS文件抽取出來,而是經過js運行,在DOM中插入<style>標籤造成樣式。

這在CSR中是沒有問題的,由於在插入<style>標籤以前用戶看不到網頁內容,可是如今用戶打開網頁第一眼就能看到完整的HTML內容,js運行後才插入<style>樣式,就會形成頁面樣式「突變」的效果。

因而我將babel-plugin-import中的style的值設置爲false,樣式將不會經過js生成style標籤插入。
對於css樣式,antd使用less做爲開發語言,其中定義了一些樣式變量,能夠使用lessc生成.css文件,做爲靜態資源使用

//好比此處修改了兩個樣式變量@primary-color與@link-hover-color
lessc --js --modify-var=@primary-color=#ffc34e --modify-var=@link-hover-color=#ffc34e ./node_modules/antd/dist/antd.less > dist/ant.css
複製代碼

其中dist爲靜態資源目錄
至此,自定義的樣式文件在返回的html中引用便可

const theHtml = ` <html> <head> <title>${title}</title> <link rel="shortcut icon" href="${...靜態資源目錄}/favicon.ico"> <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/ant.css"> //引用antd樣式文件 <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/main.css"> ... </head> <body> <div id="App">${reactComHtml}</div> <script src="${...靜態資源目錄}/main.js" charset="utf-8"></script> </body> </html> `;
複製代碼

5.react-router

在CSR下,路由交給前端控制,後端沒有對應的路由,因此若是前端進入了某個路徑,刷新了網頁,後臺會返回404。舉個例子:

某項目爲CSR,使用了react-router,主頁爲www.somebody.com,咱們瀏覽器進入了www.somebody.com/test,此時刷新,或者將此連接分享給好友,返回的都會是404,由於後臺對應路徑下並不存在html文件或者服務。StackOverflow上對此有多種解決方案

同構也能解決此問題。
在服務端,咱們使用進行路由,瀏覽器一樣是使用

// server/index.js
const reactComHtml = renderToString(
  <StaticRouter location={req.url}> <App /> </StaticRouter>
);
const theHtml = ` <html> <head> <title>Home</title> <link rel="shortcut icon" href="${...靜態資源目錄}/favicon.ico"> <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/ant.css"> <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/main.css"> ... </head> <body> <div id="App">${reactComHtml}</div> <script src="/main.js" charset="utf-8"></script> </body> </html> `;

// browser/index.js
const app = document.getElementById("App");
app ? ReactDOM.hydrate(
      <BrowserRouter> <App /> </BrowserRouter>,app): false;
複製代碼

經過StaticRouter的location屬性填入路徑,咱們能得到對應的路由html,拼接成html返回給瀏覽器

6.Ajax數據請求處理

先後端分離下前端基本經過ajax請求向後端拉取數據,而SSR下咱們但願返回的html包含初始數據。
那這裏有兩個問題:a.如何在服務端請求數據?b.如何將數據進行呈現?

a.如何在服務端請求數據

一樣是經過Ajax向端口請求數據,可是要注意異步與環境問題。
對於異步,當須要請求多個接口時,須要作好對異步的處理,保證能將請求的數據傳給html,好比可使用Promise進行處理
對於環境問題,要注意使用的Ajax庫必須能跑在node上,而不是隻能跑在瀏覽器環境上。我在這裏使用的是axios

app.get("路徑",(req, res)=>{
  axios.get(url)//ajax請求接口
      .then((response)=>{//獲取數據
          const data = response.data;
          const context = data;
          //如何呈現見下文
          const reactComHtml = ...;
          const theHtml = ...;
          res.setHeader("Content-Type", "text/html");
          res.send(theHtml);
      })
})

複製代碼

在經過Ajax獲取數據後,咱們就要進行如何將數據呈現的步驟了

b.如何將數據進行呈現

如何將數據進行呈現有很是多的方案,此處本人的方案是StaticRouter的context屬性 + 自定義window.__ROUTE_DATE__屬性進行(這裏的__ROUTE_DATA__屬性是自定義的,你可使用任何與window對象不衝突的屬性)
補充上文的代碼

// server/index.js
  const reactComHtml = renderToString(
    <StaticRouter location={req.url} context={context}>
      <App reqPathname={req.path} />
    </StaticRouter>
  );
  const theHtml = `
      <html>
      <head>
      <title>${title}</title>
      <link rel="shortcut icon" href="${...靜態資源目錄}/favicon.ico">
      <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/ant.css">
      <link rel="stylesheet" type="text/css" href="${...靜態資源目錄}/main.css">
      <script>window.__ROUTE_DATA__ = ${JSON.stringify(context)}</script> //以字符串的形式傳遞給html,在瀏覽器端使用此數據
      </head>
      <body>
          <div id="App">${reactComHtml}</div>
          <script src="${...靜態資源目錄}/main.js" charset="utf-8"></script>
      </body>
      </html>
  `;


  //  舉例:shared/container/Article/index.jsx  /article/:articleId路由的對應組件
  class Article extends Component {
  constructor(props) {
    super(props);
    const staticContext = props.staticContext;

    if (staticContext) {
      //S端運行
      const { title, content, time, tag } = staticContext;
      this.state = {
        title: title,
        content: content,
        time: time,
        tag: tag
      };
    } else {
      //B端運行
      const data = window.__ROUTE_DATA__;
      this.state = {
        content: data.content,
        title: data.title,
        time: data.time,
        tag: data.tag
      };
      delete window.__ROUTE_DATA__;
    }
  }
  ...
  ...
複製代碼

運行於服務端,props.staticContext也僅存於服務端
服務端上經過staticContext取值,renderToString輸出HTML字符串
瀏覽器端上經過window.__ROUTE_DATA__取值,hydrate對已存在的元素掛載監聽事件

7.對DOM

因爲node上不存在DOM、Window、Storage對象,因此須要進行判斷處理,本應用舉例對DOM的處理
封裝函數判斷是否在瀏覽器環境

const isBrowser = ()=>{
    return typeof window !== "undefined";
}
export default isBrowser;
複製代碼

在對應的涉及到DOM的代碼中,經過isBrowser判斷當前是否爲瀏覽器環境,是則執行相應操做,不是則不執行

constructor(props) {
    super(props);
    //舉例
    if (isBrowser() === true) {
      this.handleDom = this.handleDom.bind(this);
      this.handleDom();
    }
  }
複製代碼

結語

通過這次實踐後,對同構、服務端渲染、node、React、react router等有了更清楚的認識和了解,也着實體會到了SSR帶來的首屏速度提高。可是同構過程也相對繁瑣,本次改寫僅僅是一個很是簡單的博客應用,也暫時不存在性能問題,如果複雜應用則一定會是大工程。

SSR帶來的好處就是SEO首屏速度的提高,對原應用進行同構的過程也着實繁瑣。現階段,需不須要將CSR改成SSR私覺得得看具體場景,得看投入和產出是否值得。

將來,隨着瀏覽器的升級、搜索引擎爬蟲的進步、網絡的提高,是否能直接解決CSR在SEO和首屏速度的問題呢?但是這將來會不會特別遠呢?會不會又有新的方案解決呢?將來可真是使人好奇
以上有不當之處請大佬指出。

參考資料

本次學習參考了比較多的文章,不少實現方法各有不一樣,也存在着對應的能夠優化的問題。
什麼是前端同構——知乎
Server-Side Rendering with React, Redux, and React-Router(強烈推薦此文)
User-centric Performance Metrics(包含性能指標闡述)
Ant Design定製主題
react-router官網——Server Rendering
Stack Overflow React-router urls don't work when refreshing or writing manually
An Introduction to React Server-Side Rendering
Using React Router 4 with Server-Side Rendering(瀏覽器端上,數據初始化放在了不是特別恰當的生命週期)
YouTube——ReactCasts #12 - Server Side Rendering
React Server Side Rendering with Express(比較推薦的入門實踐文章,可是中間多了沒必要要的大鬍子語法)
Tips for server-side rendering with React

相關文章
相關標籤/搜索