【超详细】React SSR 服务端渲染实战

前言

这篇文章和大家一起来聊一聊 React SSR,本文更偏向于实战。你可以从中学到:

  • 从 0 到 1 搭建 React SSR

  • 服务端渲染需要注意什么

  • react 18 的流式渲染如何使用

文章如有误,欢迎指出,大家一起学习交流~。 👇项目地址 ,期待大家的一键三连 💗

一、认识服务端渲染

1.1 基本概念

Server Side Rendering即服务端渲染。在服务端渲染成 HTM L片段 ,发送到浏览器端,浏览器端完成状态与事件的绑定,达到页面完全可交互的过程。

现阶段我们说的 ssr 渲染是现代化的服务端渲染,将传统服务端渲染和客户端渲染的优点结合起来,既能降低首屏耗时,又能有 SPA 的开发体验。这种渲染又可以称为”同构渲染”,将内容的展示和交互写成一套代码,这一套代码运行两次,一次在服务端运行,来实现服务端渲染,让 html 页面具有内容,另一次在客户端运行,用于客户端绑定交互事件。

1.2 简单的服务端渲染

了解基本概念后,我们开始手写实现一个 ssr 渲染。先来看一个简单的服务端渲染,创建一个 node-server文件夹, 使用 express 搭建一个服务,返回一个 HTML 字符串。

const express = require('express')

const app = express()

app.get('/', (req, res) => {
  res.send(`
    <html>
      <head>
        <title>hello</title>
      </head>
      <body>
        <div id="root">hello, 小柒</div> 
      </body>
    </html>
  `)
})

app.listen(3000, () => {
  console.log('Server started on port 3000')
})

运行起来, 页面显示如下,查看网页源代码, body 中就包含页面中显示的内容,这就是一个简单的服务端渲染。

在这里插入图片描述

对于客户端渲染,我们就比较熟悉了,像 React 脚手架运行起来的 demo 就是一个csr。(这里小柒直接使用之前手动搭建的 react 脚手架模版)。启动之后,打开网页源代码,可以看到 html 文件中的 body 标签中只有一个id 为root 的标签,没有其他的内容。网页中的内容是加载 script 文件后,动态添加DOM后展现的。

在这里插入图片描述

一个 React ssr 项目永不止上述那么简单,那么对于日常的一个 React 项目来说,如何实现 SSR 呢?接下来小柒将手把手演示。

二、服务端渲染的前置准备

在实现服务端渲染前,我们先做好项目的前置准备。

  • 目录结构改造

  • 编译配置改造

2.1 目录结构改造

React SSR 的核心即服务端客户端执行同一份代码。 那我们先来改造一下模版内容(👇模版地址),将服务端代码和客户端代码放到一个项目中。创建 clientserver 目录,分别用来放置客户端代码和服务端代码。创建 compoment 目录来存放公共组件,对于客户端和服务端所能执行的同一份代码那一定是组件代码,只有组件才是公共的。目录结构如下:
在这里插入图片描述

compoment/home文件的内容很简单,即网页中显示的内容。

import * as React from 'react' 
export const Home: React.FC = () => {
  const handleClick = () => {
    console.log('hello 小柒')
  }
  return (
    <div className="wrapper" onClick={handleClick}>
      hello 小柒
    </div>
  )
}

2.2 打包环境区分

对于服务端代码的编译我们也借助 webpack,在 script 目录中 创建 webpack.serve.js 文件,目标编译为 node ,打包输出目录为 build。为了避免 webpack 重复打包,使用 webpack-node-externals ,排除 node 中的内置模块和 node\_modules中的第三方库,比如 fspath等。

const path = require('path')
const { merge } = require('webpack-merge')
const base = require('./webpack.base.js')
const nodeExternals = require('webpack-node-externals') // 排除 node 中的内置模块和node_modules中的第三方库,比如 fs、path等,

module.exports = merge(base, {
  target: 'node',
  entry: path.resolve(__dirname, '../src/server/index.js'),
  output: {
    filename: '[name].js',
    clean: true, // 打包前清除 dist 目录,
    path: path.resolve(__dirname, '../build'),
  },
  externals: [nodeExternals()], // 避免重复打包
  module: {
    rules: [
      {
        test: /\.(css|less)$/,
        use: [
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              // 它可以帮助我们将一些现代的 CSS 特性,转成大多数浏览器认识的 CSS,并且会根据目标浏览器或运行时环境添加所需的 polyfill;
              // 也包括会自动帮助我们添加 autoprefixer
              postcssOptions: {
                plugins: ['postcss-preset-env'],
              },
            },
          },
          'less-loader',
        ],
        // 排除 node_modules 目录
        exclude: /node_modules/,
      },
    ],
  },
})

为项目启动方便,安装 npm run all 来实现同时运行多个脚本,我们修改下 package.json文件中 scripts 属性,pnpm run dev 先执行服务端代码再执行客户端代码,最后运行打包的服务端代码。

  "scripts": {
    "dev": "npm-run-all --parallel build:*",
    "build:serve": "cross-env NODE_ENV=production webpack  -c scripts/webpack.serve.js --watch",
    "build:client": "cross-env NODE_ENV=production webpack -c scripts/webpack.prod.js --watch",
    "build:node": "nodemon --watch build --exec node \"./build/main.js\"",
  },

到这里项目前置准备搭建完毕。

三、实现 React SSR 应用

3.1 简单的React 组件的服务端渲染

接下来我们开始一步一步实现同构,让我们回忆一下前面说的同构的核心步骤:同一份代码先在服务端执行一遍生成 html 文件,再到客户端执行一遍,加载 js 代码完成事件绑定。

第一步:我们引入 conpoment/home 组件到 server.js 中,服务端要做的就是将 Home 组件中的 jsx 内容转为 html 字符串返回给浏览器,我们可以利用 react-dom/server 中的 renderToString 方法来实现,这个方法会将 jsx 对应的虚拟dom 进行编译,转换为 html 字符串。

import express from 'express'
import { renderToString } from 'react-dom/server'
import { Home } from '../component/home'
 
const app = express()

app.get('/', (req, res) => {
  const content = renderToString(<Home />)

  res.send(`
    <html>
      <head>
        <title>React SSR</title>
      </head>
      <body>
        <div id="root">${content}</div> 
      </body>
    </html>
  `)
})

app.listen(3000, () => {
  console.log('Server started on port 3000')
})

第二步:使用 ReactDOM.hydrateRoot 渲染 React 组件。

ReactDOM.hydrateRoot 可以直接接管由服务端生成的HTML字符串,不会二次加载,客户端只会进行事件绑定,这样避免了闪烁,提高了首屏加载的体验。

import * as React from 'react'
import * as ReactDOM from 'react-dom/client'

import App from './App'

// hydrateRoot 不会二次渲染,只会绑定事件
ReactDOM.hydrateRoot(document.getElementById('root')!, <App />)

注意:hydrateRoot 需要保证服务端和客户端渲染的组件内容相同,否则会报错。

运行pnpm run dev,即可以看到 Home 组件的内容显示在页面上。

在这里插入图片描述

但细心的你一定会发现,点击事件并不生效。原因很简单:服务端只负责将 html 代码返回到浏览器,这只是一个静态的页面。而事件的绑定则需要客户端生成的 js 代码来实现,这就需要同构核心步骤的第二点,将同一份代码在客户端也执行一遍,这就是所谓的“注水”。

dist/main.bundle.js 为客户端打包的 js 代码,修改 server/index.js 代码,加上对 js 文件的引入。注意这里添加 app.use(express.static('dist')) 这段代码,添加一个中间件,来提供静态文件,即可以通过 http://localhost:3000/main.bundle.js 来访问, 否则会 404。

import express from 'express'
import { renderToString } from 'react-dom/server'
import { Home } from '../component/home'

const app = express()

app.use(express.static('dist'))

app.get('/', (req, res) => {
  const content = renderToString(<Home />)

  res.send(`
    <html>
      <head>
        <title>React SSR</title>
        <script defer src='main.bundle.js'></script>
      </head>
      <body>
        <div id="root">${content}</div> 
      </body>
    </html>
  `)
})
// ...

一般来说打包的文件都是用hash 值结尾的,不好直接写死, 我们可以读取 dist 中以.js 结尾的文件,实现动态引入。

// 省略...
app.get('/', (req, res) => {
	// 读取dist文件夹中js 文件
  const jsFiles = fs.readdirSync(path.join(__dirname, '../dist')).filter((file) => file.endsWith('.js'))
  const jsScripts = jsFiles.map((file) => `<script src="${file}" defer></script>`).join('\n')
  
  const content = renderToString(<Home />)

  res.send(`
    <html>
      <head>
        <title>React SSR</title>
         ${jsScripts}
      </head>
      <body>
        <div id="root">${content}</div> 
      </body>
    </html>
  `)
})
// 省略...

点击文案,控制台有内容打印,这样事件的绑定就成功啦。
在这里插入图片描述

以上仅仅是一个最简单的 react ssr 应用,而 ssr 项目需要注意的地方还有很多。接下来我们继续探索同构中的其他问题。

3.2 路由问题

先来看看从输入URL地址,浏览器是如何显示出界面的?

1、在浏览器输入 http://localhost:3000/ 地址

2、服务端路由要找到对应的组件,通过 renderToString 将转化为字符串,拼接到 HTML 输出

3、浏览器加载 js 文件后,解析前端路由,输出对应的前端组件,如果发现是服务端渲染,不会二次渲染,只会绑定事件,之后的点击跳转都是前端路由,与服务端路由没有关系。

同构中的路由问题即: 服务端路由和前端路由是不同的,在代码处理上也不相同。服务端代码采用StaticRouter实现,前端路由采用BrowserRouter实现。

注意:StaticRouter 与 BrowserRouter 的区别如下:
BrowserRouter 的原理使用了浏览器的 history API ,而服务端是不能使用浏览器中的
API ,而StaticRouter 则是利用初始传入url 地址,来寻找对应的组件。

接下来对代码进行改造,需要提前安装 react-router-dom

  • 新增一个detail组件
import * as React from 'react'

export const Detail = () => {
  return <div>这是详情页</div>
}
  • 新增路由文件src/routes.ts
// src/routes.ts
import { Home } from './component/home'
import { Detail } from './component/detail'

export default [
  {
    key: 'home',
    path: '/',
    exact: true,
    component: Home,
  },
  {
    key: 'detail',
    path: '/detail',
    exact: true,
    component: Detail,
  },
]

  • 前端路由改造
// App.jsx
import * as React from 'react'
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import routes from '@/routes'

const App: React.FC = () => {
  return (
    <BrowserRouter>
      <Link to="/">首页</Link>
      <Link to="/detail">detail</Link>
      <Routes>
        {routes.map((route) => (
          <Route key={route.path} path={route.path} Component={route.component} />
        ))}
      </Routes>
    </BrowserRouter>
  )
}

export default App
  • 服务端路由改造
import express from 'express'
import React from 'react'
const fs = require('fs')
const path = require('path')
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { Routes, Route, Link } from 'react-router-dom'
import routes from '../routes'

const app = express()

app.use(express.static('dist'))

app.get('*', (req, res) => {
  // ... 省略
  const content = renderToString(
    <StaticRouter location={req.url}>
      <Link to="/">首页</Link>
      <Link to="/detail">detail</Link>
      <Routes>
        {routes.map((route) => (
          <Route key={route.path} path={route.path} Component={route.component} />
        ))}
      </Routes>
    </StaticRouter>
  )
  // ... 省略
})

 // ... 省略

pnpm run dev运行项目,可以看到如下内容,说明 ssr 路由渲染成功。

在这里插入图片描述

3.2 状态管理问题

ssr中,store的问题有两点需要注意:

  • 与客户端渲染不同,在服务器端,一旦组件内容确定 ,就没法重新render ,所以必须在确定组件内容前将store的数据准备好,然后和组件的内容组合成 HTML 一起下发。

  • store的实例只能有一个。

状态管理我们使用 Redux Toolkit,安装依赖 pnpm i @reduxjs/toolkit react-redux,添加 store 文件夹,编写一个userSlice,两个状态statuslist
其中list的有一个初始值:

export const userSlice = createSlice({
  name: 'users',
  initialState: {
    status: 'idle',
    list: [
      {
        id: 1,
        name: 'xiaoqi',
        first_name: 'xiao',
        last_name: 'qi',
      },
    ],
  } as UserState,
  reducers: {},
})

store/user-slice.ts文件完整代码:

// store/user-slice.ts
// https://www.reduxjs.cn/tutorials/fundamentals/part-8-modern-redux/
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import axios from 'axios'

interface User {
  id: number
  name: string
  first_name: string
  last_name: string
}

// 定义初始状态
export interface UserState {
  status: 'idle' | 'loading' | 'succeeded' | 'failed'
  list: User[]
}

export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await axios.get('https://reqres.in/api/users')
  return response.data.data
})

export const userSlice = createSlice({
  name: 'users',
  initialState: {
    status: 'idle',
    list: [
      {
        id: 1,
        name: 'xiaoqi',
        first_name: 'xiao',
        last_name: 'qi',
      },
    ],
  } as UserState,
  reducers: {},
})

export default userSlice.reducer

// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import usersReducer, { UserState } from './user-slice'

export const getStore = () => {
  return configureStore({
    // reducer是必需的,它指定了应用程序的根reducer
    reducer: {
      users: usersReducer,
    },
  })
}

// 全局State类型
export type RootState = ReturnType<ReturnType<typeof getStore>['getState']>

export type AppDispatch = ReturnType<typeof getStore>['dispatch']

store/index.ts中我们导出了一个getStore方法用于创建store

注意:到上述获取store 实例时,我们采用的是 getStore 方法来获取。原因是在服务端,store 不能是单例的,如果直接导出store,用户就会共享store,这肯定不行。

改造客户端,并在home组件中显示初始list:

// App.tsx
// ...省略
import { Provider } from 'react-redux'
import { getStore } from '../store'

const App: React.FC = () => {
  return (
    <Provider store={getStore()}>
      <BrowserRouter>
        <Link to="/">首页</Link>
        <Link to="/detail">detail</Link>
        <Routes>
          {routes.map((route) => (
            <Route key={route.path} path={route.path} Component={route.component} />
          ))}
        </Routes>
      </BrowserRouter>
    </Provider>
  )
}

export default App

// home.tsx
import * as React from 'react'
import styles from './index.less'
import { useAppSelector } from '@/hooks'

export const Home = () => {
  const userList = useAppSelector((state) => state.users?.list)
  const handleClick = () => {
    console.log('hello 小柒')
  }

  return (
    <div className={styles.wrapper} onClick={handleClick}>
      hello 小柒
      {userList?.map((user) => (
        <div key={user.id}>{user.first_name + user.last_name}</div>
      ))}
    </div>
  )
}

改造服务端:

// ...省略
import { Provider } from 'react-redux'
import { getStore } from '../store'
// ...省略
app.get('*', (req, res) => {
  const store = getStore()
	//...省略
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.url}>
        <Link to="/">首页</Link>
        <Link to="/detail">detail</Link>
        <Routes>
          {routes.map((route) => (
            <Route key={route.path} path={route.path} Component={route.component} />
          ))}
        </Routes>
      </StaticRouter>
    </Provider>
  )
// ...省略
})
// ...省略

改造完毕,效果如下,初始值显示出来了。

3.3 异步数据的处理

上述例子中,已经添加了store,但如果初始的userList数据是通过接口拿到的,服务端又该如何处理呢?

我们先来看下如果是客户端渲染是什么流程:

1、创建store

2、根据路由显示组件

3、触发Action获取数据

4、更新store的数据

5、组件Rerender

改造 userSlice.ts文件,添加异步请求:

// ... 省略
// 1、添加异步请求
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await axios.get('https://reqres.in/api/users')
  return response.data.data
})

export const userSlice = createSlice({
  name: 'users',
  initialState: {
    status: 'idle',
    list: [],
  } as UserState,
  reducers: {},
  // 2、更新 store
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading'
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.list = action.payload
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed'
      })
  },
})

export default userSlice.reducer

改造客户端: 在 Home 组件中,新增 useEffect 调用 dispatch 更新数据。

// ... 省略 
import { useAppDispatch, useAppSelector } from '@/hooks'
import { fetchUsers } from '../../store/user-slice'

export const Home = () => {
  const dispatch = useAppDispatch()
  const userList = useAppSelector((state) => state.users?.list)
 
  // ... 省略 
  React.useEffect(() => {
    dispatch(fetchUsers())
  }, [])

  return (
    <div className={styles.wrapper} onClick={handleClick}>
      hello 小柒
      {userList?.map((user) => (
        <div key={user.id}>{user.first_name + user.last_name}</div>
      ))}
    </div>
  )
}

从效果上可以发现list数据渲染会从无到有,有明显的空白闪烁。
在这里插入图片描述

这是因为useEffect只会在客户端执行,服务端不会执行。如果要解决这个问题,服务端也要生成好这个数据,然后将数据和组件一起生成 HTML。

在服务端生成 HTML 之前要实现流程如下:

1、创建store

2、根据路由分析store中需要的数据

3、触发Action获取数据

4、更新store的数据

5、结合数据和组件生成HTML

改造服务端,即我们需要在现有的基础上,实现 2、3 就行。

matchRoutes可以帮助我们分析路由,服务端要想触发Action,也需要有一个类似useEffect方法用于服务端获取数据。我们可以给组件添加loadData方法,并修改路由配置。

// Home.tsx

export const Home = () => {
  // ... 省略 
}
Home.loadData = (store: any) => {
  return store.dispatch(fetchUsers())
}

// 路由配置
import { Home } from './component/home'
import { Detail } from './component/detail'

export default [
  {
    key: 'home',
    path: '/',
    exact: true,
    component: Home,
    loadData: Home.loadData, // 新增 loadData 方法
  },
  {
    key: 'detail',
    path: '/detail',
    exact: true,
    component: Detail,
  },
]

服务端代码如下:

import express from 'express'
import React from 'react'
import { Provider } from 'react-redux'
import { getStore } from '../store'

const fs = require('fs')
const path = require('path')
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { Routes, Route, Link, matchRoutes } from 'react-router-dom'
import routes from '../routes'

const app = express()

app.use(express.static('dist'))

app.get('*', (req, res) => {
  // 1、创建store
  const store = getStore()
  const promises = []
  // 2、matchRoutes 分析路由组件,分析 store 中需要的数据
  const matchedRoutes = matchRoutes(routes, req.url)
  // https://reactrouter.com/6.28.0/hooks/use-routes
  matchedRoutes?.forEach((item) => {
    if (item.route.loadData) {
      const promise = new Promise((resolve) => {
        // 3/4、触发 Action 获取数据、更新 store 的数据
        item.route.loadData(store).then(resolve).catch(resolve)
      })
      promises.push(promise)
    }
  })
  // ... 省略
  // 5、结合数据和组件生成HTML
  Promise.all(promises).then(() => {
    const content = renderToString(
      <Provider store={store}>
          <StaticRouter location={req.url}>
            <Link to="/">首页</Link>
            <Link to="/detail">detail</Link>
            <Routes>
              {routes.map((route) => (
                <Route key={route.path} path={route.path} Component={route.component} />
              ))}
            </Routes>
          </StaticRouter>
      </Provider>
    )
    res.send(`
      <!doctype html>
      <html>
        <head>
          <title>React SSR</title>
          ${jsScripts}
        </head>
        <body>
          <div id="root">${content}</div>
        </body>
      </html>
    `)
  })
})

app.listen(3000, () => {
  console.log('Server started on port 3000')
})
  • matchedRoutes方法分析路由,当路由中有loadData方法时,将store作为参数传入,执行loadData方法。

  • 将结果放入promises数组中,结合Promise.all方法来实现等待异步数据获取之后,再将数据和组件生成 HTML

效果如下,你会发现,即使服务端已经返回了初始数据,页面还是闪烁明显,并且控制台还会出现报错。
在这里插入图片描述
在这里插入图片描述

3.4 数据的脱水和注水

由于客户端的初始store数据还是空数组,导致服务端和客户端渲染的结果不一样,造成了闪屏。我们需要让客户端渲染时也能拿到服务端中store的数据,可以通过在window上挂载一个INITIAL\_STATE,和 HTML 一起下发,这个过程也叫做“注水”。

  // server/index.js
   res.send(`
      <!doctype html>
      <html>
        <head>
          <title>React SSR</title>
          ${jsScripts}
          <script>
            window.INITIAL_STATE =${JSON.stringify(store.getState())}
          </script>
        </head>
        <body>
          <div id="root">${content}</div>
        </body>
      </html>
    `)
    

在客户端创建store时,将它作为初始值传给state,即可拿到数据进行渲染,这个过程也叫做”脱水”。

// store/index.ts
export const getStore = () => {
  return configureStore({
    // reducer是必需的,它指定了应用程序的根reducer
    reducer: {
      users: usersReducer,
    },
    // 对象,它包含应用程序的初始状态
    preloadedState: {
      users:
        typeof window !== 'undefined'
          ? window.INITIAL_STATE?.users
          : ({
              status: 'idle',
              list: [],
            } as UserState),
    },
  })
}    

这样页面就不会出现闪烁现象,控制台也不会出现报错了。
在这里插入图片描述

3.5 css 处理

客户端渲染时,一般有两种方法引入样式:

  • style-loader: 将 css样式通过style标签插入到DOM

  • MiniCssExtractPlugin: 插件将样式打包到单独的文件,并使用link标签引入.

对于服务端渲染来说,这两种方式都不能使用。

  • 服务端不能操作DOM,不能使用style-loader

  • 服务端输出的是静态页面,等待浏览器加载 css文件,如果样式文件较大,必定会导致页面闪烁。

对于服务端来说,我们可以使用isomorphic-style-loader来解决。isomorphic-style-loader利用context Api,结合useStyles hooks Api 在渲染组件渲染的拿到组件的 css 样式,最终插入 HTML 中。

isomorphic-style-loader 这个 loader 利用了 loader的 pitch 方法的特性,返回三个方法供样式文件使用。关于 loader 的执行机制可以戳 → loader 调用链。

  • _getContent:数组,可以获取用户使用的类名等信息
  • _getCss:获取 css 样式
  • _insertCss :将 css 插入到 style 标签中

服务端改造:定义insertCss方法, 该方法调用 \_getCss 方法获取将组件样式添加到css Set中, 通过contextinsertCss方法传递给每一个组件,当insertCss方法被调用时,则样式将被添加到css Set中,最后通过[...css].join('')获取页面的样式,放入<style>标签中。

import StyleContext from 'isomorphic-style-loader/StyleContext'
// ... 省略
app.get('*', (req, res) => {
  // ... 省略
  // 1、新增css set 
  const css = new Set() // CSS for all rendered React components
  // 2、定义 insertCss 方法,调用 _getCss 方法获取将组件样式添加到  css Set  中
  const insertCss = (...styles) => styles.forEach((style) => css.add(style._getCss()))
  // ... 省略
  // 3、使用 StyleContext,传入insertCss 方法
  Promise.all(promises).then(() => {
    const content = renderToString(
      <Provider store={store}>
        <StyleContext.Provider value={{ insertCss }}>
          <StaticRouter location={req.url}>
            <Link to="/">首页</Link>
            <Link to="/detail">detail</Link>
            <Routes>
              {routes.map((route) => (
                <Route key={route.path} path={route.path} Component={route.component} />
              ))}
            </Routes>
          </StaticRouter>
        </StyleContext.Provider>
      </Provider>
    )
    res.send(`
      <!doctype html>
      <html>
        <head>
          <title>React SSR</title>
          ${jsScripts}
          <script>
            window.INITIAL_STATE =${JSON.stringify(store.getState())}
          </script>
          <!-- 获取页面的样式,放入 <style> 标签中 -->
          <style>${[...css].join('')}</style>
        </head>
        <body>
          <div id="root">${content}</div>
        </body>
      </html>
    `)
  })
})
// ...省略

对于客户端也要进行处理:

  • 在 App 组件中使用定义使用 StyleContext,定义insertCss方法,与服务端不同的是 insertCss 方法中调用_insertCss_insertCss方法会操作DOM,将样式插入HTML 中,功能类似于style-loader
  • 在对应的组件中引入useStyle传入样式文件。
// .. 省略 
import StyleContext from 'isomorphic-style-loader/StyleContext'

const App: React.FC = () => {
  const insertCss = (...styles: any[]) => {
    const removeCss = styles.map((style) => style._insertCss())
    return () => removeCss.forEach((dispose) => dispose())
  }
  return (
    <Provider store={getStore()}>
      <StyleContext.Provider value={{ insertCss }}>
        <BrowserRouter>
          <Link to="/">首页</Link>
          <Link to="/detail">detail</Link>
          <Routes>
            {routes.map((route) => (
              <Route key={route.path} path={route.path} Component={route.component} />
            ))}
          </Routes>
        </BrowserRouter>
      </StyleContext.Provider>
    </Provider>
  )
}

export default App

// Home.tsx
import useStyles from 'isomorphic-style-loader/useStyles'
import styles from './index.less'
// ...省略

export const Home = () => {
  useStyles(styles)
  // ...省略
  return (
    <div className={styles.wrapper} onClick={handleClick}>
      hello 小柒
      {userList?.map((user) => (
        <div key={user.id}>{user.first_name + user.last_name}</div>
      ))}
    </div>
  )
}

服务端/客户端的编译配置要注意,isomorphic-style-loader需要配合css modulecss-loader的配置要开启css module, 否则会报错。

  module: {
    rules: [
      {
        test: /\.(css|less)$/,
        use: [
          'isomorphic-style-loader', // 服务端渲染时,需要使用 isomorphic-style-loader 来处理样式
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]_[local]_[hash:base64:5]', // 开启 css module
              },
              esModule: false, // 启用 CommonJS 模块语法
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              // 它可以帮助我们将一些现代的 CSS 特性,转成大多数浏览器认识的 CSS,并且会根据目标浏览器或运行时环境添加所需的 polyfill;
              // 也包括会自动帮助我们添加 autoprefixer
              postcssOptions: {
                plugins: ['postcss-preset-env'],
              },
            },
          },
          'less-loader',
        ],
        // 排除 node_modules 目录
        exclude: /node_modules/,
      },
    ],
  },

注意:这里服务端和客户端都是使用 isomorphic-style-loader 去实现样式的引入。

最终效果如下,不会造成样式闪烁:
在这里插入图片描述

3.6 流式SSR渲染

前面的例子我们可以发现 3个问题:

  • 必须在发送HTML之前拿到所有的数据

    上述例子中我们需要获取到 user 的数据之后 ,才能开始渲染。 假设我们还需要获取评论信息,那么我们只有获取到这两部分的数据之后,才能开始渲染。而在实际场景中接口的速度也不同,等到接口慢的数据获取到之后再开始渲染,务必会影响首屏的速度。

  • 必须等待所有的 JavaScript 内容加载完才能开始吸水

    上述例子中我们提到过,客户端渲染的组件树要和服务端渲染的组件树保持一致,否则React就无法匹配,客户端换渲染会代替服务端渲染。假如组件树的加载和执行的执行比较长,那么吸水也需要等待所有组件树都加载执行完。

  • 必须等所有的组件都吸水完才能开始页面交互

    React DOM Root 只会吸水一次,一旦开始吸水,就不会停止,只有等到吸水完毕中后才能交互。假如 js 的执行时间很长,那么用户交互在这段时间内就得不到响应,务必就会给用户一种卡顿的感觉,留下不好的体验。

react 18 以前上面3个问题都是我们在 ssr 渲染过程中需要考虑的问题,而 react 18 给 ssr 提供的新特性可以帮助我们解决。

  • 支持服务端流式输出 HTML(renderToPipeableStream)。

  • 支持客户端选择性吸水。使用 Suspense 包裹对应的组件。

接下来开始进行代码改造:

1、新增Comment组件

import * as React from 'react'
import useStyles from 'isomorphic-style-loader/useStyles'
import styles from './index.less'

const Comment = () => {
  useStyles(styles)
  return <div className={styles.comment}>这是相关评论</div>
}

export default Comment

2、在Home组件中使用 Suspense包裹Comment组件。Suspense组件必须结合lazyuseuseTransition 等一起使用,这里使用 lazy 懒加载 Comment 组件。

// Home.tsx
const Comment = React.lazy(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(import('../Comment'))
    }, 3000)
  })
})
export const Home = () => {
  // ... 省略
  return (
    <div className={styles.wrapper} onClick={handleClick}>
      hello 小柒
      {userList?.map((user) => (
        <div key={user.id}>{user.first_name + user.last_name}</div>
      ))}
      <div className={styles.comment}>
        <React.Suspense fallback={<div>loading...</div>}>
          <Comment />
        </React.Suspense>
      </div>
    </div>
  )
}

3、服务端将renderToString 替换为renderToPipeableStream

有两种方式替换,第一种官方推荐写法,需要自己写一个组件传递给renderToPipeableStream:

import * as React from 'react'

import { Provider } from 'react-redux'
import { StaticRouter } from 'react-router-dom/server'
import { Routes, Route, Link } from 'react-router-dom'
import StyleContext from 'isomorphic-style-loader/StyleContext'
import routes from '../routes'

export const HTML = ({ store, insertCss, req }) => {
  return (
    <html lang="en">
      <head>
        <meta charSet="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>React SSR</title>
      </head>
      <body>
        <div id="root">
          <Provider store={store}>
            <StyleContext.Provider value={{ insertCss }}>
              <StaticRouter location={req.url}>
                <Link to="/">首页</Link>
                <Link to="/detail">detail</Link>
                <Routes>
                  {routes.map((route) => (
                    <Route key={route.path} path={route.path} Component={route.component} />
                  ))}
                </Routes>
              </StaticRouter>
            </StyleContext.Provider>
          </Provider>
        </div>
      </body>
    </html>
  )
}

服务端改造: 这种方式没法直接传递css,需要我们拼接下。

 // ... 省略
import { renderToPipeableStream } from 'react-dom/server'
import { Transform } from 'stream'

  app.get('*', (req, res) => {
	  Promise.all(promises).then(() => {
	    const { pipe, abort } = renderToPipeableStream(
	      <HTML store={store} insertCss={insertCss} req={req} />,
	      {
	        bootstrapScripts: jsFiles,
	        bootstrapScriptContent: `window.INITIAL_STATE = ${JSON.stringify(store.getState())}`,
	        onShellReady: () => {
	          res.setHeader('content-type', 'text/html')
	          let isShellStream = true
	
	          const injectTemplateTransform = new Transform({
	            transform(chunk, _encoding, callback) {
	              if (isShellStream) {
	              // 拼接 css 
	                const chunkString = chunk.toString()
	                let curStr = ''
	                const titleIndex = chunkString.indexOf('</title>')
	                if (titleIndex !== -1) {
	                  const styleTag = `<style>${[...css].join('')}</style>`
	                  curStr = chunkString.slice(0, titleIndex + 8) + styleTag + chunkString.slice(titleIndex + 8)
	                }
	                this.push(curStr)
	                isShellStream = false
	              } else {
	                this.push(chunk)
	              }
	              callback()
	            },
	          })
	          pipe(injectTemplateTransform).pipe(res)
	        },
	        onErrorShell() {
	          // 错误发生时替换外壳
	          res.statusCode = 500
	          res.send('<!doctype><p>Error</p>')
	        },
	      }
	    )
	
	    setTimeout(abort, 10_000)
	  })
}

方式二:自己拼接 HTML 字符串。

// ..。省略
import { renderToPipeableStream } from 'react-dom/server'
import { Transform } from 'stream'

// ... 省略

app.get('*', (req, res) => {
  // ... 省略
  const jsFiles = fs.readdirSync(path.join(__dirname, '../dist')).filter((file) => file.endsWith('.js'))
    // 5、结合数据和组件生成HTML
  Promise.all(promises).then(() => {
    console.log('store', [...css].join(''))
    const { pipe, abort } = renderToPipeableStream(
      <Provider store={store}>
        <StyleContext.Provider value={{ insertCss }}>
          <StaticRouter location={req.url}>
            <Link to="/">首页</Link>
            <Link to="/detail">detail</Link>
            <Routes>
              {routes.map((route) => (
                <Route key={route.path} path={route.path} Component={route.component} />
              ))}
            </Routes>
          </StaticRouter>
        </StyleContext.Provider>
      </Provider>,
      {
        bootstrapScripts: jsFiles,
        bootstrapScriptContent: `window.INITIAL_STATE = ${JSON.stringify(store.getState())}`,
        onShellReady: () => {
          res.setHeader('content-type', 'text/html')
          // headTpl 代表 <html><head>...</head><body><div id='root'> 部分的模版
          // tailTpl 代表 </div></body></html> 部分的模版
          const headTpl = `
          <html lang="en">
          <head>
            <meta charSet="UTF-8" />
            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
            <title>React SSR</title>
            <style>${[...css].join('')}</style>
          </head>
          <body>
            <div id="root">`

          const tailTpl = `
            </div>
          </body>
        </html>
      `
          let isShellStream = true

          const injectTemplateTransform = new Transform({
            transform(chunk, _encoding, callback) {
              if (isShellStream) {
                this.push(`${headTpl}${chunk.toString()}`)
                isShellStream = false
              } else {
                this.push(chunk)
              }
              callback()
            },
            flush(callback) {
              // end触发前执行
              this.push(tailTpl)
              callback()
            },
          })
          pipe(injectTemplateTransform).pipe(res)
        },
        onErrorShell() {
          // 错误发生时替换外壳
          res.statusCode = 500
          res.send('<!doctype><p>Error</p>')
        },
      }
    )

    setTimeout(abort, 10_000)
  })
})

两种方式都可以,这里需要注意 js 的处理:

  • bootstrapScripts:一个 URL 字符串数组,它们将被转化为

当看到评论组件能异步加载出来,并且模版文件中出现占位符即成功。
在这里插入图片描述

简单介绍下 ssr 流式替换的流程:先使用占位符,再替换为真实的内容。

第一次访问页面:ssr 第 1 段数据传输,Suspense组件包裹的部分先是使用<templte id="B:0"></template>标签占位children,注释 <!—$?—> 和 <!—/$—> 中间的内容表示异步渲染出来的,并展示fallback中的内容。

     <div class="index_wrapper_RPDqO">
      hello 小柒<div>GeorgeBluth</div>
      <div>JanetWeaver</div>
      <div>EmmaWong</div>
      <div>EveHolt</div>
      <div>CharlesMorris</div>
      <div>TraceyRamos</div>
      <div class="index_comment_kem02">
          <!--$?-->
          <template id="B:0"></template>
          <div>loading...</div>
          <!--/$-->
      </div>

传输的第 2 段数据,经过格式化后,如下:

  <div hidden id="S:0">
    <div>这是相关评论</div>
</div>
<script>
    function $RC(a, b) {
        a = document.getElementById(a);
        b = document.getElementById(b);
        b.parentNode.removeChild(b);
        if (a) {
            a = a.previousSibling;
            var f = a.parentNode
              , c = a.nextSibling
              , e = 0;
            do {
                if (c && 8 === c.nodeType) {
                    var d = c.data;
                    if ("/$" === d)
                        if (0 === e)
                            break;
                        else
                            e--;
                    else
                        "$" !== d && "$?" !== d && "$!" !== d || e++
                }
                d = c.nextSibling;
                f.removeChild(c);
                c = d
            } while (c);
            for (; b.firstChild; )
                f.insertBefore(b.firstChild, c);
            a.data = "$";
            a._reactRetry && a._reactRetry()
        }
    }
    ;$RC("B:0", "S:0")
</script>

id="S:0" 的 div 是 Suspensechildren 的渲染结果,不过这个div设置了hidden属性。接下来的$RC 函数,会负责将这个div插入到第 1 段数据中template标签所在的位置,同时删除template标签。

第二次访问页面:html的内容不会分段传输,评论组件也不会异步加载,而是一次性返回。这是因为Comment组件对应的 js 模块已经被加入到服务端的缓存模块中了,再一次请求时,加载Comment组件是一个同步的过程,所以整个渲染就是同步的。即只有当 Suspense中包裹的组件需要异步渲染时,ssr 返回的HTML内容才会分段传输。

四、小结

本文讲述了关于如何实现一个基本的 React SSR 应用,希望能帮助大家更好的理解服务端渲染。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/950746.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

js策略模式

定义一组算法&#xff0c;将每个算法封装成一个独立的类&#xff0c;并使它们可以互相替换。策略模式使得算法的变化不会影响到使用算法的客户。 const priceProcessor {pre(originPrice) {if (originPrice > 100) {return originPrice - 20;}return originPrice * 0.9;}…

Python中的可变对象与不可变对象;Python中的六大标准数据类型哪些属于可变对象,哪些属于不可变对象

Python中的可变对象与不可变对象&#xff1b;Python中的六大标准数据类型哪些属于可变对象&#xff0c;哪些属于不可变对象 Python中的可变对象与不可变对象一、Python的六大标准数据类型1. 数字类型 (Number)2. 字符串 (String)3. 列表 (List)4. 元组 (Tuple)5. 集合 (Set)6. …

js状态模式

允许一个对象在其内部状态改变时改变它的行为。 状态模式将对象的状态封装成独立的类&#xff0c;并使它们可以互相转换 // 定义状态接口class State {constructor() {if (this.constructor State) {throw new Error(不能实例化抽象类);}}// 定义状态方法handle(context) {th…

基于64QAM的载波同步和定时同步性能仿真,包括Costas环和gardner环

目录 1.算法仿真效果 2.算法涉及理论知识概要 3.MATLAB核心程序 4.完整算法代码文件获得 1.算法仿真效果 matlab2022a仿真结果如下&#xff08;完整代码运行后无水印&#xff09;&#xff1a; 仿真操作步骤可参考程序配套的操作视频。 2.算法涉及理论知识概要 载波同步是…

【Web安全】SQL 注入攻击技巧详解:布尔盲注(Boolean-Based Blind SQL Injection)

【Web安全】SQL 注入攻击技巧详解&#xff1a;布尔盲注&#xff08;Boolean-Based Blind SQL Injection&#xff09; 引言 布尔盲注&#xff08;Boolean-Based Blind SQL Injection&#xff09;是一种在无法直接从数据库获取数据的情况下&#xff0c;通过观察数据库响应的布尔…

太速科技-418-基于AD9361 +ZYNQ7020 的软件无线电 SDR 套件

基于AD9361 ZYNQ7020 的软件无线电 SDR 套件 一、板卡信息 ● ZYNQ芯片采用XC7Z020&#xff0c;逻辑容量更大&#xff0c;支持更大的逻辑设计&#xff1b; ● 内存采用两片512M DDR3&#xff0c;共1GByte&#xff0c;更大容量。 ● 支持千兆网口&#xff0c;支持ZEDFMCO…

SpringBoot日常:集成Kafka

文章目录 1、pom.xml文件2、application.yml3、生产者配置类4、消费者配置类5、消息订阅6、生产者发送消息7、测试发送消息 本章内容主要介绍如何在springboot项目对kafka进行整合&#xff0c;最终能达到的效果就是能够在项目中通过配置相关的kafka配置&#xff0c;就能进行消息…

分布式IO模块:激光切割机产线高效控制的创新引擎

在智能制造的浪潮中&#xff0c;激光切割技术以其高精度、高效率的特点&#xff0c;成为了现代工业生产中不可或缺的一部分。特别是在汽车制造、航空航天、电子设备及精密零部件加工等领域&#xff0c;激光切割机以其无与伦比的切割精度和灵活性&#xff0c;引领着制造业的转型…

RK3562编译Android13 ROOT固件教程,触觉智能开发板演示

本文介绍编译Android13 ROOT权限固件的方法&#xff0c;触觉智能RK3562开发板演示&#xff0c;搭载4核A53处理器&#xff0c;主频高达2.0GHz&#xff1b;内置独立1Tops算力NPU&#xff0c;可应用于物联网网关、平板电脑、智能家居、教育电子、工业显示与控制等行业。 关闭seli…

wireshark抓包工具新手使用教程

wireshark抓包工具新手入门使用教程 一、Wireshark软件安装二、Wireshark 抓包示范三、Wireshakr抓包界面四、Wireshark过滤器设置五、wireshark过滤器表达式的规则六、Wireshark抓包分析TCP三次握手七、Wireshark分析常用列标签格式 Wireshark是一款开源的网络协议分析工具&am…

如何用Python编程实现自动整理XML发票文件

传统手工整理发票耗时费力且易出错&#xff0c;而 XML 格式发票因其结构化、标准化的特点&#xff0c;为实现发票的自动化整理与保存提供了可能。本文将详细探讨用python来编程实现对 XML 格式的发票进行自动整理。 一、XML 格式发票的特点 结构化数据&#xff1a;XML 格式发票…

【网络安全 | 漏洞挖掘】HubSpot 全账户接管(万字详析)

未经许可,不得转载。 今天我们将分享一个关于在 Bugcrowd 平台的 HubSpot 公共漏洞赏金计划中实现全账户接管的故事。 文章目录 正文SQL 注入主机头污染(Host Header Poisoning)负载均衡器主机头覆盖(Load Balancer Host Header Override)Referer Header 测试ORIGIN Heade…

2025_0105_生活记录

3号去内蒙看了流星雨。还记得上次看流星的时间是2018年&#xff0c;也是冬天&#xff0c;大家在雁栖湖校区的操场上仰望星空。那个时候幸运的看到了一颗流星&#xff0c;便迅速地在心里许愿。这次看到了三颗流星&#xff0c;我也许了愿&#xff0c;希望实现。 24年走过了十多个…

(四)ROS通信编程——服务通信

前言 学完了话题通信其实操作流程基本都已经很熟悉了&#xff0c;因此服务通讯的学习就会流畅许多。 服务通信也是ROS中一种极其常用的通信模式&#xff0c;服务通信是基于请求响应模式的&#xff0c;是一种应答机制。也即: 一个节点A向另一个节点B发送请求&#xff0c;B接收…

《繁星路》V1.8.3(Build16632266)官方中文学习版

《繁星路》官方中文版https://pan.xunlei.com/s/VODae2_2Z3QyMF02I5y321uHA1?pwdqgsh# 作为一款星际模拟游戏&#xff0c;完美融合了硬科幻元素与基地建设玩法&#xff0c;体验改造行星的恢弘与壮阔。化身人工意识AMI&#xff0c;遵照基本指示推进火星改造的各项工作&#xf…

学习threejs,导入wrl格式的模型

&#x1f468;‍⚕️ 主页&#xff1a; gis分享者 &#x1f468;‍⚕️ 感谢各位大佬 点赞&#x1f44d; 收藏⭐ 留言&#x1f4dd; 加关注✅! &#x1f468;‍⚕️ 收录于专栏&#xff1a;threejs gis工程师 文章目录 一、&#x1f340;前言1.1 ☘️THREE.VRMLLoader wrl模型加…

Element-plus、Element-ui之Tree 树形控件回显Bug问题。

需求&#xff1a;提交时&#xff0c;需要把选中状态和半选中状态 的数据id提交。如图所示&#xff1a; 数据回显时&#xff0c;会出现代码如下&#xff1a; <template><el-tree ref"treeRef" :data"data" show-checkbox node-key"id" …

专家混合(MoE)大语言模型:免费的嵌入模型新宠

专家混合&#xff08;MoE&#xff09;大语言模型&#xff1a;免费的嵌入模型新宠 今天&#xff0c;我们深入探讨一种备受瞩目的架构——专家混合&#xff08;Mixture-of-Experts&#xff0c;MoE&#xff09;大语言模型&#xff0c;它在嵌入模型领域展现出了独特的魅力。 一、M…

cursor试用出现:Too many free trial accounts used on this machine 的解决方法

文章精选推荐 1 JetBrains Ai assistant 编程工具让你的工作效率翻倍 2 Extra Icons&#xff1a;JetBrains IDE的图标增强神器 3 IDEA插件推荐-SequenceDiagram&#xff0c;自动生成时序图 4 BashSupport Pro 这个ides插件主要是用来干嘛的 &#xff1f; 5 IDEA必装的插件&…

若依 ruoyi-vue HandlerInterceptor 拦截器 文件接口自定义权限

文件资源添加自定义权限 package com.huida.framework.config;import com.huida.framework.interceptor.FileInterceptor; import com.huida.framework.interceptor.RequestInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springfr…