从零到精通服务器端渲染
- 在本文中,我们将分析SSR对应用程序SEO优化的影响。
- 我们将带您了解将常规React应用程序移植到 SSR 的方法。
- 我们将研究 SSR 应用程序中异步操作的处理。
- 我们将看到如何使用Redux Saga在应用程序中实现 SSR 。
- 我们将配置Webpack 5以与 SSR 应用程序协同工作。
- 我们还将考虑 SSR 的复杂性:生成HTML 元标记、动态导入、使用LocalStorage、调试等。
几年前,在开发Cleverbrush产品时,我和一位朋友遇到了一个 SEO 优化问题。我们创建了一个网站,理论上应该用来销售我们的产品,但它只是一个普通的单页 React 应用程序,却从未出现在 Google 搜索结果中!经过多次细致的分析,iSSR库应运而生,我们的网站终于出现在了 Google 搜索结果的第一页。那就让我们一起解决这个问题吧!
问题
单页应用的主要问题是服务器返回给客户端的是一个空白的 HTML 页面。只有在所有 JS 文件(包括所有代码、库和框架)下载完成后才会生成该页面。在大多数情况下,该页面的大小会超过 2MB,并且会产生代码处理延迟。
即使 Google 机器人知道如何执行 JS,它也需要一段时间才能收到内容,而这段时间对网站的排名至关重要。Google 机器人只会在几秒钟内看到一个空白页面!这很糟糕!
如果您的网站渲染时间超过 3 秒,Google 就会开始发出红牌。首次内容绘制 (FPC) 和可交互时间 (TCR) 是单页应用容易被低估的指标。点击此处了解更多信息。
还有一些不太先进的搜索引擎根本不知道如何使用 JS。它们不会索引单页应用程序。
许多因素仍然会影响网站的排名率,其中一些我们将在本文后面进行分析。
渲染
有几种方法可以解决加载时出现空白页的问题,请考虑其中几种:
静态网站生成 (SSG)。在网站上传到服务器之前进行预渲染。这是一个非常简单有效的解决方案。非常适合简单的网页,无需后端 API 交互。
服务端渲染 (SSR)。在服务端运行时渲染内容。通过这种方式,我们可以发出后端 API 请求,并提供 HTML 以及必要的内容。
服务器端渲染(SSR)
让我们仔细看看 SSR 的工作原理:
-
我们需要一个服务器,能够像用户在浏览器中一样执行我们的应用程序,请求必要的资源,渲染所有必要的 HTML,并填充状态。
-
服务器向客户端提供完整的HTML、完整的状态,还提供所有必要的JS、CSS和其他资源。
-
客户端接收 HTML 和资源,同步状态,并像普通单页应用一样与应用程序协同工作。这里的重点是状态必须同步。
SSR 应用程序示意图如下所示:
从前面描述的SSR工作中,我们可以强调以下问题:
-
该应用程序分为服务器端和客户端。也就是说,我们实际上得到了两个应用程序。这种分离应该最小化,否则,对此类应用程序的支持将会很困难。
-
服务器应该能够处理带有数据的 API 请求。这些操作是异步的,被称为副作用 (Side Effects)。默认情况下,React 的renderToString服务器端方法是同步的,无法处理异步操作。
-
在客户端,应用程序必须同步状态并继续作为正常的 SPA 应用程序工作。
固态反应堆
这是一个小型库,可以解决数据请求的异步处理以及从服务器到客户端的状态同步问题。这不会是另一个Next.JS杀手,绝对不会!Next.JS是一个功能强大的框架,但要使用它,您需要完全重写您的应用程序并遵循Next.JS的规则。
让我们看一下将常规 SPA 应用程序移植到 SSR 是多么容易的示例。
例如,我们有一个具有异步逻辑的简单应用程序。
import React, { useState, useEffect } from 'react';
import { render } from 'react-dom';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
const TodoList = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
getTodos()
.then(todos => setTodos(todos))
}, []);
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
)
}
render(
<TodoList />,
document.getElementById('root')
);
此代码使用jsonplaceholder服务来模拟 API 交互,从而呈现已完成任务的列表。
让我们将应用程序移至 SSR!
步骤 1.安装依赖项
要安装iSSR,您需要执行以下操作:
npm install @issr/core --save
npm install @issr/babel-plugin --save-dev
安装 webpack 5 构建系统的依赖项:
npm install @babel/core @babel/preset-react babel-loader webpack webpack-cli nodemon-webpack-plugin --save-dev
在 SSR 应用开发中,一个不太明显的方面是,某些 API 和库可以在客户端运行,但无法在服务器端运行。fetch 就是这样一个 API 。在 Node.js 中,fetch 方法不存在,而我们的应用程序的服务器逻辑将在 Node.js 中执行。为了实现同样的效果,请安装以下软件包:
npm install node-fetch --save
我们将使用express作为服务器,但这并不重要,您可以使用任何其他框架:
npm install express --save
让我们添加一个用于在服务器上序列化应用程序状态的模块:
npm install serialize-javascript --save
步骤2.配置webpack.config.js
const path = require('path');
const NodemonPlugin = require('nodemon-webpack-plugin');
const commonConfig = {
module: {
rules: [
{
test: /\.jsx$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-react'
],
plugins: [
'@issr/babel-plugin'
]
}
}
]
}
]
},
resolve: {
extensions: [
'.js',
'.jsx'
]
}
}
module.exports = [
{
...commonConfig,
target: 'node',
entry: './src/server.jsx',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'index.js',
},
plugins: [
new NodemonPlugin({
watch: path.resolve(__dirname, './dist'),
})
]
},
{
...commonConfig,
entry: './src/client.jsx',
output: {
path: path.resolve(__dirname, './public'),
filename: 'index.js',
}
}
];
-
要编译 SSR 应用,webpack配置文件必须包含两个配置(MultiCompilation)。一个用于构建服务器,另一个用于构建客户端。我们将一个数组传递给module.exports。
-
要配置服务器,我们需要设置target: 'node'。target 对于客户端来说是可选的。默认情况下,webpack 配置有 target: 'web'。target: 'node' 允许 webpack 处理服务器代码、默认模块(例如 path、child_process 等)。
-
const commonConfig - 设置的公共部分。由于服务器和客户端代码共享相同的应用程序结构,因此它们必须以相同的方式处理 JS。
您需要向 babel-loader 添加一个插件:
@issr/babel-plugin
这是一个辅助插件 @issr/babel-plugin,允许你跟踪应用程序中的异步操作。它与babel/typescript-preset以及其他 babel 插件配合使用效果极佳。
步骤3.修改代码。
我们将应用程序的通用逻辑移到一个单独的文件App.jsx中。这样做是必要的,这样client.jsx和server.jsx文件中就只保留渲染逻辑,其他什么都不剩。这样,我们就能拥有整个应用程序的通用代码了。
App.jsx:
import React from 'react';
import fetch from 'node-fetch';
import { useSsrState, useSsrEffect } from '@issr/core';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
export const App = () => {
const [todos, setTodos] = useSsrState([]);
useSsrEffect(async () => {
const todos = await getTodos()
setTodos(todos);
});
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
);
};
客户端.jsx:
import React from 'react';
import { hydrate } from 'react-dom';
import { App } from './App';
hydrate(
<App />,
document.getElementById('root')
);
我们将默认的 React渲染方法更改为hydrate,该方法适用于 SSR 应用程序。
server.jsx:
import React from 'react';
import express from 'express';
import { renderToString } from 'react-dom/server';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const html = renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">${html}</div>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});
在服务器代码中,注意我们必须与构建的SPA webpack应用程序共享文件夹:
app.use(express.static('public'));
这样,从服务器收到的HTML将继续作为常规SPA工作
步骤4.处理异步函数。
我们已经分离了应用程序的公共部分,并连接了应用程序客户端和服务器部分的编译器。现在让我们解决与异步调用和状态相关的其余问题。
要处理异步函数,您需要将它们包装在@issr/core包中的useSsrEffect钩子中:
App.jsx:
import React from 'react';
import fetch from 'node-fetch';
import { useSsrEffect } from '@issr/core';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
export const App = () => {
const [todos, setTodos] = useState([]);
useSsrEffect(async () => {
const todos = await getTodos()
setTodos(todos);
});
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
);
};
在 server.jsx 中,将标准renderToString替换为@issr/core包中的serverRender:
import React from 'react';
import express from 'express';
import { serverRender } from '@issr/core';
import serialize from 'serialize-javascript';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const { html } = await serverRender(() => <App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});
如果你直接运行应用程序,什么都不会发生!我们看不到执行 getTodos 异步函数的结果。为什么?我们忘了同步状态了。让我们来解决这个问题。
在 App.jsx 中,用@issr/core包中的useSsrState替换标准setState:
App.jsx:
import React from 'react';
import fetch from 'node-fetch';
import { useSsrState, useSsrEffect } from '@issr/core';
const getTodos = () => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then(data => data.json())
};
export const App = () => {
const [todos, setTodos] = useSsrState([]);
useSsrEffect(async () => {
const todos = await getTodos()
setTodos(todos);
});
return (
<div>
<h1>Hi</h1>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>{todo.title}</li>
))}
</ul>
</div>
);
};
让我们对 client.jsx 进行修改,以同步从服务器传输到客户端的状态:
import React from 'react';
import { hydrate } from 'react-dom';
import createSsr from '@issr/core';
import { App } from './App';
const SSR = createSsr(window.SSR_DATA);
hydrate(
<SSR>
<App />
</SSR>,
document.getElementById('root')
);
window.SSR_DATA是从服务器传递过来的对象,具有缓存状态,用于客户端同步。
让我们在服务器上进行传输状态:
import React from 'react';
import express from 'express';
import { serverRender } from '@issr/core';
import serialize from 'serialize-javascript';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const { html, state } = await serverRender(() => <App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script>
window.SSR_DATA = ${serialize(state, { isJSON: true })}
</script>
</head>
<body>
<div id="root">${html}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});
请注意,serverRender函数不仅传递 HTML,还会传递通过useSsrState传递的状态,我们将其作为全局变量SSR_DATA传递给客户端。在客户端,此状态将自动同步。
步骤 5. 构建脚本
仍需将脚本添加到package.json中:
"scripts": {
"start": "webpack -w --mode development",
"build": "webpack"
},
Redux 和其他状态管理库
iSSR完美支持各种状态管理库。在开发iSSR的过程中,我注意到 React 状态管理库分为两种类型:
-
实现在 React 层使用副作用。例如,Redux Thunk将 Redux 调度调用转换为异步方法,这意味着我们可以像上例中 setState 那样实现服务端渲染 (SSR)。redux-thunk 的示例可在此处获取。
-
在 React 之外的独立层上实现对副作用的处理。例如,Redux Saga将异步操作的功能引入了 Sagas。
让我们看一下使用Redux Saga 的应用程序的 SSR 实现的示例。
我们不会像上一个示例那样详细地讨论这个示例。完整的代码可以在这里找到。
Redux Saga
为了更好地理解正在发生的事情,请阅读上一章
服务器通过serverRender运行我们的应用程序,代码按顺序执行,执行所有useSsrEffect操作。
从概念上讲,Redux在使用 Saga 时不会执行任何异步操作。我们的任务是发送一个 action 来启动 Cag 层中的异步操作,这与我们的 React-flow 是分离的。在上面链接的示例中,我们在Redux容器中执行:
useSsrEffect(() => {
dispatch(fetchImage());
});
这不是一个异步操作!但是iSSR意识到系统中发生了一些事情。iSSR将遍历其余的 React 组件执行所有的useSsrEffect操作,并在完成后调用回调:
const { html } = await serverRender(() => (
<Provider store={store}>
<App />
</Provider>
), async () => {
store.dispatch(END);
await rootSaga.toPromise();
});
因此,我们不仅可以在 React 级别处理异步操作,还可以在其他级别处理异步操作,在这种情况下,首先我们放置需要执行的 sagas,然后启动serverRender回调并等待它们结束。
我准备了许多使用iSSR的示例,您可以在这里找到它们。
SSR技巧
在开发 SSR 应用的过程中,我们会遇到很多挑战。异步操作问题只是其中之一。我们来看看其他一些常见问题。
SSR 的 HTML 元标记
SSR 开发的一个重要方面是使用正确的HTML 元标签。它们会告诉搜索机器人页面上的关键信息。
为了完成这项任务,我推荐你使用以下模块之一:
React-Helmet-Async
React-Meta-Tags
我已经准备了一些示例:
React-Helmet-Async
React-Meta-Tags
动态导入
为了减少最终应用程序包的大小,可以将应用程序拆分成多个部分。例如,动态导入 Webpack可以自动拆分应用程序。我们可以将单个页面拆分成多个块。使用 SSR,我们需要能够将应用程序的数据块作为一个整体来处理。为此,我建议使用非常棒的@loadable模块。
假人
某些组件可能无法在服务器上渲染。例如,如果您有一篇帖子和一些评论,则不建议同时处理这两个异步操作。帖子数据优先于评论,而评论数据构成了应用程序的 SEO 负载。因此,我们可以使用类型检查来排除不重要的部分:
if (typeof windows === 'undefined') {
}
localStorage,数据存储
NodeJS 不支持 localStorage。我们使用 Cookie 而不是 localStorage 来存储会话数据。每次请求都会自动发送 Cookie。Cookie 有一些限制,例如:
-
Cookie 是一种古老的数据存储方式,每个 Cookie 的限制为 4096 字节(实际上是 4095 字节)。
-
localStorage 是存储接口的一个实现。它存储的数据没有过期日期,并且只能通过 JavaScript 或清除浏览器缓存/本地存储的数据来清除 - 这与 Cookie 过期不同。
某些数据需要在 URL 中传递。例如,如果我们在网站上使用了本地化,那么当前语言将成为 URL 的一部分。这种方法可以提升 SEO,因为我们会针对应用程序的不同本地化版本使用不同的 URL,并按需提供数据传输。
React 服务器组件
React 服务器组件或许是 SSR 的一个很好的补充。它的理念是通过在服务器上执行组件并返回一个现成的 JSON React 树来减轻 Bundle 的负载。我们在Next.JS中也看到了类似的功能。更多信息,请阅读链接
路由
React Router开箱即用地支持 SSR。区别在于,在服务端,StaticRouter会传递当前 URL 参数;而在客户端,Router会使用 location API 自动确定 URL。示例
调试
服务器上的调试可以像通过inpsect调试 node.js 应用程序一样进行。
为此,请在nodejs 应用程序的webpack.config中添加以下内容:
devtool: 'source-map'
在NodemonPlugin设置中:
new NodemonPlugin({
watch: path.resolve(__dirname, './dist'),
nodeArgs: [
'--inspect'
]
})
此外,为了改善源映射的工作,您可以添加模块
npm install source-map-support --save-dev
在NodemonPlugin选项的nodeArgs中添加:'--Require =“ source-map-support / register ”'示例
Next.JS
如果您正在从头构建应用程序,我建议您关注这个框架。它是目前最流行的从头构建支持 SSR 的应用程序的解决方案。其优点之一是所有功能(构建系统、路由器)都是开箱即用的。缺点是需要重写现有应用程序,并使用Next.JS 的方法。
SEO 不仅仅与 SSR 有关!
Google 机器人 SEO 标准包含许多指标。例如渲染数据、获取首字节等等,这些只是指标的一部分!在对应用程序进行 SEO 优化时,需要尽量减少图片大小、打包、正确使用 HTML 标签和 HTML 元标签等等。
要检查您的网站是否进行了 SEO 优化,您可以使用:
lighthouse
sitechecker
pagespeed
结论
在本文中,我描述了开发 SSR 应用的主要问题,但并非全部。但本文的目的是向您展示 SSR 并没有那么糟糕。通过这种方法,我们可以生存下去,并开发出优秀的应用!祝愿所有读到最后的读者都能成功且有趣地完成项目,减少 bug,并在我们所有人的艰难时期保持健康!
文章来源:https://dev.to/alexsergey/server-side-rendering-from-zero-to-hero-2610