Phân tích mã nguồn singleSpa và importHTMLEntry trong micro-frontend

single-spa v5.9.3

Dựa trên cơ chế định tuyến nhẹ và mô hình trạng thái, single-spa thực hiện việc tải động và cô lập các ứng dụng con trong kiến trúc micro-frontend. Các chức năng chính bao gồm:

  • Quản lý định tuyến: Can thiệp vào sự kiện hashchange, popstate, pushState và replaceState để kích hoạt hàm reroute() khi thay đổi URL.
  • Quản lý trạng thái ứng dụng con: Chuyển đổi giữa các trạng thái theo logic thực thi khác nhau:
  • Quy trình tải: toLoadPromise → toBootstrapPromise → toMountPromise
  • Quy trình hủy: toUnmountPromise → toUnloadPromise
  • Gọi vòng đời ứng dụng con:
  • app.bootstrap(): Chỉ chạy một lần khi khởi tạo.
  • app.mount(): Kích hoạt mỗi khi ứng dụng được hiển thị.
  • app.unmount(): Khi ứng dụng chuyển từ trạng thái kích hoạt sang không kích hoạt.
  • app.unload(): Chạy một lần duy nhất khi ứng dụng bị gỡ bỏ hoàn toàn.

single-spa sử dụng phương thức JS Entry để tích hợp vào hệ thống micro-frontend.

Trong ứng dụng nền (host), ta cần đăng ký các ứng dụng con bằng cách chỉ định đường dẫn tương ứng và phương thức tải:

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

Sơ đồ quy trình tổng thể

1. registerApplication()

Khi khởi tạo ứng dụng nền, hàm registerApplication() được gọi để đăng ký ứng dụng con.

Từ mã nguồn, ta thấy rằng quá trình thực hiện gồm:

  • Chuẩn hóa thông tin cấu hình ứng dụng con: sanitizeArguments()
  • Thêm ứng dụng vào danh sách apps
  • Nếu đang chạy trên trình duyệt:
  • đảm bảo hỗ trợ jQuery: ensureJQuerySupport()
  • kích hoạt xử lý định tuyến: reroute()
function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );
  if (isInBrowser) {
    ensureJQuerySupport();
    reroute();
  }
}

1.1 reroute()

  • Tính toán trạng thái: Dùng getAppChanges() để xác định ứng dụng cần tải hay hủy dựa trên URL hiện tại. Phân loại thành 4 loại:

  • appsToLoad: Cần tải

  • appsToUnload: Cần gỡ bỏ

  • appsToMount: Cần gắn kết

  • appsToUnmount: Cần tháo gỡ

  • Dựa vào trạng thái start() đã được gọi hay chưa để quyết định hành động tiếp theo:

  • Nếu đã start: thực hiện performAppChanges()

  • Nếu chưa start: thực hiện loadApps()

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

Dựa trên trạng thái hiện tại của từng ứng dụng để phân loại:

apps.forEach(function (app) {
  const 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;
  }
});
1.1.2 loadApps()

Duyệt qua danh sách appsToLoad và gọi toLoadPromise(app) để bắt đầu tải ứng dụng con.

function loadApps() {
  return Promise.resolve().then(() => {
    const loadPromises = appsToLoad.map(toLoadPromise);
    return Promise.all(loadPromises)
      .then(callAllEventListeners)
      .then(() => [])
      .catch(err => {
        callAllEventListeners();
        throw err;
      });
  });
}
1.1.2.1 toLoadPromise()

Gọi hàm loadApp() do ứng dụng con cung cấp, trả về Promise.

Thay đổi trạng thái thành NOT_BOOTSTRAPPED.

function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.loadPromise) return app.loadPromise;
    if (![NOT_LOADED, LOAD_ERROR].includes(app.status)) return app;

    app.status = LOADING_SOURCE_CODE;
    return (app.loadPromise = Promise.resolve()
      .then(() => app.loadApp(getProps(app)))
      .then(val => {
        app.loadErrorTime = null;
        const 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(err => {
        // Xử lý lỗi
      }));
  });
}

2. Theo dõi thay đổi định tuyến

single-spa tự động thiết lập các lắng nghe sự kiện:

  • Lắng nghe hashchange và popstate → gọi urlReroute() → reroute()
  • Can thiệp vào window.addEventListener và removeEventListener: lưu các hàm lắng nghe từ ứng dụng con vào capturedEventListeners, chỉ thực thi sau khi hoàn tất quá trình unmount.
  • Ghi đè lên history.pushState và history.replaceState: thêm dispatchEvent cho sự kiện popstate để kích hoạt lại routing.

Tổng kết:

  • Mọi thay đổi định tuyến đều gây ra việc tải ứng dụng con.
  • Sử dụng pushState/replaceState cũng kích hoạt xử lý micro-frontend.
  • Ngăn chặn việc thực thi trực tiếp các hàm lắng nghe từ bên ngoài, trì hoãn đến khi xử lý xong.
const routingEventsListeningTo = ["hashchange", "popstate"];
if (isInBrowser) {
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  const originalAdd = window.addEventListener;
  const originalRemove = window.removeEventListener;
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function" && routingEventsListeningTo.includes(eventName)) {
      if (!find(capturedEventListeners[eventName], f => f === fn)) {
        capturedEventListeners[eventName].push(fn);
      }
      return;
    }
    return originalAdd.apply(this, arguments);
  };

  window.removeEventListener = function (eventName, listenerFn) {
    // ...
  };

  window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
  window.history.replaceState = patchedUpdateState(window.history.replaceState, "replaceState");

  window.singleSpaNavigate = navigateToUrl;
}

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

function callAllEventListeners() {
  pendingPromises.forEach(p => callCapturedEventListeners(p.eventArguments));
  callCapturedEventListeners(eventArguments);
}

3. Bắt đầu hệ thống với start()

Khi ứng dụng nền gọi start():

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

Sau khi start() được gọi, hệ thống chuyển từ giai đoạn đăng ký sang giai đoạn vận hành. Khi reroute() được gọi, nó sẽ đi vào performAppChanges().

3.1 getAppChanges()

Giống như ở trên, dựa trên trạng thái hiện tại để phân loại ứng dụng.

3.2 performAppChanges()
  • Trước tiên, xử lý việc gỡ bỏ các ứng dụng không còn hoạt động:
const unloadPromises = appsToUnload.map(toUnloadPromise);
const unmountUnloadPromises = appsToUnmount
  .map(toUnmountPromise)
  .map(unmountPromise => unmountPromise.then(toUnloadPromise));
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);
  • Tiếp theo, tải và gắn các ứng dụng mới:
const loadThenMountPromises = appsToLoad.map(app => 
  toLoadPromise(app).then(app => tryToBootstrapAndMount(app, unmountAllPromise))
);

const mountPromises = appsToMount
  .filter(app => !appsToLoad.includes(app))
  .map(app => tryToBootstrapAndMount(app, unmountAllPromise));
  • Cuối cùng, đợi unmount hoàn tất, rồi thực hiện tải và gắn:
return unmountAllPromise
  .catch(err => {
    callAllEventListeners();
    throw err;
  })
  .then(() => {
    callAllEventListeners();
    return Promise.all(loadThenMountPromises.concat(mountPromises))
      .catch(err => {
        pendingPromises.forEach(p => p.reject(err));
        throw err;
      })
      .then(finishUpAndReturn);
  });

3.2.1 tryToBootstrapAndMount()

Khi chuyển từ /app1 sang /app2:

  • Kiểm tra điều kiện kích hoạt: shouldBeActive(app2) → trả về true.
  • Gọi toBootstrapPromise(app2) → trạng thái BOOTSTRAPPING → gọi app2.bootstrap() → trạng thái NOT_MOUNTED.
  • Sau khi hoàn tất unmount /app1, kiểm tra lại shouldBeActive(app2):
  • Nếu vẫn đúng → gọi toMountPromise(app2)app2.mount() → MOUNTED.
  • Nếu sai → dừng lại, giữ trạng thái NOT_MOUNTED.
function tryToBootstrapAndMount(app, unmountAllPromise) {
  return shouldBeActive(app)
    ? toBootstrapPromise(app).then(() => unmountAllPromise.then(() => shouldBeActive(app) ? toMountPromise(app) : app))
    : unmountAllPromise.then(() => app);
}
3.2.2 toUnloadPromise()

Gọi app.unload() → thay đổi trạng thái thành UNLOADING → sau đó về NOT_LOADED.

function toUnloadPromise(app) {
  return Promise.resolve().then(() => {
    const unloadInfo = appsToUnload[toName(app)];
    if (app.status === NOT_LOADED) return finishUnloadingApp(app, unloadInfo);
    if (app.status === UNLOADING) return unloadInfo.promise.then(() => app);
    if (![NOT_MOUNTED, LOAD_ERROR].includes(app.status)) return app;

    const unloadPromise = app.status === LOAD_ERROR ? Promise.resolve() : reasonableTime(app, "unload");
    app.status = UNLOADING;
    return unloadPromise
      .then(() => finishUnloadingApp(app, unloadInfo))
      .catch(err => errorUnloadingApp(app, unloadInfo, err));
  });
}
3.2.3 finishUpAndReturn()
  • Trả về danh sách ứng dụng đã gắn.
  • Xử lý các promise chờ.
  • Phát sự kiện toàn cục.
  • Reset trạng thái appChangeUnderway.
  • Nếu còn pending, gọi reroute() tiếp.
function finishUpAndReturn() {
  const returnValue = getMountedApps();
  pendingPromises.forEach(p => p.resolve(returnValue));
  const eventName = appsThatChanged.length === 0 ? "single-spa:no-app-change" : "single-spa:app-change";
  window.dispatchEvent(new CustomEvent(eventName, getCustomEventDetail()));
  window.dispatchEvent(new CustomEvent("single-spa:routing-event", getCustomEventDetail()));
  appChangeUnderway = false;
  if (peopleWaitingOnAppChange.length > 0) {
    const next = peopleWaitingOnAppChange;
    peopleWaitingOnAppChange = [];
    reroute(next);
  }
  return returnValue;
}

import-html-entry v1.17.0

Xét file index.html mẫu:

<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");

Ta sử dụng importEntry(entry) để lấy dữ liệu ứng dụng con.

Với entry = "index.html", ta gọi importHTML():

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

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

  // Xử lý cấu hình
  if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {
    // ...
  } else {
    throw new SyntaxError("entry scripts or styles should be array!");
  }
}

Quy trình:

  • Lấy nội dung HTML: dùng fetch để tải file index.html.

  • Phân tích template: processTpl() trả về:

  • scripts: ["/app.js", ""]

  • entry: "/app.js"

  • styles: ["/style.css"]

  • Nội tuyến CSS: tải style.css và thay thế thẻ <link> bằng <style> trong template.

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

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

Kết quả trả về

3.1 template

Sau khi nội tuyến CSS, trả về đối tượng chứa:

  • template: HTML đã thay thế style.
  • assetPublicPath: Đường dẫn gốc của ứng dụng con.
  • getExternalScripts(): Hàm tải các script.
  • getExternalStyleSheets(): Hàm tải các stylesheet.
  • execScripts(proxy, strictGlobal, opts): Thực thi script đã tải, chạy trong môi trường sandbox để tránh ảnh hưởng đến global.
function importHTML(url, opts = {}) {
  return (
    embedHTMLCache[url] ||
    (embedHTMLCache[url] = fetch(url)
      .then(res => readResAsString(res, 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 });
          },
        }));
      }))
  );
}

Thẻ: single-spa micro-frontend import-html-entry JavaScript Modular Architecture

Đăng vào ngày 13 tháng 8 lúc 18:18