likes
comments
collection
share

聊一款简单且精妙的微前端框架 ice stark(上)

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

这个系列计划分上下两篇文章,上篇主要交代文章背景和最关键的对 ice stark 整个框架的原理和运行流程进行分析。工欲善其事,必先利其器。对项目使用的技术知根知底是基本素养,了解原理才能完美应对各种 bug 场景、各种业务需求。

下篇会先从开发规范进行讲解,因为微前端目前的一些痛点都可以通过规范去约束,所以开发规范尤其地重要。然后是本地开发的相关配置和使用、生产环境的配置和使用、在开发过程中会遇到的各种问题,以及对一些扩展能力都会提供对应的分析过程和解决结果。内容比较多,在写作上耗时会较长所以放后面了。

聊一款简单且精妙的微前端框架 ice stark(上)

业务背景

公司要做一个可观测平台的产品,需要将监控领域的五大产品融合成一个端到端的全链路平台。重点是要保证五个产品能够独立进行迭代、出售的同时,在交互体验、甚至功能上融为一体。

技术选型

提到对大型系统的解耦和多个团队的协作模式,第一想法就是当今大行其道的微前端了。

微前端体系能够很好的解决了这种大型应用的分工,同时没有 iframe 那种”完全隔离“的割裂感,无论是给开发团队还是给用户都能带来不错的体验。市面上有很多的微前端框架,为什么选择 ice stark呢?主要基于以下几点

1、使用简单,子应用改造成本小。ice stark 对子应用的侵入几乎可以忽略不计。

2、同类的微前端框架(single-spa、qiankun)实现基本差不多,ice stark 的文档写的很不错

3、源码质量高,整体代码思路也特别清晰,这个时候我产生了将它 fork 下来可以由自己团队内部根据业务进行改造的心思

4、支持 vite,对于 Vue 生态的企业来说是个很棒的消息,虽然当前业务场景用不到,但后续的扩展还是方便的

5、支持微模块,对于一个大型融合应用来说,可以很方便地通过微模块来复用和共享功能组件

前端架构

整个体系的结构比较纯粹,在五个子应用能够独立迭代开发的基础上,增加一个主应用作为基座应用,连接不同子应用的功能的同时,给用户提供统一的交互感。当然,主应用给用户保持统一的交互体验感应该是这个体系中相对重要的部分。

聊一款简单且精妙的微前端框架 ice stark(上)

ice stark 简介

说起微前端,大家可能对蚂蚁的 qiankun 耳熟能详。其实在阿里内部早在微前端这个概念出现之前,就有对应的解决方案诞生了,那就是 ice stark。Ice stark 是由 ice 团队开源的一款面向大型系统的微前端解决方案,已经服务于阿里巴巴内外部300+应用

聊一款简单且精妙的微前端框架 ice stark(上)

同时具备微前端解决方案传统意义上的所有功能

聊一款简单且精妙的微前端框架 ice stark(上)

当然,最令人欣喜的是它的微模块部分:提供了很好的跨子应用共享模块的能力

聊一款简单且精妙的微前端框架 ice stark(上)

基本使用

ice stark 使用的是主应用 + 子应用的模式,子应用用于提供自身所负责的业务能力;主应用用来控制系统整体布局和配置、注册所有的微应用,连接所有子应用的同时负责给用户带来统一的视觉与交互,所以微前端体系前端有个很重要的工作就是维护统一 & 模糊主子应用之间的边界感,这个主要放在下篇讲解。

主应用的接入十分简单,只有三步:

  1. 提供一个 DOM 节点作为子应用渲染的节点

    <div id="ice-container"></div>
    
  1. 注册子应用,主要是提供子应用的服务地址、路由、应用名称等信息

    import { registerMicroApps, start } from '@ice/stark'const appContainer = document.getElementById('ice-container')
    ​
    registerMicroApps([
      {
        name: 'app1',
        activePath: ['/', '/message', '/about'],
        exact: true,
        title: '通用页面',
        container: appContainer,
        url: ['//unpkg.com/icestark-child-common/build/js/index.js'],
      }
    ])
    
  1. 运行

    start()
    

子应用的接入只要向外提供mountunmount两个生命周期函数就可以了

export function mount(props) {
    const { container } = props
    vue = new Vue({
        router,
        store,
        components: {App},
        template: '<App/>'
    }).$mount()
    container.innerHTML = ''
    container.appendChild(vue.$el)
}
​
export function unmount() {
    vue && vue.$destroy()
}

当然这两个钩子函数是在生产环境起作用的,如果想在本地开发环境运行,可以增加以下配置:

import { isInIcestark, getMountNode, registerAppEnter, registerAppLeave, setLibraryName } from '@ice/stark-app'
// isInIcestark 用于区分当前应用的运行环境
if (!isInIcestark()) {
    new Vue({
        el: '#app',
        router,
        store,
        components: {App},
        template: '<App/>'
    })
} else {
    // 获取主应用暴露出的 DOM 节点
    const mountNode = getMountNode()
    registerAppEnter(() => {
        vue = new Vue({
            router,
            store,
            components: {App},
            template: '<App/>'
        }).$mount()
        
        // 挂载前先清空下,避免被上一个子应用污染视图
        mountNode.innerHTML = ''
        mountNode.appendChild(vue.$el)
    })
​
    registerAppLeave(() => {
        vue && vue.$destroy()
    })
}

可以看到,ice stark 的核心实现就是直接将子应用的 vue 实例渲染到主应用暴露出的 DOM 节点上面去

从全局视角分析,整体流程可以大致拆分成注册子应用 => 劫持路由 => 获取子应用资源 => 执行挂载四大部分

源码 & 运行流程详解

这部分主要是对ice stark工作流程的拆解,官方给出的工作流程图如下:

聊一款简单且精妙的微前端框架 ice stark(上)

总结一下就是:通过劫持当前路由去匹配到对应微应用后,通过fetch/script/import其中一种方式获取微应用的静态资源(js、css等),然后将微应用渲染到主应用的指定区域当中

接下来,我们从源码层面对其中原理进行探讨:

一、注册子应用

上文有提到过,主应用的配置只有里简单两步:注册子应用然后调用start()方法就能运行了。首先我们就来看看注册子应用的方法registerMicroApps

export function registerMicroApps(appConfigs: AppConfig[], appLifecyle?: AppLifecylceOptions) {
  appConfigs.forEach((appConfig) => {
    registerMicroApp(appConfig, appLifecyle);
  });
}

遍历子应用的配置项,然后调用registerMicroApp对每一个子应用的配置进行处理

export function registerMicroApp(appConfig: AppConfig, appLifecyle?: AppLifecylceOptions) {
  // 通过 name 属性校验子应用是否已经被注册,已注册的话抛出异常
  if (getAppNames().includes(appConfig.name)) {
    throw Error(`name ${appConfig.name} already been regsitered`);
  }
​
  const { activePath, hashType = false, exact = false, sensitive = false, strict = false } = appConfig;
​
  // 格式化子应用需要激活的路由列表
  const activePathArray = formatPath(activePath, {
    hashType,
    exact,
    sensitive,
    strict,
  });
​
  const { basename: frameworkBasename } = globalConfiguration;
​
  // 标记当前的路由是否在该子应用路由列表中
  const findActivePath = findActivePathCurry(mergeFrameworkBaseToPath(activePathArray, frameworkBasename));
​
  const microApp = {
    // 添加子应用状态参数
    status: NOT_LOADED,
    ...appConfig,
    appLifecycle: appLifecyle,
    findActivePath,
  };
  
  // 将标准化后的子应用对象推入microApp数组
  microApps.push(microApp);
}

注册子应用的目的就是根据用户输入的子应用的配置,通过格式化路由、添加status状态标记等转换成内部使用的标准化格式,方便后续的处理。这一步对应的流程图如下:

聊一款简单且精妙的微前端框架 ice stark(上)

二、start(options)

start方法可以传入一些配置参数,包含一些常见的 Hooks 以及自定义配置:

onAppEnter?: (appConfig: AppConfig) => void;      // 微应用渲染前的回调(选填)
onAppLeave?: (appConfig: AppConfig) => void;      // 微应用卸载前的回调(选填)
onLoadingApp?: (appConfig: AppConfig) => void;    // 微应用开始加载的回调(选填)
onFinishLoading?: (appConfig: AppConfig) => void; // 微应用结束加载的回调(选填)
onError?: (err: Error) => void;                   // 微应用加载过程发生错误的回调(选填)
onActiveApps?: (appConfigs: AppConfig[]) => void; // 微应用开始被激活的回调(选填)
fetch?: Fetch;                                    // 自定义 fetch(选填)
shouldAssetsRemove?: (
  assetUrl?: string,
  element?: HTMLElement | HTMLLinkElement | HTMLStyleElement | HTMLScriptElement,
) => boolean;                                     // 判断页面资源是否持久化保留(选填)
onRouteChange?: (
  url: string,
  pathname: string,
  query: object,
  hash?: string,
  type?: RouteType | 'init' | 'popstate' | 'hashchange',
) => void;                                        // 页面路由变化会触发的钩子
prefetch?: Prefetch;                              // 预加载微应用资源(选填)
basename?: string;                                // 微应用路由匹配统一添加 basename,选填

整个start()方法很简洁,也就二十多行,按代码逻辑分,小小的start方法一共做了七件事:

样式缓存的配置保存到全局、避免重复调用、标记主应用资源、更新全局配置项、预加载子应用、路由劫持及初始化子应用

function start(options?: StartConfiguration) {
  // 1、样式缓存的配置保存到全局
  if (options?.shouldAssetsRemove && !temporaryState.shouldAssetsRemoveConfigured) {
    temporaryState.shouldAssetsRemoveConfigured = true;
  }
  
  // 2、避免重复调用
  if (started) {
    console.log('icestark has been already started');
    return;
  }
  started = true;
  
  // 3、标记主应用资源
  recordAssets();

  // 4、更新全局配置项
  globalConfiguration.reroute = reroute;
  Object.keys(options || {}).forEach((configKey) => {
    globalConfiguration[configKey] = options[configKey];
  });
  
  // 5、预加载子应用
  const { prefetch, fetch } = globalConfiguration;
  if (prefetch) {
    doPrefetch(getMicroApps(), prefetch, fetch);
  }
  
  // 6、路由劫持
  hijackHistory();
  hijackEventListener();
  
  // 7、初始化子应用
  globalConfiguration.reroute(location.href, 'init');
}
1、样式缓存的配置保存到全局

首先会判断传参中是否配置了shouldAssetsRemove,配置了就将它保存到临时的全局变量temporaryState

// See https://github.com/ice-lab/icestark/issues/373#issuecomment-971366188
// todos: remove it from 3.x
if (options?.shouldAssetsRemove && !temporaryState.shouldAssetsRemoveConfigured) {
  temporaryState.shouldAssetsRemoveConfigured = true;
}

从功能上说,shouldAssetsRemove是为了提供缓存样式的能力,但实际体验上会有样式闪烁甚至样式错乱的问题,不建议开启。注释说会在 3.x版本移除这个配置项,不过3.x版本什么时候发布咱也不敢问。

2、避免重复调用

接着会根据started的值判断是否启动过,启动过则直接 return,避免重复调用

if (started) {
  console.log('icestark has been already started');
  return;
}
started = true;

不过我觉得这里用console.warn好一点,因为出现重复调用start()方法的唯一场景应该就是代码写出问题了,警告一下比较好。

3、标记主应用资源
recordAssets();

export function recordAssets(): void {
  // getElementsByTagName is faster than querySelectorAll
  const assetsList = getAssetsNode();
  assetsList.forEach((assetsNode) => {
    setStaticAttribute(assetsNode);
  });
}

Tips

这里我们可以从源码的注释里学习到代码的优化技巧:

// getElementsByTagName is faster than querySelectorAll

”getElementsByTagName 比 querySelectorAll 快“,但是为什么呢?

因为使用getElementsByTagName方法我们得到的结果就像是一个对象的索引,而通过querySelectorAll方法我们得到的是一个对象的克隆,当对象数据量越大,克隆带来的消耗就会越大。具体可以查看这篇文章Why is getElementsByTagName() faster than querySelectorAll()?

我们来拆解下recordAssets

export const PREFIX = 'icestark';
export const DYNAMIC = 'dynamic';
export const STATIC = 'static';
​
export function getAssetsNode(): Array<HTMLStyleElement|HTMLScriptElement> {
  let nodeList = [];
  ['style', 'link', 'script'].forEach((tagName) => {
    nodeList = [...nodeList, ...Array.from(document.getElementsByTagName(tagName))];
  });
  return nodeList;
}
​
export function setStaticAttribute(tag: HTMLStyleElement | HTMLScriptElement): void {
  if (tag.getAttribute(PREFIX) !== DYNAMIC) {
    tag.setAttribute(PREFIX, STATIC);
  }
  tag = null;
}

此时,并没有加载子应用相关资源,所以recordAssets是给主应用的stylelink, script标签添加上icestark="static"的属性(icestark="dynamic"的除外),如下图所示

聊一款简单且精妙的微前端框架 ice stark(上)

因为主应用的资源是稳定的,加载完成后基本就不会变化了;相对来说,子应用的资源就属于”动态的“了

但其实根据 start 的流程执行到这一步是还没有icestark="dynamic"的元素存在的,哈哈。不过加上if (tag.getAttribute(PREFIX) !== DYNAMIC)判断应该是保证职业的严谨,后面的手动释放变量tag = null也值得我们学习。

4、更新全局配置项
// 将路由劫持方法放到全局配置对象 globalConfiguration 当中
globalConfiguration.reroute = reroute;
// 将 start 中的配置项放到全局配置对象 globalConfiguration 当中
Object.keys(options || {}).forEach((configKey) => {
  globalConfiguration[configKey] = options[configKey];
});

个人觉得这里的写法可以优化下更好一点:options 存在才遍历,而不是为了避免遍历出错而声明个空对象

options && Object.keys(options).forEach(...);
5、预加载子应用

当配置项中开启prefetch参数时,会执行预加载

const { prefetch, fetch } = globalConfiguration;
if (prefetch) {
  doPrefetch(getMicroApps(), prefetch, fetch);
}

老规矩,拆解一下这个方法 getMicroApps + doPrefetch

export function getMicroApps() {
  return microApps;
}     

getMicroApps就是去拿第一步格式化好的子应用数组,然后将子应用数组塞进doPrefetch

export function doPrefetch(
  apps: MicroApp[],
  prefetchStrategy: Prefetch,
  fetch: Fetch,
) {
  const executeAllPrefetchTasks = (strategy: (app: MicroApp) => boolean) => {
    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);
  }
}

配置 prefetch 时有三种类型选择:Boolean | string[] | Function(app),所以doPrefetch中的判断分支逻辑是针对不同类型做的处理,这里就不分开讲解了,我们只需要关注核心的executeAllPrefetchTasks方法:

const executeAllPrefetchTasks = (strategy: (app: MicroApp) => boolean) => {
  getPrefetchingApps(apps)(strategy)
    .forEach(prefetchIdleTask(fetch));
};

function prefetchIdleTask(fetch = window.fetch) {
  return (app: MicroApp) => {
    window.requestIdleCallback(async () => {
      const { url, entry, entryContent, name } = app;
      const { jsList, cssList } = url ? getUrlAssets(url) : await getEntryAssets({
        entry,
        entryContent,
        assetsCacheKey: name,
        fetch,
      });
      window.requestIdleCallback(() => fetchScripts(jsList, fetch));
      window.requestIdleCallback(() => fetchStyles(cssList, fetch));
    });
  };
}

window.requestIdleCallback会在浏览器空闲时间执行其中的回调函数,作为一个”预加载“功能,这无疑是一个很好的实现方式。

源码中也很贴心地提供了该方法的 polyfill

window.requestIdleCallback =
  window.requestIdleCallback ||
  function (cb) {
    const start = Date.now();
    return setTimeout(() => {
      cb({
        didTimeout: false,
        timeRemaining() {
          return Math.max(0, 50 - (Date.now() - start));
        },
      });
    }, 1);
  };

window.requestIdleCallback的回调函数中,会通过我们配置的子应用中的 url 或者 entry 去获取子应用的 js 和 css 列表,再通过fetchScriptsfetchStyles加载子应用的js、css资源

export function fetchScripts(jsList: Asset[], fetch: Fetch = defaultFetch) {
  return Promise.all(jsList.map((asset) => {
    const { type, content } = asset;
    if (type === AssetTypeEnum.INLINE) {
      return content;
    } else {
      return cachedScriptsContent[content]
        || (cachedScriptsContent[content] = fetch(content)
          .then((res) => res.text())
          .then((res) => `${res} \n //# sourceURL=${content}`)
        );
    }
  }));
}

export function fetchStyles(cssList: Asset[], fetch: Fetch = defaultFetch) {
  return Promise.all(
    cssList.map((asset) => {
      const { type, content } = asset;
      if (type === AssetTypeEnum.INLINE) {
        return content;
      }
      return cachedStyleContent[content] || (cachedStyleContent[content] = fetch(content).then((res) => res.text()));
    }),
  );
}

这两个方法实现的很巧妙,先使用 fetch API 请求对应的资源,然后存储在变量cachedStyleContent中,遇到下一次调用这个方法获取资源时,就可以直接从cachedStyleContent获取了。

看完预加载的完整实现逻辑,你应该就了解了:这里的预加载只能提升非首屏首次加载的子应用渲染速度

6、路由劫持

ice stark 的核心就是劫持路由去匹配对应的子应用,然后加载子应用的资源并渲染到主应用当中去。

让我们对路由劫持的实现一探究竟:

// hajack history & eventListener
hijackHistory();
hijackEventListener();

globalConfiguration.reroute(location.href, 'init');

发现个小错误:注释中的 hijack 拼错了 (°ー°〃)

这里一共做了三步处理:劫持 history,劫持事件侦听器和初始化路由。初始化路由部分代码较多就拆到下一部分说了。

hijackHistory

hijackHistory用来劫持 window.history 中的pushStatereplaceState,以及监听路由变化的popstatehashchange两个方法

const originalPush: OriginalStateFunction = window.history.pushState;
const originalReplace: OriginalStateFunction = window.history.replaceState;

const hijackHistory = (): void => {
  window.history.pushState = (state: any, title: string, url?: string, ...rest) => {
    originalPush.apply(window.history, [state, title, url, ...rest]);
    const eventName = 'pushState';
    handleStateChange(createPopStateEvent(state, eventName), url, eventName);
  };

  window.history.replaceState = (state: any, title: string, url?: string, ...rest) => {
    originalReplace.apply(window.history, [state, title, url, ...rest]);
    const eventName = 'replaceState';
    handleStateChange(createPopStateEvent(state, eventName), url, eventName);
  };

  window.addEventListener('popstate', urlChange, false);
  window.addEventListener('hashchange', urlChange, false);
};

pushStatereplaceState是 HTML5 中 history 提供的 API。他们用于操作浏览器历史栈,能够在不加载页面的情况下改变浏览器的URL。

hash 模式的路由变化会触发 hashchange事件,history 模式的路由变化会触发popstate事件。

所以无论路由怎样玩着花地变化,都能在这里得到照顾。

我么可以再深入点看看细节:

看看重写的window.history.pushState`window.history.replaceState做了什么额外操作

handleStateChange(createPopStateEvent(state, eventName), url, eventName);
export function createPopStateEvent(state, originalMethodName) {
  let evt;
  try {
    evt = new PopStateEvent('popstate', { state });
  } catch (err) {
    evt = document.createEvent('PopStateEvent');
    evt.initPopStateEvent('popstate', false, false, state);
  }
  evt.icestark = true;
  evt.icestarkTrigger = originalMethodName;
  return evt;
}

const handleStateChange = (event: PopStateEvent, url: string, method: RouteType) => {
  setHistoryEvent(event);
  globalConfiguration.reroute(url, method);
};

let historyEvent = null;
export function setHistoryEvent(evt: PopStateEvent | HashChangeEvent) {
  historyEvent = evt;
}

这里其实做了三件事情:

  • 创建一个原生的popstate事件
  • 使用historyEvent变量来记录这个事件
  • 调用reroute激活并加载对应子应用,挂载完子应用后会执行保存在上一步historyEvent中的事件

监听hashchangepopstate事件执行的方法urlChange实现的逻辑与上面的也基本一致

const urlChange = (event: PopStateEvent | HashChangeEvent): void => {
  setHistoryEvent(event);
  globalConfiguration.reroute(location.href, event.type as RouteType);
};

三、reroute(url, type)

start() 方法最后一步就是调用 reroute 方法对当前路由对应的子应用进行初始化

globalConfiguration.reroute(location.href, 'init');

let lastUrl = null;
export function reroute(url: string, type: RouteType | 'init' | 'popstate'| 'hashchange') {
  const { pathname, query, hash } = urlParse(url, true);
  // trigger onRouteChange when url is changed
  if (lastUrl !== url) {
    globalConfiguration.onRouteChange(url, pathname, query, hash, type);

    const unmountApps = [];
    const activeApps = [];
    getMicroApps().forEach((microApp: AppConfig) => {
      const shouldBeActive = !!microApp.findActivePath(url);
      if (shouldBeActive) {
        activeApps.push(microApp);
      } else {
        unmountApps.push(microApp);
      }
    });
    // trigger onActiveApps when url is changed
    globalConfiguration.onActiveApps(activeApps);

    // call captured event after app mounted
    Promise.all(
      // call unmount apps
      unmountApps.map(async (unmountApp) => {
        if (unmountApp.status === MOUNTED || unmountApp.status === LOADING_ASSETS) {
          globalConfiguration.onAppLeave(unmountApp);
        }
        await unmountMicroApp(unmountApp.name);
      }).concat(activeApps.map(async (activeApp) => {
        if (activeApp.status !== MOUNTED) {
          globalConfiguration.onAppEnter(activeApp);
        }
        await createMicroApp(activeApp);
      })),
    ).then(() => {
      callCapturedEventListeners();
    });
  }
  lastUrl = url;
}

拆解一下,

首先声明一个let lastUrl = null;用于存储上一次的 url 路径

使用urlParse对当前 url 进行了解析,并获取pathname, query, hash三个属性

const { pathname, query, hash } = urlParse(url, true);

Warning

注意:如果使用 params 传参,比如 this.$router.push({ name: 'xxx', params: { a: 1 } })就会导致 params 丢失,实测场景也是如此,这块逻辑应该可以再优化一下

接下来使用当前 urllastUrl 对比,不相同才触发reroute的核心逻辑,避免重复调用;并在函数末尾更新lastUrl的值

if (lastUrl !== url) {
  // 核心逻辑
  ...
}
// reoute每被调用一次就更新一次lastUrl
lastUrl = url;

核心逻辑中,路由变化意味着需要触发全局的onRouteChange钩子

globalConfiguration.onRouteChange(url, pathname, query, hash, type);

接着对全部的子应用进行分类

const unmountApps = [];
const activeApps = [];
getMicroApps().forEach((microApp: AppConfig) => {
  const shouldBeActive = !!microApp.findActivePath(url);
  if (shouldBeActive) {
    activeApps.push(microApp);
  } else {
    unmountApps.push(microApp);
  }
});

和当前路由匹配的子应用放activeApps里准备激活,否则放unmountApps里准备卸载

分完类后就可以对被激活的子应用调用全局的onActiveApps钩子了

globalConfiguration.onActiveApps(activeApps);

最后遍历unmountApps数组将它们统统卸载掉,并且对状态为MOUNTEDLOADING_ASSETS的子应用调用onAppLeave钩子

Promise.all(
  // call unmount apps
  unmountApps.map(async (unmountApp) => {
    if (unmountApp.status === MOUNTED || unmountApp.status === LOADING_ASSETS) {
      globalConfiguration.onAppLeave(unmountApp);
    }
    await unmountMicroApp(unmountApp.name);
  }).concat(activeApps.map(async (activeApp) => {
    if (activeApp.status !== MOUNTED) {
      globalConfiguration.onAppEnter(activeApp);
    }
    await createMicroApp(activeApp);
  })),
).then(() => {
  callCapturedEventListeners();
});

这里有一定单例模式的思想:每次只激活和当前路由匹配的一个子应用。可以很好地避免子应用之间的耦合与相互污染。

卸载子应用的逻辑就不赘述了,我们来仔细看看是如何创建并渲染子应用的

await createMicroApp(activeApp);
export async function createMicroApp(
  app: string | AppConfig,
  appLifecyle?: AppLifecylceOptions,
  configuration?: StartConfiguration,
) {
  // 代码太多,省略一些校验的逻辑,我们关注核心逻辑就好了
  // ...
  const { container, basename, activePath, configuration: userConfiguration, findActivePath } = appConfig;

  if (container) {
    setCache('root', container);
  }

  const { fetch } = userConfiguration;
  
  // 部分代码省略
  // ...
  
  switch (appConfig.status) {
    case NOT_LOADED:
    case LOAD_ERROR:
      await loadApp(appConfig);
      break;
    case UNMOUNTED:
      if (!appConfig.cached) {
        const appendAssets = [
          ...(appConfig?.appAssets?.cssList || []),
          ...(appConfig?.loadScriptMode === 'import' 
              ? filterRemovedAssets(importCachedAssets[appConfig.name] ?? [], ['LINK', 'STYLE']) 
              : []),
        ];

        await loadAndAppendCssAssets(appendAssets, {
          cacheCss: shouldCacheCss(appConfig.loadScriptMode),
          fetch,
        });
      }
      await mountMicroApp(appConfig.name);
      break;
    case NOT_MOUNTED:
      await mountMicroApp(appConfig.name);
      break;
    default:
      break;
  }
​
  return getAppConfig(appName);
}
1、主子应用共享 DOM

创建子应用时有个最重要的前提:

const { container, basename, activePath, configuration: userConfiguration, findActivePath } = appConfig;

if (container) {
  setCache('root', container);
}

export const setCache = (key: string, value: any): void => {
  if (!(window as any)[namespace]) {
    (window as any)[namespace] = {};
  }
  (window as any)[namespace][key] = value;
};

这一步就是将主应用中我们抛出来的那个 DOM 节点给挂到window['root']上去作为全局的共享节点,后续就可以调用子应用暴露的 mount方法直接挂载到这个共享节点上面。

我们可以从这里发现一丝端倪:主子应用之间的信息共享可以通过window实现

2、根据子应用状态作不同处理

继续往下,我们先来快速过一遍子应用各个状态(status)值的含义:

在注册子应用的时候,会给所有初始的子应用对象标记状态为NOT_LOADED;获取子应用资源之前,会先将子应用的状态标记为LOADING_ASSETS,当资源获取成功后,会将状态变更为NOT_MOUNTED,获取失败则标记为LOAD_ERROR;已经挂载过的子应用被卸载状态会变更为UNMOUNTED;

所以你应该明白了上面代码的大致逻辑

  • 没加载过的子应用会调用**loadApp(appConfig)**
  • 加载过的且被卸载了的子应用会先根据appConfig.cached缓存配置判断是否重新获取资源或者直接挂载
  • 资源获取完毕的子应用直接调用mountMicroApp执行挂载
3、loadApp(appConfig)

loadApp做了两件事情:

  • 获取子应用资源
  • 调用mountMicroApp挂载子应用
async function loadApp(app: MicroApp) {
  const { title, name, configuration } = app;

  if (title) {
    document.title = title;
  }

  updateAppConfig(name, { status: LOADING_ASSETS });

  let lifeCycle: ModuleLifeCycle = {};
  try {
    lifeCycle = await loadAppModule(app);
    // in case of app status modified by unload event
    if (getAppStatus(name) === LOADING_ASSETS) {
      updateAppConfig(name, { ...lifeCycle, status: NOT_MOUNTED });
    }
  } catch (err) {
    configuration.onError(err);
    log.error(err);
    updateAppConfig(name, { status: LOAD_ERROR });
  }
  if (lifeCycle.mount) {
    await mountMicroApp(name);
  }
}

获取子应用资源可以说是加载子应用的核心逻辑了,官方给出的流程图其实也表明的很清楚了:

子应用资源的获取可以通过三种方式:

聊一款简单且精妙的微前端框架 ice stark(上)

switch (loadScriptMode) {
  case 'import':
    await loadAndAppendCssAssets([
      ...appAssets.cssList,
      ...filterRemovedAssets(importCachedAssets[name] || [], ['LINK', 'STYLE']),
    ], {
      cacheCss,
      fetch,
    });
    lifecycle = await loadScriptByImport(appAssets.jsList);
    // Not to handle script element temporarily.
    break;
  case 'fetch':
    await loadAndAppendCssAssets(appAssets.cssList, {
      cacheCss,
      fetch,
    });
    lifecycle = await loadScriptByFetch(appAssets.jsList, appSandbox, fetch);
    break;
  default:
    await Promise.all([
      loadAndAppendCssAssets(appAssets.cssList, {
        cacheCss,
        fetch,
      }),
      loadAndAppendJsAssets(appAssets, { scriptAttributes }),
    ]);
    lifecycle =
      getLifecyleByLibrary() ||
      getLifecyleByRegister() ||
      {};
}

这三种方式调 css 资源的处理方式基本是一致的,使用loadAndAppendCssAssets

export async function loadAndAppendCssAssets(cssList: Array<Asset | HTMLElement>, {
  cacheCss = false,
  fetch = defaultFetch,
}: {
  cacheCss?: boolean;
  fetch?: Fetch;
}) {
  const cssRoot: HTMLElement = document.getElementsByTagName('head')[0];

  if (cacheCss) {
    // ...
    // 省略部分逻辑
  }

  // load css content
  return await Promise.all(
    cssList.map((asset, index) => appendCSS(cssRoot, asset, `${PREFIX}-css-${index}`)),
  );
}

该方法目的是将子应用的css列表遍历添加前缀属性icestark-css-${index}后拼到主应用的head当中去。

js 资源处理方式对应如下:

  • import loadScriptByImport
  • fetch loadScriptByFetch
  • script getLifecyleByLibrary || getLifecyleByRegister

这些不同的处理方式最终的目的就是获取到子应用生命周期即导出的mountunmount方法:

lifecycle = {
  mount,
  unmount,
};

再将它合并到子应用的配置当中去:

return combineLifecyle(lifecycle, appConfig);
function combineLifecyle(lifecycle: ModuleLifeCycle, appConfig: AppConfig) {
  const combinedLifecyle = { ...lifecycle };
  ['mount', 'unmount', 'update'].forEach((lifecycleKey) => {
    if (lifecycle[lifecycleKey]) {
      combinedLifecyle[lifecycleKey] = async (props) => {
        await callAppLifecycle('before', lifecycleKey, appConfig);
        await lifecycle[lifecycleKey](props);
        await callAppLifecycle('after', lifecycleKey, appConfig);
      };
    }
  });
  return combinedLifecyle;
}
4、mountMicroApp(name)

获取完子应用的资源和生命周期之后,这一步将执行子应用的挂载,为整个流程画上句号

export async function mountMicroApp(appName: string) {
  const appConfig = getAppConfig(appName);
  // check current url before mount
  const shouldMount = appConfig?.mount && appConfig?.findActivePath(window.location.href);

  if (shouldMount) {
    if (appConfig?.mount) {
      await appConfig.mount({ container: appConfig.container, customProps: appConfig.props });
    }
    updateAppConfig(appName, { status: MOUNTED });
  }
}
  • 先对子应用是否有导出mount方法以及当前激活路由是否属于该子应用的进行一个判断

    const shouldMount = appConfig?.mount && appConfig?.findActivePath(window.location.href);
    
  • 判断无误后则直接调用子应用的mount方法,将子应用渲染到其配置的container中,并更改子应用状态为MOUNTED

    if (appConfig?.mount) {
      await appConfig.mount({ container: appConfig.container, customProps: appConfig.props });
    }
    updateAppConfig(appName, { status: MOUNTED });
    

整个微前端流程的实现思想和原理在 Ice stark源码中体现的淋漓尽致

我绘制了张流程图作为最后的总结和提炼:

聊一款简单且精妙的微前端框架 ice stark(上)

最后,创作不易,你的赞就是给作者最大的鼓励~

作者水平有限,如有错误或者不严谨之处,恳请批评指正~

转载自:https://juejin.cn/post/7151983454609997855
评论
请登录