css 沙箱
css沙箱隔离的是css样式,主要是为了防止样式污染,关于样式污染又有一下四种情况:
- 单实例场景下的子应用与子应用的样式隔离
- 单实例场景下的子应用与主应用的样式隔离
- 多实例场景下的子应用与子应用的样式隔离
- 多实例场景下的子应用与主应用的样式隔离
qiankun的css沙箱对于每种不同的场景都有不同的处理方式,下面我们来看一下qiankun是如何处理的。
默认的css样式隔离策略
我们以最简单的单例模式为突破口:qiankun默认是启用css隔离的,但是我们要理解的是,这里说的是子应用之间的应用隔离,qiankun子应用css隔离的实现思路是:当我们加载子应用的时候,会给子应用做一层处理,外边加了一层容器,容器中有两个东西:
- 添加一个
<qiankun-head>
标签,这个head标签中有子应用的css样式。 - 原来的内容部分,也就是子应用的内容。
源码如下:
js
function loadApp () {
//...
// 给子应用添加一个容器<qiankun-head>
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
//...
}
export const qiankunHeadTagName = '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>`;
};
}
上面的源码部分是比较简单的,就是给子应用的html添加一个容器,当我们切换到另一个子应用的时候,会将上一个子应用的容器移除,然后再添加一个新的子应用容器,那么head
中的样式自然也就随着子应用的卸载而卸载,这样就可以实现子应用之间的样式隔离了。伪代码如下:
html
<html>
<head></head>
<body>
<div id="__qiankun_microapp_wrapper_for_vue__">
<qiankun-head>
<style>
.app1 {
color: red;
}
</style>
</qiankun-head>
<div id="app1">content...</div>
</div>
</body>
<html>
但问题也是显而易见的,就是子应用的css仍然是作用于全局css上下文的,也就是父子应用之间的样式是没有隔离的,这样就会导致样式污染的问题。那么遇到这种问题,我们应该怎么办?第二种方案呼之欲出,那就是shadowDom。
shadowDom样式隔离策略
在qiankun中可以,通过配置strictStyleIsolation
为true
来启用shadowDom
。
shadowDom
是一种浏览器级别的样式隔离方案,它的原理是:shadowDom
中的样式只会作用于shadowDom
中的元素,不会作用于全局的css上下文中,这样就可以实现样式隔离了。那么我们可以将子应用的内容放在shadowDom
中,这样就可以实现子应用与主应用的样式隔离了。 在qiankun
中的实现如下:
ts
function loadApp () {
//...
// 给子应用添加一个容器<qiankun-head>
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
// 创建元素,这里会判断是否启用shadowDom
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
//...
}
// 创建元素
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;
// 是否启用严格隔离模式
if (strictStyleIsolation) {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
shadow = appElement.attachShadow({ mode: 'open' });
shadow.innerHTML = innerHTML;
}
return appElement;
}
上面的代码,在我们之前的基础上,有了一些改动,在创建元素的时候,会根据strictStyleIsolation
的值判断是否启用shadowDom
,如果启用了shadowDom
,那么就会将子应用的内容放在shadowDom
中, 伪代码如下:
html
<html>
<head></head>
<body>
<div id="__qiankun_microapp_wrapper_for_vue__">
#shadow-root (open)
<qiankun-head>
<style>
.app1 {
color: red;
}
</style>
</qiankun-head>
<div id="app1">content...</div>
</div>
</body>
<html>
如上使用shadow-dom
的方式,就可以实现dom间的样式隔离,那么子应用与主应用的样式隔离自然也就实现了,除此之外,在多实例场景下,shadowDom仍然可以实现子应用之间的样式隔离,但是shadow DOM
也不是绝对的银弹, 网上说的兼容性问题倒也并不是最大的问题,最大的问题还是因为shadow DOM
的隔离,在我们日常开发中,会遇到以下三个问题:
- 兼容性问题
- 可能会用到第三方UI库,比如element-ui,第三方库的dialog组件、drawer组件,当这些组件挂载到全局
document.body
时,但由于我们的子应用的样式只能作用于shadow DOM
中,所以这些组件的样式是无法作用于shadow DOM
中的,这样就会导致样式丢失的问题。 - 当我们按照常规思维去访问元素,是访问不到的,比如我们通过
document.getElementById('app')
是访问不到的,因为shadow DOM
是一个封闭的DOM树,我们无法访问到shadow DOM
中的元素(当然可以通过改造获取,详见shadowRoot使用),这个问题很头疼。
以上三点,我认为是shadowDom
的核心问题所在,并导致在v3版本中弃用
接下来我们看qiankun提供的第三中样式隔离方案experimentalStyleIsolation
,
experimentalStyleIsolation样式隔离策略
当experimentalStyleIsolation
被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,官方文档中的例子如下:
css
// 假设应用名是 react16
.app-main {
font-size: 14px;
}
div[data-qiankun-react16] .app-main {
font-size: 14px;
}
这种方案类似 Vue 的scoped
,会给应用添加一个 data-qiankun=${appInstanceId} 的属性标识,通过对 style的textContent
进行重写(rewrite) CSSRule
的方式解析添加前缀。核心实现代码如下:
ts
export const process = (
appWrapper: HTMLElement,
stylesheetElement: HTMLStyleElement | HTMLLinkElement,
appName: string,
): void => {
// lazy singleton pattern
if (!processor) {
processor = new ScopedCSS();
}
const mountDOM = appWrapper;
if (!mountDOM) {
return;
}
const tag = (mountDOM.tagName || '').toLowerCase();
if (tag && stylesheetElement.tagName === 'STYLE') {
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);
}
};
// rewrite逻辑
private rewrite(rules: CSSRule[], prefix: string = '') {
let css = '';
rules.forEach((rule) => {
// 区分不同的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:
if (typeof rule.cssText === 'string') {
css += `${rule.cssText}`;
}
break;
}
});
return css;
}
// 重写style
styleNode.textContent = css;
上面的代码是experimentalStyleIsolation
的核心实现,核心思路是:通过重写CSSRule
的方式,给样式添加一个前缀,这样就可以实现样式隔离了,但是这种方式也是有缺陷的,比如我们刚刚提到的
- 第三方UI库的往body插入modal的样式丢失问题
总结:
qiankun
的三种样式隔离,都有各自的优点和缺陷,没有哪种方案是绝对的银弹,在实际开发中,我们需要根据使用场景选择改动成本最小的方案。