任何FAAS提供商上任何React应用程序的服务器端渲染
#javascript #网络开发人员 #react #netlify

通过此文档,我想介绍一种通用方法来为任何FAAS提供商上的任何React应用程序设置服务器端渲染(SSR)。 “ React App”是一个Web应用程序,其客户端(或前端)用React构建。 “ FAAS提供商”是一个无服务器计算平台,例如AWS Lambda。为了清楚地说明,可运行的演示应用程序是在低于下一步的一步之下构建的。我会指导您完成这些步骤,然后总结一下想法。

认为演示应用程序应该是实用的,但不会被细节所掩盖,其React客户端将由常见的功能(例如样式,路由,数据获取和资产加载)构建,但成本有限。同时,它将部署在具有简单设置的广泛接受的FAA提供商中。因此,我将使用create-react-app(CRA)初始化演示应用程序,增强它,然后将其部署在Netlify上。

演示应用程序

在没有SSR的情况下构建React客户端

首先,让我使用CRA初始化演示应用的React客户端。由于today's前端开发中通常使用Typescript,因此这里使用了--template typescript

$ npx create-react-app the-demo-app --template typescript
# ...
$ cd the-demo-app

使用的CRA版本是5.0.1,生成的目录结构如下:

$ tree -I node_modules
.
├── README.md
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.css
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── logo.svg
│   ├── react-app-env.d.ts
│   ├── reportWebVitals.ts
│   └── setupTests.ts
└── tsconfig.json

2 directories, 19 files

在生成的文件中,src/App.css正在导入并使用src/App.csssrc/logo.svg,这意味着样式和资产加载的功能:

// src/App.tsx
import './App.css';
import logo from './logo.svg';

function App() {
  return (
    <div className="App">
      ...
      <img src={logo} className="App-logo" alt="logo" />
      ...
    </div>
  );
}

export default App;

现在,为了确保设置常见的功能,还将包括路由和数据获取。对于路由,将使用react-router-domde facto路由在react中使用:

$ npm i react-router-dom

要以最低成本,2页和可以切换它们的路由逻辑进行操作。 src/App.tsx的内容可以用路由逻辑替换,而旧内容被移至src/pages/Home/Page.tsx作为一个需要的页面。然后,可以添加一个未创建的页面src/pages/NotFound/Page.tsx作为另一个需要的页面。当访问/路径时,以前的页面将显示。当访问任何其他路径时,后一页将显示:

// src/App.tsx
import { FC } from 'react';
import { Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/Home/Page';
import { NotFoundPage } from './pages/NotFound/Page';

export const App: FC = () => {
  return (
    <Routes>
      <Route path="/" element={<HomePage />} />
      <Route path="*" element={<NotFoundPage />} />
    </Routes>
  );
};
// src/App.test.tsx
import { render, screen } from '@testing-library/react';
import { StaticRouter } from 'react-router-dom/server';
import { App } from './App';

jest.mock('./pages/Home/Page', () => ({
  HomePage: () => 'Home Page',
}));

jest.mock('./pages/NotFound/Page', () => ({
  NotFoundPage: () => 'Not Found Page',
}));

for (const [path, page] of Object.entries({
  '/': 'Home Page',
  '/somewhere-else': 'Not Found Page',
})) {
  test(`renders "${page}" if "${path}" is visited`, () => {
    render(
      <StaticRouter location={path}>
        <App />
      </StaticRouter>
    );
    expect(screen.getByText(page)).toBeInTheDocument();
  });
}
// src/pages/Home/Page.tsx
import { FC } from 'react';
import logo from '../../logo.svg';
import styles from './Page.module.css';

export const HomePage: FC = () => {
  return (
    <div className={styles.root}>
      <header className={styles.header}>
        <img src={logo} className={styles.logo} alt="logo" />
        <p>
          Edit <code>src/**/*.tsx</code> and save to reload.
        </p>
        <a
          className={styles.link}
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
};
/* src/pages/Home/Page.module.css */
.root {
  text-align: center;
}

.logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .logo {
    animation: logo-spin infinite 20s linear;
  }
}

.header {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.link {
  color: #61dafb;
}

@keyframes logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
// src/pages/NotFound/Page.tsx
import { FC } from 'react';
import styles from './Page.module.css';

export const NotFoundPage: FC = () => {
  return (
    <div className={styles.root}>
      <h1>Not Found</h1>
    </div>
  );
};
/* src/pages/NotFound/Page.tsx */
.root {
  text-align: center;
}

之后,为了使路由逻辑生效,<BrowserRouter>src/index.tsx中应用:

// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

用于数据获取,另外2个libs,@tanstack/react-query(其旧的软件包名称为react-query)和axios

$ npm i @tanstack/react-query axios

数据获取逻辑是,Home/Page.tsx调用查询函数,以获取github repo的星数,然后呈现结果:

// src/pages/Home/Page.tsx
-import { FC } from 'react';
+import { FC, useMemo } from 'react';
import logo from '../../logo.svg';
+import { ParamsOfGetRepoStarCount, useGhRepoStarCountQuery } from '../../queries/gh';
import styles from './Page.module.css';

+export const paramOfGhRepoStarCountQuery: ParamsOfGetRepoStarCount = {
+  userName: 'facebook',
+  repoName: 'react',
+};

export const HomePage: FC = () => {
+  const numberFormat = useMemo(() => new Intl.NumberFormat(), []);
+  const { isLoading, isSuccess, data } = useGhRepoStarCountQuery(paramOfGhRepoStarCountQuery);
+
  return (
    <div className={styles.root}>
      <header className={styles.header}>
        <img src={logo} className={styles.logo} alt="logo" />
        <p>
          Edit <code>src/**/*.tsx</code> and save to reload.
        </p>
        <a
          className={styles.link}
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
-          Learn React
+          Learn React (⭐️ = {isLoading && 'loading...'}
+          {isSuccess && numberFormat.format(data.result)})
        </a>
      </header>
    </div>
  );
};

这是src/queries/gh.ts中的查询函数:

// src/queries/gh.ts
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

export type ParamsOfGetRepoStarCount = Partial<{ userName: string; repoName: string }>;

export type ReturnOfGetRepoStarCount = { result: number };

export function getKeyOfGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return ['ghRepoStarCountQuery', params];
}

export function useGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return useQuery(getKeyOfGhRepoStarCountQuery(params), async () => {
    const { data: repoInfo } = await axios.get(
      `https://api.github.com/repos/${params.userName}/${params.repoName}`
    );
    return { result: repoInfo.stargazers_count } as ReturnOfGetRepoStarCount;
  });
}

以及其基础设置:

// src/queries/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export function createQueryClient(): QueryClient {
  return new QueryClient();
}
// src/index.tsx
+ import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
+import { createQueryClient } from './queries/queryClient';
import reportWebVitals from './reportWebVitals';

+const queryClient = createQueryClient();
+
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
-    <BrowserRouter>
-      <App />
-    </BrowserRouter>
+    <QueryClientProvider client={queryClient}>
+      <BrowserRouter>
+        <App />
+      </BrowserRouter>
+    </QueryClientProvider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

到目前为止,构建了具有React应用程序常见功能的演示应用的客户端。您可以通过从package.json运行scripts进行检查。

// package.json
  ...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
  ...

这些命令是用于预览的npm start,用于构建的npm run buildnpm test用于单位测试。在某些版本的CRA中,具有以上功能,页面无法自动在预览中进行编辑。如果是您的情况,设置FAST_REFRESH=false将有所帮助:

$ npm i cross-env
// package.json
  ...
  "scripts": {
-    "start": "react-scripts start",
+    "start": "cross-env FAST_REFRESH=false react-scripts start",
  ...

添加SSR并将应用程序部署在NetLify上

在潜入SSR和Netlify的部署时,让我们看一下React应用程序中SSR的基本逻辑如何工作:

  1. 当页面的请求到达时,应用程序的服务器端准备了请求页面将在客户端端请求的数据。然后,将数据与根客户端反应组件一起用于生成所请求的页面的服务器端渲染的HTML。随着数据序列化,服务器端将其与服务器端渲染的HTML一起插入顶级HTML,以使最终的HTML作为响应。
  2. 当请求页面的响应到达时,应用程序的客户端可以从响应中对序列化数据进行序列化,然后将其与root Client Client-side React组件一起使用,以将服务器端渲染的HTML从响应中进行水合以初始化React客户端。

因此,首先,应设置能够导入和使用src/App.tsx的服务器端(这里是root客户端react组件)。同样,由于NetLify函数主要由NetLify上的服务器端逻辑进行,因此应遵循convention of registering functions by files paths

要在NetLify函数中使用类型,安装了@netlify/functions

$ npm i @netlify/functions

然后,在src/server/functions/render_pages.tsx中创建了SSR的NetLify函数,现在返回了一个占位符字符串:

// src/server/functions/render_pages.tsx
import { Handler } from '@netlify/functions';

export const handler: Handler = async (event, context) => {
  return { statusCode: 200, body: 'It works!' };
};

和客户端WebPack配置与创建服务器端WebPack配置一样多。调整了用于转移NetLify函数文件以及其导入的文件,保留目录结构并停止资产发射的文件。服务器端WebPack配置是在webpack.server.config.js中创建的:

// webpack.server.config.js
const glob = require('glob');
const { set } = require('lodash');
const TranspilePlugin = require('transpile-webpack-plugin');
const nodeExternals = require('webpack-node-externals');

const envInQuestion = process.env.NODE_ENV ?? 'development';
const shouldPrintConfig = Boolean(process.env.PRINT_CONFIG);

process.env.NODE_ENV = envInQuestion;
process.env.FAST_REFRESH = 'false';
const webpackConfig = require('react-scripts/config/webpack.config')(envInQuestion);

webpackConfig.entry = () => glob.sync('src/server/**/*', { nodir: true, absolute: true });
webpackConfig.target = 'node';
webpackConfig.externals = [nodeExternals()];

removeAssetsEmitting();
removeUnusedPluginsAndOptimizers();

webpackConfig.plugins.push(
  new TranspilePlugin({
    longestCommonDir: __dirname + '/src',
    extentionMapping: { '.ts': '.js', '.tsx': '.js' },
  })
);

if (shouldPrintConfig) {
  console.dir(webpackConfig, { depth: Infinity });
}

function removeAssetsEmitting() {
  webpackConfig.module.rules.forEach(({ oneOf }) => {
    oneOf?.forEach((rule) => {
      if (rule.type?.startsWith('asset')) {
        set(rule, 'generator.emit', false);
      }

      const fileLoaderUseItem = rule.use?.find(({ loader }) => loader?.includes('file-loader'));
      if (fileLoaderUseItem) {
        set(fileLoaderUseItem, 'options.emitFile', false);
      }

      const cssLoaderUseItemIndex = rule.use?.findIndex(({ loader }) =>
        loader?.includes('css-loader')
      );
      if (cssLoaderUseItemIndex >= 0) {
        const cssLoaderOptionModules = rule.use[cssLoaderUseItemIndex].options?.modules;
        if (cssLoaderOptionModules) {
          cssLoaderOptionModules.exportOnlyLocals = true;
        }
        rule.use = rule.use.slice(cssLoaderUseItemIndex);
      }
    });
  });
}

function removeUnusedPluginsAndOptimizers() {
  webpackConfig.plugins = webpackConfig.plugins.filter((p) => {
    const ctorName = p.constructor.name;
    if (ctorName.includes('Html')) return false;
    if (ctorName.includes('Css')) return false;
    if (ctorName === 'WebpackManifestPlugin') return false;
    if (ctorName === 'DefinePlugin') return false;
    return true;
  });

  webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.filter((m) => {
    const ctorName = m.constructor.name;
    if (ctorName.includes('Css')) return false;
    return true;
  });
}

module.exports = webpackConfig;

另外,需要安装webpack.server.config.js中使用的软件包:

$ npm i webpack glob lodash transpile-webpack-plugin webpack-node-externals

WebPack插件koude28直接或间接收集条目导入的文件,然后将其编译并输出保存目录结构。 Webpack助手koude29将安装的DEP外部化以减少输出的文件计数。

之后,要运行WebPack,需要WebPack CLI:

$ npm i webpack-cli npm-run-all

需要从package.json运行多个scripts,需要npm-run-all

$ npm i npm-run-all

要启动整个演示应用程序的预览,包括客户端和服务器端,需要Netlify CLI,但在全球安装它已经足够好:

$ npm i -g netlify-cli

然后,package.json中的scripts被扩展并创建了netlify.toml

// package.json
...
  "scripts": {
-    "start": "cross-env FAST_REFRESH=false react-scripts start",
+    "start:client": "cross-env BROWSER=none FAST_REFRESH=false react-scripts start",
+    "start:server": "cross-env BUILD_PATH=server webpack -w -c webpack.server.config.js",
+    "start-all": "run-p start:*",
+    "dev": "netlify dev",
...
# netlify.toml
[functions]
directory = "server/server/functions"


[dev]
port = 8888
command = "npm run start-all"
targetPort = 3000

现在,在本地计算机上执行命令npm run dev启动了预览,该预览启动了port 3000上的客户端dev服务器,在观察模式下的服务器端转卸式和port 8888上的服务器端dev服务器,并带有port 8888, URL http://127.0.0.1:8888/在默认浏览器上打开。 Home/Page.tsx在访问的小径上渲染。 NotFound/Page.tsx/zxcv一样呈现在路径上。 It works!的内容呈现在访问的/.netlify/functions/render_pages的路径上。制作了演示应用程序服务器端的初始设置,并且演示应用程序最初可以整体预览。

请注意,预览会生成2个目录.netlifyserver,这应该由版本控件未跟踪。在执行的命令netlify dev上,.netlify自动添加到.gitignore中。和server需要手动添加到.gitignore

# .gitignore
...
# production
/build
+/server
...

认为,在现实世界的Web应用程序中,客户端通常会获取在其自己的服务器端内置的HTTP端点。为了密切模仿,要添加另一个NetLify函数,以返回GitHub存储库的星数,并且客户端查询函数是从中获取数据,而不是直接从GitHub端点获取数据。因此,新的NetLify函数是在src/server/functions/gh_repo_star_count.ts中创建的,其数据访问逻辑和类型被提取到src/server/gh.ts中,该函数由客户端和SSR重复使用:

// src/server/functions/gh_repo_star_count.ts
import { Handler } from '@netlify/functions';
import { getGhRepoStarCount } from '../gh/repo';

export const handler: Handler = async (event, context) => {
  return {
    statusCode: 200,
    body: JSON.stringify(await getGhRepoStarCount(event.queryStringParameters ?? {})),
  };
};
// src/server/gh/repo.ts
import axios from 'axios';

export type ParamsOfGetRepoStarCount = Partial<{ userName: string; repoName: string }>;

export type ReturnOfGetRepoStarCount = { result: number };

export async function getGhRepoStarCount(
  params: ParamsOfGetRepoStarCount
): Promise<ReturnOfGetRepoStarCount> {
  if (!params.userName || !params.repoName) throw new Error('Bad params');
  const { data: repoInfo } = await axios.get(
    `https://api.github.com/repos/${params.userName}/${params.repoName}`
  );
  return { result: repoInfo.stargazers_count };
}
// src/queries/gh.ts
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
-
-export type ParamsOfGetRepoStarCount = Partial<{ userName: string; repoName: string }>;
-
-export type ReturnOfGetRepoStarCount = { result: number };
+import type { ParamsOfGetRepoStarCount, ReturnOfGetRepoStarCount } from '../server/gh/repo';

export function getKeyOfGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return ['ghRepoStarCountQuery', params];
}

export function useGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return useQuery(getKeyOfGhRepoStarCountQuery(params), async () => {
-    const { data: repoInfo } = await axios.get(
-      `https://api.github.com/repos/${params.userName}/${params.repoName}`
-    );
-    return { result: repoInfo.stargazers_count } as ReturnOfGetRepoStarCount;
+    const { data } = await axios.get(
+      '/.netlify/functions/gh_repo_star_count?' + new URLSearchParams(params).toString()
+    );
+    return data as ReturnOfGetRepoStarCount;
  });
}
// src/pages/Home/Page.tsx
import { FC, useMemo } from 'react';
import logo from '../../logo.svg';
-import { ParamsOfGetRepoStarCount, useGhRepoStarCountQuery } from '../../queries/gh';
+import { useGhRepoStarCountQuery } from '../../queries/gh';
+import type { ParamsOfGetRepoStarCount } from '../../server/gh/repo';
import styles from './Page.module.css';
...

有时,由于速率限制,GITHUB端点可能会失败。如果是您的情况,设置后备值或引入缓存将有所帮助:

// src/server/gh/repo.ts
...
+const fallbackResultOfGetRepoStarCount = 12345;
+
export async function getGhRepoStarCount(
  params: ParamsOfGetRepoStarCount
): Promise<ReturnOfGetRepoStarCount> {
  if (!params.userName || !params.repoName) throw new Error('Bad params');
-  const { data: repoInfo } = await axios.get(
-    `https://api.github.com/repos/${params.userName}/${params.repoName}`
-  );
-  return { result: repoInfo.stargazers_count };
+  try {
+    const { data: repoInfo } = await axios.get(
+      `https://api.github.com/repos/${params.userName}/${params.repoName}`
+    );
+    return { result: repoInfo.stargazers_count };
+  } catch {
+    return { result: fallbackResultOfGetRepoStarCount };
+  }
}

此外,如果需要使用客户端DEV服务器调试,这是一个可选的补丁程序。在package.json中声明proxy字段指向http://127.0.0.1:8888可以帮助http://127.0.0.1:3000中的页面获取服务器端端点:

// package.json
...
+  "proxy": "http://127.0.0.1:8888",
...

接下来,是时候做SSR了。仅应处理页面的请求,但是受到可用的routing选项的限制,可能需要将所有页面的请求重定向到functions/render_pages.tsx

# netlify.toml
[functions]
directory = "server/server/functions"


+[[redirects]]
+from = "/"
+to = "/.netlify/functions/render_pages"
+status = 200
+force = true
+
+[[redirects]]
+from = "/*"
+to = "/.netlify/functions/render_pages"
+status = 200
+
+
[dev]
port = 8888
command = "npm run start-all"
targetPort = 3000
+
+
+[context.dev.environment]
+CLIENT_DEV_ORIGIN = "http://127.0.0.1:3000"

在这里,环境变量CLIENT_DEV_ORIGIN被注入预览,以便公开客户端端DEV服务器的存在和URL。

CLIENT_DEV_ORIGIN上呈现时,当对functions/render_pages.tsx的获取请求以其路径以非空文件扩展为止时,请求将派遣到客户端dev服务器:

// src/server/functions/render_pages.tsx
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
import axios from 'axios';
import path from 'node:path';

const { CLIENT_DEV_ORIGIN } = process.env;

export const handler: Handler = async (event, context) => {
  const shouldGetFromClientDevServer =
    CLIENT_DEV_ORIGIN && event.httpMethod === 'GET' && path.parse(event.path).ext;
  if (shouldGetFromClientDevServer) {
    return await getFromClientDevServer(event);
  }

  return { statusCode: 200, body: 'It works!' };
};

async function getFromClientDevServer(event: HandlerEvent): Promise<HandlerResponse> {
  const { status, data, headers } = await axios.get(`${CLIENT_DEV_ORIGIN}${event.path}`, {
    responseType: 'text',
    responseEncoding: 'binary',
  });
  return {
    statusCode: status,
    headers: headers as {},
    body: Buffer.from(data, 'binary').toString('base64'),
    isBase64Encoded: true,
  };
}

其余的请求是页面的内容,并将为他们添加SSR。要操纵html,安装了jsdom

$ npm i jsdom @types/jsdom

然后,src/server/functions/render_pages.tsxsrc/pages/Home/ssrData.tssrc/types.ts一起扩展:

// src/server/functions/render_pages.tsx
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
import { dehydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { JSDOM } from 'jsdom';
import path from 'node:path';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from '../../App';
import { createQueryClient } from '../../queries/queryClient';
import { ParamsOfFillInSsrData } from '../../types';

const { CLIENT_DEV_ORIGIN } = process.env;

export const handler: Handler = async (event, context) => {
  const shouldGetFromClientDevServer =
    CLIENT_DEV_ORIGIN && event.httpMethod === 'GET' && path.parse(event.path).ext;
  if (shouldGetFromClientDevServer) {
    return await getFromClientDevServer(event);
  }

  const clientIndexDom = new JSDOM(await getClientIndexHtml());
  const { document } = clientIndexDom.window;

  try {
    const queryClient = createQueryClient();
    await autoFillInSsrData({ event, context, queryClient });
    addDehydratedScript(document, queryClient);
    const rootHtml = renderToString(
      <QueryClientProvider client={queryClient}>
        <StaticRouter location={event.path}>
          <App />
        </StaticRouter>
      </QueryClientProvider>
    );
    document.querySelector('#root')!.innerHTML = rootHtml;
    queryClient.clear();
  } catch (e) {
    console.error(e);
  }

  return { statusCode: 200, body: clientIndexDom.serialize() };
};

async function getFromClientDevServer(event: HandlerEvent): Promise<HandlerResponse> {
  const { status, data, headers } = await axios.get(`${CLIENT_DEV_ORIGIN}${event.path}`, {
    responseType: 'text',
    responseEncoding: 'binary',
  });
  return {
    statusCode: status,
    headers: headers as {},
    body: Buffer.from(data, 'binary').toString('base64'),
    isBase64Encoded: true,
  };
}

async function getClientIndexHtml(): Promise<string> {
  if (!getClientIndexHtml.cachedResult) {
    let result: string;
    if (CLIENT_DEV_ORIGIN) {
      const { data } = await axios.get(`${CLIENT_DEV_ORIGIN}/`, { responseType: 'text' });
      result = data;
    } else {
      result = '<div id="root"></div>';
    }
    getClientIndexHtml.cachedResult = result;
  }
  return getClientIndexHtml.cachedResult;
}
getClientIndexHtml.cachedResult = false as string | false;

async function autoFillInSsrData(params: ParamsOfFillInSsrData): Promise<void> {
  const { event } = params;

  let pageName: string | false = false;
  if (event.path === '/') {
    pageName = 'Home';
  }

  if (pageName) {
    const { fillInSsrData } = await import(`../../pages/${pageName}/ssrData`);
    await fillInSsrData(params);
  }
}

function addDehydratedScript(document: Document, queryClient: QueryClient): void {
  const scriptAsStr = `window.__REACT_QUERY_STATE__=${JSON.stringify(dehydrate(queryClient))};`;
  const scriptAsElm = document.createElement('script');
  scriptAsElm.innerHTML = scriptAsStr;
  document.head.append(scriptAsElm);
}
// src/pages/Home/ssrData.ts
import { paramOfGhRepoStarCountQuery } from './Page';
import { getKeyOfGhRepoStarCountQuery } from '../../queries/gh';
import { getGhRepoStarCount } from '../../server/gh/repo';
import type { ParamsOfFillInSsrData } from '../../types';

export async function fillInSsrData({ queryClient }: ParamsOfFillInSsrData): Promise<void> {
  await queryClient.prefetchQuery(getKeyOfGhRepoStarCountQuery(paramOfGhRepoStarCountQuery), () =>
    getGhRepoStarCount(paramOfGhRepoStarCountQuery)
  );
}
// src/types.ts
import type { HandlerContext, HandlerEvent } from '@netlify/functions';
import type { QueryClient } from '@tanstack/react-query';

export type ParamsOfFillInSsrData = {
  event: HandlerEvent;
  context: HandlerContext;
  queryClient: QueryClient;
};

对于请求服务器端数据的每个页面,都会给出一个ssrData.ts文件,以准备自己的SSR数据。 functions/render_pages.tsx基于请求路径选择适当的ssrData.ts文件,以准备适当的SSR数据。与客户端root React组件<App>一起,生成了服务器端渲染的HTML。之后,SSR数据被序列化为脚本HTML元素内的全局变量__REACT_QUERY_STATE__。同时,在提出的CLIENT_DEV_ORIGIN上,从客户端端DEV服务器中获取顶级HTML。随着脚本HTML元素和服务器端渲染的HTML插入,返回顶级HTML作为响应。

这是SSR的服务器端部分。对于客户端部分,全局变量__REACT_QUERY_STATE__是值得的:

// src/react-app-env.d.ts
/// <reference types="react-scripts" />

+interface Window {
+  __REACT_QUERY_STATE__?: {};
+}
// src/index.tsx
-import { QueryClientProvider } from '@tanstack/react-query';
+import { Hydrate, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
import { createQueryClient } from './queries/queryClient';
import reportWebVitals from './reportWebVitals';

const queryClient = createQueryClient();
+const dehydratedState = window.__REACT_QUERY_STATE__ ?? {};

-const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
-root.render(
+ReactDOM.hydrateRoot(
+  document.getElementById('root') as HTMLElement,
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
-      <BrowserRouter>
-        <App />
-      </BrowserRouter>
+      <Hydrate state={dehydratedState}>
+        <BrowserRouter>
+          <App />
+        </BrowserRouter>
+      </Hydrate>
    </QueryClientProvider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

现在,随着预览启动,访问//zxcv的路径可验证Home/Page.tsxNotFound/Page.tsx的SSR结果,该结果也可以通过使用curl
来完成。

$ curl http://127.0.0.1:8888/
...
<div id="root"><div class="Page_root__VJmx-"><header class="Page_header__LpSVE"><img src="/static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg" class="Page_logo__KFvWL" alt="logo"><p>Edit <code>src/**/*.tsx</code> and save to reload.</p><a class="Page_link__iAwDC" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React (⭐️ = <!-- -->201,386<!-- -->)</a></header></div></div>
...

$ curl http://127.0.0.1:8888/zxcv
...
<div id="root"><div class="Page_root__3XcfJ"><h1>Not Found</h1></div></div>
...

wth在预览中添加的SSR,可能发生了一个未风格的内容(FOUC),因为在浏览器上,在加载JS文件的样式之前,在浏览器上出现了服务器端渲染的HTML。不过,预计使用style-loader。 CRA在react-scripts start中使用style-loader来对预览中的样式进行热模块替换(HMR)。但是,当该应用程序构建并部署到远程环境时,CRA将样式在react-scripts build中的编译时间插入顶级HTML,因此FOUC问题最终将不存在。 (但是,如果您仍然认为FOUC问题很重要,则可以通过检查process.env.NODE_ENV === 'development'来控制服务器端渲染的HTML的可见性。)

准备就绪预览后,演示应用程序正在构建并部署到远程环境中。在远程环境中,没有启动客户端DEV服务器,但仅是构建的客户端资产。我需要扩展package.jsonnetlify.tomlsrc/server/functions/render_pages.tsx

// package.json
...
"scripts": {
    "start:client": "cross-env BROWSER=none FAST_REFRESH=false react-scripts start",
    "start:server": "cross-env BUILD_PATH=server webpack -w -c webpack.server.config.js",
    "start-all": "run-p start:*",
    "dev": "netlify dev",
-    "build": "react-scripts build",
+    "build:client": "cross-env BUILD_PATH=client react-scripts build",
+    "build:server": "cross-env BUILD_PATH=server NODE_ENV=production webpack -c webpack.server.config.js",
+    "build-all": "run-s build:*",
...
# netlify.toml
[functions]
directory = "server/server/functions"
+node_bundler = "nft"
+included_files = ["server/**/*", "client/**/*.html"]
...
+[build]
+command = "npm run build-all"
+publish = "client"
+
+[build.environment]
+NODE_ENV = "production"
...
// src/server/functions/render_pages.tsx
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
import { dehydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { JSDOM } from 'jsdom';
+import fs from 'node:fs';
import path from 'node:path';
+import { promisify } from 'node:util';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from '../../App';
import { createQueryClient } from '../../queries/queryClient';
import { ParamsOfFillInSsrData } from '../../types';

const { CLIENT_DEV_ORIGIN } = process.env;
+const CLIENT_INDEX_HTML_PATH = path.resolve('client/index.html');
...
async function getClientIndexHtml(): Promise<string> {
  if (!getClientIndexHtml.cachedResult) {
    let result: string;
    if (CLIENT_DEV_ORIGIN) {
      const { data } = await axios.get(`${CLIENT_DEV_ORIGIN}/`, { responseType: 'text' });
      result = data;
    } else {
-      result = '<div id="root"></div>';
+      result = await promisify(fs.readFile)(CLIENT_INDEX_HTML_PATH, 'utf8');
    }
    getClientIndexHtml.cachedResult = result;
  }
  return getClientIndexHtml.cachedResult;
}
getClientIndexHtml.cachedResult = false as string | false;
...

客户端资产的构建目录已从buildclient进行了调整,并用作NetLify的发布目录。在远程环境中,具有NetLify的当前路由选项,对于GET请求,其路径与发布目录中的资产路径匹配,返回了匹配的资产。同时,在functions/render_pages.tsx中,从文件client/index.html读取顶级HTML。请注意,由于已经完成了服务器端转卸液,因此node_bundler = "nft"netlify.toml中用于停止NetLify在部署中进行任何额外的处理。

顺便说一下,要解开生成的目录client,需要修改.gitignore

# .gitignore
...
# production
-/build
+/client
/server
...

最后,遵循Netlify文档中的指南Import from an existing repository,部署了演示应用程序。演示应用程序的最终代码库是在GitHub repo licg9999/server-side-rendering-with-cra-on-netlify中。部署后其URL是https://bucolic-sprinkles-002beb.netlify.app/。可以通过访问//zxcv来验证SSR,这也可以通过使用curl
可用

$ curl https://bucolic-sprinkles-002beb.netlify.app/
...<div id="root"><div class="Page_root__VJmx-"><header class="Page_header__LpSVE"><img src="/static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg" class="Page_logo__KFvWL" alt="logo"><p>Edit <code>src/**/*.tsx</code> and save to reload.</p><a class="Page_link__iAwDC" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React (⭐️ = <!-- -->201,431<!-- -->)</a></header></div></div>...

$ curl https://bucolic-sprinkles-002beb.netlify.app/zxcv
...<div id="root"><div class="Page_root__3XcfJ"><h1>Not Found</h1></div></div>...

到目前为止,已完全构建了一个常见的功能并在Netlify上部署在NetLify上的React应用程序的演示应用程序已完全构造。

引擎盖下的想法

介绍演示应用程序的构建,将在任何FAAS提供商上设置任何React应用程序的SSR的想法如下:

  1. 必须先制作能够导入和使用客户端根反应组件的服务器端。客户端汇编配置与创建服务器端转质配置的可能性尽可能多地使用,并为FAAS条目的文件提供了保存目录结构和停止资产的关键调整。
  2. 对于请求服务器端数据的每个页面,都提供了一个SSR同级文件,以准备自己的SSR数据。在即将到来的页面请求上,服务器端根据请求路径选择了适当的SSR同胞文件,以准备适当的SSR数据。可以提取访问数据源的逻辑和类型
  3. SSR数据与客户端根组件一起用于生成服务器端渲染的HTML。可以在本地环境(或预览)中从客户端开发服务器(或预览)中获取顶级HTML,也可以从远程环境中的构建客户端资产中读取。随着SSR数据序列化,然后与服务器端渲染的HTML一起插入,将返回最高级别的HTML作为响应。
  4. 客户端可以从响应中对SSR数据进行测试,然后将其与根客户端React组件一起使用,以将服务器端渲染的HTML从响应中进行水合以初始化React Client端。
  5. 如果有的话,FAAS提供商在部署中的额外处理需要停止,因为已经完成了服务器端转卸液。另外,需要添加一些调度以帮助整个应用程序在本地环境中启动该应用程序。

在这些要点中,我认为#1可以是最重要的。原因是,其余的只是建议可能的最佳实践,但是能够导入和使用客户端root React组件的服务器端始终构成SSR的基础。因此,一旦可以通过重复使用客户端汇编配置来进行服务器端转滤线,就像NELTIFY上的CRA的SSR的此演示应用程序一样,可以将SSR添加到任何FAAS提供商上的任何React应用中。