ARTICLE DETAIL

资讯详情

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

Vue PC端屏幕缩放适配:从1920设计稿到4K屏的rem落地方案

Vue PC端屏幕缩放适配:从1920设计稿到4K屏的rem落地方案 上周帮一个朋友看他们公司的后台系统需求听起来很朴素设计稿按 1920×1080 出的要求在不同分辨率的电脑上打开别散架顺便把 4K 显示器也照顾一下。翻代码的时候我发现了两种很典型的写法一种是直接把内容塞进固定 1920px 宽的容器再 transform: scale 缩一下另一种是到处写媒体查询1440、1600、1920 各来一套。这两种都能跑但都埋了雷前者在滚动和固定定位上出事后者维护成本高到没人愿意改。前端页面适配、屏幕缩放、4K 屏这几个词放在一起本质上是同一件事当设计稿的像素坐标系和用户屏幕的像素坐标系不一致时怎么让页面看起来还是那个样子。这篇文章就是把这套东西从原理到代码完整讲一遍包括 Vue 工程里怎么配 PostCSS、运行时脚本怎么写、ECharts 和 Canvas 怎么跟着走、4K 屏为什么要做上限收敛还有我踩过的几个真坑。不管你是刚接触 Vue 的入门选手还是做过几年项目的老开发这里面的参数计算过程和排查路径都能直接拿去用。1. 设计稿 1920 宽度为什么换个屏幕就变形1.1 三层像素概念的错位先把最基础的东西捋清楚不然后面所有参数都是瞎调。我们平时说的像素在不同语境下其实是三样东西设计稿像素、CSS 像素、设备物理像素。设计师给你的 1920×1080 是设计稿像素它只是一个坐标系约定浏览器里写 width: 100px 是 CSS 像素而显示器上真正发光的点是物理像素。三者之间隔着两层换算任何一层没考虑清楚页面就会看起来不对。第一层换算是浏览器缩放和系统缩放。Windows 上默认的 125% 缩放非常常见一台标称 1920 宽的笔记本开了 125% 之后浏览器里能拿到的 clientWidth 只有 1536 左右。这时候如果你按 1920 硬写布局页面就会出现横向滚动条。第二层换算是 devicePixelRatio也就是物理像素和 CSS 像素的比值。在 125% 缩放下 dpr 通常是 1.25在 4K 屏 200% 缩放下 dpr 是 2。这个值直接影响图片和 Canvas 的清晰度跟布局适配是两码事但经常被人混在一起谈。很多人一开始会想当然地认为4K 屏就是宽度 3840然后按 3840 写一套样式。实际情况是4K 屏在操作系统里绝大多数都会开 150% 到 200% 的缩放最终浏览器里的 clientWidth 往往还是 1920 甚至 2560 左右。所以你真正需要面对的不是3840 这么宽怎么办而是视口宽度在 1280 到 2560 这个区间里连续变化时怎么保持稳定。把这个认知扭转过来后面的方案选型会清晰很多。还有一点容易被忽略视口宽度并不等于 window.innerWidth。window.innerWidth 包含了垂直滚动条的宽度在 Windows 上经典滚动条大约占 17px。如果你用 innerWidth 去算缩放比例页面内容会比你预期的宽一点点在临界值上就会莫名其妙冒出横向滚动条。正确做法是读 document.documentElement.clientWidth它排除了滚动条。这个细节我在后面写运行时脚本时会再强调一次。1.2 四种常见但会埋雷的偷懒方案我在实际项目里见过不少能跑但不敢改的写法这里逐个拆一下它们的问题在哪理解了这些你才能明白后面为什么选 rem 这条路。第一种是固定宽度加横向滚动。容器写死 1920px屏幕小就出滚动条。这个方案在内部工具里其实不算错因为保真度最高但用户体验很差1440 的笔记本上要左右拖着看表头做数据录入的同事会直接来找你。而且一旦页面里用了 position: fixed 的侧边栏或表头横向滚动时它们会脱节修起来很麻烦。第二种是全站 transform: scale。思路是把整个页面当成一张图按屏幕宽度等比缩。看起来省事实际上有几个硬伤scale 之后所有点击热区的坐标虽然浏览器会帮你换算但跟 fixed 定位、position: sticky、以及输入法弹窗的配合会出各种怪问题另外字体被整体缩放在 1366 屏上会缩放成 0.71 倍小字直接糊掉Chrome 还会因为最小字号限制拒绝渲染过小的字导致文字和容器比例失调。这个方案只适合纯展示的大屏不适合有交互的业务系统。第三种是纯媒体查询堆断点。在 1280、1440、1600、1920 各写一套覆盖样式。问题是断点之间是跳变的1440 到 1600 之间那一段没人管页面要么挤要么空而且每加一个组件就要补四份样式一个按钮的 padding 要写四遍三个月后没人敢动。这种方案在小项目上能撑住一旦页面超过二十个维护成本会指数上升。第四种是用 vw 单位硬怼。1vw 等于视口宽度的 1%1920 设计稿下 100px 就等于 5.2083vw。听着很优雅问题是它没有任何上限和下限在 4K 屏上视口 2560 时一个设计稿里 14px 的字会变成 18.6px还行但如果用户把浏览器拉满到超宽屏 3440字会变成 25px整个页面像被吹了气。所以 vw 必须配合 clamp 或者媒体查询做边界收敛裸用是不行的。把这四种方案的问题摆在一起看你会发现核心矛盾就两个一是缩放要连续平滑不能跳变二是缩放要有上下限不能无限放大或缩小。rem 方案恰好能同时满足这两点因为它的缩放比例是在运行时用 JS 算出来的你想怎么限定就怎么限定。2. 适配方案选型rem、vw、scale 到底怎么挑2.1 三条路线的能力对比把选择摊开来讲PC 端适配主流就三条路rem 动态根字号、vw/vh 视口单位、transform scale 整体缩放。它们在能力上是有明确差异的我整理了一张表你可以直接按自己的场景对号入座。对比维度rem 动态根字号vw / vh 视口单位transform scale缩放连续性连续JS 计算连续CSS 自动连续能否设上下限可以JS 里 clamp需要 clamp 配合可以对 fixed 定位影响无无有需额外处理第三方组件库适配需排除或接受需排除或接受无影响字体清晰度正常正常小屏会糊适合场景业务后台、管理系统移动端、简单落地页大屏可视化、演示页调试难度中需要换算低低但问题隐蔽从这个表能看出来rem 是唯一一个既能连续缩放、又能精确控制边界、还不破坏 fixed 定位的方案。它的代价是需要一个运行时脚本以及 PostCSS 的配合。这个代价我认为完全值得因为脚本只有十几行写完就再也不用管了。vw 的问题在于它的锚点是视口你没法在 CSS 层面说最多放大到 1.5 倍只能靠 clamp 包一层。而 clamp 在大量属性上写起来很啰嗦不如在 JS 里统一算好根字号来得干净。transform scale 我保留它但只用在纯展示的大屏可视化场景后面第 4 章会专门讲怎么写。还有一个混合思路值得一提主结构用 rem 保证业务组件的适配个别需要极致保真的页面比如一张工艺流程图的展示页单独用 scale 包起来。这种按页面选方案的做法在实际项目里很常见不用强迫全站统一。2.2 rem 方案的缩放公式与参数推导选定 rem 之后最关键的一步是确定基准字号。很多教程直接给 rootValue: 16 或者 37.5但没解释为什么我这儿把推导过程走一遍。设设计稿宽度为 W这里是 1920设计稿中某个元素的宽度为 P比如 100px我们希望它在任意视口宽度 V 下显示的宽度是 P × (V / W)也就是等比缩放。用 rem 实现的话元素的 CSS 宽度写成 P / B rem其中 B 是基准字号那么它的实际像素宽度等于 (P / B) × R其中 R 是运行时的根字号。让这两个式子相等(P / B) × R P × (V / W)两边约掉 P得到 R B × (V / W)。也就是说运行时的根字号等于基准字号乘以视口与设计稿的比值。这个公式就是整个方案的心脏PostCSS 负责把 P 转成 P/B运行时脚本负责把 R 设成 B × (V/W)两边一配合等比缩放就成立了。接下来定 B 的值。B 不能太小原因前面提过Chrome 对中文有 12px 的最小字号限制如果 R 被算出来低于 12px浏览器会强行按 12px 渲染但 CSS 里 rem 的换算仍然按你设置的数值走结果就是实际渲染比预期大了几个百分点容易出现横向溢出。所以 B 的选择要保证在你要支持的最小宽度下R 仍然大于 12px。假设最小要支持到 1280 宽那么 V/W 1280/1920 0.6667。要 R 12则 B 12 / 0.6667 18。取整用 20 比较舒服这样 1280 时 R 13.33px安全1920 时 R 20px2560 时 R 26.67px。B 20 还有个好处设计稿里 100px 对应的 rem 值是 5rem整数好算调样式的时候心里有数。如果你的项目完全不需要支持 1280最低只到 1440那 B 取 16 也可以用此时 1440 下 R 12px正好卡在最小字号的边界上有点悬我建议还是留余量。另一个思路是 B 取 32 或者 40让 rem 的数值更碎换算更精细但调试时手算麻烦。综合下来 B 20 是我用得最顺的一个值后面所有代码示例都按这个来。再补一句关于高度的。这套公式只处理了宽度方向的等比缩放高度方向是自然流式的。为什么不用同时按宽度和高度缩放因为 PC 浏览器的高度可变性太大了地址栏、书签栏、任务栏都会影响按高度缩放会导致刷新一次页面尺寸就变一次。所以 PC 端适配只锚定宽度高度交给内容自然撑开需要满屏的场景再用 min-height: 100vh 兜底。2.3 缩放比例要设上限尤其是往 4K 方向公式里的 V/W 如果是无约束的在 3840 宽4K 屏不开缩放的情况下会得到 2.0根字号变成 40px。40px 的正文是什么概念一个 14px 的字变成 28px一屏能放下的信息量锐减用户会觉得这个网站怎么这么大。这就是为什么必须给缩放比例设上限。我的经验值是上限 1.5下限 0.65。也就是说当 V/W 0.65 时锁定 0.65防止超小屏上字太小当 V/W 1.5 时锁定 1.5防止 4K 上元素被吹大中间区间按实际比例走。这个 1.5 是怎么来的我做过几轮对比在 2560 和 3840 的屏幕上分别试过 1.25、1.5、1.75、2.0。1.25 时页面在大屏上显得空右侧留白过多2.0 时字太大一个表格一屏只能看五六行1.5 是个中间值2560 屏上内容撑满3840 屏上内容区居中两侧各留一点空隙视觉上是最舒服的。当然这个值跟你的信息密度有关如果你的系统本来就是大卡片、大字号的风格可以放宽到 1.75。上限锁定之后3840 屏上页面实际只占 1920 × 1.5 2880px 的宽度剩下 960px 怎么处理两个选择一是容器居中两侧留白二是把容器的 max-width 放开让内部栅格比如 Element Plus 的 el-row/el-col自动铺开多出来的空间分配给列表和内容区。第二种更适合后台系统因为表格能多显示几列图表能更宽。实现方式是把外层容器设成 max-width: 1920px; margin: 0 auto而内部内容区不设上限。这样在 4K 上侧边栏保持 1.5 倍缩放内容区横向拉伸观感最好。需要提醒的是缩小方向的下限 0.65 也有讲究。如果你的用户群体里还有 1280×720 的老设备0.65 对应根字号 13px勉强能看清再小就伤眼了。低于这个宽度我建议直接出横向滚动条而不是继续缩因为缩下去的可读性损失比滚动带来的不便更严重。3. Vue 工程里从配置到代码的完整落地3.1 PostCSS 插件的安装与关键配置项工程化这块首选还是 postcss-pxtorem它能在构建时自动把 CSS 里的 px 按你定的基准转成 rem你写样式的时候完全按设计稿标注的数值写不用自己换算。这一步是整个方案里最省事的关键。安装很简单Vue CLI 和 Vite 项目都一样npm install postcss-pxtorem --save-devVite 项目在项目根目录建 postcss.config.js// postcss.config.js import pxtorem from postcss-pxtorem export default { plugins: [ pxtorem({ rootValue: 20, unitPrecision: 5, propList: [*], selectorBlackList: [.norem, /^\.el-/], replace: true, mediaQuery: false, minPixelValue: 2, exclude: /node_modules/i }) ] }这几个参数每个都值得说清楚配错了会出现打包后布局异常这种很难查的问题。rootValue 填 20跟前面推导的基准字号 B 保持一致这个值必须和运行时脚本里的 B 一模一样不一致的话所有尺寸会整体偏掉一个固定倍数最容易出问题。propList 填 [*] 表示所有属性都转换。如果你只想转宽度高度可以填 [width, height, padding, margin, font-size]这样 border 和 box-shadow 保留 px 不被缩放细节更精致。我个人的习惯是转全部但把 1px 边框用 minPixelValue 保护起来。selectorBlackList 是最容易被忽略的配置。第三方组件库比如 Element Plus、Ant Design Vue的样式如果也被转成 rem会因为它们的内部实现假设了 px 而出现错位尤其是那些用 JS 动态计算位置的组件比如下拉菜单、日期选择器、tooltip。我一般把 .el- 前缀加进黑名单让组件库保持 px 原样自己在组件外面包一层做适配。exclude 设成 /node_modules/i 是同一目的双保险。minPixelValue 设成 2意思是小于等于 2px 的值不转换。为什么要设这个因为 1px 边框如果在 0.65 倍缩放下变成 0.65px浏览器会渲染成模糊的灰线看起来像虚线。保留成 1px 反而清晰。同理那些用来做 1px 微调的 margin 也建议保护。mediaQuery 设成 false 表示媒体查询里的 px 不转换。这个要看你的断点策略如果你要用媒体查询做上限锁定那必须设成 false否则媒体查询里的 1920px 会被转成 rem而媒体查询中 rem 的参照是浏览器默认的 16px 而不是你设置的根字号断点会完全错位。这是个非常隐蔽的坑很多人在这里卡半天。3.2 运行时根字号脚本十行代码解决战斗PostCSS 负责静态转换运行时的根字号得靠 JS 动态设置。写一个独立的模块在 main.js 里引入即可// src/utils/flexible.js const DESIGN_WIDTH 1920 const BASE_FONT_SIZE 20 const MIN_SCALE 0.65 const MAX_SCALE 1.5 let cachedWidth 0 let timer null function calcScale() { // 用 clientWidth 而不是 innerWidth排除滚动条宽度 const viewportWidth document.documentElement.clientWidth || window.innerWidth const rawScale viewportWidth / DESIGN_WIDTH return Math.min(MAX_SCALE, Math.max(MIN_SCALE, rawScale)) } function setRootFontSize() { const scale calcScale() const fontSize BASE_FONT_SIZE * scale document.documentElement.style.fontSize fontSize px // 把缩放比挂到 CSS 变量上供个别组件使用 document.documentElement.style.setProperty(--vh-scale, scale) } function onResize() { const viewportWidth document.documentElement.clientWidth // 只有宽度真正变化了才重算避免移动端地址栏收缩触发误判 if (Math.abs(viewportWidth - cachedWidth) 1) return cachedWidth viewportWidth clearTimeout(timer) timer setTimeout(setRootFontSize, 100) } export function initFlexible() { cachedWidth document.documentElement.clientWidth setRootFontSize() window.addEventListener(resize, onResize, { passive: true }) window.addEventListener(pageshow, (e) { // 从缓存恢复页面时补算一次 if (e.persisted) setRootFontSize() }) }这段代码里有几个细节我想展开说。第一个是用 clientWidth 而不是 innerWidth前面提过是为了排除滚动条。第二个是防抖我给了 100ms。因为拖拽浏览器窗口时 resize 事件触发频率极高每次都重算根字号会导致大量重排页面会卡成幻灯片。防抖之后只在停止拖拽时算一次流畅度差很多。第三个是 pageshow 里的 persisted 判断。浏览器前进后退时如果命中了缓存页面不会重新执行 JS但视口可能已经变了比如用户从 1440 的外接屏拖到了笔记本屏幕上这时候不补算就会尺寸不对。这个场景不常见但确实存在加上只有三行代码不亏。第四个是那个 --vh-scale 变量。有些组件的尺寸没法用 rem 表达比如通过 JS 计算的 Canvas 高度、或者传给 ECharts 的数值这时候把缩放比暴露成 CSS 变量用 getComputedStyle 读出来或者直接在 JS 里导出这个值都会方便很多。还有一个 optional 的增强监听 devicePixelRatio 的变化。用户在浏览器里按 Ctrl 加号放大页面或者把窗口从 100% 缩放的屏幕拖到 150% 缩放的屏幕resize 事件有时不触发。可以加一个媒体查询监听const dprQuery window.matchMedia((resolution: ${window.devicePixelRatio}dppx)) dprQuery.addEventListener(change, setRootFontSize)这个主要用于处理跨屏拖拽的场景如果你的用户都是固定工位可以不加。3.3 组件库和第三方样式怎么隔离处理第三方组件库的适配是个绕不开的问题。这里我分三种情况说。第一种是纯 CSS 实现、用 px 写死的组件比如 Element Plus 的按钮、输入框。这类组件如果被 pxtorem 转换了在 1280 屏上会整体缩小本身不难看但和你的业务样式混在一起会有一点点比例不协调。我的做法是把它加进 selectorBlackList让它保持 px 原样然后在必要的场景比如大屏上用外层容器的 font-size 和 padding 去补偿。第二种是内部用 JS 计算位置的组件比如 Select 的下拉面板、Tooltip、Popover、DatePicker。这类组件的问题是它们通过 getBoundingClientRect 拿到触发元素的位置然后算出面板该出现在哪。如果你把触发元素的 px 转了 rem而面板的样式被黑名单排除了两边的坐标系就不一致面板会出现偏移。所以这类组件必须成对处理要么都转要么都不转。我倾向都不转用黑名单统一排除代价是在小屏上组件本身不缩小但这比位置错乱好得多。第三种是图表库。ECharts 完全不认 rem它的 fontSize、grid、padding 全部要传数值。所以在 ECharts 的配置里你得手动把设计稿数值换算成当前实际像素// src/utils/rem.js const DESIGN_BASE 20 export function remToPx(designPx) { const rootFontSize parseFloat( getComputedStyle(document.documentElement).fontSize ) return (designPx / DESIGN_BASE) * rootFontSize } export function getScale() { const rootFontSize parseFloat( getComputedStyle(document.documentElement).fontSize ) return rootFontSize / DESIGN_BASE }用的时候ECharts 的 fontSize 写 remToPx(14)grid 的 left 写 remToPx(40)出来的效果就和其他元素一致了。注意这个函数必须在根字号设置之后再调用否则 getComputedStyle 读到的是默认值。在 Vue 组件里放在 onMounted 里调比较稳因为 main.js 里的 initFlexible 已经先执行了。图表还需要监听窗口变化重新 resizeimport { onMounted, onBeforeUnmount, shallowRef } from vue import * as echarts from echarts import { remToPx } from /utils/rem export default { setup() { const chartRef shallowRef(null) let chart null let observer null const renderChart () { if (!chartRef.value) return chart echarts.init(chartRef.value) chart.setOption({ textStyle: { fontSize: remToPx(14) }, grid: { left: remToPx(40), right: remToPx(20), top: remToPx(30) }, // ...其他配置 }) } onMounted(() { renderChart() // 用 ResizeObserver 监听容器本身的变化比监听 window 更精准 observer new ResizeObserver(() { chart chart.resize() }) observer.observe(chartRef.value) }) onBeforeUnmount(() { observer observer.disconnect() chart chart.dispose() }) return { chartRef } } }这里我用 ResizeObserver 而不是 window 的 resize是因为 window resize 会受防抖影响图表和页面其他部分的缩放可能不同步出现短暂的错位。ResizeObserver 监听的是容器元素自身页面缩放导致容器变化时会立即触发时机更准。3.4 大屏可视化场景的 scale 写法如果你的项目里有那种需要全屏铺满、不留空白的展示页比如指挥中心大屏、展厅屏幕rem 方案反而不合适因为你会希望内容严格按比例铺满不留任何多余空间。这种场景用 scale 更直接。标准写法是在外层包一个容器template div classscreen-wrapper div classscreen-content :stylecontentStyle slot / /div /div /template script setup import { ref, computed, onMounted, onBeforeUnmount } from vue const DESIGN_WIDTH 1920 const DESIGN_HEIGHT 1080 const scale ref(1) const offsetX ref(0) const offsetY ref(0) const contentStyle computed(() ({ width: DESIGN_WIDTH px, height: DESIGN_HEIGHT px, transform: scale(${scale.value}), transformOrigin: left top, marginLeft: offsetX.value px, marginTop: offsetY.value px })) function resize() { const w document.documentElement.clientWidth const h document.documentElement.clientHeight const scaleX w / DESIGN_WIDTH const scaleY h / DESIGN_HEIGHT // 取小值保证内容完整可见不被裁切 scale.value Math.min(scaleX, scaleY) offsetX.value (w - DESIGN_WIDTH * scale.value) / 2 offsetY.value (h - DESIGN_HEIGHT * scale.value) / 2 } onMounted(() { resize() window.addEventListener(resize, resize) }) onBeforeUnmount(() { window.removeEventListener(resize, resize) }) /script这段代码的核心是 Math.min(scaleX, scaleY)取两个方向缩放比里更小的那个保证内容既不横向溢出也不纵向溢出多出来的方向用 offset 居中。如果要做成铺满不留黑边就改成 Math.max代价是内容会被裁掉一部分。这两种模式我都用过展厅屏幕一般用 min 保完整监控大屏一般用 max 求铺满看你现场观众距离屏幕的远近决定。需要特别提示的是这个 scale 容器里的所有内部尺寸都按设计稿原样写不要用 rem也不要被 pxtorem 转换。所以最佳实践是给这个容器加个 class 放进 selectorBlackList比如就叫 .screen-content配置成 selectorBlackList: [.norem, /^.el-/, .screen-content]让它的所有像素保持原样。4. 4K 屏专项处理的几个细节4.1 缩放上限之外还要处理内容密度前面定了 1.5 倍的上限这在 4K 屏上会把页面撑到 2880px 宽剩下 960px 空白。如果你的页面是居中的卡片式布局空白会让页面看起来很小气。我的处理方式是把页面拆成框架和内容两层框架部分是侧边栏、顶部导航、面包屑它们跟随 1.5 倍缩放并居中内容部分是表格、图表、列表它们不设最大宽度直接铺满剩余空间。具体实现上根字号照常按 1.5 倍设置但内容区用一个容器包起来并放开宽度/* 框架层跟随缩放居中最大 1920 设计宽度 */ .app-shell { max-width: calc(1920px / 20rem * 1rem); /* 别这么写见下方说明 */ margin: 0 auto; }上面这个写法是错的我故意写出来提醒一下CSS 里没法用 rem 除以 rem 得到纯数字calc 不支持这种运算。正确做法是把设计稿的 1920px 换算成 rem 值 96rem然后写成 max-width: 96rem。这个值会跟着根字号缩放在 1.5 倍时等于 2880px在 1 倍时等于 1920px行为符合预期。.app-shell { max-width: 96rem; /* 1920 / 20 96 */ margin: 0 auto; } .app-content { /* 内容区不限制宽度4K 上可以横向铺开 */ width: 100%; }这样在 4K 屏上侧边栏和导航保持 1.5 倍缩放居中显示内容区的表格会横向变宽原本需要横向滚动的列现在一屏就能看全用户体验明显好于整体放大。这个框架收敛、内容放开的策略是我目前最满意的一套兼顾了美观和信息密度。还有一个细节表格的列宽。如果你在 Element Plus 的 el-table-column 上写死了 width那在 4K 上表格不会自动变宽因为列宽是固定的。解决办法是尽量用 min-width 代替 width让列宽可以随容器弹性变化或者用 width 加百分比混合的方式关键列固定次要列弹性。这个改动看起来小但在大屏上的观感差异很明显。4.2 图片、字体和 Canvas 的清晰度适配不只是布局的事4K 屏上图片糊不糊、字体锐不锐同样影响观感。这里分三块说。图片方面如果你的图是 1x 导出的在 dpr 为 2 的屏幕上会明显发虚。解决办法有三个层次。最简单的是把图片导出成 2x 尺寸然后在 CSS 里把宽度写成设计稿的一半或者用设计稿尺寸浏览器自己会缩小渲染看起来就很清楚。更规范一点用 image-set().banner { background-image: image-set( url(./banner.png) 1x, url(./banner2x.png) 2x ); background-size: cover; }如果项目用了构建工具也可以上 webpack 的 responsive-loader 或者 Vite 的插件自动生成多倍图不过对于只有几张装饰图的场景手动导出 2x 就够了不必引工具。字体方面4K 屏上一个常见问题是字体渲染变细、发虚。这跟字体的字形设计和渲染引擎有关。一个实用技巧是给正文加上 -webkit-font-smoothing: antialiased 和 text-rendering: optimizeLegibility让浏览器用灰度渲染代替次像素渲染在高 dpr 下边缘会更干净。另外中文字体建议优先用系统字体栈别硬上自定义的细体字库细体在 4K 上放大 1.5 倍之后笔画会显得发飘。Canvas 是容易被忽略的一块特别是那些用 Canvas 画的自定义图表、水印、签名板。如果只按 CSS 尺寸初始化 canvas在 dpr 为 2 的屏幕上会糊成一片。标准做法是function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1 const rect canvas.getBoundingClientRect() canvas.width Math.round(rect.width * dpr) canvas.height Math.round(rect.height * dpr) canvas.style.width rect.width px canvas.style.height rect.height px const ctx canvas.getContext(2d) ctx.scale(dpr, dpr) return ctx }关键是 canvas 的 width/height 属性像素缓冲尺寸要乘以 dpr而 CSS 尺寸保持逻辑尺寸然后用 ctx.scale 把绘图坐标系缩回去。这样你按逻辑坐标画图实际输出的是高分辨率位图在 4K 上依然锐利。注意这段代码必须在布局完成之后再执行否则 getBoundingClientRect 拿到的是 0。4.3 超宽屏和多屏拼接的兜底策略实际项目里还有一种情况用户用的是 21:9 的超宽屏宽度可能到 3440或者干脆是两块屏拼接成 3840 以上的宽度。这种情况下的缩放比例已经超过 1.5 的上限被锁死了页面只占 2880px右边会留很大一块空白。我的兜底策略是分三步走。第一步是给页面加一个背景装饰层让空白区域不是纯白比如放一层淡色的渐变或者网格纹理视觉上不至于太空。第二步是把内容区的栅格从固定列数改成自适应比如 Element Plus 的 el-col 用 :xs :sm :md :lg :xl 响应式配置让卡片数量随宽度自动增加。第三步是对于确实需要全屏铺满的页面切回第 3.4 节的 scale 方案按 max 模式铺满并允许裁边。还有一种更极端的情况用户的屏幕特别小比如 1024 宽的旧显示器。这时候缩放比被锁在 0.65根字号 13px页面宽度只有 1920 × 0.65 1248px超出了视口会出现横向滚动条。这是预期行为但如果你的用户里这类设备占比不低可以考虑把最小缩放降到 0.55或者干脆为小屏单独做一套简化布局隐藏侧边栏、减少表格列。我的经验是如果一个系统的主要用户都在 1366 以上0.65 这个下限就够用了如果有明确的低分屏用户群体那还是老老实实做响应式简化比硬缩更好。5. 常见问题与排查实录5.1 打包后布局异常的排查路径开发环境正常打包后布局异常是这类适配问题里最高频的投诉我接过好几次这种活。下面这条排查路径你可以照着走基本能覆盖九成的情况。第一步打开构建后的 CSS 文件搜一下有没有 rem。如果一行 rem 都没有说明 postcss-pxtorem 根本没生效。原因通常是配置文件格式不对Vite 项目如果同时存在 postcss.config.js 和 vite.config.js 里的 css.postcss 配置后者会覆盖前者另外 package.json 里如果有 browserslist 配置过于陈旧也可能导致插件被跳过。还有一种情况是用了 CJS 的 module.exports 写法但项目是 ESM配置文件直接报错被忽略了。第二步如果 CSS 里确实有 rem但尺寸整体偏了固定倍数那就是 rootValue 和运行时脚本里的 BASE_FONT_SIZE 不一致。这是最典型的问题改一个忘一个。建议把这两个值抽到一个共享配置里比如建一个 config/design.js 导出 DESIGN_WIDTH 和 BASE_FONT_SIZEpostcss.config.js 和 flexible.js 都从这里读从源头上杜绝不一致。第三步如果尺寸对了但组件位置错乱大概率是第三方组件库的样式被误转。检查 selectorBlackList 和 exclude 是否生效尤其是那些用 JS 定位的浮层组件。可以临时把 propList 改成 [*] 之外的白名单看看问题是否消失从而确认范围。第四步如果只有某些页面异常先看这些页面有没有用内联 style 写 px。内联样式是 JS 拼接的PostCSS 处理不到不会转成 rem所以在缩放的页面上就会大小不对。解决办法是内联样式里也用 rem 单位或者通过 remToPx 函数动态计算。第五步如果是在 SSR 场景比如 Nuxt下白屏或者样式闪烁通常是 flexible.js 在服务端执行时访问了 document。要把它包在 if (typeof window ! undefined) 里或者在 onMounted 阶段才初始化。5.2 问题速查表我把这几年遇到过的问题整理成了一张表出问题的时候可以直接对照。现象可能原因排查与解决出现横向滚动条用了 innerWidth 或 100vw包含滚动条宽度改用 documentElement.clientWidth100vw 改成 100%小屏上文字比预期大根字号低于浏览器 12px 最小字号被截断提高基准字号到 20 或以上或抬高最小缩放比大屏上元素被吹得过大缩放比没有上限在 JS 里 clamp 到 1.5打包后 px 没转 remPostCSS 配置未生效或被覆盖检查配置文件格式、插件安装、构建日志下拉浮层位置偏移组件库样式被转 rem 而 JS 用 px 计算把组件库加进 selectorBlackList图表文字大小不对ECharts 不认 rem需要数值用 remToPx 换算后再传给配置拖拽窗口时卡顿resize 未防抖频繁重排加 100ms 防抖4K 上图片发虚只有 1x 图dpr 为 2导出 2x 图或使用 image-setCanvas 绘制模糊未按 dpr 放大像素缓冲按 dpr 设置 canvas.width/height从后台返回页面尺寸不对页面命中缓存未重算监听 pageshow 的 persisted媒体查询断点失效mediaQuery 开启了 px 转 rem把 mediaQuery 设为 false1px 边框变虚线或消失被缩放成小于 1pxminPixelValue 设为 2这张表里我自己踩得最惨的是最后一条。当时一个列表的边框在 1366 屏上全变成了浅灰色虚线查了半天以为是显示器问题后来才发现是 1px 被缩成了 0.65px浏览器做了抗锯齿处理。把 minPixelValue 调整之后立刻就正常了。这种小细节特别容易被忽略但用户一眼就能看出来。5.3 我踩过的坑和几条实操心得最后分享几条从真实项目里攒下来的经验都是文档里不会写的东西。关于基准字号的选择我一开始按网上教程用了 16结果在 1366 的笔记本上刚好卡在 12px 边界某些页面的文字看起来比设计稿大一点点一开始以为是字体渲染问题查了很久才定位到最小字号限制。换成 20 之后彻底解决。所以如果你的项目要支持 1366 及以下基准字号别低于 18。关于防抖的时长我试过 50ms 和 200ms。50ms 在快速拖拽时还是会抖200ms 手感上会觉得松开鼠标后页面会顿一下。100ms 是我试下来最平衡的。但如果是低配设备或者页面特别复杂比如一屏十个图表可以放宽到 150ms宁可顿一点也别卡。关于 CSS 变量的用法除了把缩放比暴露出来我还会把一些关键尺寸也做成变量比如 --page-gap: 1rem。这样在需要针对某个断点调整间距时改一个变量就够了不用满页搜。这个习惯在大屏适配阶段特别省事。还有一个反直觉的点不要给 html 和 body 设置 font-size。有些人习惯在 global.css 里写 html { font-size: 16px } 做默认值这会跟 JS 设置的根字号打架。JS 是通过 element.style.fontSize 设置的内联样式优先级虽然高但如果你在某个媒体查询里又写了 html { font-size: 14px }在缩放切换的瞬间会闪一下。正确做法是让 JS 独占根字号的设置权CSS 里完全不要碰需要默认值就在 JS 初始化的时候给。关于测试一定要在真机上测不要只靠浏览器的设备模拟。模拟器的 dpr 和真实屏幕对不上图片清晰度和字体渲染的差异都看不出来。我一般的测试组合是Chrome 窗口拉到 1280 一档、1440 一档、1920 全屏一档然后在系统缩放分别为 100%、125%、150% 的情况下各看一遍最后上 4K 显示器拉满看一遍。这个流程走下来九成的问题都能提前发现。关于代码组织我建议把适配相关的所有东西收敛到两个文件一个是 utils/flexible.js只管根字号一个是 utils/rem.js只提供 remToPx 和 getScale 两个函数。其他业务代码不要出现任何硬编码的缩放计算。这样做的好处是将来如果要换方案比如从 rem 换成 clamp 加 vw只需要改这两个文件业务代码一行都不用动。我在一个项目里就是这么干的后来因为需求变化把缩放上限从 1.5 改到 1.75改了一个常量全站生效。还有个小技巧调试的时候可以在页面上临时显示当前根字号和缩放比用一行代码插到 body 末尾document.body.insertAdjacentHTML( beforeend, div styleposition:fixed;right:0;bottom:0;z-index:99999;background:#000;color:#0f0;font-size:12px;padding:4px id__debug_scale/div ) setInterval(() { const el document.getElementById(__debug_scale) if (!el) return const root getComputedStyle(document.documentElement).fontSize const w document.documentElement.clientWidth el.textContent viewport:${w} root:${root} scale:${(parseFloat(root) / 20).toFixed(3)} }, 500)上线前记得删掉这一段或者用环境变量控制。我在联调阶段基本都会开着它谁的值不对一眼就能看出来比打开开发者工具翻计算样式快得多。个人体会是PC 端适配这件事方案本身不复杂公式就一个代码也就几十行真正花时间的是边界情况的处理和各种第三方库的兼容。所以我的建议是尽早把适配方案定下来把参数写成常量集中管理然后在整个项目周期里都不要去动它。最怕的是做到一半换方案或者不同页面用不同方案那后期维护会非常痛苦。我见过一个项目里三种适配方案并存改一个公共组件的间距要同时理解三套逻辑那种感觉真的很难受。
返回列表