qiankun
是一个很流行的微前端解决方案。之前我也详细的分析过qiankun的原理,感兴趣的可以看看。
Vite是当下比较流行的构建工具,它对标的是webpack,并作为Vue3脚手架的默认工具替代了老版vue-cli中的webpack。当然,Vite不仅仅能使用在Vue中,React+Vite也是很好用的。它的特点就是开发模式下运行特别“快”,这个得益于浏览器对ESM的原生支持。
既然两个都是流行的解决方案,那么是否可以把它们结合在一起使用呢?比如React+Vite+qiankun
架构的项目,把它作为一个微应用接入到主应用中,同时微应用还保留ESM的特性?很可惜,直接接入是不行的。
存在哪些问题?
Vite构建的应用,开发环境下使用ESM的方式加载脚本。而到目前为止,qiankun
还没有增加对ESM的官方支持。如果我们在加载基于Vite的微应用时,会出现一系列问题:
-
直接报错:
Cannot use import statement outside a module
原因:ESM的脚本内部直接使用
import xxx from 'xxx'
语法,Vite开发模式下并不会对其转码。而这样的语法是无法在qiankun
里直接fetch
然后eval
的,它只能在ESM中使用。即通过<sciprt type="module>"
声明的入口脚本。 -
ESM无法导出qiankun生命周期函数
原因:
qiankun
需要微应用打包为umd格式。这种情况下,qiankun
对微应用的脚本进行eval
操作后,可以在js沙箱*(window.proxy[appName])*下,获取到导出的所有生命周期函数。而对于ESM格式的脚本,qiankun
无法直接获取到生命周期。 -
应用内相对路径的静态资源出现404
原因:微应用独立运行时,相对路径从自己的根开始。但当微应用被加载到主应用中时,相对路径从主应用的根开始。此时静态资源会出现404。webpack支持运行时publicPath技术,即
__webpack_public_path__
。所以需要进行如下设置:// public-path.js if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }
这代表如果微应用被加载到主应用中,则publicPath会设置为
qiankun
注入的变量,这个变量就是微应用的地址。这样所有的静态资源都会从微应用的地址获取,不会404。而独立运行时也不会404。但是Vite是不支持运行时publicPath技术的。我们得去手动设置静态的publicPath。
-
不支持多环境部署
原因同上,由于Vite不支持在运行时,动态修改publicPath。所以只能手动设置静态的publicPath。但是这样的话多环境就得配置环境变量
.env
,分开构建并部署。不能实现一次构建,多环境运行。
对于这些问题,qiankun
没有解决,我们只能自己写个插件,看看在插件的帮助下,能否让Vite微应用,既能保留ESM特性,又能接入微前端的能力。
实现解决方案
针对于上面的这几个问题,最好的肯定是qiankun官方去支持,对ESM和普通script分开去处理。但是它没有做,那只能我们去做了。我们也不可能大量的改项目源码去支持。那最好的就是开发一个Vite插件,让Vite插件去modify我们的代码。
初始化一个插件项目,然后一步一步的解决问题。
import导入ESM并运行
首要的问题就是ESM的脚本无法直接被fetch
然后eval
。但是通过测试发现,动态的import()
语法,可以做到不报错正常执行。即如下代码:
// <==静态import语句会报错:`Cannot use import statement outside a module`
const scriptText = `
import "./main.js";
`;
evalSandox(scriptText);
// ==>动态import()语句不报错,正常执行
const scriptText = `
import("./main.js");
`;
evalSandox(scriptText);
那么,我们的插件就需要对index.html
下,所有<script type="module" src="xxx">
的ESM入口脚本进行转换。转换为内联脚本:
<script>
import(window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ + "xxx")
</script>
由于我们改为手动控制ESM脚本的导入,所以需要自己追加base路径。否则它会从主应用下找脚本,导致404.
ESM导出应用生命周期
qiankun需要应用导出生命周期函数。我们需要插件做到:把生命周期函数绑定到window.proxy[appName]
下。
在上一步中,我们使用动态import语法导入了模块,正好我们可以从模块中获取到生命周期。这样模块中仍然保持qiankun官方的设置方法:使用export
导出生命周期。
// 插件中
import("./main.js").then((mod) => {
mod.bootstrap
mod.mount
mod.unmount
})
// main.js模块中
// ...
export function bootstrap() { /* */ }
export function mount(props) { /* */ }
export function unmount() { /* */ }
在模块导入后再设置到window上肯定不行的,因为动态import语法是异步的。而qiankun
需要在所有入口脚本执行完毕后,同步获取生命周期函数。类似于下面:
// 执行所有脚本
execScripts(entry, global);
// 获取生命周期
const scriptExports = global[app.name];
const { bootstrap, mount, unmount } = scriptExports;
所以如果在导入后再设置到window上,直接就undefined报错了。那怎么办?看到qiankun
的文档中,生命周期函数可以是异步的。这就有操作空间了。我们可以先返回一个虚拟的生命周期对象,生命周期函数内,如果模块已加载那调用真实的生命周期,否则返回Promise,这个Promise会在模块加载后被resolve。简化代码如下:
// 插件内
let realModule = null;
let taskQueue = [];
window[appName] = {
mount: (props) => {
// 模块已加载完,直接执行;否则放到任务队列中
if (realModule) return realModule.mount(props)
else return new Promise((resolve) => {
taskQueue.push(() => {
resolve(realModule.mount(props))
})
})
}
}
import("./main.js").then((mod) => {
// 模块加载完成,执行任务
realModule = mod
taskQueue.forEach((task) => task())
taskQueue.length = 0
})
封装成Vite插件
上面的代码其实都应该是在运行时执行,即从html entry导入后开始执行。而我们的Vite插件执行在编译时,所以需要包装成字符串并修改index.html
。
import { load } from "cheerio";
//...
export default function pluginEntry(appName) {
return {
// ...
transformIndexHtml(html) {
const $html = load(html);
$html('script[type="module"]').each((_, el) => {
const $el = $html(el);
const src = $el.attr("src");
// 移除原来的src和type
$el.removeAttr("src");
$el.removeAttr("type");
// 将脚本内容作为内联脚本插入script中
$el.html(convertESMScript(src, appName));
});
}
// ...
}
}
// 返回实现上述功能的脚本字符串
function convertModuleScriptContent(moduleScriptSrcUrl, appName) {
return `
// ...
let realModule;
window['${appName}'] = { /* ... */ }
// ...
`
}
我们在Vite插件的hooks方法transformIndexHtml
里,修改index.html
的内容。这样qiankun执行我们的内联脚本后,让ESM能正常加载。
处理静态资源
微应用中的相对路径的静态资源如果不设置publicPath,那么就会出现404,因为浏览器会从主应用路径下加载。我们的插件不去直接设置publicPath。我们需要通过Vite的配置来设置,在vite.config.js
中。
在开发环境下,我们设置server.origin
。生产环境下,设置base
。都设置为具体的域名。
// vite.config.js
export default defineConfig({
//...
base: "http://xxx.com", // 生产环境,使用部署线上域名
server: {
//...
origin: "http://localhost:xxx" // 开发环境,使用本地ip+端口
}
//...
})
测试一下
到目前为止,我们让Vite的微应用保留了ESM特性,并导出了生命周期以支持qiankun,并处理了静态资源。理论上来说已经可以正常在主应用中加载和展示了。来测试一下。
参照官方文档创建一个Vue3项目。完成后修改vite.config.js
,加载插件并设置资源publicPath:
// vite.config.js
import qiankun from '@/plugin/vite-qiankun-plugin'
export default defineConfig({
//...
plugin: [
//...
qiankun('vite-demo'), // 加载我们的插件
],
base: "http://xxx.com", // 生产环境,使用部署线上域名
server: {
//...
cors: true, // 设置跨域
origin: "http://localhost:xxx" // 开发环境,使用本地ip+端口
}
//...
})
修改main.ts
,导出生命周期。修改router,设置base
路径。这个参照qiankun官方文档即可,没有任何不同。
然后找个主应用,注册下此微应用,也参照qiankun官方文档即可。设置完之后,打开微应用的路由,看看能否正确加载出来。
Perfect!从结果来看,我们的插件集成后,Vite的微应用已经可以直接使用了。但它并不是完美无瑕的,我们接着来看。
ESM脱离了沙箱环境
qiankun的重要特性之一就是js沙箱。很简单的,我们在微应用中打印一下window对象
,就知道当前是否运行在沙箱中了。
// main.ts
//...
console.log('window is', window);
//...
可以看到打印的window对象是真实的window对象
,而不是Proxy对象
。说明此时Vite微应用并不是在沙箱环境中运行的。
从源码上分析一下就可得知。前面我们是通过动态import()
语法导入和执行入口ESM,并不是通过fetch
然后eval
执行。后者能保证被eval
的脚本和当前是处于同一运行环境。而import()
语法却不行,因为它是浏览器提供的能力,被它导入的脚本其实已经脱离了沙箱环境了。
目前没有想到最完美的方案去解决。能想到的方案有:
-
提供一个新的
window
,在Vite微应用内,使用新的window
来set
和get
全局变量。这个方案缺点比较明显,有一定的改造成本和使用负担。而且对于三方包就无能为力了。 -
在插件中转换js模块代码,将Vite微应用里所有使用到
window
的地方都改为(window.proxy || window)
。这个方案也存在风险,因为有可能会遗漏。
我们测试一下方案一。还是在插件中,我们注入一个新的window变量,比如window.windowX = (window.proxy || window)
。这即是说,如果我们运行在js沙箱中,windowX
就取的代理对象,否则取正常的window对象。
此时,我们在微应用中,打印一下windowX
。并为它设置一个新变量。
// main.ts
//...
windowX.aaa = 'aaa'
console.log("window is ", windowX)
console.log("window.aaa is ", window.aaa)
//...
在主应用中,跳转其他路由将微应用卸载。我们再看看window对象是否恢复。
很不错,看来沙箱正确的装载和卸载了。
ESM内联脚本
Vite + React
的微应用,如果我们启用了HMR功能的话,就会发现仍然出现了Cannot use import statement outside a module
这样的报错。这是因为react-refresh
插件会在我们的入口html中注入一段内联的ESM。内容如下:
<script type="module">
import { injectIntoGlobalHook } from "/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
</script>
在我们前面的代码中,我们只处理了单一src的ESM。而对于这种内联脚本的代码,并没有做处理。所以qiankun会把这段代码直接执行,导致报错。
该怎么解决呢?和单一ESM一样,也要转换为动态import()
语法。不一样的是,一段内联脚本里,可能会有多个import,同时可能还会使用模块导出值。想一想,如果我们把它转换为动态import()
语法,会有什么区别?对比一下:
// ESM脚本
import { varA } from "modA";
import "modB";
console.log(varA);
// 动态import()脚本
const { varA } = await import("modA")
await import("modB")
console.log(varA);
上面是转换前的,下面是转换后的。可以看出来,语法其实比较固定。我们可以直接使用字符串替换就可以实现。当然,由于我们转换后使用了await
关键字。那我们需要将整段代码放入一个异步函数中,函数也需要立即执行。即如下代码:
(async () => {/** 转换后代码 */})()
具体转换函数就不在这里展示了。详细可查询源码。处理完内联脚本的问题,自然React的HMR插件也能正常使用了。当然不仅仅是HMR插件,只要使用到内联ESM的插件,甚至自己在index.html
中添加了内联脚本。也都能正确执行了。
CSS样式隔离
前面提到,由于动态import()
导致我们的微应用代码脱离了js沙箱。而多例沙箱模式
下,document也会生成代理,并且只有使用document代理生成的元素,才会被添加到微应用的dom容器下(代码注释在这里)。这就导致了,如果我们使用多例沙箱模式(这个模式在qiankun中是默认的),微应用的动态样式都会被直接添加到主应用的<head>标签下,并且不会走重写处理。这带来了两个问题:
-
启用
严格样式隔离
,即Shadow DOM
时,微应用无法获取自己的动态样式。(这是因为样式在主应用下,非Shadow root下)。 -
启用
实验性样式隔离
,即样式重写
时,微应用的动态样式仍然不会被重写,保持原样。
这两个问题都属于很严重的bug,目前无法解决。不过它只会出现在多例沙箱模式
下。如果我们改用单例沙箱模式
,这两个问题就不存在了。
import { registerMicroApps, start } from "qiankun";
registerMicroApps(microApps);
// 使用loose: true启用单例沙箱模式
start({ sandbox: { loose: true } });
此外,我们也可以在应用中自己去处理样式隔离。有很多方案:
-
CSS Module。
-
使用CSS in JS框架。
-
约定一个统一的命名前缀。
这些方案其实是更有效的。
在实现样式隔离时,我发现qiankun自身仍然有两个bug未解决:
- qiankun劫持了
appendChild
和insertBefore
这两个方法,而Vite开发环境下使用了insertAdjacentElement
添加样式表。(issue在这里)- qiankun启用
loose
模式时。由于劫持的方法只会设置一次,导致isInvokedByMicroApp
函数内取到的appName值始终为第一个加载的微应用名称。问题1可以通过插件替换vite脚本中的
insertAdjacentElement
方法来绕过。而问题二则必须qiankun去修复,它会导致样式不被重写并且直接添加到<head>下。
生产环境构建
开发模式下运行没问题了,但是不代表万事大吉。因为Vite架构的特殊性,开发模式和生产模式很有可能会有差异。
还是上面的例子,我们在Vite打生产包后,再进行preview预览。尝试一下加载微应用…可怕的事情还是发生了,本来展示微应用页面的地方一片空白,但是控制台却没有任何报错…
调试一下,发现我们的realModule
始终为空,接着看模块导入后的对象,发现对象里的方法已经不是mount
、unmount
了…
难道是被压缩了?还是被tree-shaking掉了?设置vite.config.js
中build.minify: false
,关闭压缩,然后查看构建出来的js最后导出的内容:
// dist/assets/index-C9v7q0Gc.js
//...
export {
_export_sfc as _,
createBaseVNode as a,
createElementBlock as c,
openBlock as o
};
看来我们入口函数main.ts
中的export function xxx()
等等生命周期都被tree-shaking掉了。所以导致了qiankun执行了虚拟的生命周期,并在真实生命周期获取成功后resolve。但由于被树摇掉了,所以没法执行真实的生命周期函数,也就出现了白屏。
我们要做的就是在插件中,想办法将main.ts
中的导出函数都给保留下来。
到这里就是要看你对rollup
和tree-shaking
的了解程度啦。它们都不是本文章的重点内容,所以不做详解。简单介绍下tree-shaking
会将你模块图中未被使用到的导出移除掉。比如:
// index.ts
import "./unused";
// unused.ts
export function unusedFunction() {
console.log('unused');
}
入口index.ts
导入了unused.ts
,但是没有任何使用。那最后rollup构建后不会保留unusedFunction
在产物中。也就是tree-shaking
。
但是对于入口模块中的导出,rollup理应保留才对,没有任何导出的第三方库就不是一个合格的库不是吗?有一个属性叫preserveentrysignatures,就是专门控制入口模块导出的。在rollup中,它默认是"exports-only",即保留。但是在Vite中。很可惜,它默认是false,即不保留入口文件中的导出。
这样的话,我们在插件中,强制设置此配置为"exports-only",是不是就可以了,再试试:
export default function pluginEntry(appName) {
return {
//...
config: () => ({
build: { rollupOptions: { preserveEntrySignatures: "exports-only" } },
}),,
}
}
构建后,查看产物…很可惜,main.ts
中的导出还是没保留下来…
找了半天也测试了半天,没发现原因。我猜测应该不是这个配置不生效,还有另一个可能就是:也许main.ts
并不是入口模块。查看Vite文档,可以看到这一点:index.html才是项目的入口。**Vite会解析index.html,把它作为入口,生成一个虚拟的入口模块,并导入index.html中定义的<script>模块。**这个也是很合理的,因为index.html
中我们可能会加多个模块。main.ts
不总是入口。
<!-- index.html -->
<html>
<!-- ... -->
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/fake_entry.ts"></script>
<!-- ... -->
</html>
这样的话,我们需要对index.html
的虚拟模块代码进行转换,为了将main.ts
中的导出都保留下来。那我们需要将虚拟模块里,导入改为全部导出。
// index.html的虚拟模块
// 修改前
import "main.ts"
// 修改后
export * from "main.ts"
如上代码。这样就能保留main.ts
中的export了。改一下插件。
export default function pluginEntry(appName) {
return {
//...
config: /* ... */,
// 我们转换的时机,是在Vite对index.html执行解析后
enforce: "post",
transform(code, id) {
if (id.endsWith("html")) {
return code.replaceAll("import", "export * from");
}
return null;
},
}
}
查看生成的bundle,发现产物里面已经包含了我们export的生命周期了。在主应用中查看,已经正常加载。
// 生产bundle
//...
export { b as bootstrap, m as mount, u as unmount, d as update };
多环境部署
前面也提到了,多环境部署很不方便。因为Vite无法在运行时修改publicPath。不过搜索一下npm市场后,我找到了一款可以支持运行时设置publicPath的插件vite-plugin-dynamic-base。它的功能类似于webpack的__webpack_public_path__
。
用法就不说了,看它的文档就可以了。它的原理大概是这样:
-
在
vite.config.js
里设置base
为固定字符串。Vite
会将所有静态资源在构建时,前面追加这个固定字符串。 -
插件在
generateBundle
的钩子里,搜索固定字符串并转换为动态的。比如/__dynamic_base__/assets/index-4ur9fIU9.css
就被转换为window.__dynamic_base__ + "/assets/index-4ur9fIU9.css"
-
在所有代码被执行前,设置
window.__dynamic_base__ = http://xxx
。那所有的资源都会从设置的地址去获取。
所以这个插件完全能实现微应用接入qiankun时,静态资源从qiankun注入的地址获取。比如在index.html
中:
<!-- index.html -->
<html>
<head>
<!-- ... -->
<script>
window.__dynamic_base__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ || ''
</script>
</head>
<!-- ... -->
</html>
这样的话,多环境部署也没有问题了。一套代码构建后部署到多个环境,每个环境的主应用加载对应环境的微应用即可。
不过由于这个插件会修改
index.html
中的脚本url,而我们也需要修改脚本url追加base路径,这就产生了冲突。所以我们在修改之前,需要对url进行处理。把其中的/__dynamic_base__
给去掉。
感谢三方插件
感谢三方插件vite-plugin-qiankun。它提供了最初的灵感和实现。那既然已经有了这个插件,我为什么还要去重新写一个呢?
我在vite-plugin-qiankun
的基础上优化了很多:
-
API很简单,只需要配置插件并导出生命周期即可使用。不需要使用
renderWithQiankun
方法来导出生命周期。 -
支持了
index.html
中的ESM内联脚本。所以React HMR这样的插件也能用了。 -
访问window的地方,直接使用
windowX
即可,不需要导入qiankunWindow
。 -
配合
vite-plugin-dynamic-base
插件,能够实现线上的多环境部署。 -
vite-plugin-qiankun
的作者已经很久不维护了。
源码仓库
Github地址:https://github.com/missmess/vite-plugin-qiankun-x
标签:插件,接入,qiankun,window,import,ESM,Vite From: https://blog.csdn.net/wangleixhlm/article/details/143418187