【Xilem 0.4 基础语法学与练】第17课:Lens 机制——状态切片与子组件复用

一、本课目标

把一个"胖"状态拆成多个"瘦"子状态,让每个子组件只关注自己需要的那部分数据。掌握 Xilem 的 lens 机制------它是怎么做到"从父状态中切出一块给子组件用,子组件的修改又能自动写回父状态"的。

二、问题场景:为什么需要 Lens?

回顾第16课的计数器,所有逻辑都写在一个 app_logic 里,状态也只有一个 Counter。这在小程序里没问题,但当你开始构建真实应用时,状态会迅速膨胀:

rust 复制代码
struct AppState {
    count: i32,
    title: String,
    settings: Settings,
    todos: Vec<TodoItem>,
    // ... 还可能继续增长
}

如果所有子组件都接收 &mut AppState,会产生两个问题:

耦合问题------一个只负责显示标题的组件,却能访问和修改 count、todos、settings 等完全无关的字段。组件的职责边界被打破了。

复用问题------你想把"计数器"这个组件抽出来,放到另一个项目里用。但它绑死了 AppState 类型,离开这个类型就没法编译。你没法把一个"只需要 i32 就能工作"的计数器,复用到另一个"只需要 u64 就能工作"的场景里。

Lens 就是为了解决这两个问题而存在的。

三、Lens 的核心思想

Lens 这个词来自光学------"透镜"。它的作用就像一副放大镜:你透过它看一个复杂的整体,但只聚焦在其中的一个局部。

在 Xilem 中,lens 是一个 View 适配器,它做两件事:

  1. 读取:从父状态中提取出一个子字段(&Parent → &Child)
  2. 写回:把子组件对子字段的修改,映射回父状态(&mut Child 的修改 → 反映到 &mut Parent)

用一句话概括:lens 把一个"操作大状态的 View"变成一个"操作小状态的 View"。

四、完整示例:计数器 + 标题编辑器

4.1 Cargo.toml

toml 复制代码
[package]
name = "xilem-lens-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
xilem = "0.4"
winit = "0.30"

4.2 main.rs

rust 复制代码
use winit::error::EventLoopError;
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};

// ---- 父状态 ----
#[derive(Default)]
struct AppState {
    count: i32,
    title: String,
}

// ---- 子组件 1:计数器(只操作 i32)----
fn counter_view(data: &mut i32) -> impl WidgetView<i32> + use<> {
    flex(Axis::Horizontal, (
        label(format!("计数: {}", data)),
        text_button("+1", |data: &mut i32| *data += 1),
        text_button("-1", |data: &mut i32| *data -= 1),
    ))
}

// ---- 子组件 2:标题编辑器(只操作 String)----
fn title_view(data: &mut String) -> impl WidgetView<String> + use<> {
    flex(Axis::Horizontal, (
        label(format!("标题: {}", data)),
        text_button("改名", |data: &mut String| {
            data.push_str(" ✨");
        }),
        text_button("清空", |data: &mut String| {
            data.clear();
        }),
    ))
}

// ---- 根视图:用 lens 把父状态切给子组件 ----
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
    flex(Axis::Vertical, (
        // lens 第一参数:子 View(操作 i32 的 counter_view)
        // lens 第二参数:闭包,从 &mut AppState 中取出 &mut i32
        lens(
            |data: &mut i32| counter_view(data),
            |data: &mut AppState| &mut data.count,
        ),

        // lens 第一参数:子 View(操作 String 的 title_view)
        // lens 第二参数:闭包,从 &mut AppState 中取出 &mut String
        lens(
            |data: &mut String| title_view(data),
            |data: &mut AppState| &mut data.title,
        ),

        // 根视图自己的控件:同时访问两个字段
        text_button("全部重置", |data: &mut AppState| {
            data.count = 0;
            data.title.clear();
        }),
    ))
}

fn main() -> Result<(), EventLoopError> {
    let app = Xilem::new_simple(
        AppState {
            count: 0,
            title: "我的应用".to_string(),
        },
        app_logic,
        WindowOptions::new("Lens Demo"),
    );
    app.run_in(EventLoop::with_user_event())?;
    Ok(())
}

五、逐行拆解

5.1 状态定义

rust 复制代码
#[derive(Default)]
struct AppState {
    count: i32,
    title: String,
}

父状态有两个字段:count(i32)和 title(String)。这是整个应用的唯一状态源。

5.2 子组件:counter_view

rust 复制代码
fn counter_view(data: &mut i32) -> impl WidgetView<i32> + use<> {
    flex(Axis::Horizontal, (
        label(format!("计数: {}", data)),
        text_button("+1", |data: &mut i32| *data += 1),
        text_button("-1", |data: &mut i32| *data -= 1),
    ))
}

注意这个函数的签名:参数是 &mut i32,不是 &mut AppState。它完全不知道 AppState 的存在------它只知道"我手里有一个 i32,我可以读它、改它"。

这就是解耦:counter_view 不依赖任何具体的父状态类型。你可以把它放到任何一个需要计数器功能的应用里,只要那个应用能通过 lens 提供一个 &mut i32 就行。

5.3 子组件:title_view

rust 复制代码
fn title_view(data: &mut String) -> impl WidgetView<String> + use<> {
    flex(Axis::Horizontal, (
        label(format!("标题: {}", data)),
        text_button("改名", |data: &mut String| {
            data.push_str(" ✨");
        }),
        text_button("清空", |data: &mut String| {
            data.clear();
        }),
    ))
}

同理,title_view 只认识 String,不知道 AppState。

5.4 根视图:用 lens 连接

rust 复制代码
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
    flex(Axis::Vertical, (
        lens(
            |data: &mut i32| counter_view(data),
            |data: &mut AppState| &mut data.count,
        ),
        lens(
            |data: &mut String| title_view(data),
            |data: &mut AppState| &mut data.title,
        ),
        text_button("全部重置", |data: &mut AppState| {
            data.count = 0;
            data.title.clear();
        }),
    ))
}

这里是关键。lens 接收两个参数:

第一个参数:子 View 函数。它接收子状态类型(&mut i32 或 &mut String),返回一个 WidgetView<子状态类型>。

第二个参数:lens 闭包。它接收 &mut AppState(父状态的可变引用),返回 &mut 子状态类型(子字段的可变引用)。这个闭包就是"透镜"------它告诉框架"从父状态中取出哪个字段"。

当用户点击"+1"按钮时,数据流是这样的:

  1. counter_view 内部的闭包 |data: &mut i32| *data += 1 修改了 &mut i32
  2. 这个 &mut i32 实际上是通过 lens 闭包 |data: &mut AppState| &mut data.count 从 AppState 中取出来的
  3. 所以修改 &mut i32 等价于修改 AppState.count
  4. 框架检测到 AppState 被修改 → 重新调用 app_logic → 界面更新

六、数据流对比

不用 lens(第16课的方式)

用户点击 → 闭包直接修改 AppState.count → 框架检测 AppState 变化 → 重新执行 app_logic → 整个 View Tree 重建

所有子组件都直接操作 AppState,每个子组件都能访问所有字段。

用 lens(本课的方式)

用户点击 → 闭包修改 &mut i32 → lens 闭包把修改映射回 AppState.count → 框架检测 AppState 变化 → 重新执行 app_logic → 整个 View Tree 重建

子组件只操作自己类型的子状态,lens 负责在子状态和父状态之间做双向映射。

注意:在 Xilem 的当前实现中,app_logic 仍然是整体重新执行的。lens 的核心价值不在于"减少重建次数",而在于代码组织------让子组件的类型签名更精确、职责更单一、可复用性更强。

七、运行

shell 复制代码
cargo run

弹出一个窗口,上方是计数器(显示数值 + "+1" "-1" 按钮),中间是标题编辑器(显示标题 + "改名" "清空" 按钮),底部是"全部重置"按钮。

点击"+1",计数加一,标题不受影响。点击"改名",标题追加 "✨",计数不受影响。点击"全部重置",两者同时归零。

八、lens 的底层原理

lens 在 xilem_core 中对应的 View 类型是 Lens:

rust 复制代码
pub struct Lens<C, F> {
    child: C,
    lens: F,
}
  • C 是子 View 的类型
  • F 是 lens 闭包的类型

当框架调用 build 或 rebuild 时,Lens View 会:

  1. 用 lens 闭包从父状态中取出子字段的 &mut 引用
  2. 把这个 &mut 引用传给子 View 的 build/rebuild
  3. 子 View 对 &mut 的任何修改,都会直接反映到父状态的对应字段上(因为它们指向同一块内存)

这就是 Rust 引用的力量------不需要任何额外的"写回"逻辑,&mut 本身就是双向的。

九、lens vs 直接传父状态

直接传 &mut AppState 的特点是:子组件能访问所有字段,绑定 AppState 类型,类型签名为 fn(&mut AppState) -> impl WidgetView,职责边界模糊,但代码量少(不需要 lens 调用)。

用 lens 传 &mut i32 的特点是:子组件只能访问一个字段,可在任何提供 i32 的场景复用,类型签名为 fn(&mut i32) -> impl WidgetView,职责边界清晰,但代码量稍多(需要 lens 包装)。

小项目中直接传父状态完全没问题。当项目变大、子组件变多时,lens 的价值才真正体现出来。

十、练习

练习一:填空题------补全 lens 调用

以下代码中有一个带颜色和形状两个字段的应用状态,以及两个子组件。请补全 app_logic 中的 lens 调用。

rust 复制代码
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{WidgetView};

#[derive(Default)]
struct AppState {
    color: String,
    shape: String,
}

fn color_view(data: &mut String) -> impl WidgetView<String> + use<> {
    flex(Axis::Horizontal, (
        label(format!("颜色: {}", data)),
        text_button("变红", |data: &mut String| *data = "红色".to_string()),
    ))
}

fn shape_view(data: &mut String) -> impl WidgetView<String> + use<> {
    flex(Axis::Horizontal, (
        label(format!("形状: {}", data)),
        text_button("变圆", |data: &mut String| *data = "圆形".to_string()),
    ))
}

fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
    flex(Axis::Vertical, (
        // 填空①:用 lens 把 color_view 连接到 data.color
        lens(______, ______),

        // 填空②:用 lens 把 shape_view 连接到 data.shape
        lens(______, ______),
    ))
}

要求:

  • 填空①:写出 lens 的两个参数(子 View 闭包 + lens 闭包)
  • 填空②:同上

练习二:改造题------三色灯控制器

在练习一的基础上,增加一个 brightness 字段(u8 类型,0-255),并新增一个 brightness_view 子组件:

要求:

  1. AppState 增加 brightness: u8 字段,默认值为 128
  2. 新增 brightness_view(data: &mut u8) 子组件,显示当前亮度值,提供 "+10" 和 "-10" 按钮(注意边界:不超过 255,不低于 0)
  3. 在 app_logic 中用 lens 把 brightness_view 连接到 data.brightness
  4. 增加一个"恢复默认"按钮,把三个字段都恢复为默认值
rust 复制代码
#[derive(Default)]
struct AppState {
    color: String,
    shape: String,
    brightness: u8,
}

// 你的代码:实现 brightness_view 和 app_logic

练习三:思考题------lens 闭包能不能返回计算值?

假设你写了这样的代码:

rust 复制代码
lens(
    |data: &mut i32| counter_view(data),
    |data: &mut AppState| {
        let mut temp = data.count * 2;
        &mut temp
    },
)

问题:

  1. 这段代码能编译吗?
  2. 如果不能,编译器会报什么错?
  3. 为什么 lens 闭包必须返回父状态中某个字段的引用,而不能返回一个临时变量的引用?

练习四:改造题------嵌套状态 + 多层 lens

要求:

  1. 定义嵌套状态结构:
rust 复制代码
struct Settings {
    theme: String,
    font_size: u32,
}

struct AppState {
    count: i32,
    settings: Settings,
}
  1. 实现 Default,theme 默认为 "light",font_size 默认为 14
  2. 写一个 theme_view(data: &mut String) 子组件,显示当前主题,提供"切换"按钮(在 "light" 和 "dark" 之间切换)
  3. 写一个 font_size_view(data: &mut u32) 子组件,显示当前字号,提供"+"和"-"按钮(每次加减 2,最小为 8)
  4. 在 app_logic 中,用两次嵌套 lens 把 theme_view 和 font_size_view 连接到 data.settings.theme 和 data.settings.font_size

提示:第一层 lens 从 AppState 取出 &mut Settings,第二层 lens 从 Settings 取出 &mut String 或 &mut u32。

// 你的代码:实现 Default、theme_view、font_size_view、app_logic、main

练习答案与知识要点

练习答案

练习一答案

rust 复制代码
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
    flex(Axis::Vertical, (
        // 填空①
        lens(
            |data: &mut String| color_view(data),
            |data: &mut AppState| &mut data.color,
        ),

        // 填空②
        lens(
            |data: &mut String| shape_view(data),
            |data: &mut AppState| &mut data.shape,
        ),
    ))
}

解读:lens 的两个参数顺序是固定的:第一个是子 View 函数(接收子状态类型,返回 WidgetView<子状态类型>),第二个是 lens 闭包(接收 &mut 父状态,返回 &mut 子字段)。两个 lens 调用的结构完全对称,只是字段名不同。

练习二答案

rust 复制代码
use winit::error::EventLoopError;
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};

#[derive(Default)]
struct AppState {
    color: String,
    shape: String,
    brightness: u8,
}

impl Default for AppState {
    fn default() -> Self {
        AppState {
            color: "白色".to_string(),
            shape: "方形".to_string(),
            brightness: 128,
        }
    }
}

fn color_view(data: &mut String) -> impl WidgetView<String> + use<> {
    flex(Axis::Horizontal, (
        label(format!("颜色: {}", data)),
        text_button("变红", |data: &mut String| *data = "红色".to_string()),
    ))
}

fn shape_view(data: &mut String) -> impl WidgetView<String> + use<> {
    flex(Axis::Horizontal, (
        label(format!("形状: {}", data)),
        text_button("变圆", |data: &mut String| *data = "圆形".to_string()),
    ))
}

fn brightness_view(data: &mut u8) -> impl WidgetView<u8> + use<> {
    flex(Axis::Horizontal, (
        label(format!("亮度: {}", data)),
        text_button("+10", |data: &mut u8| {
            *data = data.saturating_add(10);
        }),
        text_button("-10", |data: &mut u8| {
            *data = data.saturating_sub(10);
        }),
    ))
}

fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
    flex(Axis::Vertical, (
        lens(
            |data: &mut String| color_view(data),
            |data: &mut AppState| &mut data.color,
        ),
        lens(
            |data: &mut String| shape_view(data),
            |data: &mut AppState| &mut data.shape,
        ),
        lens(
            |data: &mut u8| brightness_view(data),
            |data: &mut AppState| &mut data.brightness,
        ),
        text_button("恢复默认", |data: &mut AppState| {
            *data = AppState::default();
        }),
    ))
}

fn main() -> Result<(), EventLoopError> {
    let app = Xilem::new_simple(
        AppState::default(),
        app_logic,
        WindowOptions::new("三色灯"),
    );
    app.run_in(EventLoop::with_user_event())?;
    Ok(())
}

解读:这道题考察的是"为新的基本类型字段添加 lens"。brightness_view 的参数类型是 &mut u8,和之前的 &mut i32、&mut String 是同一个模式------子组件只认识自己的基本类型,不关心父状态长什么样。saturating_add 和 saturating_sub 是 Rust 标准库提供的方法,它们在做加法/减法时自动处理溢出:超过 255 就停在 255,低于 0 就停在 0,不会 panic。这比手动写 if *data > 245 更简洁也更安全。

练习三答案

不能编译。

编译器会报错,大意是:temp 是一个局部变量,函数返回后它就会被销毁,你不能返回一个指向局部变量的引用("returns a reference to data owned by the current function")。

原因:lens 闭包返回的 &mut 引用,必须指向父状态中某个实际存在的字段。因为框架需要在子组件的整个生命周期内持有这个引用,而局部变量在闭包返回时就被销毁了,引用会变成悬垂引用。

更深层的原因是:lens 的双向映射依赖"子引用和父字段指向同一块内存"这个事实。如果你返回一个临时变量的引用,子组件修改的是临时变量,而不是父状态的字段------修改就丢失了,"写回"机制就断了。

lens 闭包的第二个参数必须是一个字段访问表达式(如 &mut data.count),不能是任何计算结果或临时值。

练习四答案

rust 复制代码
use winit::error::EventLoopError;
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};

struct Settings {
    theme: String,
    font_size: u32,
}

impl Default for Settings {
    fn default() -> Self {
        Settings {
            theme: "light".to_string(),
            font_size: 14,
        }
    }
}

struct AppState {
    count: i32,
    settings: Settings,
}

impl Default for AppState {
    fn default() -> Self {
        AppState {
            count: 0,
            settings: Settings::default(),
        }
    }
}

fn counter_view(data: &mut i32) -> impl WidgetView<i32> + use<> {
    flex(Axis::Horizontal, (
        label(format!("计数: {}", data)),
        text_button("+1", |data: &mut i32| *data += 1),
    ))
}

fn theme_view(data: &mut String) -> impl WidgetView<String> + use<> {
    flex(Axis::Horizontal, (
        label(format!("主题: {}", data)),
        text_button("切换", |data: &mut String| {
            if *data == "light" {
                *data = "dark".to_string();
            } else {
                *data = "light".to_string();
            }
        }),
    ))
}

fn font_size_view(data: &mut u32) -> impl WidgetView<u32> + use<> {
    flex(Axis::Horizontal, (
        label(format!("字号: {}", data)),
        text_button("+", |data: &mut u32| *data += 2),
        text_button("-", |data: &mut u32| {
            if *data > 8 {
                *data -= 2;
            }
        }),
    ))
}

fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
    flex(Axis::Vertical, (
        // 第一层:直接从 AppState 取 count
        lens(
            |data: &mut i32| counter_view(data),
            |data: &mut AppState| &mut data.count,
        ),

        // 第二层:嵌套 lens
        // 外层 lens:从 AppState 取出 &mut Settings
        // 内层 lens:从 Settings 取出 &mut String(theme)
        lens(
            |settings: &mut Settings| {
                lens(
                    |data: &mut String| theme_view(data),
                    |settings: &mut Settings| &mut settings.theme,
                )
            },
            |data: &mut AppState| &mut data.settings,
        ),

        // 第三层:嵌套 lens
        // 外层 lens:从 AppState 取出 &mut Settings
        // 内层 lens:从 Settings 取出 &mut u32(font_size)
        lens(
            |settings: &mut Settings| {
                lens(
                    |data: &mut u32| font_size_view(data),
                    |settings: &mut Settings| &mut settings.font_size,
                )
            },
            |data: &mut AppState| &mut data.settings,
        ),
    ))
}

fn main() -> Result<(), EventLoopError> {
    let app = Xilem::new_simple(
        AppState::default(),
        app_logic,
        WindowOptions::new("Nested Lens"),
    );
    app.run_in(EventLoop::with_user_event())?;
    Ok(())
}

解读:这道题考察的是"嵌套状态的多次 lens"。核心技巧是:外层 lens 从 AppState 取出 &mut Settings,内层 lens 从 Settings 取出具体字段。

注意两个嵌套 lens 共享同一个外层 lens 闭包 |data: &mut AppState| &mut data.settings,但内层分别取不同的字段(theme 和 font_size)。这是合法的,因为两个 lens 在不同时刻被框架调用,不会同时持有对 data.settings 的可变借用。

嵌套 lens 的读法是从外到内:先读外层 lens 的第二个参数("从谁取"),再读内层 lens 的第二个参数("取哪个字段")。写数据时方向相反:内层闭包修改子字段 → 外层闭包把修改映射回父状态。

知识要点

要点一:lens 的本质是"可变引用的传递链"

lens 不是什么魔法------它只是把一个 &mut Parent 通过闭包转换成 &mut Child,然后把 &mut Child 交给子组件。子组件对 &mut Child 的修改,因为 Rust 引用的语义,会直接反映到 Parent 的对应字段上。没有拷贝,没有回调,没有事件总线------就是普通的可变引用。

要点二:lens 闭包必须返回字段的引用,不能返回临时值

lens 闭包的第二个参数必须形如 |data: &mut Parent| &mut data.field。它返回的引用必须指向父状态中实际存在的字段,不能是局部变量、计算结果或 clone 出来的值。原因有二:一是生命周期------临时值在闭包返回后就被销毁,引用变成悬垂引用;二是语义------修改临时值不会反映到父状态,"写回"机制就断了。

要点三:子组件的类型签名决定可复用性

fn counter_view(data: &mut i32) -> impl WidgetView 这个签名意味着:这个组件可以在任何能提供 &mut i32 的场景中复用。它不依赖 AppState,不依赖任何具体的父状态类型。如果你把它放到另一个项目中,只要那个项目能通过 lens 提供一个 &mut i32,这个组件就能直接用------一行代码都不用改。

这就是 lens 的核心价值:让组件的类型签名尽可能精确,只声明自己真正需要的数据。

要点四:嵌套 lens 的读法------从外到内

当状态有多层嵌套时(如 AppState.settings.theme),需要多次 lens。嵌套 lens 的读法是:外层 lens 负责"从父取子",内层 lens 负责"从子取孙"。每一层只做一次字段访问,层层递进。

要点五:lens 解决的是代码组织问题,不是性能问题

在 Xilem 当前的实现中,即使用了 lens,app_logic 仍然是整体重新执行的。lens 不会让框架"只重建变化的子树"------它解决的是代码层面的耦合和复用问题,不是运行时的性能问题。如果你需要性能优化,应该用 memoize(后续课程会讲)。

下一课可以讲 map_state------它和 lens 类似但更灵活,允许你在映射时做计算和转换,而不仅仅是字段访问。要继续吗?

相关推荐
CoderYanger2 小时前
前端基础——JavaScript(WebAPI)代码案例
java·开发语言·前端·javascript·css·前端框架·html5
AI职业加油站3 小时前
2026大数据运维行业趋势复盘:大数据运维工程师赋能发展
大数据·运维·人工智能·学习·数据分析·职场发展
小黄人软件3 小时前
享受独处、终身学习
学习
天道kabuto3 小时前
前端每日知识点:React 与 Vue Diff 算法对比 · 完整总结
vue.js·react.js·前端框架
马东183 小时前
冲突后的解决状态
学习
CCC:CarCrazeCurator3 小时前
LeRobot 深度技术方案解析:Hugging Face 端到端机器人学习框架
学习·机器人
Titan20244 小时前
网络学习:网络层与数据链路层知识梳理
服务器·网络·学习·tcp/ip
老猿讲编程4 小时前
【Eclipse OpenSOVD学习之十一】网关二进制与部署
学习·中间件·rust·eclipse·汽车
Yanjun2i4 小时前
Agent学习记录六:Tool 类 + Tool Registry
开发语言·python·学习