前言
在日常开发中,SSR的使用是非常常见的,可以解决CSR SEO的弊端也能优化首屏加载速度等。
下面,通过React 自带的API和Express(SSR涉及到服务端渲染,所以这里使用Express作为服务端)简单实现一下SSR,加深对SSR的认识。
初始化项目
创建一个文件夹,并初始化package.json
mkdir ssr
cd ssr
npm init
创建一个名为ssr的文件夹,进入文件夹,init package.json
安装所需要的依赖
npm install webpack webpack-cli babel-loader @babel/core @babel/preset-env @babel/preset-react react react-dom
因为需要将jsx格式的文件转换为js文件,所以需要webpack和loader打包转换。
- webpack、webpack-cli 用于 webpack 打包
- babel-loader、@babel/core、@babel/preset-env、 @babel/preset-react 用于编译 React
- react、react-dom 用于书写 React 代码
实现CSR
目前项目环境搭建完毕,我们先简单的实现CSR
创建一个index.html文件
<html>
<head>
<title>Tiny React SSR</title>
</head>
<body>
<div id='root'>
</div>
<script src="./index.js"></script>
</body>
</html>
使用webpack打包出index.js用浏览器打开html文件,就可以看到我们CSR是否实现成功。
现在开始编写React代码 创建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 MyApp() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Counters { count } times</h1>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
逻辑很简单,就是一个计时器的demo。
随后我们开始配置webpack配置文件,因为需要将jsx代码转换为浏览器可以识别的js文件 创建webpack.client.js文件,这里命名为webpack.client.js是因为要区分客户端和服务端的配置文件,webpack是无法自动识别的,需要自己配置。
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" }]]
}
}
}
]
}
}
修改packag.json文件
"start": "webpack --config webpack.client.js"
随后运行npm run start
项目根目录就会生成index.js文件,浏览器打开index.html就能看到内容。
实现SSR
首先下载express
npm install express
创建server.js文件
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import App from "./app"
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>
</body>
</html>
`))
app.listen(3000, () => console.log('listening on port 3000!'))
使用React提供的renderTostring API将渲染的内容转换为HTML字符串,再使用Express接口返回。
现在使用 node server.js 启动express服务肯定会报错,因为node环境不支持import导入的语法。
所以要使用webpack进行打包,解决import的问题以及将jsx转换为js。
创建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']
}
}
}
]
}
}
配置packag.json文件
"scripts": {
"start": "webpack --config webpack.client.js",
"test": "echo \"Error: no test specified\" && exit 1",
"server-start": "webpack --config webpack.server.js && node ./build/server.bundle.js"
}
运行 npm run server-start
用浏览器访问localhost:3000端口
但是你发现点击按钮,没有反应,这是因为没有进行水合,服务端只是返回了一个静态的HTML文件。
接下来,为静态的HTML绑定事件(水合)
首先我们捋一下思路,服务端返回了静态HTML文件,客户端绑定事件,那我们就可以结合一下,只不过这样会渲染两次,服务端渲染一次,客户端渲染一次,我们返回的静态HTML文件引入打包的客户端代码就可以实现,我们先来实现一下。
修改server.js文件,注意创建pages/index.js文件,内容和app.js文件一致。
//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!'))
引用打包后的客户端js文件,这里声明了public静态目录,所以需要创建一个public目录
//client.js
import React from 'react';
import { createRoot } 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: 'client.bundle.js',
path: path.resolve(__dirname, 'public')
},
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.js 中,然后 node 开启 server.bundle.js。
这样当访问 localhost:3000的时候,服务端会先渲染一遍组件代码,然后输出到 HTML 中,然后引用 client.bundle.js,然后用 JS 重新渲染一遍,并同时绑定上事件。
修改package.json
"scripts": {
"start": "webpack --config webpack.client.js",
"test": "echo \"Error: no test specified\" && exit 1",
"server-start": "webpack --config webpack.server.js && node ./build/server.bundle.js",
"all-start": "webpack --config webpack.client.js && webpack --config webpack.server.js && node ./build/server.bundle.js"
},
运行 npm run all-start
现在就可以正常点击了。
但是现在有一个弊端,就是要渲染两次,所以我们要优化一下。
优化SSR
使用 React提供的 hydrateRoot API 就可以避免渲染两次的情况。
我们常用的 createRoot 会重新渲染,hydrateRoot 会复用已有的 DOM 节点(当然前提是服务端和客户端渲染一致,这样才能够复用)。
hydrateRoot 通常就是搭配 React 的服务端 API react-dom/server 而使用的:react-dom/server 负责服务端渲染,hydrateRoot 负责复用 DOM 进行水合。 修改client.js文件
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from'./pages/index'
hydrateRoot(document.getElementById('root'), <App />);
重新运行,这时,实现了服务端渲染,客户端绑定,不会出现渲染两次的效果。