- 跨平台
- UI组件
- 前端
- 移动开发
【免费下载链接】Valdi
Valdi is a cross-platform UI framework that delivers native performance without sacrificing developer velocity.
导读
Valdi 是一个跨平台 UI 框架,其"Polyglot(多语言)模块"机制允许开发者在一个模块内同时维护 Valdi TSX 组件与各平台的原生实现:Android(Kotlin/Java)、iOS(Objective-C)、macOS(AppKit)以及浏览器端 Web(TypeScript)。本文以 ai-skills/skills/valdi-polyglot-module/skill.md 为核心,完整讲解 Polyglot 模块的目录结构、BUILD.bazel接线模式、Web 入口的自动注册机制、macOS 原生视图的属性回调桥接,并结合仓库源码剖析底层实现。读完本文,你将能够:搭建一个四端共享的 Polyglot 模块、用ts_project正确编译 Web 实现、让webPolyglotViews导出自动注册到WebViewClassRegistry,以及在 macOS 上用SCValdiMacOSFunction把 AppKit 事件回调给 TSX 组件。
一、Polyglot 模块的目录结构与职责
Polyglot 模块与普通 Valdi 模块的区别在于:它在 Valdi TSX 源码之外,还携带多个平台的"原生实现目录"。文档给出的标准结构如下:
my_module/ src/ # Valdi TSX components (compiled by Valdi compiler) android/ # Kotlin/Java native implementation ios/ # Objective-C native implementation macos/ # Objective-C native implementation (macOS desktop) web/ # TypeScript web implementation (compiled by ts_project, NOT Valdi compiler) strings/ # Localization module.yaml tsconfig.json BUILD.bazel各目录的核心职责:
| 目录 | 实现语言 | 编译方式 | 说明 |
|---|---|---|---|
src/ | TS / TSX | Valdi 编译器 | 模块的 UI 主体,所有平台共享的组件与业务逻辑 |
android/ | Kotlin / Java | kt_android_library | Android 原生视图实现 |
ios/ | Objective-C | objc_library | iOS 原生视图实现 |
macos/ | Objective-C | objc_library(AppKit) | macOS 桌面端原生视图实现 |
web/ | TypeScript | ts_project(tsc) | 浏览器端实现,不使用Valdi 编译器 |
strings/ | JSON(strings-*.json) | Valdi 编译流程 | 本地化字符串资源,通过valdi_module的strings_dir参数接入 |
关键分界点是web/:它运行在浏览器而非 Valdi runtime 中,因此必须走标准 TypeScript 工具链(ts_project+tsc),而src/则交给 Valdi 编译器生成各平台产物。这条分界贯穿整篇指南。
二、BUILD.bazel 模式:把四端实现接入 valdi_module
文档给出的BUILD.bazel模板是 Polyglot 模块的接线核心,其中web_deps必须是ts_project,永远不要对 Web 代码使用filegroup:
load("@aspect_rules_ts//ts:defs.bzl", "ts_project") # web_deps MUST be a ts_project — never use filegroup for web code. ts_project( name = "my_module_web", srcs = glob( [ "web/**/*.ts", "src/**/*.d.ts", # include if web code imports module type declarations ], exclude = ["web/**/*.d.ts"], # exclude to avoid TS5055 output collision with composite ), allow_js = True, composite = True, transpiler = "tsc", # required — aspect_rules_ts does not default it tsconfig = "web/tsconfig.json", visibility = ["//visibility:public"], ) valdi_module( name = "my_module", srcs = glob(["src/**/*.ts", "src/**/*.tsx"]) + ["tsconfig.json"], android_deps = [":android_impl"], ios_deps = [":ios_impl"], macos_deps = [":macos_impl"], web_deps = [":my_module_web"], deps = [...], )各*_deps参数在构建系统中如何被消费
从源码看,valdi_module宏(bzl/valdi/valdi_module.bzl)正是按平台把这些依赖"接线"到对应产物上的:
android_deps:在_setup_android_target中通过exports = [":{}_api_kt".format(name)] + android_deps(valdi_module.bzl)把原生实现随{name}_kt一起导出,保证 Android 侧能拿到原生类;ios_deps:在_setup_ios_target中拼入deps = objc_deps + [... ] + ios_deps + [...](valdi_module.bzl),成为{name}_objc的依赖;macos_deps:在_setup_native_target中通过select按平台条件挂载(valdi_module.bzl),即"Obj-C/C++ deps for the module's _desktop native target on macOS only(例如 NSOpenPanel),在 Linux 上被忽略"(宏参数 doc,见 valdi_module.bzl);web_deps:传给valdi_compiled(valdi_module.bzl),再由_setup_web_target把传递性的 web 产物、资源、字符串、protodecl 收拢成{name}_web_srcs_filegroup等目标(valdi_module.bzl),最终进入 Web bundle。
自动注册:web_deps 与 WebViewClassRegistry
web_deps的一个关键特性是:web_deps中任何导出了webPolyglotViews的文件,都会在打包时自动注册到WebViewClassRegistry,无需额外配置。注册机制的实现见 src/valdi_modules/src/valdi/web_renderer/src/WebPolyglotRegistry.ts,其中registerWebPolyglotViewClass会调用addRegistrationCallback把类名→工厂的映射挂到共享注册表上。
修改 module.yaml 后重新生成 BUILD
文档强调:修改module.yaml的 deps 之后,需要重新生成模块的 BUILD.bazel 文件,对应命令为./scripts/regenerate_valdi_modules_build_bazel_files.sh(该命令出自上游工程脚手架;若你的工程根目录未内置此脚本,需以自身工程提供的生成流程为准)。另外,对于手工维护 BUILD 文件的模块,必须在module.yaml中设置bazel_build_file_generation_disabled: true,否则重新生成脚本会覆盖手工 BUILD 文件并丢掉macos_deps/ios_deps。
三、Web Polyglot 入口:webPolyglotViews 与自动注册
入口文件范式
Web 端入口文件导出一组"视图工厂"(view factories),打包时自动注册到WebViewClassRegistry。文档要求使用带AttributeHandler接口的强类型 TypeScript:
interface AttributeHandler { changeAttribute(name: string, value: unknown): void; } type ViewFactory = (container: HTMLElement) => AttributeHandler; function createMyViewFactory(): ViewFactory { return (container: HTMLElement): AttributeHandler => { const element = document.createElement('div'); container.appendChild(element); return { changeAttribute(name: string, value: unknown): void { if (name === 'myAttribute' && typeof value === 'number') { element.textContent = String(value); } }, }; }; } // Keys must match the webClass attribute in <custom-view webClass="MyCustomViewClass"> export const webPolyglotViews: Record<string, ViewFactory> = { MyCustomViewClass: createMyViewFactory(), };两个要点:
- 键名必须与 TSX 中
<custom-view webClass="...">的属性值完全一致。渲染器正是以webClass的值为 key 去注册表中查工厂; - 工厂返回的 handler 中的
changeAttribute(name, value)负责接收后续属性更新,这与原生平台"属性绑定"的语义对齐。
源码级原理:注册表与渲染器如何协作
仓库中的实现印证了文档描述(见 src/valdi_modules/src/valdi/web_renderer/src/WebViewClassRegistry.ts):
- 注册表与待执行回调都存放在
globalThis上(__valdiWebViewClassRegistry与__valdiWebViewClassRegistryCallbacks,见 WebViewClassRegistry.ts),目的是"所有 chunk 共享同一个注册表"; addRegistrationCallback(WebViewClassRegistry.ts)在注册表已存在时立即执行回调,否则把回调压入 pending 队列;getRegistry(WebViewClassRegistry.ts)在首次被调用(例如ValdiWebRendererDelegate构造时)才创建注册表并一次性 flush 所有 pending 回调——这保证了模块代码无论以何种加载顺序执行,都能在渲染器真正查询之前完成注册。
渲染侧的WebValdiCustomView(src/valdi_modules/src/valdi/web_renderer/src/views/WebValdiCustomView.ts)处理<custom-view>的webClass属性:
- 收到
webClass后通过getWebViewClassFactory(attributeValue)查表并调用工厂填充 DOM(WebValdiCustomView.ts); androidClass/iosClass/macosClass在 Web 渲染器中被直接忽略(WebValdiCustomView.ts),这正是"一份 TSX、按平台分发到不同实现"的落点;- 若
webClass尚未到达时其他属性先到,会被缓冲进_pendingAttributes,待工厂执行后统一 flush(WebValdiCustomView.ts); - 查表失败时渲染一个占位符,并保证容器至少 80px 的最小高度(
minHeight = '80px'),避免 0 尺寸容器导致布局塌陷。
四、Web 端 tsconfig.json 与 web/ 目录约束
web/tsconfig.json必须是独立配置,不要 extend 模块级 tsconfig(后者带有 Valdi 的路径映射,不适用于 Web 构建)。文档给出的标准配置:
{ "compilerOptions": { "target": "ES2016", "module": "commonjs", "strict": true, "composite": true, "allowJs": true, "lib": ["dom", "ES2019"] } }web/目录文件的硬性约束(务必逐条核对):
- 由
tsc(经ts_project)编译,而不是 Valdi 编译器; - 不要使用 Valdi 导入(
valdi_core/、valdi_tsx/)——Web 文件运行在浏览器里,没有 Valdi runtime; - 不要手动调用
globalThis.moduleLoader.resolveRequire()——构建系统已处理注册,手动调用会破坏加载流程; web/tsconfig.json必须独立,且带lib: ["dom", "ES2019"]与module: "commonjs";ts_project规则中始终显式指定transpiler = "tsc"——aspect_rules_ts 不会默认选择 transpiler;srcsglob 中纳入src/**/*.d.ts(当 Web 代码导入模块类型声明时)并用exclude = ["web/**/*.d.ts"]排除,以避免 composite 模式下的 TS5055 输出冲突。
五、macOS 原生视图与属性绑定:把 AppKit 事件桥接回 TSX
macOS 的<custom-view>实现可以通过SCValdiMacOSFunction从 TSX 接收回调属性——这是把 AppKit 视图事件(例如键盘输入)桥接回 Valdi 组件的标准途径。其底层机制由两个类提供(源码见 valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.h 与 valdi/src/valdi/macos/SCValdiMacOSFunction.h):
SCValdiMacOSAttributesBinder:负责把 TSX 属性名绑定到原生 selector,提供bindUntypedAttribute:invalidateLayoutOnChange:selector:、bindColorAttribute:...、bindAccessibilityAttributes等 API;SCValdiMacOSFunction:封装指向 Valdi/JS 函数的桥接对象,核心方法为performWithParameters:与performWithParametersAndReturnValue:(支持返回值),其SCValdiMacOSFunctionBlock类型签名是(NSArray<id>* parameters) -> id(见 SCValdiMacOSFunction.h)。
原生侧(Objective-C)
// macos/SCMyView.h #import <AppKit/AppKit.h> @class SCValdiMacOSAttributesBinder; @interface SCMyView : NSView + (void)bindAttributes:(SCValdiMacOSAttributesBinder *)attributesBinder; @end // macos/SCMyView.m #import "SCMyView.h" #import "valdi/macos/SCValdiMacOSAttributesBinder.h" #import "valdi/macos/SCValdiMacOSFunction.h" @interface SCMyView () { SCValdiMacOSFunction *_onEvent; } @end @implementation SCMyView - (void)valdi_setOnEvent:(id)value { _onEvent = value; } // Call the Valdi callback with a dictionary parameter: // [_onEvent performWithParameters:@[@{@"key": @"ArrowUp"}]]; + (void)bindAttributes:(SCValdiMacOSAttributesBinder *)attributesBinder { [attributesBinder bindUntypedAttribute:@"onEvent" invalidateLayoutOnChange:NO selector:@selector(valdi_setOnEvent:)]; } @endTSX 侧
<custom-view macosClass='SCMyView' onEvent={this.handleEvent} width={200} height={200} > {/* child elements render inside the custom-view */} </custom-view>BUILD.bazel
objc_library( name = "macos_impl", srcs = glob(["macos/**/*.m"]), hdrs = glob(["macos/**/*.h"]), copts = ["-I."], sdk_frameworks = ["AppKit"], visibility = ["//visibility:public"], deps = ["@valdi//valdi:valdi_macos_desktop_lib"], )关键行为约定
bindUntypedAttribute:中的属性名必须与 TSX 属性名完全一致(例如"onEvent"↔onEvent={...});- 回调值以
SCValdiMacOSFunction对象到达——调用[callback performWithParameters:@[...]]即可调用 Valdi/JS 函数; - 参数以
NSArray传入,元素可为NSDictionary/NSString/NSNumber,会被自动转换为 JS 对象; - custom-view 必须具有非零尺寸才能参与 responder chain(键盘焦点等依赖于此);
- 键盘输入处理:覆写
acceptsFirstResponder返回YES、实现keyDown:,并在viewDidMoveToWindow中调用[self.window makeFirstResponder:self]; - 手工维护 BUILD 文件时,在
module.yaml中设置bazel_build_file_generation_disabled: true,防止重新生成脚本覆盖手工内容。
相关底层实现可继续阅读 valdi/src/valdi/macos/SCValdiMacOSFunction.mm、valdi/src/valdi/macos/SCValdiMacOSAttributesBinder.mm 与 valdi/src/valdi/macos/SCValdiMacOSViewManager.mm;测试可参考 valdi/test/macos/SCValdiMacOSViewManagerTests.mm。iOS / Android 侧<custom-view>的通用语义可参见 docs/docs/native-customviews.md 与 ai-skills/skills/valdi-custom-view/skill.md。
六、常见错误与排查清单
- 把
web/文件放进srcs:Web 代码必须走ts_project+web_deps,不能交给 Valdi 编译器; - 遗漏平台
_deps:每个平台实现都要通过android_deps、ios_deps、macos_deps接线,漏接会导致该平台产物缺失; - 在
web/中使用 Valdi 导入:valdi_core/、valdi_tsx/在浏览器 bundle 中不存在; - 创建 0×0 的 custom-view 用于键盘输入:macOS 不会授予其 first responder 状态——应在内部包裹可见内容,保证非零尺寸;
- 忘记在
module.yaml中设置bazel_build_file_generation_disabled: true:重新生成脚本会覆盖手工维护的 BUILD 文件,丢掉macos_deps/ios_deps。
七、速查:一个 Polyglot 模块的完整落地路径
- 按第一节结构创建
src/、android/、ios/、macos/、web/、strings/目录及module.yaml、tsconfig.json; - 在
web/编写入口文件,导出webPolyglotViews(键名与webClass对齐),并放置独立的web/tsconfig.json; - 编写
BUILD.bazel:ts_project产出_web目标,valdi_module用四个*_deps接入各平台实现; - 在
src/的 TSX 中使用<custom-view webClass="..." androidClass="..." iosClass="..." macosClass="...">分发到各平台; - macOS 原生侧用
SCValdiMacOSAttributesBinder绑定属性、用SCValdiMacOSFunction回传事件; - 修改
module.yaml后重新生成 BUILD 文件;若手工维护 BUILD,务必设置bazel_build_file_generation_disabled: true。
以上流程覆盖了文档定义的全部规则与源码中的实现细节,你可以对照仓库中的valdi_module宏定义与WebViewClassRegistry实现逐项验证。
- 跨平台
- UI组件
- 前端
- 移动开发
【免费下载链接】Valdi
Valdi is a cross-platform UI framework that delivers native performance without sacrificing developer velocity.
相关推荐
ESP32S3接ML307上4G:xiaozhi-esp32模块接线与注册实战
ESP32S3接ML307上4G:xiaozhi esp32模块接线与注册实战 拿到ML307 Cat.1模块的头30分钟,我做对了一件事,也做错了一件事:做对
跨平台UI组件前端移动开发GameHub定制化设置:控制器配置、主题切换和界面优化的10个技巧
GameHub定制化设置:控制器配置、主题切换和界面优化的10个技巧 GameHub是一款功能强大的游戏聚合管理工具,能够将来自不同平台的游戏统一管理在一个界面
桌面应用如何用 GoHTTPServer 实现文件上传和下载功能:完整配置指南
如何用 GoHTTPServer 实现文件上传和下载功能:完整配置指南 GoHTTPServer 是一个功能强大的 HTTP 静态文件服务器,基于 Golang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考