风控验证码组件架构设计与实现
✨文章摘要(AI生成)
本文深入探讨了小程序风控验证码组件的架构设计与演进历程。从旧方案的 Layout 注册-回调模式出发,分析了生命周期管理复杂、多实例性能问题以及严重的页面卡顿 BUG。通过引入事件发布订阅模式,利用小程序底层的页面唯一标识机制实现自动路由,结合双向 Promise 传递链路和事件队列机制,完美解决了跨页面调用、生命周期管理、并发控制等技术难题。新方案代码量减少约 37.5%,架构更加优雅,为小程序中的全局组件调用提供了最佳实践参考。
一、背景与问题
1.1 业务场景
在电商小程序中,为了防止恶意攻击和刷单行为,需要在关键接口(如登录、下单、支付等)集成风控验证码功能。验证码需要在请求拦截器等非 React 组件中触发,同时要保证用户体验流畅。
1.2 技术挑战
- 跨页面调用:请求拦截器是全局单例,需要在任意页面触发验证码
- 多实例问题:每个页面都有独立的组件实例,如何避免重复创建验证码组件
- 生命周期管理:页面切换时如何正确管理验证码实例的生命周期
- Promise 异步处理:验证码验证是异步的,需要优雅的 Promise 处理方案
二、架构演进历程
2.1 旧方案:Layout 注册-回调模式
实现方式
// Layout 组件
const Layout = () => {
const [showCaptchaModal, setShowCaptchaModal] = useState(false);
const promiseRef = useRef<{resolve, reject} | null>(null);
// 每个页面注册到 RiskControlManager
useEffect(() => {
const handlerId = riskControlManager.registerHandlers(
showCaptcha,
hideCaptcha,
);
return () => {
riskControlManager.unregisterHandlers(handlerId);
};
}, [showCaptcha, hideCaptcha]);
return (
<>
{children}
<DxCaptcha
ref={captchaRef}
show={showCaptchaModal}
onSuccess={handleCaptchaSuccess}
onHide={handleCaptchaHide}
/>
</>
);
};
// RiskControlManager
class RiskControlManager {
private handlers = new Map<string, {show, hide}>();
private currentHandlerId: string;
registerHandlers(show, hide) {
const id = generateId();
this.handlers.set(id, {show, hide});
return id;
}
async showCaptcha() {
const handler = this.handlers.get(this.currentHandlerId);
return handler.show();
}
}存在的问题
| 问题类型 | 具体表现 | 影响 |
|---|---|---|
| 生命周期复杂 | 每个页面挂载时注册,卸载时注销 | 容易遗漏,导致内存泄漏 |
| 依赖更新风险 | useEffect([showCaptcha, hideCaptcha]) 依赖函数引用 | 函数引用变化会导致频繁重新注册 |
| 映射表维护 | 需要手动维护 Map<handlerId, handlers> | 增加代码复杂度 |
| 找不到 handler | 注册失败或时序错误时 handlers.get() 返回 undefined | 导致验证码无法显示 |
| 多实例浪费 | 每个页面都创建独立的 DxCaptcha 实例 | 性能问题(注释中标注 // TODO 解决性能问题) |
| ⚠️ 严重BUG | 反复进出商详页后,某次进入会卡在loading | 生产环境严重问题,影响核心业务流程 |
🔴 严重问题详解:页面反复进出后卡住
问题现象:
- 用户在首页和商详页之间反复跳转(如:首页 → 商详 → 返回 → 商详 → 返回 → 商详...)
- 某一次进入商详页时,页面卡在 loading 状态,无法加载
- 注释掉风控组件后问题消失
根本原因分析:
- 事件监听器泄漏与污染
// Layout 组件
useEffect(() => {
const handlerId = riskControlManager.registerHandlers(showCaptcha, hideCaptcha);
handlerIdRef.current = handlerId;
return () => {
if (handlerIdRef.current) {
riskControlManager.unregisterHandlers(handlerIdRef.current);
}
};
}, [showCaptcha, hideCaptcha]); // ❌ 问题根源问题链路:
第1次进商详:
Layout mount → registerHandlers(handler1) ✅
Layout unmount → unregisterHandlers(handler1) ✅
第2次进商详:
Layout mount → registerHandlers(handler2) ✅
由于 showCaptcha/hideCaptcha 闭包更新,useEffect 依赖变化
→ 执行 cleanup → unregister(handler2)
→ 重新执行 effect → register(handler3) ⚠️
Layout unmount → unregisterHandlers(?) ❌ 可能注销错误的 handler
第3次进商详:
currentHandlerId 指向已删除的 handler
→ showCaptcha() 调用 handlers.get(currentHandlerId) 返回 undefined
→ 验证码无法显示
→ 请求挂起,页面卡在 loading ❌- Promise 引用泄漏
const promiseRef = useRef<{resolve, reject} | null>(null);
const showCaptcha = useCallback((options?: any): Promise<any> => {
setShowCaptchaModal(true);
return new Promise((resolve, reject) => {
promiseRef.current = { resolve, reject }; // ❌ 可能泄漏
});
}, []);泄漏场景:
- 用户快速关闭验证码弹窗或快速切换页面
promiseRef.current未被清空(没有调用 resolve/reject)- 旧的 Promise 永远不会 resolve
- 下次进入页面创建新 Promise,但请求仍在等待旧 Promise
- 导致请求永久挂起,页面卡在 loading
- handler 映射表时序错误
class RiskControlManager {
private handlers = new Map<string, {show, hide}>();
private currentHandlerId: string;
registerHandlers(show, hide) {
const id = generateId();
this.handlers.set(id, {show, hide}); // ❌ 可能残留
this.currentHandlerId = id; // ❌ 可能被覆盖
return id;
}
}并发注册问题:
- 页面 A 还未完全卸载时,页面 B 已经开始挂载
- 两个页面同时调用
registerHandlers() currentHandlerId被覆盖,指向错误的页面- 后续调用
showCaptcha()时路由到错误的页面或找不到 handler
为什么注释掉风控就好了?
- 不再创建
DxCaptcha组件实例 - 不再注册/注销 handlers,避免映射表污染
- 不再有 Promise 引用泄漏
- 此时 GlobalComponent 的新实现已接管风控功能(通过事件机制)
线上影响:
- 用户体验极差,页面完全无法使用
- 概率性复现,难以定位
- 影响核心购物流程(商详页是转化关键页面)
2.2 新方案:事件发布订阅模式
核心思想
将调用方与实现方解耦,利用事件中心作为中间层,通过页面唯一标识自动路由事件到正确的页面。
架构图
┌─────────────────────────────────────────────────────────────┐
│ 调用方(任意位置) │
│ RiskControlManager.showCaptcha() │
│ Taro.$showCaptcha() │
└─────────────────────┬───────────────────────────────────────┘
│
│ 发布事件
↓
┌─────────────────────────────────────────────────────────────┐
│ Taro.eventCenter │
│ 事件名:GLOBAL_COMP_EVENT_${pageId} │
└─────────────────────┬───────────────────────────────────────┘
│
│ 自动路由(基于页面唯一标识)
↓
┌─────────────────────────────────────────────────────────────┐
│ 当前页面的 GlobalComponent │
│ 监听并处理事件 │
│ 显示 DxCaptcha 组件 │
└─────────────────────────────────────────────────────────────┘三、GlobalComponent 核心设计原理
3.1 发布-订阅模式深度解析
什么是发布-订阅模式?
发布-订阅(Pub/Sub)是一种消息传递范式,发布者(Publisher)不直接向特定的订阅者(Subscriber)发送消息,而是通过事件中心(Event Center)作为中间层进行解耦。
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 发布者 │ ----→ │ 事件中心 │ ----→ │ 订阅者 │
│ Publisher │ 发布 │ Event Center │ 推送 │ Subscriber │
└──────────────┘ └──────────────┘ └──────────────┘
不知道订阅者 管理所有事件 不知道发布者关键特征:
- 解耦:发布者和订阅者互不感知对方的存在
- 多播:一个事件可以有多个订阅者
- 异步:事件发布和处理可以是异步的
为什么选择发布-订阅模式?
| 对比维度 | 传统回调注册 | 发布-订阅模式 |
|---|---|---|
| 耦合度 | 高(需要手动注册/注销) | 低(通过事件中心解耦) |
| 可维护性 | 需要维护映射表 | 事件中心自动管理 |
| 扩展性 | 新增订阅者需修改发布者 | 新增订阅者只需监听事件 |
| 生命周期 | 手动管理容易泄漏 | 组件卸载自动清理 |
| 跨层级通信 | 需要层层传递回调 | 直接通过事件通信 |
3.2 事件注册机制:基于页面唯一标识的路由
核心原理:每个页面有独立的事件名
// 页面 A 的事件名
"GLOBAL_COMP_EVENT_node_12345"
↑ ↑
固定前缀 页面唯一标识
// 页面 B 的事件名
"GLOBAL_COMP_EVENT_node_67890"
↑ ↑
固定前缀 页面唯一标识(不同)关键实现步骤:
步骤 1:获取页面唯一标识
// src/components/GlobalComponent/index.tsx
useEffect(() => {
// 1. 获取当前页面实例
const { page } = Taro.getCurrentInstance();
// 2. 提取页面唯一标识
const path = page?.[EVENT_PATH_KEY];
// 微信小程序:__wxExparserNodeId__ = "exparser-node-id-12345"
// 其他平台:$taroPath = "/pages/index/index_1234567890"
if (path) {
// 3. 生成该页面专属的事件名
eventName.current = `GLOBAL_COMP_EVENT_${path}`;
// 结果示例:"GLOBAL_COMP_EVENT_exparser-node-id-12345"
}
}, []);步骤 2:注册事件监听器
// 继续 useEffect 内部
// 4. 注册事件监听器
Taro.eventCenter.on(eventName.current, (options = {}) => {
const { type, config } = options;
// 处理不同类型的事件
switch (type) {
case 'showCaptcha': {
// 显示验证码逻辑
setShowCaptcha(true);
// 创建 Promise 并返回给调用方
const promise = new Promise((resolve, reject) => {
captchaPromiseRef.current = { resolve, reject };
});
config?.callback?.(promise);
break;
}
case 'hideCaptcha': {
// 隐藏验证码逻辑
setShowCaptcha(false);
break;
}
}
});步骤 3:通知事件已就绪
// 5. 广播事件初始化完成信号
Taro.eventCenter.trigger(`GLOBAL_COMP_EVENT_INITED_${path}`);
// 这个信号会被 setup.ts 中的事件队列机制监听步骤 4:组件卸载时清理
// 6. 返回清理函数
return () => {
if (eventName.current) {
Taro.eventCenter.off(eventName.current);
// ✅ 自动解绑,不会有内存泄漏
}
};完整事件注册流程图
页面挂载
↓
getCurrentInstance() → 获取 page 对象
↓
page[EVENT_PATH_KEY] → 提取唯一标识 "node_12345"
↓
生成事件名 "GLOBAL_COMP_EVENT_node_12345"
↓
Taro.eventCenter.on(eventName, handler) → 注册监听器
↓
Taro.eventCenter.trigger("INITED_node_12345") → 通知已就绪
↓
等待外部调用...
↓
页面卸载
↓
Taro.eventCenter.off(eventName) → 自动清理 ✅3.3 事件通知机制:调用方如何触发事件
完整的事件通知流程
阶段 1:调用方发起请求
// 任意位置调用全局方法
const result = await Taro.$showCaptcha();阶段 2:全局方法触发事件
// src/components/GlobalComponent/setup.ts
Taro.$showCaptcha = (): Promise<RiskControlResult> => {
return new Promise((resolveA) => { // ← Promise A(最终返回给调用方)
// 1. 调用事件发布函数
globalComponentEvent({
type: 'showCaptcha',
config: {
// 2. 传入回调,用于接收 GlobalComponent 创建的 Promise B
callback: (promiseB: Promise<any>) => {
// 3. 将 Promise B 的结果传递给 Promise A
promiseB.then(resolveA).catch(resolveA);
},
},
});
});
};阶段 3:获取当前页面标识并发布事件
// globalComponentEvent 函数内部
const globalComponentEvent = (options) => {
// 1. 获取当前页面栈
const pages = Taro.getCurrentPages();
const curPage = pages[pages.length - 1]; // 栈顶 = 当前页面
// 2. 提取页面唯一标识
let path = curPage?.[EVENT_PATH_KEY];
// path = "node_12345"(与 GlobalComponent 获取的一致!)
if (path) {
// 3. 生成事件名(与监听器的事件名完全相同)
const eventName = `GLOBAL_COMP_EVENT_${path}`;
// 4. 检查该事件是否已注册
if ((Taro.eventCenter as any).callbacks[eventName]) {
// 已注册 → 立即发布事件
Taro.eventCenter.trigger(eventName, options);
} else {
// 未注册 → 放入队列,等待 GlobalComponent 初始化
eventQueue.push({ eventName, options });
// 监听初始化信号
Taro.eventCenter.once(`GLOBAL_COMP_EVENT_INITED_${path}`, () => {
// 收到信号后,触发队列中的所有事件
eventQueue.forEach(item => {
Taro.eventCenter.trigger(item.eventName, item.options);
});
eventQueue = []; // 清空队列
});
}
}
};阶段 4:GlobalComponent 接收事件并处理
// GlobalComponent 的事件监听器被触发
Taro.eventCenter.on(eventName.current, (options = {}) => {
const { type, config } = options;
if (type === 'showCaptcha') {
// 1. 显示验证码 UI
setShowCaptcha(true);
// 2. 创建 Promise B
const promiseB = new Promise((resolveB, rejectB) => {
// 保存 resolve/reject,等待用户操作
captchaPromiseRef.current = { resolve: resolveB, reject: rejectB };
});
// 3. 通过 callback 将 Promise B 返回给调用方
config?.callback?.(promiseB);
// ↑
// 这个 callback 就是 setup.ts 中定义的函数
// 它会将 Promise B 的结果传递给 Promise A
}
});阶段 5:用户完成验证后 resolve Promise
// 用户点击验证码完成验证
const handleCaptchaSuccess = (data: string) => {
// 1. 隐藏验证码
setShowCaptcha(false);
// 2. resolve Promise B(自动传递给 Promise A)
captchaPromiseRef.current?.resolve({
success: true,
data, // 验证 token
});
// 3. 清理引用,防止泄漏
captchaPromiseRef.current = null;
};
// 用户关闭验证码
const handleCaptchaHide = () => {
setShowCaptcha(false);
// resolve 失败状态
captchaPromiseRef.current?.resolve({
success: false,
error: '用户取消验证',
});
captchaPromiseRef.current = null;
};完整通知流程时序图
调用方 setup.ts eventCenter GlobalComponent 用户
│ │ │ │ │
│ await $showCaptcha()│ │ │ │
├─────────────────────→│ │ │ │
│ │ new Promise(resolveA) │ │
│ │ │ │ │
│ │ globalComponentEvent() │ │
│ │ getCurrentPages() │ │ │
│ │ path="node_12345" │ │ │
│ │ │ │ │
│ │ trigger(EVENT_node_12345, {type, config})│ │
│ ├────────────────────→│ │ │
│ │ │ 路由到 node_12345 │ │
│ │ ├───────────────────→│ │
│ │ │ handler(options)│ │
│ │ │ │ setShowCaptcha(true)
│ │ │ │ 显示验证码 UI │
│ │ │ ├───────────────→│
│ │ │ │ │
│ │ │ new Promise(resolveB) │
│ │ │ captchaPromiseRef.current = {resolveB}
│ │ │ │ │
│ │ │ config.callback(promiseB) │
│ │←────────────────────┼────────────────────┤ │
│ │ promiseB.then(resolveA) │ │
│ │ │ │ │
│ (等待 Promise A) │ (等待 Promise B) │ │ (等待用户操作) │
│ │ │ │ │
│ │ │ │ 用户点击验证 │
│ │ │ │←───────────────┤
│ │ │ │ onSuccess(data)│
│ │ │ │ resolveB({success:true, data})
│ │ │ │ │
│ │ resolveA 被触发(通过 promiseB.then) │ │
│←─────────────────────┤ │ │ │
│ result={success:true}│ │ │ │
│ │ │ │ │3.4 事件队列机制:解决时序问题
为什么需要事件队列?
问题场景:
// 页面刚开始加载时,可能出现竞态条件
时刻 T1:请求拦截器触发 → Taro.$showCaptcha() 发布事件
时刻 T2:GlobalComponent 挂载 → 注册事件监听器
// 如果 T1 < T2,事件在监听器注册之前就发布了 → 事件丢失 ❌解决方案:事件队列 + 初始化信号
// setup.ts
let eventQueue = []; // 事件队列
const globalComponentEvent = (options) => {
const eventName = `GLOBAL_COMP_EVENT_${path}`;
// 检查事件是否已注册
if ((Taro.eventCenter as any).callbacks[eventName]) {
// ✅ 已注册,直接发布
Taro.eventCenter.trigger(eventName, options);
} else {
// ⚠️ 未注册,放入队列
console.log('[事件队列] 监听器未就绪,加入队列');
eventQueue.push({ eventName, options });
// 监听初始化信号(只监听一次)
const initedEventName = `GLOBAL_COMP_EVENT_INITED_${path}`;
Taro.eventCenter.once(initedEventName, () => {
console.log('[事件队列] 收到初始化信号,处理队列');
// 批量触发队列中的所有事件
eventQueue.forEach(item => {
if (item.eventName === eventName) {
Taro.eventCenter.trigger(item.eventName, item.options);
}
});
// 清空已处理的事件
eventQueue = eventQueue.filter(item => item.eventName !== eventName);
});
}
};时序图:
请求拦截器 setup.ts eventQueue GlobalComponent
│ │ │ │
│ $showCaptcha() │ │ │
├─────────────────→│ │ │
│ │ 检查监听器是否注册 │ │
│ │ → 未注册 ⚠️ │ │
│ │ │ │
│ │ push({eventName, options}) │
│ ├───────────────────→│ │
│ │ │ [{event1}] │
│ │ │ │
│ │ on("INITED_xxx") │ │
│ │ (等待初始化信号) │ │
│ │ │ │
│ │ │ 挂载 + 注册监听器
│ │ │ │
│ │ │ trigger("INITED_xxx")
│ │←───────────────────┼────────────────────┤
│ │ 收到信号! │ │
│ │ │ │
│ │ forEach(queue.trigger) │
│ ├──────────────────────────────────────→│
│ │ │ handler(event1) ✅
│ │ │ │3.5 双向 Promise 传递链路
这是整个设计中最精妙的部分,通过两个 Promise 的链式传递实现异步结果返回。
Promise A 和 Promise B 的角色
Promise A:调用方持有的 Promise(在 setup.ts 中创建)
↓
作用:提供给调用方 await,返回最终验证结果
生命周期:从调用 $showCaptcha() 到用户完成验证
Promise B:GlobalComponent 创建的 Promise
↓
作用:等待用户操作,resolve/reject 由用户行为触发
生命周期:从显示验证码到用户点击完成/关闭
关键桥梁:config.callback 函数
↓
将 Promise B 的结果传递给 Promise A
实现代码:promiseB.then(resolveA).catch(resolveA)完整代码链路
// ==================== 步骤 1:调用方 ====================
const result = await Taro.$showCaptcha();
// ↑ 等待 Promise A resolve
// ==================== 步骤 2:setup.ts ====================
Taro.$showCaptcha = () => {
return new Promise((resolveA) => { // ← Promise A
globalComponentEvent({
type: 'showCaptcha',
config: {
callback: (promiseB) => { // ← 接收 Promise B
// 关键!将 B 的结果传给 A
promiseB.then(resolveA).catch(resolveA);
},
},
});
});
};
// ==================== 步骤 3:GlobalComponent ====================
Taro.eventCenter.on(eventName.current, (options) => {
const { type, config } = options;
if (type === 'showCaptcha') {
const promiseB = new Promise((resolveB, rejectB) => { // ← Promise B
captchaPromiseRef.current = { resolve: resolveB, reject: rejectB };
});
// 通过 callback 返回 Promise B
config?.callback?.(promiseB); // ← 触发 promiseB.then(resolveA)
}
});
// ==================== 步骤 4:用户操作 ====================
const handleCaptchaSuccess = (data: string) => {
// resolve Promise B
captchaPromiseRef.current?.resolve({ success: true, data });
// ↓
// Promise B resolve
// ↓
// promiseB.then(resolveA) 被触发
// ↓
// Promise A resolve
// ↓
// 调用方的 await 得到结果 ✅
};数据流向图
调用方 Promise A callback Promise B 用户操作
│ │ │ │ │
│ await │ │ │ │
├───────────────────→│ new Promise(resolveA)│ │ │
│ │ │ │ │
│ │ │ callback(promiseB) │ │
│ │ │←────────────────────┤ new Promise(resolveB)│
│ │ │ │ │
│ │ │ promiseB.then(resolveA) │
│ │ │ (建立 B → A 的连接) │ │
│ │ │ │ │
│ (等待中...) │ (等待中...) │ │ (等待中...) │
│ │ │ │ │
│ │ │ │ 用户点击验证 │
│ │ │ │←─────────────────────┤
│ │ │ │ resolveB({data}) │
│ │ │ │ ↓ │
│ │ │ Promise B resolve│ │
│ │ │ ↓ │ │
│ │ │ then(resolveA) 触发 │ │
│ │ │ ↓ │ │
│ │ Promise A resolve│ │ │
│ │←─────────────────────┤ │ │
│ 得到 result │ │ │ │
│←───────────────────┤ │ │ │四、核心实现详解
4.1 全局方法定义(setup.ts)
// src/components/GlobalComponent/setup.ts
// 获取页面唯一标识的 key
export const EVENT_PATH_KEY =
process.env.TARO_ENV === 'weapp' ? '__wxExparserNodeId__' : '$taroPath';
// 事件发布函数
const globalComponentEvent = (options) => {
// 1. 获取当前页面
const pages = Taro.getCurrentPages();
const curPage = pages[pages.length - 1];
// 2. 提取页面唯一标识
let path = curPage?.[EVENT_PATH_KEY];
if (options?.ctx) {
path = options.ctx[EVENT_PATH_KEY];
}
if (path) {
// 3. 生成事件名(带页面标识)
const eventName = `GLOBAL_COMP_EVENT_${path}`;
// 4. 发布事件
if ((Taro.eventCenter as any).callbacks[eventName]) {
Taro.eventCenter.trigger(eventName, options);
} else {
// 防止页面事件未绑定,先放入队列
eventQueue.push({ eventName, options });
// ... 队列处理逻辑
}
}
};
/**
* 全局方法:显示风控验证码
* @returns Promise<{success: boolean, data?: string, error?: string}>
*/
Taro.$showCaptcha = (): Promise<RiskControlResult> => {
return new Promise((resolve) => {
globalComponentEvent({
type: 'showCaptcha',
config: {
callback: (promise: Promise<any>) => {
promise.then(resolve).catch(resolve);
},
},
});
});
};
/**
* 全局方法:隐藏风控验证码
*/
Taro.$hideCaptcha = () => {
globalComponentEvent({
type: 'hideCaptcha',
});
};关键点:
- 利用
Taro.getCurrentPages()获取当前页面栈 - 通过
__wxExparserNodeId__(微信小程序)或$taroPath(其他平台)获取页面唯一标识 - 将页面标识嵌入事件名,实现自动路由
- 返回 Promise,支持异步等待验证结果
3.2 GlobalComponent 组件(事件监听)
// src/components/GlobalComponent/index.tsx
const GlobalComponent = () => {
// 风控相关状态
const captchaRef = useRef<DxCaptchaRef>(null);
const [showCaptcha, setShowCaptcha] = useState(false);
const captchaPromiseRef = useRef<{
resolve: (value: any) => void;
reject: (reason?: any) => void;
} | null>(null);
const eventName = useRef('');
useEffect(() => {
// 1. 获取当前页面实例
const { page } = Taro.getCurrentInstance();
const path = page?.[EVENT_PATH_KEY];
if (path) {
// 2. 生成事件名(与 setup.ts 中逻辑一致)
eventName.current = `GLOBAL_COMP_EVENT_${path}`;
// 3. 监听事件
Taro.eventCenter.on(eventName.current, (options = {}) => {
const { type, config } = options;
switch (type) {
case 'showCaptcha': {
setShowCaptcha(true);
// 创建 Promise 供调用方等待
const promise = new Promise((resolve, reject) => {
captchaPromiseRef.current = { resolve, reject };
});
// 通过回调返回 promise
config?.callback?.(promise);
break;
}
case 'hideCaptcha': {
setShowCaptcha(false);
captchaRef.current?.hideCaptcha();
break;
}
}
});
// 通知事件已初始化
Taro.eventCenter.trigger(`GLOBAL_COMP_EVENT_INITED_${path}`);
}
// 4. 卸载时解绑事件
return () => {
if (eventName.current) {
Taro.eventCenter.off(eventName.current);
}
};
}, []); // ✅ 只在挂载/卸载时执行一次
// 验证成功回调
const handleCaptchaSuccess = (data: string) => {
setShowCaptcha(false);
captchaPromiseRef.current?.resolve({
success: true,
data,
});
captchaPromiseRef.current = null;
};
// 验证关闭回调
const handleCaptchaHide = () => {
setShowCaptcha(false);
captchaPromiseRef.current?.resolve({
success: false,
error: '用户取消验证',
});
captchaPromiseRef.current = null;
};
return (
<>
{/* 其他全局组件 */}
{/* 风控验证组件 */}
<DxCaptcha
ref={captchaRef}
show={showCaptcha}
onSuccess={handleCaptchaSuccess}
onHide={handleCaptchaHide}
/>
</>
);
};关键点:
- 使用
useEffect([])确保只在挂载时注册事件,避免重复注册 - 通过
captchaPromiseRef保存 Promise 的 resolve/reject 方法 - 验证成功/失败时调用 resolve,返回结果给调用方
- 组件卸载时自动解绑事件
3.3 RiskControlManager(调用方)
// src/services/riskControl/RiskControlManager.ts
export class RiskControlManager {
private static instance: RiskControlManager;
private isVerifying: boolean = false;
private currentVerifyPromise: Promise<RiskControlResult> | null = null;
private readonly VERIFY_TIMEOUT = 60000; // 60秒
/**
* 显示验证码
*/
public async showCaptcha(
options?: RiskControlVerifyOptions,
): Promise<RiskControlResult> {
// 检查全局方法是否可用
if (typeof Taro.$showCaptcha !== 'function') {
console.error('[RiskControlManager] Taro.$showCaptcha 未定义');
return { success: false, error: '风控系统未初始化' };
}
// 防止并发:如果正在验证中,复用当前的验证 Promise
if (this.isVerifying && this.currentVerifyPromise) {
console.log('[RiskControlManager] 检测到并发验证请求,复用当前验证');
return this.currentVerifyPromise;
}
this.isVerifying = true;
try {
const verifyPromise = this.createVerifyPromise(options);
this.currentVerifyPromise = verifyPromise;
const result = await verifyPromise;
return result;
} catch (error) {
console.error('[RiskControlManager] 验证过程发生错误:', error);
return {
success: false,
error: error instanceof Error ? error.message : '验证过程发生未知错误',
};
} finally {
this.isVerifying = false;
this.currentVerifyPromise = null;
}
}
/**
* 创建带超时控制的验证 Promise
*/
private createVerifyPromise(
options?: RiskControlVerifyOptions,
): Promise<RiskControlResult> {
// 超时 Promise
const timeoutPromise = new Promise<RiskControlResult>((_, reject) => {
setTimeout(() => {
reject(new Error('验证超时,请重试'));
}, this.VERIFY_TIMEOUT);
});
// 调用全局方法
const verifyPromise = Taro.$showCaptcha();
// 竞态:先完成的胜出
return Promise.race([verifyPromise, timeoutPromise]);
}
}
export const riskControlManager = RiskControlManager.getInstance();关键点:
- 直接调用
Taro.$showCaptcha()全局方法,无需注册 - 实现并发控制,多个请求复用同一个验证 Promise
- 添加超时机制,防止用户长时间不操作
- 单例模式,全局共享一个实例
四、核心技术点深度解析
4.1 页面唯一标识机制
微信小程序:__wxExparserNodeId__
Exparser 是微信小程序的组件系统底层框架,负责组件的渲染、更新、事件处理。每个组件/页面节点都有一个由框架自动生成的唯一 NodeId。
// 微信小程序底层架构
小程序页面实例
↓
WXWebAssembly 虚拟 DOM 节点(Exparser 框架)
↓
每个节点都有唯一的 __wxExparserNodeId__特性:
- ✅ 全局唯一:整个小程序运行期间,没有两个页面的 ID 相同
- ✅ 自动生成:不需要开发者手动创建或维护
- ✅ 稳定不变:页面实例存在期间,ID 不会变化
- ✅ 准确标识:即使相同路径的页面在页面栈中有多个实例,每个实例的 ID 也不同
其他平台:Taro 的 $taroPath
Taro 为了跨平台兼容,在其他平台(支付宝、抖音、H5)上提供了 $taroPath 作为页面唯一标识:
// Taro 内部实现(伪代码)
class TaroPage {
constructor(route, params) {
// 生成唯一标识:路径 + 时间戳 + 随机数
this.$taroPath = `${route}_${params.$taroTimestamp}_${Math.random()}`;
}
}4.2 事件自动路由原理
为什么能自动路由到正确页面?
关键:监听和触发使用相同的逻辑生成事件名。
// 场景:用户在页面 A 触发验证码
┌─────────────────────────────────────────────────────┐
│ 步骤 1:页面 A 的 GlobalComponent 挂载 │
└─────────────────────────────────────────────────────┘
Taro.getCurrentInstance().page[EVENT_PATH_KEY]
↓
path = "__wxExparserNodeId__:abc123" ← 页面 A 的唯一标识
↓
eventName = "GLOBAL_COMP_EVENT___wxExparserNodeId__:abc123"
↓
Taro.eventCenter.on(eventName, handleEvent) ← 页面 A 开始监听
┌─────────────────────────────────────────────────────┐
│ 步骤 2:在页面 A 调用 Taro.$showCaptcha() │
└─────────────────────────────────────────────────────┘
globalComponentEvent({ type: 'showCaptcha' })
↓
Taro.getCurrentPages()[pages.length - 1] ← 获取当前页面(栈顶)
↓
curPage[EVENT_PATH_KEY] = "__wxExparserNodeId__:abc123" ← 页面 A
↓
eventName = "GLOBAL_COMP_EVENT___wxExparserNodeId__:abc123"
↓
Taro.eventCenter.trigger(eventName, options)
↓
✅ 页面 A 的 GlobalComponent 收到事件(监听的是同一个事件名)多页面场景
// 页面栈:[首页(node_000), 列表A(node_001), 详情B(node_002)]
页面 A 监听: "GLOBAL_COMP_EVENT_node_001"
页面 B 监听: "GLOBAL_COMP_EVENT_node_002"
// 当前在页面 B,调用 Taro.$showCaptcha()
getCurrentPages()[pages.length - 1] // 获取 node_002
触发事件: "GLOBAL_COMP_EVENT_node_002"
↓
只有页面 B 的 GlobalComponent 收到事件 ✅
页面 A 的 GlobalComponent 收不到(事件名不同)❌4.3 Promise 异步处理链路
调用方
↓
await Taro.$showCaptcha()
↓
返回 Promise A (在 setup.ts 中创建)
↓
发布事件,携带 callback
↓
GlobalComponent 收到事件
↓
创建 Promise B,resolve/reject 存入 captchaPromiseRef
↓
通过 callback 将 Promise B 返回给 Promise A
↓
用户完成验证
↓
onSuccess 触发,调用 captchaPromiseRef.current.resolve({success: true, data})
↓
Promise B resolve
↓
Promise A resolve(通过 promise.then(resolve))
↓
调用方的 await 得到结果 ✅示例代码流程:
// 1. 调用方
const result = await Taro.$showCaptcha();
console.log(result); // { success: true, data: 'xxx' }
// 2. setup.ts
Taro.$showCaptcha = () => {
return new Promise((resolveA) => { // Promise A
globalComponentEvent({
config: {
callback: (promiseB) => {
promiseB.then(resolveA).catch(resolveA); // B → A
},
},
});
});
};
// 3. GlobalComponent
const promise = new Promise((resolveB, rejectB) => { // Promise B
captchaPromiseRef.current = { resolve: resolveB, reject: rejectB };
});
config?.callback?.(promise); // 传递 Promise B
// 4. 用户验证成功
const handleCaptchaSuccess = (data: string) => {
captchaPromiseRef.current?.resolve({ // Promise B resolve
success: true,
data,
});
};五、新旧方案对比
5.1 功能对比表
| 维度 | 旧方案(Layout 注册) | 新方案(GlobalComponent 事件) |
|---|---|---|
| 调用方式 | riskControlManager.showCaptcha() 需注册 | Taro.$showCaptcha() 全局方法 |
| 生命周期 | 手动 register/unregister | 自动 on/off(跟随组件) |
| 依赖更新 | useEffect([show, hide]) 可能频繁触发 | useEffect([]) 只触发一次 |
| 找到目标页面 | 通过 handlerId 映射 | 通过页面 path 自动路由 |
| 维护映射表 | ✅ 需要 Map<id, handlers> | ❌ 不需要 |
| 代码复杂度 | 高(注册机制 + 回调管理) | 低(事件驱动) |
| Promise 处理 | 间接(通过 callbacksRef) | 直接返回 Promise |
| 并发安全 | 需要手动处理 | RiskControlManager 层统一处理 |
| 多实例 | 每页面一个(无统一管理) | 每页面一个(事件统一管理) |
5.2 代码量对比
// 旧方案:约 80 行
- Layout: 50 行(状态管理 + 注册逻辑 + 回调处理)
- RiskControlManager: 30 行(Map 管理 + register/unregister)
// 新方案:约 50 行
- setup.ts: 20 行(全局方法定义 + 事件发布)
- GlobalComponent: 30 行(事件监听 + Promise 处理)
- RiskControlManager: 10 行(直接调用全局方法)
代码量减少约 37.5%六、使用指南
6.1 在请求拦截器中使用
// src/utils/request.ts
import Taro from '@tarojs/taro';
import { riskControlManager } from '@/services/riskControl/RiskControlManager';
Taro.addInterceptor({
async request(chain) {
const { requestParams } = chain;
try {
const res = await chain.proceed(requestParams);
// 检查风控标识
if (res.data?.riskControl) {
// 触发验证码
const result = await riskControlManager.showCaptcha();
if (result.success) {
// 验证成功,重新发起请求
requestParams.headers['X-Risk-Token'] = result.data;
return chain.proceed(requestParams);
} else {
// 验证失败
throw new Error(result.error || '验证失败');
}
}
return res;
} catch (error) {
throw error;
}
},
});6.2 在组件中使用
import Taro from '@tarojs/taro';
const MyComponent = () => {
const handleSubmit = async () => {
// 直接调用全局方法
const result = await Taro.$showCaptcha();
if (result.success) {
// 验证成功,继续业务逻辑
console.log('验证通过,Token:', result.data);
await submitOrder(result.data);
} else {
// 验证失败
Taro.showToast({
title: result.error || '验证失败',
icon: 'none',
});
}
};
return <Button onClick={handleSubmit}>提交订单</Button>;
};6.3 在页面中集成
// src/layout/index.tsx
import GlobalComponent from '@/components/GlobalComponent';
const Layout = ({ children }) => {
return (
<>
{children}
{/* 全局组件(包含验证码) */}
<GlobalComponent />
</>
);
};七、最佳实践与注意事项
7.1 并发控制
RiskControlManager 已实现并发控制,多个请求同时触发验证时,会复用同一个验证 Promise:
// 同时触发两个需要验证的请求
const [result1, result2] = await Promise.all([
request1(), // 触发验证
request2(), // 复用验证
]);
// 用户只需要验证一次7.2 超时处理
默认超时时间为 60 秒,超时后自动返回失败:
private readonly VERIFY_TIMEOUT = 60000; // 60秒
// 用户长时间不操作
const result = await Taro.$showCaptcha();
// 60 秒后:{ success: false, error: '验证超时,请重试' }7.3 分包场景注意事项
在龙湖项目作为分包时:
- ✅
setup.ts中的全局方法在应用启动时定义 - ✅ 每个页面的
GlobalComponent独立监听自己的事件 - ✅ 事件通过页面 ID 自动路由,不会串页面
- ⚠️ 注意检查
Taro.$showCaptcha是否已定义(分包可能延迟加载)
7.4 错误处理
// 推荐的错误处理方式
try {
const result = await Taro.$showCaptcha();
if (result.success) {
// 验证成功
console.log('Token:', result.data);
} else {
// 验证失败(用户取消、超时等)
console.warn('验证失败:', result.error);
Taro.showToast({ title: result.error, icon: 'none' });
}
} catch (error) {
// 异常情况(网络错误、系统错误等)
console.error('验证异常:', error);
Taro.showToast({ title: '验证系统异常', icon: 'none' });
}八、性能优化
8.1 懒加载优化
DxCaptcha 组件根据平台动态加载实现:
// src/components/DxCaptcha/index.tsx
let DxCaptchaImpl;
if (process.env.TARO_ENV === 'weapp') {
DxCaptchaImpl = require('./weapp').default; // 小程序实现
} else {
DxCaptchaImpl = require('./web').default; // H5 实现
}8.2 事件队列机制
防止页面还未初始化时事件丢失:
// setup.ts
if ((Taro.eventCenter as any).callbacks[eventName]) {
Taro.eventCenter.trigger(eventName, options);
} else {
// 事件未绑定,放入队列
eventQueue.push({ eventName, options });
// 等待页面初始化完成后触发
Taro.eventCenter.on(initedEventName, () => {
eventQueue.forEach(item => {
Taro.eventCenter.trigger(item.eventName, item.options);
});
});
}九、总结
9.1 核心优势
- 架构优雅:事件发布订阅模式,调用方与实现方完全解耦
- 自动路由:利用小程序/Taro 底层机制,无需手动维护映射表
- 代码简洁:减少约 37.5% 代码量,降低维护成本
- 类型安全:完整的 TypeScript 类型定义
- 并发安全:自动处理并发请求,避免重复验证
- 超时控制:防止用户长时间不操作导致请求挂起
9.2 技术亮点
- 利用底层机制:
__wxExparserNodeId__自动生成页面唯一标识 - Promise 链式传递:优雅的异步处理方案
- 事件队列:防止时序问题导致事件丢失
- 单例模式:全局共享 RiskControlManager 实例
- 跨平台兼容:通过 Taro 适配层支持多端
9.3 未来优化方向
- 应用级单例:将 GlobalComponent 提升到 App 级别,真正实现单实例
- 验证结果缓存:短时间内复用验证结果,减少用户操作
- 智能降级:验证失败时提供降级方案(如图形验证码 → 短信验证码)
- 性能监控:添加验证耗时、成功率等数据埋点