React-SSR

通过一个小项目把React SSR聊明白。

1、SSR

ssr(服务端渲染),在讲解这个概念之前,我们首先了解一下什么是csr(客户端渲染)。

  • CSR

在这种渲染模式下,服务端只返回JSON数据,data和html的渲染在客户端进行。

  • SSR

在这种渲染模式下,服务端返回完整的Html,html和data在服务端渲染完成,返回给客户端。

1.1、csr存在的问题

  • 首屏等待时间长,用户体验差。
  • 页面结构为空,不适合SEO。

image-20230907163926600

1.2、ssr解决了什么问题

image-20230907164458615

通过ssr,浏览器一开始拿到的就是准备好的html数据,首屏十分友好。

1.3、ssr同构

同构指代码复用,即实现客户端和服务端最大程度的代码复用。

2、项目结构初始化

-- react-ssr
   -- src
     -- client (客户端代码)
     -- server(服务端代码)
     -- share(同构代码)
  • 安装依赖
{
  "name": "react-ssr",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev:server-build": "webpack --config webpack.server.js --watch",
    "dev:client-build": "webpack --config webpack.client.js --watch",
    "dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^1.5.0",
    "express": "^4.18.2",
    "nodemon": "^3.0.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.15.0",
    "webpack-cli": "^5.1.4",
    "webpack-merge": "^5.9.0",
    "webpack-node-externals": "^3.0.0"
  },
  "devDependencies": {
    "@babel/cli": "^7.22.15",
    "@babel/core": "^7.22.15",
    "@babel/preset-env": "^7.22.15",
    "@babel/preset-react": "^7.22.15",
    "babel-loader": "^9.1.3",
    "webpack": "^5.88.2"
  }
}

3、实现ReactSSR雏形

  • 引入要渲染你的React组件。
  • 通过renderToString方法将React组件转换成HTML字符串。
  • 将结果HTML字符串响应到客户端。

3.1、server

  • 新建/src/server/http.js,内容如下:
import express from 'express'
 
const app = express()
 
app.listen(3000, () => {
  console.log('Server is listening on port 3000')
})
 
export default app
  • 新建/src/share/pages/Home.js,内容如下:
import React from 'react'
 
function Home() {
  return <div>Home Works</div>
}
 
export default Home
  • 新建/src/server/index.js,内容如下:
import app from './http'
import Home from '../share/pages/Home'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
 
app.get('/', (req, res) => {
  const html = ReactDOMServer.renderToString(<Home />)
  res.send(`
    <html>
      <head>
        <title>React SSR</title>
      </head> 
      <body>
        <div id="root">${html}</div>
      </body>
    </html>
  `)
})

3.2、配置webpack打包规则

  • 新建webpack.server.js,内容如下:
const path = require('path')
module.exports = {
  mode: 'development',
  target: 'node',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'build'),
  },
  module: {
    rules: [
      {
        test: /\.js?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
}
  • 更改package.json,新增如下的script
{
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev:server-build": "webpack --config webpack.server.js --watch",
    "dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
  }
}
  • 打包并运行
nr dev:server-build
nr dev:server-run

image-20230907184258650

看到组件的内容被成功渲染出来了。

3.3、为组件附加事件

  • 更改Home组件的内容:
import React from 'react'
 
function Home() {
  return (
    <div
      onClick={() => {
        console.log('hello')
      }}
    >
      Home1 Works
    </div>
  )
}
export default Home

重新打包并运行,发现组件的事件并没有生效。(服务端并不能提供js交互的能力)

因此我们需要,在客户端对组件进行二次渲染时候添加对应的事件

  • 客户端二次渲染hydrate

使用hydrate方法对组件进行渲染,为组件元素附加事件。

hydrate方法在实现渲染的时候,会复用原本已经存在的DOM节点,减少重新生成节点以及删除原本DOM节点的开销

  • 新建webpack.client.js(客户端运行在浏览器环境中)
const path = require('path')
 
module.exports = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'public'),
  },
  module: {
    rules: [
      {
        test: /\.js?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
}
  • 添加客户端打包命令:
"dev:client-build": "webpack --config webpack.client.js --watch",
  • 配置静态资源文件夹
app.use(express.static('public'))
  • 新建/src/client/index.js
import React from 'react'
import ReactDom from 'react-dom/client'
import Home from '../share/pages/Home'
 
ReactDom.hydrateRoot(document.getElementById('root'), <Home />)
  • 在服务端返回的html中引用客户端打包的代码:
app.get('/', (req, res) => {
  const content = renderToString(<Home />)
  res.send(`
    <html>
      <head>
        <title>React SSR</title>
      </head> 
      <body>
        <div id="root">${content}</div>
        <script src="bundle.js"></script>
      </body>
    </html>
  `)
})

整个流程如下: image-20230907190651793

这样就完成了为服务端的组件附加事件。

4、优化

4.1、合并配置文件

借助webpack-merge这个工具来进行合并配置文件的操作。

将公共的配置选项抽出来放在webpack.base.js这个文件中。

  • 新建webpack.base.js
module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        test: /\.js?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
}
  • 修改webpack.server.js
const path = require('path')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
 
const config = {
  entry: './src/client/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'public'),
  },
}
 
module.exports = merge(baseConfig, config)

服务端打包配置也差不多。

4.2、服务器端打包文件体积优化

服务端在打包的时候将node中内置的很多模块进行了打包,但是由于我们代码本身就是运行在node环境中的,因此这些模块都无需打包。

通过webpack-node-externals这个工具来优化打包文件的体积。

const nodeExternals = require('webpack-node-externals')
 
const config = {
  externals: [nodeExternals()],
}

4.3、代码拆分

将组件渲染的代码拆分到一个单独的文件中。

  • 新建/src/server/renderer.js,内容如下:
import Home from '../share/pages/Home'
import React from 'react'
import { renderToString } from 'react-dom/server'
 
export default () => {
  const html = renderToString(<Home />)
  return '<!DOCTYPE html>' + html
}
  • 修改index.js代码如下:
import app from './http'
import renderer from './renderer'
 
app.get('/', (req, res) => {
  res.send(renderer())
})

5、路由实现

我们需要实现两端的路由:

  • 客户端路由是指用户通过点击链接来跳转页面。
  • 服务端路由是指用户通过键入链接访问页面。
  • 客户端和服务器端公用一套路由规则。

5.1、服务端路由

配置文档参见:https://reactrouter.com/en/main/guides/ssr#without-a-data-router

  • 新建一个新的组件的/src/share/pages/About.js
import React from 'react'
 
function About() {
  return <div> About Works</div>
}
export default About
  • 新建`/src/share/App.js`,用来配置我们的路由规则:

import React from 'react'
import { Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'
 
export default function App() {
  return (
    <>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </>
  )
}
  • 摈弃掉上面的做法,我们通过更灵活的配置文件的方式进行,新建/share/routes/index.js
import React from 'react'
import Home from '../pages/Home'
import About from '../pages/About'
 
const router = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: '/about',
    element: <About />,
    loadData: About.getInitProps,
  },
]
 
export default router
  • 配置静态路由:
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Route, Routes } from 'react-router-dom'
import { StaticRouter } from 'react-router-dom/server'
import { Helmet } from 'react-helmet'
import router from '../share/routes'
 
export default (req) => {
  const helmet = Helmet.renderStatic()
  let html = ReactDOMServer.renderToString(
    <StaticRouter location={req.url}>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />
        })}
      </Routes>
    </StaticRouter>,
  )
  return `
    <html
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `
}

这样一个简单的服务端路由就完成了。

react-router v5版本需要借助react-router-config来完成服务端路由的配置,但是在v6版本中不需要了(会报错)。

5.2、客户端路由

和服务端路由一样,但是在这里我们通过BroswerRouter来配置客户端路由:

  • 修改/src/client/index.js
import React from 'react'
import ReactDom from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
 
ReactDom.hydrateRoot(
  document.getElementById('root'),
  <BrowserRouter>
    <Routes>
      {router?.map((item, index) => {
        return <Route {...item} key={index} />
      })}
    </Routes>
  </BrowserRouter>,
)
  • Home组件中添加一个跳转链接:注意这里要使用客户端链接组件Link
import React from 'react'
import { Link } from 'react-router-dom'
function Home() {
  return (
    <div>
      Home Works
      <hr />
      <Link to={'/about'}>to about</Link>
    </div>
  )
}
export default Home

5.2、header标签的修改

我们现在跳转路由的时候修改的都是body的部分,不过这个还不能满足我们所有静态页面的需求,因为模板页面只是影响到 body 的部分,修改不同路由下对应的标题,多媒体适配或是 SEO 时加入的相关 meta 关键字,都需要加入相关的 header。

  • 安装react-helmet
ni react-helmet
  • 我们可定制化任何页面的header,修改/share/pages/Home,内容如下:
import React, { Fragment } from 'react'
import { Helmet } from 'react-helmet'
 
function About() {
  return (
    <Fragment>
      <Helmet>
        <title>简易的服务器端渲染 - ABOUT</title>
        <meta name="description" content="服务器端渲染"></meta>
      </Helmet>
      <div>
        <h1>About</h1>
      </div>
    </Fragment>
  )
}
 
export default About

由于服务端和客户端的不一致性导致客户端会hydrate失败,因此我们需要对服务端的代码进行变更:

  • 修改/server/renderer.js,内容如下:
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Route, Routes } from 'react-router-dom'
import { StaticRouter } from 'react-router-dom/server'
import { Helmet } from 'react-helmet'
 
export default (req) => {
  const helmet = Helmet.renderStatic()
  let html = ReactDOMServer.renderToString(
    <StaticRouter location={req.url}>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />
        })}
      </Routes>
    </StaticRouter>,
  )
  return `
    <html
      <head>
        ${helmet.title.toString()}
        ${helmet.meta.toString()}
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `
}

添加对应的head标签并且添加helmet相关的内容。

6、请求数据

使用常规的hook试一下:

  • 安装axios
ni axios
  • About页面中添加下面内容
import axios from 'axios'
 
function About() {
  const [content, setContent] = useState('')
  useEffect(() => {
    axios.post('/api/getDemoData', { name: 'zhangsan' }).then((res) => {
      setContent(res.data.data.name)
    })
  }, [])
  return (
    <Fragment>
      ...
      <div>
        ...
        <p>{content}</p>
      </div>
    </Fragment>
  )
}
 
export default About
  • 访问about对应的路由:

请求的内容被渲染出来了,但是可以在network中看到对应的请求,数据也没有在服务端请求的时候塞入html,这意味着这部分内容的渲染走的是客户端请求,而不是服务端请求,这个和我们的预期不一致。

我们来回忆之前静态页面的思路,是在服务器端拼凑好 HTML 并返回,所以请求的话,咱们应该也是获取到每个模板页面初始化的请求,并在服务器端请求好,进行 HTML 拼凑,在这之前我们需要建立一个全局的 store,使得服务端请求的数据可以提供到模板页面来进行操作。确认好思路,咱们就根据这个思路先来解决试试。

6.1、建立全局store

  • 安装redux
ni -D @reduxjs/toolkit redux-thunk react-redux

其中 @reduxjs/toolkit 是 redux 最新提供的工具包,可以用于状态的统一管理,提供了更多 hook 的能力,相对代码更为简易,至于 redux-thunk 是一个 redux 的中间件,提供了 dispatch 和 getState 与异步方法交互的能力。

  • redux基础架构如下:

img

  • 创建/share/store/userReducer.js,内容如下:
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
 
const getDemoData = createAsyncThunk(
  "demo/getData",
  async (initData: string) => {
    const res = await axios.post("http://127.0.0.1:3000/api/getDemoData", {
      content: initData,
    });
    return res.data?.data?.content;
  }
);
 
const demoReducer = createSlice({
  name: "demo",
  initialState: {
    content: "默认数据",
  },
  // 同步reducer
  reducers: {},
  // 异步reducer
  extraReducers(build) {
    build
      .addCase(getDemoData.pending, (state, action) => {
        state.content = "pending";
      })
      .addCase(getDemoData.fulfilled, (state, action) => {
        state.content = action.payload;
      })
      .addCase(getDemoData.rejected, (state, action) => {
        state.content = "rejected";
      });
  },
});
 
export { demoReducer, getDemoData };
  • reducers:可以存放同步的 reducers(不需要请求参数);
  • initialState:可以理解成原来的 state;
  • name: 是这个 reducer 的空间,后面取 store 的时候会根据这个进行区分;
  • extraReducers:这个是我们这里需要的异步 reducer,其中包含三个状态,pending、fulfilled 和 rejected,分别对应到请求的三种状态。
  • 创建/share/store/index.js对所有的reducer进行统一的导入和导出。
import { demoReducer } from './demoReducer'
 
export { demoReducer }

接下来分别创建服务端和客户端的store,将 reducer 导入一下,并且接入一下 thunk 的中间件,使得 dispatch 相关的函数支持异步函数的入参:

import { demoReducer } from './demoReducer'
import { configureStore } from '@reduxjs/toolkit'
import thunk from 'redux-thunk'
 
const clientStore = configureStore({
  reducer: {
    demo: demoReducer.reducer,
  },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
})
 
const serverStore = configureStore({
  reducer: {
    demo: demoReducer.reducer,
  },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
})
 
export { clientStore, serverStore }
  • 分别在服务端和客户端路由处进行注入:

/client/index.js

import React from 'react'
import ReactDom from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from '../share/App'
import { Provider } from 'react-redux'
import { clientStore } from '../share/store'
 
ReactDom.hydrateRoot(
  document.getElementById('root'),
  <Provider store={clientStore}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>,
)
  • /server/renderer.js
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import App from '../share/App'
import { Helmet } from 'react-helmet'
import { Provider } from 'react-redux'
import { serverStore } from '../share/store'
 
export default (req) => {
  const helmet = Helmet.renderStatic()
  let html = ReactDOMServer.renderToString(
    <Provider store={serverStore}>
      <StaticRouter location={req.url}>
        <App />
      </StaticRouter>
    </Provider>,
  )
  return `
    <html
      <head>
        ${helmet.title.toString()}
        ${helmet.meta.toString()}
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `
}

到这里我们的store就注入好了,我们只需要将我们的组件和store进行连接。

connect暴露了两个参数,一个 state,一个 dispatch,它会根据你的需要拼接成指定的参数,以装饰器的形式包装你定义的函数

import React, { Fragment, useState, useEffect } from 'react'
import { Helmet } from 'react-helmet'
import axios from 'axios'
import { getDemoData } from '../store/demoReducer'
import { connect } from 'react-redux'
 
function About(data) {
  // const [content, setContent] = useState('')
  // useEffect(() => {
  //   axios.post('/api/getDemoData', { name: 'zhangsan' }).then((res) => {
  //     setContent(res.data.data.name)
  //   })
  // }, [])
  return (
    <Fragment>
      <Helmet>
        <title>简易的服务器端渲染 - ABOUT</title>
        <meta name="description" content="服务器端渲染"></meta>
      </Helmet>
      <div>
        <h1>About</h1>
        <button
          onClick={() => {
            data.getDemoData && data.getDemoData('刷新过后的数据')
          }}
        >
          刷新
        </button>
        <p>{data.content}</p>
      </div>
    </Fragment>
  )
}
 
const mapStateToProps = (state) => {
  // 将对应reducer的内容透传回dom
  return {
    content: state?.demo?.content,
  }
}
 
const mapDispatchToProps = (dispatch) => {
  return {
    getDemoData: (data) => {
      dispatch(getDemoData(data))
    },
  }
}
 
const storeAbout = connect(mapStateToProps, mapDispatchToProps)(About)
 
export default storeAbout

可以看到新增了对应的请求,对应展示的内容也切换为了刷新过后的数据,那这就意味着咱们 store的部分已经走通了,接下来咱们只需要考虑,应该怎样在服务器端进行请求,使得在 html 拼接的时候就可以拿到初始化的数据呢?

6.2、建立服务器请求数据体系

首先我们肯定得先在服务器端拿到所有需要请求的函数,怎么透传过去呢?我们应该可以使用路由,因为客户端和服务端咱们都有配置路由,如果加一个参数通过路由把参数透传,然后在服务器端遍历,最后把结果对应分发是不是就可以了。

这里有个小细节大家要注意一下,服务器端不同于客户端,它是拿不到请求的域名的,所以服务器端下的axios请求应该是包含域名的绝对路径,而不是使用相对路径

  • 给我们要进行数据初始化的页面挂载一个函数,用来出发请求store中的数据:About.js
...
const storeAbout = connect(mapStateToProps, mapDispatchToProps)(About)
 
storeAbout.getInitProps = (store, data) => {
  return store.dispatch(getDemoData(data || '这是初始化的demo'))
}
 
export default storeAbout
  • 将这个函数添加到对应的路由中去:/share/routes/index.js
import React from 'react'
import Home from '../pages/Home'
import About from '../pages/About'
 
const router = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: '/about',
    element: <About />,
    //添加下面的方法 对应页面数据的初始化方法
    loadData: About.getInitProps,
  },
]
 
export default router
  • 最后就是在渲染的时候拿到数据并且拼接成最终的html 文件:
...
//匹配当前路由
  const matchedRoutes = matchRoutes(router, req.path)
 
  const promises = []
  matchedRoutes?.forEach((item) => {
    //如果当前路由下有loadData方法,就执行
    if (routeMap.has(item.pathname)) {
      promises.push(routeMap.get(item.pathname))
    }
  })
 
  Promise.all(promises).then((data) => {
    const helmet = Helmet.renderStatic()
 
    let html = renderToString(
      <Provider store={serverStore}>
        <StaticRouter location={req.path}>
          <Routes>
            {router?.map((item, index) => {
              return <Route {...item} key={index} />
            })}
          </Routes>
        </StaticRouter>
      </Provider>
    )
    res.send(`
      <html
        <head>
          ${helmet.title.toString()}
          ${helmet.meta.toString()}
        </head>
        <body>
          <div id="root">${html}</div>
          <script>
          window.context = {
            state: ${JSON.stringify(serverStore.getState())}
          }
          </script>
          <script src="/bundle.js"></script>
        </body>
      </html>
    `)
  })
 ...

7、总结

🤔至此,我们使用react实现ssr的整体流程大体上都走通了,我们在优化web用户的体验方面又多了一种强有力的手段。