diff --git a/examples/ssr-demo/src/pages/index.tsx b/examples/ssr-demo/src/pages/index.tsx
index e5b750b5354c..279b00b9d5c1 100644
--- a/examples/ssr-demo/src/pages/index.tsx
+++ b/examples/ssr-demo/src/pages/index.tsx
@@ -1,6 +1,7 @@
import {
- IServerLoaderArgs,
Link,
+ MetadataLoader,
+ ServerLoader,
useClientLoaderData,
useServerInsertedHTML,
useServerLoaderData,
@@ -51,8 +52,26 @@ export async function clientLoader() {
return { message: 'data from client loader of index.tsx' };
}
-export async function serverLoader({ request }: IServerLoaderArgs) {
- const { url } = request;
+export const serverLoader: ServerLoader = async (req) => {
+ const url = req!.request.url;
await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000));
return { message: `data from server loader of index.tsx, url: ${url}` };
-}
+};
+
+// SEO-设置页面的TDK
+export const metadataLoader: MetadataLoader<{ message: string }> = (
+ serverLoaderData,
+) => {
+ return {
+ title: '开发者学堂 - 支付宝开放平台',
+ description: '支付宝小程序开发入门实战经验在线课程,让更多的开发者获得成长',
+ keywords: ['小程序开发', '入门', '实战', '小程序云'],
+ lang: 'zh-CN',
+ metas: [
+ {
+ name: 'msg',
+ content: serverLoaderData.message,
+ },
+ ],
+ };
+};
diff --git a/packages/preset-umi/src/features/ssr/ssr.ts b/packages/preset-umi/src/features/ssr/ssr.ts
index ec65e3c5971e..d5fa28b509c0 100644
--- a/packages/preset-umi/src/features/ssr/ssr.ts
+++ b/packages/preset-umi/src/features/ssr/ssr.ts
@@ -105,7 +105,16 @@ export function useServerInsertedHTML(callback: () => React.ReactNode): void {
api.writeTmpFile({
path: 'types.d.ts',
content: `
-export type { IServerLoaderArgs, UmiRequest } from '${winPath(ssrTypesPath)}'
+export type {
+ // server loader
+ IServerLoaderArgs,
+ UmiRequest,
+ ServerLoader,
+ // metadata loader
+ MetadataLoader,
+ IMetadata,
+ IMetaTag,
+} from '${winPath(ssrTypesPath)}'
`,
});
});
diff --git a/packages/preset-umi/src/features/tmpFiles/routes.ts b/packages/preset-umi/src/features/tmpFiles/routes.ts
index 270520f1da51..8752d3ddb21f 100644
--- a/packages/preset-umi/src/features/tmpFiles/routes.ts
+++ b/packages/preset-umi/src/features/tmpFiles/routes.ts
@@ -165,6 +165,7 @@ export async function getRoutes(opts: {
: [];
if (enableSSR) {
routes[id].hasServerLoader = exports.includes('serverLoader');
+ routes[id].hasMetadataLoader = exports.includes('metadataLoader');
}
if (enableClientLoader && exports.includes('clientLoader')) {
routes[id].clientLoader = `clientLoaders['${id}']`;
diff --git a/packages/renderer-react/src/server.tsx b/packages/renderer-react/src/server.tsx
index 9414745d7f08..bf0695372323 100644
--- a/packages/renderer-react/src/server.tsx
+++ b/packages/renderer-react/src/server.tsx
@@ -1,3 +1,4 @@
+import type { IMetadata } from '@umijs/server/dist/types';
import React from 'react';
import { StaticRouter } from 'react-router-dom/server';
import { AppContext } from './appContext';
@@ -5,16 +6,18 @@ import { Routes } from './browser';
import { createClientRoutes } from './routes';
import { IRouteComponents, IRoutesById } from './types';
-// Get the root React component for ReactDOMServer.renderToString
-export async function getClientRootComponent(opts: {
+interface IHtmlProps {
routes: IRoutesById;
routeComponents: IRouteComponents;
pluginManager: any;
location: string;
loaderData: { [routeKey: string]: any };
manifest: any;
- withoutHTML?: boolean;
-}) {
+ metadata?: IMetadata;
+}
+
+// Get the root React component for ReactDOMServer.renderToString
+export async function getClientRootComponent(opts: IHtmlProps) {
const basename = '/';
const components = { ...opts.routeComponents };
const clientRoutes = createClientRoutes({
@@ -57,36 +60,33 @@ export async function getClientRootComponent(opts: {
{rootContainer}
);
- if (opts.withoutHTML) {
- return (
- <>
-
{app}
-
- >
- );
- }
- return (
-
- {app}
-
- );
+ return {app};
}
-function Html({ children, loaderData, manifest }: any) {
+function Html({
+ children,
+ loaderData,
+ manifest,
+ metadata,
+}: React.PropsWithChildren) {
// TODO: 处理 head 标签,比如 favicon.ico 的一致性
// TODO: root 支持配置
return (
-
+
+ {metadata?.title && {metadata.title}}
+ {metadata?.description && (
+
+ )}
+ {metadata?.keywords?.length && (
+
+ )}
+ {metadata?.metas?.map((em) => (
+
+ ))}
{manifest.assets['umi.css'] && (
)}
diff --git a/packages/server/src/ssr.ts b/packages/server/src/ssr.ts
index a254026b3b9c..2c012adadb1c 100644
--- a/packages/server/src/ssr.ts
+++ b/packages/server/src/ssr.ts
@@ -2,7 +2,13 @@ import React, { ReactElement } from 'react';
import * as ReactDomServer from 'react-dom/server';
import { matchRoutes } from 'react-router-dom';
import { Writable } from 'stream';
-import type { IRoutesById, IServerLoaderArgs, UmiRequest } from './types';
+import type {
+ IRoutesById,
+ IServerLoaderArgs,
+ MetadataLoader,
+ ServerLoader,
+ UmiRequest,
+} from './types';
interface RouteLoaders {
[key: string]: () => Promise;
@@ -11,11 +17,6 @@ interface RouteLoaders {
export type ServerInsertedHTMLHook = (callbacks: () => React.ReactNode) => void;
interface CreateRequestServerlessOptions {
- /**
- * only return body html
- * @example {app}
...
- */
- withoutHTML?: boolean;
/**
* folder path for `build-manifest.json`
*/
@@ -37,6 +38,16 @@ interface CreateRequestHandlerOptions extends CreateRequestServerlessOptions {
ServerInsertedHTMLContext: React.Context;
}
+interface IExecLoaderOpts {
+ routeKey: string;
+ routesWithServerLoader: RouteLoaders;
+ serverLoaderArgs?: IServerLoaderArgs;
+}
+
+interface IExecMetaLoaderOpts extends IExecLoaderOpts {
+ serverLoaderData?: any;
+}
+
const createJSXProvider = (
Provider: any,
serverInsertedHTMLCallbacks: Set<() => React.ReactNode>,
@@ -93,18 +104,33 @@ function createJSXGenerator(opts: CreateRequestHandlerOptions) {
return;
}
- const loaderData: { [key: string]: any } = {};
+ const loaderData: Record = {};
+ const metadata: Record = {};
await Promise.all(
matches
.filter((id: string) => routes[id].hasServerLoader)
.map(
(id: string) =>
new Promise(async (resolve) => {
- loaderData[id] = await executeLoader(
- id,
+ loaderData[id] = await executeLoader({
+ routeKey: id,
routesWithServerLoader,
serverLoaderArgs,
- );
+ });
+ // 如果有metadataLoader,执行metadataLoader
+ // metadataLoader在serverLoader返回之后执行这样metadataLoader可以使用serverLoader的返回值
+ // 如果有多层嵌套路由和合并多层返回的metadata但最里层的优先级最高
+ if (routes[id].hasMetadataLoader) {
+ Object.assign(
+ metadata,
+ await executeMetadataLoader({
+ routesWithServerLoader,
+ routeKey: id,
+ serverLoaderArgs,
+ serverLoaderData: loaderData[id],
+ }),
+ );
+ }
resolve();
}),
),
@@ -121,7 +147,7 @@ function createJSXGenerator(opts: CreateRequestHandlerOptions) {
location: url,
manifest,
loaderData,
- withoutHTML: opts.withoutHTML,
+ metadata,
};
const element = (await opts.getClientRootComponent(
@@ -219,14 +245,11 @@ export default function createRequestHandler(
return async function (req: any, res: any, next: any) {
// 切换路由场景下,会通过此 API 执行 server loader
if (req.url.startsWith('/__serverLoader') && req.query.route) {
- const loaderArgs: IServerLoaderArgs = {
- request: req,
- };
- const data = await executeLoader(
- req.query.route,
- opts.routesWithServerLoader,
- loaderArgs,
- );
+ const data = await executeLoader({
+ routeKey: req.query.route,
+ routesWithServerLoader: opts.routesWithServerLoader,
+ serverLoaderArgs: { request: req },
+ });
res.status(200).json(data);
return;
}
@@ -293,10 +316,11 @@ export function createUmiServerLoader(opts: CreateRequestHandlerOptions) {
return async function (req: UmiRequest) {
const query = Object.fromEntries(new URL(req.url).searchParams);
// 切换路由场景下,会通过此 API 执行 server loader
- const loaderArgs: IServerLoaderArgs = {
- request: req,
- };
- return executeLoader(query.route, opts.routesWithServerLoader, loaderArgs);
+ return await executeLoader({
+ routeKey: query.route,
+ routesWithServerLoader: opts.routesWithServerLoader,
+ serverLoaderArgs: { request: req },
+ });
};
}
@@ -335,15 +359,29 @@ function createClientRoute(route: any) {
};
}
-async function executeLoader(
- routeKey: string,
- routesWithServerLoader: RouteLoaders,
- serverLoaderArgs?: IServerLoaderArgs,
-) {
+async function executeLoader(params: IExecLoaderOpts) {
+ const { routeKey, routesWithServerLoader, serverLoaderArgs } = params;
const mod = await routesWithServerLoader[routeKey]();
if (!mod.serverLoader || typeof mod.serverLoader !== 'function') {
return;
}
// TODO: 处理错误场景
- return mod.serverLoader(serverLoaderArgs);
+ return (mod.serverLoader satisfies ServerLoader)(serverLoaderArgs);
+}
+
+async function executeMetadataLoader(params: IExecMetaLoaderOpts) {
+ const {
+ routesWithServerLoader,
+ routeKey,
+ serverLoaderArgs,
+ serverLoaderData,
+ } = params;
+ const mod = await routesWithServerLoader[routeKey]();
+ if (!mod.serverLoader || typeof mod.serverLoader !== 'function') {
+ return;
+ }
+ return (mod.metadataLoader satisfies MetadataLoader)(
+ serverLoaderData,
+ serverLoaderArgs,
+ );
}
diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts
index 8726ab7dd271..cbba9351b44f 100644
--- a/packages/server/src/types.ts
+++ b/packages/server/src/types.ts
@@ -13,11 +13,31 @@ export interface IRouteCustom extends IRoute {
[key: string]: any;
}
-export type UmiRequest = Partial & Pick;
+type LoaderReturn = T | Promise;
-/**
- * serverLoader 的参数类型
- */
+export type UmiRequest = Partial & Pick;
export interface IServerLoaderArgs {
request: UmiRequest;
}
+export type ServerLoader = (
+ req?: IServerLoaderArgs,
+) => LoaderReturn;
+
+export interface IMetaTag {
+ name: string;
+ content: string;
+}
+export interface IMetadata {
+ title?: string;
+ description?: string;
+ keywords?: string[];
+ /**
+ * @default 'en'
+ */
+ lang?: string;
+ metas?: IMetaTag[];
+}
+export type MetadataLoader = (
+ serverLoaderData: T,
+ req?: IServerLoaderArgs,
+) => LoaderReturn;