React Native 扫码事件调度实战:用监听栈解决页面、弹窗与 Sheet 冲突

PDA 扫码接入完成后,真正容易出问题的地方,往往不在原生模块,而在事件进入页面之后。

列表页面需要扫码搜索。打开底部 Sheet 后,Sheet 需要扫描另一个编码。Sheet 上再出现确认弹窗时,扫码又应该交给确认弹窗。原来的页面没有卸载,原来的订阅也可能还在。

这时,一次扫码触发多个回调,并不一定是原生广播发了两次。也可能是 JS 侧同时存在多个有效监听。

上一篇讨论了如何把原生扫码能力包装成 Turbo Module、SDK 和 Hook。这篇继续往上拆一层:扫码结果进入 JS 之后,应该交给谁。

对于按页面和弹层逐层展开的交互,更适合把扫码当成一种需要明确归属的输入,而不是广播给所有页面的通知。

事件归属

React Navigation 中,跳转到新页面不等于旧页面卸载。以栈导航为例,旧页面可以继续保留在栈中。因此,只在组件卸载时取消监听,不能完整表达页面是否应该接收扫码。官方生命周期说明也明确区分了组件生命周期与导航聚焦状态。

isFocused 能解决一部分问题,但它描述的是导航页面是否聚焦。页面内打开一个普通 Modal 或 Sheet,并不一定改变页面的聚焦状态。

继续在页面里叠加条件也能实现:

tsx 复制代码
const canScan = isFocused && !sheetVisible && !confirmVisible && !submitting;

问题在于,每新增一层交互,都需要回头修改其它监听方。某个分支漏掉条件,后台页面就可能收到不属于它的编码。

更推荐集中定义一条规则:同一时刻,一次硬件扫码最多交给一个接收方。页面负责声明自己何时参与,调度层负责决定归属。

监听栈

如果交互按"页面 → Sheet → 确认弹窗"的顺序展开,可以用一个后进先出的监听栈表达接管关系。

text 复制代码
原生扫码事件
    ↓
根级唯一订阅
    ↓
监听栈:页面 → Sheet → 确认弹窗
                           ↑
                      只检查栈顶

栈里存放的是接收方,不是待处理的扫码结果。它不是消息队列,也不会保存扫描历史。

一次交互可以表示为:

交互状态 栈内接收方(从底到顶) 扫码处理者
页面显示 页面 页面
打开 Sheet 页面、Sheet Sheet
Sheet 正在提交 页面、禁用的 Sheet 不处理,也不向下传递
Sheet 关闭并注销 页面 页面

这里有一个需要提前确定的语义:栈顶暂时禁用时,不能自动退回下层。

例如,Sheet 已收到编码并正在提交。此时再次扫码,通常应该忽略。如果调度器继续寻找下面的可用监听,编码就会被页面当成搜索条件处理。

所以,"离开当前交互"和"当前交互暂时不能扫描"是两件事,需要分别表达。

注册与激活

接收方使用两个状态:

  • registered:是否参与当前扫码交互。为 false 时从栈中移除。
  • enabled:已经参与,但当前是否允许处理。为 false 时保留位置,阻止向下传递。

页面失去焦点、弹层完成关闭,属于注销。弹层仍然显示但请求尚未结束,属于临时禁用。

这两个状态不能简单合并成一个 enabled。否则很难区分"让下层恢复扫码"和"继续占用输入,但暂时不处理"。

下面只保留结果分发的核心。错误事件可以沿用同一归属规则;硬件初始化、厂商协议和资源释放仍由扫码 SDK 负责。

ts 复制代码
// scanRouter.ts
export type ScanEvent = { data: string };

type Owner = {
  label: string;
  enabled: boolean;
  onScan: (event: ScanEvent) => void;
};

export const createScanRouter = () => {
  const entries: Array<Owner & { id: symbol }> = [];

  return {
    push(owner: Owner) {
      const id = Symbol(owner.label);
      entries.push({ ...owner, id });
      return id;
    },

    update(id: symbol, patch: Pick<Owner, 'label' | 'enabled'>) {
      const entry = entries.find(item => item.id === id);
      if (entry) Object.assign(entry, patch);
    },

    remove(id: symbol) {
      const index = entries.findIndex(item => item.id === id);
      if (index >= 0) entries.splice(index, 1);
    },

    dispatch(event: ScanEvent) {
      const top = entries[entries.length - 1];
      if (!top || !top.enabled) return false;
      top.onScan(event);
      return true;
    },
  };
};

export const scanRouter = createScanRouter();

update 不改变栈顺序。输入框文字变化、请求状态变化、回调引用变化,都不应该让一个旧页面重新取得最高优先级。

remove 按唯一 ID 删除,而不是直接 pop()。页面可能因为导航重置而先于上层弹窗卸载,注销顺序不一定等于入栈顺序的反序。

重复删除会被忽略。对生命周期清理来说,这比依赖"只会调用一次"更稳妥。

原生订阅

业务页面不再分别订阅原生事件,只在根部保留一个入口:

tsx 复制代码
import { useEffect } from 'react';
import { DemoScan } from './DemoScan';
import { scanRouter } from './scanRouter';

export const ScanEventProvider = () => {
  useEffect(() => {
    const subscription = DemoScan.onScanResult(event => {
      scanRouter.dispatch(event);
    });

    return () => subscription.remove();
  }, []);

  return null;
};

这里假设 DemoScan 已完成初始化,并提供返回可取消订阅对象的 onScanResult。这个组件只负责分发,在应用根部挂载一次。其卸载只移除自己的订阅,不替代 SDK 的整体销毁流程。

所有业务监听都需要经过同一个调度入口。只要还有页面直接订阅原生 SDK,就可能绕过这条互斥规则。

应用进入后台时是否接收硬件输入,也应该由这一层统一决定。路由仍然聚焦,不代表应用仍在前台;如果产品只允许前台扫描,可以在分发前结合 AppState增加总开关。

Hook 生命周期

Hook 需要同时处理两件事:让注册跟随交互生命周期,让回调始终使用最近一次提交的状态。

这两件事不能共用一个不断重新执行注册的 Effect。否则回调一更新,旧接收方就可能被移除后重新压到栈顶。

tsx 复制代码
// useScanOwner.ts
import { useLayoutEffect, useRef } from 'react';
import { scanRouter, type ScanEvent } from './scanRouter';

type Options = {
  registered: boolean;
  enabled?: boolean;
  label: string;
};

export const useScanOwner = (
  onScan: (event: ScanEvent) => void,
  { registered, enabled = true, label }: Options,
) => {
  const latest = useRef({ onScan, enabled, label });
  const idRef = useRef<symbol | null>(null);

  useLayoutEffect(() => {
    latest.current = { onScan, enabled, label };
    if (idRef.current) {
      scanRouter.update(idRef.current, { enabled, label });
    }
  }, [onScan, enabled, label]);

  useLayoutEffect(() => {
    if (!registered) return;

    const id = scanRouter.push({
      label: latest.current.label,
      enabled: latest.current.enabled,
      onScan: event => latest.current.onScan(event),
    });
    idRef.current = id;

    return () => {
      scanRouter.remove(id);
      if (idRef.current === id) idRef.current = null;
    };
  }, [registered]);
};

注册时放入栈中的函数保持稳定,真正执行时再读取 latest.current。普通重渲染只更新回调和元信息,不重新排队。

示例在提交阶段更新 ref,而不是在每次 render 中直接改写它。React 的 useRef 文档提醒,除初始化外,不应在渲染期间读写 ref。尚未提交的渲染结果,也不应提前暴露给外部事件回调。

这里的 useLayoutEffect 只做很短的同步更新,用来让接收方状态紧跟界面提交。不要在里面发请求或处理大量数据;官方文档也说明了它对渲染时机的影响。这并不意味着所有订阅都应该改成 layout Effect。

清理使用本次注册取得的 id。即使开发环境出现额外的 setup → cleanup → setup,也应该回到正确状态,而不是累积监听。React 的 Effect 检查机制正好可以帮助暴露这类问题。

页面与弹层

以一个页面打开编码确认 Sheet 为例,页面通常先注册,Sheet 在打开时再注册。

页面中的调用:

tsx 复制代码
useScanOwner(event => searchItems(event.data), {
  registered: isFocused,
  enabled: !searching,
  label: 'item-search',
});

Sheet 中的调用:

tsx 复制代码
useScanOwner(event => verifyCode(event.data), {
  registered: ownerFocused && visible,
  enabled: !submitting,
  label: 'code-confirm-sheet',
});

ownerFocused 由所属页面传入。即使 Sheet 被 Portal 渲染到其它位置,它也应该跟随所属页面退出,而不是靠自身是否挂载来猜测归属。

这里的 visible 需要覆盖完整交互周期。如果组件在退出动画开始时就把它设为 false,应另外维护接管状态,在组件提供的关闭完成回调中注销,不要靠猜测动画时长来恢复下层输入。

示例中的 searchItemsverifyCode 表示已经处理了错误的业务入口。调度器不等待 Promise,也不接管接口请求;一次事件交出去之后,不会因为业务失败再交给下层重试。

隐藏但保留挂载的弹层,必须让 registered 变为 false。仅设置 enabled: false 会继续占据栈顶,导致页面恢复可见后仍然无法扫码。

这里也存在一个边界:监听栈按照注册顺序工作,不会读取 zIndex,也不会自动理解 JSX 的嵌套关系。

如果页面与一个默认打开的弹层在同一轮提交里同时注册,不能依赖父子 Effect 的执行顺序推导视觉优先级。多窗口、预挂载弹层等场景,更适合由页面统一编排接管顺序,或者升级成带显式层级的调度器。不要把普通的后进先出包装成通用弹层管理器。

连续扫描

监听栈只保证一个事件最多分发给一个接收方,不保证一次业务只会提交一次。

两个扫码事件可能在 submitting 的状态更新提交之前连续到达。如果不允许并行处理,还需要在业务入口加同步保护:

tsx 复制代码
const busyRef = useRef(false);

const handleCode = async (code: string) => {
  if (busyRef.current) return;
  busyRef.current = true;
  setSubmitting(true);

  try {
    await submitCode(code);
  } catch (error) {
    handleSubmitError(error);
  } finally {
    busyRef.current = false;
    setSubmitting(false);
  }
};

这里选择的是忙碌时忽略后续扫描,适用于一次只允许处理一个编码的确认交互。如果每次扫描都必须保留,应另建结果队列,而不是修改监听栈去缓存事件。

同样,也不适合在调度层默认按编码去重。连续扫描相同编码,在某些场景下是合法操作。去重规则和后端幂等应留在业务层确定。

相机结果回传

硬件扫码是持续发生的外部事件,适合交给当前接收方。相机扫码通常由某个输入组件主动打开,是一次有明确发起方的请求。

这两条路径可以复用同一个 handleCode,但不必共用同一种路由方式。

例如,输入组件打开相机页时创建一个 requestId。相机识别成功后,按这个 ID 将结果交回发起方,再关闭相机页。取消、退出页面或请求已经失效时,清理对应记录。

请求 ID 必须唯一且不复用。回传时找不到对应记录,就忽略结果,不再尝试交给其它接收方。

回传记录应先移除,再调用结果回调,防止重复识别导致重复交付。取消也必须匹配 ID,避免旧相机页面的清理误删后来创建的请求。

不要在相机页面返回时,直接把结果派发给"此刻的栈顶"。导航切换过程中,栈顶未必还是打开相机的那个输入组件。

统一的是最终业务入口,不是强行把两种输入来源塞进同一套生命周期。

诊断信息

只有 enabled 布尔值,很难解释"为什么这次没有响应"。更有用的是记录接收方的变化。

调试时可以保留少量结构化信息:事件序号、接收方 ID、label、注册或注销动作、当前栈顶以及是否禁用。这样可以区分事件根本没进入 JS、没有注册接收方、被栈顶阻断,以及已经进入业务回调这几种情况。

label 应由调用方提供。item-searchcode-confirm-sheet 比随机 ID 更容易定位,但不要把真实编码、账号或接口响应直接放进日志。

如果需要保留最近一段记录,可以使用有长度上限的缓冲区。开发开关默认关闭,诊断数据只保留排查所需的元信息,不必为每一次扫描同步写入完整历史。

验证范围

调度核心是一个不依赖 React Native 的 JavaScript 状态容器,可以先验证它自己的规则,不需要依赖真实 PDA。

下面用 Jest 风格的断言验证临时禁用不会向下传递:

ts 复制代码
const router = createScanRouter();
const calls: string[] = [];
router.push({
  label: 'page',
  enabled: true,
  onScan: () => calls.push('page'),
});
const sheetId = router.push({
  label: 'sheet',
  enabled: true,
  onScan: () => calls.push('sheet'),
});

router.dispatch({ data: 'DEMO-001' });
router.update(sheetId, { label: 'sheet', enabled: false });
router.dispatch({ data: 'DEMO-002' });
expect(calls).toEqual(['sheet']);

router.remove(sheetId);
router.dispatch({ data: 'DEMO-003' });
expect(calls).toEqual(['sheet', 'page']);

核心还应覆盖非栈顶删除、重复清理,以及重新注册后取得栈顶这些情况。Hook 层需要单独验证回调更新不改变优先级、重渲染能读到新回调、失焦能注销、卸载不残留监听。

核心单元测试证明的是分发规则,不是整个扫码链路。真机仍需验证页面切换、弹层关闭动画、应用前后台切换,以及相机取消后重新打开等时序。

适用边界

只有一个扫码页面时,带焦点控制的订阅 Hook 通常已经够用。监听栈的价值,出现在多个仍然挂载的交互层开始竞争同一个硬件输入之后。

这层抽象不处理接口重试,不替代业务幂等,也不承担弹窗状态管理。它只回答两个问题:当前谁有权接收扫码,以及这个接收方暂时不能处理时,事件应不应该继续向下传递。

把这两个边界固定下来,新增扫码页面时就不必同步修改所有已有页面的禁用条件。原生模块负责提供输入,调度层负责分配输入,业务页面只处理属于自己的那一次事件。

相关推荐
光影少年5 小时前
react navite封装一个RN 通用按钮组件
前端·javascript·react native·react.js·前端框架
杉氧1 天前
页面栈与路由:React Navigation 与 Expo Router 深度实践
android·前端·react native
光影少年1 天前
react navite高频手写/实操题
前端·javascript·react native·react.js·前端框架
杉氧2 天前
状态管理变迁史:为什么我们放弃了 Redux 选择 Zustand?
android·前端·react native
光影少年3 天前
react navite 安卓iOS 打包、签名、环境区分
前端·react native·react.js
墨狂之逸才3 天前
React Native 环境变量方案选型:自定义、react-native-dotenv 与 react-native-config
react native
杉氧3 天前
React 灵魂:深入剖析 Hooks 与副作用(Side Effects)陷阱
android·前端·react native
光影少年4 天前
react navite调试方案:Flipper、远程调试
前端·javascript·react native·react.js·前端框架