往期阅读
摘要
承接上问 window 隔离篇,本文将从 CSS 样式隔离上分析一下 qiankun 的实现逻辑。
css 隔离方案
Web Components
通过创建 影子 dom,与主文档隔离。影子 DOM(Shadow DOM)允许你将一个 DOM 树附加到一个元素上,并且使该树的内部对于在页面中运行的 JavaScript 和 CSS 是隐藏的。
css 选择器
通过添加属性选择器,精选约束,类似 vue 中的 scoped css。
qiankun 中的实现
qiankun 中提供了 sandbox.strictStyleIsolation 与 sandbox.experimentalStyleIsolation 分别开启 shadow dom 或者 scoped css,scoped css 与 shadow dom 是互斥的,所以在不支持 shadow dom 中,即使把两个打开了也只会生效一个。受如下代码影响。
export function isEnableScopedCSS(sandbox: FrameworkConfiguration['sandbox']) {
if (typeof sandbox !== 'object') {
return false;
}
if (sandbox.strictStyleIsolation) {
return false;
}
return !!sandbox.experimentalStyleIsolation;
}
在 qiankun 中,在 loadApp 时,会使用 import-html-entry 进行子应用的资源获取解析等,这一部分呢,就先忽略吧,预计会放在后面流程串联中介绍。本次就关心 css 隔离的内部实现细节吧。
shadow dom
- loadApp
在 loadApp 调用中,importHtmlentry 会返回对应的 template 以及 assetPublicPath
- 根据 template 进行 shadow dom 的包裹
源码中创建空的 div 元素,并设置了对应模板为子元素,但实际上,这个 containerElement 并没有别的用处了。。。。。这里我理解只是单纯地将 string 转变为 dom 元素对象。
appContent 为对应 app 的渲染模板内容
将 appContent 的子元素作为 shadow dom 的元素插入
可见渲染的元素模板中,会将子应用的 head 标签给替换为 qiankun-head 同时会添加一些属性。
export function getDefaultTplWrapper(name: string, sandboxOpts: FrameworkConfiguration['sandbox']) {
return (tpl: string) => {
let tplWithSimulatedHead: string;
if (tpl.indexOf('<head>') !== -1) {
// We need to mock a head placeholder as native head element will be erased by browser in micro app
tplWithSimulatedHead = tpl
.replace('<head>', `<${qiankunHeadTagName}>`)
.replace('</head>', `</${qiankunHeadTagName}>`);
} else {
// Some template might not be a standard html document, thus we need to add a simulated head tag for them
tplWithSimulatedHead = `<${qiankunHeadTagName}></${qiankunHeadTagName}>${tpl}`;
}
return `<div id="${getWrapperId(
name,
)}" data-name="${name}" data-version="${version}" data-sandbox-cfg=${JSON.stringify(
sandboxOpts,
)}>${tplWithSimulatedHead}</div>`;
};
}
export function getWrapperId(name: string) {
return `__qiankun_microapp_wrapper_for_${snakeCase(name)}__`;
}
至于 shadow dom 里面的样式如何生成的呢,这里就是 import-entry-html 中返回 execScripts 所做的了,会绑定对应的 global 进行 js 沙箱隔离地执行模板的脚本文件。shadow dom 的方案相对来说会更加简单,高效。
scoped css
scoped css 方案会在对应的子应用中添加 scoped 属性进行 css 隔离,类似于 vue 的 scoped css。
在渲染的子应用模板添加 data-qiankun 属性
改写原有的 css 样式表规则
- 对于 css 样式的属性设置上,qiankun 会改写原有的标签元素添加方法
- HTMLHeadElement.prototype.appendChild
- HTMLBodyElement.prototype.appendChild
- HTMLHeadElement.prototype.insertBefore
export function patchHTMLDynamicAppendPrototypeFunctions(
isInvokedByMicroApp: (element: HTMLElement) => boolean,
containerConfigGetter: (element: HTMLElement) => ContainerConfig,
) {
const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
const rawBodyAppendChild = HTMLBodyElement.prototype.appendChild;
const rawHeadInsertBefore = HTMLHeadElement.prototype.insertBefore;
// Just overwrite it while it have not been overwritten
if (
rawHeadAppendChild[overwrittenSymbol] !== true &&
rawBodyAppendChild[overwrittenSymbol] !== true &&
rawHeadInsertBefore[overwrittenSymbol] !== true
) {
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;
}
const rawHeadRemoveChild = HTMLHeadElement.prototype.removeChild;
const rawBodyRemoveChild = HTMLBodyElement.prototype.removeChild;
// Just overwrite it while it have not been overwritten
if (rawHeadRemoveChild[overwrittenSymbol] !== true && rawBodyRemoveChild[overwrittenSymbol] !== true) {
HTMLHeadElement.prototype.removeChild = getNewRemoveChild(
rawHeadRemoveChild,
containerConfigGetter,
'head',
isInvokedByMicroApp,
);
HTMLBodyElement.prototype.removeChild = getNewRemoveChild(
rawBodyRemoveChild,
containerConfigGetter,
'body',
isInvokedByMicroApp,
);
}
return function unpatch() {
HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
};
}
- 对于 style node,使用 MutationObserver 进行监听,改写 css rule
process(styleNode: HTMLStyleElement, prefix: string = '') {
console.log('>>>> 【scoped css】process func params', styleNode, prefix, 'textContent:', styleNode.textContent);
if (ScopedCSS.ModifiedTag in styleNode) {
return;
}
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 ?? []);
console.log('>>>> 【scoped css】rules', rules);
const css = this.rewrite(rules, prefix);
// eslint-disable-next-line no-param-reassign
console.log('>>>> 【scoped css】rewrited css', css);
styleNode.textContent = css;
// cleanup
this.swapNode.removeChild(textNode);
(styleNode as any)[ScopedCSS.ModifiedTag] = true;
return;
}
const mutator = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i += 1) {
const mutation = mutations[i];
if (ScopedCSS.ModifiedTag in styleNode) {
return;
}
if (mutation.type === 'childList') {
const sheet = styleNode.sheet as any;
const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
console.log('>>>> 【scoped css】mutation rules', rules);
const css = this.rewrite(rules, prefix);
console.log('>>>> 【scoped css】mutation rewrited css', css);
// eslint-disable-next-line no-param-reassign
styleNode.textContent = css;
// eslint-disable-next-line no-param-reassign
(styleNode as any)[ScopedCSS.ModifiedTag] = true;
}
}
});
// since observer will be deleted when node be removed
// we dont need create a cleanup function manually
// see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/disconnect
mutator.observe(styleNode, { childList: true });
}
- css rule 改写操作如下
private ruleStyle(rule: CSSStyleRule, prefix: string) {
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
const rootCombinationRE = /(html[^\w{[]+)/gm;
const selector = rule.selectorText.trim();
let cssText = '';
if (typeof rule.cssText === 'string') {
cssText = rule.cssText;
}
// handle html { ... }
// handle body { ... }
// handle :root { ... }
if (selector === 'html' || selector === 'body' || selector === ':root') {
return cssText.replace(rootSelectorRE, prefix);
}
// handle html body { ... }
// handle html > body { ... }
if (rootCombinationRE.test(rule.selectorText)) {
const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;
// since html + body is a non-standard rule for html
// transformer will ignore it
if (!siblingSelectorRE.test(rule.selectorText)) {
cssText = cssText.replace(rootCombinationRE, '');
}
}
// handle grouping selector, a,span,p,div { ... }
cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
// handle div,body,span { ... }
if (rootSelectorRE.test(item)) {
return item.replace(rootSelectorRE, (m) => {
// do not discard valid previous character, such as body,html or *:not(:root)
const whitePrevChars = [',', '('];
if (m && whitePrevChars.includes(m[0])) {
return `${m[0]}${prefix}`;
}
// replace root selector with prefix
return prefix;
});
}
return `${p}${prefix} ${s.replace(/^ */, '')}`;
}),
);
return cssText;
}
改写的操作是为对应的 cssRule 选择器添加 div[data-qiankun='appName']
综合比较
- 对于 web component 的 shadow dom 方案来将,操作比较简单,且能够隔离父应用的样式影响。
- 对于 scoped css 方案,这里看下来感觉不是那么完美,对于 css 较多的情况下有可能会对性能有影响,因为还是用的 css 选择器,所以父应用的使用 标签选择器的样式修改也会影响到子应用。
- scoped css 对于 antd 挂载到 父应用 dom 元素中时,样式可能会丢失。