likes
comments
collection
share

ikun练习时长两年半,5分钟学会微前端qiankun源码!

作者站长头像
站长
· 阅读数 18

前言

最近公司项目在用qiankun构建微前端的应用,深深体会到微前端的魅力,无框架限制,主应用统一管理,弹窗的统一位置等。如果是刚开始就植入微前端还好,不过基本上都是后期老项目植入微前端,各种拆分模块,也是一件很头疼的事情。

基石

我们为什么要用微前端

大的应用体量维护成本是很高的,拆分成单独的模块,由主应用处理登录等通用逻辑,子应用来只负责模块的业务实现,这样不管资源加载、按需加载、人员维护成本降低、增量升级、独立部署都有很好的体检提升。当然前提是体量非常大的web应用可以这么做,但是开始做的时候你会很头疼各种拆解带来的不确定性,但是长痛不如短痛。

Why Not Iframe

下面是我从qiankun文档摘抄的:

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。那么为什么不用iframe呢 ?

  1. url 不同步。浏览器刷新 iframe url 状态丢失等(本地缓存不就行了?)
  2. UI 不同步,DOM 结构不共享(主应用控制不就行了?子应用通过postMessage传递数据给父应用)
  3. 一次性加载,慢!(个人感觉就是项目体积小了!跟iframe有啥区别么?)
  4. 全局上下文完全隔离,内存变量不共享。(当然这里通过postMessage是可以实现通信的!)

ikun练习时长两年半,5分钟学会微前端qiankun源码!

所以其实用iframe就够了,微前端是不是有点kpi的味道呢?当然学习下源码还是对自己有提升的,万一iframe没有,是不是可以手撸一个呢?

源码入口

ikun练习时长两年半,5分钟学会微前端qiankun源码!

最核心的就是手动加载loadMicroApp、registerMicroApps注册微应用,start开始构建这3个api,但其实qiankun的核心是基于single-spa框架封装的, 我们看下single-spa做了些什么,以及single-spa内部核心api的registerApplication做了什么

single-spa

single-spa是一个框架,用于将多个JavaScript微前端组合在一个前端应用程序中。使用单一页面中心构建前端可以带来许多好处,例如:

registerApplication 注册应用

export function reroute (pendingPromises = [], eventArguments) {
  if (appChangeUnderway) {
    //如果某个应用正在进行中,那么等待
    //...
  }
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();  //返回不同生命周期的队列
  
  //记录基础的应用信息
  let appsThatChanged,
    navigationIsCanceled = false,
    oldUrl = currentUrl,
    newUrl = (currentUrl = window.location.href);
    
   //这里根据是否已挂载做处理
  if (isStarted()) {
    //....
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }
    
   //加载apps
  function loadApps () {
    return Promise.resolve().then(() => {
      const loadPromises = appsToLoad.map(toLoadPromise);

      return (
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          // there are no mounted apps, before start() is called, so we always return []
          .then(() => [])
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
      );
    });
  }

  function performAppChanges () {
    return Promise.resolve().then(() => {
      // https://github.com/single-spa/single-spa/issues/545
      window.dispatchEvent(
        new CustomEvent(
          appsThatChanged.length === 0
            ? "single-spa:before-no-app-change"
            : "single-spa:before-app-change",
          getCustomEventDetail(true)
        )
      );
        
      //...做了大量的自定义事件以及卸载事件
  }

  function finishUpAndReturn () {
    const returnValue = getMountedApps();
    pendingPromises.forEach((promise) => promise.resolve(returnValue));

    try {
      const appChangeEventName =
        appsThatChanged.length === 0
          ? "single-spa:no-app-change"
          : "single-spa:app-change";
      window.dispatchEvent(
        new CustomEvent(appChangeEventName, getCustomEventDetail())
      );
      window.dispatchEvent(
        new CustomEvent("single-spa:routing-event", getCustomEventDetail())
      );
    } catch (err) {
      //...
    }
      //...
  }

  function callAllEventListeners () {
    pendingPromises.forEach((pendingPromise) => {
      callCapturedEventListeners(pendingPromise.eventArguments);
    });

    callCapturedEventListeners(eventArguments);
  }

   //更新状态
  function getCustomEventDetail (isBeforeChanges = false, extraProperties) {
    const newAppStatuses = {};
    const appsByNewStatus = {
      // for apps that were mounted
      [MOUNTED]: [],
      // for apps that were unmounted
      [NOT_MOUNTED]: [],
      // apps that were forcibly unloaded
      [NOT_LOADED]: [],
      // apps that attempted to do something but are broken now
      [SKIP_BECAUSE_BROKEN]: [],
    };
    
    //重置状态
    if (isBeforeChanges) {
      appsToLoad.concat(appsToMount).forEach((app, index) => {
        addApp(app, MOUNTED);
      });
      appsToUnload.forEach((app) => {
        addApp(app, NOT_LOADED);
      });
      appsToUnmount.forEach((app) => {
        addApp(app, NOT_MOUNTED);
      });
    } else {
    //更新不同状态的队列
      appsThatChanged.forEach((app) => {
        addApp(app);
      });
    }

    const result = {
      detail: {
        newAppStatuses,
        appsByNewStatus,
        totalAppChanges: appsThatChanged.length,
        originalEvent: eventArguments?.[0],
        oldUrl,
        newUrl,
        navigationIsCanceled,
      },
    };

    if (extraProperties) {
      assign(result.detail, extraProperties);
    }

    return result;

    function addApp (app, status) {
      const appName = toName(app);
      status = status || getAppStatus(appName);
      newAppStatuses[appName] = status;
      const statusArr = (appsByNewStatus[status] =
        appsByNewStatus[status] || []);
      statusArr.push(appName);
    }
  }
}

说实话这里的源码很绕,这里只摘取最关键的,在registerApplication内部,将qiankun的registerMicroApps的参数传入做些兼容判断,然后调用了一个核心的reroute方法, 这里删除了不必要的干扰信息,说白了single-spa做了spa的生命周期的管理,每个应用有单独的html做页面的加载,但是环境的隔绝是需要qiankun做的

getAppChanges 状态管理

export function getAppChanges () {
  const appsToUnload = [],
    appsToUnmount = [],
    appsToLoad = [],
    appsToMount = [];

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  const currentTime = new Date().getTime();

  apps.forEach((app) => {
    const appShouldBeActive =
      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

    switch (app.status) {
      case LOAD_ERROR:
        if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
          appsToLoad.push(app);
        }
        break;
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
          appsToUnload.push(app);
        } else if (appShouldBeActive) {
          appsToMount.push(app);
        }
        break;
      case MOUNTED:
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;
      // all other statuses are ignored
    }
  });

  return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}

getAppChange返回了应用一旦改变那么不同生命周期的队列会更新

registerMicroApps注册微应用

简单的看下用法,qiankun的核心api并不多

ikun练习时长两年半,5分钟学会微前端qiankun源码!

ikun练习时长两年半,5分钟学会微前端qiankun源码!

registerMicroApps注册微应用中,传入apps的信息,如name、路由匹配规则、container挂载dom等,和生命周期的钩子lifeCycles。核心的应用状态管理在single-spa中,而qiankun在外层又做了统一的上层的主应用封装。这里比较重要的是loadApp,里面统一处理解决了全局变量混入的问题也就是沙箱隔离,样式隔离等。下面是loadApp的一部分核心逻辑

环境隔离

1. 全局变量的隔离

createSandboxContainer 创建沙箱

ikun练习时长两年半,5分钟学会微前端qiankun源码!

这里核心就是createSandboxContainer的用法,这里简单讲下几个核心的参数,initialAppWarpperGetter用于处理检查是否有无包裹的dom元素,scpredCSS是代表样式是否已经被隔离状态, useLooseSandbox和speedySandbox处理不同状态的沙箱。 ,在里面核心的方法是patchAtBootstrapping做了不同的沙箱隔离方式的隔绝处理

patchAtBootstrapping 启动器

ikun练习时长两年半,5分钟学会微前端qiankun源码!

patchAtBootstrapping中的策略者模式,我们可以看到有3种沙箱的处理方式,legacyProxy、Proxy、Snapshot,并分别对应了pathLooseSandbox、pathStrictSandbox、patchLooseSandbox,下面简单解析下原理,理解即可。

Snapshot沙箱隔离

先将主应用的window拷贝一份,一旦微应用切换到主应用做回退,如果微应用切换,那么会提前生成微应用的diff过程的对象,然后回退。而缺陷就是diff属性量一旦过大会性能不好

Legacy沙箱隔离

那么与Snapshot最大的不同是用了Proxy来处理,set做记录,一旦应用切换就回退,相对于我不断循环遍历diff,性能好了不少

Proxy沙箱隔离

前面两种应用场景在于都是一个路由对应一个微应用,那么如果是多个微应用同时出现在一个页面中,那么环境是不是不可控了呢。这种情况就不能在window直接操作,而是要每个应用都要有一个独立的fakeWindow,这样区分环境后,数据处理尽量在fakeWindow上处理,而不是原生window

Proxy模式的核心的我们看下pathStrictSandbox源码

pathStrictSandbox 严格模式

ikun练习时长两年半,5分钟学会微前端qiankun源码!

Proxy代理模式的沙箱,通过Object.defineProperty来拦截对象属性,但是不可枚举可写入, 这样每次切换应用我都重新获取新的nativeGlobal

nativeGlobal 全局对象

export const nativeGlobal = new Function('return this')();

通过new Function来更安全的返回全局对象

2. DOM的隔离

ikun练习时长两年半,5分钟学会微前端qiankun源码!

ikun练习时长两年半,5分钟学会微前端qiankun源码!

ikun练习时长两年半,5分钟学会微前端qiankun源码!

很明显这里是通过ShadowDOM来实现dom的隔离,我们常见的比如video、audio标签内部都是可以看到shadowDOM实现的,同时我们也可以看到做了兼容性的处理

3. 样式隔离

ikun练习时长两年半,5分钟学会微前端qiankun源码! scopedCSS代表是否要隔离css,如果要隔离首先去判断将微应用的根元素挂载qiankun的属性标记,然后遍历所有style标签,css.process对每个内部的样式属性名做了模块化的处理,而appInstanceId就是做微应用样式隔离的id区分

通信

import-html-entry

qiankun用的是import-html-entry这个库的execSceipts方法来请求获得并解析脚本的,然后直接把html插入到容器里,所以应用间需要允许跨域才行,在importEntry你可以发现他使用了浏览器空闲的api,requestIdleCallback以及为基础实现预加载prefetch

ikun练习时长两年半,5分钟学会微前端qiankun源码!

ikun练习时长两年半,5分钟学会微前端qiankun源码!

ikun练习时长两年半,5分钟学会微前端qiankun源码!

initGlobalState 全局状态

ikun练习时长两年半,5分钟学会微前端qiankun源码!

ikun练习时长两年半,5分钟学会微前端qiankun源码!

ikun练习时长两年半,5分钟学会微前端qiankun源码!

我们主要看下initGlobalState,通过emitGlobal来触发更新全局状态,从上图可以看出核心通过deps发布订阅模式来管理每个微应用,然后更新状态。返回的onGlobalChange和setGlobalState来监听变化和触发通知。状态管理还是比较简单的。

总结

花了几天时间看了源码,收获还是挺大的,微前端其实主要有3个的核心点在于应用通信、应用的生命周期及状态管理、沙箱环境隔离。相对来说iframe足够满足我们业务需求了,微前端提供了一种思路还是不错的,但是真的有必要用qiankun么?