Giải quyết vấn đề về Shared Dependencies trong kiến trúc Microfrontend
Hello các bạn lại là mình đây 👋👋
Tiếp tục quay trở lại với series Chập chững làm quen với Microfrontend, hôm nay ta sẽ tìm hiểu về cách xử lý shared dependencies sao cho giảm thiểu sự đụng độ giữa các Microfrontend, tránh load trùng lặp nhé.
Lại mặc áo phao zô và lên thuyền với mình thoaiiii 🛳️🛳️
Đàm đạo về shared dependencies
Trong kiến trúc MFE, ta dễ dàng gặp phải một số vấn đề phổ biến về dependencies như sau
Tăng Kích Thước Bundle
Khi nhiều microfrontend đóng gói các thư viện chung một cách độc lập, bundle sẽ tăng kích thước không cần thiết.
Ở trên ta để ý rằng trên App shell có nhiều React MFE, mỗi bản thân chúng thì yêu cầu bắt buộc phải có react
là dependency, mỗi khi load lên là chúng lại load kèm theo cả react
riêng biệt, mà không sử dụng lại:
- điều này làm cho bundle size của các MFE tăng lên
- bundle size tăng tức là thời gian load sẽ tăng, data truyền qua network sẽ tốn nhiều hơn
Xung Đột Phiên Bản
Khi các microfrontend có nhu cầu sử dụng các phiên bản khác nhau của cùng một thư viện, điều này có thể gây ra lỗi runtime.
Ở ví dụ trên ta thấy rằng có nhiều React MFE khác nhau, mỗi cái lại dùng 1 phiên bản React khác nhau. Giả sử ta muốn dùng Hook thì ở các phiên bản React < 16.8 sẽ không support, và ta sẽ gặp lỗi 🫠
Khó Bảo Trì
Việc cập nhật dependencies trở nên phức tạp khi các thư viện chung không được quản lý đồng bộ, dẫn đến việc từng microfrontend có thể sử dụng các phiên bản khác nhau hoặc phải sao chép chúng trong từng bundle. Điều này không chỉ làm tăng độ phức tạp trong bảo trì mà còn dễ gây ra lỗi không tương thích giữa các module.
Giờ ta cùng dzô phần chính để xem cách giải quyết những vấn đề này trong kiến trúc MFE như nào nha 💪💪
Setup
Đầu tiên các bạn clone code của mình ở đây: https://github.com/maitrungduc1410/viblo-mfe-shared-deps.git
Sau khi clone về ta có như sau:
Ta install dependencies cho tất cả các folder bằng 1 command:
npm run install:all
Sau đó thì ta start tất cả các project lên (chỉ cần chạy 1 lần ở root folder project ở đó mình đã setup concurrently
để start all in all trong 1 command):
npm start
Oke thì ta mở trình duyệt ở địa chỉ http://localhost:4200
:
Login zô trong ta có 1 cái giao diện lởm lởm, có thể kéo kéo mấy cái MFE như kiểu widget loanh quanh 🤣🤣
Nghịch nghịch chút rồi ta zô phần chính nha 😘
React
Ta để ý rằng hiện tại ở webpack.config.js
của app-shell
phần shared
của ModuleFederationPlugin
trông như sau:
new container.ModuleFederationPlugin({
shared: {
'@angular/core': { eager: true, singleton: true },
'@angular/common': { eager: true, singleton: true },
'@angular/router': { eager: true, singleton: true },
vue: {
eager: true,
singleton: true,
},
react: {
eager: true,
singleton: true,
},
'react-dom/client': {
eager: true,
singleton: true,
},
},
}),
Ở đó ta có:
react: {
eager: true,
singleton: true,
},
'react-dom/client': {
eager: true,
singleton: true,
},
Còn phía project react-app > rsbuild.config.ts
thì ta có:
react: {
singleton: true,
},
"react-dom/client": {
singleton: true,
},
Các bạn có để ý rằng phía MFE thì ta lại không có eager: true
. Vậy bản chất mấy thằng đó là gì???? 🤔🤔
Thì bản chất của hội này như sau:
singleton: true
: Đảm bảo chỉ có một instance của thư viện được tải trên toàn bộ ứng dụng. Điều này giúp tránh các vấn đề liên quan đến việc tạo nhiều phiên bản, chẳng hạn như mất context của React.eager: true
: Đảm bảo module được tải ngay khi ứng dụng khởi động, tránh trường hợp module chỉ được tải khi cần. Cái việc "tải ngay" này tức là nó sẽ được bundle chung vào với code JS của app-shell hoặc MFE
Ở trong bài này ta có thể thấy rằng vì app shell luôn là thực thể được load đầu tiên, nên ta set eager=true
cho React ở phía app shell, các MFE sau load lên thì nó sẽ chỉ đơn giản là tận dụng lại luôn (vì ta có singleton=true
)
Hiện tịa nếu ta inspect network request và check JS resource, ta để ý rằng với React MFE ta có 4 files như sau:
Giờ ở react-app > rsbuild.config.ts
ta cho 2 cái dependency của React thành eager=true
xem nhé:
react: {
singleton: true,
eager: true
},
"react-dom/client": {
singleton: true,
eager: true
},
Sau đó quay trở lại app shell F5:
Ta để ý rằng bundle size của remoteEntry.js
đã tăng lên từ 76KB -> 390KB
Ở app-shell/src/app/utils/federation-utils.ts
mình có để đoạn log sau:
console.log(111, __webpack_share_scopes__)
Mở console của trình duyệt ta thấy như sau:
Ta bấm vào xem 1 trong mấy cái đó cái nào cũng được (vì nó là object nên sẽ chỉ show giá trị mới nhất)
Ở trên ta để ý rằng ta có shareScope=default
, cái này dùng để share dependency với những scope khác nhau, chỉ những cái nào chung scope thì mới share, bình thường ta không dùng tới cái này mấy mà cứ để default
thôi
Bên trong ta thấy các dependencies đang được share ở thời điểm hiện tại trong toàn bộ các project của chúng ta (bao gồm cả app-shell và MFE - lưu ở __webpack_share_scopes__
của app shell)
Ở đây ta thấy rằng trong scope hiện tại ta có 2 phiên bản của React 18.2.0
và 18.3.1
, nhưng chỉ có 18.2.0
mới được loaded
, còn 18.3.1
thì chưa
1 điều lưu ý rằng hiện tại ta đang dùng singleton
, nên nếu có nhiều phiên bản React đã được loaded
trong cùng shared scope
, thì phiên bản cao nhất sẽ được sử dụng. Ví dụ nếu tương lai ta có thêm MFE khác và nó dùng React 19, xong nó khai báo eager=true
nữa, thì khi đó ta có React của app-shell
(18.2) và cái React 19 cùng được loaded
, thì sau đó nếu load thêm MFE React thì nó sẽ chọn cái 19 đó nha, 😘
Ta test thử phát coi nè, ở react-app/src/App.tsx
ta import version
của React vào và in ra:
import { version } from "react";
console.log(4444, version);
Tương tự ở app-shell/src/app/wrappers/react-wrapper/react-wrapper.component.ts
import { createElement, version } from 'react';
console.log(33333, version)
Khi chạy lên ta sẽ thấy như sau:
Từ cả 2 phía App shell và React MFE ta đều thấy chung version 18.2.0
, nhưng nếu ta mở trực tiếp React ở địa chỉ http://localhost:3002
thì ta lại thấy 18.3.1
bởi vì lúc này nó sẽ load trực tiếp từ bản local React của chính MFE đó:
À ta để ý thêm nữa là ta với những gì hiện tại thì ta không cần share react-dom/client
, cái này được dùng bởi app shell để render trực tiếp React component thôi, còn phía MFE thì ta đang expose
cái loader.ts
, ở đó ta chỉ có mỗi App.tsx
và trong đó ta cũng không dùng gì tới react-dom/client
, do vậy ta có thể bỏ đi ở cả phía app-shell và react-app 😎
Vue
Với Vue thì cùng rất là giống React thôi, nói chung là làm việc với Vue và React trong kiến trúc MFE là sướng mịa nhất lun 🤣🤣
À nhưng có điều này, nếu các bạn để ý console của trình duyệt ta sẽ thấy như sau:
Có cảnh báo rằng cái version được loaded
ở app-shell là 3.2.47
, và nó không thoả mãn yêu cầu của remote tức là Vue MFE, remote cần ^3.5.8
. Lí do là vì ở cấu hình của Vue MFE nó cần như vậy:
Để fix cái này thì ta có thể làm như sau, ở vue-app/vite.config.ts
ta sửa thành:
shared: {
vue: {
singleton: true,
requiredVersion: "^3.0.0",
}
},
Ở trên ta thêm vào requiredVersion
ý nói rằng: cái này của tôi chỉ yêu cầu Vue >= 3.0.0 thôi, đừng lấy mặc định ở node_modules
là 3.5.8
Thậm chí nếu như component của ta chỉ dùng Vue 2 syntax thì ta có thể để requiredVersion: "*"
luôn, tức là phiên bản nào tôi cũng chơi được 💪💪
requiredVersion
: Xác định phiên bản thư viện cần sử dụng. Nếu phiên bản không phù hợp, sẽ đưa ra cảnh báo hoặc lỗi.
Oke thì ta lưu lại, đóng terminal đang chạy và start lại tất cả các project:
npm start
Sau đó quay lại app-shell F5 ta sẽ thấy không còn Warning nữa 🥰🥰, check console trình duyệt sẽ thấy như sau:
Với
requiredVersion
thì ta có thể viết theo dạng semantic version kiểu:^3.1.2
,~3.2.1
,*
hoặc exact version3.0.0
Angular
Có một điều quan trọng mà từ đầu bài tới giờ mà ta chưa nói tới đó là Xung đột phiên bản
Với một số Library/framework mà tính dynamic cao kiểu React hay Vue, thì mình thấy thường vẫn luôn có cách để ta có thể xử lý được dễ dàng. Ví dụ app-shell React 15 vẫn có thể render được React 18 với Hooks. Ví dụ: https://github.com/module-federation/module-federation-examples/tree/908450d95d5d45f196dd629c9fe0b0746578d476/different-react-versions-16-18
Nhưng với một số framework như Angular, thường ta sẽ build AOT (Ahead-of-Time) và code được compile ra gần như là nó chỉ hoạt động từ version đó trở đi, khá là khoai để có thể làm nó chạy được với version cũ (cái này mình thấy là một trong những cái tù nhất của Angular luôn 😁). Giờ ta cùng xem những gì ta có thể làm với vấn đề này nhé 😁
Các bạn để ý rằng mình có để sẵn folder angular-13-app
và angular-18-app
. Ở app-shell/src/app/app.service.ts
ta bỏ comment đoạn sau để load MFE:
{
remoteEntry: 'http://localhost:3004/remoteEntry.js',
remoteName: 'angular_13_mfe_app',
exposedModule: 'Angular13AppLoader',
},
{
remoteEntry: 'http://localhost:3005/remoteEntry.js',
remoteName: 'angular_18_mfe_app',
exposedModule: 'Angular18AppLoader',
},
Sau đó ta quay lại trình duyệt F5 sẽ thấy như sau:
Như ta thấy thì tất cả các MFE đã lên chỉ trừ Angular 18, mở console trình duyệt thì thấy như sau:
Có lỗi ở file app.component.html
, ta click vào xem sao nha:
À thì ra là ở đó ta dùng block @for
, tính năng mới trên Angular 18, trong khi app-shell của ta dùng Angular 15 và nó không support, cay nhờ, trong khi Angular 13 thì chạy ngon luôn 🫤
Một trong những cách xử lý là ta cho cái Angular 18 MFE "fail fast" luôn, tức là cho nó fail càng sớm càng tốt chứ đừng có render ra empty UI như kia.
Ta mở webpack.config.js
của angular-18-app
và sửa lại đoạn shared
như sau:
shared: {
"@angular/core": {
singleton: true,
requiredVersion: "^18.0.0",
strictVersion: true,
},
"@angular/common": {
singleton: true,
requiredVersion: "^18.0.0",
strictVersion: true,
},
"@angular/router": {
singleton: true,
requiredVersion: "^18.0.0",
strictVersion: true,
},
},
Ở trên ta thêm vào requiredVersion
và strictVersion
, ý bảo là tôi yêu cầu "phải" là angular version ^18.0.0
trở lên.
Sau đó ta lưu lại, đóng terminal lại và chạy npm start
để start lại tất cả các project, oke thì quay lại trình duyệt và ta sẽ thấy không còn MFE của Angular 18 được show trên UI nữa, check console trình duyệt sẽ thấy báo lỗi sau
Lỗi bên trên ý bảo là version 15 được loaded
bởi app-shell không thoả mãn cái mà MFE cần và từ chối load MFE luôn 😜
Như các bạn thấy thì ở trên kết quả cuối cùng là không Angular 18, kể ra cũng chưa phải thật sự tốt lắm. Cá nhân mình thấy thì vấn đề về version là một trong những vấn đề cực kì nhức nhối khi làm việc với Angular, vì internal API của họ thay đổi liên tục, có khi ngay giữa các phiên bản liền kề nhau kiểu 13,14,15,16 còn không tương thích. Ở bài này ví dụ đơn giản chứ nếu ta dùng thêm các thư viện ngoài họ có nhiều ràng buộc về version còn dễ lỗi nữa.
Mình có một vài lời khuyên về việc này như sau:
- Upgrade lên Angular 18 cho app-shell: cách này là cách tăng độ tương thích lên tốt nhất, nhưng tất nhiên upgrade app-shell cần cân nhắc nhiều thứ trong dự án thực tế
- Dùng Web Components ✅: giải pháp này mình thấy khả thi nè, ta build Angular 18 component thành Web component và sau đó dùng ở app-shell như 1 web component MFE (các bạn có thể xem lại bài cũ cách add thêm support cho nhiều framework)
- Dùng
ngFor
thay vì@for
✅: đây cũng là 1 cách khả thi, chỉ là ta có thể sẽ không tận dụng được những tính năng mới nhất mà @for mang lại (https://angular.dev/api/core/@for)
Truy cập trực tiếp MFE
Các bạn có để ý rằng, hiện tại nếu ta truy cập địa chỉ của các MFE, thì chỉ có React và Vue là chạy được ở địa chỉ http://localhost:3002
và http://localhost:3003
. Còn tất cả những MFE Angular đều không chạy?? 🧐🧐
Lỗi in ra Shared module is not available for eager consumption: 5212
Lí do là vì các shared depedencies ta khai báo ở webpack.config.js
của Angular FE chưa được load:
shared: {
"@angular/core": { singleton: true },
"@angular/common": { singleton: true },
"@angular/router": { singleton: true },
},
Và nó cũng không có cơ chế fallback để tự load Angular từ chính project của MFE đó. Việc này đến từ việc bundler mà ta dùng, hiện tại tất cả các MFE angular ta đang dùng Webpack, nên nó sẽ không có cơ chế fallback, khác với Vue và React, ở đó ta dùng package @module-federation
, họ setup khá chỉn chu và có khá nhiều cơ chế đặc biệt để xử lý việc này 😘😘
Và hiện tại để truy cập được trực tiếp Angular MFE thì ta phải update lại webpack.config.js
và cho tất cả các shared dependencies thành eager=true
. Ta test thử với angular-app
coi nha
shared: {
"@angular/core": { singleton: true, eager: true },
"@angular/common": { singleton: true, eager: true },
"@angular/router": { singleton: true, eager: true },
},
Sau khi sửa xong ta lưu lại, đóng terminal đang chạy đi và start lại:
npm start
Sau đó mở trình duyệt ở địa chỉ http://localhost:3001
:
Ủa sao vẫn lỗi vậy nhờ?????? 🤔🤔
Ta thử click zô đây xem:
Ahihi, thì ta ta cũng phải thêm cả @angular/platform-browser
vào shared dependencies nữa:
shared: {
"@angular/core": { singleton: true, eager: true },
"@angular/common": { singleton: true, eager: true },
"@angular/router": { singleton: true, eager: true },
"@angular/platform-browser": { singleton: true, eager: true },
},
Sau đó ta lưu lại, đóng terminal và chạy lại:
npm start
Quay lại trình duyệt F5:
Angular MFE lên ngon luôn nha 😎😎
Nhưng nếu ta quay lại app-shell F5 và để ý network request thì sẽ thấy rằng file remoteEntry.js của angular-app đã tăng lên đáng kể (1.6MB):
Trong khi trước đó chỉ có 28KB
Bởi vì bây giờ các dependencies của Angular được bundle cả vào với remoteEntry.js do ta đang để eager=true
đoá 😁
Do vậy ta cần cân nhắc về trường hợp này khi làm dự án thật nha 😘
Chỉ load wrapper khi cần
Ở app-shell các bạn để ý rằng với một framework/library ta cần có 1 wrapper:
Ở mỗi wrapper thì ta lại dùng API của framework/library tương ứng, ví dụ với React:
import { createElement } from 'react';
import { Root, createRoot } from 'react-dom/client';
Vấn đề là ở thời điểm ngay khi app-shell được load, ở màn login, lúc đó ta còn chưa đăng nhập, chưa biết user là gì, có được load React MFE hay không, nhưng phần code của API của React kia vẫn được bundle cả vào và user vẫn phải load lên
Thử nghĩ xem sau này ta có 50 framework/library khác nhau, trong khi sau khi login thì nó có mỗi tí Vue chẳng hạn, vậy thì thật sự là lãng phí khi ta bundle cả 49 framework/library còn lại và bắt user load
Ta update app-shell/src/app/wrappers/react-wrapper/react-wrapper.component.ts
và sửa lại như sau coi nha:
import { Component, ElementRef, Input } from '@angular/core';
import type { Root } from 'react-dom/client';
@Component({
selector: 'app-react-wrapper',
templateUrl: './react-wrapper.component.html',
styleUrls: ['./react-wrapper.component.scss'],
})
export class ReactWrapperComponent {
@Input() component: any;
root!: Root;
constructor(private readonly host: ElementRef) {}
async ngAfterViewInit() {
import('react').then((r) => {
import('react-dom').then((r1: any) => {
this.root = r1.default.createRoot(this.host.nativeElement);
this.root.render(r.createElement(this.component));
});
});
}
ngOnDestroy() {
if (this.root) {
this.root.unmount();
}
}
}
Ta lưu ý rằng hiện tại ta đã dùng Dynamic import
để load react
và react-dom
, sau đó ta quay lại trình duyệt F5
Ta để ý rằng ngay ở màn Login thì size của các bundle files đã giảm đáng kể:
So với trước kia:
Sau khi login xong, nếu thật sự ta cần với React Wrapper thì ta mới load React vào:
Cách này khá ổn trong việc tối ưu, chỉ có điều ta cần lưu ý rằng hiện tại ta đang import cả package React/ReactDOM thay vì chỉ 1 function, tức là ta không có Tree Shaking nữa đâu, tổng bundle size sẽ cao hơn so với trước kia nhiều đó 😜
Vấn đề đó thì ta cũng có thể xử lý được, thay vì lazyload chỉ mỗi React/ReactDOM, ta sẽ lazyload cả ReactWrapperComponent
Với Angular < 17 thì các bạn có thể tham khảo cách mình là ở AngularWrapperComponent
:
this.viewContainerRef.createComponent(this.component, {});
Còn từ Angular 17 trở đi ta có thể dùng @defer
và apply trực tiếp vào file main.component.html
(cách này khá đơn giản và nom gọn gàng hơn, nhưng yêu cầu V17 trở lên 😁)
Cái này các bạn thử tự làm xem được không nhé
Tổng hợp lưu ý
Dưới đây là một số lưu ý của mình cho các bạn:
- Khi xây dựng kiến trúc MFE, thì ta nên định nghĩa rõ ràng các chuẩn, các shared dependencies mà các MFE "nên" follow theo. Kiểu cho các MFE team biết rằng ở app-shell đã "load" sẵn các dependencies này (React,Vue, Angular, Lodash, RxJS,...) và mọi người không cần
eager
load không cần thiết nữa - Cố gắng giữ app-shell luôn có phiên bản mới nhất, để có thể support tất cả các tính năng của các phiên bản cũ (thường với những lib/framework lớn họ sẽ có backward compatibility)
- Chỉ share những dependencies cần thiết, không nên share toàn bộ dependencies có trong
package.json
lý do là vì với mỗi shared dependency nó sẽ được tách ra thành 1 file và ta cần thêm 1 network request (cái này thì giữa các bundler khác nhau nó có cách bundle khác nhau nhưng hầu như là ta sẽ tốn thêm network request)
Kể ra khi nói tới vấn đề về shared dependencies trong kiến trúc MFE thì cũng khá là chuối và cần nhiều kinh nghiệm, khả năng xử lý vấn đề trong dự án thực tế đó 😅
Nhưng theo thời gian gặp nhiều vấn đề mình tin là các bạn sẽ quen dần thôi nếu hiểu rõ bản chất mọi thứ hoạt động như thế nào 😘
Ở bài này ta xử lý share giữa các lib/framework như React/Vue/Angular, còn thực tế ta có thể share nhiều thứ nữa (axios, lodash, rxjs, ...)
Kết bài
Phewwwww, bài vừa dài vừa khoai 🤣
Hi vọng qua bài này đã giúp các bạn tự tin hơn khi xử lý các vấn đề xoay quanh việc shared dependencies trong kiến trúc Microfrontend
Chặng đường muôn vàn chông gai, luôn luôn chăm chỉ trau dồi, tích luỹ kinh nghiệm thực chiến nha 😘. Cám ơn các bạn đã theo dõi, hẹn gặp lại các bạn vào những bài sau 👋👋
All rights reserved