实现 Express+React 的服务端渲染 (ssr)

时之世 发布于 2024-09-16 799 次阅读 预计阅读时间: 7 分钟 最后更新于 2024-12-16 1453 字 无~


AI 摘要

Express + React 的服务端渲染是通过 Express 建立路由,将打包后的 React 文件转换为字符串后返回给用户。在这个过程中,Express 可以通过更改页面字符串来注入一些信息。 React 提供了 renderToString 和 hydrateRoot 等 api 来帮助服务端渲染。需要在项目中创建 server 文件夹,然后新建 index.js 文件,并进行一些额外的打包文件配置以支持在 Node 环境中引入 React 格式。配置文件包括 package.json 、.babelrc 和 webpack.config.js 等。

一、 Express + React 的服务端渲染

实质上是通过 Express 建立路由,当用户发起请求时,Express 会把打包后的 React 文件 (如 build/index.html) 转换为字符串,然后将这个页面 (字符串形式) 发回用户。

在此过程中,Express 能通过更改页面 (字符串) 来注入一些信息。

React 有 renderToString 和 hydrateRoot 等 api 来帮助服务端渲染。

二、创建项目

首先,需要新建一个 React 项目,这里不做赘述

接着,转到项目中创建 server 文件夹,在文件夹中新建 index.js

具体项目结构如下 (当然,你也可以不在 src 内创建 server)

↑这里的 webpack.config.js 是用于打包的,上面的.babelrc 也是用于打包的

为什么需要这些额外的打包文件?

虽然使用 create-react-app 创建的 react 项目拥有自己的打包命令,但是,我们创建了 express 项目,并在 express 的文件中引入了 React 的格式

const LimitPageString = renderToString(<Limit/>);

Express 和 React 使用两种不同的格式 「module」 和 「commonjs」,因为项目的运行环境是 NodeJs,NodeJS  中,目前有两种标准的模块引入模式,一种是旧的 CommonJS(CJS),另外一种是现代的 ESModule(ESM) 。

有的时候,我们不得不混用这两种引入模式 (一些第三方库仅支持 ESM),这时候就会产生一些坑,比如如果尝试 require(CJS) 一个 ESM 文件时,就会报错。

//可能会出现
const App = require('./build/index').default; // 引入你的 React 应用 Error: Cannot find module '../build/index'
//或
const LimitPageString = renderToString(<Limit/>); 在 「<」 这里报错
//等各种各样的问题

所以我们需要将 React 打包成 commonjs 格式

三、 package.json 配置

主要是框出来的,nodemon 是为了方便查看运行状态,并加入代码热更新 (文件改变时,项目会自动重启),你也可以用 「node」 这个 node 自带的命令

接下来导入一些包

  • 首先是 antd 的 pro-comment 的 (假如你用了的情况下),由于在 24 年 8 月左右,这个包的 loah 改为 loah-es,虽然换了个轻量的包 (loah-es),但是这个包的格式由 commonjs 变为 module,但是 pro-comment 仍然使用 commonjs,所以会使 nodejs 报错,所以需要让 pro-comment 退回"2.7.12"版本
  • 接着导入一些打包时需要的包
//首先卸载,避免一些版本冲突
npm uninstall @babel/cli @babel/core @babel/preset-env @babel/preset-react babel-cli
//然后再次下载
npm install --save-dev @babel/cli @babel/core @babel/preset-env @babel/preset-react

四、配置.babelrc 和 webpack.config.js

// .babelrc
{
    // 预设配置数组,用于指定 Babel 应使用的预设
    "presets": [
        // 使用 @babel/preset-env 预设,以便于编写符合 ES6+标准的代码,同时保证向后兼容
        "@babel/preset-env",
        // 使用 @babel/preset-react 预设,用于转换 React 特有的 JSX 语法,使之能在不支持该语法的环境中运行
        "@babel/preset-react"
    ],
    // 插件配置数组,目前未指定任何插件
    "plugins": [
    ],
    // 禁止 Babel 输出紧凑的代码,设置为 false 后将生成更易读的代码格式,便于调试
    "compact": false
}
// webpack.config.js(别忘了下载你没有的包)
const path = require('path');

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const WebpackBar = require('webpackbar');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TranspilePlugin = require('transpile-webpack-plugin');
const IS_PROD = process.env.NODE_ENV;

const config = {
    // Start mode / environment
    mode: IS_PROD ? 'production' : 'development',

    // Entry files
    entry: path.resolve(__dirname, 'src/index'),

    // Output files and chunks
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name]-[chunkhash:8].js',
    },

    // Resolve files configuration
    resolve: {
        extensions: ['.js', '.jsx', '.ts', '.tsx', '.json', '.scss'],
    },

    // Module/Loaders configuration
    module: {
        rules: [
            {
                test: /\.(js|jsx|ts|tsx)$/,
                exclude: /node_modules/,
                use: 'babel-loader',//用.babelrc 文件来转换格式
            },
            {
                test: /\.css$/,
                use: [MiniCssExtractPlugin.loader, 'css-loader'],
            },
            {
                test: /\.module.(sass|scss)$/,
                use: [
                    MiniCssExtractPlugin.loader,
                    {
                        loader: 'css-loader',
                        options: {
                            modules: {
                                localIdentName: '[path][name]__[local]--[hash:base64:5]',
                            },
                            sourceMap: true,
                        },
                    },
                    'sass-loader',
                ],
            },
            {
                //svg
                test: /\.svg$/,
                use: [
                    {
                        loader: '@svgr/webpack',
                        options: {
                            babel: false,
                        },
                    },
                ],
            }
        ],
    },

    // Plugins
    plugins: [
        new WebpackBar(),
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            filename: 'index.html',
            template: path.resolve(__dirname, 'public/index.html'),
            minify: IS_PROD,
        }),
        new MiniCssExtractPlugin({
            filename: 'styles-[chunkhash:8].css',
        }),
    ],

    // Webpack chunks optimization
    optimization: {
        splitChunks: {
            cacheGroups: {
                default: false,
                venders: false,

                vendor: {
                    chunks: 'all',
                    name: 'vender',
                    test: /node_modules/,
                },

                styles: {
                    name: 'styles',
                    type: 'css/mini-extract',
                    chunks: 'all',
                    enforce: true,
                },
            },
        },
    },

    // DevServer for development
    devServer: {
        port: 3000,
        historyApiFallback: true,
    },

    // Generate source map
    devtool: 'source-map',
};

module.exports = config;

五、配置 express

编辑 server/index.js

const path = require('path');

// ignore `.scss` imports
require('ignore-styles');

// 将引入的包转换格式
require('@babel/register')({
    configFile: path.resolve(__dirname, '../../.babelrc'),
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
});
// 引入具体路由设置
require('./express.js');

编辑 express.js

const fs = require('fs');
const path = require('path');
const express = require('express');
const { renderToString } = require('react-dom/server');
const { get } = require('../util/request');
const React = require('react');
const {Application} = require('../App');
const ApplicationString= renderToString(<Application/>);
// create express application
const app = express();
const indexHTML = fs.readFileSync(
    path.resolve(__dirname, '../../dist/index.html'),
    { encoding: 'utf8' }
);
const appHtml = async (req, res) => {
    // set header and status
    res.contentType('text/html');
    res.status(200);
    const initialData = JSON.parse(await getInitialData());

    //第一种插入数据 (替换 String)
    return res.send(indexHTML
        .replace(`<script>window.__LINK_ID__ = ''</script>`, `<script>window.__LINK_ID__ = "${initialData.linkId}"</script>`)
        .replace(`<script>window.__LINK_KEY__ = ''</script>`, `<script>window.__LINK_KEY__ = "${initialData.linkKey}"</script>`));
}
    //第二种插入界面
    return res.send(indexHTML.replace(<div id="root"></div>,ApplicationString));


// serve static assets
app.get(
    /\.(js|css|map|ico)$/,
    express.static(path.resolve(__dirname, '../../dist'))
);

// for any other requests, send `index.html` as a response
app.use('/*',(req, res) => {

    appHtml(req, res);
});

// run express server on port 9000
app.listen(9000, () => {
    console.log('Express server started at http://localhost:9000');
});

六、配置 React 的 index.js

import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import ReactDOM from 'react-dom/client';
// 获取需要预先存储的内容
import Application from './App'
// 获取服务器传递的初始数据
const linkId = window.__LINK_ID__;
const linkKey = window.__LINK_KEY__;
let initialData = {
    linkId: linkId,
    linkKey: linkKey
}; // 这个数据应该是从服务器端传递过来的
initialData = JSON.stringify(initialData);
const isLimit = window.__IS_LIMIT__;

delete window.__LINK_ID__; // 清除这个全局变量
delete window.__LINK_KEY__;

// 开始渲染应用
hydrateRoot(document.getElementById('root'), <Application initialData={initialData} isLimit={isLimit} />);

七、启动项目

先使用 「npm run webpack:build」 打包,然后使用 " npm run ssr"启动项目