在 Expo 原生应用中使用 React DOM

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


在 SDK 52 及以上版本中可用。

Expo 通过 'use dom' 指令提供了一种新颖的方法,可直接在原生应用中使用现代 Web 代码。这通过按组件移动,实现了整个网站到通用应用的增量迁移。

¥Expo offers a novel approach to work with modern web code directly in a native app via the 'use dom' directive. This enables incremental migration for an entire website to a universal app by moving on a per-component basis.

虽然 Expo 原生运行时通常不支持 <div><img> 等元素,但在某些情况下你可能需要快速合并 Web 组件。在这种情况下,DOM 组件提供了一个有用的解决方案。

¥While the Expo native runtime generally does not support elements like <div> or <img>, there may be instances where you need to quickly incorporate web components. In such cases, DOM components provide a useful solution.

先决条件

¥Prerequisites

Your project must use Expo CLI and extend the Expo Metro Config

如果你已经使用 npx expo [command] 运行你的项目(例如,如果你使用 npx create-expo-app 创建它),那么你就一切就绪了,你可以跳过此步骤。

¥If you already run your project with npx expo [command] (for example, if you created it with npx create-expo-app), then you're all set, and you can skip this step.

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

¥If you don't have the expo package in your project yet, then install it by running the command below and opt in to using Expo CLI and Metro Config:

Terminal
npx install-expo-modules@latest

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

¥If the command fails, refer to the Installing Expo modules guide.

Expo Metro Runtime, React DOM, and React Native Web

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

¥If you are using Expo Router and Expo Web, you can skip this step. Otherwise, install the following packages:

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

用法

¥Usage

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

¥Install react-native-webview in your project:

Terminal
npx expo install react-native-webview

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

¥To render a React component to the DOM, add the 'use dom' directive to the top of the web component file:

my-component.tsx (web)
'use dom';

export default function DOMComponent({ name }: { name: string }) {
  return (
    <div>
      <h1>Hello, {name}</h1>
    </div>
  );
}

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

¥Inside the native component file, import the web component to use it:

App.tsx (native)
import DOMComponent from './my-component.tsx';

export default function App() {
  return (
    // This is a DOM component. It re-exports a wrapped `react-native-webview` behind the scenes.
    <DOMComponent name="Europa" />
  );
}

特性

¥Features

  • 在 Web、原生和 DOM 组件之间共享打包器配置。

    ¥Shared bundler config across web, native, and DOM components.

  • React、TypeScript、CSS 和所有其他 Metro 功能均在 DOM 组件中启用。

    ¥React, TypeScript, CSS, and all other Metro features are enabled in DOM components.

  • 登录终端并进行 Safari/Chrome 调试。

    ¥Logging in the terminal and Safari/Chrome debugging.

  • 快速刷新和 HMR。

    ¥Fast Refresh and HMR.

  • 嵌入式导出以提供离线支持。

    ¥Embedded exports for offline support.

  • 资源在 Web 和原生之间统一。

    ¥Assets are unified across web and native.

  • 可以在 Expo Atlas 中自省 DOM 组件包以进行调试。

    ¥DOM component bundles can be introspected in Expo Atlas for debugging.

  • 无需原生重建即可访问​​所有 Web 功能。

    ¥Access to all web functionality without needing a native rebuild.

  • 开发中的运行时错误覆盖。

    ¥Runtime error overlay in development.

  • 支持 Expo Go。

    ¥Supports Expo Go.

WebView props

要将 props 传递给底层原生 WebView,请在组件上使用 dom prop。此 prop 内置于每个 DOM 组件中,并接受你想要更改的任何 WebView 属性 的对象。

¥To pass props to the underlying native WebView, use the dom prop on the component. This prop is built into every DOM component and accepts an object with any WebView props that you would like to change.

App.tsx (native)
import DOMComponent from './my-component';

export default function App() {
  return (
    <DOMComponent
      dom={{
        scrollEnabled: false,
      }}
    />
  );
}

在你的 DOM 组件上,添加 dom 属性,以便在 TypeScript 中识别它:

¥On your DOM component, add the dom prop so it is recognized in TypeScript:

my-component.tsx (web)
'use dom';

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

编组 props

¥Marshalled props

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

¥You can send data to the DOM component through serializable props (number, string, boolean, null, undefined, Array, Object). For example, inside a native component file, you can pass a prop to the DOM component:

App.tsx (native)
import DOMComponent from './my-component';

export default function App() {
  return <DOMComponent hello={'world'} />;
}

在 Web 组件文件中,你可以接收 prop,如下例所示:

¥Inside the web component file, you can receive the prop as shown in the example below:

my-component.tsx (web)
'use dom';

export default function DOMComponent({ hello }: { hello: string }) {
  return <p>Hello, {hello}</p>;
}

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

¥Props are sent over an asynchronous bridge so they are not updated synchronously. They are passed as props to the React root component, which means they re-render the entire React tree.

原生操作

¥Native actions

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

¥You can send type-safe native functions to DOM components by passing asynchronous functions as top-level props to the DOM component:

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>;
}

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

¥You cannot pass functions as nested props to DOM components. They must be top-level props.

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

¥Native actions are always asynchronous and accept only serializable arguments (meaning no functions) because the data is sent over a bridge to the DOM component's JavaScript engine.

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

¥Native actions can return serializable data to the DOM component, which is useful for getting data back from the native side.

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

将这些函数视为 React 服务器函数,但它们不是驻留在服务器上,而是驻留在原生应用中并与 DOM 组件通信。这种方法提供了一种强大的方法来向 DOM 组件添加真正的原生功能。

¥Think of these functions like React Server Functions, but instead of residing on the server, they live locally in the native app and communicate with the DOM component. This approach provides a powerful way to add truly native functionality to your DOM components.

传递 refs

¥Passing refs

警告:这是实验性的,将来可能会发生变化。

你可以在 DOM 组件中使用 useDOMImperativeHandle 钩子接受来自原生端的 ref 调用。此钩子类似于 React 的 useImperativeHandle 钩子,但不需要将 ref 对象传递给它。

¥You can use the useDOMImperativeHandle hook inside a DOM component to accept ref calls from the native side. This hook is similar to React's useImperativeHandle hook, but it does not need a ref object to be passed to it.

App.tsx (native)
import { useRef } from 'react';
import { Button, View } from 'react-native';

import MyComponent, { type MyRef } from './my-component';

export default function App() {
  const ref = useRef<MyRef>(null);

  return (
    <View style={{ flex: 1 }}>
      <MyComponent ref={ref} />
      <Button
        title="focus"
        onPress={() => {
          ref.current?.focus();
        }}
      />
    </View>
  );
}
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 组件。

¥React is meant to have a unilateral data flow, so the concept of using callbacks to go back up the tree is not idiomatic. Expect the behavior to be flakey and possibly phased out in the future with newer versions of React. The preferred way to send data back up the tree is to use native actions, which update the state and then pass it back to the DOM component.

功能检测

¥Feature detection

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

¥Since DOM components are used to run websites, you might need extra qualifiers to better support certain libraries. You can detect if a component is running in a DOM component with the following code:

const IS_DOM = typeof ReactNativeWebView !== 'undefined';

公共资源

¥Public assets

警告:这是实验性的,将来可能会发生变化。EAS 更新不支持公共资源。改用 require() 加载本地资源。

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

¥The contents of the root public directory are copied to the native app's binary to support the use of public assets in DOM components. Since these public assets will be served from the local filesystem, use the process.env.EXPO_BASE_URL prefix to reference the correct path. For example:

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

调试

¥Debugging

默认情况下,所有 console.log 方法都在 WebViews 中扩展以将日志转发到终端。这样可以快速轻松地查看 DOM 组件中发生的情况。

¥By default, all console.log methods are extended in WebViews to forward logs to the terminal. This makes it fast and easy to see what's happening in your DOM components.

Expo 还在开发模式下打包时启用 WebView 检查和调试。你可以打开 Safari > 开发 > 模拟器 > MyComponent.tsx 以查看 WebView 的控制台并检查元素。

¥Expo also enables WebView inspection and debugging when bundling in development mode. You can open Safari > Develop > Simulator > MyComponent.tsx to see the WebView's console and inspect elements.

手动 WebViews

¥Manual WebViews

你可以使用 react-native-webview 中的 WebView 组件创建手动 WebView。这对于从远程服务器渲染网站很有用。

¥You can create a manual WebView using the WebView component from react-native-webview. This can be useful for rendering websites from a remote server.

App.tsx (native)
import { WebView } from 'react-native-webview';

export default function App() {
  return <WebView source={{ html: '<h1>Hello, world!</h1>' }} />;
}

在本地运行 EAS Build

¥Routing

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

¥Expo Router APIs such as <Link />, and useRouter can be used in DOM components to navigate between routes.

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>
  );
}

同步返回路由信息的 API,如 useLocalSearchParams()useGlobalSearchParams()usePathname()useSegments()useRootNavigation()useRootNavigationState(),不会自动受支持。相反,在 DOM 组件之外读取这些值并将它们作为 props 提供。

¥APIs that synchronously return routing info such as useLocalSearchParams(), useGlobalSearchParams(), usePathname(), useSegments(), useRootNavigation(), and useRootNavigationState() are not automatically supported. Instead, read these values outside of DOM components and supply them as props.

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() 函数也不受支持,需要手动编组,这可确保不会触发无关的渲染周期。

¥The router.canGoBack() and router.canDismiss() functions are also unsupported and require manual marshalling, this ensures no extraneous render cycles are triggered.

避免使用标准 Web <a /> 锚元素进行导航,因为这些元素会以用户可能无法导航回的方式更改 DOM 组件原点。如果你想展示外部网站,最好启动 WebBrowser

¥Avoid using standard web <a /> anchor elements for navigation as these will change the DOM component origin in a way that users may not be able to navigate back from. Prefer launching WebBrowsers if you want to present external websites.

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

¥Since DOM components cannot render native children, layout routes (_layout) can never be DOM components. You can render DOM components from layout routes to create headers, backgrounds, and more, but the layout route itself should always be native.

测量 DOM 组件

¥Measuring DOM components

你可能想要测量 DOM 组件的大小并将其报告回原生端(例如,原生滚动)。这可以使用 matchContents 属性或手动原生操作来完成:

¥You may want to measure the size of a DOM component and report it back to the native side (for example, native scrolling). This can be done using a matchContents prop or a manual native action:

使用 matchContents prop 自动配置

¥Automatically with matchContents prop

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

¥You can use the dom={{ matchContents: true }} prop to measure the size of the DOM component automatically and resize the native view. This is particularly useful for certain layouts where the DOM component must have an intrinsic size in order to be displayed, such as when the component is centered within a parent view:

App.tsx (native)
import DOMComponent from './my-component';

export default function Route() {
  return <DOMComponent dom={{ matchContents: true }} />;
}

通过指定大小手动提交

¥Manually by specifying a size

你还可以通过 dom 属性将尺寸传递给 WebView style 属性来手动提供尺寸:

¥You can also manually provide a size by passing it to the WebView style prop via the dom prop:

App.tsx (native)
import DOMComponent from './my-component';

export default function Route() {
  return (
    <DOMComponent
      dom={{
        style: { width, height },
      }}
    />
  );
}

观察大小变化

¥Observing changes in size

如果你希望将 DOM 组件大小的变化报告给原生端,则可以向 DOM 组件添加原生操作,该操作在大小发生变化时调用:

¥If you would like to report changes in the size of the DOM component back to the native side, you can add a native action to your DOM component that is called whenever the size is changed:

my-component.tsx (web)
'use dom';

import { useEffect } from 'react';

function useSize(callback: (size: { width: number; height: number }) => void) {
  useEffect(() => {
    // Observe window size changes
    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 组件报告大小变化时设置状态中的大小:

¥Then update your native code to set the size in state whenever the DOM component reports a change in size:

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>
  );
}

架构

¥Architecture

内置 DOM 支持仅将网站渲染为单页应用(没有 SSR 或 SSG)。这是因为搜索引擎优化和索引对于嵌入式 JS 代码来说是不必要的。

¥Built-in DOM support only renders websites as single-page applications (no SSR or SSG). This is because search engine optimization and indexing are unnecessary for embedded JS code.

当模块标记为 'use dom' 时,它将被替换为运行时导入的代理引用。此功能主要通过一系列打包器和 CLI 技术实现。

¥When a module is marked with 'use dom', it is replaced with a proxy reference imported at runtime. This feature is primarily achieved through a series of bundler and CLI techniques.

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

¥If desired, you can still use a WebView with the standard approach by passing raw HTML to a WebView component.

在网站或其他 DOM 组件中渲染的 DOM 组件将表现为常规组件,并且 dom prop 将被忽略。这是因为 Web 内容是直接传递的,而不是封装在 iframe 中。

¥DOM components rendered within websites or other DOM components will behave as regular components, and the dom prop will be ignored. This is because web content is passed directly through and not wrapped in an iframe.

总体而言,该系统与 Expo 的 React Server Components 实现有许多相似之处。

¥Overall, this system shares many similarities with Expo's React Server Components implementation.

注意事项

¥Considerations

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

¥We recommend building truly native apps using universal primitives such as View, Image, and Text. DOM components only support standard JavaScript, which is slower to parse and start up than optimized Hermes bytecode.

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

¥Data can be sent between DOM components and native components only through an asynchronous JSON transport system. Avoid relying on data across JS engines and deep linking to nested URLs in DOM components, as they do not currently support full reconciliation with Expo Router.

虽然 DOM 组件并非 Expo Router 独有,但它们是针对 Expo Router 应用开发和测试的,以便在与 Expo Router 一起使用时提供最佳体验。

¥While DOM components are not exclusive to Expo Router, they are developed and tested against Expo Router apps to provide the best experience when used with Expo Router.

如果你拥有用于共享数据的全局状态,则无法跨 JS 引擎访问它。

¥If you have a global state for sharing data, it will not be accessible across JS engines.

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

¥While native modules in the Expo SDK can be optimized to support DOM components, this optimization has not been implemented yet. Use native actions and props to share native functionality with DOM components.

DOM 组件和网站通常不如原生视图理想,但它们有一些合理的用途。例如,从概念上讲,Web 是渲染富文本和 markdown 的最佳方式。Web 还具有非常好的 WebGL 支持,但需要注意的是,低功耗模式下的设备通常会限制 Web 帧速率以节省电池。

¥DOM components and websites in general are less optimal than native views but there are some reasonable uses for them. For example, the web is conceptually the best way to render rich-text and markdown. The web also has very good WebGL support, with the caveat that devices in low-power mode will often throttle web frame rates to preserve battery.

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

¥Many large apps also use some web content for auxiliary routes such as blog posts, rich-text (for example, long-form posts on X), settings pages, help pages, and other less frequently visited parts of the app.

设置提供程序

¥Server Components

DOM 组件目前仅渲染为单页应用,不支持静态渲染或 React Server Components (RSC)。当项目使用 React Server Components 时,无论平台如何,'use dom' 的工作方式与 'use client' 相同。RSC Payloads 可以作为属性传递给 DOM 组件。但是,它们无法在原生平台上正确水化,因为它们将在原生运行时渲染。

¥DOM components currently only render as single-page applications and don't support static rendering or React Server Components (RSC). When the project uses React Server Components, 'use dom' will work the same as 'use client' regardless of the platform. RSC Payloads can be passed as properties to DOM components. However, they cannot be hydrated correctly on native platforms as they'll be rendered for a native runtime.

局限性

¥Limitations

  • 与服务器组件不同,你不能将 children 传递给 DOM 组件。

    ¥Unlike server components, you cannot pass children to DOM components.

  • DOM 组件是独立的,不会自动在不同实例之间共享数据。

    ¥DOM components are standalone and do not automatically share data between different instances.

  • 你无法将原生视图添加到 DOM 组件。虽然你可以尝试将原生视图浮动在 DOM 组件上,但这种方法会导致用户体验不佳。

    ¥You cannot add native views to DOM components. While you can attempt to float native views over DOM components, this approach results in a suboptimal user experience.

  • 函数 props 无法同步返回值。它们必须是异步的。

    ¥Function props cannot return values synchronously. They must be asynchronous.

  • DOM 组件目前只能嵌入,不支持 OTA 更新。此功能可能会在未来作为 React Server Components 的一部分添加。

    ¥DOM components can currently only be embedded and do not support OTA updates. This functionality may be added in the future as part of React Server Components.

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

¥Ultimately, universal architecture is the most exciting kind. Expo CLI's extensive universal tooling is the only reason we can even offer a feature as intricate and valuable as this one.

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

¥While DOM components help with migration and moving quickly, we recommend using truly native views whenever possible.