动态

详情 返回 返回

【微前端】singleSpa&importHTMLEntry(流程圖)源碼解析 - 动态 详情

single-spa v5.9.3

通過輕量級路由劫持和狀態機設計,實現微前端的動態加載與隔離,主要實現

  • 路由管理:hashchangepopstatehistory.pushStatehistory.replaceState進行劫持,路由變化時,觸發 reroute()
  • 子應用狀態管理:不同執行邏輯轉化不同的狀態,比如

    • 加載流程:toLoadPromisetoBootstrapPromisetoMountPromise
    • 卸載流程:toUnmountPromisetoUnloadPromise
  • 子應用生命週期觸發:

    • app.bootstrap():初始化時僅執行一次
    • app.mount():應用激活時觸發
    • app.unmount():應用從激活變為非激活狀態時觸發
    • app.unload():最終卸載時觸發一次
single-spa 採用 JS Entry 的方式接入微前端

我們需要在基座中註冊子應用,比如下面代碼中,我們註冊了對應的映射路徑 path 以及對應的加載的方法

registerApplication({
  name: "app1",
  app: loadApp(url),
  activeWhen: activeWhen("/app1"),
  customProps: {},
});

整體流程圖

single-spa

1. registerApplication()

在基座初始化時,會調用 registerApplication() 進行子應用的註冊

從下面的源碼我可以知道,主要執行:

  • 格式化用户傳遞的子應用配置參數:sanitizeArguments()
  • 將子應用加入到 apps 中
  • 如果是瀏覽器,則觸發

    • ensureJQuerySupport():增加 JQuery的支持
    • reroute():統一處理路由的方法
function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  var registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );
  if (isInBrowser) {
    ensureJQuerySupport();
    reroute();
  }
}

reroute()

  • 狀態計算:通過 getAppChanges() 根據當前的 URL 篩選出需要 加載/卸載 的應用,主要分為 4 種類型
  • 根據是否已經觸發 start(),從而決定要觸發

    • loadApps(): 加載應用資源,沒有其他邏輯
    • performAppChanges():卸載非 active 狀態的應用(調用 umount 生命週期) + 加載並掛載 active 子應用
function reroute() {
  if (appChangeUnderway) {
    return new Promise(function (resolve, reject) {
      peopleWaitingOnAppChange.push({
        resolve: resolve,
        reject: reject,
        eventArguments: eventArguments,
      });
    });
  }
  var { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
    getAppChanges();
  if (isStarted()) {
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }
}

1.1 getAppChanges()

根據目前 app.status 的狀態進行不同數組數據的組裝

  • appsToLoad
  • appsToUnload
  • appsToMount
  • appsToUnmount
apps.forEach(function (app) {
  var appShouldBeActive =
    app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
  switch (app.status) {
    case LOAD_ERROR:
      if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
        appsToLoad.push(app);
      }
      break;
    case NOT_LOADED:
    case LOADING_SOURCE_CODE:
      if (appShouldBeActive) {
        appsToLoad.push(app);
      }
      break;
    case NOT_BOOTSTRAPPED:
    case NOT_MOUNTED:
      if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
        appsToUnload.push(app);
      } else if (appShouldBeActive) {
        appsToMount.push(app);
      }
      break;
    case MOUNTED:
      if (!appShouldBeActive) {
        appsToUnmount.push(app);
      }
      break;
    // all other statuses are ignored
  }
});

1.2 loadApps()

loadApps() 中,就是遍歷 appsToLoad 數組 => toLoadPromise(app),本質就是觸發 app.loadApp()進行子應用的加載

function loadApps() {
  return Promise.resolve().then(function () {
    var loadPromises = appsToLoad.map(toLoadPromise);
    return (
      Promise.all(loadPromises)
        .then(callAllEventListeners)
        // there are no mounted apps, before start() is called, so we always return []
        .then(function () {
          return [];
        })
        .catch(function (err) {
          callAllEventListeners();
          throw err;
        })
    );
  });
}
1.2.1 toLoadPromise()

觸發 app.loadApp()進行子應用的加載

需要子應用提供一個 loadApp()並且返回 Promise

狀態改為 NOT_BOOTSTRAPPED

function toLoadPromise(app) {
  return Promise.resolve().then(function () {
    if (app.loadPromise) {
      return app.loadPromise;
    }
    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }
    app.status = LOADING_SOURCE_CODE;
    var appOpts, isUserErr;
    return (app.loadPromise = Promise.resolve()
      .then(function () {
        var loadPromise = app.loadApp(getProps(app));
        return loadPromise.then(function (val) {
          app.loadErrorTime = null;
          appOpts = val;
          app.status = NOT_BOOTSTRAPPED;
          app.bootstrap = flattenFnArray(appOpts, "bootstrap");
          app.mount = flattenFnArray(appOpts, "mount");
          app.unmount = flattenFnArray(appOpts, "unmount");
          app.unload = flattenFnArray(appOpts, "unload");
          app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
          delete app.loadPromise;
          return app;
        });
      })
      .catch(function (err) {
        //...
      }));
  });
}

2. 監聽路由變化

single-spa 源碼中有自動執行的一系列代碼:

  • 監聽 hashchangepopstate 變化,觸發urlReroute()->reroute()
  • 劫持 window.addEventListenerwindow.removeEventListener,將外部應用通過註冊的 ["hashchange", "popstate"] 的監聽方法 放入到 capturedEventListeners 中,在下面的 unmountAllPromise.then() 之後才會調用 capturedEventListeners 存儲的方法執行
  • 重寫 history.pushState()history.replaceState() 方法,在原來的基礎上增加 window.dispatchEvent(createPopStateEvent(window.history.state, methodName)) ,從而觸發第一步的 popstate 監聽,從而觸發 urlReroute()->reroute() 進行子應用路由的狀態同步

總結:

  • 路由變化觸發微前端子應用加載
  • pushState 和 replaceState 改變路由觸發微前端子應用加載
  • 阻止外部的hashchangepopstate對應的監聽方法直接執行,而是等待微前端執行後才觸發這些方法
var routingEventsListeningTo = ["hashchange", "popstate"];
if (isInBrowser) {
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  var originalAddEventListener = window.addEventListener;
  var originalRemoveEventListener = window.removeEventListener;
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function") {
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], function (listener) {
          return listener === fn;
        })
      ) {
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }
    return originalAddEventListener.apply(this, arguments);
  };
  window.removeEventListener = function (eventName, listenerFn) {
    //...
  };
  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );
  if (window.singleSpaNavigate) {
    //...
  } else {
    window.singleSpaNavigate = navigateToUrl;
  }
}

function urlReroute() {
  reroute([], arguments);
}

function callAllEventListeners() {
  pendingPromises.forEach(function (pendingPromise) {
    callCapturedEventListeners(pendingPromise.eventArguments);
  });
  callCapturedEventListeners(eventArguments);
}

3. start()啓動開始狀態

當基座主動觸發 single-spa 的 start() 方法時

function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

此時已經在監聽路由變化,然後進行 active 子應用的掛載 performAppChanges()

function reroute() {
  if (appChangeUnderway) {
    return new Promise(function (resolve, reject) {
      peopleWaitingOnAppChange.push({
        resolve: resolve,
        reject: reject,
        eventArguments: eventArguments,
      });
    });
  }
  var { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
    getAppChanges();
  if (isStarted()) {
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }
}

3.1 getAppChanges()

根據目前 app.status 的狀態進行不同數組數據的組裝

  • appsToLoad
  • appsToUnload
  • appsToMount
  • appsToUnmount
apps.forEach(function (app) {
  var appShouldBeActive =
    app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
  switch (app.status) {
    case LOAD_ERROR:
      if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
        appsToLoad.push(app);
      }
      break;
    case NOT_LOADED:
    case LOADING_SOURCE_CODE:
      if (appShouldBeActive) {
        appsToLoad.push(app);
      }
      break;
    case NOT_BOOTSTRAPPED:
    case NOT_MOUNTED:
      if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
        appsToUnload.push(app);
      } else if (appShouldBeActive) {
        appsToMount.push(app);
      }
      break;
    case MOUNTED:
      if (!appShouldBeActive) {
        appsToUnmount.push(app);
      }
      break;
    // all other statuses are ignored
  }
});

3.2 performAppChanges()

而當 start() 方法觸發後,started 設置為 true, 標誌着應用從 初始化註冊應用(加載應用)的模式進入到 運行階段(監聽路由變化)


此時觸發 reroute(),則進入 performAppChanges()

urlRerouteOnly控制路由觸發規則:

  • urlRerouteOnly=true:用户點擊或者使用 API 才會觸發 reroute()
  • urlRerouteOnly=false:任何 history.pushState() 的調用都會觸發 reroute()
function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

performAppChanges() 中,先組裝出需要卸載的子應用

var unloadPromises = appsToUnload.map(toUnloadPromise);
var unmountUnloadPromises = appsToUnmount
  .map(toUnmountPromise)
  .map(function (unmountPromise) {
    return unmountPromise.then(toUnloadPromise);
  });
var allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
var unmountAllPromise = Promise.all(allUnmountPromises);

再組裝出需要加載的應用

/* We load and bootstrap apps while other apps are unmounting, but we
 * wait to mount the app until all apps are finishing unmounting
 */
var loadThenMountPromises = appsToLoad.map(function (app) {
  return toLoadPromise(app).then(function (app) {
    return 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.
 */
var mountPromises = appsToMount
  .filter(function (appToMount) {
    return appsToLoad.indexOf(appToMount) < 0;
  })
  .map(function (appToMount) {
    return tryToBootstrapAndMount(appToMount, unmountAllPromise);
  });

先觸發 unmountAllPromise ,然後再觸發 loadThenMountPromises.concat(mountPromises),最終全部完成後觸發finishUpAndReturn

return unmountAllPromise
  .catch(function (err) {
    callAllEventListeners();
    throw err;
  })
  .then(function () {
    /* 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(function (err) {
        pendingPromises.forEach(function (promise) {
          return promise.reject(err);
        });
        throw err;
      })
      .then(finishUpAndReturn);
  });
在上面的方法中,我們看到了很多封裝的方法,比如toLoadPromise()tryToBootstrapAndMount()toUnloadPromise()finishUpAndReturn(),接下來我們將展開分析
3.2.1 tryToBootstrapAndMount()

當用户目前的路由是 /app1,導航到 /app2時:

  • 調用 app.activeWhen() 進行子應用狀態的檢測(需要子應用提供實現方法),shouldBeActive(app2) 返回 true
  • 觸發 toBootstrapPromise(app2) 更改狀態為 BOOTSTRAPPING,並且觸發子應用提供的 app2.bootstrap() 生命週期方法 => 更改狀態為 NOT_MOUNTED
  • 觸發傳入的unmountAllPromise,進行 /app1 卸載,然後再觸發 toMountPromise(app2) 執行子應用提供的 app2.mount() 生命週期方法,然後更改狀態為 MOUNTED
如果卸載完成 /app1 後,我們再次檢測 shouldBeActive(app2) 的時候發現路由改變,不是 /app2,那麼 app2 停止掛載,直接返回 app2,狀態仍然保留在 toBootstrapPromise(app2) 時的 NOT_MOUNTED
function tryToBootstrapAndMount(app, unmountAllPromise) {
  if (shouldBeActive(app)) {
    return toBootstrapPromise(app).then(function (app) {
      return unmountAllPromise.then(function () {
        return shouldBeActive(app) ? toMountPromise(app) : app;
      });
    });
  } else {
    return unmountAllPromise.then(function () {
      return app;
    });
  }
}
function shouldBeActive(app) {
  return app.activeWhen(window.location);
}
function toBootstrapPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(function () {
    if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
      return appOrParcel;
    }
    appOrParcel.status = BOOTSTRAPPING;
    if (!appOrParcel.bootstrap) {
      // Default implementation of bootstrap
      return Promise.resolve().then(successfulBootstrap);
    }
    return reasonableTime(appOrParcel, "bootstrap")
      .then(successfulBootstrap)
      .catch(function (err) {
        //...
      });
  });
  function successfulBootstrap() {
    appOrParcel.status = NOT_MOUNTED;
    return appOrParcel;
  }
}
function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(function () {
    return reasonableTime(appOrParcel, "mount")
      .then(function () {
        appOrParcel.status = MOUNTED;
        //...
        return appOrParcel;
      })
      .catch(function (err) {
        //...
      });
  });
}
3.2.2 toUnloadPromise()

邏輯也非常簡單,就是觸發子應用提供的 app2.unload() 生命週期方法,將狀態改為 UNLOADING => 將狀態改為 NOT_LOADED

var appsToUnload = {};
function toUnloadPromise(app) {
  return Promise.resolve().then(function () {
    var unloadInfo = appsToUnload[toName(app)];

    if (app.status === NOT_LOADED) {
      finishUnloadingApp(app, unloadInfo);
      return app;
    }
    if (app.status === UNLOADING) {
      return unloadInfo.promise.then(function () {
        return app;
      });
    }
    if (app.status !== NOT_MOUNTED && app.status !== LOAD_ERROR) {
      return app;
    }
    var unloadPromise =
      app.status === LOAD_ERROR
        ? Promise.resolve()
        : reasonableTime(app, "unload");
    app.status = UNLOADING;
    return unloadPromise
      .then(function () {
        finishUnloadingApp(app, unloadInfo);
        return app;
      })
      .catch(function (err) {
        errorUnloadingApp(app, unloadInfo, err);
        return app;
      });
  });
}
function finishUnloadingApp(app, unloadInfo) {
  delete appsToUnload[toName(app)];
  delete app.bootstrap;
  delete app.mount;
  delete app.unmount;
  delete app.unload;
  app.status = NOT_LOADED;
  unloadInfo.resolve();
}
3.3.3 finishUpAndReturn()
  • 返回已經掛載應用的列表
  • 處理等待中的 pendingPromises
  • 觸發全局事件通知
  • 重置全局狀態 appChangeUnderway = false
  • 檢測是否有未處理的後續請求,如果有,則重新觸發 reroute() 處理
function finishUpAndReturn() {
  var returnValue = getMountedApps();
  pendingPromises.forEach(function (promise) {
    return promise.resolve(returnValue);
  });
  var appChangeEventName =
    appsThatChanged.length === 0
      ? "single-spa:no-app-change"
      : "single-spa:app-change";
  window.dispatchEvent(
    new customEvent(appChangeEventName, getCustomEventDetail())
  );
  window.dispatchEvent(
    new customEvent("single-spa:routing-event", getCustomEventDetail())
  );

  appChangeUnderway = false;
  if (peopleWaitingOnAppChange.length > 0) {
    var nextPendingPromises = peopleWaitingOnAppChange;
    peopleWaitingOnAppChange = [];
    reroute(nextPendingPromises);
  }
  return returnValue;
}

import-html-entry v1.17.0

假設我們要轉化的 index.html 為:

<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Hello Micro Frontend</h1>
    <script src="app.js" entry></script>
    <script src="async.js" async></script>
    <script>console.log('Inline script');</script>
</body>
</html>

style.css 的具體內容為:

body { background-color: lightblue; }

app.js 內容為:

// 子應用導出的生命週期鈎子
export function bootstrap() {
  console.log("Sub app bootstrap");
}
export function mount() {
  console.log("Sub app mounted");
}
bootstrap();

**async.js** 內容為:

console.log("Async script loaded");
我們在外部使用這個庫一般直接使用 importEntry() 獲取子應用的數據

在我們這個示例中,會傳入一個 entry = "index.html",因此會直接走 importHTML()

function importEntry(entry, opts = {}) {
  const {
    fetch = defaultFetch,
    getTemplate = defaultGetTemplate,
    postProcessTemplate,
  } = opts;
  const getPublicPath =
    opts.getPublicPath || opts.getDomain || defaultGetPublicPath;

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

  // config entry
  if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {
    //...
  } else {
    throw new SyntaxError("entry scripts or styles should be array!");
  }
}

從下面代碼可以知道,主要分為幾個步驟:

  • 獲取 HTML 內容:通過fetch直接請求對應的 https://xxxxx 得到對應的 HTML 字符串(也就是我們上面示例的 index.html 內容)
  • 解析 HTML:調用 processTpl() 解析 HTML 得到

    • scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
    • entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
    • styles = ["https://sub-app.com/style.css "]
  • 將 CSS 樣式進行內聯:getEmbedHTML() 下載 style.css 的內容,替換 template 模板中 <link><style> 標籤
function importHTML(url, opts = {}) {
  //...
  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,
            });
          },
        }));
      }))
  );
}

1. processTpl()

processTpl() 在上面的分析中,我們可以知道,就是用來解析 HTML,得到:

  • scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
  • entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
  • styles = ["https://sub-app.com/style.css "]
那具體是如何運行的呢?

1.1 移除 HTML 註釋

const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;

.replace(HTML_COMMENT_REGEX, '')

1.2 處理 <link> 標籤

從下面的正則表達式可以知道,主要分為:

  • 處理<link rel="stylesheet">:提取出 href 並且轉為絕對路徑,然後進行兩種類型的註釋代碼轉化:

    • 檢測是否有 ignore 標記,通過 genIgnoreAssetReplaceSymbol() 轉化為 <!-- ignore asset ${url || 'file'} replaced by import-html-entry -->
    • 添加到 styles 數組中,然後通過 genLinkReplaceSymbol() 轉化為 <!-- ${preloadOrPrefetch ? 'prefetch/preload' : ''} link ${linkHref} replaced by import-html-entry -->
  • 處理預加載/預取資源:若匹配到 rel="preload"rel="prefetch" 且非字體資源,則通過 genLinkReplaceSymbol()轉化為佔位符註釋
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const LINK_IGNORE_REGEX = /<link(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/;
const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const LINK_AS_FONT = /.*\sas=('|")?font\1.*/;

.replace(LINK_TAG_REGEX, match => {
    /*
    change the css link
    */
    const styleType = !!match.match(STYLE_TYPE_REGEX);
    if (styleType) {

        const styleHref = match.match(STYLE_HREF_REGEX);
        const styleIgnore = match.match(LINK_IGNORE_REGEX);

        if (styleHref) {

            const href = styleHref && styleHref[2];
            let newHref = href;

            if (href && !hasProtocol(href)) {
                newHref = getEntirePath(href, baseURI);
            }
            if (styleIgnore) {
                return genIgnoreAssetReplaceSymbol(newHref);
            }

            newHref = parseUrl(newHref);
            styles.push(newHref);
            return genLinkReplaceSymbol(newHref);
        }
    }

    const preloadOrPrefetchType = match.match(LINK_PRELOAD_OR_PREFETCH_REGEX) && match.match(LINK_HREF_REGEX) && !match.match(LINK_AS_FONT);
    if (preloadOrPrefetchType) {
        const [, , linkHref] = match.match(LINK_HREF_REGEX);
        return genLinkReplaceSymbol(linkHref, true);
    }

    return match;
})

1.3 處理 <style> 標籤

如果 <style> 標籤中包含 ignore 屬性,則通過 genIgnoreAssetReplaceSymbol() 轉化為 <!-- ignore asset ${url || 'file'} replaced by import-html-entry -->,否則直接返回原來的值

const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;

.replace(STYLE_TAG_REGEX, match => {
    if (STYLE_IGNORE_REGEX.test(match)) {
        return genIgnoreAssetReplaceSymbol('style file');
    }
    return match;
})

1.4 處理 **<script>** 標籤

分為external script(匹配到src屬性或者匹配到<script>並且不具備type="text/ng-template"屬性)和 inline script兩種情況進行分析

在解析 HTML 模板時,忽略 Angular 的 **ng-template** 標籤 ,僅提取需要執行的腳本標籤
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|")text\/ng-template\3).)*?>.*?<\/\1>/is;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;

.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
  if (SCRIPT_TAG_REGEX.test(match) && scriptTag.match(SCRIPT_SRC_REGEX)) {
    //...
  } else {
    //...
  }
}
1.4.1 external script

將相對路徑的 matchedScriptSrc 通過 getEntirePath() 轉化為絕對路徑,並且使用 parseUrl() 標準化拿到標準化後的 src

然後分為 3 種情況進行處理:

  • 如果包含 ignore 屬性,則通過 genIgnoreAssetReplaceSymbol() 轉化為註釋代碼
  • 如果瀏覽器不支持 module 但是<script type="module">或者瀏覽器支持 module 但是 <script nomodule>,則通過 genModuleScriptReplayceSymbol() 轉化為註釋代碼
  • 提取出 asynccrossorigin 屬性,將當前 <script> 添加到 scripts數組中,然後通過 genScriptReplaceSymbol() 轉化為註釋代碼
if (matchedScriptSrc) {
  if (!hasProtocol(matchedScriptSrc)) {
    matchedScriptSrc = getEntirePath(matchedScriptSrc, baseURI);
  }
  matchedScriptSrc = parseUrl(matchedScriptSrc);
}
entry = entry || (matchedScriptEntry && matchedScriptSrc);

if (scriptIgnore) {
  return genIgnoreAssetReplaceSymbol(matchedScriptSrc || "js file");
}

const moduleScriptIgnore =
  (moduleSupport && !!scriptTag.match(SCRIPT_NO_MODULE_REGEX)) ||
  (!moduleSupport && !!scriptTag.match(SCRIPT_MODULE_REGEX));
if (moduleScriptIgnore) {
  return genModuleScriptReplaceSymbol(
    matchedScriptSrc || "js file",
    moduleSupport
  );
}

if (matchedScriptSrc) {
  const asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
  const crossOriginScript = !!scriptTag.match(SCRIPT_CROSSORIGIN_REGEX);
  scripts.push(
    asyncScript || crossOriginScript
      ? {
          async: asyncScript,
          src: matchedScriptSrc,
          crossOrigin: crossOriginScript,
        }
      : matchedScriptSrc
  );
  return genScriptReplaceSymbol(
    matchedScriptSrc,
    asyncScript,
    crossOriginScript
  );
}
return match;
1.4.2 inline script
  • 如果包含 ignore 屬性,則通過 genIgnoreAssetReplaceSymbol() 轉化為註釋代碼
  • 如果瀏覽器不支持 module 但是<script type="module">或者瀏覽器支持 module 但是 <script nomodule>,則通過 genModuleScriptReplayceSymbol() 轉化為註釋代碼
  • 如果是純註釋的代碼塊isPureCommentBlock() ,那麼直接返回 inlineScriptReplaceSymbol,不做任何處理;否則添加到 scripts 數組中
const isPureCommentBlock = code
  .split(/[\r\n]+/)
  .every((line) => !line.trim() || line.trim().startsWith("//"));
export const inlineScriptReplaceSymbol = `<!-- inline scripts replaced by import-html-entry -->`;
if (!isPureCommentBlock) {
  scripts.push(match);
}
return inlineScriptReplaceSymbol;
1.4.3 觸發 postProcessTemplate 鈎子函數
let tplResult = {
  template,
  scripts,
  styles,
  // set the last script as entry if have not set
  entry: entry || scripts[scripts.length - 1],
};
if (typeof postProcessTemplate === "function") {
  tplResult = postProcessTemplate(tplResult);
}
return tplResult;

2. getEmbedHTML()

processTpl() 解析 HTML,得到:

  • scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
  • entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
  • styles = ["https://sub-app.com/style.css "]
源碼中對應位置的代碼已經被我們轉化為註釋代碼,我們需要下載這些代碼並且轉化

我們通過 getExternalStyleSheets() 進行 fetch 請求拿到 styles = ["https://sub-app.com/style.css "] 的數據,返回 styleSheets=[{src, value}]

然後我們將 template 中對應的外部 CSS 替換為 <style>{styleSheet.value}</style> 的內聯樣式

function getEmbedHTML(template, styles, opts = {}) {
  const { fetch = defaultFetch } = opts;
  let embedHTML = template;

  return getExternalStyleSheets(styles, fetch).then((styleSheets) => {
    embedHTML = styleSheets.reduce((html, styleSheet) => {
      const styleSrc = styleSheet.src;
      const styleSheetContent = styleSheet.value;
      html = html.replace(
        genLinkReplaceSymbol(styleSrc),
        isInlineCode(styleSrc)
          ? `${styleSrc}`
          : `<style>/* ${styleSrc} */${styleSheetContent}</style>`
      );
      return html;
    }, embedHTML);
    return embedHTML;
  });
}

3. 返回值對象解析

3.1 template

替換 template 模板中 <link><style> 標籤後的內容,比如

從上面分析可以知道,我們會先使用 processTpl() 獲取 scripts、styles 的數據,然後下載 styles 的數據,然後轉化為 <style> 替換原來 template 數據
<html>...<style>body { background-color: lightblue; }</style>...</html>

3.2 assetPublicPath

微應用的路徑,比如 assetPublicPath: "https://sub-app.com/xxxx/xxxx/"(不包含最後的 index.html)

export function defaultGetPublicPath(entry) {
  if (typeof entry === "object") {
    return "/";
  }
  try {
    const { origin, pathname } = new URL(entry, location.href);
    const paths = pathname.split("/");
    // 移除最後一個元素
    paths.pop();
    return `${origin}${paths.join("/")}/`;
  } catch (e) {
    console.warn(e);
    return "";
  }
}

3.3 getExternalScripts

遍歷所有的 js 文件

  • 如果遇到內聯 js,直接返回:比如 <script>console.log('Inline script');</script>
  • 如果遇到外部 js,使用 fetch 進行 js 文件的請求下載:比如<script src="app.js" entry></script>
  • 如果遇到異步 js,也就是<script async></script>,通過 requestIdleCallback 進行延遲加載
scripts.map(async (script) => {
  if (typeof script === "string") {
    if (isInlineCode(script)) {
      // if it is inline script
      return getInlineCode(script);
    } else {
      // external script
      return fetchScript(script);
    }
  } else {
    // use idle time to load async script
    const { src, async, crossOrigin } = script;
    const fetchOpts = crossOrigin ? { credentials: "include" } : {};

    if (async) {
      return {
        src,
        async: true,
        content: new Promise((resolve, reject) =>
          requestIdleCallback(() =>
            fetchScript(src, fetchOpts).then(resolve, reject)
          )
        ),
      };
    }

    return fetchScript(src, fetchOpts);
  }
});

最終返回值為

[
  {
    src: "https://sub-app.com/app.js ",
    value: "export function bootstrap() ...bootstrap();",
  },
  {
    src: "https://sub-app.com/async.js ",
    async: true,
    content: Promise.resolve("console.log('Async script loaded');"),
  },
  {
    src: "<script>console.log('Inline script');</script>",
    value: "console.log('Inline script');",
  },
];

3.4 getExternalStyleSheets

styleCache[styleLink] ||
(styleCache[styleLink] = fetch(styleLink).then(response => {
  if (response.status >= 400) {
    throw new Error(`${styleLink} load failed with status ${response.status}`);
  }
  return response.text();
})
// 省略很多promise
if (result.status === 'fulfilled') {
  result.value = {
    src: styles[i],
    value: result.value,
  };
}
return result;

使用 fetch 進行樣式文件的請求下載,最終形成

[
  {
    src: "https://sub-app.com/style.css ",
    value: "body { background-color: lightblue; }",
  },
];

3.5 execScripts

傳入的 scripts 就是 上面分析的 getExternalScripts 的 scripts

if (!scripts.length) {
  return Promise.resolve();
}
return execScripts(entry, scripts, proxy, {
  fetch,
  strictGlobal,
  ...opts,
});

使用 getExternalScripts() 進行 js 文件的下載並存入緩存中,然後對每一個 JS 都調用 geval(scriptSrc, inlineScript)

  • entry JS:

    • noteGlobalProps() 記錄微應用在執行入口腳本前的全局變量,用於後續沙箱清理
    • 觸發geval()生成沙箱代碼並執行,確保 JS 在代理的上下文中運行,避免全局污染
    • 觸發 resolve(exports) 將 entry JS 導出對象(包含生命週期鈎子函數)傳遞給外部
  • 同步的 JS:觸發geval()生成沙箱代碼並執行
  • 異步的 JS:在瀏覽器空閒時觸發下載的方法然後再調用geval()生成沙箱代碼並執行
export function execScripts(entry, scripts, proxy = window, opts = {}) {
  //...
  return getExternalScripts(scripts, fetch, entry).then((scriptsText) => {
    const geval = (scriptSrc, inlineScript) => {
      //...
    };
    function exec(scriptSrc, inlineScript, resolve) {
      if (scriptSrc === entry) {
        noteGlobalProps(strictGlobal ? proxy : window);
        geval(scriptSrc, inlineScript);
        const exports =
          proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
        resolve(exports);
      } else {
        if (typeof inlineScript === "string") {
          if (scriptSrc?.src) {
            geval(scriptSrc.src, inlineScript);
          } else {
            geval(scriptSrc, inlineScript);
          }
        } else {
          // external script marked with async
          inlineScript.async &&
            inlineScript?.content.then((downloadedScriptText) =>
              geval(inlineScript.src, downloadedScriptText)
            );
        }
      }
    }
    function schedule(i, resolvePromise) {
      if (i < scriptsText.length) {
        const script = scriptsText[i];
        const scriptSrc = script.src;
        const inlineScript = script.value;

        exec(scriptSrc, inlineScript, resolvePromise);
        // resolve the promise while the last script executed and entry not provided
        if (!entry && i === scriptsText.length - 1) {
          resolvePromise();
        } else {
          schedule(i + 1, resolvePromise);
        }
      }
    }

    return new Promise((resolve) => schedule(0, success || resolve));
  });
}
3.5.1 geval()生成沙箱代碼
const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
const code = getExecutableScript(scriptSrc, rawCode, {
  proxy,
  strictGlobal,
  scopedGlobalVariables,
});

evalCode(scriptSrc, code);

afterExec(inlineScript, scriptSrc);

通過 getExecutableScript() 生成 sandbox 代碼

(function (window, self, globalThis) {
  with (window) {
    // app.js 的原始代碼
    export function bootstrap() {
      console.log("Sub app bootstrap");
    }
    export function mount() {
      console.log("Sub app mounted");
    }
    bootstrap();
  }
}).bind(proxy)(proxy, proxy, proxy);

使用 evalCode() 轉化代碼並緩存到 evalCache,然後強制該函數在全局上下文中執行

使用 **(0, eval)** 是一種“間接調用”方式,強制 **eval** 在全局作用域中執行,從而模擬瀏覽器對 **<script>** 標籤的執行行為
function evalCode(scriptSrc, code) {
  const key = scriptSrc;
  if (!evalCache[key]) {
    const functionWrappedCode = `(function(){${code}})`;
    evalCache[key] = (0, eval)(functionWrappedCode);
  }
  const evalFunc = evalCache[key];
  evalFunc.call(window);
}

evalCode 示例

evalCode("app.js", 'console.log("Hello from eval"); var x = 42;');

比如上面這段代碼,經過 evalCode() 轉化得到 evalFunc,然後直接觸發 evalFunc 的執行

(function () {
  console.log("Hello from eval");
  var x = 42;
}).call(window);

4. 總結

我們通過 processTpl() 抽離出資源數據,並且將這些資源數據代碼轉化為註釋代碼,避免重複多次加載

  • scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"]
  • entry = "[https://sub-app.com/app.js](https://sub-app.com/app.js) "
  • styles = ["https://sub-app.com/style.css "]

然後調用 getEmbedHTML() 先把上面得到的 styles 下載並轉化為 <style></style> 內聯樣式替換到 template 中,然後返回一個對象數據,包括

  • template:替換了所有 styles 的模板數據
  • assetPublicPath:微應用的路徑
  • getExternalScripts():提供外部調用可以下載 scripts = ["https://sub-app.com/app.js ", "<script>console.log('Inline script');</script>"] 的方法
  • getExternalStyleSheets():提供外部調用可以下載 styles = ["https://sub-app.com/style.css "] 的方法
  • execScripts():執行 getExternalScripts() 下載 scripts,然後調用 geval() 生成沙箱代碼並執行,確保 JS 在代理的上下文中運行,避免全局污染
function importHTML(url, opts = {}) {
  //...
  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,
            });
          },
        }));
      }))
  );
}
user avatar cyzf 头像 Leesz 头像 alibabawenyujishu 头像 zaotalk 头像 smalike 头像 freeman_tian 头像 qingzhan 头像 kobe_fans_zxc 头像 dirackeeko 头像 chongdianqishi 头像 razyliang 头像 leexiaohui1997 头像
点赞 165 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.