Files
education/frontend/src/utils/adaptive.js
T
2026-09-10 17:42:46 +08:00

161 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全局视口等比缩放 —— 让整个系统在不同大小屏幕(桌面 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)
}