
设计稿是 375 宽的标注写着卡片间距 24px我在 iPhone 14 Pro 上打开一看间距明显比设计稿上窄了一圈右边还多出十几像素空白。这不是什么玄学是屏幕分辨率这个词被揉成了三个不同的东西混着用就会出这种结果。前端做适配分辨率本质上就是在这三套坐标之间做翻译翻译规则搞清楚了PC 和手机就都不是问题。下面这些内容是我这几年在移动端 H5、PC 后台、数据大屏三类项目里反复验证过的。既有常见分辨率的实际清单也有 moment 我需要选方案时的判断依据还有几个只有踩过才知道的坑。内容偏实操前端新手能照着抄有几年经验的也能在里面找到点之前没想透的地方。1. 分辨率不是一条数是三条数叠在一起1.1 物理像素、逻辑像素、设备像素比很多人第一次被绕晕是因为把屏幕分辨率当成了一个数字。实际上它至少是三条物理像素是屏幕面板上真实存在的发光点数量。厂商宣传的2K 屏1.5K 屏说的就是它比如 2560×1440 就是横向 2560 个物理像素点。逻辑像素也叫 CSS 像素是浏览器排版时用的长度单位。你写width: 375px这个 375 就是逻辑像素。设备像素比 DPR是这两者的商DPR 物理像素 / 逻辑像素。它是个缩放系数告诉你一个 CSS 像素要用几个物理像素来画。举个地图的类比。物理像素是地图上真实的路CSS 像素是你手指在地图上划出的一格。同一块地方你缩放级别调高一格对应的实际面积就小了格子数量变多。DPR 就是这个缩放级别。以 iPhone 14 Pro 为例物理分辨率 1179×2556CSS 视口是 393×8521179 / 393 3所以 DPR 3。这意味着你在 CSS 里写的 1px浏览器实际会用 3 个物理像素点去渲染它。这就是为什么1px 边框在高清屏上看起来偏粗也是为什么设计师给你的切图是 2 倍或 3 倍尺寸。在浏览器里可以直接读出来const dpr window.devicePixelRatio || 1; const cssWidth window.innerWidth; // CSS 像素宽度 const cssHeight window.innerHeight; // CSS 像素高度 const screenW window.screen.width; // 注意不同浏览器语义不完全一致 const physicalW cssWidth * dpr; // 大致对应的物理像素宽度这里有个细节值得强调window.screen.width在部分浏览器上返回的是缩放前的值和innerWidth不是一个语义。做适配时统一以innerWidth/document.documentElement.clientWidth为准别混着用。1.2 PC 与手机常见分辨率对照把常见设备摊开看规律很清楚手机侧 CSS 视口宽度集中在 360~430 之间PC 侧则在 1280~1920 之间中间那段 768~1024 是平板。设备类型常见物理分辨率典型 CSS 视口DPR传统 PC 显示器1920×10801920×约 9501PC 2K 125% 系统缩放2560×14402048×约 11521.25PC 4K 200% 系统缩放3840×21601920×10802笔记本高分屏2560×16001280×8002主流安卓手机1080×2340360×7803iPhone SE750×1334375×6672iPhone 14 / 151170×2532390×8443iPhone 14 Pro Max1290×2796430×9323折叠屏展开态约 2208×1848约 736×6163看这张表要抓住一个反直觉的点PC 上 2K、4K 屏的 CSS 视口宽度经常还是 1920 左右甚至更小。因为 Windows 默认会按屏幕尺寸推荐一个缩放比例2K 屏常见 125%4K 屏常见 200%。你写断点时如果按2K 2560 宽来设计实际根本进不去那个分支。这是很多 PC 端适配做偏了的根因。1.3 浏览器缩放为什么能让适配一夜崩盘用户按了 Ctrl 加号或者系统设了 125% 缩放DPR 就会跟着变。比如系统 125% 加浏览器 100%devicePixelRatio就是 1.25。如果代码里写的是if (dpr 2) {...} else if (dpr 3) {...}那 1.25 落不进任何分支样式直接错乱。这个错误我在一个旧项目里见过用户投诉页面在笔记本上打开是白板排查了半天才定位到。正确的处理是把 DPR 当连续值而不是枚举值// 反例枚举判断遇到 1.25 / 1.5 / 2.75 就翻车 if (window.devicePixelRatio 2) { /* ... */ } // 正例直接参与计算 const dpr window.devicePixelRatio || 1; const hairlineScale 1 / dpr;还有一个更隐蔽的问题用transform: scale()整体缩放页面时如果 scale 值和 DPR 叠加元素在实际渲染上的像素对齐会出现半像素文字发虚。所以缩放比例尽量用有限小数别用1/3这种无限循环的结果。2. viewport 这一行 meta 到底改了什么2.1 每个参数的真实作用meta nameviewport contentwidthdevice-width, initial-scale1这行代码在移动端项目里几乎是标配但它的每个参数到底控制什么不少人是在出问题之后才回头去看的。widthdevice-width是把布局视口的宽度设成设备逻辑宽度。没有这一行的话iOS Safari 会默认用 980px 的布局视口你的页面会被整体缩小成桌面网页的样子字体小到需要双指放大。这就是为什么有的 PC 页面直接扔到手机上看起来也能用但特别小而有的页面就正常——差别就在这行 meta。initial-scale1是初始缩放比例为 1配合上一行一起用保证首次渲染时没有缩放。maximum-scale1和user-scalableno是禁止用户缩放。这两个参数我现在的做法是不加。它们会影响无障碍体验而且部分新版本浏览器已经忽略这两个值了加了没效果反而让代码显得旧。还有一个容易被忽略的是viewport-fitcover配合 iOS 刘海屏的安全区使用meta nameviewport contentwidthdevice-width, initial-scale1, viewport-fitcover不加这个的话env(safe-area-inset-*)系列变量会全部返回 0底部安全区适配就失效了。这个是硬性前提别只写 CSS 忘了 meta。2.2 rem 方案的完整计算链路rem 的基准是html元素的font-size。核心思路是让这个基准值随视口宽度线性变化那么所有用 rem 表示的长度就都会等比缩放。设计稿 375 宽标注是 24px 间距。我们希望它在任何屏宽下都保持占屏宽的比例一致。设一个基准375 宽时让html { font-size: 100px }那么设计稿上的 24px 就对应0.24rem。(function () { const docEl document.documentElement; const DESIGN_WIDTH 375; const BASE_FONT 100; // 375 宽时 html font-size 为 100px const MAX_WIDTH 768; // 超过这个宽度不再放大 function setRem() { const clientWidth docEl.clientWidth; if (!clientWidth) return; const width Math.min(clientWidth, MAX_WIDTH); docEl.style.fontSize (width / DESIGN_WIDTH) * BASE_FONT px; } setRem(); window.addEventListener(resize, setRem); // iOS 从后台恢复页面时不会触发 resize必须补这个 window.addEventListener(pageshow, function (e) { if (e.persisted) setRem(); }); })();这段代码里有三个经验点值得单独拎出来。第一是MAX_WIDTH上限。手机横屏、平板、或者用户把浏览器窗口拉宽时如果不设上限font-size会一直变大一个标题能撑满屏幕。加了Math.min之后超过 768 的部分不再参与计算页面左右留白。第二是pageshow事件。iOS 上从 bfcache 恢复页面时resize不会触发如果用户之前转过屏再切回来字号还是旧的。这个坑只在真机上能复现模拟器里看不到。第三是执行时机。这段脚本要尽量放在head里同步执行否则首屏会先按默认的 16px 渲染一帧再跳到 100px出现字先大后小的闪烁。放在 body 底部就晚了。写样式时设计稿上 24px 换算成 0.24rem。手算太累实际项目都用 PostCSS 插件自动转。2.3 vw 方案少一层 JS但有自己的边界1vw 等于视口宽度的 1%。375 设计稿上的 24px换算成 vw 就是24 / 375 * 100 6.4vw。它的优势很直接纯 CSS没有 JS 依赖首屏不会闪。构建时用插件一次性转好运行时零成本。它的边界也很清楚PC 端打开时 vw 会跟窗口宽度走。1920 宽下 6.4vw 等于 122px一个间距比整屏还夸张。所以 vw 方案要么用在纯移动端项目要么必须配合媒体查询封顶。/* 视口超过 768px 后按 768 宽重新计算基准 */ media (min-width: 768px) { html { font-size: 10vw; /* 会被上面的规则覆盖为固定值视项目而定 */ } }不过说实话rem 和 vw 的路线之争没那么重要。两者数学上是等价的rem 靠 JS 算vw 靠浏览器算真正会造成灾难的是同一个项目里一半用 rem 一半用 vw。混用之后某个模块在某个屏宽下就是差那么几个像素调起来非常折磨。选一个然后在项目规范里写死。3. 移动端适配的四条路线用过之后的选择3.1 flexible rem 手写方案该不该用早期流行的 flexible 方案除了设置font-size还会做两件事动态改写viewport的initial-scale以及在html上打一个>// postcss.config.js module.exports { plugins: { postcss-px-to-viewport-8-plugin: { unitToConvert: px, viewportWidth: 375, unitPrecision: 5, propList: [*], viewportUnit: vw, fontViewportUnit: vw, selectorBlackList: [.ignore-, .hairline], minPixelValue: 1, mediaQuery: false, exclude: [/node_modules/] } } };几个配置项的含义和取值讲究配置项作用常见取值与说明unitToConvert要转换的单位pxviewportWidth设计稿宽度375 或 750必须和设计稿一致unitPrecision保留小数位5太少会有累计误差viewportUnit转换目标单位vwselectorBlackList不转换的选择器类名含这些前缀的跳过minPixelValue小于该值不转换设 1可挡住 0.5pxmediaQuery是否转换媒体查询内的 pxfalse通常保持原样exclude跳过的文件node_modules 必加关于viewportWidth取 375 还是 750这里有个容易绕晕的点750 的设计稿标注的是物理像素2 倍图375 的设计稿标注的是 CSS 像素这两者在数学上等价。因为 750 / 2 375。插件按 750 换算出的 vw 值和按 375 换算出的值完全相同。所以关键是设计稿宽度和配置值一致不一致就会整体差一倍。exclude一定要加上node_modules。我见过有人漏了这条结果 vant 组件内部的font-size: 14px被转成3.7vw在小屏上字号忽大忽小弹窗都变形了。排查了半天才想到是构建配置的问题。selectorBlackList主要用来保护发丝线。1px边框如果被转成0.26vw在 3 倍屏上会变成不到一个物理像素有些浏览器直接不渲染边框就消失了。3.3 媒体查询断点方案管什么、不管什么媒体查询适合处理布局结构的变化不适合处理等比例缩放。打个比方列表在 375 宽是单列768 宽变两列1024 宽变三列——这就是结构变化用媒体查询干净利落每个断点里写一套grid-template-columns就行。但如果你的诉求是所有间距都按屏宽等比缩放媒体查询做不到因为断点之间的宽度是离散的425 宽和 500 宽会落在同一个分支里用的是同一套值不会平滑变化。我的实际做法是混合使用字号、间距、圆角这类视觉尺寸用 vw / rem 等比缩放栅格列数、导航栏折叠、侧边栏显隐这类结构变化用媒体查询这样两边的好处都能拿到也不会出现某个奇怪的宽度下布局崩掉的情况。断点值也别照抄别人的。看自家后台里真实的设备宽度分布取占比高的几个区间。一套常见的组合是 375 / 768 / 1024 / 1440但如果你面向的是特定的工业平板或者车机屏幕就得按实际设备来定。3.4 图片资源的适配srcset 的真实收益图片适配经常被忽略但它的收益很直接。img srcbanner-800.jpg srcsetbanner-400.jpg 400w, banner-800.jpg 800w, banner-1200.jpg 1200w sizes(max-width: 600px) 100vw, 800px altbanner /sizes告诉浏览器在什么条件下我这张图会显示多宽浏览器结合当前 DPR 自己挑最合适的那张。拿 iPhone 14 举例DPR 3CSS 宽 390100vw就是 390乘以 DPR 得到 1170浏览器会去 srcset 里挑最接近的 1200w 那张。而在一个 DPR 1 的 PC 上显示宽度 800它会挑 800w 那张。同一个标签两端各自拿到最合适的资源省掉了要么糊、要么浪费流量的两难。再进一步可以用picture做格式回退picture source srcsethero.avif typeimage/avif source srcsethero.webp typeimage/webp img srchero.jpg althero /picture实测同一张图AVIF 比 JPG 小 40%~60%比 WebP 还要再小一截。老浏览器读不懂 AVIF 就自动回退到下一层最后兜底 JPG。这个改造对首屏时间的改善相当可观而且代码改动量极小。4. PC 和大屏是另一套逻辑4.1 PC 响应式的核心矛盾不是宽度是高度移动端适配主要跟宽度较劲PC 端不一样它的核心矛盾是屏幕很宽但可视高度不够。一台 1920×1080 的笔记本浏览器去掉标签栏、地址栏、书签栏再算上任务栏实际 CSS 可视高度大概只剩 950 左右。如果你按 1200 高来设计首屏用户就必须滚动才能看到关键内容。所以 PC 端布局更关心横向怎么排、纵向怎么截而不是整体等比缩放。最稳的基础结构是流式容器加最大宽度.container { width: 100%; max-width: 1440px; margin: 0 auto; padding: 0 24px; }max-width保证在超宽屏上内容不会散得太开width: 100%保证窄屏上能撑满padding防止贴边。断点方向建议用min-width递进而不是max-width递减/* 推荐从小屏往大屏加规则 */ media (min-width: 768px) { /* 平板及以上 */ } media (min-width: 1024px) { /* 桌面及以上 */ } media (min-width: 1440px) { /* 宽屏 */ }min-width递进的好处是规则是累加的后写的会覆盖前面的维护时只要在最后追加新断点就行。max-width递减则是每加一个断点都要回头改已有规则改到第四个断点就乱了。还有一个实用技巧是做高度断点。笔记本外接显示器时高度差异很大可以针对矮屏单独收紧纵向间距media (max-height: 740px) { .hero { padding-top: 32px; padding-bottom: 32px; } }4.2 数据大屏的等比缩放以及它的三个副作用数据大屏是个特例。它要求严格按 1920×1080 的设计稿还原元素之间的相对位置不能变。这时候最省事的方案是把整个内容塞进一个固定尺寸容器然后整体scale。function applyScale(designWidth 1920, designHeight 1080) { const el document.getElementById(screen); const realW window.innerWidth; const realH window.innerHeight; const scaleX realW / designWidth; const scaleY realH / designHeight; // Math.min 保持比例留黑边Math.max 铺满但会裁切 const scale Math.min(scaleX, scaleY); el.style.transform scale(${scale}); el.style.transformOrigin left top; el.style.left (realW - designWidth * scale) / 2 px; el.style.top (realH - designHeight * scale) / 2 px; } applyScale(); window.addEventListener(resize, () applyScale());Math.min和Math.max的选择要看场景投屏演示场合用Math.min保证内容完整不被裁如果是嵌在一个固定区域里通常也用Math.min。但整体缩放有代价得提前知道第一文字会发虚。transform: scale()走的是 GPU 合成缩放倍数超过 1.5 之后文字边缘会明显模糊。所以设计稿的尺寸尽量贴近目标屏的 1 倍或 2 倍不要出现设计稿 1920 硬拉到 3840这种情况。第二图表里的浮层会跑偏。ECharts 的 tooltip 默认挂在body上它的定位是按页面坐标算的不会跟着缩放容器走。缩放一次之后鼠标位置和 tooltip 位置就对不上了。解决办法是把 tooltip 的appendToBody关掉让浮层留在图表容器内部跟着一起缩放。第三交互热区跟着缩。如果 scale 是 0.6原本 30px 高的按钮变成 18px在触摸屏上点起来会很费劲。大屏如果有触控需求按钮的实际尺寸要按缩放后再留一次余量。如果业务允许我更推荐用 CSS Grid 加 vw / vh 做弹性布局而不是整体 scale。这样文字是原生渲染的不管什么分辨率都清晰图表浮层也不会错位。代价是每个区块的尺寸要单独算前期工作量稍大但后期维护舒服得多。4.3 组件库默认字号在大屏上的问题Element Plus、Ant Design 这类组件库的默认基础字号是 14px它们是按普通桌面网页的使用距离设计的。放到 1920 宽、观看距离三米的大屏上14px 基本看不清。这时候有两个方向一是整体缩放就是上一节说的 scale 方案二是把字号体系整个放大。改字号要注意组件库内部有大量固定 px 值。只改--el-font-size-base这个 CSS 变量只能改到一部分表单、表格、分页里的字号还是原样。更彻底的做法是覆盖 SCSS 变量重新编译// 引入前先覆盖变量 use element-plus/theme-chalk/src/common/var.scss with ( $font-size: ( extra-large: 32px, large: 28px, medium: 22px, base: 18px, small: 16px, extra-small: 14px ) );这样出来的每个组件都是放大后的版本比例协调。代价有两个构建时间变长以及升级组件库版本时要检查变量名有没有改动。所以这个方案适合在项目初期就定下来中途改会造成大量视觉走样。还有一个折中方案是定义一个自定义属性然后在自己写的组件里统一引用只放大业务区域的文字组件库的弹窗、下拉这些保持原样。适合主体是自研大屏图表只有少量表单交互的场景。如果项目里还有桌面客户端比如用 PyQt5 写的上位机界面那 DPI 适配又是另一套规则。它需要在创建应用之前设置高 DPI 属性from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication import sys # 必须在 QApplication 实例化之前设置顺序错了不生效 QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) app QApplication(sys.argv)AA_EnableHighDpiScaling让它支持系统缩放AA_UseHighDpiPixmaps让位图按 DPR 选高清版本。这两行不写的话在 4K 屏上整个界面会小到看不清。和 Web 端一样这类桌面框架也要处理文字锯齿和图标模糊两个问题思路都是让资源按实际物理像素生成。5. 那些踩过才知道的细节5.1 1px 边框为什么会变粗以及怎么修设计师要一条发丝线代码里写border-bottom: 1px solid #eee在 DPR 3 的屏幕上渲染出来是 3 个物理像素视觉上就是一条粗线。标准修法是伪元素配合transform: scaleY().hairline { position: relative; } media (-webkit-min-device-pixel-ratio: 2) { .hairline::after { content: ; position: absolute; left: 0; bottom: 0; width: 100%; height: 1px; background: #e5e5e5; transform: scaleY(0.5); transform-origin: 0 100%; } } media (-webkit-min-device-pixel-ratio: 3) { .hairline::after { transform: scaleY(0.333); } }这里有三个坑。第一如果项目用了postcss-px-to-viewport上面这个height: 1px会被转成 vw。要么把它加进selectorBlackList要么用媒体查询包起来mediaQuery: false时媒体查询里的 px 不转。第二圆角加边框的组合下伪元素还要处理border-radius的继承比较麻烦可以先用box-shadow的 inset 做模拟。第三安卓低版本 WebView 对scaleY(0.333)的渲染精度不一个别机型上会直接消失遇到这种情况就退回 1px接受它稍粗。5.2 系统缩放 125% 带来的取整误差Windows 的 125% 缩放下devicePixelRatio 1.25。如果一个元素宽度是33.33%在 1920 的容器里算出来是 639.9 左右乘以 1.25 得到 799.9 物理像素。浏览器做像素对齐时有的取 800 有的取 799相邻元素之间就会冒出 1px 的缝或者反过来重叠。这类问题的处理原则是别让浏览器去做除法表格和栅格用 flex 或 grid 的gap不要用 margin 拼避免出现33.33%、14.28%这种除不尽的值用flex: 1让浏览器自己分配实在避不开给父元素加overflow: hidden遮掉缝隙或者用outline替代 border图表容器宽度用getBoundingClientRect().width取整后再传给 ECharts别直接传浮点数最后一条特别实用。ECharts 在宽度是小数时canvas 尺寸会算出半像素某些缩放比例下坐标轴的刻度线会模糊或者缺一条。5.3 iOS 安全区和横屏的方向问题.safe-bottom { padding-bottom: constant(safe-area-inset-bottom); /* iOS 11.0 - 11.2 */ padding-bottom: env(safe-area-inset-bottom); /* iOS 11.2 */ }前提是 meta 里写了viewport-fitcover不然这些变量全是 0。但只处理底部是不够的。横屏时刘海会挡住左侧内容右侧的圆角也会影响元素完整写法是四个方向都留.safe-area { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); }还有一个只有踩过才记得的点安全区变量在页面旋转的瞬间不会立即更新。用户横竖屏切换后如果布局里用了这些值需要监听orientationchange事件延迟一小段时间再读一次否则拿到的是旧值。一般延迟 100ms 就够。5.4 一套可以直接照抄的选型参考把上面的经验收拢成一张决策表场景推荐方案选择理由移动端 H5 营销页PostCSS 转 vw375 设计稿纯 CSS 零闪烁改动成本最低移动端复杂交互应用rem 基准 JS 上限控制需要动态限制放大倍数PC 官网 / 后台系统流式容器 max-width min-width 断点主要矛盾是结构变化数据可视化大屏固定设计稿 transform scale严格还原开发最快大屏且要求文字清晰CSS Grid vw/vh原生渲染图表浮层不错位PC 与手机同一套代码媒体查询 clamp()一套代码两个形态最后提一下clamp()这几年浏览器支持度已经很好了一行就能搞定字号随屏宽平滑变化.title { font-size: clamp(18px, 2.5vw, 32px); }意思是最小 18px理想值是 2.5vw最大不超过 32px。它比媒体查询那种断点之间是台阶的写法平滑得多而且不用写三四个断点。间距、圆角也可以照这个思路来。我自己现在做移动端项目的默认起手式是clamp()管字号和间距PostCSS 管整体的 vw 换算媒体查询只管布局结构的切换。三层各司其职遇到新设备基本不用改代码改动量最小的方案往往也是最不容易出问题的那个。还有一个建议是别急着上复杂方案。新项目先按最朴素的 vw 方案跑一版真机测一轮看看有没有实际问题。大部分适配问题其实是某几个特定机型、特定缩放比例下的边界情况等真的遇到了再针对性处理比一开始就堆一套灵活框架要好维护得多。我见过太多项目因为过早引入了完整的 rem flexible 自定义 dpr 体系后来换人维护时没人敢动最后成了技术债。