微前端样式隔离方案之工程化处理

笔者公司使用了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子元素本身也能享受到子应用样式了。

两句话总结就是

  1. 通过魔改postcss-selector-namesapce 解决子应用影响主应用或者其它子应用问题(子应用还是可能被主应用影响到,除了影子dom方案,其它方案都无法避免,只能是降低概率)
  2. 通过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}`
            }
  1. 重写: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')
}
相关推荐
lichenyang4535 分钟前
React移动端开发项目优化
前端·react.js·前端框架
DoraBigHead1 小时前
你写前端按钮,他们扛服务器压力:搞懂后端那些“黑话”!
前端·javascript·架构
isNotNullX2 小时前
数据中台架构解析:湖仓一体的实战设计
java·大数据·数据库·架构·spark
Kookoos3 小时前
ABP VNext + .NET Minimal API:极简微服务快速开发
后端·微服务·架构·.net·abp vnext
码字的字节3 小时前
深入理解Transformer架构:从理论到实践
深度学习·架构·transformer
wzj_what_why_how3 小时前
Android网络层架构:统一错误处理的问题分析到解决方案与设计实现
android·架构
bug攻城狮3 小时前
Alloy VS Promtail:基于 Loki 的日志采集架构对比与选型指南
运维·架构·grafana·数据可视化
代码改变世界ctw3 小时前
ARM汇编编程(AArch64架构)课程 - 第5章函数调用规范
汇编·arm开发·架构
当牛作馬4 小时前
React——ant-design组件库使用问题记录
前端·react.js·前端框架