Skip to content

异步资源

Microsoft.UI.Reactor(Reactor)的异步 Hook —— UseResourceUseInfiniteResourceUseMutation —— 取代了「一个 UseState 放数据、一个放加载中、一个放错误,再来一个 UseEffect 把它们串起来」的模式。它们接管了取数生命周期:依赖变化时取消、卸载后丢弃迟到的结果、同级组件间共享缓存,以及默认的「陈旧数据先显示、后台重新校验」。

Hook 适用场景
UseResource 单次异步读取(取一条记录、一页数据、一个计算值)
UseInfiniteResource 游标分页读取,配合 VirtualList 或滚动驱动的加载器
UseMutation 乐观写入,用 InvalidateKeys 刷新同级资源
PendingFactory.Pending 让一个兜底内容留在原位,直到所有嵌套资源都离开 Loading

完整的状态机参考见 async-system.md。本页以任务为导向:每一节都是可以直接抄用的实践范例。

从 UseEffect + UseState 迁移到 UseResource

旧模式:三个 UseState Hook(数据、错误、加载中)外加一个 UseEffect 来编排取数、取消与状态更新:

class BeforeUseResourceExample : Component
{
    public override Element Render()
    {
        var (data, setData) = UseState<DemoApi.User?>(null);
        var (error, setError) = UseState<Exception?>(null);
        var (loading, setLoading) = UseState(true);

        UseEffect(() =>
        {
            var cts = new CancellationTokenSource();
            setLoading(true);
            _ = Task.Run(async () =>
            {
                try
                {
                    var u = await DemoApi.GetUserAsync(42, cts.Token);
                    if (!cts.IsCancellationRequested)
                    {
                        setData(u);
                        setError(null);
                        setLoading(false);
                    }
                }
                catch (OperationCanceledException) { /* swallow */ }
                catch (Exception ex)
                {
                    if (!cts.IsCancellationRequested)
                    {
                        setError(ex);
                        setLoading(false);
                    }
                }
            });
            // Cancel only. The fire-and-forget worker shares ownership of the
            // source, and CancellationTokenSource.Dispose is not safe alongside
            // concurrent member access — a cancellation-aware call still
            // registering against the token would see ObjectDisposedException.
            // Nothing leaks: a CTS with no timer holds no unmanaged resource, so
            // dropping the reference is enough. Dispose only when a single owner
            // can prove the worker has finished.
            return () => cts.Cancel();
        }, 42);

        if (loading) return (Element)TextBlock("Loading…").Padding(24);
        if (error is not null) return (Element)TextBlock($"Error: {error.Message}").Padding(24);
        return VStack(4,
            Heading("Before: manual plumbing").FontSize(14),
            TextBlock(data?.Name ?? "(none)").FontSize(20).Bold(),
            TextBlock(data?.Role ?? "").Opacity(0.6)
        ).Padding(24);
    }
}

之前:四个 Hook 加上手写的取消管线

UseResource 把上面四个都收拢成一次调用。取数函数会收到一个在依赖变化或卸载时触发的取消令牌;该 Hook 返回一个 AsyncValue<T>,你对其做模式匹配:

class AfterUseResourceExample : Component
{
    public override Element Render()
    {
        var user = UseResource(
            ct => DemoApi.GetUserAsync(42, ct),
            deps: new object[] { 42 });

        return user.Match<Element>(
            loading: () => TextBlock("Loading…").Padding(24),
            loaded: u => VStack(4,
                Heading("After: one hook").FontSize(14),
                TextBlock(u.Name).FontSize(20).Bold(),
                TextBlock(u.Role).Opacity(0.6)
            ).Padding(24),
            error: ex => TextBlock($"Error: {ex.Message}").Padding(24));
    }
}

之后:一个 Hook 返回一个 AsyncValue

AsyncValue<T>.Match 按四种状态分派:Loading(首次取数)、Data(value)(成功)、Error(exception)(失败),以及 Reloading(previous)(陈旧数据先显示、后台重新校验 —— 即一次重新取数,旧值仍然可用)。省略 reloading 分支会让它落到 loading 分支,于是刷新时会显示加载 UI。若要在重新校验期间保持最后已知值可见(陈旧数据先显示、后台重新校验),请显式传入 reloading: 处理函数。

用 UseInfiniteResource 实现无限滚动

UseInfiniteResource 为游标分页读取建模。在 VirtualListrenderItem 内部通过 ItemAt(i) 驱动取数 —— 这就是拉取模型:虚拟化器请求第 i 项,该 Hook 调度覆盖到它的那一页,在此页落地之前该行渲染一个微光占位:

class InfiniteScrollExample : Component
{
    public override Element Render()
    {
        var commits = UseInfiniteResource<DemoApi.Commit, string>(
            fetchPage: async (cursor, ct) =>
            {
                var (items, next, total) = await DemoApi.GetCommitsPageAsync(cursor, ct);
                return new Page<DemoApi.Commit, string>(items, next, total);
            },
            deps: new object[] { "repo-main" });

        return VStack(4,
            Heading($"Commits ({commits.TotalCount ?? 0})").FontSize(14),
            VirtualList(
                itemCount: commits.TotalCount ?? Math.Max(commits.Items.Count, 20),
                renderItem: i =>
                {
                    var commit = commits.ItemAt(i);
                    return commit is null
                        ? TextBlock("…").Opacity(0.4).Padding(4)
                        : TextBlock($"{commit.Sha} — {commit.Message}").Padding(4);
                },
                getItemKey: i => commits.ItemAt(i)?.Sha ?? $"placeholder-{i}",
                itemHeight: 32,
                onVisibleRangeChanged: (first, last) => commits.EnsureRange(first, last))
                .Height(200)
        ).Padding(24);
    }
}

按需加载页面的无限滚动

向前滚动会自动拉入新页。LoadState 驱动页脚 UI(加载指示器、列表末尾标记、重试按钮)。若要显式预取,请从 onVisibleRangeChanged 回调里调用 commits.EnsureRange(first, last) —— 比逐行调用 ItemAt 更快。

下拉刷新是 commits.Refresh():它取消进行中的取数、清空页表,并重新获取第 0 页。出错后重试是 commits.Retry() —— 只重新请求失败的那一页。

从 DataPageCache 式缓存迁移

如果你曾在 IDataSource<T> 之上自建块缓存 —— 第 3 阶段之前的 DataPageCache<T> 就是典型例子 —— 替代路径是五步机械式操作:

  1. 把缓存字段换成一次 Hook 调用。var resource = UseDataSource(source, request, options); 取代长期存活的 DataPageCache<T> 实例。UseDataSource 把任何 IDataSource<T> 桥接到 UseInfiniteResource 上,以 request.ContinuationToken 作为游标。
  2. resource.Items[i] 读取。 这个稀疏的 IReadOnlyList<T?> 对进行中的槽位含有 null 占位。它取代了 cache.PeekItem(i) 加上 LoadingBlock 哨兵值。
  3. 删掉 BlockLoaded 事件接线。 该 Hook 在每次状态转换时都会重渲染组件 —— 不需要手动订阅。
  4. 把预取改走 EnsureRangeonVisibleRangeChanged 里的 cache.RequestBlock(i) 调用换成 resource.EnsureRange(first, last)。该 Hook 会对进行中的取数去重。
  5. 请求变化 = 依赖变化。 排序/筛选/搜索的更新通过 request 流入该 Hook 的依赖。该 Hook 会取消进行中的取数、退订旧的缓存键,并从第 0 页重新开始。

内置的 DataGrid<T> 目前同时跑着两条代码路径,由 ReactorFeatureFlags.UseHookBasedPaging 开关控制。完整的 DataGrid 故事见 data-system.md

变更叠加层仍在调用方手里。 该 Hook 的页表来自服务器且不可变。乐观编辑应当保存在你自己的叠加层里(Dictionary<int, T>),它在读取时优先于 resource.Items[i]DataGridState<T> 实现了这个模式 —— 参考形状见它的 _mutations 字段。

用 UseMutation 做乐观写入

UseMutation 把读取与写入分开。每个组件注册一次;在点击处理程序里调用 mutation.RunAsync(input)。乐观回调是同步触发的,因此 UI 永远不会为了等服务器而闪过陈旧数据:

class UseMutationExample : Component
{
    public override Element Render()
    {
        var (todos, setTodos) = UseState<IReadOnlyList<DemoApi.Todo>>(Array.Empty<DemoApi.Todo>());

        var mutation = UseMutation<DemoApi.TodoInput, DemoApi.Todo>(
            mutator: (input, ct) => DemoApi.AddTodoAsync(input, ct),
            options: new MutationOptions<DemoApi.TodoInput, DemoApi.Todo>(
                OnOptimistic: input =>
                    setTodos([.. todos, new DemoApi.Todo(input.Title, IsTemporary: true)]),
                OnSuccess: (todo, _) =>
                    setTodos([.. todos.Select(t => t.IsTemporary ? todo : t)]),
                OnError: (_, input) =>
                    setTodos([.. todos.Where(t => t.Title != input.Title)]),
                InvalidateKeys: ["todos/list"]));

        return VStack(8,
            Heading($"Todos ({todos.Count})").FontSize(14),
            VStack(2, todos.Select(t =>
                TextBlock(t.Title + (t.IsTemporary ? " (saving…)" : ""))
                    .Opacity(t.IsTemporary ? 0.5 : 1.0)
                    .WithKey(t.Title)).ToArray()),
            Button("Add Todo",
                () => _ = mutation.RunAsync(new DemoApi.TodoInput($"Item {todos.Count + 1}")))
        ).Padding(24);
    }
}

带服务器确认的乐观新增

InvalidateKeys 在成功时使缓存条目失效。任何订阅了这些键的同级 UseResource 都会观察到这次失效并重新取数。出错时 InvalidateKeys 不会触发 —— 服务器状态没有变化,因此缓存依然有效。

挂起期间卸载会取消变更令牌。取消不会触发 OnError —— 它是静默的,与 UseResource 的取消语义一致。

挂起兜底

把一个依赖多个资源的子树包在 PendingFactory.Pending(fallback, child) 里。兜底内容会保持可见,直到该子树内部的每个 UseResource / UseInfiniteResource 都离开初始的 Loading 状态:

class PendingFallbackExample : Component
{
    public override Element Render()
    {
        return PendingFactory.Pending(
            fallback: TextBlock("Loading dashboard…").Opacity(0.5).Padding(24),
            child: VStack(8,
                Heading("Dashboard").FontSize(14),
                Component<UserHeader>(),
                Component<RecentActivity>(),
                Component<Stats>()
            ).Padding(24));
    }

    private class UserHeader : Component
    {
        public override Element Render()
        {
            var user = UseResource(
                ct => DemoApi.GetUserAsync(1, ct),
                deps: new object[] { "user-1" });
            return user.Match<Element>(
                loading: () => TextBlock("• user loading…").Opacity(0.5),
                loaded: u => TextBlock($"• {u.Name} ({u.Role})"),
                error: ex => TextBlock($"• error: {ex.Message}"));
        }
    }

    private class RecentActivity : Component
    {
        public override Element Render()
        {
            var feed = UseInfiniteResource<DemoApi.Commit, string>(
                fetchPage: async (cursor, ct) =>
                {
                    var (items, next, total) = await DemoApi.GetCommitsPageAsync(cursor, ct);
                    return new Page<DemoApi.Commit, string>(items, next, total);
                },
                deps: new object[] { "feed" });
            return TextBlock($"• {feed.Items.Count} recent items");
        }
    }

    private class Stats : Component
    {
        public override Element Render()
        {
            var user = UseResource(
                ct => DemoApi.GetUserAsync(99, ct),
                deps: new object[] { "stats" });
            return user.Match(
                loading: () => TextBlock("• stats loading…").Opacity(0.5),
                loaded: _ => TextBlock("• stats ready"),
                error: ex => TextBlock($"• error: {ex.Message}"));
        }
    }
}

嵌套资源加载期间的挂起兜底

两棵树都会被挂载 —— 子树在后台渲染,以便三个资源全部就绪时它已经准备好了。Reloading(陈旧数据先显示、后台重新校验的重新取数)不会重新触发兜底;只有初始的 Loading 会。这与 TanStack 的 Suspense 语义一致,避免了「每次重新校验都闪一下」的反模式。

提示

让依赖可做值比较。 每次渲染都新建一个 List<T> 或 lambda 会让该 Hook 的依赖变化检测疲于奔命。用 UseMemo 记忆化,或投影成一个标量缓存键。等 REACTOR_HOOKS_004(开发中)来在编译期标出这种写法。

UseResource 只用于读取。 该 Hook 可能重试并重新取数 —— 非幂等的取数函数会在重试时重复创建。写入请用 UseMutation

游标分页本质上是串行的。 UseInfiniteResource 必须先加载第 N-1 页才能请求第 N 页,因为游标存在于上一页的载荷中。若要做基于偏移量的并行分页,请把偏移量作为游标传入,并用 InfiniteResourceOptions.PageSize 来指定每批的大小 —— 该 Hook 不会替你并行化。

不要在无关的 Hook 之间共享 CacheKey 默认的逐 Hook 自动键让各同级组件彼此独立。要共享请显式通过 ResourceOptions.CacheKey 选择开启 —— 通常是你想让两个相距很远的子树观察同一条条目时。

Pending 只适用于 Loading,不适用于 Reloading。 如果兜底在每次重新取数时都闪一下,你大概是把资源包在了一个同时检查 ReloadingPending 里。确切的判定谓词见 spec 的 §10.1。

下一步

  • 副作用与生命周期 —— 上一个:旧模式底下的 UseEffect 原语
  • 命令 —— 下一个:带异步执行的命令追踪
  • 数据系统 —— 完整的 DataGrid 故事,含基于 Hook 的分页
  • Hook —— 核心 Hook 原语(UseStateUseReducerUseRef