@ice/stark 随记

之前研究过相应的沙箱实现,现在走一遍整个微前端方案@ice/stark的启动流程。

版本:2.0.2

需求效果确认

再看实际代码之前,我们先简单确认下需求:主应用的路由改变时,内容节点能渲染对应的微前端。

根据该需求,首先需要每个微前端的配置上有对应的path去匹配。

但微前端不可能只有一个路由页面,但启动后的微前端,路由的改变是由主应用调用的,特别是主应用使用window.history.pushState等去修改路由的话,子应用是捕捉不到的,此时子应用就不会切换路由。

要解决上面的问题,就要统一主动触发子应用改变,所以就要拦截捕捉子应用的监听路由的方法,在匹配路由后去触发捕捉的方法。最后拦截了还是要重新包装注册监听,同样应该是先保存事件,待路由匹配完成后再触发。

以上这些都应该是启动完成前需要完成的,这些逻辑都能在入口里看到。

入口 start.ts

function start(options?: StartConfiguration) {
  if (started) {
    console.log('icestark has been already started');
    return;
  }
  started = true;
  recordAssets();
  // update globalConfiguration
  Object.keys(options || {}).forEach((configKey) => {
    globalConfiguration[configKey] = options[configKey];
  });
  hijackHistory();
  hijackEventListener();

  // trigger init router
  globalConfiguration.reroute(location.href, 'init');
}

首先就看到一个上面没有分析出来的recordAssets(),这是为了记录主应用固有的资源('style', 'link', 'script'),通过加个attribute去区分子应用动态append进来的相关资源,方便子应用unmount的时候需要这些资源。

接下来是配置的合并处理

然后终于到上面提到的逻辑了。
hijackHistory() 包装主应用主动改变路由的方法,缓存事件类型,待子应用挂载后触发下面拦截到的方法
hijackEventListener() 拦截捕捉子应用的监听路由的方法
globalConfiguration.reroute(location.href, 'init')这个就是统一触发子应用改变的方法了

包装拦截那里不深入了,看默认的reroute,这是可以通过options传入覆盖的。

let lastUrl = null;
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.checkActive(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;
};

初步看就是通过对比外部变量lastUrl去判断路由是否改变了。然后遍历所有的子应用配置,区分需要激活和需要被卸载的应用。然后并行异步去执行挂载和卸载,完成后触发子应用的路由监听方法callCapturedEventListeners()(关于官网的例子,实际是传入了自定义的reroute,大概是只显示一个子应用的话可以稍微优化下)

卸载时,包括要移除子应用的资源,以及清理沙箱。主要还是看挂载吧createMicroApp(activeApp)

function createMicroApp(app: string | AppConfig, appLifecyle?: AppLifecylceOptions) {
  const appConfig = getAppConfigForLoad(app, appLifecyle);
  const appName = appConfig && appConfig.name;
  // compatible with use inIcestark
  const container = (app as AppConfig).container || appConfig?.container;
  if (container && !getCache('root')) {
    setCache('root', container);
  }
  if (appConfig && appName) {
    // check status of app
    if (appConfig.status === NOT_LOADED || appConfig.status === LOAD_ERROR ) {
      if (appConfig.title) document.title = appConfig.title;
      updateAppConfig(appName, { status: LOADING_ASSETS });
      let lifeCycle: ModuleLifeCycle = {};
      try {
        lifeCycle = await loadAppModule(appConfig);
        // in case of app status modified by unload event
        if (getAppStatus(appName) === LOADING_ASSETS) {
          updateAppConfig(appName, { ...lifeCycle, status: NOT_MOUNTED });
        }
      } catch (err){
        globalConfiguration.onError(err);
        updateAppConfig(appName, { status: LOAD_ERROR });
      }
      if (lifeCycle.mount) {
        await mountMicroApp(appConfig.name);
      }
    } else if (appConfig.status === UNMOUNTED) {
      if (!appConfig.cached && appConfig.umd) {
        await loadAndAppendCssAssets(appConfig.appAssets || { cssList: [], jsList: []});
      }
      await mountMicroApp(appConfig.name);
    } else if (appConfig.status === NOT_MOUNTED) {
      await mountMicroApp(appConfig.name);
    } else {
      console.info(`[icestark] current status of app ${appName} is ${appConfig.status}`);
    }
    return getAppConfig(appName);
  } else {
    console.error(`[icestark] fail to get app config of ${appName}`);
  }
  return null;
}
  1. 在已注册的app列表中查找对应的子应用;
  2. 在全局变量中保存子应用挂载的节点,从而子应用启动时可根据getMountNode获得根节点;
  3. 接着根据状态去判断,先搞清楚这些状态:
    1. NOT_LOADED 初次注册的默认状态,未加载
    2. LOADING_ASSETS 加载资源中(js,css)
    3. LOAD_ERROR 加载失败
    4. NOT_MOUNTED 资源已加载,但还没挂载(这个状态应该是在加载资源后同一子应用内路由切换)
    5. MOUNTED 已挂载
    6. UNMOUNTED 已卸载,由于资源已保存到配置对象中,所以不会回到未加载状态。

若是NOT_LOADED 或 LOAD_ERROR ,则需要先加载资源,然后挂载

若是NOT_MOUNTED 则直接进行挂载就好

若是UNMOUNTED,则需要重新挂载css,js资源,但实际最多只会加载css,这里是有点问题。通过看官网例子得知,官网是在unmount生命周期后继续调用unloadMicroApp去清理资源缓存及修改状态到NOT_LOADED,所以实际UNMOUNTED只是短暂存在,幸好对于js是有缓存逻辑,所以不会看到js反复加载。

接下来看资源加载

function loadAppModule(appConfig: AppConfig) {
  let lifecycle: ModuleLifeCycle = {};
  globalConfiguration.onLoadingApp(appConfig);
  const appSandbox = createSandbox(appConfig.sandbox);
  const { url, container, entry, entryContent, name } = appConfig;
  const appAssets = url ? getUrlAssets(url) : await getEntryAssets({
    root: container,
    entry,
    href: location.href,
    entryContent,
    assetsCacheKey: name,
  }); // 获取js,css,html。
  updateAppConfig(appConfig.name, { appAssets, appSandbox });
  if (appConfig.umd) {
    await loadAndAppendCssAssets(appAssets);
    lifecycle = await loadUmdModule(appAssets.jsList, appSandbox);
  } else {
    await appendAssets(appAssets, appSandbox);
    lifecycle = {
      mount: getCache(AppLifeCycleEnum.AppEnter),
      unmount: getCache(AppLifeCycleEnum.AppLeave),
    };
    setCache(AppLifeCycleEnum.AppEnter, null);
    setCache(AppLifeCycleEnum.AppLeave, null);
  }
  globalConfiguration.onFinishLoading(appConfig);
  return combineLifecyle(lifecycle, appConfig);
}

沙箱已经研究过了,然后就到获取资源了,可以看出资源的提供可以有基本以下种类:

  1. 仅有js和css资源,可同过url属性提供;
  2. 通过html,这里又分两种,url形式的entry和直接提供模板的entryContent,一般是跨域的情况下使用entryContent。另外这种方式也兼容了有多个根节点的应用

主要看第二种,如何从html里获取资源

function getEntryAssets({
  root,
  entry,
  entryContent,
  assetsCacheKey,
  href,
  fetch = winFetch,
}: {
  root: HTMLElement | ShadowRoot;
  entry?: string;
  entryContent?: string;
  assetsCacheKey: string;
  href?: string;
  fetch?: Fetch;
  assertsCached?: boolean;
}) {
  let cachedContent = cachedProcessedContent[assetsCacheKey];
  if (!cachedContent) {
    let htmlContent = entryContent;
    if (!htmlContent && entry) {
      if (!fetch) {
        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`,
        );
      }

      const res = await fetch(entry);
      htmlContent = await res.text();
    }
    cachedContent = processHtml(htmlContent, entry || href);
    cachedProcessedContent[assetsCacheKey] = cachedContent;
  }

  root.innerHTML = cachedContent.html;
  return cachedContent.assets;
}

function processHtml(html: string, entry?: string): ProcessedContent {
  if (!html) return { html: '', assets: { cssList:[], jsList: []} };

  const processedJSAssets = [];
  const processedCSSAssets = [];
  const processedHtml = html
    .replace(COMMENT_REGEX, '')
    .replace(SCRIPT_REGEX, (...args) => {
      const [matchStr, matchContent] = args;
      if (!matchStr.match(SCRIPT_SRC_REGEX)) {
        processedJSAssets.push({
          type: AssetTypeEnum.INLINE,
          content: matchContent,
        });

        return getComment('script', 'inline', AssetCommentEnum.REPLACED);
      } else {
        return matchStr.replace(SCRIPT_SRC_REGEX, (_, argSrc2) => {
          const url = argSrc2.indexOf('//') >= 0 ? argSrc2 : getUrl(entry, argSrc2);
          processedJSAssets.push({
            type: AssetTypeEnum.EXTERNAL,
            content: url,
          });

          return getComment('script', argSrc2, AssetCommentEnum.REPLACED);
        });
      }
    })
    .replace(CSS_REGEX, (...args) => {
      const [matchStr, matchStyle, matchLink] = args;
      // not stylesheet, return as it is
      if (matchStr.match(STYLE_SHEET_REGEX)) {
        const url = matchLink.indexOf('//') >= 0 ? matchLink : getUrl(entry, matchLink);
        processedCSSAssets.push({
          type: AssetTypeEnum.EXTERNAL,
          content: url,
        });
        return `${getComment('link', matchLink, AssetCommentEnum.PROCESSED)}`;
      } else if (matchStyle){
        processedCSSAssets.push({
          type: AssetTypeEnum.INLINE,
          content: matchStyle,
        });
        return getComment('style', 'inline', AssetCommentEnum.REPLACED);
      }
      return matchStr;
    });
  return {
    html: processedHtml,
    assets: {
      jsList: processedJSAssets,
      cssList: processedCSSAssets,
    },
  };
}

提供entry的话用fetch获取html,这里也有缓存机制,解析就是通过processHtml,最后还会把渲染节点的innerHTML改为解析产生的html。

解析时,主要是使用了正则以及在replace的第二个参数传入function,这里就要知道function里能拿到什么结果了,第一个变量是符合正则的片段,接来下的可以是正则中分组(就是用小括号包住的)的片段,所以可以有多个。

  1. 备注清空
  2. js资源,没有src属性的,获取分组片段script包住的内容,有src的,结合entry去获得完整的url。
  3. css资源的也是相似的逻辑,同样最后会返回替换后的占位备注

最后就能获得干净的html,js和css列表

然后是加载资源,这里又分了两种,官方是推荐子应用使用umd打包,然后导出生命周期。

先看非umd的,就是直接把css资源插入html中,js话分直接插入和再沙箱中运行,然后把通过全局变量缓存的生命周期取出来,然后清空缓存。

而对于umd,css资源同样直接插入,而js的话,这里就要先知道如何获得新添加的全局变量,对于直接挂在window下而非proto上的变量,也就是Object.keys(),新增加的会在eys遍历的最后一个,源码上也补充,safari浏览器有时会把新增加的变量放在第一或第二个上。代码是要求生命周期由js列表的最后一个产生,所以结合以上的,js应该顺序运行,但有些可能要通过网络去获取,所以需要先获取所以的js代码,然后一个个运行,在最后一个运行前,记录第一,第二,和最后的全局key,运行后,对比此时的第一,第二,和最后的全局key,不一样的就是最后一个js代码导出的生命周期。最后记得delete掉对应全局变量的生命周期key,不然后面再次load的时候的获取不到了。

最后loadAppModule就只剩下合并生命周期到配置上了。

接下来就是等待callCapturedEventListeners的调用了,调用后,子应用就会在节点里渲染出来了

你可能感兴趣的:(@ice/stark 随记)