首页 > 其他分享 >Vite微应用如何接入qiankun

Vite微应用如何接入qiankun

时间:2024-10-31 21:48:55浏览次数:3  
标签:插件 接入 qiankun window import ESM Vite

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()语法却不行,因为它是浏览器提供的能力,被它导入的脚本其实已经脱离了沙箱环境了。

目前没有想到最完美的方案去解决。能想到的方案有:

  1. 提供一个新的window,在Vite微应用内,使用新的windowsetget全局变量。这个方案缺点比较明显,有一定的改造成本和使用负担。而且对于三方包就无能为力了。

  2. 在插件中转换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未解决:

  1. qiankun劫持了appendChildinsertBefore这两个方法,而Vite开发环境下使用了insertAdjacentElement添加样式表。(issue在这里
  2. qiankun启用loose模式时。由于劫持的方法只会设置一次,导致isInvokedByMicroApp函数内取到的appName值始终为第一个加载的微应用名称。

问题1可以通过插件替换vite脚本中的insertAdjacentElement方法来绕过。而问题二则必须qiankun去修复,它会导致样式不被重写并且直接添加到<head>下。

生产环境构建

开发模式下运行没问题了,但是不代表万事大吉。因为Vite架构的特殊性,开发模式和生产模式很有可能会有差异。

还是上面的例子,我们在Vite打生产包后,再进行preview预览。尝试一下加载微应用…可怕的事情还是发生了,本来展示微应用页面的地方一片空白,但是控制台却没有任何报错…

调试一下,发现我们的realModule始终为空,接着看模块导入后的对象,发现对象里的方法已经不是mountunmount了…

在这里插入图片描述

难道是被压缩了?还是被tree-shaking掉了?设置vite.config.jsbuild.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中的导出函数都给保留下来。

到这里就是要看你对rolluptree-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

相关文章

  • 以外部表 (External Table) 的形式,接入其他数据源
    外部表|StarRockshttps://docs.starrocks.io/zh/docs/data_source/External_table/外部表StarRocks支持以外部表(ExternalTable)的形式,接入其他数据源。外部表指的是保存在其他数据源中的数据表,而StartRocks只保存表对应的元数据,并直接向外部表所在数据源发起查询。目......
  • A股\美股\港股 WebSocket实时行情接口接入
    Websocket行情接入请按照下面的步骤完成沪深、港股或美股的行情接入。原文地址:https://jvquant.com/wiki.html#websocket-分配服务器为实现更好的用户体验,系统将自动为您分配合适的服务器。注意:每次分配的服务器地址会发生变化,连接服务前,请务必调用该接口获取最新的服务器地......
  • vue3第一章基础:创建Vue3.0工程,包括使用vue-cli 创建、使用 vite 创建
    @目录一、vue2、vue3、vue-cli版本、vue-router版本的关联关系1.说明2.不同版本的vue对应的vue-router版本和vuex版本二、创建Vue3.0工程1.使用vue-cli创建2.使用vite创建一、vue2、vue3、vue-cli版本、vue-router版本的关联关系1.说明1.VueCLI4.5以下,对应的是Vue2;Vue......
  • NVR设备ONVIF接入平台EasyCVR国标GB28181视频平台智能视频质量检测:自动化技术的革命性
    视频质量诊断技术是一种基于图像和视频处理的前沿技术,旨在评估和分析视频的质量,发现潜在问题并提供改进建议。该技术通过智能化的图像分析算法,对前端设备传回的视频流进行实时监测和诊断,确保视频监控系统能够持续提供高质量的监控画面。一、EasyCVR平台支持视频质量诊断NVR设备O......
  • 国标GB28181摄像机接入EasyGBS国标GB28181-2022平台全面支持GB28181协议接入
    国标GB28181协议作为我国制定的专门用于视频监控联网的协议标准,其在设计之初便致力于解决公共安全部门大规模的视频监控联网需求。该协议借鉴了SIP协议在通信信令上的优势,成功实现了大规模视频监控联网和跨网穿透等难题,因此受到了广泛的关注和应用。国标GB28181从诞生至今,已经......
  • 国标GB28181摄像机接入LiteGBS国标GB28181设备管理软件提升管理效率
    在信息技术迅猛发展和安全需求日益增加的今天,视频监控系统已成为我们生活中的一个重要组成部分。它在公共安全、城市治理、企业安全防护以及各类建筑项目中扮演着至关重要的角色。正是在这样的大环境下,遵循国家标准GB28181协议的LiteGBS视频云服务应需而生,它以全面的功能和灵活的......
  • 烟火检测视频分析网关摄像机实时接入分析平台在火灾预防中是如何应用的?
    在现代火灾预防领域,人工智能(AI)的应用正变得日益广泛和关键。烟火检测视频分析网关通过实时监控、智能分析和预警等多种功能,极大地提升了火灾预防的效率和准确性。以下是AI在火灾预防中的具体应用方式,这些技术正在改变我们对火灾防控的认知和实践。人工智能(AI)在火灾预防中的应用是......
  • 【langchain4j接入springboot项目】想学AI平台接入?langchain4j,是不二的选择
    一、项目结构二、示例代码1.Calulator.javapackageorg.ivy.aiservice.func;importdev.langchain4j.agent.tool.Tool;importorg.springframework.stereotype.Component;@ComponentpublicclassCalculator{@Tool("Calculatesthelengthofastring")......
  • NVR设备ONVIF接入平台EasyCVR视频分析设备平台视频质量诊断技术与能力
    视频诊断技术是一种智能化的视频故障分析与预警系统,NVR设备ONVIF接入平台EasyCVR通过对前端设备传回的码流进行解码以及图像质量评估,对视频图像中存在的质量问题进行智能分析、判断和预警。这项技术在安防监控领域尤为重要,因为它能够确保监控系统的正常运行,提高视频监控的可靠性和......
  • 萤石设备视频接入平台EasyCVR私有化部署视频平台高速公路视频上云的高效解决方案
    经济的迅猛发展带来了高速公路使用频率的激增,其封闭、立交和高速的特性变得更加显著。然而,传统的人工巡查方式已不足以应对当前高速公路的监控挑战,监控盲点和响应速度慢成为突出问题。比如,非法占用紧急车道的情况屡见不鲜,却因缺乏即时监控和确凿证据,给执法带来了不小的挑战。在许......