笔者公司使用了qiankun作为微前端框架,在子应用接入时,遇到了一些css样式冲突,现在将经验分享给大家。 此方案的优点是对业务代码无影响,只需接入postcss插件和webapck/vite插件即可完成。
在介绍具体方案前,我们先来说一下为什么要进行样式隔离,与qiankun是如何进行隔离的,我们为什么没有采用qiankun的方案
为什么要进行样式隔离?
如果我们不进行样式隔离,当子应用和主应用定义同一个class 类名,但是他们里面的容完全不同,就会出现样式冲突,导致页面显示异常
例如: 主应用定义了
css
.container {
padding: 24px;
background: white;
}
子应用定义了
css
.container {
padding: 36px
background: blue;
}
此时后面的container变回覆盖掉前面container的样式规则,导致主应用显示异常
可能大家有的子应用使用了css-in-js,css-module,css scoped 方案来处理了一些样式冲突,但是只能处理自己业务代码部分。对于组件库(同ui库的不同版本),全局样式库(tailwind css或者bootstrap)的样式冲突很难解决。
qiankun是怎么做的样式隔离的

以上信息摘抄自乾坤官网 API 说明 - qiankun (umijs.org)
从上面信息可以,乾坤有三种方案来处理样式冲突问题:
1.sandbox: true 乾坤将子应用的样式行内放入子应用挂载容器中的style标签里,当子应用失活时,容器销毁,样式也随之会被销毁掉。
这种方案的缺点如下:
- 子应用的激活时,样式会对主应用造成影响,多子应用激活时,互相之间也会造成影响
- 开发模式下,对于动态加载的样式,乾坤没做任何处理,只要子应用激活后,不管是否失活,随时会对其它应用造成影响
所以这种方案,只能减少单子应用模式下,子应用与子应用之间的样式冲突概率
2. sandbox:{ strictStyleIsolation
} shadow dom 影子dom方案,
优点:主应用不会影响子应用,子应用不会影响主应用。应用之间,样式完全不会有任何影响
缺点:子应用挂载在shadow dom外的元素无法应用到子应用的样式,常见的全局弹窗,遮罩层等
3.sandbox: { experimentalStyleIsolation: true } css 作用域方案 给子应用所有的样式都加上一个作用域
css
.app-main {
font-size: 14px;
}
div[data-qiankun-react16] .app-main {
font-size: 14px;
}
这样,.app-main就不会影响到主应用了,主应用对子应用影响的概率也会降低,因为子应用样式优先级提升了
优点:
- 子应用不会影响主应用,子应用之间也不会互相影响
缺点:
- 挂载在容器元素外的元素无法应用到子应用的样式
- 主应用依然会影响到子应用,只是概率降低了
- 代码运行时添加,对性能有要求的可能会追求更优方案
我们是怎么处理的
针对qiankun的不足,最终决定对其方案三进行改进,让挂载在body下面的元素也能享受到子应用的样式规则
首先是如何添加作用域类名?
最开始我们采用的是postcss-selector-namesapce在构建阶段就给我们所有的样式加上作用域。 但是我们发现其对:root的处理有问题,便将源码download下来了自己处理了一下
如何处理挂载在body下的元素样式?
方案就是通过loader重写子应用的document.body.appendchild方法,当子应用往body上加元素的时候,手动给元素加上作用域的选择器,这样body下挂载的元素也能享受到子应用样式
但是此方案有个缺点,那么就是body的子元素本身无法享受到,因为我们作用域方案的本质是后代选择器,所以作用域方案只能作用到body子元素的后代。 于是继续对postcss-selector-namesapce 进行改进,让其支持交集选择器,以便body子元素本身也能享受到子应用样式了。
两句话总结就是
- 通过魔改postcss-selector-namesapce 解决子应用影响主应用或者其它子应用问题(子应用还是可能被主应用影响到,除了影子dom方案,其它方案都无法避免,只能是降低概率)
- 通过loader重写子应用document.body.appendchild 方法,让子应用添加到body下的元素也能享受到子应用样式效果
说完了原理,附上代码改造步骤
webpackloader处理document.body.appendchild转换
js
const loaderUtils = require('loader-utils');
module.exports = function(source) {
// 获取loader的options
const options = loaderUtils.getOptions(this) || {};
const exclude = options.exclude || [];
// 检查当前处理文件是否在排除列表中
const shouldExclude = exclude.some((exclusion) => {
if (typeof exclusion === 'string') {
return this.resourcePath.includes(exclusion);
} else if (exclusion instanceof RegExp) {
return exclusion.test(this.resourcePath);
}
return false;
});
// 如果当前文件不在排除列表中,则进行替换
if (!shouldExclude) {
const result = source.replace(/document\.body\.appendChild/g, `document.body.${options.newName}`);
return result;
}
// 如果在排除列表中,则不做任何处理
return source;
};
在vue.config.js中使用loader
js
module: {
rules: [
{
test: /\.m?jsx?$/,
loader: './plugin/transform-appendchild',
options: {
newName: 'appendChildSubAPPName', // 转换后的新名字
exclude: [path.join(__dirname, 'src/rewrite.js')]
}
}
]
}
在src/rewrite.js(此文件需被loader排除掉,否则调用会出现死循环)中或者html中重写appendchild方法
js
document.body.appendChildSubAPPName = function (element) {
element.setAttribute('data-subapp-namespace', 'subAppName')
return document.body.appendChild(element)
}
在vue.config.js中使用 postcss-selector-namesapce 插件
js
css: {
loaderOptions: {
postcss: {
postcssOptions: () => {
return {
plugins: [
postcssSelectorNamespace({
namespace: function (css) {
// 如果不需要样式隔离的文件可以按如下方式,排除不用添加前缀的文件('cssFile'为namespace函数的参数)
// if (/global.less/.test(css)) {
// return '';
// }
return '[data-subapp-namespace="subAppName"]'; // 作为作用域选择器的名称
},
}),
],
};
},
},
},
},
最后是魔改的postcss-selector-namesapce 代码 最主要是这两部分
1.新增交集选择器
js
if (['.', '#'].some(key => selector.startsWith(key))) { // 同时支持后代和交集选择器
return `${computedNamespace} ${selector}, ${computedNamespace}${selector}`
}
- 重写:root 处理
js
function dropRootSelector(selector) {
if (dropRoot)
return (
selector
.replace(
rootSelector,
`${selector} ${computedNamespace}`,
)
.trim() || selector
)
return selector
}
下面是完整源码
js
const postcss = require('postcss')
module.exports = postcss.plugin(
'postcss-selector-namespace-new',
(options = {}) => {
let selfSelector = /:--namespace/
const {
namespace = '.self',
rootSelector = /:root/,
ignoreRoot = true,
dropRoot = true,
} = options
selfSelector = regexpToGlobalRegexp(selfSelector)
let computedNamespace = ''
return (css) => {
computedNamespace
= typeof namespace === 'string'
? namespace
: namespace(css.source.input.file)
if (!computedNamespace)
return
css.walkRules((rule) => {
if (canNamespaceSelectors(rule))
return
rule.selectors = rule.selectors.map(selector =>
namespaceSelector(selector, computedNamespace),
)
})
}
function namespaceSelector(selector, computedNamespace) {
if (selector) {
if (hasSelfSelector(selector))
return selector.replace(selfSelector, computedNamespace)
}
if (hasRootSelector(selector))
return dropRootSelector(selector)
if (['.', '#'].some(key => selector.startsWith(key))) { // 同时支持后代和交集选择器
return `${computedNamespace} ${selector}, ${computedNamespace}${selector}`
}
return `${computedNamespace} ${selector}`
}
function hasSelfSelector(selector) {
selfSelector.lastIndex = 0
return selfSelector.test(selector)
}
function hasRootSelector(selector) {
return ignoreRoot && selector.search(rootSelector) === 0
}
function dropRootSelector(selector) { // 重写root处理,本来这里是直接删除掉,图简单就直接在这里改了
if (dropRoot)
return (
selector
.replace(
rootSelector,
`${selector} ${computedNamespace}`,
)
.trim() || selector
)
return selector
}
},
)
/**
* Returns true if the rule selectors can be namespaces
*
* @param {Rule} rule The rule to check
* @return {boolean} whether the rule selectors can be namespaced or not
*/
function canNamespaceSelectors(rule) {
return hasParentRule(rule) || parentIsAllowedAtRule(rule)
}
/**
* Returns true if the parent rule is a not a media or supports atrule
*
* @param {Rule} rule The rule to check
* @return {boolean} true if the direct parent is a keyframe rule
*/
function parentIsAllowedAtRule(rule) {
return (
rule.parent
&& rule.parent.type === 'atrule'
&& !/(?:media|supports|for)$/.test(rule.parent.name)
)
}
/**
* Returns true if any parent rule is of type 'rule'
*
* @param {Rule} rule The rule to check
* @return {boolean} true if any parent rule is of type 'rule' else false
*/
function hasParentRule(rule) {
if (!rule.parent)
return false
if (rule.parent.type === 'rule')
return true
return hasParentRule(rule.parent)
}
/**
* Newer javascript engines allow setting flags when passing existing regexp
* to the RegExp constructor, until then, we extract the regexp source and
* build a new object.
*
* @param {RegExp|string} regexp The regexp to modify
* @return {RegExp} The new regexp instance
*/
function regexpToGlobalRegexp(regexp) {
const source = regexp instanceof RegExp ? regexp.source : regexp
return new RegExp(source, 'g')
}