PDF.js 源码解读: api
2026年10月27日 · StreamPDF Team

前言
这是 PDF.js 源码解读系列中关于「展示层入口」的一篇,介绍 src/display/api.js(约 3560 行)。用户 import * as pdfjsLib from "pdf.mjs" 拿到的几乎全部名字——getDocument、PDFDocumentProxy、PDFPageProxy、PDFWorker、RenderTask——都定义在这一个文件里。
「从 demo 深入浅出」系列已经按调用链走过这些方法的实例化路径:第 2 篇跟随 getDocument 的参数展开与握手,第 3 篇跟随 getPage,第 5 篇跟随 render。本篇换一个视角:不按某一次调用走,而是把整个文件的结构完整拆开,回答「这个文件由哪几部分组成、每部分承担什么职责、相互之间怎么咬合」。
一句话概括 api.js 的职责:它是主线程的门面层——对上给用户一组干净的代理类与任务句柄,对下把每一个方法调用翻译成 MessageHandler 消息发往 worker,并把 worker 推回来的数据安置到正确的缓存与回调里。
文件末尾的导出表就是全文的目录:
export {
build,
getDocument,
PDFDataRangeTransport,
PDFDocumentLoadingTask,
PDFDocumentProxy,
PDFPageProxy,
PDFWorker,
RenderTask,
version,
};九个名字里,build 与 version 是构建期注入的常量字符串,剩下七个是运行时 API。
模块总览
api.js 可以切成八块:
| 区块 | 大致行数 | 是否导出 | 职责 |
|---|---|---|---|
getDocument | ~240 | 是 | 参数解析、worker 启动、握手、网络流选择 |
PDFDocumentLoadingTask | ~110 | 是 | 加载过程的用户句柄:promise / onProgress / destroy |
PDFDataRangeTransport | ~60 | 是 | 自定义 range 加载的抽象基类(用户继承) |
PDFDocumentProxy | ~450 | 是 | worker 端 PDFDocument 的主线程代理 |
PDFPageProxy | ~720 | 是 | worker 端 PDFPage 的主线程代理,渲染状态机 |
PDFWorker | ~330 | 是 | worker 的创建、握手、假 worker 回退 |
WorkerTransport | ~840 | 否 | 消息路由中枢,页面与对象缓存 |
RenderTask / InternalRenderTask | ~280 | 是 / 否 | 渲染任务的用户句柄与调度循环 |
八块之间的持有关系:
| 持有者 | 持有谁 | 关系 |
|---|---|---|
getDocument | 以上全部 | 把七个类串起来,同步返回 task |
PDFDocumentLoadingTask | _transport、_worker | 加载完成后代理交给用户 |
PDFDocumentProxy | _transport | 所有方法转发给 transport |
WorkerTransport | messageHandler、#pageCache、三个工厂 | 每个方法 = 一条或一组消息 |
PDFPageProxy | _transport、_intentStates | 渲染状态按 cacheKey 分桶 |
RenderTask | _internalRenderTask | 用户可见句柄包内部任务 |
InternalRenderTask | gfx(CanvasGraphics) | 真正驱动 canvas 绘制 |
getDocument:一次性展开的入口
getDocument 的全部工作可以分成五段:建任务、展参数、起 worker、组装参数包、挂握手回调。demo 系列第 2 篇已经带具体值走了一遍,这里只保留结构性的观察。
参数展开:只读一次 src
函数开头把 src 一次性展开成 35+ 个局部变量,之后再也不回读 src:
function getDocument(src = {}) {
const task = new PDFDocumentLoadingTask();
const { docId } = task;
const url = src.url ? getUrlProp(src.url) : null;
const data = src.data ? getDataProp(src.data) : null;
// ... 35+ 个局部变量
const useWorkerFetch =
typeof src.useWorkerFetch === "boolean"
? src.useWorkerFetch
: (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) ||
!!(
BinaryDataFactory === DOMBinaryDataFactory &&
cMapUrl && cMapPacked && standardFontDataUrl && wasmUrl &&
isValidFetchUrl(cMapUrl, document.baseURI) &&
// ...
);每个参数的解析都落在三行以内,但模式各异:有的归一化类型(getUrlProp 把 string 转成 URL),有的语义取反(ignoreErrors = src.stopAtErrors !== true),有的级联依赖前面刚算出的变量(useSystemFonts 依赖 disableFontFace),有的是复合条件(useWorkerFetch)。这一段的规则全集见 demo 系列第 2 篇,此处不重复。
设计要点:入口函数把「用户输入」与「内部状态」的转换集中在一个代码块里完成。后面的 4000 行代码只看到干净的局部变量与参数包,永远不需要再判断「用户到底传没传」。这是 PDF.js 全库的通行做法——校验收敛在边界。
两份参数包:跨线程的与主线程自留的
参数解析完毕后被打包成两份:
const docParams = {
docId,
apiVersion: /* BUNDLE_VERSION */,
data,
password,
disableAutoFetch,
rangeChunkSize,
docBaseUrl,
enableXfa,
evaluatorOptions: {
maxImageSize, disableFontFace, ignoreErrors,
isOffscreenCanvasSupported, isImageDecoderSupported,
canvasMaxAreaInBytes, fontExtraProperties, useSystemFonts,
useWasm, useWorkerFetch, cMapUrl, cMapPacked, iccUrl,
standardFontDataUrl, wasmUrl,
hasGPU: false, // 占位,worker 就绪后回填
},
};
const transportParams = {
ownerDocument,
pdfBug,
styleElement,
enableHWA,
loadingParams: { disableAutoFetch, enableXfa },
};docParams 经 postMessage 发往 worker,因此里面所有值必须可序列化;transportParams 只留在主线程,所以可以装 ownerDocument 这样的 DOM 对象。evaluatorOptions.hasGPU 先占位为 false,等 gpuPromise 与 worker 握手都完成后回填真实值——GPU 探测与 worker 启动是并行的,谁也不等谁。
网络流三分支
data、range、url 三个参数互斥,优先级即代码顺序:
let networkStream;
if (data) {
// 整份 PDF 已在内存,不需要网络流
} else if (rangeTransport) {
networkStream = new PDFDataTransportStream({
pdfDataRangeTransport: rangeTransport, disableRange, disableStream,
});
} else if (url) {
const NetworkStream = getNetworkStream(url); // 优先 fetch,回退 XHR
networkStream = new NetworkStream({
url, httpHeaders, withCredentials, rangeChunkSize, disableRange, disableStream,
});
} else {
throw new Error("getDocument - expected either `data`, `range`, or `url` parameter.");
}getNetworkStream 按环境选择实现:http(s) 走 PDFFetchStream,不支持的旧环境回退 PDFNetworkStream(XHR)。data 分支连流都不建——字节已经齐了,worker 直接通过 GetDocRequest 的 transfer 拿到 data.buffer 的所有权,主线程侧的那份随即失效。
握手时序与竞态
Promise.all([worker.promise, gpuPromise])
.then(function ([, hasGPU]) {
if (worker.destroyed) throw new Error("Worker was destroyed");
docParams.evaluatorOptions.hasGPU = hasGPU;
const workerIdPromise = worker.messageHandler.sendWithPromise(
"GetDocRequest", docParams, data ? [data.buffer] : null
);
// ... 建 networkStream
return workerIdPromise.then(workerId => {
const messageHandler = new MessageHandler(docId, workerId, worker.port);
const transport = new WorkerTransport(
messageHandler, task, networkStream, transportParams, transportFactory, pagesMapper
);
task._transport = transport;
if (task.destroyed) throw new Error("Loading aborted");
messageHandler.send("Ready", null);
});
})
.catch(task._capability.reject)
.finally(task._setupCapability.resolve);
return task;注意三件事:
task是同步返回的,整条.then链在后台推进。链上任何一步出错都 rejecttask._capability,即用户await loadingTask.promise等的那个 promise。_setupCapability无论成败都 resolve。它不向用户报告结果,只向destroy()报告「_transport要么已就绪、要么永远不会就绪」——这是destroy与握手竞态的同步点,下文详述。- 挂
task._transport之后还要再查一次task.destroyed:用户可能在握手期间调了destroy(),此时抛错让链路走.catch,有序关闭交给 transport 完成。
PDFDocumentLoadingTask:加载过程的用户句柄
class PDFDocumentLoadingTask {
static #docId = 0;
_capability = Promise.withResolvers();
_setupCapability = Promise.withResolvers();
_transport = null;
_worker = null;
docId = `d${PDFDocumentLoadingTask.#docId++}`;
destroyed = false;
onPassword = null;
onProgress = null;
get promise() {
return this._capability.promise;
}
}docId 由静态计数器生成,第一个文档是 d1。这个 id 会出现在 MessageHandler 的源/目标名里(new MessageHandler(docId, workerId, ...)),多文档并存时靠它区分消息属于哪份文档。
两个 capability 分工明确:
| 字段 | 谁 resolve/reject | 谁等待 | 语义 |
|---|---|---|---|
_capability | transport 的 GetDoc / DocException 处理器 | 用户(task.promise) | 文档就绪或加载失败 |
_setupCapability | getDocument 的 .finally | destroy() | 启动链路已结束(不论成败) |
destroy 的竞态处理
destroy() 是这个小类里最讲究的方法,四步顺序不能乱:
async destroy() {
this.destroyed = true;
// 启动链稍后会用 "Loading aborted" reject _capability;先认领这个 rejection,
// 避免被当成未处理的 promise 拒绝
this._capability.promise.catch(() => {});
try {
// 必须同步设置:后续对共享 workerPort 的 PDFWorker.create() 要立即看到并抛错
if (this._worker?.port) this._worker._pendingDestroy = true;
// 等启动链结束,保证 _transport 已设置,"Terminate" 消息才发得出去
await this._setupCapability.promise;
await this._transport?.destroy();
} catch (ex) {
if (this._worker?.port) delete this._worker._pendingDestroy;
throw ex;
}
this._transport = null;
this._worker?.destroy();
this._worker = null;
}_pendingDestroy 的同步设置对应 issue 16777:如果等 await 之后再标记,事件循环空隙里另一个 getDocument 可能复用同一个 workerPort,拿到一个正在销毁的 worker。失败时回滚标记再抛错——worker 没毁掉,就不该留着「正在销毁」的封条。
PDFDataRangeTransport:推模式的 range 抽象
用户想自己控制 range 请求(比如走自定义协议、加鉴权)时,继承这个抽象类:
class PDFDataRangeTransport {
#capability = Promise.withResolvers();
#listener = null;
constructor(length, initialData, progressiveDone = false, contentDispositionFilename = null) { ... }
onDataRange(begin, chunk) {
this.#listener({ type: "range", begin, chunk });
}
onDataProgressiveRead(chunk) {
this.#capability.promise.then(() => {
this.#listener({ type: "progressiveRead", chunk });
});
}
transportReady(listener) {
this.#listener = listener;
this.#capability.resolve();
}
requestDataRange(begin, end) {
unreachable("Abstract method PDFDataRangeTransport.requestDataRange");
}
abort() {}
}注意两类入口的不对称:
onDataRange直接转发给 listener。range 数据是 transport 通过requestDataRange主动拉来的,能拉到数据说明 listener 早已注册;onDataProgressiveRead/onDataProgressiveDone要先等#capability。渐进数据是用户那边网络就绪后就推过来的,可能早于transportReady被调用;不排队的话这批数据会打在#listener === null上。
设计要点:同样是「数据到了」,拉模式天然有序、推模式必须用 promise 排队。把排队逻辑收在基类里,子类只管拿到数据就调,不用关心时序。
PDFDocumentProxy:三十个转发方法
PDFDocumentProxy 是 worker 端 PDFDocument 的代理,构造时只存两个字段:
class PDFDocumentProxy {
constructor(pdfInfo, transport) {
this._pdfInfo = pdfInfo;
this._transport = transport;
// TESTING / INTERNAL_VIEWER 构建下再动态挂 getRawData 等方法
}
}之后约 30 个方法,绝大多数是一行转发:getPage、getOutline、getMetadata、getAttachments、getJSActions……全部委托给 this._transport 的同名方法。值得单独说的只有四类。
同步 getter:把已就绪的数据直接摊开
numPages、fingerprints、annotationStorage、canvasFactory 等 getter 不发消息——数据在 GetDoc 握手时已经随 pdfInfo 到达主线程。isPureXfa 用了 shadow 工具:
get isPureXfa() {
return shadow(this, "isPureXfa", !!this._transport._htmlForXfa);
}shadow 在首次读取时把值算出来,再用 Object.defineProperty 替换成普通数据属性,之后每次访问都是纯字段读取,不再走 getter。
测试专属的动态方法
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
Object.defineProperty(this, "getNetworkStreamName", {
value: () => this._transport.getNetworkStreamName(),
});
// getXFADatasets / getStartXRefPos / getAnnotArray 同理
}生产构建的预处理会把整个 if 块删掉——PDFJSDev.test("TESTING") 在发布包中是常量 false,方法根本不会出现在实例上。pdf.js 全库的诊断能力都走这个开关,不污染生产 API 表面。
getOptionalContentConfig 的 intent 转换
getOptionalContentConfig({ intent = "display" } = {}) {
const { renderingIntent } = this._transport.getRenderingIntent(intent);
return this._transport.getOptionalContentConfig(renderingIntent);
}字符串 intent 先经 getRenderingIntent 换算成位标志,再随请求发往 worker。这个 renderingIntent 稍后会与 render() 里的做按位与校验——两边 intent 不一致时 render 抛错,防止「按 display 取的配置拿去 print 渲染」。
cleanup 的 XFA 特例
cleanup(keepLoadedFonts = false) {
return this._transport.startCleanup(keepLoadedFonts || this.isPureXfa);
}纯 XFA 文档强制保留已加载的字体。XFA 表单走 HTML 渲染路径,字体靠 DOM 的 @font-face 挂在文档上;清掉字体再渲染会整页回退成系统字体,因此 isPureXfa 时无论如何都 keepLoadedFonts = true。
PDFPageProxy:intentState 分桶的渲染状态机
PDFPageProxy 是全文件状态最重的类。构造时除了页码与页信息,还建立两级对象缓存:
constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
this._pageIndex = pageIndex;
this._pageInfo = pageInfo;
this._transport = transport;
/** @type {PDFObjects} */
this.commonObjs = transport.commonObjs; // 文档级共享:字体、跨页图片
this.objs = new PDFObjects(); // 页面级私有:本页图片与图案
this._intentStates = new Map();
// ...
}commonObjs 与 objs 的分工对应 worker 推数据的两条消息:commonobj(文档级)与 obj(页面级),下文 WorkerTransport 一节会看到。
_intentStates:按 cacheKey 分桶
同一页可能被并发渲染多次:display 一份、print 一份、编辑模式一份。每份的 operatorList、renderTasks 集合、ready capability 都独立,PDF.js 用 cacheKey 把它们分桶:
const intentState = this._intentStates.getOrInsertComputed(cacheKey, makeObj);cacheKey 由 WorkerTransport.getRenderingIntent 合成(intent 位标志 + 注解存储哈希),详见下文。render() 与 getOperatorList() 各自先算 cacheKey,同 key 的调用共享同一份 operatorList——同一页用同一 intent 渲染两次,worker 只解析一遍。
render():立即泵流,就绪后启绘
render() 的执行分两段。第一段同步完成:
render({ canvasContext, canvas = canvasContext.canvas, viewport, intent = "display", ... }) {
const intentArgs = this._transport.getRenderingIntent(intent, annotationMode, ...);
const { renderingIntent, cacheKey } = intentArgs;
this.#pendingCleanup = false; // 有渲染在跑,取消挂起的清理
optionalContentConfigPromise ||=
this._transport.getOptionalContentConfig(renderingIntent);
const intentState = this._intentStates.getOrInsertComputed(cacheKey, makeObj);
if (intentState.streamReaderCancelTimeout) {
clearTimeout(intentState.streamReaderCancelTimeout); // 取消延迟中止
intentState.streamReaderCancelTimeout = null;
}
if (!intentState.displayReadyCapability) {
// 首次渲染:建好 operatorList 容器,立刻开始向 worker 泵取
intentState.displayReadyCapability = Promise.withResolvers();
intentState.operatorList = { fnArray: [], argsArray: [], lastChunk: false, separateAnnots: null };
this._pumpOperatorList(intentArgs);
}
// ... 建 InternalRenderTask,登记进 intentState.renderTasks第二段异步发生:等「operatorList 首块就绪」与「可选内容配置」都到位后,才真正初始化画布:
Promise.all([
intentState.displayReadyCapability.promise,
optionalContentConfigPromise,
]).then(([transparency, optionalContentConfig]) => {
if (this.destroyed) { complete(); return; }
if (!(optionalContentConfig.renderingIntent & renderingIntent)) {
throw new Error("Must use the same `intent`-argument ...");
}
internalRenderTask.initializeGraphics({ transparency, optionalContentConfig });
internalRenderTask.operatorListChanged();
}).catch(complete);
return renderTask; // 同步返回,不等任何 promise
}这里有两个设计决定值得记住:
- 取流不等人。
_pumpOperatorList在render()体内同步启动,不等 canvas 就绪。worker 解析与主线程准备画布并行,第一块 operatorList 到达时StartRenderPage消息回执displayReadyCapability,画布通常早已初始化完。 - intent 按位校验。
optionalContentConfig.renderingIntent & renderingIntent为零说明两次调用的 intent 不匹配(比如配置按print取、渲染按display),直接抛错而不是渲染出缺图层的页面。
_pumpOperatorList:把流式接口泵进数组
operatorList 跨线程传输用的是 sendWithStream——worker 解析出一块就推一块:
_pumpOperatorList({ renderingIntent, cacheKey, annotationStorageSerializable, modifiedIds }) {
const { map, transfer } = annotationStorageSerializable;
const readableStream = this._transport.messageHandler.sendWithStream(
"GetOperatorList",
{
pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1,
pageIndex: this._pageIndex,
intent: renderingIntent,
cacheKey,
annotationStorage: map,
modifiedIds,
},
/* queueingStrategy = */ undefined,
transfer
);
const reader = readableStream.getReader();
const intentState = this._intentStates.get(cacheKey);
intentState.streamReader = reader;
const pump = () => {
reader.read().then(({ value, done }) => {
if (done) { intentState.streamReader = null; return; }
if (this._transport.destroyed) return;
this._renderPageChunk(value, intentState); // 追加进 fnArray/argsArray,唤醒各渲染任务
pump();
}, reason => { /* 出错:标记 lastChunk,唤醒并 reject 各 capability */ });
};
pump();
}pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1 这一行值得展开:_pageIndex + 1 是用户眼中的页码,getPageId 把它映射成源文档页码,再 - 1 换回零基索引。页面被重排/删除/复制时(pagesMapper 的职责),用户页码与 worker 页码不再相同,所有跨线程消息都必须经过这层翻译。
_abortOperatorList:100ms 的延迟取消
渲染被取消时(比如用户快速缩放),主线程不会立刻通知 worker 停止解析:
if (reason instanceof RenderingCancelledException) {
let delay = RENDERING_CANCELLED_TIMEOUT; // 100ms
if (reason.extraDelay > 0 && reason.extraDelay < 1000) {
delay += reason.extraDelay;
}
intentState.streamReaderCancelTimeout = setTimeout(() => {
intentState.streamReaderCancelTimeout = null;
this._abortOperatorList({ intentState, reason, force: true });
}, delay);
return;
}注释解释了原因:viewer 里缩放/旋转后马上会以新参数重新 render() 同一页,而 cacheKey 只含 intent 不含 scale——新渲染会复用同一条流。如果一取消就掐断 worker 的解析,重新渲染得从零再解析一遍。留 100ms 窗口,新 render() 进来时 clearTimeout 掉这个延迟任务,流就保住了。超过窗口没人复用,才 force: true 真正取消。
另一层保护在此之前:
if (intentState.renderTasks.size > 0) return;同一 intentState 上还挂着别的渲染任务时,单个任务的错误不掐断流——一次失败的 render() 不能搞坏同页的其他渲染。
cleanup:四个条件缺一不可
页面级清理不是「调了就清」:
#tryCleanup() {
if (!this.#pendingCleanup || this.destroyed) return false;
for (const { renderTasks, operatorList } of this._intentStates.values()) {
if (renderTasks.size > 0 || !operatorList.lastChunk) return false;
}
this._intentStates.clear();
this.objs.clear();
this.#pendingCleanup = false;
return true;
}必须同时满足:有人发起过清理(#pendingCleanup)、页面未销毁、所有 intentState 都没有在跑的渲染任务、所有 operatorList 都已收完末块。render() 完成回调里打印意图会主动置 #pendingCleanup = true 并尝试清理——打印动辄几十页,打完立即释放字图数据,内存才能压住。
PDFWorker:创建、握手与回退
PDFWorker 封装「解析线程」这一资源。它的静态块在类加载时就定好两件事(GENERIC 构建):
static {
if (isNodeJS) {
// Node.js 不支持这里的 Worker 用法,直接禁用
this.#isWorkerDisabled = true;
GlobalWorkerOptions.workerSrc ||= PDFJSDev.test("LIB")
? "../pdf.worker.js"
: "./pdf.worker.mjs";
}
this._isSameOrigin = (baseUrl, otherUrl) => { /* 非同源或非 http 返回 false */ };
this._createCDNWrapper = url => {
const wrapper = `await import("${url}");`;
return URL.createObjectURL(new Blob([wrapper], { type: "text/javascript" }));
};
}_createCDNWrapper 解决的是真实部署里的高频问题:workerSrc 指向 CDN(跨域)时 new Worker(url) 会被浏览器拒绝。包一层同源 blob,由 blob 里的 import() 去拉 CDN 代码——import 不受 worker 脚本的同源限制。
test 握手:验证 transfer 真的可用
#initialize 建好 Worker 与 MessageHandler 后,不是直接宣布就绪,而是先发一个探测消息:
const sendTest = () => {
const testObj = new Uint8Array();
// 验证 postMessage 的 transfer 能力:Uint8Array 到 worker 侧仍是 Uint8Array
messageHandler.send("test", testObj, [testObj.buffer]);
};
sendTest(); // 立即发一次
// worker 的 "ready" 消息到达后再发一次;worker 只处理先到的那条worker 回包 data 为真,才把 #messageHandler / #port / #webWorker 挂上实例并 resolve。回包为假或出错,走 terminateEarly 清理现场并回退到假 worker。两次发送是为了覆盖时序:worker 加载快时立即的 test 就到,慢时由 ready 触发的那次兜底。
假 worker 回退链
真 worker 不可用(Node 环境、创建抛异常、test 失败)时回退到「假 worker」——把 worker 代码加载进主线程跑:
#setupFakeWorker() {
if (!PDFWorker.#isWorkerDisabled) {
warn("Setting up fake worker.");
PDFWorker.#isWorkerDisabled = true; // 后续文档不再尝试真 worker
}
PDFWorker._setupFakeWorkerGlobal.then(WorkerMessageHandler => {
if (this.destroyed) { /* reject */ return; }
const port = new LoopbackPort();
const id = `fake${PDFWorker.#fakeWorkerId++}`;
const workerHandler = new MessageHandler(id + "_worker", id, port);
WorkerMessageHandler.setup(workerHandler, port);
this.#messageHandler = new MessageHandler(id, id + "_worker", port);
this.#resolve();
});
}LoopbackPort 是一对互通的本地端口,消息走同步派发而非 postMessage。对上层的 WorkerTransport 来说接口完全一致——它不知道对端是线程还是回环。_setupFakeWorkerGlobal 用 shadow 缓存加载 promise,全库只动态 import 一次 worker 代码。
create:端口复用与销毁封条
static create(params) {
const cachedPort = this.#workerPorts.get(params?.port);
if (cachedPort) {
if (cachedPort._pendingDestroy) {
throw new Error(
"PDFWorker.create - the worker is being destroyed.\n" +
"Please remember to await `PDFDocumentLoadingTask.destroy()`-calls."
);
}
return cachedPort;
}
return new PDFWorker(params);
}#workerPorts 是 WeakMap,以 workerPort 为键缓存 PDFWorker:传入同一 port 的多次 create 共享同一个 worker。撞上 _pendingDestroy 封条(上文 destroy() 同步设置的那个)直接抛错,错误信息明确告诉你该 await 销毁——这是 issue 16777 的修复。
WorkerTransport:消息路由中枢
WorkerTransport 不导出,却是主线程真正的枢纽:文档级缓存、页面缓存、消息路由表都在它身上。
三组页面缓存
#pageCache = new Map(); // pageIndex -> PDFPageProxy
#pagePromises = new Map(); // pageIndex -> Promise<PDFPageProxy>
#pageRefCache = new Map(); // "12R0" -> pageIndexgetPage 先查 #pagePromises,同一页的并发调用共享同一个 promise,worker 只收一条 GetPage 消息。注意两个索引方向:pageIndex = pageNumber - 1 是用户视角的零基序号(缓存的键),newPageIndex = pagesMapper.getPageId(pageNumber) - 1 才是发给 worker 的源文档序号。页面重排后两者分叉,缓存按用户视角建,消息按源视角发。
#pageRefCache 服务于反向查询:cachedPageNumber(ref) 把 "12R0" 形式的引用字符串(gen 为 0 时省略后缀)映射回页码,纯本地命中,不发消息。
#cacheSimpleMethod:幂等请求的本地缓存
#cacheSimpleMethod(name, data = null) {
return this.#methodPromises.getOrInsertComputed(name, () =>
this.messageHandler.sendWithPromise(name, data)
);
}GetFieldObjects、GetDocJSActions、GetOptionalContentConfig、GetMetadata 这类结果不随调用变化的请求走它:第一次调用发消息,之后直接返回缓存的 promise。反例是 getSignatureData——签名字节体量不小,缓存在 Map 里会跟着文档活一辈子,注释明确写了「不缓存,一次性取用」。
getRenderingIntent:位标志与 cacheKey
render、getOperatorList、getAnnotations、getOptionalContentConfig 都先经它把字符串参数编译成位标志:
let renderingIntent = RenderingIntentFlag.DISPLAY;
switch (intent) {
case "any": renderingIntent = RenderingIntentFlag.ANY; break;
case "print": renderingIntent = RenderingIntentFlag.PRINT; break;
}
switch (annotationMode) {
case AnnotationMode.DISABLE: renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE; break;
case AnnotationMode.ENABLE_FORMS: renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS; break;
case AnnotationMode.ENABLE_STORAGE:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
annotationStorageSerializable = annotationStorage.serializable; // 序列化表单数据随消息走
break;
}
if (isEditing) renderingIntent += RenderingIntentFlag.IS_EDITING;
if (isOpList) renderingIntent += RenderingIntentFlag.OPLIST;
const { ids: modifiedIds, hash: modifiedIdsHash } = annotationStorage.modifiedIds;
const cacheKeyBuf = [renderingIntent, annotationStorageSerializable.hash, modifiedIdsHash];
return { renderingIntent, cacheKey: cacheKeyBuf.join("_"), ... };cacheKey 把「渲染意图 + 表单快照 + 改动集合」压成一个字符串——表单内容变了,key 就变,_intentStates 自然另起一桶重新解析;表单没变,同 intent 的渲染共享流。打印且传了 PrintAnnotationStorage 时改用打印专用存储,与 display 的编辑互不影响。
setupMessageHandler:十三张消息路由
构造函数的末尾调 setupMessageHandler(),注册 worker→主线程方向的全部消息处理器。按职能分组:
数据通道三条
| 消息 | 处理器要点 |
|---|---|
GetReader | 取 networkStream.getFullReader(),sink.onPull 里读一块 enqueue 一块,buffer 以 transfer 送出 |
ReaderHeadersReady | 回 {isStreamingSupported, isRangeSupported, contentLength};两者皆支持时摘掉 fullReader 的 onProgress |
GetRangeReader | 按 [begin, end) 取 range reader;取不到(数据已在全量流里)直接 sink.close(),不重复发请求 |
GetRangeReader 里「取不到就关闭」的分支有完整注释:流式加载时数据可能已被 fullReader 拉下来但还没 postMessage 到 worker,此时再发 range 请求是浪费,worker 等全量流即可。
文档生命周期四条
| 消息 | 处理器要点 |
|---|---|
GetDoc | 设置 pagesMapper.pagesNumber,摘出 htmlForXfa,resolve loadingTask._capability 为 PDFDocumentProxy |
DocException | wrapReason(ex) 后 reject 同一个 capability |
PasswordRequest | 建 #passwordCapability,调用户的 onPassword(updatePassword, code),把回调 promise 返回给 worker |
DataLoaded | 补发一次 100% 的 onProgress,resolve downloadInfoCapability |
PasswordRequest 把「worker 阻塞等密码」翻译成「主线程等用户回调」:updatePassword 收到 Error 就 reject(放弃打开),收到字符串就 resolve {password} 让 worker 重试解密。
对象下发两条
commonobj(文档级)按类型分五路:
messageHandler.on("commonobj", ([id, type, exportedData]) => {
if (this.commonObjs.has(id)) return null; // 去重
switch (type) {
case "Font": {
const font = new FontFaceObject(new FontInfo(exportedData), inspectFont, ...);
this.fontLoader.bind(font)
.catch(() => messageHandler.sendWithPromise("FontFallback", { id }))
.finally(() => {
if (!font.fontExtraProperties) font.clearData(); // 挂进 DOM 后立即释放字体字节
this.commonObjs.resolve(id, font);
});
break;
}
case "CopyLocalImage": /* 跨页复用图片:在页面缓存里找到同 ref 的数据,structuredClone 一份 */ break;
case "FontPath": this.commonObjs.resolve(id, new FontPathInfo(exportedData)); break;
case "Image": this.commonObjs.resolve(id, exportedData); break;
case "Pattern": this.commonObjs.resolve(id, new PatternInfo(exportedData).getIR()); break;
}
return null;
});字体路径最讲究:bind 失败(系统拒绝加载)不直接判死,而是发 FontFallback 让 worker 换内置渲染兜底;成功后 clearData 立即释放可能上 MB 的字体字节,不等文档 cleanup。
obj(页面级)只接 Image / Pattern 两类,但多一道防线:
if (pageProxy._intentStates.size === 0) {
imageData?.bitmap?.close(); // 页面已清理,直接释放 ImageBitmap
return;
}这是 bug 1854145 的修复:页面 cleanup 成功后 worker 才推来的数据不能入库(PDFObjects 已清空,入了也没人释放),直接 close bitmap 防泄漏。
其余:StartRenderPage 唤醒页面对象的 displayReadyCapability;DocProgress 转发加载进度;FetchBinaryData(非 MOZCENTRAL)由 binaryDataFactory 在主线程下载 CMap/字体/wasm——正是 getDocument 里 useWorkerFetch = false 时的主线程下载路径。
saveDocument 与 extractPages:序列化与重映射
保存文档要把主线程的表单改动带回 worker:
saveDocument() {
const { map, transfer } = this.annotationStorage.serializable;
return this.messageHandler.sendWithPromise("SaveDocument", {
isPureXfa: !!this._htmlForXfa,
numPages: this._numPages,
annotationStorage: map,
filename: this.#fullReader?.filename ?? null,
}, transfer).finally(() => {
this.annotationStorage.resetModified(); // 无论成败都清「已改动」标记
});
}extractPages 更复杂:页面经过重排后,注解里记的 pageIndex 是用户视角,worker 按源视角查找,必须经 pagesMapper.getMapping() 翻译;同一源页被抽成多份副本时,再用 copyLevels 标明每条编辑器注解归属第几份副本:
const copyLevel = copyLevels?.[v.pageIndex] ?? 0;
const sourceIdx = mapping[v.pageIndex] - 1;
if (sourceIdx !== v.pageIndex || copyLevel !== 0) {
remapped.set(k, { ...v, pageIndex: sourceIdx, copyLevel });
continue;
}copyLevels 里 -1 表示该页未被抽取——对应条目仍然保留,因为抽出的印章注解可能引用着它的 bitmapId,worker 看到负值会跳过注解本身但保留位图关联。
destroy:先停页面,再停 worker
destroy() {
if (this.destroyCapability) return this.destroyCapability.promise; // 幂等
this.destroyed = true;
this.destroyCapability = Promise.withResolvers();
this.#passwordCapability?.reject(new Error("Worker was destroyed during onPassword callback"));
const waitOn = [];
for (const page of this.#pageCache.values()) waitOn.push(page._destroy()); // 等所有渲染收尾
// ... 清缓存、resetModified
const terminated = this.messageHandler.sendWithPromise("Terminate", null); // 等 worker 长任务结束
waitOn.push(terminated);
Promise.all(waitOn).then(() => {
this.commonObjs.clear();
this.fontLoader.clear();
this.#methodPromises.clear();
this.filterFactory.destroy();
TextLayer.cleanup();
this.#networkStream?.cancelAllRequests(new AbortException("Worker was terminated."));
this.messageHandler?.destroy();
this.messageHandler = null;
this.destroyCapability.resolve();
}, this.destroyCapability.reject);
return this.destroyCapability.promise;
}顺序是「页面 → worker → 本地资源」:先让所有 RenderTask 落定(rAF 回调可能还在排队),再发 Terminate 等 worker 收尾,最后清主线程的字体、滤镜、文本层与网络请求。挂在 onPassword 上的文档会立即收到拒绝,不会悬死。
RenderTask 与 InternalRenderTask:句柄与调度器
render() 同步返回的 RenderTask 只是一层壳,真正的调度在 InternalRenderTask:
class RenderTask {
onContinue = null; // 每次渲染暂停时调用,参数是「继续」函数
onError = null; // 渲染出错/被取消时同步调用
get promise() {
return this._internalRenderTask.capability.promise;
}
cancel(extraDelay = 0) {
this._internalRenderTask.cancel(null, extraDelay);
}
}注意 RenderTask 本身不是 thenable——await page.render(...) 等不到渲染完成,必须 await renderTask.promise。demo 系列第 5 篇专门踩过这个坑。
initializeGraphics:画布只能同时服务一个任务
initializeGraphics({ transparency = false, optionalContentConfig }) {
if (this.cancelled) return;
if (this._canvas) {
if (InternalRenderTask.#canvasInUse.has(this._canvas)) {
throw new Error("Cannot use the same canvas during multiple render() operations. ...");
}
InternalRenderTask.#canvasInUse.add(this._canvas);
}
const canvasContext = this._canvasContext || this._canvas.getContext("2d", {
alpha: false,
willReadFrequently: !this._enableHWA,
});
this.gfx = new CanvasGraphics(canvasContext, this.commonObjs, this.objs, ...);
this.gfx.beginDrawing({ transform, viewport, transparency, background });
this.operatorListIdx = 0;
this.graphicsReady = true;
this.graphicsReadyCallback?.();
}#canvasInUse 是静态 WeakSet:同一 canvas 上的第二次 render() 直接抛错,报错信息里写明了解法(换 canvas 或先取消/等完成)。2D 上下文的两个开关也各有来历:alpha: false 告诉浏览器画布不透明,合成时省一次混合;willReadFrequently: !enableHWA 在开硬件加速时声明「不常读回」,让浏览器放心把画布留在 GPU 侧。
调度循环:rAF 与微任务两条路
_scheduleNext() {
if (this._useRequestAnimationFrame) {
this.#rAF = window.requestAnimationFrame(() => {
this.#rAF = null;
this._nextBound().catch(this._cancelBound);
});
} else {
Promise.resolve().then(this._nextBound).catch(this._cancelBound);
}
}
async _next() {
if (this.cancelled) return;
this.operatorListIdx = this.gfx.executeOperatorList(
this.operatorList, this.operatorListIdx, this._continueBound, this.stepper, this._operationsFilter
);
if (this.operatorListIdx === this.operatorList.argsArray.length) {
this.running = false;
if (this.operatorList.lastChunk) {
this.gfx.endDrawing();
InternalRenderTask.#canvasInUse.delete(this._canvas);
this.callback(); // 回到 PDFPageProxy.render 里的 complete
}
}
}executeOperatorList 每轮最多执行约 15ms(canvas.js 的时间片常量),到点经 continueCallback 回到这里再排下一轮。显示渲染用 requestAnimationFrame 与帧对齐;打印渲染走微任务(useRequestAnimationFrame: !intentPrint)——打印没有帧的概念,微任务排空更快。若用户设了 RenderTask.onContinue,调度权交给用户:每个时间片结束时调 onContinue(continueFn),由用户决定何时调 continueFn 继续,可以借此实现「空闲时再渲染」之类的策略。
每轮结束检查「已执行到数组末尾且已是末块」,成立才 endDrawing 并回调完成;否则说明流还没推完,running = false 挂着,等下一个 chunk 到达时由 operatorListChanged() 重新启动。
版本常量与导出
文件末尾两个常量由构建期注入:
const version = typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_VERSION") : null;
const build = typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_BUILD") : null;PDFJSDev.eval 在打包时被替换成字面量(如 "6.3.289"),源码树里直接跑则为 null。getDocument 组装 docParams 时把 apiVersion 发给 worker,worker 用它校验主从两端版本一致——主线程 API 与 worker 来自不同版本是部署事故的高发区,这一校验能在握手时就炸出来。
总结
api.js 是 PDF.js 主线程的门面:它把 worker 通讯、流式传输、字体装载、画布调度全部藏在七个导出名字背后,用户面对的只有「加载文档 → 取页面 → 渲染」三个动词。
一次完整渲染在本文各节的落点
| 阶段 | 入口 | 关键机制 |
|---|---|---|
| 加载 | getDocument(src) | 参数一次性展开、GetDocRequest 握手、网络流三分支 |
| 等待 | loadingTask.promise | _capability / _setupCapability 双 promise |
| 取页 | pdf.getPage(n) | 三组页面缓存、pagesMapper 页码翻译 |
| 渲染 | page.render(params) | intentState 分桶、流式 operatorList、rAF 时间片 |
| 取消 | renderTask.cancel() | 100ms 延迟中止、同桶任务隔离 |
| 保存 | pdf.saveDocument() | annotationStorage 序列化回传 |
| 销毁 | loadingTask.destroy() | _pendingDestroy 封条、Terminate 收尾 |
六条设计原则
- 校验收敛在边界。
src只在getDocument开头读一次,展开成 35+ 个干净局部变量;内部 4000 行不再面对「用户传没传」。 - 跨线程的数据必须可序列化,主线程自留的随便。
docParams与transportParams的拆分就是按「过不过postMessage」划的线。 - 同步返回句柄,异步推进链路。
getDocument返回task、render()返回renderTask,都是同步的;错误经 capability 异步送达,destroy()与启动链的竞态由_setupCapability与_pendingDestroy两个同步点消解。 - 流式优于整包。operatorList 按块泵、文本按
highWaterMark分段、range 数据随要随取——首屏不等全量解析完。 - 取消要懒,清理要严。渲染取消留 100ms 复用窗口;清理则必须满足「无任务、流已尽」才执行,
obj消息迟到时宁可丢弃 bitmap 也不入库。 - 能力回退不打扰上层。真 worker 不行就假 worker,字体挂了走
FontFallback,LoopbackPort与真端口同构——WorkerTransport以上的代码对降级无感知。
读懂 api.js 之后,展示层的其余文件都可以按「它服务于这里的哪个环节」来定位:message_handler.js 是消息语义的实现,canvas.js 是 executeOperatorList 的算子集,fetch_stream.js / network.js 是网络流三分支的两条腿。它们各自的源码解读,另文展开。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日