ARTICLE DETAIL

资讯详情

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

数据大屏自适应方案对比:scale缩放与rem动态计算的工程实践

数据大屏自适应方案对比:scale缩放与rem动态计算的工程实践 做可视化项目这些年每次接到数据大屏需求第一个要解决的问题不是图表选型而是页面怎么在不同分辨率下不垮掉。明明开发时好好的换到客户那台 7680x2160 的拼接屏上要么图表被拉变形要么右侧一大片空白要么字体大得溢出容器。说实话数据大屏的自适应方案市面上能搜到很多但真正自己动手埋过坑、调过细节的总结并不多。我目前在做的这套 vue3 ts vite 技术栈是当前新项目里比较顺手的前端组合尤其配合 echarts 做可视化整体开发体验比过去 webpack 时代利索不少。这篇文章我把这两年在大屏项目里实测过的两种自适应方案从原理到代码到坑点完整拆开讲一遍给正在被大屏布局折磨的朋友一个能直接抄作业的参考。先说结论目前主流的做法无非两种一种是基于 scale 的整体缩放方案另一种是基于 rem 动态计算的方案。两者没有绝对的好坏只看你的项目场景更适合哪一类。1. 大屏自适应的核心问题和解决方案选型1.1 大屏项目为什么不能直接用常规响应式布局普通后台管理系统做响应式靠的是媒体查询断点、flex 弹性布局、grid 栅格这些手段让页面在不同宽度的浏览器窗口里自动重排。但数据大屏不一样设计稿通常只有一个尺寸最常见的是 1920x1080很多时候你拿到的视觉稿甚至是按 3840x2160 出图的。开发要求是高保真还原像素级对齐。如果在大屏项目里也只用 flex 百分比会出现几个让人抓狂的情况图表组件 echarts 的 canvas 是根据容器宽高计算的容器尺寸变化时图表内部元素坐标轴文字、图例、气泡大小不会跟着等比变化顶部图例可能会溢出画布。大屏项目里大量用到绝对定位来摆放装饰元素比如地图上的标签、动态飞线、角落的发光边框这些坐标是写死的窗口一变它不会动。即便你把字体设为 rem 单位图标、图片、边框底纹这些东西的缩放比例和文字缩放比例很难保持一致最终结果是布局没乱但整体视觉效果失衡。常规响应式布局解决的是“内容重排”而大屏的诉求其实是“等比缩放”。这两者的路径完全不同所以需要单独设计自适应方案。1.2 两类方案的核心思路和适用范围先说 scale 缩放方案。它的思路非常简单粗暴写死一个基准尺寸通常是 1920x1080把整个页面当作一张画布通过 CSS transform 的 scale 属性按照当前浏览器窗口和基准尺寸的比例进行缩放。窗口变大整张画布跟着放大窗口变小画布跟着缩小。无论窗口怎么变页面内部的比例永远和设计稿一致。再来说 rem 方案。它的核心是让 html 根的 font-size 跟着视口宽度变化然后把页面里所有 px 单位都换算成 rem。这样当浏览器宽度变化时所有使用 rem 的元素都会按比例变化。和 echarts 配合时需要额外处理图表尺寸的同步更新。从应用场景来看scale 方案适合那些设计稿固定、画面元素丰富、强调视觉还原的大屏尤其是展厅沉浸式大屏、指挥中心监控大屏这类对“不变形”要求极高的场景。而 rem 方案更适合那些内容需要随窗口增减而合理流动、有一定交互操作的数据分析看板。这两个方案不冲突很多成熟的项目是两者结合用的比如 rem 处理整体布局尺寸scale 处理某个局部大图的缩放。但刚开始接触大屏项目时建议先把其中一种吃透。2. 方案一基于 scale 的整体缩放实现2.1 scale 方案的原理和计算逻辑这个方案我相信很多朋友都看过网上流传的代码版本也不少但很多都没有解释清楚一个关键问题缩放比例到底怎么算。核心逻辑是拿到当前窗口的宽高和设计稿的宽高分别做比值。这里有两个选择等比缩放取 Math.min(窗口宽 / 设计稿宽, 窗口高 / 设计稿高)这样页面整体不会变形但多出来的区域会是空白需要处理背景。适合对画面比例要求严格的场景。非等比缩放宽和高分别计算 scaleX 和 scaleY这样页面会铺满窗口但元素会被拉伸变形。适合一些对变形不敏感、以柱状图折线图为主的图表类大屏。我自己的项目里绝大多数情况下用的是等比缩放方案。因为数据大屏通常设计感很强一旦变形那些圆形装饰、地图轮廓、科幻风格的边框马上会变得很难看。但等比缩放带来的问题是比例不一致时会出现留白我通常在缩放容器外面套一层铺满窗口的背景层把留白区域用背景色或动态粒子背景填充视觉上不会突兀。计算逻辑用代码表达就是这样一个思路先定义设计稿尺寸监听 resize 后动态计算比例。2.2 手写一个通用的 Vue3 缩放 Hook这个 Hook 我会拆成两部分一个是监听窗口尺寸变化并计算比例的 composable另一个是页面根容器上使用的缩放组件。先看监听窗口尺寸的部分。这里用到了 Vue3 组合式 API 的写法ts 类型标注也一起给出来。// src/hooks/useScalePage.ts import { ref, onMounted, onBeforeUnmount } from vue export interface ScaleOption { /** 设计稿宽度 */ width: number /** 设计稿高度 */ height: number /** 缩放容器 id缺省为 scale-container */ containerId?: string /** 等比缩放还是宽高分别缩放 */ isUniform?: boolean }这是类型定义部分明确入参是设计稿尺寸、容器 id 和缩放方式。isUniform 为 true 时表示等比缩放。接下来是具体的实现逻辑。export function useScalePage(option: ScaleOption) { // 页面缩放比例 const scale ref(1) // 非等比时的宽高缩放比例 const scaleX ref(1) const scaleY ref(1) const { width, height, containerId scale-container, isUniform true } option // 获取需要缩放的 DOM 容器 const getContainer () document.getElementById(containerId) const setScale () { const container getContainer() if (!container) return // 窗口实际宽高这里要用 window.innerWidth/innerHeight const windowWidth window.innerWidth const windowHeight window.innerHeight if (isUniform) { // 等比缩放取最小比例保证完整显示 const ratio Math.min(windowWidth / width, windowHeight / height) container.style.transform scale(${ratio}) container.style.transformOrigin left top scale.value ratio } else { // 分别缩放填满整个窗口 const sx windowWidth / width const sy windowHeight / height container.style.transform scale(${sx}, ${sy}) container.style.transformOrigin left top scaleX.value sx scaleY.value sy } } const resizeHandler () { // 简单防抖避免高频触发 window.requestAnimationFrame(setScale) } onMounted(() { setScale() window.addEventListener(resize, resizeHandler) }) onBeforeUnmount(() { window.removeEventListener(resize, resizeHandler) }) return { scale, scaleX, scaleY, setScale } }这里有几个细节值得说。第一个是 transformOrigin 一定要设置为 left top默认是 center不设置的话缩放的基准点是容器中心整个页面会往中心收拢位置就偏了。第二个是缩放对象是整个页面根容器容器需要设置固定的设计稿宽高并且不能用 margin 居中而是用 transform-origin 配合 left top 定位到左上角再手动处理水平方向的居中。2.3 页面上如何配合使用在页面组件里使用这个 Hook 时根节点的样式比较有讲究。我的做法是让最外层铺满窗口内层固定设计稿尺寸把缩放动画作用在内层容器上。template div classscreen-wrapper div idscale-container classscale-container !-- 所有大屏内容尺寸按 1920x1080 设计稿写死 -- Header / LeftPanel / CenterMap / RightPanel / /div /div /template script setup langts import { useScalePage } from /hooks/useScalePage useScalePage({ width: 1920, height: 1080, containerId: scale-container, isUniform: true }) /script style scoped .screen-wrapper { width: 100vw; height: 100vh; background: #030b1a; overflow: hidden; display: flex; align-items: center; justify-content: center; } .scale-container { width: 1920px; height: 1080px; flex-shrink: 0; position: relative; transform-origin: left top; background: #030b1a; } /style外层的 screen-wrapper 使用 flex 居中内层缩放容器保持 1920x1080 不变。因为缩放比例小于或大于 1 时容器的视觉尺寸会变化flex 居中能保证容器始终在浏览器窗口的正中间。这样即使浏览器比例不是 16:9画面也能居中显示配合外层深色背景效果比较干净。2.4 scale 方案在 echarts 上的表现和细节echarts 图表在 scale 方案下基本不需要额外处理。因为 echarts 的 canvas 渲染基于容器像素尺寸而缩放是通过 CSS transform 实现的容器本身的 1920x1080 大小没变canvas 内部的绘制逻辑完全不受影响。但这里要注意一个特殊场景如果大屏里放视频流或者 WebGL 场景scale 缩放会让渲染内容变模糊尤其是在超大分辨率的拼接屏上。因为 transform 缩放本质是对已渲染画面的位图进行缩放而视频流本身是逐帧渲染的经过 CSS 缩放后会损失一定的清晰度。这种情况下建议把视频层放在缩放容器外面单独按实际窗口尺寸渲染然后通过定位和 scale 容器对齐。还有一点大屏里的热区点击和 tooltip 交互在 scale 下是正常工作的。因为 transform 不影响元素的命中区域鼠标坐标会跟着变换echarts 的 tooltip 定位也正常。3. 方案二基于 rem 动态计算的实现3.1 rem 方案的原理和 vite 项目接入方式rem 方案的核心理解起来也不复杂浏览器默认字号是 16px1rem 等于根元素 html 的 font-size。如果我们把 html 的 font-size 根据屏幕宽度动态设置为 1920px 设计稿的 1/10也就是 192px那么 1rem 就等于设计稿里的 192px。页面里任何一个元素只要把设计稿的 px 值除以 192 换算成 rem就能做到随屏幕宽度等比缩放。实际操作中当然不会手算每个值工程上会引入 postcss-pxtorem 这个插件让构建工具自动把 css 里的 px 转换成 rem。vite 项目使用 postcss 插件需要在 vite.config.ts 里配置或者单独维护一个 postcss.config.js 文件。我先讲纯手写的动态设置根字号的逻辑再看工程配置。// src/utils/rem.ts export function setRem(designWidth 1920) { const htmlWidth document.documentElement.clientWidth const htmlFontSize htmlWidth / designWidth * 100 document.documentElement.style.fontSize ${htmlFontSize}px } window.addEventListener(resize, () { setRem(1920) }) setRem(1920)这段逻辑把 designWidth 分成 100 份当窗口宽度是 1920 时根字号是 100px设计稿里的 192px 就写成 1.92rem。按 100 作基数纯粹是换算方便实际用多少都行。在 vite.config.ts 里配置 pxtorem 时有一个很重要的点需要保证 echarts 的容器不要被转换。因为 echarts 的宽高如果是 rem 单位那么 canvas 会被放大或缩小但 echarts 内部生成的 canvas 像素尺寸不会自动跟着变化刷新时会闪一下。正确的做法是给图表容器用固定的 px 或者通过 JS 动态计算。// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import postcssPxtorem from postcss-pxtorem export default defineConfig({ plugins: [vue()], css: { postcss: { plugins: [ postcssPxtorem({ rootValue: 100, propList: [*], selectorBlackList: [.ignore-rem] }) ] } } })rootValue 是 100和动态设置的根字号基数保持一致。selectorBlackList 表示命中类名 .ignore-rem 的元素不做转换。这样写行内样式、echarts 初始化传入的尺寸、canvas 相关设置都用专心 px 即可避免转换带来的坑。3.2 rem 和 echarts 的整合以及组件内自适应封装rem 方案在 vw 移动端弹性布局里很常见但放到数据大屏场景里最大的坑是我在前面提到过的 echarts resize 问题。容器的宽度变成 rem 后实际像素会跟着窗口变化但 echarts 实例创建时会把 canvas 的宽高确定下来之后容器变化了canvas 不会自动更新。解决思路是封装一个统一的图表组件初始化图表后监听窗口变化手动调用 chart.resize()。这里我给出一个示例性的封装思路用 useResize 钩子统一管理所有图表的 resize 时机另外在根组件或布局组件里做一次窗口 resize 的广播// src/hooks/useChartResize.ts import { onMounted, onBeforeUnmount, shallowRef } from vue import * as echarts from echarts export function useChartResize(chartRef: RefHTMLDivElement | null) { const chart shallowRefecharts.ECharts | null(null) const initChart () { if (chartRef.value) { chart.value echarts.init(chartRef.value) } } const resize () { chart.value?.resize() } onMounted(() { initChart() window.addEventListener(resize, resize) }) onBeforeUnmount(() { window.removeEventListener(resize, resize) chart.value?.dispose() }) return { chart, initChart, resize } }这段封装里有两个细节。第一个是用了 shallowRef因为 echarts 实例对象结构很深用 ref 会带来不必要的递归响应式代理影响性能。第二个是 resize 时对窗口 resize 事件直接监听没有做防抖因为 echarts 的 resize 本身开销不算大如果图表数量多了可以在项目里统一加一个 200ms 的防抖避免浏览器卡顿。还有一个更省事的思路在 root 布局统一订阅窗口变化用 provide/inject 往下派发事件图表组件只负责订阅。这样多个图表不会重复监听窗口事件。3.3 rem 方案的字体和边框处理rem 方案最常被吐槽的问题之一就是字体大小也跟着缩放以后某些字号会在屏幕上显得很模糊。原因是 rem 换算成 px 时可能出现小数比如 12px 的字体在 rootValue 100 下是 0.12rem浏览器渲染时有可能四舍五入成 12px 或 13px。这个问题在普通显示器上不明显在超大拼接屏上会显得文字边缘发虚。我的处理经验是正文类文本尽量用较小的基数换算标题类装饰类文本建议单独定为 px 或者使用 clamp() 函数。对 echarts 内部的文本我通常使用 formatter 返回固定的 px 大小不参与 rem 缩放。这样图表文字保持清晰整体布局动态缩放。边框线的处理也是类似宽度很细的边框如果被 rem 换算缩放到小屏时会变得几乎看不见。所以装饰线的宽度通常写死为 1px 或 2px加上 .ignore-rem 类名避让转换。4. 两种理论方案的实测对比与选型建议4.1 两种方案在典型场景中的实测结果为了直观说明我把这两种方案放到三个典型的大屏场景里跑了测试。测试机器分别是一台 1920x1080 的普通显示器一台 2560x1440 的办公屏以及一台 3840x2160 的展示用大屏。对比维度scale 整体缩放rem 动态计算视觉还原度像素级还原设计稿有小数位渲染偏差还原度略低窗口比例变化等比时不拉伸但出现上下或左右留白宽度弹性变化布局不变形比例自适应字体清晰度缩放可能模糊尤其放大时字体按 rem 缩放相对稳定但小数会产生轻微发虚echarts 适配无需额外处理需要封装 resize 逻辑第三方组件适配基本不需要改对 video、canvas 友好需排查每个第三方组件对 px 的使用大量代码改造量只需改根容器样式需要配置插件并处理忽略列表从表里能看出scale 方案在“还原设计稿”这件事上优势非常明显几乎零心智负担。而 rem 方案更灵活但需要更精细的工程配置和维护成本。4.2 什么项目选 scale什么项目选 rem我在实际给团队做技术选型时一般按这几个维度来判断。如果大屏是固定场景、固定分辨率不需要适配各种浏览器窗口比如展厅里专门的一体机或者会议室里的 16:9 显示屏scale 方案是首选。它的实时性和稳定性都很好因为设计稿是多少就呈现多少不会因为浏览器窗口变化出现内容被裁剪的问题。如果大屏需要经常在不同分辨率的屏幕上演示或者客户会在普通电脑浏览器里打开看看效果窗口允许拖拽拉伸那 rem 方案更有优势。至少窗口变化时背景铺满不错位不需要等缩放比例的重新计算。唯一要接受的是 echarts 需要额外写 resize 逻辑。如果是那种既有大屏、又有普通后台管理页面的项目我更倾向于 split 开大屏路由专门用 scale 容器包裹普通页面走常规响应式布局。两者混在一起时postcss 插件会把大屏和后台的 px 一起转换容易互相干扰所以给大屏目录单独建一个 vite 入口或者利用 css 类名做 blacklist是比较稳妥的隔离方式。4.3 一个比较实用的混合思路最后分享一个我目前用得比较顺手的组合整体用 scale局部关键交互组件抽出来用 rem。比如某个大屏项目里整体画面按 1920x1080 缩放地图上的弹窗和右下角的控制面板因为需要适配不同的窗口位置单独用 rem 来设置大小和偏移。这样既保证了主画面的视觉还原又让交互元素在不同分辨率下都处于合理位置。具体做法是给交互组件的外层容器加一个类名在 postcss 配置的 selectorBlackList 里把这个类名列入忽略列表然后组件内部自己写 rem 单位由动态设置的根字号控制。这个混合思路比较灵活后续扩展时也不会推翻原有结构。5. 大屏自适应项目中的常见问题排查5.1 pxtorem 对 echarts 没起到效果这个问题在热词里出现得非常高频vue3 vite echarts 的项目里几乎每个人都会踩一次。原因其实很直接pxtorem 只转换 CSS 文件里的 px 单位而 echarts 初始化时传入的 width 和 height 是 JavaScript 运行时的数值根本不经过 postcss 处理。排查思路分两种情况。如果 echarts 容器的高宽是 CSS 写死的比如 width: 800px那 pxtorem 会把它转成 rem此时 echarts 创建 canvas 时会拿 offsetWidth 去取值结果 canvas 的像素尺寸是换算后的 rem 对应的实际像素而 echarts 内部的坐标系可能没有及时感知就会出现图表变形或空白。这种情况的解法是给容器加 ignore 类名让宽度保持 px再用 resize 监听同步。如果 echarts 是通过 JS 拿容器尺寸初始化的比如const chart echarts.init(document.getElementById(chart))那就不要传 width 和 height 参数让 echarts 自动读取容器宽高。窗口变化时手动调用 resize() 即可。5.2 缩放容器出现滚动条或者白边使用 scale 方案时滚动条是个很讨厌的问题。根本原因通常是 body 或外层容器的尺寸没有约束好。外层容器必须设置为 overflow: hidden同时高度不能超过 100vh。还有一个隐藏比较深的问题如果页面上有弹窗组件弹窗默认挂载到 body 下body 如果被 overflow 隐藏弹窗的 fixed 定位在某些情况下会失效出现位置偏移。解决办法是把弹窗挂载节点设置为缩放容器内部或者给弹窗组件添加 :teleportedfalse让弹窗渲染在缩放容器内。白边的问题通常出现在等比缩放后窗口很宽或很高的情况下。处理白边最有效的办法是给外层背景层做一个拉伸背景让背景铺满整个窗口而缩放容器居中。这样即使出现白边也不会露怯视觉上是整体背景的一部分。5.3 打开大屏时图表渲染位置错乱项目刚回本时最容易遇到的问题是图表渲染时窗口尺寸还没稳定或者页面资源还没加载完容器高度是 0导致 echarts 初始化失败。这种情况尤其容易出现在大屏这种图片资源多、特效多的页面。建议的规避方式是所有图表组件延迟到 mounted 之后初始化并且在大屏根容器上监听一个 onLoad 事件等页面所有资源加载完成后再统一执行首次 resize。还有一种做法是用 v-if 控制图表容器的渲染时机等数据请求完成后再让图表容器出现在 DOM 中。如果图表是在异步数据返回后才初始化的也需要在 setOption 之后调用一次 chart.resize()确保图表宽度和容器一致。5.4 性能问题窗口拖动时画面非常卡大屏页面通常图表多、特效多resize 事件一旦高频触发浏览器可能来不及重绘。我的做法是给 resize 加一个节流至少 100ms 以上。过去我用的是 requestAnimationFrame 加一个开关效果还不错。另一个优化点是尽量避免在 resize 里直接操作 echarts 实例去做复杂的 setOption 更新只调用 chart.resize() 调整画布大小。真正数据更新通过轮询或者 websocket 推送时再做和窗口尺寸变化解耦。6. 两种方案的基建工程化落地最后把工程上两个方案常用到的目录结构和封装思路放出来方便大家直接对着搭。6.1 scale 方案推荐目录结构src/ ├── hooks/ │ └── useScalePage.ts ├── components/ │ └── ScaleContainer/ │ ├── index.vue │ └── types.ts ├── views/ │ └── dashboard/ │ ├── index.vue │ └── config.tsScaleContainer 组件内部封装好 useScalePage 的调用对外通过 props 接收设计稿宽高和缩放模式。子组件里只需要把内容放进容器即可不用关心自适应逻辑。config.ts 里可以统一管理多个大屏的设计稿尺寸不同大屏项目直接复用组件传参。6.2 rem 方案的推荐封装rem 方案的工程化比 scale 要繁琐一些主要在于插件配置和图表组件封装两层。配置层面维护一个 postcss.config.js并在忽略列表里维护好无需转换的类名。组件层面封装 echarts 基础组件统一处理 init、setOption、resize、销毁生命周期其他页面复用时只传 option 即可。这种封装还会带来一个附加好处当产品提出要给大屏增加导出截图功能时echarts 组件都有一个实例引用可以在统一的地方调 getDataURL 或者 canvas 转图片不用每个页面单独处理。6.3 上线之前一定要做的一轮核验清单我每次交付大屏项目之前都会要求团队过一遍自查清单这里一并列出来当参考1920x1080、1366x768、2560x1440、3840x2160 四种分辨率下UI完整无变形窗口从最小值拉到全屏再快速缩放图表无错乱、无白屏不同字号下文本无截断图表 tooltip 显示位置正常全屏 API 切换后页面自适应正常触发浏览器缩放ctrl 加减和窗口缩放同时发生时布局不冲突远程桌面连接和局域网访问时页面加载完成后自适应生效这个清单不是每项都需要自动化测试但至少手动过一遍。尤其是全屏 API 和窗口缩放同时发生的情况很容易被忽略等客户在展厅环境里演示时才发现字体错位那时候返工成本就高了。我在实际交付里更偏爱 scale 方案多一点点因为它让产品还原度极高视觉冲击力强特别适合对外展示的场景。但如果你接手的项目已经有 rem 的技术债积累改造起来也不是难事核心是把 echarts 的 resize 逻辑处理好其他都只是配配置的问题。两种方法我都跑过不下十个大屏项目整体感受是选方案没那么纠结真正考验人的是对细节的把控。从监听事件到防抖节流从插件黑名单到图表 resize 时机每一处细节都能决定交付时画面是否完美。
返回列表