Micro-frontend Learning
Bắt đầu

Cấu trúc workspace

Sắp xếp apps, packages, scripts và port cho workspace thực hành.

Khi shell, catalog và cart cùng nằm trong một repo, rất dễ import thẳng source của nhau để làm cho ứng dụng chạy trước. Cách đó tiện trong vài phút đầu, nhưng khiến bạn khó biết mình đang tích hợp ba frontend hay chỉ chia một ứng dụng thành ba thư mục.

Bài này thiết lập workspace cho shop mẫu: mỗi app có tên package, lệnh chạy và artifact riêng; phần dùng chung có ranh giới rõ ràng. Mình chọn npm workspaces và webpack 5 cho ví dụ, để bước tiếp theo có thể thêm React và Module Federation mà không đổi bundler.

Workspace thực hành, không phải repo tài liệu

Repo micro-frontend-learn hiện là site Next.js + Fumadocs. Các đường dẫn và lệnh bên dưới dành cho một thư mục mới tên shop-workspace, đặt bên cạnh repo tài liệu. Không sửa package.json của site docs để làm bài thực hành.

Kết thúc bài, bạn sẽ có ba trang standalone tối thiểu. Chúng chưa dùng React, chưa expose remote và chưa tải code của nhau; đó là nội dung của bài tiếp theo.

Mở Source Explorer: đối chiếu các file trong bài

Mục lục

Workspace giải quyết việc gì

Nếu đặt ba project độc lập cạnh nhau, bạn sẽ phải vào từng thư mục để cài dependency và chạy lệnh. Workspace giúp package manager nhận diện các package local, quản lý dependency graph bằng một lockfile ở root và chọn app cần chạy bằng tên package.

Nhưng workspace chỉ là cách tổ chức source và tooling. Nó không quyết định browser tải catalog lúc nào, không tạo remoteEntry.js và không đảm bảo các frontend deploy độc lập.

direction: right
npm: npm workspaces
webpack: "webpack của mỗi app"
federation: Module Federation
npm -> packages: "Quản lý"
packages: "Package local và lệnh phát triển"
webpack -> artifacts: "Tạo"
artifacts: "Artifact cho app"
federation -> runtime: "Tích hợp"
runtime: "Module giữa các build ở runtime"
npm workspaceswebpack của mỗi appModule FederationPackage local và lệnh phát triểnArtifact cho appModule giữa các build ở runtime Quản lý Tạo Tích hợp
npm workspaceswebpack của mỗi appModule FederationPackage local và lệnh phát triểnArtifact cho appModule giữa các build ở runtime Quản lý Tạo Tích hợp
Workspace giải quyết việc gì

Ví dụ, shell dùng import { CatalogPage } from '@shop/catalog' rồi bundler đóng code catalog vào artifact của shell thì đó vẫn là build-time composition. Việc catalog có một package.json riêng không biến import ấy thành remote loading.

Mình dùng monorepo ở bài thực hành vì bạn có thể nhìn toàn bộ ví dụ và sửa contract trong một chỗ. Khi các team đã có repository và pipeline riêng, không cần gom về một repo chỉ để dùng micro-frontend. Phần lựa chọn repository được tách ở Monorepo và multi-repo.

Cấu trúc thư mục và quyền sở hữu

Đây là cấu trúc đạt được sau các bước bên dưới. Thư mục packages ban đầu để trống; node_modules và dist không được commit.

index.js
package.json
webpack.config.cjs
package.json
package-lock.json (sinh khi cài)
.nvmrc
.gitignore

Apps là các đơn vị chạy và build

AppTrách nhiệm trong shop mẫuKhông nên sở hữu
shellLayout chung, điều hướng cấp ứng dụng, vùng mount remote và fallbackLogic lọc sản phẩm hoặc tính giỏ hàng
catalogDanh sách, tìm kiếm và chi tiết sản phẩmStore nội bộ của cart
cartThao tác giỏ hàng, số lượng và hiển thị tổng tiền theo contractComponent hoặc state riêng của catalog

Mỗi app có src, cấu hình bundler và thư mục output riêng. Ở bài này, cả ba đều chạy standalone để bạn kiểm tra được app nào hỏng mà không phải mở shell trước.

Đừng để apps/shell/src import từ ../../catalog/src/.... Đường dẫn đó bỏ qua public boundary và buộc shell biết cấu trúc nội bộ của catalog. Khi thêm Federation, shell sẽ consume module qua public request được khai báo, không qua đường dẫn filesystem của remote.

Packages chỉ chứa phần dùng chung có chủ đích

packages dành cho code được consume như dependency khi phát triển hoặc build, không phải chỗ chuyển mọi đoạn code lặp lại vào đó. Có thể thêm các package sau khi thật sự có nhu cầu:

Package dự kiếnNội dung phù hợpNội dung cần tránh
@shop/contractsPublic types, payload sự kiện, schema và quy ước tương thíchStore, API client hoặc implementation của một domain
@shop/uiButton, dialog, token giao diện; không chứa luật nghiệp vụComponent tự truy cập cart store hoặc catalog API
@shop/toolingCấu hình lint hoặc helper build có owner rõ ràngCấu hình Federation riêng của mọi app trộn vào một object lớn

Các package này chưa được tạo trong bài. Giữ packages trống cũng được: không cần một shared package chỉ để cây thư mục trông đầy đủ.

Một dependency nằm trong workspace không đồng nghĩa với dependency nằm trong shared của Module Federation. Workspace linking xảy ra ở máy phát triển/build; Federation sharing là cơ chế runtime riêng. Với React, còn phải kiểm tra cách bundler resolve và đóng gói dependency, không chỉ nhìn xem root có mấy thư mục node_modules.

Scripts ở root chỉ điều phối

Root nên trả lời những câu hỏi như “chạy catalog bằng lệnh nào?” hoặc “build cả ba app ra sao?”. Cấu hình entry, output và plugin vẫn nằm trong từng app, vì đó là phần quyết định artifact của app ấy.

Mình chưa thêm Nx, Turborepo hay shared webpack config ở bước này. Ba app nhỏ chưa cần task cache hoặc task graph phức tạp; cấu hình ngắn, tách riêng giúp bạn nhìn rõ boundary. Khi có package phải build trước consumer hoặc CI bắt đầu tốn thời gian, mới bổ sung cơ chế điều phối phù hợp.

Tên package và port local

Dùng một bảng port cố định ngay từ đầu để shell không tải nhầm remote. Các số dưới đây là quy ước của lab, không phải yêu cầu của webpack hoặc Federation.

Thư mụcTên package npmVai trò dự kiếnURL local
apps/shell@shop/shellHosthttp://localhost:3000
apps/catalog@shop/catalogRemote cataloghttp://localhost:3001
apps/cart@shop/cartRemote carthttp://localhost:3002

Tên package dùng để chọn workspace trong lệnh npm. Tên container Federation, alias mà host dùng và public module key là các tên khác; chúng sẽ được khai báo ở bước tích hợp. Đừng suy ra URL remote từ tên package.

Port 3000 có thể đang dành cho site docs

Site tài liệu Next.js cũng mặc định chạy ở port 3000. Trước khi chạy shell, dừng dev server của site docs hoặc chạy site docs ở port khác. Nếu đổi port một remote sau này, phải cập nhật cả URL mà host dùng để tải entry.

Hai port khác nhau là hai origin khác nhau. Giữ cùng hostname localhost trong cả lệnh chạy lẫn URL cấu hình; đừng trộn tùy tiện với 127.0.0.1. Bài này chưa có request cross-origin giữa các app, nên chưa cần mở CORS. Khi dùng ESM remote, API hoặc manifest, hãy cấu hình CORS theo loại request thực tế, không mặc định thêm Access-Control-Allow-Origin: * cho mọi thứ.

Tạo workspace tối thiểu

Bạn cần Node.js 22 trở lên và npm đi kèm Node. Các lệnh tạo thư mục dùng shell kiểu Bash/Zsh; trên PowerShell, tạo cây thư mục tương đương rồi dùng cùng các lệnh npm.

Tạo thư mục và cấu hình root

Từ thư mục cha nơi bạn muốn đặt project thực hành, chạy:

mkdir shop-workspace
cd shop-workspace
mkdir -p apps/shell/src apps/catalog/src apps/cart/src packages

Tạo các file dưới đây bằng editor. Đừng chạy npm install trước khi đã có package.json của cả ba app ở bước kế tiếp.

shop-workspace/package.json
{
  "name": "shop-workspace",
  "version": "0.1.0",
  "private": true,
  "engines": {
    "node": ">=22"
  },
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "dev:shell": "npm run dev --workspace=@shop/shell",
    "dev:catalog": "npm run dev --workspace=@shop/catalog",
    "dev:cart": "npm run dev --workspace=@shop/cart",
    "build": "npm run build --workspace=@shop/shell --workspace=@shop/catalog --workspace=@shop/cart"
  }
}
Đối chiếu package.json

private: true ngăn publish nhầm package root lên registry. workspaces khai báo các thư mục package cần nhận diện; bản thân một thư mục chưa có package.json thì chưa phải workspace package.

shop-workspace/.nvmrc
22
Đối chiếu .nvmrc

.nvmrc chọn major Node cho người dùng nvm; nó không tự cài hoặc đổi Node trong mọi terminal. Nếu bạn dùng nvm, chạy nvm use tại root và kiểm tra lại bằng node --version.

shop-workspace/.gitignore
node_modules/
dist/
coverage/
*.log
Đối chiếu .gitignore

Khai báo ba app

shop-workspace/apps/shell/package.json
{
  "name": "@shop/shell",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "webpack serve --config webpack.config.cjs --mode development --port 3000",
    "build": "webpack --config webpack.config.cjs --mode production"
  }
}
Đối chiếu apps/shell/package.json
shop-workspace/apps/catalog/package.json
{
  "name": "@shop/catalog",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "webpack serve --config webpack.config.cjs --mode development --port 3001",
    "build": "webpack --config webpack.config.cjs --mode production"
  }
}
Đối chiếu apps/catalog/package.json
shop-workspace/apps/cart/package.json
{
  "name": "@shop/cart",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "webpack serve --config webpack.config.cjs --mode development --port 3002",
    "build": "webpack --config webpack.config.cjs --mode production"
  }
}
Đối chiếu apps/cart/package.json

Tên package phải duy nhất trong workspace. Khi npm chạy script của một app, working directory là thư mục app đó, nên webpack.config.cjs được tìm ngay cạnh package.json của app.

Không thêm @shop/catalog hoặc @shop/cart vào dependencies của shell chỉ vì shell sẽ là host. Remote được tải ở runtime không bắt buộc là npm dependency của host; package contract là chuyện khác.

Cài tooling vào đúng workspace

Chạy từ root shop-workspace:

npm install --save-dev --save-exact webpack@5 webpack-cli@6 webpack-dev-server@5 html-webpack-plugin@5 --workspace=@shop/shell --workspace=@shop/catalog --workspace=@shop/cart

Lệnh này chọn các major tương thích cho ví dụ và ghi version được resolve chính xác vào devDependencies của từng app. Nó cũng sinh một package-lock.json ở root. html-webpack-plugin tạo HTML có script tag đúng với output, để bạn không phải sửa HTML thủ công sau mỗi lần build.

Tooling thuộc app nào thì khai báo trong manifest của app đó. npm có thể hoist package lên node_modules ở root, nhưng vị trí vật lý trên disk không thay cho khai báo dependency. Việc app chạy được nhờ dependency chỉ khai báo ở root có thể che lỗi khi tách app sang project khác.

Tạo entry và cấu hình build cho từng app

Chưa cần React để kiểm tra workspace. Entry này chỉ xác nhận mỗi dev server phục vụ đúng app và JavaScript đã chạy.

shop-workspace/apps/shell/src/index.js
const heading = document.createElement('h1');
heading.textContent = 'Shop shell';

const status = document.createElement('p');
status.textContent = 'Shell đang chạy standalone. Chưa kết nối remote.';

document.body.append(heading, status);
Đối chiếu apps/shell/src/index.js
shop-workspace/apps/catalog/src/index.js
const heading = document.createElement('h1');
heading.textContent = 'Shop catalog';

const status = document.createElement('p');
status.textContent = 'Catalog đang chạy standalone. Chưa expose module.';

document.body.append(heading, status);
Đối chiếu apps/catalog/src/index.js
shop-workspace/apps/cart/src/index.js
const heading = document.createElement('h1');
heading.textContent = 'Shop cart';

const status = document.createElement('p');
status.textContent = 'Cart đang chạy standalone. Chưa expose module.';

document.body.append(heading, status);
Đối chiếu apps/cart/src/index.js

Tạo webpack.config.cjs với nội dung sau trong cả ba app: apps/shell, apps/catalog và apps/cart. Config lấy tên app từ package.json nằm cạnh nó, nên có thể dùng cùng nội dung ở bước tối thiểu này.

webpack.config.cjs — đặt một bản trong mỗi app
const path = require('node:path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { name } = require('./package.json');

module.exports = {
  context: __dirname,
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
    publicPath: 'auto',
    uniqueName: name.replace(/[^a-zA-Z0-9_]/g, '_'),
    clean: true,
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: name,
    }),
  ],
  devServer: {
    host: 'localhost',
    static: false,
  },
};
Đối chiếu templates/webpack.config.cjs

context giữ việc resolve entry gắn với thư mục app. output.path tách artifact thành ba thư mục dist; uniqueName tạo tên runtime riêng khi các build sau này cùng xuất hiện trên một trang. Các app mẫu sinh lần lượt _shop_shell, _shop_catalog và _shop_cart.

publicPath: 'auto' để webpack suy ra base URL cho asset runtime; nó không khai báo URL remote cho host. static: false tắt việc phục vụ thêm thư mục static ngoài webpack assets. HTML do plugin sinh vẫn được dev middleware phục vụ.

Mình dùng đuôi .cjs để cấu hình webpack có CommonJS rõ ràng, không phụ thuộc vào việc bạn có thêm "type": "module" trong manifest sau này. Config hiện chưa có JSX loader, React hoặc ModuleFederationPlugin; không dùng nó như một cấu hình Federation hoàn chỉnh.

Chạy và kiểm tra cấu trúc

Chạy ba dev server

Mở ba terminal, mỗi terminal đều ở root shop-workspace:

Terminal 1
npm run dev:shell
Terminal 2
npm run dev:catalog
Terminal 3
npm run dev:cart

Mở các URL trong bảng port. Kết quả đúng là:

URLNội dung nhìn thấy
http://localhost:3000Heading Shop shell và trạng thái chưa kết nối remote
http://localhost:3001Heading Shop catalog và trạng thái chưa expose module
http://localhost:3002Heading Shop cart và trạng thái chưa expose module

Sửa heading của catalog rồi kiểm tra lại trang catalog. Shell không cần import source catalog để thay đổi ấy xuất hiện trên trang standalone của catalog.

Không dùng lệnh tuần tự để chạy nhiều server lâu dài

npm run dev --workspaces không phải trình quản lý process chạy song song. npm chạy script theo thứ tự workspace; một dev server không kết thúc có thể giữ lệnh ở workspace đầu tiên. Dùng ba terminal ở bước này. Nếu muốn một lệnh dev duy nhất, hãy thêm công cụ điều phối process song song và cấu hình dừng các process khi thoát.

Kiểm tra artifact riêng

Từ root, chạy:

npm run build

Mỗi app phải có dist/index.html và JavaScript do webpack sinh. Tên JavaScript chứa hash nên không nên kiểm tra bằng một tên file cố định. Dev server thường phục vụ output trong memory; việc trang dev mở được không chứng minh thư mục dist đã có trên disk.

Để kiểm tra riêng một app:

npm run build --workspace=@shop/catalog

Lệnh này chỉ chọn script build của catalog, không build shell hay cart. Với cấu hình hiện tại, catalog cũng không cần artifact của hai app còn lại để build.

Root build liệt kê rõ ba app cần build. Nếu sau này thêm package cần compile trước app, phải bổ sung thứ tự hoặc task graph; npm không tự suy ra topological build order từ dependencies. Đừng thêm --if-present để che việc một app quên khai báo script bắt buộc.

dist riêng là điều kiện thuận tiện để deploy riêng, không phải bằng chứng đã có independent deployment. Còn phải kiểm tra public contract, URL artifact và tính tương thích với consumer đã phát hành.

Lockfile và cài đặt lại

Commit package.json ở root, manifest của từng app, cấu hình, source và một package-lock.json ở root. Không chạy một vòng npm install riêng trong từng app và duy trì nhiều lockfile cho cùng workspace.

Trên checkout mới, chạy từ root:

npm ci
npm run build

npm ci cần lockfile có sẵn và khớp manifest. Nếu bạn chủ động thêm hoặc nâng dependency, dùng npm install với workspace đích, kiểm tra diff manifest/lockfile rồi commit; không xóa lockfile như bước sửa lỗi mặc định.

Các version cụ thể được lưu trong lockfile của lần cài đặt. Nếu bài thực hành cần tái lập ở nhiều máy, dùng chung lockfile đã kiểm tra thay vì mỗi máy resolve lại các major trong lệnh cài tooling.

Khi nào thêm shared package

Giả sử catalog phát sự kiện yêu cầu thêm một SKU vào giỏ, còn cart cần hiểu payload ấy. Bạn có lý do để thêm packages/contracts với tên @shop/contracts: public type hoặc schema của sự kiện là phần hai bên phải thống nhất, còn store và cách tính tổng vẫn do cart sở hữu.

Khi đã tạo manifest và public entry hợp lệ cho package đó, có thể khai báo dependency cho consumer từ root:

npm install @shop/contracts --workspace=@shop/catalog --workspace=@shop/cart

Lệnh trên dành cho workspace đã có package local @shop/contracts, không phải bước phải chạy của bài hiện tại. npm có thể link workspace local phù hợp thay vì tải từ registry. Kiểm tra manifest, lockfile và public exports để chắc consumer dùng đúng package.

Trong ví dụ npm này, không dùng workspace:* theo thói quen từ pnpm/Yarn: đó không phải dependency protocol được npm workspaces hỗ trợ theo cách ấy. Nếu chuyển package manager, chuyển cả quy ước manifest, lockfile và lệnh CI một cách có chủ đích.

Với TypeScript, một package chỉ chứa types có thể được consume bằng import type, không tạo dependency JavaScript runtime từ import đó. Nếu package export schema dùng để validate dữ liệu, schema là code runtime và phải có entry JavaScript phù hợp. Type không tự validate payload nhận từ remote.

Mình chỉ tách shared package khi có public API nhỏ và owner chịu trách nhiệm về thay đổi. Nếu package bắt đầu re-export mọi store và component của cả catalog lẫn cart, hãy thu hẹp nó thay vì xem đó là lợi ích của monorepo. Đọc thêm Contracts giữa các frontend trước khi chốt payload và versioning.

Những lỗi thường gặp

Dấu hiệuĐiều cần kiểm traCách xử lý
npm không tìm thấy workspaceTerminal có đang ở root không; tên package có đúng và manifest đã tồn tại khôngKiểm tra workspaces, apps/*/package.json và tên sau --workspace
Báo Missing script: dev hoặc buildWorkspace được chọn có script đó khôngThêm script đúng cho app; không bỏ qua lỗi bằng --if-present
Báo webpack: not foundĐã cài tooling vào workspace chưa; có bỏ dev dependencies khi cài khôngCài lại từ root theo lệnh của bài; không phụ thuộc webpack global
Báo EADDRINUSEPort của app có bị site docs hoặc process khác chiếm khôngDừng process đang chiếm port hoặc đổi port và các URL liên quan
Build không tìm thấy ./src/index.jsEntry có tồn tại trong app và config có context: __dirname khôngTạo đúng file; giữ đường resolve gắn với app
Dev chạy nhưng không thấy distBạn mới chạy dev server hay đã chạy buildChạy script build để sinh artifact trên disk
Shell build kéo theo source catalogCó import đường dẫn nội bộ hoặc npm package implementation khôngPhân biệt build-time dependency với runtime remote; sửa integration boundary
npm ci thất bại do manifest và lockfile khác nhauManifest vừa thay đổi nhưng lockfile chưa cập nhật khôngChạy npm install để cập nhật có chủ đích, review và commit cả hai

Đừng sửa lỗi port bằng cách cho server chọn port ngẫu nhiên rồi quên URL mà host đang dùng. Với một lab Federation, địa chỉ remote ổn định giúp bạn phân biệt lỗi cấu hình URL với lỗi build hoặc contract.

Checklist trước khi thêm Federation

  • Project thực hành nằm riêng với site Next.js + Fumadocs.
  • npm nhận diện được @shop/shell, @shop/catalog và @shop/cart.
  • Mỗi app khai báo tooling của mình, không chỉ dùng dependency hoist tình cờ.
  • Ba trang standalone mở được ở port đã quy ước.
  • Build cả workspace tạo ba artifact riêng; build catalog riêng cũng thành công.
  • Shell không import source nội bộ của catalog hoặc cart.
  • Chỉ có một lockfile workspace, được commit cùng manifests.
  • Chưa xem standalone page là remote: remoteEntry.js chưa tồn tại trong cấu hình này.

Bước tiếp theo

Giữ workspace này cho bước thêm React và Federation: bắt đầu với shell và catalog, kiểm tra một public module trước, rồi mới đưa cart vào luồng tích hợp. Không cần tạo mọi shared package hoặc một pipeline production để chứng minh host tải được remote đầu tiên.

Tài liệu tham khảo

  • npm workspaces — chọn workspace, thêm dependency và thứ tự chạy script.
  • npm ci — cài đặt theo lockfile trong checkout sạch hoặc CI.
  • webpack dev server — host, port, static content và dev middleware.
  • webpack output — path, publicPath, uniqueName và clean.

On this page