split dynamic append patcher for loose and strict sandbox (#995)

*  split dynamic append patcher for proxy and non-proxy sandbox

*  change useLooseSandbox to sandbox.loose configuration

*  improve warning message for non-proxy environment
This commit is contained in:
Kuitos 2020-10-14 14:26:58 +08:00 committed by GitHub
parent d97bf55d20
commit d537790f7d
14 changed files with 585 additions and 555 deletions

View File

@ -14,6 +14,10 @@
</style>
</head>
<body>
<button id="unmount">unmount</button>
<button id="mount">mount</button>
<div id="react15">react loading...</div>
<div id="vue">vue loading...</div>

View File

@ -1,28 +1,16 @@
import { loadMicroApp } from '../../es';
const app1 = loadMicroApp(
{ name: 'react15', entry: '//localhost:7102', container: '#react15' },
{
sandbox: {
// strictStyleIsolation: true,
},
},
);
let app;
// for cached scenario
setTimeout(() => {
app1.unmount();
function mount() {
app = loadMicroApp({ name: 'react15', entry: '//localhost:7102', container: '#react15' });
}
setTimeout(() => {
loadMicroApp({ name: 'react15', entry: '//localhost:7102', container: '#react15' });
}, 1000 * 5);
}, 1000 * 5);
function unmount() {
app.unmount();
}
const app2 = loadMicroApp(
{ name: 'vue', entry: '//localhost:7101', container: '#vue' },
{
sandbox: {
// strictStyleIsolation: true,
},
},
);
document.querySelector('#mount').addEventListener('click', mount);
document.querySelector('#unmount').addEventListener('click', unmount);
loadMicroApp({ name: 'vue', entry: '//localhost:7101', container: '#vue' });

View File

@ -108,10 +108,11 @@ export function start(opts: FrameworkConfiguration = {}) {
if (sandbox) {
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
// 快照沙箱不支持非 singular 模式
frameworkConfiguration.sandbox = typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true };
if (!singular) {
console.error('[qiankun] singular is forced to be true when sandbox enable but proxySandbox unavailable');
frameworkConfiguration.singular = true;
console.warn(
'[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
);
}
}
}

View File

@ -60,6 +60,10 @@ type QiankunSpecialOpts = {
| {
strictStyleIsolation?: boolean;
experimentalStyleIsolation?: boolean;
/**
* @deprecated We use strict mode by default
*/
loose?: boolean;
patchers?: Patcher[];
};
/*

View File

@ -244,12 +244,12 @@ export async function loadApp<T extends object>(
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const enableScopedCSS = isEnableScopedCSS(configuration);
const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
let appWrapperElement: HTMLElement | null = createElement(appContent, strictStyleIsolation);
if (appWrapperElement && isEnableScopedCSS(configuration)) {
const enableScopedCSS = isEnableScopedCSS(sandbox);
if (appWrapperElement && enableScopedCSS) {
const styleNodes = appWrapperElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appWrapperElement!, stylesheetElement, appName);
@ -277,12 +277,13 @@ export async function loadApp<T extends object>(
let global = window;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
if (sandbox) {
const sandboxInstance = createSandbox(
appName,
appWrapperGetter,
Boolean(singular),
enableScopedCSS,
useLooseSandbox,
excludeAssetFilter,
);
// 用沙箱的代理对象作为接下来使用的全局对象
@ -301,7 +302,7 @@ export async function loadApp<T extends object>(
await execHooksChain(toArray(beforeLoad), app, global);
// get the lifecycle hooks from module exports
const scriptExports: any = await execScripts(global, !singular);
const scriptExports: any = await execScripts(global, !useLooseSandbox);
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global);
const {

View File

@ -24,33 +24,26 @@ export { css } from './patchers';
*
* @param appName
* @param elementGetter
* @param singular
* @param scopedCSS
* @param useLooseSandbox
* @param excludeAssetFilter
*/
export function createSandbox(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
singular: boolean,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
) {
let sandbox: SandBox;
if (window.Proxy) {
sandbox = singular ? new LegacySandbox(appName) : new ProxySandbox(appName);
sandbox = useLooseSandbox ? new LegacySandbox(appName) : new ProxySandbox(appName);
} else {
sandbox = new SnapshotSandbox(appName);
}
// some side effect could be be invoked while bootstrapping, such as dynamic stylesheet injection with style-loader, especially during the development phase
const bootstrappingFreers = patchAtBootstrapping(
appName,
elementGetter,
sandbox,
singular,
scopedCSS,
excludeAssetFilter,
);
const bootstrappingFreers = patchAtBootstrapping(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter);
// mounting freers are one-off and should be re-init at every mounting time
let mountingFreers: Freer[] = [];
@ -80,7 +73,7 @@ export function createSandbox(
/* ------------------------------------------ 2. 开启全局变量补丁 ------------------------------------------*/
// render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
mountingFreers = patchAtMounting(appName, elementGetter, sandbox, singular, scopedCSS, excludeAssetFilter);
mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter);
/* ------------------------------------------ 3. 重置一些初始化时的副作用 ------------------------------------------*/
// 存在 rebuilder 则表明有些副作用需要重建

View File

@ -1,479 +0,0 @@
/**
* @author Kuitos
* @since 2019-10-21
*/
import { execScripts } from 'import-html-entry';
import { isFunction, noop } from 'lodash';
import { checkActivityFunctions } from 'single-spa';
import { frameworkConfiguration } from '../../apis';
import { Freer } from '../../interfaces';
import { attachDocProxySymbol } from '../common';
import * as css from './css';
const styledComponentSymbol = Symbol('styled-component-qiankun');
const attachElementContainerSymbol = Symbol('attach-proxy-container');
declare global {
interface HTMLStyleElement {
// eslint-disable-next-line no-undef
[styledComponentSymbol]?: CSSRuleList;
}
}
const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
const rawHeadRemoveChild = HTMLHeadElement.prototype.removeChild;
const rawBodyAppendChild = HTMLBodyElement.prototype.appendChild;
const rawBodyRemoveChild = HTMLBodyElement.prototype.removeChild;
const rawHeadInsertBefore = HTMLHeadElement.prototype.insertBefore;
const rawRemoveChild = HTMLElement.prototype.removeChild;
const rawDocumentCreateElement = Document.prototype.createElement;
const SCRIPT_TAG_NAME = 'SCRIPT';
const LINK_TAG_NAME = 'LINK';
const STYLE_TAG_NAME = 'STYLE';
const proxyContainerInfoMapper = new WeakMap<WindowProxy, Record<string, any>>();
function isHijackingTag(tagName?: string) {
return (
tagName?.toUpperCase() === LINK_TAG_NAME ||
tagName?.toUpperCase() === STYLE_TAG_NAME ||
tagName?.toUpperCase() === SCRIPT_TAG_NAME
);
}
/**
* Check if a style element is a styled-component liked.
* A styled-components liked element is which not have textContext but keep the rules in its styleSheet.cssRules.
* Such as the style element generated by styled-components and emotion.
* @param element
*/
function isStyledComponentsLike(element: HTMLStyleElement) {
return !element.textContent && ((element.sheet as CSSStyleSheet)?.cssRules.length || getCachedRules(element)?.length);
}
function getCachedRules(element: HTMLStyleElement) {
return element[styledComponentSymbol];
}
function setCachedRules(element: HTMLStyleElement, cssRules: CSSRuleList) {
Object.defineProperty(element, styledComponentSymbol, { value: cssRules, configurable: true, enumerable: false });
}
function patchCustomEvent(e: CustomEvent, elementGetter: () => HTMLScriptElement | null): CustomEvent {
Object.defineProperties(e, {
srcElement: {
get: elementGetter,
},
target: {
get: elementGetter,
},
});
return e;
}
function getOverwrittenAppendChildOrInsertBefore(opts: {
appName: string;
proxy: WindowProxy;
singular: boolean;
dynamicStyleSheetElements: HTMLStyleElement[];
appWrapperGetter: CallableFunction;
rawDOMAppendOrInsertBefore: <T extends Node>(newChild: T, refChild?: Node | null) => T;
scopedCSS: boolean;
excludeAssetFilter?: CallableFunction;
elementAttachmentMap: WeakMap<HTMLElement, Node>;
}) {
return function appendChildOrInsertBefore<T extends Node>(
this: HTMLHeadElement | HTMLBodyElement,
newChild: T,
refChild?: Node | null,
) {
let element = newChild as any;
const { rawDOMAppendOrInsertBefore } = opts;
if (element.tagName) {
// eslint-disable-next-line prefer-const
let { appName, appWrapperGetter, proxy, singular, dynamicStyleSheetElements } = opts;
const { scopedCSS, excludeAssetFilter } = opts;
const storedContainerInfo = element[attachElementContainerSymbol];
if (storedContainerInfo) {
// eslint-disable-next-line prefer-destructuring
appName = storedContainerInfo.appName;
// eslint-disable-next-line prefer-destructuring
singular = storedContainerInfo.singular;
// eslint-disable-next-line prefer-destructuring
appWrapperGetter = storedContainerInfo.appWrapperGetter;
// eslint-disable-next-line prefer-destructuring
dynamicStyleSheetElements = storedContainerInfo.dynamicStyleSheetElements;
// eslint-disable-next-line prefer-destructuring
proxy = storedContainerInfo.proxy;
}
const invokedByMicroApp = singular
? // check if the currently specified application is active
// While we switch page from qiankun app to a normal react routing page, the normal one may load stylesheet dynamically while page rendering,
// but the url change listener must to wait until the current call stack is flushed.
// This scenario may cause we record the stylesheet from react routing page dynamic injection,
// and remove them after the url change triggered and qiankun app is unmouting
// see https://github.com/ReactTraining/history/blob/master/modules/createHashHistory.js#L222-L230
checkActivityFunctions(window.location).some(name => name === appName)
: // have storedContainerInfo means it invoked by a micro app in multiply mode
!!storedContainerInfo;
switch (element.tagName) {
case LINK_TAG_NAME:
case STYLE_TAG_NAME: {
const stylesheetElement: HTMLLinkElement | HTMLStyleElement = newChild as any;
const { href } = stylesheetElement as HTMLLinkElement;
if (!invokedByMicroApp || (excludeAssetFilter && href && excludeAssetFilter(href))) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
const mountDOM = appWrapperGetter();
if (scopedCSS) {
css.process(mountDOM, stylesheetElement, appName);
}
// eslint-disable-next-line no-shadow
dynamicStyleSheetElements.push(stylesheetElement);
const referenceNode = mountDOM.contains(refChild) ? refChild : null;
return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);
}
case SCRIPT_TAG_NAME: {
const { src, text } = element as HTMLScriptElement;
// some script like jsonp maybe not support cors which should't use execScripts
if (!invokedByMicroApp || (excludeAssetFilter && src && excludeAssetFilter(src))) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
const mountDOM = appWrapperGetter();
const { fetch } = frameworkConfiguration;
const referenceNode = mountDOM.contains(refChild) ? refChild : null;
if (src) {
execScripts(null, [src], proxy, {
fetch,
strictGlobal: !singular,
beforeExec: () => {
Object.defineProperty(document, 'currentScript', {
get(): any {
return element;
},
configurable: true,
});
},
success: () => {
// we need to invoke the onload event manually to notify the event listener that the script was completed
// here are the two typical ways of dynamic script loading
// 1. element.onload callback way, which webpack and loadjs used, see https://github.com/muicss/loadjs/blob/master/src/loadjs.js#L138
// 2. addEventListener way, which toast-loader used, see https://github.com/pyrsmk/toast/blob/master/src/Toast.ts#L64
const loadEvent = new CustomEvent('load');
if (isFunction(element.onload)) {
element.onload(patchCustomEvent(loadEvent, () => element));
} else {
element.dispatchEvent(loadEvent);
}
element = null;
},
error: () => {
const errorEvent = new CustomEvent('error');
if (isFunction(element.onerror)) {
element.onerror(patchCustomEvent(errorEvent, () => element));
} else {
element.dispatchEvent(errorEvent);
}
element = null;
},
});
const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
opts.elementAttachmentMap.set(element, dynamicScriptCommentElement);
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode);
}
execScripts(null, [`<script>${text}</script>`], proxy, {
strictGlobal: !singular,
success: element.onload,
error: element.onerror,
});
const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
opts.elementAttachmentMap.set(element, dynamicInlineScriptCommentElement);
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
}
default:
break;
}
}
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
};
}
function getNewRemoveChild(opts: {
appWrapperGetter: CallableFunction;
headOrBodyRemoveChild: typeof HTMLElement.prototype.removeChild;
elementAttachmentMap: WeakMap<HTMLElement, Node>;
}) {
return function removeChild<T extends Node>(this: HTMLHeadElement | HTMLBodyElement, child: T) {
const { headOrBodyRemoveChild } = opts;
try {
const { tagName } = child as any;
if (isHijackingTag(tagName)) {
let { appWrapperGetter } = opts;
const storedContainerInfo = (child as any)[attachElementContainerSymbol];
if (storedContainerInfo) {
// eslint-disable-next-line prefer-destructuring
appWrapperGetter = storedContainerInfo.appWrapperGetter;
}
// container may had been removed while app unmounting if the removeChild action was async
const container = appWrapperGetter();
const attachedElement = opts.elementAttachmentMap.get(child as any) || child;
if (container.contains(attachedElement)) {
return rawRemoveChild.call(container, attachedElement) as T;
}
}
} catch (e) {
console.warn(e);
}
return headOrBodyRemoveChild.call(this, child) as T;
};
}
function patchHTMLDynamicAppendPrototypeFunctions(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
singular = true,
scopedCSS = false,
dynamicStyleSheetElements: HTMLStyleElement[],
excludeAssetFilter?: CallableFunction,
) {
const elementAttachmentMap = new WeakMap();
// Just overwrite it while it have not been overwrite
if (
HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&
HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore
) {
HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawHeadAppendChild,
appName,
appWrapperGetter,
proxy,
singular,
dynamicStyleSheetElements,
scopedCSS,
excludeAssetFilter,
elementAttachmentMap,
}) as typeof rawHeadAppendChild;
HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawBodyAppendChild,
appName,
appWrapperGetter,
proxy,
singular,
dynamicStyleSheetElements,
scopedCSS,
excludeAssetFilter,
elementAttachmentMap,
}) as typeof rawBodyAppendChild;
HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
appName,
appWrapperGetter,
proxy,
singular,
dynamicStyleSheetElements,
scopedCSS,
excludeAssetFilter,
elementAttachmentMap,
}) as typeof rawHeadInsertBefore;
}
// Just overwrite it while it have not been overwrite
if (
HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&
HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild
) {
HTMLHeadElement.prototype.removeChild = getNewRemoveChild({
appWrapperGetter,
headOrBodyRemoveChild: rawHeadRemoveChild,
elementAttachmentMap,
});
HTMLBodyElement.prototype.removeChild = getNewRemoveChild({
appWrapperGetter,
headOrBodyRemoveChild: rawBodyRemoveChild,
elementAttachmentMap,
});
}
return function unpatch(recoverPrototype: boolean) {
if (recoverPrototype) {
HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
}
};
}
function patchDocumentCreateElement(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
singular: boolean,
proxy: Window,
dynamicStyleSheetElements: HTMLStyleElement[],
) {
if (singular) {
return noop;
}
proxyContainerInfoMapper.set(proxy, { appName, proxy, appWrapperGetter, dynamicStyleSheetElements, singular });
if (Document.prototype.createElement === rawDocumentCreateElement) {
Document.prototype.createElement = function createElement<K extends keyof HTMLElementTagNameMap>(
this: Document,
tagName: K,
options?: ElementCreationOptions,
): HTMLElement {
const element = rawDocumentCreateElement.call(this, tagName, options);
if (isHijackingTag(tagName)) {
const proxyContainerInfo = proxyContainerInfoMapper.get(this[attachDocProxySymbol]);
if (proxyContainerInfo) {
Object.defineProperty(element, attachElementContainerSymbol, {
value: proxyContainerInfo,
enumerable: false,
});
}
}
return element;
};
}
return function unpatch(recoverPrototype: boolean) {
proxyContainerInfoMapper.delete(proxy);
if (recoverPrototype) {
Document.prototype.createElement = rawDocumentCreateElement;
}
};
}
let bootstrappingPatchCount = 0;
let mountingPatchCount = 0;
/**
* Just hijack dynamic head append, that could avoid accidentally hijacking the insertion of elements except in head.
* Such a case: ReactDOM.createPortal(<style>.test{color:blue}</style>, container),
* this could made we append the style element into app wrapper but it will cause an error while the react portal unmounting, as ReactDOM could not find the style in body children list.
* @param appName
* @param appWrapperGetter
* @param proxy
* @param mounting
* @param singular
* @param scopedCSS
* @param excludeAssetFilter
*/
export default function patch(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
singular = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
): Freer {
let dynamicStyleSheetElements: Array<HTMLLinkElement | HTMLStyleElement> = [];
const unpatchDocumentCreate = patchDocumentCreateElement(
appName,
appWrapperGetter,
singular,
proxy,
dynamicStyleSheetElements,
);
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
appName,
appWrapperGetter,
proxy,
singular,
scopedCSS,
dynamicStyleSheetElements,
excludeAssetFilter,
);
if (!mounting) bootstrappingPatchCount++;
if (mounting) mountingPatchCount++;
return function free() {
// bootstrap patch just called once but its freer will be called multiple times
if (!mounting && bootstrappingPatchCount !== 0) bootstrappingPatchCount--;
if (mounting) mountingPatchCount--;
const allMicroAppUnmounted = mountingPatchCount === 0 && bootstrappingPatchCount === 0;
// release the overwrite prototype after all the micro apps unmounted
unpatchDynamicAppendPrototypeFunctions(allMicroAppUnmounted);
unpatchDocumentCreate(allMicroAppUnmounted);
dynamicStyleSheetElements.forEach(stylesheetElement => {
/*
With a styled-components generated style element, we need to record its cssRules for restore next re-mounting time.
We're doing this because the sheet of style element is going to be cleaned automatically by browser after the style element dom removed from document.
see https://www.w3.org/TR/cssom-1/#associated-css-style-sheet
*/
if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
if (stylesheetElement.sheet) {
// record the original css rules of the style element for restore
setCachedRules(stylesheetElement, (stylesheetElement.sheet as CSSStyleSheet).cssRules);
}
}
// As now the sub app content all wrapped with a special id container,
// the dynamic style sheet would be removed automatically while unmoutting
});
return function rebuild() {
dynamicStyleSheetElements.forEach(stylesheetElement => {
// re-append the dynamic stylesheet to sub-app container
// Using document.head.appendChild ensures that appendChild calls
// can also directly use the HTMLHeadElement.prototype.appendChild method which is overwritten at mounting phase
document.head.appendChild.call(appWrapperGetter(), stylesheetElement);
/*
get the stored css rules from styled-components generated element, and the re-insert rules for them.
note that we must do this after style element had been added to document, which stylesheet would be associated to the document automatically.
check the spec https://www.w3.org/TR/cssom-1/#associated-css-style-sheet
*/
if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
const cssRules = getCachedRules(stylesheetElement);
if (cssRules) {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < cssRules.length; i++) {
const cssRule = cssRules[i];
(stylesheetElement.sheet as CSSStyleSheet).insertRule(cssRule.cssText);
}
}
}
});
// As the hijacker will be invoked every mounting phase, we could release the cache for gc after rebuilding
if (mounting) {
dynamicStyleSheetElements = [];
}
};
};
}

View File

@ -0,0 +1,313 @@
/**
* @author Kuitos
* @since 2019-10-21
*/
import { execScripts } from 'import-html-entry';
import { isFunction } from 'lodash';
import { frameworkConfiguration } from '../../../apis';
import * as css from '../css';
export const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
const rawHeadRemoveChild = HTMLHeadElement.prototype.removeChild;
const rawBodyAppendChild = HTMLBodyElement.prototype.appendChild;
const rawBodyRemoveChild = HTMLBodyElement.prototype.removeChild;
const rawHeadInsertBefore = HTMLHeadElement.prototype.insertBefore;
const rawRemoveChild = HTMLElement.prototype.removeChild;
const SCRIPT_TAG_NAME = 'SCRIPT';
const LINK_TAG_NAME = 'LINK';
const STYLE_TAG_NAME = 'STYLE';
export function isHijackingTag(tagName?: string) {
return (
tagName?.toUpperCase() === LINK_TAG_NAME ||
tagName?.toUpperCase() === STYLE_TAG_NAME ||
tagName?.toUpperCase() === SCRIPT_TAG_NAME
);
}
/**
* Check if a style element is a styled-component liked.
* A styled-components liked element is which not have textContext but keep the rules in its styleSheet.cssRules.
* Such as the style element generated by styled-components and emotion.
* @param element
*/
export function isStyledComponentsLike(element: HTMLStyleElement) {
return (
!element.textContent &&
((element.sheet as CSSStyleSheet)?.cssRules.length || getStyledElementCSSRules(element)?.length)
);
}
const styledComponentCSSRulesMap = new WeakMap<HTMLStyleElement, CSSRuleList>();
const dynamicScriptAttachedCommentMap = new WeakMap<HTMLScriptElement, Comment>();
export function recordStyledComponentsCSSRules(styleElements: HTMLStyleElement[]): void {
styleElements.forEach(styleElement => {
/*
With a styled-components generated style element, we need to record its cssRules for restore next re-mounting time.
We're doing this because the sheet of style element is going to be cleaned automatically by browser after the style element dom removed from document.
see https://www.w3.org/TR/cssom-1/#associated-css-style-sheet
*/
if (styleElement instanceof HTMLStyleElement && isStyledComponentsLike(styleElement)) {
if (styleElement.sheet) {
// record the original css rules of the style element for restore
styledComponentCSSRulesMap.set(styleElement, (styleElement.sheet as CSSStyleSheet).cssRules);
}
}
});
}
export function getStyledElementCSSRules(styledElement: HTMLStyleElement): CSSRuleList | undefined {
return styledComponentCSSRulesMap.get(styledElement);
}
function patchCustomEvent(e: CustomEvent, elementGetter: () => HTMLScriptElement | null): CustomEvent {
Object.defineProperties(e, {
srcElement: {
get: elementGetter,
},
target: {
get: elementGetter,
},
});
return e;
}
export type ContainerConfig = {
appName: string;
proxy: WindowProxy;
strictGlobal: boolean;
dynamicStyleSheetElements: HTMLStyleElement[];
appWrapperGetter: CallableFunction;
scopedCSS: boolean;
excludeAssetFilter?: CallableFunction;
};
function getOverwrittenAppendChildOrInsertBefore(opts: {
rawDOMAppendOrInsertBefore: <T extends Node>(newChild: T, refChild?: Node | null) => T;
isInvokedByMicroApp: (element: HTMLElement) => boolean;
containerConfigGetter: (element: HTMLElement) => ContainerConfig;
}) {
return function appendChildOrInsertBefore<T extends Node>(
this: HTMLHeadElement | HTMLBodyElement,
newChild: T,
refChild?: Node | null,
) {
let element = newChild as any;
const { rawDOMAppendOrInsertBefore, isInvokedByMicroApp, containerConfigGetter } = opts;
if (!isInvokedByMicroApp(element)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
if (element.tagName) {
const containerConfig = containerConfigGetter(element);
const {
appName,
appWrapperGetter,
proxy,
strictGlobal,
dynamicStyleSheetElements,
scopedCSS,
excludeAssetFilter,
} = containerConfig;
switch (element.tagName) {
case LINK_TAG_NAME:
case STYLE_TAG_NAME: {
const stylesheetElement: HTMLLinkElement | HTMLStyleElement = newChild as any;
const { href } = stylesheetElement as HTMLLinkElement;
if (excludeAssetFilter && href && excludeAssetFilter(href)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
const mountDOM = appWrapperGetter();
if (scopedCSS) {
css.process(mountDOM, stylesheetElement, appName);
}
// eslint-disable-next-line no-shadow
dynamicStyleSheetElements.push(stylesheetElement);
const referenceNode = mountDOM.contains(refChild) ? refChild : null;
return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);
}
case SCRIPT_TAG_NAME: {
const { src, text } = element as HTMLScriptElement;
// some script like jsonp maybe not support cors which should't use execScripts
if (excludeAssetFilter && src && excludeAssetFilter(src)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
const mountDOM = appWrapperGetter();
const { fetch } = frameworkConfiguration;
const referenceNode = mountDOM.contains(refChild) ? refChild : null;
if (src) {
execScripts(null, [src], proxy, {
fetch,
strictGlobal,
beforeExec: () => {
Object.defineProperty(document, 'currentScript', {
get(): any {
return element;
},
configurable: true,
});
},
success: () => {
// we need to invoke the onload event manually to notify the event listener that the script was completed
// here are the two typical ways of dynamic script loading
// 1. element.onload callback way, which webpack and loadjs used, see https://github.com/muicss/loadjs/blob/master/src/loadjs.js#L138
// 2. addEventListener way, which toast-loader used, see https://github.com/pyrsmk/toast/blob/master/src/Toast.ts#L64
const loadEvent = new CustomEvent('load');
if (isFunction(element.onload)) {
element.onload(patchCustomEvent(loadEvent, () => element));
} else {
element.dispatchEvent(loadEvent);
}
element = null;
},
error: () => {
const errorEvent = new CustomEvent('error');
if (isFunction(element.onerror)) {
element.onerror(patchCustomEvent(errorEvent, () => element));
} else {
element.dispatchEvent(errorEvent);
}
element = null;
},
});
const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
dynamicScriptAttachedCommentMap.set(element, dynamicScriptCommentElement);
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode);
}
execScripts(null, [`<script>${text}</script>`], proxy, {
strictGlobal,
success: element.onload,
error: element.onerror,
});
const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
dynamicScriptAttachedCommentMap.set(element, dynamicInlineScriptCommentElement);
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
}
default:
break;
}
}
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
};
}
function getNewRemoveChild(
headOrBodyRemoveChild: typeof HTMLElement.prototype.removeChild,
appWrapperGetterGetter: (element: HTMLElement) => ContainerConfig['appWrapperGetter'],
) {
return function removeChild<T extends Node>(this: HTMLHeadElement | HTMLBodyElement, child: T) {
try {
const { tagName } = child as any;
if (isHijackingTag(tagName)) {
const appWrapperGetter = appWrapperGetterGetter(child as any);
// container may had been removed while app unmounting if the removeChild action was async
const container = appWrapperGetter();
const attachedElement = dynamicScriptAttachedCommentMap.get(child as any) || child;
if (container.contains(attachedElement)) {
return rawRemoveChild.call(container, attachedElement) as T;
}
}
} catch (e) {
console.warn(e);
}
return headOrBodyRemoveChild.call(this, child) as T;
};
}
export function patchHTMLDynamicAppendPrototypeFunctions(
isInvokedByMicroApp: (element: HTMLElement) => boolean,
containerConfigGetter: (element: HTMLElement) => ContainerConfig,
) {
// Just overwrite it while it have not been overwrite
if (
HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&
HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore
) {
HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawHeadAppendChild,
containerConfigGetter,
isInvokedByMicroApp,
}) as typeof rawHeadAppendChild;
HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawBodyAppendChild,
containerConfigGetter,
isInvokedByMicroApp,
}) as typeof rawBodyAppendChild;
HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
containerConfigGetter,
isInvokedByMicroApp,
}) as typeof rawHeadInsertBefore;
}
// Just overwrite it while it have not been overwrite
if (
HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&
HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild
) {
HTMLHeadElement.prototype.removeChild = getNewRemoveChild(
rawHeadRemoveChild,
element => containerConfigGetter(element).appWrapperGetter,
);
HTMLBodyElement.prototype.removeChild = getNewRemoveChild(
rawBodyRemoveChild,
element => containerConfigGetter(element).appWrapperGetter,
);
}
return function unpatch() {
HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
};
}
export function rebuildCSSRules(
styleSheetElements: HTMLStyleElement[],
reAppendElement: (stylesheetElement: HTMLStyleElement) => void,
) {
styleSheetElements.forEach(stylesheetElement => {
// re-append the dynamic stylesheet to sub-app container
// Using document.head.appendChild ensures that appendChild invocation can also directly use the HTMLHeadElement.prototype.appendChild method which is overwritten at mounting phase
reAppendElement(stylesheetElement);
/*
get the stored css rules from styled-components generated element, and the re-insert rules for them.
note that we must do this after style element had been added to document, which stylesheet would be associated to the document automatically.
check the spec https://www.w3.org/TR/cssom-1/#associated-css-style-sheet
*/
if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
const cssRules = getStyledElementCSSRules(stylesheetElement);
if (cssRules) {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < cssRules.length; i++) {
const cssRule = cssRules[i];
(stylesheetElement.sheet as CSSStyleSheet).insertRule(cssRule.cssText);
}
}
}
});
}

View File

@ -0,0 +1,84 @@
/**
* @author Kuitos
* @since 2020-10-13
*/
import { checkActivityFunctions } from 'single-spa';
import { Freer } from '../../../interfaces';
import { patchHTMLDynamicAppendPrototypeFunctions, rebuildCSSRules, recordStyledComponentsCSSRules } from './common';
let bootstrappingPatchCount = 0;
let mountingPatchCount = 0;
/**
* Just hijack dynamic head append, that could avoid accidentally hijacking the insertion of elements except in head.
* Such a case: ReactDOM.createPortal(<style>.test{color:blue}</style>, container),
* this could made we append the style element into app wrapper but it will cause an error while the react portal unmounting, as ReactDOM could not find the style in body children list.
* @param appName
* @param appWrapperGetter
* @param proxy
* @param mounting
* @param scopedCSS
* @param excludeAssetFilter
*/
export function patchLooseSandbox(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
): Freer {
let dynamicStyleSheetElements: Array<HTMLLinkElement | HTMLStyleElement> = [];
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
/*
check if the currently specified application is active
While we switch page from qiankun app to a normal react routing page, the normal one may load stylesheet dynamically while page rendering,
but the url change listener must to wait until the current call stack is flushed.
This scenario may cause we record the stylesheet from react routing page dynamic injection,
and remove them after the url change triggered and qiankun app is unmouting
see https://github.com/ReactTraining/history/blob/master/modules/createHashHistory.js#L222-L230
*/
() => checkActivityFunctions(window.location).some(name => name === appName),
() => ({
appName,
appWrapperGetter,
proxy,
strictGlobal: false,
scopedCSS,
dynamicStyleSheetElements,
excludeAssetFilter,
}),
);
if (!mounting) bootstrappingPatchCount++;
if (mounting) mountingPatchCount++;
return function free() {
// bootstrap patch just called once but its freer will be called multiple times
if (!mounting && bootstrappingPatchCount !== 0) bootstrappingPatchCount--;
if (mounting) mountingPatchCount--;
const allMicroAppUnmounted = mountingPatchCount === 0 && bootstrappingPatchCount === 0;
// release the overwrite prototype after all the micro apps unmounted
if (allMicroAppUnmounted) unpatchDynamicAppendPrototypeFunctions();
recordStyledComponentsCSSRules(dynamicStyleSheetElements);
// As now the sub app content all wrapped with a special id container,
// the dynamic style sheet would be removed automatically while unmoutting
return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, stylesheetElement =>
// Using document.head.appendChild ensures that appendChild invocation can also directly use the HTMLHeadElement.prototype.appendChild method which is overwritten at mounting phase
document.head.appendChild.call(appWrapperGetter(), stylesheetElement),
);
// As the patcher will be invoked every mounting phase, we could release the cache for gc after rebuilding
if (mounting) {
dynamicStyleSheetElements = [];
}
};
};
}

View File

@ -0,0 +1,107 @@
/**
* @author Kuitos
* @since 2020-10-13
*/
import { Freer } from '../../../interfaces';
import { attachDocProxySymbol } from '../../common';
import {
ContainerConfig,
isHijackingTag,
patchHTMLDynamicAppendPrototypeFunctions,
rawHeadAppendChild,
rebuildCSSRules,
recordStyledComponentsCSSRules,
} from './common';
const rawDocumentCreateElement = Document.prototype.createElement;
const proxyAttachContainerConfigMap = new WeakMap<WindowProxy, ContainerConfig>();
const elementAttachContainerConfigMap = new WeakMap<HTMLElement, ContainerConfig>();
function patchDocumentCreateElement() {
if (Document.prototype.createElement === rawDocumentCreateElement) {
Document.prototype.createElement = function createElement<K extends keyof HTMLElementTagNameMap>(
this: Document,
tagName: K,
options?: ElementCreationOptions,
): HTMLElement {
const element = rawDocumentCreateElement.call(this, tagName, options);
if (isHijackingTag(tagName)) {
const proxyContainerConfig = proxyAttachContainerConfigMap.get(this[attachDocProxySymbol]);
if (proxyContainerConfig) {
elementAttachContainerConfigMap.set(element, proxyContainerConfig);
}
}
return element;
};
}
return function unpatch() {
Document.prototype.createElement = rawDocumentCreateElement;
};
}
let bootstrappingPatchCount = 0;
let mountingPatchCount = 0;
export function patchStrictSandbox(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
): Freer {
let containerConfig = proxyAttachContainerConfigMap.get(proxy);
if (!containerConfig) {
containerConfig = {
appName,
proxy,
appWrapperGetter,
dynamicStyleSheetElements: [],
strictGlobal: true,
excludeAssetFilter,
scopedCSS,
};
proxyAttachContainerConfigMap.set(proxy, containerConfig);
}
// all dynamic style sheets are stored in proxy container
const { dynamicStyleSheetElements } = containerConfig;
const unpatchDocumentCreate = patchDocumentCreateElement();
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
element => elementAttachContainerConfigMap.has(element),
element => elementAttachContainerConfigMap.get(element)!,
);
if (!mounting) bootstrappingPatchCount++;
if (mounting) mountingPatchCount++;
return function free() {
// bootstrap patch just called once but its freer will be called multiple times
if (!mounting && bootstrappingPatchCount !== 0) bootstrappingPatchCount--;
if (mounting) mountingPatchCount--;
const allMicroAppUnmounted = mountingPatchCount === 0 && bootstrappingPatchCount === 0;
// release the overwrite prototype after all the micro apps unmounted
if (allMicroAppUnmounted) {
unpatchDynamicAppendPrototypeFunctions();
unpatchDocumentCreate();
}
proxyAttachContainerConfigMap.delete(proxy);
recordStyledComponentsCSSRules(dynamicStyleSheetElements);
// As now the sub app content all wrapped with a special id container,
// the dynamic style sheet would be removed automatically while unmoutting
return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, stylesheetElement =>
rawHeadAppendChild.call(appWrapperGetter(), stylesheetElement),
);
};
};
}

View File

@ -0,0 +1,7 @@
/**
* @author Kuitos
* @since 2020-10-13
*/
export { patchLooseSandbox } from './forLooseSandbox';
export { patchStrictSandbox } from './forStrictSandbox';

View File

@ -4,18 +4,16 @@
*/
import { Freer, SandBox, SandBoxType } from '../../interfaces';
import patchDynamicAppend from './dynamicAppend';
import * as css from './css';
import { patchLooseSandbox, patchStrictSandbox } from './dynamicAppend';
import patchHistoryListener from './historyListener';
import patchInterval from './interval';
import patchWindowListener from './windowListener';
import * as css from './css';
export function patchAtMounting(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
sandbox: SandBox,
singular: boolean,
scopedCSS: boolean,
excludeAssetFilter?: Function,
): Freer[] {
@ -23,13 +21,21 @@ export function patchAtMounting(
() => patchInterval(sandbox.proxy),
() => patchWindowListener(sandbox.proxy),
() => patchHistoryListener(),
() => patchDynamicAppend(appName, elementGetter, sandbox.proxy, true, singular, scopedCSS, excludeAssetFilter),
];
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [...basePatchers],
[SandBoxType.Proxy]: [...basePatchers],
[SandBoxType.Snapshot]: basePatchers,
[SandBoxType.LegacyProxy]: [
...basePatchers,
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Proxy]: [
...basePatchers,
() => patchStrictSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Snapshot]: [
...basePatchers,
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter),
],
};
return patchersInSandbox[sandbox.type]?.map(patch => patch());
@ -39,18 +45,19 @@ export function patchAtBootstrapping(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
sandbox: SandBox,
singular: boolean,
scopedCSS: boolean,
excludeAssetFilter?: Function,
): Freer[] {
const basePatchers = [
() => patchDynamicAppend(appName, elementGetter, sandbox.proxy, false, singular, scopedCSS, excludeAssetFilter),
];
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: basePatchers,
[SandBoxType.Proxy]: basePatchers,
[SandBoxType.Snapshot]: basePatchers,
[SandBoxType.LegacyProxy]: [
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Proxy]: [
() => patchStrictSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Snapshot]: [
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
};
return patchersInSandbox[sandbox.type]?.map(patch => patch());

View File

@ -4,10 +4,20 @@
* @since 2020-3-31
*/
import { SandBox, SandBoxType } from '../interfaces';
import { nextTick, uniq } from '../utils';
import { nextTick } from '../utils';
import { attachDocProxySymbol, getTargetValue } from './common';
import { clearSystemJsProps, interceptSystemJsProps } from './noise/systemjs';
/**
* fastest(at most time) unique array method
* @see https://jsperf.com/array-filter-unique/30
*/
function uniq(array: PropertyKey[]) {
return array.filter(function filter(this: PropertyKey[], element) {
return element in this ? false : ((this as any)[element] = true);
}, {});
}
// zone.js will overwrite Object.defineProperty
const rawObjectDefineProperty = Object.defineProperty;

View File

@ -65,16 +65,6 @@ export function isBoundedFunction(fn: CallableFunction) {
return bounded;
}
/**
* fastest(at most time) unique array method
* @see https://jsperf.com/array-filter-unique/30
*/
export function uniq(array: PropertyKey[]) {
return array.filter(function filter(this: string[], element) {
return element in this ? false : ((this as any)[element] = true);
}, {});
}
export function getDefaultTplWrapper(id: string, name: string) {
return (tpl: string) => `<div id="${getWrapperId(id)}" data-name="${name}">${tpl}</div>`;
}
@ -120,23 +110,23 @@ export function performanceMark(markName: string) {
}
export function performanceMeasure(measureName: string, markName: string) {
if (supportsUserTiming) {
if (supportsUserTiming && performance.getEntriesByName(markName, 'mark').length) {
performance.measure(measureName, markName);
performance.clearMarks(markName);
performance.clearMeasures(measureName);
}
}
export function isEnableScopedCSS(opt: FrameworkConfiguration) {
if (typeof opt.sandbox !== 'object') {
export function isEnableScopedCSS(sandbox: FrameworkConfiguration['sandbox']) {
if (typeof sandbox !== 'object') {
return false;
}
if (opt.sandbox.strictStyleIsolation) {
if (sandbox.strictStyleIsolation) {
return false;
}
return !!opt.sandbox.experimentalStyleIsolation;
return !!sandbox.experimentalStyleIsolation;
}
/**