屏幕适配的题,区分度不在「用过什么方案」,而在「知不知道自己站在哪一层」。 同样是 375 宽的手机,设计稿上的 32px、样式表里的 2rem、屏幕上的 32 个物理点,是三种不同的东西。 把这三者之间的换算说清楚,1px 变粗、vw 在桌面失控、大屏缩放发虚,就都成了同一道题的变体。
这篇文章用 8 道面试题走完这条链路。前 6 题是移动端与通用响应式,后 2 题是企业大屏。每题先给能在面试现场说出口的结论,再拆到规范与实现;涉及空间关系的部分,都配了可以拖动、切换的沙盘。
先把地图画出来:三次换算,一个观感
在讨论任何方案之前,先记住这张图。后面所有问题,都是某一层换算出了偏差。
第一层是工程问题(选方案),第二层是设备问题(DPR),第三层是人体工学问题(字号与触控)。 面试里被追问「还有呢」,通常就是往上跳一层。
把常见设备摊开,这几层的数值关系就很具体了:
| 设备 | 物理宽度 | DPR | CSS 视口宽 | 容易出问题的地方 |
|---|---|---|---|---|
| iPhone SE | 750 | 2 | 375 | 375 稿以外的设计稿都要换算 |
| iPhone 15 Pro | 1179 | 3 | 393 | 1px 线要覆盖 3 个物理像素 |
| 1080p 大屏 | 1920 | 1 | 1920 | 按 1920 稿写死 px 恰好合适,换屏就崩 |
| 4K 大屏(Windows 150%) | 3840 | 1.5 | 2560 | 系统缩放下 1px 线的视觉粗细不一致 |
| MacBook 14 | 3024 | 2 | 1512 | 桌面端 vw 方案会把内容放大到失控 |
注意最后一列:同一个方案在不同行里会以完全不同的方式失败。这就是为什么「你们怎么做适配」这种问题,答方案名没有意义,要答约束。
Q1 · 1px 的边框为什么看起来更粗
面试官: 设计稿是 750 宽(@2x 图),上面标了一条 1px 分割线。你写
border-bottom: 1px solid,真机上明显比稿子粗。为什么?有几种改法?
面试官在考什么: 你知不知道 CSS 像素和物理像素是两个单位,以及「1px 问题」本质是单位不匹配,不是浏览器画错了。
先给结论。 750 稿上的 1px 指的是 1 个物理像素。设备 DPR 为 2 时,1 个 CSS 像素要占 2 个物理像素,所以写 1px 等于把这条线放大了一倍。所谓 1px 方案,目标只有一个:让线只覆盖 1 个物理像素,也就是 0.5 个 CSS 像素。
把镜头推到像素级,这件事就没有歧义了:
INTERACTIVE · PIXEL LENS
1px 到底有多粗?
把屏幕放大到能看见物理像素:一个格子就是一个设备像素。切换 DPR 与写法,看这条线真正吃掉几格。
Retina / 主流手机
border: 1px → 占 2 个物理像素
1 CSS px 在 2× 屏上占 2 个物理像素。设计稿那条 1px 是按物理像素标的,于是它比你想要的粗了 2 倍——这不是浏览器画错了,是单位对不上。
四条线是按真实 CSS 渲染的,不是画出来的示意。数值那一列才是它在 2× 屏上的实际覆盖。
切换 DPR 与写法,看这条线实际吃掉几个物理像素格。四种写法都是真实 CSS 渲染,不是画出来的示意。
四种写法的取舍。
| 写法 | 2× 屏实际覆盖 | 圆角 | 代价 |
|---|---|---|---|
border: 1px | 2 个物理像素 | 支持 | 最简单,但粗一倍 |
border: 0.5px | 1 个物理像素(取整) | 支持 | iOS 稳定,部分 Android WebView 渲染成 1px 或直接不画 |
伪元素 + scaleY(0.5) | 1 个物理像素 | 要单独处理 | 多一层合成,圆角与动画要额外照顾 |
box-shadow | 1 个物理像素 | 跟随圆角 | 不占布局,做单边描边的表达力有限 |
渐变 / border-image | 1 个物理像素 | 不支持 | 适合直线分割,不能用于卡片描边 |
工程上更稳的写法是把「缩放比」交给变量,而不是在每个组件里写死 0.5:
.hairline {
position: relative;
}
.hairline::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: var(--line-color);
transform: scaleY(calc(1 / var(--dpr, 1)));
transform-origin: bottom;
}追问:什么情况下不用做? 设计稿按 375(@1x)出图时,标注的 1px 就是 1 个 CSS 像素,问题不存在。1px 问题只在「按 2 倍、3 倍图标注」的流程里出现——所以先确认设计稿的基准,再决定要不要引入这套复杂度。
Q2 · vw、rem、px、scale 到底怎么选
面试官: 移动端适配你用什么方案?rem 加动态根字号,还是 vw?两者有什么区别?
面试官在考什么: 你有没有意识到这两种方案在数学上是同一个东西,真正的差异在运行时机、兜底能力和可访问性。
先看数学。 750 稿、视口宽 W:
- 转 vw:
x × 100 / 750,因为 1vw 就是 W 的 1% - 转 rem:
x / 75 rem,配合根字号W / 10
两条式子展开后都等于 x × W / 750。也就是说 10vw 恒等于 1rem(在根字号跟着屏宽走的方案里)。所以「选哪个」不是数学问题。
INTERACTIVE · ADAPTATION LAB
一份设计稿,四种落地方式
拖动视口宽度,切换方案与 DPR。四种做法在 375 宽的手机上几乎一致,差异要拉到 1024、1440 才会暴露出来。
- 当前组合下没有明显副作用。
画布最宽 420px;比阅读区更宽时保持真实宽度,可以横向拖动 —— 这正是横向溢出的样子。比例条是「内容宽 / 视口宽」,不是缩放比。
拖动视口从 320 到 1440:四种方案的差距不在手机上,而在宽屏。切到「裸 px」看它在 1440 下有多小,切到 vw 看它放大成什么样。
差异表。
| vw | rem + JS | 裸 px | transform | |
|---|---|---|---|---|
| 需要 JS | 否 | 是 | 否 | 是(算比例) |
| 首屏抖动 | 无 | 有(根字号晚于首帧) | 无 | 有 |
| 宽屏表现 | 线性放大,需 max-width 兜底 | 同上 | 不放大,页面显得空 | 整体放大 |
| 用户字号设置 | 不影响(也是问题) | 被根字号覆盖 | 正常 | 不影响 |
| 1px 细线 | 一起被缩放 | 一起被缩放 | 保持 | 一起被缩放 |
可访问性是这里最容易被忽略的一层。 用 vw 或固定 rem 给 font-size 赋值,等于把排版基准从「用户偏好」换成了「屏幕宽度」。视力不好的用户把浏览器默认字号调到 20px,页面却仍然按 375 宽算出一个 14px 的正文——WCAG 1.4.4 要求文本能放大到 200% 而不丢内容,这类方案天然违背。
现代答案通常是「不选」。 布局的伸缩交给 flex / grid 加 minmax()、clamp();只有需要严格等比缩放的营销页、活动 H5 才用 vw,并且必须配 max-width 与桌面降级。组件库场景则用容器查询,下一题展开。
追问:750 稿上的 1px 在 vw 方案里怎么办? 它会被算成 0.1333vw,在 375 屏上是 0.5px,在 414 屏上是 0.55px——比裸 px 更不可控。所以 1px 线要单独抽出来,不要进换算管线。
Q3 · 100vh 为什么会滚出屏幕
面试官: 移动端给首屏写
height: 100vh,为什么 iOS 上会多出一条滚动条?dvh、svh、lvh 该怎么选?
先给结论。 100vh 对应的是「地址栏收起时」的大视口,而不是当前可视高度。地址栏展开时实际可见区域更小,100vh 自然就溢出了。
为什么会这样。 移动浏览器的地址栏参与布局,但会随着滚动收起或展开。规范早期只有 vh 这一个静态值,浏览器把它定义成「最大可视高度」——这样布局不会因为工具栏动画而反复重排。代价就是页面在地址栏展开时被顶出屏幕。
三个单位分别对应三种口径:
大视口:地址栏收起后的高度。值最大,永远不随工具栏变化。
小视口:地址栏展开时的高度。值最小,做首屏最稳,不会跳动。
动态视口:当前的真实可视高度。随工具栏实时变化,布局会跟着重排。
怎么选。
- 首屏、首屏 CTA:
min-height: 100svh。宁可底部留一点,也不要让按钮被地址栏挡住。 - 需要「永远刚好一屏」的容器(全屏图表、全屏视频):
100dvh,但要接受地址栏动画期间的连续重排;长列表里慎用,可能掉帧。 - 固定底栏、悬浮按钮:
position: fixed加padding-bottom: env(safe-area-inset-bottom),并且<meta name="viewport">里要写viewport-fit=cover,否则安全区变量恒为 0。 - 桌面端也有类似问题:出现横向滚动条时,
100vh会比可视区高出一个滚动条的高度,100dvh同样能修正。
追问:为什么不是所有浏览器都支持? dvh / svh / lvh 是 2022 年前后落地的(Safari 15.4、Chrome 108),老设备要留 100vh 的降级声明,按顺序写两行即可。
Q4 · 一个容器怎么永远保持 16:9
面试官: 视频容器要始终保持 16:9,除了
aspect-ratio还有别的写法吗?各自的坑是什么?
结论: 旧方案的原理是「padding-top 的百分比基于包含块宽度」,所以 56.25% 能算出 16:9;新方案直接用 aspect-ratio,但会被显式的 height 或 min-height 覆盖。
/* 现代写法 */
.ratio {
aspect-ratio: 16 / 9;
width: 100%;
}
/* 需要兼容老浏览器时 */
@supports not (aspect-ratio: 16 / 9) {
.ratio {
position: relative;
height: 0;
padding-top: 56.25%;
}
.ratio > * {
position: absolute;
inset: 0;
}
}几个真实的坑:
aspect-ratio只在「两个方向里有一个未确定」时起作用。同时写了height: 300px,比例就失效了。min-height会直接压过比例算出的高度,常见于「卡片至少 200px 高」的需求。padding-top方案要求容器height: 0且子元素绝对定位,内容一旦要参与文档流就会塌。- 图片用
aspect-ratio主要是占位防 CLS,真实比例仍由图片自身决定,要配object-fit。
Q5 · 断点怎么定,媒体查询和容器查询差在哪
面试官: 你的响应式断点是怎么定的?组件库里的卡片,应该用媒体查询还是容器查询?
先给结论: 断点按内容断,不按设备断;组件查容器,页面骨架查视口。
为什么按内容断。 设备宽度是无穷的,但一个布局「开始难看」的宽度是有限的。做法是把视口从窄往宽拖,记下卡片挤成一行、标题折行、表格开始横向滚动的那几个宽度——那就是断点。iPhone 的 390、393、430 只是巧合,不是依据。
这个差别在组件复用时最致命:同一个卡片,放在 1200px 的主栏里很宽松,放进 280px 的侧栏就会挤爆——但视口一直是 1440,媒体查询什么都不会发生。
INTERACTIVE · QUERY SCOPE
它问的是谁有多宽?
两张一模一样的告警卡,只有断点的写法不同。拖动容器宽度:一个会跟着容器换布局,另一个只认视口。
容器只有 360px,@container 已经切成竖排;@media 仍然认为「屏幕 1440px 很宽」,坚持行布局,卡片保住 380px 的最小宽度,右边被裁掉。
拖动容器宽度。上面那张卡用 @container,下面那张用 @media;视口没变,所以只有第一张会换布局。
@media | @container | JS + ResizeObserver | |
|---|---|---|---|
| 参照物 | 视口 | 最近的容器祖先 | 任意元素 |
| 组件复用 | 失效(只看窗口) | 正确 | 正确 |
| 前置声明 | 无 | container-type | ref + 观察器 |
| 首屏抖动 | 无 | 无 | 可能有一帧 |
| 兼容性 | 全部 | Chrome 105+ / Safari 16+ | 全部 |
container-type: inline-size 不是没有代价。 它给容器加上了 inline 方向的尺寸包含:容器的宽度不再由内容撑开,绝对定位子元素的包含块也会变。所以别随手给 body 或大布局容器加,只在真正需要响应式的组件外壳上加。
追问:容器查询能查自己吗? 不能。@container 永远查询最近的祖先容器,所以一个组件想根据自身宽度变化,必须由父级声明 container-type。
Q6 · Retina 上图片和 canvas 为什么还是糊
面试官: 同一张 2 倍图,在 3 倍屏手机上为什么还是发虚?canvas 画出来的东西为什么总是模糊?
先给结论: 位图有自己的分辨率,CSS 只负责把它显示成多大;两者不匹配就会重采样。canvas 更特殊——它有「位图尺寸」和「显示尺寸」两套尺寸,只设一套必然糊。
图片:让浏览器自己选。 srcset + sizes 把「布局宽度」和「候选分辨率」都告诉浏览器,由它按 DPR 和网络状况挑:
<img
src="cover-800.jpg"
srcset="cover-800.jpg 800w, cover-1600.jpg 1600w, cover-2400.jpg 2400w"
sizes="(min-width: 768px) 50vw, 100vw"
width="800"
height="450"
alt=""
/>sizes 写错是常见事故:写成 100vw 但实际只占半屏,浏览器就会选一张两倍大的图,白付流量。
canvas:位图尺寸必须是显示尺寸乘 DPR。
function fitCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return ctx;
}现在的 ctx 坐标系已经回到 CSS 像素,按设计稿标注值画就行。
四个细节:
- 修改
canvas.width会清空画布并重置全部上下文状态,必须重画。 - 只改位图尺寸不改 CSS 尺寸,画面会被拉伸;只改 CSS 尺寸不改位图,画面会被放大——两种都糊。
- 用户把窗口从 2× 屏拖到 1× 屏时 DPR 会变,要监听
resize或用matchMedia('(resolution: 2dppx)')重建。 - 图表库通常有自己的 DPR 处理,容器尺寸变化后必须显式
resize(),否则它还在按旧尺寸的位图渲染。
Q7 · 企业大屏投到 4K、超宽、竖屏上怎么办
面试官: 一块 1920×1080 的运营大屏,要投到 4K、21:9 拼接屏和竖屏上,你的适配方案是什么?
面试官在考什么: 你会不会先问约束。大屏适配没有通用解,只有「画布是否固定、比例是否可控、有没有需要精确坐标的交互」这三问的答案。
INTERACTIVE · BIG SCREEN LAB
同一块看板,三种拉伸方式
设计稿 1920×1080。换一块 21:9 的拼接屏、或者一台 4:3 的老投影,三种策略给出的不是「一样好」,而是三种不同的坏。
整层 scale(0.39):内容零变形、零重排,但字号掉到 15.6px,1px 边框按比例栅格化,四周留下 0px 黑边。适合「大屏就是一块固定画布」的项目,不适合要塞进 iframe 或侧栏的场景。
切换策略与屏幕比例。注意「等比缩放」下圆环仍然是正圆但会出现黑边,「拉伸铺满」没有黑边但圆环变成椭圆。
| 策略 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 等比缩放 | 固定 1920×1080,整层 transform: scale | 像素级还原,改一处全对 | 黑边、非整数缩放发虚、坐标与 canvas 不跟随 |
| 拉伸铺满 | 宽高各 100% | 没有黑边 | 图形变形,字号不随屏变 |
| 弹性重排 | 栅格 + clamp() + ResizeObserver | 不变形、不留白 | 每个模块都要写响应式规则 |
生产环境通常是混合的:
- 外层保底:等比缩放,保证 16:9 拼接屏上像素级还原。
- 内部弹性:图表、列表、地图用
ResizeObserver跟随容器,自己重算尺寸。 - 极端比例:超宽或竖屏不再硬缩,用断点直接换布局(4 列变 6 列、地图换到主位),否则 9:16 屏上会缩成一条。
4K 的两个陷阱。 一是系统缩放:Windows 设 150% 时,3840 的物理宽度对应的 CSS 视口只有 2560,设计稿 1920 仍然放得下,但 1px 线在物理上是 1.5 像素,看起来会「忽粗忽细」。二是 DPR 不是固定的整数值,用 window.devicePixelRatio 做乘法时不要假设它是 2。
Q8 · transform: scale 有哪些坑
面试官: 你说用 scale 适配大屏,具体有哪些坑?怎么绕过?
先给结论: transform 只改绘制,不改布局。任何依赖布局尺寸的东西——滚动高度、fixed 定位、canvas 位图、鼠标坐标——都不会跟着缩放。
基本骨架:
.viewport {
position: relative;
overflow: hidden;
}
.stage {
position: absolute;
top: 50%;
left: 50%;
width: 1920px;
height: 1080px;
transform-origin: center center;
}const DESIGN = { width: 1920, height: 1080 };
function fit(stage) {
const parent = stage.parentElement;
const scale = Math.min(
parent.clientWidth / DESIGN.width,
parent.clientHeight / DESIGN.height,
);
stage.style.transform = `translate(-50%, -50%) scale(${scale})`;
}六个真实的坑:
- 文字发虚。 非整数缩放(0.66×)时,浏览器先按原尺寸栅格化字体纹理,再整体重采样,14px 的字实际渲染成 9.24px 的字形位图。整数倍或 0.5 倍最安全,但比例通常由屏幕决定,控制不了。
- 固定定位逃逸。 下拉菜单、Tooltip、Modal 一旦挂到
body,就不在缩放层里,坐标按视口算,位置会偏。要么挂进缩放容器,要么手动乘scale。 - canvas 不会跟着清晰。 位图被拉伸,只是变大不是变清楚。必须用
ResizeObserver重设位图尺寸,并把devicePixelRatio × scale一起算进去。 - 鼠标坐标要换算。 在 canvas 里做拾取、绘制、拖拽时,
clientX是视口坐标,要减偏移再除scale。 - 1px 线会消失。 0.4 个物理像素的线会被舍入掉,窄边框在某些缩放下直接看不见。
- 滚动计算错位。 虚拟列表按
scrollHeight估算可见条数,但缩放后的视觉高度和布局高度不是一回事。
什么时候可以接受这些坑? 屏幕比例固定、内容以展示为主、没有画布交互——这三点同时成立时,scale 是最省事的方案。只要有地图、绘制、拖拽这类交互,就该换弹性布局。
一页速查
| 场景 | 推荐 | 避免 |
|---|---|---|
| 移动端营销页 / 活动 H5 | vw + max-width + 桌面降级 | 纯 rem + JS、无兜底的 vw |
| 后台、组件库 | flex/grid + 容器查询 | 用视口断点写组件内部布局 |
| 严格等比的设计还原 | 单一换算管线(vw 或 rem,二选一) | 三套单位混用 |
| 企业大屏(固定画布) | 外层等比 + 内部 ResizeObserver | 直接 scale body |
| 全屏首屏 | 100svh,需要贴合时 100dvh | 裸 100vh |
| 1px 细线 | 伪元素 + scaleY(1/dpr),0.5px 作降级 | 把设计稿的 1px 直接写成 CSS 1px |
| 高清 canvas | 位图尺寸 = 显示尺寸 × DPR | 只改 CSS 宽高 |
| 响应式图片 | srcset + 准确的 sizes | 只放一张 2x 图 |
30 秒版本的口头回答:
适配分三层。第一层是设计稿到 CSS 像素,用栅格加
clamp()做弹性,只有严格等比的页面才上 vw,并且一定配max-width;第二层是 CSS 像素到物理像素,由 DPR 决定,1px 线、图片srcset、canvas 位图都在这一层处理;第三层是观看距离,决定字号和触控目标的最小尺寸。大屏另说:画布固定就外层等比、内部用ResizeObserver,有画布交互就不能 scale。
面试官可能继续追问
为什么不用 zoom 代替 transform: scale?
zoom 会参与布局计算(相当于改了整个盒模型的比例),历史上各浏览器实现不一致,Firefox 直到 2024 年才支持。它不会产生 transform 那样的合成层,文字更清晰,但 zoom 后的 getBoundingClientRect、滚动位置、fixed 定位仍然会让人意外。做新项目可以用,做兼容性要求高的老项目仍然建议 scale 或弹性布局。
DPR 是 3 的手机上,1px 线真的只能做到 1/3 像素吗?
不能。物理上最小就是 1 个设备像素,也就是 1/3 个 CSS 像素。再细的线只能靠颜色变淡(降低不透明度)来「看起来更细」,这是设计上的妥协,不是技术上还能再分。
vw 方案在桌面端失控,除了 max-width 还有别的兜底吗?
可以,但要组合:给根容器设 max-width 并居中;用媒体查询在 768 以上切回固定字号;或者干脆只在移动端启用 vw 换算(构建时按断点生成两套样式)。最省事的仍然是「不用 vw 做全站基准」。
容器查询能不能替代媒体查询?
不能。容器查询解决的是「组件不知道自己会被放多宽」,页面骨架(导航折叠、主栏与侧栏切换)仍然要看视口。两者是分工,不是替代。