微前端icestark源码解读-子应用加载详解(二)

528 阅读8分钟

我正在参加「掘金·启航计划」

前言

在第一章节中我们大致浏览了一下icestark是如何控制子应用的加载与卸载的,本章节我们将会看到完整的子应用加载的过程。

快速链接

-icestark源码解读(一):控制微应用加载与卸载的核心原理

概念

在阅读子应用加载的过程之前,我们先了解下几个概念,有助于我们后续的阅读。

microApps

一个存储所有子应用完整配置信息的全局变量,其类型是一个数组

子应用的6个状态

  • NOT_LOADED

标志着子应用的静态资源还没有下载,也就是子应用的静态资源还没有获取

  • LOADING_ASSETS

标志着子应用处于正在下载中

  • LOAD_ERROR

标志着子应用下载出现了错误

  • NOT_MOUNTED

标志着子应用的静态资源已经全部下载完成,但是没有挂载到主应用的DOM节点上

  • MOUNTED

标志着子应用已经插入到主应用的DOM节点上面了,此时网页中已经能够看到子应用的页面了

  • UNMOUNTED

标志着子应用已经从主应用的DOM节点上移除掉了

createMicroApp 函数

在上一章节中我们看到了reroute函数(控制子应用加载与卸载的函数),reroute函数中加载子应用的方法就是是createMicroApp函数。

export async function createMicroApp(
  app: string | AppConfig,
  appLifecyle?: AppLifecylceOptions,
  configuration?: StartConfiguration,
) {
  const appName = typeof app === 'string' ? app : app.name; // 获取子应用的唯一标识

  if (typeof app !== 'string') {
    registerAppBeforeLoad(app, appLifecyle); // 这一步其实就是将子应用放到全局变量microApps之中
  }

  mergeThenUpdateAppConfig(appName, configuration); // 合并更新子应用的配置信息

  const appConfig = getAppConfig(appName); // 获取子应用的配置信息

  if (!appConfig || !appName) {
    console.error(`[icestark] fail to get app config of ${appName}`);
    return null;
  }

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

  if (container) {
    setCache('root', container); // 缓存子应用的的根DOM节点
  }

  const { fetch } = userConfiguration;

  if (shouldSetBasename(activePath, basename)) {
    let pathString = findActivePath(window.location.href);

    // When use `createMicroApp` lonely, `activePath` maybe not provided.
    pathString = typeof pathString === 'string' ? pathString : '';
    setCache('basename', getAppBasename(pathString, basename));
  }

  switch (appConfig.status) { // 子应用的status
    case NOT_LOADED: // 未加载
    case LOAD_ERROR: // 加载错误
      await loadApp(appConfig);
      break;
    case UNMOUNTED: // 已卸载
      if (!appConfig.cached) {
        const appendAssets = [
          ...(appConfig?.appAssets?.cssList || []),
          // In vite development mode, styles are inserted into DOM manually.
          // While es module natively imported twice may never excute twice.
          // https://github.com/ice-lab/icestark/issues/555
          ...(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); // 返回要注册的子应用配置信息
}

createMicroApp函数中,先是拿到子应用的完整配置信息,然后将子应用注册到全局变量microApps之中,注册完成之后合并更新子应用的配置信息,紧接着缓存子应用的挂载目标节点,然后为子应用统一添加路由匹配的basename, 再根据子应用的状态去决定执行加载、挂载子应用。我们在这里顺便看下icestark内部的cache是如何做到的

cache

const namespace = 'ICESTARK'; // 命名空间

/**
 * 设置缓存
 * @param key
 * @param value
 */
export const setCache = (key: string, value: any): void => {
  if (!(window as any)[namespace]) {
    (window as any)[namespace] = {};
  }
  (window as any)[namespace][key] = value;
};

/**
 * 从缓存中读取数据
 * @param key
 */
export const getCache = (key: string): any => {
  const icestark: any = (window as any)[namespace];
  return icestark && icestark[key] ? icestark[key] : null;
};

icestarkcache很简单,就是将数据直接挂到window对象身上的ICESTARK属性下。

registerAppBeforeLoad 函数

function registerAppBeforeLoad(app: AppConfig, options?: AppLifecylceOptions) {
  const { name } = app; // 取出子应用的唯一标识
  const appIndex = getAppNames().indexOf(name); // 查看存储所有子应用的全局变量中是否有当前子应用

  if (appIndex === -1) {
    registerMicroApp(app, options); // 注册子应用
  } else {
    updateAppConfig(name, app); // 更新子应用的配置信息
  }

  return getAppConfig(name);
}

该函数其实本质是在判断全局变量microApps中是否有目标子应用,有就更新配置,没有的话就执行registerMicroApp函数追加进去。

registerMicroApp 函数

/**
 * 注册子应用
 * @param appConfig
 * @param appLifecyle
 */
export function registerMicroApp(appConfig: AppConfig, appLifecyle?: AppLifecylceOptions) {
  // check appConfig.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;

  /**
   * Format activePath in advance
   */
  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,
  };

  microApps.push(microApp); // 向全局 microApps 数组变量插入子应用
}

从该函数中我们可以看到其最终目的是向microApps变量中插入子应用的配置信息,并且在push之前将子应用的状态设置为了NOT_LOADED。所以说icestark中的注册子应用的目的就是向microApps变量中插入子应用的配置信息。

mergeThenUpdateAppConfig函数

function mergeThenUpdateAppConfig(name: string, configuration?: StartConfiguration) {
  const appConfig = getAppConfig(name); // 获取子应用配置信息

  if (!appConfig) {
    return;
  }

  const { umd, sandbox } = appConfig;

  // 位子应用创建一个沙箱
  const appSandbox = createSandbox(sandbox) as Sandbox;

  // 获取加载子应用js的方式是fetch还是script链接
  const sandboxEnabled = sandbox && !appSandbox.sandboxDisabled;
  const loadScriptMode = appConfig.loadScriptMode ?? (umd || sandboxEnabled ? 'fetch' : 'script');

  // Merge global configuration
  const cfgs = {
    ...globalConfiguration,
    ...configuration,
  };

  // 更新子应用的配置
  updateAppConfig(name, {
    appSandbox,
    loadScriptMode,
    configuration: cfgs,
  });
}

该函数的主要作用就是为子应用创建一个沙箱并且决定加载子应用js的方式

loadApp 函数

/**
 * 下载子应用
 * @param app
 */
async function loadApp(app: MicroApp) {
  const { title, name, configuration } = app;

  if (title) {
    document.title = title; // 更改页面的标题
  }

  updateAppConfig(name, { status: LOADING_ASSETS }); // 更新子应用的状态为正在加载资源 LOADING_ASSETS

  let lifeCycle: ModuleLifeCycle = {};
  try {
    lifeCycle = await loadAppModule(app); // 加载子应用的js和css资源
    // 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); // 子应用静态资源获取完成后执行挂载App
  }
}

该函数的主要逻辑是;更改浏览器页签的标题文字,将子应用的状态设置为LOADING_ASSETS正在加载资源中,调用loadAppModule函数去获取子应用的静态资源,获取完成之后更改子应用的状态为NOT_MOUNTED未挂载状态, 最后调用mountMicroApp函数去挂载子应用。现在我们去看下loadAppModule函数是如何去拿到子应用的静态资源的。

loadAppModule 函数

/**
 * 下载子应用的核心逻辑
 * @param appConfig 子应用的配置信息
 * @returns
 */
export async function loadAppModule(appConfig: AppConfig) {
  const { onLoadingApp, onFinishLoading, fetch } = getAppConfig(appConfig.name)?.configuration || globalConfiguration;

  let lifecycle: ModuleLifeCycle = {};
  onLoadingApp(appConfig); // 执行子应用开始加载的回调 onLoadingApp
  const { url, container, entry, entryContent, name, scriptAttributes = [], loadScriptMode, appSandbox } = appConfig;
  // 根据配置的子应用的url 或者entry去获取子应用的静态资源文件对应的url地址
  const appAssets = url ? getUrlAssets(url) : await getEntryAssets({
    root: container,
    entry,
    href: location.href,
    entryContent,
    assetsCacheKey: name,
    fetch,
  });

  updateAppConfig(appConfig.name, { appAssets }); // 更新子应用的配置信息

  const cacheCss = shouldCacheCss(loadScriptMode); // 是否要缓存css

  switch (loadScriptMode) {
    case 'import': // 说明是ESM应用
      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() ||
          {};
  }

  if (isEmpty(lifecycle)) {
    log.error(
      formatErrMessage(
        ErrorCode.EMPTY_LIFECYCLES,
        isDev && 'Unable to retrieve lifecycles of {0} after loading it',
        appConfig.name,
      ),
    );
  }

  onFinishLoading(appConfig); // 执行子应用加载完成的回调 onLoadingApp

  return combineLifecyle(lifecycle, appConfig);
}

该函数中先是根据子应用配置的是url还是entry来去调用getUrlAssets或者getEntryAssets去将子应用的资源地址组合成jsList & cssList的形式。然后根据子应的加载模式(fetch or import or script)来去执行loadAndAppendCssAssets loadScriptByImport loadScriptByFetch loadAndAppendJsAssets这些函数,将css与js获取并插入。

组合子应用资源的格式

getUrlAssets

/**
 * 根据js和css的url去组合成cssList 和 jsList的数据格式
 * @param urls
 */
export function getUrlAssets(urls: string | string[]) {
  const jsList = [];
  const cssList = [];

  toArray(urls).forEach((url) => {
    // //icestark.com/index.css -> true
    // //icestark.com/index.css?timeSamp=1575443657834 -> true
    // //icestark.com/index.css?query=test.js -> false
    const isCss: boolean = IS_CSS_REGEX.test(url); // 判断是不是css url
    const assest: Asset = {
      type: AssetTypeEnum.EXTERNAL, // 给静态资源指定类型,inline / external
      content: url, // 静态资源的url地址
    };
    if (isCss) {
      cssList.push(assest);
    } else {
      jsList.push(assest);
    }
  });

  return { jsList, cssList };
}

getEntryAssets

/**
 * 通过子应用的ip + 端口号 获取子应用的静态资源并插入到主应用的DOM节点上面
 * @param root
 * @param entry
 * @param entryContent
 * @param assetsCacheKey
 * @param href
 * @param fetch
 */
export async function getEntryAssets({
  root,
  entry,
  entryContent,
  assetsCacheKey,
  href = location.href,
  fetch = defaultFetch,
}: {
  root?: HTMLElement | ShadowRoot; // 子应用要挂载到主应用上的节点
  entry?: string; // 子应用的访问地址 ip + port
  entryContent?: string; // 开发者自己配置的 子应用html结构
  assetsCacheKey: string; // 缓存数据的key值
  href?: string;
  fetch?: Fetch;
  assertsCached?: boolean;
}) {
  const cachedContent = cachedProcessedContent[assetsCacheKey];
  let htmlContent = entryContent;

  if (!cachedContent) {
    if (!htmlContent && entry) {
      if (!fetch) {
        log.warn('Current environment does not support window.fetch, please use custom fetch');
        throw new Error(
          `fetch ${entry} error: Current environment does not support window.fetch, please use custom fetch`,
        );
      }
      // 通过fetch api 根据entry地址获取子应用的html字符串
      htmlContent = await fetch(entry).then((res) => res.text());
    }
    // 缓存子应用的html字符串
    cachedProcessedContent[assetsCacheKey] = htmlContent;
  }

  // 解析html字符串,拿到静态资源的资源的url
  const { html, assets } = processHtml(cachedContent || htmlContent, entry || href);

  if (root) {
    root.appendChild(html); // 将子应用插入到主应用的DOM节点上面
  }

  return assets;
}

该函数的作用是利用配置的entry(子应用部署所在的ip+端口号),通过fetchapi 去获取到子应用的html结构字符串,通过processHtml函数从html结构字符串中取出html结构以及html中css 与 js的url,最后将子应用的html内容append到主应用的节点上面。

processHtml 函数

/**
 * 从html字符串中提取子应用DOM结构以及根据entry组合子应用静态资源js与css的url地址
 * @param html
 * @param entry
 */
export function processHtml(html: string, entry?: string): ProcessedContent {
  if (!html) return { html: document.createElement('div'), assets: { cssList: [], jsList: [] } };

  // 通过DOMParser将html字符串获取完整的DOM对象
  const domContent = (new DOMParser()).parseFromString(html.replace(COMMENT_REGEX, ''), 'text/html');

  // 创建base标签,将子应用html中所有相对路径全部变成指向子应用entry的绝对路径
  if (entry) {
    const baseElementMatch = html.match(BASE_LOOSE_REGEX);

    const baseElements = domContent.getElementsByTagName('base');
    const hasBaseElement = baseElements.length > 0;

    if (baseElementMatch && hasBaseElement) {
      // Only take the first one into consideration.
      const baseElement = baseElements[0];

      const [, baseHerf] = baseElementMatch;
      // 将子应用的资源url由相对路径改成带有子应用域名端口号的绝对路径
      baseElement.href = isAbsoluteUrl(baseHerf) ? baseHerf : getUrl(entry, baseHerf);
    } else {
      // add base URI for absolute resource.
      // see more https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
      const base = document.createElement('base');
      // <base /> element also takes effects if href includues `.html`
      base.href = entry;
      domContent.getElementsByTagName('head')[0].appendChild(base);
    }
  }

  // 获取子应用中所有的script标签
  const scripts = Array.from(domContent.getElementsByTagName('script'));
  const processedJSAssets = scripts.map((script) => {
    const inlineScript = script.src === EMPTY_STRING; // 判断是不是行内script
    const module = script.type === 'module'; // 判断是不是ESModule

    // 获取子应用非行内script的url(entry + 资源路径) ---- 暂且成为外部js
    const externalSrc = !inlineScript && (isAbsoluteUrl(script.src) ? script.src : getUrl(entry, script.src));

    const commentType = inlineScript ? AssetCommentEnum.PROCESSED : AssetCommentEnum.REPLACED;
    // 使用注释来代替script标签节点进行占位
    replaceNodeWithComment(script, getComment('script', inlineScript ? 'inline' : script.src, commentType));

    return {
      module, // ESModule的标识
      type: inlineScript ? AssetTypeEnum.INLINE : AssetTypeEnum.EXTERNAL, // 行内还是外部js
      content:
        inlineScript
          ? (
            // If entryContent provided, skip this.
            (module && entry)
              ? replaceImportIdentifier(script.text, entry)
              : script.text)
          : externalSrc,
    };
  });

  // 获取子应用中所有的style标签
  const inlineStyleSheets = Array.from(domContent.getElementsByTagName('style')); // 获取行内样式
  const externalStyleSheets = Array.from(domContent.getElementsByTagName('link')) // 获取link标签外部样式
    .filter((link) => !link.rel || link.rel.includes(STYLESHEET_LINK_TYPE));

  const processedCSSAssets = [
    ...inlineStyleSheets
      .map((sheet) => {
        replaceNodeWithComment(sheet, getComment('style', 'inline', AssetCommentEnum.REPLACED)); // 使用注释来代替style标签节点
        return {
          type: AssetTypeEnum.INLINE,
          content: sheet.innerText,
        };
      }),
    ...externalStyleSheets
      .map((sheet) => {
        replaceNodeWithComment(sheet, getComment('link', sheet.href, AssetCommentEnum.PROCESSED)); // 使用注释来代替link标签节点
        return {
          type: AssetTypeEnum.EXTERNAL,
          content: isAbsoluteUrl(sheet.href) ? sheet.href : getUrl(entry, sheet.href),
        };
      }),
  ];

  if (entry) {
    // 移除之前为子应用创建的base标签,以此来避免影响主应用
    const baseNodes = domContent.getElementsByTagName('base');
    for (let i = 0; i < baseNodes.length; ++i) {
      baseNodes[i]?.parentNode.removeChild(baseNodes[i]);
    }
  }

  return {
    html: domContent.getElementsByTagName('html')[0], // 子应用的html DOM对象 (此时已经移除了子应用自己全部的script以及css,由icestark注释来代替占位)
    assets: {
      jsList: processedJSAssets, // 子应用js静态资源
      cssList: processedCSSAssets, // 子应用css静态资源
    },
  };
}
  • 该函数接收html字符串&entry作为参数,首先是利用new DOMParser()将html从字符串解析成DOM对象。在解析的过程中会遇到一个问题,就是子应用的html 对象中的<script /><link />的src或者href地址是相对路径,此时在主应用中运行子应用那么肯定是相对于主应用的,而主应用里面并没有子应用引用的资源。为了避免这个问题,我们就需要把子应用的相对路径更改为绝对路径(子应用的entry与子应用相对路径合并成为绝对路径)。

  • 具体需要怎么做,我们可以看到源码中先是创建一个base标签,并指定base标签的href属性值为子应用的entry,然后将base标签插入至子应用的document文档中的header标签内,这样浏览器在解析子应用时会将子应用的所有相对路径前加上base的href。这样子应用document中所有的路径都会指向子应用的entry路径。 base标签介绍

  • 添加base标签之后,紧接着就是遍历子应用所有的script与style link标签,拿到所有的js与css的url以及行内js、css,同时将行内和外部引用的js与css从子应用的document中移除掉,使用icestark专门的注释来对其进行占位。

loadAndAppendCssAssets 函数

/**
 * 下载并插入 css assets
 *
 * @export
 * @param {Assets} assets
 */
export async function loadAndAppendCssAssets(cssList: Array<Asset | HTMLElement>, {
  cacheCss = false,
  fetch = defaultFetch,
}: {
  cacheCss?: boolean;
  fetch?: Fetch;
}) {
  const cssRoot: HTMLElement = document.getElementsByTagName('head')[0]; // 主应用的head标签

  if (cacheCss) {
    let useLinks = false;
    let cssContents = null;

    try {
      // No need to cache css when running into `<style />`
      const needCachedCss = cssList.filter((css) => !isElement(css));
      cssContents = await fetchStyles(
        needCachedCss as Asset[],
        fetch,
      );
    } catch (e) {
      useLinks = true;
    }

    // Try hard to avoid break-change if fetching links error.
    // And supposed to be remove from 3.x
    if (!useLinks) {
      return await Promise.all([
        ...cssContents.map((content, index) => appendCSS(
          cssRoot,
          { content, type: AssetTypeEnum.INLINE }, `${PREFIX}-css-${index}`,
        )),
        ...cssList.filter((css) => isElement(css)).map((asset, index) => appendCSS(cssRoot, asset, `${PREFIX}-css-${index}`)),
      ]);
    }
  }

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

该函数主要作用是根据子应用的css资源的url来获取css并插入到主应用document之中。

首先是拿到主应用的header标签作为css插入的根节点,然后根据css的url,通过fetch api进行调用获取。对于插入到主应用document中子应用的style和link标签会添加icestark=dynamic标识,以此来与主应用的css资源进行区分。同时还需要监听每一个link标签的load事件,等所有的link标签链接的外部样式资源全部下载完成,再去执行后面的代码

/**
 * 创建 link/style 元素 接受子应用的css资源 并且 插入 到 主应用的head标签内部
 */
export function appendCSS(
  root: HTMLElement | ShadowRoot,
  asset: Asset | HTMLElement,
  id: string,
): Promise<void> {
  return new Promise<void>(async (resolve, reject) => {
    if (!root) reject(new Error('no root element for css asset'));

    if (isElement(asset)) { // asset 是DOM 的话,直接插入即可
      root.append(asset);
      resolve();
      return;
    }

    const { type, content } = asset;

    if (type && type === AssetTypeEnum.INLINE) { // 行内样式的话直接插入到style标签即可
      const styleElement: HTMLStyleElement = document.createElement('style');
      styleElement.id = id;
      styleElement.setAttribute(PREFIX, DYNAMIC);
      styleElement.innerHTML = content;
      root.appendChild(styleElement);
      resolve();
      return;
    }

    /**
     * if external resource is cached by prefetch, use cached content instead.
     * For cachedStyleContent may fail to fetch (cors, and so on),recover to original way
     */
    let useExternalLink = true;
    if (type && type === AssetTypeEnum.EXTERNAL && cachedStyleContent[content]) { // 从缓存中,直接取出样式资源并插入到主应用的head标签即可
      try {
        const styleElement: HTMLStyleElement = document.createElement('style');
        styleElement.innerHTML = await cachedStyleContent[content];
        styleElement.id = id;
        styleElement.setAttribute(PREFIX, DYNAMIC);
        root.appendChild(styleElement);
        useExternalLink = false;
        resolve();
      } catch (e) {
        useExternalLink = true;
      }
    }

    if (useExternalLink) { // 说明是需要外部链接样式资源
      const element: HTMLLinkElement = document.createElement('link');
      element.setAttribute(PREFIX, DYNAMIC);
      element.id = id;
      element.rel = 'stylesheet';
      element.href = content;

      // 监听元素加载错误情况
      element.addEventListener(
        'error',
        () => {
          log.error(
            formatErrMessage(
              ErrorCode.CSS_LOAD_ERROR,
              isDev && 'The stylesheets loaded error: {0}',
              (content || asset) as string,
            ),
          );
          return resolve();
        },
        false,
      );
      // 监听元素加载完成
      element.addEventListener('load', () => resolve(), false);

      // 将link标签插入到主应用的head标签内部
      root.appendChild(element);
    }
  });
}

loadScriptByImport函数

/**
 * 下载 es modules 子应用并且获取顺序的生命周期.
 * `import` returns a promise for the module namespace object of the requested module which means
 * + non-export returns empty object
 * + default export return object with `default` key
 */
export async function loadScriptByImport(jsList: Asset[]): Promise<null | ModuleLifeCycle> {
  let mount = null;
  let unmount = null;
  await asyncForEach(jsList, async (js, index) => {
    if (js.type === AssetTypeEnum.INLINE) { // 加载行内js
      await appendExternalScript(js, {
        id: `${PREFIX}-js-module-${index}`,
      });
    } else { // 加载外部js
      let dynamicImport = null;
      try {
        /**
        * 使用 new Function 去检测浏览器是否支持import 函数导入js的语法
        * Then use `new Function` to escape compile error.
        * Inspired by [dynamic-import-polyfill](https://github.com/GoogleChromeLabs/dynamic-import-polyfill)
        */
        // eslint-disable-next-line no-new-func
        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) {
          // 使用import函数去导入es module的js
          const { mount: maybeMount, unmount: maybeUnmount } = await dynamicImport(js.content);

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

  if (mount && unmount) {
    return {
      mount,
      unmount,
    };
  }

  return null;
}

该函数的主要作用是加载ES Module的子应用,根据子应用的js资源以及url来获取子应用js并插入到主应用document之中。

  • 对于行内js,直接创建script标签去引用子应用的js,同时添加icestark=dynamic标识用于区分主应用的js,又由于加载的是es module, 故需要给script标签设置type=module属性,最后插入到主应用的header标签内。

  • 对于外部js,采用import函数的方式引入,由于在chorme 61之下以及ie上 直接使用import函数导入会报错,故源码里面采用es6 的 new Function 方式创建一个函数用于去执行import​(子应用外部js的url)代码,这样做的目的是去检测浏览器是否支持import函数方式导入js,如果不支持则会抛出可被try catch捕捉的错误

loadScriptByFetch 函数

/**
 * 通过fetch的方式去下载子应用的js
 */
export function loadScriptByFetch(jsList: Asset[], sandbox?: Sandbox, fetch = window.fetch) {
  return fetchScripts(jsList, fetch) // fetchScripts 获取js
    .then((scriptTexts) => {
      const globalwindow = getGobalWindow(sandbox);

      const libraryExport = executeScripts(scriptTexts, sandbox, globalwindow); // executeScripts 执行js

      let moduleInfo = getLifecyleByLibrary() || getLifecyleByRegister();
      if (!moduleInfo) {
        moduleInfo = (libraryExport ? globalwindow[libraryExport] : {}) as ModuleLifeCycle;

        if (globalwindow[libraryExport]) {
          delete globalwindow[libraryExport];
        }
      }

      return moduleInfo;
    });
}

/**
 * 通过window.fetch获取js文本
 * @param jsList
 * @param fetch
 */
export function fetchScripts(jsList: Asset[], fetch: Fetch = defaultFetch) {
  return Promise.all(jsList.map((asset) => {
    const { type, content } = asset;
    if (type === AssetTypeEnum.INLINE) { // 对于行内js直接返回
      return content;
    } else { // 对于外部js利用fetch api  获取
      // content will script url when type is AssetTypeEnum.EXTERNAL
      // eslint-disable-next-line no-return-assign
      return cachedScriptsContent[content]
        /**
        * If code is being evaluated as a string with `eval` or via `new Function`,then the source origin
        * will be the page's origin. As a result, `//# sourceURL` appends to the generated code.
        * See https://sourcemaps.info/spec.html
        */
        || (cachedScriptsContent[content] = fetch(content)
          .then((res) => res.text())
          .then((res) => `${res} \n //# sourceURL=${content}`)
        );
    }
  }));
}

/**
 * 采用eval函数去执行js
 * @param scripts
 * @param sandbox
 * @param globalwindow
 */
function executeScripts(scripts: string[], sandbox?: Sandbox, globalwindow: Window = window) {
  let libraryExport = null;

  for (let idx = 0; idx < scripts.length; ++idx) {
    const lastScript = idx === scripts.length - 1;
    if (lastScript) {
      noteGlobalProps(globalwindow);
    }

    if (sandbox?.execScriptInSandbox) {
      sandbox.execScriptInSandbox(scripts[idx]);
    } else {
      // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval
      // eslint-disable-next-line no-eval
      (0, eval)(scripts[idx]); // eval 执行js 
    }

    if (lastScript) {
      libraryExport = getGlobalProp(globalwindow);
    }
  }

  return libraryExport;
}

该函数是加载子应用js的第二种方式,主要通过window.fetch api 去获取子应用的js,拿到js之后,通过 eval函数去执行js。

  • 对于行内js,直接运行eval函数执行
  • 对于外部js,先通过fetch去获取,拿到字符串类型的js代码之后,再去运行eval函数执行

loadAndAppendJsAssets 函数

/**
 * 下载并插入 js 资源, compatible with v1
 * @export
 * @param {Assets} assets
 * @param {Sandbox} [sandbox]
 * @returns
 */
export function loadAndAppendJsAssets(
  assets: Assets,
  {
    scriptAttributes = [],
  }: {
    scriptAttributes?: ScriptAttributes;
  },
) {
  const jsRoot: HTMLElement = document.getElementsByTagName('head')[0]; // 获取主应用的head标签

  const { jsList } = assets; // 获取js静态资源

  // 加载js
  const hasInlineScript = jsList.find((asset) => asset.type === AssetTypeEnum.INLINE);
  if (hasInlineScript) {
    // make sure js assets loaded in order if has inline scripts
    return jsList.reduce((chain, asset, index) => {
      return chain.then(() => appendExternalScript(asset, {
        root: jsRoot,
        scriptAttributes,
        id: `${PREFIX}-js-${index}`,
      }));
    }, Promise.resolve());
  }
  
  return Promise.all(
    jsList.map((asset, index) => appendExternalScript(asset, {
      root: jsRoot,
      scriptAttributes,
      id: `${PREFIX}-js-${index}`,
    })),
  );
}
/**
 * Create script element (without inline) and append to root
 * 创建script标签并插入到主应用的head标签内部
 */
export function appendExternalScript(asset: string | Asset,
  {
    id,
    root = document.getElementsByTagName('head')[0],
    scriptAttributes = [],
  }: {
    id: string;
    root?: HTMLElement | ShadowRoot;
    scriptAttributes?: ScriptAttributes;
  }): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    const { type, content, module } = (asset as Asset);
    // 创建script标签
    const element: HTMLScriptElement = document.createElement('script');
    // 行内 script 代码 直接插入到创建的script标签里面即可
    if (type && type === AssetTypeEnum.INLINE) {
      element.innerHTML = content;
      element.id = id;
      element.setAttribute(PREFIX, DYNAMIC); // 添加icestark=dynamic标识
      module && (element.type = 'module');
      root.appendChild(element);

      /*
      * For inline script never fire onload event, resolve it immediately.
      */
      resolve();
      return;
    }

    // 给script标签设置自定义的属性
    setAttributeForScriptNode(element, {
      module,
      id,
      src: content || (asset as string),
      scriptAttributes,
    });

    if (isAssetExist(element, 'script')) {
      resolve();
      return;
    }

    element.addEventListener(
      'error',
      () => {
        reject(
          new Error(
            formatErrMessage(
              ErrorCode.JS_LOAD_ERROR,
              isDev && 'The script resources loaded error: {0}',
              (content || asset) as string,
            ),
          ),
        );
      },
      false,
    );
    // 监听外部js 加载完成事件
    element.addEventListener('load', () => resolve(), false);

    // 将外部链接的script代码添加至主应用的head标签内
    root.appendChild(element);
  });
}

该函数是默认下载子应用js的一种方式,

  • 对于行内js,直接创建script标签去引用子应用的js,同时添加icestark=dynamic标识用于区分主应用的js,插入到主应用的header标签内。
  • 对于外部js,同样也是创建script标签,设置src为外部js的URL,添加icestark=dynamic标识,并监听script标签的load事件,等待子应用所有的js下载完成。

mountMicroApp 函数

/**
 * 执行子应用挂载的生命周期函数
 * @param appName
 */
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 });
  }
}

该函数用于在子应用静态资源获取完成并插入到主应用document之后,去执行子应用的已挂载生命周期,以及更新microApps中子应用的状态为 MOUNTED 已挂载。该函数是上面loadApp函数的最后一步执行代码。到此子应用的挂载就完成了。

总结

经过上面的源码阅读,现在可以对icestark加载子应用做一个简要的总结。

  • 定义microApps全局变量去储存所有子应用的配置信息
  • 解析子应用的html结构,从中获取子应用的行内以及外部js与css
  • 对于css的加载,直接创建link与style标签去引用子应用的css资源,并插入到主应用的header标签内
  • 对于js的加载,分为三种方式:es module 子应用js加载 、 fetch 加载子应用js、 默认加载子应用js
  1. es module 子应用js加载

创建script标签包裹行内js,利用import函数,导入外部js

  1. fetch 加载子应用js

无论是行内js还是外部js,都是通过eval函数去执行,只不过外部js要先通过fetch api 获取js 代码。

  1. 默认加载子应用js

无论是行内js还是外部js,都是直接创建script标签去引用子应用的js资源,并插入到主应用的header标签内

  • 子应用加载完成,执行子应用配置的挂载生命周期函数,更新microApps中对应子应用的配置信息