diff --git a/examples/antd-pro-create/package.json b/examples/antd-pro-create/package.json index 32130a66187f..dcbbde374867 100644 --- a/examples/antd-pro-create/package.json +++ b/examples/antd-pro-create/package.json @@ -85,7 +85,7 @@ "prettier": "^2.8.4", "start-server-and-test": "^1.15.2", "swagger-ui-dist": "^4.14.2", - "umi-presets-pro": "^1.0.5", + "umi-presets-pro": "^2.0.3", "umi-serve": "^1.9.11" }, "engines": { diff --git a/examples/ssr-demo/.umirc.ts b/examples/ssr-demo/.umirc.ts index 18627a8fbbc7..c905a4960ba7 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', + hydrateFromRoot: 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/routePreloadOnLoad/routePreloadOnLoad.ts b/packages/preset-umi/src/features/routePreloadOnLoad/routePreloadOnLoad.ts index 22525e4f137f..04d623b3570f 100644 --- a/packages/preset-umi/src/features/routePreloadOnLoad/routePreloadOnLoad.ts +++ b/packages/preset-umi/src/features/routePreloadOnLoad/routePreloadOnLoad.ts @@ -219,44 +219,47 @@ export default (api: IApi) => { api.config.routeLoader?.moduleType === 'esm', }); - api.addHTMLHeadScripts(() => { - if (api.name === 'build' && routeChunkFilesMap) { - // internal tern app use map mode - return api.config.tern - ? // map mode - [ - { - type: PRELOAD_ROUTE_MAP_SCP_TYPE, - content: JSON.stringify(routeChunkFilesMap), - }, - ] - : // script mode - [ - { - content: readFileSync( - join( - TEMPLATES_DIR, - 'routePreloadOnLoad/preloadRouteFilesScp.js', - ), - 'utf-8', - ) - .replace( - '"{{routeChunkFilesMap}}"', - JSON.stringify(routeChunkFilesMap), + api.addHTMLHeadScripts({ + fn: () => { + if (api.name === 'build' && routeChunkFilesMap) { + // internal tern app use map mode + return api.config.tern + ? // map mode + [ + { + type: PRELOAD_ROUTE_MAP_SCP_TYPE, + content: JSON.stringify(routeChunkFilesMap), + }, + ] + : // script mode + [ + { + content: readFileSync( + join( + TEMPLATES_DIR, + 'routePreloadOnLoad/preloadRouteFilesScp.js', + ), + 'utf-8', ) - .replace('{{basename}}', api.config.base) - .replace( - '"{{publicPath}}"', - `${ - // handle runtimePublicPath - api.config.runtimePublicPath ? 'window.publicPath||' : '' - }"${api.config.publicPath}"`, - ), - }, - ]; - } + .replace( + '"{{routeChunkFilesMap}}"', + JSON.stringify(routeChunkFilesMap), + ) + .replace('{{basename}}', api.config.base) + .replace( + '"{{publicPath}}"', + `${ + // handle runtimePublicPath + api.config.runtimePublicPath ? 'window.publicPath||' : '' + }"${api.config.publicPath}"`, + ), + }, + ]; + } - return []; + return []; + }, + stage: Infinity, }); api.onBuildComplete(async ({ err, stats }) => { diff --git a/packages/preset-umi/src/features/ssr/ssr.ts b/packages/preset-umi/src/features/ssr/ssr.ts index d5fa28b509c0..ede66b9d22a0 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']), + hydrateFromRoot: 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..1863f0b19b8c 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 || [], + }), + hydrateFromRoot: api.config.ssr?.hydrateFromRoot ?? false, }, }); } diff --git a/packages/preset-umi/templates/server.tpl b/packages/preset-umi/templates/server.tpl index e01021b9836a..02a2a181f97f 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}}}, + hydrateFromRoot: {{{hydrateFromRoot}}} + }; const requestHandler = createRequestHandler(createOpts); /** diff --git a/packages/renderer-react/src/browser.tsx b/packages/renderer-react/src/browser.tsx index 2e3adcc7a6f9..3aae23d7bd1c 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 根节点开始 hydrate + * @doc 默认 false, 从 app root 开始水合,为 true 时从 html 开始 + */ + hydrateFromRoot?: 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/dataFetcher.ts b/packages/renderer-react/src/dataFetcher.ts index 2122a46444ec..0db63b41a6cc 100644 --- a/packages/renderer-react/src/dataFetcher.ts +++ b/packages/renderer-react/src/dataFetcher.ts @@ -12,7 +12,10 @@ export function fetchServerLoader({ url: window.location.href, }).toString(); // 在有basename的情况下__serverLoader的请求路径需要加上basename - const url = `${withEndSlash(basename)}__serverLoader?${query}`; + // FIXME: 先临时解自定义 serverLoader 请求路径的问题,后续改造 serverLoader 时再提取成类似 runtimeServerLoader 的配置项 + const url = `${withEndSlash( + (window as any).umiServerLoaderPath || basename, + )}__serverLoader?${query}`; fetch(url, { credentials: 'include', }) diff --git a/packages/renderer-react/src/html.tsx b/packages/renderer-react/src/html.tsx new file mode 100644 index 000000000000..c80adc363e70 --- /dev/null +++ b/packages/renderer-react/src/html.tsx @@ -0,0 +1,118 @@ +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 }; +} + +export function Html({ + children, + loaderData, + manifest, + metadata, +}: React.PropsWithChildren) { + // TODO: 处理 head 标签,比如 favicon.ico 的一致性 + // TODO: root 支持配置 + 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 ( + + ); + })} + {manifest?.assets['umi.css'] && ( + + )} + + +