StreamPDFStreamPDF
返回博客

PDF.js 源码解读: internal_evt.js

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: internal_evt.js

前言

本篇分析 src/shared/internal_evt.js,除去许可证与同步注释,其实现只有七行。web/internal_evt.js 中还有一个镜像文件。

这个模块不是事件总线,也没有定义任何事件名。它导出两个用于给事件监听器分类的值:

  • INTERNAL_EVT,在预处理阶段选定的 token;
  • internalOpt,包含该 token 的冻结选项对象。

真正的注册与分发逻辑位于 web/event_utils.js。把两侧放在一起读,才能看到完整机制:内部监听器凭构建期 token 获得内部分类,而 EventBus 会先投递同一事件的内部监听器,再投递外部监听器。

模块总览

区块行号职责
同步注释16-18要求本文件与 web/internal_evt.js 保持一致
INTERNAL_EVT20-23选择构建注入 token 或源码/测试回退值
internalOpt25把 token 包装为不可变的监听器选项
导出27向 EventBus 提供 token,向内部调用方提供选项对象

这个设计清晰地区分了职责:本模块拥有能力值,内部组件通过 internalOpt 声明分类,EventBus 再用严格相等比较决定监听器类别。

INTERNAL_EVT:在构建期选择 token

const INTERNAL_EVT =
  typeof PDFJSDev !== 'undefined' && !PDFJSDev.test('TESTING')
    ? PDFJSDev.eval('INTERNAL_EVT')
    : 'internalEvent';

这里有两种模式:

模式选定的值原因
预处理后的非测试构建PDFJSDev.eval("INTERNAL_EVT")注入构建定义提供的值
原始源码或测试构建"internalEvent"不依赖生产构建注入,提供确定性回退值

生产值来自 gulpfile.mjs 中的 DEFINES 对象:

const DEFINES = Object.freeze({
  // Other build flags...
  INTERNAL_EVT: crypto.randomUUID(),
});

Babel 预处理器根据这些定义计算 PDFJSDev.eval,再把表达式替换为结果字面量。因此,发布模块不会在运行时调用 crypto.randomUUID();UUID 在构建产物生成时选定,并在该构建中保持固定。

这个 token 不是内部事件的名称,而是监听器选项中的一种能力式标识。调用方不能只传 { internal: true } 就变成内部监听器,因为 EventBus 会用严格相等比较检查传入值是否等于 INTERNAL_EVT。

固定的测试回退值服务于另一个目的。源码级测试与未预处理导入需要比较双方保持一致,但不能依赖生产预处理流程。它不应与非测试构建使用的随机值混为一谈。

internalOpt:冻结内部注册选项

const internalOpt = Object.freeze({ internal: INTERNAL_EVT });

internalOpt 把 token 变成 EventBus.on 所期望的选项形状。冻结可以防止某个消费者修改共享对象的 internal 属性,进而悄悄影响之后的注册。这里的冻结是浅冻结,但对象只有一个原始值属性,因此已经足够。

内部调用方可以直接传入这个对象,也可以与其他标准选项组合。例如,批注编辑器 UI 管理器注册多个带中止信号的监听器:

const evtOpts = { signal, ...internalOpt };
eventBus.on('editingaction', this.onEditingAction.bind(this), evtOpts);
eventBus.on('pagechanging', this.onPageChanging.bind(this), evtOpts);
eventBus.on('scalechanging', this.onScaleChanging.bind(this), evtOpts);

展开语法把 token 复制到新的选项对象,同时保留 signal。其他调用点也会用同样方式添加 once。这样,生命周期与投递分类可以自由组合,不必为每种组合发明单独的注册 API。

EventBus.on() 与 dispatch():落实内部优先投递

EventBus 记录监听器时,token 才开始发挥作用。在 web/event_utils.js 中,注册逻辑通过与模块契约值的严格比较,保存一个布尔分类:

this.#listeners.getOrInsertComputed(eventName, makeSet).add({
  listener,
  internal: options?.internal === INTERNAL_EVT,
  once: options?.once === true,
  rmAbort,
});

因此,三种情况都会产生外部监听器:没有选项、没有 internal 属性,或者属性值不等于构建 token。只有匹配的 token 才会把保存的 internal 标记设为真。

分发时,EventBus 根据这个标记把同一事件分成两个阶段:

for (const { listener, internal, once } of new Set(eventListeners)) {
  if (once) {
    this.off(eventName, listener);
  }
  if (!internal) {
    (extListeners ??= []).push(listener);
    continue;
  }
  listener(data);
}
if (extListeners) {
  for (const listener of extListeners) {
    listener(data);
  }
}

内部回调在第一轮执行;外部回调先被收集,之后再执行。源码注释直接说明了原因:先让 viewer 组件处理事件并更新状态,再让外部观察者运行。

这里做的是监听器分类与排序,而不是两套独立总线。外部监听器仍然会收到事件。new Set(eventListeners) 创建的快照也能在投递期间增删监听器时保持本轮分发稳定,once 监听器则会在调用前移除。

在原始源码与 GENERIC 分支中,EventBus 构造函数还会调用 Object.seal(this)。源码注释明确把它与“防止第三方覆写方法后获取 INTERNAL_EVT”联系起来。对象密封、构建期随机 token 以及严格比较,共同支撑这套内部/外部契约。

镜像文件:让 library 与 viewer 遵守同一契约

src/shared/internal_evt.js 被 src/display/editor/tools.js 等显示层代码导入;viewer 模块和 web/event_utils.js 则导入 web/internal_evt.js。两个实现完全相同,并且都带有要求维护者保持同步的注释。

构建时,两侧都通过同一个构建定义项解析 INTERNAL_EVT,所以严格比较使用的是同一个 UUID。这意味着显示 library 与 viewer 之间不需要在运行时交换秘密值。对应关系由预处理建立,而重复的固定回退值让直接源码运行与测试也保持一致。

因此,这种重复是有意设计,但不代表两个文件可以独立演化。如果只修改一边的选项形状或回退值,分类所依赖的约定就会被破坏。

总结

这个小模块支撑的是一套精确的事件路由机制,而不是自带一套事件系统:

  1. 非测试构建通过 PDFJSDev.eval 注入随机 UUID;原始源码与测试使用固定的 internalEvent 回退值。
  2. INTERNAL_EVT 是比较 token,不是事件名,{ internal: true } 不能替代它。
  3. internalOpt 把 token 封装为冻结、可复用的选项对象,并能与 signal、once 组合。
  4. EventBus.on 把成功的身份比较转换为内部分类;dispatch 先调用内部监听器,再调用外部监听器。
  5. shared 与 web 两份文件必须保持同步,才能让显示层注册与 viewer 侧分发始终遵守同一契约。

关键认识是:构建期注入与事件排序是同一设计的两半。token 确定哪些监听器属于内部;分发器利用这一信息,在把事件交给外部观察者前先更新 viewer 状态。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章