Xử lý bài toán multi bundlers khi làm việc với Microfrontend
Hello các bạn lại là mình đây 👋👋
Mỗi đợt bận việc trên cty nhưng ngày nào cũng lên Viblo nhìn thông báo nhảy tưng tưng thấy mọi người vẫn đọc blog của mình đều, nhìn thôi là thấy có động lực viết tiếp liền. Nhưng khổ cũng bắt đầu già rồi nên càng ngày càng lười 😂😂
Kể từ khi viết bài Microfrontend, Module Federation - đưa microservices đến với frontend đến giờ, có khá là nhiều bạn nhắn hỏi mình các vấn đề xoay quanh nó. Nhận thấy MFE hiện tại (ở VN) có vẻ vẫn còn mới và nhiều người gặp khó khăn trong việc tiếp cận nó nên mình sẽ viết thành 1 series với tất cả những gì mà mình đã từng làm, hi vọng rằng có thể đưa đến cho các bạn nhiều giải pháp trong cách vận hành nó.
Ở bài ngày hôm nay ta sẽ cùng nhau setup để kiến trúc MFE của chúng ta có thể support nhiều bundlers thay vì chỉ mỗi Webpack nhé.
Lên thuyền thôi anh em ơiiiiiiiiii 🚢🚢
Review kiến thức
Mình rất hi vọng các bạn đã đọc bài trước của mình và làm theo ví dụ ở bài đó: Microfrontend, Module Federation - đưa microservices đến với frontend, để có thể có các kiến thức và hiểu cơ bản về MFE.
Một số tiêu chí mà mình nghĩ 1 kiến trúc MFE tốt cần có như sau:
- Các MFE và app shell là độc lập, các team dev riêng, deploy riêng, pipeline riêng...Không ai block ai
- Thay đổi 1 MFE thì chỉ MFE đó cần deploy lại
- Có thể áp dụng authentication/authorization để control khi nào load MFE nào
- Mỗi MFE có thể tuỳ ý setup tech của riêng nó: bundler nào, framework nào,...Đều là do team dev cái MFE đó quyết định
- Việc giao tiếp giữa các MFE phải đơn giản hết sức có thể
- Developers người mà code cái MFE thì nên có cái trải nghiệm như kiểu họ đang code một project bình thường chứ không phải đang dùng 1 cái inhouse framework nào đó
Chúng ta sẽ dùng những tiêu chí trên đi xuyên suốt series này nhé
Vấn đề
Như các bạn thấy, một trong những tiêu chí ở trên đó là kiến trúc MFE của chúng ta có thể support nhiều bundlers nhất có thể
Ở ví dụ trong bài trước: Microfrontend, Module Federation - đưa microservices đến với frontend. Thì mình dùng hết Webpack Module Federation.
Nhưng như trong bài mình có nói, Module federation giờ đã được phát triển thành 1 chuẩn (standard), miễn là ta bundle nó với những tiêu chí cụ thể, thì nó có thể hoạt động như 1 MFE
Trong thực tế khi ta phải support nhiều framework, nhiều team, ta sẽ thấy rằng mỗi team họ sẽ chọn 1 framework riêng, đi kèm với đó là bundler riêng: khi thì Vite, lúc thì Rollup, Esbuild, giờ thì hot những cái JS bundler viết bằng Rust như Rolldown hay Rspack,...
Và một kiến trúc MFE tốt thì không nên hạn chế các team MFE được chọn tech stack của họ 😊
Những gì ta sẽ làm
Ở ví dụ ngày hôm nay ta sẽ bắt đầu với app shell (Webpack) + 1 MFE (Webpack).
Sau đó ta sẽ setup để có thể support thêm Vite và rsbuild (build tool dựa trên rspack) nhé
Lên nhạc anh em ơiiiiii 🕺🕺
Setup
Đầu tiên các bạn clone code của mình ở đây nhé: https://github.com/maitrungduc1410/viblo-mfe-multi-bundlers
Sau khi clone về ta sẽ thấy trong folder có app shell và 1 mfe như sau:
Tiếp đó ở mỗi folder ta chạy npm install
nhé
Và ta start từng thứ lên nha:
# app-shell
npm start
# angular-app
npm start
Và giờ ta mở trình duyệt ở địa chỉ http://localhost:4200
sẽ thấy như sau:
Ta login với user username=user
và password=user
:
Và ta zô bên trong sẽ thấy MFE Angular được load lên thành công:
Vì hiện tại cả app shell và MFE dùng Webpack nên chúng tương thích khá dễ dàng vì code bundler cho ra cùng 1 chuẩn. Để xem khi làm với các bundler khác sẽ thế nào nhé
Zô món chính thôiiiiiii
Rsbuild - React
Ở phần này ta sẽ setup 1 một React MFE dùng Rsbuild và https://module-federation.io/ nhé
À giới thiệu chút là cái https://module-federation.io/ nó là 1 cái lib opensource tích hợp với rsbuild để làm MFE cực kì mượt, support cả typecheck luôn, nếu các bạn mà bắt đầu làm việc với MFE thì mình khuyên là nên dùng cái đó, nó abstract cho ta rất nhiều, không phải setup gì loằng ngoằng, support nhiều framework (nhưng chưa có Angular 😂)
Ta bắt đầu nhé 💪💪
Ở folder viblo-mfe-multi-bundlers
ta chạy command sau để tạo project rsbuild
mới
npm create rsbuild@latest
Ta đặt tên project là react-app
và chọn framework là React nhé, setting chọn như sau:
Sau khi tạo xong ta có như sau:
Ta chạy npm install
ở folder react-app
nhé, sau đó ta thử start nó lên coi xem nó như thế nào:
# react-app
npm run dev
Oke ngon rồi, giờ ta install module federation cho nó nhé, vẫn ở folder react-app
ta chạy:
npm add @module-federation/enhanced
Tiếp theo ta sẽ tạo asynchronous boundary
bằng cách tạo file src/bootstrap.tsx
sau đó copy content từ file src/index.tsx
sang nhé:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootEl = document.getElementById('root');
if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
Sau đó sửa file src/index.tsx
thành như sau:
import('./bootstrap')
Tiếp theo ở file rsbuild.config.ts
ta update lại như sau nhé:
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
export default defineConfig({
plugins: [pluginReact()],
server: {
port: 3002,
},
dev: {
assetPrefix: true,
},
tools: {
rspack: {
output: {
uniqueName: "react_mfe_app", // cái này phải unique cho mỗi mfe nhé
},
plugins: [
new ModuleFederationPlugin({
name: "react_mfe_app",
exposes: {
ReactAppLoader: "./src/loader.ts",
},
shared: {
react: {
singleton: true,
},
"react-dom/client": {
singleton: true,
},
},
}),
],
},
},
});
Phần setup này rất cơ bản mình lấy từ Gettings Started của module federation thôi
Ở trên mình fix cứng port là 3002 để tí app shell còn gọi tới
Như các bạn thấy thì đoạn setup ModuleFederationPlugin các options mà họ support cũng giống y như bên Webpack vậy (check ở file webpack.config.js)
Cuối cùng là ta tạo file src/loader.ts
nhé
import App from './App.tsx';
export default {
framework: 'react',
component: App
}
Nếu các bạn thắc mắc file loader này để làm gì, thì mình dùng nó để khai báo các thuộc tính cần thiết để lát nữa app shell load cái MFE này lên thì nó biết cần làm gì, dùng framework nào để render MFE
Ổn roài, sau khi setup thì cấu trúc thư mục ta có như sau:
Giờ ta quay lại app shell và khai báo MFE này ở file app-shell/src/app/app.service.ts
nhé:
//.......
const remoteModules = [
{
remoteEntry: 'http://localhost:3001/remoteEntry.js',
remoteName: 'angular_mfe_app',
exposedModule: 'AngularAppLoader',
},
{
remoteEntry: 'http://localhost:3002/remoteEntry.js',
remoteName: 'react_mfe_app',
exposedModule: 'ReactAppLoader',
},
];
Oke ngon rồi, ta restart lại react-app
nhé:
npm run dev
Sau đó ta thấy terminal sẽ in ra như sau tức là module federation đã được setup cho MFE nhé:
Ở trên các bạn thấy có dòng
Federated types created correctly
tức là nó gen cả type check cho MFE xịn luôn, nhưng mà nó chỉ tích hợp nếu ta dùng toàn bộ cả app-shell và MFE bằng https://module-federation.io/ thôi
Sau khi React MFE đã lên và được khai báo với app shell thì giờ ta quay lại app shell, F5 và login lại xem React lên chưa nhé:
Ủa mới có mỗi Angular???? React đâu??????? 🧐🧐🧐
Mở console thì thấy lỗi sau:
Mở tiếp tab Network
để xem file remoteEntry.js
của React MFE nó có được load không:
Ầu sết, lỗi 😢😢
Ta thử mở đường dẫn tới file remoteEntry từ trình duyệt xem nhé:
Ầuuuuu, nó trả về trang web (HTML) chứ không phải file JS, ủa lạ nhẩy.....
Kiểm tra lại Terminal thấy rằng có vẻ nó gen ra file mf-manifest.json
chứ không phải remoteEntry.js
:
Check trên Website của Module Federation: https://module-federation.io/configure/index.html thì có vẻ đúng như vậy thật, mặc định họ dùng file manifest để giao tiếp giữa app-shell và MFE vì file đó nó chưa nhiều thông tin hơn và cho phép linh hoạt hơn trong việc triển khai MFE, nhưng nó chỉ phù hợp nếu ta dùng toàn bộ App shell và MFE với setup của họ thôi, còn trường hợp của chúng ta là mỗi thành phần thoải mái chọn tech stack
May quá có một option để ta có thể generate remoteEntry.js
, ta sửa lại lại file rsbuild.config.ts
cho react-app
như sau:
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
export default defineConfig({
plugins: [pluginReact()],
server: {
port: 3002,
},
dev: {
assetPrefix: true,
},
tools: {
rspack: {
output: {
uniqueName: "react_mfe_app", // cái này phải unique cho mỗi mfe nhé
},
plugins: [
new ModuleFederationPlugin({
name: "react_mfe_app",
exposes: {
ReactAppLoader: "./src/loader.ts",
},
shared: {
react: {
singleton: true,
},
"react-dom/client": {
singleton: true,
},
},
filename: "remoteEntry.js", //-----> Ở đây
}),
],
},
},
});
Ở trên các bạn thấy rằng ta chỉ thêm vào đúng 1 dòng filename: "remoteEntry.js"
, cái đó sẽ nói với rsbuild
rằng generate thêm cho tôi file remoteEntry.js
nữa, và để tuỳ tôi dùng, thích dùng manifest.js hay remoteEntry.js thì kệ tôi 😂😂
Sau đó ta lưu lại và sẽ thấy react-app tự restart.
Ngay sau đó ta quay lại App shell, F5 và login lại lần nữa sẽ thấy như sau:
Pòm pòm, React MFE lên rồi 🎉🎉🎉🎉
Mà style hơi to chiếm nhiều chỗ quá, ta sửa lại chút ở file react-app/src/App.css
comment mấy chỗ này nhé:
/* body {
margin: 0;
color: #fff;
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
background-image: linear-gradient(to bottom, #020917, #101725);
} */
.content {
display: flex;
/* min-height: 100vh; */
line-height: 1.1;
text-align: center;
flex-direction: column;
justify-content: center;
}
.content h1 {
font-size: 3.6rem;
font-weight: 700;
}
.content p {
font-size: 1.2rem;
font-weight: 400;
opacity: 0.5;
}
Lưu lại và quay lại App shell F5 ta sẽ thấy nó hiển thị gọn gàng hơn roàiiii:
Cũng đơn giản nhẩy 😍😍
Thực tế là cũng may là https://module-federation.io/ họ support tốt, bundle ra file remoteEntry.js
theo đúng chuẩn của Module federation mà mọi người đang dùng, cộng thêm đó là cấu hình như webpack luôn nên là một phát ăn ngay không cần setup thêm nhiều. 10 điểm 👍️👍️
Nhưng cuộc đời sẽ không dễ như vậy mãi đâu các bạn ạ 🤣🤣
Vite - Vue
Tiếp theo ta sẽ setup tiếp 1 MFE với Vite nhé, ở folder viblo-mfe-multi-bundlers
ta tạo mới project Vite:
npm create vite@latest
Ta đặt tên là vue-app
và chọn framework là Vue
nhé:
Sau đó ở folder vue-app
ta chạy npm install
, và trước khi làm ta phải chạy nó lên test xem đầu đuôi nó trông như thế nào đã chứ nhỉ
Các bạn mở terminal ở vue-app
chạy:
npm run dev
Sau đó ta mở http://localhost:5173
, thấy như sau là oke rồi nhé:
Giờ ta setup Module federation cho Vite nha, các bạn cài package sau @originjs/vite-plugin-federation
:
npm i -D @originjs/vite-plugin-federation
Sau đó ở file vite.config.ts
ta sửa lại như sau:
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import federation from "@originjs/vite-plugin-federation";
// https://vitejs.dev/config/
export default defineConfig({
build: {
target: "esnext",
},
preview: {
port: 3003,
},
plugins: [
vue(),
federation({
name: "vue_mfe_app",
filename: "remoteEntry.js",
exposes: {
"VueAppLoader": "./src/loader.ts",
},
shared: ["vue"],
}),
],
});
Cấu hình Module federation thì vẫn với các options tương tự như ta vẫn làm thôi nhỉ 😁
Chú ý rằng ta có thêm build-> target: "esnext",
vì nếu khi tí nữa ta build nó sẽ báo lỗi Top-level await is not available in the configured target environment
đó nhé
Ở trên mình cũng fix port=3003 khi preview luôn để tí khai báo với bên app-shell
Cuối cùng là ta tạo file app-vue/src/loader.ts
với nội dung như sau:
import App from './App.vue';
export default {
framework: 'vue',
component: App
}
Giờ ta build Vue MFE nhé (cái này ta phải build chứ chạy dev với Vite thì nó sẽ không gen ra cho ta file remoteEntry.js
đâu):
npm run build
Sau đó ta start preview
nhé:
npm run preview
Thấy terminal in ra như sau là oke nhé:
Ta mở thử ở trình duyệt kiểm tra xem lên chưa nữa nha:
Tiện kiểm tra luôn đường dẫn của file remoteEntry.js
xem oke ko, chú ý rằng với Vite thì JS bundle files nằm ở trong dist/assets
nhé:
Do vậy đường dẫn chính xác của remoteEntry.js
là: http://localhost:3003/assets/remoteEntry.js. Mở ở trình duyệt ta thấy như sau::
Giờ ta khai báo Vue MFE với app-shell nhé, ở file app-shell/src/app/app.service.ts
ta thêm Vue vào:
//......
const remoteModules = [
{
remoteEntry: 'http://localhost:3001/remoteEntry.js',
remoteName: 'angular_mfe_app',
exposedModule: 'AngularAppLoader',
},
{
remoteEntry: 'http://localhost:3002/remoteEntry.js',
remoteName: 'react_mfe_app',
exposedModule: 'ReactAppLoader',
},
{
remoteEntry: 'http://localhost:3003/assets/remoteEntry.js',
remoteName: 'vue_mfe_app',
exposedModule: 'VueAppLoader',
},
];
Sau đó ở app-shell
ta F5 login lại:
Ầu, Vue không thấy lên, kiểm tra console thì thấy lỗi gì đó:
Kiểm tra network thì thấy load ngon nghẻ rồi:
Hầy... lỗi gì ta???????? 🧐🧐
Nếu ta mở http://localhost:3003/assets/remoteEntry.js để ý sẽ thấy ta có xíu code thôi à:
So với MFE khác, ví dụ React thì code siêu dài:
Quay lại cái lỗi ở console ta chú ý lỗi này Uncaught SyntaxError: Cannot use 'import.meta' outside a module (at remoteEntry.js:1:293)
Từ đây ta nhận định rằng, có thể là cái remoteEntry.js
của Vue nó đang là JS Module
Cái JS module nó khác với JS truyền thống mà ta vẫn dùng, JS truyền thống nó là global, import vào phát là nó chạy luôn và global, còn JS module thì scope của nó nhỏ hơn
Đó là lí do vì sao với React MFE ta thấy có dòng var react_mfe_app
này:
Còn với Vue thì ta không có mà nó là import
các thứ. Hơn nữa ở app-shell/src/app/utils/federation-utils.ts
logics để load MFE của ta hiện tại chưa support cho JS module, mà mới chỉ support cho global JS thôi
Để khẳng định cho giả định này thì ta test thử local xem nhé. Các bạn tạo cho mình file index.html
ở bất kì đâu, ngoài folder làm việc nhé, vì ta chỉ để test thôi, với nội dung như sau:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script>
const url = "http://localhost:3003/assets/remoteEntry.js";
import(url).then((module) => {
console.log("module", module);
});
</script>
</body>
</html>
Sau đó ta lưu lại (ví dụ lưu ra Desktop), sau đó kéo thả thẳng file đó vào trình duyệt, và kiểm tra console ta sẽ thấy như sau:
Ồ, load thành công module 🥸
Vậy từ đây ta có thể khẳng định là remoteEntry.js cho Vue MFE (Vite) nó được build thành JS module và do vậy ta cần update federation-utils.ts
ở app-shell để support cho dạng này.
Ta update lại function loadRemoteModule
như sau:
export async function loadRemoteModule(
options: LoadRemoteModuleOptions
): Promise<any> {
if (options.remoteEntry.startsWith('vite:')) {
const module = await import(options.remoteEntry.split('vite:')[1]);
(window as any)[options.remoteName] = module;
} else {
await loadRemoteEntry(options.remoteEntry);
}
return await lookupExposedModule<any>(
options.remoteName,
options.exposedModule
);
}
Ở trên ta khai báo thêm support cho Vite, nếu url của remoteEntry bắt đầu bằng vite
thì ta sẽ load nó như JS Module sau đó gán vào biến window
(như ta làm với các mfe khác)
Sau đó ta quay lại app.service.ts
update lại url của Vue:
//.....
const remoteModules = [
{
remoteEntry: 'http://localhost:3001/remoteEntry.js',
remoteName: 'angular_mfe_app',
exposedModule: 'AngularAppLoader',
},
{
remoteEntry: 'http://localhost:3002/remoteEntry.js',
remoteName: 'react_mfe_app',
exposedModule: 'ReactAppLoader',
},
{
remoteEntry: 'vite:http://localhost:3003/assets/remoteEntry.js',
remoteName: 'vue_mfe_app',
exposedModule: 'VueAppLoader',
},
];
Lưu lại và quay lại trình duyệt test thôi nào 💪💪
Mế, lại lỗi nữaaaaaaaaaaaaaaaaa:
Lý do ở đây là do webpack (app-shell đang dùng webpack), nó parse cái dynamic import
và thay thế bằng 1 url khác dựa vào cái base url (url của app shell). Xem thêm ở đây: https://webpack.js.org/api/module-methods/#webpackignore
Do vậy ở đây ta cần nói với webpack là ignore đi đừng có parse gì cả:
export async function loadRemoteModule(
options: LoadRemoteModuleOptions
): Promise<any> {
if (options.remoteEntry.startsWith('vite:')) {
const module = await import(
/* webpackIgnore: true */ options.remoteEntry.split('vite:')[1]
);
(window as any)[options.remoteName] = module;
} else {
await loadRemoteEntry(options.remoteEntry);
}
return await lookupExposedModule<any>(
options.remoteName,
options.exposedModule
);
}
Sau đó ta lưu lại, F5 và login lại app-shell:
Lại lỗi nữaaaaaaaaaaaaaaaa 😭😭
Cơ mà may, lần này là lỗi khác 😂🤣🤣
Ở trên các bạn thấy rằng nó đang báo là không đọc được property framework
(mà ta khai báo ở file loader.ts
phía MFE). Ở đây thì ta cần check lại ngAfterViewInit
ở app-shell/src/app/main/main.component.ts
:
async ngAfterViewInit() {
for (const m of this.appService.authorized_modules) {
loadRemoteModule(m).then((module) => {
this.loaders.push(module.default);
});
}
}
Ở trên các bạn thấy rằng, sau khi load được module lên thì ta truy cập vào thuộc tính default
của module đó (vì module được build với webpack thì nó được export default
). Nhưng ở đây do cách Vite và cái plugin ta dùng mà ta chỉ cần truy cập thẳng vào module
luôn thôi (các bạn có thể kiểm chứng bằng cách console.log(module)
).
Do vậy ta cần sửa lại chút, nếu có .default
ở module thì dùng nó, không thì thôi:
async ngAfterViewInit() {
for (const m of this.appService.authorized_modules) {
loadRemoteModule(m).then((module) => {
if (module.default) {
this.loaders.push(module.default);
} else {
this.loaders.push(module);
}
});
}
}
Cuối cùng ta lưu lại, quay lại app-shell F5 login:
Pòm pòm chíu chíu, Vue lên rồiiiiiiiii 🎉🎉🎉🎉🎉🎉
Cái logo Vite đang không load được, mình sẽ nói về vấn đề đó ở bài xử lý assets khi làm việc với MFE nhé 😉
Review và kết bài
Như các bạn thấy để support nhiều bundler hơn cho 1 kiến trúc frontend thì ta cần phải hiểu rõ từng thứ hoạt động như thế nào, cách xử lý ra sao với các bundler cho ra output ở các dạng khác nhau, đôi khi cũng chuối phết á 😆
Ở trong bài url của Vue
mình dùng tiền tố vite:
, ví dụ ta có nhiều bundler hơn thì ta có thể dùng kiểu esbuild:
/ rollup:
...
Việc support được nhiều bundler hơn giúp cho kiến trúc MFE của chúng ta mạnh hơn, bằng cách cho developers (người dev MFE) nhiều sự lựa chọn hơn trong việc dùng tech stack mà họ muốn.
Chúc các bạn buổi tối vui vẻ và hẹn gặp các bạn vào những bài sau về Microfrontend 👋👋
All Rights Reserved