手摸手服务端渲染-react

目录
服务端渲染基础
添加路由
添加ajax异步请求
添加样式
代码拆分
引入react-helmet
服务端渲染基础
类似vue ssr思路,在react ssr我们也需要创建两个入口文件,entry-client.js、entry-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}>年龄+1button>
    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('', `${appString} `)
  res.send(html)
})
server.listen(1234)
创建html模版文件
ssr/public/index.html
html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Documenttitle>
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="/">HomeNavLink> |
        <NavLink to="/about">AboutNavLink>
      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(
      '' ,
      `${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="/">HomeNavLink> |
        <NavLink to="/about">AboutNavLink>
      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(
      '',
      `${appString} `
    );
    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(
        '',
        `${appString} `
      )
      .replace(
        "Document ",
        `Document `
      );
    res.send(html);
  });
  server.listen(1234);
})();
执行npm start后打开页面http://localhost:1234/并查看源码


我们可以看到样式已经生效,并且服务端渲染也返回了标签。但这样的方式不利于页面的性能优化,所以我们需要将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(
        '',
        `${appString} `
      )
    res.send(html);
  });
  server.listen(1234);
})();
这里直接引入打包好的css文件即可/static/index.css
ssr/public/index.html
html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link type="text/css" rel="stylesheet" href="/static/index.css" />
  <title>Documenttitle>
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
html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Documenttitle>
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(
        '',
        styleTags
      )
      .replace(
        '',
        `${appString} `
      )
      .replace(
        '',
        ``
      )
    res.send(html);
  });
  server.listen(1234);
})();
ssr/public/index.html
html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  
  <title>Documenttitle>
head>
<body>
  
  
  <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 name="viewport" content="width=device-width, initial-scale=1.0">meta>
        <title>默认标题title>
      Helmet>
      <nav>
        <NavLink to="/">HomeNavLink> |
        <NavLink to="/about">AboutNavLink>
      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(
        '',
        helmet.meta.toString()
      )
      .replace(
        '',
        helmet.link.toString()
      )
      .replace(
        '',
        styleTags
      )
      .replace(
        '',
        helmet.title.toString()
      )
      .replace(
        'replace-body-attributes',
        helmet.bodyAttributes.toString()
      )
      .replace(
        '',
        `${appString} `
      )
      .replace(
        '',
        ``
      )
    res.send(html);
  });
  server.listen(1234);
})();
ssr/public/index.html
html>
<html replace-html-attributes>
<head>
  
  
  
  
head>
<body replace-body-attributes>
  
  
  <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
