Trong bài trước, chúng ta đã tìm hiểu cách Fluxor hoạt động trong môi trường Console. Tuy nhiên, sức mạnh thực sự của mô hình Flux (Redux) chỉ tỏa sáng rực rỡ khi đối mặt với sự phức tạp của UI.
Hôm nay, mình sẽ hướng dẫn các bạn tích hợp Fluxor vào Blazor Server để giải quyết bài toán truyền dữ liệu giữa các Component mà không cần dùng đến "chuỗi sự kiện" (Event Callback) rắc rối.
Trước khi đi vào chi tiết, mình sẽ nói sơ về ví dụ. Hãy tưởng tượng bạn đang xây dựng một tính năng nhỏ cho phép người dùng quản lý danh sách sách cần đọc. Các thao tác cơ bản bao gồm:- Thêm sách: Nhập tên sách và nhấn nút, hệ thống giả lập lưu vào Database (mất khoảng 1 giây) rồi hiển thị lên danh sách.
- Xóa sách: Nhấn nút xóa bên cạnh mỗi đầu sách để loại bỏ khỏi danh sách.
1. Cài đặt
Cài đặt Fluxor
dotnet add package Fluxor.Blazor.Web
Đăng ký dịch vụ trong Program.cs
Thay vì đăng ký thủ công từng thành phần, Fluxor cung cấp khả năng tự động quét (Scan) toàn bộ dự án:builder.Services.AddFluxor(options => options.ScanAssemblies(typeof(Program).Assembly));
Trong C#, một Assembly thường tương ứng với một file .dll (một Project trong Solution của bạn).
Hàm ScanAssemblies yêu cầu đầu vào là các đối tượng thuộc kiểu Assembly. Tuy nhiên, thay vì phải viết đường dẫn file vật lý (rất dễ lỗi), chúng ta sử dụng cách "mượn" một Class đại diện
typeof(Program): Lấy thông tin kiểu dữ liệu (Type) của class Program..Assembly: Truy xuất xem class đó đang nằm ở Project (DLL) nào.
Xử lý với Solution có nhiều Libraries (Projects)
Trong một Clean Architecture, bạn thường chia nhỏ Solution:- Project Web (UI): Chứa Program.cs.
- Project State (Domain): Chứa các logic nghiệp vụ, State, Reducer.
Bạn chỉ cần chọn một Class bất kỳ (thường là một class rỗng hoặc class cấu hình) trong mỗi Project đó để làm "điểm neo":
options.ScanAssemblies(
typeof(Program).Assembly, // Scan Project Web (UI)
typeof(RootState).Assembly, // Scan Project chứa State/Logic
typeof(SharedElements).Assembly // Scan Project chứa các thành phần chung
);
2. Render Mode & Fluxor Store
Trong kiến trúc Blazor mới (từ .NET 8 trở đi), việc đặt <StoreInitializer /> không còn đơn giản như các phiên bản cũ. Bạn cần hiểu rõ cơ chế Render Mode để tránh lỗi Store không hoạt động hoặc không cập nhật UI.
File
App.razor mặc định chạy ở chế độ Static Server. Nó chỉ kết xuất HTML một lần rồi kết thúc tác vụ. Trong khi đó, Fluxor cần một "mạch" (Circuit) hoạt động liên tục để quản lý State. Nếu đặt ở đây, Store sẽ bị "chết" ngay sau khi trang load xong.
Các mode mà StoreInitializer hỗ trợ: @rendermode InteractiveServer, InteractiveWebAssembly, hoặc InteractiveAuto
Giải pháp: Cấu hình Global Interactivity
Đây là cách tối ưu nhất để có một Store duy nhất chạy toàn cục mà không phải khai báo lặp lại ở từng trang con.
Bước 1: Kích hoạt tại App.razor
Chúng ta sẽ chuyển tính tương tác xuống component <Routes />.
<!-- File: App.razor -->
<!DOCTYPE html>
<html lang="en">
<head>
<HeadOutlet />
</head>
<body>
<!-- Kích hoạt InteractiveServer toàn cục tại đây -->
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
Bước 2: Khởi tạo Store tại Routes.razor
Vì Routes đã có tính tương tác, hãy đặt StoreInitializer vào đây để nó bao phủ toàn bộ ứng dụng.
<!-- File: Routes.razor -->
<Fluxor.Blazor.Web.StoreInitializer />
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@rendermode InteractiveServer ở đầu các file trang con như Counter.razor. Các page kế thừa sẽ tự động thừa hưởng "rendermode" tương tác từ Parent, giúp source code của bạn gọn gàng hơn đáng kể.
3. Tạo trang Book.razor
Bạn tạo trang Book.razor@page "/book"
@using BlazorWebSample.State.Book
@using Fluxor
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@inject IState<BookState> BookState
@inject IDispatcher Dispatcher
<PageTitle>Books</PageTitle>
<h1>Book Management</h1>
<div class="mb-3 d-flex">
<input type="text" class="form-control me-2" placeholder="Enter book title..." @bind="newBookTitle" @bind:event="oninput" @onkeyup="HandleKeyUp" />
<button class="btn btn-primary" @onclick="AddBook" disabled="@string.IsNullOrWhiteSpace(newBookTitle)">Add</button>
</div>
<h3>Book List</h3>
<ul class="list-group mb-3">
@foreach (var book in BookState.Value.Books)
{
<li class="list-group-item d-flex justify-content-between align-items-center">
@book
<button class="btn btn-danger btn-sm" @onclick="() => RemoveBook(book)">Xóa</button>
</li>
}
</ul>
@if (!BookState.Value.Books.Any())
{
<p class="text-muted">Chưa có sách nào trong danh sách.</p>
}
else
{
<p class="text-muted">Tổng số sách: @BookState.Value.Books.Count</p>
}
@code {
private string newBookTitle = string.Empty;
private void AddBook()
{
if (!string.IsNullOrWhiteSpace(newBookTitle))
{
Dispatcher.Dispatch(new AddBookAction(newBookTitle));
newBookTitle = string.Empty;
}
}
private void HandleKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
AddBook();
}
}
private void RemoveBook(string title)
{
Dispatcher.Dispatch(new RemoveBookAction(title));
}
}
Flux Pattern
Flux là một kiến trúc quản lý dữ liệu một chiều (Unidirectional Data Flow), giúp mọi thay đổi trong ứng dụng trở nên minh bạch và dễ dự đoán. Thay vì để các Component tự ý thay đổi dữ liệu của nhau, Flux bắt buộc mọi thứ phải đi qua một quy trình khép kín:Luồng hoạt động của Flux trong ví dụ Reading List:
-
Action (Hành động):
Khi bạn nhấn nút "Add Book", một Action mang tên
AddBookActionkèm tiêu đề sách được gửi đi. Đây là tín hiệu thông báo cho toàn hệ thống biết một sự kiện vừa xảy ra. - Dispatcher (Người điều phối): Fluxor nhận Action này và chuyển đến các bộ phận xử lý tương ứng. Hãy coi đây là "trung tâm điều khiển" đảm bảo mọi thứ được xử lý theo đúng trình tự.
-
Store (Kho lưu trữ):
- Effect (Xử lý bất đồng bộ): Sẽ bắt lấy Action này để thực hiện các tác vụ "ngoại lai" như gọi API lưu vào Database (giả lập 1 giây).
- Reducer: Sau khi Effect hoàn tất, Reducer sẽ nhận kết quả và tính toán để cập nhật lại danh sách sách trong State.
- View (Giao diện): Component nhận được State mới từ Store và tự động vẽ lại (Re-render). Người dùng sẽ thấy cuốn sách mới xuất hiện trên danh sách mà bạn không cần phải viết code JS để "chọc" vào DOM.
Tại sao cách làm này lại ưu việt?
Bởi vì dữ liệu chỉ chảy theo một hướng duy nhất. Bạn sẽ không bao giờ gặp phải tình trạng State bị thay đổi "lén lút" từ một Component xa lạ nào đó. Mọi thay đổi đều để lại dấu vết thông qua các Action, giúp việc Debug trở nên cực kỳ nhẹ nhàng.
State
Là một đối tượng (thường là record) chứa toàn bộ dữ liệu hiện tại của ứng dụng hoặc một tính năng.Đặc điểm: Bất biến (Immutable). Bạn không thay đổi State, bạn thay thế nó bằng một phiên bản mới.
[FeatureState]
public record BookState
{
public List<string> Books { get; init; } = new()
{
"Clean Code",
"Design Patterns",
"Sử ký Tư Mã Thiên",
"Đông Châu liệt quốc"
};
public bool IsLoading { get; init; }
}
Mẹo nâng cao: Tùy biến khởi tạo State với Class Feature
Thông thường, [FeatureState] là đủ. Nhưng nếu bạn cần thực hiện logic phức tạp (như Log, gọi Factory, hoặc đặt tên định danh riêng cho Store) ngay khi ứng dụng khởi chạy, hãy kế thừa lớp Feature<TState>:
public class UIFeature : Feature<UIState>
{
public override string GetName() => "UI_Module";
protected override UIState GetInitialState()
{
// Bạn có thể thực hiện logic chuẩn bị dữ liệu tại đây
return UIState.CreateDefaultConfig();
}
}
ScanAssemblies trong Program.cs, bạn không cần đăng ký thủ công thêm gì cả!
Reducer
Vai trò: Là một hàm chịu trách nhiệm tạo ra State mới dựa trên State cũ và một Action.Nguyên tắc vàng: Phải là Pure Function (Hàm thuần túy).
- Nó không được gọi API, không được lấy thời gian thực (DateTime.Now), không được sinh số ngẫu nhiên.
- Với cùng một đầu vào (State cũ + Action), nó luôn phải trả về cùng một kết quả (State mới).
public class BookReducer
{
[ReducerMethod]
public static BookState ReduceAddBookAction(BookState state, AddBookAction action)
{
return state with { IsLoading = true };
}
[ReducerMethod]
public static BookState ReduceAddBookSuccessAction(BookState state, AddBookSuccessAction action)
{
return state with
{
IsLoading = false,
Books = state.Books.Append(action.Title).ToList()
};
}
[ReducerMethod]
public static BookState ReduceRemoveBookAction(BookState state, RemoveBookAction action)
{
return state with { Books = state.Books.Where(book => book != action.Title).ToList() };
}
}
Effect - "Kẻ gây ra các tác động ngoại lai" (Side Effects)
Vai trò: Xử lý các tác vụ nằm ngoài tầm kiểm soát của Reducer.
Khi nào dùng Effect?
- Gọi HTTP Client để lấy dữ liệu từ Server.
- Truy cập Database hoặc LocalStorage.
- Thực hiện các thuật toán phức tạp (như chạy mô hình ML.NET cho Personal Stock VN).
Luồng hoạt động: Effect lắng nghe một Action -> Thực hiện logic nặng/bất đồng bộ -> Sau khi xong, nó sẽ Dispatch một Action khác (thường là SuccessAction) để Reducer cập nhật kết quả vào State.
public class BookEffect
{
[EffectMethod]
public async Task HandleAddBookAction(AddBookAction action, IDispatcher dispatcher)
{
await Task.Delay(1000);
dispatcher.Dispatch(new AddBookSuccessAction(action.Title));
}
}
4. Tổ chức mã nguồn theo Features
Thay vì gom nhóm theo loại file (State, Effect...), chúng ta nên tổ chức theo Features (Tính năng). Cách tiếp cận này giúp code gọn gàng, dễ tìm kiếm và mở rộng khi dự án phình to.
Solution/
└── YourProject.Web/
└── Store/
└── BookUseCase/
├── Actions/
│ ├── AddBookAction.cs
│ ├── AddBookSuccessAction.cs
│ └── RemoveBookAction.cs
├── BookState.cs <-- Định nghĩa dữ liệu
├── BookReducers.cs <-- Logic cập nhật UI đồng bộ
└── BookEffects.cs <-- Xử lý API, Async, logic nặng
Tại sao nên chọn cách này?
- Dễ bảo trì: Toàn bộ logic của một tính năng nằm trọn trong một thư mục.
- Ít xung đột: Khi nhiều người cùng làm các tính năng khác nhau, khả năng bị trùng file hay lỗi Merge sẽ thấp hơn.
- Tương đồng Backend: Rất gần gũi với kiến trúc Vertical Slice Architecture phổ biến trong .NET Core.
5. Kết bài
Fluxor có thể hơi rườm rà lúc setup ban đầu, nhưng khi ứng dụng của bạn vượt quá 10 trang với hàng chục component lồng nhau, nó sẽ là "cứu cánh" giúp code của bạn luôn sạch sẽ và dễ bảo trì.Chúc các bạn thành công với dự án Blazor Server của mình!
Nhận xét
Đăng nhận xét