欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

React SSR服務(wù)端渲染的實現(xiàn)示例

 更新時間:2025年01月06日 09:25:55   作者:crazy的藍(lán)色夢想  
本文主要介紹了實現(xiàn)React服務(wù)端渲染,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧

前言

這篇文章和大家一起來聊一聊 React SSR,本文更偏向于實戰(zhàn)。你可以從中學(xué)到:

  • 從 0 到 1 搭建 React SSR
  • 服務(wù)端渲染需要注意什么
  • react 18 的流式渲染如何使用

一、認(rèn)識服務(wù)端渲染

1.1 基本概念

Server Side Rendering即服務(wù)端渲染。在服務(wù)端渲染成 HTM L片段 ,發(fā)送到瀏覽器端,瀏覽器端完成狀態(tài)與事件的綁定,達(dá)到頁面完全可交互的過程。

現(xiàn)階段我們說的 ssr 渲染是現(xiàn)代化的服務(wù)端渲染,將傳統(tǒng)服務(wù)端渲染和客戶端渲染的優(yōu)點結(jié)合起來,既能降低首屏耗時,又能有 SPA 的開發(fā)體驗。這種渲染又可以稱為”同構(gòu)渲染”,將內(nèi)容的展示和交互寫成一套代碼,這一套代碼運行兩次,一次在服務(wù)端運行,來實現(xiàn)服務(wù)端渲染,讓 html 頁面具有內(nèi)容,另一次在客戶端運行,用于客戶端綁定交互事件。

1.2 簡單的服務(wù)端渲染

了解基本概念后,我們開始手寫實現(xiàn)一個 ssr 渲染。先來看一個簡單的服務(wù)端渲染,創(chuàng)建一個 node-server文件夾, 使用 express 搭建一個服務(wù),返回一個 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')
})

運行起來, 頁面顯示如下,查看網(wǎng)頁源代碼, body 中就包含頁面中顯示的內(nèi)容,這就是一個簡單的服務(wù)端渲染。

在這里插入圖片描述

對于客戶端渲染,我們就比較熟悉了,像 React 腳手架運行起來的 demo 就是一個csr。(這里小柒直接使用之前手動搭建的 react 腳手架模版)。啟動之后,打開網(wǎng)頁源代碼,可以看到 html 文件中的 body 標(biāo)簽中只有一個id 為root 的標(biāo)簽,沒有其他的內(nèi)容。網(wǎng)頁中的內(nèi)容是加載 script 文件后,動態(tài)添加DOM后展現(xiàn)的。

在這里插入圖片描述

一個 React ssr 項目永不止上述那么簡單,那么對于日常的一個 React 項目來說,如何實現(xiàn) SSR 呢?接下來小柒將手把手演示。

二、服務(wù)端渲染的前置準(zhǔn)備

在實現(xiàn)服務(wù)端渲染前,我們先做好項目的前置準(zhǔn)備。

  • 目錄結(jié)構(gòu)改造

  • 編譯配置改造

2.1 目錄結(jié)構(gòu)改造

React SSR 的核心即服務(wù)端客戶端執(zhí)行同一份代碼。 那我們先來改造一下模版內(nèi)容(??模版地址),將服務(wù)端代碼和客戶端代碼放到一個項目中。創(chuàng)建 client 和 server 目錄,分別用來放置客戶端代碼和服務(wù)端代碼。創(chuàng)建 compoment 目錄來存放公共組件,對于客戶端和服務(wù)端所能執(zhí)行的同一份代碼那一定是組件代碼,只有組件才是公共的。目錄結(jié)構(gòu)如下:

在這里插入圖片描述

compoment/home文件的內(nèi)容很簡單,即網(wǎng)頁中顯示的內(nèi)容。

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 打包環(huán)境區(qū)分

對于服務(wù)端代碼的編譯我們也借助 webpack,在 script 目錄中 創(chuàng)建 webpack.serve.js 文件,目標(biāo)編譯為 node ,打包輸出目錄為 build。為了避免 webpack 重復(fù)打包,使用 webpack-node-externals ,排除 node 中的內(nèi)置模塊和 node\_modules中的第三方庫,比如 fs、path等。

const path = require('path')
const { merge } = require('webpack-merge')
const base = require('./webpack.base.js')
const nodeExternals = require('webpack-node-externals') // 排除 node 中的內(nèi)置模塊和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()], // 避免重復(fù)打包
  module: {
    rules: [
      {
        test: /\.(css|less)$/,
        use: [
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              // 它可以幫助我們將一些現(xiàn)代的 CSS 特性,轉(zhuǎn)成大多數(shù)瀏覽器認(rèn)識的 CSS,并且會根據(jù)目標(biāo)瀏覽器或運行時環(huán)境添加所需的 polyfill;
              // 也包括會自動幫助我們添加 autoprefixer
              postcssOptions: {
                plugins: ['postcss-preset-env'],
              },
            },
          },
          'less-loader',
        ],
        // 排除 node_modules 目錄
        exclude: /node_modules/,
      },
    ],
  },
})

為項目啟動方便,安裝 npm run all 來實現(xiàn)同時運行多個腳本,我們修改下 package.json文件中 scripts 屬性,pnpm run dev 先執(zhí)行服務(wù)端代碼再執(zhí)行客戶端代碼,最后運行打包的服務(wù)端代碼。

  "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\"",
  },

到這里項目前置準(zhǔn)備搭建完畢。

三、實現(xiàn) React SSR 應(yīng)用

3.1 簡單的React 組件的服務(wù)端渲染

接下來我們開始一步一步實現(xiàn)同構(gòu),讓我們回憶一下前面說的同構(gòu)的核心步驟:同一份代碼先在服務(wù)端執(zhí)行一遍生成 html 文件,再到客戶端執(zhí)行一遍,加載 js 代碼完成事件綁定。

第一步:我們引入 conpoment/home 組件到 server.js 中,服務(wù)端要做的就是將 Home 組件中的 jsx 內(nèi)容轉(zhuǎn)為 html 字符串返回給瀏覽器,我們可以利用 react-dom/server 中的 renderToString 方法來實現(xiàn),這個方法會將 jsx 對應(yīng)的虛擬dom 進(jìn)行編譯,轉(zhuǎn)換為 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 可以直接接管由服務(wù)端生成的HTML字符串,不會二次加載,客戶端只會進(jìn)行事件綁定,這樣避免了閃爍,提高了首屏加載的體驗。

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

import App from './App'

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

注意:hydrateRoot 需要保證服務(wù)端和客戶端渲染的組件內(nèi)容相同,否則會報錯。

運行pnpm run dev,即可以看到 Home 組件的內(nèi)容顯示在頁面上。

在這里插入圖片描述

但細(xì)心的你一定會發(fā)現(xiàn),點擊事件并不生效。原因很簡單:服務(wù)端只負(fù)責(zé)將 html 代碼返回到瀏覽器,這只是一個靜態(tài)的頁面。而事件的綁定則需要客戶端生成的 js 代碼來實現(xiàn),這就需要同構(gòu)核心步驟的第二點,將同一份代碼在客戶端也執(zhí)行一遍,這就是所謂的“注水”。

dist/main.bundle.js 為客戶端打包的 js 代碼,修改 server/index.js 代碼,加上對 js 文件的引入。注意這里添加 app.use(express.static('dist')) 這段代碼,添加一個中間件,來提供靜態(tài)文件,即可以通過 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 值結(jié)尾的,不好直接寫死, 我們可以讀取 dist 中以.js 結(jié)尾的文件,實現(xiàn)動態(tài)引入。

// 省略...
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>
  `)
})
// 省略...

點擊文案,控制臺有內(nèi)容打印,這樣事件的綁定就成功啦。

在這里插入圖片描述

以上僅僅是一個最簡單的 react ssr 應(yīng)用,而 ssr 項目需要注意的地方還有很多。接下來我們繼續(xù)探索同構(gòu)中的其他問題。

3.2 路由問題

先來看看從輸入URL地址,瀏覽器是如何顯示出界面的?

1、在瀏覽器輸入 http://localhost:3000/ 地址

2、服務(wù)端路由要找到對應(yīng)的組件,通過 renderToString 將轉(zhuǎn)化為字符串,拼接到 HTML 輸出

3、瀏覽器加載 js 文件后,解析前端路由,輸出對應(yīng)的前端組件,如果發(fā)現(xiàn)是服務(wù)端渲染,不會二次渲染,只會綁定事件,之后的點擊跳轉(zhuǎn)都是前端路由,與服務(wù)端路由沒有關(guān)系。

同構(gòu)中的路由問題即: 服務(wù)端路由和前端路由是不同的,在代碼處理上也不相同。服務(wù)端代碼采用StaticRouter實現(xiàn),前端路由采用BrowserRouter實現(xiàn)。

注意:StaticRouter 與 BrowserRouter 的區(qū)別如下:
BrowserRouter 的原理使用了瀏覽器的 history API ,而服務(wù)端是不能使用瀏覽器中的
API ,而StaticRouter 則是利用初始傳入url 地址,來尋找對應(yīng)的組件。

接下來對代碼進(jìn)行改造,需要提前安裝 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
  • 服務(wù)端路由改造
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運行項目,可以看到如下內(nèi)容,說明 ssr 路由渲染成功。

在這里插入圖片描述

3.2 狀態(tài)管理問題

ssr中,store的問題有兩點需要注意:

  • 與客戶端渲染不同,在服務(wù)器端,一旦組件內(nèi)容確定 ,就沒法重新render ,所以必須在確定組件內(nèi)容前將store的數(shù)據(jù)準(zhǔn)備好,然后和組件的內(nèi)容組合成 HTML 一起下發(fā)。

  • store的實例只能有一個。

狀態(tài)管理我們使用 Redux Toolkit,安裝依賴 pnpm i @reduxjs/toolkit react-redux,添加 store 文件夾,編寫一個userSlice,兩個狀態(tài)status、list
其中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
}

// 定義初始狀態(tài)
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是必需的,它指定了應(yīng)用程序的根reducer
    reducer: {
      users: usersReducer,
    },
  })
}

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

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

store/index.ts中我們導(dǎo)出了一個getStore方法用于創(chuàng)建store

注意:到上述獲取store 實例時,我們采用的是 getStore 方法來獲取。原因是在服務(wù)端,store 不能是單例的,如果直接導(dǎo)出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>
  )
}

改造服務(wù)端:

// ...省略
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 異步數(shù)據(jù)的處理

上述例子中,已經(jīng)添加了store,但如果初始的userList數(shù)據(jù)是通過接口拿到的,服務(wù)端又該如何處理呢?

我們先來看下如果是客戶端渲染是什么流程:

1、創(chuàng)建store

2、根據(jù)路由顯示組件

3、觸發(fā)Action獲取數(shù)據(jù)

4、更新store的數(shù)據(jù)

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 調(diào)用 dispatch 更新數(shù)據(jù)。

// ... 省略 
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>
  )
}

從效果上可以發(fā)現(xiàn)list數(shù)據(jù)渲染會從無到有,有明顯的空白閃爍。

在這里插入圖片描述

這是因為useEffect只會在客戶端執(zhí)行,服務(wù)端不會執(zhí)行。如果要解決這個問題,服務(wù)端也要生成好這個數(shù)據(jù),然后將數(shù)據(jù)和組件一起生成 HTML。

在服務(wù)端生成 HTML 之前要實現(xiàn)流程如下:

1、創(chuàng)建store

2、根據(jù)路由分析store中需要的數(shù)據(jù)

3、觸發(fā)Action獲取數(shù)據(jù)

4、更新store的數(shù)據(jù)

5、結(jié)合數(shù)據(jù)和組件生成HTML

改造服務(wù)端,即我們需要在現(xiàn)有的基礎(chǔ)上,實現(xiàn) 2、3 就行。

matchRoutes可以幫助我們分析路由,服務(wù)端要想觸發(fā)Action,也需要有一個類似useEffect方法用于服務(wù)端獲取數(shù)據(jù)。我們可以給組件添加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,
  },
]

服務(wù)端代碼如下:

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、創(chuàng)建store
  const store = getStore()
  const promises = []
  // 2、matchRoutes 分析路由組件,分析 store 中需要的數(shù)據(jù)
  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、觸發(fā) Action 獲取數(shù)據(jù)、更新 store 的數(shù)據(jù)
        item.route.loadData(store).then(resolve).catch(resolve)
      })
      promises.push(promise)
    }
  })
  // ... 省略
  // 5、結(jié)合數(shù)據(jù)和組件生成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方法分析路由,當(dāng)路由中有loadData方法時,將store作為參數(shù)傳入,執(zhí)行loadData方法。

  • 將結(jié)果放入promises數(shù)組中,結(jié)合Promise.all方法來實現(xiàn)等待異步數(shù)據(jù)獲取之后,再將數(shù)據(jù)和組件生成 HTML

效果如下,你會發(fā)現(xiàn),即使服務(wù)端已經(jīng)返回了初始數(shù)據(jù),頁面還是閃爍明顯,并且控制臺還會出現(xiàn)報錯。

在這里插入圖片描述

在這里插入圖片描述

3.4 數(shù)據(jù)的脫水和注水

由于客戶端的初始store數(shù)據(jù)還是空數(shù)組,導(dǎo)致服務(wù)端和客戶端渲染的結(jié)果不一樣,造成了閃屏。我們需要讓客戶端渲染時也能拿到服務(wù)端中store的數(shù)據(jù),可以通過在window上掛載一個INITIAL\_STATE,和 HTML 一起下發(fā),這個過程也叫做“注水”。

  // 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>
    `)
    

在客戶端創(chuàng)建store時,將它作為初始值傳給state,即可拿到數(shù)據(jù)進(jìn)行渲染,這個過程也叫做”脫水”。

// store/index.ts
export const getStore = () => {
  return configureStore({
    // reducer是必需的,它指定了應(yīng)用程序的根reducer
    reducer: {
      users: usersReducer,
    },
    // 對象,它包含應(yīng)用程序的初始狀態(tài)
    preloadedState: {
      users:
        typeof window !== 'undefined'
          ? window.INITIAL_STATE?.users
          : ({
              status: 'idle',
              list: [],
            } as UserState),
    },
  })
}    

這樣頁面就不會出現(xiàn)閃爍現(xiàn)象,控制臺也不會出現(xiàn)報錯了。

在這里插入圖片描述

3.5 css 處理

客戶端渲染時,一般有兩種方法引入樣式:

  • style-loader: 將 css樣式通過style標(biāo)簽插入到DOM

  • MiniCssExtractPlugin: 插件將樣式打包到單獨的文件,并使用link標(biāo)簽引入.

對于服務(wù)端渲染來說,這兩種方式都不能使用。

  • 服務(wù)端不能操作DOM,不能使用style-loader

  • 服務(wù)端輸出的是靜態(tài)頁面,等待瀏覽器加載 css文件,如果樣式文件較大,必定會導(dǎo)致頁面閃爍。

對于服務(wù)端來說,我們可以使用isomorphic-style-loader來解決。isomorphic-style-loader利用context Api,結(jié)合useStyles hooks Api 在渲染組件渲染的拿到組件的 css 樣式,最終插入 HTML 中。

isomorphic-style-loader 這個 loader 利用了 loader的 pitch 方法的特性,返回三個方法供樣式文件使用。關(guān)于 loader 的執(zhí)行機制可以戳 → loader 調(diào)用鏈。

  • _getContent:數(shù)組,可以獲取用戶使用的類名等信息
  • _getCss:獲取 css 樣式
  • _insertCss :將 css 插入到 style 標(biāo)簽中

服務(wù)端改造:定義insertCss方法, 該方法調(diào)用 \_getCss 方法獲取將組件樣式添加到css Set中, 通過contextinsertCss方法傳遞給每一個組件,當(dāng)insertCss方法被調(diào)用時,則樣式將被添加到css Set中,最后通過[...css].join('')獲取頁面的樣式,放入<style>標(biāo)簽中。

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 方法,調(diào)用 _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> 標(biāo)簽中 -->
          <style>${[...css].join('')}</style>
        </head>
        <body>
          <div id="root">${content}</div>
        </body>
      </html>
    `)
  })
})
// ...省略

對于客戶端也要進(jìn)行處理:

  • 在 App 組件中使用定義使用 StyleContext,定義insertCss方法,與服務(wù)端不同的是 insertCss 方法中調(diào)用_insertCss,_insertCss方法會操作DOM,將樣式插入HTML 中,功能類似于style-loader。
  • 在對應(yīng)的組件中引入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>
  )
}

服務(wù)端/客戶端的編譯配置要注意,isomorphic-style-loader需要配合css module,css-loader的配置要開啟css module, 否則會報錯。

  module: {
    rules: [
      {
        test: /\.(css|less)$/,
        use: [
          'isomorphic-style-loader', // 服務(wù)端渲染時,需要使用 isomorphic-style-loader 來處理樣式
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]_[local]_[hash:base64:5]', // 開啟 css module
              },
              esModule: false, // 啟用 CommonJS 模塊語法
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              // 它可以幫助我們將一些現(xiàn)代的 CSS 特性,轉(zhuǎn)成大多數(shù)瀏覽器認(rèn)識的 CSS,并且會根據(jù)目標(biāo)瀏覽器或運行時環(huán)境添加所需的 polyfill;
              // 也包括會自動幫助我們添加 autoprefixer
              postcssOptions: {
                plugins: ['postcss-preset-env'],
              },
            },
          },
          'less-loader',
        ],
        // 排除 node_modules 目錄
        exclude: /node_modules/,
      },
    ],
  },

注意:這里服務(wù)端和客戶端都是使用 isomorphic-style-loader 去實現(xiàn)樣式的引入。

最終效果如下,不會造成樣式閃爍:

在這里插入圖片描述

3.6 流式SSR渲染

前面的例子我們可以發(fā)現(xiàn) 3個問題:

  • 必須在發(fā)送HTML之前拿到所有的數(shù)據(jù)

    上述例子中我們需要獲取到 user 的數(shù)據(jù)之后 ,才能開始渲染。 假設(shè)我們還需要獲取評論信息,那么我們只有獲取到這兩部分的數(shù)據(jù)之后,才能開始渲染。而在實際場景中接口的速度也不同,等到接口慢的數(shù)據(jù)獲取到之后再開始渲染,務(wù)必會影響首屏的速度。

  • 必須等待所有的 JavaScript 內(nèi)容加載完才能開始吸水

    上述例子中我們提到過,客戶端渲染的組件樹要和服務(wù)端渲染的組件樹保持一致,否則React就無法匹配,客戶端換渲染會代替服務(wù)端渲染。假如組件樹的加載和執(zhí)行的執(zhí)行比較長,那么吸水也需要等待所有組件樹都加載執(zhí)行完。

  • 必須等所有的組件都吸水完才能開始頁面交互

    React DOM Root 只會吸水一次,一旦開始吸水,就不會停止,只有等到吸水完畢中后才能交互。假如 js 的執(zhí)行時間很長,那么用戶交互在這段時間內(nèi)就得不到響應(yīng),務(wù)必就會給用戶一種卡頓的感覺,留下不好的體驗。

react 18 以前上面3個問題都是我們在 ssr 渲染過程中需要考慮的問題,而 react 18 給 ssr 提供的新特性可以幫助我們解決。

  • 支持服務(wù)端流式輸出 HTML(renderToPipeableStream)。

  • 支持客戶端選擇性吸水。使用 Suspense 包裹對應(yīng)的組件。

接下來開始進(jìn)行代碼改造:

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}>這是相關(guān)評論</div>
}

export default Comment

2、在Home組件中使用 Suspense包裹Comment組件。Suspense組件必須結(jié)合lazy、 use、useTransition 等一起使用,這里使用 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、服務(wù)端將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>
  )
}

服務(wù)端改造: 這種方式?jīng)]法直接傳遞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() {
	          // 錯誤發(fā)生時替換外殼
	          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、結(jié)合數(shù)據(jù)和組件生成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觸發(fā)前執(zhí)行
              this.push(tailTpl)
              callback()
            },
          })
          pipe(injectTemplateTransform).pipe(res)
        },
        onErrorShell() {
          // 錯誤發(fā)生時替換外殼
          res.statusCode = 500
          res.send('<!doctype><p>Error</p>')
        },
      }
    )

    setTimeout(abort, 10_000)
  })
})

兩種方式都可以,這里需要注意 js 的處理:

  • bootstrapScripts:一個 URL 字符串?dāng)?shù)組,它們將被轉(zhuǎn)化為

當(dāng)看到評論組件能異步加載出來,并且模版文件中出現(xiàn)占位符即成功。

在這里插入圖片描述

簡單介紹下 ssr 流式替換的流程:先使用占位符,再替換為真實的內(nèi)容。

第一次訪問頁面:ssr 第 1 段數(shù)據(jù)傳輸,Suspense組件包裹的部分先是使用<templte id="B:0"></template>標(biāo)簽占位children,注釋 <!—$?—> 和 <!—/$—> 中間的內(nèi)容表示異步渲染出來的,并展示fallback中的內(nèi)容。

     <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>

傳輸?shù)牡?2 段數(shù)據(jù),經(jīng)過格式化后,如下:

  <div hidden id="S:0">
    <div>這是相關(guān)評論</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 是 Suspense 的 children 的渲染結(jié)果,不過這個div設(shè)置了hidden屬性。接下來的$RC 函數(shù),會負(fù)責(zé)將這個div插入到第 1 段數(shù)據(jù)中template標(biāo)簽所在的位置,同時刪除template標(biāo)簽。

第二次訪問頁面:html的內(nèi)容不會分段傳輸,評論組件也不會異步加載,而是一次性返回。這是因為Comment組件對應(yīng)的 js 模塊已經(jīng)被加入到服務(wù)端的緩存模塊中了,再一次請求時,加載Comment組件是一個同步的過程,所以整個渲染就是同步的。即只有當(dāng) Suspense中包裹的組件需要異步渲染時,ssr 返回的HTML內(nèi)容才會分段傳輸。

四、小結(jié)

本文講述了關(guān)于如何實現(xiàn)一個基本的 React SSR 應(yīng)用,希望能幫助大家更好的理解服務(wù)端渲染。

到此這篇關(guān)于React SSR服務(wù)端渲染的實現(xiàn)示例的文章就介紹到這了,更多相關(guān)React SSR服務(wù)端渲染內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家! 

相關(guān)文章

  • React?Native項目設(shè)置路徑別名示例

    React?Native項目設(shè)置路徑別名示例

    這篇文章主要為大家介紹了React?Native項目設(shè)置路徑別名實現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪
    2023-05-05
  • React?Hooks之useDeferredValue鉤子用法示例詳解

    React?Hooks之useDeferredValue鉤子用法示例詳解

    useDeferredValue鉤子的主要目的是在React的并發(fā)模式中提供更流暢的用戶體驗,特別是在有高優(yōu)先級和低優(yōu)先級更新的情況下,本文主要講解一些常見的使用場景及其示例
    2023-09-09
  • 取消正在運行的Promise技巧詳解

    取消正在運行的Promise技巧詳解

    這篇文章主要為大家介紹了取消正在運行的Promise技巧詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪
    2022-06-06
  • react導(dǎo)出excel文件的四種方式

    react導(dǎo)出excel文件的四種方式

    本文主要介紹了react導(dǎo)出excel文件的四種方式,主要包括原生js導(dǎo)出,使用?js-export-excel,使用xlsx導(dǎo)出, 使用react-html-table-to-excel,感興趣的可以了解一下
    2023-11-11
  • 如何使用React的VideoPlayer構(gòu)建視頻播放器

    如何使用React的VideoPlayer構(gòu)建視頻播放器

    本文介紹了如何使用React構(gòu)建一個基礎(chǔ)的視頻播放器組件,并探討了常見問題和易錯點,通過組件化思想和合理管理狀態(tài),可以實現(xiàn)功能豐富且性能優(yōu)化的視頻播放器
    2025-01-01
  • React可定制黑暗模式切換開關(guān)組件

    React可定制黑暗模式切換開關(guān)組件

    這篇文章主要為大家介紹了React可定制黑暗模式切換開關(guān)組件示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪
    2022-10-10
  • React實現(xiàn)動態(tài)調(diào)用的彈框組件

    React實現(xiàn)動態(tài)調(diào)用的彈框組件

    這篇文章主要為大家詳細(xì)介紹了React實現(xiàn)動態(tài)調(diào)用的彈框組件,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下
    2022-08-08
  • react lazyLoad加載使用詳解

    react lazyLoad加載使用詳解

    lazy是React提供的懶(動態(tài))加載組件的方法,React.lazy(),路由組件代碼會被分開打包,能減少打包體積、延遲加載首屏不需要渲染的組件,依賴內(nèi)置組件Suspense標(biāo)簽的fallback屬性,給lazy加上loading指示器組件,Suspense目前只和lazy配合實現(xiàn)組件等待加載指示器的功能
    2023-03-03
  • React Class組件生命周期及執(zhí)行順序

    React Class組件生命周期及執(zhí)行順序

    這篇文章主要介紹了React Class組件生命周期,包括react組件的兩種定義方式和class組件不同階段生命周期函數(shù)執(zhí)行順序,本文給大家介紹的非常詳細(xì),需要的朋友可以參考下
    2021-08-08
  • ios原生和react-native各種交互的示例代碼

    ios原生和react-native各種交互的示例代碼

    本篇文章主要介紹了ios原生和react-native各種交互的示例代碼,非常具有實用價值,需要的朋友可以參考下
    2017-08-08

最新評論