mcpField
F
fetch-and-send-data

fetch-and-send-data

github.com/dotnet开发5.8k3.2k 次安装1 天前更新
代码仓库

关于 fetch-and-send-data

面向 Blazor 开发者的技能指南,涵盖数据获取、提交和组件加载状态处理。按渲染模式区分数据访问方式,并说明取消、错误及参数变化时的处理模式。

主要功能

  1. 01按渲染模式选择数据访问方式
  2. 02配置命名或类型化 HttpClient
  3. 03处理静态 SSR 与预渲染
  4. 04使用 ErrorBoundary 管理错误
  5. 05按参数变化取消并重新加载
  6. 06通过 HTTP 提交数据

适用场景

  1. 01为 Blazor 页面加载外部 API 数据
  2. 02为静态 SSR 页面设置流式加载
  3. 03处理路由参数变化造成的重复请求
  4. 04提交表单并防止重复操作

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill fetch-and-send-data

安装后在 Claude Code 中输入/fetch-and-send-data确认已加载。

详细介绍

获取和发送数据

步骤 1 — 阅读 AGENTS.md

检查 交互模式 和 范围:

模式数据访问
None(静态 SSR)服务端:注入服务/DbContext。使用 [StreamRendering] 提供加载体验。
Server服务端:注入服务/DbContext。使用 ??= + [PersistentState] 防止预渲染重复执行。
WebAssembly浏览器端:仅使用 HttpClient。无法直接访问服务端。
Auto服务端和浏览器端均可。始终通过 API 访问。

步骤 2 — 注册 HttpClient

仅当从 Server 调用外部 API 时才需要;WebAssembly/Auto 则始终需要。访问自身数据库的服务端组件应直接注入 DbContext 或服务。

// Named client — requires Microsoft.Extensions.Http NuGet
builder.Services.AddHttpClient("CatalogAPI", client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
});

// Typed client
builder.Services.AddHttpClient<CatalogClient>(client =>
    client.BaseAddress = new Uri("https://api.example.com/"));

使用预渲染的 WebAssembly/Auto 项目,需要在服务端和 .Client 的 Program.cs 中都进行注册。

步骤 3 — 获取数据

简单加载
@page "/products"
@inject CatalogClient Catalog

@if (products is null)
{
    <p>Loading…</p>
}
else
{
    @foreach (var p in products)
    {
        <p>@p.Name — @p.Price.ToString("C")</p>
    }
}

@code {
    private Product[]? products;

    protected override async Task OnInitializedAsync()
    {
        products = await Catalog.GetProductsAsync();
    }
}

最简单的情况下无需处理错误——在父级或布局级别用 <ErrorBoundary> 包装组件,以捕获未处理的异常。

静态 SSR — StreamRendering

不使用 [StreamRendering] 时,用户在 OnInitializedAsync 完成前看不到任何内容:

@attribute [StreamRendering]

仅对静态 SSR 有效。对交互式组件没有影响。

预渲染保护

预渲染会调用两次 OnInitializedAsync。跳过重复调用:

[PersistentState] private Product[]? products;

protected override async Task OnInitializedAsync()
{
    products ??= await Catalog.GetProductsAsync();
}

详情请参阅 support-prerendering 技能。

步骤 4 — 处理错误

默认错误处理策略是使用 <ErrorBoundary>。它能为所有组件提供一致的错误体验,无需在每个组件中编写捕获逻辑。在布局或父级中包装组件:

<ErrorBoundary>
    <ChildContent>
        <ProductList />
    </ChildContent>
    <ErrorContent>
        <div class="alert alert-danger">Something went wrong. Please refresh.</div>
    </ErrorContent>
</ErrorBoundary>

非取消异常(HttpRequestException 等)会自动传播到 ErrorBoundary,组件中无需编写 catch 块。

取消情况比较特殊

ComponentBase 会静默吞掉所有 OperationCanceledException,包括自行触发的取消(组件释放、参数更改)和外部取消(HttpClient 超时)。ErrorBoundary 永远不会收到这些异常。这意味着:

  • 自行取消 → 静默忽略。行为正确,无需处理。
  • 外部取消(超时)→ 同样会被静默吞掉。组件会卡在加载状态。通常可以接受,因为超时很少发生。
何时在组件内添加错误处理

仅当组件需要实现 ErrorBoundary 无法提供的行为时才添加 catch 块,通常是重试或超时专属消息。即便如此,也只捕获所需的异常:

// Catch only external cancellation (timeouts) — everything else flows to ErrorBoundary
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
    Logger.LogWarning(ex, "Request timed out for category {CategoryId}", CategoryId);
    error = "The request timed out. Please try again.";
}

如果组件还需要处理一般错误,并显示重试按钮,而不是交由 ErrorBoundary 处理:

catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
    Logger.LogWarning(ex, "Request timed out for category {CategoryId}", CategoryId);
    error = "The request timed out. Please try again.";
}
catch (Exception ex)
{
    Logger.LogError(ex, "Failed to load products for category {CategoryId}", CategoryId);
    error = "Unable to load products. Please try again.";
}
规则
  • 绝不显示 exception.Message——其中可能包含 PII、连接字符串或内部详情。请使用硬编码的用户友好消息。
  • 始终通过 ILogger 记录日志——真实异常会进入日志记录管道。
  • 服务必须接受 CancellationToken——将其传递给每个异步调用,以便组件取消时停止工作。

步骤 5 — 根据参数重新加载

如果数据取决于会发生变化的路由或查询参数(例如,在 /products/1 和 /products/2 之间导航),请使用 OnParametersSetAsync,并通过保护条件跳过不影响数据的参数所触发的重新加载。

模式:取消并重新加载,同时覆盖显示旧数据
@page "/products/{CategoryId:int}"
@implements IAsyncDisposable
@inject ProductService ProductService
@inject ILogger<Products> Logger

@if (error is not null)
{
    <div class="alert alert-danger">
        <p>@error</p>
        <button @onclick="LoadAsync">Retry</button>
    </div>
}
else if (products is null)
{
    <p>Loading…</p>
}
else
{
    @if (isLoading)
    {
        <p><em>Refreshing…</em></p>
    }
    @foreach (var p in products)
    {
        <p>@p.Name — @p.Price.ToString("C")</p>
    }
}

@code {
    [Parameter] public int CategoryId { get; set; }
    [SupplyParameterFromQuery] public string? ViewMode { get; set; } // UI-only

    private CancellationTokenSource? cts;
    private int? loadedCategoryId;
    private List<Product>? products;
    private bool isLoading;
    private string? error;

    protected override async Task OnParametersSetAsync()
    {
        if (CategoryId == loadedCategoryId)
        {
            return; // Only ViewMode changed — no reload
        }

        loadedCategoryId = CategoryId;
        await LoadAsync();
    }

    private async Task LoadAsync()
    {
        if (cts is not null)
        {
            await cts.CancelAsync();
            cts.Dispose();
        }

        cts = new CancellationTokenSource();
        var cancellationToken = cts.Token; // Capture locally before await

        error = null;
        isLoading = true;

        try
        {
            var result = await ProductService.GetByCategoryAsync(CategoryId, cancellationToken);
            products = result;
        }
        catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
        {
            Logger.LogWarning(ex, "Timed out loading category {CategoryId}", CategoryId);
            error = "The request timed out. Please try again.";
        }
        finally
        {
            isLoading = false;
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (cts is not null)
        {
            await cts.CancelAsync();
            cts.Dispose();
        }
    }
}

要点:

  • 使用跟踪值进行保护:当只有 UI 参数变化时,loadedCategoryId 会跳过重新加载。
  • 在 await 之前将令牌保存到局部变量——CTS 字段可能会因并发的参数更改而被替换。
  • 后续加载时不要将 products 设为 null——保留现有数据显示,并叠加 isLoading 状态。
  • IAsyncDisposable 会在用户离开页面时取消待处理的工作。

步骤 6 — 发送数据

var response = await http.PostAsJsonAsync("products", newProduct);
response.EnsureSuccessStatusCode();

var response = await http.PutAsJsonAsync($"products/{id}", updated);
response.EnsureSuccessStatusCode();

var response = await http.DeleteAsync($"products/{id}");
response.EnsureSuccessStatusCode();

保存期间禁用提交按钮,以防止重复请求。显示保存指示器。

步骤 7 — 为启用预渲染的 Auto 或 WebAssembly 模式抽象服务

当组件同时在服务器和浏览器中运行时(Auto 模式,或启用预渲染的 WebAssembly),请使用抽象基类封装数据访问:

public abstract class ProductServiceBase
{
    public abstract Task<Product[]> GetAllAsync(CancellationToken ct = default);
}

// Server — direct database access
public class ServerProductService(AppDbContext db) : ProductServiceBase
{
    public override async Task<Product[]> GetAllAsync(CancellationToken ct = default) =>
        await db.Products.ToArrayAsync(ct);
}

// Client — calls API
public class ClientProductService(HttpClient http) : ProductServiceBase
{
    public override async Task<Product[]> GetAllAsync(CancellationToken ct = default) =>
        await http.GetFromJsonAsync<Product[]>("api/products", ct) ?? [];
}

在每个项目的 Program.cs 中注册相应的实现。组件注入抽象基类。

注意事项

  • 不要在构造函数中调用 API —— 使用 OnInitializedAsync。
  • 除非数据依赖于会发生变化的参数,否则不要使用 OnParametersSetAsync。 初次加载时使用 OnInitializedAsync。
  • 不要在 WebAssembly/Auto 组件中注入 DbContext —— 浏览器中没有数据库。
  • 不要通过 HttpClient 调用自己的服务端 —— 直接注入服务。
  • 不要向用户显示 exception.Message —— 存在泄露个人身份信息(PII)的风险。记录日志,并显示通用消息。
  • 不要捕获用于自行取消的 OperationCanceledException —— ComponentBase 会处理它。

评论

支持 Markdown