qiankun微前端引入vite+vue3项目子应用

5,988 阅读11分钟

背景

由于业务复杂度的提升和业务发展到一定程度后会出现业务聚合的需求,现代前端开发的趋势更趋向于巨石应用或者一个综合业务需要多部门协作或者多个公司协作才能完成。不同部门,不同公司之间使用不同的技术栈(react,vue,jquery等)多技术栈的集成问题则成为了热门话题,微前端的概念也应运而生。最近公司有需求开发的一个子系统(vite+vue3)接入对方的react(ant design pro)的主应用,起初开发时对方并未明确主应用的技术框架,对方确定使用qiankun后便有了这个踩坑之旅。

qiankun(乾坤)

现在各个大厂都有开源自己的微前端:

  • 阿里(qiankun)
  • 腾讯(无界)
  • 京东(micro-app)
  • 携程(零界)
  • 。。。

各家都有各家的实现特点,比如阿里是基于signle-spa,还有利用iframe,自定义元素,甚至WebComponent等技术的,这里不讨论各家所长所短,因为本文的目的就是要使vite+vue3的技术栈作为子系统接入到qiankun。

既然是接入qiankun,那就说一下qiankun的一些技术背景和技术路线

  • 基于single-spa封装
  • 技术栈无关(但是其实现方式对vite用户并不友好)
  • HTML Entry接入的方式(大致意思就是可以不改变原来单页面的构建形式,只需要引入入口HTML即可实现接入)
  • 样式隔离(作为使用最为广泛的微前端框架,样式隔离做的并没有想象的那么好,尤其面对vite打包的形式仍有很多坑,需要用户手动去做全局的样式隔离)
  • JS沙箱(同样对vite用户不太友好,也需要用户手动对全局对象window进行隔离)
  • 资源预加载
  • umi插件(这个挺方便的作为主应用一个插件就可以搞定配置,常用ant design pro的同学可以优先考虑)

上面对qiankun的一些特点做了一些解释,上面提到的一些特性看似很强大但是却都是有前置条件的,现在看来对于vite用户都不太友好需要自己手动做一下样式和js的隔离来实现接入,不过相信随着vite的普及当前流行的微前端框架都会对vite的支持更好。

当然语雀上关于qiankun的问答也明确说了目前不支持vite,支持vite的方案都是不支持沙箱的。

引入方式(import-html-entry)

这里为什么要提引入方式呢?这涉及到引入之后出现的一些问题后的排查,如果不知道qiankun主应用为何可以像加载iframe一样可以通过子应用的HTML Entry(index.html)来加载子应用。当然qiankun也可以通过入口的js文件加载,这里不讨论这种情况。

qiankun基本运行的方式就是通过import-html-entrt对入口文件(HTML Entry)进行解析,比如将link,style,script等标签通过区分是inline还是external的引入形式进行不同的处理:

  • inline的处理方式(内部)

    对于内部的标签style script等需要解析出来直接插入到主应用的header或者相应的位置去执行,以达到执行的目的,当然这里只是粗略的说了一下import-html-entry的处理方式,实际的处理肯定比这复杂。当然如果插入到主应用直接去执行的话会有样式污染和js污染的问题。

  • exernal的处理方式(外部)

    对于外部的引入标签script link等需要解析出来,取出对应的资源路径(herf,src等)通过fetch的方法来加载外部的资源(js,css文件等)

这里只是简单的说明了一下import-html-entry要做的事儿,当然我接入的经验并没有那么多,都是通过接入vite项目结合部分代码逻辑做的一个简单介绍,由于这种形式也不存在沙箱的运行方式,所以一眼就可以看出整个入口的加载都是在“裸奔”,并没有什么隔离措施。

路由

上面提到的是qiankun对于入口文件的加载解析和处理,本小节则主要说主应用对路由的处理。前面提到qiankun是对single-spa的二次封装,人后结合import-html-entry组成了qiankun的整个运行逻辑。

single-spa主要做的应该就是对子应用的注册,启动,以及对路由的监控了,这里也是简单说说整个运行逻辑,比如single-spa会对子应用的生命周期,挂载,卸载,路由变化(hashChange等路由变化的事件)进行监控和管理,让主应用和子应用在页面表现上更顺畅。比如路由命中了主应用的activeRule,则会触发相应的子应用的挂载,子应用的的路由变化同样会触发相应的路由监控事件以便主应用可以做出相应的变化。

生命周期

这里的生命周期是指的qiankun子应用的生命周期

  • bootstrap(只在初始化时调用一次,再次进入子应用后直接进mount,注意这个钩子函数里面也没有传入的props可用,这里可以做一些全局变量的初始化,和不会在unmount阶段被销毁的缓存等)
  • mount(这里是重点,在这里处理子应用的挂载服务,这个回调函数中可以拿到主应用传入的一些参数(props),做一些数据处理)
  • unmount(应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例)

这里各个生命周期相信各个同学都能见名知义,我的解释也大多照搬官方的介绍。

我简单列一下我子应用中使用情况可能大家会具象一些

  renderWithQiankun({
    // 当前应用在主应用中的生命周期
    // 文档 https://qiankun.umijs.org/zh/guide/getting-started#
    mount(props) {
      console.log('props: ', props);
      // 这里需要拿到props提供container来挂载
    },
    bootstrap() {
      console.log('bootstrap');
      // 这里没有props,而且只触发一次再次进入后
    },
    unmount(props) {
      console.log('unmount props: ', props);
      // 卸载应用
      app.unmount();
    },
  });

引入步骤

上面聊了一些qiankun的基础运行,当然可能很片面,因为我只接入了一个vite项目,但是介绍以上饿一些运行方式和特点的目的是让各位同学遇到问题后不至于手足无措可以有一个思路去排查问题所在。这里讨论的是引入的vite构建的项目,诸如webpack构建的项目,官方的例子讲的还算详细,当然也不从头讨论主应用如何配置,是在完全相信有一个可以直接接入的qiankun主应用的前提下来引入一个vite项目。

好了,做了很多的铺垫,下面来说一下引入vite项目的一些注意的点。

vite-plugin-qiankun

首先就是在社区找到这个插件(vite-plugin-qiankun),这个插件是vite构建的插件:

// vite.config.js
import qiankun from 'vite-plugin-qiankun';

export default () => {
  return {
    plugin: [
      qiankun('子应用标识', {
        // 其他的一些配置
      })
    ]
  }
}

个人感觉使用还是很简单的,不需要特殊配置就可以使用,这里大概说一下这个插件到底做了什么事儿:

  • 在HTML Entry注入qiankun的一些生命周期,并一致的导入主应用传入的props
  • 在HTML Entry做一些js的隔离工作,比如将主应用的window和子应用的window隔离开来
  • 其提供的helper中提供给子应用使用render方法(renderWithQiankun)和隔离出来的qiankunWindow

通过以上的操作就可以在子应用的入口js文件中配置不同的加载方式了。

mount(入口启动方式修改)

上面有提到vite-plugin-qiankun的helper提供的子应用的方法和隔离的qiankunWindow,那我的入口js就可以进行挂载的操作了:

// 假设我的入口js文件是 src下面的 main.js
// 这里只涉及渲染的相关代码,main.js中的其他代码逻辑均不变

import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper';

// 有一个我本来vue3启动的方法里边包括对Router,Store,mount,国际化等配置这里略去
const app = null;
function bootstrapVue3(container) {
  // 进行创建,挂载app的一系列操作,这里挂载的时候可以利用传入的container
  app = createApp(App);
  app.mount(container);
}

// 增加qiankun子应用的render方法
const initQianKun = () => {
  renderWithQiankun({
    // 当前应用在主应用中的生命周期
    // 文档 https://qiankun.umijs.org/zh/guide/getting-started#
    mount(props) {
      console.log('props: ', props);
      bootstrap(props.container?.querySelector('#app'));
      //  可以通过props读取主应用的参数:msg
      // 监听主应用传值以及我本身的一些业务逻辑
      props.setLoading(false);
      props.onGlobalStateChange((res) => {
        console.log('res: ', res);
        // store.count = res.count
        // console.log(res.count)
      });
    },
    bootstrap() {
      console.log('bootstrap');
      // 做一些一次性的初始化的工作,因为只触发一次
    },
    unmount() {
      // 卸载子应用
      app.unmount();
      app = null;
    },
  });
};

// 判断当前应用是否在主应用中
qiankunWindow.__POWERED_BY_QIANKUN__ ? initQianKun() : bootstrapVue3('#app');

通过上面的一系列操作我们大概率已经实现了vite子应用的引入工作,接下来就是处理一些适配问题。

PUBLIC_PATH

首先要解决的是和webpack类似的public-path的问题,在webpack子项目中我们需要自己创建一个public-path.js然后引入到入口js文件(类似main.js)

// src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

在vite中不存在这个操作,但是需要有类似的配置,vite配置对应的是base字段,我的项目是利用env的方式配置了一个VITE_PUBLIC_PATH的变量

// vite.config.js
export default () => {
  return {
    base: VITE_PUBLIC_PATH,
  }
}

这样可以通过配置VITE_PUBLIC_PATH来实现子应用的资源前缀问题,比如

  • env.development中配置 VITE_PUBLIC_PATH = /
  • env.production中配置的则是我实际引入的资源地址 VITE_PUBLIC_PATH = https:xxx.com

这样可以兼顾本地开发和线上的资源路径的配置。

路由的适配修改

一般情况下qiankun主应用配置接入子应用时需要配置在某个路由下加载该子应用(activeRule: '/xxxx'),这时需要子应用同时配置相应的路由,增加这个activeRule对应的/xxxx作为前缀,其他的路由作为该路由的子路由来使用

cosnt { basePath } = useGlobSetting(); // 通过env配置拿到对应的basePath,这样可以在部署时约定好activeRule

const router = [
  path: basePath,
  name: 'Sub',
  redirect: `${basePath}/home`,
  meta: {
    // meta信息
  },
  children: [
    // 这里定义真实路由,访问路径就是 basePath/home...
  ]
]

如果使用的vue-router的版本有base配置的话可以像官网的配置

cosnt { basePath } = useGlobSetting(); // 通过env配置拿到对应的basePath,这样可以在部署时约定好activeRule
router = new VueRouter({
    base: basePath,
    mode: 'history',
    routes,
  });

通过以上配置即可达到动态添加访问前缀的方式

数据管理和通信

vite-plugin-qiankun提供的renderWithQiankun方法在mount执行时传入的参数(props)中有监控主应用传入子应用的数据的事件(onGlobalStateChange)

  renderWithQiankun({
    // 当前应用在主应用中的生命周期
    // 文档 https://qiankun.umijs.org/zh/guide/getting-started#
    mount(props) {
      props.onGlobalStateChange((res) => {
        // 这里拿到数据后可以更新到本地使用
        console.log('res: ', res);
        // store.count = res.count
        // console.log(res.count)
      });
    },
  });

CSS样式污染问题

接下来就是vite引入时问题的重灾区了,由于没有沙箱,样式也不能进行隔离,由于前文提到的css加载的方式都是全局在主应用上生效的,所以全局上css互相影响的概率就很大,并且子应用使用的和主应用使用的组件库都是ant design系列的,一些类名都是一致,这种情况下如果还按照之前的开发习惯直接控制组件对应的类名样式,大概率会出现互相层叠污染的情况,这时就需要一些开发规范和工程化的手段来设置样式。

ant design修改prefix

从工程化的角度可以解决多个ant design组件库同时使用的样式污染问题,qiankun官方也有提供该解决方案,就是通过ant-desigtn注入prefix,拿ant-desigin-vue来说:

<template>
  <a-config-provider prefix-cls="custom">
    <my-app />
  </a-config-provider>
</template>

这样实际使用的ant的类名都是以.custom-xxx的类名了

截屏2023-03-01 10.36.24.png

上图所见这样实现的自定义类名可以做到多份ant的css引入类名重复的问题。

当然如果自定义类名的话可以在less构建时注入相同的prefix,让全局的less存在相同的一个前缀变量,实际修改.ant-xxx类似的类名时需要替换.ant为相应的注入的prefix变量:

// vite.config.js
export default () => {
  return {
      css: {
      preprocessorOptions: {
        less: {
          modifyVars: {
            // 这里可以注入全局的less变量,通过注入的变量名称去实际业务样式修改的地方拼接prefix变量
            'ant-prefix': 'custom', // 这里注入的prefix如上文提到<a-config-provider prefix-cls="custom"> 需要一致,以便一致
          },
          javascriptEnabled: true,
        },
      },
    },

  }
}

通过上面的vite配置注入的ant-prefix这样可以在实际的样式中拼接使用:

.@{ant-prefix} {
  .@{ant-prefix}-col {
    width: 100%;
  }
}

这样构建出来的样式修改也是.custom-xxx的类名,可以达到修改自己的ant组件样式还不影响其他ant的项目UI。

手动隔离子应用的样式

上文的方式解决了UI框架级别的css污染,同时我们自己的全局样式配置也会有一些css样式:

  input:-webkit-autofill {
    -webkit-box-shadow: 0 0 0 1000px white inset !important;
  }

  :-webkit-autofill {
    transition: background-color 5000s ease-in-out 0s !important;
  }

  a:focus,
  a:active,
  button,
  div,
  svg,
  span,
  label {
    outline: none !important;
  }

  a {
    color: #51ffff !important;
  }

这种情况需要我们手动进行隔离,我们可以在挂载的根节点元素上设置vite注入的less变量ant-prefix相同的类名,然后如下配置:

.@{ant-prefix} {
  input:-webkit-autofill {
    -webkit-box-shadow: 0 0 0 1000px white inset !important;
  }

  :-webkit-autofill {
    transition: background-color 5000s ease-in-out 0s !important;
  }

  a:focus,
  a:active,
  button,
  div,
  svg,
  span,
  label {
    outline: none !important;
  }

  a {
    color: #51ffff !important;
  }
}

通过如上的包裹实现手动样式隔离,子应用设置的全局样式只作用于自己,不会影响到其他的项目。

延伸

通过以上的一些手动操作大概率把遇到的问题都解决掉了,做的一些铺垫也是为遇到问题能有思路解决做准备,通过接入也发现了一些问题

qiankun的一个bug

截屏2023-02-26 14.06.54.png

如上图qiankun的issue中确实有跟我遇到一样的问题,这个问题原因可能是qiankun的问题,也可能是qiankun主应用配置的问题,但是我作为第三方接入并没有权限去调整对方的主应用配置,只能自己排查去从子应用去解决。

这个问题大概是我构建完的HTML Entry中样式是通过link标签引入的,同时还有一些我的第三方直接引入的css:

<head>
  <link rel="styleSheet" href="/resouce/assets/xxx.css" />
  <link rel="styleSheet" href="/resouce/assets/vonder.xxxx.css" />
  <link rel="styleSheet" href="/resouce/assets/index.xxxx.css" />
</head>

上文介绍的这种情况通过import-html-entry解析会通过fetch请求回来,但是奇怪的是并不会插入到主应用中,fetch的类型也不是styleSheet,这样虽然资源请求回来了但是css样式并不会生效,通过排查发现如果是通过javascript动态插入的link,主应用则会正确插入css使样式生效:

<script>
   var i = document.createElement('link');
   i.rel = 'stylesheet';
   i.href = ip:port/path/to/css.css;
   document.head.appendChild(i); 
</script>

以上的代码会被解析并正确执行,插入到主应用中,使之生效。

vite插件

上一节介绍了发现的一个qiankun加载css的问题,导致入口的css样式并不生效,也找到了方法去解决,开始的想法是在HTML Entry中用javascript脚本来将入口中的link标签转换为动态插入的javascript的代码,但是很遗憾由于打包后的css路径带有hash是动态的不能直接写死创建哪些link标签,动态获取link标签在主应用执行的时候会误伤主应用以及其他的子应用的link标签,所以这样并未达到预期.

那思路就转换为是否能通过工程化的思维来通过脚本的方式来处理,在生成HTML Entry时将拿到的link动态转换成上文提到代码,同时前文也分析过vite-plugin-qainkun的实现,就想是不是可以通过vite插件的方式来对Index.html生成时做一些文章

  • 可以把link标签对应的css资源转换成style标签
  • 可以像上文提到的通过javascript动态插入link把原来的link标签remove掉

最终选择了第二种方式来进行实现,当然这之前还需要一些vite插件编写的基础知识,这里就不再赘述,以后有机会单独写一篇关于这些构建工具插件开发的文章。

好了,现在通过参考vite-plugin-qiankun和vite插件的基本知识,结合自己的需求确定了我们只需要在插件的transformIndexHtml钩子方法中对Index.html进行脚本开发:

// vite-plugin-css-importtransform.js
import cheerio from 'cheerio'; // 插件需要安装cheerio来操作html(cheerio的一些api与jquery一致,node爬虫中会比较常用这个库)
export default function cssImportPlugin() {
  return {
    name: 'transform-css-import',
    transformIndexHtml: (html) => { // 该钩子函数里面可以拿到构建时的html字符串
      const $ = cheerio.load(html); // 载入然后可以像jquery一样控制html的各个标签了
      const links = $('head link[rel=styleSheet]'); // 拿到link css的标签然后转换
      links.each(function (i, link) {
        $('head').append(`<script> 
          var i${i} = document.createElement('link');
          i${i}.rel = 'stylesheet';
          i${i}.href = '${$(link).attr('href')}';
          document.head.appendChild(i${i});
         </script>`);
      });
      links.remove(); // 把原有的link删掉
      return $.html(); // 返回html字符串
    },
  };
}

上面的插件逻辑比较简单,当然使用也比较简单,不需要传入什么参数

// vite.config.js
import qiankun from 'vite-plugin-qiankun';
import cssImportPlugin from './vite-plugin-css-import-transform';

export default () => {
  return {
    plugin: [
      qiankun('子应用标识', {
        // 其他的一些配置
      }),
      cssImportPlugin(), // 引入自定义的vite插件并使用
    ]
  }
}

通过插件构建后的HTMl Entry就变成了这样:

截屏2023-03-01 12.15.46.png

至此也解决了实际中HTML Entry引入css样式不生效的问题。

子应用的开发规范

当然通过本次接入微前端也体会到之前的前端规范应该有针对微前端的一些规范加入进来:

  • 预置样式隔离(样式隔离的设置可能是重点,如何设置样式避免微前端引入的场景下形成css样式污染)
  • 预置base路由(可以支持到打包后仍可以修改配置)
  • 路由跳转封装(base路由的动态拼接,vue是否可以考虑使用name属性跳转)
  • public-path的设置
  • 子应用挂载以及生命周期的使用封装(挂载,卸载,数据管理,事件管理等)

总结

通过这一系列的踩坑之旅基本完成了vite子应用接入qiankun的操作,当然有些逻辑针对性比较强导致我的理解会是片面的,这里也欢迎大家积极提出来互相交流。后边延伸的一部分可能介绍的有些潦草,因为限于篇幅不能展开讲,多是为大家提供一些思路吧。关于vite插件和前端子应用开发规范这方面以后有机会会展开来分别说一说。

前面对qiankun的基础知识做了一个铺垫,主要是为大家小小的揭开微前端的面纱吧,权当抛砖引玉,让大家对接入微前端遇到问题时能有一个排查的思路。