背景与目标
一个后台管理系统,订单列表页要支持批量打印发货单。第一版直接用 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 时按钮就是灰的,用户点不出错。
具体要达成的几件事:
- 实例唯一。无论 StrictMode 双跑、路由来回切几次,全局只保留一个控件实例。用模块级单例 + 引用计数解决,最后一个使用者卸载时才真正销毁。
- 加载状态可读 。
ready、loading、error三个状态暴露给组件,按钮的禁用和 loading 直接用,不用每个页面自己写useState。 - 重复点击拦截 。Hook 内部维护一个
printingRef,上一次没结束前第二次调用直接 return,顺手给个提示。 - 卸载安全 。
useEffect的 cleanup 里注销回调、清定时器,异步回调统一走一个mountedRef判断,杜绝卸载后 setState。 - 参数收敛。模板名、纸张、边距、打印机这些走 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 就可以在项目里复制粘贴了,剩下的页面接入成本基本是改两个配置项。