通过扩展 React 功能实现 Expo Web Payments
结论
Expo Web 可以创建支付弹窗,但 Portal 只负责弹窗的渲染位置和页面覆盖效果,不能补上 Expo Web 本身没有的支付能力。
比较稳妥的做法是:
- Web 端接入支付服务商提供的浏览器 SDK,例如 Stripe Elements 或 PayPal JavaScript SDK。
- 使用 React Portal、React Native 的
Modal,或者普通的绝对定位组件展示支付界面。 - 由后端创建支付订单或
PaymentIntent。 - 通过支付服务商托管的安全表单收集支付信息。
- 分别为 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_ 前缀的环境变量或前端构建产物。
后端必须承担的工作
支付金额不能完全交给浏览器决定,因为前端提交的价格、商品名称、用户身份和支付结果都可能被篡改。
后端至少需要负责:
- 根据商品或订单重新计算应付金额。
- 创建支付订单、Checkout Session 或
PaymentIntent。 - 将前端可用的
clientSecret或跳转地址返回给 Web 应用。 - 接收并验证支付服务商的 Webhook。
- 根据验证通过的异步通知更新订单状态。
- 做好幂等处理,避免重复扣款或重复发货。
浏览器跳转到“支付成功”页面,只能说明前端流程完成了跳转,不能将它作为发货或开通服务的唯一依据。最终订单状态应以后端验证过的支付结果为准。
如果一定要直接使用 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 和安全的后端支付流程。
备注:内容仅供参考。
