独立运行时 —— 沙箱
沙箱 的目的是 为了隔离子应用间 脚本
和 样式
的影响,即需要针对子应用的 <style>、<link>、<script>
等类型的标签进行特殊处理,而处理时机分为两种:
- 在 初始化加载时,因为初始化加载子应用时,需要 加载其对应的
脚本
和样式
- 在 子应用正在运行时,因为子应用运行时可能会 动态添加
脚本
和样式
重写 appendChild、insertBefore、removeChild 方法
qiankun
中重写 appendChild、insertBefore、removeChild
等原生方法,以便于可以监听 新添加/删除 的节点,,并对 <style>、<link>、<script>
等标签进行处理。
// qiankun\src\sandbox\patchers\dynamicAppend\common.ts export function patchHTMLDynamicAppendPrototypeFunctions( isInvokedByMicroApp: (element: HTMLElement) => boolean, containerConfigGetter: (element: HTMLElement) => ContainerConfig, ) { // 只在 appendChild 和 insertBefore 没有被重写时进行重写 if ( HTMLHeadElement.prototype.appendChild === rawHeadAppendChild && HTMLBodyElement.prototype.appendChild === rawBodyAppendChild && HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore ) { // 重写方法 HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({ rawDOMAppendOrInsertBefore: rawHeadAppendChild, containerConfigGetter, isInvokedByMicroApp, target: 'head', }) as typeof rawHeadAppendChild; // 重写方法 HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({ rawDOMAppendOrInsertBefore: rawBodyAppendChild, containerConfigGetter, isInvokedByMicroApp, target: 'body', }) as typeof rawBodyAppendChild; // 重写方法 HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({ rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any, containerConfigGetter, isInvokedByMicroApp, target: 'head', }) as typeof rawHeadInsertBefore; } // 只在 removeChild 没有被重写时进行重写 if ( HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild && HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild ) { // 重写方法 HTMLHeadElement.prototype.removeChild = getNewRemoveChild(rawHeadRemoveChild, containerConfigGetter, 'head'); HTMLBodyElement.prototype.removeChild = getNewRemoveChild(rawBodyRemoveChild, containerConfigGetter, 'body'); } // 恢复重写前的方法 return function unpatch() { HTMLHeadElement.prototype.appendChild = rawHeadAppendChild; HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild; HTMLBodyElement.prototype.appendChild = rawBodyAppendChild; HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild; HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore; }; } 复制代码
CSS 样式隔离
shadowDom 实现隔离
若开启了 strictStyleIsolation
模式,并且当前环境支持 Shadow DOM
,则直接通过 Shadow DOM
来实现隔离效果,有关 Shadow DOM
的内容可参考之前的一篇文章:Web Components —— Web 组件
function createElement( appContent: string, strictStyleIsolation: boolean, scopedCSS: boolean, appInstanceId: string, ): HTMLElement { const containerElement = document.createElement('div'); containerElement.innerHTML = appContent; // appContent always wrapped with a singular div const appElement = containerElement.firstChild as HTMLElement; // strictStyleIsolation 模式 if (strictStyleIsolation) { if (!supportShadowDOM) { console.warn( '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!', ); } else { const { innerHTML } = appElement; appElement.innerHTML = ''; let shadow: ShadowRoot; // 若当前环境支持 Shadow DOM,则通过 Shadow DOM 实现样式隔离 if (appElement.attachShadow) { shadow = appElement.attachShadow({ mode: 'open' }); } else { // createShadowRoot was proposed in initial spec, which has then been deprecated shadow = (appElement as any).createShadowRoot(); } shadow.innerHTML = innerHTML; } } // 通过 css.process 处理 css 规则 if (scopedCSS) { const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr); if (!attr) { appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId); } const styleNodes = appElement.querySelectorAll('style') || []; forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => { css.process(appElement!, stylesheetElement, appInstanceId); }); } return appElement; } 复制代码
prefix 限定 CSS 规则
CSS
样式分为 内联样式 和 外链样式,而在 qiankun
中选择把外链的方式处理成 <style>
包裹的形式,目的是提供符合 postProcess(styleElement)
处理的数据格式,即符合 css.process(...)
的数据格式,因为外部传入的 postProcess
形参就是包含了 css.process()
的方法:
// qiankun\src\sandbox\patchers\dynamicAppend\common.ts function convertLinkAsStyle( element: HTMLLinkElement, postProcess: (styleElement: HTMLStyleElement) => void, fetchFn = fetch, ): HTMLStyleElement { // 创建 style 标签 const styleElement = document.createElement('style'); const { href } = element; // add source link element href styleElement.dataset.qiankunHref = href; // 通过 fetch 请求 link.href 指向的 css 资源 fetchFn(href) .then((res: any) => res.text()) .then((styleContext: string) => { // 将得到的 css 文本作为文本节点添加到 style 节点中 styleElement.appendChild(document.createTextNode(styleContext)); // 方便统一通过 postProcess 进行处理,本质上就是 css.process() 方法 postProcess(styleElement); manualInvokeElementOnLoad(element); }) .catch(() => manualInvokeElementOnError(element)); return styleElement; } 复制代码
CSS
样式隔离核心本质其实就是 css.process()
方法,而这其实就是通过为每个 css
规则添加 特定的前缀 来实现 样式隔离 的作用:
- 创建一个临时的
style
节点用来后续处理 - 通过
process()
方法来处理style
规则, 即通过style.sheet
属性来获取所有的规则 - 通过
ruleStyle()
方法进行转换,即通过正则进行匹配然后替换,如子应用中的h1{color: red;}
变为[.appName] h1{color: red;}
- 将重写后的
css
内容替换到原有的style
节点中
// qiankun\src\sandbox\patchers\css.ts let processor: ScopedCSS; export const QiankunCSSRewriteAttr = 'data-qiankun'; export const process = ( appWrapper: HTMLElement, stylesheetElement: HTMLStyleElement | HTMLLinkElement, appName: string, ): void => { // 惰性单例模式 if (!processor) { processor = new ScopedCSS(); } if (stylesheetElement.tagName === 'LINK') { console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.'); } const mountDOM = appWrapper; if (!mountDOM) { return; } const tag = (mountDOM.tagName || '').toLowerCase(); if (tag && stylesheetElement.tagName === 'STYLE') { // 根据当前子应用的 appName 生成自定义前缀 const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`; processor.process(stylesheetElement, prefix); } }; export class ScopedCSS { private static ModifiedTag = 'Symbol(style-modified-qiankun)'; private sheet: StyleSheet; private swapNode: HTMLStyleElement; constructor() { const styleNode = document.createElement('style'); rawDocumentBodyAppend.call(document.body, styleNode); this.swapNode = styleNode; this.sheet = styleNode.sheet!; this.sheet.disabled = true; } process(styleNode: HTMLStyleElement, prefix: string = '') { if (ScopedCSS.ModifiedTag in styleNode) { return; } // style 中文本节点不为空时进行处理 if (styleNode.textContent !== '') { const textNode = document.createTextNode(styleNode.textContent || ''); this.swapNode.appendChild(textNode); const sheet = this.swapNode.sheet as any; // type is missing const rules = arrayify<CSSRule>(sheet?.cssRules ?? []); // 重写 css 内容 const css = this.rewrite(rules, prefix); // eslint-disable-next-line no-param-reassign styleNode.textContent = css; // cleanup this.swapNode.removeChild(textNode); (styleNode as any)[ScopedCSS.ModifiedTag] = true; return; } // 省略代码 } // 根据 prefix 来限定 css 选择器 private rewrite(rules: CSSRule[], prefix: string = '') { let css = ''; rules.forEach((rule) => { switch (rule.type) { case RuleType.STYLE: css += this.ruleStyle(rule as CSSStyleRule, prefix); break; case RuleType.MEDIA: css += this.ruleMedia(rule as CSSMediaRule, prefix); break; case RuleType.SUPPORTS: css += this.ruleSupport(rule as CSSSupportsRule, prefix); break; default: css += `${rule.cssText}`; break; } }); return css; } } 复制代码
JavaScript 脚本隔离
从如下源码中不难看出,qiankun
中的 JS
沙箱有 LegacySandbox、ProxySandbox、SnapshotSandbox
三种方式,但是其实就分为 代理(Proxy)沙箱 和 快照(Snapshot)沙箱,并且是根据情况来选择创建:
- 若当前环境支持
window.Proxy
,则通过useLooseSandbox
的值选择LegacySandbox
和ProxySandbox
方式 - 若当前环境不支持
window.Proxy
,则直接使用SnapshotSandbox
方式
// qiankun\src\loader.ts export async function loadApp<T extends ObjectType>( app: LoadableApp<T>, configuration: FrameworkConfiguration = {}, lifeCycles?: FrameworkLifeCycles<T>, ): Promise<ParcelConfigObjectGetter> { 省略代码 let sandboxContainer; if (sandbox) { // 创建沙箱 sandboxContainer = createSandboxContainer( appInstanceId, // FIXME 应该在重新挂载时使用严格的沙盒逻辑: https://github.com/umijs/qiankun/issues/518 initialAppWrapperGetter, scopedCSS, useLooseSandbox, excludeAssetFilter, global, speedySandbox, ); // 用沙箱的代理对象作为接下来使用的全局对象 global = sandboxContainer.instance.proxy as typeof window; mountSandbox = sandboxContainer.mount; unmountSandbox = sandboxContainer.unmount; } 省略代码 } // qiankun\src\sandbox\index.ts export function createSandboxContainer( appName: string, elementGetter: () => HTMLElement | ShadowRoot, scopedCSS: boolean, useLooseSandbox?: boolean, excludeAssetFilter?: (url: string) => boolean, globalContext?: typeof window, speedySandBox?: boolean, ) { let sandbox: SandBox; if (window.Proxy) { sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext); } else { sandbox = new SnapshotSandbox(appName); } 省略代码 } 复制代码
代理(Proxy)沙箱
为了避免 多个子应用 操作或者修改 基座应用 的全局对象 window
,而导致微应用间运行状态可能相互影响的问题,Proxy 沙箱 本质就是基于 Proxy
来实现代理:
- 通过
createFakeWindow(window)
将原window
上的一些descriptor.configurable
为true
拷贝到新对象fakeWindow
上 - 通过
new Proxy(fakeWindow, {...})
的方式创建代理对象
- 读取属性时优先从
proxy
上查找,若没有查到则再到原始的window
上查找 - 设置属性时会设置到
proxy
对象里,即不会修改原始的window
实现隔离
// qiankun\src\sandbox\proxySandbox.ts export default class LegacySandbox implements SandBox { 省略代码 constructor(name: string, globalContext = window) { const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext); 省略代码 const proxy = new Proxy(fakeWindow, { set: (target: FakeWindow, p: PropertyKey, value: any): boolean => { if (this.sandboxRunning) { this.registerRunningApp(name, proxy); // 必须保留它的描述,而该属性之前存在于 globalContext 中 if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); const { writable, configurable, enumerable, set } = descriptor!; // 这里只有可写属性可以被覆盖,忽略 globalContext 的访问器描述符,因为触发它的逻辑没有意义(这可能会使沙箱转义)强制通过数据描述符设置值 if (writable || set) { Object.defineProperty(target, p, { configurable, enumerable, writable: true, value }); } } else { target[p] = value; } // 将属性同步到 globalContext if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) { this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p); // @ts-ignore globalContext[p] = value; } updatedValueSet.add(p); this.latestSetProp = p; return true; } if (process.env.NODE_ENV === 'development') { console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`); } // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误 return true; }, get: (target: FakeWindow, p: PropertyKey): any => { this.registerRunningApp(name, proxy); if (p === Symbol.unscopables) return unscopables; // 避免使用 window.window 或 window.self 逃离沙箱环境去触碰真实 window if (p === 'window' || p === 'self') { return proxy; } // 使用 globalThis 关键字劫持 globalWindow 访问 if (p === 'globalThis') { return proxy; } if ( p === 'top' || p === 'parent' || (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop')) ) { // 如果主应用程序在 iframe 上下文中,允许逃离沙箱 if (globalContext === globalContext.parent) { return proxy; } return (globalContext as any)[p]; } // proxy.hasOwnProperty 将首先调用 getter,然后将其值表示为 globalContext.hasOwnProperty if (p === 'hasOwnProperty') { return hasOwnProperty; } if (p === 'document') { return document; } if (p === 'eval') { return eval; } const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext; const value = actualTarget[p]; // 冻结值应该直接返回 if (isPropertyFrozen(actualTarget, p)) { return value; } /* 某些dom api必须绑定到native window,否则会导致异常:'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation' */ const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext; return getTargetValue(boundTarget, value); }, has(target: FakeWindow, p: string | number | symbol): boolean { return p in unscopables || p in target || p in globalContext; }, getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined { /* 由于原始窗口中 top/self/window/mockTop 的描述符是可配置的,但在代理目标中不可配置,需要从目标中获取它以避免 TypeError */ if (target.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(target, p); descriptorTargetMap.set(p, 'target'); return descriptor; } if (globalContext.hasOwnProperty(p)) { const descriptor = Object.getOwnPropertyDescriptor(globalContext, p); descriptorTargetMap.set(p, 'globalContext'); // 如果属性不作为目标对象的自有属性存在,则不能将其报告为不可配置 if (descriptor && !descriptor.configurable) { descriptor.configurable = true; } return descriptor; } return undefined; }, ownKeys(target: FakeWindow): ArrayLike<string | symbol> { return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target))); }, defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean { const from = descriptorTargetMap.get(p); /* Descriptor 必须通过 Object.getOwnPropertyDescriptor(window, p) 来自本地窗口时定义到本地窗口,否则会导致 TypeError 非法调用 */ switch (from) { case 'globalContext': return Reflect.defineProperty(globalContext, p, attributes); default: return Reflect.defineProperty(target, p, attributes); } }, deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => { this.registerRunningApp(name, proxy); if (target.hasOwnProperty(p)) { // @ts-ignore delete target[p]; updatedValueSet.delete(p); return true; } return true; }, // 确保 `window instanceof Window` 在微应用中返回 true getPrototypeOf() { return Reflect.getPrototypeOf(globalContext); }, }); } }