做数据大屏的同行应该都有过这种经历开发的时候在 1920×1080 的显示器上把图表、边框、标题调得严丝合缝一到客户现场接上汇报厅那台不明分辨率的屏幕整个版面要么被拉成宽银幕要么上下空出一大片字还糊成一团。去年我交付一个智慧园区项目时就吃过这个亏后来花了两天把 vue3 ts vite 技术栈下的大屏自适应方案重新整理了一遍沉淀出两套思路完全不同的做法。这篇文章就把这两套方案的原理、代码、坑一次性说清楚。先说结论大屏自适应这件事本质上是设计稿坐标系和浏览器实际视口坐标系之间的映射问题。设计稿永远有一个固定尺寸通常是 1920×1080而浏览器窗口是一个随时可能变化的矩形两者之间只存在两种可靠映射——等比缩放映射和流式换算映射。下面要讲的两种方法恰好分别对应这两种映射。1. 老生常谈的问题大屏为什么总是变形1.1 一个交付现场的真实翻车案例先交代一下翻车现场。当时项目用的是 vue3 ts vite大屏页面所有间距、边框、字体都用 px 写死内部测试清一色 1920×1080 戴尔显示器一切正常。到了客户机房控制台接了一台 3840×1080 的超宽带鱼屏打开页面的那一刻所有横向布局被拉长了 100%圆角边框变成了椭圆角ECharts 饼图被拉成鸡蛋形标题字间距宽得离谱。客户当时只说了一句这屏做得太糙了。但问题根源并不在于 UI 设计而在于我压根没有做任何自适应处理。浏览器在非 1920×1080 视口下默认行为就是把 1920px 的内容平铺到实际像素宽度里元素数量不变、字号不变所以所有几何形状全部被横向拉伸。这件事让我意识到大屏项目从第一行 CSS 开始就必须先确定自适应策略不能指望画完再做适配。1.2 自适应到底在解决哪几类偏差大屏页面和一个普通后台管理系统自适应诉求其实不一样的。普通后台系统追求信息密度随屏幕大小变化内容可以换行、滚动、压缩而大屏的核心诉求是视觉效果整体统一——所有元素必须保持设计稿里的相对位置、比例、字体层级不能因为屏幕变宽就出现图表被拉扁但标题字号不变的撕裂感。具体来说大屏场景下要处理的偏差有三类宽高比例偏差设计稿 16:9实际屏是 21:9 或者 4:3直接导致整体拉伸或留边。物理像素偏差同样 1920×1080 的视口在 2K 屏和 4K 屏上渲染出的文字清晰度、边框粗细观感完全不同。运行时窗口偏差大屏页通常会被投屏到拼接屏、会议屏这些设备经常以浏览器全屏模式打开但 F11 全屏、F 键全屏、浏览器工具栏的显隐都会改变视口尺寸页面必须实时响应。理解了这三类偏差两种方法的差异就很容易看明白了scale 缩放方案把所有偏差一锅端让页面整体缩放rem 换算方案则把设计稿当成可变宽度流来处理只保证横向不溢出不拉伸。至于哪种更适合你的项目往下看。2. 方法一scale 等比缩放把画布整体装进屏幕2.1 核心思路一张固定尺寸画布解决所有布局这个方法的思想非常简单粗暴后台页面是一个 1920×1080 的画布所有布局、字号、间距全部按这个尺寸用 px 写死然后通过 CSS 的transform: scale()把整张画布等比例缩放直到它刚好装进当前浏览器窗口。打个比方这就像拿着一张大尺寸海报去看展会场地不够大你不会把海报剪裁掉一部分而是直接把它按比例缩小贴到墙上。海报里的每个元素相对位置、字体大小层级、圆角比例全部保持原样观感最接近设计稿。实现上需要一个关键前提画布必须用固定宽高并且外层容器要精确控制溢出和定位。下面这段是我工程里的核心结构div classfit-screen div classfit-screen-canvas :stylecanvasStyle Header / LeftPanel / CenterChart / RightPanel / /div /div.fit-screen { width: 100vw; height: 100vh; overflow: hidden; background: #0a0e27; } .fit-screen-canvas { width: 1920px; height: 1080px; transform-origin: left top; }overflow: hidden是必须的因为 canvas 的宽高始终是 1920×1080如果不隐藏溢出屏幕的部分浏览器可能会出现横向滚动条用户拉一下滚动条整屏布局就露馅了。transform-origin: left top也很关键它决定了缩放的中心点设置为左上角之后canvas 会以屏幕左上角为锚点进行缩放否则默认以元素中心为原点一缩放整个页面位置就偏了。2.2 封装 useFitScale 组合式函数在 vue3 ts 项目里缩放逻辑最优雅的做法是封装成一个组合式函数所有大屏页面只需要调用一次就能拿到实时计算的缩放样式。我建议同时支持等比缩放和非等比缩放两种模式下面是我的完整实现import { ref, computed, onMounted, onUnmounted, type CSSProperties } from vue interface UseFitScaleOptions { designWidth?: number designHeight?: number /** * true 表示等比缩放取宽高缩放值中较小的值 * false 表示非等比缩放宽度和高度各自拉伸 */ uniform?: boolean } export function useFitScale(options: UseFitScaleOptions {}) { const { designWidth 1920, designHeight 1080, uniform true } options const scaleX ref(1) const scaleY ref(1) const updateScale () { const { innerWidth, innerHeight } window const nextScaleX innerWidth / designWidth const nextScaleY innerHeight / designHeight if (uniform) { const scale Math.min(nextScaleX, nextScaleY) scaleX.value scale scaleY.value scale } else { scaleX.value nextScaleX scaleY.value nextScaleY } } const canvasStyle computedCSSProperties(() ({ width: ${designWidth}px, height: ${designHeight}px, transform: scale(${scaleX.value}, ${scaleY.value}), transformOrigin: left top, })) const handleResize () { updateScale() } onMounted(() { updateScale() window.addEventListener(resize, handleResize) }) onUnmounted(() { window.removeEventListener(resize, handleResize) }) return { canvasStyle, scaleX, scaleY } }使用起来非常干净在组件里一行接入const { canvasStyle } useFitScale()然后模板里的 canvas 直接绑定:stylecanvasStyle就可以。resize 事件没有做防抖是因为绝大多数场景下浏览器触发 resize 的频率并不高人为拖拽窗口时连续触发几十次 scale 计算也毫无压力如果你在页面里还挂了 ECharts 的 resize 监听建议给 ECharts 的 resize 做防抖而 scale 本身不需要。有一个细节uniform: false的非等比模式实际项目中我不推荐作为默认方案因为宽度拉伸和高度拉伸系数不一致时所有正方形会变成矩形文字会被压扁或拉长。但有一种特殊情况必须用它——当你明确知道目标屏幕是一个固定分辨率、且和设计稿宽高比完全不一致时比如设计稿是 16:9、投屏设备是 16:10 的小分辨率非等比缩放至少能保证画面铺满整个屏幕虽然几何形状略有变形但比留两大条黑边在视觉上更能接受。2.3 等比缩放会留黑边怎么处理更好看等比缩放有个绕不开的问题屏幕宽高比跟设计稿不一致时必然有一侧留白。比如设计稿 1920×1080实际屏幕 1920×1200高度按比例算出来scale 1920/1920 1但1080 * 1 1080 1200所以上下各留 60px 空白。处理手段是把外层容器的背景色和设计稿底图融为一体。大屏页面的底色通常是深色而且有渐变光效、科技感背景图只要把外层的背景做成和画布背景完全连续的图案留白部分看起来就是底色延伸而不是一块未适配的空白。我通常会给.fit-screen设置跟 canvas 背景同一张底图并用background-size: cover铺满.fit-screen { width: 100vw; height: 100vh; overflow: hidden; background: url(/assets/bg.png) center / cover no-repeat; background-color: #0a0e27; }这样即使留白视觉上也像是背景的一部分。中心区域如果不够满ECharts 地图或大标题可以适当用height: 100%让内容撑开而不是固定在 1000px 之类的绝对高度。3. 方法二rem postcss-pxtorem流式布局的自适应3.1 原理让一切单位跟着根字号走第二种方法走的是前端移动端适配的老路把设计稿里的 px 全部编译成 rem然后根据视口宽度动态调整html的 font-size让整个页面像水一样跟着屏幕宽度流动。原理一句话就能说清楚rem 是相对单位1rem 等于html根节点的 font-size。只要根字号变大页面上所有用 rem 标记的尺寸就同步变大根字号变小所有尺寸同步变小。因此只要以设计稿宽度为基准让根字号 视口宽度 / 设计稿宽度 × 设计稿基准字号一切 px 转 rem 后的元素就会跟视口宽度保持线性关系。在 1920 设计稿场景下最省心的做法是基准字号取 100。即1920px 设计稿宽度 19.2rem这个数字不算难算而且在 postcss-pxtorem 里把rootValue设 192即 1920/10会更常见因为 1rem192px10px0.052rem心算确实费劲但反正有自动转换工具开发者写代码时仍然写 px编译阶段统一转 rem所以基准字号算不算得顺手其实无所谓。3.2 Vite 工程接入配置在 vite 工程里接入 postcss-pxtorem 有两种方式一种是在postcss.config.js里配置一种是直接写在 vite.config.ts 的css.postcss字段里。我习惯写在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: 192, propList: [*], selectorBlackList: [.ignore-rem], exclude: /node_modules/i, }), ], }, }, })除了工程配置还需要一段 JS 动态设置根字号。把下面这段放到main.ts入口或者放到大屏 Layout 组件的onMounted里const setRootFontSize () { const designWidth 1920 const clientWidth document.documentElement.clientWidth const rootFontSize clientWidth / (designWidth / 192) document.documentElement.style.fontSize ${rootFontSize}px } window.addEventListener(resize, setRootFontSize) setRootFontSize()注意一个关键点postcss-pxtorem的rootValue: 192是从设计稿 1920 除以 10 推出来的而 JS 里designWidth / 192的计算逻辑必须和它对齐。也就是说设计稿宽度如果是 1920rootValue 设 192 后JS 根字号的目标值就是视口宽度对应的 1920 等分值。一旦切到 1366×768 的笔记本屏幕clientWidth1366根字号就是1366 / 10 136.6px页面所有元素相对宽度缩小这一套公式就闭环了。使用 rem 方案后设计稿里写在 scoped 样式里的 px 会自动转 rem但如果某些第三方 UI 组件库比如 Element Plus 的el-dialog、el-table的样式是打包在自己的 css 里的exclude: /node_modules/i会把这些样式排除掉导致组件尺寸不跟随缩放。这时候需要在业务代码里手动给组件设置:style{ fontSize: …rem }或者干脆把组件放到 scale 方案的外层画布里用绝对定位缩放两种方案结合使用我后面细说。3.3 ECharts 图表在 rem 方案里的适配处理热词里有一条pxtorem 对 ECharts 没起到效果这个问题几乎所有用过 rem 方案的人都会遇到原因一句话postcss-pxtorem 只处理 CSS 文件里的 px而 ECharts 的配置项是通过 JavaScript 直接传给 canvas 渲染引擎的CSS 编译根本不会经过这些 JS 里的数字。所以 ECharts 里的fontSize: 14、图形大小数值不会因为 rem 根字号变化而变化。要让 ECharts 也跟随 rem 方案自适应必须在传给 ECharts 之前做一次px 转 rem换算。我封装了一个专门用于 ECharts 坐标轴、字体、图形大小的辅助函数const designWidth 1920 /** 把设计稿里的 px 换算成当前屏幕下的实际像素值 */ export function pxToCurrent(px: number) { const clientWidth document.documentElement.clientWidth return (px / designWidth) * clientWidth } export function getChartOptions() { return { title: { text: 园区设备在线率, textStyle: { fontSize: pxToCurrent(18), }, }, tooltip: { textStyle: { fontSize: pxToCurrent(14), }, }, // 其他配置项 } }然后在大屏页面监听 resize里面除了更新根字号还要调用每个 ECharts 实例的chart.resize()方法并重新setOption。因为图表容器的尺寸变了如果不手动resize()canvas 内部的绘制尺寸不会自动更新const chartInstance echarts.init(chartRef.value) const handleResize () { setRootFontSize() chartInstance.resize() chartInstance.setOption(getChartOptions()) } window.addEventListener(resize, handleResize)这套逻辑跑通之后ECharts 会跟着根字号一起适配。代价是每个图表组件都要写一遍 resize 和 setOption 流程比 scale 方案稍微繁琐一点。4. 两种方案的真实对比没有银弹只有取舍4.1 从六个维度拉个对照表经常有人问我到底用 scale 还是 rem我的回答是取决于你的大屏是纯展示型还是交互型。纯展示型数据可视化 LED 屏、监控大屏用 scale几乎零成本还能保证效果交互型带有筛选器、轮播表格、弹窗表单的大屏用 rem因为交互元素的点击热区、表单控件、弹窗需要跟随窗口真实变化而 scale 缩小的元素点击热区也会跟着缩小在小屏上容易点不中。对比维度scale 等比缩放方案rem 流式换算方案实现复杂度低一个组合式函数搞定中需要工程配置 换算工具函数视觉还原度高完全还原设计稿比例较高但高度方向依赖设计稿长宽比宽高比差距大时纵向可能溢出对 ECharts 的影响整体缩放canvas 内文字可能发虚需要在配置项里手动换算但渲染清晰对第三方 UI 组件库无解组件样式照常被缩放需要额外适配 node_modules 里的样式运行时窗口比例变化表现稳定始终完整显示比例变化太大时可能上下截断或留白异常多分辨率极端场景适合均匀缩放适合宽度主导的普通显示器真实项目里我遇到过一个典型的对比案例同样是 3840×1080 带鱼屏scale 方案把所有图表等比缩小 0.5虽然左右两侧会留出大量黑边但至少大屏内容完整可读rem 方案则把设计稿宽度撑到 2 倍宽度方向所有图都被拉宽饼图被拉成鸡蛋形。所以如果知道客户的投放屏是带鱼屏第一选择一定是 scale 方案然后通过背景铺色把留白消掉如果不知道投屏设备具体比例scale 方案也永远比 rem 方案更保底。4.2 更推荐的大屏组合打法scale 为主 rem/vw 补细节实际落地时我很少只用单一方案而是做一个组合外层布局和模块尺寸用 scale 整体缩放但字体大小、表格行高、弹窗宽度这类精细交互元素用 rem 或 vw 单独标注。这么做的直接原因是transform scale 的缩放会让位图文字在非整数缩放比下变糊注意是糊而非小因为浏览器把元素缩到 0.83 倍时文字栅格化结果不是按 0.83 重新渲染而是先按原始尺寸渲染再降采样小字号文字在高 DPI 屏上尤其明显。所以我的实际策略是大屏容器useFitScale等比例缩放到正好装进视口。所有图表配置项字体、图例间距用pxToCurrent动态换算保证 canvas 内渲染的文字始终以当前真实像素绘制清晰不糊。标题、数值、单位等关键文字用 rem 标注跟随根字号微调避免被 scale 缩放后发虚。这样组合之后页面整体比例是稳定的文字细节是清晰的ECharts 图表也不会出现缩放的锯齿感。代价是每个图表组件里要写一个适配函数但对一个真正要交付给客户看的大屏系统来说这个成本非常值得。5. 我踩过的坑从能显示到像样的细节5.1 最小字号限制所有方案都躲不过的坑浏览器有个不成文的规定Chrome 下小于等于 12px 的中文渲染相当难受部分浏览器甚至强制最小字号为 12px。在大屏场景里设计稿为了信息密度经常会用到 10px、11px 的小字如果这些文字在 rem 方案里被转换成很小的 rem 值浏览器会四舍五入或强制抬升到 12px导致实际排版和设计稿有细微偏差在 scale 方案里如果整屏缩放比是 0.712px 的字会被缩到 8.4px虽然浏览器不会强制抬升 CSS 变换后的渲染大小但观感上已经超过肉眼辨认的舒适区。我的处理建议是设计稿验收阶段就把小于 12px 的字体全部改掉大屏上业务数据比普通 web 系统更追求扫一眼能看见宁可信息量少一点也不要字小到看不清。如果实在有 10px 的辅助文字在 rem 方案里就放弃自动转换手动写成 vw 单位让它的实际渲染宽度跟视口宽度挂钩不会触发最小字号强制。5.2 弹窗、滚动条、全屏 APIscale 方案的三颗地雷scale 方案有个隐蔽的坑transform: scale()不会改变元素的占位尺寸它只是视觉上缩放了。所以如果你的大屏里有一个很高的弹窗列表即使视觉上被缩到屏幕内它的点击热区仍然是原来那么大而且如果外层没有overflow: hidden页面依然可能出现滚动条用户一滚就露馅。另一个坑是全屏 API。很多大屏页面会在按钮上调用document.documentElement.requestFullscreen()进入全屏但全屏切换会改变window.innerWidth和innerHeight如果你的 scale 计算只监听resize有些浏览器在进入/退出全屏时并不触发resize尤其 Safari 和部分国产浏览器页面不会重新缩放。我处理的办法是除了监听resize再监听fullscreenchange事件在回调里强制重新调用一次updateScale()。5.3 异形屏和拼接屏的最终兜底固定方案 输出参数化最后说一个身经百战后总结的经验大屏项目开发阶段根本无法模拟客户现场所有的投屏设备所以最好的兜底不是写更多适配代码而是把这个项目做到换屏幕不重构的程度。在我维护的大屏模板里设计稿宽高、是否等比缩放、背景图是否 cover这些参数全部是集中放在一个config.ts里暴露出来的export const screenConfig { // 改这里就能适配不同设计稿 designWidth: 1920, designHeight: 1080, uniform: true, background: linear-gradient(135deg, #0a0e27 0%, #1a1f3d 100%), }这样下次遇到客户临时把 16:9 改成 21:9 的屏我只需要改 designWidth 或背景图所有组件、图表、排版整体跟着画布走不用一个个调。这种参数化兜底的思路比任何自适应方案都更能应对现实世界的混乱需求。6. 从这套方案出发还能顺手解决的事两套方案跑通之后你会发现很多此前头疼的问题都顺手解决了。vue3 的 TS 组合式函数封装让自适应逻辑可以在多个大屏页面之间共享不用每个页面复制一遍脚本。比如写了一个大屏用的useECharts组合式函数里面内置了resize监听和pxToCurrent换算后续所有图表组件引进来直接用代码量下降明显。这种共享组合式函数的模式是 vue3 ts 相比 vue2 时代 Options API 最大的红利自适应这种横切关注点很适合用 Composition API 收纳。另外如果未来需要做多语言版本的大屏或者要把页面导出成图片scale 方案的优势会更明显——因为所有元素都在一个固定尺寸画布里用html2canvas截图时生成的图片尺寸就是设计稿原始尺寸清晰度非常高而 rem 方案里截图的尺寸会随当前窗口大小变化打印或分享时效果不稳定。最后关于 ts 在这套方案里的价值多说一句canvasStyle: CSSProperties、UseFitScaleOptions这些类型定义会让组合式函数的输入输出非常明确团队其他人接这个大屏项目时光看函数签名就能知道传入设计稿宽高、拿到缩放样式不用翻实现代码。这就是 ts 在业务代码里最实在的价值不是炫技而是让别人接手时不踩坑。