Web Totals

Học từ web lớn #64: Ghép micro-frontend (Similarweb, InVision, HubSpot)

Similarweb cho AngularJS và React chung trang bằng single-spa, InVision truyền dữ liệu qua URL, HubSpot nói chuyện giữa iframe. Đọc code và viết lại.

hunghg255hunghg25527 min read
#web-patterns#similarweb#invision#hubspot#micro-frontend#postmessage
Học từ web lớn #64: Ghép micro-frontend (Similarweb, InVision, HubSpot)

Ứng dụng web lớn hiếm khi là một khối code. Similarweb có một app AngularJS viết từ những năm 2010 và các trang React mới; InVision có trang chủ (home-ui) và trang quản lý team (team-management-web) do hai nhóm khác nhau làm; HubSpot nhúng thanh thông báo, banner dùng thử và nhiều widget khác bằng iframe vào trang của các sản phẩm. Các mảnh này phải cùng sống trên một trang và nói chuyện với nhau, dù mỗi mảnh có framework, có lịch phát hành riêng.

Bài này đọc ba "cây cầu" nối các mảnh đó:

  1. Mount theo route (Similarweb): mỗi app tự gắn vào và gỡ ra khỏi trang khi URL đổi.
  2. Truyền dữ liệu qua URL (InVision): app này mở trang của app kia, gửi kèm dữ liệu trong tham số.
  3. postMessage giữa frame (HubSpot): iframe báo cho trang chứa, trang chứa báo lại cho iframe.

Tip

Tóm tắt

  • Mỗi micro-app có bootstrap, mount, unmount và activeWhen(url). Khi URL đổi: gỡ app cũ trước, gắn app mới sau, xếp hàng các lần đổi dồn dập. Lỗi lúc bootstrap phải báo ra, không nuốt rồi để Promise treo.
  • Dữ liệu đi qua URL thì dựng bằng URLSearchParams và mã hoá base64url từ UTF-8; nối chuỗi tay làm hỏng tham số có & và btoa ném lỗi với tiếng Việt.
  • Kiểm event.origin bằng cách parse URL rồi so hostname, không dùng regex chưa neo hai đầu; kiểm thêm event.source; gửi đi thì ghi rõ targetOrigin, không dùng '*' cho dữ liệu quan trọng.
  • document.domain (cách cũ để iframe cùng site đọc DOM của nhau) đã bị Chrome vô hiệu hoá mặc định từ 2023: đừng dựa vào nó.

Trong demo: đổi route giữa /dashboard, /sspa/insights, /onboarding để thấy app cũ và app React lần lượt được gỡ, gắn. Tick "Bootstrap của app cũ lỗi" để thấy app bị đánh dấu broken thay vì treo. Phần 2 so URL kiểu InVision và bản viết lại; nhập type có dấu để thấy btoa ném lỗi. Phần 3 là một iframe thật gửi tin cho trang chứa, kèm bảng so regex kiểm origin của HubSpot với cách parse URL.

#Similarweb: AngularJS và React chung một trang

Similarweb dùng single-spa, thư viện điều phối micro-frontend theo route. Cấu hình nằm ở pro.similarweb.com/single-spa/single-spa-config.ts:

const matchesRegex = (regex) => (location) => regex.test(location.hash); // viết gọn lại

const commonEnv = await bootstrap();
registerApplication("angularjs", () => import("./angularjs/angularjs.app"),
    matchesRegex(/^(?!.*(ripng|sspa|onboarding))/), commonEnv);
registerApplication("react", () => import("./react/react.app"), matchesRegex(/sspa/), commonEnv);
start();
ts

Đáng chú ý:

  • App cũ là mặc định. Regex ^(?!.*(ripng|sspa|onboarding)) là "negative lookahead": khớp mọi hash không chứa ba từ đó. Mọi route chưa chuyển sang React vẫn thuộc AngularJS; muốn chuyển một trang thì đổi URL của nó sang dạng có sspa. Đây là cách làm của mẫu "strangler fig" (cây sung bóp nghẹt): app mới lớn dần quanh app cũ, từng route một.
  • Mỗi app là một chunk riêng (import()), chỉ tải khi route khớp lần đầu.
  • Dữ liệu dùng chung tải một lần. bootstrap() trong single-spa/bootstrap.tsx tải song song dữ liệu người dùng, cài đặt, bản dịch, mục gần đây, mục yêu thích… rồi truyền cho cả hai app dưới dạng customProps (commonEnv). Hai app không phải tự gọi lại cùng API.

#Bộ chuyển đổi cho AngularJS

single-spa cần mỗi app xuất ra ba hàm bootstrap, mount, unmount. AngularJS không có khái niệm đó, nên Similarweb dùng một adapter (single-spa/angularjs/angularjsLifecycleManager.ts). Tên tuỳ chọn và thông báo lỗi trong file (single-spa-angularjs requires a configuration object) cho thấy nó là bản chép, sửa nhẹ từ package mã nguồn mở single-spa-angularjs:

function unmount(opts, mountedInstances): Promise<void> {
    return new Promise((resolve) => {
        const container = getContainerEl(opts);
        if (!opts.appRemainsMounted) {
            mountedInstances.instance.get("$rootScope").$destroy();
            container.innerHTML = "";
        } else {
            container.style.display = "none";
        }
        setTimeout(resolve);
    });
}
ts

mount (không trích ở đây) tạo một phần tử mới trong container rồi gọi angular.bootstrap lên nó; unmount gọi $rootScope.$destroy() để AngularJS gỡ mọi watcher, listener, $interval rồi xoá DOM. Tuỳ chọn appRemainsMounted cho phép chỉ ẩn app bằng display: none thay vì huỷ, đổi lấy việc quay lại nhanh (giống keep-alive, xem bài keep-alive của series Facebook). Similarweb để false: mỗi lần rời khỏi vùng AngularJS là huỷ hẳn.

#Lỗi: bootstrap lỗi thì treo mãi

Hàm bootstrap của app AngularJS (single-spa/angularjs/angularjs.app.ts):

export const bootstrap = (opts, props) => {
    return new Promise<void>(async (resolve) => {
        try {
            await legacyBootstrap(opts);
            await import("../../scripts");
            await import("../../app");
            window.runSWModule(opts);
            resolve();
        } catch (e) {
            swLog.error("ERROR in bootstrap: ", e);
        }
    });
};
ts

Nhánh catch ghi log nhưng không gọi reject. Promise không bao giờ xong, single-spa đứng chờ ở bước bootstrap mãi (theo tài liệu của single-spa, mặc định nó chỉ cảnh báo khi quá thời gian chứ không huỷ). Hàm mount, nơi xoá màn hình loading .app-loader, không bao giờ chạy: người dùng thấy loading vô tận thay vì một trang báo lỗi. Thêm vào đó, new Promise(async (resolve) => …) là mẫu nên tránh: hàm async bên trong đã trả về một Promise, bọc thêm một lớp chỉ tạo cơ hội để quên reject.

#InVision: truyền dữ liệu qua URL

Khi người dùng chọn "Chuyển tài liệu sang team khác" ở trang chủ InVision, trang chủ chuyển sang app quản lý team kèm dữ liệu trong URL (utils/encodeDocumentTransferData.js):

export const encodeDocumentTransferData = ({ id, type }) => {
  /* … This gets stringifyied and base64 encoded to make sure it never exceeds the url query
      parameter limit. As of now, this is the only way to share data between home-ui and
      team-management-web */
  const documentData = JSON.stringify({ [type]: [`${id}`] })
  return window.btoa(documentData)
}
js

Nơi gọi trong components/Document/TileContainer.jsx:

const redirectTo = `${pathname}${search}`
const encodedDocumentData = encodeDocumentTransferData({ id, type })
const href = `/teams/documents/transfer?transferDocuments=${encodedDocumentData}&redirectTo=${redirectTo}`
navigate(href)
js

Ý tưởng hợp lý: hai app không chung store, không chung backend-for-frontend, thì URL là "hợp đồng" đơn giản nhất. Nhưng có ba chỗ không ổn:

  1. Chú thích sai về base64. Base64 làm dữ liệu dài thêm khoảng một phần ba, không giúp tránh giới hạn độ dài URL. Lợi ích thật của base64 ở đây là gói JSON (có {, ", [) thành một chuỗi ít ký tự đặc biệt hơn.
  2. Không mã hoá URL. Base64 chuẩn có thể chứa +, /, =. Khi đọc bằng URLSearchParams, + thành dấu cách. Nặng hơn là redirectTo: nếu trang hiện tại có query ?filter=mine&sort=recent thì &sort=recent bị tách thành một tham số riêng của URL mới, redirectTo chỉ còn /projects?filter=mine. Demo cho thấy đúng như vậy.
  3. btoa chỉ nhận Latin-1. Một ký tự ngoài bảng mã đó (tiếng Việt, emoji…) là btoa ném InvalidCharacterError. Với id và type do hệ thống đặt thì có lẽ không gặp, nhưng hàm trông như dùng được cho mọi dữ liệu.

#HubSpot: iframe nói chuyện với trang chứa

Thanh thông báo của HubSpot chạy trong iframe, còn biểu tượng chuông có số đỏ nằm ở thanh điều hướng của trang chứa. Có hai cách để hai bên nói chuyện, và code HubSpot dùng cả hai.

#Cách cũ: document.domain và đọc thẳng DOM của trang cha

js/util/CounterUtil.js:

try {
  nav = window.parent.document.querySelector("#" + NEW_NAV_ID);
} catch (error) {
  document.domain = window.location.hostname.indexOf('qa') > 0 ? 'hubspotqa.com' : 'hubspot.com';
  // … thử lại, lỗi nữa thì gán document.domain = hostname rồi thử lần cuối
}
js

Khi iframe và trang chứa nằm ở hai subdomain khác nhau thì chúng khác origin, đọc window.parent.document sẽ ném SecurityError. Mẹo cũ: cả hai trang cùng gán document.domain = 'hubspot.com', trình duyệt coi chúng là cùng origin, iframe đọc và sửa thẳng DOM của trang cha (thêm <span> số thông báo vào nút chuông). js/util/BrowserApiUtil.js còn gắn hàm lên window.top.hubspot.notifications để trang chứa gọi trực tiếp, và báo SecurityError về Sentry kèm document.domain, document.referrer để biết trang nào không cho truy cập.

Cách này giờ không dùng được nữa: Chrome đã vô hiệu hoá việc gán document.domain theo mặc định từ bản 115 (giữa năm 2023), trừ khi trang gửi header Origin-Agent-Cluster: ?0 để xin giữ hành vi cũ, và MDN đánh dấu nó là deprecated (tính tới 10/2026). Lý do: nó phá vỡ ranh giới origin, và buộc trình duyệt đặt các trang cùng site vào cùng một tiến trình.

#Cách đúng: postMessage

js/manager/PostMessageManager.js:

// Only accept messages from hubspot urls
if (!event.origin.match(/\.hubspot(qa)?\.com/)) {
  return;
}
// … JSON.parse(event.data) trong try, rồi switch (message.name):
// hiện / gỡ popup xin quyền thông báo, đặt lại số trên chuông
js

Có kiểm origin, có parse JSON trong try, có switch theo tên: đúng hướng. Nhưng regex /\.hubspot(qa)?\.com/ không neo đầu cuối: nó tìm chuỗi con .hubspot.com ở bất cứ đâu trong origin. https://app.hubspot.com.attacker.io khớp. https://evil.hubspotqa.com-login.net cũng khớp. http:// (không mã hoá) cũng khớp. Ngược lại https://hubspot.com (không có dấu chấm phía trước) thì bị chặn. Ở đây thiệt hại có lẽ nhỏ (trang lạ chỉ làm được việc như đặt lại số trên chuông), nhưng cùng mẫu regex này đặt trước một tin nhắn quan trọng hơn thì thành lỗ hổng.

Phía gửi trong cùng thư mục (js/utils.js) có hai điểm đáng học và một điểm nên tránh:

  • verifyEvent(window.parent, event) kiểm event.source (tin nhắn đến từ đúng cửa sổ cha), một lớp kiểm hay bị bỏ quên.
  • Một MutationObserver theo dõi document.body, mỗi khi chiều cao đổi thì gửi HEIGHT_CHANGE cho trang cha để nó chỉnh chiều cao iframe: iframe "tự co giãn theo nội dung".
  • Nhưng mọi tin đều gửi với targetOrigin = '*': nếu iframe bị trang khác nhúng, trang đó nhận được hết. Với chiều cao hay tín hiệu "đã sẵn sàng" thì không sao; với dữ liệu người dùng thì phải ghi rõ origin đích.

#Bên trong: luồng của một lần đổi route

sequenceDiagram
    participant U as "URL"
    participant R as "Registry"
    participant A as "App cũ (AngularJS)"
    participant B as "App mới (React)"
    U->>R: "đổi sang /sspa/insights"
    R->>A: "activeWhen? không khớp: unmount"
    A-->>R: "đã gỡ, dọn watcher"
    R->>B: "activeWhen? khớp, chưa tải: load()"
    B-->>R: "bootstrap (một lần)"
    R->>B: "mount(container)"
    Note over R: "lỗi ở bất kỳ bước nào thì app thành broken, không treo"

Thứ tự "gỡ trước, gắn sau" quan trọng: hai framework cùng nghe sự kiện hashchange, cùng chỉnh document.title, cùng thêm phím tắt toàn cục. Để hai app cùng sống trên màn hình dù chỉ một khung hình là dễ có hành vi lạ.

#Viết lại

Registry tối giản, thay cho single-spa trong demo. Không phải để thay thư viện trong dự án thật, mà để thấy phần lõi chỉ có vài chục dòng. Hàm áp dụng một URL:

src/lib/microFrontend.ts
  async function apply(path: string | null) {
    // Gỡ app không còn khớp TRƯỚC, rồi mới gắn app mới: hai app không bao giờ cùng chiếm màn hình
    for (const app of apps) {
      if (app.status === 'mounted' && (path === null || !app.activeWhen(path))) {
        try {
          await app.lifecycles!.unmount(app.container(), props);
          setStatus(app, 'unmounted');
        } catch (error) {
          setStatus(app, 'broken', error);
        }
      }
    }
    if (path === null) return;
    for (const app of apps) {
      if (app.status === 'mounted' || app.status === 'broken' || !app.activeWhen(path)) continue;
      try {
        if (!app.lifecycles) {
          app.lifecycles = await app.load();
          // bootstrap lỗi thì ném ra ngoài (KHÔNG nuốt rồi để Promise treo mãi)
          await app.lifecycles.bootstrap?.(props);
        }
        await app.lifecycles.mount(app.container(), props);
        setStatus(app, 'mounted');
      } catch (error) {
        setStatus(app, 'broken', error);
      }
    }
  }
ts

Kiểm origin:

src/lib/microFrontend.ts
/**
 * Origin có thuộc `domain` (chính nó hoặc subdomain) không. So khớp trên hostname đã parse,
 * không dùng regex chưa neo hai đầu.
 */
export function isTrustedOrigin(origin: string, domain: string, { allowHttp = false } = {}): boolean {
  let url: URL;
  try {
    url = new URL(origin);
  } catch {
    return false; // "null" (iframe sandbox, file://…) cũng rơi vào đây
  }
  if (url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:')) return false;
  return url.hostname === domain || url.hostname.endsWith(`.${domain}`);
}
ts

Phần xếp hàng reroute, encodeTransferParam, buildTransferUrl và listenToFrames nằm trong file đầy đủ:

Xem toàn bộ src/lib/microFrontend.ts
src/lib/microFrontend.ts
/**
 * Ba "cây cầu" giữa các mảnh của một micro-frontend, ý tưởng từ:
 * - pro.similarweb.com (single-spa/single-spa-config.ts): AngularJS cũ và React mới cùng sống
 *   trong một trang, mỗi app tự mount/unmount theo URL.
 * - invisionapp.com (utils/encodeDocumentTransferData.js): truyền dữ liệu sang app khác qua
 *   một tham số URL mã hoá base64.
 * - app.hubspot.com (js/manager/PostMessageManager.js, js/util/CounterUtil.js): iframe thông
 *   báo nói chuyện với trang chứa bằng postMessage (và document.domain, cách cũ).
 */

// ---------------------------------------------------------------------------
// 1. Registry mount/unmount theo route (single-spa tối giản)
// ---------------------------------------------------------------------------

export interface Lifecycles<P> {
  /** chạy một lần, lần đầu app được kích hoạt */
  bootstrap?: (props: P) => Promise<void> | void;
  mount: (container: HTMLElement, props: P) => Promise<void> | void;
  unmount: (container: HTMLElement, props: P) => Promise<void> | void;
}

export interface AppDefinition<P> {
  name: string;
  /** tải code của app (thường là import() động) */
  load: () => Promise<Lifecycles<P>>;
  activeWhen: (path: string) => boolean;
  container: () => HTMLElement;
}

export type AppStatus = 'not-loaded' | 'mounted' | 'unmounted' | 'broken';

export function createAppRegistry<P>(props: P, onChange?: (name: string, status: AppStatus, error?: unknown) => void) {
  const apps: (AppDefinition<P> & { status: AppStatus; lifecycles?: Lifecycles<P> })[] = [];
  let queue = Promise.resolve();
  let pending: string | null = null;

  const setStatus = (app: (typeof apps)[number], status: AppStatus, error?: unknown) => {
    app.status = status;
    onChange?.(app.name, status, error);
  };

  async function apply(path: string | null) {
    // Gỡ app không còn khớp TRƯỚC, rồi mới gắn app mới: hai app không bao giờ cùng chiếm màn hình
    for (const app of apps) {
      if (app.status === 'mounted' && (path === null || !app.activeWhen(path))) {
        try {
          await app.lifecycles!.unmount(app.container(), props);
          setStatus(app, 'unmounted');
        } catch (error) {
          setStatus(app, 'broken', error);
        }
      }
    }
    if (path === null) return;
    for (const app of apps) {
      if (app.status === 'mounted' || app.status === 'broken' || !app.activeWhen(path)) continue;
      try {
        if (!app.lifecycles) {
          app.lifecycles = await app.load();
          // bootstrap lỗi thì ném ra ngoài (KHÔNG nuốt rồi để Promise treo mãi)
          await app.lifecycles.bootstrap?.(props);
        }
        await app.lifecycles.mount(app.container(), props);
        setStatus(app, 'mounted');
      } catch (error) {
        setStatus(app, 'broken', error);
      }
    }
  }

  return {
    register(app: AppDefinition<P>) {
      apps.push({ ...app, status: 'not-loaded' });
    },
    /**
     * Gọi mỗi khi URL đổi. Các lần đổi dồn dập được xếp hàng, và nếu trong lúc chờ có URL mới
     * hơn thì chỉ áp dụng URL mới nhất.
     */
    reroute(path: string): Promise<void> {
      pending = path;
      queue = queue.then(() => {
        if (pending === null) return;
        const next = pending;
        pending = null;
        return apply(next);
      });
      return queue;
    },
    /** gỡ hết (trang chứa bị huỷ) */
    destroy(): Promise<void> {
      pending = null;
      queue = queue.then(() => apply(null));
      return queue;
    },
    status: (name: string) => apps.find((a) => a.name === name)?.status,
  };
}

// ---------------------------------------------------------------------------
// 2. Truyền dữ liệu qua URL
// ---------------------------------------------------------------------------

/** JSON → UTF-8 → base64url (không có + / =, an toàn trong URL, không lỗi với tiếng Việt). */
export function encodeTransferParam(data: unknown): string {
  const bytes = new TextEncoder().encode(JSON.stringify(data));
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export function decodeTransferParam<T>(param: string): T {
  const base64 = param.replace(/-/g, '+').replace(/_/g, '/');
  const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
  return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
}

/** Dựng URL bằng URLSearchParams: mọi giá trị đều được mã hoá, kể cả `redirectTo` có `?` và `&`. */
export function buildTransferUrl(path: string, params: Record<string, string>): string {
  return `${path}?${new URLSearchParams(params)}`;
}

// ---------------------------------------------------------------------------
// 3. postMessage giữa các frame
// ---------------------------------------------------------------------------

/**
 * Origin có thuộc `domain` (chính nó hoặc subdomain) không. So khớp trên hostname đã parse,
 * không dùng regex chưa neo hai đầu.
 */
export function isTrustedOrigin(origin: string, domain: string, { allowHttp = false } = {}): boolean {
  let url: URL;
  try {
    url = new URL(origin);
  } catch {
    return false; // "null" (iframe sandbox, file://…) cũng rơi vào đây
  }
  if (url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:')) return false;
  return url.hostname === domain || url.hostname.endsWith(`.${domain}`);
}

export type BridgeMessage = { name: string; [key: string]: unknown };

/**
 * Nghe postMessage có kiểm origin + kiểm cả `source` (đúng frame mình mong đợi), parse an toàn,
 * rồi chuyển cho handler theo `name`.
 */
export function listenToFrames(
  handlers: Record<string, (message: BridgeMessage, event: MessageEvent) => void>,
  { isAllowed, sources }: { isAllowed: (origin: string) => boolean; sources?: () => (Window | null)[] },
  target: Window = window,
) {
  const onMessage = (event: MessageEvent) => {
    if (!isAllowed(event.origin)) return;
    if (sources && !sources().includes(event.source as Window)) return;
    let message: unknown = event.data;
    if (typeof message === 'string') {
      try {
        message = JSON.parse(message);
      } catch {
        return;
      }
    }
    if (!message || typeof message !== 'object' || typeof (message as BridgeMessage).name !== 'string') return;
    const handler = handlers[(message as BridgeMessage).name];
    // Object.hasOwn: "toString", "constructor"… không được coi là tên handler hợp lệ
    if (handler && Object.hasOwn(handlers, (message as BridgeMessage).name)) handler(message as BridgeMessage, event);
  };
  target.addEventListener('message', onMessage);
  return () => target.removeEventListener('message', onMessage);
}
ts

#Đi qua code

apply(path) chạy hai vòng: vòng một gỡ các app đang mount mà không còn khớp, vòng hai tải, bootstrap (chỉ lần đầu) và mount các app khớp. Mọi lỗi đều được bắt và chuyển app sang broken; app broken không được thử lại cho tới khi tải lại trang (single-spa làm tương tự với trạng thái SKIP_BECAUSE_BROKEN).

reroute(path) không gọi apply ngay mà xếp vào một chuỗi Promise (queue), và chỉ nhớ URL mới nhất (pending). Người dùng bấm Back năm lần liên tiếp thì không phải mount, unmount năm lần: các lần nằm chờ bị thay bằng lần cuối. Test cho thấy ba lần đổi trong cùng một tick chỉ dẫn tới một lần mount.

destroy() gỡ hết khi trang chứa bị huỷ, để app con dọn timer, listener.

encodeTransferParam đi qua TextEncoder (UTF-8) trước khi btoa, nên tiếng Việt không làm btoa ném lỗi; rồi đổi sang base64url (-, _, bỏ =), không còn ký tự nào cần mã hoá trong URL. buildTransferUrl dựng query bằng URLSearchParams, nên redirectTo=/projects?filter=mine&sort=recent đi nguyên vẹn.

isTrustedOrigin parse origin bằng new URL: origin "null" (iframe sandbox, file://) làm URL ném lỗi và bị chặn. Chỉ nhận https: (cho phép http: khi phát triển). So hostname bằng === hoặc endsWith('.' + domain): dấu chấm phía trước loại được nothubspot.com, và so trên hostname nên phần đuôi .attacker.io không lọt qua.

listenToFrames kiểm origin, rồi kiểm event.source có nằm trong danh sách frame mong đợi không, parse JSON an toàn, kiểm có trường name dạng chuỗi, và chỉ gọi handler là thuộc tính riêng của object (Object.hasOwn). Bỏ bước cuối thì một tin nhắn { name: 'toString' } sẽ gọi Object.prototype.toString; vô hại ở đây, nhưng { name: 'constructor' } hay __proto__ trong những đoạn code khác là đầu mối của lỗi khó đoán.

Khác bản gốc: registry là bản tự viết tối giản, không phải single-spa (không có parcel, không có timeout); bootstrap lỗi thì báo broken thay vì treo. Phần URL dùng base64url + URLSearchParams thay cho btoa + nối chuỗi. Phần postMessage so hostname thay cho regex, kiểm thêm source và tên handler.

#Tại sao không làm cách khác?

Sao không dùng Module Federation? Module Federation (webpack 5, có bản cho Vite, Rspack) giải bài toán chia sẻ code giữa các bản build: app A dùng component của app B lúc chạy. single-spa giải bài toán điều phối vòng đời trên một trang. Hai thứ thường đi cùng nhau. Similarweb chỉ cần bài toán thứ hai: hai app sống chung, không import lẫn nhau.

Sao không dùng iframe cho cả app AngularJS? iframe cách ly hoàn toàn (CSS, global, lỗi), nhưng mỗi iframe là một trang riêng: tải lại framework, không chung lịch sử điều hướng, khó làm modal phủ cả màn hình, khó co giãn chiều cao. HubSpot dùng iframe cho widget nhỏ, nơi cách ly đáng giá hơn sự liền mạch.

Sao InVision không dùng sessionStorage thay URL? sessionStorage không đi theo khi mở trong tab mới, không chia sẻ được qua link, và biến mất khi mở từ email. URL là cách duy nhất "chạy được ở mọi nơi" giữa hai app khác origin hoặc khác codebase.

Sao không dùng BroadcastChannel thay postMessage? BroadcastChannel chỉ hoạt động trong cùng origin. Giữa trang chứa và iframe khác origin thì postMessage là lựa chọn duy nhất.

#Edge case và cạm bẫy

  • Rò rỉ khi unmount. App AngularJS phải $destroy(); app React phải root.unmount(). Quên thì setInterval, listener window của app cũ vẫn chạy trong lúc app mới đang hiển thị. Demo có một bộ đếm setInterval để thấy nó dừng khi app bị gỡ.
  • Đừng xoá trắng container dùng chung. Adapter của Similarweb làm container.innerHTML = "". Nếu hai app chia một container (hay lần unmount cũ chạy chậm hơn lần mount mới), app cũ có thể xoá luôn DOM của app mới. Bản demo cho mỗi app tự gỡ đúng phần tử của mình.
  • CSS và global. Hai framework, hai bộ CSS, hai phiên bản lodash trên window. Similarweb có tuỳ chọn preserveGlobal để không xoá window.angular khi unmount. Đặt tiền tố cho class CSS, hoặc dùng CSS Modules.
  • Origin "null". iframe có sandbox không kèm allow-same-origin gửi tin với event.origin === "null". Đừng thêm "null" vào danh sách tin cậy: mọi trang sandbox đều có origin đó.
  • Gửi với '*'. Chỉ dùng targetOrigin = '*' cho dữ liệu không nhạy cảm. Nếu cần gửi lại cho đúng bên đã nhắn, dùng event.origin của tin nhận được.
  • Listener chồng nhau. Trong js/app.js của HubSpot, addEventListener('message', …) nằm trong useEffect có dependency nhưng không có hàm dọn: mỗi lần dependency đổi là thêm một listener. Luôn trả về hàm removeEventListener (như listenToFrames làm).
  • URL dài. Trình duyệt hiện đại chịu được URL rất dài, nhưng proxy, CDN, log server thường cắt ở vài KB. Dữ liệu lớn thì lưu ở server, URL chỉ mang id.

#Dùng trong dự án

Nối registry với URL thật (single-spa làm sẵn việc này):

import { createAppRegistry } from './microFrontend';

const registry = createAppRegistry({ user, i18n }, (name, status, error) => {
  if (status === 'broken') reportError(error, { app: name });
});
registry.register({
  name: 'legacy',
  load: () => import('./legacy/app'),
  activeWhen: (path) => !path.startsWith('/new/'),
  container: () => document.getElementById('legacy-root')!,
});
// app 'next' đăng ký tương tự, activeWhen: (path) => path.startsWith('/new/')

const sync = () => registry.reroute(location.pathname);
window.addEventListener('popstate', sync);
sync();
ts

Trang chứa nghe iframe của một domain đối tác:

import { isTrustedOrigin, listenToFrames } from './microFrontend';

const frame = document.querySelector<HTMLIFrameElement>('#chat-widget')!;
const stop = listenToFrames(
  { resize: (m) => (frame.style.height = `${Number(m.height)}px`) },
  { isAllowed: (o) => isTrustedOrigin(o, 'widget.example.com'), sources: () => [frame.contentWindow] },
);
// phía iframe: parent.postMessage(JSON.stringify({ name: 'resize', height }), 'https://app.example.com')
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { buildTransferUrl, createAppRegistry, decodeTransferParam, encodeTransferParam, isTrustedOrigin, listenToFrames } from './microFrontend';

describe('isTrustedOrigin', () => {
  it('so khớp hostname, không bị lừa bởi chuỗi con', () => {
    expect(isTrustedOrigin('https://app.hubspot.com', 'hubspot.com')).toBe(true);
    expect(isTrustedOrigin('https://hubspot.com', 'hubspot.com')).toBe(true);
    expect(isTrustedOrigin('https://app.hubspot.com.attacker.io', 'hubspot.com')).toBe(false);
    expect(isTrustedOrigin('https://nothubspot.com', 'hubspot.com')).toBe(false);
    expect(isTrustedOrigin('http://app.hubspot.com', 'hubspot.com')).toBe(false);
    expect(isTrustedOrigin('null', 'hubspot.com')).toBe(false);
  });
});

describe('transfer param', () => {
  it('chịu được Unicode và đi qua URL nguyên vẹn', () => {
    const data = { 'bản-thiết-kế': ['42'] };
    const url = buildTransferUrl('/t', { d: encodeTransferParam(data), redirectTo: '/p?x=1&y=2' });
    const q = new URL(url, 'https://x.test').searchParams;
    expect(q.get('redirectTo')).toBe('/p?x=1&y=2');
    expect(decodeTransferParam(q.get('d')!)).toEqual(data);
  });
});

describe('createAppRegistry', () => {
  it('gỡ app cũ trước khi gắn app mới, bootstrap một lần, lỗi thì thành broken', async () => {
    const order: string[] = [];
    const div = () => document.createElement('div');
    const app = (name: string, failBootstrap = false) => ({
      bootstrap: () => {
        order.push(name + ':bootstrap');
        if (failBootstrap) throw new Error('x');
      },
      mount: () => void order.push(name + ':mount'),
      unmount: () => void order.push(name + ':unmount'),
    });
    const r = createAppRegistry({});
    r.register({ name: 'old', load: async () => app('old'), activeWhen: (p) => !p.includes('new'), container: div });
    r.register({ name: 'new', load: async () => app('new'), activeWhen: (p) => p.includes('new'), container: div });
    r.register({ name: 'bad', load: async () => app('bad', true), activeWhen: (p) => p.includes('bad'), container: div });
    await r.reroute('/home');
    await r.reroute('/new');
    await r.reroute('/home');
    await r.reroute('/bad');
    expect(order).toEqual(['old:bootstrap', 'old:mount', 'old:unmount', 'new:bootstrap', 'new:mount', 'new:unmount', 'old:mount', 'bad:bootstrap']);
    expect(r.status('bad')).toBe('broken');
  });

  it('URL đổi dồn dập: chỉ áp dụng URL mới nhất', async () => {
    const mounts: string[] = [];
    const r = createAppRegistry({});
    for (const name of ['a', 'b', 'c']) {
      r.register({
        name,
        load: async () => ({ mount: () => void mounts.push(name), unmount: () => {} }),
        activeWhen: (p) => p === '/' + name,
        container: () => document.createElement('div'),
      });
    }
    r.reroute('/a');
    r.reroute('/b');
    await r.reroute('/c');
    expect(mounts).toEqual(['c']);
  });
});

describe('listenToFrames', () => {
  it('bỏ qua origin lạ, tên không có handler và tên kế thừa từ Object.prototype', () => {
    const handler = vi.fn();
    const stop = listenToFrames({ ping: handler }, { isAllowed: (o) => o === 'https://ok.test' });
    const send = (data: unknown, origin: string) => window.dispatchEvent(new MessageEvent('message', { data, origin }));
    send(JSON.stringify({ name: 'ping' }), 'https://evil.test');
    send(JSON.stringify({ name: 'toString' }), 'https://ok.test');
    send('không phải json', 'https://ok.test');
    send({ name: 'ping' }, 'https://ok.test');
    expect(handler).toHaveBeenCalledTimes(1);
    stop();
  });
});
ts

#So với thư viện và chuẩn web

  • single-spa là thư viện Similarweb dùng; có sẵn adapter cho React, Vue, Angular, AngularJS, và khái niệm "parcel" để nhúng một mảnh app vào app khác. Nếu cần đúng bài toán "nhiều app theo route", dùng nó thay vì tự viết.
  • Module Federation chia sẻ code lúc chạy giữa các bản build; thường đi kèm single-spa hoặc một router tự viết.
  • postMessage + kiểm origin là cách chuẩn để nói chuyện giữa các origin. Thư viện như Comlink (của Google) bọc nó thành gọi hàm kiểu RPC, giống ý tưởng bài #12 về Worker RPC.
  • document.domain: đã bị Chrome vô hiệu hoá theo mặc định từ bản 115 (2023), chỉ còn chạy khi trang gửi Origin-Agent-Cluster: ?0 (tính tới 10/2026). Code mới không nên dùng.

Bài này thuộc series Học từ mã nguồn các web lớn (65 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Mở đầu

Phần 1: trải nghiệm người dùng

Phần 2: hiệu năng và đo lường

Phần 3: nhiều tab, phiên đăng nhập, cập nhật

Phần 4: kiến trúc

Phần 5: soạn thảo và hiển thị nội dung

Phần 6: tương tác

Phần 7: mạng, dữ liệu và async

Phần 8: hiệu năng và vận hành

Phần 9: thuật toán và dữ liệu phía client

Phần 10: thời gian, ngôn ngữ, định dạng

Phần 11: tương tác và giao diện

Phần 12: media

Phần 13: đo lường, bảo mật, vận hành

Code + demo: examples/web-patterns · Mở trên StackBlitz