手摸手服務端渲染 - react

目錄

服務端渲染基礎

類似vue ssr思路,在 react ssr 我們也需要創建兩個入口文件,entry-client.jsentry-server.js,這兩個文件都引入 react 的主入口App.jsx文件,entry-server 返回一個渲染 react 應用的渲染函數,在 node server 中拿到 entry-server 端返回的渲染函數並獲取到 html 字符串,最後將其處理好並返回給客戶端,client 端則負責激活 html 字符串,react 管這個步驟叫做 hydrate(水合)

mkdir ssr
cd ssr
npm init -y
npm install --save-dev webpack webpack-cli webpack-node-externals babel-loader @babel/core @babel/preset-env @babel/preset-react
npm install --save express react react-dom

我們先簡單創建一個 React 應用,新建App.jsx

ssr/src/App.jsx

import React, { useState } from 'react'

export default function App () {
  const [name, setName] = useState('初始姓名')
  const [age, setAge] = useState(0)

  function onClick () {
    setAge(age + 1)
  }
  return (
    <article>
      <p>姓名: { name }</p>
      <p>年齡: { age }</p>
      <button onClick={onClick}>年齡+1</button>
    </article>
  )
}

創建雙端入口

ssr/src/entry-client.jsx

import React from 'react'
import ReactDOM from 'react-dom'

import App from './App.jsx'

ReactDOM.hydrate(<App />, document.querySelector('#root'))

ssr/src/entry-server.jsx

import React from 'react'
import ReactDOMServer from 'react-dom/server'

import App from './App.jsx'

export default function createAppString () {
  return ReactDOMServer.renderToString(<App />)
}

我們需要將連個入口打包變異成 node.js 可解析的 es5 版本語法,我們需要配置 webpack

ssr/webpack.client.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/entry-client.jsx',
  output: {
    path: path.join(__dirname, 'dist''client'),
    filename: 'index.js'
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
              presets: ['@babel/preset-env''@babel/preset-react']
          }
        }
      }
    ]
  }
}

ssr/webpack.server.js

const path = require('path')
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'development',
  entry: './src/entry-server.jsx',
  output: {
    path: path.join(__dirname, 'dist''server'),
    filename: 'index.js',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    globalObject: 'this',
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
              presets: ['@babel/preset-env''@babel/preset-react']
          }
        }
      }
    ]
  },
  externals: [nodeExternals()],
  target: 'node',
}

修改 ssr/package.json 的 scripts 如下

{
  "name""init",
  "version""1.0.0",
  "description""",
  "main""index.js",
  "scripts"{
    "build:client""webpack --config webpack.client.js",
    "build:server""webpack --config webpack.server.js",
    "build""npm run build:client && npm run build:server",
    "start""nodemon server.js"
  },
  "author""",
  "license""ISC",
  "devDependencies"{
    "@babel/core""^7.17.8",
    "@babel/preset-env""^7.16.11",
    "@babel/preset-react""^7.16.7",
    "babel-loader""^8.2.4",
    "webpack""^5.70.0",
    "webpack-cli""^4.9.2",
    "webpack-node-externals""^3.0.0"
  },
  "dependencies"{
    "express""^4.17.3",
    "react""^17.0.2",
    "react-dom""^17.0.2"
  }
}

執行npm run build

此時目錄結構如下

ssr
├── dist
│   ├── client
│   │   └── index.js
│   └── server
│       └── index.js
├── package-lock.json
├── package.json
├── src
│   ├── App.jsx
│   ├── entry-client.jsx
│   └── entry-server.jsx
├── webpack.client.js
└── webpack.server.js

ssr/dist 爲編譯產物,其中 node server 渲染靜態 html 時需要用 ssr/dist/server/index.js,客戶端激活時需要使用 ssr/dist/client/index.js

我們搭建一個 node server 服務,來將 SSR 服務整體跑起來

ssr/server.js

const express = require('express')
const path = require('path')
const fs = require('fs/promises')

const server = express()
const createAppString = require('./dist/server/index').default

server.use('/js', express.static(path.join(__dirname, 'dist''client')))

server.get('/', async (req, res) ={
  const htmlTemplate = await fs.readFile('./public/index.html''utf-8')
  const appString = createAppString()
  const html = htmlTemplate.replace('<article id="root"></article>'`<article id="root">${appString}</article>`)
  res.send(html)
})

server.listen(1234)

創建 html 模版文件

ssr/public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta >
  <title>Document</title>
</head>
<body>
  <article id="root"></article>
  <script src="/js/index.js"></script>
</body>
</html>

運行npm start

我們打開http://localhost:1234/並查看源碼如下

通過點擊按鈕我們可以知道現在頁面已經是一個可交互的 react 應用了,我們查看源碼發現源碼已經正確的渲染出來 react 應用。

添加路由

npm install --save react-router-dom

創建路由映射表

ssr/src/routes.js

import Home from './pages/Home.jsx'
import About from './pages/About.jsx'

const routes = [
  {
    path: '/',
    element: Home
  },
  {
    path: '/about',
    element: About
  }
]

export default routes

ssr/src/pages/Home.jsx

import React from 'react'

export default function Home () {
  return (
    <article>我是首頁</article>
  )
}

ssr/src/pages/About.jsx

import React, { useState } from 'react'

export default function About () {
  const [name, setName] = useState('姓名默認值')
  const [age, setAge] = useState(0)

  function onClick () {
    setAge(age + 1)
  }

  return (
    <article>
      <p>name: { name }</p>
      <p>age: { age }</p>
      <button onClick={onClick}>過年</button>
    </article>
  )
}

修改 ssr/src/App.jsx

import React from 'react'
import { Route, NavLink, Routes } from 'react-router-dom'

import routes from './routes'

export default function App () {
  return (
    <article>
      <nav>
        <NavLink to="/">Home</NavLink> |
        <NavLink to="/about">About</NavLink>
      </nav>
      <main>
        <Routes>
          {
            routes.map(item => 
              <Route key={item.path} exact path={item.path} element={<item.element />} />
            )
          }
        </Routes>
      </main>
    </article>
  )
}

修改 ssr/src/entry-client.jsx

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'

import App from './App.jsx'

ReactDOM.hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>
, document.querySelector('#root'))

修改 ssr/src/entry-server.jsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";

import App from './App.jsx'

export default function createAppString({url}) {
  console.log('url', url)
  return renderToString(
    <StaticRouter location={url}>
      <App />
    </StaticRouter>
  );
}

運行npm run build

此時目錄結構如下

ssr
├── dist
│   ├── client
│   │   └── index.js
│   └── server
│       └── index.js
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── server.js
├── src
│   ├── App.jsx
│   ├── entry-client.jsx
│   ├── entry-server.jsx
│   ├── pages
│   │   ├── About.jsx
│   │   └── Home.jsx
│   └── routes.js
├── webpack.client.js
└── webpack.server.js

修改 ssr/server.js

const express = require("express");
const path = require("path");
const fs = require("fs/promises");

(async () ={
  const indexTemplate = await fs.readFile(path.join(__dirname, "public""index.html"),"utf-8");

  const server = express();
  server.use('/js', express.static(path.join(__dirname, 'dist/client')))
  server.get("*", async (req, res) ={
    const createAppString = require('./dist/server/index').default
    const appString = createAppString({ url: req.url })
    const html = indexTemplate.replace(
      '<article id="root">',
      `<article id="root">${appString}`
    );
    res.send(html);
  });
  server.listen(1234);
})();

運行npm start

打開頁面 http://localhost:1234 / 並查看源碼

如上圖所示,服務端渲染正確

添加 ajax 異步請求

npm install --save axios
npm install --save-dev @babel/plugin-transform-runtime

修改 webpack 配置,使其支持async function語法

ssr/webpack.server.js

const path = require('path')
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'development',
  entry: './src/entry-server.jsx',
  output: {
    path: path.join(__dirname, 'dist''server'),
    filename: 'index.js',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    globalObject: 'this',
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
              presets: ['@babel/preset-env''@babel/preset-react'],
              plugins: ['@babel/plugin-transform-runtime']
          }
        }
      }
    ]
  },
  externals: [nodeExternals()],
  target: 'node',
}

ssr/webpack.client.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/entry-client.jsx',
  output: {
    path: path.join(__dirname, 'dist''client'),
    filename: 'index.js'
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env''@babel/preset-react'],
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      }
    ]
  }
}

創建文件 ssr/src/api.js

import axios from 'axios'

export function getInfo () {
  return axios.get('http://localhost:1234/info')
}

export function getText () {
  return axios.get('http://localhost:1234/text')
}

我們在 SSR 階段可以通過瀏覽器返回的 url 與路由表信息來獲取與之匹配的頁面組件,我們假設匹配到的頁面組件中有getInitData方法,我們通過該方法拿到頁面初始數據後再渲染react app,然後我們將初始數據傳入 App

ssr/src/entry-server.jsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";

import routes from './routes'

import App from './App.jsx'

export default async function createAppString({url}) {
  const match = routes.filter(item => item.path === url)
  let __INIT_DATA__ = {}
  if (match.length > 0) {
    await Promise.all(match.map(async item ={
      const { getInitData, initDataId } = item.element
      const { data } = await getInitData()
      __INIT_DATA__[initDataId] = data
    }))
  }
  const appString = renderToString(
    <StaticRouter location={url}>
      <App initData={__INIT_DATA__} />
    </StaticRouter>
  )
  return { appString, __INIT_DATA__ };
}

App 在服務端渲染時傳入了initData初始數據,我們拿到初始數據並將其傳入匹配到的頁面組件中

ssr/src/App.jsx

import React, { useState } from 'react'
import { Route, NavLink, Routes } from 'react-router-dom'

import routes from './routes'

export default function App (props) {
  const [initData, setInitData] = useState((props.initData ? props.initData : window.__INIT_DATA__) || {})
  return (
    <article>
      <nav>
        <NavLink to="/">Home</NavLink> |
        <NavLink to="/about">About</NavLink>
      </nav>
      <main>
        <Routes>
          {
            routes.map(item => 
              <Route key={item.path} exact path={item.path} element={<item.element initData={initData} setInitData={setInitData} />} />
            )
          }
        </Routes>
      </main>
    </article>
  )
}

接下來我們需要在頁面組件定義getInitData方法,以使其在服務端渲染時能夠獲取到初始化數據。

因爲每個頁面都有一部分相同的處理初始數據的邏輯,所以需要我們將這部分邏輯抽離出來做成一個公共組件。

ssr/src/components/Layout.jsx

import React from 'react'

export default function Layout (Component, { getInitData, initDataId }) {
  const PageComponent = (props) ={
    const initData = props.initData[initDataId]
    if (!initData) {
      (async () ={
        const { data } = await getInitData()
        props.setInitData({ ...props.initData, [initDataId]: data })
      })()
    }

    return <Component initData={initData || {}} />
  }

  PageComponent.getInitData = getInitData
  PageComponent.initDataId = initDataId

  return PageComponent
}

如上代碼所示,我們定義了一個 Layout 方法,專門用來處理初始數據,執行 Layout 會返回一個 PageComponent 組件,該組件包含當前頁面的getInitData方法與initDataId屬性。這兩個對象會被用於 SSR 階段獲取和保存數據,PageComponent 組件渲染並返回了我們傳入 Layout 方法的頁面組件(Home、About),頁面組件在渲染時傳入了已經處理好的當前頁面的初始數據initData,所以我們在頁面組件 Home、About 組件中可以直接通過props.initData獲取初始數據。

initDataId 的含義:我們在每個頁面會定義一個唯一的 initDataId變量,我們儲存數據時會使用該變量作爲 key 值。用於在不同的頁面獲取與其對應的初始數據。

接下來我們修改頁面組件 Home 與 About,我們在頁面組件中引入 Layout,並傳入getInitData方法與initDataId屬性。

ssr/src/pages/Home.jsx

import React, { useState, useEffect } from "react";

import Layout from "../components/Layout.jsx";
import { getText } from "./../api";

function Home({ initData }) {
  const [text, setText] = useState(initData && initData.text || "首頁");

  useEffect(() ={
    initData.text && setText(initData.text);
  }[initData]);

  return <article>{text}</article>;
}

export default Layout(Home, { getInitData: getText, initDataId: 'home' })

ssr/src/pages/About.jsx

import React, { useEffect, useState } from "react";

import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";

function About({ initData }) {
  const [name, setName] = useState(initData.name || "姓名默認值");
  const [age, setAge] = useState(initData.age || 0);

  useEffect(() ={
    initData.name && setName(initData.name)
    initData.age && setAge(initData.age)
  }[initData])

  function onClick() {
    setAge(age + 1);
  }

  return (
    <article>
      <p>name: {name}</p>
      <p>age: {age}</p>
      <button onClick={onClick}>過年</button>
    </article>
  );
}

export default Layout(About, { getInitData: getInfo, initDataId: 'about' })

我們在 ssr/server.js 中添加幾個接口。

調用createAppString後我們能得到頁面的初始數據,我們將其放入 html 的全局變量window.__INIT_DATA__中,用於客戶端激活與數據的初始化。

const express = require("express");
const path = require("path");
const fs = require("fs/promises");

(async () ={
  const indexTemplate = await fs.readFile(path.join(__dirname, "public""index.html"),"utf-8");

  const server = express();

  server.get('/info', async (req, res) ={

    setTimeout(() ={
      res.send({
        name: 'hagan',
        age: 22
      })
    }, 1000)
  })

  server.get('/text', async (req, res) ={
    setTimeout(() ={
      res.send({
        text: '我是服務端渲染出來的首頁文案'
      })
    }, 1000)
  })

  server.use('/js', express.static(path.join(__dirname, 'dist/client')))
  server.get("*", async (req, res) ={
    const createAppString = require('./dist/server/index').default
    const { appString, __INIT_DATA__ } = await createAppString({ url: req.url })
    const html = indexTemplate.replace(
      '<article id="root"></article>',
      `<article id="root">${appString}</article><script>window.__INIT_DATA__ = ${JSON.stringify(__INIT_DATA__)}</script>`
    );
    res.send(html);
  });
  server.listen(1234);
})();
npm run build

此時目錄結構如下

.
├── dist
│   ├── client
│   │   └── index.js
│   └── server
│       └── index.js
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── server.js
├── src
│   ├── App.jsx
│   ├── api.js
│   ├── components
│   │   └── Layout.jsx
│   ├── entry-client.jsx
│   ├── entry-server.jsx
│   ├── pages
│   │   ├── About.jsx
│   │   └── Home.jsx
│   └── routes.js
├── webpack.client.js
└── webpack.server.js

我們運行npm start後打開頁面 http://localhost:1234 / 並查看源碼

此時服務端渲染已正確運行。

添加樣式

我們使用 css-loader 來處理 css,在客戶端渲染階段我們使用 style-loader 來將樣式插入到 html,在服務端渲染階段我們使用 isomorphic-style-loader 來獲取 css 字符串並手動將其插入到 html 模版中。

npm install --save-dev css-loader style-loader isomorphic-style-loader

修改 webpack 配置

ssr/webpack.client.js

const path = require('path')

module.exports = {
  mode: 'development',
  entry: './src/entry-client.jsx',
  output: {
    path: path.join(__dirname, 'dist''client'),
    filename: 'index.js'
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env''@babel/preset-react'],
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      },
      {
        test: /\.css?$/,
        use: [
          // "isomorphic-style-loader",
          "style-loader",
          {
            loader: "css-loader",
            options: {
              modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]'
              },
            },
          },
        ],
      },
    ]
  }
}

ssr/webpack.server.js

const path = require('path')
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'development',
  entry: './src/entry-server.jsx',
  output: {
    path: path.join(__dirname, 'dist''server'),
    filename: 'index.js',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    globalObject: 'this',
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
              presets: ['@babel/preset-env''@babel/preset-react'],
              plugins: ['@babel/plugin-transform-runtime']
          }
        }
      },
      {
        test: /\.css?$/,
        use: [
          "isomorphic-style-loader",
          {
            loader: "css-loader",
            options: {
              modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]'
              },
              esModule: false, // 不加這個CSS內容會顯示爲[Object Module],且styles['name']方式拿不到樣式名
            },
          },
        ],
      },
    ]
  },
  externals: [nodeExternals()],
  target: 'node',
}

ssr/src/entry-server.jsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";
import StyleContext from'isomorphic-style-loader/StyleContext'

import routes from './routes'

import App from './App.jsx'

export default async function createAppString({url}) {
  const css = new Set();
  const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))

  const match = routes.filter(item => item.path === url)
  let __INIT_DATA__ = {}
  if (match.length > 0) {
    await Promise.all(match.map(async item ={
      const { getInitData, initDataId } = item.element
      const { data } = await getInitData()
      __INIT_DATA__[initDataId] = data
    }))
  }
  const appString = renderToString(
    <StyleContext.Provider value={{ insertCss }}>
      <StaticRouter location={url}>
        <App initData={__INIT_DATA__} />
      </StaticRouter>
    </StyleContext.Provider>
  )
  return { appString, __INIT_DATA__, styles: [...css].join(' ') };
}

如上代碼所示,我們定義了一個css變量和一個insertCss方法,用來收集匹配到的 css。我們將 insertCss 傳入到StyleContext.Provider中,然後我們在頁面組件中調用useStyles就能收集到匹配頁面的 css 了。最後我們將收集到的 css 轉換成字符串返回給server.js

創建 ssr/src/pages/home.css

.home {
  width: 80vw;
  height: 200px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: azure;
  border: 1px solid blue;
}

ssr/src/pages/Home.jsx

import React, { useState, useEffect } from "react";
import useStyles from "isomorphic-style-loader/useStyles";

import Layout from "../components/Layout.jsx";
import { getText } from "./../api";
import styles from './home.css'

function Home({ initData }) {
  if (styles._insertCss) {
    useStyles(styles);
  }
  
  const [text, setText] = useState(initData && initData.text || "首頁");

  useEffect(() ={
    initData.text && setText(initData.text);
  }[initData]);

  return <article className={styles['home']}>{text}</article>;
}

export default Layout(Home, { getInitData: getText, initDataId: 'home' })

創建 ssr/src/pages/about.css

.about {
  width: 80vw;
  height: 300px;
  background-color: cornsilk;
  border: 1px solid rgb(183, 116, 255);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
}

.name {
  color: red;
  margin: 0;
}

.age {
  color: blue;
  margin: 0;
  padding: 5px;
}

ssr/src/pages/About.jsx

import React, { useEffect, useState } from "react";
import useStyles from "isomorphic-style-loader/useStyles";

import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";
import styles from './about.css'

function About({ initData }) {
  if (styles._insertCss) {
    useStyles(styles);
  }
  
  const [name, setName] = useState(initData.name || "姓名默認值");
  const [age, setAge] = useState(initData.age || 0);

  useEffect(() ={
    initData.name && setName(initData.name)
    initData.age && setAge(initData.age)
  }[initData])

  function onClick() {
    setAge(age + 1);
  }

  return (
    <article className={styles["about"]}>
      <p className={styles["name"]}>name: {name}</p>
      <p className={styles["age"]}>age: {age}</p>
      <button onClick={onClick}>過年</button>
    </article>
  );
}

export default Layout(About, { getInitData: getInfo, initDataId: 'about' })

執行npm run build後目錄結構如下

ssr
├── dist
│   ├── client
│   │   └── index.js
│   └── server
│       └── index.js
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── server.js
├── src
│   ├── App.jsx
│   ├── api.js
│   ├── components
│   │   └── Layout.jsx
│   ├── entry-client.jsx
│   ├── entry-server.jsx
│   ├── pages
│   │   ├── About.jsx
│   │   ├── Home.jsx
│   │   ├── about.css
│   │   └── home.css
│   └── routes.js
├── webpack.client.js
└── webpack.server.js

我們接收createAppString返回的 css 字符串,並添加到 html 中。

ssr/server.js

const express = require("express");
const path = require("path");
const fs = require("fs/promises");

(async () ={
  const indexTemplate = await fs.readFile(path.join(__dirname, "public""index.html"),"utf-8");

  const server = express();

  server.get('/info', async (req, res) ={

    setTimeout(() ={
      res.send({
        name: 'hagan',
        age: 22
      })
    }, 1000)
  })

  server.get('/text', async (req, res) ={
    setTimeout(() ={
      res.send({
        text: '我是服務端渲染出來的首頁文案'
      })
    }, 1000)
  })

  server.use('/js', express.static(path.join(__dirname, 'dist/client')))
  server.get("*", async (req, res) ={
    const createAppString = require('./dist/server/index').default
    const { appString, __INIT_DATA__, styles } = await createAppString({ url: req.url })
    const html = indexTemplate
      .replace(
        '<article id="root"></article>',
        `<article id="root">${appString}</article><script>window.__INIT_DATA__ = ${JSON.stringify(
          __INIT_DATA__
        )}</script>`
      )
      .replace(
        "<title>Document</title>",
        `<title>Document</title><style ssr>${styles}</style>`
      );
    res.send(html);
  });
  server.listen(1234);
})();

執行npm start後打開頁面 http://localhost:1234 / 並查看源碼

我們可以看到樣式已經生效,並且服務端渲染也返回了<style>標籤。但這樣的方式不利於頁面的性能優化,所以我們需要將 css 抽離出來單獨的文件來進行引入。

抽離 css 需要mini-css-extract-plugin

npm install --save-dev mini-css-extract-plugin

這裏我們將兩個頁面的 css 抽離成一個 css 文件,然後我們直接在服務端渲染時引用這個 css 文件,這樣服務端渲染階段就不需要做 css 的收集了,我們需要把前面 css 收集相關的代碼都去掉。

修改 webpack 配置

ssr/webpack.client.js

const path = require('path')
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  mode: 'development',
  entry: './src/entry-client.jsx',
  output: {
    path: path.join(__dirname, 'dist''client'),
    filename: 'index.js'
  },
  plugins:[
    new MiniCssExtractPlugin({
      filename: "index.css",
      chunkFilename: "[id].css"
    })
  ],
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env''@babel/preset-react'],
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      },
      {
        test: /\.css?$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: path.join(__dirname, 'dist''client')
            }
          },
          {
            loader: "css-loader",
            options: {
              modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]'
              },
            },
          },
        ],
      },
    ]
  }
}

去掉收集 css 並插入到 html 的邏輯

ssr/server.js

const express = require("express");
const path = require("path");
const fs = require("fs/promises");

(async () ={
  const indexTemplate = await fs.readFile(path.join(__dirname, "public""index.html"),"utf-8");

  const server = express();

  server.get('/info', async (req, res) ={

    setTimeout(() ={
      res.send({
        name: 'hagan',
        age: 22
      })
    }, 1000)
  })

  server.get('/text', async (req, res) ={
    setTimeout(() ={
      res.send({
        text: '我是服務端渲染出來的首頁文案'
      })
    }, 1000)
  })

  server.use("/static", express.static(path.join(__dirname, "dist/client")));
  server.get("*", async (req, res) ={
    const createAppString = require('./dist/server/index').default
    const { appString, __INIT_DATA__ } = await createAppString({ url: req.url })
    const html = indexTemplate
      .replace(
        '<article id="root"></article>',
        `<article id="root">${appString}</article><script>window.__INIT_DATA__ = ${JSON.stringify(
          __INIT_DATA__
        )}</script>`
      )
    res.send(html);
  });
  server.listen(1234);
})();

這裏直接引入打包好的 css 文件即可/static/index.css

ssr/public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta >
  <link type="text/css" rel="stylesheet" href="/static/index.css" />
  <title>Document</title>
</head>
<body>
  <article id="root"></article>
  <script src="/static/index.js"></script>
</body>
</html>

去掉收集 css 的邏輯

ssr/src/entry-server.jsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";

import routes from './routes'

import App from './App.jsx'

export default async function createAppString({url}) {
  const match = routes.filter(item => item.path === url)
  let __INIT_DATA__ = {}
  if (match.length > 0) {
    await Promise.all(match.map(async item ={
      const { getInitData, initDataId } = item.element
      const { data } = await getInitData()
      __INIT_DATA__[initDataId] = data
    }))
  }
  const appString = renderToString(
    <StaticRouter location={url}>
      <App initData={__INIT_DATA__} />
    </StaticRouter>
  )
  return { appString, __INIT_DATA__ };
}

去掉收集 css 的邏輯

ssr/src/pages/Home.jsx

import React, { useState, useEffect } from "react";

import Layout from "../components/Layout.jsx";
import { getText } from "./../api";
import styles from './home.css'

function Home({ initData }) {
  const [text, setText] = useState(initData && initData.text || "首頁");

  useEffect(() ={
    initData.text && setText(initData.text);
  }[initData]);

  return <article className={styles['home']}>{text}</article>;
}

export default Layout(Home, { getInitData: getText, initDataId: 'home' })

去掉收集 css 的邏輯

ssr/src/pages/About.jsx

import React, { useEffect, useState } from "react";

import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";
import styles from './about.css'

function About({ initData }) {
  const [name, setName] = useState(initData.name || "姓名默認值");
  const [age, setAge] = useState(initData.age || 0);

  useEffect(() ={
    initData.name && setName(initData.name)
    initData.age && setAge(initData.age)
  }[initData])

  function onClick() {
    setAge(age + 1);
  }

  return (
    <article className={styles["about"]}>
      <p className={styles["name"]}>name: {name}</p>
      <p className={styles["age"]}>age: {age}</p>
      <button onClick={onClick}>過年</button>
    </article>
  );
}

export default Layout(About, { getInitData: getInfo, initDataId: 'about' })

執行npm run build後目錄結構如下

ssr
├── dist
│   ├── client
│   │   ├── index.css
│   │   └── index.js
│   └── server
│       └── index.js
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── server.js
├── src
│   ├── App.jsx
│   ├── api.js
│   ├── components
│   │   └── Layout.jsx
│   ├── entry-client.jsx
│   ├── entry-server.jsx
│   ├── pages
│   │   ├── About.jsx
│   │   ├── Home.jsx
│   │   ├── about.css
│   │   └── home.css
│   └── routes.js
├── webpack.client.js
└── webpack.server.js

我們可以看到dist/client/index.css中已經包含了兩個頁面的 css。

然後我們運行npm start後打開頁面 http://localhost:1234 / 並查看源碼

此時添加外鏈樣式已完成

代碼拆分

在項目開發時,爲了性能考慮,我們通常會使用React.lazy的方式加載異步組件,但React.lazy並不適用於服務端渲染,此時我們可以使用loadable代替React.lazy來進行異步組件的加載。

npm install --save @loadable/component @loadable/server
npm install --save-dev @loadable/babel-plugin @loadable/webpack-plugin

我們先修改 webpack 配置

ssr/webpack.client.js

const path = require('path')
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const LoadablePlugin = require('@loadable/webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './src/entry-client.jsx',
  output: {
    path: path.join(__dirname, 'dist''client'),
    filename: 'index.js'
  },
  plugins:[
    new MiniCssExtractPlugin({
      filename: "index.css",
      chunkFilename: "[id].css"
    }),
    new LoadablePlugin()
  ],
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env''@babel/preset-react'],
            plugins: ["@babel/plugin-transform-runtime""@loadable/babel-plugin"],
          }
        }
      },
      {
        test: /\.css?$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: path.join(__dirname, 'dist''client')
            }
          },
          {
            loader: "css-loader",
            options: {
              modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]'
              },
            },
          },
        ],
      },
    ]
  }
}

ssr/webpack.server.js

const path = require('path')
const nodeExternals = require('webpack-node-externals');
const LoadablePlugin = require('@loadable/webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './src/entry-server.jsx',
  output: {
    path: path.join(__dirname, 'dist''server'),
    filename: 'index.js',
    libraryTarget: 'umd',
    umdNamedDefine: true,
    globalObject: 'this',
  },
  plugins:[
    new LoadablePlugin()
  ],
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
              presets: ['@babel/preset-env''@babel/preset-react'],
              plugins: ["@babel/plugin-transform-runtime""@loadable/babel-plugin"],
          }
        }
      },
      {
        test: /\.css?$/,
        use: [
          "isomorphic-style-loader",
          {
            loader: "css-loader",
            options: {
              modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]'
              },
              esModule: false, // 不加這個CSS內容會顯示爲[Object Module],且styles['name']方式拿不到樣式名
            },
          },
        ],
      },
    ]
  },
  externals: [nodeExternals()],
  target: 'node',
}

然後我們修改路由表,使其異步加載頁面組件

ssr/src/routes.js

import loadable from '@loadable/component'

const routes = [
  {
    path: '/',
    element: loadable(() => import('./pages/Home.jsx'))
  },
  {
    path: '/about',
    element: loadable(() => import('./pages/About.jsx'))
  }
]

export default routes

我們需要修改客戶端入口文件,使loadable能夠正確加載組件

ssr/src/entry-client.jsx

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import { loadableReady } from '@loadable/component'

import App from './App.jsx'

loadableReady(() ={
  ReactDOM.hydrate(
    <BrowserRouter>
      <App />
    </BrowserRouter>
  , document.querySelector('#root'))
})

ssr/src/entry-server.jsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter, matchPath } from "react-router-dom/server";

import routes from './routes'

import App from './App.jsx'

export default async function createAppString({url}) {
  const match = routes.filter(item => item.path === url)
  let __INIT_DATA__ = {}
  if (match.length > 0) {
    await Promise.all(match.map(async item ={
      const Component = (await item.element.load()).default
      const { getInitData, initDataId } = Component
      const res = await getInitData()
      __INIT_DATA__[initDataId] = res.data
    }))
  }

  const appString = renderToString(
    <StaticRouter location={url}>
      <App initData={__INIT_DATA__} />
    </StaticRouter>
  )
    
  return { appString, __INIT_DATA__ };
}

我們需要在 html 模版中刪掉static/index.css的引入,因爲loadable會自動加載匹配的 css

ssr/public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta >
  <title>Document</title>
</head>
<body>
  <article id="root"></article>
  <script src="/static/index.js"></script>
</body>
</html>

然後我們運行npm run build

此時目錄結構如下

ssr
├── dist
│   ├── client
│   │   ├── index.js
│   │   ├── loadable-stats.json
│   │   ├── src_pages_About_jsx.css
│   │   ├── src_pages_About_jsx.index.js
│   │   ├── src_pages_Home_jsx.css
│   │   ├── src_pages_Home_jsx.index.js
│   │   └── vendors-node_modules_babel_runtime_regenerator_index_js-node_modules_axios_index_js-node_modu-8131bb.index.js
│   └── server
│       ├── index.js
│       ├── loadable-stats.json
│       ├── src_pages_About_jsx.index.js
│       └── src_pages_Home_jsx.index.js
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── server.js
├── src
│   ├── App.jsx
│   ├── api.js
│   ├── components
│   │   └── Layout.jsx
│   ├── entry-client.jsx
│   ├── entry-server.jsx
│   ├── pages
│   │   ├── About.jsx
│   │   ├── Home.jsx
│   │   ├── about.css
│   │   └── home.css
│   └── routes.js
├── webpack.client.js
└── webpack.server.js

運行npm start後打開頁面 http://localhost:1234/

我們可以發現頁面在加載的一瞬間會閃爍一下,我們查看源碼可以發現在服務端渲染階段並沒有返回給我們 css 樣式,而客戶端渲染時加載了樣式,但在客戶端加載完樣式之前是有一定的時間差的,所以纔會有一瞬間的閃爍,爲了解決此問題,我們需要在服務端渲染時把樣式插入進來。我們可以通過loadable提供的ChunkExtractor構造函數來獲取樣式,具體代碼如下

詳細的 loadable 文檔請參考鏈接 https://loadable-components.com/docs/server-side-rendering/

ssr/src/entry-server.jsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { ChunkExtractor } from '@loadable/server'
import { join } from 'path'

import routes from './routes'

import App from './App.jsx'

export default async function createAppString({url}) {
  const match = routes.filter(item => item.path === url)
  let __INIT_DATA__ = {}
  if (match.length > 0) {
    await Promise.all(match.map(async item ={
      const Component = (await item.element.load()).default
      const { getInitData, initDataId } = Component
      const res = await getInitData()
      __INIT_DATA__[initDataId] = res.data
    }))
  }

  const extractor = new ChunkExtractor({
    statsFile: join(__dirname, '../''client''loadable-stats.json'),
    publicPath: '/static'
  })

  const appString = renderToString(
    extractor.collectChunks(
      <StaticRouter location={url}>
        <App initData={__INIT_DATA__} />
      </StaticRouter>
    )
  )

  const styleTags = extractor.getStyleTags()
  console.log('styleTags', styleTags)
    
  return { appString, __INIT_DATA__, styleTags };
}

如上createAppString返回了styleTags樣式鏈接,我們在 node server 中接收並添加到 html 返回值中就可以了

ssr/server.js

const express = require("express");
const path = require("path");
const fs = require("fs/promises");

(async () ={
  const indexTemplate = await fs.readFile(path.join(__dirname, "public""index.html"),"utf-8");

  const server = express();

  server.get('/info', async (req, res) ={

    setTimeout(() ={
      res.send({
        name: 'hagan',
        age: 22
      })
    }, 1000)
  })

  server.get('/text', async (req, res) ={
    setTimeout(() ={
      res.send({
        text: '我是服務端渲染出來的首頁文案'
      })
    }, 1000)
  })

  server.use("/static", express.static(path.join(__dirname, "dist/client")));
  server.get("*", async (req, res) ={
    const createAppString = require('./dist/server/index').default
    const { appString, __INIT_DATA__, styleTags } = await createAppString({ url: req.url })
    const html = indexTemplate
      .replace(
        '<!-- replace-css -->',
        styleTags
      )
      .replace(
        '<!-- replace-root -->',
        `<article id="root">${appString}</article>`
      )
      .replace(
        '<!-- replace-data -->',
        `<script>window.__INIT_DATA__ = ${JSON.stringify(
          __INIT_DATA__
        )}</script>`
      )
    res.send(html);
  });
  server.listen(1234);
})();

ssr/public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta >
  <!-- replace-css -->
  <title>Document</title>
</head>
<body>
  <!-- replace-root -->
  <!-- replace-data -->
  <script src="/static/index.js"></script>
</body>
</html>
npm run build
npm start

打開頁面 http://localhost:1234 / 並查看源碼

此時樣式已經在服務端渲染階段插入進來。

引入 react-helmet

使用 react-helmet 可以提升搜索引擎對於頁面的解析抓取效果,可以讓我們的頁面更好的被搜索引擎收錄。這裏我們直接引入,並將 Home、About 頁面分別修改爲兩個標題來做測試。

npm install --save react-helmet

ssr/src/App.jsx

import React, { useState } from 'react'
import { Route, NavLink, Routes } from 'react-router-dom'
import Helmet from 'react-helmet'

import routes from './routes'

export default function App (props) {
  const [initData, setInitData] = useState((props.initData ? props.initData : window.__INIT_DATA__) || {})
  return (
    <article>
      <Helmet>
        <html lang="en" />
        <meta charset="UTF-8"></meta>
        <meta http-equiv="X-UA-Compatible" content="IE=edge"></meta>
        <meta ></meta>
        <title>默認標題</title>
      </Helmet>
      <nav>
        <NavLink to="/">Home</NavLink> |
        <NavLink to="/about">About</NavLink>
      </nav>
      <main>
        <Routes>
          {
            routes.map(item => 
              <Route key={item.path} exact path={item.path} element={<item.element initData={initData} setInitData={setInitData} />} />
            )
          }
        </Routes>
      </main>
    </article>
  )
}

Home 的頁面標題爲首頁

ssr/src/pages/Home.jsx

import React, { useState, useEffect } from "react";
import Helmet from 'react-helmet'

import Layout from "../components/Layout.jsx";
import { getText } from "./../api";
import styles from './home.css'

function Home({ initData }) {
  const [text, setText] = useState(initData && initData.text || "首頁");

  useEffect(() ={
    initData.text && setText(initData.text);
  }[initData]);

  return (
    <article className={styles['home']}>
      <Helmet>
        <title>首頁</title>
      </Helmet>
      {text}
    </article>
  );
}

export default Layout(Home, { getInitData: getText, initDataId: 'home' })

About 的頁面標題爲關於頁

ssr/src/pages/About.jsx

import React, { useEffect, useState } from "react";
import Helmet from 'react-helmet'

import Layout from "../components/Layout.jsx";
import { getInfo } from "./../api";
import styles from './about.css'

function About({ initData }) {
  const [name, setName] = useState(initData.name || "姓名默認值");
  const [age, setAge] = useState(initData.age || 0);

  useEffect(() ={
    initData.name && setName(initData.name)
    initData.age && setAge(initData.age)
  }[initData])

  function onClick() {
    setAge(age + 1);
  }

  return (
    <article className={styles["about"]}>
      <Helmet>
        <title>關於頁</title>
      </Helmet>
      <p className={styles["name"]}>name: {name}</p>
      <p className={styles["age"]}>age: {age}</p>
      <button onClick={onClick}>過年</button>
    </article>
  );
}

export default Layout(About, { getInitData: getInfo, initDataId: 'about' })

ssr/src/entry-server.jsx

import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { ChunkExtractor } from '@loadable/server'
import { join } from 'path'
import Helmet from 'react-helmet'

import routes from './routes'

import App from './App.jsx'

export default async function createAppString({url}) {
  const match = routes.filter(item => item.path === url)
  let __INIT_DATA__ = {}
  if (match.length > 0) {
    await Promise.all(match.map(async item ={
      const Component = (await item.element.load()).default
      const { getInitData, initDataId } = Component
      const res = await getInitData()
      __INIT_DATA__[initDataId] = res.data
    }))
  }

  const extractor = new ChunkExtractor({
    statsFile: join(__dirname, '../''client''loadable-stats.json'),
    publicPath: '/static'
  })

  const appString = renderToString(
    extractor.collectChunks(
      <StaticRouter location={url}>
        <App initData={__INIT_DATA__} />
      </StaticRouter>
    )
  )

  const styleTags = extractor.getStyleTags()
  const helmet = Helmet.renderStatic()
  console.log('styleTags', styleTags)
    
  return { appString, __INIT_DATA__, styleTags, helmet };
}

ssr/server.js

const express = require("express");
const path = require("path");
const fs = require("fs/promises");

(async () ={
  const indexTemplate = await fs.readFile(path.join(__dirname, "public""index.html"),"utf-8");

  const server = express();

  server.get('/info', async (req, res) ={

    setTimeout(() ={
      res.send({
        name: 'hagan',
        age: 22
      })
    }, 1000)
  })

  server.get('/text', async (req, res) ={
    setTimeout(() ={
      res.send({
        text: '我是服務端渲染出來的首頁文案'
      })
    }, 1000)
  })

  server.use("/static", express.static(path.join(__dirname, "dist/client")));
  server.get("*", async (req, res) ={
    const createAppString = require('./dist/server/index').default
    const { appString, __INIT_DATA__, styleTags, helmet } = await createAppString({ url: req.url })
    const html = indexTemplate
      .replace(
        'replace-html-attributes',
        helmet.htmlAttributes.toString()
      )
      .replace(
        '<!-- replace-meta -->',
        helmet.meta.toString()
      )
      .replace(
        '<!-- replace-link -->',
        helmet.link.toString()
      )
      .replace(
        '<!-- replace-css -->',
        styleTags
      )
      .replace(
        '<!-- replace-title -->',
        helmet.title.toString()
      )
      .replace(
        'replace-body-attributes',
        helmet.bodyAttributes.toString()
      )
      .replace(
        '<!-- replace-root -->',
        `<article id="root">${appString}</article>`
      )
      .replace(
        '<!-- replace-data -->',
        `<script>window.__INIT_DATA__ = ${JSON.stringify(
          __INIT_DATA__
        )}</script>`
      )
    res.send(html);
  });
  server.listen(1234);
})();

ssr/public/index.html

<!DOCTYPE html>
<html replace-html-attributes>
<head>
  <!-- replace-meta -->
  <!-- replace-link -->
  <!-- replace-css -->
  <!-- replace-title -->
</head>
<body replace-body-attributes>
  <!-- replace-root -->
  <!-- replace-data -->
  <script src="/static/index.js"></script>
</body>
</html>

運行npm run build

此時目錄結構如下

ssr
├── dist
│   ├── client
│   │   ├── index.js
│   │   ├── loadable-stats.json
│   │   ├── pages-About-jsx.css
│   │   ├── pages-About-jsx.index.js
│   │   ├── pages-Home-jsx.css
│   │   ├── pages-Home-jsx.index.js
│   │   ├── src_pages_About_jsx.css
│   │   ├── src_pages_About_jsx.index.js
│   │   ├── src_pages_Home_jsx.css
│   │   ├── src_pages_Home_jsx.index.js
│   │   └── vendors-node_modules_babel_runtime_regenerator_index_js-node_modules_axios_index_js-node_modu-8131bb.index.js
│   └── server
│       ├── index.js
│       ├── loadable-stats.json
│       ├── pages-About-jsx.index.js
│       ├── pages-Home-jsx.index.js
│       ├── src_pages_About_jsx.index.js
│       └── src_pages_Home_jsx.index.js
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── server.js
├── src
│   ├── App.jsx
│   ├── api.js
│   ├── components
│   │   └── Layout.jsx
│   ├── entry-client.jsx
│   ├── entry-server.jsx
│   ├── pages
│   │   ├── About.jsx
│   │   ├── Home.jsx
│   │   ├── about.css
│   │   └── home.css
│   └── routes.js
├── webpack.client.js
└── webpack.server.js

我們運行npm start後打開頁面 http://localhost:1234/about 並查看源碼

我們可以看到 SSR 階段頁面 title 就已經正確的被解析成我們想要的 title 了,helmet 還有很多功能,詳細 API 請參考官方文檔 https://www.npmjs.com/package/react-helmet

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/2PpU_JGNTftk4nOYbIs58A