这篇内容是在有道云旧笔记基础上重新整理的 JavaScript 与前端开发 笔记。原始记录偏碎片化,这里补充了使用场景、阅读顺序和落地时需要注意的边界,方便以后在项目里快速复用。

适用场景 ​

  • 前端业务中需要快速处理数据、DOM、浏览器能力或通用工具函数时参考。
  • 把散落在项目里的小技巧抽成可复用代码片段,减少重复实现。
  • 排查兼容性和边界输入问题前,先用本文示例确认核心思路。

核心要点 ​

  • 正文已经按 「参考资料(我查到的原始来源)」 等小节组织,阅读时可以先定位到当前问题对应的小节。
  • 保留了 7 段可直接参考的代码或命令,主要涉及 javascript、html,复制前需要按当前项目路径、版本和变量名做调整。
  • 文中保留了 2 个导出图片资源,适合和文字步骤一起对照查看。
  • 涉及命令行操作时,建议先在测试目录或测试环境执行,确认输出符合预期后再应用到生产环境。

这里需要注意修改样式要在origin-dom结构里面适配,如果在pdf-dom结果里面更改可能出现分页视觉效果不一致问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
// improved-paginator.js

// ================== 页面构建 ==================
export function defaultCreatePage(pageNumber, totalPages, opts = {}) {
const {
pageClass = 'pdf-page',
pageWidth = 'auto',
pageHeight = 'auto',
headerClass = 'pdf-header',
footerClass = 'pdf-footer',
contentClass = 'pdf-content',
header,
footer,
headerHeight,
footerHeight,
gapBetweenHeaderContentFooter = 0,
} = opts;

const createSection = (tag, cls, height, content) => {
const el = document.createElement(tag);
el.className = cls;
if (height) el.style.height = typeof height === 'number' ? `${height}px` : height;
if (typeof content === 'function') el.innerHTML = content(pageNumber, totalPages);
else if (content) el.innerHTML = content;
return el;
};

const page = document.createElement('div');
Object.assign(page.style, {
width: typeof pageWidth === 'number' ? `${pageWidth}px` : pageWidth,
height: typeof pageHeight === 'number' ? `${pageHeight}px` : pageHeight,
boxSizing: 'border-box',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
});
page.className = pageClass;

const headerEl = createSection('header', headerClass, headerHeight, header);
const footerEl = createSection('footer', footerClass, footerHeight, footer);

const content = document.createElement('div');
content.className = contentClass;
content.style.height = `calc(${pageHeight}px - ${headerEl.style.height || '0px'} - ${footerEl.style.height || '0px'} - ${gapBetweenHeaderContentFooter}px)`;

page.append(headerEl, content, footerEl);

return { page, content, header: headerEl, footer: footerEl, pageNumber, totalPages };
}

// ================== 工具函数 ==================
function formatNumber(num, digits = 0) {
return (typeof num === 'number' && digits >= 2)
? String(num).padStart(digits, '0')
: String(num);
}

function getMeasurementContainer(width) {
let m = document.getElementById('_pagination_measure');
if (!m) {
m = document.createElement('div');
m.id = '_pagination_measure';
Object.assign(m.style, {
position: 'absolute',
visibility: 'hidden',
pointerEvents: 'none',
left: '-99999px',
top: '0px',
overflow: 'visible',
zIndex: -9999,
});
document.body.appendChild(m);
}
if (width) m.style.width = typeof width === 'number' ? `${width}px` : width;
m.innerHTML = '';
return m;
}

function waitImagesLoaded(node) {
const imgs = [...node.querySelectorAll('img')];
if (!imgs.length) return Promise.resolve();

return new Promise((resolve) => {
let done = 0;
const total = imgs.length;
const check = () => (++done === total) && resolve();

imgs.forEach((img) => {
if (img.complete) {
check();
}
else {
img.addEventListener('load', check, { once: true });
img.addEventListener('error', check, { once: true });
}
});
});
}

async function measureNodeHeight(node, measureContainer, wrapperTag = null) {
const clone = node.cloneNode(true);
const wrapper = wrapperTag ? document.createElement(wrapperTag) : null;
const toMeasure = wrapper ? (wrapper.appendChild(clone), wrapper) : clone;

measureContainer.appendChild(toMeasure);
await waitImagesLoaded(toMeasure);

const style = window.getComputedStyle(toMeasure);
const marginTop = Number.parseFloat(style.marginTop) || 0;
const marginBottom = Number.parseFloat(style.marginBottom) || 0;

const rect = toMeasure.getBoundingClientRect();
const totalHeight = rect.height + marginTop + marginBottom;

measureContainer.removeChild(toMeasure);
return totalHeight;
}

// ================== 头/尾高度测量 ==================
async function measureHeaderFooterHeights(opts, measureContainer) {
const measure = async (template, cls) => {
if (!template) return 0;
const tmp = document.createElement('div');
tmp.className = cls;
tmp.innerHTML = typeof template === 'function' ? template(1, 1) : template;
measureContainer.appendChild(tmp);
await waitImagesLoaded(tmp);
const h = tmp.offsetHeight;
measureContainer.removeChild(tmp);
return h;
};

return {
measuredHeaderHeight: opts.headerHeight ?? await measure(opts.header, opts.headerClass || 'pdf-header'),
measuredFooterHeight: opts.footerHeight ?? await measure(
opts.footerTemplate ? opts.footerTemplate(1, 1) : opts.footer,
opts.footerClass || 'pdf-footer',
),
};
}

// ================== 分页核心 ==================
async function paginateList(child, tag, measureContainer, CONTENT_HEIGHT, opts, pages, currentPageObj, outputEl, currentContainer, currentHeight) {
const lis = [...child.children].filter(n => n.nodeType === 1);

// 如果 child 本身就是 module-title 模块,则整个 child 必须放在新的一页顶部
const moduleTitleClass = opts.moduleTitleClass;
const isModuleTitleList = child.classList?.contains(moduleTitleClass);

// 如果是 module-title 模块:把整个 child(作为一个整体)放到新页顶部
if (isModuleTitleList) {
// 如果当前页已有内容,先把当前页推送并新建页
if (currentHeight > 0) {
pages.push(currentPageObj);
({ currentPageObj, currentContainer } = createNewPage(opts, pages, outputEl));
currentHeight = 0;
}

// 把整个 child 作为单个块添加到当前页(当前页此时应该为空)
// 注意用 cloneNode(true) 避免移动原始 DOM
const wholeBlock = child.cloneNode(true);

// 测量整个块高度
const blockHeight = await measureNodeHeight(wholeBlock, measureContainer, tag);

// 如果整个块高度大于 CONTENT_HEIGHT:按“单元素超页”逻辑处理(保持不拆分)
if (blockHeight > CONTENT_HEIGHT) {
// 将它放入当前页(即使高度超出一页),并把该页作为完整页推送
currentContainer.appendChild(wholeBlock);
currentHeight += blockHeight;

pages.push(currentPageObj);
({ currentPageObj, currentContainer } = createNewPage(opts, pages, outputEl));
currentHeight = 0;
}
else {
// 能放下则直接放在当前页(当前页应该是新页)
currentContainer.appendChild(wholeBlock);
currentHeight += blockHeight;
}

// 处理完这个 child(整个模块已被放置),直接返回当前状态(不遍历其子元素)
return { currentPageObj, currentContainer, currentHeight };
}

// ----- 非 module-title 列表:保留原来的逐项分页逻辑 -----
let listWrapperForPage = child.cloneNode(false);
currentContainer.appendChild(listWrapperForPage);

for (const li of lis) {
const liHeight = await measureNodeHeight(li, measureContainer, tag);

// 单个元素超出页面 → 单独成页(保持原逻辑)
if (liHeight > CONTENT_HEIGHT) {
if (listWrapperForPage.children.length) {
pages.push(currentPageObj);
({ currentPageObj, currentContainer } = createNewPage(opts, pages, outputEl));
}
const singleList = child.cloneNode(false);
singleList.appendChild(li);
currentContainer.appendChild(singleList);

pages.push(currentPageObj);
({ currentPageObj, currentContainer } = createNewPage(opts, pages, outputEl));

listWrapperForPage = child.cloneNode(false);
currentContainer.appendChild(listWrapperForPage);
currentHeight = 0;
continue;
}

// 正常分页逻辑:如果放不下则新页
if (currentHeight + liHeight > CONTENT_HEIGHT) {
pages.push(currentPageObj);
({ currentPageObj, currentContainer } = createNewPage(opts, pages, outputEl));
currentHeight = 0;
listWrapperForPage = child.cloneNode(false);
currentContainer.appendChild(listWrapperForPage);
}

listWrapperForPage.appendChild(li);
currentHeight += liHeight;
}

return { currentPageObj, currentContainer, currentHeight };
}

async function paginateElement(child, measureContainer, CONTENT_HEIGHT, opts, pages, currentPageObj, outputEl, currentContainer, currentHeight) {
const elHeight = await measureNodeHeight(child, measureContainer);

const newPageIfNeeded = () => {
pages.push(currentPageObj);
return createNewPage(opts, pages, outputEl);
};

if (elHeight > CONTENT_HEIGHT) {
if (currentHeight > 0) ({ currentPageObj, currentContainer } = newPageIfNeeded());
currentContainer.appendChild(child);
currentHeight = elHeight;
({ currentPageObj, currentContainer } = newPageIfNeeded());
currentHeight = 0;
}
else {
if (currentHeight + elHeight > CONTENT_HEIGHT) {
({ currentPageObj, currentContainer } = newPageIfNeeded());
currentHeight = 0;
}
currentContainer.appendChild(child);
currentHeight += elHeight;
}

return { currentPageObj, currentContainer, currentHeight };
}

function createNewPage(opts, pages, outputEl) {
const pageObj = opts.createPage(pages.length + 1, 0, opts);
outputEl?.appendChild(pageObj.page);
return { currentPageObj: pageObj, currentContainer: pageObj.content };
}

function updatePageHeadersFooters(pages, opts) {
const total = pages.length;
pages.forEach((pg, idx) => {
pg.pageNumber = idx + 1;
pg.totalPages = total;

if (typeof opts.header === 'function') {
pg.header.innerHTML = opts.header(pg.pageNumber, total);
}

if (opts.footerTemplate) {
pg.footer.innerHTML = typeof opts.footerTemplate === 'function'
? opts.footerTemplate(pg.pageNumber, total)
: opts.footerTemplate
.replace('{page}', formatNumber(pg.pageNumber, opts.pageDigitsLength))
.replace('{total}', formatNumber(total, opts.pageDigitsLength));
}
else if (typeof opts.footer === 'function') {
pg.footer.innerHTML = opts.footer(pg.pageNumber, total);
}
else if (opts.footer) {
pg.footer.innerHTML = opts.footer;
}
});
}

// ================== 主函数 ==================
export default async function paginatePdf(outputDomId, sourceDomId, options = {}) {
const outputEl = document.getElementById(outputDomId);
const sourceEl = document.getElementById(sourceDomId);
if (!sourceEl) throw new Error('pdfDom or sourceDom not found');

const opts = {
pageHeight: 1123,
pageWidth: null,
header: '公司报告 - 2025年9月',
footerTemplate: '第 {page} 页 / 共 {total} 页',
createPage: defaultCreatePage,
pageClass: 'pdf-page',
headerClass: 'pdf-header',
footerClass: 'pdf-footer',
contentClass: 'pdf-content',
gapBetweenHeaderContentFooter: 0,
pageDigitsLength: 1,
listSplitSelector: 'div.splits',
moduleTitleClass: 'independent', // 如果所有的divsplits 中又对应的这个类名,他就会新开一页,比如一个模块单独开始显示
...options,
};

const measureContainer = getMeasurementContainer(opts.pageWidth || outputEl.clientWidth || undefined);
const { measuredHeaderHeight, measuredFooterHeight } = await measureHeaderFooterHeights(opts, measureContainer);

const CONTENT_HEIGHT = Math.max(0, opts.pageHeight - measuredHeaderHeight - measuredFooterHeight - (opts.gapBetweenHeaderContentFooter || 0));

const pages = [];
let { currentPageObj, currentContainer } = createNewPage(opts, pages, outputEl);
let currentHeight = 0;

const children = Array.from(sourceEl.children || []);
for (const child of children) {
if (child.matches(opts.listSplitSelector)) {
({ currentPageObj, currentContainer, currentHeight }
= await paginateList(child, child.tagName.toLowerCase(), measureContainer, CONTENT_HEIGHT, opts, pages, currentPageObj, outputEl, currentContainer, currentHeight));
}
else {
({ currentPageObj, currentContainer, currentHeight }
= await paginateElement(child, measureContainer, CONTENT_HEIGHT, opts, pages, currentPageObj, outputEl, currentContainer, currentHeight));
}
}

pages.push(currentPageObj);
updatePageHeadersFooters(pages, opts);

const m = document.getElementById('_pagination_measure');
if (m?.parentNode) m.parentNode.removeChild(m);

const htmlList = pages.map(pg => pg.page.outerHTML);
const nodes = pages.map(pg => pg.page);

return { pages, htmlList, nodes, contentHeight: CONTENT_HEIGHT, headerHeight: measuredHeaderHeight, footerHeight: measuredFooterHeight };
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 页面调用

const PAGE_HEIGHT = 1123; // A4 px 高度
const PAGE_WIDTH = 794; // A4 px 宽度
const HEADER_HEIGHT = 70;
const FOOTER_HEIGHT = 50;
const originDomStyle = ref({});

const PLATFORM = __PLATFORM__;

originDomStyle.value = {
position: 'fixed',
left: '-9999px',
pointerEvents: 'none',
width: `${PAGE_WIDTH}px`,
};

const result = await paginatePdf('pdf-dom', 'target-dom', {
pageHeight: PAGE_HEIGHT,
pageWidth: PAGE_WIDTH,
header: () => `<img src="${__STATIC_URL__}/images/logo.webp" class="logo" />`,
footerTemplate: `<img src="${__STATIC_URL__}/images/footer-text.webp" class="logo" /><div class="page-num">{page}</div>`,
// 可选:显式指定 header/footer 高度以跳过测量(单位 px)
headerHeight: HEADER_HEIGHT,
footerHeight: FOOTER_HEIGHT,
pageDigitsLength: 2
});
1
2
<div id="origin-dom" :style="originDomStyle" />
<div id="pdf-dom" />

如果采用屋头浏览器来生成 puppeteer

参考资料(我查到的原始来源) &#8203;

  1. Chromium 源码(headless DevTools 部分,ParsePrintSettings,默认 margin 1.0cm):headless/lib/browser/headless_devtools_manager_delegate.cc(查看已定位片段)。 Chromium Git Repositories
  2. Puppeteer / Chromium community issue:PDF margins doesn't seem to be respectedhow to hide margins in headless chrome generated pdf 等讨论,很多人遇到过与 margin/默认 header/footer 相关的问题。 GitHub+1
  3. Chrome 关于打印 / paged media 的文档与博客(解释打印页模型、@page 等),可作补充理解(不同 Chromium 版本逐步增强了 paged media 支持)。

如果仍有空白,试试减去默认 margin 的补偿(经验法,按 Chromium 版本约 1cm = ~37px @96dpi,或你测得的约 70px 进行微调)

pdf 生成指定单页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 获取页面内容高度
const contentHeight = await page.evaluate(() => {
const body = document.body;
const html = document.documentElement;
return Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
) - 70;
});
await page.setViewport({
width: 794, // 可以根据内容动态获取
height: contentHeight,
deviceScaleFactor: 1,
});

return await page.pdf({
// format: 'A4',
// landscape: true, // 添加这一行来设置横向
printBackground: true,
margin: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
displayHeaderFooter: false,
pageRanges: '', // 所有页面
width: '794px',
height: `${contentHeight}px`, // 使用精确像素
path: undefined
});

实践检查清单 &#8203;

  • 把示例函数放进业务前,先补齐空值、异常输入、异步失败和浏览器兼容性测试。
  • 通用工具函数应尽量收敛到项目公共模块,避免多个页面复制出不同版本。
  • 涉及 UI 或浏览器行为时,至少在桌面端和移动端各验证一次关键路径。
  • 涉及接口、服务端或权限逻辑时,补充失败分支和权限边界测试。

复盘小结 &#8203;

这篇笔记更适合作为问题排查或方案落地前的速查材料。后续如果在真实项目中再次遇到同类问题,可以继续把具体版本、报错信息、最终取舍和验证结果补到对应小节里,让它从代码片段逐步沉淀成完整方案。