+10

Giao tiếp giữa các thành phần trong kiến trúc Microfrontend

Hello các bạn lại là mình đây 👋👋

Tiếp tục với series Chập chững làm quen với Microfrontend, ở bài hôm nay chúng ta sẽ cùng tìm hiểu cách để các Microfrontend giao tiếp với nhau như thế nào nhé.

Mặc áo phao và lên thuyền luôn với mình nào anh chị em ơiiii 🛳️🛳️

Lắc não một chút

Đến bài này thì ta đã biết được cách dựng một kiến trúc MFE cơ bản như thế nào, ở đó ta có các MFE với nhiều framework khác nhau cũng tồn tại:

Giao tiếp giữa các thành phần trong kiến trúc Microfrontend.png

Các MFE ở đây có thể là dạng widget - là các phần nhỏ trên 1 màn hình, share chung không gian với các MFE khác. Hoặc cũng có thể là MFE ở dạng app view, hoạt động như 1 app đầy đủ, với routing các thứ.

Nhưng hiện tại các MFE của ta đang hoàn toàn độc lập với nhau, không có sự giao tiếp gì với chúng hết trơn. Nhưng trên thực tế, rất nhiều khi ta cần chuyển data từ một MFE sang một nơi khác để hiển thị, ví dụ dạng master-detail: click vào 1 row trên table sẽ show detail ở bên cạnh:

Giao tiếp giữa các thành phần trong kiến trúc Microfrontend (1).png

Có rất nhiều cách tiếp cận khi xử lý vấn đề về giao tiếp này, ở bài hôm nay chúng ta sẽ điểm qua một số cách cơ bản và theo mình là dễ để triển khai nhất nhé 😘

Yêu cầu bài toán

Sau một thời gian làm với MFE thì đây là check list mình đặt ra khi xây dựng mô hình cho phép giao tiếp giữa các MFE:

  • 1 MFE có thể gửi data sang 1 hoặc nhiều MFE khác cùng lúc
  • 1 MFE có thể chỉ nhận (receiver), hoặc chỉ gửi data (emitter), hoặc vừa nhận vừa gửi (hybrid, hoặc không nhận không gửi gì từ MFE nào cả (standalone)
  • Khi gửi hoặc nhận data, thì có thể gửi/nhận trên nhiều channel khác nhau, lấy ví dụ master-detail ở trên, ở phía MFE Detail, ta có 2 chart, barchart có thể nhận từ 1 MFE , pie chart nhận data từ 1 MFE khác, điều này cho phép 1 MFE có thể gửi/nhận data với nhiều loại MFE khác nhau
  • Điều cuối cùng và quan trọng nhất: developer, người mà dev MFE phải có trải nghiệm "native" nhất có thể, họ không nên cảm thấy rằng như họ đang làm việc với một internal framework nào đó. Cần phải mang lại cho họ trải nghiệm như kiểu họ đang dev React/Vue/Angular bình thường. Hơn thế nữa, đảm bảo được điều này sẽ giúp cho code của team dev MFE có thể cắm vào 1 kiến trúc microfrontend hoặc tách ra chạy ở 1 project độc lập

Thực hành

Setup

Bởi vì implementation cho phần giao tiếp này không ngắn (nhưng cũng không quá dài 😂), và để tiết kiệm thời gian, thì mình đã implement sẵn, chúng ta có thể chạy được luôn, và xuyên suốt bài này chúng ta sẽ cùng "thẩm" từng phần nhé 😘 Triển thôi nàooooooo. 💪

Đầu tiên các bạn clone source của mình ở đây: https://github.com/maitrungduc1410/viblo-mfe-linking

Clone về thì ta có như sau:

Screenshot 2025-03-17 at 10.13.43 PM.jpg

Ở đây, như thường lệ ta vẫn có 1 app shell và 3 MFE (React/Vue/Angular)

Để setup thì ta đơn giản chạy npm install ở root folder project là nó sẽ tự install cho từng project luôn, sau đó ta start project lên:

npm start

Tổng quan

Tiếp theo ta mở trình duyệt ở địa chỉ http://localhost:4200 sẽ thấy như sau:

Screenshot 2025-03-17 at 10.16.02 PM.jpg

Ta bấm Sign In zô trong:

Screenshot 2025-03-17 at 10.16.36 PM.jpg

Ở đây ta có giao diện mặc định của app shell ở chế độ Widget View. Trên thanh header bar ta có nút switch để chuyển giữa App View và Widget View, 1 nút màu xanh lá 🟢 để mở danh sách các Widgets (là các MFE), và nút Logout. Bấm mở list widgets ta thấy:

Screenshot 2025-03-17 at 10.17.19 PM.jpg

Ở đây ta thấy rằng 3 MFE đã được load lên thành công, tương tự ở bên App View ta cũng thấy có 3 App:

Screenshot 2025-03-17 at 10.20.15 PM.png

Giờ ta quay lại Widget View, ta có thể bấm vào từng widget để thêm chúng vào màn hình chính, ta có thể thêm bao nhiêu instance của một widget cũng được:

ezgif-69641802753a1a.gif

Ta cũng có thể resize, kéo kéo xung quanh, hoặc close widget:

ezgif-656297326fb47a.gif

Trông cũng ra gì phết ý nhờ, đỉnh nóc kịch trần bay phấp phới đớiiiiiii 🤣🤣🤣🤣🤣

Ý tưởng

Ý tưởng của bài thì chắc các bạn cũng có thể đoán ra được ngay, đó là ta ta sẽ dùng App shell như 1 cái Event bus - nhận event bắn ra từ các MFE và gửi nó tới các MFE muốn nhận:

Giao tiếp giữa các thành phần trong kiến trúc Microfrontend (2).png

Ở file loader.ts ở mỗi widget, các widget sẽ cần khai báo các channel mà nó sẽ gửi data đi (emittingChannels) hoặc các channel mà từ đó nó muốn nhận data (receivingChannels):

image.png

Sau đó, mỗi Widget sẽ mặc định nhận được 2 thứ sau truyền từ app shell xuống, đó là inputDataoutputData:

  • inputData: data được gửi tới từ widget khác, bao gồm tên channel và payload là gì
  • outputData là 1 function để widget có thể gửi data đi

inputData và outputData nhận được từ phía widget sẽ dùng chính những công nghệ của framework mà widget đó đang dùng, ví dụ với Angular thì là @Input/Output, Vue thì là ref, React thì như các bạn vẫn biết đó 🤣🤣

Vọc vạch

Giờ ta thử vọc tí xem mấy đứa MFE này giao tiếp như thế nào nào 😊😊

ezgif-776f68cc02f438.gif

Ở trên các bạn thấy ở phía góc trên bên trái mỗi widget ta có 1 nút để show ra tất cả các widgets mà từ các widgets đó có thể bắn data tới widget hiện tại, chú ý rằng các widgets nhận/gửi data cần phải khai báo cùng channel ở loader.ts thì nó mới hiện ra để ta chọn đó nha 😉

Sau khi ấn chọn 1 widget ta sẽ thấy icon sau là báo hiện link thành công nha:

Screenshot 2025-03-18 at 10.02.24 PM.png

Như trên báo hiệu rằng widget Vue hiện tại đã được link tới widget Angular (màu tím), và sẵn sàng nhận data emit từ Angular

Giờ ta đi vào sâu hơn chút phần implementation ở các widget nhé

Angular

Đầu tiên là Angular widget, ta có loader.ts trông như sau:

const widget = {
  id: 'ANGULAR_WIDGET',
  framework: 'angular',
  component: AppComponent,
  title: 'My Angular Widget',
  emittingChannels: ['ANGULAR_WIDGET'],
  receivingChannels: ['ANGULAR_WIDGET', 'REACT_WIDGET'],
  width: 600,
  height: 400
};

Ở trên ta thấy rằng Angular widget có cả emittingChannelsreceivingChannels ý nó là hybrid widget, nên là có icon 2 chiều mũi tên:

Screenshot 2025-03-18 at 10.10.30 PM.png

Nó có thể emit data trên channel ANGULAR_WIDGET và nhận data từ 2 channel 'ANGULAR_WIDGET', 'REACT_WIDGET', việc gửi và nhận ở ở cùng ANGULAR_WIDGET sẽ cho phép ta có thể tạo 2 instance của cùng 1 angular widget và link nó với nhau:

ezgif-3985fd8938e63b.gif

Phần code của Angular widget để gửi/nhận data cũng rất đơn giản như sau:

export class AppComponent {
  angularLogo = angularLogo;
  @Input() inputData!: Data;
  @Output() outputData = new EventEmitter<Data>();

  stringify = JSON.stringify;

  _inputData?: Data;

  handleClick() {
    this.outputData.emit({
      channel: 'ANGULAR_WIDGET',
      data: 'Hello from Angular',
    });
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes['inputData'] && changes['inputData'].currentValue) {
      this._inputData = changes['inputData'].currentValue;
    }
  }
}

Ở trên ta có 1 cái @Input() inputData để nhận data từ các widget khác, và @Output() outputData để gửi data đi khi cần, khi gửi data thì ta cần nói rõ channel là gì, data thì gửi bất kì cái gì cũng được

Để listen khi có data mới gửi về từ widget khác thì ta dùng lifecycle hook của Angular ngOnChanges và lấy ra inputData trong đó.

Như các bạn thấy thì về mặt trải nghiệm developer thì hoàn toàn đây đều là kiến thức của Angular hết, chứ không có gì gọi là custom dành riêng cho Microfrontend cả, điều này sẽ không gây cho người dev widget cảm giác như kiểu đang làm việc với inhouse framework và thi thoảng tự hỏi "liệu mai kia mình nghỉ công ty thì kiến thức này có còn dùng được không?" 😂😂

Ở phía App shell -> angular-wrapper, để lắng nghe outputData gửi lên từ phía MFE thì mình có đoạn:

this.componentRef.instance.outputData?.subscribe((data: Data) => {
  this.outputData.emit(data);
});

Vì bản chất cái EventEmitter trong Angular nó extends từ RxJS Subject, nên trên app shell mình chỉ cần subscribe nó là sẽ nhận được data gửi lên từ widget. Sau đó từ angular-wrapper mình emit 1 lần nữa lên widget-view , ở đó ta có handleOutputData sẽ làm nhiệm vụ tìm ra tất cả các widget muốn nhận data trên channel tương ứng và gửi chúng đi

Ở đây mình dùng Signal cho tiện, có thể thay đổi trực tiếp luôn mà không cần phải gán object/array mới (kiểu như React vẫn làm, để đảm bảo immutable). widget-view.component.ts ta có mfe.inputData.set(event), sau đó bên widget-view.component.html ta truyền luônwidget.inputData()

React

Về phần implementation của React thì đơn giản miễn chê rồi:

import "./App.css";
import reactLogo from "../public/react.svg?inline";

export type Data = {
  channel: string;
  data: any;
};

interface IProps {
  inputData: Data;
  outputData: (data: Data) => void;
}

const App = (props: IProps) => {
  return (
    <div className="content">
      <div>
        <img src={reactLogo} alt="React Logo" width={50} />
      </div>
      <h2>Input Data</h2>
      <pre>{JSON.stringify(props.inputData, null, 2)}</pre>
      <div>
        <button
          onClick={() =>
            props.outputData({
              channel: "REACT_WIDGET",
              data: "Hello from React",
            })
          }
        >
          Click me
        </button>
      </div>
    </div>
  );
};

export default App;

Trông không khác 1 tí gì so với các app React thường, đây là 1 trong những cái mình thích nhất của React 😚😚

Phía app-shell cứ mỗi khi ta nhận được inputData mới thì ta gọi _renderComponent, React sẽ không render lại toàn bộ UI tree mà sẽ diff để tìm thành phần thay đổi và chỉ render lại những thứ liên quan

Vue

Đến Vue thì ta để ý ở loader.ts ta có như sau:

const widget = {
  id: "VUE_WIDGET", // must be globally unique
  framework: "vue",
  component: App,
  title: "My Vue Widget",
  receivingChannels: ["REACT_WIDGET", "ANGULAR_WIDGET"],
  width: 600,
  height: 400,
};

Ở trên ta chỉ khai báo receivingChannels, tức là nó là receiver widget, chỉ nhận được chứ không thể emit data

Phần implementation ở App.vue thì vẫn vậy, có inputDataoutputData và ta để ý rằng inputData là 1 cái ref được truyền vào từ app-shell > vue-wrapper, nên là phía MFE nó hoàn toàn reactive như thường và ta có thể watch hay dùng với computed các kiểu nha 💪

Chỉ có 1 điều duy nhất, đó là ở Vue 3 thì ta không lắng nghe được event emit từ root component, nên ở app-shell ta không lắng nghe được event emit từ phía App.vue của MFE, do vậy ở App.vue ta vẫn phải dùng dạng function props.outputData. Chứ nếu tuyệt vời nhất thì nó phải kiểu như sau thì chuẩn Vue 100%:

const emit = defineEmits<{
  (e: 'outputData', data: Data): void
}>()

emit('outputData', {
  channel: 'VUE_WIDGET',
  data: 'Hello from Vue!'
})

Thôi thì trade off này cũng nhỏ ta bỏ qua nha 🤣🤣

Còn App View thì sao?

App view bản chất nó chỉ là 1 MFE, không cần giao tiếp với các MFE khác, nên cùng lắm thì nó chỉ nhận data truyền xuống từ app-shell, cái này ta có thể truyền thẳng vào route được, y như ta đang làm khi truyền componentplugins vào vậy:

//main.component.ts

mainRoute.children?.push({
  path: l.routeName,
  ...(l.framework === 'angular'
    ? { loadChildren: () => l.module }
    : l.framework === 'react'
    ? { component: ReactWrapperComponent }
    : { component: VueWrapperComponent }),
  data: {
    component: l.component,
    plugins: l.plugins,
  },
});

Thực tế mình thấy thì App View bản chất nó cũng đã như 1 cái "app-shell nhỏ" rồi, nó sẽ tự maintain phần linking 😉

Bài tập về nhà

Phần demo bài này mình mới chỉ làm có hybridreceiver widget, các bạn thử emitterstandalone widgets xem có chạy ổn không nhé 😘

Nhìn lại chút và kết bài

Triển khai việc "kết nối" giữa các MFE gần như là bài toán mà chắc chắn ta sẽ gặp phải khi làm thực tế, và để làm sao cho việc này đơn giản nhất, mang lại trải nghiệm tốt nhất cho người dev MFE cũng là những điều ta cần luôn phải để tâm. Như vậy thì kiến trúc Microfrontend của chúng ta mới mạnh, hỗ trợ nhiều, và code dev từ phía MFE team cũng sẽ có thể tái sử dụng được ở nhiều môi trường khác nhau, không phụ thuộc vào tool ngoài

Hi vọng rằng qua bài hôm nay các bạn đã biết thêm 1 cách để làm việc giao tiếp giữa các MFE, từ đó tinh chỉnh (hoặc bê y nguyên 😅) cho phù hợp vào các dự án thực tế. 💪

Chúc các bạn buổi tối vui vẻ, hẹn gặp lại các bạn vào những bài sau 👋👋


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí