StreamPDFStreamPDF
Quay lại blog

PDF.js Khám phá sâu từ Demo 3: Toàn cảnh phương thức getPage

27 tháng 10, 2026 · StreamPDF Team

PDF.js Khám phá sâu từ Demo 3: Toàn cảnh phương thức getPage

Lời nói đầu

Trong bài viết trước, chúng ta đã lấy được biến pdf qua await loadingTask.promise, nó là một thực thể của PDFDocumentProxy.

Quay lại demo của bài thứ nhất, dòng 8-9:

const page = await pdf.getPage(1);

Truyền vào tham số số:

  • pageNumber: số trang, trang đầu tiên là 1. Giá trị là 1

getPage trả về một Promise, resolve ra đối tượng PDFPageProxy. Bài này theo lời gọi này đi vào mã nguồn.

Lời gọi getPage

Dựa trên định nghĩa PDFDocumentProxy trong api.js, luồng thực thi mã như sau.

Dòng 773-775: PDFDocumentProxy.getPage: một dòng ủy quyền

getPage(pageNumber) {
  return this._transport.getPage(pageNumber);
}

Bản thân đối tượng pdf chỉ là lớp mặt tiền, logic thực sự nằm trên this._transport — tức là thực thể WorkerTransport tạo ra ở bài 2 (task._transport). Toàn bộ luồng dưới đây đều nằm trong WorkerTransport.getPage.

Dòng 3031-3037: Kiểm tra tham số

if (
  !Number.isInteger(pageNumber) ||
  pageNumber <= 0 ||
  pageNumber > this.pagesMapper.pagesNumber
) {
  return Promise.reject(new Error("Invalid page request."));
}

Thay giá trị của demo vào:

  • Number.isInteger(1) là true
  • 1 > 0
  • this.pagesMapper.pagesNumber là 756 — được ghi từ pdfInfo.numPages khi tài liệu sẵn sàng. PDF demo của bài này có tổng cộng 756 trang, 1 <= 756

Kiểm tra pass. Nếu truyền vào 0, số âm, số thập phân, hoặc vượt quá số lượng trang, tại đây sẽ trả về promise bị reject ngay lập tức.

H: Tại sao dùng Promise.reject thay vì throw? Đ: Để giữ nhất quán ngữ nghĩa bất đồng bộ. Người gọi getPage luôn await một promise, lỗi tham số cũng đi qua cùng một kênh lỗi, người gọi không cần phải đồng thời xử lý hai trường hợp throw đồng bộ và reject bất đồng bộ.

Dòng 3038-3039: Quy đổi số trang

const pageIndex = pageNumber - 1;                        // 1 - 1 = 0
const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0

Tại đây xuất hiện hai hệ thống đánh số:

  • pageNumber: số trang theo góc nhìn người dùng, bắt đầu từ 1, trực giác
  • pageIndex: số trang theo góc nhìn bên trong, bắt đầu từ 0, dùng làm chỉ số mảng và khóa cache

pagesMapper.getPageId(1) trả về 1. PagesMapper được tạo với new PagesMapper() khi phân tích tham số ở bài 2, nó duy trì ánh xạ giữa「số trang người dùng nhìn thấy」và「ID trang tài liệu gốc」. Demo chưa từng di chuyển, xóa hoặc sao chép trang, nên ánh xạ là ánh xạ đồng nhất (#pageNumberToId là null, getPageId trả về nguyên tham số đầu vào), do đó newPageIndex === pageIndex === 0.

H: Tại sao cần PagesMapper làm vòng quay này? Đ: Để hỗ trợ sắp xếp lại trang. Trong trường hợp trình soạn thảo, người dùng có thể di chuyển, xóa, sao chép trang, sau đó「trang 3」không còn nhất thiết là trang 3 của tài liệu gốc. Phía worker chỉ biết thứ tự trang của tài liệu gốc, nên cái được gửi đến worker là newPageIndex; còn cache luồng chính dùng pageIndex theo góc nhìn người làm khóa. Dưới ánh xạ đồng nhất cả hai giống nhau, có thể bỏ qua.

Dòng 3041-3044: Truy vấn cache

const cachedPromise = this.#pagePromises.get(0);
if (cachedPromise) {
  return cachedPromise;
}

#pagePromises là Map<pageIndex, Promise<PDFPageProxy>>. Lần gọi đầu tiên không trúng, tiếp tục đi xuống.

H: Hiệu ứng của cache là gì? Đ: Thực tế thử nghiệm gọi await pdf.getPage(1) hai lần liên tiếp, trả về là cùng một đối tượng PDFPageProxy (page === page2 là true). Vì lần gọi thứ hai trực tiếp trúng promise trong cache, không gửi thêm tin nhắn đến worker nữa.

Dòng 3045-3048: Gửi tin nhắn GetPage đến worker

const promise = this.messageHandler
  .sendWithPromise("GetPage", {
    pageIndex: 0, // newPageIndex
  })
  .then(pageInfo => { ... });
this.#pagePromises.set(0, promise);
return promise;

messageHandler là MessageHandler tạo ra ở bài 2. Cơ chế giao tiếp của sendWithPromise sẽ được trình bày ở bài viết riêng. Ở đây chỉ cần biết: nó gửi tin nhắn đến luồng worker, và trả về một promise đợi回执 của worker.

Khi worker nhận được GetPage (core/worker.js):

handler.on("GetPage", async function ({ pageIndex }) {
  const page = await pdfManager.getPage(pageIndex);
  const [rotate, ref, userUnit, view] = await Promise.all([
    pdfManager.ensure(page, "rotate"),
    pdfManager.ensure(page, "ref"),
    pdfManager.ensure(page, "userUnit"),
    pdfManager.ensure(page, "view"),
  ]);
  return {
    rotate,
    ref,
    refStr: ref?.toString() ?? null,
    userUnit,
    view,
  };
});

Cách worker parse đối tượng trang bên trong sẽ được trình bày ở bài viết riêng.回执 của trang đầu tiên của demo là:

pageInfo = {
  rotate: 0,
  ref: { num: 4, gen: 0 }, // sau khi structured cloning xuyên luồng, ref suy giảm thành đối tượng thường
  refStr: "4 0 R",
  userUnit: 1,
  view: [0, 0, 595, 842],
}

H: Tại sao chỉ lấy 4 thuộc tính này? Đ: Chúng đều là metadata của trang: góc quay, tham chiếu đối tượng, hệ số quy đổi đơn vị, khu vực nhìn thấy. Đọc chúng chỉ cần parse từ điển đối tượng trang, không đụng vào luồng nội dung trang. Việc parse nội dung thực sự (lệnh vẽ) được trì hoãn đến tin nhắn GetOperatorList tại thời điểm render. Đây là lý do getPage có thể trả về nhanh chóng.

Bốn thuộc tính này sau đó lần lượt tương ứng với các getter trên PDFPageProxy: rotate, ref, userUnit, view. Bài tiếp theo của getViewport sẽ sử dụng hai trong số chúng.

Dòng 3049-3540: Tạo PDFPageProxy

Khi回执 đến, vào callback .then:

.then(pageInfo => {
  if (this.destroyed) {
    throw new Error("Transport destroyed");
  }
  if (pageInfo.refStr) {
    this.#pageRefCache.set("4 0 R", 0);
  }
  const page = new PDFPageProxy(0, pageInfo, this, this.pagesMapper, false);
  this.#pageCache.set(0, page);
  return page;
});

Tuần tự làm ba việc:

  1. Kiểm tra destroyed: trong khi đợi回执, tài liệu có thể đã bị hủy (loadingTask.destroy()), nếu có thì ném lỗi;
  2. Cache tra cứu ngược #pageRefCache: "4 0 R" → 0. Để getPageIndex(ref) tra cứu ngược — ví dụ khi mục lục nhảy đến trang, bên trong PDF dùng tham chiếu đối tượng (4 0 R) định vị trang, cần tra cứu ngược lại ra số trang;
  3. Tạo PDFPageProxy và ghi vào #pageCache.

Hàm khởi tạo PDFPageProxy chỉ thực hiện gán trường, không có bất kỳ việc parse nào:

this._pageIndex = 0;
this._pageInfo = pageInfo;
this._transport = transport;
this._stats = null; // pdfBug là false, không bật thống kê hiệu năng
this.commonObjs = transport.commonObjs; // vùng chứa tài nguyên dùng chung toàn tài liệu (phông chữ v.v.)
this.objs = new PDFObjects(); // vùng chứa tài nguyên riêng của trang này
this._intentStates = new Map(); // cache trạng thái ý định render, render ở bài 5 sẽ用到
this.destroyed = false;

commonObjs và objs là vùng chứa tài nguyên khi render sau này, PDFObjects sẽ được trình bày ở bài viết riêng.

Đến đây, promise của getPage(1) resolve ra page, await trong demo nhận được nó.

Tổng kết

Trong phần này, chúng ta theo dõi demo đi qua toàn bộ lời gọi getPage(1):

  1. PDFDocumentProxy.getPage một dòng ủy quyền cho WorkerTransport.getPage;
  2. Kiểm tra tham số: 1 là số trang hợp lệ (tổng cộng 756 trang);
  3. Quy đổi số trang: pageNumber(1) → pageIndex(0) → newPageIndex(0), dưới ánh xạ đồng nhất cả ba giống nhau;
  4. Truy vấn cache #pagePromises, lần đầu không trúng;
  5. Gửi tin nhắn GetPage đến worker, nhận 4 metadata của trang;
  6. Ghi #pageRefCache, tạo PDFPageProxy, ghi vào #pageCache và #pagePromises;
  7. Trả về promise, await của demo nhận được page.

Trong quá trình, chúng ta cố ý bỏ qua hai「nhân vật quan trọng」: giao tiếp xuyên luồng của MessageHandler.sendWithPromise, và parse trang pdfManager.getPage phía worker. Mỗi cái sẽ có bài viết riêng.

Sau khi có page, bước tiếp theo của demo là page.getViewport({ scale: 0.5 }) — nó sẽ用到 view và userUnit trong pageInfo của bài này. Bài tiếp theo sẽ trình bày.

Tiếp tục PDF.js Khám phá sâu từ Demo