qiankun框架集成子工程时无法加载字体文件

427 阅读6分钟

前言

半年多以前项目引入了微前端架构,使用qiankun框架重构了原来的iframe嵌入的子工程。为了解决主子工程的状态、样式隔离问题,所以开启了shadowDOM选项,然后就埋下了许多的坑。

背景

事情的起因是,某一天领导觉得有一个icon不好看,于时让UI设计了新的icon,我把它制作为字体文件,在子工程使用css引用字体文件,找到对应元素替换class、运行子工程、icon成功替换、效果完美。提交代码,发布测试环境一气呵成,打开测试环境一看,icon变成了一个长方形。 截取图片_20220812163745.png
再三确认代码确定已提交,然后我人麻了。


问题定位

把项目完整运行起来,发现单独运行子工程icon正常渲染,一旦集成到qiankun环境后,icon就不对。浏览器元素审查发现,所有样式都正常加载,font-family和content都有,但就是icon出不来。

截取图片_20220812165458.png

截取图片_20220812165512.png
这个种情况基本可以确定字体文件在qiankun环境下存在问题。将字体文件复制一份导入主工程,刷新页面,icon就正常了,确认就是子工程的字体文件在qiankun环境有问题。


根因分析

通过 qiankun环境下和子工程单独运行环境的network请求对比,在qiankun环境下发现没有子工程的字体文件网络请求。然后开始怀疑是不是因为开启了shadowDOM的原因(因为它出现过很多问题),通过资料查找,发现一篇文章# 微前端乾坤使用过程中的坑,博主也遭遇了相同的问题。总结就是shadowDOM里不支持@font-face。


解决办法

  1. 最简单的办法就是将字体文件copy一份在主工程也再来引入一次,但是这样以后子工程有字体文件的新引入或者修改,主工程也要需要再copy一次。
  2. 既然在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正常渲染了,问题解决。


后续优化

  1. 当同一子工程多次挂载时,发现同一个字体的选择器会重复添加到head里。解决办法:在调用addStyleToDom方法生成style标签时,添加一个id(我直接用的字体名字),后续添加时,根据这个id先查找style是否已存在,再判断是否需要创建添加。
  2. 主公程的样式加载时,也会被检索一遍,如果有字体引用的话,并且也会被添加到head里,这部分不是我们所期望的。解决办法:再调用appendChild时,我们可以知道调用者是谁,用它判断它是否在qiankun应用的容器内,如果在容器内,就让他执行我们代码逻辑,如果没有,直接执行原有逻辑就好了。

结语

使用微前端确实可以让项目轻松解耦,但是不同项目间的样式、状态干扰等问题,解决起来让人很头痛。虽然可以开启shadowDOM选项,但是会有其他问题的出现,下次我再写遇到的另外一个也是因为开启shadowDOM而引发的问题。最后,这是我第一次写文章,好多东西都不太会用,书写语法、格式排版之类的,如果有问题请指教。希望我这篇文章对需要的人有帮助,当然如果有其他的思路或办法也不吝赐教,谢谢阅读。