这是一个非常硬核且有趣的问题!Valdi 的热重载(Hot Reload)不仅仅是简单的“刷新网页”,而是一套贯穿了 CLI工具、Swift编译器、C++运行时 和 JavaScript引擎 的完整工业级流水线。如果说 React Native 的热重载是“换件衣服”,那 Valdi 的热重载更像是“F1 赛车进站换胎”,而且是在赛车还在跑的时候换的。我们来详细拆解这个过程。整个流程大致可以分为 四步走:监听(Watch)、增量编译(Compile)、传输(Transport)、热更(Patch)。
1. 启动与监听 (The Eye)
一切始于 CLI,但核心的大脑在 Swift 编译器里。当你运行 valdi hotreload 时,CLI 工具(npm_modules/cli)并没有自己去写文件监听逻辑,而是通过 Bazel 找到并启动了一个编译器的“守护进程”模式。
-
入口: Compiler/Sources/ValdiCompilerRunner.swift
-
关键角色: AutoRecompiler
Valdi 并没有使用 webpack-dev-server 这种通用的 JS 方案,而是自己在 Swift 编译器里实现了一个 AutoRecompiler。当 hotReloadingEnabled 为真时,它会启动一个文件监听器(ValdiFilesFinder)。
// ValdiCompilerRunner.swift (伪代码摘要)
if hotReloadingEnabled {
// 编译器亲自下场监听文件
let autoRecompiler = AutoRecompiler(compiler: compiler, ...)
autoRecompiler.start()
// 此时,编译器进程不会退出,而是进入 RunLoop 等待
RunLoop.main.run()
}
老司机点评:这样做的好处是编译器完全掌握上下文。它不需要外部告诉它“文件变了,请重编”,而是“我看到了文件变化,我知道依赖关系,我只编译受影响的部分”。
2. 增量编译与拦截 (The Brain)
一旦检测到文件变化,编译器会触发 compiler.compile()。但这里有个骚操作:它利用了Pipeline(流水线)机制。
- 关键角色: HotReloadingProcessor.swift
在正常的编译流程中,产物会被写入磁盘。但在热重载模式下,有一个特殊的处理器 HotReloadingProcessor 会拦截编译产物。
//HotReloadingProcessor.swift
class HotReloadingProcessor: CompilationProcessor {
func process(items: CompilationItems) throws -> CompilationItems {
// 遍历所有编译好的文件 (.finalFile)
for item in items.allItems {
// 只有 .debug 模式才处理
if item.outputTarget.contains(.debug) {
// 拦截!不要只存硬盘,还要发给 DaemonService
let resource = Resource(item: item, finalFile: finalFile, data: data)
daemonService.resourcesDidChange(resources: [resource])
}
}
return items
}
}
这意味着:Valdi 的热重载是基于二进制/字节码层面的。它发给手机的不是 TypeScript 源码,而是编译好的 .valdimodule 片段、处理好的图片资源或者布局定义。
3. 传输通道 (The Pipe)
编译好的资源通过 socket 发送给连接的设备。
-
发送端: DaemonService.swift
-
它维护了一个 socket 服务器,等待真机(iOS/Android)连接。
-
一旦 HotReloadingProcessor 塞给它新资源,它会遍历所有连接的客户端,根据平台(iOS 还是 Android)过滤掉不需要的资源,然后推流。
4. 运行时热更 (The Magic)
这是最精彩的部分。C++ 运行时收到数据包后,需要“边开飞机边换引擎”。
- 接收端: Runtime.cpp 的 updateResources 方法。
这个 C++ 函数极其暴力且高效,它做了三件事:
A. 更新内存中的 Bundle
它直接修改内存中虚拟文件系统(Bundle)的内容。
Runtime.cpp
void Runtime::updateResources(const std::vector<Shared<Resource>>& resources) {
// ... 锁住 JS 线程 ...
for (const auto& resource : resources) {
// 如果是图片,更新图片缓存
if (path.hasSuffix(".png")) { _resourceManager->insertImageAssetInBundle(...); }
// 如果是布局定义,直接覆盖
else if (path.hasSuffix(".protodecl")) { bundle->setResourceContent(...); }
// 如果是 JS/字节码,覆盖旧代码
else { bundle->setEntry(path, resource->data); }
}
// ...
}
B. 卸载 JS 模块
这是通过 C++ 调用 JavaScript 层的 ModuleLoader 完成的。
// Runtime.cpp
_javaScriptRuntime->unloadModulesAndDependentModules(unloadedJsFiles, true);
在 JS 侧(src/valdi_modules/src/valdi/valdi_core/src/ModuleLoader.ts),unload 方法会简单粗暴地从 this.modules 缓存中删除对应的模块 ID。这样下一次 require 这个模块时,就会强制从 Bundle(刚才已经被 C++ 更新过了)里重新加载最新的代码。
C. 标记脏布局 (Dirty Layout)
最后,C++ 运行时会通知布局引擎:
onViewNodeTreeLayoutBecameDirty(viewNodeTree);
这会触发一次根节点的重新布局(Relayout)和重绘(Repaint)。由于 Valdi 是原生渲染,界面会瞬间刷新成新的样子。
Valdi 热重载的“优雅”之处
-
全栈打通:不是简单的文件监听,而是编译器深度参与。
-
真·原生更新:它不仅能热更 JS 逻辑,还能热更 C++ 布局定义(.protodecl)和图片资源。
-
精确打击:利用 Bazel 和 Swift 编译器的依赖分析,只编译和传输变化的文件,速度极快。
这套机制保证了你写 TypeScript 时,手机上的原生 View 能在毫秒级同步更新,体验非常丝滑。下次你看到屏幕一闪,别忘了是 Swift 编译器和 C++ 运行时在后台完成了一次精密的空中加油配合。😎。
Valdi热重载的粒度
这是一个关于“粒度”的绝佳问题。我们从三个维度来“切”一下 Valdi 的热重载:编译粒度、模块替换粒度 和 状态保持粒度。
1. 编译粒度:精准制导(File/Module Level)
Valdi 的编译器(AutoRecompiler.swift)是文件级(File-based)增量编译的。
-
机制:当一个文件(例如 MyComponent.tsx)发生变化时,编译器只重新编译该文件及其直接依赖链。它不会全量重编整个项目。
-
产物:编译器会生成一个更新后的 .valdimodule 或 .js 字节码片段。
-
传输:HotReloadingProcessor.swift 会拦截这些新生成的二进制片段,只将变化的部分通过 socket 发送给运行时。这与 Webpack HMR 发送 JS 补丁包类似,但 Valdi 是在编译后产物级别操作。
结论:编译和传输是文件/模块级的,非常高效。
2. 运行时替换粒度:强制模块重载(Module Level)
在运行时(Runtime)接收到更新后,粒度也是模块级的。
-
模块卸载:Runtime.cpp 会调用 ModuleLoader.ts 的 unload 方法。这个操作会将模块从 require 缓存中移除。
-
模块重载:虽然被卸载了,但并不会立即重新执行 JS。只有当代码再次执行 require('MyComponent') 时,才会加载新的代码。
-
触发机制:Runtime.cpp 接收到资源更新后,会通知 RootComponentsManager 或者直接通过 onViewNodeTreeLayoutBecameDirty 触发重新渲染,从而间接导致重新 require 新的组件代码。
3. 状态保持与 UI 更新粒度:页面级刷新(Page Level Refresh)
这里是关键! 也是和 React Native Fast Refresh 最大的区别所在。Valdi 默认的热重载行为倾向于“页面级/根组件级重绘”,通常不保持组件局部状态 (State),除非做了特殊处理。
- 根节点重置:在 RootComponentsManager.ts 中,我们看到了 hotReload(contextId) 方法:
// RootComponentsManager.ts
private hotReload(contextId: string) {
// 1. 获取根组件句柄
const handle = this.rootComponents[contextId];
// 2. 使用新的 renderFunction (会加载新代码)
handle.renderFunction = this.makeRenderFunction(handle.componentPath);
// 3. 强制重新渲染整个根节点
this.renderRoot(handle, true);
}
-
状态丢失:renderRoot 实际上是重新运行了组件的构造和渲染流程。对于大多数组件来说,内存中的 this.state 会因为组件实例的销毁和重建而丢失。这更接近于 Web 开发中的 Live Reload(刷新页面),而不是 Hot Module Replacement(保留状态替换)。
-
C++ 层的暴力:Runtime.cpp 中的 onViewNodeTreeLayoutBecameDirty 更是直接标记整个视图树脏了,导致全量重排。
Valdi 的热重载粒度如下:
| 维度 | 粒度 | 说明 |
|---|---|---|
| 监听 | 文件级 | 编译器监听文件系统事件 |
| 编译 | 文件/模块级 | 仅编译变更文件及其依赖 |
| 传输 | 文件/模块级 | Socket 仅传输变更的二进制资源 |
| JS 执行 | 模块级 | 卸载旧模块,下次 require 时加载新模块 |
| UI 刷新 | 根组件/页面级 | 通常会重建组件树,丢失组件局部状态 (Local State) |
一句话总结:Valdi 实现了“模块级的代码热更,页面级的 UI 重绘”。虽然传输和替换代码很精细,但为了保证原生渲染的一致性,它选择重绘整个组件树,这使得开发体验上通过快速刷新来查看 UI 变化,但往往无法像 React 那样保留表单里输入了一半的文字。
开发环境生产环境的一致性保证
这是热重载系统中最容易翻车的地方——“在开发环境跑得好好的,一上线就崩了”。Valdi 通过一套非常严谨的“双轨制但同源”策略来保证一致性。简单来说:开发环境是在“魔改”生产环境的加载机制,而不是另起炉灶。我们从三个核心层面来剖析这种一致性是如何保证的:
1. 编译源头的一致性:同一个编译器,同一条流水线
首先,无论是开发环境的 valdi hotreload 还是生产环境的 bazel build,它们调用的全是同一个 Swift 编译器内核,走的是同一条 CompilationPipeline。证据:ValdiCompilerRunner.swift 中,热重载逻辑只是在原有编译流程外包裹了一个 AutoRecompiler。
一致性保证:
-
产物一致:热重载发送给手机的二进制片段(.finalFile),和生产环境打包进 APK/IPA 的二进制文件是完全一样的。
-
处理拦截:HotReloadingProcessor.swift 并没有修改编译产物,它只是作为一个观察者(Observer),在产物生成后,拦截一份拷贝通过 Socket 发送出去。原有的保存到磁盘的逻辑(SaveFilesProcessor.swift)依然可以照常运行(尽管在热重载模式下为了性能可能会被跳过)。
-
结论:因为是用同一个编译器生成的同一个二进制格式,所以不存在“开发环境运行的是源码/解释型代码,生产环境运行的是编译型代码”这种差异。
2. 运行时加载的一致性:虚拟文件系统 (VFS) 抽象
这是 Valdi 最精妙的设计。C++ 运行时并不直接读取磁盘文件,而是通过一个 Bundle 抽象层来访问资源。 生产环境:
-
App 启动时,ResourceManager.cpp 会从磁盘(APK assets 或 Bundle 目录)加载一个压缩包(Archive)。
-
这个压缩包被映射到内存中的 Bundle 对象。
-
JS 引擎通过 Bundle->getJs("MyComponent.js") 获取代码。
开发环境:
-
Runtime.cpp 的 updateResources 接收到 Socket 数据后,直接调用 bundle->setEntry(...)。
-
关键点:这个操作直接修改了内存中的 Bundle 对象,覆盖了从磁盘读取的内容。
-
对于 JS 引擎来说:它根本不知道代码是从磁盘读的,还是被热重载修改过的。它只管找 Bundle 要数据。
//Bundle.cppLine 219
// 生产环境和热重载环境最终都走这里
Result<BundleResourceContent> Bundle::getResourceContent(const StringBox& path) {
// 1. 先查内存覆盖层(热重载写入的数据)
auto it = _resourceContentByPath.find(path);
if (it != _resourceContentByPath.end()) { return it->second; }
// 2. 再查原始归档层(生产环境的磁盘数据)
auto entryResult = getEntry(path);
// ...
}
这种“内存 Overlay”机制保证了上层业务逻辑(JS 虚拟机、布局引擎)完全感知不到热重载的存在,从而保证了行为的一致性。
3. 模块加载的一致性:标准的 CommonJS 语义
即使在热重载时,模块的加载依然遵循严格的模块系统规范。
-
卸载而非替换:Valdi 并没有去 hack JS 引擎的运行栈。它做的是标准的 unload 操作——把模块从 require.cache 中删掉。
-
重载:当代码再次执行 require 时,走的是标准的加载流程。
-
一致性保证:这意味着初始化逻辑、副作用(Side Effects)、依赖解析顺序在热重载后和冷启动时是完全一致的。如果你的代码在热重载时因为模块副作用导致 bug,那么在生产环境多次加载该模块时也一定复现。
一致性是如何炼成的?
Valdi 保证开发与生产一致性的秘诀在于“最少特例原则”:
-
不搞特殊代码:不为热重载注入特殊的 JS 垫片(Shim)或代理(Proxy)。
-
不搞特殊路径:运行时加载代码的路径永远是 Bundle -> getEntry,热重载只是在这个路径的起点(内存)做了手脚。
-
同源产物:传输的是生产级二进制,不是源码。
这种设计虽然实现难度大(需要手写编译器和 VFS),但彻底消除了“开发时好好的,上线就挂”的隐患。这也解释了为什么 Valdi 敢在生产环境大规模使用这套看似复杂的编译型 UI 框架。