IT Blog #5 | Xây dựng micro-frontend với Vite và Module Federation: Hướng dẫn thực tế

IT Blog_5_Thumbnail

Tổng quan
 

Trong bài viết trước, chúng ta đã tìm hiểu về khái niệm micro-frontends, đặt nền tảng cho phần triển khai thực tế trong bài hôm nay. Bài viết này sẽ chỉ tập trung vào cách xây dựng micro-frontends bằng Vite và Module Federation. Nếu bạn chưa đọc bài trước, chúng tôi khuyến khích bạn xem lại bài Đi sâu vào Kiến trúc Micro-frontend trước khi bắt đầu với phần hướng dẫn này. 

Basic requirements
 

Để minh họa quy trình phát triển, chúng ta sẽ lấy ví dụ xây dựng một website e-commerce với các yêu cầu chức năng và kỹ thuật cụ thể.

Yêu cầu chức năng

Website e-commerce của chúng ta cần có các chức năng sau:

  • Danh sách sản phẩm
  • Thêm sản phẩm vào giỏ hàng

Yêu cầu kỹ thuật

Để đáp ứng các yêu cầu chức năng, chúng ta sẽ sử dụng các thông số kỹ thuật sau:

  • Dễ phát triển: Tất cả ứng dụng sẽ được tổ chức trong một mono-repo để tối ưu quá trình development.
  • Bundler sử dụng Vite: Vite sẽ được dùng để tối ưu tốc độ build và development.
  • Module Federation với Vite: Giúp chia sẻ code và tài nguyên giữa các micro-frontends.
  • Shared components giữa ứng dụng: Tận dụng khả năng tái sử dụng component.
  • Kết hợp Vue trong React: Demo việc tích hợp Vue component vào micro-frontend React.
  • Shared state giữa micro-frontends: Triển khai cách chia sẻ dữ liệu giữa các micro-frontends

Kiến trúc tổng thể

IT Blog_5_1

  • Root MFE: Micro-frontend gốc, chịu trách nhiệm compose các remote micro-frontends thành một SPA.
  • Product MFE: Micro-frontend sản phẩm, hiển thị và cho phép tương tác với sản phẩm.
  • Cart MFE: Micro-frontend giỏ hàng, quản lý giỏ hàng người dùng.
  • About MFE: Một micro-frontend khác sử dụng framework khác.

Khởi tạo dự án mới

Đầu tiên, hãy tạo root project. Ở đây tôi sử dụng pnpm  làm package manager - một lựa chọn thay thế cho npm hoặc yarn.

# install pnpm globally using npm
npm i pnpm -g
# Create new repo
mkdir demo && cd demo
# Initialize
pnpm init

Lý do tôi chọn công cụ này là vì nó có hỗ trợ tích hợp sẵn cho mono-repository và một số ưu điểm khác như:

  • Tiết kiệm dung lượng ổ đĩa: Khi có nhiều micro-frontends sử dụng cùng dependency, pnpm không tạo thư mục node_modules riêng biệt cho từng app mà sử dụng hard-link để chia sẻ package.
    → pnpm luôn tái sử dụng package đã cài trước đó.
  • Tốc độ nhanh hơn so với các lựa chọn khác.

IT Blog_5_2
2 - pnpm vs npm.png

Bước tiếp theo, hãy tạo 4 package:

  • root-app: Host application
  • product: Ứng dụng sản phẩm
  • cart: Ứng dụng giỏ hàng
  • about: Ứng dụng sử dụng tech stack khác

# Create root-app
pnpm create vite@latest root-app --template react-ts
# Create product app
pnpm create vite@latest product --template react-ts
# Create cart app
pnpm create vite@latest cart --template react-ts
# Create about app
pnpm create vite@latest about --template vue-ts
# Install tailwindcss
pnpm add -D tailwindcss postcss autoprefixer -w

Tạo file pnpm-workspace.yaml trong thư mục root để pnpm có thể quản lý tất cả các package.


packages:
- "packages/*"

Thêm script sau vào package.json cho môi trường development, script này sẽ start tất cả package ở chế độ development.


"dev": "pnpm --stream -r run dev"

Bây giờ, hãy cài đặt và khởi động tất cả các ứng dụng UI của chúng ta.


# Install all dependencies
pnpm i
# Start all UI
pnpm run dev

Phát triển layout cho ứng dụng root.
 


import { Outlet } from "react-router-dom";
import Header from "./Header";
import Footer from "./Footer";
const Layout = () => {
return (
<>
<Header />
<Outlet />
<Footer />
</>
);
};
export default Layout;

import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [],
},
]);
const App = () => {
return <RouterProvider router={router} />;
}

Layout của ứng dụng chúng ta sẽ trông như sau:

IT Blog_5_3

Phát triển ứng dụng product
 

Ứng dụng này sẽ nằm ở route "/application" và chịu trách nhiệm hiển thị danh sách sản phẩm. Để triển khai nhanh, tôi sẽ sử dụng một số dữ liệu mock cho các product item.


import { Product } from "../store";
import CartIcon from "./CartIcon";
import Image from "./Image";
const ProductItem = ({ product }: { product: Product }) => {
return (
<div className="w-full flex flex-col shadow-md rounded-md cursor-pointer group">
<div className="relative w-full aspect-square overflow-hidden rounded-md rounded-b-none">
<Image
src={product.img}
alt={product.title}
className="w-full h-full object-cover rounded-md rounded-b-none group-hover:scale-125 group-hover:filter transition-all duration-200"
/>
</div>
<div className="flex justify-between items-start p-3">
<div className="flex flex-col">
<p className="font-medium">{product.title}</p>
<p className="font-thin text-gray-400 text-sm">{product.type}</p>
<p className="font-thin text-gray-400 text-sm">{product.variants}</p>
</div>
<div className="flex flex-col items-between">
<p className="">${product.price}</p>
<button className="p-2">
<CartIcon />
</button>
</div>
</div>
</div>
);
};
export default ProductItem;
```
```
const App = () => {
return (
<div className="w-full p-8 flex items-center justify-center">
<div className="w-full max-w-screen-xl grid grid-cols-[repeat(2,minmax(0,1fr))] md:grid-cols-[repeat(4,minmax(0,1fr))] gap-4">
{mockedProducts.map((product) => (
<ProductItem key={product.id} product={product} />
))}
</div>
</div>
);
};
export default App;

Truy cập localhost:3001  We have the following UI

IT Blog_5_4
Nếu bạn để ý, port 3001 là port mà ứng dụng product của chúng ta đang chạy. Vậy làm thế nào để tích hợp nó với host application?

Module Federation - Phần quan trọng nhất
 

Hãy thêm Module Federation plugin vào tất cả ứng dụng của chúng ta. Vì chúng ta đang dùng Vite làm bundler, tôi sẽ sử dụng https://github.com/originjs/vite-plugin-federation — một Vite/Rollup plugin hỗ trợ Module Federation cho Vite, được lấy cảm hứng từ Webpack. Tất nhiên, bạn cũng có thể sử dụng Webpack Module Federation nếu bạn dùng Webpack làm bundler.


pnpm add -D @originjs/vite-plugin-federation --save-dev -w

Ứng dụng remote

Bây giờ, chúng ta sẽ expose ứng dụng product thông qua vite.config.js để host có thể load và render nó.


import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
federation({
filename: "product-mfe-entry.js",
name: "product-mfe",
shared: ["react", "react-dom", "zustand"],
exposes: {
"./App": "./src/App.tsx",
},
remotes: {
"cart-mfe": "http://localhost:3002/assets/cart-mfe-entry.js",
},
}),
],
build: {
modulePreload: false,
target: "esnext",
minify: false,
cssCodeSplit: false,
},
server: {
port: 3001,
},
preview: {
port: 3001,
},
});

Phần chính là hàm federation từ Vite federation plugin. Bạn cần cung cấp một số tham số cho Federation plugin để nó hoạt động:

  • name: string: Bắt buộc, dùng làm tên module của remote module.
  • filename: string: Đây là file remote entry, không bắt buộc (mặc định là remoteEntry.js), nhưng hữu ích để bạn biết đoạn code nào đang được load vào ứng dụng.
  • exposes: Field này chỉ định danh sách các component mà bạn muốn expose ra public.
  • shared: Field này rất quan trọng, dùng để chỉ định những dependency nào sẽ được share giữa local và remote modules. Các shared module sẽ chỉ được load một lần để tránh trùng lặp. Ví dụ, nếu bạn có nhiều ứng dụng React, bạn nên thêm một số dependency dùng chung như: react, react-router-dom, lodash, v.v.

Ngoài ra còn một số cấu hình khác, bạn có thể tham khảo thêm trong tài liệu của plugin.

Root app - Host Application

Host application là root-app, nơi bạn sẽ host tất cả các ứng dụng khác. Hãy chỉnh sửa file vite.config.js.


import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "root-app",
filename: "root-app-entry.js",
shared: ["react", "react-dom", "react-router-dom", "zustand"],
remotes: {
"product-mfe": "http://localhost:3001/assets/product-mfe-entry.js"
},
}),
],
build: {
modulePreload: false,
target: "esnext",
minify: false,
cssCodeSplit: false,
},
server: {
port: 3000,
},
preview: {
port: 3000,
},
});

Để load remote component mà chúng ta đã tạo ở bước trước, chúng ta cần thêm remote entry file thông qua field remotes trong federation plugin với cú pháp như sau:

{
   remotes: {
       <module_name>: <entry_file> // Typically this would be where you deploy your micro frontend's entry file
   }
}

Bây giờ, trong file router.tsx của root-app, hãy import Button từ product micro-frontend để kiểm tra xem nó có hoạt động không.

import { createBrowserRouter } from "react-router-dom";
import Layout from "./components/Layout";
import ProductApplication from "product-mfe/App";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <ProductApplication />, // Remote application
},
],
},
]);
export default router;

Kết quả

IT Blog_5_5

Nó đã hoạt động! Đồng thời, nếu bạn xem phần request trong tab Network, bạn sẽ thấy root application tải product micro-frontend tại run-time thay vì build-time như thông thường, thông qua entry file product-mfe-entry.js. Về cơ bản, đây chính là cách micro-frontend vận hành.

IT Blog_5_6

Phát triển ứng dụng cart

Với thiết kế hiện tại, ứng dụng cart là một ứng dụng độc lập. Vậy làm sao để hỗ trợ tính năng “add to cart”? Cart micro-frontend và product micro-frontend sẽ giao tiếp với nhau như thế nào?

Chia sẻ state giữa các micro-frontends

Chia sẻ state giữa các micro-frontends có thể được xem là một anti-pattern trong kiến trúc micro-frontend, vì nó đi ngược lại mục tiêu decoupling hoàn toàn. Tuy nhiên, vẫn có những trường hợp cần chia sẻ một phần state nhất định giữa các micro-frontends.

Nếu nhu cầu chia sẻ state là ở mức tối thiểu, custom browser events có thể là một giải pháp phù hợp. Bằng cách sử dụng custom browser events, bạn có thể thiết lập kênh giao tiếp giữa các micro-frontends mà không cần phụ thuộc vào một global store tập trung. Việc implement custom browser event sẽ trông như sau:

IT Blog_5_7

Kịch bản là: khi người dùng click nút "Add to Cart" trên UI, ứng dụng product sẽ dispatch một event tên là ADD_TO_CART. Ứng dụng cart sẽ subscribe event này, xử lý dữ liệu và lưu vào local store của nó.


const App = () => {
   const handleAddToCart = (product) => {
       window.dispatchEvent(new CustomEvent('ADD_TO_CART', { detail: product }))
   }
return (
<div className="w-full p-8 flex items-center justify-center">
<div className="w-full max-w-screen-xl grid grid-cols-[repeat(2,minmax(0,1fr))] md:grid-cols-[repeat(4,minmax(0,1fr))] gap-4">
{mockedProducts.map((product) => (
<ProductItem key={product.id} product={product} />
))}
</div>
</div>
);
};
export default App;

Bên trong cart micro-frontend


const App = () => {
   useEffect(() => {
           const handleAddToCart = (detail) => {
               // add new product into cart
           }
           window.addEventListener('ADD_TO_CART', handleAddToCart);
           return () => {
               window.removeEventListener('ADD_TO_CART', handleAddToCart);
           }
   }, []);
return (
<div className="cart-application">
</div>
);
};
export default App;

 

Cách tiếp cận này đơn giản và giúp duy trì state tách biệt giữa các ứng dụng. Việc trao đổi state diễn ra thông qua custom events, loại bỏ nhu cầu sử dụng một central store. Tuy nhiên, khi làm việc với state phức tạp và cần chia sẻ nhiều hơn, bạn có thể cân nhắc sử dụng một central store để chia sẻ state.

Chia sẻ state bằng Zustand

Zustand là một thư viện state-management nhỏ gọn và nhanh, sử dụng nguyên tắc flux đơn giản. Bạn có thể dễ dàng expose local store của mỗi micro-frontend như một remote module và sử dụng nó ở các micro-frontend khác. Tuy nhiên, cần lưu ý rằng cách tiếp cận này có thể tạo ra coupling giữa các micro-frontends.

IT Blog_5_8

Tạo store cho cart micro-frontend


import create from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
export interface IProduct {
id: string;
title: string;
type: string;
variants: string;
img: string;
price: number;
}
export interface ICartItem extends IProduct {
quantity: number;
subTotal: number;
}
export interface ICartStore {
products: ICartItem[];
addToCart: (product: IProduct) => void;
removeFromCart: (product: IProduct) => void;
clearCart: () => void;
}
const useStore = create<ICartStore>()(
persist(
(set, get) => ({
products: [],
           // Add product to cart handler
addToCart: (product: IProduct) => {
const existingProduct = get().products.some((p) => p.id === product.id);
set({
products: existingProduct
? get().products.map((p) => {
return p.id === product.id
? {
...p,
quantity: p.quantity + 1,
subTotal: p.subTotal + product.price,
}
: { ...p };
})
: [
...get().products,
{ ...product, quantity: 1, subTotal: product.price },
],
});
},
           // Remove product from cart handler
removeFromCart: (product: IProduct) => {
const existingProduct = get().products.find((p) => p.id === product.id);
set({
products:
!existingProduct || existingProduct.quantity === 1
? [...get().products.filter((p) => p.id !== product.id)]
: [
...get().products.map((p) => {
return p.id === product.id
? {
...p,
quantity: p.quantity - 1,
subTotal: p.subTotal - product.price,
}
: { ...p };
}),
],
});
},
clearCart: () => set({ products: [] }),
}),
{
name: "cart-store",
storage: createJSONStorage(() => localStorage),
}
)
);
export default useStore;

Chúng ta có thể expose store cho các micro-frontends khác thông qua module federation. Thêm cấu hình sau vào vite.config.ts của cart application.


plugins: [
react(),
federation({
name: "cart-mfe",
filename: "cart-mfe-entry.js",
shared: ["react", "react-dom", "zustand"],
exposes: {
"./App": "./src/App.tsx",
"./store": "./src/store", // Expose the cart store
},
}),
]

Quay lại product micro-frontend, bạn có thể truy cập cart store một cách dễ dàng


// Import the cart store
import useCartStore from "cart-mfe/store";
const ProductItem = ({ product }: { product: Product }) => {
   // select the addToCart function from the cart store
const addToCart = useCartStore((state: any) => state.addToCart);
const any) => {
       // This will update the cart store directly
addToCart(product)
};
return (
<div className="w-full flex flex-col shadow-md rounded-md cursor-pointer group">
<div className="relative w-full aspect-square overflow-hidden rounded-md rounded-b-none">
<Image
src={product.img}
alt={product.title}
className="w-full h-full object-cover rounded-md rounded-b-none group-hover:scale-125 group-hover:filter transition-all duration-200"
/>
</div>
<div className="flex justify-between items-start p-3">
<div className="flex flex-col">
<p className="font-medium">{product.title}</p>
<p className="font-thin text-gray-400 text-sm">{product.type}</p>
<p className="font-thin text-gray-400 text-sm">{product.variants}</p>
</div>
<div className="flex flex-col items-between">
<p className="">${product.price}</p>
<button className="p-2"> <CartIcon />
</button>
</div>
</div>
</div>
);
};

Tích hợp với các framework khác

Với Module Federation, việc tích hợp với tech stack, framework hoặc thư viện khác trở nên dễ dàng hơn nhiều. Ở ví dụ trước, chúng ta đã dùng React để xây dựng micro-frontends, giờ hãy tạo một ứng dụng Vue như một remote micro-frontend để xem chúng tích hợp với nhau như thế nào.

Khởi tạo một ứng dụng Vue mới


pnpm create vite@latest about --template vue-ts

Expose một micro-frontend mới


<template>
<div class="v-root">
<div class="v-container">
<div class="column">
<img
src="https://images.unsplash.com/photo-1611510338559-2f463335092c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=764&q=80"
alt="">
</div>
<div class="column">
<h1 class="page-title">About Us</h1>
<p class="description">"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed
sollicitudin pellentesque.
Nunc
posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor
venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus
dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros,
mattis
at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu."</p>
</div>
</div>
</div>
</template>
<style>
.v-root {
width: 100%;
display: flex;
flex-wrap: wrap;
min-height: calc(100vh - 244px);
justify-content: center;
}
.column {
flex: 50%;
}
.v-container {
max-width: 1280px;
width: 100%;
height: 100%;
margin: 32px;
display: flex;
gap: 32px;
}
.page-title {
font-size: 1.875rem;
font-weight: 600;
}
.description {
font-weight: 400;
}
</style>

Đăng ký như một remote component


import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import federation from "@originjs/vite-plugin-federation";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
federation({
name: "about-mfe",
filename: "about-mfe-entry.js",
exposes: {
"./App": "./src/App.vue",
},
shared: ["vue"],
}),
],
server: {
port: 4001,
},
preview: {
port: 4001,
},
build: {
minify: false,
target: ["chrome89", "edge89", "firefox89", "safari15"],
},
});

Import vào root application


import AboutApp from "about-mfe/App";
import { createApp } from "vue";
import { useEffect } from "react";
const VueAboutApp = () => {
useEffect(() => {
const app = createApp(AboutApp);
app.mount("#vue-app");
return () => app.unmount();
}, []);
return <div id="vue-app"></div>;
};
export default VueAboutApp;

import { createBrowserRouter } from "react-router-dom";
import Layout from "./components/Layout";
import ProductApplication from "product-mfe/App";
import CartApplication from "cart-mfe/App";
import VueAboutApp from "./components/VueApp";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <ProductApplication />,
},
{
path: "cart",
element: <CartApplication />,
},
{
path: "about",
element: <VueAboutApp />, // Add the Vue application with the path /about
},
],
},
]);
export default router;

Kết quả

IT Blog_5_9

Live Demo

Live demo có thể xem tại:

Kết luận
 

Trong suốt hướng dẫn này, chúng ta đã tìm hiểu cách triển khai thực tế micro-frontends bằng Module Federation và các công cụ khác nhằm cải thiện quy trình phát triển, đồng thời khám phá những lợi ích và thách thức đi kèm với kiến trúc này.

Bằng cách sử dụng khả năng của Module Federation, việc chia sẻ code giữa nhiều build system độc lập trở nên đơn giản hơn rất nhiều. Điều này cải thiện đáng kể phát triển web, đặc biệt trong ngữ cảnh micro-frontend.

Bài viết được thực hiện bởi Duc Ta – Front-end Software Developer tại Home Credit Vietnam