React SSR(Webpack+Node.js)API数据未预渲染至源码问题求助
React SSR 预渲染API数据问题解决
问题描述
基于Webpack和Node.js搭建的React SSR项目,浏览器页面展示正常,但页面源码仅包含硬编码文本,API返回的内容和数据未被预渲染到源码中,仅在客户端渲染后可见,严重影响SEO效果。
现有配置代码
Webpack配置文件
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const path = require('path'); // 补充原代码遗漏的path导入 const CommonWebpackConfig = { mode: "development", devtool: "inline-source-map", resolve: { extensions: [".tsx", ".ts", ".js"], }, module: { rules: [ { test: /\.jsx?$/, use: { loader: "babel-loader", options: { presets: [ [ "@babel/preset-env", { targets: "defaults", }, ], "@babel/preset-react", ], }, }, }, { test: /\.tsx?$/, use: "ts-loader", }, { test: /\.css$/i, use: [MiniCssExtractPlugin.loader, 'css-loader'] }, { test: /\.scss$/, use: [ MiniCssExtractPlugin.loader, "css-loader", "sass-loader" ], }, { test: /\.(jpg|jpeg|png|svg|gif|webp)$/, type: 'asset/resource' } ], }, }; module.exports = [ { ...CommonWebpackConfig, target: "node", mode: 'development', entry: path.resolve(__dirname, "./SsrApp.tsx"), output: { clean: true, publicPath: "/", path: path.resolve(__dirname, "../dist/ssr"), assetModuleFilename: 'images/[hash][ext][query]', library: { type: "commonjs2", }, globalObject: "this" }, externals: { react: "react", }, plugins: [ new MiniCssExtractPlugin({ filename: "[name].css", }), ], }, { ...CommonWebpackConfig, entry: path.resolve(__dirname, "./Hydrate.tsx"), output: { clean: true, path: path.resolve(__dirname, "../dist/hydrate"), assetModuleFilename: 'images/[hash][ext][query]' }, plugins: [ new MiniCssExtractPlugin({ filename: "[name].css", }), new HtmlWebpackPlugin({ scriptLoading: "defer", filename: 'index.html', template: './ssr/index.html' }), ], }, ];
SSR服务端文件
const express = require("express"); const fs = require("fs"); const ReactDOMServer = require("react-dom/server"); const React = require("react"); const App = require("../dist/ssr/main.js"); const app = express(); const htmlPageContent = fs.readFileSync("./dist/hydrate/index.html", "utf8"); app.use(express.static("dist/hydrate", { index: false })); app.get("*", (req, res) => { const html = ReactDOMServer.renderToString(App.SsrApp(req.url)); const finalHtml = htmlPageContent.replace( '<div id="root"></div>', `<div id="root">${html}</div>` ); res.send(finalHtml); }); app.listen(3000, () => { console.log("SSR Server is listening on port http://localhost:3000/"); });
SSRApp.tsx
import { StaticRouter } from "react-router-dom/server"; import App from "../src/App"; export const SsrApp = (pathname: string) => { return ( <StaticRouter location={pathname}> <App /> </StaticRouter> ); };
Hydrate.tsx
import * as React from "react"; import { hydrateRoot } from "react-dom/client"; import App from "../src/App"; import { BrowserRouter } from "react-router-dom"; import "../src/index.css"; import { SnackbarProvider } from "notistack"; import "animate.css"; import "../src/index.css"; import "../src/assets/template/css/styles.min.css"; hydrateRoot( document.getElementById("root") as HTMLElement, <BrowserRouter> <SnackbarProvider maxSnack={3} autoHideDuration={3000}> <App /> </SnackbarProvider> </BrowserRouter> );
问题根源
当前代码中,组件内的API请求大概率放在useEffect或类组件的componentDidMount生命周期中,而服务端渲染时不会执行这些客户端生命周期方法,导致renderToString生成的HTML是未加载数据的空状态,只有客户端hydrate后才会触发数据请求并渲染内容。
解决方案
要实现API数据的预渲染,需要在服务端渲染前主动获取数据,将数据传入组件渲染,同时把数据注入到页面供客户端复用,避免重复请求。
步骤1:给业务组件添加数据获取方法
在需要预渲染数据的组件(比如App.tsx)中添加静态方法,供服务端调用获取数据:
// src/App.tsx import { useState, useEffect } from 'react'; interface InitialProps { data?: any; } function App({ initialData }: InitialProps) { // 优先使用服务端传入的初始数据,没有则客户端请求 const [data, setData] = useState(initialData || null); useEffect(() => { if (!data) { fetch('/api/data') .then(res => res.json()) .then(setData); } }, [data]); // 服务端调用的静态数据获取方法 static async getInitialProps() { // 服务端需使用绝对路径请求API const res = await fetch('http://localhost:3000/api/data'); const data = await res.json(); return { data }; } return ( <div className="app-container"> {data ? ( <div className="content">{data.content}</div> ) : ( <div className="loading">加载中...</div> )} </div> ); } export default App;
步骤2:修改SSRApp.tsx接收初始数据
让SSR入口组件支持传入服务端获取的数据:
import { StaticRouter } from "react-router-dom/server"; import App from "../src/App"; export const SsrApp = (pathname: string, initialData: any) => { return ( <StaticRouter location={pathname}> <App initialData={initialData} /> </StaticRouter> ); };
步骤3:服务端路由中先获取数据再渲染
修改服务端代码,先匹配路由对应的组件,调用数据获取方法,再渲染组件并注入数据到页面:
const express = require("express"); const fs = require("fs"); const ReactDOMServer = require("react-dom/server"); const React = require("react"); const App = require("../dist/ssr/main.js"); const { matchRoutes } = require('react-router-dom/server'); const appRoutes = require('../src/routes'); // 需确保有共享的路由配置文件 const app = express(); const htmlPageContent = fs.readFileSync("./dist/hydrate/index.html", "utf8"); // 示例API接口 app.get('/api/data', (req, res) => { res.json({ content: "这是预渲染的API数据,会出现在页面源码中" }); }); app.use(express.static("dist/hydrate", { index: false })); app.get("*", async (req, res) => { try { // 匹配当前路由对应的组件 const matches = matchRoutes(appRoutes, req); // 批量调用组件的getInitialProps获取数据 const initialDataList = await Promise.all( matches.map(async ({ route }) => { const component = route.element.props?.children?.type; if (component?.getInitialProps) { return component.getInitialProps(); } return {}; }) ); // 合并所有组件的初始数据 const initialData = Object.assign({}, ...initialDataList); // 传入数据渲染组件 const html = ReactDOMServer.renderToString(App.SsrApp(req.url, initialData)); // 将初始数据注入到页面全局变量,供客户端复用 const finalHtml = htmlPageContent .replace('<div id="root"></div>', `<div id="root">${html}</div>`) .replace('</body>', `<script>window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}</script></body>`); res.send(finalHtml); } catch (err) { console.error("服务端渲染出错:", err); res.status(500).send("服务器内部错误"); } }); app.listen(3000, () => { console.log("SSR Server is listening on http://localhost:3000/"); });
步骤4:客户端Hydrate时复用初始数据
修改Hydrate.tsx,从全局变量中取出初始数据传给组件:
import * as React from "react"; import { hydrateRoot } from "react-dom/client"; import App from "../src/App"; import { BrowserRouter } from "react-router-dom"; import "../src/index.css"; import { SnackbarProvider } from "notistack"; import "animate.css"; import "../src/index.css"; import "../src/assets/template/css/styles.min.css"; // 声明全局变量类型 declare global { interface Window { __INITIAL_DATA__: any; } } const initialData = window.__INITIAL_DATA__; hydrateRoot( document.getElementById("root") as HTMLElement, <BrowserRouter> <SnackbarProvider maxSnack={3} autoHideDuration={3000}> <App initialData={initialData} /> </SnackbarProvider> </BrowserRouter> );
补充:共享路由配置
创建服务端和客户端共用的路由配置文件src/routes.tsx:
import { RouteObject } from 'react-router-dom'; import App from './App'; import About from './pages/About'; // 示例其他页面组件 const appRoutes: RouteObject[] = [ { path: '/', element: <App />, }, { path: '/about', element: <About />, }, ]; export default appRoutes;
关键注意事项
- 服务端请求路径:服务端没有浏览器的base URL,请求API时必须使用绝对路径。
- 错误处理:服务端数据获取过程中要捕获异常,避免请求崩溃。
- 数据序列化:注入到页面的初始数据要通过
JSON.stringify序列化,注意不能包含循环引用的对象。 - 路由匹配:必须使用服务端和客户端共用的路由配置,确保
matchRoutes能正确找到对应组件。
内容的提问来源于stack exchange,提问作者Umar Ahmad
相关产品推荐
相关产品推荐

