手写实现一个简易的微前端qiankun框架

204 阅读8分钟

什么是微前端

微前端用通俗的话来讲就是将一个大型的应用拆分开来,实现多团队协作开发,并且无关技术栈,比如一个项目可以包含Vue、React、Angular等等,用三个关键词来概括就是:多合一、跨技术栈、团队协作,近几年来,越来越多的企业级公司不断普及微前端技术,因此掌握一门微前端框架是非常有必要的

qiankun乾坤

随着微前端概念的火热,自然也衍生出了许多的微前端框架,其中比较有名的有singleSpa、qiankun等,后者在基于前者的基础上进行了改进,因此大多数人所知道的第一个微前端框架就是 qiankun,目前qiankun是阿里团队在维护,并且阿里内部也有不少项目是基于qiankun开发的,可见qiankun是得到了一定程度上的认可,所以今天我们就来实现一个简易的qiankun,让我们能在一定程度上对微前端有一个更加深入的了解,lets go!

微前端项目初始化

这里需要大家先去看一下qiankun的官方文档,直接点击上一个标题进去即可,花五分钟阅读一下,能够更好地了解微前端机制!ok,首先,微前端一定得有一个父容器,父容器就是用于将不同技术栈的子应用包裹起来,随后监听url地址的变化去切换不同的子应用,而子应用必须导出三个生命周期函数,分别为:bootstrap、mount、unmount,分别对应子组件的加载、挂载、卸载。知道这些微前端基本规则后,就可以开始手写实现简易版的qiankun了。

  • 首先通过vue-cli以及create-react-app分别创建vue2、vue3以及react项目充当这次微前端的子应用
  • 再使用任意一个框架去创建一个父容器

创建完之后的项目整体结构如下所示:

image.png

父容器基本骨架

  • 父容器主要用于注册子应用并启动,因此我们需要一个注册和启动的方法,根据qiankun的官方文档,我们也按照这个标准去编写父容器的基本骨架:

image.png

这里简单解释一下四个字段的含义,name就是当前子应用的名称,entry是当前子应用所运行的路径,container是当前子应用要挂载到父容器的哪个容器中去,activeRule是当前子应用在url地址为该参数的时候激活子应用,我这边在生成html文件中添加了几个id容器,用于挂载不同子应用。 image.png

image.png

  • src/qiankun的文件中一共包含5个文件,分别对应路由的监听、注册以及启动方法的导出、静态资源的处理、路由方法的重写、以及工具类库

image.png

子应用基本骨架

  • 这里我们先只用关心子应用中的 index.js文件,参照qiankun本身的官方文档,我们可以很快速的完成react、vue2、vue3子应用的基本搭建。(注意,子应用中的生命周期函数都是异步的)

image.png

image.png image.png

image.png

image.png

核心流程

  1. index.js

这部分主要有三个函数,分别对应获取所有的app子应用,注册app子应用的函数(就一个步骤,就是保存当前的子应用内容),启动app,可以看到,我们在父容器最外层中引用的注册app和启动app函数就是在这导出的

import { listenRouter} from "./handleRouter"
import { rewriteRouter } from "./rewriteRouter"

let apps = []

// 获取所有app
export const getApps = () => apps

export const registerMicroApps = (_apps) => {
   // 保存所有app
   apps = _apps
}


export const start = async () => {
   rewriteRouter()
   await listenRouter()
}
  1. rewriteRouter.js
    在index.js中,我们启动app的第一步就是进入路由里面,重写路由函数,这里需要大家对浏览器路由的两个api有了解,首先是hash路由的hashchange,这个api在我们进行hash路由跳转的时候,能够监听到路由的hash变化并且能够获取到路由的url地址;第二个api则是history,它和hashchange不同,它这边只支持调用,不支持监听,什么意思呢,我们本身只能使用 window.history.pushState(url),直接跳转,本身并不能监听到我这个跳转的行为,这就有点麻烦了,毕竟微前端就是监听路由的url变化去加载不同的子应用,无法监听到的话,那不是废了嘛?别急,qiankun这边的处理很巧妙,它先是分别创建了两个变量去保存了当前的history中的pushState和replaceState方法,随后将浏览器本身的history方法进行了重写,再在重写的函数内部进行监听,能够监听到之后就可以使用刚才保存的跳转函数去实现页面跳转了,是不是很巧妙。所以我们这里就只实现较为繁琐的history监听。
      import { listenRouter } from "./handleRouter"

      // 最开始的值 一定要给, 这里用于获取之前的url以及之后的url;
      // 在切换url时,通过上一个url去卸载上一个应用,再通过下一个url去加载新应用
      // 避免不同的url上面存在多个不该出现的子应用
      let nextRoute = window.location.pathname
      let prevRoute = ""


      export const getPrevRoute = () => prevRoute
      export const getNextRoute = () => nextRoute

      export const rewriteRouter = () => {
          // 必须使用 addEventListener 不会覆盖
          window.addEventListener('popstate', () => {
              prevRoute = nextRoute
              nextRoute = window.location.pathname
              listenRouter()
          })

          // 保存原生的 pushState方法
          const originPushState = window.history.pushState
          // 监听 pushState方法方法
          window.history.pushState = (...args) => {
              prevRoute = window.location.pathname
              originPushState.apply(window.history, args)
              nextRoute = window.location.pathname
              listenRouter()
          }

          const originReplaceState = window.history.replaceState
          window.history.replaceState = (...args) => {
              prevRoute = window.location.pathname
              originReplaceState.apply(window.history, args)
              nextRoute = window.location.pathname
              listenRouter()
          }
      }

image.png

  1. handleRouter.js
    在监听到 url 地址的变化后,就要开始加载当前应用了,这边主要分为三个步骤:卸载上一个子应用 => 加载当前应用的html资源等 => 挂载当前应用,在目前的步骤中,挂载子应用会有静态资源不显示的问题(比如图片),那是因为默认静态资源的路径是以 “ / ” 开头的,也就是当前的父容器的绝对路径,而每个子应用是一个单独的个体,他们是根据自身的路径去加载静态资源的,所以我们需要添加一个webpack打包配置:publicPath,这个api是为所有的静态资源文件添加一个路径前缀,那么如何添加呢?让我们看一下官方文档

image.png

image.png

可以看到,qiankun要求我们在src目录下创建一个 public-path.js,然后通过判断是否为qiankun环境,如果是qiankun环境的话就会给webpack添加一个全局的静态资源前缀,那么我们就照做吧,给所有的子应用都添加一下

image.png

做到这里还不够,细心的人会发现官方文档下面还会要求我们配置一下webpack和跨域请求,跨域是为了方便我们后期加载js文件以及其他静态资源,webpack的打包格式为umd格式是为了让我们获取到当前子应用导出的三个生命周期函数,我这里就偷懒,只配了vue子应用(注意:devserver中的port要和父容器中注册的端口号一致

image.png

image.png

image.png

这部分是 umd 格式下 打包的子应用代码,可以看到如果判断到exports对象存在的话,我们就能够获取到导出的三个生命周期函数,这就是为什么要统一使用umd格式打包

image.png

综上所述,我们在加载应用前还要做两件事:添加一个 POWERED_BY_QIANKUN 的全局变量,添加一个 INJECTED_PUBLIC_PATH_BY_QIANKUN 的静态资源前缀

image.png

import { getApps } from "./index"
import { importHtml } from "./resolveResource"
import { getPrevRoute, getNextRoute } from "./rewriteRouter"



export const listenRouter = async () => {
    const apps = getApps()

    // 卸载前面的 app
    const prevApp = apps.find(app => getPrevRoute().startsWith(app.activeRule))
    unmount(prevApp)

    // 获取当前 激活的app
    const activeApp = apps.find(app => getNextRoute().startsWith(app.activeRule))
    if (!activeApp) return
    const { template, getScripts, execScripts } = await importHtml(activeApp.entry)
    // 获取挂载的容器
    const container = document.querySelector(activeApp.container)
    // 添加app到容器中
    container.appendChild(template)
    // 注入全局变量
    window.__POWERED_BY_QIANKUN__ = true
    // 给 静态资源url添加前缀
    window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = activeApp.entry + '/'
    const app = await execScripts()
    activeApp.bootstrap = app.bootstrap
    activeApp.mount = app.mount
    activeApp.unmount = app.unmount

    // 加载app
    await bootstrap(activeApp)
    // 挂载app
    await mount(activeApp)
}





async function bootstrap(app) {
    app?.bootstrap && (await app.bootstrap())
}

// react应用挂载的时候需要一个container
async function mount(app) {
    app?.mount && (await app.mount({
        container: document.querySelector(app.container),
    }))
}

// react应用卸载的时候需要一个container
async function unmount(app) {
    app?.unmount && (await app.unmount({
        container: document.querySelector(app.container),
    }))
}
  1. resolveResource.js
    最后一步便是加载js文件了,这边之所以说是实现一个简易的qiankun,是因为没有考虑到 css 污染 以及 js沙箱 的实现步骤,主要是为了让大家能够对微前端有一定的认识。
      import { fetchSource } from "./utils"
      export const importHtml = async (url) => {
      const template = document.createElement("div");
      const html = await fetchSource(url);
      template.innerHTML = html

      // 获取 所有的 js 脚本
      function getScripts() {
          const scripts = template.querySelectorAll("script");
          return Promise.all(Array.from(scripts).map(script => {
              const src = script.getAttribute("src");
              if (!src) return Promise.resolve(script.innerHTML)
              // 判断是否为本地的 js
              return fetchSource(src.startsWith("http") ? src : url + src)
          }))
      }


      async function execScripts() {
          const scripts = await getScripts()

          // umd 打包格式 会判断 是否为 es5 如果为 es5的话 导出的就是 app  这边手动模拟 es5 环境
          const module = { exports: {} }
          const exports = module.exports

          // 简易版 直接使用 eval 去执行js代码,开发中不推荐使用 eval
          scripts.map(script => eval(script))

          // 此处 是 app
          return module.exports
      }

      return {
          template,
          getScripts,
          execScripts
      }
  }
  
  
  
  utils.js代码如下,就一个简单的资源请求方法 
  
  export const fetchSource = url => fetch(url).then(res => res.text())


预览以及总结总结

完成以上步骤后,我们就启动所有的子应用以及父应用来看看效果吧

image.png

这是初次加载父应用的页面

image.png

我们点击 react 去跳转到react应用(由于react前面没有配置跨域,因此图片没有加载出来

image.png

我们再来跳转一下 vue2应用,可以看到 vue2 应用成功地被挂载到了我们指定样式的右边容器中

image.png

再看一下 vue3应用,也是成功的加载了

image.png

总结
本篇文章主要实现了一个基本的qiankun框架,整体上难度不高,仔细阅读文章内容并理解代码含义,能够较为快速地对微前端有相对深入的认知,另外这边建议各位初学者们多动手写,毕竟纸上得来终觉浅。