🥇🥇🥇一网打尽主流的微前端框架生命周期/路由/资源系统:原理篇

本文接上文 一文带你打通微前端任督二脉-原理篇(qiankun/microapp/icestark/wujie全解析),建议先阅读上篇再阅读本篇文章

这篇文章主要讲述怎么设计一个微前端架构的系统,参考主流微前端框架

生命周期

qiankun生命周期

微应用的生命周期boostrap、mount、unmount,在注册微应用只需要导出上面三个方法即可,分别对应微应用的注册、挂载、卸载

基本使用

vue2使用示例

javascript 复制代码
// main.js
import './public-path';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

Vue.use(ElementUI);

let router = null;
let instance = null;

function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/vue' : '/',
    mode: 'history',
    routes,
  });

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

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

function storeTest(props) {
  props.onGlobalStateChange &&
    props.onGlobalStateChange(
      (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
      true,
    );
  props.setGlobalState &&
    props.setGlobalState({
      ignore: props.name,
      user: {
        name: props.name,
      },
    });
}
export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

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

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}

vue3使用示例

javascript 复制代码
// main.js
import './public-path';
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

let router = null;
let instance = null;
let history = null;


function render(props = {}) {
  const { container } = props;
  history = createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/vue3' : '/');
  router = createRouter({
    history,
    routes,
  });

  instance = createApp(App);
  instance.use(router);
  instance.use(store);
  instance.mount(container ? container.querySelector('#app') : '#app');
}

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

export async function bootstrap() {
  console.log('%c%s', 'color: green;', 'vue3.0 app bootstraped');
}

function storeTest(props) {
  props.onGlobalStateChange &&
    props.onGlobalStateChange(
      (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
      true,
    );
  props.setGlobalState &&
    props.setGlobalState({
      ignore: props.name,
      user: {
        name: props.name,
      },
    });
}

export async function mount(props) {
  storeTest(props);
  render(props);
  instance.config.globalProperties.$onGlobalStateChange = props.onGlobalStateChange;
  instance.config.globalProperties.$setGlobalState = props.setGlobalState;
}

export async function unmount() {
  instance.unmount();
  instance._container.innerHTML = '';
  instance = null;
  router = null;
  history.destroy();
}

原理

qiankun的生命周期大部分都是继承的single-spa的生命周期

micro app生命周期

基本使用

micro app通过自定义事件CustomEvent实现生命周期,主要有:

  • created

<micro-app>标签初始化后,资源加载前触发

  • beforeMount

资源加载后,开始渲染前触发

  • mounted

子应用渲染完成后触发

  • unmount

子应用卸载时触发

  • error

子应用发生错误时触发

vue使用示例

xml 复制代码
<template> 
<micro-app 
    name='xx' 
    url='xx' 
    @created='created' 
    @beforemount='beforemount' 
    @mounted='mounted' @unmount='unmount' 
    @error='error' /> 
</template> 
<script> 
export default { 
    methods: { 
        created () { 
          console.log('micro-app元素被创建') 
        }, 
        beforemount () { 
          console.log('即将被渲染') 
        }, 
        mounted () { 
          console.log('已经渲染完成') 
        }, 
        unmount () { 
          console.log('已经卸载') 
        }, 
        error () { 
          console.log('渲染出错') 
        } 
    } 
} 
</script>

react使用示例

javascript 复制代码
/** @jsxRuntime classic */ 
/** @jsx jsxCustomEvent */ 
import jsxCustomEvent from '@micro-zoe/micro-app/polyfill/jsx-custom-event'

<micro-app 
  name='xx' 
  url='xx' 
  onCreated={() => console.log('micro-app元素被创建')} 
  onBeforemount={() => console.log('即将被渲染')} 
  onMounted={() => console.log('已经渲染完成')} 
  onUnmount={() => console.log('已经卸载')} 
  onError={() => console.log('渲染出错')} />

原理

定义了lifeCyclesType接口(这些生命周期钩子都是CustomEvent自定义事件)

csharp 复制代码
  interface lifeCyclesType {
    // 被创建
    created?(e: CustomEvent): void
    // 被渲染
    beforemount?(e: CustomEvent): void
    // 挂载
    mounted?(e: CustomEvent): void
    // 卸载
    unmount?(e: CustomEvent): void
    // 出错
    error?(e: CustomEvent): void
    // 下面几个用于keep-alive
    beforeshow?(e: CustomEvent): void
    aftershow?(e: CustomEvent): void
    afterhidden?(e: CustomEvent): void
  }

dispatchLifecyclesEvent

在主应用中触发生命周期事件,接收四个参数:

  1. element 元素,ShadowDOM元素或者html元素
  2. appName 微应用名称(唯一标识)
  3. lifecycleName 生命周期名称(符合上面的lifeCyclesType类定义)
  4. error 可选 错误处理函数
scss 复制代码
dispatchLifecyclesEvent (
  element: HTMLElement | ShadowRoot,
  appName: string,
  lifecycleName: LifecycleEventName,
  error?: Error,
): void {
  // element不存在报错返回
  if (!element) {
    return logError(`element does not exist in lifecycle ${lifecycleName}`, appName)
  }
  // 获取根节点
  element = getRootContainer(element)

  // 清除DOM作用域
  removeDomScope()

  const detail = assign({
    name: appName,
    container: element,
  }, error && {
    error
  })

// 自定义生命周期事件
  const event = new CustomEvent(lifecycleName, {
    detail,
  })

  formatEventInfo(event, element)
  // 全局hooks 是函数就执行
  if (isFunction(microApp.options.lifeCycles?.[lifecycleName])) {
    microApp.options.lifeCycles![lifecycleName]!(event)
  }
  // 在根节点触发事件event
  element.dispatchEvent(event)
}

dispatchCustomEventToMicroApp

微应用触发自定义事件,接收三个参数:

  1. app app实例
  2. eventName 事件名
  3. detail 上面dispatchLifecyclesEvent方法定义的detail信息
csharp 复制代码
export function dispatchCustomEventToMicroApp (
  app: AppInterface,
  eventName: string,
  detail: Record<string, any> = {},
): void {
  const event = new CustomEvent(eventName, {
    detail,
  })
 // 在沙箱上执行事件
  app.sandBox?.microAppWindow.dispatchEvent(event)
}

挂载阶段

回到micro app的初始化函数

scss 复制代码
   renderApp (options: RenderAppOptions){
    ...
       for (const attr in options) {
    ...
    // 属性是生命周期    
    else if (attr === 'lifeCycles') {
        // 取出生命周期配置
        const lifeCycleConfig = options[attr]
        if (isPlainObject(lifeCycleConfig)) {
        // 是对象就进入对象遍历
          for (const lifeName in lifeCycleConfig) {
            // 符合生命周期定义且是函数 
            if (lifeName.toUpperCase() in lifeCycles && isFunction(lifeCycleConfig[lifeName])) {
 // 对应生命周期钩子添加到事件监听             microAppElement.addEventListener(lifeName.toLowerCase(), lifeCycleConfig[lifeName])
            }
          }
        }
      } 
     }
   
   
   
   }

挂载阶段只不过是对mount和error生命周期进行了监听,执行完毕后并移除相应监听

卸载阶段

unmountApp

目前只关注对生命周期的处理,其他逻辑忽略

scss 复制代码
export function unmountApp (appName: string, options?: unmountAppOptions): Promise<boolean> {
  // 根据name拿到微应用
  const app = appInstanceMap.get(formatAppName(appName))
  // 返回promise
  return new Promise((resolve) => {
    if (app) {
      if (app.isUnmounted() || app.isPrefetch) {
        // 省略预加载处理
        if (app.isPrerender) {
         ...
      } else if (app.isHidden()) {
        // 省略hidden处理
        ...
        } else if (options?.clearAliveState) {
          // 省略clearAliveState处理
          ...
        } else {
          resolve(true)
        }
      } else {
        // 获取根节点
        const container = getRootContainer(app.container!)
        // 移除对生命周期unmount的监听
        const unmountHandler = () => {
          container.removeEventListener(lifeCycles.UNMOUNT, unmountHandler)
 // 移除对生命周期afterhidden的监听         container.removeEventListener(lifeCycles.AFTERHIDDEN, afterhiddenHandler)
          // 处理完成
          resolve(true)
        }
        // 移除监听(消除副作用)
        const afterhiddenHandler = () => {
          container.removeEventListener(lifeCycles.UNMOUNT, unmountHandler)
          container.removeEventListener(lifeCycles.AFTERHIDDEN, afterhiddenHandler)
          resolve(true)
        }
        // 添加对unmount和afterhidden的监听
        container.addEventListener(lifeCycles.UNMOUNT, unmountHandler)
        container.addEventListener(lifeCycles.AFTERHIDDEN, afterhiddenHandler)
        ...
      }
    } else {
      logWarn(`app ${appName} does not exist`)
      resolve(false)
    }
  })
}

可以看到卸载阶段只绑定了对unmount和afterhidden的事件监听,执行完成后移除相应监听

icestark生命周期

icestark有两个生命周期:

  • 微应用挂载到主应用
  • 微应用从主应用中卸载

挂载阶段

registerMicroApps用于自动注册微应用,createMicroApp用于手动注册微应用;微应用注册成功后,路由劫持,根据路由判断微应用是否激活,如果是激活的,就会进入挂载阶段

start

挂载执行的核心方法,主要做的是:

  • 配置name防重复处理
  • 更新全局配置,判断是否执行资源预加载
  • 劫持history和eventListener
  • 根据loadScriptMode确定js使用哪种方式加载,加载css/js/图片等微应用资源
scss 复制代码
function start(options?: StartConfiguration) {
  if (options?.shouldAssetsRemove && !temporaryState.shouldAssetsRemoveConfigured) {
    temporaryState.shouldAssetsRemoveConfigured = true;
  }

  // icestark只能被start一次(防止重复启动)
  if (started) {
    console.log('icestark has been already started');
    return;
  }
  // 启动状态置为true
  started = true;

  recordAssets();

  // update globalConfiguration  更新全局配置
  globalConfiguration.reroute = reroute;
  Object.keys(options || {}).forEach((configKey) => {
    globalConfiguration[configKey] = options[configKey];
  });

  const { prefetch, fetch } = globalConfiguration;
  // 配置了prefetch字段且为true
  if (prefetch) {
    // 执行预加载
    doPrefetch(getMicroApps(), prefetch, fetch);
  }

  // 劫持history和eventListener
  hijackHistory();
  hijackEventListener();

  // 触发路由初始化
  globalConfiguration.reroute(location.href, 'init');
}

loadScriptByImport

loadScriptMode为import时使用这个方法

根据js类型执行不同逻辑:

  • js类型为INLINE时,创建<script>标签动态添加到根节点的<head>中
  • js类型为EXTERNAL,即为外部引入,使用动态引入的方法加载js资源

最终这个方法会返回mount(挂载阶段)和unmount(卸载阶段)的生命周期钩子

javascript 复制代码
export async function loadScriptByImport(jsList: Asset[]): Promise<null | ModuleLifeCycle> {
  // 定义挂载和卸载执行的操作
  let mount = null;
  let unmount = null;
  // asyncForEach用于将js文件列表遍历执行
  await asyncForEach(jsList, async (js, index) => {
    // js类型为INLINE
    if (js.type === AssetTypeEnum.INLINE) {
      // 执行appendExternalScript
      await appendExternalScript(js, {
        id: `${PREFIX}-js-module-${index}`,
      });
    } else {
      let dynamicImport = null;
      try {
        // 兼容IE 
        dynamicImport = new Function('url', 'return import(url)');
      } catch (e) {
        return Promise.reject(
          new Error(
            formatErrMessage(
              ErrorCode.UNSUPPORTED_IMPORT_BROWSER,
              isDev && 'You can not use loadScriptMode = import where dynamic import is not supported by browsers.',
            ),
          ),
        );
      }

      try {
        if (dynamicImport) {
          // 根据上面兼容IE的处理返回mount和unmount时应该进行的操作
          const { mount: maybeMount, unmount: maybeUnmount } = await dynamicImport(js.content);

          if (maybeMount && maybeUnmount) {
            mount = maybeMount;
            unmount = maybeUnmount;
          }
        }
      } catch (e) {
        return Promise.reject(e);
      }
    }
  });

  // 导出mount和unmount方法 
  if (mount && unmount) {
    return {
      mount,
      unmount,
    };
  }

  return null;
}

loadScriptByFetch

loadScriptMode为fetch时使用这个方法,实现功能:通过加载fetch请求获取js资源

处理js bundle的情况

scss 复制代码
export function loadScriptByFetch(jsList: Asset[], sandbox?: Sandbox, fetch = window.fetch) {
  //fetchScripts 通过Promise.all获取js资源
  return fetchScripts(jsList, fetch)
    .then((scriptTexts) => {
      // 获取window对象
      const globalwindow = getGobalWindow(sandbox);

      const libraryExport = executeScripts(scriptTexts, sandbox, globalwindow);

      let moduleInfo = getLifecyleByLibrary() || getLifecyleByRegister();
      if (!moduleInfo) {
        moduleInfo = (libraryExport ? globalwindow[libraryExport] : {}) as ModuleLifeCycle;
        
        // 
        if (globalwindow[libraryExport]) {
          delete globalwindow[libraryExport];
        }
      }
      // 返回js bundle信息
      return moduleInfo;
    });
}

卸载阶段

这个阶段主要功能:

  • 解绑history和eventListener
  • 如果开启了沙箱,变量location还原
  • 清除微应用的所有资源
  • 将微应用缓存清空(如果调用了removeMicroApps)
scss 复制代码
function unload() {
  // 解绑eventListener/history
  unHijackEventListener();
  unHijackHistory();
  // 重置started为false
  started = false;
  // 清除微应用的所有资源
  // remove all assets added by micro apps
  emptyAssets(globalConfiguration.shouldAssetsRemove, true);
  clearMicroApps();
}

wujie生命周期

基本使用

wujie提供的生命周期有:

  • beforeLoad

子应用加载静态资源前触发

  • beforeMount

子应用渲染前触发

  • aferMount

子应用渲染后触发

  • beforeUnmount

子应用卸载前触发

  • afterUnmount

子应用卸载后触发

  • activated

子应用保活模式下激活触发(路由进入),类似于keep-alive,在保活模式下子应用实例和web component都不会被销毁,子应用的状态和路由也不会丢失,适用于不同的子应用切换时不想做生命周期改造或者需要减少白屏时间的情况

  • deactivated

子应用保活模式下失活触发(路由离开)

  • loadError

子应用加载资源失败后触发

原理

挂载

在核心APIstartApp中有处理生命周期

scss 复制代码
export async function startApp(startOptions: startOptions): Promise<Function | void> {
  // 根据name获取微应用
  const sandbox = getWujieById(startOptions.name);
  // 根据name获取配置项
  const cacheOptions = getOptionsById(startOptions.name);
  // 合并缓存配置
  const options = mergeOptions(startOptions, cacheOptions);
  const {
    name,
    url,
    html,
    replace,
    fetch,
    props,
    attrs,
    degradeAttrs,
    fiber,
    alive,
    degrade,
    sync,
    prefix,
    el,
    loading,
    plugins,
    lifecycles,
  } = options;
  }
    // 已经初始化过的应用,快速渲染
  if (sandbox) {
    // 获取插件列表
    sandbox.plugins = getPlugins(plugins);
    // 获取生命周期
    sandbox.lifecycles = lifecycles;
    // 拿到iframe中的window对象
    const iframeWindow = sandbox.iframe.contentWindow;
    // 执行预加载
    if (sandbox.preload) {
      await sandbox.preload;
    }
    if (alive) {
      // 保活
      await sandbox.active({ url, sync, prefix, el, props, alive, fetch, replace });
      // 预加载但是没有执行的情况
      if (!sandbox.execFlag) {
        // 执行beforeLoad生命周期
        sandbox.lifecycles?.beforeLoad?.(sandbox.iframe.contentWindow);
        // 获取js脚本 
        const { getExternalScripts } = await importHTML({
          url,
          html,
          opts: {
            fetch: fetch || window.fetch,
            plugins: sandbox.plugins,
            loadError: sandbox.lifecycles.loadError,
            fiber,
          },
        });
        // start启动沙箱
        await sandbox.start(getExternalScripts);
      }
      // 执行activated生命周期
      sandbox.lifecycles?.activated?.(sandbox.iframe.contentWindow);
      return sandbox.destroy;
    } else if (isFunction(iframeWindow.__WUJIE_MOUNT)) {
      /**
       * 子应用切换会触发webcomponent的disconnectedCallback调用sandbox.unmount进行实例销毁
       * 此处是防止没有销毁webcomponent时调用startApp的情况,需要手动调用unmount
       */
      sandbox.unmount();
      await sandbox.active({ url, sync, prefix, el, props, alive, fetch, replace });
      // 正常加载的情况,先注入css,最后才mount。重新激活也保持同样的时序
      sandbox.rebuildStyleSheets();
      // 有渲染函数 执行beforeMount
      sandbox.lifecycles?.beforeMount?.(sandbox.iframe.contentWindow);
      iframeWindow.__WUJIE_MOUNT();
      // 执行afterMount
      sandbox.lifecycles?.afterMount?.(sandbox.iframe.contentWindow);
      sandbox.mountFlag = true;
      return sandbox.destroy;
    } else {
      // 没有渲染函数
      sandbox.destroy();
    }
  }

从这一大段代码中,可以梳理出:

  1. 当微应用预加载但是没执行时,就会触发beforeLoad生命周期钩子,加载css和js资源,如果资源加载失败会触发loadError
  2. 配置了alive属性为true时,进入保活模式,步骤1执行后资源加载完成,进入activated阶段
  3. 子应用切换时,如果是保活模式就恢复样式,触发beforeMount,新的子应用挂载后触发afterMount

卸载

在unmount方法会执行卸载相关的生命周期钩子

kotlin 复制代码
public unmount(): void {
    this.activeFlag = false;
    // 清理子应用过期的同步参数
    clearInactiveAppUrl();
    // 保活模式执行deactivated
    if (this.alive) {
      this.lifecycles?.deactivated?.(this.iframe.contentWindow);
    }
    // 防止重复调用
    if (!this.mountFlag) return;
    if (isFunction(this.iframe.contentWindow.__WUJIE_UNMOUNT) && !this.alive && !this.hrefFlag) {
      this.lifecycles?.beforeUnmount?.(this.iframe.contentWindow);
      this.iframe.contentWindow.__WUJIE_UNMOUNT();
      this.lifecycles?.afterUnmount?.(this.iframe.contentWindow);
      this.mountFlag = false;
      this.bus.$clear();
      if (!this.degrade) {
        clearChild(this.shadowRoot);
        // head body需要复用,每次都要清空事件
        removeEventListener(this.head);
        removeEventListener(this.body);
      }
      clearChild(this.head);
      clearChild(this.body);
    }
  }

主要流程:

  1. unmount执行子应用卸载,如果配置了保活模式,执行deactivated生命周期钩子
  2. 执行beforeUnmount钩子,卸载子应用皇后执行afterUnmount钩子

路由系统

vue的路由系统分为history模式和hash模式

history模式

history模式允许我们通过JavaScript操作浏览器的历史记录,所有浏览过的页面会记录在一个栈中,可以通过浏览器的前进、后退、刷新导航

原理是通过监听HTML5的History API的popchange事件

javascript 复制代码
window.addEventListener('popchange',(event)=>{
    console.log('event',event)
})
window.history.pushState(state,title,url) // 不刷新界面向浏览记录栈添加一条新的记录
window.history.replaceState(state,title,url) // 不刷新界面替换地址,不会在浏览记录栈添加新的项,当前地址将会替换原先的地址
window.history.back() // 不刷新界面的前提下回到上一个页面(基于浏览记录)
window.history.forward() // 不刷新界面的前提下前进到下一个页面(基于浏览记录)
window.history.go() // 传入正数等于于history.forward(),负数等价于history.back(),传入0则会刷新当前页面

pushState 接收三个参数:

  • state state对象用于传值
  • title 跳转页面的标题
  • url 跳转地址

比如当前页面是localhost:3000/home,执行

javascript 复制代码
window.history.pushState({
 title:"测试"
},'null','test')

页面将会跳转到localhost:3000/test

replaceState

参数同pushState,当前页面时localhost:3000/test,执行

javascript 复制代码
window.history.replaceState({
 title:"测试2"
},'null','test2')

页面将会被替换为localhost:3000/test2,history.length不会发生变化

onpopstatechange事件

pushState和replaceState不会触发onpopstatechange事件,其他方法会触发,比如调用histo的forward()/back()/go()方法

hash模式

原理是监听window.onhashchange事件

掌握了路由模式,就掌握了微前端的路由跳转原理,Vue/React的路由跳转原理也是一样的

qiankun路由

qiankun的路由继承的single-spa

基本使用

主应用跳转子应用

可以直接通过主应用的路由系统跳转,在主应用中注册路由暴露微应用

子应用使用的hash模式: 使用location.hash区分不同子应用

javascript 复制代码
const getActiveRule = (hash) => (location) => location.hash.startsWith(hash);
registerMicroApps([
  {
    name: 'microApp1', // 微应用的唯一标识
    entry: 'http://localhost:8080',// 微应用入口
    container: '#container', // 挂载微应用的DOM节点
    activeRule: getActiveRule('#/microApp1'),
    // 这里也可以直接写 activeRule: '#/microApp1',但是如果主应用是 history 模式或者主应用部署在非根目录,这样写不会生效。
  },
]);

子应用使用的history模式:使用location.pathname区分不同子应用,子应用设置路由base

php 复制代码
registerMicroApps([
  {
    name: 'microApp1',
    entry: 'http://localhost:8080',
    container: '#container',
    activeRule: '/microApp1',
  },
]);

子应用跳转主应用

支持以下方法:

  • history.pushState()

只适用于history模式,切换时只有url改变了,不会触发页面刷新

  • window.location.href跳转

这种方式可能会出现页面加载时间长,白屏的问题

  • 将主应用实例暴露给子应用
ini 复制代码
const parentRoute = ['/main']; //主应用路由
const isParentRoute = (path) => parentRoute.some(item => path.startsWith(item));
const rawAppendChild = HTMLHeadElement.prototype.appendChild;
const rawAddEventListener = window.addEventListener;
router.beforeEach((to, from, next) => {
    // 从子应用跳转到主应用
    //当前路由属于子应用,跳转主应用,还原对象appendChild,addEventListener
    if (!isParentRoute(from.path) && isParentRoute(to.path)) {
        HTMLHeadElement.prototype.appendChild = rawAppendChild;
        window.addEventListener = rawAddEventListener;
    }

这种方式跳转会出现主应用css未加载的情况,原因是qiankun的沙箱隔离机制,在子应用中移除了主应用的css和事件监听方法,只有在子应用卸载完成才会还原主应用的css和事件监听方法,因此需要额外处理

子应用互相跳转

支持三种方法:

  • history.pushState()
  • 使用a链接跳转,拼接完整地址
ini 复制代码
<a href="http://localhost:8080/microApp1">微应用1</a>
  • 修改location href跳转
ini 复制代码
window.location.href='http://localhost:8080/microApp1'

原理

通过监听hashchange和popstate事件获取路由的变化

ini 复制代码
const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};
export const routingEventsListeningTo = ["hashchange", "popstate"];

  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

为了兼容IE浏览器,还定义了navigateToUrl处理路由跳转

csharp 复制代码
export function navigateToUrl(obj) {
  let url;
  // obj是字符串,直接跳转路径
  if (typeof obj === "string") {
    url = obj;
  } else if (this && this.href) {
    // 取href属性作为跳转路径
    url = this.href;
  } else if (
    obj &&
    obj.currentTarget &&
    obj.currentTarget.href &&
    obj.preventDefault
  ) {
    url = obj.currentTarget.href;
    obj.preventDefault();
  } else {
    throw Error(
      formatErrorMessage(
        14,
        __DEV__ &&
          `singleSpaNavigate/navigateToUrl must be either called with a string url, with an <a> tag as its context, or with an event whose currentTarget is an <a> tag`
      )
    );
  }

在single-spa中查看start方法

ini 复制代码
// 判断是否已经执行过start
let started = false;

export function start(opts) {
  // 避免重复调用
  started = true;
  if (isInBrowser) {
    // 在浏览器环境才执行
    patchHistoryApi(opts);
    reroute();
  }
}

怎么判断是否在浏览器环境呢?很简单,只要window属性存在即可(node环境判断global属性存在即可)

ini 复制代码
const isInBrowser = typeof window !== "undefined";

上述的start主要执行了两个方法:

  • patchHistoryApi
  • reroute

patchHistoryApi 这个方法主要是劫持hashchange/popstate/addEventListener/removeEventListener事件

reroute方法主要逻辑:

  1. 监听路由的变化触发路由导航
  2. 根据不同生命周期执行不同逻辑
javascript 复制代码
export function reroute(
  pendingPromises = [],
  eventArguments,
  silentNavigation = false
) {
  // appChangeUnderway是一个boolean变量,初始值为false 
  if (appChangeUnderway) {
    // 为true表示目前触发了appchange
    // 返回一个promise
    return new Promise((resolve, reject) => {
      // peopleWaitingOnAppChange是一个数组
      peopleWaitingOnAppChange.push({
        resolve,// 成功回调
        reject,// 失败回调
        eventArguments,// 事件参数
      });
    });
  }

  ...
  // 获取微应用的四种状态(注销/卸载/加载/挂载)
  const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
    getAppChanges();
  let appsThatChanged,
    cancelPromises = [],
    // 旧的url(记录用于路由回退) window.location.href
    oldUrl = currentUrl,
    // 新的url
    newUrl = (currentUrl = window.location.href);

  if (isStarted()) {
    // 执行了start方法后走这段逻辑
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }

  // 取消导航
  function cancelNavigation(val = true) {
    const promise =
      typeof val?.then === "function" ? val : Promise.resolve(val);
    cancelPromises.push(
      promise.catch((err) => {
        ...
        console.warn(err);

        // 返回false说明导航被取消
        return false;
      })
    );
  }

  // 加载app
  function loadApps() {
    return Promise.resolve().then(() => {
      const loadPromises = appsToLoad.map(toLoadPromise);
      let succeeded;

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

            return [];
          })
          .catch((err) => {
            if (__PROFILE__) {
              succeeded = false;
            }

            callAllEventListeners();
            throw err;
          })
          ...
      );
    });
  }

  function performAppChanges() {
    return Promise.resolve().then(() => {
      // 根据appsThatChanged长度判断触发哪种自定义事件
      // 长度为0,触发before-no-app-change,否则触发before-app-change
      fireSingleSpaEvent(
        appsThatChanged.length === 0
          ? "before-no-app-change"
          : "before-app-change",
        getCustomEventDetail(true)
      );
      
      // 触发spa自定义事件before-routing-event
      fireSingleSpaEvent(
        "before-routing-event",
        getCustomEventDetail(true, { cancelNavigation })
      );

      return Promise.all(cancelPromises).then((cancelValues) => {
        // 是否取消导航
        const navigationIsCanceled = cancelValues.some((v) => v);

        if (navigationIsCanceled) {
          // 取消导航就回到之前的旧url
          originalReplaceState.call(
            window.history,
            history.state,
            "",
            oldUrl.substring(location.origin.length)
          );

          // 更新新url
          currentUrl = location.href;

          // necessary for the reroute function to know that the current reroute is finished
          appChangeUnderway = false;

         ...
          
          // 重新导航
          return reroute(pendingPromises, eventArguments, true);
        }

        const unloadPromises = appsToUnload.map(toUnloadPromise);

        const unmountUnloadPromises = appsToUnmount
          .map(toUnmountPromise)
          .map((unmountPromise) => unmountPromise.then(toUnloadPromise));

        const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);

        const unmountAllPromise = Promise.all(allUnmountPromises);

        let unmountFinishedTime;

        unmountAllPromise.then(
          () => {
            if (__PROFILE__) {
              unmountFinishedTime = performance.now();

              addProfileEntry(
                "routing",
                "unmountAndUnload",
                profilerKind,
                startTime,
                performance.now(),
                true
              );
            }
            // 触发spa自定义事件before-mount-routing-event
            fireSingleSpaEvent(
              "before-mount-routing-event",
              getCustomEventDetail(true)
            );
          },
          ...

        /* We load and bootstrap apps while other apps are unmounting, but we
         * wait to mount the app until all apps are finishing unmounting
         */
        const loadThenMountPromises = appsToLoad.map((app) => {
          return toLoadPromise(app).then((app) =>
            tryToBootstrapAndMount(app, unmountAllPromise)
          );
        });

        /* These are the apps that are already bootstrapped and just need
         * to be mounted. They each wait for all unmounting apps to finish up
         * before they mount.
         */
        const mountPromises = appsToMount
          .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
          .map((appToMount) => {
            return tryToBootstrapAndMount(appToMount, unmountAllPromise);
          });
        return unmountAllPromise
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
          .then(() => {
            /* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
             * events (like hashchange or popstate) should have been cleaned up. So it's safe
             * to let the remaining captured event listeners to handle about the DOM event.
             */
            callAllEventListeners();

            return Promise.all(loadThenMountPromises.concat(mountPromises))
              .catch((err) => {
                pendingPromises.forEach((promise) => promise.reject(err));
                throw err;
              })
              .then(finishUpAndReturn)
              .then(
                () => {
                  if (__PROFILE__) {
                    addProfileEntry(
                      "routing",
                      "loadAndMount",
                      profilerKind,
                      unmountFinishedTime,
                      performance.now(),
                      true
                    );
                  }
                },
                (err) => {
                  if (__PROFILE__) {
                    addProfileEntry(
                      "routing",
                      "loadAndMount",
                      profilerKind,
                      unmountFinishedTime,
                      performance.now(),
                      false
                    );
                  }

                  throw err;
                }
              );
          });
      });
    });
  }

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

    try {
      const appChangeEventName =
        appsThatChanged.length === 0 ? "no-app-change" : "app-change";
      fireSingleSpaEvent(appChangeEventName, getCustomEventDetail());
      fireSingleSpaEvent("routing-event", getCustomEventDetail());
    } catch (err) {
      
      setTimeout(() => {
        throw err;
      });
    }

    appChangeUnderway = false;

    if (peopleWaitingOnAppChange.length > 0) {
      // 我们在重新导航时别人触发了reroute会先排队,后面还要执行reroute 
      const nextPendingPromises = peopleWaitingOnAppChange;
      peopleWaitingOnAppChange = [];
      reroute(nextPendingPromises);
    }

    return returnValue;
  }
    ...
  
}

micro app路由

基本使用

详见micro-zoe.github.io/micro-app/d...

原理

updateMicroLocation

更新微应用路由,触发更新的条件:从路由守卫中拿到from和to,from和to的fullPath不同,最终执行的是executeNavigationGuard方法

typescript 复制代码
export function updateMicroLocation (
  appName: string, // 微应用name
  path: string, // 路径
  microLocation: MicroLocation, // 微应用路由
  type?: string, // 类型
): void {
  // 从 `from`中记录旧路径
  const from = createGuardLocation(appName, microLocation)
  // 如果在iframeSandbox中, microLocation muse be rawLocation of iframe, 不能是proxyLocation代理路径
  const newLocation = createURL(path, microLocation.href)
  if (isIframeSandbox(appName)) {
    const microAppWindow = appInstanceMap.get(appName)!.sandBox.microAppWindow
    microAppWindow.rawReplaceState?.call(microAppWindow.history, getMicroState(appName), '', newLocation.href)
  } else {
    // 更新路由信息 
    for (const key of locationKeys) {
      microLocation.self[key] = newLocation[key]
    }
  }
  // 从`to`拿到更新的路由
  const to = createGuardLocation(appName, microLocation)

  //当fullPath发生变化会触发这个钩子
  if (type === 'auto' || (from.fullPath !== to.fullPath && type !== 'prevent')) {
    // 执行路由跳转 
    executeNavigationGuard(appName, to, from)
  }
}

executeNavigationGuard

这个方法主要用于更新路由

scss 复制代码
function executeNavigationGuard (
    appName: string,
    to: GuardLocation,
    from: GuardLocation,
  ): void {
    // 设置要跳转的路由 to 
    router.current.set(appName, to)
    // 执行beforeEach路由守卫 
    runGuards(appName, to, from, beforeGuards.list())
    // 浏览器空闲时执行afterEach路由守卫
    requestIdleCallback(() => {
      runGuards(appName, to, from, afterGuards.list())
    })
  }

icestark路由

基本使用

主应用技术栈:vue3/vite/vue-router4

微应用技术栈:vue3/vite/vue-router4

主应用

基座、通用组件放在主应用中处理,在主应用中定义路由用于挂载微应用,主应用中路由定义如下:

typescript 复制代码
//router/index.ts
import { createRouter,createWebHashHistory,type RouteRecordRaw } from "vue-router";
import Home from  "@/views/Home/index";
import Entrust from "@/views/Entrust/index"
import Lowcode from "@/views/Lowcode/index"
const routes:Array<RouteRecordRaw> = [
    {
        path:'/',
        name:'Home',
        component:Home // 主应用
    },
    {
        path:'/entrust',
        name:'Entrust',
        component:Entrust // 主应用
    },
    {
        path:'/lowcode',
        name:'Lowcode',
        component:Lowcode// 微应用
    }
];

Lowcode主要逻辑如下:

  1. 定义一个节点用于挂载微应用
  2. 在vue组件挂载阶段手动引入微应用,通过start启动
  3. 在vue组件卸载阶段手动移除微应用
xml 复制代码
// Lowcode/index.vue
<template>
    <div id="lc-container"></div>
</template>

<script setup lang="ts">
  import {onMounted, onUnmounted} from 'vue';
  import {useRoute} from 'vue-router';
//   import { registerMicroApps,start } from '@ice/stark';// react使用这种引入方式,vue中引入会报错 could not resolve 'react'
import start from '@ice/stark/lib/start';
import {
  createMicroApp,
  unmountMicroApp
} from '@ice/stark/lib/apps';
  
  onMounted(()=>{
    let container:HTMLElement|null = document.getElementById('lc-container')
    const route = useRoute() // vue3的setup不能访问this,因此通过useRoute拿到主应用路由信息
    createMicroApp({
        name:'lowcode-micro',// 微应用的唯一标识
        title:'微应用vue3',// 页面标题
        entry:'http://localhost:3000/', // 微应用对应的 html 入口,当渲染微应用时,会通过 `window.fetch` 将 html 内容获取过来,然后 `append` 至动态创建的节点
        loadScriptMode:'import',// 加载js文件模式,支持fetch/script/import选项,import表明使用esmodoule加载文件(因为微应用使用了vite)
        container,// 挂载微应用的节点
        // props存放主应用传给微应用的数据(非响应式)
        props:{
            name:'from icestark',
            route // 将主应用路由信息传递给微应用
        }
    })
    start({
        onAppEnter(){
            console.log('micro enter')
        },
        onAppLeave(){
            console.log('micro leave')
        },
        onRouteChange(_,pathname){}
    })
  })
  onUnmounted(()=>{
        // 从节点上移除微应用
        unmountMicroApp('lowcode-micro').then(()=>{
            console.log('低代码已移除')
        })
  })
</script>

主应用跳转子应用路由

方法一: 可以使用vue-router的API实现

// 在主应用跳转到子应用时,调用

javascript 复制代码
import {useRouter} from "vue-router"
const router = useRouter()
const goMicro = () => {
  router.push('/lowcode') // 上面微应用的path
}

点击对应微应用菜单时,通过路由劫持触发微应用挂载

子应用跳转主应用路由

使用appHistory跳转

arduino 复制代码
appHistory.push('/home')

原理

appHistory是如何实现的?

实质上通过路由劫持全局的popchange事件,在路由发生了前进/后退/刷新

ini 复制代码
// 监听两种事件:popstate/hashchange
export enum CapturedEventNameEnum {
  POPSTATE = 'popstate',
  HASHCHANGE = 'hashchange',
}
const capturedEventListeners = {
  [CapturedEventNameEnum.POPSTATE]: [],
  [CapturedEventNameEnum.HASHCHANGE]: [],
};

export function callCapturedEventListeners() {
  if (historyEvent) {
    Object.keys(capturedEventListeners).forEach((eventName) => {
      const capturedListeners = capturedEventListeners[eventName];
      if (capturedListeners.length) {
        capturedListeners.forEach((listener) => {
          listener.call(this, historyEvent);
        });
      }
    });
    historyEvent = null;
  }
}
const appHistory: AppHistory = {
  push: (path: string) => {
    window.history.pushState({}, null, path);
  },
  replace: (path: string) => {
    window.history.replaceState({}, null, path);
  },
};

wujie路由

基本使用

具体使用查看官网示例,不再赘述 wujie-micro.github.io/doc/guide/j...

原理

同步子应用到主应用

syncUrlToWindow

使用的history api的replaceState实现的路由跳转,触发条件: 需要跳转的路由不等于当前路由(window.location.href)

javascript 复制代码
function syncUrlToWindow(iframeWindow: Window): void {
  const { sync, id, prefix } = iframeWindow.__WUJIE;
  let winUrlElement = anchorElementGenerator(window.location.href);
  // 获取查询参数map对象
  const queryMap = getAnchorElementQueryMap(winUrlElement);
  // 非同步且url上没有当前id的查询参数,否则就要同步参数或者清理参数
  if (!sync && !queryMap[id]) return (winUrlElement = null);
  // 路由拼接处理 处理成http://localhost:8080/microApp?name=lyllovelemon/#/的格式
  const curUrl = iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;
  let validShortPath = "";
  // 处理短路径
  if (prefix) {
    Object.keys(prefix).forEach((shortPath) => {
      const longPath = prefix[shortPath];
      // 找出最长匹配路径
      if (curUrl.startsWith(longPath) && (!validShortPath || longPath.length > prefix[validShortPath].length)) {
        validShortPath = shortPath;
      }
    });
  }
  // 同步
  if (sync) {
    queryMap[id] = window.encodeURIComponent(
      validShortPath ? curUrl.replace(prefix[validShortPath], `{${validShortPath}}`) : curUrl
    );
    // 清理
  } else {
    delete queryMap[id];
  }
  // 拼接查询参数
  const newQuery =
    "?" +
    Object.keys(queryMap)
      .map((key) => key + "=" + queryMap[key])
      .join("&");
  winUrlElement.search = newQuery;
  // 如果需要处理的路由不等于当前路由 需要跳转
  if (winUrlElement.href !== window.location.href) {
   // 使用replaceState跳转(history模式使用)
    window.history.replaceState(null, "", winUrlElement.href);
  }
  // 跳转后清空需要处理的路由
  winUrlElement = null;
}

同步主应用跳转到子应用

syncUrlToIframe 跟上面一样,使用history api的replaceState实现路由跳转

javascript 复制代码
export function syncUrlToIframe(iframeWindow: Window): void {
  // 获取当前路由路径
  const { pathname, search, hash } = iframeWindow.location;
  const { id, url, sync, execFlag, prefix, inject } = iframeWindow.__WUJIE;

  // 只在浏览器刷新或者第一次渲染时同步
  const idUrl = sync && !execFlag ? getSyncUrl(id, prefix) : url;
  // 排除href跳转情况
  const syncUrl = (/^http/.test(idUrl) ? null : idUrl) || url;
  const { appRoutePath } = appRouteParse(syncUrl);

  const preAppRoutePath = pathname + search + hash;
  if (preAppRoutePath !== appRoutePath) {
    iframeWindow.history.replaceState(null, "", inject.mainHostPath + appRoutePath);
  }
}

location.href情况路由处理

processAppForHrefJump

监听popstate的变化

scss 复制代码
export function processAppForHrefJump(): void {
  window.addEventListener("popstate", () => {
    // 获取url元素
    let winUrlElement = anchorElementGenerator(window.location.href);
    // 查询参数map
    const queryMap = getAnchorElementQueryMap(winUrlElement);
    // 清空旧路由
    winUrlElement = null;
    // 遍历查询参数的key
    Object.keys(queryMap)
      // 拿到微应用id
      .map((id) => getWujieById(id))
      // 只保留沙箱模式下的微应用路由查询参数
      .filter((sandbox) => sandbox)
      .forEach((sandbox) => {
        // 拿到url 
        const url = queryMap[sandbox.id];
        // iframe的body
        const iframeBody = rawDocumentQuerySelector.call(sandbox.iframe.contentDocument, "body");
        // 前进href
        if (/http/.test(url)) {
          if (sandbox.degrade) {
            renderElementToContainer(sandbox.document.documentElement, iframeBody);
            renderIframeReplaceApp(
              window.decodeURIComponent(url),
              getDegradeIframe(sandbox.id).parentElement,
              sandbox.degradeAttrs
            );
          } else
            renderIframeReplaceApp(
              window.decodeURIComponent(url),
              sandbox.shadowRoot.host.parentElement,
              sandbox.degradeAttrs
            );
          sandbox.hrefFlag = true;
          // href后退
        } else if (sandbox.hrefFlag) {
          if (sandbox.degrade) {
            // 走全套流程,但是事件恢复不需要
            const { iframe } = initRenderIframeAndContainer(sandbox.id, sandbox.el, sandbox.degradeAttrs);
            patchEventTimeStamp(iframe.contentWindow, sandbox.iframe.contentWindow);
            iframe.contentWindow.onunload = () => {
              sandbox.unmount();
            };
            iframe.contentDocument.appendChild(iframeBody.firstElementChild);
            sandbox.document = iframe.contentDocument;
          } else renderElementToContainer(sandbox.shadowRoot.host, sandbox.el);
          sandbox.hrefFlag = false;
        }
      });
  });
}

资源系统与预加载

预加载是指在子应用尚未渲染时提前加载静态资源,从而提升子应用的首次渲染速度。

为了不影响主应用的性能,预加载会在浏览器空闲时间执行。

qiankun

qiankun通过import-html-entry实现资源加载(import-html-entry是一个三方库)

资源预加载

首先使用requestIdleCallback,不支持上述方法就使用MessageChannel降级处理,不支持MesageChannel才会用setTimeout实现,传入的是一个任务队列(先进先出)

requestIdlleCallback指在浏览器空闲的时候执行,适合执行耗时长但优先级不高的任务

由此引出问题,MessageChannel为什么比setTimeout实现要好?

setTimeout(callback,timeout)接收两个参数,第一个是定时器到期后要执行的回调函数,第二个是执行回调前要等待的时间(单位:毫秒),但是它实际延迟的时间可能会比预期时间长。

当浏览器执行其他优先级更高的任务时,超时会比预期时间长,setTimeout会被加入宏任务队列,每调用一次的最少时延为4毫秒,而MessageChannel不会有时延的问题,因此我们优先使用MessageChannel

scss 复制代码
// 预加载
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  if (!navigator.onLine || isSlowNetwork) {
    // 离线或弱网环境不执行预加载
    return;
  }

  requestIdleCallback(async () => {
   // 获取外部script和外部style
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
    // 在浏览器空闲时执行上面两个方法
    requestIdleCallback(getExternalStyleSheets);
    requestIdleCallback(getExternalScripts);
  });
}

从这里我们可以学到怎么判断弱网环境

go 复制代码
const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData ||
    (navigator.connection.type !== 'wifi' &&
      navigator.connection.type !== 'ethernet' &&
      /([23])g/.test(navigator.connection.effectiveType))
  : false;

navigator.connection.effectiveType用于检测用户的网络连接质量,返回值有slow-2g/2g/3g/4g,上面写的判断条件是2g/3g时属于弱网

navigator.connection.saveData 返回一个boolean值,当用户在user agent中设置了保存数据就会返回true

navigator.onchange用于检测网络的变化

navigator.connection.type 返回联网的设备类型,返回值有:

  • "bluetooth" 蓝牙连接
  • "cellular" 手机
  • "ethernet" 以太网
  • "none" 没有联网
  • "wifi" wifi
  • "wimax" 和wifi不同的其他无线网
  • "other" 其他
  • "unknown" 未知

核心方法importEntry

importEntry接收两个参数:

  • entry 需要解析的html模板路径
  • opts 配置项,默认为空对象

其中opts配置项包含:

  • fetch window.fetch方法,支持自定义,用于获取远程的脚本/样式资源
  • getPublicPath 用于路径转换,将相对路径转换为绝对路径
  • getDomain getPublicPath不存在时使用
  • getTemplate 获取模板字符串
  • postProcessTemplate 处理模板
javascript 复制代码
export function importEntry(entry, opts = {}) {
	const { fetch = defaultFetch, getTemplate = defaultGetTemplate, postProcessTemplate } = opts;
	const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;

	if (!entry) {
		throw new SyntaxError('entry should not be empty!');
	}

	// html entry
	if (typeof entry === 'string') {
		return importHTML(entry, {
			fetch,
			getPublicPath,
			getTemplate,
			postProcessTemplate,
		});
	}
        ...

importHTML

ini 复制代码
export default function importHTML(url, opts = {}) {
// 省略一些变量处理...
if (typeof opts === 'function') {
    fetch = opts;
	} else {
    // fetch 选项可选
    if (opts.fetch) {
        // fetch是函数
        if (typeof opts.fetch === 'function') {
               fetch = opts.fetch;
        } else {
        fetch = opts.fetch.fn || defaultFetch;
        // 自动编解码的处理,在charset不是utf-8时为true
        autoDecodeResponse = !!opts.fetch.autoDecodeResponse;
    }
}
    getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
    getTemplate = opts.getTemplate || defaultGetTemplate;
	}
       // html缓存处理
	return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
		.then(response => readResAsString(response, autoDecodeResponse))
		.then(html => {

			const assetPublicPath = getPublicPath(url);
			const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath, postProcessTemplate);

			return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
				template: embedHTML,
				assetPublicPath,
				getExternalScripts: () => getExternalScripts(scripts, fetch),
				getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
				execScripts: (proxy, strictGlobal, opts = {}) => {
					if (!scripts.length) {
						return Promise.resolve();
					}
					return execScripts(entry, scripts, proxy, {
						fetch,
						strictGlobal,
						...opts,
					});
				},
			}));
		}));
}

getExternalScripts 遍历处理<script>标签内容

getExternalStyleSheets 遍历处理<link>和<style>标签的内容

execScripts 执行script中的代码,返回为html入口脚本链接entry指向的模块导出对象

micro app

micro app提供了API执行预加载

microApp.preFetch

ini 复制代码
microApp.preFetch(apps: app[] | () => app[], delay?: number)
  • apps 微应用列表,支持的配置如下:
ruby 复制代码
microApp.preFetch([
 {
   name:'microApp',// 微应用名称(唯一标识)
   url:'localhost:8080/microApp/',// 应用地址
   iframe:false, // 是否开启iframe沙箱
   inlinne:false, // 是否使用内联模式运行js
   'disable-scopecss': false, // 是否关闭样式隔离,可选 'disable-sandbox': false, // 是否关闭沙盒,可选
   level:1, // 预加载等级,可选(分为三个等级:1、2、3,1表示只加载资源,2表示加载并解析,3表示加载解析并渲染,默认为2)
   'default-page': '/microApp', // 指定默认渲染的页面,level为3时才会生效,可选
   'disable-patch-request': false, // 关闭子应用请求的自动补全功能,level为3时才会生效,可选 }
 }
])
  • delay 延迟执行的时间,单位为毫秒

prefetch源码

micro App的预加载也是由requestIDLECallback实现的,在不支持 requestIDLECallback时用setTimeout降级处理

scss 复制代码
export default function preFetch (apps: prefetchParamList, delay?: number): void {
  // 不是浏览器环境则报错
  if (!isBrowser) {
    return logError('preFetch is only supported in browser environment')
  }
  // delayTime 延迟执行的时间(毫秒数)
  requestIdleCallback(() => {
    const delayTime = isNumber(delay) ? delay : microApp.options.prefetchDelay
    // 降级方案用setTimeout
    setTimeout(() => {
      // releasePrefetchEffect()
      preFetchInSerial(apps)
    }, isNumber(delayTime) ? delayTime : 3000)
  })
}

fetch拿到js列表和css列表,处理后分别添加到<script>标签和<style>标签中

scss 复制代码
export function getGlobalAssets (assets: globalAssetsType): void {
  // 资源列表是对象
  if (isPlainObject(assets)) {
    // 在浏览器空闲时执行
    requestIdleCallback(() => {
       // 获取全局的js资源
      fetchGlobalResources(assets.js, 'js', sourceCenter.script)
      // 获取全局的css资源
      fetchGlobalResources(assets.css, 'css', sourceCenter.link)
    })
  }
}

icestark

icestark的预加载通过在registerMicroApps或createMicroApp中配置prefetch为true时开启,它会在requestIdleCallback浏览器空闲时执行资源的加载,从而加快首屏加载速度(icestark性能优化的重要手段)

scss 复制代码
export function doPrefetch(
  apps: MicroApp[],
  prefetchStrategy: Prefetch,
  fetch: Fetch,
) {
  // 
  const executeAllPrefetchTasks = (strategy: (app: MicroApp) => boolean) => {
    // 根据app的name的fetch字段拿到所有需要执行预加载的微应用
    getPrefetchingApps(apps)(strategy)
      .forEach(prefetchIdleTask(fetch));
  };
  // 预加载策略是数组
  if (Array.isArray(prefetchStrategy)) {
 // 遍历执行   executeAllPrefetchTasks(names2PrefetchingApps(prefetchStrategy));
    return;
  }
  // 预加载策略是函数
  if (typeof prefetchStrategy === 'function') {
    // 传入策略执行
    executeAllPrefetchTasks(prefetchStrategy);
    return;
  }
  if (prefetchStrategy) {
    // 只执行没有被加载过的微应用资源
    executeAllPrefetchTasks((app) => app.status === NOT_LOADED || !app.status);
  }
}

在不支持requestIdleCallback的情况下,使用setTimeout降级处理

wujie

预加载时主要处理css和js

getExternalStyleSheets

处理样式的核心方法,主要处理流程:

  1. 遍历样式列表,根据样式类型选择不同的处理逻辑
  2. 内联样式,将内容返回Promise
  3. 行内样式,调用getInlineCode返回结果
  4. 外部样式调用fetchAssets
javascript 复制代码
export function getExternalStyleSheets(
  styles: StyleObject[],
  fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> = defaultFetch,
  loadError: loadErrorHandler
): StyleResultList {
  return styles.map(({ src, content, ignore }) => {
    // 内联
    if (content) {
      return { src: "", contentPromise: Promise.resolve(content) };
    } else if (isInlineCode(src)) {
      // if it is inline style
      return { src: "", contentPromise: Promise.resolve(getInlineCode(src)) };
    } else {
      // external styles
      return {
        src,
        ignore,
        contentPromise: ignore ? Promise.resolve("") : fetchAssets(src, styleCache, fetch, true, loadError),
      };
    }
  });
}

getExternalScripts

处理js脚本的核心方法,主要流程:

  1. 遍历js列表,配置了async异步加载或defer延迟加载,src属性存在且不在module中,fiber存在执行requestIDLECallback,不存在就执行fetchAssets
  2. 配置了igore属性或者module、src属性同时存在,返回Promise.resolve("")
  3. src属性不存在,返回script.content作为Promise.resolve的结果(处理行内js情况)
  4. 不是上述情况则为外部js脚本,执行fetchAssets
javascript 复制代码
export function getExternalScripts(
  scripts: ScriptObject[],
  fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> = defaultFetch,
  loadError: loadErrorHandler,
  fiber: boolean
): ScriptResultList {

  // 模块需要在iframe中被请求
  return scripts.map((script) => {
    // 解构拿到下面的属性
    const { src, async, defer, module, ignore } = script;
    let contentPromise = null;
    // 配置了async异步加载或defer延迟加载,src属性存在且不在module中
    if ((async || defer) && src && !module) {
      contentPromise = new Promise((resolve, reject) =>
        fiber
          ? requestIdleCallback(() => fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject))
          : fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject)
      );
      // module || ignore
    } else if ((module && src) || ignore) {
      contentPromise = Promise.resolve("");
      // inline
    } else if (!src) {
      contentPromise = Promise.resolve(script.content);
      // outline
    } else {
      contentPromise = fetchAssets(src, scriptCache, fetch, false, loadError);
    }
    // 在module中且没有配置async属性,延迟执行script脚本
    if (module && !async) script.defer = true;
    // 返回执行结果
    return { ...script, contentPromise };
  });
}

fetchAssets

处理资源的核心方法,实际上就是执行fetch请求,拿到对应的资源列表处理结果

typescript 复制代码
const fetchAssets = (
  src: string,
  cache: Object,
  fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>,
  cssFlag?: boolean,
  loadError?: loadErrorHandler
) =>
  // 缓存资源
  cache[src] ||
  (cache[src] = fetch(src)
    .then((response) => {
      // 处理错误请求
      if (response.status >= 400) {
        cache[src] = null;
        if (cssFlag) {
          // css报错处理
          error(WUJIE_TIPS_CSS_ERROR_REQUESTED, { src, response });
          loadError?.(src, new Error(WUJIE_TIPS_CSS_ERROR_REQUESTED));
          return "";
        } else {
          // js脚本报错处理
          error(WUJIE_TIPS_SCRIPT_ERROR_REQUESTED, { src, response });
          loadError?.(src, new Error(WUJIE_TIPS_SCRIPT_ERROR_REQUESTED));
          throw new Error(WUJIE_TIPS_SCRIPT_ERROR_REQUESTED);
        }
      }
      // 没有错误就返回结果
      return response.text();
    })
    .catch((e) => {
      cache[src] = null;
      if (cssFlag) {
        error(WUJIE_TIPS_CSS_ERROR_REQUESTED, src);
        loadError?.(src, e);
        return "";
      } else {
        error(WUJIE_TIPS_SCRIPT_ERROR_REQUESTED, src);
        loadError?.(src, e);
        return "";
      }
    }));

如何防止资源重复加载

设置外部external

qiankun/micro app/icestark/wujie都支持通过打包工具配置提取公共依赖库

webpack配置示例

css 复制代码
{
  externals: {
    'vue': 'Vue',
    'vue-router': 'vueRouter'
  }
}

资源缓存

icestark样式资源缓存可以通过微应用配置实现:

  1. 开启沙箱sandbox:true
  2. loadScriptMode 为 'fetch'、'import'
  3. umd为true(兼容场景)

如果脚本资源可以通过fetch加载,基本不会存在样式资源跨域问题

多实例激活

这个目前只有qiankun能做到

参考文档

single-spa

micro app

wujie

相关推荐
Smile丶凉轩13 分钟前
微服务即时通讯系统的实现(客户端)----(3)
qt·微服务·架构
surfirst15 分钟前
【微服务设计】从理论到实践:2PC(两阶段提交)与SAGA的全面比较与示例
微服务·架构·事务处理
new出一个对象1 小时前
react+hook+vite项目使用eletron打包成桌面应用+可以热更新
前端·react.js·前端框架
GoFly开发者2 小时前
GoFly框架使用vue flow流程图组件说明
前端·vue.js·流程图·vue flow流程图
幸运小圣2 小时前
Vue3 -- 环境变量的配置【项目集成3】
前端·vue.js
如鹿觅水3 小时前
通过JS删除当前域名中的全部COOKIE教程
服务器·前端·javascript
#sakura3 小时前
web-02
前端
Lipn3 小时前
前端怎么获取视口大小
开发语言·前端·javascript
晓风伴月3 小时前
腾讯IM web版本实现迅飞语音听写(流式版)
前端·语音识别·讯飞语音听写
开心工作室_kaic4 小时前
ssm126基于HTML5的出租车管理系统+jsp(论文+源码)_kaic
java·前端·html5