forked from liweijie/education
161 lines
5.8 KiB
JavaScript
161 lines
5.8 KiB
JavaScript
/**
|
||
* 全局视口等比缩放 —— 让整个系统在不同大小屏幕(桌面 1280~4K)下布局不崩塌。
|
||
*
|
||
* 原理:
|
||
* 以 DESIGN_WIDTH(1920)为设计基准宽度。将视口宽度与设计宽的比值作为缩放倍数,
|
||
* 作用到文档根节点 <html> 的 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)
|
||
} |