qiankun微前端实践中的那些坑和解决方案

奥哲 框架 阅读 2,334
赞 28 收藏
二维码
手机扫码查看
反馈

微前端项目中样式隔离的各种坑

最近重构老系统,用了qiankun做微前端,结果样式冲突的问题把我搞得够呛。主应用和子应用之间的样式互相影响,各种奇怪的显示问题,花了不少时间才搞定。

qiankun微前端实践中的那些坑和解决方案

一开始想简单了

最开始觉得qiankun应该自带样式隔离,结果上线后发现子应用的样式把主应用的布局全搞乱了。按钮变色、字体错乱、定位偏移,基本上就是一场灾难。查了官方文档才发现,qiankun默认是通过js沙箱来处理JavaScript上下文,但样式这块需要自己处理。

我先尝试了最简单的方案——给每个子应用加上独立的CSS前缀。这招看起来不错,但问题是老项目改起来太麻烦,而且维护成本高,每次新增样式都得手动加前缀,明显不现实。

Shadow DOM方案踩坑记

后来想到用Shadow DOM,这个听起来比较高级。给子应用套个shadow root,理论上就能实现真正的样式隔离。代码大概这样:

class MyWebComponent extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({mode: 'open'});
    
    // 子应用内容插入到shadow dom
    const container = document.createElement('div');
    container.id = 'sub-app-container';
    shadow.appendChild(container);
    
    // 加载子应用
    loadMicroApp({
      name: 'subapp',
      entry: '//localhost:8081',
      container: '#sub-app-container',
    });
  }
}

customElements.define('my-web-component', MyWebComponent);

折腾了半天,发现有个严重问题:很多第三方组件库不兼容Shadow DOM。比如Ant Design、Element UI这些,在shadow dom里各种样式错乱,表单验证弹窗出不来,下拉菜单位置计算错误。最终放弃了这个方案。

CSS Modules + 动态类名才是正解

后来试了下动态生成唯一类名的方案,效果还不错。思路是给每个子应用生成一个唯一的容器类名,然后所有样式都基于这个类名作用域:

// 微应用入口文件
export async function mount(props) {
  const { container, appInstanceId } = props;
  
  // 生成唯一容器ID
  const uniqueId = micro-app-${appInstanceId};
  const appContainer = document.createElement('div');
  appContainer.className = uniqueId; // 关键:添加唯一类名
  
  container.appendChild(appContainer);
  
  // 启动Vue应用
  new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount(.${uniqueId});
}

对应的CSS需要这样写:

/* 在webpack配置中设置 */
.micro-app-subapp1 .el-button {
  background-color: #409eff;
}

.micro-app-subapp2 .ant-btn {
  border-radius: 4px;
}

webpack配置也得调整:

// vue.config.js
module.exports = {
  css: {
    loaderOptions: {
      sass: {
        prependData: 
          $app-namespace: micro-app-#{$app-id};
          .#{$app-namespace} {
        ,
        appendData: 
          }
        
      }
    }
  }
}

这个方案看起来挺完美,但实际上还有个小问题:如果子应用内部用了全局样式(比如重置样式reset.css),还是会泄漏到全局。所以还需要额外处理全局样式注入。

全局样式处理

针对全局样式泄漏问题,我想到了一个临时方案:重写子应用的样式加载逻辑,确保所有样式都限定在特定作用域内:

// 子应用入口
import './styles/reset.scss'; // 需要改造这个文件

// 改造前的reset.scss
* {
  margin: 0;
  padding: 0;
}

// 改造后的reset.scss - 限定作用域
.micro-app-${APP_INSTANCE_ID} {
  * {
    margin: 0;
    padding: 0;
  }
}

为了自动化这个过程,我还写了个小脚本,在构建时自动给所有全局样式添加作用域:

// build/style-scope-plugin.js
const path = require('path');

class StyleScopePlugin {
  constructor(options) {
    this.scopeClass = options.scopeClass || '.micro-app';
  }

  apply(compiler) {
    compiler.hooks.emit.tapAsync('StyleScopePlugin', (compilation, callback) => {
      Object.keys(compilation.assets).forEach(filename => {
        if (filename.endsWith('.css')) {
          let source = compilation.assets[filename].source();
          
          // 给全局样式添加作用域
          source = source.replace(/(body|html|.global-reset)/g, ${this.scopeClass} $1);
          
          compilation.assets[filename] = {
            source: () => source,
            size: () => source.length
          };
        }
      });
      
      callback();
    });
  }
}

module.exports = StyleScopePlugin;

样式资源动态加载的坑

还有一个坑是动态加载的样式资源。有些子应用会在运行时动态加载CSS,比如按需加载组件库的样式。这些样式默认是在head标签里,还是会污染全局。

解决方法是在子应用卸载时清理相关样式:

let injectedStyles = [];

export async function unmount(props) {
  const { container } = props;
  
  // 清理动态注入的样式
  injectedStyles.forEach(style => {
    if (style.parentNode) {
      style.parentNode.removeChild(style);
    }
  });
  
  // 清空引用
  injectedStyles = [];
  
  // 清理DOM
  container.innerHTML = '';
}

// 拦截动态样式注入
function interceptStyleInjection() {
  const originalAppendChild = HTMLHeadElement.prototype.appendChild;
  
  HTMLHeadElement.prototype.appendChild = function(node) {
    if (node.tagName === 'STYLE' || 
        (node.tagName === 'LINK' && node.rel === 'stylesheet')) {
      injectedStyles.push(node);
    }
    return originalAppendChild.call(this, node);
  };
}

不过这种方式有点暴力,可能会干扰正常的页面样式管理。所以我后来改成了更温和的方式:在子应用初始化时给每个动态样式节点添加标记,方便后面精准清理。

最终方案整合

综合下来,我用了这样的最终方案:

// 主应用路由切换时
router.beforeEach(async (to, from, next) => {
  // 卸载之前的微应用
  if (currentMicroApp) {
    await currentMicroApp.unmount();
  }
  
  // 创建新的微应用实例
  currentMicroApp = loadMicroApp({
    name: to.name,
    entry: getEntryUrl(to.name),
    container: '#micro-app-container',
    props: {
      appInstanceId: generateUniqueId()
    }
  });
  
  next();
});

// 子应用通用入口封装
export class MicroAppWrapper {
  constructor(config) {
    this.config = config;
    this.styleScopes = [];
  }
  
  async mount(props) {
    const { container, appInstanceId } = props;
    const scopeClass = micro-app-${appInstanceId};
    
    // 创建作用域容器
    const appContainer = document.createElement('div');
    appContainer.className = scopeClass;
    container.appendChild(appContainer);
    
    // 启动具体应用
    await this.startApplication(appContainer);
    
    return {
      unmount: () => this.cleanup(scopeClass)
    };
  }
  
  cleanup(scopeClass) {
    // 清理作用域内的样式
    document.querySelectorAll(style[data-scope="${scopeClass}"]).forEach(el => {
      el.remove();
    });
    
    // 清理DOM
    const container = document.querySelector(.${scopeClass});
    if (container) {
      container.remove();
    }
  }
}

这套方案基本解决了样式隔离的问题,虽然还有些小瑕疵(比如某些复杂组件的样式可能还是会有轻微泄漏),但整体可控,线上运行稳定。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论