- 跨平台
- UI组件
- 前端
- 移动开发
【免费下载链接】Valdi
Valdi is a cross-platform UI framework that delivers native performance without sacrificing developer velocity.
本文是 Valdi 快速上手 Codelab 系列的核心章节(对应仓库 docs/codelabs/getting_started/5-component_state.md),深入讲解 Valdi 组件在脱离热重载(hotreloader)后如何自主更新 UI:父组件通过ViewModel向下传递不可变数据,子组件通过StatefulComponent与setState()管理内部状态。读完本文,你将掌握 ViewModel 接口的定义与传参、State 的初始化与局部更新、定时器驱动的状态刷新及生命周期清理,并能理解这些 API 在 Valdi 运行时中的底层实现原理。
从热重载到生产环境:组件为什么需要"真正的状态"
在前面的章节中,我们一直使用局部变量配合热重载工具来刷新界面:改一行代码,热重载工具重编译模块,应用随之重新渲染。但一个组件一旦进入生产环境运行,就没有"热重载"替它刷新视图了——此时,状态的变更必须由组件自己感知并触发重渲染。
Valdi 的答案是:当特定种类的状态发生变化时,自动重新渲染组件。为此,每个组件拥有两种主要状态:
- ViewModel:由父组件注入的数据,向子组件单向流动;
- State:组件自身的内部状态,由组件自己维护和更新。
下面分别展开。
ViewModel 与单向数据流
在 Valdi 中,数据沿组件树自上而下传播:父组件通过ViewModel把数据传给子组件。对于子组件而言,ViewModel 是**不可变(immutable)**的——只能读取其中的变量,不能修改它。当父组件更新子组件的 ViewModel 时,子组件可以在下一次onRender中拿到新值,也可以在生命周期回调onViewModelUpdate中感知这次更新。
第一步:定义 ViewModel 接口
ViewModel 被定义为 TypeScript 接口,并作为泛型参数指定在Component上。在GettingStartedCodelab组件定义之外、imports 之下定义:
export interface GettingStartedCodeLabViewModel { header: string; }推荐命名规范为NameOfComponentViewModel(组件名 + ViewModel 后缀)。
第二步:把 ViewModel 指定为 Component 的泛型
export class GettingStartedCodelab extends Component<GettingStartedCodeLabViewModel> {第三步:在 onRender 中消费 ViewModel
onRender() { const fullMsg = this.msg + new Date().toLocaleTimeString(); <layout> <label value={this.viewModel.header} font={systemBoldFont(16)} /> {this.onRenderMessage(fullMsg)} </layout>; }this.viewModel.header直接读取父组件注入的值。从源码看,Component基类把 ViewModel 保存为只读属性:readonly viewModel: Readonly<ViewModel>,在构造函数中由渲染器传入(见 Component.ts)。Readonly<T>保证了类型层面的不可变性——子组件只能读,不能写。
第四步:父组件必须传入参数,否则编译报错
此时如果让热重载刷新,你会看到报错或缺失的 header。原因在于:我们没有在父组件添加<GettingStartedCodelab>的位置定义header参数。打开 HelloWorldApp.tsx,在onRender中找到<GettingStartedCodelab>所在位置(初始组件定义见 GettingStartedCodelab.tsx)。
VS Code 会给出红色波浪线,悬停后提示:
"Property 'header' is missing in type '{}' but required in type 'Readonly<GettingStartedCodeLabViewModel>'"这是因为父组件没有把header值传进子组件的 ViewModel。修复方法是为header标签补上属性值:
<GettingStartedCodelab header='State codelab'></GettingStartedCodelab>热重载刷新后,新 header 就会出现。
可选字段:不是所有 ViewModel 变量都必须传
如果某些 ViewModel 变量并非每次都需要,可在接口定义中将其设为可选(optional):
export interface GettingStartedCodeLabViewModel { header: string; numKittens?: number; }numKittens?是可选的,父组件传不传都不会报错。这与 TypeScript 的可选属性语义一致,也是 ViewModel 驱动类型安全 UI 的典型用法。
State:组件的内部状态
State对象用于追踪组件的内部状态。与 ViewModel 类似,State 也是不可变的;但与 ViewModel 不同的是,它可以通过组件的setState()函数被修改,而setState()会触发组件重新渲染。
第一步:定义 State 接口
紧跟在 ViewModel 之后定义:
interface GettingStartedCodeLabState { elapsed: number; kitten: boolean; }State 接口不需要export,因为它不会在本文件之外使用——这正是"内部状态"语义的体现。
第二步:改用 StatefulComponent
Valdi 提供了专门处理 State 的组件基类。把组件改为继承StatefulComponent,并同时指定 ViewModel 与 State 两个泛型:
import { StatefulComponent } from "valdi_core/src/Component"; /** * @Component */ export class GettingStartedCodelab extends StatefulComponent< GettingStartedCodeLabViewModel, GettingStartedCodeLabState > {从源码可以看到,StatefulComponent继承自Component,在基类之上增加了state?: Readonly<State>属性和setState()方法(见 Component.ts)。@Component注释用于标记 Valdi 编译器需要处理的组件类。
第三步:初始化 state 变量
/** * @Component */ export class GettingStartedCodelab extends StatefulComponent< GettingStartedCodeLabViewModel, GettingStartedCodeLabState > { state = { elapsed: 0, kitten: false, };这里直接为state字段赋初始值,作为组件创建时的默认内部状态。
第四步:用定时器驱动状态更新
现在让elapsed状态"动起来"。在onCreate中设置一个定时器,并用interval属性保存其引用:
private interval?: number; onCreate() { this.msg = 'Hello valdi on '; this.kitten = true; this.interval = setInterval(() => { this.setState({ elapsed: this.state.elapsed + 1 }); }, 1000); }这里涉及两个关键概念:
setInterval是 JavaScript 提供的定时器工具,会在固定延迟后重复调用指定函数。第一个参数是要调用的函数——这里是一个 lambda,每秒钟调用一次setState让elapsed加 1;第二个参数是毫秒数(1000ms = 1 秒)。setState做的是局部更新(partial update):传入的只是状态对象的子集,Valdi 会把新值与旧状态合并,而不是整体替换。因此这里只更新elapsed,kitten的值保持不变。
从源码看,setState的合并逻辑由mergePartial实现(见 PartialUtils.ts):它会遍历 partial 中的每个键,只把发生变化(oldValue !== newValue)的字段复制到新对象中,其余字段原样保留;若没有任何字段变化,则返回undefined,不会触发无谓的重渲染。setState在合并出新状态后调用scheduleRender(),同步调度组件重新渲染(见 Component.ts)。
第五步:在 onDestroy 中清理定时器
setInterval会永远运行下去,因此组件销毁时必须清理,否则会造成泄漏:
onDestroy() { if (this.interval) { clearInterval(this.interval); } }clearInterval清除由setInterval创建的定时器;onDestroy是 Component 的生命周期函数,在组件被销毁时调用。作为对照,源码中setState在组件已销毁时会在控制台输出错误警告,提示"请确保在 onDestroy 中取消所有订阅和异步任务,否则可能造成内存泄漏"(见 Component.ts)——这正是本小节要规避的问题。Valdi 还提供了更便捷的setTimeoutDisposable,它创建的定时器会在组件销毁时自动失效(见 Component.ts)。
第六步:在 onRender 中读取 State
在onRender中再添加一个<label>,显示不断增长的elapsed:
onRender() { const fullMsg = this.msg + new Date().toLocaleTimeString(); <layout> <label value={this.viewModel.header} font={systemBoldFont(16)} /> <label value={`Time Elapsed: ${this.state.elapsed} seconds`} /> {this.onRenderMessage(fullMsg)} </layout>; }注意这里的 value 是模板字符串(template literal):以反引号(back tick,`)包裹、用${}插值,而不是普通引号。让热重载刷新后,就能看到秒数逐秒递增——UI 已经由状态驱动自动更新,不再依赖热重载。
第七步:用状态驱动条件渲染
再使用另一个 State 变量kitten。把setInterval里的更新逻辑改为基于elapsed % 5计算:
this.interval = setInterval(() => { this.setState({ elapsed: this.state.elapsed + 1, kitten: this.state.elapsed % 5 == 0, }); }, 1000);这样每当经过 5 秒的倍数时,kitten会被置为true。注意这里同时更新了两个字段——由于setState是局部合并,即使一次传入多个字段,也只会合并变化的部分,不会影响其他状态。
然后在onRenderMessage中消费state.kitten,并清理原来的 kitten 局部变量:
onRenderMessage(message: string) { if (this.state.kitten) { <image height={50} width={50} src='https://placecats.com/300/300' />; } else { <label value={message} font={systemFont(12)} />; } }运行后观察 UI:大多数时间显示 hello 消息,每隔 5 秒切换显示一张小猫图片。至此,一个由setState驱动的完整状态循环已经跑通:状态变化 → 触发重渲染 → 读取新状态 → 更新 UI → 再次变化。
结合生命周期理解状态流
onCreate、onRender、onDestroy这些回调的调用时机,与 ViewModel/State 的流转密切相关。根据配套章节 6-component_lifecycle.md 及源码注释(见 Component.ts),可以总结如下:
- onCreate:组件首次创建时调用一次,此时 ViewModel 与 State 已就绪但尚未开始渲染,适合初始化局部变量、启动定时器等一次性工作;
- onViewModelUpdate(previousViewModel?):ViewModel 被设置、或父组件在最近一次
onRender后更新了它时调用;若父组件在初始化时提供了 ViewModel,它会在onCreate之后立刻被调用,且其后总会紧跟一次onRender; - onRender:渲染入口,只有在该函数调用栈内的 TSX 标签才会生效。它有三种触发场景——
onCreate之后的首次渲染、onViewModelUpdate之后、以及StatefulComponent调用setState之后; - onDestroy:组件被销毁时调用一次(例如父组件不再在
onRender中渲染它),是清理长任务与资源的正确位置。
也就是说,ViewModel 的更新走"父传子 +onViewModelUpdate"通道,State 的更新走"setState+ 重渲染"通道,二者最终都汇聚到onRender,这正是 Valdi 声明式 UI 的核心循环。
更多进阶:动画化状态变更与测试注入
StatefulComponent还提供了两个在源码中可见的进阶 API,值得一提:
setStateAnimated(state, animationOptions)/setStateAnimatedPromise(...):与setState语义相同(局部合并、同步重渲染),但会把本次状态变更引发的元素变化关联到动画上,动画结束时可获得 Promise 通知(见 Component.ts)。适合需要"数字跳动、列表平滑过渡"等场景。- 测试中的 ViewModel 注入:在编写组件单元测试时,可以通过
createComponent(GettingStartedCodelab, { header: 'Something' })直接注入 ViewModel 来验证渲染结果,相关用法详见 8-unittest.md。
小结
- ViewModel是父组件向子组件单向传递数据的通道,子组件只读不可写,通过泛型
Component<ViewModel>声明,父组件必须提供全部必填字段; - State是组件内部状态,通过
StatefulComponent<ViewModel, State>与setState()维护;setState执行局部合并(底层为mergePartial),仅更新发生变化的字段,并同步触发重渲染; - onCreate / onRender / onDestroy等生命周期回调与 ViewModel/State 的更新通道共同构成了 Valdi 的声明式渲染循环;长任务必须在
onDestroy中清理,setTimeoutDisposable可自动完成这一职责。
掌握了 ViewModel 与 State 的配合,你的 Valdi 组件就具备了在生产环境中自主响应数据变化的能力。下一步可以继续阅读 6-component_lifecycle.md,深入理解组件生命周期各阶段的完整时序。
- 跨平台
- UI组件
- 前端
- 移动开发
【免费下载链接】Valdi
Valdi is a cross-platform UI framework that delivers native performance without sacrificing developer velocity.
相关推荐
Valdi 组件状态管理:单向数据流与 setState 深度实践指南
Valdi 组件状态管理:单向数据流与 setState 深度实践指南 导读 本文聚焦 Valdi 跨平台 UI 框架中的组件状态(Component Stat
跨平台UI组件前端移动开发如何快速在本地跑起离线语音识别:Vosk 从零上手完整指南
如何快速在本地跑起离线语音识别:Vosk 从零上手完整指南 Vosk 是一个开源的离线语音识别工具包,无需联网就能把 20 多种语言的音频转成文字,适合想把"语
人工智能语音Zag.js组件数据流管理:单向数据流与状态更新策略终极指南
Zag.js组件数据流管理:单向数据流与状态更新策略终极指南 Zag.js是一个基于有限状态机的UI组件库,采用严格的单向数据流设计模式。这个框架无关的库通过状
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考