Giao tiếp và trạng thái
Hiểu cách chia trách nhiệm và trao đổi dữ liệu giữa các micro-frontend qua một ví dụ thêm sản phẩm vào giỏ hàng.
Cách đọc nhóm bài
Trang này giải thích bức tranh chung, chưa yêu cầu bạn chọn Redux, event bus hay thư viện cache. Ví dụ là một shop giả định. Các snippet mô tả thiết kế; phần “Thử trực tiếp React và Vue” liên kết một lab chạy thật trong repo example-learn, không phải backend shop production.
Mục lục
- Một lần thêm hàng liên quan đến những phần nào
- Ai giữ dữ liệu và ai được sửa
- Ba việc khác nhau khi trao đổi dữ liệu
- Đi hết một lần bấm thêm vào giỏ
- Vì sao chỉ nghe event vẫn chưa đủ
- Thử trực tiếp React và Vue
- Nên nối các phần với nhau bằng cách nào
- URL cache và session đặt ở đâu
- Thử thiết kế khi mọi thứ không đi đúng thứ tự
- Lộ trình đọc và bài tập
- Đọc tiếp
Một lần thêm hàng liên quan đến những phần nào
Bạn bấm “Thêm vào giỏ” ở trang sản phẩm. Server đã thêm hàng, nhưng con số trên biểu tượng giỏ hàng vẫn không đổi. Refresh thì con số lại đúng. Vì sao?
Giả sử shop được chia thành ba phần frontend:
| Phần | Việc nó hiển thị |
|---|---|
| Shell — khung ứng dụng chung | Header, menu và con số trên biểu tượng giỏ hàng, gọi là badge |
| Catalog — phần danh sách sản phẩm | Sản phẩm, lựa chọn số lượng và nút “Thêm vào giỏ” |
| Cart — phần giỏ hàng | Các dòng sản phẩm và thao tác sửa giỏ |
Catalog biết người dùng vừa bấm nút. Backend biết giỏ hàng mới có gì. Nhưng header không tự biết hai việc đó. Bạn cần thiết kế đường đưa dữ liệu đã cập nhật tới nơi hiển thị nó.
Ta sẽ dùng một ví dụ xuyên suốt: ban đầu giỏ có hai chiếc áo. Người dùng thêm một chiếc cốc từ catalog. Sau khi backend xác nhận, badge phải hiện 3, dù màn hình cart chưa được mở.
Ai giữ dữ liệu và ai được sửa
Đọc chung không có nghĩa là cùng được ghi
Cách nối nhanh là để catalog gọi setItemCount(3) trong store chung. Cart cũng có thể sửa store đó, header cũng vậy. Lúc này bạn phải tìm nhiều nơi mới biết ai đã thay đổi con số và vì sao.
Mình chọn một đường khác: catalog yêu cầu thêm hàng; phần quản lý cart quyết định cập nhật giỏ thế nào; header chỉ đọc kết quả.
Phần chịu trách nhiệm đó gọi là owner. Với cart ở frontend, owner là module gọi API giỏ hàng, giữ dữ liệu nhận về và cung cấp cách đọc hoặc sửa giỏ cho các phần khác. Ta gọi module này là cart service trong bài; nó là code JavaScript ở frontend, không phải một backend service mới.
Cart service và màn hình cart là hai thứ khác nhau. Service cần nhận yêu cầu thêm hàng ngay khi người dùng đang ở catalog. Nó không thể chỉ tồn tại sau khi route /cart được mở.
Backend vẫn là nguồn dữ liệu chuẩn của giỏ đã xác nhận. Backend kiểm tra tồn kho, giá và quyền truy cập. Cart service chỉ điều phối việc gọi API và cung cấp dữ liệu cho UI; nó không thay backend quyết định các quy tắc đó.
Không phải state nào cũng cần chia sẻ
State là dữ liệu mô tả trạng thái hiện tại. Chẳng hạn, “đang chọn số lượng 2” là state của catalog; “giỏ đã có 3 sản phẩm” là dữ liệu do backend xác nhận. Hai giá trị này không cần đặt chung một chỗ.
| Dữ liệu | Giữ ở đâu theo mặc định | Lý do |
|---|---|---|
| Modal đang mở, số lượng chưa bấm thêm | Component hoặc catalog | Phần khác chưa cần biết lựa chọn chưa hoàn tất |
| Từ khóa và bộ lọc đã áp dụng | URL, nếu cần share link và Back/Forward | Mở lại URL phải dựng được cùng kết quả |
| Giỏ hàng đã được server xác nhận | Backend; cart service giữ bản sao ở frontend | Các UI cần đọc cùng kết quả nghiệp vụ |
| Số lượng trên badge | Tính từ dữ liệu cart | Header không có công thức nghiệp vụ riêng |
| Theme, ngôn ngữ | Module cấu hình chung | Nhiều phần thực sự cần cùng lựa chọn hiển thị |
Giữ bản sao để đọc là bình thường. Vấn đề không phải có nhiều bản sao, mà là mỗi nơi tự sửa bản của mình rồi coi đó là kết quả đúng. Với ví dụ này, hãy để cart service cung cấp số lượng và để header hiển thị nó.
Ba việc khác nhau khi trao đổi dữ liệu
Trước khi chọn cách nối các module, hãy phân biệt “nhờ làm”, “hỏi hiện tại” và “báo đã xong”. Đây là ba nhu cầu khác nhau, không phải ba tên gọi cho cùng một message.
Command là yêu cầu làm một việc
Catalog gọi:
cart.addItem({ productId: 'mug', quantity: 1 });Lời gọi này là một command: yêu cầu owner thay đổi dữ liệu. Catalog cần kết quả để biết nên hiện “Đã thêm” hay thông báo hết hàng. Việc người dùng bấm nút chưa chứng minh giỏ đã được cập nhật.
Trong thực tế, hàm thường trả Promise vì phải chờ backend. Kết quả thành công và lỗi cụ thể sẽ được thiết kế trong bài Hợp đồng giao tiếp.
Query là hỏi dữ liệu hiện tại
Header cần biết “giỏ hiện có bao nhiêu sản phẩm?”. Đây là query, tức thao tác đọc không yêu cầu thay đổi giỏ.
Kết quả đọc tại một thời điểm gọi là snapshot. Ví dụ, snapshot có itemCount: 3 nghĩa là cart đã có tổng quantity bằng 3 tại thời điểm đó. Nó không có nghĩa là “cộng thêm 3”.
Query có thể đọc bản sao trong memory hoặc gọi API, tùy chính sách của cart service. UI phải phân biệt “chưa tải xong” với “đã tải và giỏ rỗng”; đừng dùng số 0 cho cả hai.
Event là thông báo việc đã xảy ra
Sau khi backend xác nhận thay đổi, cart có thể phát thông báo shop.cart.updated.v1. Đây là event: “giỏ vừa được cập nhật”. Header đang nghe thông báo có thể lấy dữ liệu mới để render lại.
Event không phải lời hứa rằng mọi phần đều đã nhận được thông báo. Một event bus — bộ phận chuyển thông báo tới các listener — đơn giản trong memory thường không lưu lịch sử. Header chưa được gắn vào trang khi event phát ra thì sẽ bỏ lỡ nó.
Example: EventTarget và cleanupỞ ví dụ JavaScript thuần này, thử phát event trước khi subscribe rồi đăng ký listener. Bạn sẽ không nhận lại event cũ. Unsubscribe, subscribe lại vài lần để kiểm tra cleanup; một lần phát chỉ nên tăng bộ đếm một lần.
Cũng đừng dùng số listener nhận event làm kết quả thêm hàng. Command thành công dựa trên xác nhận của backend, không dựa trên việc badge có render được hay không.
Đi hết một lần bấm thêm vào giỏ
Sơ đồ dưới đây trả lời hai câu hỏi: catalog nhờ ai thêm hàng, và header lấy số mới từ đâu. Các mũi tên là luồng logic, không phải phép đo thời gian.
Cart service cập nhật snapshot trước khi báo cho header. Nhờ vậy, lúc header đọc lại, nó có thể lấy số 3 tương ứng với lần cập nhật vừa được xác nhận.
Catalog chỉ cần xử lý kết quả của addItem. Nó không cần tìm component header, sửa badge bằng DOM hay import store của cart. Khi mở màn hình cart sau đó, màn hình đọc dữ liệu hiện tại chứ không cần biết người dùng đã bấm nút ở đâu.
Mình chưa dùng optimistic update trong luồng này. Optimistic update là hiển thị trước kết quả dự đoán khi server chưa xác nhận; nếu áp dụng, cart owner phải điều phối cả việc dự đoán lẫn xử lý khi server từ chối.
Vì sao chỉ nghe event vẫn chưa đủ
Giả sử cart đã có 3 sản phẩm và đã phát event. Sau đó header bị tháo khỏi trang rồi gắn lại. Hai thao tác này thường gọi là unmount và mount.
Nếu header chỉ chờ event tiếp theo, nó không biết con số hiện tại cho tới lần sửa giỏ kế tiếp. Vì thế, nơi đọc cần cả hai khả năng:
- Đọc snapshot khi mount để biết dữ liệu hiện có.
- Nhận thông báo thay đổi để cập nhật trong lúc đang hiển thị.
Có thể hình dung event là tiếng chuông báo “sổ vừa đổi”, còn snapshot là nội dung sổ hiện tại. Người đến muộn không nghe được tiếng chuông cũ nhưng vẫn phải đọc được sổ.
Example: bản chỉ nghe eventBản này cố ý bỏ bước đọc snapshot khi mount. Dấu — nghĩa là consumer chưa biết dữ liệu, không phải giỏ rỗng. Thêm một cốc, ẩn header/Cart Vue, thêm tiếp rồi mount lại: consumer vẫn chờ một event mới. Trong cùng dialog, đổi Phiên bản về Snapshot + subscription để làm lại và đối chiếu. Đây là hai biến thể của lab v1, không phải hai release triển khai độc lập.
Implementation còn phải tránh bỏ lỡ thay đổi giữa lúc đọc và lúc bắt đầu nghe. Với snapshot đồng bộ, đăng ký listener trước rồi đọc là một cách xử lý. Với query bất đồng bộ, owner còn phải ngăn response cũ ghi đè dữ liệu mới. Hai bài Events và message bus và State ownership đi sâu vào phần này.
Thử trực tiếp React và Vue
Bạn có thể kiểm tra các ý trên bằng một Catalog viết bằng React, một Cart viết bằng Vue và header do shell JavaScript render. Chúng dùng cùng cart owner qua command, snapshot và subscription; Catalog không import store của Vue, còn Cart không cần biết component React nào đã gửi yêu cầu.
Example: shop React + VueBấm nút trên hoặc nút nổi Example ở góc trang để mở dialog. Trong dialog, Ví dụ chọn app và Phiên bản chọn hành vi cần so sánh. Trang docs không bị thu hẹp, không đặt một iframe lớn giữa bài. Chỉ app đang chọn được chạy; đổi phiên bản, tải lại hoặc đóng rồi mở lại sẽ reset state. Nếu iframe bị chặn, dùng nút mở tab mới; khi không có JavaScript, link example dẫn thẳng tới app.
Demo trông như một shop nhỏ: có ảnh sản phẩm, giá minh họa, nút tăng/giảm số lượng và giỏ có tạm tính. Trên mobile, chọn Giỏ hàng ở header để cuộn tới giỏ. Mở mục Xem luồng giao tiếp bên dưới shop khi muốn xem log hoặc thử các nút ẩn/mount và mô phỏng lỗi.
Ban đầu giỏ có hai chiếc áo. Giữ số lượng ở thẻ cốc bằng 1 rồi thử theo thứ tự:
| Thao tác | Kết quả cần quan sát |
|---|---|
| Bấm Thêm vào giỏ ở thẻ cốc | Log đi qua COMMAND, COMMIT rồi EVENT. Header và Cart Vue cùng hiện 3 sản phẩm, nhưng giỏ chỉ có hai dòng hàng. |
| Bấm Ẩn Header và Ẩn Cart Vue, rồi thêm cốc | Listener về 0 nhưng command vẫn thành công vì cart owner chưa bị tháo. |
| Bấm Mount Header và Mount Cart Vue | Cả hai đọc snapshot mới và hiện 4, không chờ một event tiếp theo. |
| Ẩn/mount Cart Vue vài lần | Listener quay lại đúng 2: một cho header, một cho Cart Vue. Không tích lũy listener cũ. |
| Bấm Phát lại event cuối hoặc Gửi event sai schema | Event lặp bị bỏ qua theo revision; event sai bị từ chối trước dispatch. Quantity không bị cộng thêm. |
| Bấm Từ chối lần thêm tiếp theo, rồi Thêm vào giỏ ở thẻ áo | API giả lập từ chối; không COMMIT snapshot mới và không phát event cập nhật giỏ. |
Bạn cũng có thể tăng/giảm số lượng hoặc Xóa một dòng trong giỏ Vue. Thao tác đó gọi command setQuantity qua cart owner; badge header, tổng quantity và tạm tính cùng cập nhật sau xác nhận giả lập. Đây là thao tác bổ sung để quan sát cùng một owner, không phải cho component Vue sửa thẳng store. Giá chỉ là fixture hiển thị; lab không xác nhận giá qua backend và không có checkout.
Log QUERY thể hiện việc consumer đọc snapshot; đó không phải request HTTP. Xóa log chỉ xóa lịch sử quan sát, không reset cart. Muốn bắt đầu lại từ hai chiếc áo, chọn nút Tải lại example trong toolbar dialog.
Giới hạn của lab
API chỉ là mô phỏng bất đồng bộ trong memory với thời gian chờ 350 ms; không gọi backend, không lưu giỏ qua reload. React và Vue được build cùng một demo, chưa minh họa tải remote bằng Module Federation hoặc deploy độc lập. Event schema được rút gọn cho thực hành. Iframe chỉ tách app demo khỏi trang docs; các phần bên trong vẫn giao tiếp trong cùng document.
Nên nối các phần với nhau bằng cách nào
Bắt đầu bằng lời gọi nhỏ thay vì một global store
Nếu shell và remote chạy trong cùng môi trường JavaScript của trang, shell có thể truyền hàm addItem vào catalog lúc mount. Cách truyền một chức năng cụ thể như vậy còn gọi là cấp một capability: catalog được phép yêu cầu thêm hàng, không được sửa tùy ý toàn bộ cart state.
Mình chọn cách nhỏ này làm mặc định. Chỉ thêm event bus khi thực sự có nhiều bên cần phản ứng với thông báo, hoặc cần cách kết nối không phụ thuộc trực tiếp vào từng bên nghe.
| Nhu cầu | Điểm bắt đầu đơn giản |
|---|---|
| Truyền theme hoặc locale từ shell | Props hoặc cấu hình lúc mount |
| Thêm hàng và chờ kết quả | Hàm command do cart cung cấp |
| Hiển thị dữ liệu hiện tại và theo dõi thay đổi | Hàm đọc snapshot cùng subscription, tức đăng ký nhận thông báo |
| Nhiều phần phản ứng khi cart thay đổi | Event có tên và payload rõ ràng |
| Bookmark bộ lọc | URL và API điều hướng |
| Giao tiếp với iframe | Protocol message qua postMessage, không truyền trực tiếp function |
Dùng shared store vẫn hợp lý khi các component cùng một team sở hữu và cùng vòng release. Điều cần tránh là cho mọi domain phụ thuộc trực tiếp vào cấu trúc store của nhau.
Hai remote cùng cài Redux hay TanStack Query cũng không tự dùng chung dữ liệu. Nếu mỗi bên tạo store hoặc query client riêng, chúng vẫn là hai instance riêng. Module Federation giúp tải/chia sẻ module; nó không tự chọn owner hoặc tạo cơ chế đồng bộ state cho bạn.
Thống nhất ý nghĩa trước khi chọn công cụ
Hai bên cùng đọc được { itemCount: 3 } nhưng vẫn có thể hiểu khác nhau. Cart hiểu là tổng quantity, còn header hiểu là số dòng sản phẩm. Giỏ có hai chiếc áo và một chiếc cốc thì hai cách đếm lần lượt cho ra 3 và 2.
Thỏa thuận về dữ liệu và cách dùng nó gọi là contract. Trong ví dụ này, contract phải nói itemCount đếm gì, đọc lúc nào và dữ liệu lỗi thì làm gì. Function, event bus hay store chỉ là cách truyền dữ liệu theo thỏa thuận đó.
Vì vậy, bước tiếp theo nên là viết một contract nhỏ cho addItem và số lượng giỏ, chưa phải chọn thêm một thư viện giao tiếp.
URL cache và session đặt ở đâu
Cùng cách chia trách nhiệm đó áp dụng được cho những state khác. Bạn chưa cần giải quyết hết ở trang này; chỉ cần biết mỗi bài sau trả lời vấn đề nào.
URL: Với /catalog?q=mug&page=2, catalog định nghĩa ý nghĩa của bộ lọc. Shell có thể quản lý browser history — lịch sử dùng cho Back/Forward — và nhận yêu cầu điều hướng từ remote. Mặc định nên có một nơi điều phối history của trang chính, thay vì nhiều router cùng tự ghi URL.
Cache: Cache là bản sao dữ liệu để đọc lại nhanh. Cart service quản lý cache cart và quyết định khi nào phải tải lại sau một thay đổi. Header không nên tự biết query key hoặc endpoint nội bộ để xóa cache hộ cart.
Session: Session xác định người dùng hiện tại. Khi logout hoặc đổi user, dữ liệu cart cũ phải bị loại bỏ. Request của user A về muộn không được đưa dữ liệu của A vào phiên user B. Đây là lý do vòng đời dữ liệu không thể chỉ phụ thuộc vào việc component còn mount hay không.
Chia module không tạo vùng bảo mật
Không broadcast access token qua event để đồng bộ login. Các remote cùng chạy trong trang không tự được cách ly chỉ vì có tên khác nhau. Backend vẫn phải kiểm tra quyền trên từng request.
Thử thiết kế khi mọi thứ không đi đúng thứ tự
Sau khi luồng bình thường chạy được, hãy kiểm tra các tình huống sau. Mỗi tình huống cho thấy một phần trách nhiệm còn thiếu.
| Tình huống | Hành vi cần có |
|---|---|
| Chưa mở màn hình cart nhưng bấm thêm hàng ở catalog | Cart service vẫn nhận được command |
| Header mount sau lần thêm hàng | Đọc được snapshot hiện tại, không chờ event mới |
| Header unmount rồi mount nhiều lần | Listener cũ được dọn, một thay đổi không gọi nhiều handler cũ |
| Request timeout | Không vội kết luận server chưa thêm; kiểm tra lại kết quả trước khi retry |
| Logout khi request chưa xong | Response của phiên cũ không điền lại cart cache |
| Cart vừa deploy nhưng shell vẫn là bản cũ | Contract cũ còn được hỗ trợ hoặc có fallback rõ ràng |
Timeout là trường hợp đáng chú ý: server có thể đã thêm cốc nhưng response bị mất. Gửi một yêu cầu mới ngay có thể thêm hai lần. Bài contracts sẽ giải thích cách biểu diễn kết quả “chưa biết” thay vì gộp nó với lỗi bị từ chối.
Lộ trình đọc và bài tập
Nếu đọc từ đầu, hãy theo thứ tự sidebar:
| Bài | Câu hỏi chính |
|---|---|
| Hợp đồng giao tiếp | Hàm, dữ liệu và hành vi nào là cam kết giữa hai phần? |
| Events và message bus | Phát/nghe event, kiểm tra payload và dọn listener thế nào? |
| State ownership | Ai giữ state và ai được thay đổi nó? |
| Routing và navigation | Ai đổi URL và xử lý Back/Forward? |
| Data fetching và cache | Ai gọi API, giữ cache và yêu cầu dữ liệu mới? |
| Authentication và session | Ai quản lý phiên và xóa dữ liệu khi đổi user? |
Trước khi đọc tiếp, thử trả lời bằng lời cho shop của bạn: catalog nhờ ai thêm hàng; header hỏi ai để lấy số lượng; header mở muộn thì đọc từ đâu; logout thì ai xóa bản sao cart?
Nếu câu trả lời đang là “phần nào cũng sửa store chung”, hãy chọn lại một owner và viết ra đúng những thao tác phần khác cần dùng. Đó là đầu vào cho bài contracts.
Đọc tiếp
So sánh các cách tích hợp
Chọn Module Federation, single-spa, Web Components hay iframe theo boundary, mức độ tin cậy, trải nghiệm và yêu cầu vận hành.
Hợp đồng giao tiếp
Thiết kế một API nhỏ giữa catalog và cart: dữ liệu có nghĩa gì, kết quả được xử lý ra sao và đổi phiên bản mà không làm hỏng bên dùng.