不使用框架的服务器端渲染和服务器端数据提取的演示 - 展示 React驱动的框架实际做什么。
在这篇文章中,我将解释如何在 React 中启用服务器端渲染和服务器端数据提取......无需使用框架!
虽然这篇文章中的代码并不是我所说的"可用于生产",但它应该有助于解释 React 的两个内置方法hydrateRoot
和renderToString
,这两个方法都是在 React 中启用服务器端渲染所必需的。
如果您有兴趣快速浏览,可以在以下 GitHub 存储库中查看本文中使用的所有代码。
- https://github.com/PaulieScanlon/simple-react-ssr-vite-express
两个小警告:
-
我不会介绍如何部署 React SSR 应用程序。
-
我将要解释的大部分内容都可以在 Vite 文档中找到:服务器端渲染[https://vitejs.dev/guide/ssr.html]。
设置并安装依赖项
您需要做的第一件事是初始化一个新的 npm 包。(-y 标志会跳过问卷调查并在创建 package.json 时使用 npm 默认值)
npm init -y
现在您可以安装依赖项。
npm install react react-dom express
最后,安装开发依赖项。
npm install vite @vitejs/plugin-react -D
将脚本添加到 package.json
您需要添加五个脚本。一个用于开发,其余四个用于创建生产版本,还有一个服务脚本,以便您可以在浏览器中预览生产版本。
// package.json
"scripts": {
"dev": "node server-dev.js",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.jsx --outDir dist/server",
"build": "npm run build:client && npm run build:server",
"serve": "node server-prod.js",
...
},
-
dev。该脚本启动 Vite 开发服务器。
-
build:client。此脚本捆绑了 index.html 和 entry-client.jsx。
-
build:server。此脚本捆绑了 entry-server.jsx。
-
build。此脚本运行上述两个"build:"脚本。
-
服务:此脚本运行 server-prod.js(我稍后会解释这是什么)>
将 type:module 添加到 package.json
Vite 的开发服务器使用原生 ES 模块,因此您需要将"type" : "module" 添加到您的 package.json。如果不这样做,您可能会看到与以下内容相关的错误:无法在模块外使用 import 语句。
// package.json
{
"name": "...",
"type": "module",
"scripts": {
...
},
}
创建 src 文件
index.html
在项目的根目录下创建一个名为 index.html 的文件。它充当应用程序的"模板"。此文件中有两点需要注意。
-
"app" 的 div id 是 React 在调用 hydrateRoot 时使用的目标 DOM 节点。
-
<!--outlet--> 的注释被服务器用 React 的 renderToString 函数的结果替换。
//index.html
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Simple React SSR Vite Express</title>
</head>
<body>
<div id='app'><!--outlet--></div>
<script type='module' src='/src/entry-client.jsx'></script>
</body>
</html>
app.jsx
在项目根目录创建一个 src 目录,然后创建一个名为 app.jsx 的文件。
这是一个简单的函数组件,它返回一些要由浏览器呈现的基本 HTML。该组件使用导出默认语法。
// src/app.jsx
import { useState } from 'react';
const App = () => {
const [count, setCount] = useState(0);
return (
<main>
<h1>App</h1>
<p>Lorem Ipsum</p>
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>Count</button>
</div>
</main>
);
};
export default App
entry-client.jsx
在 src 目录中创建一个名为 entry-client.jsx 的文件。此文件负责显示 id 为 app 的 div 中的 <App /> 组件。
您可以在此处的 React 文档中阅读有关 hydrateRoot 的更多信息:hydrateRoot。
//src/entry-client.jsx
import { hydrateRoot } from 'react-dom/client';
import App from './app';
hydrateRoot(document.getElementById('app'), <App />);
entry-server.jsx
在 src 目录中创建一个名为 entry-server.jsx 的文件。此文件负责将 <App /> 组件"转换"为适合在浏览器中使用的纯 HTML 字符串。
你可以在 React 文档中阅读有关 renderToString 的更多信息:renderToString。此文件导出一个名为 render 的命名函数。
//src/entry-server.jsx
import { renderToString } from 'react-dom/server';
import App from './app';
export const render = () => {
return renderToString(<App />);
};
Vite 配置
在项目根目录创建一个名为 vite.config.js 的文件并添加以下代码片段。
//vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});
创建开发服务器
在项目根目录中创建一个名为 server-dev.js 的文件。这是运行 npm run dev 时启动的服务器。
//server-dev.js
import fs from 'fs';
import express from 'express';
import { createServer } from 'vite';
const app = express();
const vite = await createServer({
server: {
middlewareMode: true,
},
appType: 'custom',
});
app.use(vite.middlewares);
app.use('*', async (req, res) => {
const url = req.originalUrl;
try {
const template = await vite.transformIndexHtml(url, fs.readFileSync('index.html', 'utf-8'));
const { render } = await vite.ssrLoadModule('/src/entry-server.jsx');
const html = template.replace(`<!--outlet-->`, render);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (error) {
res.status(500).end(error);
}
});
app.listen(4173, () => {
console.log('http://localhost:4173.');
});
应用程序=快递()
正如您所期望的,这是一个 Express 的实例,通常将其定义为名为 app 的 const。
创建服务器
这将创建一个 Vite 开发服务器,需要额外的配置以便 Vite 知道将控制权移交给 express。
应用程序.使用(vite.中间件)
这可确保任何表达请求都能传回 Vite 开发服务器。
应用程序.使用('*')
express 应用程序处理所有传入的请求,每个请求的 url 都可以从 req 对象中提取,并且在 Vite 转换 index.html 时需要它。
模板
模板是页面的起点。它由服务器上的 render 函数使用 app.jsx 中的 HTML 填充,然后使用 app.jsx 中的相同 HTML 在浏览器中再次填充(或水化)。
{ 使成为 }
如上所述,该函数负责将 React 代码"转换"为纯 HTML 字符串。
html
这就是所有内容整合在一起的地方。使用 .replace,您可以定位 index.html 并将其替换为渲染函数的返回值。
.结束(html)
使用标准的 .end() Express 方法您可以返回状态 200,设置内容类型,然后传入 HTML 以在浏览器中显示。
这些是在服务器上渲染 React 的基本原理;但由于 Vite 是一个开发工具,您必须进行一些更改才能创建一个部署后可以运行的 Express 服务器。
创建生产服务器
在项目根目录中创建一个名为 server-prod.js 的文件。这是运行 npm run serve 时启动的服务器。生产服务器与开发服务器非常相似,但也有一些明显的区别。
//server-prod.js
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import express from 'express';
const app = express();
app.use(express.static(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'dist/client'), { index: false }));
app.use('*', async (_, res) => {
try {
const template = fs.readFileSync('./dist/client/index.html', 'utf-8');
const { render } = await import('./dist/server/entry-server.js');
const html = template.replace(`<!--outlet-->`, render);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (error) {
res.status(500).end(error);
}
});
app.listen(5173, () => {
console.log('http://localhost:5173.');
});
没有 Vite
Vite 仅在开发模式下使用。当您运行 npm run build 时,Vite 会在后台使用 Rollup 编译所有必需的文件并将其输出到 npm run build 目录。因此,在生产环境中无需使用 Vite 的 createServer。
express.static
express.static() 函数是内置中间件,可用于提供 React 在浏览器中运行所需的静态文件(HTML、.js)。
模板和 { render }
这些与开发服务器大致相同,但 Vite 特定方法(transformIndexHtml 和 ssrLoadModule)已被删除。路径现在指向 ./dist 目录,而不是 src 文件。
这就是这篇文章的第一部分,但还缺少一部分......
服务器端数据获取
具有服务器端渲染功能的 React 应用还可以利用服务器端日期获取功能。这样做有两个好处。
-
服务器端请求可用于与数据库建立安全连接(例如)。
-
即使在禁用 JavaScript 时或在发生水合之前,来自服务器端请求的数据仍将显示在浏览器中。
为了使其正常工作,需要进行相当多的更改,我会解释每个更改是什么;但是,如果您希望在 GitHub 上看到它们,我已经在以下链接上准备了一个包含所有更改的拉取请求。
package.json
添加一个名为"build:function"的新脚本,并将其指向新的 function.js 文件(您将在下一步创建该文件)。然后修改构建脚本以包含 && npm run build:function。
//package.json
"scripts": {
...
+ "build:function": "vite build --ssr src/function.js --outDir dist/function",
- "build": "npm run build:client && npm run build:server",
+ "build": "npm run build:client && npm run build:server && npm run build:function",
...
}
function.js
在 src 目录中创建一个名为 function.js 的文件。此文件包含一个名为 getServerData 的异步函数,该函数将从服务器调用。
//src/function.js
export const getServerData = async () => {
const response = await fetch('https://dummyjson.com/products/1');
const data = await response.json();
return data;
};
serve-dev.js
此处的更改涉及导入新的 function.js 文件、调用 getServerData 异步函数,然后将数据传回渲染函数。还需要创建一个脚本元素,该元素将使用新获取的服务器数据填充 window.data。将数据添加到窗口将允许 React 在刷新页面时访问与服务器相同的数据。
//server-dev.js
app.use('*', async (req, res) => {
const url = req.originalUrl;
try {
const template = await vite.transformIndexHtml(url, fs.readFileSync('index.html', 'utf-8'));
const { render } = await vite.ssrLoadModule('/src/entry-server.jsx');
+ const { getServerData } = await vite.ssrLoadModule('/src/function.js');
+ const data = await getServerData();
+ const script = `<script>window.__data__=${JSON.stringify(data)}</script>`;
- const html = template.replace(`<!--ssr-outlet-->`, render);
+ const html = template.replace(`<!--outlet-->`, `${render(data)} ${script}`);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (error) {
res.status(500).end(error);
}
});
serve-prod.js
这里的更改与对开发服务器所做的更改几乎相同,除了可以找到 function.js 的路径之外。
app.use('*', async (_, res) => {
try {
const template = fs.readFileSync('./dist/client/index.html', 'utf-8');
const { render } = await import('./dist/server/entry-server.js');
+ const { getServerData } = await import('./dist/function/function.js');
+ const data = await getServerData();
+ const script = `<script>window.__data__=${JSON.stringify(data)}</script>`;
- const html = template.replace(`<!--outlet-->`, render);
+ const html = template.replace(`<!--outlet-->`, `${render(data)} ${script}`);
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (error) {
res.status(500).end(error);
}
});
entry-client.jsx
此处的更改是定义一个名为 data 的新变量,然后将其设置为等于 window.data 的值。现在 data 变量包含服务器端请求的数据,可以通过名为 data 的 prop 将其传递给 <App /> 组件。
//entry-client.jsx
import { hydrateRoot } from 'react-dom/client';
import App from './app';
+ let data;
+ if (typeof window !== 'undefined') {
+ data = window.__data__;
+ }
- hydrateRoot(document.getElementById('app'), <App />);
+ hydrateRoot(document.getElementById('app'), <App data={data} />);
entry-server.jsx
entry-server.jsx 的情况类似;但这一次,您无需从窗口对象中获取数据,而是可以在使用 data 参数调用渲染函数时访问从服务器传递的数据。然后可以使用相同的方法通过名为 data 的 prop 将数据传递给 <App /> 组件。
//entry-server.jsx
import { renderToString } from 'react-dom/server';
import App from './app';
- export const render = () => {
+ export const render = (data) => {
- return renderToString(<App />);
+ return renderToString(<App data={data} />);
};
app.jsx
最后要做的更改是解构新的数据道具并将其返回到 HTML <pre> 元素中,以便它在页面上可见。
//src/app.jsx
import { useState } from 'react';
- const App = () => {
+ const App = ({ data }) => {
const [count, setCount] = useState(0);
return (
<main>
...
+ <pre>{JSON.stringify(data, null, 2)}</pre>
</main>
);
};
export default App;
总结
就这样,您无需使用框架即可实现服务器端渲染和服务器端数据提取!
我从 2017 年左右开始使用 React,但从未真正理解过 hydrateRoot 或 renderToString,但通过这个示例项目,我现在对 React 的实际工作原理有了更好的理解,并且更加欣赏 React 驱动的框架的实际作用。
Vite 也给我留下了深刻的印象------从文档到开发者体验,一切都是一流的。另外,如果您错过了,Remix 刚刚宣布了他们的新 Vite 插件;如果 Remix 团队正在使用 Vite,这是一个很好的迹象,表明 Vite 的每一部分都和它看起来一样好!