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:
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:
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:
Ở đâ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:
Ta bấm Sign In
zô trong:
Ở đâ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:
Ở đâ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:
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:
Ta cũng có thể resize, kéo kéo xung quanh, hoặc close widget:
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:
Ở 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
):
Sau đó, mỗi Widget sẽ mặc định nhận được 2 thứ sau truyền từ app shell xuống, đó là inputData
và outputData
:
- 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 😊😊
Ở 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:
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ả emittingChannels
và receivingChannels
ý nó là hybrid
widget, nên là có icon 2 chiều mũi tên:
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:
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ó inputData
và outputData
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 component
và plugins
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ó hybrid
và receiver
widget, các bạn thử emitter
và standalone
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