Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(preset-umi): support to preload route chunk files #12095

Merged
merged 15 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/bundler-webpack/client/client/client.js

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/preset-umi/.fatherrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,18 @@ import { defineConfig } from 'father';

export default defineConfig({
extends: '../../.fatherrc.base.ts',
cjs: {
ignores: ['src/client/*'],
},
umd: {
entry: 'src/client/preloadRouteFilesScp.ts',
output: 'templates/firstRoutePreload',
chainWebpack(memo) {
memo.output.filename('preloadRouteFilesScp.js');
memo.output.delete('libraryTarget');
memo.output.iife(true);

return memo;
},
},
});
48 changes: 48 additions & 0 deletions packages/preset-umi/src/client/preloadRouteFilesScp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* NOTE: DO NOT USE ADVANCED SYNTAX IN THIS FILE, TO AVOID INSERT HELPERS TO REDUCE SCRIPT SIZE.
*/

import { getPreloadRouteFiles } from '../features/firstRoutePreload/utils';

const basename = '{{basename}}';
const publicPath = '{{publicPath}}';
const pathname = location.pathname;
const routePath =
pathname.startsWith(basename) &&
decodeURI(`/${pathname.slice(basename.length)}`);

// skip preload if basename not match
if (routePath) {
const map = '{{routeChunkFilesMap}}' as any;
const doc = document;
const head = doc.head;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新增异常处理:head 可能不存在。

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

head 应该是始终存在的,因为脚本插入时机是 addHTMLHeadScripts

const createElement = doc.createElement.bind(doc);
const files = getPreloadRouteFiles(routePath, map, {
publicPath,
});

files?.forEach((file) => {
const type = file.type;
const url = file.url;
let tag: HTMLLinkElement | HTMLScriptElement;

if (type === 'js') {
tag = createElement('script');
tag.src = url;
tag.async = true;
} else if (type === 'css') {
tag = createElement('link');
tag.href = url;
tag.rel = 'preload';
tag.as = 'style';
} else {
return;
}

file.attrs.forEach((attr) => {
tag.setAttribute(attr[0], attr[1] || '');
});

head.appendChild(tag);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import type { StatsCompilation } from '@umijs/bundler-webpack/compiled/webpack';
import { lodash, logger, winPath } from '@umijs/utils';
import { readFileSync } from 'fs';
import { dirname, isAbsolute, join, relative } from 'path';
import { TEMPLATES_DIR } from '../../constants';
import { createResolver } from '../../libs/scan';
import type { IApi, IRoute } from '../../types';
import { PRELOAD_ROUTE_MAP_SCP_TYPE } from './utils';

export interface IRouteChunkFilesMap {
/**
* script attr prefix (package.json name)
*/
p: string;
/**
* bundler type
*/
b: string;
/**
* all chunk files
*/
f: [string, string | number][];
/**
* route files index map
*/
r: Record<string, number[]>;
}

/**
* forked from: https://github.com/remix-run/react-router/blob/fb0f1f94778f4762989930db209e6a111504aa63/packages/router/utils.ts#L688C1-L719
*/
const routeScoreCache = new Map<string, number>();

function computeRouteScore(path: string): number {
if (!routeScoreCache.get(path)) {
const paramRe = /^:[\w-]+$/;
const dynamicSegmentValue = 3;
const emptySegmentValue = 1;
const staticSegmentValue = 10;
const splatPenalty = -2;
const isSplat = (s: string) => s === '*';
let segments = path.split('/');
let initialScore = segments.length;
if (segments.some(isSplat)) {
initialScore += splatPenalty;
}

routeScoreCache.set(
path,
segments
.filter((s) => !isSplat(s))
.reduce(
(score, segment) =>
score +
(paramRe.test(segment)
? dynamicSegmentValue
: segment === ''
? emptySegmentValue
: staticSegmentValue),
initialScore,
),
);
}

return routeScoreCache.get(path)!;
}

export default (api: IApi) => {
let routeChunkFilesMap: IRouteChunkFilesMap;

// enable when package name available
// because preload script use package name as attribute prefix value
api.describe({
enableBy: () => Boolean(api.pkg.name),
});

api.addHTMLHeadScripts(() => {
if (api.name === 'build') {
return api.config.tern
? // map mode (for internal tern app)
[
{
type: PRELOAD_ROUTE_MAP_SCP_TYPE,
content: JSON.stringify(routeChunkFilesMap),
},
]
: // script mode
[
{
content: readFileSync(
join(
TEMPLATES_DIR,
'firstRoutePreload/preloadRouteFilesScp.js',
),
'utf-8',
)
.replace(
'"{{routeChunkFilesMap}}"',
JSON.stringify(routeChunkFilesMap),
)
.replace('{{basename}}', api.config.base)
.replace(
'"{{publicPath}}"',
`${
// handle runtimePublicPath
api.config.runtimePublicPath ? 'window.publicPath||' : ''
}"${api.config.publicPath}"`,
),
},
];
}

return [];
});

api.onBuildComplete(async ({ err, stats }) => {
if (!err && !stats.hasErrors()) {
const routeModulePath = join(api.paths.absTmpPath, 'core/route.tsx');
const routeModuleName = winPath(relative(api.cwd, routeModulePath));
const resolver = createResolver({ alias: api.config.alias });
const { chunks = [] } = stats.toJson
? // webpack
stats.toJson()
: // mako
(stats.compilation as unknown as StatsCompilation);

// collect all chunk files and file chunks indexes
const chunkFiles: Record<string, { index: number; id: string | number }> =
{};
const fileChunksMap: Record<
string,
{ files: string[]; indexes?: number[] }
> = {};
const pickPreloadFiles = (files: string[]) =>
files.filter((f) => f.endsWith('.js') || f.endsWith('.css'));

for (const chunk of chunks) {
const routeOrigins = chunk.origins!.filter((origin) =>
origin.moduleName?.endsWith(routeModuleName),
);

for (const origin of routeOrigins) {
const queue = [chunk.id!].concat(chunk.siblings!);
const visited: typeof queue = [];
const files: string[] = [];
let fileAbsPath: string;

// resolve route file path
try {
fileAbsPath = await resolver.resolve(
dirname(routeModulePath),
origin.request!,
);
} catch (err) {
logger.error(
`[firstRoutePreload]: route file resolve error, cannot preload for ${origin.request!}`,
);
continue;
}

// collect all related chunk files for route file
while (queue.length) {
const currentId = queue.shift()!;

if (!visited.includes(currentId)) {
const currentChunk = chunks.find((c) => c.id === currentId)!;

// skip sibling entry chunk
if (currentChunk.entry) continue;

// merge files
pickPreloadFiles(chunk.files!).forEach((file) => {
chunkFiles[file] ??= {
index: Object.keys(chunkFiles).length,
id: currentId,
};
});

// merge files
files.push(...pickPreloadFiles(currentChunk.files!));

// continue to search sibling chunks
queue.push(...currentChunk.siblings!);

// mark as visited
visited.push(currentId);
}
}

fileChunksMap[fileAbsPath] = { files };
}
}

// generate indexes for file chunks
Object.values(fileChunksMap).forEach((item) => {
item.indexes = item.files.map((f) => chunkFiles[f].index);
});

// generate map for path -> files (include parent route files)
const routeFilesMap: Record<string, number[]> = {};

for (const route of Object.values<IRoute>(api.appData.routes)) {
// skip redirect route
if (!route.file) continue;

let current = route;
const files: string[] = [];

do {
// skip inline function route file
if (current.file && !current.file.startsWith('(')) {
try {
const fileReqPath =
isAbsolute(current.file!) || current.file!.startsWith('@/')
? current.file!
: current.file!.replace(/^(\.\/)?/, './');
const fileAbsPath = await resolver.resolve(
api.paths.absPagesPath,
fileReqPath,
);

files.push(fileAbsPath);
} catch {
logger.error(
`[firstRoutePreload]: route file resolve error, cannot preload for ${current.file}`,
);
}
}
current = current.parentId && api.appData.routes[current.parentId];
} while (current);

const indexes = files.reduce<number[]>((indexes, file) => {
// why fileChunksMap[file] may not existing?
// because Mako will merge minimal async chunk into entry chunk
// so the merged route chunk does not has to preload
return indexes.concat(fileChunksMap[file]?.indexes || []);
}, []);

routeFilesMap[route.absPath] =
// why different route may has same absPath?
// because umi implement route.wrappers via nested routes way, the wrapper route will has same absPath with the nested route
// so we always select the longest file indexes for the nested route
!routeFilesMap[route.absPath] ||
routeFilesMap[route.absPath].length < indexes.length
? indexes
: routeFilesMap[route.absPath];
}

routeChunkFilesMap = {
p: api.pkg.name!,
b: api.appData.bundler!,
f: Object.entries(chunkFiles).map(([k, { id }]) => [k, id]),
// sort similar to react-router@6
r: lodash(routeFilesMap)
.toPairs()
.sort(
([a]: [string, number[]], [b]: [string, number[]]) =>
computeRouteScore(a) - computeRouteScore(b),
)
.fromPairs()
.value() as any,
};
}
});
};
46 changes: 46 additions & 0 deletions packages/preset-umi/src/features/firstRoutePreload/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* NOTE: DO NOT USE ADVANCED SYNTAX IN THIS FILE, TO AVOID INSERT HELPERS TO REDUCE SCRIPT SIZE.
*/

import type { IRouteChunkFilesMap } from './firstRoutePreload';

interface IPreloadRouteFile {
type: 'js' | 'css' | unknown;
url: string;
attrs: ([string, string] | [string])[];
}

export const PRELOAD_ROUTE_MAP_SCP_TYPE = 'umi-route-chunk-files-map';

export function getPreloadRouteFiles(
path: string,
map: IRouteChunkFilesMap,
opts: { publicPath: string },
): IPreloadRouteFile[] | undefined {
const matched: IRouteChunkFilesMap['r'][string] | undefined =
// search for static route
map.r[path] ||
// search for dynamic route
Object.entries(map.r).find((p) => {
const route = p[0];
const reg = new RegExp(
// replace /:id to /[^/]+
// replace /* to /.+
`^${route.replace(/\/:[^/]+/g, '/[^/]+').replace('/*', '/.+')}$`,
);

return reg.test(path);
})?.[1];

return matched?.map((i) => {
const id = map.f[i][1];
const file = map.f[i][0];
const ext = file.split('.').pop();

return {
type: ext,
url: `${opts.publicPath}${file}`,
attrs: [[`data-${map.b}`, `${map.p}:${id}`]],
};
});
}
Loading
Loading