React 项目集成网页打印控件:Hooks 封装全流程

背景与目标

一个后台管理系统,订单列表页要支持批量打印发货单。第一版直接用 window.print(),打出来是整个页面,侧边栏、按钮、滚动条全在纸上,样式对不上。第二版换成隐藏 iframe,每次打印 document.createElement('iframe'),打完 remove()。功能是通了,但连续打 20 张之后,Chrome 任务管理器里那个标签页内存从 180MB 涨到 400MB 出头,而且 iframe.onload 在 Safari 上偶尔不触发,打印对话框压根不弹。

第三版接打印控件。这类控件(本地服务或纯前端方案都有,比如 https://gitee.com/theGreatOldFive/vue-print-designer 这类设计器配套的打印能力)通常要先加载脚本、等 ready 回调、再调打印接口。问题就集中爆发在这里:

  • React 18 开发模式下 StrictMode 会让 useEffect 跑两遍,控件实例被初始化两次,第二次直接抛「实例已存在」。
  • 打印是异步的,回调里 setState 时组件可能已经卸载,控制台一片 warning。
  • 三个页面都要打印,各自抄了一遍初始化逻辑,改一个参数要改三处。
  • 用户连点两次「打印」,弹出两个对话框,第二张单据内容还是第一张的。

这些坑单看都不难,难的是每个页面都要重踩一遍。所以目标很明确:把「加载控件 → 等就绪 → 调打印 → 处理回调 → 卸载清理」这一整套封成一个 Hook,业务组件里只留一行调用。

理想的调用形态是这样:

jsx 复制代码
function OrderList() {
  const { print, ready, loading } = usePrint({
    template: 'shipping-label',
    onSuccess: (jobId) => message.success(`已提交 ${jobId}`),
  });

  return (
    <Button disabled={!ready} loading={loading} onClick={() => print(selectedRows)}>
      批量打印({selectedRows.length})
    </Button>
  );
}

组件不关心控件是本地服务还是 iframe 方案,不关心脚本什么时候加载完,也不关心卸载时该销毁什么。ready 为 false 时按钮就是灰的,用户点不出错。

具体要达成的几件事:

  1. 实例唯一。无论 StrictMode 双跑、路由来回切几次,全局只保留一个控件实例。用模块级单例 + 引用计数解决,最后一个使用者卸载时才真正销毁。
  2. 加载状态可读 。ready、loading、error 三个状态暴露给组件,按钮的禁用和 loading 直接用,不用每个页面自己写 useState。
  3. 重复点击拦截 。Hook 内部维护一个 printingRef,上一次没结束前第二次调用直接 return,顺手给个提示。
  4. 卸载安全 。useEffect 的 cleanup 里注销回调、清定时器,异步回调统一走一个 mountedRef 判断,杜绝卸载后 setState。
  5. 参数收敛。模板名、纸张、边距、打印机这些走 options,业务侧只传数据。

验收标准也很朴素:三个页面删掉各自的初始化代码,总共少掉约 150 行;连续打印 50 张,内存曲线平稳;StrictMode 开着不报错;路由切走再切回来,打印照常工作。下面按这个目标往下拆实现。

核心做法

实例唯一的坑先踩。StrictMode 下 useEffect 跑两遍,如果初始化写在 effect 里,就会创建两个控件实例,后一个把前一个的 DOM 顶掉,打印回调却还挂在旧实例上------点一次打印出两份,或者干脆没反应。解法是模块级单例加引用计数,而不是依赖 effect 的调用次数。

ts 复制代码
// usePrint.ts(节选)
let instance: PrintControl | null = null;
let refCount = 0;
let initPromise: Promise<PrintControl> | null = null;

async function acquire(): Promise<PrintControl> {
  refCount++;
  if (instance) return instance;
  if (!initPromise) {
    initPromise = loadScript(PRINT_SDK_URL).then(() => PrintControl.create({ autoConnect: true }));
  }
  instance = await initPromise;
  return instance;
}

function release() {
  refCount--;
  if (refCount <= 0 && instance) {
    instance.destroy();      // 注销事件、断开本地服务连接
    instance = null;
    initPromise = null;      // 下次进入页面重新初始化
    refCount = 0;
  }
}

initPromise 单独缓存是关键。两个组件几乎同时挂载时,acquire 会被调用两次,没有这个 promise 就会 create 两次。有了它,第二个调用者拿到的是同一个 Promise 的 resolve 结果。

Hook 主体把五个状态收敛成三个,组件只读不写:

ts 复制代码
export function usePrint(options: PrintOptions = {}) {
  const [ready, setReady] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const printingRef = useRef(false);
  const mountedRef = useRef(true);
  const optionsRef = useRef(options);
  optionsRef.current = options;   // 避免把 options 放进依赖数组

  useEffect(() => {
    mountedRef.current = true;
    let ctrl: PrintControl | null = null;
    acquire()
      .then((c) => {
        ctrl = c;
        if (!mountedRef.current) return;
        c.on('error', (e) => mountedRef.current && setError(e));
        setReady(true);
      })
      .catch((e) => mountedRef.current && setError(e));

    return () => {
      mountedRef.current = false;
      ctrl?.off('error');
      release();
    };
  }, []);

  const print = useCallback(async (rows: Row[]) => {
    if (printingRef.current) return;          // 重复点击直接丢
    if (!instance || !rows.length) return;
    printingRef.current = true;
    setLoading(true);
    try {
      const jobId = await instance.print({
        template: optionsRef.current.template ?? 'default',
        paper: optionsRef.current.paper ?? 'A4',
        margin: optionsRef.current.margin ?? 10,
        printer: optionsRef.current.printer,
        data: rows,
      });
      return jobId;
    } finally {
      printingRef.current = false;
      if (mountedRef.current) setLoading(false);
    }
  }, []);

  return { ready, loading, error, print };
}

几个细节值得说。

optionsRef 是为了让 print 保持稳定引用。模板名、纸张这些配置在组件里多半是内联对象,写进依赖数组会让 useCallback 每次都重建,传给子组件就是一轮无谓重渲染。用 ref 存最新值,print 依赖数组留空。

printingRef 和 loading 分工不同。前者是同步的拦截开关,在 await 之前就置位,连点三次只有第一次进去;后者是 UI 状态,走 React 渲染,有延迟。只靠 loading 拦不住双击,因为两次点击可能发生在同一次渲染周期内。

卸载安全统一走 mountedRef。所有异步回调在 setState 前先判断,比到处写 AbortController 省事。注意 cleanup 里 mountedRef.current = false 要放在最前面,release() 之后可能还有微任务在跑。

如果打印任务耗时长,还可以在 print 里挂一个超时兜底,30 秒没回 jobId 就主动 reject,避免 printingRef 永远卡在 true。这个阈值按实际打印机响应调,本地服务一般是 2 到 5 秒返回。

vue-print-designer 这类控件本身提供 create/destroy 和事件接口,封装层要做的就是把这些命令式 API 翻译成声明式状态,剩下的事组件不该看见。

落地与验证

落地与验证

代码写完只是第一步,真正容易出问题的是「在真实浏览器里跑一遍」。建议按下面的顺序验证,每一步都有明确的通过标准。

第一步:本地服务连通性。 在调用 print 之前,先确认打印服务在监听。打开终端执行:

bash 复制代码
curl -i http://127.0.0.1:18080/health
# 期望 200,返回体含 {"status":"ok"}

如果这里失败,usePrint 里的 error 会一直是网络错误,后面所有验证都没有意义。注意端口要和控件初始化时传的一致,本地服务默认常被改成 18080 或 9090。

第二步:最小可跑页面。 把 Hook 挂到一个只有按钮和状态的组件上,先不接业务表单:

tsx 复制代码
function PrintDemo() {
  const { ready, loading, error, print } = usePrint({
    template: 'order',
    paper: 'A4',
  });

  return (
    <div>
      <button disabled={!ready || loading} onClick={() => print({ orderId: 'T-001' })}>
        {loading ? '打印中...' : '打印'}
      </button>
      {error && <p role="alert">{error.message}</p>}
    </div>
  );
}

通过标准有三条:按钮在 ready 之前是禁用的;点击后按钮文案切换;控制台无报错。

第三步:验证并发拦截。 这是最容易漏的一项。用浏览器 DevTools 的 Network 面板把请求限速到 Slow 3G,然后快速连点五次。通过标准是 Network 里只出现一条打印任务请求,而不是五条。如果出现多条,说明 printingRef 的置位时机晚于 await。

第四步:验证卸载安全。 点击打印后立刻切换路由,或者让父组件 setState 卸载它。控制台不应出现 Can't perform a React state update on an unmounted component 警告。这一条在 React 18 的 StrictMode 下尤其要测,因为开发模式会双调用 effect,mountedRef 的复位逻辑写错就会在这里暴露。

第五步:验证错误路径。 手动把打印服务停掉再点按钮。期望 error 有值、loading 归 false、按钮恢复可点。很多人只测成功路径,结果服务挂了页面卡在「打印中」出不来。

几个注意事项:

  • StrictMode 双挂载 :destroy 必须在 cleanup 里调用,否则开发模式下会留下两个实例,事件回调触发两次。生产构建看不出来,别等上线才发现。
  • 模板名大小写 :部分服务端对模板标识大小写敏感,Order 和 order 是两个模板。建议在 Hook 里统一 toLowerCase()。
  • 超时阈值:前面提到的 30 秒兜底,本地服务实测 2 到 5 秒返回,局域网共享打印机可能到 15 秒。上线前用真实打印机跑一次,把阈值调到实测最大值的 2 倍左右。

最后一步是回归:把 usePrint 接到真实业务页面,用一条完整的订单数据跑通「点打印 → 出纸 → 状态复位」全流程。控件本身的事件回调在这一步才会真正被触发,前面单测覆盖不到。跑通之后,这个 Hook 就可以在项目里复制粘贴了,剩下的页面接入成本基本是改两个配置项。