/** * 通过 Chrome DevTools Protocol 测量页面在移动端视口下的横向溢出。 * * 用法:node scripts/measure-overflow.mjs * * 之所以单独写这个脚本:横向溢出是纯视觉问题,单看截图难以定位到 * 具体是哪个元素撑破了布局。这里直接问浏览器要每个元素的 boundingRect。 */ const [, , targetUrl, widthArg = "390", heightArg = "900"] = process.argv; const width = Number(widthArg); const height = Number(heightArg); const wsUrl = process.env.CDP_WS; if (!wsUrl) { console.error("缺少 CDP_WS 环境变量(WebSocket 调试地址)"); process.exit(1); } const socket = new WebSocket(wsUrl); let messageId = 0; const pending = new Map(); /** 发送 CDP 命令并等待响应 */ function send(method, params = {}) { const id = ++messageId; socket.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new Error(`CDP 超时: ${method}`)); } }, 30000); }); } /** 在页面上下文中执行表达式并返回其值 */ async function evaluate(expression) { const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, }); if (result.exceptionDetails) { throw new Error( `页面执行异常: ${result.exceptionDetails.text} ${ result.exceptionDetails.exception?.description ?? "" }`, ); } return result.result.value; } socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.id && pending.has(message.id)) { const { resolve, reject } = pending.get(message.id); pending.delete(message.id); if (message.error) { reject(new Error(message.error.message)); } else { resolve(message.result); } } }); socket.addEventListener("error", (error) => { console.error("WebSocket 错误:", error.message ?? error); process.exit(1); }); socket.addEventListener("open", async () => { try { await send("Page.enable"); await send("Runtime.enable"); await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: true, }); await send("Page.navigate", { url: targetUrl }); // 等待网络空闲 + 首屏渲染完成 await new Promise((resolve) => setTimeout(resolve, 4000)); const report = await evaluate(`(() => { const doc = document.documentElement; const clientWidth = doc.clientWidth; const offenders = []; for (const el of document.querySelectorAll("body *")) { const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) continue; const overflowRight = rect.right - clientWidth; const overflowLeft = -rect.left; if (overflowRight > 1 || overflowLeft > 1) { offenders.push({ tag: el.tagName.toLowerCase(), cls: String(el.className || "").slice(0, 90), text: (el.textContent || "").trim().slice(0, 34), left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width), overflowRight: Math.round(overflowRight), }); } } // 只保留"最外层"的溢出元素,避免父子的重复报告 const minimal = offenders.filter((candidate) => { return !offenders.some( (other) => other !== candidate && other.left <= candidate.left && other.right >= candidate.right && other.width > candidate.width, ); }); return { clientWidth, scrollWidth: doc.scrollWidth, documentOverflow: doc.scrollWidth - clientWidth, culpritCount: minimal.length, culprits: minimal.slice(0, 12), }; })()`); console.log(JSON.stringify(report, null, 2)); socket.close(); process.exit(0); } catch (error) { console.error("测量失败:", error.message); socket.close(); process.exit(1); } });