You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;

关键注意事项

  1. 服务端请求路径:服务端没有浏览器的base URL,请求API时必须使用绝对路径。
  2. 错误处理:服务端数据获取过程中要捕获异常,避免请求崩溃。
  3. 数据序列化:注入到页面的初始数据要通过JSON.stringify序列化,注意不能包含循环引用的对象。
  4. 路由匹配:必须使用服务端和客户端共用的路由配置,确保matchRoutes能正确找到对应组件。

内容的提问来源于stack exchange,提问作者Umar Ahmad

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 19:18:10