【翻译】构建 Voltra:渲染器

0 阅读9分钟

原文链接:www.chmal.it/blog/buildi…

作者:Szymon Chmal

Voltra 是一个库,它允许 React Native 开发者使用标准 React 组件构建原生实时Activity和Widget——无需 Swift 或 Kotlin。

在底层实现中,Voltra 使用自定义渲染器在运行时将 React Native JSX 转换为 JSON。整个过程不涉及任何构建时魔法。其目标很明确:允许开发者在应用生命周期内动态定义和更新实时活动与小部件的布局。

Voltra 的初始版本依赖于现有渲染方案。随着项目演进,其需求逐渐超出该方案的设计范畴。最终我们意识到:沿用旧方案只会引入更多复杂性,而非解决问题。

本文将深入解析 JSX 的本质、React 的渲染机制、原始方案失效的原因,以及如何通过专为该场景定制的渲染器实现最简解决方案。

JSX究竟是什么?

你是否曾好奇过,当你用JSX描述元素层级结构时,底层究竟发生了什么?

我曾有过这样的疑问,于是深入研究了React的源代码。简而言之,JSX本质上是一种定义预定义形状对象的优雅方式。从技术角度看,你完全可以放弃JSX,直接使用React.createElement替代,效果依然完美。虽然可能损失部分类型安全,但最终效果几乎完全相同。

可以说JSX只是语法糖,让创建UI组件变得便捷。最终我们得到从主组件开始的大型树状结构,其中每个节点都代表一个UI元素及其属性与子节点。

<View style={{ padding: 20 }}>
  <Header title="Welcome" />
  <Text color="blue">
    Hello, Voltra!
  </Text>
</View>
{
  "type": "View",
  "props": {
    "children": [
      {
        "type": "Header",
        "props": {
          "title": "Welcome"
        }
      },
      {
        "type": "Text",
        "props": {
          "children": "Hello, Voltra!",
          "color": "blue"
        }
      }
    ],
    "style": {
      "padding": 20
    }
  }
}

渲染、协调与提交

JSX就位后,我们可以进入渲染阶段。众所周知,React允许定义自定义组件。初始创建的JSX树包含对这些自定义组件的引用,但尚未包含其子节点。它包含传递给组件的children属性值,但组件返回的内容尚未呈现。

此时便需要渲染过程介入。

在此过程中,React会遍历JSX树并处理每个节点。遇到自定义组件时,它会执行渲染操作——调用组件函数并将结果填入对应位置。遍历持续进行直至树中无节点剩余。最终,整棵树将完全由所谓的宿主组件构成——即平台原生组件。在Web环境中,这些组件对应divspan等HTML标签。

下一阶段是协调(reconciliation)。此时宿主组件树将转化为一系列操作指令,以使 UI 与描述保持一致。部分元素会被创建,部分被修改,部分则被移除。最终生成待执行的操作列表,使 UI 与 JSX 达到同步状态。

最后是提交阶段(commit phase),此时变更实际生效,UI 的新状态得以呈现。

react-reconciler 的作用

值得庆幸的是,我们通常无需手动实现协调功能。它并非 React 核心包的一部分,而是一个名为 react-reconciler 的独立包。react-domreact-native 以及许多其他渲染器都使用它将 JSX 转换为所需格式。

使用 react-reconciler 时,需提供"宿主配置"——本质上是一套指令集,用于定义特定事件发生时(如创建新元素或更新现有元素)的处理逻辑。遗憾的是该包的文档较为匮乏,但通过研究现有项目的源代码,理解起来并不困难。

Voltra 渲染器曾依赖 react-reconciler 实现核心功能。但在某个节点,我不得不将其废弃并从零重构。

服务器端渲染的问题

这正是 react-reconciler 方案失效之处。在服务器端场景中,我们只希望遍历 JSX 树一次

我们无需处理更新,不希望代码产生副作用,只求最终的描述结果。

听起来很简单,对吧?但 react-reconciler 并非为此而生的工具。它专为客户端场景设计,注重应用生命周期管理。我们无法安全地让它忽略效果钩子及其他钩子;若用户在 useEffect 中访问客户端全局变量,将引发异常导致服务器崩溃。

然而我们知道服务器端渲染(SSR)是可行的——react-dom 本身具备此能力。那么它如何实现?

事实证明,react-dom 有两个完全独立的实现:一个用于客户端,一个用于服务器端。客户端版本使用 react-reconciler,而服务器端版本则不使用。它实现了自己的渲染引擎,旨在一次性遍历层次结构,并以无状态的方式生成输出。

这正是Voltra所需的功能。Voltra无需持久化支持,组件不会与应用保持连接。实时活动(Live Activities)虽可实现连接,但其设计初衷并非由应用驱动——毕竟系统随时可能终止应用运行。我们需要一种方案,能直接将JSX转换为Voltra所需的格式。

因此我决定借鉴 react-dom 服务器的思路,编写定制化解决方案。

但为何不直接使用 react-dom/server?问题在于它仅设计为输出 HTML 字符串。而 Voltra 需要生成特定的 JSON 结构来驱动原生移动界面。虽然无法直接使用该工具,但其设计理念值得借鉴。

从零开始渲染

至此,你应该明白JSX不过是对象的树形结构。某些节点指向React组件,某些指向原始值(如字符串),某些是数组,还有些指向宿主组件。

我们的任务是遍历这棵树,并对每个节点做出处理决策:

  • 宿主组件:任务简单——直接处理数据。
  • 原始值:可视为文本节点。
  • 数组:递归遍历并处理每个元素。
  • React组件:现在该做些正事了。
function renderNode(node, context) {
  // 1. Handle Primitives (Strings/Numbers)
  if (typeof node === 'string' || typeof node === 'number') {
    return { type: 'text', value: String(node) };
  }

  // 2. Handle Arrays (Fragments or children lists)
  if (Array.isArray(node)) {
    return node.map(child => renderNode(child, context));
  }

  // 3. Handle React Components (Functions)
  if (typeof node.type === 'function') {
    // Call the component to get the underlying JSX
    const childJsx = node.type(node.props);
    return renderNode(childJsx, context);
  }

  // 4. Handle Host Components (div, span, etc.)
  return {
    type: 'element',
    tagName: node.type,
    props: node.props,
    children: renderNode(node.props.children, context)
  };
}

Voltra无需支持效果,也无需备忘录机制。但其中有一个钩子极其实用:useContext

设想一个允许用户更改界面主色调的应用。通常我们会使用 React Context 将主题对象注入所有需要它的组件,以避免“属性钻孔”。我们期望这种行为在实时活动和小部件中同样有效。要实现这一目标,我们需要自行实现 useContext

破解分发器

若深入研究 React 的实现机制,你会发现一个名为 Dispatcher 的特殊对象。

通常,从 React 导入的钩子函数并不包含任何实现逻辑。它们仅将控制权传递给当前的分发器。该分发器仅在 React 主动渲染组件时被设置;若尝试在组件外部使用钩子,会因缺乏可传递控制权的分发器而报错。

我们可以利用这个特性。在调用渲染函数之前,将分发器替换为自定义实现。当调用 useContext 时,由自定义逻辑处理;渲染完成后再恢复原分发器。

function renderWithHooks(component, props) {
  const reactDispatcher = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
  const prevHooksDispatcher = reactDispatcher.H;

  try {
    // 1. Swap Dispatcher
    // We will get to this part in a minute.
    reactDispatcher.H = getVoltraHooksDispatcher();

    // 2. Execute Component
    const result = component(props);

    // 3. Recurse
    return renderNode(result);
  } finally {
    // 4. Restore Dispatcher
    reactDispatcher.H = prevHooksDispatcher;
  }
}

上下文与存根处理

如何处理具体的 useContext 调用?

由于我们实现的是单遍递归渲染器,没有带父节点指针的树结构可向上遍历。取而代之的是,我们可以在向下遍历时维护上下文状态。

可维护栈的注册表——每个Context对象对应一个栈。

  • Provider:遇到Context.Provider时,在渲染子节点前将其值压入该上下文的栈中,渲染后弹出。
  • Consumer:调用useContext时,检查该上下文栈顶元素。若栈为空,则回退到默认值。 以下是我们在Voltra中实现注册表的方式:
export const getContextRegistry = () => {
  const contextMap = new Map();

  return {
    pushProvider: (context, value) => {
      const stack = contextMap.get(context) || [];
      stack.push(value);
      contextMap.set(context, stack);
    },
    popProvider: (context) => {
      const stack = contextMap.get(context);
      if (stack) stack.pop();
    },
    readContext: (context) => {
      const stack = contextMap.get(context);
      return stack && stack.length > 0 
        ? stack[stack.length - 1] 
        : context._currentValue;
    },
  };
};

就这样,useContext就完全可以正常工作了。

那么其他钩子呢?我们不希望效果被调用,因此将 useEffect 设置为“无操作”函数。对于状态管理,我们直接返回初始值与无操作函数的元组。至于 useMemo,只需调用工厂函数即可。通常我们会为所有钩子提供占位函数,确保应用在处理过程中不会崩溃。

export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatcher => ({
  useContext: <T>(context: Context<T>) => registry.readContext(context),
  useState: <S>(initial?: S | (() => S)) => [
    typeof initial === 'function' ? (initial as () => S)() : initial,
    () => {}, // No-op setter
  ],
  useReducer: <S, I, A extends React.AnyActionArg>(
    _: (prevState: S, ...args: A) => S,
    initialArg: I,
    init?: (i: I) => S
  ): [S, React.ActionDispatch<A>] => {
    const state = init ? init(initialArg) : initialArg
    return [state as S, () => {}]
  },
  // Direct pass-throughs
  useMemo: (factory) => factory(),
  useCallback: (cb) => cb,
  useRef: (initial) => ({ current: initial }),
  // No-ops for effects
  useEffect: () => {},
  useLayoutEffect: () => {},
  useInsertionEffect: () => {},
  useId: () => Math.random().toString(36).substr(2, 9),
  useDebugValue: () => {},
  useImperativeHandle: () => {},
  useDeferredValue: <T>(value: T) => value,
  useTransition: () => [false, (func: () => void) => func()],
  useSyncExternalStore: (_, getSnapshot) => {
    return getSnapshot()
  },
})

真的就这么简单吗?

现在你已经了解Voltra在后台如何实现React Native的实时Activity和Widget功能。

然而这远非Voltra渲染器的全部。我们处理了大量优化工作,让你无需操心。例如:我们会根据共享字典缩短属性名称进行处理,并通过引用替换重复样式和元素来消除冗余。

// Input JSX: <Text style={{ color: 'red' }}>Hello</Text>

// Output Voltra JSON (Simplified):
{
  "t": 1, // "t" maps to "Text" component in the dictionary
  "p": {  // "p" maps to "props"
    "s": 42 // "s" is a reference to the deduplicated style ID for { color: 'red' }
  },
  "c": "Hello" // "c" is "children"
}

但你今天经历的正是Voltra渲染器的核心,它很可能也是你未来几天构建的自定义渲染器的核心。

准备好打造自己的渲染器了吗?

这里的核心要义很简单:有些事物看起来远比实际复杂得多。勇于拆解底层结构、拼凑解决方案往往能带来意想不到的收获。

有时,最初的"权宜之计"反而能演变为优雅高效的代码。这正是我开发Rozenite、Harness和Voltra时秉持的理念——同样的思维方式,也将助你打造未来的任何作品。