手写不带隔离的mini-qiankun

254 阅读7分钟

感谢b站鹏周同学

qiankun

简单介绍一下qiankun,qiankun框架是一套微前端的架构,理念来源于微服务,目标是将多个技术栈,多个应用,整合到一个项目中,方便在多个应用之间来回切换。

实现mini-qiankun

首先 ,我们做些准备工作,需要搭建几个应用。这里我用vue2构建了三个应用,由于本文章不是介绍如何将项目改造成qiankun,所以有关配置的方面,我就简单一笔带过了

  • main 主应用 也叫作基座
  • app1 子应用1
  • app2 子应用2

main项目的 main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import { registerMicroApps, start } from "qiankun";

Vue.config.productionTip = false;

registerMicroApps([
  {
    name: "app1", // app name registered
    entry: "//localhost:8081",
    container: "#sub-app",
    activeRule: "/subapp/app1",
  },
  {
    name: "app2", // app name registered
    entry: "//localhost:8082",
    container: "#sub-app",
    activeRule: "/subapp/app2",
  },
]);

start();

new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");

子应用主要是修改 main.js、router.js 以及 vue.config.js

子应用的main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import "./public-path";

Vue.config.productionTip = false;
let vm;
function render(props = {}) {
  const { container } = props;
  vm = new Vue({
    router,
    props,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector("#app") : "#app");
}
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
export async function bootstrap() {}

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

export async function unmount() {
  // 子系统卸载,取消所有的请求
  vm.$destroy();
  vm.$el.innerHTML = "";
  vm = null;
}

子应用的router.js

import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/about",
    name: "About",
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/About.vue"),
  },
];

const router = new VueRouter({
  mode: "history",
  base: window.__POWERED_BY_QIANKUN__ ? `/subapp/app2` : process.env.BASE_URL, 
  routes,
});

export default router;

vue.config.js

const { name } = require("./package.json");

module.exports = {
  devServer: {
    port: 8082,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    output: {
      // 把子应用打包成 umd 库格式
      library: `${name}-[name]`,
      libraryTarget: "umd",
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

加入了一个 public-path.js

qiankun 主要暴露了这两个方法 registerMicroAppsstart,分别是注册子应用,以及启动。我们新建一个libs文件夹,里面加入一个index.js作为qiankun的引入。

main 项目的 main.js

import { registerMicroApps, start } from "@/libs/index";

libs/index.js

import { reWriteRouter } from "./re-router.js";

let _apps = [];

export const getApps = () => _apps;
export const registerMicroApps = (apps) => {
  _apps = apps;
};

export const start = () => {
  // 1.监视路由变化
  reWriteRouter();
};

在libs 我们主要暴露两个方法registerMicroApps\color{red}{registerMicroApps}start\color{red}{start},同时创建了一个所有应用的变量_apps\color{red}{\_apps},并且将registerMicroApps传入的参数赋值给_apps,start函数也很简单,调用了一个reWriteRouter\color{red}{reWriteRouter}的方法。

接下来,我们开始实现 reWriteRouter\color{red}{reWriteRouter},在这里,我们需要实现四个步骤:

  1. 监视路由变化
  2. 匹配子应用
  3. 加载子应用
  4. 渲染子应用

首先 我们实现第一个:监视路由变化,在这里,就不得不提路由的两种方式,一种是hash\color{red}{hash}路由,另外一种是history\color{red}{history}路由,前者比较容易,监听变化很简单,我们可以用这个hashchange方法,不管是前进还是后退,都能监听到。复杂性比较高的为后者,我们来详细说明一下:

history的变化分成两种 ,一种是history.go\color{red}{history.go}   history.back\color{red}{history.back}   history.forward\color{red}{history.forward},这些路由变化的时候会触发popstate方法,另外一种是pushStatereplaceState这些是没有专门的方法去监听到的,所以这两种,我们需要重写这两个方法,具体代码为下:

export const reWriteRouter = function () {
  window.addEventListener("popstate", () => {
    console.log("popstate");
  });

  /**
   * 重写pushstate
   */
  const rowPushState = window.history.pushState;
  window.history.pushState = function (...args) {
    rowPushState.apply(window.history, args);
    console.log("路由变化了", preRouter + "->" + nextRouter);
  };

  /**
   * 重写replacestate
   */

  const rowReplaceState = window.history.replaceState;
  window.history.replaceState = function (...args) {
    rowReplaceState.apply(window.history, args);
    console.log("路由变化了", preRouter + "->" + nextRouter);
  };
};

第一步我们已经完成,接下来我们进行第二部分:匹配子路由。由于已经监听到路由变化,我们稍微改造一下上面的方法,在上面加入一个handlerRouter方法,同时新建一个文件handle-router.js,在这里面来处理逻辑匹配子路由的逻辑。

import { handlerRouter } from "./handle-router";

export const reWriteRouter = function () {
  window.addEventListener("popstate", () => {
    console.log("popstate");
+   handlerRouter();
  });

  /**
   * 重写pushstate
   */
  const rowPushState = window.history.pushState;
  window.history.pushState = function (...args) {
    rowPushState.apply(window.history, args);
    console.log("路由变化了", preRouter + "->" + nextRouter);
+   handlerRouter();
  };

  /**
   * 重写replacestate
   */

  const rowReplaceState = window.history.replaceState;
  window.history.replaceState = function (...args) {
    rowReplaceState.apply(window.history, args);
    console.log("路由变化了", preRouter + "->" + nextRouter);
+   handlerRouter();
  };
};

hand-router.js

/**
 * 2.处理路由变化
 *
 * 获取当前的路径
 */

import { getApps } from "./index";

export const handlerRouter = async () => {
  const apps = getApps();
  /**
   * 加载上一个路由
   */
  console.log(window.location.pathname, apps);
  const app = apps.find((item) => getNextRouter().startsWith(item.activeRule));
  console.log(app);
  if (!app) {
    return;
  }
};

我们获取了所有apps并且根据路由找到了当前的子应用app,接下来,我们处理第三个:加载子应用,我们在上面的后面加上一段逻辑:

/**
 * 2.处理路由变化
 *
 * 获取当前的路径
 */

import { getApps } from "./index";

export const handlerRouter = async () => {
  const apps = getApps();
  /**
   * 加载上一个路由
   */
  console.log(window.location.pathname, apps);
  const app = apps.find((item) => getNextRouter().startsWith(item.activeRule));
  console.log(app);
  if (!app) {
    return;
  }
  //   3 获取子应用的html,js css
  const html = await fetch(app.entry).then((res) => res.text());
  console.log(html);
  const container = document.querySelector(app.container);
  container.innerHTML = html;
 }

如此,我们就将代码渲染到了页面上,虽然现在页面上没有任何信息,但是我们可以打开终端看下,

image.png 子应用的代码确实加载进来了,但是页面是空白的,

为什么呢?

原因是浏览器出于安全考虑,并不会自动执行我们引入的js,所以 我们需要手动执行js。

这里就不得不提到一个库了——import-html-entry,qiankun使用的就是这个库,里面暴露很多方法,我们这里只实现获取外部脚本,以及执行脚本,这就是我们的第四步,渲染子应用

新建一个 import-html.js

/**
 *
 * 实现import-html-entry
 * https://github.com/kuitos/import-html-entry
 * 这里只实现 getExternalScripts  execScripts
 *  从模版中获取script  以及执行js
 */

import { fetchResource } from "./fetch-resource";

export const importHtml = async (url) => {
  console.log(url);
  const html = await fetchResource(url);
  const template = document.createElement("div");
  template.innerHTML = html;

  const scripts = template.querySelectorAll("script");

  //获取所有的script
  async function getExternalScripts() {
    console.log(scripts);
    return Promise.all(
      Array.from(scripts).map((script) => {
        const src = script.getAttribute("src");
        if (!src) {
          return Promise.resolve(script.innerHTML);
        } else {
          console.log(src, url);
          return fetchResource(src.startsWith("http") ? src : `${url}${src}`);
        }
      })
    );
  }

  //执行所有的script
  async function execScripts() {
    const scripts = await getExternalScripts();
    const module = { exports: {} };
    // no-unused-vars
    const exports = module.exports;
    scripts.forEach((res) => {
      eval(res);
    });
    return module.exports;
  }

  return {
    template,
    getExternalScripts,
    execScripts,
  };
};

fetch-resource.js

export const fetchResource = async (url) =>
  await fetch(url).then((res) => res.text());

详细说明一下:我们导出一个importHtml\color{red}{importHtml}方法。这个方法接受一个url,我们获取这url上的内容,并且以文本输出他。这部分代码跟我们的第三步一致,后面又有两个方法getExternalScripts\color{red}{getExternalScripts}以及execScripts\color{red}{execScripts},前者需要注意的点是需要判断有没有src属性,因为会有如下这种方式

<script>
console.log(123)
</script>

另外一种就是外链一个url,这个就需要再次请求资源了。后者为执行js,我们将上一步拿到的js放到这来执行。这里用了commonjs的规范来获取。

为什么?

因为子应用用的umd打包方式,我们可以举个例子来看下子应用umd打包出来的代码

(function webpackUniversalModuleDefinition(root, factory) {
        //commonjs
	if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory();
         //amd
	else if(typeof define === 'function' && define.amd)
		define([], factory);
         //commonjs
	else if(typeof exports === 'object')
		exports["app2-app"] = factory();
        //最后方式为挂载在window上
	else
		root["app2-app"] = factory();
})(window, function() {
    return {a:1}
});

上面是我加的备注,你可以选择一种自己喜欢的方式引入执行js。

接下来我们需要改动 handle-router.js,因为刚才我们加载页面并没有显示,所以

/**
 * 2.处理路由变化
 *
 * 获取当前的路径
 */

import { getApps } from "./index";
import { importHtml } from "./import-html.js";

export const handlerRouter = async () => {
  const apps = getApps();
  /**
   * 加载上一个路由
   */
  console.log(window.location.pathname, apps);
  const app = apps.find((item) => getNextRouter().startsWith(item.activeRule));
  console.log(app);
  if (!app) {
    return;
  }
    // 3 获取子应用的html,js css
    // const html = await fetch(app.entry).then((res) => res.text());
    // console.log(html);
    // const container = document.querySelector(app.container);
    // container.innerHTML = html;

  const { template, execScripts } = await importHtml(app.entry);
  const container = document.querySelector(app.container);
  //   console.log(template, getExternalScripts, execScripts);
  container.appendChild(template);

  window.__POWERED_BY_QIANKUN__ = true;

  const appsScript = await execScripts();
  app.bootstrap = appsScript.bootstrap;
  app.mount = appsScript.mount;
  app.unmount = appsScript.unmount;
  await bootstrap(app);
  await mount(app);
};

async function bootstrap(app) {
  app.bootstrap && (await app.bootstrap());
}
async function mount(app) {
  app.mount &&
    (await app.mount({ container: document.querySelector(app.container) }));
}
async function unmount(app) {
  app.unmount &&
    (await app.unmount({ container: document.querySelector(app.container) }));
}

我们获取了子应用的代码,并且执行了,这时候,我们应该能拿到子应用在main.js注册的bootstrap\color{red}{bootstrap}mount\color{red}{mount}unmount\color{red}{unmount}三个方法,我们在主应用调用这个方法。同时 我们设置一个一个变量 __POWERED_BY_QIANKUN__,由于微前端的理念就是子应用能单独访问,也能作为子应用供主应用调用,所以我们在window上定义这个变量,标志着当前运行环境。这样就能渲染出来了。

到此为止,你应该页面能切换了,现在会有两个问题, 一个是图片没有加载出来,另一个问题是之前的应用没有卸载掉。

我们再次修改上面的代码

import { getApps } from "./index";
import { importHtml } from "./import-html.js";
+ import { getNextRouter, getPreRouter } from "./re-router";
  ...
  const apps = getApps();
 /**
   * 卸载上一个路由
   */
+ const preApp = apps.find((item) =>
+    getPreRouter().startsWith(item.activeRule)
+ );
  console.log(preApp, ">>>>>>");
+  if (preApp) {
+   await unmount(preApp);
+ }
  /**
   * 加载上一个路由
   */
  console.log(window.location.pathname, apps);
  const app = apps.find((item) => getNextRouter().startsWith(item.activeRule));
  console.log(app);
  ...
 
  window.__POWERED_BY_QIANKUN__ = true;
 + window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry + "/";

  const appsScript = await execScripts();
  ...

我们加了一段逻辑,获取了上一个 和下一个路由,在切换的时候卸载掉上一个,加载下一个,同时改造了re-router.js, 并且加入了一个__INJECTED_PUBLIC_PATH_BY_QIANKUN__变量,这个是webpack运行时的全局publicPath,对应着public-path.js,这个主要是在静态资源的前面加上地址的部分。这样就能解决图片的问题。

import { handlerRouter } from "./handle-router";

+ let preRouter = "";
+ let nextRouter = window.location.pathname;

+ export const getPreRouter = () => preRouter;
+ export const getNextRouter = () => nextRouter;

export const reWriteRouter = function () {
  window.addEventListener("popstate", () => {
  +  preRouter = nextRouter;
    console.log("popstate");
  + nextRouter = window.location.pathname;
    handlerRouter();
  });

  /**
   * 重写pushstate
   */
  const rowPushState = window.history.pushState;
  window.history.pushState = function (...args) {
 +  preRouter = window.location.pathname;
    rowPushState.apply(window.history, args);
 +  nextRouter = window.location.pathname;
    console.log("路由变化了", preRouter + "->" + nextRouter);
    handlerRouter();
  };

  /**
   * 重写replacestate
   */

  const rowReplaceState = window.history.replaceState;
  window.history.replaceState = function (...args) {
 +  preRouter = window.location.pathname;
    rowReplaceState.apply(window.history, args);
    nextRouter = window.location.pathname;
    console.log("路由变化了", preRouter + "->" + nextRouter);
    handlerRouter();
  };
};

如此这般,便大功告成了!!!