@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的调用了,调用后,子应用就会在节点里渲染出来了

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • Vue Vue是一个前端js框架,由尤雨溪开发,是个人项目 Vue近几年来特别的受关注,三年前的时候angular...
    hcySam阅读 276评论 0 0
  • 前端常见的一些问题 1.前端性能优化手段? 1. 尽可能使用雪碧图2. 使用字体图标代替图片3. 对HTML,cs...
    十八人言阅读 1,105评论 0 1
  • 完整版推荐在线阅读 https://interview2.poetries.top/[https://interv...
    程序员poetry阅读 3,434评论 2 20
  • 1、vue的生命周期 初始化: beforeCreate:一般没啥用,数据没挂载,DOM 没有渲染出来 creat...
    予她_阅读 1,534评论 0 24
  • 谈一下你对 MVVM 的认识 https://blog.csdn.net/Dora_5537/article/de...
    Aniugel阅读 9,586评论 1 91