实现Mini React SSR
CSR
实现SSR之前先实现一遍CSR渲染过程
react-csr
|- app.js // 测试组件
|- client.js // react生成出root节点
|- index.html // 入口文件
|- package.json // npm init 自动生成
|- webpack.client.js // 客户端打包配置
安装依赖
npm install webpack webpack-cli babel-loader @babel/preset-env @babel/preset-react react react-dom
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tiny React SSR</title>
</head>
<body>
<div id="root">
</div>
<script src="./index.js" />
</body>
</html>
先用webpack打包React代码成index.js
client.js
import React from "react";
import {createRoot} from 'react-dom/client'
import App from './app'
const root = createRoot(document.getElementById('root'))
root.render(<App />);
app.js
import { useState } from "react";
export default function App(){
const [count,setCount] = useState(0);
return (
<div>
<h1>Counters {count} times</h1>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
)
}
由于Javascript不能直接识别React的JSX格式,所以需要webpack和babel将JSX代码转译成Javascript代码,新建webpack.client.js
const path = require('path');
module.exports = {
mode: 'development',
entry: './client.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname)
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { "runtime": "automatic" }]
]
}
}
}
]
}
};
Webpack.client.js是为了区分接下来的csr和ssr起的名字,不会像webpack.config.js一样被自动读取,所以我们需要在运行webpack时候手动指定一下该文件
修改package.json
"scripts": {
"start": "webpack --config webpack.client.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
运行npm start
Support/typora-user-images/image-20241021220744178.png
此时根目录会生成index.js,打开index.html
Support/typora-user-images/image-20241021220918623.png
SSR
与CSR相比,SSR多了服务端,所以需要用到Express
react-csr
|- public
|- build
|- pages
|- index,js
|- server.js
|- package.json
|- webpack.server.js
|- webpack.client.js
安装依赖
npm install webpack webpack-cli babel-loader @babel/preset-env @babel/preset-react react react-dom express
Server.js
const express = require('express')
const app = express()
app.get('/',(req,res) => res.send(`
<html>
<head>
<title>Tiny React SSR</title>
</head>
<body>
<div id='root'>
Counter 0 times
</div>
</body>
</html>
`))
app.listen(3000,() => console.log('listening on port 3000'))
node server.js
Support/typora-user-images/image-20241021222243391.png
express 启动成功
开始实现SSR,修改server.js
import express from 'express'
import React from 'react'
import {renderToString} from 'react-dom/server'
import App from './pages/index'
const app = express()
const content = renderToString(<App />)
app.get('/',(req,res) => res.send(`
<html>
<head>
<title>Tiny React SSR</title>
</head>
<body>
<div id='root'>
${content}
</div>
<script src='/client.bundle.js'></script>
</body>
</html>
`))
app.listen(3000,() => console.log('listening on port 3000'))
pages/index.js
import React,{useState} from "react";
export default function App(){
const [count,setCount] = useState(0)
return (
<div>
<h1>Counters {count}</h1>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
)
}
此时运行node server.js看到会报错
Support/typora-user-images/image-20241021222732062.png
- 使用了import语法,这是ES规范,而非Node.js的CommonJS规范
- 我们使用了React的JSX语法,JavaScript并不认识,需要用babel转译
新建webpack.server.js
const path = require('path')
module.exports = {
mode: 'development',
target: 'node',
entry: './server.js',
output: {
filename: 'server.bundle.js',
path: path.resolve(__dirname,'build')
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env','@babel/preset-react']
}
}
}
]
}
}
修改package.json
"scripts": {
"start": "webpack --config webpack.server.js && node ./build/server.bundle.js",
}
Support/typora-user-images/image-20241021222956023.png
此时成功渲染,但是只用renderToString将React组件转为了HTML字符串,并没有任何的Hydrate(水合)事件绑定,只有静态的HTML
绑定事件
既然服务端只能渲染HTML,客户端能渲染事件,那就结合一下,再实现一遍CSR,让页面插入一个打包后的bundle,挂载到想同的节点,让客户端将一模一样的内容重新渲染一遍,并绑定上事件(重复渲染后面解决)
修改server.js
import express from 'express'
import React from 'react'
import {renderToString} from 'react-dom/server'
import App from './pages/index'
const app = express()
app.use(express.static('public'))
const content = renderToString(<App />)
app.get('/',(req,res) => res.send(`
<html>
<head>
<title>Tiny React SSR</title>
</head>
<body>
<div id='root'>
${content}
</div>
<script src='/client.bundle.js'></script>
</body>
</html>
`))
app.listen(3000,() => console.log('listening on port 3000'))
打包后的客户端代码被命名为client.bundle.js,并放入public目录
新建client.js
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './pages/index'
const root = createRoot(document.getElementById('root'));
root.render(<App />)
新建webpack.client.js
const path = require('path');
module.exports = {
mode: 'development',
entry: './client.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname)
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { "runtime": "automatic" }]
]
}
}
}
]
}
};
现在的流程是:
先打包客户端的JS,将引用pages/index.js 核心React代码的client.js打包到public下的client.bundle.js
然后将同样引用pages/index.js核心React代码的server.js打包到build下的server.bundle.j中,然后node开启server.bundle.js
服务端会先渲染一遍组件代码,然后输出到HTML中,引用client.bundle.js,然后用js再渲染一遍,并绑定上事件
修改package.json
"scripts": {
"start": "webpack --config webpack.client.js && webpack --config webpack.server.js && node ./build/server.bundle.js",
},
此时已经能交互,但是有个重复渲染问题需要解决
HydrateRoot
React官方提供了HydratRoot API,允许你在先前react-dom/server生成的HTML DOM节点中展示React组件,简单来说就是服用服务器渲染出来的DOM节点
修改client.js
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './pages/index'
const root = hydrateRoot(document.getElementById('root'));
root.render(<App />)
此时既实现了SSR又不会重复渲染