PAYATHON 2026

通过扩展 React 功能实现 Expo Web Payments

支付老李

结论

Expo Web 可以创建支付弹窗,但 Portal 只负责弹窗的渲染位置和页面覆盖效果,不能补上 Expo Web 本身没有的支付能力。

比较稳妥的做法是:

  1. Web 端接入支付服务商提供的浏览器 SDK,例如 Stripe Elements 或 PayPal JavaScript SDK。
  2. 使用 React Portal、React Native 的 Modal,或者普通的绝对定位组件展示支付界面。
  3. 由后端创建支付订单或 PaymentIntent。
  4. 通过支付服务商托管的安全表单收集支付信息。
  5. 分别为 iOS、Android 和 Web 提供对应的平台实现。

如果支付流程允许跳转到服务商托管的页面,建议优先考虑 Checkout 或 Redirect。与自行维护支付弹窗相比,这类方案通常更简单。

Portal 能做什么

Portal 可以将 React 组件渲染到当前组件层级之外的 DOM 节点,常用于:

  • Modal
  • Drawer
  • Tooltip
  • 全局提示
  • 遮罩层
  • 支付表单弹窗

例如:

import { createPortal } from 'react-dom';

type PaymentPortalProps = {
  children: React.ReactNode;
};

export function PaymentPortal({ children }: PaymentPortalProps) {
  const root = document.getElementById('payment-modal-root');

  if (!root) {
    return null;
  }

  return createPortal(children, root);
}

对应的 HTML 页面需要提供挂载节点:

<div id="root"></div>
<div id="payment-modal-root"></div>

不过,在 Expo Web 项目中,通常没必要为了一个支付弹窗直接操作 Portal。React Native 的 Modal 在 Web 端也有对应实现,可以先确认它是否已经满足布局和交互需求。

import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';

type PaymentModalProps = {
  visible: boolean;
  onClose: () => void;
};

export function PaymentModal({
  visible,
  onClose,
}: PaymentModalProps) {
  return (
    <Modal
      visible={visible}
      transparent
      animationType="fade"
      onRequestClose={onClose}
    >
      <View style={styles.backdrop}>
        <View style={styles.dialog}>
          <Text style={styles.title}>完成支付</Text>

          {/* 在这里渲染 Web 支付组件 */}

          <Pressable onPress={onClose}>
            <Text>关闭</Text>
          </Pressable>
        </View>
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  backdrop: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
  },
  dialog: {
    width: '90%',
    maxWidth: 520,
    padding: 24,
    borderRadius: 12,
    backgroundColor: '#fff',
  },
  title: {
    marginBottom: 16,
    fontSize: 20,
    fontWeight: '600',
  },
});

推荐的项目结构

Web 支付 SDK 往往依赖浏览器 DOM,原生端则使用另一套 SDK。不要在共享组件中直接访问 window、document 或 react-dom,否则原生构建可能失败。

Expo 支持按平台选择文件,可以这样组织:

PaymentModal.tsx
PaymentModal.web.tsx
PaymentModal.ios.tsx
PaymentModal.android.tsx

也可以只区分 Web 和 Native:

PaymentModal.web.tsx
PaymentModal.native.tsx

构建不同平台时,Expo 会自动选择对应的文件。

Web 端接入支付组件

以 Stripe 为例,Web 端可以安装:

npm install @stripe/stripe-js @stripe/react-stripe-js

下面的示例假设后端已经创建 PaymentIntent,并将 clientSecret 返回给前端。具体字段和操作流程应以当前支付服务商的文档为准。

// PaymentModal.web.tsx
import { useMemo } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { Elements, PaymentElement } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { CheckoutForm } from './CheckoutForm';

const stripePromise = loadStripe(
  process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
);

type PaymentModalProps = {
  visible: boolean;
  clientSecret: string | null;
  onClose: () => void;
};

export default function PaymentModal({
  visible,
  clientSecret,
  onClose,
}: PaymentModalProps) {
  const options = useMemo(() => {
    if (!clientSecret) {
      return undefined;
    }

    return {
      clientSecret,
    };
  }, [clientSecret]);

  return (
    <Modal
      visible={visible}
      transparent
      animationType="fade"
      onRequestClose={onClose}
    >
      <View style={styles.backdrop}>
        <View style={styles.dialog}>
          <Text style={styles.title}>支付订单</Text>

          {options ? (
            <Elements stripe={stripePromise} options={options}>
              <PaymentElement />
              <CheckoutForm />
            </Elements>
          ) : (
            <Text>正在准备支付信息……</Text>
          )}

          <Pressable style={styles.closeButton} onPress={onClose}>
            <Text>取消</Text>
          </Pressable>
        </View>
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  backdrop: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 16,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
  },
  dialog: {
    width: '100%',
    maxWidth: 520,
    padding: 24,
    borderRadius: 12,
    backgroundColor: '#fff',
  },
  title: {
    marginBottom: 20,
    fontSize: 20,
    fontWeight: '600',
  },
  closeButton: {
    marginTop: 16,
    alignItems: 'center',
    padding: 12,
  },
});

确认支付的组件可以这样写:

// CheckoutForm.web.tsx
import { useState } from 'react';
import { Pressable, Text } from 'react-native';
import {
  useElements,
  useStripe,
} from '@stripe/react-stripe-js';

export function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [submitting, setSubmitting] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');

  async function handlePayment() {
    if (!stripe || !elements || submitting) {
      return;
    }

    setSubmitting(true);
    setErrorMessage('');

    const result = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/payment/result`,
      },
    });

    if (result.error) {
      setErrorMessage(
        result.error.message ?? '支付未完成,请稍后重试。'
      );
      setSubmitting(false);
    }
  }

  return (
    <>
      {errorMessage ? <Text>{errorMessage}</Text> : null}

      <Pressable
        disabled={!stripe || submitting}
        onPress={handlePayment}
      >
        <Text>{submitting ? '处理中……' : '确认支付'}</Text>
      </Pressable>
    </>
  );
}

EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY 中只能存放可公开的 publishable key。支付服务商的 secret key 必须保留在后端,不能写入 Expo 应用、带有 EXPO_PUBLIC_ 前缀的环境变量或前端构建产物。

后端必须承担的工作

支付金额不能完全交给浏览器决定,因为前端提交的价格、商品名称、用户身份和支付结果都可能被篡改。

后端至少需要负责:

  1. 根据商品或订单重新计算应付金额。
  2. 创建支付订单、Checkout Session 或 PaymentIntent。
  3. 将前端可用的 clientSecret 或跳转地址返回给 Web 应用。
  4. 接收并验证支付服务商的 Webhook。
  5. 根据验证通过的异步通知更新订单状态。
  6. 做好幂等处理,避免重复扣款或重复发货。

浏览器跳转到“支付成功”页面,只能说明前端流程完成了跳转,不能将它作为发货或开通服务的唯一依据。最终订单状态应以后端验证过的支付结果为准。

如果一定要直接使用 Portal

可以将 Portal 限定在 .web.tsx 文件中,避免原生端加载 react-dom:

// Portal.web.tsx
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';

type PortalProps = {
  children: ReactNode;
};

export function Portal({ children }: PortalProps) {
  if (typeof document === 'undefined') {
    return null;
  }

  return createPortal(children, document.body);
}

然后在弹窗组件中使用:

<Portal>
  <div className="payment-backdrop">
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="payment-title"
      className="payment-dialog"
    >
      <h2 id="payment-title">支付订单</h2>
      {/* 支付服务商提供的安全组件 */}
    </div>
  </div>
</Portal>

使用 Portal 后,还要处理焦点锁定、按 Esc 关闭、滚动穿透、键盘导航、层级和无障碍属性。如果没有特殊的 DOM 层级需求,成熟的 Modal 组件通常更省事。

其他可选方案

如果不想在应用内承载支付表单,可以由后端创建支付服务商托管的结算页面,再让浏览器跳转过去。支付结束后,用户可以通过预先配置的成功地址或取消地址返回 Expo Web 应用。

这种方案的实际优势包括:

  • 卡片输入界面由支付服务商维护。
  • 认证、支付方式兼容和错误处理更集中。
  • 前端代码更少。
  • 通常更容易限制敏感支付数据的接触范围。

如果支付服务商要求通过新窗口完成支付,需要注意,浏览器可能会拦截不是由用户点击直接触发的弹窗。移动浏览器对弹窗、第三方 Cookie 和跨站跳转的限制也可能更严格,所以 Redirect 往往比自行管理 Popup 更稳定。

注意事项

  • 支付页面必须使用 HTTPS。
  • 不要自行收集、保存或记录完整银行卡号、有效期和安全码。
  • 使用支付服务商提供的托管字段、Element、iframe 或 Checkout 页面。
  • 不要在前端保存 secret key。
  • 金额、币种、订单归属和优惠信息应由后端验证。
  • 使用通过签名验证的 Webhook 更新最终订单状态。
  • Modal 需要处理键盘操作、焦点恢复、屏幕阅读器和移动端滚动。
  • DOM 相关代码应放在 .web.tsx 文件中,同时避免在服务端渲染阶段直接访问 window 或 document。
  • 如果项目同时支持原生端,应分别使用支付服务商支持的原生 SDK,不能依靠 Web Portal 替代原生支付能力。
  • 正式上线前,需要确认支付服务商是否支持所在国家或地区、币种和业务类型,并检查相关合规要求。

Expo Web 的功能边界不会阻碍支付功能的实现。Modal 或 Portal 负责承载界面,实际的支付能力则来自浏览器支付 SDK 和安全的后端支付流程。

备注:内容仅供参考。