在 Expo 原生应用中使用 React DOM

编辑

了解如何通过 'use dom' 指令在 Expo 原生应用中渲染 React DOM 组件。


可用于 SDK 52 及更高版本

Expo 提供了一种新颖的方法,可以通过 'use dom' 指令直接在原生应用中使用现代网页代码。这使得可以通过逐个组件的方式将整个网站逐步迁移到通用应用程序中。

虽然 Expo 原生运行时通常不支持像 <div><img> 这样的元素,但可能会有需要快速整合网页组件的情况。在这种情况下,DOM 组件提供了一种有用的解决方案。

前提条件

您的项目必须使用 Expo CLI 并扩展 Expo Metro Config

如果您已经使用 npx expo [command] 运行项目(例如,如果您是通过 npx create-expo-app 创建的),那么您已经准备就绪,可以跳过此步骤。

如果您的项目中还没有 expo 包,请通过运行下面的命令安装它,并 选择使用 Expo CLI 和 Metro Config:

Terminal
npx install-expo-modules@latest

如果命令失败,请参阅 安装 Expo 模块 指南。

Expo Metro Runtime, React DOM, 和 React Native Web

如果您正在使用 Expo Router 和 Expo Web,您可以跳过此步骤。否则,请安装以下软件包:

Terminal
npx expo install @expo/metro-runtime react-dom react-native-web

使用方法

在您的项目中安装 react-native-webview

Terminal
npx expo install react-native-webview

要将 React 组件渲染到 DOM,请在网页组件文件的顶部添加 'use dom' 指令:

my-component.tsx (web)
'use dom'; export default function DOMComponent({ name }: { name: string }) { return ( <div> <h1>Hello, {name}</h1> </div> ); }

在原生组件文件中,导入网页组件以使用它:

App.tsx (native)
import DOMComponent from './my-component.tsx'; export default function App() { return ( // 这是一个 DOM 组件。它在后台重新导出一个被包裹的 `react-native-webview`。 <DOMComponent name="Europa" /> ); }

特征

  • 跨网页、原生和 DOM 组件的共享捆绑器配置。
  • DOM 组件中启用 React、TypeScript、CSS 和所有其他 Metro 特性。
  • 在终端和 Safari/Chrome 调试中日志记录。
  • 快速刷新和 HMR。
  • 用于离线支持的嵌入式导出。
  • 资产在网页和原生之间统一。
  • DOM 组件捆绑可以在 Expo Atlas 中进行检查以进行调试。
  • 访问所有网页功能而无需进行原生重建。
  • 开发中的运行时错误覆盖。
  • 支持 Expo Go。

WebView 属性

要将属性传递给底层原生 WebView,请在组件上使用 dom 属性。这个属性内置于每个 DOM 组件中,并接受一个包含任意 WebView 属性 的对象,以便您进行更改。

App.tsx (native)
import DOMComponent from './my-component'; export default function App() { return ( <DOMComponent dom={{ scrollEnabled: false, }} /> ); }

在您的 DOM 组件中,添加 dom 属性以在 TypeScript 中识别:

my-component.tsx (web)
'use dom'; export default function DOMComponent({}: { dom: import('expo/dom').DOMProps }) { return ( <div> <h1>Hello, world!</h1> </div> ); }

可序列化的属性

您可以通过可序列化的属性(numberstringbooleannullundefinedArrayObject)将数据发送到 DOM 组件。例如,在原生组件文件中,您可以将属性传递给 DOM 组件:

App.tsx (native)
import DOMComponent from './my-component'; export default function App() { return <DOMComponent hello={'world'} />; }

在网页组件文件中,您可以如下接收属性:

my-component.tsx (web)
'use dom'; export default function DOMComponent({ hello }: { hello: string }) { return <p>Hello, {hello}</p>; }

属性通过异步桥传送,因此不会同步更新。它们作为属性传递给 React 根组件,这意味着它们会重新渲染整个 React 树。

原生操作

您可以通过将异步函数作为顶层属性传递给 DOM 组件,将类型安全的原生函数发送到 DOM 组件:

App.tsx (native)
import DomComponent from './my-component'; export default function App() { return ( <DomComponent hello={(data: string) => { console.log('Hello', data); }} /> ); }
my-component.tsx (web)
'use dom'; export default function MyComponent({ hello }: { hello: (data: string) => Promise<void> }) { return <p onClick={() => hello('world')}>Click me</p>; }

您不能将函数作为嵌套属性传递给 DOM 组件。它们必须是顶层属性。

原生操作始终是异步的,并且只接受可序列化的参数(意思是没有函数),因为数据通过桥发送到 DOM 组件的 JavaScript 引擎。

原生操作可以将可序列化的数据返回给 DOM 组件,这对于从原生侧获取数据非常有用。

getDeviceName(): Promise<string> { return DeviceInfo.getDeviceName(); }

可以将这些函数视为 React 服务器函数,但它们不是驻留在服务器上,而是本地存在于原生应用中,并与 DOM 组件进行通信。这种方法提供了一种将真正原生功能添加到您的 DOM 组件的强大方式。

传递 refs

这是实验性的,未来可能会更改。

您可以在 DOM 组件中使用 useDOMImperativeHandle 钩子,以接受来自原生侧的 ref 调用。这个钩子类似于 React 的 useImperativeHandle 钩子,但不需要传递 ref 对象。

App.tsx (native)
import { useRef } from 'react'; import { Button, View } from 'react-native'; import MyComponent, { type DOMRef } from './my-component'; export default function App() { const ref = useRef<DOMRef>(null); return ( <View style={{ flex: 1 }}> <MyComponent ref={ref} /> <Button title="focus" onPress={() => { ref.current?.focus(); }} /> </View> ); }

Expo SDK 53 及更高版本使用 React 19。这意味着 ref 属性作为属性传递给组件,您可以直接在组件中使用它。

my-component.tsx (web)
'use dom'; import { useDOMImperativeHandle, type DOMImperativeFactory } from 'expo/dom'; import { Ref, useRef } from 'react'; export interface DOMRef extends DOMImperativeFactory { focus: () => void; } export default function MyComponent(props: { ref: Ref<DOMRef>; dom?: import('expo/dom').DOMProps; }) { const inputRef = useRef<HTMLInputElement>(null); useDOMImperativeHandle( props.ref, () => ({ focus: () => { inputRef.current?.focus(); }, }), [] ); return <input ref={inputRef} />; }

在 Expo SDK 52 及更早版本(React 18)中,使用传统的 forwardRef 函数来访问 ref 句柄。

my-component.tsx (web)
'use dom'; import { useDOMImperativeHandle, type DOMImperativeFactory } from 'expo/dom'; import { forwardRef, useRef } from 'react'; export interface MyRef extends DOMImperativeFactory { focus: () => void; } export default forwardRef<MyRef, object>(function MyComponent(props, ref) { const inputRef = useRef<HTMLInputElement>(null); useDOMImperativeHandle( ref, () => ({ focus: () => { inputRef.current?.focus(); }, }), [] ); return <input ref={inputRef} />; });

React 的数据流是单向的,因此使用回调向上树返回的概念不是符合惯例的。预计行为可能不稳定,并可能在将来的新版本的 React 中逐步淘汰。向上发送数据的首选方式是使用原生操作,它们更新状态,然后将状态传递回 DOM 组件。

功能检测

由于 DOM 组件用于运行网站,您可能需要额外的限定符以更好地支持某些库。您可以用以下代码检测组件是否在 DOM 组件中运行:

import { IS_DOM } from 'expo/dom';

虽然 process.env.EXPO_OS 在 DOM 组件中始终为 web,但您可以通过 process.env.EXPO_DOM_HOST_OS 检测 顶层 平台。它将是 iosandroid,具体取决于最顶层的原生平台的操作系统,而在 web 上为 undefined

公共资产

警告: 这是实验性的,将来可能会更改。公共资产在 EAS 更新中不受支持。请使用 require() 加载本地资产。

public 目录的内容复制到原生应用的二进制文件中,以支持在 DOM 组件中使用公共资产。由于这些公共资产将从本地文件系统提供,使用 process.env.EXPO_BASE_URL 前缀引用正确的路径。例如:

<img src={`${process.env.EXPO_BASE_URL}img.png`} />

调试

默认情况下,所有 console.log 方法在 WebView 中被扩展,以将日志转发到终端。这使得快速查看您的 DOM 组件中发生了什么变得简单且方便。

Expo 还在开发模式下捆绑时启用 WebView 检查和调试。您可以打开 Safari > Develop > Simulator > MyComponent.tsx 来查看 WebView 的控制台并检查元素。

手动 WebViews

您可以使用来自 react-native-webviewWebView 组件创建手动 WebView。这对于从远程服务器渲染网站可能很有用。

App.tsx (native)
import { WebView } from 'react-native-webview'; export default function App() { return <WebView source={{ html: '<h1>Hello, world!</h1>' }} />; }

路由

Expo Router API,如 <Link />useRouter 可以在 DOM 组件中用于在路由之间导航。

my-component.tsx (web)
'use dom'; import Link from 'expo-router/link'; export default function DOMComponent() { return ( <div> <h1>Hello, world!</h1> <Link href="/about">About</Link> </div> ); }

useLocalSearchParams()useGlobalSearchParams()usePathname()useSegments()useRootNavigation()useRootNavigationState() 等同步返回路由信息的 API 不会自动支持。相反,您需要在 DOM 组件外部读取这些值并将其作为属性提供。

App.tsx (native)
import DOMComponent from './my-component'; import { usePathname } from 'expo-router'; export default function App() { const pathname = usePathname(); return <DOMComponent pathname={pathname} />; }

router.canGoBack()router.canDismiss() 函数也不受支持,需要手动序列化,这样可以确保不会触发多余的渲染周期。

避免使用标准网页 <a /> 锚元素进行导航,因为这将以用户可能无法返回的方式更改 DOM 组件的源。如果您想呈现外部网站,建议直接启动 WebBrowser

由于 DOM 组件无法呈现原生子组件,因此布局路由 (_layout) 永远不能是 DOM 组件。您可以从布局路由中渲染 DOM 组件,以创建标题、背景等,但是布局路由本身应该始终是原生的。

测量 DOM 组件

您可能希望测量 DOM 组件的大小并报告回原生侧(例如,原生滚动)。可以使用 matchContents 属性或手动原生操作来实现这一点:

自动使用 matchContents 属性

您可以使用 dom={{ matchContents: true }} 属性自动测量 DOM 组件的大小并调整原生视图的大小。这在某些布局中特别有用,在这些布局中,DOM 组件必须具有内在大小才能显示,例如当组件在父视图中居中时:

App.tsx (native)
import DOMComponent from './my-component'; export default function Route() { return <DOMComponent dom={{ matchContents: true }} />; }

手动指定大小

您还可以通过将大小传递给 WebViewstyle 属性,手动提供大小:

App.tsx (native)
import DOMComponent from './my-component'; export default function Route() { return ( <DOMComponent dom={{ style: { width, height }, }} /> ); }

观察大小变化

如果您希望将 DOM 组件的大小变化报告回原生侧,您可以在 DOM 组件中添加一个原生操作,该操作在大小发生变化时调用:

my-component.tsx (web)
'use dom'; import { useEffect } from 'react'; function useSize(callback: (size: { width: number; height: number }) => void) { useEffect(() => { // 观察窗口大小变化 const observer = new ResizeObserver(entries => { for (const entry of entries) { const { width, height } = entry.contentRect; callback({ width, height }); } }); observer.observe(document.body); callback({ width: document.body.clientWidth, height: document.body.clientHeight, }); return () => { observer.disconnect(); }; }, [callback]); } export default function DOMComponent({ onDOMLayout, }: { dom?: import('expo/dom').DOMProps; onDOMLayout: (size: { width: number; height: number }) => void; }) { useSize(onDOMLayout); return <div style={{ width: 500, height: 500, background: 'blue' }} />; }

然后更新您的原生代码,以便在 DOM 组件报告大小变化时在状态中设置大小:

App.tsx (native)
import DOMComponent from '@/components/my-component'; import { useState } from 'react'; import { View, ScrollView } from 'react-native'; export default function App() { const [containerSize, setContainerSize] = useState<{ width: number; height: number; } | null>(null); return ( <View style={{ flex: 1 }}> <ScrollView> <DOMComponent onDOMLayout={async ({ width, height }) => { if (containerSize?.width !== width || containerSize?.height !== height) { setContainerSize({ width, height }); } }} dom={{ containerStyle: containerSize != null ? { width: containerSize.width, height: containerSize.height } : null, }} /> </ScrollView> </View> ); }

体系结构

内置的 DOM 支持仅以单页面应用程序的形式呈现网站(不支持 SSR 或 SSG)。这是因为搜索引擎优化和索引对嵌入的 JS 代码来说是不必要的。

当一个模块标记为 'use dom' 时,它将被在运行时导入的代理引用所替代。此功能主要是通过一系列捆绑和 CLI 技术来实现的。

如果需要,您仍然可以通过将原始 HTML 传递给 WebView 组件来使用标准方法使用 WebView。

在网站或其他 DOM 组件中渲染的 DOM 组件将如常规组件一样运行,并且 dom 属性将被忽略。这是因为网页内容直接通过传递,而不是包裹在 iframe 中。

总体而言,这个系统与 Expo 的 React 服务器组件实现有许多相似之处。

注意事项

我们建议使用通用原语(如 ViewImageText)来构建真正的原生应用。DOM 组件仅支持标准 JavaScript,这比优化后的 Hermes 字节码解析和启动速度慢。

可以通过异步 JSON 传输系统在 DOM 组件和原生组件之间发送数据。避免依赖于跨 JS 引擎和对 DOM 组件中嵌套 URL 的深度链接,因为它们当前不支持与 Expo Router 的完整协调。

虽然 DOM 组件并非仅限于 Expo Router,但它们是在 Expo Router 应用程序中开发和测试的,以提供在与 Expo Router 一起使用时的最佳体验。

如果您有一个共享数据的全局状态,它将无法跨 JS 引擎访问。

虽然 Expo SDK 中的原生模块可以被优化以支持 DOM 组件,但该优化尚未实施。使用原生操作和属性将原生功能与 DOM 组件共享。

DOM 组件和网站通常不如原生视图优化,但它们有一些合理的用法。例如,网络在渲染富文本和 markdown 方面在概念上是最好的方式。网络还具有非常好的 WebGL 支持,前提是低功耗模式下的设备通常会限制网页帧率以节省电池。

许多大型应用程序还使用一些网页内容作为辅助路由,例如博客文章、富文本(例如,长篇 X 帖子)、设置页面、帮助页面以及应用程序中其他不太常访问的部分。

服务器组件

当前,DOM 组件仅以单页面应用程序形式渲染,不支持静态渲染或 React 服务器组件(RSC)。当项目使用 React 服务器组件时,'use dom' 将与 'use client' 一样工作,无论平台如何。RSC 负载可以作为属性传递给 DOM 组件。但在原生平台上无法正确水合,因为它们将被渲染为原生运行时。

限制

  • 与服务器组件不同,您无法将 children 传递给 DOM 组件。
  • DOM 组件是独立的,不能在不同实例之间自动共享数据。
  • 您不能将原生视图添加到 DOM 组件。虽然您可以尝试将原生视图漂浮在 DOM 组件上,但这种方法会导致用户体验不佳。
  • 函数属性不能同步返回值。它们必须是异步的。
  • 当前,DOM 组件只能被嵌入,不支持 OTA 更新。此功能可能在将来的 React 服务器组件中添加。

最终,通用架构是最令人兴奋的类型。Expo CLI 的广泛通用工具是我们能够提供如此复杂且有价值的功能的唯一原因。

虽然 DOM 组件有助于迁移和快速移动,但我们建议尽可能使用真正的原生视图。

常见问题

如何在 DOM 组件中获取安全上下文?

一些 Web API 需要 安全上下文 才能正常工作。例如,只有在安全上下文中才能使用 Clipboard API。安全上下文意味着远程资源必须通过 HTTPS 提供。了解更多有关限制在安全上下文中的特性

为了确保您的 DOM 组件在安全上下文中运行,请遵循以下指南:

  • 发布构建:使用 file:// 方案提供的 DOM 组件默认具有安全上下文。
  • 调试构建:当使用开发服务器(默认使用 http:// 协议)时,您可以使用 隧道 通过 HTTPS 提供 DOM 组件。
通过 HTTPS 隧道 DOM 组件的示例命令
Terminal
# 安装 expo-dev-client 以启用连接到远程开发服务器:
npx expo install expo-dev-client

# 在 Android 上运行应用:
npx expo run:android
# 按 Ctrl + C 停止服务器
npx expo start --tunnel -d -a

# 在 iOS 上运行应用:
npx expo run:ios
# 按 Ctrl + C 停止服务器
npx expo start --tunnel -d -i