Micro-frontend Learning
UI và framework

CSS isolation

Chọn CSS Modules, naming conventions hay Shadow DOM cho micro-frontend; kiểm soát style leakage, theme, portal và lifecycle của stylesheet.

Giả sử shell của shop có nút giỏ hàng, còn catalog vừa deploy một stylesheet mới với rule button { background: red; }. Catalog chạy đúng khi mở standalone, nhưng khi ghép vào shell thì nút giỏ hàng cũng chuyển đỏ. Hai ứng dụng có React root khác nhau, thậm chí dùng framework khác nhau, vẫn có thể dùng chung một CSS cascade.

CSS isolation giúp bạn giới hạn vùng ảnh hưởng của style. Nhưng tránh trùng tên class, chặn selector đi qua boundary và kiểm soát giá trị kế thừa là ba việc khác nhau. Nếu gom chúng thành một lời hứa “CSS đã scoped”, bạn dễ bỏ sót reset, theme hoặc modal render ra ngoài container.

Phạm vi bài viết

Bài này dành cho micro-frontend chạy trong trình duyệt, dùng shop với shell, catalog và cart làm ví dụ. Repository hiện tại là site tài liệu; các file code bên dưới thuộc lab riêng, không phải ứng dụng đã có trong repo. Ví dụ CSS Modules giả định bundler đã hỗ trợ .module.css; lab Shadow DOM dùng HTML và JavaScript thuần.

Mục lục

Style leakage xảy ra ở đâu

Selector và cascade dùng chung

Trong light DOM, một stylesheet được gắn vào document không biết node nào thuộc shell hay remote. Nó chỉ tìm những element khớp selector rồi tham gia cascade.

direction: down
doc: "Document: một cascade cho light\nDOM" {
  shell: Shell header / button giỏ
  catalog: Catalog slot / button thêm
  cart: Cart slot / button thanh toán
}
css: "Catalog CSS: button {\nbackground: red; }"
css -> doc.shell: "Có thể khớp"
css -> doc.catalog: "Có thể khớp"
css -> doc.cart: "Có thể khớp"
Document: một cascade cho lightDOMCatalog CSS: button {background: red; }Shell header / button giỏCatalog slot / button thêmCart slot / button thanh toán Có thể khớp Có thể khớp Có thể khớp
Document: một cascade cho lightDOMCatalog CSS: button {background: red; }Shell header / button giỏCatalog slot / button thêmCart slot / button thanh toán Có thể khớp Có thể khớp Có thể khớp
Selector và cascade dùng chung

Các rule dưới đây đều có thể làm hỏng vùng UI khác:

Ví dụ CSS không nên export từ remote
button { border-radius: 0; }
.card { padding: 2rem; }
* { box-sizing: content-box; }
body { overflow: hidden; }
:root { --primary-color: red; }

button và * có phạm vi quá rộng; .card có tên quá phổ biến; body sửa cả document; còn :root ghi token mà mọi ứng dụng có thể đọc. Tải file bằng Module Federation, dynamic import hay một thẻ link không thay đổi phạm vi của các rule này.

Cũng đừng quy mọi lỗi cho “CSS tải sau thắng”. Browser còn xét nguồn style, !important, cascade layer, specificity và những yếu tố khác trước khi dùng thứ tự xuất hiện để phân xử. Khi debug, hãy tìm declaration thực sự thắng trong DevTools thay vì liên tục đổi thứ tự import.

Inheritance và tài nguyên ngoài selector

Ngay cả khi không có selector của shell khớp node bên trong remote, node đó vẫn có thể kế thừa color, font-family, line-height hoặc CSS custom properties từ ancestor. Đây có thể là theme bridge có chủ đích, không nhất thiết là lỗi.

Một số ảnh hưởng khác không giải quyết được chỉ bằng đổi tên class:

Nguồn ảnh hưởngVí dụ trong shopCách kiểm soát
Giá trị kế thừaCatalog nhận font và text color của shellĐịnh nghĩa baseline hoặc chấp nhận chúng qua contract
Đơn vị dựa vào document1rem đổi khi shell sửa font-size của htmlChốt root font policy; kiểm tra cả khi dùng Shadow DOM
Tên animation hoặc font dùng chungHai file global định nghĩa @keyframes fade hoặc font family AppFontNamespace tài nguyên; kiểm tra CSS đầu ra của toolchain
Vị trí renderModal của catalog nằm trực tiếp dưới bodyCó overlay contract và target rõ ràng
Geometry của ancestorSlot bị overflow: hidden hoặc nằm trong stacking contextShell và remote cùng chốt layout boundary

Hãy coi boundary như vách ngăn giữa hai căn phòng: đổi nhãn đồ đạc tránh nhầm tên, nhưng không dựng lên vách ngăn. Với CSS, phép so sánh này chỉ giải thích phạm vi selector; inheritance và layout vẫn cần quy tắc riêng.

Chọn mức isolation phù hợp

Mình mặc định chọn CSS Modules cho component, baseline toàn trang do shell sở hữu, và design tokens có namespace khi các team tin cậy nhau và cùng xây một sản phẩm. Cách này giữ composition đơn giản mà giảm phần lớn va chạm do tên class.

Cách làmGiải quyết tốtKhông tự giải quyếtKhi mình chọn
Prefix và root namespaceTrùng tên class, rule remote vô tình phủ cả trangRule từ ngoài đi vào, inheritance, CSS legacy chưa xử lýMigration hoặc codebase CSS thuần có lint/review tốt
CSS ModulesTên class và animation local qua build transformGlobal selector, inheritance, portal và vendor CSSMặc định cho UI do team kiểm soát
Shadow DOMSelector thông thường không xuyên vào/ra shadow treeHost, inheritance, slots, layout, JS isolationWidget hoặc vùng UI cần chống CSS bên ngoài mạnh hơn
iframeDocument và stylesheet riêngGiao tiếp, theme bridge, focus, resize, routingApp legacy khó sửa hoặc cần đánh giá sandbox riêng

CSS-in-JS cũng có thể sinh class riêng, nhưng đó chưa phải một mức isolation độc lập: bạn vẫn phải kiểm tra global styles và nơi runtime inject stylesheet. Cascade layers chỉ quản lý độ ưu tiên; chúng không thay thế bất kỳ boundary nào trong bảng.

Đừng đưa toàn bộ catalog vào Shadow DOM chỉ để sửa một class .card trùng tên. Ngược lại, nếu bạn phải nhúng widget vào những host page có CSS không thể kiểm soát, prefix đơn thuần thường không đủ.

CSS isolation không phải security isolation

CSS Modules và Shadow DOM không sandbox JavaScript. Code remote chạy cùng môi trường vẫn có thể sửa document, chèn style global hoặc dùng tài nguyên của trang. mode: 'closed' cũng không biến Shadow DOM thành security boundary. Với nội dung không tin cậy, cần thiết kế iframe và sandbox phù hợp.

CSS Modules làm mặc định

CSS Modules transform tên class và animation local thành tên đầu ra, rồi export mapping để component sử dụng. Component viết .button, nhưng DOM nhận tên đã được transform thay vì dùng thẳng class global button.

Ví dụ component catalog

src/catalog/ProductCard.module.css
.card {
  padding: 1rem;
  border: 1px solid var(--shop-color-border, #cbd5e1);
  background: var(--shop-color-surface, #fff);
  color: var(--shop-color-text, #172033);
}

.title {
  margin: 0 0 0.75rem;
  font-size: 1.125rem;
}

.button {
  padding: 0.5rem 0.75rem;
  border: 0;
  border-radius: 0.375rem;
  background: var(--shop-color-accent, #1d4ed8);
  color: var(--shop-color-on-accent, #fff);
  font: inherit;
}

.button:focus-visible {
  outline: 2px solid var(--shop-color-focus, #9a3412);
  outline-offset: 3px;
}
src/catalog/ProductCard.tsx
import styles from './ProductCard.module.css';

export function ProductCard({ onAdd }: { onAdd: () => void }) {
  return (
    <article className={styles.card}>
      <h2 className={styles.title}>Áo thun</h2>
      <button type="button" className={styles.button} onClick={onAdd}>
        Thêm vào giỏ
      </button>
    </article>
  );
}

Cart có thể dùng .button trong file module của nó mà không cần thống nhất tên component với catalog. Tuy vậy, với nhiều build độc lập, hãy kiểm tra cấu hình tạo tên class. Nếu mọi remote dùng một template như [local] không có app identifier hay hash phù hợp, local scope ở source không còn giúp tránh va chạm lúc ghép trang.

Một tên đầu ra như catalog_ProductCard_button_a1b2 chỉ là minh họa, không phải format chuẩn mà mọi bundler đều sinh. Đừng để shell query hoặc viết selector dựa vào tên đó; nó là implementation detail, không phải public API.

Tên class riêng không chặn CSS bên ngoài

Giả sử shell có CSS sau:

Một rule shell có thể phá CSS Modules
button {
  background: red !important;
}

Button của catalog vẫn là một button, dù class đã có hash. Rule global này vẫn khớp và có thể thắng declaration normal trong module. CSS Modules tránh trùng tên, không tạo selector boundary.

Ở chiều ngược lại, đưa body, :root hoặc selector element không gắn với local class vào .module.css không làm chúng thành private. Một số toolchain pure mode sẽ từ chối selector như vậy; một số khác có thể cho chúng đi ra global. Cú pháp :global(...) còn chủ động mở phạm vi global. Vì thế, “mọi file đều có đuôi .module.css” chưa đủ làm policy.

Mình khuyên mỗi component dùng local class rõ ràng, hạn chế descendant selector quá rộng và review mọi global escape. Stylesheet của thư viện được import cạnh component cũng phải được kiểm tra riêng: nó không tự trở thành module chỉ vì component dùng CSS Modules.

Naming conventions và root namespace

Prefix class và giới hạn selector

Nếu đang migration từ CSS global, bạn có thể bắt đầu bằng prefix theo ứng dụng, ví dụ catalog-, rồi đặt rule bên dưới một root do remote sở hữu.

Markup trong light DOM
<section data-mfe="catalog">
  <article class="catalog-product-card">
    <h2 class="catalog-product-title">Áo thun</h2>
    <button class="catalog-add-button" type="button">Thêm vào giỏ</button>
  </article>
</section>
catalog.css
:where([data-mfe="catalog"]) {
  color: var(--shop-color-text, #172033);
  font-family: var(--shop-font-body, system-ui, sans-serif);
  line-height: 1.5;
}

:where([data-mfe="catalog"]) .catalog-product-title {
  margin-block: 0 0.75rem;
}

:where([data-mfe="catalog"]) .catalog-add-button {
  border-radius: 0.375rem;
  font: inherit;
}

:where(...) có specificity bằng zero, nên root namespace không cộng thêm specificity vào phần selector phía sau. Điều này giúp tránh cuộc đua selector ngày càng dài, nhưng không chặn một global rule từ shell có độ ưu tiên cao hơn.

Prefix là convention, không phải enforcement. Với pipeline tự động thêm root prefix, hãy kiểm tra cả selector list, html, body, :root, keyframes và stylesheet import. Chỉ nối [data-mfe="catalog"] vào trước body sẽ tạo selector tìm một body nằm bên trong catalog — không phải ý nghĩa mà CSS ban đầu cần.

Giới hạn khi lồng nhiều ứng dụng

Rule [data-mfe="catalog"] button vẫn khớp button của cart nếu cart được mount làm descendant bên trong catalog. Root namespace giới hạn cây con, chứ không hiểu quyền sở hữu của từng micro-frontend.

Vì vậy, mình ưu tiên local class cho từng component thay vì reset mọi descendant bằng [data-mfe="catalog"] *. Nếu có nested remote, hãy xác định vùng DOM do nó sở hữu và tránh để CSS cha nhắm vào implementation bên trong.

Portal còn tạo ra vấn đề ngược lại: modal của catalog render dưới body không còn là descendant của root catalog. Khi đó selector có root prefix không khớp nữa. Bạn cần đưa overlay vào cùng boundary hoặc cấp namespace riêng cho nó, không nên sửa bằng cách xóa mọi prefix.

Cascade layers không phải isolation

Cascade layers hữu ích khi shell, design system và remote cần thống nhất độ ưu tiên của CSS normal. Shell nên khai báo thứ tự trước khi tải remote, thay vì để mạng quyết định layer nào được gặp đầu tiên.

shell-cascade.css — tải trước remote
@layer reset, design-system, apps, shell-overrides;
catalog.css — ví dụ phối hợp layer và namespace
@layer apps {
  :where([data-mfe="catalog"]) .catalog-add-button {
    border-radius: 0.375rem;
  }
}

Các remote có thể cùng đóng góp vào layer apps. Tuy nhiên, rule trong cùng layer vẫn cần phân xử bằng specificity và thứ tự khi cần; layer không giúp hai class global .button trở thành hai class khác nhau.

Có hai bẫy cần chốt thành policy:

  • Với declaration normal trong cùng author origin, CSS ngoài layer ưu tiên hơn CSS trong layer. Vendor CSS không được đưa vào layer có thể thắng app CSS dù selector đơn giản hơn.
  • Với !important, thứ tự ưu tiên layer đảo ngược. Đừng coi shell-overrides là nơi luôn thắng mọi rule.

@scope cũng không phải Shadow DOM: nó giới hạn vùng mà rule được khai báo bên trong scope áp dụng, nhưng không ngăn rule bên ngoài tác động vào vùng đó. Nếu dùng, hãy kiểm tra browser support của sản phẩm và xem nó là công cụ scoping, không phải sandbox.

Shadow DOM tạo selector boundary

Shadow DOM tạo một tree gắn với host element. Selector thông thường trong document không đi vào các node nội bộ của tree; selector nội bộ cũng không lan ra node bên ngoài.

direction: down
doc: Document / light DOM {
  shell: Shell button
  host: div host {
    shadowroot: shadow-root {
      stylesheet: Catalog CSS
      button: Catalog button
      stylesheet -> button: Stylesheet trong shadow root
    }
  }
}
css: Shell CSS
css -> doc.shell
css -> doc.host: "Có thể style host"
Document / light DOMShell CSSShell buttondiv hostshadow-rootCatalog CSSCatalog button Stylesheet trong shadow root Có thể style host
Document / light DOMShell CSSShell buttondiv hostshadow-rootCatalog CSSCatalog button Stylesheet trong shadow root Có thể style host
Shadow DOM tạo selector boundary

Điểm khác biệt so với CSS Modules là một rule global button của shell không tự khớp button nằm trong shadow tree. Bạn không cần đăng ký custom element chỉ để có Shadow DOM; một host element phù hợp có thể gọi attachShadow().

Những gì đi qua shadow boundary

Boundary này có các lối đi có chủ đích, không phải một hộp kín hoàn toàn:

  • Host vẫn thuộc light DOM. Shell có thể đặt width, display, overflow, font hoặc custom properties lên host.
  • Inheritance vẫn đi từ host vào tree. Các giá trị như font, color và nhiều custom properties có thể được kế thừa. Remote nên định nghĩa baseline cho những gì không muốn nhận ngầm.
  • Slotted content vẫn là light DOM. Nội dung được đưa qua slot không tự trở thành node nội bộ được cô lập giống phần template. Shell vẫn có thể style nội dung đó; ::slotted(...) là một điểm nối có giới hạn.
  • ::part(...) mở styling API. Nếu remote expose part, bên ngoài được style những phần đã công khai. Hãy version API đó như public contract, không expose mọi node theo mặc định.
  • Layout vẫn phụ thuộc host và ancestor. Shadow DOM không gỡ clipping, stacking context hoặc thay đổi ý nghĩa của rem thành “đơn vị theo shadow root”.

Đặt all: initial trên host không phải nút “reset tất cả”: nó không reset custom properties, direction hay unicode-bidi, và cũng không xử lý toàn bộ layout bên ngoài. Mình ưu tiên baseline rõ ràng thay vì một reset khiến theme hoặc accessibility khó dự đoán.

Lab mount và unmount

Lab sau chứng minh selector boundary bằng hai button dùng chung tên class. Mở index.html qua một HTTP server static để trình duyệt tải ES module, rồi quan sát màu hai button.

index.html
<!doctype html>
<html lang="vi">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>CSS isolation lab</title>
    <style>
      .button { background: #b91c1c; color: white; }
      body { font-family: system-ui, sans-serif; }
      #catalog-host { --shop-color-accent: #1d4ed8; }
    </style>
  </head>
  <body>
    <button class="button" type="button">Giỏ hàng của shell</button>
    <div id="catalog-host"></div>
    <button id="toggle-catalog" type="button">Ẩn catalog</button>

    <script type="module">
      import { mountCatalog } from './catalog.js';

      const host = document.querySelector('#catalog-host');
      const toggle = document.querySelector('#toggle-catalog');
      let handle = mountCatalog(host);

      toggle.addEventListener('click', () => {
        if (handle) {
          handle.unmount();
          handle = null;
          toggle.textContent = 'Hiện catalog';
        } else {
          handle = mountCatalog(host);
          toggle.textContent = 'Ẩn catalog';
        }
      });
    </script>
  </body>
</html>
catalog.js
const activeHosts = new WeakSet();

export function mountCatalog(host) {
  if (activeHosts.has(host)) {
    throw new Error('Catalog đã mount trên host này');
  }
  if (host.childNodes.length > 0) {
    throw new Error('Host của lab phải không có light DOM children');
  }

  // Host này được shell dành riêng cho catalog.
  // Shadow root còn tồn tại sau unmount; lần sau tái sử dụng nó.
  const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
  if (root.childNodes.length > 0) {
    throw new Error('Shadow root đang có nội dung của owner khác');
  }

  const style = document.createElement('style');
  style.textContent = `
    :host {
      display: block;
      font-family: var(--shop-font-body, system-ui, sans-serif);
      line-height: 1.5;
      color: var(--shop-color-text, #172033);
    }
    *, *::before, *::after { box-sizing: border-box; }
    .card {
      margin-block: 1rem;
      padding: 1rem;
      border: 1px solid var(--shop-color-border, #cbd5e1);
      background: var(--shop-color-surface, #fff);
    }
    .button {
      padding: 0.5rem 0.75rem;
      border: 0;
      border-radius: 0.375rem;
      background: var(--shop-color-accent, #1d4ed8);
      color: var(--shop-color-on-accent, #fff);
      font: inherit;
    }
    .button:focus-visible {
      outline: 2px solid var(--shop-color-focus, #9a3412);
      outline-offset: 3px;
    }
  `;

  const card = document.createElement('section');
  card.className = 'card';
  card.setAttribute('aria-label', 'Catalog');

  const title = document.createElement('h2');
  title.textContent = 'Áo thun';

  const button = document.createElement('button');
  button.type = 'button';
  button.className = 'button';
  button.textContent = 'Thêm vào giỏ';

  const status = document.createElement('p');
  status.setAttribute('role', 'status');
  const onAdd = () => { status.textContent = 'Đã gửi yêu cầu thêm sản phẩm'; };
  button.addEventListener('click', onAdd);

  card.append(title, button, status);
  root.append(style, card);
  activeHosts.add(host);

  let disposed = false;
  return {
    unmount() {
      if (disposed) return;
      disposed = true;
      button.removeEventListener('click', onAdd);
      card.remove();
      style.remove();
      activeHosts.delete(host);
    },
  };
}

Kết quả mong đợi: button của shell đỏ, button catalog xanh. Đổi --shop-color-accent trên host sang màu khác thì catalog đổi theo; đó là inheritance có chủ đích qua token, không phải selector leakage. Ẩn rồi hiện catalog phải tạo lại UI mà không nhân đôi event listener hay stylesheet.

Lab chỉ nhận host rỗng dành riêng cho catalog. Nó không hydrate Declarative Shadow DOM, không hỗ trợ nhiều owner trên cùng root và không triển khai cart thật. Nội dung style là hằng số của lab; với CSP production, phải có chính sách cho style phù hợp, chẳng hạn nonce được shell cấp hoặc stylesheet ngoài được cho phép, chứ không bật unsafe-inline chỉ để ví dụ chạy.

Đưa CSS của framework vào đúng root

Mount một React hoặc Vue app vào element bên trong shadow root không tự chuyển stylesheet của bundler vào đó. Nếu runtime vẫn inject style vào document.head, các rule ấy không tự khớp UI nội bộ của shadow tree.

Khi chọn Shadow DOM, bạn cần một trong các chiến lược mà toolchain hỗ trợ:

  • Gắn style hoặc link rel="stylesheet" trực tiếp trong shadow root.
  • Dùng constructable stylesheet và adoptedStyleSheets sau khi kiểm tra browser support, cách tải và giới hạn của API.
  • Cấu hình style engine của UI library/CSS-in-JS để inject vào đúng shadow root, bao gồm cả SSR hoặc hydration nếu có.

Với link, mount contract nên chờ stylesheet load thành công trước khi công bố UI ready. Với CSS text hoặc constructable stylesheet, kiểm tra đường dẫn url(...): URL font và image phải resolve đúng sau build, không chỉ đúng trong môi trường standalone.

Ngoài style injection, cần kiểm tra thư viện có giả định document.querySelector, document.activeElement hoặc overlay target mặc định là body hay không. Shadow DOM có event retargeting và focus behavior riêng; test tab order, focus trap, accessible labels và screen reader trên tổ hợp browser mà sản phẩm hỗ trợ.

Theme và design tokens là contract

Isolation không có nghĩa mỗi app phải tự chọn một bộ màu khác nhau. Bạn có thể cô lập implementation CSS nhưng vẫn chia sẻ một vocabulary nhỏ: surface, text, accent, focus và font.

shell-theme.css
:root {
  --shop-color-surface: #fff;
  --shop-color-text: #172033;
  --shop-color-border: #cbd5e1;
  --shop-color-accent: #1d4ed8;
  --shop-color-on-accent: #fff;
  --shop-color-focus: #9a3412;
  --shop-font-body: system-ui, sans-serif;
}

:root[data-theme="dark"] {
  --shop-color-surface: #111827;
  --shop-color-text: #f8fafc;
  --shop-color-border: #475569;
  --shop-color-accent: #93c5fd;
  --shop-color-on-accent: #111827;
  --shop-color-focus: #fdba74;
}

Custom properties này có thể được kế thừa vào light DOM và qua shadow host. Trong iframe, chúng không tự đi vào document con; shell phải gửi theme bằng contract rồi phía nhúng áp dụng token của nó.

Quyết định cần chốtPolicy gợi ý
Ai ghi token publicShell/theme provider; remote chỉ đọc
Tên tokenPrefix --shop- cho public tokens, --catalog- cho token riêng
Giá trị fallbackRemote có fallback để chạy standalone
Đổi tên hoặc đổi ý nghĩa tokenCó giai đoạn tương thích; không xóa ngay khi remote cũ còn được phục vụ
Root font và color schemeShell sở hữu; remote kiểm thử form controls, rem và dark mode
Override theo vùngĐặt lên slot hoặc host cụ thể, không sửa :root từ remote

Custom property “riêng” trong light DOM vẫn có thể kế thừa xuống descendant; prefix chỉ tránh nhầm tên, không làm nó private. Nếu overlay bị đưa ra ngoài slot có theme override, nó có thể nhận theme khác. Đây là một lý do nữa để thiết kế overlay target trước khi viết modal.

Portal overlay và stacking context

Một component có thể thuộc React tree của catalog nhưng DOM của nó được portal vào document.body. Vue Teleport có thể tạo tình huống tương tự. CSS đi theo cây DOM và stylesheet scope, không đi theo framework component tree.

Overlay strategyLợi íchĐiều cần xử lý
Render trong boundary của remoteGiữ scoped CSS và token inheritanceAncestor có thể clip overlay; stacking context có thể hạn chế nó
Shell cấp overlay serviceCó thể tập trung z-index, focus và scroll lockContract chứa data/actions; shell sở hữu UI overlay
Remote render vào overlay root của shellLinh hoạt với portal/TeleportStylesheet, namespace, theme và cleanup phải theo overlay ra ngoài

Mình chọn shell overlay service cho những modal dùng chung như login hoặc xác nhận nghiệp vụ toàn trang. Với popup gắn chặt vào một vùng UI, giữ nó trong boundary thường đơn giản hơn, miễn là đã test geometry thực tế.

Nếu portal ra light DOM, CSS Modules vẫn có thể style node bằng local class đã transform, nhưng stylesheet phải có mặt trong document và global CSS cũng có thể khớp nó. Root-prefixed CSS sẽ không khớp nếu target nằm ngoài root. Stylesheet chỉ nằm trong shadow root lại không style được node đã đưa ra light DOM.

z-index lớn không vượt mọi boundary

z-index: 999999 không giúp một node thoát khỏi stacking context của ancestor hoặc khỏi mọi clipping. Shadow DOM cũng không giải quyết điều đó. Native dialog mở bằng showModal() và Popover API có thể dùng top layer khi phù hợp, nhưng vẫn cần kiểm tra focus, styling và browser support; chúng không tự chuyển DOM hay stylesheet sang một root khác.

Scroll lock trên body là tài nguyên dùng chung. Nếu hai modal cùng mở, unmount một modal không được tùy tiện khôi phục scroll khi modal còn lại vẫn cần khóa. Hãy có một owner hoặc reference counting rõ ràng, đồng thời trả focus về nơi hợp lệ khi overlay đóng.

CSS của thư viện và utility framework

Một micro-frontend có thể kiểm soát tốt CSS của mình nhưng import một UI kit với reset global. Isolation phải xét toàn bộ CSS đầu ra, không chỉ file team tự viết.

Nguồn CSSBẫy thường gặpHướng xử lý
Reset/normalize của UI kitThay đổi heading, button hoặc box-sizing của cả trangShell sở hữu baseline; dùng bản không reset hoặc tích hợp trong boundary phù hợp
Tailwind hoặc utility CSSPreflight global; utility trùng tên nhưng build ra giá trị khácChốt version/config, prefix theo app khi cần và quyết định ai sở hữu Preflight
CSS-in-JSClass/cache hoặc injection order xung đột; global styles vẫn globalCấu hình cache key/namespace và injection container theo thư viện
Vendor CSS legacyCó body, :root, generic selectors và keyframesAudit/transform có kiểm thử; cân nhắc Shadow DOM hoặc iframe nếu không thể sửa
Font/image assetsURL đúng standalone nhưng lỗi khi host tải remoteKiểm tra asset base, CSP, CORS khi cần và đường dẫn build thực tế

Prefix utility classes không tự scope Preflight. Cú pháp prefix và cách bật/tắt Preflight của Tailwind khác giữa major versions, nên cấu hình theo version thực tế thay vì copy một config không ghi rõ phiên bản. Nếu toàn sản phẩm dùng chung một utility build, phải chấp nhận config đó là dependency contract chung.

Với Vue, <style scoped> giới hạn selector bằng thuộc tính do compiler sinh; với Angular, chế độ Emulated cũng dựa trên selector transform. Chúng không tạo native selector boundary như Shadow DOM và không chặn global CSS bên ngoài. Global escapes, descendant rules và thư viện bên thứ ba vẫn cần audit; xem các bài Vue và Angular cho boundary framework.

Tải stylesheet và cleanup theo lifecycle

Một UI có class riêng nhưng stylesheet chưa tải xong vẫn có thể xuất hiện không style, gây FOUC — flash of unstyled content. Tải JavaScript thành công không nhất thiết đồng nghĩa CSS đã sẵn sàng; hành vi phụ thuộc bundler và loader.

Contract nên phân biệt module loaded, styles ready và UI mounted. Shell chỉ bỏ loading state theo readiness mà remote đã cam kết, và xử lý riêng lỗi tải CSS thay vì để một UI không dùng được xuất hiện.

Tài nguyênPolicy lifecycle
CSS chunk do bundler quản lýKhông tự xóa style tag của runtime; kiểm tra cách loader tải và cache
Stylesheet private trong shadow rootOwner có thể gỡ khi unmount; tái mount không chèn trùng
Stylesheet dùng chung nhiều instanceDeduplicate theo resource/version; chỉ gỡ khi không còn consumer nếu policy yêu cầu
Inline style/theme do remote thêm vào hostGhi rõ property do ai sở hữu; khôi phục đúng giá trị trước đó khi cần
Overlay, scroll lock và style ngoài slotCleanup qua owner/service, không chỉ xóa container

Unmount framework root không tự gỡ mọi CSS global mà bundler từng inject. Với CSS Modules an toàn và assets bất biến, để stylesheet đã tải tiếp tục tồn tại có thể là lựa chọn hợp lý. Với một reset global gây leakage, gỡ sau unmount chỉ giảm triệu chứng: trong lúc hai app cùng mount, nó vẫn gây xung đột.

Khi remote deploy độc lập, JS và CSS phải thuộc một release tương thích; asset có hash nên được giữ đủ lâu cho client đang mở trang. Nếu shell SSR markup từ release A nhưng client tải mapping class của release B, CSS Modules không cứu được mismatch. Shadow DOM phía client cũng không tự giải quyết SSR/hydration; cần chiến lược render riêng được framework và toolchain hỗ trợ.

Kiểm thử style leakage

Test standalone chỉ cho biết remote có đủ CSS cho chính nó. Test isolation phải ghép remote với các consumer và stylesheet thực tế.

Mình bắt đầu bằng một trang integration có shell header, catalog, cart và overlay target. Sau đó chạy cùng thao tác ở các thứ tự tải khác nhau, vì nhiều lỗi chỉ xuất hiện khi quay lại route đã từng mở.

Kịch bảnĐiều cần xác nhận
Catalog tải trước cart và ngược lạiHeader, typography và buttons không đổi ngoài contract
Mount/unmount catalog nhiều lầnKhông tích lũy style tag, overlay hoặc scroll lock do adapter sở hữu
Hai catalog instancesCSS vẫn đúng; unmount một instance không làm instance kia mất style
Light/dark và theme override theo slotRemote và overlay nhận token đúng, có contrast/focus hợp lệ
Host có rule button mạnh hoặc !importantĐo giới hạn light DOM; Shadow DOM chặn selector nhưng vẫn nhận token đã cho phép
Thay root font-size và zoomrem, text wrap và form controls vẫn dùng được
Slot nằm trong overflow/transform ancestorPopup không bị clip sai, focus không mất
CSS tải chậm hoặc thất bạiLoading/fallback đúng; không công bố ready quá sớm
Shell hiện tại với remote cũ còn được hỗ trợToken, class strategy và asset URLs vẫn tương thích

Visual regression hữu ích cho leakage không thể diễn đạt bằng một property duy nhất. Kết hợp nó với assertions cho những giá trị quan trọng, nhưng không dùng screenshot thay accessibility test.

Ví dụ dưới giả định fixture integration có data-testid="shell-cart-button" và nút điều khiển mount catalog. Assertion chỉ kiểm tra một property của shell trước/sau thao tác, không chứng minh toàn bộ trang đã cô lập.

Phác thảo Playwright assertion trong lab
import { expect, test } from '@playwright/test';

test('mount catalog không đổi màu nút cart của shell', async ({ page }) => {
  await page.goto('/integration/css-isolation');
  const button = page.getByTestId('shell-cart-button');
  const readBackground = () => button.evaluate(
    (node) => getComputedStyle(node).backgroundColor,
  );

  const before = await readBackground();
  await page.getByRole('button', { name: 'Mount catalog', exact: true }).click();
  await expect(page.getByTestId('catalog-ready')).toBeVisible();
  expect(await readBackground()).toBe(before);
});

Fixture nên chờ fonts, styles và UI ổn định trước khi chụp screenshot. Với shadow content, dùng công cụ test hỗ trợ shadow roots; đừng chuyển mode sang closed chỉ để tạo cảm giác isolation mạnh hơn rồi làm diagnostics khó hơn.

Chẩn đoán lỗi thường gặp

Khi UI bị đổi style, chọn element trong DevTools, xem matched rules và computed values, rồi xác định declaration đến từ shell, remote hay vendor. Nếu không có rule trực tiếp, kiểm tra phần inherited styles và token trên ancestor/host.

Triệu chứngNguyên nhân cần kiểm traHướng sửa
Button shell đổi khi mở catalogGeneric selector/reset từ remoteLocal class, bỏ global reset hoặc chọn boundary mạnh hơn
CSS Modules vẫn bị overrideRule global khớp element, layer hoặc !importantSửa CSS policy; không chỉ tăng hash hoặc specificity
App trong Shadow DOM không có styleCSS inject vào document.headĐưa stylesheet vào shadow root và đợi styles ready
Modal không có style hoặc sai themePortal ra ngoài root/token scopeOverlay target/service và theme bridge rõ ràng
Dark mode chỉ đổi một phần UIHardcoded colors hoặc token thiếuAudit semantic tokens, form controls và fallback
Popup bị che dù z-index lớnStacking context hoặc clippingĐổi overlay strategy; kiểm tra ancestor và top layer khi phù hợp
Route khác bị lỗi sau khi rời catalogGlobal stylesheet/scroll lock còn tác dụngAudit owner và cleanup, loại global side effect
CSS chỉ lỗi sau deployJS/CSS khác release hoặc assets cũ bị xóaPin release tương thích và giữ immutable assets

Đừng chữa sự cố bằng !important hàng loạt. Nó khiến declaration thắng hôm nay nhưng che mất boundary sai, rồi làm bản release sau khó đoán hơn.

Checklist trước production

  • Shell sở hữu baseline toàn document; remote không tự sửa html, body hoặc :root ngoài contract.
  • Class/animation naming có chiến lược tránh va chạm giữa các build độc lập.
  • Đã audit global escapes, vendor CSS, reset, utility output và stylesheet injection.
  • Public tokens có namespace, fallback, owner và chính sách tương thích.
  • Nếu dùng Shadow DOM, CSS được tải vào đúng root; host, slots và parts có contract rõ.
  • Portal/Teleport, z-index, scroll lock và focus restoration có owner.
  • Readiness bao gồm CSS cần thiết; có fallback khi stylesheet không tải được.
  • Cleanup đúng cho nhiều instance, remount và tài nguyên dùng chung.
  • Integration tests bao phủ thứ tự tải, theme, root font, overlays và release cũ còn được hỗ trợ.

Nếu chưa biết bắt đầu từ đâu, hãy tìm tất cả rule global trong CSS đầu ra của catalog trước. Với code do team kiểm soát, chuyển component sang CSS Modules và đưa reset về shell thường là bước đầu ít rủi ro hơn việc đổi cả rendering boundary.

Tài liệu tham khảo

Đọc tiếp

On this page