手摸手服务端渲染-react

前端Sharing

共 37750字,需浏览 76分钟

 · 2022-04-08

目录


  • 服务端渲染基础

  • 添加路由

  • 添加ajax异步请求

  • 添加样式

  • 代码拆分

  • 引入react-helmet


服务端渲染基础

类似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}>年龄+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',
    umdNamedDefinetrue,
    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',
    umdNamedDefinetrue,
    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',
        age22
      })
    }, 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',
    umdNamedDefinetrue,
    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]'
              },
              esModulefalse// 不加这个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 {
  width80vw;
  height200px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: azure;
  border1px 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 {
  width80vw;
  height300px;
  background-color: cornsilk;
  border1px solid rgb(183116255);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
}

.name {
  color: red;
  margin0;
}

.age {
  color: blue;
  margin0;
  padding5px;
}

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',
        age22
      })
    }, 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${styles}`
      );
    res.send(html);
  });
  server.listen(1234);
})();

执行npm start后打开页面http://localhost:1234/并查看源码

我们可以看到样式已经生效,并且服务端渲染也返回了