+5

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.

1.jpg

Ở 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.

2.jpg

Ở 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:

Screenshot 2024-12-15 at 12.29.02 PM.jpg

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:

Screenshot 2024-12-14 at 11.54.56 AM.jpg

Screenshot 2024-12-14 at 12.25.39 PM (2).jpg

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: Screenshot 2024-12-14 at 5.13.31 PM.jpg

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:

Screenshot 2024-12-14 at 5.12.51 PM.jpg

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:

20241214-175213.jpeg

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)

20241214-175512.jpg

Screenshot 2024-12-14 at 5.56.02 PM.jpg

Ở 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.018.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:

20241214-181331.jpg

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 đó:

Screenshot 2024-12-14 at 6.14.43 PM.jpg

À 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:

Screenshot 2024-12-14 at 6.30.19 PM.jpg

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:

20241214-183232.jpg

Để 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_modules3.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:

20241214-183902.jpg

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 version 3.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-appangular-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:

Screenshot 2024-12-15 at 12.34.26 PM.jpg

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:

Screenshot 2024-12-15 at 12.36.25 PM.jpg

Có lỗi ở file app.component.html, ta click vào xem sao nha:

20241215-123807.jpg

À 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 requiredVersionstrictVersion, ý 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

Screenshot 2024-12-15 at 12.53.54 PM.jpg

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:3002http://localhost:3003. Còn tất cả những MFE Angular đều không chạy?? 🧐🧐

Screenshot 2024-12-15 at 5.20.15 PM.jpg

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:

Screenshot 2024-12-15 at 5.28.24 PM.jpg

Ủa sao vẫn lỗi vậy nhờ?????? 🤔🤔

Ta thử click zô đây xem:

20241215-172943.jpg

20241215-173035.jpg

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:

Screenshot 2024-12-15 at 5.33.08 PM.jpg

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):

Screenshot 2024-12-15 at 5.34.45 PM.png

Trong khi trước đó chỉ có 28KB

Screenshot 2024-12-15 at 5.35.04 PM.png

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:

Screenshot 2024-12-15 at 5.37.50 PM.jpg

Ở 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 reactreact-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ể:

20241215-174610.jpeg

So với trước kia:

20241215-174522.jpeg

Sau khi login xong, nếu thật sự ta cần với React Wrapper thì ta mới load React vào:

Screenshot 2024-12-15 at 5.47.32 PM.jpg

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

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í