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();
}
}
}
这套方案基本解决了样式隔离的问题,虽然还有些小瑕疵(比如某些复杂组件的样式可能还是会有轻微泄漏),但整体可控,线上运行稳定。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论