從零開始React伺服器渲染(SSR)同構?(基於Koa)

光光同學發表於2019-02-16

前言

自前端框架(React,Vue,Angelar)出現以來,每個框架攜帶不同理念,分為三大陣營,以前使用JQuery的時代已經成為過去,以前每個頁面就是一個HTML,引入相對應的JSCSS,同時在HTML中書寫DOM。正因為是這樣,每次使用者訪問進來,由於HTML中有DOM的存在,給使用者的感覺響應其實並不是很慢。

但是自從使用了框架之後,無論是多少個頁面,就是單獨一個單頁面,即SPAHTML中所有的DOM元素,必須在客戶端下載完js之後,通過呼叫執行React.render()才能夠進行渲染,所以就有了很多網站上,一進來很長時間的loading動畫。

為了解決這一併不是很友好的問題,社群上提出了很多方案,例如預渲染SSR同構

當然這篇文章主要講述的是從零開始搭建一個React伺服器渲染同構

從零開始React伺服器渲染(SSR)同構?(基於Koa)

選擇方案

方案一 使用社群精選框架Next.js

Next.js 是一個輕量級的 React 服務端渲染應用框架。有興趣的可以去Next.js官網學習下。

方案二 同構

關於同構有兩種方案:

通過babel轉義node端程式碼和React程式碼後執行

let app = express();
app.get('/todo', (req, res) => {
     let html = renderToString(
     <Route path="/" component={ IComponent } >
        <Route path="/todo" component={ AComponent }>
        </Route>
    </Route>)
     res.send( indexPage(html) )
    }
})  

複製程式碼

在這裡有兩個問題需要處理:

  • Node不支援前端的import語法,需要引入babel支援。
  • Node不能解析標籤語法。

所以執行Node時,需要使用babel來進行轉義,如果出現錯誤了,也無從查起,個人並不推薦這樣做。

所以這裡採用第二種方案

webpack進行編譯處理

使用webpack打包兩份程式碼,一份用於Node進行伺服器渲染,一份用於瀏覽器進行渲染。

下面具體詳細說明下。

搭建Node伺服器

由於使用習慣,經常使用Egg框架,而KoaEgg的底層框架,因此,這裡我們採用Koa框架進行服務搭建。

搭建最基本的一個Node服務。

const Koa = require('koa');
const app = new Koa();

app.listen(3000, () => {
  console.log("伺服器已啟動,請訪問http://127.0.0.1:3000")
});
複製程式碼

配置webpack

眾所周知,React程式碼需要經過打包編譯才能執行的,而服務端和客戶端執行的程式碼只有一部分相同,甚至有些程式碼根本不需要將程式碼打包,這時就需要將客戶端程式碼和服務端執行的程式碼分開,也就有了兩份webpack配置

webpack 將同一份程式碼,通過不同的webpack配置,分別為serverConfigclientConfig,打包為兩份程式碼。

serverConfig和clientConfig配置

通過webpack文件我們可以知道,webpack不僅可以編譯web端程式碼還可以編譯其他內容。

從零開始React伺服器渲染(SSR)同構?(基於Koa)

這裡我們將target設為node

配置入口檔案和出口位置:

const serverConfig = {
  target: 'node',
  entry: {
    page1: './web/render/serverRouter.js',
  },
  resolve,
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, './app/build'),
    libraryTarget: 'commonjs'
  }
 }

複製程式碼

注意⚠

服務端配置需要配置libraryTarget,設定commonjs或者umd,用於服務端進行require引用,不然require值為{}

在這裡客戶端和服務端配置沒有什麼區別,無需配置target(預設web環境),其他入門檔案和輸出檔案不一致。

const clientConfig = {
  entry: {
    page1: './web/render/clientRouter.js'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, './public')
  }
}
複製程式碼

配置babel

由於打包的是React程式碼,因此還需要配置babel
新建.babelrc檔案。

{
  "presets": ["@babel/preset-react",
    ["@babel/preset-env",{
      "targets": {
        "browsers": [
          "ie >= 9",
          "ff >= 30",
          "chrome >= 34",
          "safari >= 7",
          "opera >= 23",
          "bb >= 10"
        ]
      }
    }]
  ],
  "plugins": [
    [
      "import",
      { "libraryName": "antd", "style": true }
    ] 
  ]
}
複製程式碼

這份配置由服務端和客戶端共用,用來處理React和轉義為ES5和瀏覽器相容問題。

處理服務端引用問題

服務端使用CommonJS規範,而且服務端程式碼也並不需要構建,因此,對於node_modules中的依賴並不需要打包,所以藉助webpack第三方模組webpack-node-externals來進行處理,經過這樣的處理,兩份構建過的檔案大小已經相差甚遠了。

處理css

服務端和客戶端的區別,可能就在於一個預設處理,一個需要將CSS單獨提取出為一個檔案,和處理CSS字首。

服務端配置

  {
    test: /\.(css|less)$/,
    use: [
      {
        loader: 'css-loader',
        options: {
          importLoaders: 1
        }
      },
      {
        loader: 'less-loader',
      }
    ]
  }
複製程式碼

客戶端配置

  {
    test: /\.(css|less)$/,
    use: [
      {
        loader: MiniCssExtractPlugin.loader,
      },
      {
        loader: 'css-loader'
      },
      {
        loader: 'postcss-loader',
        options: {
          plugins: [
            require('precss'),
            require('autoprefixer')
          ],
        }
      },
      {
        loader: 'less-loader',
        options: {
          javascriptEnabled: true,
          // modifyVars: theme   //antd預設主題樣式
        }
      }
    ],
  }
複製程式碼

SSR 中客戶端渲染與伺服器端渲染路由程式碼的差異

實現 ReactSSR 架構,我們需要讓相同的程式碼在客戶端和服務端各自執行一遍,但是這裡各自執行一遍,並不包括路由端的程式碼,造成這種原因主要是因為客戶端是通過位址列來渲染不同的元件的,而服務端是通過請求路徑來進行元件渲染的。
因此,在客戶端我們採用BrowserRouter來配置路由,在服務端採用StaticRouter來配置路由。

客戶端配置

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from "react-router-dom";
import Router from '../router';

function ClientRender() {
  return (
      <BrowserRouter >
        <Router />
      </BrowserRouter>
  )
}

複製程式碼

服務端配置

import React from 'react';
import { StaticRouter } from 'react-router'
import Router from '../router.js';

function ServerRender(req, initStore) {

  return (props, context) => {
    return (
        <StaticRouter location={req.url} context={context} >
          <Router />  
        </StaticRouter>
    )
  }
}

export default ServerRender;

複製程式碼

再次配置Node進行伺服器渲染

上面配置的伺服器,只是簡單啟動個服務,沒有深入進行配置。

引入ReactDOMServer


const Koa = require('koa');
const app = new Koa();
const path = require('path');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const koaStatic = require('koa-static');
const router = new KoaRouter();

const routerManagement = require('./app/router');
const manifest = require('./public/manifest.json');
/**
 * 處理連結
 * @param {*要進行伺服器渲染的檔名預設是build資料夾下的檔案} fileName 
 */
function handleLink(fileName, req, defineParams) {
  let obj = {};
  fileName = fileName.indexOf('.') !== -1 ? fileName.split('.')[0] : fileName;

  try {
    obj.script = `<script src="${manifest[`${fileName}.js`]}"></script>`;
  } catch (error) {
    console.error(new Error(error));
  }
  try {
    obj.link = `<link rel="stylesheet" href="${manifest[`${fileName}.css`]}"/>`;
    
  } catch (error) {
    console.error(new Error(error));
  }
  //伺服器渲染
  const dom = require(path.join(process.cwd(),`app/build/${fileName}.js`)).default;
  let element = React.createElement(dom(req, defineParams));
  obj.html = ReactDOMServer.renderToString(element);

  return obj;
}

/**
 * 設定靜態資源
 */
app.use(koaStatic(path.resolve(__dirname, './public'), {
  maxage: 0, //瀏覽器快取max-age(以毫秒為單位)
  hidden: false, //允許傳輸隱藏檔案
  index: 'index.html', // 預設檔名,預設為'index.html'
  defer: false, //如果為true,則使用後return next(),允許任何下游中介軟體首先響應。
  gzip: true, //當客戶端支援gzip時,如果存在副檔名為.gz的請求檔案,請嘗試自動提供檔案的gzip壓縮版本。預設為true。
}));

/**
* 處理響應
* 
* **/
app.use((ctx) => {
    let obj = handleLink('page1', ctx.req, {});
    ctx.body = `
        <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <meta http-equiv="X-UA-Compatible" content="ie=edge">
          <title>koa-React伺服器渲染</title>
          ${obj.link}
        </head>
        
        <body>
          <div id='app'>
             ${obj.html}
          </div>
        </body>
        ${obj.script}
        </html>
    `
})

app.listen(3000, () => {
  console.log("伺服器已啟動,請訪問http://127.0.0.1:3000")
});
複製程式碼

這裡涉及一個manifest檔案,這個檔案是webpack外掛webpack-manifest-plugin生成的,裡面包含編譯後的地址和檔案。大概結構是這樣:

{
  "page1.css": "page1.css",
  "page1.js": "page1.js"
}
複製程式碼

我們把他引入到clientConfig中,新增如下配置:

...
plugins: [
    // 提取樣式,生成單獨檔案
    new MiniCssExtractPlugin({
        filename: `[name].css`,
        chunkFilename: `[name].chunk.css`
    }),
    new ManifestPlugin()
]
複製程式碼

在上述服務端程式碼中,我們對於ServerRender.js進行了柯里化處理,這樣做的目的在於,我們在ServerRender中,使用了服務端可以識別的StaticRouter,並配置了location引數,而location需要引數URL
因此,我們需要在renderToString中傳遞req,以讓服務端能夠正確解析React元件。

  let element = React.createElement(dom(req, defineParams));
  obj.html = ReactDOMServer.renderToString(element);
複製程式碼

通過handleLink的解析,我們可以得到一個obj,包含三個引數,linkcss連結),scriptJS連結)和html(生成Dom元素)。

通過ctx.body渲染html

renderToString()

React 元素渲染到其初始 HTML 中。 該函式應該只在伺服器上使用。 React 將返回一個 HTML 字串。 您可以使用此方法在伺服器上生成 HTML ,並在初始請求時傳送標記,以加快網頁載入速度,並允許搜尋引擎抓取你的網頁以實現 SEO 目的。

如果在已經具有此伺服器渲染標記的節點上呼叫 ReactDOM.hydrate()React 將保留它,並且只附加事件處理程式,從而使您擁有非常高效能的第一次載入體驗。

renderToStaticMarkup()

類似於 renderToString ,除了這不會建立 React 在內部使用的額外DOM屬性,如 data-reactroot。 如果你想使用React 作為一個簡單的靜態頁面生成器,這很有用,因為剝離額外的屬性可以節省一些位元組。

但是如果這種方法是在瀏覽訪問之後,會全部替換掉服務端渲染的內容,因此會造成頁面閃爍,所以並不推薦使用該方法。

renderToNodeStream()

React 元素渲染到其最初的 HTML 中。返回一個 可讀的 流(stream) ,即輸出 HTML 字串。這個 流(stream) 輸出的 HTML 完全等同於 ReactDOMServer.renderToString 將返回的內容。

我們也可以使用上述renderToNodeSteam將其改造下:

  let element = React.createElement(dom(req, defineParams));
  
  ctx.res.write('
  <html>
      <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <meta http-equiv="X-UA-Compatible" content="ie=edge">
          <title>koa-React伺服器渲染</title>
      </head><body><div id="app">');
  
  // 把元件渲染成流,並且給Response
  const stream = ReactDOMServer.renderToNodeStream(element);
  stream.pipe(ctx.res, { end: 'false' });
  
  // 當React渲染結束後,傳送剩餘的HTML部分給瀏覽器
  stream.on('end', () => {
    ctx.res.end('</div></body></html>');
  });
複製程式碼

renderToStaticNodeStream()

類似於 renderToNodeStream ,除了這不會建立 React 在內部使用的額外DOM屬性,如 data-reactroot 。 如果你想使用 React 作為一個簡單的靜態頁面生成器,這很有用,因為剝離額外的屬性可以節省一些位元組。

這個 流(stream) 輸出的 HTML 完全等同於 ReactDOMServer.renderToStaticMarkup 將返回的內容。

新增狀態管理redux

以上開發一個靜態網站,或者一個相對於比較簡單的專案已經OK了,但是對於複雜的專案,這些還遠遠不夠,這裡,我們再給它加上全域性狀態管理Redux

伺服器渲染中其順序是同步的,因此,要想在渲染時出現首屏資料渲染,必須得提前準備好資料。

  • 提前獲取資料
  • 初始化store
  • 根據路由顯示元件
  • 結合資料和元件生成 HTML,一次性返回

對於客戶端來說新增redux和常規的redux並無太大差別,只是對於store新增了一個初始的window.__INIT_STORE__

let initStore = window.__INIT_STORE__;
let store = configStore(initStore);

function ClientRender() {
  return (
    <Provider store={store}>
      <BrowserRouter >
        <Router />
      </BrowserRouter>
    </Provider>

  )
}
複製程式碼

而對於服務端來說在初始資料獲取完成之後,可以採用Promise.all()來進行併發請求,當請求結束時,將資料填充到script標籤內,命名為window.__INIT_STORE__

`<script>window.__INIT_STORE__ = ${JSON.stringify(initStore)}</script>`
複製程式碼

然後將服務端的store重新配置下。

function ServerRender(req, initStore) {
  let store = CreateStore(JSON.parse(initStore.store));

  return (props, context) => {
    return (
      <Provider store={store}>
        <StaticRouter location={req.url} context={context} >
          <Router />  
        </StaticRouter>
      </Provider>
    )
  }
}
複製程式碼

從零開始React伺服器渲染(SSR)同構?(基於Koa)

整理Koa

考慮後面開發的便利性,新增如下功能:

  • Router功能
  • HTML模板

新增Koa-Router

/**
 * 註冊路由
 */
const router = new KoaRouter();
const routerManagement = require('./app/router');
...
routerManagement(router);
app.use(router.routes()).use(router.allowedMethods());

複製程式碼

為了保證開發時,介面規整,這裡將所有的路由都提到一個新的檔案中進行書寫。並保證如以下格式:

/**
 *
 * @param {router 例項化物件} router
 */

const home = require('./controller/home');

module.exports = (router) => {
  router.get('/',home.renderHtml);
  router.get('/page2',home.renderHtml);
  router.get('/favicon.ico',home.favicon);
  router.get('/test',home.test);
}

複製程式碼

處理模板

html放入程式碼中,給人感覺並不是很友好,因此,這裡同樣引入了服務模板koa-nunjucks-2

同時在其上在套一層中介軟體,以便傳遞引數和處理各種靜態資源連結。

...
const koaNunjucks = require('koa-nunjucks-2');
...
/**
 * 伺服器渲染,渲染HTML,渲染模板
 * @param {*} ctx 
 */
function renderServer(ctx) {
  return (fileName, defineParams) => {
    let obj = handleLink(fileName, ctx.req, defineParams);
    // 處理自定義引數
    defineParams = String(defineParams) === "[object Object]" ? defineParams : {};
    obj = Object.assign(obj, defineParams);
    ctx.render('index', obj);
  }
}

...

/**
 * 模板渲染
 */
app.use(koaNunjucks({
  ext: 'html',
  path: path.join(process.cwd(), 'app/view'),
  nunjucksConfig: {
    trimBlocks: true
  }
}));

/**
 * 渲染Html
 */
app.use(async (ctx, next) => {
  ctx.renderServer = renderServer(ctx);
  await next();
});
複製程式碼

在使用者訪問該伺服器時,通過呼叫renderServer函式,處理連結,執行到最後,呼叫ctx.render完成渲染。


/**
 * 渲染react頁面
 */

 exports.renderHtml = async (ctx) => {
    let initState = ctx.query.state ? JSON.parse(ctx.query.state) : null;
    ctx.renderServer("page1", {store: JSON.stringify(initState ? initState : { counter: 1 }) });
 }
 exports.favicon = (ctx) => {
   ctx.body = null;
 }

 exports.test = (ctx) => {
   ctx.body = {
     data: `測試資料`
   }
 }

複製程式碼

關於koa-nunjucks-2中,在渲染HTML時,會將有< >進行安全處理,因此,我們還需對我們傳入的資料進行過濾處理。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>koa-React伺服器渲染</title>
  {{ link | safe }}
</head>

<body>
  <div id='app'>
    {{ html | safe }}
  </div>
</body>
<script>
  window.__INIT_STORE__ = {{ store | safe }}
</script>
{{ script | safe }}
</html>
複製程式碼

文件結構

├── README.md
├── app  //node端業務程式碼
│   ├── build
│   │   ├── page1.js
│   │   └── page2.js
│   ├── controller
│   │   └── home.js
│   ├── router.js
│   └── view
│       └── index.html
├── index.js
├── package.json
├── public //前端靜態資源
│   ├── manifest.json
│   ├── page1.css
│   ├── page1.js
│   ├── page2.css
│   └── page2.js
├── web  //前端原始碼
│   ├── action //redux -action
│   │   └── count.js
│   ├── components  //元件
│   │   └── layout
│   │       └── index.jsx
│   ├── pages //主頁面
│   │   ├── page
│   │   │   ├── index.jsx
│   │   │   └── index.less
│   │   └── page2
│   │       ├── index.jsx
│   │       └── index.less
│   ├── reducer //redux -reducer
│   │   ├── counter.js
│   │   └── index.js
│   ├── render  //webpack入口檔案
│   │   ├── clientRouter.js
│   │   └── serverRouter.js
│   ├── router.js //前端路由
│   └── store //store
│       └── index.js
└── webpack.config.js
複製程式碼

最後

目前這個架構目前只能手動啟動Koa服務和啟動webpack

如果需要將Koa和webpack跑在一塊,這裡就涉及另外一個話題了,在這裡可以檢視我一開始寫的文章。

騷年,Koa和Webpack瞭解一下?

如果需要了解一個完整的伺服器需要哪些功能,可以瞭解我早期的文章。

如何建立一個可靠穩定的Web伺服器

最後GITHUB地址如下:

基於koa的react伺服器渲染

參考資料:

React中文文件
Webpack中文文件
React 中同構(SSR)原理脈絡梳理
Redux

相關文章