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'] && ( + + )} + + + +