Skip to content

Microsoft.UI.Reactor(以下简称 Reactor)中的 Context<T>,是带响应式能力、作用域限定在元素树上的命名空间式依赖注入。把一个 context 定义为一个静态字段,用 .Provide(ctx, value) 修饰符向任意元素的子树发布一个值,任意后代组件都能用 UseContext(ctx) 读取当前值。组件不需要知道是哪位祖先提供了它;中间组件也不需要把这个值作为 prop 转发出去;而当所提供的值变化时,每一个消费者都会重新渲染。这与 React 的 createContext / <Provider value> / useContext 三件套是同一个形态,也与 Compose 的 CompositionLocal 同形。这套系统最重要的性质是值身份敏感性:当所提供的值与上一个值不相等时,消费者就会重新渲染,因此每次渲染都传一个全新的对象字面量,会让整棵子树里的每个消费者都跟着抖动。当你手上有许多相距很远的组件都需要的数据(主题、当前用户、区域设置、功能开关),而逐层传 prop 会迫使每个中间组件都声明一个自己从不使用的 prop 时,就来读这一页。

上下文(Context)

Context 让你把数据穿过组件树传递,而无需在每一层 props 上打洞。定义一个 Context<T>,在任意层级提供一个值,任意后代都能用 UseContext 读取它。

速查

API 形态 用途
Context<T> new Context<T>(T defaultValue) 句柄。定义为静态字段、只定义一次;当作用域内没有 provider 时返回 defaultValue
.Provide(ctx, value) 任意 Element 上的修饰符 向该元素的后代提供 value。多次 .Provide(...) 会链接成一个合并后的字典。
UseContext(ctx) 返回 T 的 Hook 从最近的祖先 provider 读取当前值;若没有则返回 ctx.DefaultValue

Context<T> 是响应式的:改变所提供的 value(通过以不同的引用重新渲染 provider),会让子树中每一个对该 context 调用过 UseContext 的组件重新渲染。身份很重要——参见稳定所提供的值

创建一个 Context

一个 context 就是一个带默认值的静态字段:

static class Contexts
{
    public static Context<string> ThemeMode = new("light");
    public static Context<string> UserName = new("Guest");
    public static Context<int> FontScale = new(16);
}

请选择一个合理的默认值——它能让组件在开发期间独立工作,在测试夹具中无需额外接线就能工作,也能在 Storybook 风格的预览中工作。默认值取 null 会迫使每个消费者都去处理"未提供"的情况;而一个真实值的默认值几乎总是更友好。

提供与消费

在任意元素上使用 .Provide(),为它的子树提供一个值;在任意后代中使用 UseContext() 读取它:

class ProvideConsumeExample : Component
{
    public override Element Render()
    {
        return VStack(12,
            TextBlock("Outside: no provider"),
            VStack(12,
                Component<Greeting>()
            ).Provide(Contexts.UserName, "Alice")
        ).Padding(24);
    }
}

class Greeting : Component
{
    public override Element Render()
    {
        var name = UseContext(Contexts.UserName);
        return TextBlock($"Hello, {name}!").FontSize(20).Bold();
    }
}

Provider and consumer components

.Provide() 是一个修饰符,就像 .Padding().Background() 一样(参见 样式)——它作用于任意元素,并且这个值会传播到每一个后代,无论有多深。provider 与消费者之间的那些组件,根本不需要知道这个值的存在。

用 Context 做主题切换

一个常见的使用场景是影响整棵子树的主题开关。下面的根组件提供一个主题值,子组件消费它:

class ThemeSwitchExample : Component
{
    public override Element Render()
    {
        var (isDark, setIsDark) = UseState(false);
        var mode = isDark ? "dark" : "light";
        return VStack(16,
            ToggleSwitch(isDark, setIsDark, onContent: "Dark", offContent: "Light"),
            VStack(12, Component<ThemePanel>()).Provide(Contexts.ThemeMode, mode)
        ).Padding(24);
    }
}

class ThemePanel : Component
{
    public override Element Render()
    {
        var theme = UseContext(Contexts.ThemeMode);
        var elTheme = theme == "dark" ? ElementTheme.Dark : ElementTheme.Light;
        return Border(
            VStack(8,
                TextBlock($"Current theme: {theme}").Bold(),
                TextBlock("Panel adapts to context.").Foreground(Theme.SecondaryText)
            ).Padding(16)
        ).Background(Theme.CardBackground)
         .CornerRadius(8)
         .RequestedTheme(elTheme);
    }
}

Theme toggle with light and dark panels

ThemePanel 组件读取该 context,并据此应用 ElementTheme。当开关改变了所提供的值时,所有消费者都会以新主题重新渲染。这与 Reactor 的样式与主题令牌内部所用的形态是同一个。

嵌套 Context 覆盖

一个子 provider 会为自己的子树覆盖父级的值。兄弟子树则保留原值:

class NestedOverrideExample : Component
{
    public override Element Render()
    {
        return HStack(16,
            VStack(8,
                Caption("Parent value"),
                Component<NameDisplay>()
            ).Provide(Contexts.UserName, "Alice"),
            VStack(8,
                Caption("Overridden child"),
                VStack(4, Component<NameDisplay>())
                    .Provide(Contexts.UserName, "Bob")
            ).Provide(Contexts.UserName, "Alice")
        ).Padding(24);
    }
}

class NameDisplay : Component
{
    public override Element Render()
    {
        var name = UseContext(Contexts.UserName);
        return TextBlock(name).FontSize(18).SemiBold().Foreground(Theme.Accent);
    }
}

Nested contexts with different values

内层的 .Provide() 只影响该元素的后代。兄弟子树看到的仍是父级的值。这让你能够创建局部覆盖——一个始终以深色渲染的预览磁贴、一个强调色演示、一棵重新限定当前用户 context 的"权限不足"子树——而不会影响到树的其余部分。

多个 Context

组件可以同时提供和消费多个 context。每个 context 彼此独立:

class MultipleContextsExample : Component
{
    public override Element Render()
    {
        return VStack(8,
            Component<ProfileCard>()
        ).Provide(Contexts.UserName, "Charlie")
         .Provide(Contexts.FontScale, 22)
         .Padding(24);
    }
}

class ProfileCard : Component
{
    public override Element Render()
    {
        var name = UseContext(Contexts.UserName);
        var fontSize = UseContext(Contexts.FontScale);

        return Border(
            VStack(8,
                TextBlock(name).FontSize(fontSize).Bold(),
                TextBlock($"Font scale from context: {fontSize}px")
                    .Foreground(Theme.SecondaryText)
            ).Padding(16)
        ).Background(Theme.CardBackground).CornerRadius(8);
    }
}

Component reading two contexts

每一个 Context<T> 都是一个独立的通道。提供其中一个不会影响另一个。一个组件可以按需要多次调用 UseContext;每一次调用都会让该组件订阅其特定 context 的变化。

当前用户模式

在应用根节点放一个强类型 record,只提供一次,每个页面都读取它——这就是"无需逐层传 prop 地共享状态"的规范形态:

// A typed user-context record at the app root — every page reads the
// current user via UseContext rather than threading a User prop through
// every component along the way.
record CurrentUser(string Id, string DisplayName, bool IsAdmin);

static class AppContexts
{
    public static Context<CurrentUser> User = new(
        new CurrentUser("guest", "Guest", IsAdmin: false));
}

class UserContextExample : Component
{
    private static readonly CurrentUser InitialUser = new("u1", "Alice", IsAdmin: true);

    public override Element Render()
    {
        var (user, setUser) = UseState(InitialUser);

        return VStack(12,
            HStack(8,
                Button("Sign in as Alice (admin)",
                    () => setUser(new CurrentUser("u1", "Alice", IsAdmin: true))),
                Button("Sign in as Bob (user)",
                    () => setUser(new CurrentUser("u2", "Bob", IsAdmin: false)))
            ),
            VStack(8,
                Component<AccountMenu>(),
                Component<AdminPanel>()
            ).Provide(AppContexts.User, user)
        ).Padding(24);
    }
}

class AccountMenu : Component
{
    public override Element Render()
    {
        var user = UseContext(AppContexts.User);
        return TextBlock($"Signed in as: {user.DisplayName}").SemiBold();
    }
}

class AdminPanel : Component
{
    public override Element Render()
    {
        var user = UseContext(AppContexts.User);
        return user.IsAdmin
            ? Border(TextBlock("Admin tools available").Padding(8))
                .Background(Theme.CardBackground).CornerRadius(4)
            : TextBlock("(no admin tools)").Foreground(Theme.SecondaryText);
    }
}

Account menu and admin panel driven by the same user context

已登录用户存放在应用根节点的 UseState 里。 .Provide(AppContexts.User, user) 修饰符把它发布出去;AccountMenuAdminPanel 组件可以位于树中的任意位置,并通过 UseContext 读取它。换一个用户登录——两个面板都会重新渲染,因为它们订阅的是同一个 context。

稳定所提供的值

UseContext 的消费者所比较的,正是所提供值的身份。每次渲染都构造一个全新的对象字面量——即使底层数据没变——也会让每一个消费者失效:

// The value identity matters. Wrapping in UseMemo with explicit deps
// stops every consumer from re-rendering on every provider render.
// ThemeConfig is a *class*, so it compares by reference — context
// invalidation uses Equals, and a record with unchanged fields would
// compare equal and re-render nothing, hiding the very cost this
// snippet is about.
sealed class ThemeConfig(string mode, int fontScale, string accent)
{
    public string Mode { get; } = mode;
    public int FontScale { get; } = fontScale;
    public string Accent { get; } = accent;
}

static class ThemeContexts
{
    public static Context<ThemeConfig> Theme = new(new ThemeConfig("light", 14, "#0078D4"));
}

class MemoizeContextValueExample : Component
{
    public override Element Render()
    {
        var (mode, setMode) = UseState("light");
        var (scale, setScale) = UseState(14);

        // GOOD — identity stable while inputs unchanged. Consumers only
        // re-render when mode or scale actually change.
        var theme = UseMemo(() => new ThemeConfig(mode, scale, "#0078D4"), mode, scale);

        // BAD — a fresh reference every render, so every consumer re-renders
        // even when mode and scale are unchanged.
        // var theme = new ThemeConfig(mode, scale, "#0078D4");

        return VStack(12,
            HStack(8,
                Button("Toggle mode", () => setMode(mode == "light" ? "dark" : "light")),
                Button("Bump scale", () => setScale(scale + 2))
            ),
            VStack(8, Component<ThemedHeading>())
                .Provide(ThemeContexts.Theme, theme)
        ).Padding(24);
    }
}

class ThemedHeading : Component
{
    public override Element Render()
    {
        var t = UseContext(ThemeContexts.Theme);
        return TextBlock($"Headline @ {t.FontScale}px ({t.Mode})")
            .FontSize(t.FontScale).Foreground(t.Accent);
    }
}

ThemeConfig context with UseMemo-stabilized identity

那次 UseMemo 调用把 ThemeConfig 的身份钉在了它的依赖项上。消费者只在 modescale 真正变化时才重新渲染。这与 React 中为 context 值使用 useMemo 的形态相同。

注意: context 值的身份驱动着消费者的重新渲染。每次渲染都传一个全新的对象字面量——在 Render() 里写 .Provide(ctx, new Config { ... }) ——会导致子树中每一个调用过 UseContext(ctx) 的组件每一帧都重新渲染,即使底层数据并没有变化。经典的故障形态是:应用根节点的主题 provider 内联传入 new Theme(...);于是父树中任何状态变化,都会让每一个页面、每一个表单字段、每一个文本块重新渲染。修复有两条:(1) 用带显式依赖项的 UseMemo 把值包起来,使身份在更新之间保持稳定;或 (2) 把构造过程移到 Render() 之外,让它成为一个单例。性能覆盖层(mur docs perf-overlay)能把这一点显现出来——在开发工具视图里留意偏高的"context 驱动的重新渲染"计数。

模式

把状态提升到最近的共同祖先

React 中被引用最多的一条建议——"把状态提升到需要知道它的最低层祖先"——在 Reactor 中可以逐字照搬。Context 就是你把这些已提升的状态再传下去、又不必经过那些无聊的中间层的方式。一个带共享工具栏、需要知道当前选中项的双窗格编辑器:把选中项提升到同时拥有两个窗格的父组件,作为 context 提供出去,两个窗格各自消费。应用根节点上的键盘快捷键处理器读取的是同一个 context——同一个模式,不需要逐层传 prop。

用模拟 Provider 做测试

生产代码用真实的用户获取 provider 包裹组件树。测试则渲染同一棵组件树,外面包一层 .Provide(ctx, testStub)——消费方代码一行不改,而测试可以通过这个桩控制每一个值:

// Production root provides the real value; tests render the same
// component tree with a Provide(...) wrapper supplying a stub. The
// consumer code is unchanged — context lets you swap dependencies
// without re-plumbing props.
class CartConsumer : Component
{
    public override Element Render()
    {
        var user = UseContext(AppContexts.User);
        return TextBlock($"Cart for {user.DisplayName} ({user.Id})");
    }
}

class MockProviderExample : Component
{
    public override Element Render()
    {
        // Two trees: one with the "real" production default, one with a
        // test stub supplied via .Provide(). Same CartConsumer in both.
        return HStack(24,
            VStack(8,
                Caption("Production default"),
                Component<CartConsumer>()
            ),
            VStack(8,
                Caption("Test stub"),
                VStack(0, Component<CartConsumer>())
                    .Provide(AppContexts.User, new CurrentUser("test", "Test User", IsAdmin: false))
            )
        ).Padding(24);
    }
}

Same CartConsumer rendered with default and a Provide-stub user

对于跨越多个组件的单元测试,这是推荐的模式——渲染器夹具的细节与快照测试的接口见测试

有作用域的功能开关

从最近的祖先 Context<FeatureFlags> 读取一个功能开关,为某一棵子树覆盖它以演示一个 beta 界面,而应用的其余部分仍保持稳定。上面嵌套 Context 覆盖一节就是那个形态;开关 record 则是载荷。

常见错误

把内联字面量当作所提供的值

class InlineLiteralProvideDont : Component
{
    public override Element Render()
    {
        var (mode, setMode) = UseState("light");
        var (scale, setScale) = UseState(14);

        // Don't — a fresh ThemeConfig every render re-renders every consumer
        // in the subtree even when mode and scale are unchanged.
        return VStack(8, Component<ThemedHeading>())
            .Provide(ThemeContexts.Theme, new ThemeConfig(mode, scale, "#0078D4"));
    }
}

每次渲染都会构造一个全新的 ThemeConfig,它与上一次渲染的值比较不相等,于是子树中每一个 UseContext(ctx) 的消费者都会重新渲染。参见上面的稳定值的注意事项,改用 UseMemo,或把构造过程提升出去。

在任何 provider 之外读取 context

static class NullableUserContexts
{
    // Static field default of `null`.
    public static readonly Context<CurrentUser?> User = new(null);
}

class ReadsContextWithoutProvider : Component
{
    public override Element Render()
    {
        // In a component that's somehow rendered before the root provides,
        // UseContext returns the context's DefaultValue — here, null.
        var user = UseContext(NullableUserContexts.User);

        // So branch on the sentinel instead of dereferencing blind:
        return TextBlock(user?.DisplayName ?? "(not signed in)");
    }
}

当没有任何祖先调用过 .Provide 时,UseContext 会静默返回 Context<T>.DefaultValue——没有警告,也没有异常。组件会基于默认值运行,而这很少是开发者期望的结果。要么选一个可以安全渲染的非空默认值,要么把这个默认值当作"未初始化"的哨兵并显式分支(如上)。 init 模式用的是后者——匿名用户就是哨兵,认证流程会在 provider 处把它换掉。

把本该是 props 的状态放进 context

static class SelectionContexts
{
    // Don't — a single-source-single-sink value belongs in a prop.
    public static readonly Context<int> SelectedIndex = new(0);
}

class ContextForPropsDont : Component
{
    public override Element Render()
    {
        var index = UseContext(SelectionContexts.SelectedIndex);
        return TextBlock($"Selected: {index}");
    }
}

如果这个值是从一个父组件流向一个子组件,那它就是 prop。Context 适用于横切性的数据——主题、区域设置、当前用户、功能开关——即那些处于不同深度、相距很远的众多组件都需要的数据。硬把一种"单来源单去处"的关系塞进 context,会掩盖数据流,并让该组件无法带着不同的选中项被复用。参见 React 关于 context 替代方案的指导

小贴士

把 context 用于横切关注点。 主题、区域设置、当前用户、功能开关、调度器句柄——任何被处于不同深度的众多组件所需要的东西。组件特有的数据是 props 的职责。

始终设置有意义的默认值。 读取 context 的组件即便没有 provider 也应该能工作——默认值让独立测试、Storybook 式预览,以及针对孤立组件的顺手开发成为可能。

把所提供的值记忆化。UseMemo 并带上显式依赖项,把这个值包起来。每次渲染都内联字面量,会让子树里的每个消费者都跟着抖动。

在有帮助的地方做局部覆盖。 在单个测试面板上加一个仅用于调试的 .Provide(),就能预览另一种主题、另一种区域设置或一个被模拟的用户,而无需重新接线应用的其余部分。参见嵌套 Context 覆盖一节。

把 context 与 UseState 组合起来表达动态值。 在 provider 组件里把值存进状态,需要时把 setter 通过一个独立的命令 context 传下去,那么 setter 被调用时消费者就会自动更新。

下一步

  • 命令(Commanding) — 上一篇:把动作连同标签、图标与键盘快捷键打包在一起
  • 无障碍 — 下一篇:焦点捕获、屏幕阅读器播报、ARIA 角色
  • HookUseContextUseStateUseMemo 以及 Hook 接口的其余部分
  • 样式与主题 — 用 context 在整个应用中传播主题令牌
  • 组件 — props 与 context 的对比——何时该用哪种形态
  • 本地化IntlContexts.Locale 是框架中一个真实存在的 context
  • 测试 — 在测试夹具中使用 .Provide() 注入桩,无需重新接线 props