From 3f4ec9804f4195d38188d944a2dd35f283baec31 Mon Sep 17 00:00:00 2001 From: maoxiaoke Date: Fri, 26 Mar 2021 10:57:52 +0800 Subject: [PATCH] Feat/prefetch (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 prefetch * feat: 🎸 support AppRouter * feat: 🎸 Optimize the details --- CHANGELOG.md | 4 ++ package.json | 2 +- src/AppRouter.tsx | 43 +++++++++++++++- src/index.ts | 1 + src/start.ts | 18 ++++++- src/util/handleAssets.ts | 80 ++++++++++++++++++++++-------- src/util/prefetch.ts | 104 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 227 insertions(+), 25 deletions(-) create mode 100644 src/util/prefetch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11975faf..7d823b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ See [https://github.com/ice-lab/icestark/releases](https://github.com/ice-lab/icestark/releases) for what has changed in each version of icestark. +## 2.3.0 + +- [feat] support `prefetch` sub-application, which let your micro application fly. ([#188](https://github.com/ice-lab/icestark/issues/188)) + ## 2.2.2 - [fix] `basename` of `AppRouter` makes effect. ([#241](https://github.com/ice-lab/icestark/issues/241)) diff --git a/package.json b/package.json index c8af8f64..29f35164 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/stark", - "version": "2.2.2", + "version": "2.3.0", "description": "Icestark is a JavaScript library for multiple projects, Ice workbench solution.", "scripts": { "install:deps": "rm -rf node_modules && rm -rf ./packages/*/node_modules && yarn install && lerna exec -- npm install", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index b9c0d38c..15850587 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -5,9 +5,10 @@ import appHistory from './appHistory'; import renderComponent from './util/renderComponent'; import { ICESTSRK_ERROR, ICESTSRK_NOT_FOUND } from './util/constant'; import { setCache } from './util/cache'; -import start, { unload, Fetch, defaultFetch } from './start'; +import start, { unload, Fetch, defaultFetch, Prefetch } from './start'; import { matchActivePath, PathData, addLeadingSlash } from './util/matchPath'; import { AppConfig } from './apps'; +import { doPrefetch } from './util/prefetch'; type RouteType = 'pushState' | 'replaceState'; @@ -29,6 +30,7 @@ export interface AppRouterProps { ) => boolean; basename?: string; fetch?: Fetch; + prefetch?: Prefetch; } interface AppRouterState { @@ -68,15 +70,22 @@ export default class AppRouter extends React.Component {}, basename: '', fetch: defaultFetch, + prefetch: false, }; - constructor(props: AppRouterProps) { + constructor(props) { super(props); this.state = { url: location.href, appLoading: '', started: false, }; + + const { fetch, prefetch: strategy, children } = props; + + if (strategy) { + this.prefetch(strategy, children, fetch); + } } componentDidMount() { @@ -109,6 +118,36 @@ export default class AppRouter extends React.Component { + const apps: AppConfig[] = React.Children + /** + * we can do prefetch for url, entry and entryContent. + */ + .map(children, childElement => { + if (React.isValidElement(childElement)) { + const { url, entry, entryContent, name, path } = childElement.props as AppRouteProps; + if (url || entry || entryContent) { + return { + ...childElement.props, + /** + * name of AppRoute may be not provided, use `path` instead. + */ + name: name || converArray2String(path), + }; + } + + } + return false; + }) + .filter(Boolean); + + doPrefetch(apps, strategy, fetch); + } + /** * Trigger Error */ diff --git a/src/index.ts b/src/index.ts index 79cadc4e..14c5d8c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,5 @@ export { default as AppRoute } from './AppRoute'; export { default as AppLink } from './AppLink'; export { default as appHistory } from './appHistory'; export { createMicroApp, registerMicroApps, unmountMicroApp, unloadMicroApp, removeMicroApps, AppConfig } from './apps'; +export { prefetchApps } from './util/prefetch'; export { default as start } from './start'; diff --git a/src/start.ts b/src/start.ts index 765f7d56..3d6f74af 100644 --- a/src/start.ts +++ b/src/start.ts @@ -11,6 +11,7 @@ import { import { AppConfig, getMicroApps, createMicroApp, unmountMicroApp, clearMicroApps } from './apps'; import { emptyAssets, recordAssets } from './util/handleAssets'; import { LOADING_ASSETS, MOUNTED } from './util/constant'; +import { doPrefetch } from './util/prefetch'; if (!window?.fetch) { throw new Error('[icestark] window.fetch not found, you need polyfill it'); @@ -19,6 +20,10 @@ if (!window?.fetch) { export const defaultFetch = window?.fetch.bind(window); export type Fetch = typeof window.fetch | ((url: string) => Promise); +export type Prefetch = + | boolean + | string[] + | ((app: AppConfig) => boolean); export interface StartConfiguration { shouldAssetsRemove?: ( @@ -40,6 +45,7 @@ export interface StartConfiguration { onActiveApps?: (appConfigs: AppConfig[]) => void; reroute?: (url: string, type: RouteType | 'init' | 'popstate'| 'hashchange') => void; fetch?: Fetch; + prefetch?: Prefetch; } const globalConfiguration: StartConfiguration = { @@ -53,6 +59,7 @@ const globalConfiguration: StartConfiguration = { onActiveApps: () => {}, reroute, fetch: defaultFetch, + prefetch: false, }; interface OriginalStateFunction { @@ -84,7 +91,7 @@ export function reroute (url: string, type: RouteType | 'init' | 'popstate'| 'ha // trigger onRouteChange when url is changed if (lastUrl !== url) { globalConfiguration.onRouteChange(url, pathname, query, hash, type); - + const unmountApps = []; const activeApps = []; getMicroApps().forEach((microApp: AppConfig) => { @@ -191,11 +198,20 @@ function start(options?: StartConfiguration) { return; } started = true; + recordAssets(); + // update globalConfiguration Object.keys(options || {}).forEach((configKey) => { globalConfiguration[configKey] = options[configKey]; }); + + const { prefetch, fetch } = globalConfiguration; + if (prefetch) { + doPrefetch(getMicroApps(), prefetch, fetch); + } + + // hajack history & eventListener hijackHistory(); hijackEventListener(); diff --git a/src/util/handleAssets.ts b/src/util/handleAssets.ts index 2af6e358..1d85b7c0 100644 --- a/src/util/handleAssets.ts +++ b/src/util/handleAssets.ts @@ -9,6 +9,9 @@ const COMMENT_REGEX = //g; const EMPTY_STRING = ''; const STYLESHEET_LINK_TYPE = 'stylesheet'; +const cachedScriptsContent: object = {}; +const cachedStyleContent: object = {}; + export enum AssetTypeEnum { INLINE = 'inline', EXTERNAL = 'external', @@ -53,7 +56,7 @@ export function appendCSS( asset: string | Asset, id: string, ): Promise { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { const { type, content } = (asset as Asset); if (!root) reject(new Error(`no root element for css assert: ${content || asset}`)); @@ -65,23 +68,43 @@ export function appendCSS( return; } - const element: HTMLLinkElement = document.createElement('link'); - element.setAttribute(PREFIX, DYNAMIC); - element.id = id; - element.rel = 'stylesheet'; - element.href = content || (asset as string); + /** + * if external resource is cached by prefetch, use cached content instead. + * For cachedStyleContent may fail to fetch (cors, and so on),recover to original way + */ + let useExternalLink = true; + if (type && type === AssetTypeEnum.EXTERNAL && cachedStyleContent[content]) { + try { + const styleElement: HTMLStyleElement = document.createElement('style'); + styleElement.innerHTML = await cachedStyleContent[content]; + root.appendChild(styleElement); + useExternalLink = false; + resolve(); + } catch (e) { + useExternalLink = true; + } + } - element.addEventListener( - 'error', - () => { - error(`css asset loaded error: ${content || asset}`); - return resolve(); - }, - false, - ); - element.addEventListener('load', () => resolve(), false); + if (useExternalLink) { + const element: HTMLLinkElement = document.createElement('link'); + element.setAttribute(PREFIX, DYNAMIC); + element.id = id; + element.rel = 'stylesheet'; + element.href = content || (asset as string); + + element.addEventListener( + 'error', + () => { + error(`css asset loaded error: ${content || asset}`); + return resolve(); + }, + false, + ); + element.addEventListener('load', () => resolve(), false); + + root.appendChild(element); + } - root.appendChild(element); }); } @@ -146,8 +169,6 @@ export function getUrlAssets(url: string | string[]) { return { jsList, cssList }; } -const cachedScriptsContent: object = {}; - export function fetchScripts(jsList: Asset[], fetch = defaultFetch ) { return Promise.all(jsList.map((asset) => { const { type, content } = asset; @@ -159,6 +180,20 @@ export function fetchScripts(jsList: Asset[], fetch = defaultFetch ) { } })); } + +// for prefetch +export function fetchStyles(cssList: Asset[], fetch = defaultFetch) { + return Promise.all( + cssList.map((asset) => { + const { type, content} = asset; + if (type === AssetTypeEnum.INLINE) { + return content; + } + return cachedStyleContent[content] || (cachedStyleContent[content] = fetch(content).then(res => res.text())); + }) + ); +} + export async function appendAssets(assets: Assets, sandbox?: Sandbox, fetch = defaultFetch) { await loadAndAppendCssAssets(assets); await loadAndAppendJsAssets(assets, sandbox, fetch); @@ -300,10 +335,10 @@ export async function getEntryAssets({ entry, entryContent, assetsCacheKey, - href, + href = location.href, fetch = defaultFetch, }: { - root: HTMLElement | ShadowRoot; + root?: HTMLElement | ShadowRoot; entry?: string; entryContent?: string; assetsCacheKey: string; @@ -330,7 +365,10 @@ export async function getEntryAssets({ } const { html } = cachedContent; - root.appendChild(html); + + if (root) { + root.appendChild(html); + } return cachedContent.assets; } diff --git a/src/util/prefetch.ts b/src/util/prefetch.ts new file mode 100644 index 00000000..954ae58d --- /dev/null +++ b/src/util/prefetch.ts @@ -0,0 +1,104 @@ +import { Prefetch, Fetch } from '../start'; +import { MicroApp, AppConfig } from '../apps'; +import { NOT_LOADED } from '../util/constant'; +import { fetchScripts, fetchStyles, getUrlAssets, getEntryAssets } from './handleAssets'; + +/** + * https://github.com/microsoft/TypeScript/issues/21309#issuecomment-376338415 + */ +type RequestIdleCallbackHandle = any; +interface RequestIdleCallbackOptions { + timeout: number; +} +interface RequestIdleCallbackDeadline { + readonly didTimeout: boolean; + timeRemaining: (() => number); +} + +declare global { + interface Window { + requestIdleCallback: (( + callback: ((deadline: RequestIdleCallbackDeadline) => void), + opts?: RequestIdleCallbackOptions, + ) => RequestIdleCallbackHandle); + cancelIdleCallback: ((handle: RequestIdleCallbackHandle) => void); + } +} + +/** + * polyfill/shim for the `requestIdleCallback` and `cancelIdleCallback`. + * https://github.com/pladaria/requestidlecallback-polyfill/blob/master/index.js + */ +window.requestIdleCallback = + window.requestIdleCallback || + function(cb) { + const start = Date.now(); + return setTimeout(function() { + cb({ + didTimeout: false, + timeRemaining() { + return Math.max(0, 50 - (Date.now() - start)); + }, + }); + }, 1); + }; + +window.cancelIdleCallback = + window.cancelIdleCallback || + function(id) { + clearTimeout(id); + }; + +function prefetchIdleTask(fetch = window.fetch) { + return (app: MicroApp) => { + window.requestIdleCallback(async () => { + const { url, entry, entryContent, name } = app; + const { jsList, cssList } = url ? getUrlAssets(url) : await getEntryAssets({ + entry, + entryContent, + assetsCacheKey: name, + fetch, + }); + window.requestIdleCallback(() => fetchScripts(jsList, fetch)); + window.requestIdleCallback(() => fetchStyles(cssList, fetch)); + }); + }; +} + +const names2PrefetchingApps = (names: string[]) => (app: MicroApp) => names.includes(app.name) && (app.status === NOT_LOADED || !app.status); + +/** + * get prefetching apps by strategy + * @param apps + * @returns + */ +const getPrefetchingApps = (apps: MicroApp[]) => (strategy: (app: MicroApp) => boolean) => apps.filter(strategy); + +export function doPrefetch( + apps: MicroApp[], + prefetchStrategy: Prefetch, + fetch: Fetch, +) { + const executeAllPrefetchTasks = (strategy: (app: MicroApp) => boolean) => { + getPrefetchingApps(apps)(strategy) + .forEach(prefetchIdleTask(fetch)); + }; + + if (Array.isArray(prefetchStrategy)) { + executeAllPrefetchTasks(names2PrefetchingApps(prefetchStrategy)); + return; + } + if (typeof prefetchStrategy === 'function') { + executeAllPrefetchTasks(prefetchStrategy); + return; + } + if (prefetchStrategy) { + executeAllPrefetchTasks((app) => app.status === NOT_LOADED || !app.status); + } +} + +export function prefetchApps (apps: AppConfig[], fetch: Fetch) { + if (apps && Array.isArray(apps)) { + apps.forEach(prefetchIdleTask(fetch)); + } +}