响应式设计与移动端适配

1. 为什么响应式不是"改几个 @media 那么简单"

做后端时你处理的是 JSON 和 HTTP 状态码,前端要处理的是:

响应式 ≠ 写一堆 @media,它是一套系统化的"尺寸思维"

  1. 布局层面用 flex/grid 自适应
  2. 字号/间距用相对单位(rem)统一缩放
  3. 关键断点用 @media 改布局形态(侧边栏变抽屉之类)
  4. 移动端加点"触屏专属"补丁(热区、安全区、禁止缩放等)

这篇把四层串起来。

Java 对照:类似后端的"多租户" + "多环境配置"——同一份代码在不同上下文跑出不同行为,靠的是 Profile 切换和依赖注入,而不是给每个租户各拷一份代码。


2. 视口 meta:移动端一切的起点

打开 index.html,找到这行:

<meta name="viewport" content="width=device-width, initial-scale=1.0" />

少这行,所有响应式设计都白搭——因为手机浏览器会把页面当成 980px 宽的桌面页渲染,然后整体缩放显示,你的 @media (max-width: 768px) 永远不生效。

逐字拆解

常见追加配置(按需):

<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />

viewport-fit=cover — iPhone 刘海屏全屏铺满,配合后面讲的"安全区"使用。

不要加 user-scalable=no:禁用缩放严重损害可访问性,用户双指放大是合法需求(尤其老人看小字)。


3. CSS 单位:什么时候用什么

前端单位多得让人头大,实际用好 5 个就够:

单位 含义 用在哪
px 绝对像素 边框、细节定位(不随缩放变化的地方)
rem 相对根元素字号 字号、间距、大部分尺寸
em 相对当前元素字号 需要和当前字号绑定的(比如按钮的 padding)
% 相对父元素 布局宽度、进度条
vw / vh 视口宽度/高度的 1% 全屏元素、大标题

3.1 为什么优先 rem

默认 html 的字号是 16px,所以 1rem = 16px。它的好处:

用户在浏览器里调了默认字号(比如设成 20px),你的整个 UI 自动按比例放大——盲人、弱视用户的可访问性就是这么实现的。

html { font-size: 16px; }  /* 不写也是默认值 */

.btn {
  padding: 0.5rem 1rem;    /* 8px 16px */
  font-size: 0.875rem;     /* 14px */
  border-radius: 0.375rem; /* 6px */
}

Tailwind 已经这么干了p-4 等于 padding: 1rem,整站跟着根字号缩放。不要去覆盖 html 的 font-size,会打破 Tailwind 的 scale

3.2 什么时候用 em

em 相对当前元素,常用于"让内部元素跟外部字号缩放":

.btn {
  font-size: 1rem;
  padding: 0.5em 1em;  /* padding 跟字号走 */
}
.btn-lg { font-size: 1.5rem; }  /* 大按钮字号大,padding 自动也变大 */

换成 rem 就得每个尺寸写一套 padding,em 省了重复。

3.3 vw/vh 的坑

100vh 在手机浏览器里不等于屏幕可见高度——它是"没有地址栏时的全屏高度",地址栏在时内容会被挡。

解法:用现代浏览器支持的 svh(small viewport height,地址栏出现时的高度)和 dvh(dynamic viewport height,动态计算)。

/* 全屏登录页 */
.login-container {
  min-height: 100vh;  /* 老浏览器回退 */
  min-height: 100dvh; /* 现代浏览器生效 */
}

4. 断点策略:移动优先 vs 桌面优先

4.1 移动优先(推荐)

默认写手机样式,往上加断点覆盖桌面:

/* 默认(手机) */
.card { padding: 1rem; }

/* 平板及以上 */
@media (min-width: 768px) {
  .card { padding: 1.5rem; }
}

/* 桌面及以上 */
@media (min-width: 1024px) {
  .card { padding: 2rem; }
}

Tailwind 内置这一套:

<div class="p-4 md:p-6 lg:p-8">...</div>

4.2 为什么移动优先

4.3 桌面优先什么时候用

改造老项目时(原来只有桌面版,现在要加手机适配),反向用 max-width 更划算:

.sidebar { width: 240px; }

@media (max-width: 767px) {
  .sidebar { display: none; }  /* 小屏隐藏 */
}

新项目从头写,一律移动优先


5. 实战:一个响应式的笔记列表

综合前面讲的 Flex/Grid/Tailwind,in4vue 的笔记列表:

<template>
  <section class="mx-auto max-w-6xl px-4 py-8 md:px-6 md:py-12">
    <!-- 标题区:手机单行,桌面两栏 -->
    <header class="mb-6 flex flex-col gap-2 md:mb-10 md:flex-row md:items-end md:justify-between">
      <div>
        <h1 class="text-2xl font-bold md:text-4xl">学习笔记</h1>
        <p class="mt-1 text-sm text-gray-500 md:text-base">
          从 Java 后端视角理解前端
        </p>
      </div>
      <el-input v-model="keyword" placeholder="搜索..." class="md:w-64" />
    </header>

    <!-- 列表:手机 1 列,平板 2 列,桌面 3 列 -->
    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
      <NoteCard v-for="n in notes" :key="n.id" :note="n" />
    </div>
  </section>
</template>

关键决策

从 320px 拖到 2560px,始终合理。


6. 触摸交互的 3 个注意点

6.1 点击热区 ≥ 44×44px

苹果 HIG 和 Material Design 都建议:可点击元素的最小触摸区域 44×44 CSS 像素

<!-- ❌ 图标太小,手指戳不准 -->
<button class="p-0"><el-icon><Delete /></el-icon></button>

<!-- ✅ 加内边距把热区撑大 -->
<button class="p-2"><el-icon><Delete /></el-icon></button>
<!-- 16px 图标 + 2×8px padding = 32px,再加 border 勉强够,建议 p-3 更稳 -->

Element Plus 的 <el-button> 默认尺寸已经够,自己用 icon 时要注意。

6.2 不要依赖 :hover

手机没有鼠标悬停。把 hover 态仅当锦上添花,核心信息别靠 hover 才出现。

<!-- ❌ 移动端看不到信息 -->
<div>
  <span class="opacity-0 hover:opacity-100">删除</span>
</div>

<!-- ✅ 移动端直接可见,桌面悬停时再高亮 -->
<div>
  <button class="text-gray-400 hover:text-red-500">删除</button>
</div>

6.3 消除 300ms 点击延迟

早期手机浏览器点击时会等 300ms 判断是不是双击。现代浏览器在**有 viewport meta(见第 2 节)**时已经自动消除,不用再加 fastclick 那种老库。


7. iPhone 刘海和底部手势条:安全区适配

iPhone X 之后引入刘海和手势条,不做适配的页面要么被遮挡要么留一条白边。

7.1 启用 viewport-fit=cover

<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />

开启后页面铺满整个屏幕,包括刘海区域——关键内容要自己避开。

7.2 用 env() 读取安全区

.header {
  /* 固定顶部:原 12px 内边距 + 状态栏高度 */
  padding-top: calc(12px + env(safe-area-inset-top));
}

.bottom-nav {
  /* 固定底部:原 12px + 手势条高度 */
  padding-bottom: calc(12px + env(safe-area-inset-bottom));
}

env(safe-area-inset-*) 在非刘海屏上返回 0,所以安全。需要 viewport-fit=cover 才生效

Tailwind 里也支持:

<header class="pt-3 pt-[calc(0.75rem+env(safe-area-inset-top))]">...</header>

8. 图片适配:不同屏幕用不同资源

8.1 Retina 屏适配

<!-- 普通屏用 logo.png(100×100),Retina 用 logo@2x.png(200×200) -->
<img
  src="logo.png"
  srcset="logo.png 1x, logo@2x.png 2x, logo@3x.png 3x"
  alt="logo"
/>

浏览器根据设备像素比自动选图。

8.2 不同尺寸屏用不同图

<picture>
  <source media="(max-width: 639px)" srcset="hero-mobile.webp" />
  <source media="(max-width: 1023px)" srcset="hero-tablet.webp" />
  <img src="hero-desktop.webp" alt="hero" class="w-full" />
</picture>

手机下载 mobile 版(小而窄),桌面下载 desktop 版(大而宽),不要让手机下载 4K 图再缩小显示,浪费流量还卡。

8.3 懒加载

<img src="photo.webp" loading="lazy" alt="照片" />

loading="lazy" 是浏览器原生懒加载,滚到可视区才加载。非首屏图片都加上。


9. 排版:字号的响应式

正文字号在手机 16px、桌面 18px 通常最合适。别在手机用 14px——看着累。

9.1 简单写法

<h1 class="text-2xl md:text-4xl lg:text-5xl">响应式标题</h1>
<p class="text-base md:text-lg">响应式正文</p>

9.2 流式字号(进阶)

clamp() 让字号在屏幕尺寸间平滑插值:

h1 {
  font-size: clamp(1.5rem, 2vw + 1rem, 3rem);
  /* 最小 1.5rem,随 2vw + 1rem 增大,最大 3rem */
}

优点:不用写多个断点,所有尺寸都流畅过渡。 缺点:调起来不直观,设计师给的是具体值时用断点反而快。


10. 测试:别只靠 DevTools 的模拟器

Chrome DevTools 的设备工具栏(Cmd/Ctrl + Shift + M)能切换常见设备,90% 的问题能查出来,但它不能完全模拟:

in4vue 的测试流程

  1. 开发时用 DevTools 拉宽高拖,覆盖 320 / 375 / 768 / 1024 / 1440 五档
  2. 关键功能用真机测一遍
  3. pnpm dev --host 让手机连局域网访问:
pnpm dev --host
# 输出:Network: http://192.168.1.x:5173/

手机连同 Wi-Fi 扫码或输这个地址,即可在真机调试。


11. 工具链速查

工具 作用
Chrome DevTools Device Toolbar 快速切换屏幕尺寸
Safari 开发 → 响应式设计模式 测 Safari/iOS 特有行为
Responsively App 同屏预览多设备
pnpm dev --host + 手机 真机调试
BrowserStack 真机云测试(付费,有试用)

12. 常见坑点速查

现象 原因 解法
手机上文字挤成一坨 没加 viewport meta 检查 index.html 第 2 节
100vh 在 iOS 留白 Safari 地址栏高度问题 100dvhmin-height: -webkit-fill-available 兜底
iPhone 输入框聚焦页面跳动 默认字号 < 16px 时 Safari 强制放大 给 input 加 font-size: 16px
底部按钮被手势条盖住 没处理安全区 第 7 节的 env(safe-area-inset-bottom)
@media 不生效 没加 viewport meta 或单位写 px 大小写错了 检查两处
横屏模式布局怪 没考虑横向 @media (orientation: landscape) 或用 flex wrap

小练习

  1. 把 in4vue 的首页笔记列表改成 1/2/3 列响应式网格
  2. index.html 的 viewport meta 加上 viewport-fit=cover,检查手机顶部是否铺满
  3. pnpm dev --host 让手机访问 in4vue,观察真实效果
  4. 给一个 position: fixed 底部栏加 env(safe-area-inset-bottom),在 iPhone 模拟器验证
  5. 把一个 100vh 的登录背景图换成 100dvh,在模拟手机地址栏动画里观察差异

延伸阅读