模拟 qiankun 实现加载子应用

430 阅读3分钟

qiankun 的概念大家可以参考官网链接去看,这篇文章模拟简单实现一下如何加载子应用以及监听路由变化实现加载应用的效果

准备阶段

image.png

准备三个项目 mainvite 创建的 react 的项目, app1create-react-app 创建的项目 ,app2vue-cli 创建的项目

演示:

2022-08-19 15.59.28.gif

实现阶段

这里实现了 registerMicroAppsstart 两个主要方法的基本功能

目录结构

image.png

代码分析

qiankun 主要是靠监听路由变化来实现渲染不同的应用 这里用 rewriteRouter 方法来重写路由方法来实现监听的效果

先看一下 index 文件 registerMicroApps 就是取到了 apps 的值,

// index.js

import { rewriteRouter } from './rewrite-router';
import { handleRouter } from './handle-router';

let _apps = []

export const getApps = () => _apps;

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

export const start = () => {
  // 监听路由变化
  rewriteRouter()
  
  // 首次加载的时候触发,避免首次加载应用没加载的情况
  handleRouter()
}

可以看到 start 方法用到了 rewriteRouter 这个函数

// rewrite-router.js

import { handleRouter } from './handle-router';

let prevRouter = ''; // 记录上一个路由,用于卸载
let nextRouter = window.location.pathname;

export const getPrevRouter = () => prevRouter;
export const getNextRouter = () => nextRouter;

export const rewriteRouter = () => {
  /**
   * 有两种
   *    1. hashRouter  监听onHashChange
   *    2. historyRouter 监听 popState ,pushState replaceState
   * 这里只监听histortRouter
   */
  //  用事件监听的形式不会影响到其他包给popState做的监听
  window.addEventListener('popstate', () => {
    // 在触发这个事件的时候 pathname 已经是新的了,这个时候 nextRouter就是上一个路由
    prevRouter = nextRouter
    nextRouter = window.location.pathname;
    handleRouter()
  })

  const rawPushState = window.history.pushState;
  window.history.pushState = (...args) => {
    // 触发pushSate前的这个路由就是旧的
    prevRouter = window.location.pathname;
    rawPushState.apply(window.history, args);
    // 触发pushSate后的这个路由就是新的
    nextRouter = window.location.pathname;
    handleRouter()
  }

  const rawReplaceState = window.history.replaceState;
  window.history.replaceState = (...args) => {
    prevRouter = window.location.pathname;

    rawReplaceState.apply(window.history, args);
    nextRouter = window.location.pathname;

    handleRouter()
  }
}

这个方法的作用就是监听了监听路由变化,触发 handleRouter 这个函数,可以看出来这个函数应该是个主函数

// handle-router.js

import { getApps } from '.';
import { importEntry } from './import-entry'
import { getNextRouter, getPrevRouter } from './rewrite-router';

export const handleRouter = async () => {
  // 拿到当前注册的apps 
  const apps = getApps();
  
  // 找到上一个app用于调用卸载函数
  const prevApp = apps.find(app => getPrevRouter().startsWith(app.activeRule));
  if (prevApp) {
    // 卸载阶段卸载应用
    //   如果有上一个app ,则需要卸载上一个app 
    prevApp.unmount?.({
      ...prevApp,
      container: document.querySelector(prevApp.container)
    })
  }
  // 根据路由找到当前对应的app 
  const app = apps.find(app => getNextRouter().startsWith(app.activeRule));
  if (!app) {
    return
  }

  // 修改环境变量
  window.__POWERED_BY_QIANKUN__ = true;
  window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry.startsWith('http') ? app.entry : `http:${app.entry}`;

  // 请求获取资源
  const { template, execScripts } = await importEntry(app.entry)
  // 挂载dom节点
  let container = document.querySelector(app.container)
  if (!container) {
    return
  }

  container.appendChild(template)
  // 拿到执行完js的返回生命周期函数
  const appExports = await execScripts()
  const appProps = {
    ...app,
    container,
  }
  // 先把当前应用的生命周期函数存在app里面,可以用于卸载上一个app
  app.bootstrap = appExports.bootstrap;
  app.mount = appExports.mount;
  app.unmount = appExports.unmount;

  // 渲染对应的应用
  appExports.bootstrap?.(appProps)
  appExports.mount?.(appProps)
}


这里有几个点

  1. getPrevRouter()getNextRouter() 分别有什么用?

    getPrevRouter 可以用来获取上一次的路由信息,用于销毁旧的 app

    getNextRouter 就是获取当前新的路由信息,用于获取新的 app

  2. 这里的 __POWERED_BY_QIANKUN____INJECTED_PUBLIC_PATH_BY_QIANKUN__ 有什么用?

    第一个变量用来给子应用记录是不是在 qiankun 下运行的

    第二个变量用来注入一个当前加载应用的 publicPath 子应用配合使用 可以避免子应用加载静态资源文件404 的问题,详情可以看 qiankun 官网

  3. importEntry 这个函数我们后面说,这个函数的主要功能是通过子应用的 url 来得到子应用的 html 信息,获取到子应用的 js 文件,以及导出的那些生命周期钩子方法

  4. 为什么要 app.bootstrap = appExports.bootstrap? 这是用来吧加载过的这些 js 导出的钩子记录下来,可以用于在有上一个子应用的时候直接调用 app.unmount

// importEntry.js

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

// 通过url请求到资源 并且加载js  可以获取到js导出的钩子函数
export const importEntry = async (url) => {
    // 获取到 html 的 text文本
  const resource = await fetchResource(url + '/')

  const template = document.createElement('div');
  template.innerHTML = resource;
  const scriptsDOM = template.querySelectorAll('script');

  // 获取脚本资源
  const getExternalScripts = () => {
    return Promise.all(
      Array.from(scriptsDOM).map(async script => {
        const src = script.getAttribute('src')
         // 有两种js资源情况,第一种 带src的 需要手动请求这个资源。第二种在innerHTML里带的js代码
        if (src) {
          return await fetchResource(src.startsWith('http') ? src : `${url}${src}`)
        }
        return Promise.resolve(script.innerHTML)
      })
    )
  }

  // 执行js文件 可以获取js文件的返回的生命周期函数
  const execScripts = async () => {
    const scripts = await getExternalScripts()
    const module = { exports: {} };
    const exports = module.exports;
    scripts.forEach(script => {
      eval(script)
    })

    return module.exports
  }
  return {
    template,
    getExternalScripts,
    execScripts
  }
}

importEntry 这个方法 通过 url 请求到资源 并且加载 js 可以获取到 js 导出的钩子函数

  1. 为什么不把请求回来的资源直接 eval 加载?

eval不能加载 script 标签,需要手动取出来加载

  1. const module = { exports: {} }; const exports = module.exports; 这串代码干嘛的

修改子应用的打包 outputlibraryTargetumd 规范,看打包文件 image.png 这个是子应用打包之后的入口文件,可以看到是个自执行函数,判断了 exportsmodules 如果都为 object 则把函数的返回值赋值给 module.exports 我们可以手动制造一个这个函数执行完 eval 这里面的代码 可以就会给这个对象里附上值

image.png

// fetch-resource.js

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

fetch-resource 这个函数比较简单,就是封装了个请求 返回 text 数据

参考资料

www.bilibili.com/video/BV1H3…