ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue大屏自适应方案:transform scale落地与ECharts补偿

Vue大屏自适应方案:transform scale落地与ECharts补偿 第一次给指挥中心做投屏我用 Vue 写的大屏在本地开发机1920×1080上分毫不差接到现场一块 3840×2160 的屏幕上整个页面缩在左上角只占四分之一右边和下边是两大片黑。当时我以为是浏览器的问题折腾了半天才发现是页面宽度被写死成 1920px而视口变成了 3840px。后来这些年从会议室一体机到展厅竖屏从数据看板到视频监控墙Vue 大屏自适应这件事我基本踩遍了能踩的坑也试过 rem、vw/vh、transform scale、容器查询四条技术路线。这篇东西不打算给你一个复制粘贴就能用的万能公式因为大屏适配从来就没有万能公式。我想做的是把这几套方案的边界说清楚——它们各自在什么场景下成立、在哪里会崩、崩了以后怎么补救然后把我们线上跑了两年多的那套 transform scale 方案连同配套的图表像素补偿、弹层定位修正、非 16:9 妥协策略完整地摊开讲一遍。不管你是刚接到第一个大屏需求的新人还是已经被模糊的 ECharts 和乱飞的弹窗折磨过的老手应该都能从里面捞到点能直接落地的东西。1. 大屏适配到底在适配什么先把物理场景和设计稿对齐1.1 三种大屏场景物理特性完全不同大屏这个词在不同团队嘴里含义差得很远方案选型从第一步就该分叉。我接触最多的是这三类第一类是会议室或展厅的一体机常见 65 到 98 寸面板物理分辨率 3840×2160跑 Windows 或者安卓系统。这种场景最麻烦的变量不是分辨率而是Windows 的显示缩放。当系统缩放设成 150% 时浏览器全屏后的window.innerWidth只有 2560你的 4K 面板实际按 2560 的逻辑像素在排版。同一个项目在 A 会议室正常搬到 B 会议室就错位八成是两台的显示缩放设置不一样。第二类是指挥中心的 LED 拼接墙物理分辨率可能是 3×3 拼接的 5760×3240。但实际交付时绝大多数项目走的是拼接处理器方案一台主机输出 1920×1080由硬件把这一路信号拉伸铺满整墙。这意味着你的页面设计稿还是 1920×1080只是最终会被物理放大三倍左右。这里有个容易被忽略的点1px 的发丝线物理放大后会变成三四个像素宽看着发糊反过来如果整体缩小1px 的线可能直接消失。所以给拼接墙做的设计稿线条我会统一用 2px 起步。第三类是展会竖屏1080×1920 那种立式广告机。这种基本算另一套产品了布局逻辑跟横屏完全不同我一般会单独出一版设计稿而不是指望同一套代码翻转。还有一类是超宽屏比如 21:9 甚至 32:9 的弧形屏常见于地铁站或者展厅。1.2 第一步不是写代码是量屏幕我现在的固定动作是拿到项目后第一时间要求在目标设备上打开浏览器控制台把这三个值读出来写进项目的 README 里。console.log({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, dpr: window.devicePixelRatio, screenW: screen.width, screenH: screen.height })为什么要这么较真因为window.innerWidth和screen.width经常不是一回事设备像素比devicePixelRatio又决定了你的 canvas 该开多大位图。这三个数决定了后面所有的取舍比讨论用什么框架重要得多。顺手把设计稿的尺寸也在这个环节敲定。设计稿宽高一旦确定就是整个项目的世界坐标系后面所有布局都以它为基准不允许中途改。1.3 等比缩放、弹性布局、混合策略的分界线真正决定方案走向的其实是一个业务问题你的大屏能不能接受留白或者变形指挥中心监控墙绝对不能变形地图、雷达、拓扑图一旦拉伸就失真留黑边可以接受。展会宣传屏也不能变形但也不能留黑边因为太难看了所以倾向于背景铺满、内容等比。内部数据看板可以接受弹性布局左右两栏随屏幕宽度伸缩反而更好用。这三种业务诉求对应三条完全不同的技术路线。很多团队一上来就问哪个方案最好其实应该先问这块屏允许我留白吗答案出来方案基本就锁定一半了。2. 四套主流方案的实测对比rem、vw/vh、transform scale、容器查询2.1 每套方案到底在做什么先说 rem。核心就一行 JS把根元素的字号按视口宽度动态算出来然后所有尺寸都用 rem。function setRootFontSize() { const w document.documentElement.clientWidth document.documentElement.style.fontSize (w / 1920) * 100 px }设计稿上 200px 宽的元素写 2rem 就行配合postcss-pxtorem自动转换改造成本很低。它的好处是像素是像素没有 GPU 拉伸那回事字体边缘永远锐利。再说 vw/vh。这是纯 CSS 路线用postcss-px-to-viewport把 px 全部转成 vw连 JS 都不用写。构建期完成转换运行时零开销理论上最优雅。代价是它对非等比这件事完全没有办法——屏幕比例一变元素要么挤要么散。第三条是 transform scale也就是大家都在用的那套整块内容按设计稿写死 1920×1080外层套一个缩放容器按视口算一个缩放比靠 CSS 变换整体放大缩小。优点是开发体验极好设计稿上量多少就写多少所见即所得出了一点像素级偏差都看得见。缺点集中在渲染质量上后面单独开一章讲。第四条是容器查询container。给容器声明container-type: inline-size然后内部按容器宽度写断点。它是目前唯一组件级自适应的原生方案适合做设计系统但对于追求像素级还原的大屏说实话性价比不高——大屏要的是精确复刻不是响应式优雅降级。2.2 四套方案的关键指标对照维度remvw/vhtransform scale容器查询像素级还原一般较差优秀较差改造成本中低低高第三方组件兼容需单独处理需单独处理天然兼容天然兼容非等比屏幕表现会变形会变形可留白可延展取决于断点字体清晰度锐利锐利有模糊风险锐利canvas/视频质量正常正常需补偿正常最小字号限制会受影响会受影响不受影响不受影响表格里有两个点值得展开。一个是第三方组件兼容rem 和 vw 方案下Element Plus 这类组件库内部的 px 不会跟着你的设计稿走一个 32px 高的输入框在 4K 屏上看着像颗芝麻你得额外配置插件的转换范围风险不小。transform scale 方案下组件库的东西连带你自己的布局一起被缩放了视觉比例天然一致。另一个是最小字号限制。rem 方案在视口宽度很小时根字号会被算得很小此时部分浏览器环境的字号下限会跳出来兜底把文字强制拉大导致排版直接错乱。我确实在几个中文环境的机器上撞到过这个问题排查起来相当费劲。transform scale 方案因为字号本身写的是设计稿值比如 14px缩放发生在渲染层反而不受字号下限影响——这是我后来坚定选它的一个重要原因。2.3 我的选型判断表把上面的东西压缩成几条经验需求里有像素级还原四个字直接上 transform scale别犹豫。项目是通用后台只是偶尔投到大屏上看看用弹性布局 少量断点别搞缩放。团队前端规范成熟、组件库自研、追求长期可维护可以考虑容器查询但别指望它一晚上搞定。项目里全是第三方图表、地图、视频而且必须清晰做好像素补偿的心理准备再上 scale。3. transform scale 方案的完整落地链路3.1 容器结构的四个关键属性先看 DOM 结构。外层负责铺满视口和裁切内层负责承载设计稿尺寸的内容。div classscreen-wrapper div classscreen-stage :stylestageStyle router-view / /div /div外层样式只有三件事铺满、裁切、铺底色。.screen-wrapper { position: fixed; inset: 0; overflow: hidden; background: #0a1428; // 与设计稿边缘同色避免留白突兀 }内层的关键是四个属性固定宽高对应设计稿、绝对定位到左上角、变换原点设在左上角、由 JS 动态注入变换矩阵。.screen-stage { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; transform-origin: 0 0; }这里必须强调一次transform-origin选 0 0 还是 center center决定了你后面的偏移计算方式两者不能混用。我见过有人在样式里写了transform-origin: center centerJS 里又按左上角原点算偏移结果页面永远对不齐查了一整天。3.2 useScreenScale把缩放逻辑封装成一个组合式函数Vue3 里我会把它做成 composableVue2 项目可以改写成 mixin 或者全局指令思路完全一样。// useScreenScale.ts import { ref, computed, onMounted, onBeforeUnmount } from vue export const DESIGN_WIDTH 1920 export const DESIGN_HEIGHT 1080 type Mode contain | cover | width | height export function useScreenScale( designWidth DESIGN_WIDTH, designHeight DESIGN_HEIGHT, mode: Mode contain ) { const scale ref(1) const offsetX ref(0) const offsetY ref(0) let rafId 0 function calc() { const vw document.documentElement.clientWidth const vh document.documentElement.clientHeight let s 1 if (mode contain) s Math.min(vw / designWidth, vh / designHeight) if (mode cover) s Math.max(vw / designWidth, vh / designHeight) if (mode width) s vw / designWidth if (mode height) s vh / designHeight scale.value s offsetX.value (vw - designWidth * s) / 2 offsetY.value (vh - designHeight * s) / 2 } function schedule() { cancelAnimationFrame(rafId) rafId requestAnimationFrame(calc) } const stageStyle computed(() ({ transform: translate(${offsetX.value}px, ${offsetY.value}px) scale(${scale.value}) })) onMounted(() { calc() window.addEventListener(resize, schedule) window.addEventListener(orientationchange, schedule) }) onBeforeUnmount(() { cancelAnimationFrame(rafId) window.removeEventListener(resize, schedule) window.removeEventListener(orientationchange, schedule) }) return { scale, offsetX, offsetY, stageStyle, recalc: calc } }三个细节解释一下。第一为什么用requestAnimationFrame而不是防抖定时器拖动窗口时resize会连续触发几十上百次如果每次都同步读写布局会引发布局抖动。rAF 把计算压到下一帧之前一次渲染周期只算一次比定时器更贴合渲染节奏也不会出现松手后还延迟几百毫秒才对齐的观感。第二translate必须写在scale前面。CSS 的变换按矩阵顺序右乘写在前面的变换作用在最终的视觉位移上不会被后面的缩放放大。如果你写成scale(s) translate(x, y)那个 x 和 y 就会被乘上 s偏移量彻底错位。这个坑我踩过当时现象是屏幕越宽页面偏得越离谱。第三translate的偏移值用未缩放的视口像素算所以offsetX (vw - 1920 * s) / 2直接就是视觉上的居中偏移不用再除以 s。3.3 双轴策略让侧栏弹性、让主视觉等比上面这套是整体等比缩放适合监控墙。但如果需求是左右两侧列表随屏幕变宽多显示几行内容中间地图保持比例整体缩放就不合适了。我用的变体是这样缩放比仍按高度算但舞台宽度反向撑开。const s vh / designHeight scale.value s stageWidth.value vw / s // 关键把视口宽度换算回设计稿坐标系 offsetX.value 0 offsetY.value 0舞台的高度固定 1080宽度变成一个动态值比如 2600。此时页面里左右两侧的列表容器用flex: 1自动吃掉多余宽度中间的地图容器给固定宽度、保持设计比例。缩放回去之后整块内容正好铺满屏幕没有黑边两侧列表还多了可用空间。这个思路我在多个指挥中心项目里用过配合display: grid的三栏布局非常顺。要注意的是宽度撑开之后所有依赖设计稿 1920 宽的绝对定位元素都会错位所以这套方案下布局必须用 flex 或 grid 相对布局不能用绝对定位堆。4. 缩放的代价ECharts、视频流、3D 场景的像素比补偿4.1 图表为什么不跟着放大一次典型的误判transform scale 方案上线后第一个报障十有八九是图表变糊。而且诡异的地方在于你监听窗口 resize调用了chart.resize()图表明明响应了还是糊。根因在于CSS transform 不改变布局尺寸。图表容器的clientWidth永远是设计稿里写的那个值比如 600px无论外面把它放大到了 1.5 倍还是 3 倍。ECharts 内部判定尺寸没变于是既不重绘也不调整 canvas 位图。浏览器拿着一张 600×300 的位图直接拉到 1800×900 显示结果就是文字边缘糊成一片折线像毛边。所以正确的补偿方向不是让图表 resize而是让 canvas 的位图分辨率跟上放大的倍数。4.2 三种补偿写法的取舍第一种在初始化时把缩放比乘进设备像素比import * as echarts from echarts function createChart(el: HTMLElement, scale: number) { return echarts.init(el, undefined, { renderer: canvas, // 关键把 CSS 缩放倍数补进 dpr devicePixelRatio: Math.min(window.devicePixelRatio * scale, 3) }) }注意我加了Math.min(..., 3)做上限。4K 屏上devicePixelRatio本身可能是 2再乘个 2 倍缩放就是 4canvas 面积直接膨胀到 16 倍显存和绘制耗时都会爆掉大屏一卡就全卡。压到 3 是性能和效果的平衡点实测 3 倍位图在全屏观看距离下已经看不出模糊。第二种缩放比变化时重建实例。ECharts 官方的resize()不接受devicePixelRatio参数也就是说窗口大小变了、缩放比变了之后旧的 dpr 不会自动更新。我的做法是在缩放比变化超过阈值比如 0.05时把实例 dispose 掉重新 initwatch(scale, (next, prev) { if (Math.abs(next - prev) 0.05) { chartRef.value?.dispose() chartRef.value createChart(elRef.value!, next) bindOption(chartRef.value) } })窗口拖拽过程中缩放比会连续变化所以要加防抖一般 200 毫秒足够。重建实例有成本但窗口尺寸变化本身就不是高频事件可以接受。第三种是直接改内部 painter 的 dpr属于非公开 API我不建议在正式项目里用升级一次版本可能就废了。这里提一句只是让你在别人的代码里看到时不至于懵。4.3 视频流、WebGL 和地图的额外处理视频流在大屏里极其常见无论是 HLS 切片播放还是 WebRTC 低延迟推流最后落到 DOM 上都是一个video元素。它跟 canvas 不太一样现代浏览器对视频纹理的缩放处理更聪明放大后看着是柔而不是糊。真正的问题往往出在源头分辨率上——你拿一路 720p 的流放到 4K 拼接墙上放大六倍怎么补偿都没用。我的处理顺序是先确认能不能拿到更高分辨率的源源没问题再考虑适配层的优化。WebGL 场景就更直接了Three.js 和 Cesium 这类库必须显式告诉渲染器像素比const renderer new THREE.WebGLRenderer({ antialias: true }) renderer.setPixelRatio(Math.min(window.devicePixelRatio * scale, 2)) renderer.setSize(width, height, false)具体倍数按现场机器显卡能力定大屏主机普遍用的是核显或者入门独显像素比给高了帧率掉到 20 以下看着比模糊更难受。我的经验值是 2 封顶。顺便说一句 1px 边框。设计稿里 1px 的描边在 2 倍缩放下变成 2 个物理像素在拼接墙上再被硬件放大 3 倍就是 6 个像素宽粗得刺眼。我现在的做法是给大屏做的设计稿边框统一 2px 起分割线用背景色块或者渐变模拟别用发丝线。5. 缩放容器里的定位陷阱fixed 弹层、鼠标坐标与表格滚动5.1 一个反直觉的规范transform 会改变 fixed 的包含块这个坑我认为是 transform scale 方案里最隐蔽的一个。按 CSS 规范只要元素的transform不是none它就会成为其后代position: fixed元素的包含块。也就是说缩放舞台内部写position: fixed的遮罩层不再相对视口定位而是相对这块 1920×1080 的舞台定位。表现是什么你在缩放容器里手写了一个全屏加载遮罩position: fixed; inset: 0本地开发看着好好的因为本地视口刚好接近设计稿尺寸偏差看不出来。一上大屏遮罩只盖住了舞台范围内的一部分边缘露出一圈没遮住的内容。知道原理之后处理起来很简单在缩放容器内部一律不用 fixed需要铺满就用position: absolute; inset: 0。覆盖范围跟着舞台走缩放之后视觉上就是铺满的。5.2 坐标换算从 clientX 到设计稿坐标第二个高频问题是鼠标坐标。只要你做的是自定义绘制、热点区域、跟随鼠标的提示框就一定会遇到。event.clientX给的是视口坐标而你的业务逻辑是按设计稿坐标系写的中间差着一个偏移和一个缩放比。function toStageCoord(e: MouseEvent) { const wrapper document.querySelector(.screen-wrapper) as HTMLElement const rect wrapper.getBoundingClientRect() const x (e.clientX - rect.left - offsetX.value) / scale.value const y (e.clientY - rect.top - offsetY.value) / scale.value return { x, y } }这里的offsetX和offsetY就是useScreenScale里算出来的那两个值。如果舞台是宽度延展模式偏移为 0公式退化成只除以缩放比。还有个更隐蔽的坑getBoundingClientRect()返回的是变换之后的矩形。你写el.getBoundingClientRect().width在缩放 2 倍的屏幕上拿到的是 1200而不是元素本来的 600。要拿设计稿尺寸用el.offsetWidth或el.clientWidth。我在一个拖拽排序需求里被这个坑了大半天手柄一直吸不准位置最后发现是尺寸拿错了坐标系除以缩放比之后就正常了。5.3 组件库浮层的实际表现与我的处理偏好拿 Element Plus 举例。它的下拉、气泡、弹窗默认会传送到body下因为这些浮层挂在缩放容器外面所以它们不受缩放影响——位置计算是对的但尺寸是 1:1 的。在 2 倍缩放的界面上一个 14px 字体、32px 高的下拉框显得特别小和周围被放大过的界面明显不在一个层里视觉上很割裂。我的处理偏好是把浮层留在缩放容器内部。Element Plus 的下拉支持:teleportedfalse对话框支持:append-to-bodyfalse关掉传送之后浮层就跟着容器一起缩放尺寸和位置同时对齐。代价是要注意两件事——一是容器的overflow: hidden不能把浮层裁掉大屏本身不滚动一般没影响二是浮层的层级要在容器内部足够高别被别的卡片盖住。表格也是重灾区。el-table的自动列宽计算基于clientWidth缩放不影响它所以列宽算得是对的这点可以放心。真正别扭的是滚动条——滚动条宽度会被一起缩放2 倍缩放下原本 8px 的滚动条变成 16px 视觉宽度看着很粗反过来缩小时可能细到不好拖。大屏一般是鼠标操作或者遥控操作滚动条本身用得少我通常直接美化掉用隐藏滚动条 自定义指示器替代。6. 非 16:9 屏幕的三种妥协策略与实现6.1 居中留白最安全也最保守设计稿 16:9屏幕 21:9等比缩放必然在左右各留一条黑边这是contain模式。它的好处是零风险任何比例都能正确显示内容永远不变形。指挥中心、演示汇报这类场景我首选它。要减轻黑边的观感可以做两件事。一是底色对齐把外层 wrapper 的背景色设成跟设计稿边缘一样的深色黑边和内容边界就模糊掉了。二是背景往外延设计稿边缘那圈装饰性的光效、网格、粒子不要用普通 DOM 画改用一张background-size: cover的背景图层铺满整个视口内容层再等比缩放叠在上面。视觉上几乎看不出有留白。6.2 高度铺满 宽度延展内容型大屏的最优解如果业务能接受两侧多显示点内容那第 3.3 节讲的宽度延展模式是最舒服的。核心就是把舞台宽度设成vw / scale然后让布局容器的弹性生效。具体到三栏布局.stage-layout { display: grid; grid-template-columns: 1fr 1200px 1fr; // 中间地图锁死两侧弹性 height: 1080px; gap: 24px; }中间地图锁 1200px保证比例不变两侧用1fr屏幕越宽列表越长。16:9 的屏幕上侧栏各 336px21:9 的屏幕上能到 600 多 px正好多放两行数据。这套方案实际用下来客户满意度明显高于留黑边。要注意的是如果侧栏里是图表图表宽度变了之后必须重新 resize而且因为舞台宽度是按设计稿坐标系算的图表的容器宽度变化是真实的布局变化ECharts 的resize()这次是有效的——这也是它跟第 4 章场景的区别同样的图表组件在整体缩放模式下要靠 dpr 补偿在宽度延展模式下靠 resize 就能解决。两套逻辑别搞混。6.3 内容等比 背景铺满展会场景的常用组合展会屏幕不允许出现任何黑边但内容也不能变形剩下的办法就是把背景层和内容层拆开。背景层固定铺满视口内容层按contain缩放居中。这样即使两侧有区域没放内容观众看到的也是完整的背景不会觉得这屏没做完。内容层如果实在偏窄还可以做一档轻微的补偿把缩放比在contain和cover之间插值。比如scale minRatio (maxRatio - minRatio) * 0.2允许内容被拉伸 20%肉眼几乎看不出来但能吃掉一大截留白。这个比例我是凭经验给的超过 0.3 圆形的指示灯会明显变椭圆慎用。7. 上线前的检查清单打包、定时器与长跑稳定性7.1 本地正常、打包后布局异常的三个常见原因这个现象太常见了我整理了三个高频原因按排查优先级排第一CSS 压缩工具吃掉了 calc 里的空格。早期版本的压缩器会把calc(100% - 16px)压成calc(100%-16px)减号两侧没有空格整个表达式直接失效元素宽度变成 auto布局全乱。这类问题在开发环境不会出现因为 dev 阶段不做压缩。排查方法很简单打开生产环境的 CSS搜一下calc(看两边有没有空格。第二静态资源路径不对。打包后部署到子目录背景图 404 了看起来就像布局错乱其实只是背景丢了。检查构建配置里的资源基础路径以及 CSS 里引用的图片是否走了正确的解析规则。第三插件配置在生产和开发之间不一致。比如 px 转 rem/vw 的插件只在开发配置里挂了生产构建漏了本地一切正常发布后所有尺寸按原始 px 渲染。这个最容易排查搜一下打包产物里有没有出现 vw 单位就知道了。7.2 7×24 长跑的稳定性问题大屏项目跟普通后台最大的不同是它要连续跑几个月不关。这类问题里内存泄漏排第一。最常见的泄漏点就是图表实例。每分钟刷新数据、重建一次 ECharts 实例如果没调dispose()一天的累积足够把内存吃到几个 G。我的做法是给每个图表封一个统一的生命周期onBeforeUnmount里dispose切换数据源时先clear()再setOption尽量不重建实例。同理setInterval和addEventListener都必须在卸载时清掉。第二个问题是浏览器后台节流。用户切走标签页或者屏幕上弹了个系统窗口浏览器会把定时器大幅降频requestAnimationFrame干脆完全暂停。切回来的时候如果你的轮播逻辑是靠每次 tick 加一实现的就会出现一段诡异的静止和一次突兀的跳跃。我的处理方式是基于时间戳算进度而不是靠次数累加并且在visibilitychange里主动暂停和恢复动画。document.addEventListener(visibilitychange, () { if (document.hidden) { pauseAll() } else { resumeFromTimestamp(Date.now()) } })第三是Windows 显示缩放带来的跨机器差异。部署脚本里我没法控制这个所以我会在页面启动时把innerWidth、devicePixelRatio这些值打到控制台工作人员截个图发过来我就能判断现场环境是否异常。7.3 上线前的巡检清单检查项判断标准常见后果目标设备视口尺寸innerWidth/innerHeight 是否与预期一致整体缩放比算错显示缩放设置是否为 100%或已纳入计算跨机器表现不一致图表位图补偿全屏后文字是否锐利图表发虚、折线毛边浮层层级与定位下拉、弹窗是否跟随缩放尺寸割裂或位置偏移1px 线条视觉宽度是否可接受过粗或消失定时器清理路由切换后是否持续增长内存持续上涨后台恢复切走再切回是否正常动画错乱生产构建产物calc 空格、vw 单位、资源路径布局突然错乱这张表我基本每次交付前都会过一遍其中前三项每次必查。最后分享一个我现在的固定动作在项目根目录放一份screen-info.md写下目标屏幕的物理分辨率、逻辑视口、设备像素比、显示缩放设置、适配模式和缩放比计算公式再附上一张现场实拍图。这玩意儿看着不起眼但半年后要加一块新屏、或者换个人接手的时候能省掉整整一天的现场排查。我上一个项目就是因为没这东西为了搞清楚为什么客户说字变小了来回跑了三趟。
返回列表