我花了三天彻底搞懂了webpack中这个报错
文章内将使用
MF/mf
指代 Webpack Module Federation,并假设知晓基本的MF
使用
一、页面偶发报错:"Container initialization failed as it has already been initialized with a different share scope"
1.1 问题背景
我司是大量依赖 MF 的能力,包括:应用加载、模块(组件)共享以及 shared library(共享依赖包)
某天测试同学提了一个bug过来说门户页面偶发
组件显示异常。
门户大概长这样,橙色部分就是用户自行配置的显示模块,他可能来自多个应用
1.2 解决问题
首先报错信息参考性不大,因为涉及到 MF 的一些内部术语,这种情况我们先使用科学上网搜索报错信息
很快啊,就我们找到了 github上关于此问题的讨论,作者给出了两个解决方案
try catch
报错代码- 添加标识,避免多次初始化同一个容器
这里我们采用了第一种方案,对 loadComponent 添加try catch
function loadComponent(scope, module) {
return async () => {
// Initializes the shared scope. Fills it with known provided modules from this build and all remotes
await __webpack_init_sharing__('default');
const container = window[scope]; // or get the container somewhere else
// Initialize the container, it may provide shared modules
try {
await container.init(__webpack_share_scopes__.default);
} catch(err) {
// 忽略
}
const factory = await window[scope].get(module);
const Module = factory();
return Module;
};
}
loadComponent('abtests', 'test123');
很顺利啊,问题解决了。bug成功关闭
1.3 bug解决了,但是问题却没有
bug是关掉了,但是我满脑子一堆❓
- 为什么不可以重复初始化?其他很多地方也是重复调用
loadComponent
都没问题 - 为什么就这个页面会报错,还是偶发
- 加了 try catch 有什么潜在风险
这些对于一个有技术洁癖的人来说就像是:你告诉一个强迫症患者,治好你的病只需要两步
- 每天按时起床
二、追根溯源
想要真正知道为什么报这个错误,就必须深入了解 MF 里面的概念
首先我们先看报错信息。翻译成中文:容器初始化失败,因为它已经使用不同的共享范围进行了初始化
这里有3个关键词:
容器(Container)
容器初始化(initialization)
share scope(共享范围)
2.1 容器(Container)的一生
从官方文档我们可以知道,容器就是:任意单独构建、遵循公开对特定模块的异步访问能力的应用
而其中 公开对特定模块异步访问能力
具体步骤如下:
- 加载模块(异步)
- 评估(evaluating)模块(同步)
大白话就是:你想要使用任意一个容器的模块,需要先加载这个容器中模块对应的资源,然后解析模块
而任意容器本身可以充当 Host,也可以作为 Remote。即:可以加载其他容器,也可以被其他容器加载。
道理讲完了,我们看看代码。从官方加载MF模块代码示例代码中我们可以看到,其实是存在两次初始化
第一次初始化说的是 初始化共享范围
,第二次初始化才是 容器初始化
你可以看到这里的两次初始化都是魔术字符串:
__webpack_init_sharing__
__webpack_share_scopes__
这是人类能看懂的代码吗??
没办法,只能使用我的终极绝招了:
看源码,跑dome 调试
这个办法我尝试了很多次,百试百灵
2.2 实践才是检验知识的唯一标准
首先我们clone webpack,直接搜索那两个魔术字符串。这种 magic string 最后一定会被编译成具体方法的,果然顺利找到
再把dome跑起来,对照看下
果然没错了,而 webpack_require 这个函数是 webpack 实现的模块引用函数, 每个应用(容器)的入口文件处通过闭包
的形式实现了该方法,
敲重点,可以看到 MF 的入口文件(remoteEntry.js)也是通过闭包
的方式实现的 webpack_require,而我们刚才的两个魔术字符串都是挂载在这个函数上的静态属性/方法(I
和 S
)
ok,现在我们在回头再看看 loadComponent
的代码,是不是就so easy了?嗯,问题来了:
- loadComponent 中 第一个初始化共享范围是初始化谁的?
- 第二个容器初始化是初始化哪个容器?它的参数是谁的?
2.3 场景模拟
回答上面两个问题之前,我们先来一个模拟下真实场景
假设我们的门户页面
会使用 loadComponent
加载商城应用的商品列表模块
,它们都是 MF 应用。请回答上面两个问题的答案:
- 当然是初始化 门户 的共享范围对吧
- 必须是初始化 商城 容器,它的参数是 门户 的
共享范围
那什么是共享范围呢?官方文档没有太多解释,莫得关系,我可以自食其力!
只需要花费区区两天时间,就可以通过调试断点以及读代码领悟出来:
从用户打开浏览器页面开始,每一个
非webpack应用
打开的第一个 webpack 应用
,就会构建一个共享范围,一直传递给这个应用加载的所有的 MF 应用共用
应用A是加载的第一个webpack应用,所以它会初始化产生一个共享范围,然后会传递给 应用B/C/D/E/F... 所以共享范围就像是一个作用域链,只要在这个链条内。应用共享同一个shared scope
2.4 制造犯罪现场
基于上面的知识,那么我们怎么才能制造出 “Container initialization failed as it has already been initialized with a different share scope”
这个报错呢?
- 加载链路中出现非MF加载(可能性高)
可以看到应用D会被初始化多次,并且应用C是全新的共享范围,应用B是应用A的共享范围。所以必定报错
- 代码错误,某一次加载中不传递共享范围(可能性低)
- 其他?
三、功败垂成转柳暗花明
当我成功制造出犯罪现场时候,我以为再去复盘这个bug应该就轻而易举了。
万万没想到,生活又给了我一刀...
3.1 功败垂成
按照上面的结论,我反推bug产生的真正原因。发现代码 loadComponent 没问题,和官方代码一模一样。虽然存在多处拷贝,这不是啥大问题
那么就一定是加载链路有问题了,按照这个思路排查。半天过去了,发现整个加载链路都是对的。但是就是出现了多个共享范围的情况,并且还都是同一个应用(还记得门户那张图吧,里面的模块都来自同一个应用同一个模块)
如果遇到这种情况,我的建议是好好休息下。再整理整理现在所有已经掌握的信息,从头再来制定突破点。不然只会在细节处愈陷愈深,最后功败垂成
3.2 踏入深水区
从传递共享范围入口,通过阅读代码。应用的共享范围是挂在一个空对象的 default
属性上,这个空对象是 evaluation
阶段通过闭包跟随 webpack_require 产生的
那么我可以对这个空对象做手脚,通过堆栈找到幕后黑手呢!给大家分享一个小技巧:通过条件断点注入调试代码
Object.defineProperty(__webpack_require__.S, 'default',{set(val) {debugger;window.aaa = val}, get() {debugger;return window.aaa}});false
我选择劫持设置 default 属性,结果全部指向 容器初始化 的函数,看来此路不通
就在一筹莫展并思考许久之后,突然发现一个问题:之前断点发现,入口文件被反复 evaluation
,是不是意味着同一个应用存在反复加载入口文件的问题?这个会不会有影响??
再次使用条件断点注入调试代码,收集 webpack_require 函数到全局变量,然后对比函数的指针,发现确实是同一个应用存在多个 webpack_require,翻看代码,是因为这个应用的开发同学每次在 loadComponent 之前都会加载一次 remoteEntry。我感觉可能找到了问题关键了!
3.3 官方误我啊,柳暗花明
按照上面的逻辑,如果每次都是一个新的 webpack_require,相当于下图
再仔细看图并思考过后,发现这样写并无不妥。最后只是浪费几次请求而已,事情又陷入了僵局。
享受了片刻失败的感觉,我又打起精神,再读了一遍代码,并仔细推敲每个过程。到底什么情况会出现问题??
直到我再次看到
难道会是走到这儿了吗?在我们这种场景下,什么情况才会走到这里面?我尝试打了断点,一次,两次,三次刷新页面。进了进了,进入断点了!!!这次我真的感觉谜题的答案要被破解了!!
通过堆栈可以看到,是 loadComponent 中
const factory = await window[scope].get(module);
这儿进来的,这是容器初始化结束后,加载模块了。正常来说绝对不可能进入断点才对,容器明明已经初始化了!
我再次读了 loadComponent 的实现,颤抖的打下了一个条件断点。没有意外,刷新页面,进入了断点。真相大白
你看出来了问题了吗?
没错,官方的代码存在问题。因为初始化的容器和加载模块的容器可能不是同一个了啊!可以看到容器初始化是一个异步的过程,假如恰好在初始化过程中,相同应用的入口文件又 evaluation
了一次,window[scope]上挂载的容器变成了最新的闭包产生的,而不是上一次的。相当于下面就会用这个没有初始化化的容器去加载模块,共享范围就会创建一个新的!!
正确的代码应该是:
--const factory = await window[scope].get(module);
++const factory = await container.get(module);
至此,所有问题全部解决。总共花费了我三天的时间(其实不止,周末也搭进去了)
四、总结
1、loadComponent 的正确写法应该是
function loadComponent(scope, module) {
return async () => {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
await __webpack_require__.I('default');
const container = window[scope]; // or get the container somewhere else
// Initialize the container, it may provide shared modules
await container.init(__webpack_require__.S.default);
const factory = await container.get(module);
const Module = factory();
return Module;
};
}
并非是官方示例有问题,其实官方也说了,容器只能初始化一次,入口文件正常情况也只会 evaluation 一次。但是说实话 MF 官方提供的文档还是比较难懂,并且加载函数本身是也是用了 LOW LEAVL API,出现这种情况并不显的意外了。只需要苦一苦开发者就好
2. 不要害怕未知
俗话说好女怕缠郎,只要舍得时间对未知进攻,一定会拿下它的!而这次的排查之旅也给我让我对 MF 有了更多的认知,现在再来回答一开始的问题就完全胸有成竹了
如果使用 try catch 包裹组件,会有什么问题?
答案是:try catch 会忽略错误,但同时也断开了共享范围链,意味着 MF 的 shared 能力失效,如果是一些单例依赖,比如 react 等,页面就会直接报错。
至于多次初始化容器会怎么样?正常来说没问题,大胆初始化就行 :)
希望这篇文章可以帮助到遇到这个问题的其他朋友~那么下班愉快了
转载自:https://juejin.cn/post/7309678898027233334