Skip to content

副作用(Effect)是带依赖追踪的提交期副作用。当 Microsoft.UI.Reactor(以下简称 Reactor)完成元素树的协调、把 WinUI 控件打好补丁之后,它会遍历本次渲染的副作用队列,运行每一个自上次提交以来依赖数组发生了变化 UseEffect 函数体。函数体在新 UI 提交之后运行——绝不会在渲染期间——因此副作用是启动定时器、打开订阅或发起一个最终会调用 setter 来重新渲染组件Task.Run 的安全场所。你返回的清理函数则是它的镜像:Reactor 会在带着新依赖集重新运行该副作用之前调用它一次,并在组件卸载时再调用一次。依赖数组是你与调度器之间的契约——副作用从状态、props 或上下文里读取到的每一个值,都应当出现在其中。漏掉一个,副作用就会捕获到过期的闭包;每次渲染都新分配一个数组或 lambda,则副作用会因为引用身份不断变动而永远重跑。

副作用与生命周期

UseEffect 用于执行副作用——那些触及组件渲染函数之外的代码。定时器、数据获取、订阅以及控件操作都属于副作用,而不该写在 Render() 里。

速查

重载 函数体何时运行 清理何时运行
UseEffect(Action body, params object[] deps) 在每一次 deps 中任一项比较不相等的提交之后(Array.Empty<object>() → 仅挂载时)。 从不——没有清理函数。
UseEffect(Func<Action> bodyWithCleanup, params object[] deps) 同上。 下一次运行函数体之前,以及卸载时。
UseEffect<T1>(Action body, T1 d1)(还有 <T1,T2><T1,T2,T3> 以及 Func<Action> 版本) params 重载相同——针对 1–3 个依赖项的按元数定类型的语法糖。 同上。

两个重载都接受 params 形式的 deps 参数;不传值表示"每次提交都运行"(很少是正确的),传 Array.Empty<object>() 表示"只在挂载时运行一次",传一个或多个响应式值表示"这些值中任一变化时重新运行"。当这个副作用其实是一次带缓存的异步读取时,请对比 UseResource——带缓存的获取形态见异步资源,它会替你处理取消、重试与重新校验。

当你有一个、两个或三个依赖项时,按元数定类型的重载UseEffect(body, a, b))可以作为 params 形式的即插即用替代。它们以位置参数而非 params object[] 传递依赖项,因此在依赖项未变的路径上既不分配数组,也绝不会对值类型依赖项(intbool、枚举、结构体)装箱。其余行为完全一致——决定函数体是否重跑的是同一套逐元素相等性比较——所以在频繁的渲染路径上请优先使用它们。单个编译期类型为引用类型数组(例如 string[])的依赖项仍然会逐元素比较,与 params 形式一致;而像 int[] 这样的值类型数组则被视为一个按引用比较的依赖项,因此若你想做逐元素比较,就把它的元素作为独立依赖项传入。

只在挂载时运行一次

传入一个空依赖数组,让副作用在组件挂载时运行一次:

class MountEffectExample : Component
{
    public override Element Render()
    {
        var (loadedAt, setLoadedAt) = UseState("");

        UseEffect(() =>
        {
            setLoadedAt(DateTime.Now.ToString("HH:mm:ss"));
        }, Array.Empty<object>());

        return VStack(8,
            TextBlock("Component mounted at:"),
            TextBlock(loadedAt).FontSize(20).Bold()
        ).Padding(24);
    }
}

Mount effect with loaded timestamp

空的 Array.Empty<object>() 依赖数组告诉 Reactor:这个副作用没有外部依赖。它会在首次渲染之后运行一次,之后永不运行。

在依赖变化时运行

把值放进依赖数组,副作用就会在这些值变化时重新运行:

class DependencyEffectExample : Component
{
    public override Element Render()
    {
        var (query, setQuery) = UseState("");
        var (results, setResults) = UseState("Type to search...");

        UseEffect(() =>
        {
            if (string.IsNullOrWhiteSpace(query))
                setResults("Type to search...");
            else
                setResults($"Found 3 results for \"{query}\"");
        }, query);

        return VStack(12,
            TextBox(query, setQuery, placeholderText: "Search...", header: "Search query").Width(300),
            TextBlock(results).Foreground(Theme.SecondaryText)
        ).Padding(24);
    }
}

Search reacting to query changes

每当 query 变化,副作用就再跑一次。Reactor 会用结构相等性把当前依赖项与上一次的做比较。如果什么都没变,副作用就被跳过。

用清理函数处理定时器

当副作用创建了某个资源(定时器、订阅、事件处理器)时,请返回一个清理函数。Reactor 会在重新运行该副作用之前、以及组件卸载时调用它:

class TimerCleanupExample : Component
{
    public override Element Render()
    {
        var (seconds, updateSeconds) = UseReducer(0);
        var (isRunning, setIsRunning) = UseState(false);

        UseEffect(() =>
        {
            if (!isRunning) return () => { };
            var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
            var cts = new CancellationTokenSource();
            var token = cts.Token;   // 只捕获一次——循环里不能反复读取 cts.Token
            _ = Task.Run(async () =>
            {
                try
                {
                    while (await timer.WaitForNextTickAsync(token))
                        updateSeconds(s => s + 1);
                }
                catch (OperationCanceledException) { /* 清理时的预期行为 */ }
            });
            return () => { cts.Cancel(); timer.Dispose(); };
        }, isRunning);

        return VStack(12,
            TextBlock($"Elapsed: {seconds}s").FontSize(24).Bold(),
            HStack(8,
                Button(isRunning ? "Stop" : "Start", () => setIsRunning(!isRunning))
                    .AutomationName(isRunning ? "Stop timer" : "Start timer"),
                Button("Reset", () => updateSeconds(_ => 0))
            )
        ).Padding(24);
    }
}

Timer counting up with start/stop

UseEffectFunc<Action> 重载返回一个清理函数。这里的清理逻辑会释放定时器,从而在组件卸载或 isRunning 变化时避免泄漏。

定时器循环体运行在后台线程上(它在 Task.Run 内部),并从那里调用 updateSeconds 这个 reducer。宿主启动完成后,这开箱即用——从非 UI 线程调用时,每一个 UseState / UseReducer 的 setter 都会自动封送到已捕获的 UI 调度器上,因此定时器、PeriodicTimer、网络回调,以及 await ... ConfigureAwait(false) 之后的代码,都可以直接调用返回的 setter,无需任何额外启用。只有当你需要大量并发 setter 就地(加锁)生效、而不是一个个排队到 UI 线程上时,才给 Hook 传 threadSafe: true。若 setter 在跨线程调用时、尚无任何宿主启动完成(未捕获到 UI 调度器),或调度器已开始关闭,它会抛出 InvalidOperationException——请确保副作用的清理逻辑取消了后台生产者,让它随组件一起停下。

异步数据加载

UseEffectUseState 配合使用来异步加载数据:

class AsyncLoadingExample : Component
{
    public override Element Render()
    {
        var (items, setItems) = UseState<string[]?>(null);

        UseEffect(() =>
        {
            _ = Task.Run(async () =>
            {
                await Task.Delay(1500); // 模拟网络调用
                setItems(new[] { "Alice", "Bob", "Charlie" });
            });
        }, Array.Empty<object>());

        if (items is null)
            return TextBlock("Loading...").Padding(24);

        return VStack(8,
            Heading("Loaded Users"),
            VStack(4, items.Select(name => TextBlock(name).WithKey(name)).ToArray())
        ).Padding(24);
    }
}

Loading indicator then data

副作用在挂载时触发,启动一个异步任务,并在任务完成时更新状态。调用 setItems 时组件会自动重新渲染。

避免无限循环

一个常见的错误是:在一个副作用里无条件地调用某个状态 setter,而该状态又出现在它的依赖数组中:

class InfiniteLoopWarning : Component
{
    public override Element Render()
    {
        var (count, setCount) = UseState(0);

        // 不好:这会造成无限循环!
        // UseEffect(() => { setCount(count + 1); }, count);

        // 好:用条件加以保护
        UseEffect(() =>
        {
            if (count < 5) setCount(count + 1);
        }, count);

        return TextBlock($"Count stopped at: {count}").Padding(24);
    }
}

那样做会重新渲染、重跑副作用、再次设置状态、再重新渲染,然后永远循环下去。请始终用条件保护状态更新,或把那个不断变化的值从依赖数组中移除。

注意: 清理运行在下一个副作用函数体之前,而不是之后。当某个依赖变化时,下一次提交中的顺序是:先清理上一个副作用实例,然后带着新依赖集渲染,再运行新的副作用函数体。因此,如果你的副作用订阅了频道 A,而依赖项变成了频道 B,你看到的顺序会是 unsubscribe(A) → subscribe(B)——绝不会出现两个同时存在的订阅,中间也绝不会漏掉一次退订。卸载时是同样的顺序:清理运行一次,副作用函数体永不再运行。这个顺序所防止的具体故障形态是:一个在清理闭包中捕获了 ctsPeriodicTimer,只有在你从函数体返回 () => { cts.Cancel(); timer.Dispose(); } 时,才能在依赖变化后正确停止。 忘了清理,旧的定时器就会与新的那个一起继续触发。至于各个阶段相对于协调器何时运行的调度内部机制,记录在 effects-scheduling 中。

模式

带取消的数据获取

当副作用发起一次异步获取时,清理函数必须取消它。否则一个过期的请求可能在用户已经离开之后才落地,覆盖掉下一个组件实例上的状态(更糟的情况下,还会因为 setter 已释放而抛异常):

class FetchCancellationExample : Component
{
    static Task<string[]> FetchAsync(string q, CancellationToken ct) =>
        Task.FromResult(new[] { $"{q} result" });

    public override Element Render()
    {
        var (query, setQuery) = UseState("reactor");
        var (items, setItems) = UseState(Array.Empty<string>());

        UseEffect(() =>
        {
            var cts = new CancellationTokenSource();
            _ = Task.Run(async () =>
            {
                try
                {
                    var data = await FetchAsync(query, cts.Token);
                    setItems(data);
                }
                catch (OperationCanceledException) { /* 预期行为 */ }
            });
            return () => { cts.Cancel(); };
        }, query);

        return VStack(8,
            TextBox(query, setQuery, header: "Search query").Width(250),
            ForEach(items, item => TextBlock(item).WithKey(item))
        ).Padding(24);
    }
}

query 变化时,清理会在新的请求开始之前取消那次在途的获取。同一模式的"带缓存 / 可重试 / 聚焦时重新校验"形态,请改用 UseResource——它包办了这一切,还免费提供一个按组件划分的查询缓存键。

带清理的订阅

无论你订阅的是什么——INotifyPropertyChanged、一个 IObservable、一个全局事件总线、还是某个 WinUI 控件的 RoutedEventHandler——模式都一样:在函数体中订阅,在清理中退订:

class EventSource
{
    public event EventHandler? Changed;
    public void Fire() => Changed?.Invoke(this, EventArgs.Empty);
}

class SubscriptionCleanupExample : Component
{
    static readonly EventSource Source = new();

    public override Element Render()
    {
        var (tick, updateTick) = UseReducer(0);

        UseEffect(() =>
        {
            void Handler(object? s, EventArgs e) => updateTick(t => t + 1);
            Source.Changed += Handler;
            return () => Source.Changed -= Handler;
        }, Source); // 若 Source 的身份变化则重新挂载

        return VStack(8,
            TextBlock($"Ticks: {tick}"),
            Button("Fire", Source.Fire)
        ).Padding(24);
    }
}

如果你只需要响应某个 INotifyPropertyChanged 数据源上的属性变化,请改用 UseObservable——它自己拥有订阅/退订这一对操作,你就不用操心了。

对异步数据使用 UseResource

对于存在于组件之外的数据(服务端获取、文件读取、耗时计算),请使用 UseResource,而不是手工拼一个 UseEffect + UseState。它返回一个带 Pending / Value / Error 形态的 AsyncValue<T>,在依赖变化时处理取消、在 await Refresh() 时重试,并共享一个按上下文划分的查询缓存,让读取同一个键的两个组件复用同一个在途任务。要认出这个模式:如果你的副作用函数体会写成"发起一次获取、把结果写进状态、处理加载标志、处理错误标志",那你就正在重新实现 UseResource

常见错误

依赖数组里放对象或数组字面量

class DepsLiteralDontExample : Component
{
    static Task FetchAsync(object options) => Task.CompletedTask;

    public override Element Render()
    {
        var (url, setUrl) = UseState("https://example.test");

        // 不要这样——`options` 每次渲染都是一个全新的匿名对象,
        // 于是副作用每次提交都会重跑,请求陷入循环。
        var options = new { Url = url, Limit = 10 };
        UseEffect(() => FetchAsync(options), options);

        return TextBox(url, setUrl, header: "Request URL").Width(250).Padding(24);
    }
}

options 每次渲染都是一个全新的匿名对象——它的引用与上一个比较不相等,于是副作用每次提交都重跑,请求陷入循环。当某个 Hook 的 deps 参数是一个刚分配出来的对象、数组或 lambda 时,REACTOR_HOOKS_004 分析器会发出警告。请改为传递基元值

class DepsLiteralDoExample : Component
{
    static Task FetchAsync(string url, int limit) => Task.CompletedTask;

    public override Element Render()
    {
        var (url, setUrl) = UseState("https://example.test");

        // 应当这样——传基元值,它们按值比较。
        UseEffect(() => FetchAsync(url, 10), url);

        return TextBox(url, setUrl, header: "Request URL").Width(250).Padding(24);
    }
}

或者用 UseMemo 把那个容器记忆化,让它的身份在渲染之间保持稳定。

缺少清理

class MissingCleanupDontExample : Component
{
    public override Element Render()
    {
        var (tick, updateTick) = UseReducer(0);

        // 不要这样——没有清理,定时器会在卸载之后永远触发。
        // UseEffect(() =>
        // {
        //     var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
        //     _ = Task.Run(async () =>
        //     {
        //         while (await timer.WaitForNextTickAsync())
        //             updateTick(t => t + 1);
        //     });
        // }, Array.Empty<object>());

        return TextBlock($"Ticks: {tick}").Padding(24);
    }
}

如果你把那段被注释的副作用启用起来,组件卸载之后定时器仍会继续触发,setter 会被调用在一个已失效的 RenderContext 上,于是 setter 抛异常(更糟的情况下,定时器所捕获的那棵闭包树被静默泄漏)。请始终返回一个取消该生产者的清理函数:

class MissingCleanupDoExample : Component
{
    public override Element Render()
    {
        var (tick, updateTick) = UseReducer(0);

        UseEffect(() =>
        {
            var cts = new CancellationTokenSource();
            var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
            var token = cts.Token;   // 只捕获一次——循环里不能反复读取 cts.Token
            _ = Task.Run(async () =>
            {
                try
                {
                    while (await timer.WaitForNextTickAsync(token))
                        updateTick(t => t + 1);
                }
                catch (OperationCanceledException) { /* 卸载时的预期行为 */ }
            });
            // 取消令牌源并释放定时器;不要释放令牌源本身。
            // 那个即发即忘的工作线程与它共享所有权,而
            // CancellationTokenSource.Dispose 在并发成员访问下并不安全——
            // 一个仍在向该令牌注册的调用会看到 ObjectDisposedException。
            // 不带定时器的 CTS 不持有非托管资源,因此丢弃引用就够了。
            return () => { cts.Cancel(); timer.Dispose(); };
        }, Array.Empty<object>());

        return TextBlock($"Ticks: {tick}").Padding(24);
    }
}

本该是记忆化的副作用

class EffectVsMemoDontExample : Component
{
    public override Element Render()
    {
        var (first, setFirst) = UseState("Ada");
        var (last, setLast) = UseState("Lovelace");

        // 不要这样——为了推导出一个字符串,多付了两次渲染的代价。
        var (full, setFull) = UseState("");
        UseEffect(() => setFull($"{first} {last}"), first, last);

        return VStack(8,
            TextBox(first, setFirst, header: "First name").Width(150),
            TextBox(last, setLast, header: "Last name").Width(150),
            TextBlock(full)
        ).Padding(24);
    }
}

这为了推导出一个字符串,多付了两次渲染的代价(先以空的 full 挂载,再在副作用写入后重新渲染)。推导应当发生在渲染期间——要么内联,要么在计算昂贵时用 UseMemo

class EffectVsMemoDoExample : Component
{
    static string Compute(string input) => input.ToUpperInvariant();

    public override Element Render()
    {
        var (first, setFirst) = UseState("Ada");
        var (last, setLast) = UseState("Lovelace");

        var full = $"{first} {last}";                        // 内联
        var stats = UseMemo(() => Compute(full), full);      // 昂贵时做记忆化

        return VStack(8,
            TextBox(first, setFirst, header: "First name").Width(150),
            TextBox(last, setLast, header: "Last name").Width(150),
            TextBlock(full),
            TextBlock(stats)
        ).Padding(24);
    }
}

副作用适用于副作用——那些触及组件之外的工作。对既有状态的纯粹推导属于渲染。

小贴士

一次性初始化请用空依赖。 UseEffect(action, Array.Empty<object>()) 等价于"在挂载时运行"。把它用于初始数据获取、事件注册或日志记录。

总是清理资源。 如果你的副作用创建了定时器、订阅或事件处理器,就返回一个清理函数。泄漏的资源会造成难以追踪的 bug。

让依赖数组保持诚实。 把副作用从组件状态中读取的每一个值都放进去。省略某个依赖项并不能阻止读取——它只是阻止了重跑,从而导致过期闭包。

不要在依赖该状态的副作用里无条件地调用 setState。 这会造成无限循环。请用条件加以保护,或重构这段逻辑。

复杂且由副作用驱动的状态,优先用 UseReducer 当一个副作用需要更新多个相互关联的值时,reducer 能把逻辑集中在一处,并避免级联的重新渲染。

下一步

  • 样式与主题 — 上一篇:应用主题令牌、颜色与深色/浅色模式
  • 命令(Commanding) — 下一篇:把动作连同标签、图标与快捷键打包在一起
  • Hook — 深入副作用所依赖的 UseState、UseReducer 及其他 Hook
  • 异步资源 — 用 UseResourceUseMutation 做带缓存的异步读取
  • 副作用调度 — 副作用相对于渲染与提交何时运行
  • 高级模式 — 把副作用与其他 Hook 组合起来应对复杂场景