Un serveur unique - rocambille/start-express-react GitHub Wiki
Vite fournit un serveur de développement qui intègre des fonctionnalités avancées (par exemple, le HMR ou la gestion native de JSX). L'outil fonctionne parfaitement pour notre application React.
Mais lorsque vous souhaitez inclure une application Express, les choses peuvent devenir compliquées pour gérer 2 serveurs indépendants (le serveur de développement Vite et le serveur Express) et les faire communiquer ensemble.
Dans StartER, nous avons choisi d'implémenter un serveur unique en attachant un serveur de développement Vite au serveur de notre application Express.
En complément, cette architecture à serveur unique nous permet d'activer le rendu côté serveur (SSR) de manière fluide. Le SSR fait référence à l'exécution d'une application dans Node.js, son pré-rendu en HTML et enfin son hydratation sur le client.
La documentation de Vite fournit un guide sur le SSR. Cette page reprend les étapes implémentées dans StartER et les complète avec des éléments spécifiques à React Router.
Si nous isolons les éléments clés du SSR dans StartER, la structure des fichiers source est la suivante :
.
├── index.html
├── server.ts # Serveur d'application principal
└── src
├── entry-client.tsx # Monte l'application sur un élément DOM
├── entry-server.tsx # Rend l'application en utilisant l'API SSR de Vite
└── react
└── routes.tsx # Point d'entrée du code React, indépendant de l'environnement client/server
Cette organisation sépare clairement les responsabilités entre le client, le serveur et les routes partagées.
Le fichier index.html référence entry-client et inclut un marqueur où le balisage rendu par le serveur est injecté :
<div class="container" id="root"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client"></script>Vous pouvez utiliser n'importe quel marqueur que vous préférez à la place de <!--ssr-outlet-->, à condition qu'il puisse être détecté sans ambiguité.
Dans notre application SSR, Vite est utilisé en mode middleware. Cela nous permet d'avoir un contrôle total sur notre serveur principal et de dissocier Vite de l'environnement de production.
import express from "express";
import { createServer as createViteServer } from "vite";
const app = express();
const isProduction = process.env.NODE_ENV === "production";
if (isProduction === false) {
// Create Vite server in middleware mode and configure the app type as
// 'custom', disabling Vite's own HTML serving logic so parent server
// can take control
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
});
// Use vite's connect instance as middleware. If you use your own
// express router (express.Router()), you should use router.use
// When the server restarts (for example after the user modifies
// vite.config.js), `vite.middlewares` is still going to be the same
// reference (with a new internal stack of Vite and plugin-injected
// middlewares). The following is valid even after restarts.
app.use(vite.middlewares);
app.use(/(.*)/, async (req, res, next) => {
// serve index.html - we will tackle this next
});
}Ici, vite est une instance de ViteDevServer. vite.middlewares est une instance de Connect qui peut être utilisée comme middleware dans notre application Express.
L'étape suivante consiste à implémenter le gestionnaire * (/(.*)/ à partir d'Express 5) pour servir le code HTML rendu par le serveur :
app.use(/(.*)/, async (req, res, next) => {
const url = req.originalUrl;
const indexHtml = fs.readFileSync("index.html", "utf-8");
// 1. Apply Vite HTML transforms. This injects the Vite HMR client,
// and also applies HTML transforms from Vite plugins, e.g. global
// preambles from @vitejs/plugin-react
const template = await vite.transformIndexHtml(url, indexHtml);
// 2. Load the server entry. ssrLoadModule automatically transforms
// ESM source code to be usable in Node.js! There is no bundling
// required, and provides efficient invalidation similar to HMR.
const { render } = await vite.ssrLoadModule("/src/entry-server");
// 3. render the app HTML. This assumes entry-server.js's exported
// `render` function calls appropriate framework SSR APIs,
// e.g. ReactDOMServer.renderToString()
await render(template, req, res);
});Le rôle de la fonction render dans entry-server.tsx est de rendre l'HTML de l'application et de l'injecter dans le template à la place du marqueur <!--ssr-outlet-->.
import type { Request, Response } from "express";
import routes from "./react/routes";
export const render = async (template: string, req: Request, res: Response) => {
const appHtml = // render using routes imported from ./react/routes
const html = template.replace("<!--ssr-outlet-->", () => appHtml);
res.status(200).set("Content-Type", "text/html; charset=utf-8").end(html);
};C'est le moment où la documentation de React Router prend le relais.
Pour obtenir un rendu serveur compatible avec React Router, nous transformons nos routes en gestionnaire de requêtes avec createStaticHandler.
import { createStaticHandler } from "react-router";
import routes from "./react/routes";
const { query, dataRoutes } = createStaticHandler(routes);L'étape suivante est d'obtenir le contexte de routage et d'effectuer le rendu. Voyons maintenant comment la fonction render combine ces éléments pour produire le HTML envoyé au navigateur.
import type { Request, Response } from "express";
import { renderToString } from "react-dom/server";
import {
createStaticHandler,
createStaticRouter,
StaticRouterProvider,
} from "react-router";
import routes from "./react/routes";
const { query, dataRoutes } = createStaticHandler(routes);
export const render = async (template: string, req: Request, res: Response) => {
// 1. Run actions/loaders to get the routing context with `query`
const context = await query(
new Request(`${req.protocol}://${req.get("host")}${req.originalUrl}`),
);
// If `query` returns a Response, send it raw
if (context instanceof Response) {
for (const [key, value] of context.headers.entries()) {
res.set(key, value);
}
return res.status(context.status).end(context.body);
}
// Setup headers from action and loaders from deepest match
const leaf = context.matches[context.matches.length - 1];
const actionHeaders = context.actionHeaders[leaf.route.id];
if (actionHeaders) {
for (const [key, value] of actionHeaders.entries()) {
res.set(key, value);
}
}
const loaderHeaders = context.loaderHeaders[leaf.route.id];
if (loaderHeaders) {
for (const [key, value] of loaderHeaders.entries()) {
res.set(key, value);
}
}
// 2. Create a static router for SSR
const router = createStaticRouter(dataRoutes, context);
// 3. Render everything with StaticRouterProvider
const appHtml = renderToString(
<StrictMode>
<StaticRouterProvider router={router} context={context} />
</StrictMode>,
);
const html = template.replace("<!--ssr-outlet-->", () => appHtml);
// 4. Send a response
res.status(200).set("Content-Type", "text/html; charset=utf-8").end(html);
}Un point à noter sur l'utilisation de renderToString : la méthode ne prend pas en charge les <Suspense> de React. Dans StartER, nous utilisons renderToPipeableStream, qui prend en charge <Suspense> sur le serveur.
import { Transform } from "node:stream";
import type { Request, Response } from "express";
import { renderToPipeableStream } from "react-dom/server";
import {
StaticRouterProvider,
createStaticHandler,
createStaticRouter,
} from "react-router";
import routes from "./react/routes";
const { query, dataRoutes } = createStaticHandler(routes);
export const render = async (template: string, req: Request, res: Response) => {
// 1. Run actions/loaders to get the routing context with `query`
// ...
// 2. Create a static router for SSR
const router = createStaticRouter(dataRoutes, context);
// 3. Render everything with StaticRouterProvider
const { pipe } = renderToPipeableStream(
<StaticRouterProvider router={router} context={context} />
);
// 4. Send a response
res.status(200).set("Content-Type", "text/html; charset=utf-8");
const [htmlStart, htmlEnd] = template.split("<!--ssr-outlet-->");
res.write(htmlStart);
const transformStream = new Transform({
transform(chunk, encoding, callback) {
res.write(chunk, encoding);
callback();
},
});
pipe(transformStream);
transformStream.on("finish", () => {
res.end(htmlEnd);
});
};Pour déployer un projet SSR en production, nous devons :
- Produire un build client comme d'habitude ;
- Produire un build SSR, qui peut être chargée directement via
import()afin d'éviter de passer par le modulessrLoadModulede Vite.
Les scripts de build dans package.json ressemble à ceci :
{
"scripts": {
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr src/entry-server"
}
}Notez l'option --ssr qui indique qu'il s'agit d'un build SSR, suivie de l'entrée SSR src/entry-server.
Le reste de la logique de déploiement se trouve dans server.ts, où StartER adapte son comportement selon process.env.NODE_ENV.
Voir les codes complets pour les détails :