【超详细】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',
      },
    ],
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

crazy的蓝色梦想

如果对你有帮助,就鼓励我一下吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值