Hướng dẫn triển khai kiến trúc Micro-Frontends với Web Components và Module Federation
Trong môi trường phát triển phần mềm hiện đại, các nhóm công nghệ lớn thường phải đối mặt với thách thức về việc quản lý một ứng dụng web khổng lồ (Monolith) gồm nhiều tính năng phức tạp. Khi nhiều đội ngũ cùng làm việc trên một dự án React hoặc Vue duy nhất, các vấn đề về xung đột phiên bản thư viện, thời gian build lâu và khó khăn trong việc cập nhật từng phần riêng lẻ sẽ trở nên nan giải. Giải pháp Micro-Frontends (Micro Front-ends) ra đời để chia nhỏ ứng dụng thành các module độc lập có thể được phát triển, deploy và maintain riêng biệt. Bài viết này sẽ hướng dẫn các bạn kỹ sư phần mềm xây dựng một kiến trúc Micro-Frontends hiện đại, linh hoạt bằng cách kết hợp sức mạnh của Module Federation (Webpack 5) trong Node.js, sự mạnh mẽ của TypeScript, và tính tương thích vượt trội của Web Components, đảm bảo sự giao thoa mượt mà giữa React và Vue mà không gặp xung đột framework.
Tổng quan về kiến trúc và lợi ích của việc sử dụng Web Components
Trước khi đi sâu vào các bước thực hiện, chúng ta cần hiểu rõ tại sao lại chọn kết hợp Module Federation và Web Components. Module Federation là một tính năng của Webpack 5 cho phép chia nhỏ một ứng dụng thành nhiều gói nhỏ, nơi các gói này có thể truy cập code của nhau tại thời điểm chạy (runtime). Tuy nhiên, khi bạn muốn để một Remote Module viết bằng Vue được nhúng vào Host Module viết bằng React, bạn sẽ gặp vấn đề lớn về "Global State" và "Context", cụ thể là cả React và Vue đều cố gắng quản lý DOM và Virtual DOM theo cách riêng, dẫn đến xung đột khi cùng hoạt động trên cùng một cây DOM.
Web Components (Shadow DOM, Custom Elements) là tiêu chuẩn của trình duyệt, cung cấp sự đóng gói (encapsulation) hoàn hảo. Khi một component được render bên trong Shadow DOM, nó sẽ bị cô lập hoàn toàn khỏi styles và logic của host application. Bằng cách đóng gói ứng dụng React hoặc Vue của bạn thành một Web Component (thông qua các thư viện như React-Shadow hoặc Vue-Shadow), bạn tạo ra một ranh giới rõ ràng. Host application chỉ cần mount một thẻ HTML tùy chỉnh (custom tag) vào DOM, và bên trong thẻ đó, framework của Remote Module sẽ hoạt động độc lập. Đây là chìa khóa để xây dựng hệ sinh thái Micro-Frontends đa framework bền vững.
Chuẩn bị môi trường phát triển và khởi tạo dự án
Để bắt đầu, chúng ta sẽ thiết lập một dự án mẫu gồm một Host Application (ứng dụng chính) và một Remote Module (ứng dụng con). Tôi sẽ sử dụng Vite làm công cụ build vì tốc độ nhanh và khả năng hỗ trợ Webpack plugin tốt, tuy nhiên logic vẫn áp dụng được cho các hệ thống dùng Webpack trực tiếp. Trước hết, hãy tạo thư mục cho dự án và thiết lập môi trường Node.js với các bản patch mới nhất. Bạn cần đảm bảo hệ thống của bạn đã cài đặt Node.js phiên bản LTS. Sau đó, chúng ta sẽ tạo hai dự án riêng biệt: một dùng React (đóng vai trò là Host) và một dùng Vue (đóng vai trò là Remote).
mkdir micro-frontend-demo
cd micro-frontend-demo
mkdir host-react remote-vue
Tiếp theo, chúng ta di chuyển vào thư mục của Remote Module (Vue) để khởi tạo dự án. Ở đây, tôi sử dụng template Vue 3 với TypeScript. Việc sử dụng TypeScript là bắt buộc trong các dự án enterprise để đảm bảo tính an toàn kiểu dữ liệu (type safety) khi giao tiếp giữa các module qua biên giới của hệ thống. Command sau sẽ tạo ra bộ khung chuẩn cho Vue 3.
cd remote-vue
npm create vite@latest . -- --template vue-ts
Ngay sau đó, chúng ta sẽ làm tương tự cho Host Application (React) nằm ở thư mục cha. Đảm bảo chuyển thư mục về host-react trước khi chạy lệnh tạo dự án React.
cd ../host-react
npm create vite@latest . -- --template react-ts
Việc cài đặt các gói phụ thuộc là bước quan trọng tiếp theo. Để Remote Module có thể được chia sẻ, chúng ta cần cấu hình Module Federation. Chúng ta sẽ cần cài đặt plugin @module-federation/vite-plugin cho cả hai dự án. Ngoài ra, để đóng gói thành Web Component, Host cần thư viện để mount Remote, và Remote cần thư viện để đóng gói Vue thành Web Component. Dưới đây là các lệnh cài đặt package cần thiết cho Remote Module (Vue).
cd remote-vue
npm install
npm install @module-federation/vite-plugin vue-shadow-dom
Tương tự, Host Module (React) cũng cần cài đặt các thư viện tương ứng để tiêu thụ Remote Module.
cd ../host-react
npm install
npm install @module-federation/vite-plugin
Cấu hình Module Federation cho Remote Module (Vue)
Bây giờ chúng ta đi vào phần cấu hình cốt lõi. Mở file vite.config.ts trong thư mục remote-vue. Chúng ta cần cấu hình plugin Module Federation để Remote này có thể xuất (expose) các component của nó ra bên ngoài. Đặc biệt, chúng ta sẽ cấu hình để remote expose một Custom Element (Web Component) thay vì một component Vue thông thường.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@module-federation/vite-plugin'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'remoteVueApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/ShadowButton.vue',
},
shared: {
vue: {
singleton: true,
requiredVersion: '^3.0.0',
eager: true,
},
},
}),
],
build: {
manifest: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue'],
},
},
},
},
})
Tuy nhiên, điểm mấu chốt nằm ở file component ShadowButton.vue mà chúng ta sẽ expose. Component này không thể là một component Vue thông thường, mà phải được đóng gói trong Shadow DOM. Chúng ta sẽ tạo file này trong thư mục src/components của remote-vue. Trong file này, chúng ta sử dụng thư viện vue-shadow-dom để tự động hóa việc tạo Custom Element và mount Vue vào đó.
Nội dung của src/components/ShadowButton.vue sẽ bao gồm logic Vue thông thường, nhưng ở cuối file script, chúng ta phải có phần code để đăng ký nó thành một Web Component. Điều này cho phép bất kỳ ứng dụng nào, dù là React, Angular hay thuần JS, đều có thể sử dụng thẻ <shadow-button> để gọi vào component này.
<template>
<div class="button-container">
<button @click="handleClick">
{{ message }}
</button>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { registerElement } from 'vue-shadow-dom'
export default defineComponent({
props: {
message: { type: String, default: 'Click me from Vue Remote' },
},
setup(props) {
const handleClick = () => {
alert(`Clicked: ${props.message}`)
}
return {
handleClick,
message: props.message,
}
},
})
registerElement('shadow-button', defineComponent({}))
</script>
<style scoped>
.button-container {
padding: 20px;
background: #f0f0f0;
border: 1px solid #ddd;
}
button {
background-color: #42b883;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
</style>
Chú ý dòng registerElement. Dòng này là cầu nối biến một component Vue trở thành một phần tử HTML hợp lệ có thể nhúng vào bất kỳ đâu. Các style được định nghĩa trong scoped sẽ tự động được đóng gói vào Shadow DOM, không bị rò rỉ ra ngoài Host Application.
Cấu hình Host Module (React) để tiêu thụ Remote
Chuyển sang thư mục host-react. Chúng ta cần cấu hình Vite để nhận diện remote module từ Vue. Trong file vite.config.ts của React, chúng ta sẽ thêm phần remotes trong plugin federation. Ở đây, chúng ta khai báo địa chỉ URL nơi Remote Entry file (remoteEntry.js) của Vue được đặt. Trong môi trường phát triển (dev), Remote Module thường chạy trên một cổng khác (ví dụ port 5173), còn Host chạy trên cổng khác (ví dụ 5174).
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@module-federation/vite-plugin'
import { resolve } from 'path'
const isDev = process.env.NODE_ENV === 'development'
const remoteUrl = isDev ? 'http://localhost:5173' : 'http://your-production-domain.com'
export default defineConfig({
plugins: [
react(),
federation({
name: 'hostReactApp',
filename: 'remoteEntry.js',
remotes: {
remoteVueApp: `${remoteUrl}/remoteEntry.js`,
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
eager: true,
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
eager: true,
},
},
}),
],
})
Cấu hình trên cho phép Host tải file remoteEntry.js từ địa chỉ của Remote Module. Webpack/Vite sẽ tự động xử lý việc tải code và khởi tạo runtime. Bây giờ, trong file src/App.tsx của React, chúng ta cần import component đã được expose từ Remote và mount nó vào DOM dưới dạng Custom Element.
Việc mount Custom Element trong React yêu cầu một chút code thủ công vì React không tự động hiểu các tag tùy chỉnh là gì. Chúng ta sẽ sử dụng useRef và useEffect để tạo phần tử HTML và gán thuộc tính (props) cho nó.
import { useEffect, useRef } from 'react'
type ButtonProps = {
message: string
}
const ShadowButton = (props: ButtonProps) => {
const ref = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!ref.current) return
// Tự động tải remote module nếu chưa được tải
import('remoteVueApp/Button').then((Component) => {
// Component này thực chất là một constructor của Web Component
const shadowButton = ref.current
if (shadowButton) {
// Gán thuộc tính vào custom element
shadowButton.setAttribute('message', props.message)
}
})
}, [props.message])
return <shadow-button ref={ref}></shadow-button>
}
export default function App() {
return (
<div style={{ fontFamily: 'Arial, sans-serif', padding: '40px' }}>
<h1>Host React Application</h1>
<p>This is the host app rendering a Micro-Frontend from Vue.</p>
<div style={{ border: '2px dashed red', padding: '20px' }}>
<ShadowButton message="I am a Vue Remote Web Component" />
</div>
</div>
)
}
Trong đoạn code trên, dòng import('remoteVueApp/Button') là cú pháp đặc biệt của Module Federation. Nó báo cho Webpack rằng hãy tải module Button từ remote remoteVueApp. Khi module được tải, chúng ta nhận được constructor của Web Component và gán nó vào DOM ref. Cách làm này đảm bảo rằng Vue chỉ chạy khi cần thiết và bị cô lập hoàn toàn trong Shadow DOM.
Cấu hình proxy và chạy dự án song song
Để kiến trúc này hoạt động trong môi trường phát triển, chúng ta cần chạy cả hai máy chủ (server) song song. Host cần biết đường dẫn đến Remote. Chúng ta sẽ cấu hình script package.json để chạy cả hai cùng lúc hoặc sử dụng tool như concurrently. Tuy nhiên, cách đơn giản nhất là chạy riêng biệt và cấu hình biến môi trường.
Trước tiên, cập nhật file vite.config.ts ở Remote Module để mở cổng 5173 (mặc định Vite thường là 5173, Host ta sẽ dùng 5174). Trong package.json của remote-vue, sửa script dev:
"scripts": {
"dev": "vite --port 5173",
"build": "tsc && vite build"
}
Tương tự cho host-react, cập nhật script dev để chạy trên cổng 5174:
"scripts": {
"dev": "vite --port 5174",
"build": "tsc && vite build"
}
Bây giờ, bạn cần mở hai terminal. Terminal 1 chạy Remote Module (Vue) và Terminal 2 chạy Host Module (React). Khi bạn mở trình duyệt và truy cập vào địa chỉ Host (ví dụ http://localhost:5174), bạn sẽ thấy ứng dụng React hiển thị chính xác component Button từ Vue. Nếu bạn thay đổi code trong file Vue, Remote Module sẽ hot-reload, và Host Module sẽ tự động nhận phiên bản mới của Remote Module mà không cần phải build lại toàn bộ.
Lưu ý quan trọng về bảo mật và triển khai Production
Khi chuyển từ môi trường Dev sang Production, có một số điểm bạn cần đặc biệt chú ý để đảm bảo hệ thống hoạt động ổn định. Thứ nhất là vấn đề Caching. File remoteEntry.js thay đổi mỗi khi bạn deploy version mới của Remote Module. Nếu trình duyệt cache file này, người dùng sẽ không thấy các thay đổi mới. Bạn cần cấu hình CDN hoặc Web Server của mình để sử dụng cache busting (ví dụ: thêm hash vào tên file remoteEntry.[hash].js). Vite đã hỗ trợ tính năng này mặc định khi build production.
Thứ hai là vấn đề bảo mật. Khi expose một module ra ngoài, bạn đang mở một giao diện công khai. Đảm bảo rằng bạn chỉ expose những component thực sự cần thiết và các thư viện bên thứ ba (shared dependencies) được kiểm soát chặt chẽ về phiên bản để tránh các lỗ hổng bảo mật (supply chain attacks). Trong cấu hình shared, hãy luôn xác định requiredVersion để tránh xung đột phiên bản (version mismatch) giữa Host và Remote.
Thứ ba là xử lý lỗi (Error Handling). Nếu Remote Module bị lỗi hoặc không thể tải (do mạng hoặc server down), Host Module không nên bị crash. Bạn nên thêm logic try-catch trong phần import động của Host. Nếu import thất bại, hãy hiển thị một fallback UI thông báo lỗi cho người dùng thay vì để màn hình trắng. Điều này giúp trải nghiệm người dùng (UX) mượt mà hơn.
Thứ tư là vấn đề về Global State và Styling. Dù Shadow DOM đã giải quyết phần lớn vấn đề xung đột CSS, nhưng nếu Remote Module cần truy cập vào Theme Provider hoặc Context global của Host, bạn sẽ cần thiết lập một "Event Bus" hoặc cơ chế phát sự kiện (Custom Events) để giao tiếp. Host có thể lắng nghe sự kiện từ Web Component và ngược lại. Tuyệt đối tránh việc Host truy cập trực tiếp vào DOM bên trong Shadow DOM của Remote vì điều này vi phạm nguyên lý đóng gói.
Kết luận
Việc triển khai Micro-Frontends bằng kiến trúc Module Federation kết hợp với Web Components là một giải pháp cực kỳ mạnh mẽ cho các dự án lớn yêu cầu tính độc lập cao giữa các team. Phương pháp này không chỉ giúp phá vỡ rào cản giữa các framework khác nhau như React và Vue mà còn mang lại lợi ích về hiệu năng và khả năng bảo trì. Bằng cách sử dụng TypeScript, chúng ta đảm bảo tính chính xác của dữ liệu giao tiếp, còn Shadow DOM giúp cách ly hoàn toàn các tác động phụ về style và logic.
Quá trình triển khai đòi hỏi sự hiểu biết sâu sắc về Webpack/Vite, cơ chế đóng gói của trình duyệt và quy trình CI/CD để tự động hóa việc deploy các remote module. Tuy nhiên, một khi bạn đã xây dựng được hạ tầng này, việc mở rộng hệ thống sẽ trở nên dễ dàng và linh hoạt hơn bao giờ hết. Các đội ngũ có thể đổi mới công nghệ, upgrade framework hay thậm chí đổi ngôn ngữ lập trình mà không ảnh hưởng đến phần còn lại của ứng dụng. Đây chính là bước tiến quan trọng để xây dựng các nền tảng web bền vững, scalable và đáp ứng tốt nhất cho nhu cầu doanh nghiệp trong kỷ nguyên số.
Hy vọng hướng dẫn chi tiết này sẽ giúp bạn có cái nhìn tổng quan và các bước cụ thể để bắt đầu hành trình chuyển đổi sang kiến trúc Micro-Frontends. Đừng ngại thử nghiệm và tùy biến các bước trên theo nhu cầu thực tế của dự án của bạn. Chúc bạn thành công!