diff --git a/package.json b/package.json index 0b4d37f5..0eab1ebc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/stark", - "version": "2.0.2", + "version": "2.1.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/packages/icestark-app/package.json b/packages/icestark-app/package.json index aae74969..aa6c0afb 100644 --- a/packages/icestark-app/package.json +++ b/packages/icestark-app/package.json @@ -1,6 +1,6 @@ { "name": "@ice/stark-app", - "version": "1.2.0", + "version": "1.2.1", "description": "icestark-app is a JavaScript library for icestark, used by sub-application.", "scripts": { "build": "rm -rf lib && tsc", diff --git a/packages/icestark-app/src/AppLink.tsx b/packages/icestark-app/src/AppLink.tsx new file mode 100644 index 00000000..959d0cb4 --- /dev/null +++ b/packages/icestark-app/src/AppLink.tsx @@ -0,0 +1,37 @@ +/* eslint-disable react/jsx-filename-extension */ +import * as React from 'react'; +import formatUrl from './util/formatUrl'; + +export type AppLinkProps = { + to: string; + hashType?: boolean; + replace?: boolean; + message?: string; + children?: React.ReactNode; +} & React.AnchorHTMLAttributes; + +const AppLink = (props: AppLinkProps) => { + const { to, hashType, replace, message, children, ...rest } = props; + const linkTo = formatUrl(to, hashType); + return ( + { + e.preventDefault(); + // eslint-disable-next-line no-alert + if (message && window.confirm(message) === false) { + return false; + } + + const changeState = window.history[replace ? 'replaceState' : 'pushState']; + + changeState({}, null, linkTo); + }} + > + {children} + + ); +}; + +export default AppLink; diff --git a/packages/icestark-app/src/appHistory.ts b/packages/icestark-app/src/appHistory.ts index eb1666eb..e95b977c 100644 --- a/packages/icestark-app/src/appHistory.ts +++ b/packages/icestark-app/src/appHistory.ts @@ -1,9 +1,19 @@ +import formatUrl from './util/formatUrl'; + const appHistory = { - push: (url: string) => { - window.history.pushState({}, null, url); + push: (url: string, hashType?: boolean) => { + window.history.pushState( + {}, + null, + formatUrl(url, hashType) + ); }, - replace: (url: string) => { - window.history.replaceState({}, null, url); + replace: (url: string, hashType?: boolean) => { + window.history.replaceState( + {}, + null, + formatUrl(url, hashType) + ); }, }; diff --git a/packages/icestark-app/src/index.ts b/packages/icestark-app/src/index.ts index d16b48a1..5687db3c 100644 --- a/packages/icestark-app/src/index.ts +++ b/packages/icestark-app/src/index.ts @@ -5,3 +5,4 @@ export { default as registerAppEnter } from './registerAppEnter'; export { default as registerAppLeave } from './registerAppLeave'; export { default as appHistory } from './appHistory'; export { default as isInIcestark } from './isInIcestark'; +export { default as AppLink } from './AppLink'; diff --git a/packages/icestark-app/src/util/formatUrl.ts b/packages/icestark-app/src/util/formatUrl.ts new file mode 100644 index 00000000..17894552 --- /dev/null +++ b/packages/icestark-app/src/util/formatUrl.ts @@ -0,0 +1,10 @@ +/** + * format url + * @param url + * @param hashType + */ +const formatUrl = (url: string, hashType?: boolean) => { + return (hashType && url.indexOf('#') === -1) ? `#${url}` : url; +}; + +export default formatUrl; diff --git a/packages/icestark-app/tests/index.spec.tsx b/packages/icestark-app/tests/index.spec.tsx index 1e04a960..62f2be80 100644 --- a/packages/icestark-app/tests/index.spec.tsx +++ b/packages/icestark-app/tests/index.spec.tsx @@ -10,6 +10,7 @@ import { isInIcestark, } from '../src/index'; import { setCache, getCache } from '../src/cache'; +import formatUrl from '../src/util/formatUrl'; const namespace = 'ICESTARK'; @@ -132,3 +133,13 @@ describe('isInIcestark', () => { expect(isInIcestark()).toBe(true); }); }); + +describe('formatUrl', () => { + test('formatUrl', () => { + expect(formatUrl('/seller')).toBe('/seller'); + + expect(formatUrl('#/seller')).toBe('#/seller'); + + expect(formatUrl('/seller', true)).toBe('#/seller'); + }) +}) diff --git a/src/AppRoute.tsx b/src/AppRoute.tsx index 605c5f0d..7cfb73bf 100644 --- a/src/AppRoute.tsx +++ b/src/AppRoute.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import renderComponent from './util/renderComponent'; import { AppHistory } from './appHistory'; -import { unloadMicroApp, BaseConfig, getAppConfig, createMicroApp, AppConfig } from './apps'; +import { unloadMicroApp, BaseConfig, createMicroApp } from './apps'; import { converArray2String } from './AppRouter'; import { PathData } from './util/matchPath'; -import { setCache } from './util/cache'; import { callCapturedEventListeners, resetCapturedEventListeners } from './util/capturedListeners'; // eslint-disable-next-line import/order import isEqual = require('lodash.isequal'); @@ -41,19 +40,18 @@ export interface AppRouteProps extends BaseConfig { basename?: string; render?: (componentProps: AppRouteComponentProps) => React.ReactElement; path?: string | string[] | PathData[]; - loadingApp?: (appConfig: AppConfig) => void; onAppEnter?: (appConfig: CompatibleAppConfig) => void; onAppLeave?: (appConfig: CompatibleAppConfig) => void; } -export type CompatibleAppConfig = Omit +export type CompatibleAppConfig = Omit /** * Gen compatible app config from AppRoute props */ function genCompatibleAppConfig (appRouteProps: AppRouteProps): CompatibleAppConfig { const appConfig: CompatibleAppConfig = {}; - const omitProperties = ['componentProps', 'cssLoading', 'loadingApp', 'onAppEnter', 'onAppLeave']; + const omitProperties = ['componentProps', 'cssLoading', 'onAppEnter', 'onAppLeave']; Object.keys(appRouteProps).forEach(key => { if (omitProperties.indexOf(key) === -1) { @@ -156,7 +154,7 @@ export default class AppRoute extends React.Component { - const { path, name, rootId, loadingApp, ...rest } = this.props; + const { path, name, rootId, ...rest } = this.props; // reCreate rootElement to remove sub-application instance, // rootElement is created for render sub-application const rootElement: HTMLElement = this.reCreateElementInBase(rootId); @@ -166,10 +164,7 @@ export default class AppRoute extends React.Component boolean; basename?: string; + fetch?: Fetch; } interface AppRouterState { @@ -65,6 +66,7 @@ export default class AppRouter extends React.Component {}, onAppLeave: () => {}, basename: '', + fetch: defaultFetch, }; constructor(props: AppRouterProps) { @@ -73,12 +75,9 @@ export default class AppRouter extends React.Component app.name); } @@ -77,7 +81,7 @@ export function getAppStatus(appName: string) { } export function registerMicroApp(appConfig: AppConfig, appLifecyle?: AppLifecylceOptions) { - // check appConfig.name + // check appConfig.name if (getAppNames().includes(appConfig.name)) { throw Error(`name ${appConfig.name} already been regsitered`); } @@ -131,8 +135,10 @@ export function updateAppConfig(appName: string, config) { // load app js assets export async function loadAppModule(appConfig: AppConfig) { + const { onLoadingApp, onFinishLoading, fetch } = getAppConfig(appConfig.name)?.configuration || globalConfiguration; + let lifecycle: ModuleLifeCycle = {}; - globalConfiguration.onLoadingApp(appConfig); + onLoadingApp(appConfig); const appSandbox = createSandbox(appConfig.sandbox); const { url, container, entry, entryContent, name } = appConfig; const appAssets = url ? getUrlAssets(url) : await getEntryAssets({ @@ -141,13 +147,17 @@ export async function loadAppModule(appConfig: AppConfig) { href: location.href, entryContent, assetsCacheKey: name, + fetch, }); updateAppConfig(appConfig.name, { appAssets, appSandbox }); + + cacheLoadMode(appConfig); + if (appConfig.umd) { await loadAndAppendCssAssets(appAssets); lifecycle = await loadUmdModule(appAssets.jsList, appSandbox); } else { - await appendAssets(appAssets, appSandbox); + await appendAssets(appAssets, appSandbox, fetch); lifecycle = { mount: getCache(AppLifeCycleEnum.AppEnter), unmount: getCache(AppLifeCycleEnum.AppLeave), @@ -155,7 +165,7 @@ export async function loadAppModule(appConfig: AppConfig) { setCache(AppLifeCycleEnum.AppEnter, null); setCache(AppLifeCycleEnum.AppLeave, null); } - globalConfiguration.onFinishLoading(appConfig); + onFinishLoading(appConfig); return combineLifecyle(lifecycle, appConfig); } @@ -198,15 +208,33 @@ export function getAppConfigForLoad (app: string | AppConfig, options?: AppLifec return getAppConfig(name); }; -export async function createMicroApp(app: string | AppConfig, appLifecyle?: AppLifecylceOptions) { +// cache loadMode +export function cacheLoadMode (app: AppConfig) { + const { umd, sandbox } = app; + // cache loadMode + // eslint-disable-next-line no-nested-ternary + const loadMode = umd ? 'umd' : ( sandbox ? 'sandbox' : 'script' ); + setCache('loadMode', loadMode); +} + +export async function createMicroApp(app: string | AppConfig, appLifecyle?: AppLifecylceOptions, configuration?: StartConfiguration) { const appConfig = getAppConfigForLoad(app, appLifecyle); const appName = appConfig && appConfig.name; + // compatible with use inIcestark const container = (app as AppConfig).container || appConfig?.container; - if (container && !getCache('root')) { + if (container) { setCache('root', container); } + if (appConfig && appName) { + // add configuration to every micro app + const userConfiguration = globalConfiguration; + Object.keys(configuration || {}).forEach(key => { + userConfiguration[key] = configuration[key]; + }); + updateAppConfig(appName, { configuration: userConfiguration }); + // check status of app if (appConfig.status === NOT_LOADED || appConfig.status === LOAD_ERROR ) { if (appConfig.title) document.title = appConfig.title; @@ -219,7 +247,7 @@ export async function createMicroApp(app: string | AppConfig, appLifecyle?: AppL updateAppConfig(appName, { ...lifeCycle, status: NOT_MOUNTED }); } } catch (err){ - globalConfiguration.onError(err); + userConfiguration.onError(err); updateAppConfig(appName, { status: LOAD_ERROR }); } if (lifeCycle.mount) { @@ -257,7 +285,8 @@ export async function unmountMicroApp(appName: string) { const appConfig = getAppConfig(appName); if (appConfig && (appConfig.status === MOUNTED || appConfig.status === LOADING_ASSETS || appConfig.status === NOT_MOUNTED)) { // remove assets if app is not cached - emptyAssets(globalConfiguration.shouldAssetsRemove, !appConfig.cached && appConfig.name); + const { shouldAssetsRemove } = getAppConfig(appName)?.configuration || globalConfiguration; + emptyAssets(shouldAssetsRemove, !appConfig.cached && appConfig.name); updateAppConfig(appName, { status: UNMOUNTED }); if (!appConfig.cached && appConfig.appSandbox) { appConfig.appSandbox.clear(); diff --git a/src/start.ts b/src/start.ts index f776436f..765f7d56 100644 --- a/src/start.ts +++ b/src/start.ts @@ -12,6 +12,14 @@ import { AppConfig, getMicroApps, createMicroApp, unmountMicroApp, clearMicroApp import { emptyAssets, recordAssets } from './util/handleAssets'; import { LOADING_ASSETS, MOUNTED } from './util/constant'; +if (!window?.fetch) { + throw new Error('[icestark] window.fetch not found, you need polyfill it'); +} + +export const defaultFetch = window?.fetch.bind(window); + +export type Fetch = typeof window.fetch | ((url: string) => Promise); + export interface StartConfiguration { shouldAssetsRemove?: ( assetUrl?: string, @@ -31,6 +39,7 @@ export interface StartConfiguration { onError?: (err: Error) => void; onActiveApps?: (appConfigs: AppConfig[]) => void; reroute?: (url: string, type: RouteType | 'init' | 'popstate'| 'hashchange') => void; + fetch?: Fetch; } const globalConfiguration: StartConfiguration = { @@ -43,6 +52,7 @@ const globalConfiguration: StartConfiguration = { onError: () => {}, onActiveApps: () => {}, reroute, + fetch: defaultFetch, }; interface OriginalStateFunction { diff --git a/src/util/handleAssets.ts b/src/util/handleAssets.ts index 8ccb943b..6e93a5f0 100644 --- a/src/util/handleAssets.ts +++ b/src/util/handleAssets.ts @@ -2,15 +2,12 @@ import * as urlParse from 'url-parse'; import Sandbox, { SandboxProps, SandboxContructor } from '@ice/sandbox'; import { PREFIX, DYNAMIC, STATIC, IS_CSS_REGEX } from './constant'; import { warn, error } from './message'; +import { Fetch, defaultFetch } from '../start'; -const winFetch = window.fetch; const COMMENT_REGEX = //g; -const SCRIPT_REGEX = /]*>([\s\S]*?)<\/script>/gi; -const SCRIPT_SRC_REGEX = /]*src=['"]?([^'"]*)['"]?\b[^>]*>/gi; -const STYLE_REGEX = /]*>([^<]*)<\/style>/gi; -const LINK_HREF_REGEX = /]*href=['"]?([^'"]*)['"]?\b[^>]*>/gi; -const CSS_REGEX = new RegExp([STYLE_REGEX, LINK_HREF_REGEX].map((reg) => reg.source).join('|'), 'gi'); -const STYLE_SHEET_REGEX = /rel=['"]stylesheet['"]/gi; + +const EMPTY_STRING = ''; +const STYLESHEET_LINK_TYPE = 'stylesheet'; export enum AssetTypeEnum { INLINE = 'inline', @@ -28,7 +25,7 @@ export interface Asset { } export interface ProcessedContent { - html: string; + html: HTMLElement; assets: Assets; } @@ -42,10 +39,6 @@ export interface ParsedConfig { pathname: string; } -export interface Fetch { - (input: RequestInfo, init?: RequestInit): Promise; -} - // Lifecycle Props export interface ILifecycleProps { container: HTMLElement; @@ -154,7 +147,8 @@ export function getUrlAssets(url: string | string[]) { } const cachedScriptsContent: object = {}; -export function fetchScripts(jsList: Asset[], fetch: Fetch = winFetch) { + +export function fetchScripts(jsList: Asset[], fetch = defaultFetch ) { return Promise.all(jsList.map((asset) => { const { type, content } = asset; if (type === AssetTypeEnum.INLINE) { @@ -165,9 +159,9 @@ export function fetchScripts(jsList: Asset[], fetch: Fetch = winFetch) { } })); } -export async function appendAssets(assets: Assets, sandbox?: Sandbox) { +export async function appendAssets(assets: Assets, sandbox?: Sandbox, fetch = defaultFetch) { await loadAndAppendCssAssets(assets); - await loadAndAppendJsAssets(assets, sandbox); + await loadAndAppendJsAssets(assets, sandbox, fetch); } export function parseUrl(entry: string): ParsedConfig { @@ -209,61 +203,75 @@ export function getUrl(entry: string, relativePath: string): string { * If script/link processed by @ice/stark, add comment for it */ export function getComment(tag: string, from: string, type: AssetCommentEnum): string { - return ``; + return `${tag} ${from} ${type} by @ice/stark`; +} + +/** + * check if link is absolute url + * @param url + */ +export function isAbsoluteUrl(url: string): boolean { + return (/^(https?:)?\/\/.+/).test(url); +} + + +export function replaceNodeWithComment(node: HTMLElement, comment: string): void { + if (node?.parentNode) { + const commentNode = document.createComment(comment); + node.parentNode.appendChild(commentNode); + node.parentNode.removeChild(node); + } } /** * html -> { html: processedHtml, assets: processedAssets } */ export function processHtml(html: string, entry?: string): ProcessedContent { - if (!html) return { html: '', assets: { cssList:[], jsList: []} }; - - const processedJSAssets = []; - const processedCSSAssets = []; - const processedHtml = html - .replace(COMMENT_REGEX, '') - .replace(SCRIPT_REGEX, (...args) => { - const [matchStr, matchContent] = args; - if (!matchStr.match(SCRIPT_SRC_REGEX)) { - processedJSAssets.push({ + if (!html) return { html: document.createElement('div'), assets: { cssList:[], jsList: []} }; + + const domContent = (new DOMParser()).parseFromString(html.replace(COMMENT_REGEX, ''), 'text/html'); + + // process js assets + const scripts = Array.from(domContent.getElementsByTagName('script')); + const processedJSAssets = scripts.map(script => { + const inlineScript = script.src === EMPTY_STRING; + + const externalSrc = !inlineScript && (isAbsoluteUrl(script.src) ? script.src : getUrl(entry, script.src)); + const commentType = inlineScript ? AssetCommentEnum.PROCESSED : AssetCommentEnum.REPLACED; + replaceNodeWithComment(script, getComment('script', inlineScript ? 'inline' : script.src, commentType)); + + return { + type: inlineScript ? AssetTypeEnum.INLINE : AssetTypeEnum.EXTERNAL, + content: inlineScript ? script.text : externalSrc, + }; + }); + + // process css assets + const inlineStyleSheets = Array.from(domContent.getElementsByTagName('style')); + const externalStyleSheets = Array.from(domContent.getElementsByTagName('link')) + .filter(link => !link.rel || link.rel.includes(STYLESHEET_LINK_TYPE)); + + const processedCSSAssets = [ + ...inlineStyleSheets + .map(sheet => { + replaceNodeWithComment(sheet, getComment('style', 'inline', AssetCommentEnum.REPLACED)); + return { type: AssetTypeEnum.INLINE, - content: matchContent, - }); - - return getComment('script', 'inline', AssetCommentEnum.REPLACED); - } else { - return matchStr.replace(SCRIPT_SRC_REGEX, (_, argSrc2) => { - const url = argSrc2.indexOf('//') >= 0 ? argSrc2 : getUrl(entry, argSrc2); - processedJSAssets.push({ - type: AssetTypeEnum.EXTERNAL, - content: url, - }); - - return getComment('script', argSrc2, AssetCommentEnum.REPLACED); - }); - } - }) - .replace(CSS_REGEX, (...args) => { - const [matchStr, matchStyle, matchLink] = args; - // not stylesheet, return as it is - if (matchStr.match(STYLE_SHEET_REGEX)) { - const url = matchLink.indexOf('//') >= 0 ? matchLink : getUrl(entry, matchLink); - processedCSSAssets.push({ + content: sheet.innerText, + }; + }), + ...externalStyleSheets + .map((sheet) => { + replaceNodeWithComment(sheet, getComment('link', sheet.href, AssetCommentEnum.PROCESSED)); + return { type: AssetTypeEnum.EXTERNAL, - content: url, - }); - return `${getComment('link', matchLink, AssetCommentEnum.PROCESSED)}`; - } else if (matchStyle){ - processedCSSAssets.push({ - type: AssetTypeEnum.INLINE, - content: matchStyle, - }); - return getComment('style', 'inline', AssetCommentEnum.REPLACED); - } - return matchStr; - }); + content: isAbsoluteUrl(sheet.href) ? sheet.href : getUrl(entry, sheet.href), + }; + }), + ]; + return { - html: processedHtml, + html: domContent.getElementsByTagName('html')[0], assets: { jsList: processedJSAssets, cssList: processedCSSAssets, @@ -279,7 +287,7 @@ export async function getEntryAssets({ entryContent, assetsCacheKey, href, - fetch = winFetch, + fetch = defaultFetch, }: { root: HTMLElement | ShadowRoot; entry?: string; @@ -307,7 +315,9 @@ export async function getEntryAssets({ cachedProcessedContent[assetsCacheKey] = cachedContent; } - root.innerHTML = cachedContent.html; + const { html } = cachedContent; + root.appendChild(html); + return cachedContent.assets; } @@ -423,14 +433,14 @@ export async function loadAndAppendCssAssets(assets: Assets) { * @param {Sandbox} [sandbox] * @returns */ -export async function loadAndAppendJsAssets(assets: Assets, sandbox?: Sandbox) { +export async function loadAndAppendJsAssets(assets: Assets, sandbox?: Sandbox, fetch = defaultFetch) { const jsRoot: HTMLElement = document.getElementsByTagName('head')[0]; const { jsList } = assets; // handle scripts if (sandbox && !sandbox.sandboxDisabled) { - const jsContents = await fetchScripts(jsList); + const jsContents = await fetchScripts(jsList, fetch); // excute code by order jsContents.forEach(script => { sandbox.execScriptInSandbox(script); diff --git a/tests/app.spec.tsx b/tests/app.spec.tsx index 3fa85f60..32fd05d2 100644 --- a/tests/app.spec.tsx +++ b/tests/app.spec.tsx @@ -73,6 +73,7 @@ describe('app start', () => { window.history.pushState({}, 'test', '/test6/a'); expect(getMicroApps().find(item => item.name === 'app6').status).toBe(NOT_LOADED); expect(activeApps).toStrictEqual([]); + unload(); }); diff --git a/tests/handleAssets.spec.tsx b/tests/handleAssets.spec.tsx index 54b6e42a..fc4ea7b3 100644 --- a/tests/handleAssets.spec.tsx +++ b/tests/handleAssets.spec.tsx @@ -14,6 +14,7 @@ import { processHtml, appendExternalScript, getUrlAssets, + isAbsoluteUrl, } from '../src/util/handleAssets'; import { setCache } from '../src/util/cache'; @@ -89,6 +90,9 @@ const tempHTML = ' ' + ' ' + ' ' + + ' ' + '
' + ' ' + ''; @@ -96,7 +100,7 @@ const tempHTML = describe('getComment', () => { test('getComment', () => { expect(getComment('script', 'inline', AssetCommentEnum.REPLACED)).toBe( - '', + 'script inline replaced by @ice/stark', ); expect( @@ -105,35 +109,39 @@ describe('getComment', () => { 'https://g.alicdn.com/platform/common/global.css', AssetCommentEnum.REPLACED, ), - ).toBe(''); + ).toBe('link https://g.alicdn.com/platform/common/global.css replaced by @ice/stark'); expect(getComment('link', '/test.css', AssetCommentEnum.PROCESSED)).toBe( - '', + 'link /test.css processed by @ice/stark', ); }); }); describe('processHtml', () => { test('processHtml', () => { - expect(processHtml(undefined).html).toBe(''); + expect(processHtml(undefined).html.innerHTML).toBe(''); const { html, assets: {jsList, cssList} } = processHtml(tempHTML); + const div = document.createElement('div'); + div.appendChild(html); + const content = div.innerHTML; - expect(html).not.toContain('