Skip to content

风控验证码组件架构设计与实现

✨文章摘要(AI生成)

本文深入探讨了小程序风控验证码组件的架构设计与演进历程。从旧方案的 Layout 注册-回调模式出发,分析了生命周期管理复杂、多实例性能问题以及严重的页面卡顿 BUG。通过引入事件发布订阅模式,利用小程序底层的页面唯一标识机制实现自动路由,结合双向 Promise 传递链路和事件队列机制,完美解决了跨页面调用、生命周期管理、并发控制等技术难题。新方案代码量减少约 37.5%,架构更加优雅,为小程序中的全局组件调用提供了最佳实践参考。

一、背景与问题

1.1 业务场景

在电商小程序中,为了防止恶意攻击和刷单行为,需要在关键接口(如登录、下单、支付等)集成风控验证码功能。验证码需要在请求拦截器等非 React 组件中触发,同时要保证用户体验流畅。

1.2 技术挑战

  1. 跨页面调用:请求拦截器是全局单例,需要在任意页面触发验证码
  2. 多实例问题:每个页面都有独立的组件实例,如何避免重复创建验证码组件
  3. 生命周期管理:页面切换时如何正确管理验证码实例的生命周期
  4. Promise 异步处理:验证码验证是异步的,需要优雅的 Promise 处理方案

二、架构演进历程

2.1 旧方案:Layout 注册-回调模式

实现方式

typescript
// 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 状态,无法加载
  • 注释掉风控组件后问题消失

根本原因分析:

  1. 事件监听器泄漏与污染
typescript
// 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 ❌
  1. Promise 引用泄漏
typescript
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
  1. handler 映射表时序错误
typescript
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)作为中间层进行解耦

typescript
┌──────────────┐        ┌──────────────┐        ┌──────────────┐
│   发布者      │ ----→ │   事件中心    │ ----→ │   订阅者      │
│  Publisher   │  发布  │ Event Center │  推送  │  Subscriber  │
└──────────────┘        └──────────────┘        └──────────────┘
     不知道订阅者           管理所有事件            不知道发布者

关键特征:

  • 解耦:发布者和订阅者互不感知对方的存在
  • 多播:一个事件可以有多个订阅者
  • 异步:事件发布和处理可以是异步的

为什么选择发布-订阅模式?

对比维度传统回调注册发布-订阅模式
耦合度高(需要手动注册/注销)低(通过事件中心解耦)
可维护性需要维护映射表事件中心自动管理
扩展性新增订阅者需修改发布者新增订阅者只需监听事件
生命周期手动管理容易泄漏组件卸载自动清理
跨层级通信需要层层传递回调直接通过事件通信

3.2 事件注册机制:基于页面唯一标识的路由

核心原理:每个页面有独立的事件名

typescript
// 页面 A 的事件名
"GLOBAL_COMP_EVENT_node_12345"
         ↑            ↑
      固定前缀    页面唯一标识

// 页面 B 的事件名
"GLOBAL_COMP_EVENT_node_67890"
         ↑            ↑
      固定前缀    页面唯一标识(不同)

关键实现步骤:

步骤 1:获取页面唯一标识

typescript
// 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:注册事件监听器

typescript
// 继续 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:通知事件已就绪

typescript
// 5. 广播事件初始化完成信号
Taro.eventCenter.trigger(`GLOBAL_COMP_EVENT_INITED_${path}`);
// 这个信号会被 setup.ts 中的事件队列机制监听

步骤 4:组件卸载时清理

typescript
// 6. 返回清理函数
return () => {
  if (eventName.current) {
    Taro.eventCenter.off(eventName.current);
    // ✅ 自动解绑,不会有内存泄漏
  }
};

完整事件注册流程图

typescript
页面挂载

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:调用方发起请求

typescript
// 任意位置调用全局方法
const result = await Taro.$showCaptcha();

阶段 2:全局方法触发事件

typescript
// 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:获取当前页面标识并发布事件

typescript
// 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 接收事件并处理

typescript
// 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

typescript
// 用户点击验证码完成验证

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 事件队列机制:解决时序问题

为什么需要事件队列?

问题场景:

typescript
// 页面刚开始加载时,可能出现竞态条件

时刻 T1:请求拦截器触发 → Taro.$showCaptcha() 发布事件
时刻 T2:GlobalComponent 挂载 → 注册事件监听器

// 如果 T1 < T2,事件在监听器注册之前就发布了 → 事件丢失 ❌

解决方案:事件队列 + 初始化信号

typescript
// 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 的角色

typescript
Promise A:调用方持有的 Promise(在 setup.ts 中创建)

  作用:提供给调用方 await,返回最终验证结果
  生命周期:从调用 $showCaptcha() 到用户完成验证

Promise B:GlobalComponent 创建的 Promise

  作用:等待用户操作,resolve/reject 由用户行为触发
  生命周期:从显示验证码到用户点击完成/关闭

关键桥梁:config.callback 函数

Promise B 的结果传递给 Promise A
  实现代码:promiseB.then(resolveA).catch(resolveA)

完整代码链路

typescript
// ==================== 步骤 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)

typescript
// 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',
  });
};

关键点:

  1. 利用 Taro.getCurrentPages() 获取当前页面栈
  2. 通过 __wxExparserNodeId__(微信小程序)或 $taroPath(其他平台)获取页面唯一标识
  3. 将页面标识嵌入事件名,实现自动路由
  4. 返回 Promise,支持异步等待验证结果

3.2 GlobalComponent 组件(事件监听)

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

关键点:

  1. 使用 useEffect([]) 确保只在挂载时注册事件,避免重复注册
  2. 通过 captchaPromiseRef 保存 Promise 的 resolve/reject 方法
  3. 验证成功/失败时调用 resolve,返回结果给调用方
  4. 组件卸载时自动解绑事件

3.3 RiskControlManager(调用方)

typescript
// 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();

关键点:

  1. 直接调用 Taro.$showCaptcha() 全局方法,无需注册
  2. 实现并发控制,多个请求复用同一个验证 Promise
  3. 添加超时机制,防止用户长时间不操作
  4. 单例模式,全局共享一个实例

四、核心技术点深度解析

4.1 页面唯一标识机制

微信小程序:__wxExparserNodeId__

Exparser 是微信小程序的组件系统底层框架,负责组件的渲染、更新、事件处理。每个组件/页面节点都有一个由框架自动生成的唯一 NodeId。

javascript
// 微信小程序底层架构
小程序页面实例

WXWebAssembly 虚拟 DOM 节点(Exparser 框架)

每个节点都有唯一的 __wxExparserNodeId__

特性:

  • 全局唯一:整个小程序运行期间,没有两个页面的 ID 相同
  • 自动生成:不需要开发者手动创建或维护
  • 稳定不变:页面实例存在期间,ID 不会变化
  • 准确标识:即使相同路径的页面在页面栈中有多个实例,每个实例的 ID 也不同

其他平台:Taro 的 $taroPath

Taro 为了跨平台兼容,在其他平台(支付宝、抖音、H5)上提供了 $taroPath 作为页面唯一标识:

typescript
// Taro 内部实现(伪代码)
class TaroPage {
  constructor(route, params) {
    // 生成唯一标识:路径 + 时间戳 + 随机数
    this.$taroPath = `${route}_${params.$taroTimestamp}_${Math.random()}`;
  }
}

4.2 事件自动路由原理

为什么能自动路由到正确页面?

关键:监听和触发使用相同的逻辑生成事件名。

typescript
// 场景:用户在页面 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 收到事件(监听的是同一个事件名)

多页面场景

typescript
// 页面栈:[首页(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 异步处理链路

typescript
调用方

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 得到结果 ✅

示例代码流程:

typescript
// 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 代码量对比

typescript
// 旧方案:约 80 行
- Layout: 50 行(状态管理 + 注册逻辑 + 回调处理)
- RiskControlManager: 30 行(Map 管理 + register/unregister)

// 新方案:约 50 行
- setup.ts: 20 行(全局方法定义 + 事件发布)
- GlobalComponent: 30 行(事件监听 + Promise 处理)
- RiskControlManager: 10 行(直接调用全局方法)

代码量减少约 37.5%

六、使用指南

6.1 在请求拦截器中使用

typescript
// 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 在组件中使用

typescript
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 在页面中集成

typescript
// src/layout/index.tsx
import GlobalComponent from '@/components/GlobalComponent';

const Layout = ({ children }) => {
  return (
    <>
      {children}
      {/* 全局组件(包含验证码) */}
      <GlobalComponent />
    </>
  );
};

七、最佳实践与注意事项

7.1 并发控制

RiskControlManager 已实现并发控制,多个请求同时触发验证时,会复用同一个验证 Promise:

typescript
// 同时触发两个需要验证的请求
const [result1, result2] = await Promise.all([
  request1(), // 触发验证
  request2(), // 复用验证
]);

// 用户只需要验证一次

7.2 超时处理

默认超时时间为 60 秒,超时后自动返回失败:

typescript
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 错误处理

typescript
// 推荐的错误处理方式
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 组件根据平台动态加载实现:

typescript
// 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 事件队列机制

防止页面还未初始化时事件丢失:

typescript
// 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 核心优势

  1. 架构优雅:事件发布订阅模式,调用方与实现方完全解耦
  2. 自动路由:利用小程序/Taro 底层机制,无需手动维护映射表
  3. 代码简洁:减少约 37.5% 代码量,降低维护成本
  4. 类型安全:完整的 TypeScript 类型定义
  5. 并发安全:自动处理并发请求,避免重复验证
  6. 超时控制:防止用户长时间不操作导致请求挂起

9.2 技术亮点

  1. 利用底层机制__wxExparserNodeId__ 自动生成页面唯一标识
  2. Promise 链式传递:优雅的异步处理方案
  3. 事件队列:防止时序问题导致事件丢失
  4. 单例模式:全局共享 RiskControlManager 实例
  5. 跨平台兼容:通过 Taro 适配层支持多端

9.3 未来优化方向

  1. 应用级单例:将 GlobalComponent 提升到 App 级别,真正实现单实例
  2. 验证结果缓存:短时间内复用验证结果,减少用户操作
  3. 智能降级:验证失败时提供降级方案(如图形验证码 → 短信验证码)
  4. 性能监控:添加验证耗时、成功率等数据埋点

十、参考资料


Last updated: