qiankun 微前端实践总结(二)

36,506 阅读7分钟

前言

微前端方案中我们最终选择了 qiankun ,上篇文章 qiankun 微前端方案实践及总结 也总结了 qiankun 很多的“坑点”和解决方法,这篇文章是上一篇的补充,本想直接在上一篇文章上编辑,没想到触发了掘金的文章内容最大长度限制:

所以只好重新整理下,纪录一些踩过的坑和调研,希望能对大家有所帮助。本文内容基于 qiankun 的 2.x 版本。

潜在的坑及解决方案

有一些问题是自己发现并解决的,有一些是在 qiankun 的仓库 issue 区看到的。对于这些问题,我尽可能的讲清楚产生原因及解决思路和方法。

子项目字体文件加载失败

qiankun 对于子项目的 js/css 的处理

之前讲到:qiankun 请求到子项目的 index.html 之后,会先用正则匹配到其中的 js/css 相关标签,然后替换掉,它需要自己加载 js/css 并运行,接着去掉 html/head/body 等标签,剩下的内容原样插入到子项目的容器中 :

对于 js ( <script> 标签)的处理

内联 js 的内容会直接记录到一个对象中,外链 js 则会使用 fetch 请到到内容(字符串),然后记录到这个对象中。

if (isInlineCode(script)) {
    return getInlineCode(script);
} else {
    return fetchScript(script);
}

const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
		(scriptCache[scriptUrl] = fetch(scriptUrl).then(response => response.text()));

运行子项目时,执行这些 js 即可:

//内联js
eval(`;(function(window){;${inlineScript}\n}).bind(window.proxy)(window.proxy);`)
//外链js
eval(`;(function(window){;${downloadedScriptText}\n}).bind(window.proxy)(window.proxy);`))

加载并运行外链 js 这里有一个难点就是,如何保证 js 的正确执行顺序?

<script> 标签的 asyncdefer 属性:

  1. defer : 等价于将外链的 js 放在了页面底部
  2. async : 相对于页面的其余部分异步地执行,加载好了就执行。常用于 Google Analytics

所以说外链 js 只要区分有无async,有async<script> 使用 promise 异步加载,加载完再执行即可,无async 属性的按顺序执行。

假设HTML中有如下几个 js 标签:

<script src="./a.js" onload="console.log('a load')"></script>
<script src="./b.js" onload="console.log('b load')"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="./c.js" onload="console.log('c load')"></script>

浏览器正常的加载及执行逻辑是并行加载,但是按顺序执行,但是只要第一个加载好了,就会立马执行第一个。如果第三个没加载完成,第四个即使加载完成,也不会先执行。

如图,我将网速限制到 5kb/s,第三个 js 还未加载完成,但是第四个js已经加载完成了,但不会执行。

qiankun 则是并行加载,但是等所有的 js 都加载完成了,再按顺序执行。与浏览器的原生加载执行顺序有一点点出入,但是效果一样。

这里有一点优化的空间:只要它前面的 js 都加载执行完了,那么它加载好了就可以立即执行,而不用等它后面的 js 加载完成。

对于 css ( <style><link> 标签)的处理

加载逻辑还是一样的:内联 css<style> 标签)的内容会直接记录到一个对象中,外链 css<link> 标签)则会使用 fetch 请到到内容(字符串),然后记录到这个对象中。

但是执行时,也和 js “类似的”:内容放到 <style> 标签,然后插入到页面,子项目卸载移除这些 <style> 标签。

这样会把外链的 css 变成内联 css ,好处就是切换子系统,不用重复请求,直接应用 css 样式,让子项目加载得更快。

但是会带来一个隐藏的坑,css 中如果使用了字体文件,并且是相对路径,原本是link外链样式,相对路径就是相对于这个外链 css 的路径,现在变成了内联样式,相对路径则变成了相对于 index.html 的路径,就会导致字体文件404。

更坑的是开发模式没有这个问题,开发模式下这个路径会被注入 publicPath,打包之后会有这个问题。

如何解决子项目字体文件加载失败的问题

虽然说,是由于 qiankun 将子项目的 <link> 改成 <style> 执行 ,导致了这个问题,但是它这么做似乎也没问题,并且是合理的。

根本原因在于字体文件虽然经过了 webpack 处理,但是没有被注入路径前缀。所以修改 webpack 的配置,让字体文件经过 url-loader 的处理,打包成 base64 ,就可以解决这个问题了。

子项目的 webpack 配置中加上如下内容即可:

module.exports = {
  chainWebpack: (config) => {
    config.module
      .rule('fonts')
      .test(/.(ttf|otf|eot|woff|woff2)$/)
      .use('url-loader')
      .loader('url-loader')
      .options({})
      .end()
  },
}

查看源码发现 vue-cli4 也是这样处理的,但是它限制了4kb以内的字体打包成base64。

子项目部署在二级目录

首先,一个 vue 项目要想部署到二级目录,必须配置 publicPathvue-cli3 官网描述

然后需要注意的点就是,注册子项目时 入口地址 entry 的填写。

假设子项目部署在 app-vue-hash 目录下,entry 直接写 http://localhost/app-vue-hash 会导致 qiankunpublicPath 错误。子项目入口地址 http://localhost/app-vue-hash 的相对路径是 http://localhost,而我们希望的子项目相对路径是 http://localhost/app-vue-hash,这时我们只需要写成 http://localhost/app-vue-hash/ 即可,最后面的 / 不可省略

qiankunpublicPath 源码:

function defaultGetPublicPath(url) {
  try {
    // URL 构造函数不支持使用 // 前缀的 url
    const { origin, pathname } = new URL(url.startsWith('//') ? `${location.protocol}${url}` : url, location.href);
    const paths = pathname.split('/');
    // 移除最后一个元素
    paths.pop();
    return `${origin}${paths.join('/')}/`;
  } catch (e) {
    console.warn(e);
    return '';
  }
}

通过测试我们可以发现 http://localhost/apphttp://localhost/app/ 两个不同路径的 server, 同一个 html,然后在 html 里引入一个相对路径的资源。浏览器解析的地址分别为:

说明 qiankunpublicPath 的处理是正确的。

IE11 的兼容性

qiankun 在加载子项目时,使用了 fetch,所以针对 IE11,需要引入 fetchpolyfill,然而引入了fetch-polyfill,在 IE11 下任然报错。

直观上是 single-spa.min.js 报错,但是这个代码是是压缩后的代码,不太好排查。

找到 node_modules\single-spa\package.json,找到 "module": "lib/esm/single-spa.min.js" 这一行,改成 "module": "lib/esm/single-spa.dev.js",然后重启项目。

发现 single-spa 报错原因是 APP 加载失败,代码如下:

function handleAppError(err, app, newStatus) {
  var transformedErr = transformErr(err, app, newStatus);
  if (errorHandlers.length) {
    errorHandlers.forEach(function (handler) {
      return handler(transformedErr);
    });
  } else {
    setTimeout(function () {
      throw transformedErr; // 这一行抛出的错误,也就是控制台显示的错误信息
    });
  }
}

打印了一下APP加载出错的信息,如下:

经排查是 fetchpolyfill 的问题,是它报的错。

然而只引入 fetch-polyfill,然后在项目中使用 fetch,不会报这个错。

同时引入 qiankunfetch-polyfill,就会报这个错:

import 'fetch-polyfill';
import { registerMicroApps, start } from 'qiankun';

console.log(fetch);
console.log(fetch("http://localhost:1111"));

排查了好久这两个插件不兼容的原因。最终,qiankun 作者的解决方法是使用 whatwg-fetch,然后显示的列出 promisesymbol等的 polyfill

在主项目入口文件引入以下内容即可(记得安装依赖):

import 'whatwg-fetch';
// import 'custom-event-polyfill'; // 如果还报错,需要引入这个
import 'core-js/stable/promise';
import 'core-js/stable/symbol';
import 'core-js/stable/string/starts-with';
import 'core-js/web/url';

试了下,IE11 可以完美运行,但是会偶现一些网络相关的报错,不影响页面运行,应该是 websocket 导致的,生产环境不会报错。

qiankun 作者的文章:2020 如何优雅的兼容 IE

vue子项目内存泄露问题

这个问题挺难发现的,是在 qiankunissue 区看到的: github.com/umijs/qiank… ,排查过程我就不发了,解决方案挺简单。

子项目销毁时清空 dom 即可:

export async function unmount() {
  instance.$destroy();
+ instance.$el.innerHTML = ""; //新增这一行代码
  instance = null;
  router = null;
}

但是其实,来回切换子项目并不会使内存不断增加。也就是说,即使卸载子项目时,子项目占用的内存没有被释放,但是下次加载时会复用这块内存,那这样的话,子项目会不会加载更快?(还未考证

特殊需求探索与思考

在满足基本的微前端使用后,调研了一些优化和特殊需求,方便更好的使用。

keep-alive 需求

子项目 keep-alive 其实就是想在子项目切换时不卸载掉,仅仅是样式上的隐藏(display: none),这样下次打开就会更快。

keep-alive 需要谨慎使用,同时加载并运行多个子项目,这会增加 js/css 污染的风险。

虽然 qiankunproxy 方式的 js 沙箱可以完美支持多项目运行,但是别忘了 IE11 这个毒瘤,IE11 下沙箱使用的是 diff 方法,这会让多个项目共用一个沙箱,这等于没有沙箱。路由之间也可能存在冲突。

多项目运行的 css 沙箱也没有特别好的处理方式,目前比较靠谱的是 class 命名空间 + css-scoped

实现 keep-alive 需求有多种方式,推荐使用方案一。

方案一:借助 loadMicroApp 函数

尝试使用其已有 API 来实现 keep-alive 需求:借助 loadMicroApp 函数来实现手动加载和卸载子项目,一般有 keep-alive 需求的就是 tab 页,新增一个 tab 页时就加载这个子项目,关闭 tab 页时卸载这个子项目。

由于 demo 中没有 tab 页,我就直接加载所有子项目,然后看效果,改动并不是很多。

主项目 APP.vue 文件:

<template>
  <div id="app">
    <header>
      <router-link to="/app-vue-hash/">app-vue-hash</router-link>
      <router-link to="/app-vue-history/">app-vue-history</router-link>
      <router-link to="/about">about</router-link>
    </header>
    <div id="appContainer1" v-show="$route.path.startsWith('/app-vue-hash/')"></div>
    <div id="appContainer2" v-show="$route.path.startsWith('/app-vue-history/')"></div>
    <router-view></router-view>
  </div>
</template>

<script>
import { loadMicroApp } from 'qiankun';

const apps = [
  { 
    name: 'app-vue-hash', 
    entry: 'http://localhost:1111', 
    container: '#appContainer1',
    props: { data : { store, router } }
  },
  { 
    name: 'app-vue-history',
    entry: 'http://localhost:2222', 
    container: '#appContainer2',
    props: { data : store }
  }
]

export default {
  mounted() {
    // 优先加载当前的子项目
    const path = this.$route.path;
    const currentAppIndex = apps.findIndex(item => path.includes(item.name));
    if(currentAppIndex !== -1){
      const currApp = apps.splice(currentAppIndex, 1)[0];
      apps.unshift(currApp);
    }
    // loadMicroApp 返回值是 app 的生命周期函数数组
    const loadApps = apps.map(item => loadMicroApp(item))
    // 当 tab 页关闭时,调用 loadApps 中 app 的 unmount 函数即可
  },
}
</script>

切换子项目,子项目的DOM没有被清空:

方案二:修改 qiankun 源码

实现起来也比较简单: 子系统卸载不清空容器里的 dom 也不卸载 vue 实例,用 display: none 来隐藏。子系统加载时先判断下容器有无内容,已存在就不重新插入子系统的HTML

主要分4个步骤:

  1. 修改子项目的render函数,不重复实例化vue
function render() {
  if(!instance){
    router = new VueRouter({
      base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/',
      mode: 'history',
      routes,
    });
    instance = new Vue({
      router,
      store,
      render: h => h(App),
    }).$mount('#appVueHistory');
  }
}
  1. 修改子项目的unmount生命周期,子项目unmount时不卸载 vue 实例
export async function unmount() {
  // instance.$destroy();
  // instance = null;
  // router = null;
}
  1. 修改主项目中子项目的注册及容器,每个子项目单独放一个容器(当然你也可以放到一个容器,处理起来麻烦点)。然后就是切换子系统隐藏其他的
<div id="appContainer1" v-show="$route.path && $route.path.startsWith('/app-vue-hash')"></div>
<div id="appContainer2" v-show="$route.path && $route.path.startsWith('/app-vue-history')"></div>
registerMicroApps([
  {
    name: 'app-vue-hash', 
    entry: 'http://localhost:1111', 
    container: '#appContainer1', 
    activeRule: '/app-vue-hash', 
    props: { data : { store, router } }
  },
  { 
    name: 'app-vue-history',
    entry: 'http://localhost:2222', 
    container: '#appContainer2', 
    activeRule: '/app-vue-history',
    props: { data : store }
  },
]);
  1. 修改qiankun的源码:子项目加载前先判断有无内容,有内容则不处理;子系统卸载时不清空dom

这里刚好可以用到patch-package插件,直接修改qiankun/es/loader.js,改动如下:

修改 qiankun/es/sandbox/patchers/dynamicHeadAppend.js

至此,就可以实现切换子项目不清空,下次进入秒加载的效果:

这个方案仅供学习参考,加深对 qiankun 的理解。

方案三:缓存子项目的 dom

方案来源:qiankun 仓库的 issue

这个方案比较麻烦,大致原理就是缓存 vue 实例的 dom ,子项目的入口文件修改:

let instance = null;
let router = null;

function render() {
  // 这里必须要new一个新的路由实例,否则无法响应URL的变化。
  router = new VueRouter({
    mode: 'hash',
    base: process.env.BASE_URL,
    routes
  });

  if (window.__POWERED_BY_QIANKUN__ && window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__) {
    const cachedInstance = window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__;

    // 从最初的Vue实例上获得_vnode
    const cachedNode =
      // (cachedInstance.cachedInstance && cachedInstance.cachedInstance._vnode) ||
      cachedInstance._vnode;

    // 让当前路由在最初的Vue实例上可用
    router.apps.push(...cachedInstance.catchRoute.apps);

    instance = new Vue({
      router,
      store,
      render: () => cachedNode
    });

    // 缓存最初的Vue实例
    instance.cachedInstance = cachedInstance;

    router.onReady(() => {
      const { path } = router.currentRoute;
      const { path: oldPath } = cachedInstance.$router.currentRoute;
      // 当前路由和上一次卸载时不一致,则切换至新路由
      if (path !== oldPath) {
        cachedInstance.$router.push(path);
      }
    });
    instance.$mount('#appVueHash');
  } else {
    console.log('正常实例化');
    // 正常实例化
    instance = new Vue({
      router,
      store,
      render: h => h(App)
    }).$mount('#appVueHash');
  }
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render();
}

export async function unmount() {
  console.log('[vue] vue app unmount');
  const cachedInstance = instance.cachedInstance || instance;
  window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__ = cachedInstance;
  const cachedNode = cachedInstance._vnode;
  if (!cachedNode.data.keepAlive) cachedNode.data.keepAlive = true;
  cachedInstance.catchRoute = {
    apps: [...instance.$router.apps]
  }
  instance.$destroy();
  router = null;
  instance.$router.apps = [];
}

复用公共依赖(方案)

子项目要想复用公共依赖,配置 webpackexternals 是必须的,而配置了这个之后,子项目独立运行时,这些依赖的来源有且仅有 index.html 中的外链 script 标签。

有两种情况:

  • 子项目之间的依赖“复用”

这个很好办,你只需要保证依赖的 url 一致即可。比如说子项目A 使用了 vue,子项目B 也使用了同版本的 vue,如果两个项目使用了同一份 CND 文件,加载时会先从缓存读取:

const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
	(scriptCache[scriptUrl] = fetch(scriptUrl).then(response => response.text()));
  • 子项目复用主项目的依赖

只需要给子项目 index.html 中公共依赖的 scriptlink 标签加上 ignore 属性(这是自定义的属性,非标准属性)。

有了这个属性,qiankun 便不会再去加载这个 js/css,而子项目独立运行,这些 js/css 仍能被加载,如此,便实现了“子项目复用主项目的依赖”。

<link ignore rel="stylesheet" href="//cnd.com/antd.css">
<script ignore src="//cnd.com/antd.js"></script>

需要注意的是:主项目使用externals 后,子项目可以复用它的依赖,但是不复用依赖的子项目会报错。

看了下 qiankun 官网,有写这个问题:Vue Router 报错 Uncaught TypeError: Cannot redefine property: $router

报错原因

子项目不配置 externals 时,项目中的 Vue是全局变量,但是 不属于 window ,所以子项目独立运行时这个 if 判断不生效:

if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}

qiankun 在运行这个子项目时,先找子项目的 window,再找父项目的 window,然后在window 上找到了 vueif判断会生效,然后 window 上父项目的 Vue 安装了 VueRouter,子项目自己的全局的 vue没有安装,导致报错。

解决方案一:加载子项目之前处理下全局变量

假设 app-vue-hash 子项目复用主项目依赖,app-vue-history子项目不复用主项目依赖。

在主项目中注册子项目时新增如下代码解决:

registerMicroApps(apps,
{
  beforeLoad(app){
    if(app.name === 'app-vue-hash'){
      // 如果直接在 app-vue-hash 子项目刷新页面,此时 window.Vue2 是 undefined
      // 所以先判断下 window.Vue2 是否存在
      if(window.Vue2){
        window.Vue = window.Vue2; 
        window.Vue2 = undefined;
      }
    }else if(app.name === 'app-vue-history'){
      window.Vue2 = window.Vue; 
      window.Vue = undefined
    }
  },
});

解决方案二:通过 props 传递依赖

上面的兼容性问题,可以考虑 主项目通过props把依赖传给子项目,不配置 externals 来解决。

主项目注册时,将依赖传递给子项目(省略了一些不必要的代码):

import VueRouter from 'vue-router'
registerMicroApps([
  {
    name: 'app-vue-hash', 
    entry: 'http://localhost:1111', 
    container: '#appContainer', 
    activeRule: '/app-vue-hash', 
    props: { data : { VueRouter } }
  },
]);

子项目配置 externals 并且外链依赖加上ignore 属性:

function render(parent = {}) {
  if(!instance){
    // 当它独立运行时,使用自己的外链依赖 window.VueRoute
    const VueRouter = parent.VueRouter || window.VueRouter; 
    Vue.use(VueRouter);
    router = new VueRouter({
      routes,
    });
    instance = new Vue({
      router,
      store,
      render: h => h(App),
    }).$mount('#appVueHash');
  }
}

export async function mount(props) {
  render(props.data);
}

解决方案三:修改主项目和子项目的依赖名称

主应用和子应用复用的依赖改个名称,这样就不会影响其他不复用依赖的子应用。具体改的有:

  1. 修改子应用和主应用的 externals 配置,修改依赖的名称,不使用 Vue
externals: {
  'vue': 'Vue2' , // 这个的意思是告诉 webpack 去把 winodw.Vue2 当做 vue 这个模块
}
  1. 在主应用导入外链 vue.js 之后,将名称改成 Vue2
<script src="https://unpkg.com/vue@2.5.16/dist/vue.runtime.min.js"></script>
<script>
window.Vue2 = winow.Vue;
window.Vue = undefined;
</script>

三个方案中,推荐方案三,更加简洁方便。

主项目路由的 hashhistory 之争

之前的写一些内容不太全面,重新梳理了一下。分为三种情况讨论:

主项目路由用 history 模式

主项目使用 history 模式,则需要使用 location.pathname 来区分不同的子项目,这也是 qiankun 推荐的一种形式。注册子项目时, activeRule 只需要写路径即可:

registerMicroApps([
   { 
      name: 'app-vue-hash', 
      entry: 'http://localhost:1111', 
      container: '#appContainer', 
      activeRule: '/app-vue-hash', 
   },
])

优点:

  1. 子项目可以使用 history 模式,也可以使用 hash模式 。这样旧项目就都可以直接接入,兼容性强。
  2. hash 模式子项目无影响,不需要做任何修改

缺点:

  1. history 模式路由需要设置 base
  2. 子项目之间的跳转需要使用父项目的 router 对象(不用 <a> 链接直接跳转的原因是 <a> 链接会刷新页面)。

其实不传递 router 对象,用原生的 history 对象跳转也行: history.pushState(null, 'name', '/app-vue-hash/#/about'),同样不会刷新页面。

不管是父项目的 router 对象,还是原生的 history 对象,跳转都是 js 的方式。这里有一个小小的用户体验问题:标签(<router-link><a>)形式的跳转是支持浏览器默认的右键菜单的,js 方式则没有:

主项目路由用 hash 模式且子项目没有history 模式路由

也就是说主项目和所有子项目都是 hash 模式,这种情况下也有两种做法:

  1. path 来区分子项目

做法就不赘述了

优点:无需修改子项目内部代码

缺点:项目之间的跳转,都得靠原生的 history 对象

  1. hash 来区分子项目

这样做主项目和子项目会共同接管路由,举个栗子:

  • /#/vue/home: 会加载 vue 子项目的 home 页面,但是其实,单独访问这个子项目的 home 页面的完整路由就是/#/vue/home

  • /#/react/about: 会加载 react 子项目的 about 页面,同样,单独访问这个子项目的 about 页面的完整路由就是/#/react/about

  • /#/about: 会加载主项目的about页面

做法就是自定义 activeRule

const getActiveRule = hash => location => location.hash.startsWith(hash);
registerMicroApps([
   { 
      name: 'app-vue-hash', 
      entry: 'http://localhost:1111', 
      container: '#appContainer', 
      activeRule: getActiveRule('#/app-vue-hash'), 
   },
])

然后需要在子项目的所有路由前加上这个前缀,或者将子项目的根路由设置为这个前缀。

const routes = [
  {
    path: '/app-vue-hash',
    name: 'Home',
    component: Home,
    children: [
      // 其他的路由都写到这里
    ]
  }
]

如果子项目是新项目还好,如果是旧项目,则影响还是比较大,子项目里面的路由跳转(<router-link>router.push()router.repace())如果使用的是 path ,则需要修改,得加上这个前缀,如果使用的是 name跳转,则无需改动:router.push({ name: 'user'})

优点: 所有项目之间的跳转都可以直接使用自己的 router 对象或者 <router-link>,不需要借助父项目的路由对象或者原生的 history 对象

缺点: 对子项目是入侵式修改,如果是全新项目,则无影响。

主项目路由用 hash 模式且子项目有history 模式路由

主项目是hash 模式,子项目间的跳转就只能借助原生的 history 对象了,我们既可以用 path 也可以用 hash 来区分子项目:

  1. path 来区分子项目

与主项目是 history 没有太大的差异,优缺点也一样。

  • /vue-hash/#/home: 会加载 vue 子项目的 home 页面
  • /vue-history/about: 会加载 vue-history 子项目的 about 页面
  • /#/about: 会加载主项目的about页面
  1. hash 来区分子项目

这样做其实不太好,有点反常规,但是也可以用:

  • /home/#/vue: 会加载 vue 子项目的 home 页面
  • /#/vue-hash/about: 会加载 vue-hash 子项目的 about 页面
  • /#/about: 会加载主项目的about页面

优点:无

缺点: 对 hash 子项目是入侵式修改,如果是全新项目,则无影响。

总结

主项目路由的 hashhistory 模式都可以使用,各有优劣,看情况取舍。

项目间的组件共享

组件共享,优先推荐 npm 包方式,但是如果没有部署私有 npm ,项目又涉及隐私不能放到 GitHub,或者是带数据的业务组件共享,可以考虑下面的几种方式。

父子项目间的组件共享

因为主项目会先加载,然后才会加载子项目,所以一般是子项目复用主项目的组件(主项目复用子项目的情况下面再讨论)。

做法也很简单,主项目加载时,将组件挂载到 window 上,子项目直接注册即可。

主项目入口文件:

import HelloWorld from '@/components/HelloWorld.vue'
window.commonComponent = { HelloWorld };

子项目直接使用:

components: { 
  HelloWorld: window.__POWERED_BY_QIANKUN__ ? window.commonComponent.HelloWorld :
     import('@/components/HelloWorld.vue'))
}

子项目间的组件共享(弱依赖

什么是弱依赖呢?就是子项目本身自己也有这个组件,当别的子项目已经加载过了,他就复用别人的组件,如果别的子项目未加载,就使用自己的这个组件。

适用场景就是避免组件的重复加载,这个组件可能并不是全局的,只是某个页面使用。做法分三步:

  1. 由于子项目之间的全局变量不共享,主项目提供一个全局变量,用来存放组件,通过 props 传给需要共享组件的子项目。

  2. 子项目拿到这个变量挂载到 window

export async function mount(props) {
  window.commonComponent = props.data.commonComponent;
  render(props.data);
}
  1. 子项目中的共享组件写成异步组件
components: {
   HelloWorld: () => {
      if(!window.commonComponent){
        // 独立运行时
        window.commonComponent = {};
      }
      const HelloWorld = window.commonComponent.HelloWorld;
      return HelloWorld || (window.commonComponent.HelloWorld =
             import('@/components/HelloWorld.vue'));
   }
}

这里有个 bug :来回切换时,共享组件的样式不加载。感觉这个 bug 和“子项目跳转到主项目页面,主项目的样式不加载”是同一个 bug,暂时没有很好的解决方法。

另一个子项目也这样写就行,只要某个子项目加载过一次,另一个项目就可以直接复用,不用重复加载。

子项目间的组件共享(强依赖

与弱依赖不同的是,子项目本身没有这个组件,所以另一个子项目一定先加载,然后他才能拿到这个组件。

那么如何保证子项目间的加载顺序呢?解决方案:在子项目使用这个组件前,手动加载另一个子项目,保证一定能拿到这组件(在有权限的情况下,无权限会加载失败)

qiankun 也可以使用 loadMicroApp 来手动加载子项目。基本步骤如下:

  1. 同样,由于子项目之间的全局变量不共享,主项目提供一个全局变量,用来存放组件,通过 props 传给使用组件的子项目,同时还要将 loadMicroApp 函数传过去。
const commonComponent = {};
registerMicroApps(
  [
    { 
      name: 'app-vue-hash', 
      entry: 'http://localhost:1111', 
      container: '#appContainer', 
      activeRule: '/app-vue-hash', 
      props: { data : { loadMicroApp, commonComponent } }
    },
  ],
)
  1. 子项目将加载函数和公共变量挂载到全局
export async function mount(props) {
  window.commonComponent = props.data.commonComponent;
  window.loadMicroApp = props.data.loadMicroApp;
  render();
}
  1. 子项目在需要使用组件的页面,手动加载提供组件的子项目,等它加载完成,就可以拿到组件。

这里需要注意的是:由于提供组件的子项目是通过 loadMicroApp 加载的,所以存放组件的公共变量必须由 loadMicroApp 传递过去。另外:加载子项目需要提供容器,在 APP.vue 提供一个隐藏的容器就行。

const app = window.loadMicroApp({
  name: 'app-vue-history',
  entry: 'http://localhost:2222', 
  container: '#appContainer2',
  props: { data: { commonComponent: window.commonComponent } }
})
await app.mountPromise;

由于组件强依赖,子项目这里就没法独立运行,这里注册组件有两个选择:

  • 一是使用异步组件:
components: {
   HelloWorld: async () => {
      // 这个 app  最好写成当前页的全局变量
      // 因为卸载当前页时需要调用他的 `unmount` 来卸载子项目
      const app = window.loadMicroApp({
        name: 'app-vue-history',
        entry: 'http://localhost:2222', 
        container: '#appContainer2',
        props: { data: { commonComponent: window.commonComponent } }
      })
      await app.mountPromise;
      return window.commonComponent.HelloWorld
   }
}
  • 二是动态注册,先使用 v-if 来隐藏标签:
<HelloWorld v-if="loadingEnd"/>
async created() {
  const app = window.loadMicroApp({
    name: 'app-vue-history',
    entry: 'http://localhost:2222', 
    container: '#appContainer2',
    props: { data: { commonComponent: window.commonComponent } }
  })
  await app.mountPromise;
  Vue.component('HelloWorld', window.commonComponent.HelloWorld)
  this.loadingEnd = true;
},

vue 文档有写如何处理异步组件的加载状态: 处理加载状态

  1. 另一个子项目共享组件,需要注意的是 loadMicroApp 与路由无关,所以共享的组件必须在入口文件挂载到公共变量上,而不能在路由页挂载。

如果共享组件及其子组件不依赖 storei18n 等全局的插件,这里有一个投机的处理:不实例化Vue,仅挂载组件。

export async function mount(props) {
  // 如果有commonComponent变量,说明是另一个子项目通过 loadMicroApp 加载的
  // 他此时只需要挂载组件
  if(props.data.commonComponent){
     props.data.commonComponent.HelloWorld = HelloWorld;
  }else{
  // 没有 commonComponent 变量,说明是主项目通过 registerMicroApps 加载的
  // 当让这里只是一个简单的判断,也可以传递其他参数判断
    render();
  }
}

如果这个组件及其子组件依赖 storei18n 等全局的插件,则需要返回一个函数,调用时,直接函数执行:

export async function mount(props) {
  // 如果有commonComponent变量,说明是另一个子项目通过 loadMicroApp 加载的
  // 他此时只需要挂载组件
  if(props.data.commonComponent){
     const createHelloWorld = container => new Vue({
     	el: container,
        store,
        i18n,
        render: h => h(HelloWorld)
     });
     props.data.commonComponent.createHelloWorld = createHelloWorld;
  }else{
  // 没有 commonComponent 变量,说明是主项目通过 registerMicroApps 加载的
  // 当让这里只是一个简单的判断,也可以传递其他参数判断
    render();
  }
}

父项目复用子项目的组件也适用于子项目间的组件共享这个情况。如果想实现主项目使用子项目的“小部件”(某些带数据图表),则需要使用强依赖方案

qiankun 的嵌套

首先,将 qiankun 项目按照子项目的要求做修改,然后才能被接入,基本改动如下:

  1. 修改打包配置,允许跨域及 umd
  2. 修改根id,不使用#app
  3. 修改路由文件,在入口文件实例化路由
  4. 修改 public-path 的文件
  5. 修改入口文件,将 qiankun 需要的生命周期暴露出去

方案一:子项目自己运行一个 qiankun 实例

存在的问题:

  1. 子项目无法根据已有信息判断是独立运行还是被集成
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

由于子项目本身也是一个qiankun 项目,所以独立运行时 window.__POWERED_BY_QIANKUN__true,被集成时,还是 true

解决办法:在主项目的入口文件另外定义一个全局变量window.__POWERED_BY_QIANKUN_PARENT__ = true;,用这个变量来区分是被集成还是独立运行

  1. 子项目入口文件的修改

主要有以下几点注意的地方:

  • 切换子项目时,避免重复注册孙子项目,
  • 由于子项目会被注入一个前缀,那么孙子项目的路由也要加上这个前缀
  • 注意容器的冲突,子项目和孙子项目使用不同的容器
let router = null;
let instance = null;
let flag = false;
function render() {
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN_PARENT__ ? '/app-qiankun' : '/',
    mode: 'history',
    routes,
  });
  const childRoute = ['/app-vue-hash','/app-vue-history'];
  const isChildRoute = path => childRoute.some(item => path.startsWith(item))
  const rawAppendChild = HTMLHeadElement.prototype.appendChild;
  const rawAddEventListener = window.addEventListener;
  router.beforeEach((to, from, next) => {
    // 从子项目跳转到主项目
    if(isChildRoute(from.path) && !isChildRoute(to.path)){
      HTMLHeadElement.prototype.appendChild = rawAppendChild;
      window.addEventListener = rawAddEventListener;
    }
    next();
  });

  instance = new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount('#appQiankun');

  if(!flag){
    registerMicroApps([
      { 
        name: 'app-vue-hash', 
        entry: 'http://localhost:1111', 
        container: '#appContainer', 
        activeRule: window.__POWERED_BY_QIANKUN_PARENT__ ? '/app-qiankun/app-vue-hash' : '/app-vue-hash', 
        props: { data : { store, router } }
      },
      { 
        name: 'app-vue-history',
        entry: 'http://localhost:2222', 
        container: '#appContainer', 
        activeRule: window.__POWERED_BY_QIANKUN_PARENT__ ? '/app-qiankun/app-vue-history' : '/app-vue-history',
        props: { data : store }
      },
    ]);
    
    start();
    flag = true
  }
}

if (!window.__POWERED_BY_QIANKUN_PARENT__) {
  render();
}

export async function bootstrap() {
  console.log('vue app bootstraped');
}

export async function mount(props) {
  render();
}

export async function unmount() {
  instance.$destroy();
  instance = null;
  router = null;
}
  1. history模式路由的孙子项目的 base 修改
base: window.__POWERED_BY_QIANKUN_PARENT__ ? '/app-qiankun/app-vue-history' : 
      (window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/'),
  1. 打包配置的修改

以上操作完成后,在主项目中可以把这个 qiankun 子项目加载出来,但是点击其孙子项目,报错,生命周期找不到。

修改一下孙子项目的打包配置:

- library: `${name}-[name]`,
+ library: `${name}`,

然后重启就可以了。

原因是 qiankun 取子项目的生命周期,优先取子项目运行时最后一个挂载到 window 上的变量,如果这个不是生命周期函数,再根据 appName 取。让 webpacklibrary 值对应 appName 即可。

方案二:主项目将 qiankun 的注册函数传递给子项目

基本步骤同上,但是这里有bug:孙子项目不加载。路由/app-qiankun加载qiankun子项目,孙子项目注册为主项目的子项目/app-qiankun/app-vue-hash,但是其qiankun子项目可以正常加载,孙子项目不加载也不报错,感觉这是qiankun的一个bug,两个项目共用了一部分路由前缀,路径长的一个不加载。

如果孙子项目不和qiankun子项目共用路由前缀,则可以正常加载,所以这个实用场景趋向于:将嵌套的子项目都注册为同级子项目,直接用主项目的容器,共用了主项目的注册函数,这些孙子项目本身就是主项目的子项目。

qiankun 子项目注册子项目时的代码如下:

  if(!flag){
    let registerMicroApps = parentData.registerMicroApps;
    let start = parentData.start;
    if(!window.__POWERED_BY_QIANKUN_PARENT__){
      const model = await import('qiankun');
      registerMicroApps = model.registerMicroApps;
      start = model.start;
    }
    registerMicroApps([
      { 
        name: 'app-vue-hash', 
        entry: 'http://localhost:1111', 
        container: window.__POWERED_BY_QIANKUN_PARENT__ ? '#appContainerParent' : '#appContainer', 
        activeRule: '/app-vue-hash', 
        props: { data : { store, router } }
      },
      { 
        name: 'app-vue-history',
        entry: 'http://localhost:2222', 
        container: window.__POWERED_BY_QIANKUN_PARENT__ ? '#appContainerParent' : '#appContainer', 
        activeRule: '/app-vue-history',
        props: { data : store }
      },
    ]);
    start();
    flag = true;
}

qiankun 使用总结

  1. 出现最多的问题: 偶现刷新页面报错,容器找不到。

解决方案1:在组件 mounted 周期注册并启动 qiankun

解决方案2:new Vue() 之后,等 DOM 加载好了再注册并启动 qiankun

const vueApp = new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");
vueApp.$nextTick(() => {
  //在这里注册并启动 qiankun
})
  1. 我之前的担心:所有的js脚本和css文件都在内存中缓存起来,子项目过多会不会导致浏览器卡死?

看到了 issue 区作者的回复:

复用了主项目的依赖之后,一个子项目的 jscss 体积在 2M - 5M 左右,所以基本上不用担心。

  1. qiankun 多应用同时运行 js 沙箱的处理

两个子应用同时存在, 又添加了两个全局变量 window.a, 如何保证这两个能同时运行但互不干扰?

采用了 proxy 代理之后,所有子应用的全局变量变更都是在闭包中产生的,不会真正回写到 window 上,这样就能避免多实例之间的污染了。

结尾

如果文章有什么问题或者错误,欢迎指出,谢谢!