前言
半年多以前项目引入了微前端架构,使用qiankun框架重构了原来的iframe嵌入的子工程。为了解决主子工程的状态、样式隔离问题,所以开启了shadowDOM选项,然后就埋下了许多的坑。
背景
事情的起因是,某一天领导觉得有一个icon不好看,于时让UI设计了新的icon,我把它制作为字体文件,在子工程使用css引用字体文件,找到对应元素替换class、运行子工程、icon成功替换、效果完美。提交代码,发布测试环境一气呵成,打开测试环境一看,icon变成了一个长方形。
再三确认代码确定已提交,然后我人麻了。
问题定位
把项目完整运行起来,发现单独运行子工程icon正常渲染,一旦集成到qiankun环境后,icon就不对。浏览器元素审查发现,所有样式都正常加载,font-family和content都有,但就是icon出不来。
这个种情况基本可以确定字体文件在qiankun环境下存在问题。将字体文件复制一份导入主工程,刷新页面,icon就正常了,确认就是子工程的字体文件在qiankun环境有问题。
根因分析
通过 qiankun环境下和子工程单独运行环境的network请求对比,在qiankun环境下发现没有子工程的字体文件网络请求。然后开始怀疑是不是因为开启了shadowDOM的原因(因为它出现过很多问题),通过资料查找,发现一篇文章# 微前端乾坤使用过程中的坑,博主也遭遇了相同的问题。总结就是shadowDOM里不支持@font-face。
解决办法
- 最简单的办法就是将字体文件copy一份在主工程也再来引入一次,但是这样以后子工程有字体文件的新引入或者修改,主工程也要需要再copy一次。
- 既然在shadowDOM里不支持@font-face,那么就把子工程的@font-face提取出来,放到主工程的head里加载就可以了。但是如何知道子工程加载的样式里有@font-face,这就又是一个问题。
初步想法是,看看能不能借助webpack编译时提供的钩子来找到解决办法,奈何能力不够,没有找到。后面通过断点调试发现了一个插件:style-loader。组件的css都是使用的style-loader来进行处理的,查看style-loader源码发现组件的样式都是通过插入style和link标签添加到页面上的。
// 通过link标签插入到head
module.exports = function addStyleUrl (url, options) {
// ...TODO
var link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = url;
addAttrs(link, options.attrs);
var head = document.getElementsByTagName("head")[0];
head.appendChild(link);
//...TODO
}
// 将解析好的css文本添加到style标签里
function applyToTag (style, obj) {
var css = obj.css;
var media = obj.media;
if(media) {
style.setAttribute("media", media)
}
if(style.styleSheet) {
style.styleSheet.cssText = css;
} else {
while(style.firstChild) {
style.removeChild(style.firstChild);
}
style.appendChild(document.createTextNode(css));
}
}
阅读源码发现,无论通过是link标签还是style标签添加样式都会有appendChild方法的调用。关注appendChild的参数发现,当head调用时,参数是link标签,当style调用时,参数就是css样式字符串。我们通过link的href路径,就能拿到css样式文件。拿到了css样式字符串,检查当前css字符串是否有@font-face,如果有就把定义字体选择器部分给截取出来,通过style添加到head里。
具体实现
第一种解决办法,很简单就不写实现方法了。重点是第二种解决方案,首先我们需要在加载子工程前拿到原生的appendChild方法的参数,这个时候很容易想到AOP编程。然后很轻松就能实现这部分代码
const appendChild = Element.prototype.appendChild;
Element.prototype.appendChild = function(child){
if (this.tagName === 'STYLE') {
console.log(child,'css text')
}
if (child.tagName === 'LINK') {
const href = child.href;
fetch(href).then(res => res.text()).then(res => {
console.log(res,'css text')
});
}
return appendChild.call(this, ...arguments);
};
接下来,就需要根据css找到字体引用
@font-face {
font-family: xxxx;
src: url('./assets/fonts/xxx.otf');
}
.className{
height:200px;
}
这是字体定义的css样式代码,我们需要把字体定义的选择器字符串给截取出来。一开始想的是通过第三方库解析成ast语法树然后在处理。结果没有找到,没办法只能自己写方法。
const reg = /@font-face/;
const endToken = '}';
function findFontReference (content) {
let styleText = '';
if (!content) return styleText;
const result = content.match(reg); // 是否有字体定义
if (!result) return styleText;
const { index } = result;
content = content.substring(index);
const endIndex = content.indexOf(endToken);
styleText += content.substring(0, endIndex + 1);
content = content.substring(endIndex + 1);
styleText += findFontReference(content); // 可能会有多个字体定义
return styleText;
}
通过这个方法将字体定义选择器的那部分字符串给截取出来,然后我们继续完善appendChild方法
// 将css字串添加到head里
function addStyleToDom (styleText, appendChild) {
if (!styleText) return;
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.appendChild(document.createTextNode(styleText));
appendChild.call(document.head, style);
}
Element.prototype.appendChild = function(child){
if (this.tagName === 'STYLE') {
const styleContent = child.textContent;
const styleText = findFontReference(styleContent);
addStyleToDom(styleText, appendChild);
}
if (child.tagName === 'LINK') {
const href = child.href;
fetch(href).then(res => res.text()).then(res => {
const styleText = findFontReference(res);
addStyleToDom(styleText, appendChild);
});
}
return appendChild.call(this, ...arguments);
};
完成以上代码,运行后发现并没有用,心态崩了。梳理代码,思路应该是没有问题的,debugger一步步调试发现子工程添加样式时并没有调用我们重写的appendChild方法。最后发现调用的是 ShadowRoot.prototype上的appendChild,没办法再把ShadowRoot.prototype上的appendChild重写一下,然后运行,icon正常渲染了,问题解决。
后续优化
- 当同一子工程多次挂载时,发现同一个字体的选择器会重复添加到head里。解决办法:在调用addStyleToDom方法生成style标签时,添加一个id(我直接用的字体名字),后续添加时,根据这个id先查找style是否已存在,再判断是否需要创建添加。
- 主公程的样式加载时,也会被检索一遍,如果有字体引用的话,并且也会被添加到head里,这部分不是我们所期望的。解决办法:再调用appendChild时,我们可以知道调用者是谁,用它判断它是否在qiankun应用的容器内,如果在容器内,就让他执行我们代码逻辑,如果没有,直接执行原有逻辑就好了。
结语
使用微前端确实可以让项目轻松解耦,但是不同项目间的样式、状态干扰等问题,解决起来让人很头痛。虽然可以开启shadowDOM选项,但是会有其他问题的出现,下次我再写遇到的另外一个也是因为开启shadowDOM而引发的问题。最后,这是我第一次写文章,好多东西都不太会用,书写语法、格式排版之类的,如果有问题请指教。希望我这篇文章对需要的人有帮助,当然如果有其他的思路或办法也不吝赐教,谢谢阅读。