/** * 全局视口等比缩放 —— 让整个系统在不同大小屏幕(桌面 1280~4K)下布局不崩塌。 * * 原理: * 以 DESIGN_WIDTH(1920)为设计基准宽度。将视口宽度与设计宽的比值作为缩放倍数, * 作用到文档根节点 的 zoom 上: * - 视口 < 设计宽(如 1280):zoom < 1,整体等比缩小,避免窄屏布局被撑破; * - 视口 = 设计宽(1920):zoom = 1,原生 1:1 显示; * - 视口 > 设计宽(如 4K 3840):zoom = 2,整体等比放大,大屏不再留白/字小。 * 缩放倍数被限制在 MIN_SCALE ~ MAX_SCALE 之间,MAX_SCALE=2 恰好覆盖 4K(3840/1920)。 * * 采用 zoom 挂在 documentElement 上,可覆盖包括 Element UI 挂载到 body 的 * 弹窗/提示/下拉选择在内的所有内容,保证整套界面一致缩放、不塌陷。 * * 兼容性: * - zoom:Chrome/Edge/Safari/Opera 全系、Firefox 126+(2024-05 起)、 * 以及国内常见 Chromium 内核浏览器(360/QQ/微信等)均支持; * - 本方案对不支持 zoom 的旧浏览器(如 2024 前的 Firefox)做能力探测, * 自动降级为 transform: scale() 回退,保证不崩塌、可正常使用。 */ let initialized = false export const DEFAULT_DESIGN_WIDTH = 1920 // 最小缩放倍数:极窄视口的安全下限,避免文字过小/布局异常 export const MIN_SCALE = 0.5 // 最大缩放倍数:2 对应 4K(3840/1920=2),更高分辨率也封顶在此,避免过度放大 export const MAX_SCALE = 2 /** * 探测当前浏览器是否支持 CSS zoom 属性。 * 现代 Chromium/Safari/Firefox(≥126) 返回 true;旧 Firefox(<126) 返回 false。 * @returns {boolean} */ function isZoomSupported() { const el = document.createElement('div') el.style.zoom = '0.5' // 能读回 "0.5" 说明浏览器真正支持 return el.style.zoom === '0.5' } /** * 初始化全局自适应。可在任意入口调用,重复调用幂等。 * @param {number} [designWidth] 设计基准宽度,默认 1920 */ export function initAdaptive(designWidth = DEFAULT_DESIGN_WIDTH) { if (initialized) { return } initialized = true const useZoom = isZoomSupported() // rAF 帧对齐节流:把同一帧内多次 resize 合并为一次 let rafId = null // 真实写入节流 + 尾随兜底:拖拽/设备模拟期间 resize 高频触发, // 若每帧都写 html.zoom 会强制整页重排+重绘导致卡顿,因此限制真实写入频率 let lastAppliedScale = -1 let lastWriteAt = 0 let trailingTimer = null const WRITE_MIN_INTERVAL = 100 // ms function computeScale() { const root = document.documentElement const width = window.innerWidth || (root && root.clientWidth) || designWidth const ratio = width / designWidth // 收敛到 [MIN_SCALE, MAX_SCALE],规避极端宽高的异常比例 return Math.min(MAX_SCALE, Math.max(MIN_SCALE, ratio)) } function writeScale(scale) { const root = document.documentElement if (!root) { return } // 值未变化:跳过,避免冗余的样式失效/整页重排 if (Math.abs(scale - lastAppliedScale) < 1e-4) { return } lastAppliedScale = scale if (scale === 1) { clearScale(root) return } if (useZoom) { // 首选:zoom 参与布局计算,视觉与占位一致,最平滑 root.style.zoom = String(scale) // 注入缩放与逆缩放变量:逆缩放用于把挂到 body 的 Element 浮层还原为 1:1, // 以抵消 zoom 对 getBoundingClientRect 坐标造成的二次缩放(否则下拉框位置错乱) root.style.setProperty('--adaptive-scale', String(scale)) root.style.setProperty('--adaptive-scale-inv', String(1 / scale)) } else { // 回退:transform 仅影响视觉;将布局宽度固定为设计宽并从左上角缩放, // 使其铺满视口且不改变内部结构,旧浏览器也能正常使用 root.style.transformOrigin = 'top left' root.style.width = `${designWidth}px` root.style.transform = `scale(${scale})` } } function clearScale(root) { if (useZoom) { root.style.zoom = '' root.style.removeProperty('--adaptive-scale') root.style.removeProperty('--adaptive-scale-inv') } else { root.style.transform = '' root.style.transformOrigin = '' root.style.width = '' } } /** 立即计算并写入一次(带值变化守卫) */ function applyNow() { lastWriteAt = Date.now() writeScale(computeScale()) } function onResize() { if (document.hidden) { return // 后台标签不执行,避免无谓开销 } if (rafId) { return // 本帧内已有待执行任务,合并 } rafId = window.requestAnimationFrame(() => { rafId = null if (document.hidden) { return } const now = Date.now() if (trailingTimer) { clearTimeout(trailingTimer) } // 距上次真实写入已超过阈值才立即写入,其余情况靠尾随兜底,避免满帧整页重排 if (now - lastWriteAt >= WRITE_MIN_INTERVAL) { applyNow() } // 尾随:拖拽/改变尺寸停下后,确保落到最终窗口尺寸 trailingTimer = setTimeout(applyNow, WRITE_MIN_INTERVAL) }) } function onVisibilityChange() { if (!document.hidden) { // 从后台切回时补算一次 if (trailingTimer) { clearTimeout(trailingTimer) } applyNow() } } // 首次:渲染前同步应用一次,避免首帧闪烁 applyNow() window.addEventListener('resize', onResize) // 从后台标签切回来时,若期间窗口尺寸变化(因 hidden 被跳过)需补算一次 document.addEventListener('visibilitychange', onVisibilityChange) }