iOS 从 UIWebView 迁移到 WKWebView 实现 JavaScript POST
结论
evaluateJavaScript 返回的 result 不是表单 POST 的响应数据。JavaScript 函数没有显式 return 时,result 为 nil 是正常现象。
在 WKWebView 中,也不能依赖 WKNavigationAction.request.httpBody 获取网页表单产生的 POST body。由于 WKWebView 采用多进程架构,导航代理通常只能读取 URL、请求方法等信息,POST body 不一定会暴露给 App。
处理 3D Secure 1.x 时,可以采用以下流程:
- App 在
WKWebView中向ACSURL提交PaReq、TermUrl和MD。 - 用户在 ACS 页面完成验证。
- ACS 通过 HTTP POST 将
PaRes和MD发送到你控制的TermUrl。 - 服务器解析并校验数据,再返回结果页面或跳转地址。
- App 通过 URL 跳转或
WKScriptMessageHandler获取验证结果,然后继续支付流程。
不要在正式环境中把 https://www.apple.com 之类的任意第三方地址设为 TermUrl。这个地址是 ACS 完成验证后的回调接收端,第三方服务器不会替你可靠地保存和返回 PaRes。
当前代码为什么得到 nil
代码中有几个相互独立的问题。
1. HTML 中没有表单
示例字符串只包含以下 JavaScript:
function submitForm() {
document.getElementById('submitButton').style.display = 'none';
document.getElementById('submitButton').click();
}
其中既没有定义 submitButton,也没有包含向 ACSURL 提交数据的 <form>。所以:
document.getElementById('submitButton')
会返回 null,继续访问 style 时就会抛出 JavaScript 异常。
此外,String(format:) 的格式字符串中没有 %@,传入的 acsurlString、paReqString 和 termURLString 实际上没有插入 HTML。
2. JavaScript 没有返回值
即使下面的函数成功执行,因为没有 return:
function submitForm() {
document.getElementById('threeDSForm').submit();
}
evaluateJavaScript 的 result 仍然会是 nil。调用时还应检查 error:
webView.evaluateJavaScript("submitForm()") { result, error in
if let error {
print("JavaScript error: \(error)")
return
}
print("JavaScript return value: \(String(describing: result))")
}
这里的返回值只是 JavaScript 表达式的计算结果,不是 HTTP POST body,也不是 ACS 返回的 PaRes。
3. WebView 宽度被设置为零
当前代码创建的 frame 宽度是 0.0:
CGSize(
width: 0.0,
height: secureViewContainer.frame.size.height
)
即使页面加载成功,用户也看不到验证界面。可以直接使用容器边界,再设置自动布局或 autoresizing:
webView = WKWebView(
frame: secureViewContainer.bounds,
configuration: configuration
)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
secureViewContainer.addSubview(webView)
正确构造并提交 ACS 表单
下面的代码适用于典型的 3D Secure 1.x 表单流程。字段名称是否区分大小写,以及是否需要 MD,要以支付服务商的协议为准。
把数据插入 HTML 前必须转义,不能直接拼接服务端返回的内容:
private func escapeHTML(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "\"", with: """)
.replacingOccurrences(of: "'", with: "'")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
}
然后生成完整页面:
private func loadThreeDSChallenge(
acsURL: String,
paReq: String,
termURL: String,
md: String
) {
let html = """
<!doctype html>
<html>
<head>
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
</head>
<body>
<form id="threeDSForm"
method="POST"
action="\(escapeHTML(acsURL))">
<input type="hidden"
name="PaReq"
value="\(escapeHTML(paReq))">
<input type="hidden"
name="TermUrl"
value="\(escapeHTML(termURL))">
<input type="hidden"
name="MD"
value="\(escapeHTML(md))">
<noscript>
<button type="submit">继续验证</button>
</noscript>
</form>
<script>
window.addEventListener("load", function () {
document.getElementById("threeDSForm").submit();
});
</script>
</body>
</html>
"""
webView.loadHTMLString(html, baseURL: nil)
}
页面加载后,load 事件会自动提交表单,通常不需要再在 didFinish 中调用一次 submitForm()。
decidePolicyFor 和 didFinish 分别能做什么
decidePolicyFor
它在导航开始前调用,适合:
- 检查即将访问的 URL;
- 拦截服务器返回的完成跳转;
- 阻止访问不允许的域名;
- 决定使用
.allow还是.cancel。
例如,服务器处理完 PaRes 后跳转到:
https://pay.example.com/3ds/app-complete?transactionId=123
App 可以拦截这个完成地址:
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
if url.host == "pay.example.com",
url.path == "/3ds/app-complete" {
let components = URLComponents(
url: url,
resolvingAgainstBaseURL: false
)
let transactionID = components?
.queryItems?
.first(where: { $0.name == "transactionId" })?
.value
decisionHandler(.cancel)
handleThreeDSCompletion(transactionID: transactionID)
return
}
decisionHandler(.allow)
}
不要把这里的 request.httpBody 作为获取 PaRes 的可靠来源。
didFinish
它表示当前页面已经完成主框架导航,适合:
- 隐藏加载状态;
- 更新界面;
- 执行当前页面中的 JavaScript;
- 读取你有权限访问的页面状态。
它不会提供 POST body,也不会自动返回 ACS 提交到 TermUrl 的 PaRes。
JavaScript POST 的实际过程
这个过程不是 JavaScript 把结果交给 Native,而是浏览器提交普通的 HTML 表单:
App 生成表单
↓ POST PaReq、TermUrl、MD
ACSURL
↓ 用户完成验证
ACS POST PaRes、MD
↓
你控制的 TermUrl
↓ 服务器校验并记录结果
完成页面或完成跳转
↓
App 获知流程结束
TermUrl 可以写成任意格式正确的 URL,因为它只是作为隐藏字段传给 ACS。验证完成后,ACS 会尝试向该地址发送 POST。UIWebView 过去可能会把相关 POST body 暴露给代理,但在 WKWebView 中不能继续依赖这种接口行为。
正式环境中的 TermUrl 通常还要满足支付服务商对 HTTPS、域名白名单、会话关联或签名校验的要求。
使用 WKScriptMessageHandler 通知 App
如果 TermUrl 返回的 HTML 页面由你的服务器控制,可以让该页面通知 Native。先配置消息处理器:
final class ThreeDSViewController: UIViewController,
WKNavigationDelegate,
WKScriptMessageHandler {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let contentController = WKUserContentController()
contentController.add(self, name: "threeDS")
let configuration = WKWebViewConfiguration()
configuration.userContentController = contentController
webView = WKWebView(
frame: secureViewContainer.bounds,
configuration: configuration
)
webView.navigationDelegate = self
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
secureViewContainer.addSubview(webView)
}
deinit {
webView?
.configuration
.userContentController
.removeScriptMessageHandler(forName: "threeDS")
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "threeDS",
let payload = message.body as? [String: Any],
let transactionID = payload["transactionId"] as? String else {
return
}
handleThreeDSCompletion(transactionID: transactionID)
}
private func handleThreeDSCompletion(transactionID: String?) {
// 向自己的服务器查询并确认最终验证状态,
// 然后继续支付流程。
}
}
服务器收到并处理 ACS 的 POST 后,可以返回类似的页面:
<!doctype html>
<html>
<body>
<script>
window.webkit.messageHandlers.threeDS.postMessage({
status: "completed",
transactionId: "server-generated-id"
});
</script>
</body>
</html>
更稳妥的做法是只把交易标识和完成状态传给 App,再由 App 向自己的服务器查询结果。不要根据网页传回的一个布尔值判断支付是否成功,也不要在 URL 查询参数中暴露完整的 PaRes。
安全注意事项
ACSURL应由可信的支付服务端提供,并建议限制允许加载的域名。- 不要把
PaReq、PaRes、MD等值写入普通日志。 - 不要使用 JavaScript 读取或修改第三方 ACS 页面的 DOM。跨域限制和页面实现变化都会让这种方案失效。
TermUrl应由自己的后端控制,并使用 HTTPS。- 必须通过
MD、服务器会话或不可预测的交易标识关联同一笔支付,防止串单和重放。 - Native 收到完成通知后,仍要向可信服务器确认最终支付状态。
WKUserContentController会保留消息处理器。控制器销毁时要移除 handler,避免循环引用。- 如果支付服务商提供官方 3D Secure SDK,应优先使用其 SDK,并参照最新的集成文档。不同 3DS 版本的流程差异较大,不能假设 3D Secure 1.x 的
PaReq和PaRes表单方案同样适用于后续版本。
理解 Native 与网页的交互方式时,可以重点查看 Apple WebKit 文档中的 WKWebView、WKNavigationDelegate、WKUIDelegate、WKUserContentController、WKScriptMessageHandler 和 evaluateJavaScript。需要区分三件事:导航代理负责观察页面导航,evaluateJavaScript 用于获取 JavaScript 返回值,而服务器接收的 HTTP POST 数据应由服务器端处理。
备注:内容仅供参考。
