前言
SSR服务端渲染各位前端朋友们应该都或多或少了解过,本文作为服务端渲染系列文章中的第一篇,主要包括以下内容:
- 什么是SSR
- SSR的适用场景
- React实现SSR
- 服务端
- 降级
- 样式
什么是SSR
SSR全称Server-Side Rendering,即服务端渲染的英文缩写。与SSR相对的是客户端渲染CSR。
客户端渲染是指浏览器首先下载一个空白的HTML文本,然后下载执行JS代码,直至完成HTML构建,而服务端渲染则在服务端就完成页面的构建,浏览器拿到的是一个比较完备的HTML文本。
其实在前端刀耕火种时代,服务端渲染就已经存在,写过php的同学应该知道,php里面有很多html模版代码,这些就是服务端渲染,后来随着前后端分离的流行,页面渲染逐渐从后端剥离出来,直到node出现,可以支持CSR和SSR的同构,加上服务端渲染自身的一些优势,SSR再次流行起来。
SSR的适用场景
- 更快的首屏加载:在网速较慢或者设备性能较差的情况下尤为适用。SSR渲染的HTML无需等待所有JS代码都加载并执行完成才显示,用户能够更快地看到完整的渲染页面。另外相比客户端,服务端数据请求更快。
- 更好的SEO: 搜索引擎爬虫可以直接看到渲染完成的页面,Google等可以很好地支持对同步JavaSrcipt应用进行索引,但如果你的应用是异步获取内容,爬虫不会等到页面加载完成再抓取,如果SEO对你的应用很重要,那么SSR可能是必需的。
React实现SSR
React支持将组件在服务端直接渲染成HTML字符串,作为服务端响应返回给浏览器,最后在浏览器端将静态的HTML“激活”(hydrate)为能够交互的客户端应用。
一个由服务端渲染的React应用应该是“同构的”(Isomorphic),即大部分代码可以同时运行在服务端和客户端。接下来就基于React实现一个简单的支持服务端和客户端运行的TODO List应用
第一步:项目初始化,安装npm包。
我们的应用依赖以下npm包:
react, react-dom
@babel/core,
@babel/preset-env,
@babel/preset-react,
babel-loader,
webpack,
webpack-cli
express
第二步:搭建后端服务,返回一个简单的html字符串
server/app.js
import express from 'express';
const app = express();
const PORT = 3000;
app.listen(PORT, () => console.log(`listening on ${PORT}`));
export default app;
server/index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
app.get('/', (req, res) => {
const toDoItemString = renderToString( );
res.send(`
hello world
${toDoItemString}
`)
})
第三步:增加待办事项ToDoItem组件,并在服务端渲染
src/ToDoItem/index.js
import React, { useState } from 'react';
export const ToDoItem = (props) => {
const [inputVal, setInputVal] = useState('')
return
{
setInputVal(val)
}} />
;
};
server/index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
app.get('/', (req, res) => {
const toDoItemString = renderToString( );
res.send(`
hello world
${toDoItemString}
`)})
我们需要增加webpack配置,处理React,ES Module
webpack.server.js
const path = require('path');
module.exports = {
mode: 'development',
target: 'node',
entry: './server/index.js',
output: {
path: path.join(__dirname, 'server_dist'),
filename: 'index.js'
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}]
}
}
第四步:给ToDoItem组件增加用户点击事件
我们的待办事项组件需要支持用户输入。点击完成按钮,显示输入的待办事项,点击待办事项进入编辑模式。
import React, { useCallback, useState } from 'react';
export const ToDoItem = (props) => {
const [inputVal, setInputVal] = useState('')
const [isFinished, setIsFinished] = useState(false)
const finishInput = useCallback(() => {
setIsFinished(true);
}, [inputVal])
return
{!isFinished ? <>
{
setInputVal(e.target.value)
}} />
> : {
setIsFinished(false)
}}>{inputVal}}
;
};
事件绑定只能在浏览器端执行,React提供了hydrate api(在React 18中请使用hydrateRoot替代hydrate), hydrate与render方法类似,但hydrate可以复用原本已经存在的DOM节点,并给已标记的DOM节点添加事件。
先给需要hydrate的DOM增加id
server/index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
app.get('/', (req, res) => {
const toDoItemString = renderToString( );
res.send(`
hello world
${toDoItemString} // 增加id,方便获取需要hydrate的DOM节点
`)})
增加client/index.js文件,实现在客户端获取DOM节点绑定事件
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { ToDoItem } from '../src/ToDoItem';
hydrateRoot(document.getElementById('root'), );
增加webpack.client.js,将client/index.js打包成静态文件保存到public目录下
const path = require('path');
module.exports = {
mode: 'development',
entry: './client/index.js',
output: {
path: path.join(__dirname, 'public'),
filename: 'index.js'
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}]
}
}
在server/app.js中使用public静态文件目录
app.use(express.static('public'));
在server/index.js中引入client/index.js打包后的静态文件,这样待客户端加载完script脚本后便可以执行绑定事件
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
app.get('/', (req, res) => {
const toDoItemString = renderToString( );
res.send(`
hello world
${toDoItemString}
// 引入client/index.js打包后的静态文件
`)
})
第五步:支持数据请求
我们通过一个简单的方法模拟数据请求
src/mock.js
export const fetchData = () => {
return new Promise((resolve, _) => setTimeout(() => {
resolve();
return [
'吃饭',
'睡觉',
'写代码'
]}, 500));
}
增加一个新组件,待办事件列表组件ToDoList
,支持新增待办事项。
src/ToDoList/index.js
import React from 'react';
import { ToDoItem } from '../ToDoItem';
export const ToDoList = ({ defaultToDoList }) => {
const [toDoList, setToDoList] = useState(defaultToDoList)
const addNewToDo = useCallback(() => {
const newToDoList = [...toDoList];
newToDoList.push('');
setToDoList(newToDoList);
}, [toDoList])
return (
{
todoList.map(todo => )
}
);
};
然后对我们的ToDoItem
组件稍微优化一下,支持待办事项的显示、修改。
src/ToDoItem/index.js
import React, { useState } from 'react';
export const ToDoItem = ({defaultToDo}) => {
const [inputVal, setInputVal] = useState(defaultToDo)
return
{
setInputVal(e.target.value)
}}>{inputVal}
;
};
这边为了方便直接使用contentEditable
属性,实际使用中要注意兼容性。
server/index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
import { fetchData } from '../mock';
import { ToDoList } from '../src/ToDoLIst';
app.get('/', async (req, res) => {
const result = await fetchData(); //获取数据
const toDoItemString = renderToString( );
res.send(`
hello world
${toDoItemString}
`)
})
client/index.js
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { ToDoList } from '../src/ToDoList';
hydrateRoot(document.getElementById('root'), );
此时,我们重新执行npm start
命令,刷新页面会发现报错
为什么todoList会是undefined(当然我们代码也要对空逻辑进行处理,增强代码鲁棒性)还记得第四步给页面绑定点击事件而增加了client/index.js文件吗?在这里我们将点击事件移到了ToDoList组件中,因此client/index.js也做了相应的更改,但是却忘了没有给ToDoList的props传值
数据注水与脱水
我们当然可以在client/index.js中再请求一次数据传给ToDoList组件,但那这就违背了使用ssr的初衷。我们希望能复用服务端请求的数据。在服务端请求数据后返回到客户端,这个过程即注水,客户端拿到数据并渲染这个过程即脱水。
首先在服务端注水
server/index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
import { fetchData } from '../mock';
import { ToDoList } from '../src/ToDoLIst';
app.get('/', async (req, res) => {
const result = await fetchData()
const toDoItemString = renderToString( );
res.send(`
hello world
${toDoItemString}
// 注水
`)
})
然后在客户端拿到数据,完成脱水
client/index.js
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { ToDoList } from '../src/ToDoList';
const defaultToDoList = window.data // 脱水
hydrateRoot(document.getElementById('root'), );
最后,咱们这个待办事项还是丑了点儿,需要添加点样式美观一下。服务端我们只能用style
标签添加样式。
server/index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ToDoItem } from '../src/ToDoItem/';
import app from './app';
import { fetchData } from '../mock';
import { ToDoList } from '../src/ToDoLIst';
app.get('/', async (req, res) => {
const result = await fetchData()
const toDoItemString = renderToString( );
res.send(`
hello world
${toDoItemString}
`)
})
最终效果图如下:
代码git地址:https://github.com/vciscoding/ssr-demo/tree/main