(一)Valdi 的热重载解析

91 阅读10分钟

这是一个非常硬核且有趣的问题!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 HotReloadingProcessorCompilationProcessor {

    func process(itemsCompilationItemsthrows -> 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 热重载的“优雅”之处

  1. 全栈打通:不是简单的文件监听,而是编译器深度参与。

  2. 真·原生更新:它不仅能热更 JS 逻辑,还能热更 C++ 布局定义(.protodecl)和图片资源。

  3. 精确打击:利用 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 保证开发与生产一致性的秘诀在于“最少特例原则”:

  1. 不搞特殊代码:不为热重载注入特殊的 JS 垫片(Shim)或代理(Proxy)。

  2. 不搞特殊路径:运行时加载代码的路径永远是 Bundle -> getEntry,热重载只是在这个路径的起点(内存)做了手脚。

  3. 同源产物:传输的是生产级二进制,不是源码。

这种设计虽然实现难度大(需要手写编译器和 VFS),但彻底消除了“开发时好好的,上线就挂”的隐患。这也解释了为什么 Valdi 敢在生产环境大规模使用这套看似复杂的编译型 UI 框架。