Giới thiệu về micro‑frontend và nhu cầu thực tiễn

Trong những năm gần đây, kiến trúc micro‑service đã chứng tỏ sức mạnh của mình trong việc chia nhỏ hệ thống backend thành các dịch vụ độc lập, dễ bảo trì và mở rộng. Tương tự, micro‑frontend là xu hướng áp dụng cùng nguyên tắc này cho phần giao diện người dùng, cho phép các nhóm phát triển độc lập các phần UI, triển khai và cập nhật mà không ảnh hưởng lẫn nhau.

Đối với các dự án quy mô lớn, đặc biệt là các nền tảng thương mại điện tử, portal nội bộ hay SaaS đa tenant, việc chia nhỏ frontend thành các module riêng biệt giúp:

  • Giảm độ phức tạp của codebase chung.
  • Cho phép các team sử dụng công nghệ, framework và quy trình CI/CD riêng.
  • Tăng tốc độ triển khai tính năng mới mà không cần đồng bộ toàn bộ ứng dụng.

Tuy nhiên, để thực hiện micro‑frontend một cách hiệu quả, chúng ta cần một cơ chế chia sẻ mã nguồn, tài nguyên và kiểu dữ liệu giữa các module. Webpack 5 Module Federation đã ra đời như một giải pháp tiêu chuẩn, và khi kết hợp với TypeScript, chúng ta có thể xây dựng một hệ thống mạnh mẽ, an toàn về kiểu dữ liệu và dễ bảo trì.

Lý thuyết Module Federation trong Webpack 5

Khái niệm và cách hoạt động

Module Federation cho phép một bundle (được gọi là host) tải các module được xuất khẩu từ một hoặc nhiều bundle khác (được gọi là remote) tại thời điểm chạy. Điều này nghĩa là:

  • Mỗi remote có thể triển khai độc lập, thậm chí trên các server khác nhau.
  • Host có thể quyết định tải remote nào dựa trên cấu hình runtime hoặc thậm chí dựa trên người dùng.
  • Việc chia sẻ các thư viện chung (React, lodash,…) được thực hiện thông qua shared để tránh tải lại nhiều lần.

Cấu hình cơ bản

Dưới đây là một ví dụ cấu hình webpack.config.js cho một remote module tên profile:

const path = require('path');
module.exports = {
  entry: './src/index.ts',
  output: {
    filename: 'profile.[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: 'auto',
    clean: true,
  },
  mode: 'production',
  resolve: {
    extensions: ['.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    new (require('webpack')).container.ModuleFederationPlugin({
      name: 'profile',
      filename: 'remoteEntry.js',
      exposes: {
        './UserProfile': './src/components/UserProfile',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

Với cấu hình trên, remote profile sẽ xuất ra một file remoteEntry.js chứa thông tin về các module được expose. Host có thể import chúng như sau:

import('profile/UserProfile').then(({ default: UserProfile }) => {
  // Render hoặc sử dụng component
});

Áp dụng TypeScript trong micro‑frontend

Định nghĩa giao diện chia sẻ (Shared Types)

Để tránh lỗi kiểu dữ liệu khi host và remote giao tiếp, chúng ta nên tạo một package shared-types chứa các interface, type và enum chung. Package này được publish lên npm riêng tư hoặc giữ trong monorepo.

Ví dụ, một interface mô tả dữ liệu người dùng:

export interface UserDto {
  id: string;
  name: string;
  email: string;
  avatarUrl?: string;
}

export type UserRole = 'admin' | 'editor' | 'viewer';

Remote profile và host đều import { UserDto } from 'shared-types'; để đảm bảo cùng một định nghĩa.

Quản lý phiên bản và tương thích

Khi các team phát triển độc lập, việc cập nhật shared-types là một thách thức. Một số thực tiễn:

  1. Sử dụng semantic versioning cho package shared-types. Khi thay đổi giao diện, tăng major version.
  2. Trong ModuleFederationPlugin, khai báo requiredVersion cho các shared modules, giúp Webpack phát hiện xung đột.
  3. Triển khai peerDependencies trong các remote để bắt buộc host cung cấp đúng phiên bản.

Thực tiễn triển khai: ví dụ dự án đa team

Cấu trúc thư mục

Dưới đây là một cấu trúc monorepo sử dụng pnpm workspaces:

my-microfrontend/
├─ apps/
│  ├─ host/                # Ứng dụng chính
│  ├─ profile/             # Remote 1: trang hồ sơ người dùng
│  └─ dashboard/           # Remote 2: bảng điều khiển
├─ packages/
│  ├─ shared-types/        # Giao diện, enum chung
│  └─ ui-components/       # Component library chung
└─ pnpm-workspace.yaml

Chi tiết các bước triển khai

  1. Khởi tạo monorepo và cài đặt webpack, ts-loader, react, react-dom cho mỗi package.
  2. Viết webpack.config.js cho mỗi remote như ví dụ ở trên, nhớ expose các component và khai báo shared cho react, react-domshared-types.
  3. Trong host, cấu hình ModuleFederationPlugin với remotes:
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        profile: 'profile@http://localhost:3001/remoteEntry.js',
        dashboard: 'dashboard@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'shared-types': { singleton: true, requiredVersion: '^1.0.0' },
      },
    });
  4. Trong code host, load remote component một cách lazy để tối ưu thời gian tải:
    const RemoteUserProfile = React.lazy(() => import('profile/UserProfile'));
    
    function App() {
      return (
        Loading...}>
          
        
      );
    }
  5. Chạy từng remote trên các cổng khác nhau (3001, 3002) và host trên cổng 3000. Kiểm tra console để xác nhận các module được tải thành công.

Hiệu năng và tối ưu

Lazy loading và chunking

Webpack tự động tách các remote thành các chunk dựa trên exposes. Khi host yêu cầu một module, chỉ chunk chứa module đó được tải. Để tối ưu hơn, chúng ta có thể:

  • Sử dụng dynamic import() kết hợp với React.lazy để tải khi cần.
  • Đặt maxInitialRequestssplitChunks trong optimization để kiểm soát kích thước bundle.

Cache và versioning

Mỗi bundle được đặt tên bằng [contenthash]. Khi nội dung thay đổi, hash mới sẽ được tạo, giúp CDN và trình duyệt cache hiệu quả. Đối với remote, chúng ta có thể thêm query string version vào URL, ví dụ profile@http://localhost:3001/remoteEntry.js?v=1.2.3, để buộc host tải lại khi có bản cập nhật.

Kiểm thử và CI/CD cho micro‑frontend

Unit test với Jest và ts-jest

Với TypeScript, cấu hình jest.config.js thường như sau:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '^shared-types$': '/../packages/shared-types/src',
  },
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
};

Một test mẫu cho component UserProfile:

import React from 'react';
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
import { UserDto } from 'shared-types';

test('renders user name', () => {
  const mockUser: UserDto = { id: '1', name: 'Nguyen Van A', email: '[email protected]' };
  render(<UserProfile user={mockUser} />);
  expect(screen.getByText(/Nguyen Van A/)).toBeInTheDocument();
});

Integration test với Cypress

Cypress cho phép kiểm tra toàn bộ luồng tải remote và render UI. Một kịch bản đơn giản:

describe('Micro‑frontend integration', () => {
  it('loads remote UserProfile component', () => {
    cy.visit('http://localhost:3000');
    cy.contains('Loading...').should('exist');
    cy.get('[data-test="user-profile"]').should('be.visible');
  });
});

Pipeline CI/CD mẫu

Sử dụng GitHub Actions để build và deploy từng remote và host. Dưới đây là một workflow ngắn gọn:

name: CI/CD Micro‑frontend
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: [host, profile, dashboard]
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'pnpm'
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      - name: Build ${{ matrix.app }}
        run: pnpm --filter ${{ matrix.app }} run build
      - name: Upload artifact
        uses: actions/upload-artifact@v3
        with:
          name: ${{ matrix.app }}-dist
          path: apps/${{ matrix.app }}/dist
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download artifacts
        uses: actions/download-artifact@v3
        with:
          path: ./artifacts
      - name: Deploy to server
        env:
          SSH_KEY: ${{ secrets.SSH_KEY }}
        run: |
          rsync -avz -e "ssh -i $SSH_KEY" ./artifacts/host/dist/ user@server:/var/www/host/
          rsync -avz -e "ssh -i $SSH_KEY" ./artifacts/profile/dist/ user@server:/var/www/profile/
          rsync -avz -e "ssh -i $SSH_KEY" ./artifacts/dashboard/dist/ user@server:/var/www/dashboard/

Kết luận và hướng học tiếp

Micro‑frontend kết hợp với TypeScript và Module Federation mang lại một kiến trúc linh hoạt, cho phép các team độc lập phát triển, kiểm thử và triển khai mà không gây xung đột. Việc định nghĩa shared-types giúp duy trì tính nhất quán về kiểu dữ liệu, giảm lỗi runtime và tăng tốc độ phát triển. Đồng thời, áp dụng các chiến lược lazy loading, cache versioning và CI/CD tự động sẽ tối ưu hiệu năng và độ tin cậy của hệ thống.

Để nắm vững các khái niệm trên, đồng thời nâng cao kỹ năng viết TypeScript chuyên nghiệp, Tham khảo khóa học "Lập trình TypeScript từ cơ bản đến nâng cao" tại đây.