diff --git a/examples/ssr-demo/.umirc.ts b/examples/ssr-demo/.umirc.ts
index 18627a8fbbc7..f5a95e511b89 100644
--- a/examples/ssr-demo/.umirc.ts
+++ b/examples/ssr-demo/.umirc.ts
@@ -1,10 +1,29 @@
export default {
svgr: {},
hash: true,
+ mfsu: false,
routePrefetch: {},
manifest: {},
clientLoader: {},
+ title: '测试title',
+ scripts: [`https://a.com/b.js`],
ssr: {
- serverBuildPath: './umi.server.js',
+ builder: 'webpack',
+ renderFromRoot: false,
},
+ styles: [`body { color: red; }`, `https://a.com/b.css`],
+
+ metas: [
+ {
+ name: 'test',
+ content: 'content',
+ },
+ ],
+ links: [{ href: '/foo.css', rel: 'preload' }],
+
+ headScripts: [
+ {
+ src: 'https://www.baidu.com',
+ },
+ ],
};
diff --git a/packages/preset-umi/src/commands/dev/getBabelOpts.ts b/packages/preset-umi/src/commands/dev/getBabelOpts.ts
index 172bf69895bd..27621fe3eb5c 100644
--- a/packages/preset-umi/src/commands/dev/getBabelOpts.ts
+++ b/packages/preset-umi/src/commands/dev/getBabelOpts.ts
@@ -3,7 +3,10 @@ import { IApi } from '../../types';
export async function getBabelOpts(opts: { api: IApi }) {
// TODO: 支持用户自定义
- const shouldUseAutomaticRuntime = semver.gte(opts.api.appData.react.version, '16.14.0');
+ const shouldUseAutomaticRuntime = semver.gte(
+ opts.api.appData.react.version,
+ '16.14.0',
+ );
const babelPresetOpts = await opts.api.applyPlugins({
key: 'modifyBabelPresetOpts',
initialValue: {
diff --git a/packages/preset-umi/src/features/ssr/ssr.ts b/packages/preset-umi/src/features/ssr/ssr.ts
index d5fa28b509c0..128a9de01944 100644
--- a/packages/preset-umi/src/features/ssr/ssr.ts
+++ b/packages/preset-umi/src/features/ssr/ssr.ts
@@ -27,6 +27,7 @@ export default (api: IApi) => {
serverBuildPath: zod.string(),
platform: zod.string(),
builder: zod.enum(['esbuild', 'webpack']),
+ renderFromRoot: zod.boolean(),
})
.deepPartial();
},
diff --git a/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts b/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts
index 1c3a4e2124bb..4e3543a4fa8c 100644
--- a/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts
+++ b/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts
@@ -2,11 +2,11 @@ import { importLazy, lodash, winPath } from '@umijs/utils';
import { existsSync, readdirSync } from 'fs';
import { basename, dirname, join } from 'path';
import { RUNTIME_TYPE_FILE_NAME } from 'umi';
+import { getMarkupArgs } from '../../commands/dev/getMarkupArgs';
import { TEMPLATES_DIR } from '../../constants';
import { IApi } from '../../types';
import { getModuleExports } from './getModuleExports';
import { importsToStr } from './importsToStr';
-
const routesApi: typeof import('./routes') = importLazy(
require.resolve('./routes'),
);
@@ -496,6 +496,8 @@ if (process.env.NODE_ENV === 'development') {
}
return memo;
}, []);
+ const { headScripts, scripts, styles, title, favicons, links, metas } =
+ await getMarkupArgs({ api });
api.writeTmpFile({
noPluginDir: true,
path: 'umi.server.ts',
@@ -514,6 +516,16 @@ if (process.env.NODE_ENV === 'development') {
join(api.paths.absOutputPath, 'build-manifest.json'),
),
env: JSON.stringify(api.env),
+ metadata: JSON.stringify({
+ headScripts,
+ styles,
+ title,
+ favicons,
+ links,
+ metas,
+ scripts: scripts || [],
+ }),
+ renderFromRoot: api.config.ssr?.renderFromRoot ?? false,
},
});
}
diff --git a/packages/preset-umi/templates/server.tpl b/packages/preset-umi/templates/server.tpl
index e01021b9836a..06f409fcd446 100644
--- a/packages/preset-umi/templates/server.tpl
+++ b/packages/preset-umi/templates/server.tpl
@@ -51,6 +51,9 @@ const createOpts = {
helmetContext,
createHistory,
ServerInsertedHTMLContext,
+ metadata: {{{metadata}}},
+ renderFromRoot: {{{renderFromRoot}}}
+
};
const requestHandler = createRequestHandler(createOpts);
/**
diff --git a/packages/renderer-react/src/browser.tsx b/packages/renderer-react/src/browser.tsx
index 2e3adcc7a6f9..acfa650df334 100644
--- a/packages/renderer-react/src/browser.tsx
+++ b/packages/renderer-react/src/browser.tsx
@@ -10,9 +10,9 @@ import ReactDOM from 'react-dom/client';
import { matchRoutes, Router, useRoutes } from 'react-router-dom';
import { AppContext, useAppData } from './appContext';
import { fetchServerLoader } from './dataFetcher';
+import { Html } from './html';
import { createClientRoutes } from './routes';
import { ILoaderData, IRouteComponents, IRoutesById } from './types';
-
let root: ReactDOM.Root | null = null;
// react 18 some scenarios need unmount such as micro app
@@ -96,6 +96,11 @@ export type RenderClientOpts = {
* @doc 一般不需要改,微前端的时候会变化
*/
rootElement?: HTMLElement;
+ /**
+ * ssr 是否从 app root 根节点开始 render
+ * @doc 默认 false, 从 app root 开始 render,为 true 时从 html 开始
+ */
+ renderFromRoot?: boolean;
/**
* 当前的路由配置
*/
@@ -331,12 +336,22 @@ const getBrowser = (
*/
export function renderClient(opts: RenderClientOpts) {
const rootElement = opts.rootElement || document.getElementById('root')!;
+
const Browser = getBrowser(opts, );
// 为了测试,直接返回组件
if (opts.components) return Browser;
-
if (opts.hydrate) {
- ReactDOM.hydrateRoot(rootElement, );
+ // @ts-ignore
+ const loaderData = window.__UMI_LOADER_DATA__ || {};
+ // @ts-ignore
+ const metadata = window.__UMI_METADATA_LOADER_DATA__ || {};
+
+ ReactDOM.hydrateRoot(
+ document,
+
+
+ ,
+ );
return;
}
diff --git a/packages/renderer-react/src/html.tsx b/packages/renderer-react/src/html.tsx
new file mode 100644
index 000000000000..6669378fb8db
--- /dev/null
+++ b/packages/renderer-react/src/html.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import { IHtmlProps, IScript } from './types';
+
+const RE_URL = /^(http:|https:)?\/\//;
+
+function isUrl(str: string) {
+ return (
+ RE_URL.test(str) ||
+ (str.startsWith('/') && !str.startsWith('/*')) ||
+ str.startsWith('./') ||
+ str.startsWith('../')
+ );
+}
+
+function normalizeScripts(script: IScript, extraProps = {}) {
+ if (typeof script === 'string') {
+ return isUrl(script)
+ ? {
+ src: script,
+ ...extraProps,
+ }
+ : { content: script };
+ } else if (typeof script === 'object') {
+ return {
+ ...script,
+ ...extraProps,
+ };
+ } else {
+ throw new Error(`Invalid script type: ${typeof script}`);
+ }
+}
+
+function generatorStyle(style: string) {
+ return isUrl(style)
+ ? { type: 'link', href: style }
+ : { type: 'style', content: style };
+}
+
+const NormalizeMetadata = (props: IHtmlProps) => {
+ const { metadata } = props;
+ return (
+ <>
+ {metadata?.title &&
{metadata.title}}
+ {metadata?.favicons?.map((favicon: string, key: number) => {
+ return ;
+ })}
+ {metadata?.description && (
+
+ )}
+ {metadata?.keywords?.length && (
+
+ )}
+ {metadata?.metas?.map((em: any) => (
+
+ ))}
+
+ {metadata?.links?.map((link: Record, key: number) => {
+ return ;
+ })}
+ {metadata?.styles?.map((style: string, key: number) => {
+ const { type, href, content } = generatorStyle(style);
+ if (type === 'link') {
+ return ;
+ } else if (type === 'style') {
+ return ;
+ }
+ })}
+ {metadata?.headScripts?.map((script: IScript, key: number) => {
+ const { content, ...rest } = normalizeScripts(script);
+ return (
+
+ );
+ })}
+ >
+ );
+};
+
+export function Html({
+ children,
+ loaderData,
+ manifest,
+ metadata,
+ renderFromRoot,
+}: React.PropsWithChildren) {
+ // TODO: 处理 head 标签,比如 favicon.ico 的一致性
+ // TODO: root 支持配置
+
+ if (renderFromRoot) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+ }
+ return (
+
+
+
+
+ {manifest?.assets['umi.css'] && (
+
+ )}
+
+
+
+