在 Expo 原生应用中使用 React DOM
编辑页面
了解如何在 Expo 原生应用中使用 'use dom' 指令渲染 React DOM 组件。
For the complete documentation index, see llms.txt. Use this Use this file to discover all available pages.
Expo 提供了一种新颖的方式,通过 'use dom' 指令直接在原生应用中使用现代 Web 代码。这使得可以通过按组件逐步迁移,将整个网站增量式地迁移为统一应用。
虽然 Expo 原生运行时通常不支持 <div> 或 <img> 之类的元素,但在某些情况下,你可能需要快速集成 Web 组件。在这种情况下,DOM 组件提供了一个有用的解决方案。
前提条件
你的项目必须使用 Expo CLI 并扩展 Expo Metro Config
如果你已经使用 npx expo [command] 运行项目(例如,你是通过 npx create-expo-app 创建的),那么你已经准备好了,可以跳过这一步。
如果你的项目中还没有 expo 包,那么请运行下面的命令安装它,并选择使用 Expo CLI 和 Metro Config:
- npx install-expo-modules@latest如果命令失败,请参考 安装 Expo 模块 指南。
Expo Metro Runtime、React DOM 和 React Native Web
如果你正在使用 Expo Router 和 Expo Web,可以跳过这一步。否则,请安装以下包:
- npx expo install @expo/metro-runtime react-dom react-native-web用法
在你的项目中安装 react-native-webview:
- npx expo install react-native-webview要将 React 组件渲染到 DOM,请在 Web 组件文件顶部添加 'use dom' 指令:
'use dom'; export default function DOMComponent({ name }: { name: string }) { return ( <div> <h1>你好,{name}</h1> </div> ); }
在原生组件文件中,导入该 Web 组件以使用它:
import DOMComponent from './my-component.tsx'; export default function App() { return ( // 这是一个 DOM 组件。它在底层重新导出了一个包装过的 `react-native-webview`。 <DOMComponent name="Europa" /> ); }
特性
- Web、原生和 DOM 组件共享同一套 bundler 配置。
- DOM 组件中启用了 React、TypeScript、CSS 以及所有其他 Metro 特性。
- 可在终端以及 Safari/Chrome 中进行日志记录和调试。
- Fast Refresh 和 HMR。
- 为离线支持提供嵌入式导出。
- 资源在 Web 和原生之间统一。
- DOM 组件 bundle 可在 Expo Atlas 中进行检查以便调试。
- 无需原生重新构建即可访问所有 Web 功能。
- 开发环境下的运行时错误覆盖层。
- 支持 Expo Go。
WebView 属性
要将属性传递给底层原生 WebView,请在组件上使用 dom 属性。这个属性内置于每个 DOM 组件中,并接受一个对象,其中可以包含你想要修改的任意 WebView 属性。
import DOMComponent from './my-component'; export default function App() { return ( <DOMComponent dom={{ scrollEnabled: false, }} /> ); }
在你的 DOM 组件中,添加 dom 属性以便 TypeScript 能识别它:
'use dom'; export default function DOMComponent({}: { dom: import('expo/dom').DOMProps }) { return ( <div> <h1>你好,世界!</h1> </div> ); }
序列化属性
你可以通过可序列化属性(number、string、boolean、null、undefined、Array、Object)将数据发送到 DOM 组件。例如,在原生组件文件中,你可以向 DOM 组件传递一个属性:
import DOMComponent from './my-component'; export default function App() { return <DOMComponent hello={'world'} />; }
在 Web 组件文件中,你可以像下面的示例那样接收该属性:
'use dom'; export default function DOMComponent({ hello }: { hello: string }) { return <p>你好,{hello}</p>; }
属性通过异步桥接发送,因此不会同步更新。它们作为属性传递给 React 根组件,这意味着它们会导致整个 React 树重新渲染。
原生动作
你可以通过将异步函数作为 DOM 组件的顶层属性传递,向 DOM 组件发送类型安全的原生函数:
import DomComponent from './my-component'; export default function App() { return ( <DomComponent hello={(data: string) => { console.log('你好', data); }} /> ); }
'use dom'; export default function MyComponent({ hello }: { hello: (data: string) => Promise<void> }) { return <p onClick={() => hello('world')}>点我</p>; }
你不能将函数作为嵌套属性传递给 DOM 组件。它们必须是顶层属性。
原生动作始终是异步的,并且只接受可序列化的参数(也就是说不能是函数),因为数据会通过桥接发送到 DOM 组件的 JavaScript 引擎。
原生动作可以向 DOM 组件返回可序列化数据,这对于从原生侧获取数据很有用。
getDeviceName(): Promise<string> { return DeviceInfo.getDeviceName(); }
你可以把这些函数看作 React Server Functions,只不过它们不是存在于服务器上,而是本地运行在原生应用中,并与 DOM 组件通信。这种方式为向 DOM 组件添加真正的原生功能提供了强大的手段。
传递 refs
重要 这项功能目前处于 alpha 阶段,未来可能会发生变化。
你可以在 DOM 组件内部使用 useDOMImperativeHandle 钩子,从而接受来自原生侧的 ref 调用。这个钩子类似于 React 的 useImperativeHandle 钩子,但它不需要传入 ref 对象。
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="聚焦" onPress={() => { ref.current?.focus(); }} /> </View> ); }
Expo SDK 53 及更高版本使用 React 19。这意味着 ref 属性会作为属性传递给组件,你可以在组件中直接使用它。
'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 句柄。
'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';
虽然在 DOM 组件中 process.env.EXPO_OS 始终会是 web,但你可以使用 process.env.EXPO_DOM_HOST_OS 来检测 顶层 平台。它要么是 ios、android,取决于最上层原生平台的操作系统;在 web 上则为 undefined。
公共资源
重要 警告: 这项功能目前处于 alpha 阶段,未来可能会发生变化。EAS Update 不支持公共资源。请改用
require()来加载本地资源。
根目录下 public 目录的内容会被复制到原生应用的二进制文件中,以支持在 DOM 组件中使用公共资源。由于这些公共资源将从本地文件系统提供,请使用 process.env.EXPO_BASE_URL 前缀来引用正确的路径。例如:
<img src={`${process.env.EXPO_BASE_URL}img.png`} />
调试
默认情况下,WebView 中所有 console.log 方法都会被扩展,以便将日志转发到终端。这样就可以快速而轻松地查看 DOM 组件中正在发生的事情。
Expo 还会在开发模式打包时启用 WebView 检查和调试。你可以打开 Safari > Develop > Simulator > MyComponent.tsx 来查看 WebView 的控制台并检查元素。
手动 WebView
你可以使用 react-native-webview 中的 WebView 组件创建一个手动 WebView。这对于从远程服务器渲染网站很有用。
import { WebView } from 'react-native-webview'; export default function App() { return <WebView source={{ html: '<h1>Hello, world!</h1>' }} />; }
路由
Expo Router API,例如 <Link /> 和 useRouter,可以在 DOM 组件中用于在路由之间导航。
'use dom'; import Link from 'expo-router/link'; export default function DOMComponent() { return ( <div> <h1>你好,世界!</h1> <Link href="/about">关于</Link> </div> ); }
像 useLocalSearchParams()、useGlobalSearchParams()、usePathname()、useSegments()、useRootNavigation() 和 useRootNavigationState() 这类会同步返回路由信息的 API 不会自动受到支持。相反,请在 DOM 组件外部读取这些值,并将它们作为 props 传入。
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 prop 或手动原生操作来实现:
通过 matchContents prop 自动测量
你可以使用 dom={{ matchContents: true }} prop 自动测量 DOM 组件的尺寸并调整原生视图大小。这对于某些布局特别有用,因为 DOM 组件必须具有固有尺寸才能显示,例如当组件居中于父视图中时:
import DOMComponent from './my-component'; export default function Route() { return <DOMComponent dom={{ matchContents: true }} />; }
通过指定尺寸手动设置
你也可以通过 dom prop 将尺寸传递给 WebView 的 style prop 来手动提供尺寸:
import DOMComponent from './my-component'; export default function Route() { return ( <DOMComponent dom={{ style: { width, height }, }} /> ); }
观察尺寸变化
如果你希望将 DOM 组件尺寸的变化回传给原生端,可以向 DOM 组件添加一个原生操作,每当尺寸发生变化时就会被调用:
'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 组件报告尺寸变化时,将尺寸设置到状态中:
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 prop 会被忽略。这是因为 Web 内容是直接透传的,而不是包裹在 iframe 中。
总体而言,这套系统与 Expo 的 React Server Components 实现有许多相似之处。
注意事项
我们建议使用 View、Image 和 Text 等通用原语构建真正的原生应用。DOM 组件仅支持标准 JavaScript,其解析和启动速度比经过优化的 Hermes 字节码更慢。
数据只能通过异步 JSON 传输系统在 DOM 组件和原生组件之间传递。避免依赖跨 JS 引擎的数据,以及在 DOM 组件中深度链接到嵌套 URL,因为它们目前不支持与 Expo Router 的完整协调。
虽然 DOM 组件并不专属于 Expo Router,但它们是针对 Expo Router 应用开发和测试的,以便在与 Expo Router 一起使用时提供最佳体验。
如果你有用于共享数据的全局状态,它将无法跨 JS 引擎访问。
虽然 Expo SDK 中的原生模块可以被优化以支持 DOM 组件,但这一优化目前尚未实现。请使用原生操作和 props 与 DOM 组件共享原生功能。
与原生视图相比,DOM 组件和网站整体上并不那么理想,但它们仍有一些合理用途。例如,Web 在概念上是渲染富文本和 markdown 的最佳方式。Web 对 WebGL 的支持也非常好,不过在低电量模式下,设备通常会限制网页帧率以节省电量。
许多大型应用也会为辅助路由使用一些 Web 内容,例如博客文章、富文本(例如 X 上的长文)、设置页面、帮助页面,以及应用中其他访问频率较低的部分。
服务端组件
DOM 组件目前仅渲染为单页应用程序,不支持静态渲染或 React Server Components(RSC)。当项目使用 React Server Components 时,无论平台如何,'use dom' 的行为都将与 'use client' 相同。RSC Payload 可以作为属性传递给 DOM 组件。不过,它们无法在原生平台上被正确 hydrate,因为它们会为原生运行时进行渲染。
限制
- 与服务端组件不同,你不能将
children传递给 DOM 组件。 - DOM 组件是独立的,不会在不同实例之间自动共享数据。
- 你不能向 DOM 组件添加原生视图。虽然你可以尝试将原生视图浮在 DOM 组件之上,但这种方式会导致较差的用户体验。
- 函数 props 不能同步返回值。它们必须是异步的。
- DOM 组件目前只能作为嵌入内容,且不支持 OTA 更新。此功能未来可能会作为 React Server Components 的一部分加入。
最终,通用架构才是最令人兴奋的类型。Expo CLI 广泛的通用工具链正是我们能够提供如此复杂而有价值功能的唯一原因。
尽管 DOM 组件有助于迁移和快速推进,但我们仍建议尽可能使用真正的原生视图。
常见问题
如何在 DOM 组件中获得安全上下文?
某些 Web API 需要 安全上下文 才能正常工作。例如,Clipboard API 仅在安全上下文中可用。安全上下文意味着远程资源必须通过 HTTPS 提供。了解更多受安全上下文限制的功能。
为了确保你的 DOM 组件运行在安全上下文中,请遵循以下指南:
- 发布构建:使用
file://方案提供的 DOM 组件默认会获得安全上下文。 - 调试构建:在使用开发服务器时(默认使用
http://协议),你可以使用 隧道 通过 HTTPS 提供 DOM 组件。
通过 HTTPS 隧道传输 DOM 组件的示例命令:
# 安装 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