Skip to content

Commit

Permalink
feature(SSR): 支持通过导出metadataLoader()设置页面的TDK等meta信息 (#11833)
Browse files Browse the repository at this point in the history
* fix: dev环境构建SSR server.js时,环境判断错误导致输出的dev产物带上了hash

* Update packages/preset-umi/src/features/ssr/webpack/webpack.ts

Co-authored-by: chencheng (云谦) <[email protected]>

* fix: dev环境构建SSR server.js时,环境判断错误导致输出的dev产物带上了hash

* feature: 支持自定义SSR构建目标,默认为node,可配置为webworker以支持运行在类似Cloudflare Workers的平台
以解决 react-dom/server renderToReadableStream 不存在的问题

* feature: getManifest支持传入sourceDir表示SSR产物目录

* feature: getManifest支持传入sourceDir表示SSR产物目录

* fix: rendertoreadablestream is not a function

* Revert "feature: 支持自定义SSR构建目标,默认为node,可配置为webworker以支持运行在类似Cloudflare Workers的平台"

This reverts commit 5b0946a.

* fix: rendertoreadablestream is not a function

* Update packages/server/src/ssr.ts

Co-authored-by: 咲奈Sakina <[email protected]>

* Update packages/server/src/ssr.ts

Co-authored-by: 咲奈Sakina <[email protected]>

* fix: 还原g_getAssets

* fix: 被执行时才调用getManifest(),避免在入口立即调用getManifest()

* fix: 被执行时才调用getManifest(),避免在入口立即调用getManifest()

* fix: 被执行时才调用getManifest(),避免在入口立即调用getManifest()

* feature: SSR模式下,多输出一份和index.html完全一致的*.html文件,用于ER场景

* feature: SSR模式下,多输出一份和index.html完全一致的*.html文件,用于ER场景

* fix: SSR withoutHTML模式下,包一层<div id="root">{app}</div>

* fix: 回滚SSR模式下,多输出一份和index.html完全一致的*.html文件,用于ER场景

* fix: SSR withoutHTML模式下也需要注入loaderData数据用于注水时

* fix: 在有basename的情况下__serverLoader的请求路径需要加上basename

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* fix: async 函数返回值不需要 await ,不然会多造成一次异步成本。

* fix: 在请求__serverLoader时,带上cookie,以实现在SSR时请求需要登入态的接口

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* fix: dumi 里做 ssg 时传的 path 是不带 host 的,需要加上host

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* feature: 支持在serverLoader中读取当前request,以实现在serverLoader中读取url中的参数和headers

* fix: 防止多chunk情况下(有懒加载时)的文件名冲突报错

* fix: 防止多chunk情况下(有懒加载时)的文件名冲突报错

* Update packages/preset-umi/src/features/ssr/webpack/webpack.ts

* feature(SSR): 支持通过导出metadataLoader()设置页面的TDK等meta信息

* feature(SSR): 支持通过导出metadataLoader()设置页面的TDK等meta信息

* feature(SSR): 支持通过导出metadataLoader()设置页面的TDK等meta信息

* feature(SSR): 支持通过导出metadataLoader()设置页面的TDK等meta信息

* feature(SSR): 支持通过导出metadataLoader()设置页面的TDK等meta信息

* chore: export types and format

---------

Co-authored-by: 奇风 <[email protected]>
Co-authored-by: chencheng (云谦) <[email protected]>
Co-authored-by: 咲奈Sakina <[email protected]>
  • Loading branch information
4 people authored Nov 7, 2023
1 parent 84b5ca1 commit e63fed3
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 63 deletions.
27 changes: 23 additions & 4 deletions examples/ssr-demo/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
IServerLoaderArgs,
Link,
MetadataLoader,
ServerLoader,
useClientLoaderData,
useServerInsertedHTML,
useServerLoaderData,
Expand Down Expand Up @@ -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,
},
],
};
};
11 changes: 10 additions & 1 deletion packages/preset-umi/src/features/ssr/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}'
`,
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/preset-umi/src/features/tmpFiles/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}']`;
Expand Down
50 changes: 25 additions & 25 deletions packages/renderer-react/src/server.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import type { IMetadata } from '@umijs/server/dist/types';
import React from 'react';
import { StaticRouter } from 'react-router-dom/server';
import { AppContext } from './appContext';
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({
Expand Down Expand Up @@ -57,36 +60,33 @@ export async function getClientRootComponent(opts: {
{rootContainer}
</AppContext.Provider>
);
if (opts.withoutHTML) {
return (
<>
<div id="root">{app}</div>
<script
dangerouslySetInnerHTML={{
__html: `window.__UMI_LOADER_DATA__ = ${JSON.stringify(
opts.loaderData,
)}`,
}}
/>
</>
);
}
return (
<Html loaderData={opts.loaderData} manifest={opts.manifest}>
{app}
</Html>
);
return <Html {...opts}>{app}</Html>;
}

function Html({ children, loaderData, manifest }: any) {
function Html({
children,
loaderData,
manifest,
metadata,
}: React.PropsWithChildren<IHtmlProps>) {
// TODO: 处理 head 标签,比如 favicon.ico 的一致性
// TODO: root 支持配置

return (
<html lang="en">
<html lang={metadata?.lang || 'en'}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{metadata?.title && <title>{metadata.title}</title>}
{metadata?.description && (
<meta name="description" content={metadata.description} />
)}
{metadata?.keywords?.length && (
<meta name="keywords" content={metadata.keywords.join(',')} />
)}
{metadata?.metas?.map((em) => (
<meta key={em.name} name={em.name} content={em.content} />
))}
{manifest.assets['umi.css'] && (
<link rel="stylesheet" href={manifest.assets['umi.css']} />
)}
Expand Down
96 changes: 67 additions & 29 deletions packages/server/src/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
Expand All @@ -11,11 +17,6 @@ interface RouteLoaders {
export type ServerInsertedHTMLHook = (callbacks: () => React.ReactNode) => void;

interface CreateRequestServerlessOptions {
/**
* only return body html
* @example <div id="root">{app}</div> ...
*/
withoutHTML?: boolean;
/**
* folder path for `build-manifest.json`
*/
Expand All @@ -37,6 +38,16 @@ interface CreateRequestHandlerOptions extends CreateRequestServerlessOptions {
ServerInsertedHTMLContext: React.Context<ServerInsertedHTMLHook | null>;
}

interface IExecLoaderOpts {
routeKey: string;
routesWithServerLoader: RouteLoaders;
serverLoaderArgs?: IServerLoaderArgs;
}

interface IExecMetaLoaderOpts extends IExecLoaderOpts {
serverLoaderData?: any;
}

const createJSXProvider = (
Provider: any,
serverInsertedHTMLCallbacks: Set<() => React.ReactNode>,
Expand Down Expand Up @@ -93,18 +104,33 @@ function createJSXGenerator(opts: CreateRequestHandlerOptions) {
return;
}

const loaderData: { [key: string]: any } = {};
const loaderData: Record<string, any> = {};
const metadata: Record<string, any> = {};
await Promise.all(
matches
.filter((id: string) => routes[id].hasServerLoader)
.map(
(id: string) =>
new Promise<void>(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();
}),
),
Expand All @@ -121,7 +147,7 @@ function createJSXGenerator(opts: CreateRequestHandlerOptions) {
location: url,
manifest,
loaderData,
withoutHTML: opts.withoutHTML,
metadata,
};

const element = (await opts.getClientRootComponent(
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 },
});
};
}

Expand Down Expand Up @@ -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,
);
}
28 changes: 24 additions & 4 deletions packages/server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,31 @@ export interface IRouteCustom extends IRoute {
[key: string]: any;
}

export type UmiRequest = Partial<Request> & Pick<Request, 'url' | 'headers'>;
type LoaderReturn<T> = T | Promise<T>;

/**
* serverLoader 的参数类型
*/
export type UmiRequest = Partial<Request> & Pick<Request, 'url' | 'headers'>;
export interface IServerLoaderArgs {
request: UmiRequest;
}
export type ServerLoader<T = any> = (
req?: IServerLoaderArgs,
) => LoaderReturn<T>;

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<T = any> = (
serverLoaderData: T,
req?: IServerLoaderArgs,
) => LoaderReturn<IMetadata>;

0 comments on commit e63fed3

Please sign in to comment.