React SSR服務(wù)端渲染的實(shí)現(xiàn)示例
前言
這篇文章和大家一起來聊一聊 React SSR,本文更偏向于實(shí)戰(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)點(diǎn)結(jié)合起來,既能降低首屏耗時(shí),又能有 SPA 的開發(fā)體驗(yàn)。這種渲染又可以稱為”同構(gòu)渲染”,將內(nèi)容的展示和交互寫成一套代碼,這一套代碼運(yùn)行兩次,一次在服務(wù)端運(yùn)行,來實(shí)現(xiàn)服務(wù)端渲染,讓 html 頁面具有內(nèi)容,另一次在客戶端運(yùn)行,用于客戶端綁定交互事件。
1.2 簡單的服務(wù)端渲染
了解基本概念后,我們開始手寫實(shí)現(xiàn)一個(gè) ssr 渲染。先來看一個(gè)簡單的服務(wù)端渲染,創(chuàng)建一個(gè) node-server
文件夾, 使用 express
搭建一個(gè)服務(wù),返回一個(gè) 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') })
運(yùn)行起來, 頁面顯示如下,查看網(wǎng)頁源代碼, body 中就包含頁面中顯示的內(nèi)容,這就是一個(gè)簡單的服務(wù)端渲染。
對于客戶端渲染,我們就比較熟悉了,像 React 腳手架運(yùn)行起來的 demo 就是一個(gè)csr。(這里小柒直接使用之前手動(dòng)搭建的 react 腳手架模版)。啟動(dòng)之后,打開網(wǎng)頁源代碼,可以看到 html
文件中的 body
標(biāo)簽中只有一個(gè)id 為root
的標(biāo)簽,沒有其他的內(nèi)容。網(wǎng)頁中的內(nèi)容是加載 script
文件后,動(dòng)態(tài)添加DOM
后展現(xiàn)的。
一個(gè) React ssr
項(xiàng)目永不止上述那么簡單,那么對于日常的一個(gè) React
項(xiàng)目來說,如何實(shí)現(xiàn) SSR
呢?接下來小柒將手把手演示。
二、服務(wù)端渲染的前置準(zhǔn)備
在實(shí)現(xiàn)服務(wù)端渲染前,我們先做好項(xiàng)目的前置準(zhǔn)備。
目錄結(jié)構(gòu)改造
編譯配置改造
2.1 目錄結(jié)構(gòu)改造
React SSR 的核心即服務(wù)端客戶端執(zhí)行同一份代碼。 那我們先來改造一下模版內(nèi)容(??模版地址),將服務(wù)端代碼和客戶端代碼放到一個(gè)項(xiàng)目中。創(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,并且會(huì)根據(jù)目標(biāo)瀏覽器或運(yùn)行時(shí)環(huán)境添加所需的 polyfill; // 也包括會(huì)自動(dòng)幫助我們添加 autoprefixer postcssOptions: { plugins: ['postcss-preset-env'], }, }, }, 'less-loader', ], // 排除 node_modules 目錄 exclude: /node_modules/, }, ], }, })
為項(xiàng)目啟動(dòng)方便,安裝 npm run all
來實(shí)現(xiàn)同時(shí)運(yùn)行多個(gè)腳本,我們修改下 package.json
文件中 scripts 屬性,pnpm run dev
先執(zhí)行服務(wù)端代碼再執(zhí)行客戶端代碼,最后運(yùn)行打包的服務(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\"", },
到這里項(xiàng)目前置準(zhǔn)備搭建完畢。
三、實(shí)現(xiàn) React SSR 應(yīng)用
3.1 簡單的React 組件的服務(wù)端渲染
接下來我們開始一步一步實(shí)現(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
方法來實(shí)現(xiàn),這個(gè)方法會(huì)將 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字符串,不會(huì)二次加載,客戶端只會(huì)進(jìn)行事件綁定,這樣避免了閃爍,提高了首屏加載的體驗(yàn)。
import * as React from 'react' import * as ReactDOM from 'react-dom/client' import App from './App' // hydrateRoot 不會(huì)二次渲染,只會(huì)綁定事件 ReactDOM.hydrateRoot(document.getElementById('root')!, <App />)
注意:hydrateRoot 需要保證服務(wù)端和客戶端渲染的組件內(nèi)容相同,否則會(huì)報(bào)錯(cuò)。
運(yùn)行pnpm run dev
,即可以看到 Home 組件的內(nèi)容顯示在頁面上。
但細(xì)心的你一定會(huì)發(fā)現(xiàn),點(diǎn)擊事件并不生效。原因很簡單:服務(wù)端只負(fù)責(zé)將 html 代碼返回到瀏覽器,這只是一個(gè)靜態(tài)的頁面。而事件的綁定則需要客戶端生成的 js 代碼來實(shí)現(xiàn),這就需要同構(gòu)核心步驟的第二點(diǎn),將同一份代碼在客戶端也執(zhí)行一遍,這就是所謂的“注水”。
dist/main.bundle.js
為客戶端打包的 js 代碼,修改 server/index.js
代碼,加上對 js 文件的引入。注意這里添加 app.use(express.static('dist'))
這段代碼,添加一個(gè)中間件,來提供靜態(tài)文件,即可以通過 http://localhost:3000/main.bundle.js
來訪問, 否則會(huì) 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é)尾的文件,實(shí)現(xiàn)動(dòng)態(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> `) }) // 省略...
點(diǎn)擊文案,控制臺(tái)有內(nèi)容打印,這樣事件的綁定就成功啦。
以上僅僅是一個(gè)最簡單的 react ssr 應(yīng)用,而 ssr 項(xiàng)目需要注意的地方還有很多。接下來我們繼續(xù)探索同構(gòu)中的其他問題。
3.2 路由問題
先來看看從輸入U(xiǎn)RL地址,瀏覽器是如何顯示出界面的?
1、在瀏覽器輸入 http://localhost:3000/ 地址
2、服務(wù)端路由要找到對應(yīng)的組件,通過 renderToString 將轉(zhuǎn)化為字符串,拼接到 HTML 輸出
3、瀏覽器加載 js 文件后,解析前端路由,輸出對應(yīng)的前端組件,如果發(fā)現(xiàn)是服務(wù)端渲染,不會(huì)二次渲染,只會(huì)綁定事件,之后的點(diǎn)擊跳轉(zhuǎn)都是前端路由,與服務(wù)端路由沒有關(guān)系。
同構(gòu)中的路由問題即: 服務(wù)端路由和前端路由是不同的,在代碼處理上也不相同。服務(wù)端代碼采用StaticRouter
實(shí)現(xiàn),前端路由采用BrowserRouter
實(shí)現(xiàn)。
注意:StaticRouter 與 BrowserRouter 的區(qū)別如下:
BrowserRouter 的原理使用了瀏覽器的 history API ,而服務(wù)端是不能使用瀏覽器中的
API ,而StaticRouter 則是利用初始傳入url 地址,來尋找對應(yīng)的組件。
接下來對代碼進(jìn)行改造,需要提前安裝 react-router-dom
。
- 新增一個(gè)
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
運(yùn)行項(xiàng)目,可以看到如下內(nèi)容,說明 ssr 路由渲染成功。
3.2 狀態(tài)管理問題
在ssr
中,store
的問題有兩點(diǎn)需要注意:
與客戶端渲染不同,在服務(wù)器端,一旦組件內(nèi)容確定 ,就沒法重新
render
,所以必須在確定組件內(nèi)容前將store
的數(shù)據(jù)準(zhǔn)備好,然后和組件的內(nèi)容組合成 HTML 一起下發(fā)。store
的實(shí)例只能有一個(gè)。
狀態(tài)管理我們使用 Redux Toolkit,安裝依賴 pnpm i @reduxjs/toolkit react-redux
,添加 store 文件夾,編寫一個(gè)userSlice
,兩個(gè)狀態(tài)status
、list
。
其中list
的有一個(gè)初始值:
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)出了一個(gè)getStore
方法用于創(chuàng)建store
。
注意:到上述獲取store 實(shí)例時(shí),我們采用的是 getStore 方法來獲取。原因是在服務(wù)端,store 不能是單例的,如果直接導(dǎo)出store,用戶就會(huì)共享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ù)渲染會(huì)從無到有,有明顯的空白閃爍。
這是因?yàn)?code>useEffect只會(huì)在客戶端執(zhí)行,服務(wù)端不會(huì)執(zhí)行。如果要解決這個(gè)問題,服務(wù)端也要生成好這個(gè)數(shù)據(jù),然后將數(shù)據(jù)和組件一起生成 HTML。
在服務(wù)端生成 HTML 之前要實(shí)現(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ǔ)上,實(shí)現(xiàn) 2、3 就行。
matchRoutes
可以幫助我們分析路由,服務(wù)端要想觸發(fā)Action
,也需要有一個(gè)類似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
方法時(shí),將store
作為參數(shù)傳入,執(zhí)行loadData
方法。將結(jié)果放入
promises
數(shù)組中,結(jié)合Promise.all
方法來實(shí)現(xiàn)等待異步數(shù)據(jù)獲取之后,再將數(shù)據(jù)和組件生成 HTML
效果如下,你會(huì)發(fā)現(xiàn),即使服務(wù)端已經(jīng)返回了初始數(shù)據(jù),頁面還是閃爍明顯,并且控制臺(tái)還會(huì)出現(xiàn)報(bào)錯(cuò)。
3.4 數(shù)據(jù)的脫水和注水
由于客戶端的初始store
數(shù)據(jù)還是空數(shù)組,導(dǎo)致服務(wù)端和客戶端渲染的結(jié)果不一樣,造成了閃屏。我們需要讓客戶端渲染時(shí)也能拿到服務(wù)端中store
的數(shù)據(jù),可以通過在window
上掛載一個(gè)INITIAL\_STATE
,和 HTML 一起下發(fā),這個(gè)過程也叫做“注水”。
// 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
時(shí),將它作為初始值傳給state
,即可拿到數(shù)據(jù)進(jìn)行渲染,這個(gè)過程也叫做”脫水”。
// 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), }, }) }
這樣頁面就不會(huì)出現(xiàn)閃爍現(xiàn)象,控制臺(tái)也不會(huì)出現(xiàn)報(bào)錯(cuò)了。
3.5 css 處理
客戶端渲染時(shí),一般有兩種方法引入樣式:
style-loader
: 將css
樣式通過style
標(biāo)簽插入到DOM
中MiniCssExtractPlugin
: 插件將樣式打包到單獨(dú)的文件,并使用link
標(biāo)簽引入.
對于服務(wù)端渲染來說,這兩種方式都不能使用。
服務(wù)端不能操作
DOM
,不能使用style-loader
服務(wù)端輸出的是靜態(tài)頁面,等待瀏覽器加載
css
文件,如果樣式文件較大,必定會(huì)導(dǎo)致頁面閃爍。
對于服務(wù)端來說,我們可以使用isomorphic-style-loader
來解決。isomorphic-style-loader
利用context Api
,結(jié)合useStyles hooks Api
在渲染組件渲染的拿到組件的 css 樣式,最終插入 HTML 中。
isomorphic-style-loader 這個(gè) loader 利用了 loader的 pitch 方法的特性,返回三個(gè)方法供樣式文件使用。關(guān)于 loader 的執(zhí)行機(jī)制可以戳 → loader 調(diào)用鏈。
- _getContent:數(shù)組,可以獲取用戶使用的類名等信息
- _getCss:獲取 css 樣式
- _insertCss :將 css 插入到 style 標(biāo)簽中
服務(wù)端改造:定義insertCss
方法, 該方法調(diào)用 \_getCss
方法獲取將組件樣式添加到css Set
中, 通過context
將insertCss
方法傳遞給每一個(gè)組件,當(dāng)insertCss
方法被調(diào)用時(shí),則樣式將被添加到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
方法會(huì)操作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
, 否則會(huì)報(bào)錯(cuò)。
module: { rules: [ { test: /\.(css|less)$/, use: [ 'isomorphic-style-loader', // 服務(wù)端渲染時(shí),需要使用 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,并且會(huì)根據(jù)目標(biāo)瀏覽器或運(yùn)行時(shí)環(huán)境添加所需的 polyfill; // 也包括會(huì)自動(dòng)幫助我們添加 autoprefixer postcssOptions: { plugins: ['postcss-preset-env'], }, }, }, 'less-loader', ], // 排除 node_modules 目錄 exclude: /node_modules/, }, ], },
注意:這里服務(wù)端和客戶端都是使用 isomorphic-style-loader 去實(shí)現(xiàn)樣式的引入。
最終效果如下,不會(huì)造成樣式閃爍:
3.6 流式SSR渲染
前面的例子我們可以發(fā)現(xiàn) 3個(gè)問題:
必須在發(fā)送HTML之前拿到所有的數(shù)據(jù)
上述例子中我們需要獲取到 user 的數(shù)據(jù)之后 ,才能開始渲染。 假設(shè)我們還需要獲取評論信息,那么我們只有獲取到這兩部分的數(shù)據(jù)之后,才能開始渲染。而在實(shí)際場景中接口的速度也不同,等到接口慢的數(shù)據(jù)獲取到之后再開始渲染,務(wù)必會(huì)影響首屏的速度。
必須等待所有的 JavaScript 內(nèi)容加載完才能開始吸水
上述例子中我們提到過,客戶端渲染的組件樹要和服務(wù)端渲染的組件樹保持一致,否則React就無法匹配,客戶端換渲染會(huì)代替服務(wù)端渲染。假如組件樹的加載和執(zhí)行的執(zhí)行比較長,那么吸水也需要等待所有組件樹都加載執(zhí)行完。
必須等所有的組件都吸水完才能開始頁面交互
React DOM Root 只會(huì)吸水一次,一旦開始吸水,就不會(huì)停止,只有等到吸水完畢中后才能交互。假如 js 的執(zhí)行時(shí)間很長,那么用戶交互在這段時(shí)間內(nèi)就得不到響應(yīng),務(wù)必就會(huì)給用戶一種卡頓的感覺,留下不好的體驗(yàn)。
react 18 以前上面3個(gè)問題都是我們在 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
。
有兩種方式替換,第一種官方推薦寫法,需要自己寫一個(gè)組件傳遞給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() { // 錯(cuò)誤發(fā)生時(shí)替換外殼 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() { // 錯(cuò)誤發(fā)生時(shí)替換外殼 res.statusCode = 500 res.send('<!doctype><p>Error</p>') }, } ) setTimeout(abort, 10_000) }) })
兩種方式都可以,這里需要注意 js 的處理:
bootstrapScripts
:一個(gè) URL 字符串?dāng)?shù)組,它們將被轉(zhuǎn)化為
當(dāng)看到評論組件能異步加載出來,并且模版文件中出現(xiàn)占位符即成功。
簡單介紹下 ssr 流式替換的流程:先使用占位符,再替換為真實(shí)的內(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é)果,不過這個(gè)div
設(shè)置了hidden
屬性。接下來的$RC
函數(shù),會(huì)負(fù)責(zé)將這個(gè)div
插入到第 1 段數(shù)據(jù)中template
標(biāo)簽所在的位置,同時(shí)刪除template
標(biāo)簽。
第二次訪問頁面:html
的內(nèi)容不會(huì)分段傳輸,評論組件也不會(huì)異步加載,而是一次性返回。這是因?yàn)?code>Comment組件對應(yīng)的 js 模塊已經(jīng)被加入到服務(wù)端的緩存模塊中了,再一次請求時(shí),加載Comment
組件是一個(gè)同步的過程,所以整個(gè)渲染就是同步的。即只有當(dāng) Suspense
中包裹的組件需要異步渲染時(shí),ssr
返回的HTML
內(nèi)容才會(huì)分段傳輸。
四、小結(jié)
本文講述了關(guān)于如何實(shí)現(xiàn)一個(gè)基本的 React SSR 應(yīng)用,希望能幫助大家更好的理解服務(wù)端渲染。
到此這篇關(guān)于React SSR服務(wù)端渲染的實(shí)現(xiàn)示例的文章就介紹到這了,更多相關(guān)React SSR服務(wù)端渲染內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React?Native項(xiàng)目設(shè)置路徑別名示例
這篇文章主要為大家介紹了React?Native項(xiàng)目設(shè)置路徑別名實(shí)現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-05-05React?Hooks之useDeferredValue鉤子用法示例詳解
useDeferredValue鉤子的主要目的是在React的并發(fā)模式中提供更流暢的用戶體驗(yàn),特別是在有高優(yōu)先級和低優(yōu)先級更新的情況下,本文主要講解一些常見的使用場景及其示例2023-09-09如何使用React的VideoPlayer構(gòu)建視頻播放器
本文介紹了如何使用React構(gòu)建一個(gè)基礎(chǔ)的視頻播放器組件,并探討了常見問題和易錯(cuò)點(diǎn),通過組件化思想和合理管理狀態(tài),可以實(shí)現(xiàn)功能豐富且性能優(yōu)化的視頻播放器2025-01-01React實(shí)現(xiàn)動(dòng)態(tài)調(diào)用的彈框組件
這篇文章主要為大家詳細(xì)介紹了React實(shí)現(xiàn)動(dòng)態(tài)調(diào)用的彈框組件,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-08-08