移动端适配方案

What — 是什么

移动端适配是指通过一系列技术手段,让同一套代码在不同尺寸、不同分辨率、不同像素密度的移动设备上呈现出一致且合理的UI效果。

核心概念:

  • CSS像素(CSS Pixel):浏览器使用的逻辑像素单位,CSS代码中写的px就是CSS像素,它是相对单位
  • 物理像素(Physical Pixel):屏幕实际拥有的发光点数,设备出厂就决定了,是绝对单位
  • 设备像素比(DPR)DPR = 物理像素数 / CSS像素数,iPhone6的DPR为2,即1个CSS像素对应2×2=4个物理像素
  • viewport(视口):浏览器用来展示网页的区域,分为布局视口、视觉视口和理想视口三种
  • 理想视口(Ideal Viewport):与设备屏幕等宽的视口,是移动端适配的基准,通常通过meta标签设置

关键特性:

  • 移动端屏幕尺寸碎片化严重,从320px到428px+宽度不等,需要一套代码自适应
  • 高清屏(Retina)下DPR≥2,1个CSS像素被多个物理像素渲染,导致图片模糊、边框变粗
  • 移动浏览器默认视口(布局视口)通常为980px,会导致页面缩小显示,必须设置理想视口
  • iOS安全区域(刘海屏、底部横条)需要额外适配,否则内容被遮挡
  • 触摸交互有别于鼠标,存在300ms点击延迟等特有问题

viewport三种视口详解:

视口类型说明获取方式
布局视口(Layout Viewport)浏览器默认的页面渲染区域,移动端通常980pxdocument.documentElement.clientWidth
视觉视口(Visual Viewport)用户当前可见的页面区域,会随缩放变化window.innerWidth
理想视口(Ideal Viewport)与设备屏幕CSS宽度一致的视口设置meta后与布局视口一致

Why — 为什么

适用场景:

  • H5营销活动页,需要在各种手机上展示一致的视觉效果
  • 移动端电商页面,商品布局和字体大小需要合理适配
  • Web App / PWA应用,需接近原生App的视觉体验
  • 微信公众号内嵌H5,用户设备不可控
  • 跨平台混合开发(WebView容器),一套代码多端运行
  • 响应式网站在移动端的显示优化

对比替代方案:

维度rem适配vw适配vw+rem组合媒体查询
原理根据屏幕宽度动态设置html的font-size利用vw单位直接相对于视口宽度vw设置根字号,rem继承根字号针对断点写不同样式
兼容性极佳(Android 2.1+)好(Android 4.4+)好(Android 4.4+)极佳
精确度高(JS动态计算)高(浏览器原生)低(区间跳跃)
依赖需要lib-flexible等JS库无依赖无依赖无依赖
维护性需维护JS+PostCSS只需PostCSS只需PostCSS纯CSS手动维护
极端屏幕可设最大最小值需额外限制可通过rem限制天然断点
SSR支持不友好(依赖JS)友好(纯CSS)友好(纯CSS)友好
缩放适配需监听resize自动响应自动响应自动响应

优缺点:

  • ✅ 优点:
    • 一套代码适配多端,开发效率高
    • vw方案无JS依赖,性能好、支持SSR
    • rem方案兼容性好,适合需要兼容老旧设备的场景
    • vw+rem组合方案兼顾两者优势,可限制极端屏幕下的缩放
    • PostCSS插件自动转换,开发时仍用px写代码,心智负担低
  • ❌ 缺点:
    • 无法完全还原设计稿的像素级精确(字体、间距的等比缩放可能过大或过小)
    • 大屏设备上等比放大可能不美观(如平板),需额外限制最大宽度
    • rem方案依赖JS,首屏渲染可能闪动
    • 横竖屏切换时需要额外处理
    • 部分第三方组件库可能不支持rem/vw,需额外配置

How — 怎么用

快速上手

第一步:设置viewport meta标签(所有方案通用)

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

第二步:选择适配方案(以vw方案为例)

npm install postcss-px-to-viewport-8-plugin -D
// postcss.config.js
module.exports = {
  plugins: {
    'postcss-px-to-viewport-8-plugin': {
      viewportWidth: 375,   // 设计稿宽度
      unitPrecision: 5,     // 小数位数
      viewportUnit: 'vw',   // 转换单位
      selectorBlackList: ['.ignore'], // 忽略的类名
      minPixelValue: 1,     // 小于1px不转换
      mediaQuery: false,    // 不转换媒体查询中的px
    }
  }
}

第三步:按设计稿用px写代码,构建时自动转换

.container {
  width: 375px;    /* → 100vw */
  padding: 16px;   /* → 4.26667vw */
  font-size: 14px; /* → 3.73333vw */
}

viewport meta标签详解

<!-- 完整viewport meta标签 -->
<meta name="viewport"
  content="
    width=device-width,       /* 视口宽度=设备宽度,即理想视口 */
    initial-scale=1.0,        /* 初始缩放比例1:1 */
    maximum-scale=1.0,        /* 最大缩放比例(限制用户缩放) */
    minimum-scale=1.0,        /* 最小缩放比例 */
    user-scalable=no,         /* 禁止用户手动缩放 */
    viewport-fit=cover        /* 适配刘海屏,内容覆盖安全区域 */
  "
>

各属性详解:

属性作用推荐值说明
width设置布局视口宽度device-width关键属性,让视口=屏幕宽度
initial-scale初始缩放比例1.0设为1.0才能得到理想视口
maximum-scale最大缩放比例1.05.0无障碍要求允许缩放到5倍
minimum-scale最小缩放比例1.0防止页面缩小
user-scalable是否允许用户缩放no 或不设iOS10+已忽略此属性
viewport-fit视口覆盖模式cover自动/auto/cover/contain

viewport-fit模式:

/* cover: 页面内容覆盖整个屏幕(含安全区外) */
<meta name="viewport" content="width=device-width, viewport-fit=cover">

/* contain: 页面内容仅在安全区内显示(默认值auto等同contain) */
<meta name="viewport" content="width=device-width, viewport-fit=contain">

rem适配方案

方案原理: 根据屏幕宽度动态设置html元素的font-size,所有使用rem单位的元素随之等比缩放。

1. lib-flexible方案(经典)

npm install lib-flexible -S
npm install postcss-pxtorem -D
// main.js 入口引入
import 'lib-flexible/flexible'
// postcss.config.js
module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue: 37.5,          // 设计稿宽度375 / 10 = 37.5
      propList: ['*', '!border*'], // 需要转换的属性,border不转换避免1px问题
      selectorBlackList: ['.van-'], // 忽略vant组件
      minPixelValue: 2,         // 小于2px不转换
    }
  }
}

2. 手写flexible核心逻辑

// flexible.js — 核心只有十几行
(function flexible(window, document) {
  var docEl = document.documentElement;
  var dpr = window.devicePixelRatio || 1;

  // 设置data-dpr属性,CSS中可根据dpr做差异化处理
  docEl.setAttribute('data-dpr', Math.floor(dpr));

  function setRemUnit() {
    var rem = docEl.clientWidth / 10; // 将屏幕宽度10等分
    docEl.style.fontSize = rem + 'px';
  }

  setRemUnit();

  // 页面resize时重新计算
  window.addEventListener('resize', function () {
    setRemUnit();
  });

  // pageshow处理从缓存加载的页面(iOS后退不触发resize)
  window.addEventListener('pageshow', function (e) {
    if (e.persisted) {
      setRemUnit();
    }
  });
})(window, document);

3. rem方案的CSS写法(根据dpr差异化字体)

/* 根据data-dpr属性设置不同dpr下的字体大小,保证文字清晰 */
[data-dpr="1"] .text { font-size: 12px; }
[data-dpr="2"] .text { font-size: 24px; } /* DPR=2时2倍 */
[data-dpr="3"] .text { font-size: 36px; } /* DPR=3时3倍 */

/* 常规rem布局 */
.container {
  width: 10rem;       /* 满屏宽度 */
  padding: 0.4rem;    /* 375/10=37.5 → 15px */
  font-size: 0.37333rem; /* 14px */
}

4. rem方案限制极端屏幕

// 限制最大最小rem,防止在平板/电视上过大
function setRemUnit() {
  var clientWidth = docEl.clientWidth;
  // 限制在320-540之间
  clientWidth = Math.max(320, Math.min(clientWidth, 540));
  var rem = clientWidth / 10;
  docEl.style.fontSize = rem + 'px';
}

vw/vh适配方案

方案原理: vw是视口宽度的1/100,1vw = 视口宽度的1%。无需JS计算,纯CSS方案。

1. postcss-px-to-viewport配置

npm install postcss-px-to-viewport-8-plugin -D
// postcss.config.js
module.exports = {
  plugins: {
    'postcss-px-to-viewport-8-plugin': {
      viewportWidth: 375,       // 设计稿宽度(UI稿标准)
      unitPrecision: 5,         // vw小数精度
      viewportUnit: 'vw',       // 转换单位
      selectorBlackList: ['.ignore', '.van-'], // 不转换的选择器
      minPixelValue: 1,         // 小于1px不转换
      mediaQuery: false,        // 媒体查询中px不转换
      exclude: [/node_modules/], // 排除第三方库
      include: [/src/],         // 只处理src目录
    }
  }
}

2. vw适配的CSS写法

/* 开发时用px,构建后自动转vw */
.header {
  height: 44px;       /* → 11.73333vw */
  padding: 0 16px;    /* → 0 4.26667vw */
  font-size: 16px;    /* → 4.26667vw */
}

.card {
  width: 343px;       /* → 91.46667vw */
  margin: 0 auto 12px;/* → 0 auto 3.2vw */
  border-radius: 8px; /* → 2.13333vw */
}

/* 不希望被转换的样式,加上.ignore类名 */
.ignore-fixed {
  position: fixed;
  bottom: 0;
  height: 50px;       /* 保持50px,不转换 */
}

3. vw限制极端屏幕

/* 利用clamp()限制根字号,间接限制vw缩放 */
html {
  /* 设计稿375:根字号100px → 100/375*100 = 26.6667vw */
  /* 限制在 320px(85.3333vw对应320) 到 540px(144vw对应540) */
  font-size: clamp(85.333px, 26.6667vw, 144px);
}

/* 或使用min+max */
html {
  font-size: max(85.333px, min(26.6667vw, 144px));
}

vw+rem组合方案

方案原理: 用vw设置html根字号,其他元素用rem。兼顾vw的无JS优势和rem的字号继承特性,同时方便限制极端屏幕。

/* 核心设置:vw设置根字号 */
html {
  /* 设计稿375宽度,1rem = 100px → 100/375*100 ≈ 26.6667vw */
  font-size: 26.6667vw;
}

/* 限制极端屏幕 */
@media screen and (min-width: 540px) {
  html { font-size: 144px; }  /* 540 * 0.266667 = 144 */
}
@media screen and (max-width: 320px) {
  html { font-size: 85.333px; } /* 320 * 0.266667 ≈ 85.33 */
}

/* 使用rem写样式,1rem = 100px(设计稿中) */
.container {
  width: 3.75rem;    /* 375/100 = 3.75rem → 满屏 */
  padding: 0.16rem;  /* 16/100 = 0.16rem */
  font-size: 0.14rem; /* 14/100 = 0.14rem */
}

.box {
  width: 1.6rem;     /* 160px */
  height: 1.2rem;    /* 120px */
  margin: 0.12rem auto; /* 12px auto */
}

PostCSS自动转换配置:

// postcss.config.js — vw+rem组合
module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue: 100,            // 1rem = 100px
      propList: ['*'],
      selectorBlackList: ['.van-'],
      minPixelValue: 2,
    }
  }
}
// html的font-size手动用vw设置,不经过PostCSS转换

1px边框问题的5种解决方案

问题原因: DPR≥2的设备上,CSS写的1px边框实际由2×2=4个物理像素渲染,视觉上看起来比设计稿的1物理像素粗。

方案一:viewport缩放(最优雅)

<!-- 根据DPR动态设置viewport的scale -->
<script>
  var dpr = window.devicePixelRatio || 1;
  var scale = 1 / dpr;
  var viewport = document.querySelector('meta[name=viewport]');
  viewport.setAttribute('content',
    'width=device-width, initial-scale=' + scale +
    ', maximum-scale=' + scale + ', minimum-scale=' + scale +
    ', user-scalable=no, viewport-fit=cover'
  );
  // 同时需要将html的font-size放大dpr倍来抵消缩放
  document.documentElement.style.fontSize = document.documentElement.clientWidth * dpr / 10 + 'px';
</script>
/* 缩放后1px边框就真正是1物理像素 */
.border-1px {
  border-bottom: 1px solid #eee;
}

/* 但注意所有px尺寸都要用rem,因为页面被缩小了 */
.container {
  width: 10rem;
  font-size: 0.28rem; /* 实际显示14px */
}

方案二:border-image

/* 用2倍图做1px边框 */
.border-image-1px {
  border-width: 0 0 1px 0;
  border-style: solid;
  border-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAKCAYAAABqS2EJAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAABlJREFUeNpi+P///wMIMIiC9YTBIM0AAbYDCgMCAAQBADaeOQbGCn8AAAAAElFTkSuQmCC") 2 stretch;
}

/* 缺点:颜色不灵活,圆角不支持 */

方案三:transform scale(最常用)

/* 通用1px边框mixin */
.border-1px {
  position: relative;
}

.border-1px::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background-color: #eee;
  transform: scaleY(0.5);
  transform-origin: 0 0;
}

/* 4边1px边框 */
.border-1px-all {
  position: relative;
}

.border-1px-all::after {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 200%;
  height: 200%;
  border: 1px solid #eee;
  transform: scale(0.5);
  transform-origin: 0 0;
  pointer-events: none; /* 不影响点击事件 */
}

/* 根据DPR选择缩放比 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
  .border-1px::after { transform: scaleY(0.5); }
  .border-1px-all::after { transform: scale(0.5); }
}

@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
  .border-1px::after { transform: scaleY(0.333); }
  .border-1px-all::after { transform: scale(0.333); }
}

Sass/SCSS封装:

// 1px边框mixin
@mixin border-1px($color: #eee, $direction: bottom, $radius: 0) {
  position: relative;

  &::after {
    content: '';
    position: absolute;
    background-color: $color;

    @if $direction == bottom {
      left: 0;
      bottom: 0;
      width: 100%;
      height: 1px;
      transform: scaleY(0.5);
      transform-origin: 0 0;
    } @else if $direction == top {
      left: 0;
      top: 0;
      width: 100%;
      height: 1px;
      transform: scaleY(0.5);
      transform-origin: 0 0;
    } @else if $direction == left {
      left: 0;
      top: 0;
      width: 1px;
      height: 100%;
      transform: scaleX(0.5);
      transform-origin: 0 0;
    } @else if $direction == right {
      right: 0;
      top: 0;
      width: 1px;
      height: 100%;
      transform: scaleX(0.5);
      transform-origin: 0 0;
    } @else if $direction == all {
      left: 0;
      top: 0;
      width: 200%;
      height: 200%;
      border: 1px solid $color;
      border-radius: $radius * 2;
      transform: scale(0.5);
      transform-origin: 0 0;
    }

    @media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
      @if $direction == bottom or $direction == top {
        transform: scaleY(0.333);
      } @else if $direction == left or $direction == right {
        transform: scaleX(0.333);
      } @else if $direction == all {
        transform: scale(0.333);
      }
    }

    pointer-events: none;
  }
}

// 使用
.card {
  @include border-1px(#ddd, bottom);
}
.box {
  @include border-1px(#ccc, all, 8px);
}

方案四:SVG

/* 利用SVG画1px线,矢量不失真 */
.border-svg-1px {
  border-bottom: 1px solid transparent;
  border-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3Crect width='1' height='0.5' fill='%23eee'/%3E%3C/svg%3E") 1 stretch;
}

/* 支持圆角的SVG方案 */
.border-svg-radius {
  position: relative;
  border-radius: 8px;
}

.border-svg-radius::after {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  border-radius: 8px;
  border: 1px solid transparent;
  border-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3Crect width='1' height='0.5' fill='%23eee'/%3E%3C/svg%3E") 1 stretch;
  pointer-events: none;
}

方案五:meta缩放(全局方案)

<!-- 与方案一类似,但更简洁的写法 -->
<script>
  !function () {
    var dpr = window.devicePixelRatio || 1;
    document.write(
      '<meta name="viewport" content="width=' +
      screen.width * dpr +
      ', initial-scale=' + (1 / dpr) +
      ', maximum-scale=' + (1 / dpr) +
      ', minimum-scale=' + (1 / dpr) +
      ', user-scalable=no">'
    );
  }();
</script>
/* 此方案下1px直接就是1物理像素,无需额外处理 */
.thin-border {
  border: 1px solid #eee; /* 在DPR=2设备上显示为真正的1物理像素 */
}

5种方案对比:

方案原理优点缺点推荐度
viewport缩放整体缩放页面一劳永逸,所有1px自动变细影响全部尺寸,需配合rem★★★★★
border-image图片做边框简单颜色不灵活,不支持圆角★★
transform scale伪元素缩放兼容性好,灵活伪元素占位,代码多★★★★
SVG矢量线做边框颜色可控圆角实现复杂★★★
meta缩放全局viewport缩放最简洁SSR不友好,document.write★★★

安全区域适配

1. 基本概念与meta设置

<!-- 设置viewport-fit=cover让内容覆盖安全区域 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">

2. env()函数适配安全区域

/* env()安全区域变量 */
:root {
  /* 四个方向的安全区域内边距 */
  --safe-area-top: env(safe-area-inset-top);       /* 顶部安全区(状态栏/刘海) */
  --safe-area-right: env(safe-area-inset-right);   /* 右侧安全区(横屏刘海) */
  --safe-area-bottom: env(safe-area-inset-bottom); /* 底部安全区(Home Indicator横条) */
  --safe-area-left: env(safe-area-inset-left);     /* 左侧安全区(横屏刘海) */
}

/* 顶部导航栏适配刘海 */
.header {
  padding-top: env(safe-area-inset-top); /* 非刘海屏为0,刘海屏为44px */
  height: calc(44px + env(safe-area-inset-top));
}

/* 底部固定栏适配Home Indicator */
.footer-fixed {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  padding-bottom: env(safe-area-inset-bottom); /* 非刘海屏为0,刘海屏为34px */
}

/* 完整的安全区域适配布局 */
.page {
  padding-top: constant(safe-area-inset-top);    /* iOS 11.0 */
  padding-top: env(safe-area-inset-top);          /* iOS 11.2+ */
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}

/* 两侧适配(横屏刘海) */
.content {
  padding-left: constant(safe-area-inset-left);
  padding-left: env(safe-area-inset-left);
  padding-right: constant(safe-area-inset-right);
  padding-right: env(safe-area-inset-right);
}

3. 全面屏底部安全区最佳实践

/* 方案一:直接用padding-bottom */
.bottom-bar {
  position: fixed;
  bottom: 0;
  width: 100%;
  height: 50px;
  padding-bottom: env(safe-area-inset-bottom);
  background: #fff;
}

/* 方案二:用额外div做安全区填充 */
.safe-area-bottom {
  position: fixed;
  bottom: 0;
  width: 100%;
  height: env(safe-area-inset-bottom);
  background: #fff; /* 与底部栏同色 */
}

/* 方案三:calc计算完整高度 */
.bottom-bar-full {
  position: fixed;
  bottom: 0;
  width: 100%;
  height: calc(50px + env(safe-area-inset-bottom));
  box-sizing: border-box;
  padding-bottom: env(safe-area-inset-bottom);
}

横屏适配处理

1. CSS媒体查询检测横竖屏

/* orientation媒体查询 */
@media screen and (orientation: portrait) {
  /* 竖屏样式 */
  .layout {
    flex-direction: column;
  }
  .sidebar {
    width: 100%;
    height: 200px;
  }
}

@media screen and (orientation: landscape) {
  /* 横屏样式 */
  .layout {
    flex-direction: row;
  }
  .sidebar {
    width: 200px;
    height: 100vh;
  }
}

2. 强制竖屏(锁定屏幕方向)

// 使用Screen Orientation API锁定竖屏
function lockPortrait() {
  if (screen.orientation && screen.orientation.lock) {
    screen.orientation.lock('portrait').catch(function () {
      // 部分浏览器不支持锁定
    });
  }
}

// 监听方向变化
window.addEventListener('orientationchange', function () {
  if (Math.abs(window.orientation) === 90) {
    // 横屏状态,提示用户旋转设备
    showRotateTip();
  } else {
    // 竖屏状态,隐藏提示
    hideRotateTip();
  }
});

3. 横屏提示遮罩(最常用方案)

<!-- 横屏时显示旋转提示 -->
<div class="rotate-tip" id="rotateTip">
  <div class="rotate-icon">📱</div>
  <p>请旋转手机至竖屏查看</p>
</div>
.rotate-tip {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.9);
  z-index: 9999;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  color: #fff;
}

.rotate-icon {
  animation: rotate-anim 1.5s ease-in-out infinite;
}

@keyframes rotate-anim {
  0% { transform: rotate(0deg); }
  50% { transform: rotate(-90deg); }
  100% { transform: rotate(0deg); }
}

/* 横屏时显示提示 */
@media screen and (orientation: landscape) {
  .rotate-tip {
    display: flex;
  }
}

图片适配

1. srcset属性 — 根据DPR加载不同图片

<!-- 根据设备像素比选择图片 -->
<img
  src="image-1x.jpg"
  srcset="image-1x.jpg 1x, image-2x.jpg 2x, image-3x.jpg 3x"
  alt="适配不同DPR的图片"
>

<!-- 根据视口宽度选择图片(w描述符) -->
<img
  src="image-400.jpg"
  srcset="image-200.jpg 200w, image-400.jpg 400w, image-800.jpg 800w, image-1600.jpg 1600w"
  sizes="(max-width: 375px) 200px, (max-width: 768px) 400px, 800px"
  alt="响应式图片"
>

2. picture元素 — 根据条件加载不同图片

<picture>
  <!-- 横屏加载宽图 -->
  <source media="(orientation: landscape)" srcset="wide-image.jpg">
  <!-- 竖屏加载窄图 -->
  <source media="(orientation: portrait)" srcset="narrow-image.jpg">
  <!-- 默认fallback -->
  <img src="narrow-image.jpg" alt="方向适配图片">
</picture>

<picture>
  <!-- WebP格式优先 -->
  <source type="image/webp" srcset="photo.webp">
  <!-- AVIF更优 -->
  <source type="image/avif" srcset="photo.avif">
  <!-- 兜底JPEG -->
  <img src="photo.jpg" alt="格式适配图片">
</picture>

3. CSS背景图适配

/* 使用image-set根据DPR选择背景图 */
.hero {
  background-image: image-set(
    "hero-1x.jpg" 1x,
    "hero-2x.jpg" 2x,
    "hero-3x.jpg" 3x
  );
  background-size: cover;
}

/* 使用媒体查询 */
.hero {
  background-image: url("hero-1x.jpg");
}

@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
  .hero {
    background-image: url("hero-2x.jpg");
  }
}

@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
  .hero {
    background-image: url("hero-3x.jpg");
  }
}

触摸事件处理

1. 300ms点击延迟问题

<!-- 方案一:设置viewport的user-scalable=no(最简单) -->
<meta name="viewport" content="width=device-width, user-scalable=no">

<!-- 方案二:设置touch-action(推荐) -->
/* 禁用双击缩放,消除300ms延迟 */
* {
  touch-action: manipulation; /* 只允许平移和缩放手势,禁用双击缩放 */
}

/* touch-action值说明 */
.no-scroll {
  touch-action: none;            /* 禁止所有触摸手势 */
}

.horizontal-scroll {
  touch-action: pan-y;           /* 只允许纵向平移 */
}

.pinch-zoom {
  touch-action: pan-x pan-y pinch-zoom; /* 允许平移和缩放 */
}

2. FastClick库(兼容旧浏览器)

npm install fastclick -S
// main.js
import FastClick from 'fastclick';

// 消除300ms延迟
if ('addEventListener' in document) {
  document.addEventListener('DOMContentLoaded', function () {
    FastClick.attach(document.body);
  }, false);
}

// 现代浏览器不需要FastClick,可以按需引入
if ('ontouchstart' in window && !('touchAction' in document.documentElement.style)) {
  // 仅在需要时加载
  import('fastclick').then(module => {
    module.attach(document.body);
  });
}

3. passive事件监听

// passive: true 告诉浏览器不会调用preventDefault
// 浏览器可以不等JS执行完就开始滚动,提升滚动性能

// ❌ 错误:默认passive为false,会阻塞滚动
document.addEventListener('touchmove', function (e) {
  // 这里即使不调用preventDefault,也会阻塞浏览器滚动
}, false);

// ✅ 正确:声明passive: true
document.addEventListener('touchmove', function (e) {
  // 纯监听,不阻止默认行为
  console.log('touching', e.touches[0].clientX);
}, { passive: true });

// ✅ 需要阻止默认行为时不设passive
document.addEventListener('touchmove', function (e) {
  e.preventDefault(); // 阻止滚动(如自定义下拉刷新)
}, { passive: false });

// Chrome默认对touchstart/touchmove设置了passive:true
// 如果需要preventDefault,必须显式设passive:false

4. 触摸事件基础用法

// 触摸事件类型:touchstart / touchmove / touchend / touchcancel
const element = document.querySelector('.swipe-area');

let startX = 0, startY = 0;

element.addEventListener('touchstart', function (e) {
  const touch = e.touches[0];
  startX = touch.clientX;
  startY = touch.clientY;
}, { passive: true });

element.addEventListener('touchmove', function (e) {
  const touch = e.touches[0];
  const deltaX = touch.clientX - startX;
  const deltaY = touch.clientY - startY;
  // 判断滑动方向
  if (Math.abs(deltaX) > Math.abs(deltaY)) {
    console.log(deltaX > 0 ? '向右滑' : '向左滑');
  } else {
    console.log(deltaY > 0 ? '向下滑' : '向上滑');
  }
}, { passive: true });

// 点击(tap)判定:移动距离小于10px且时间小于300ms
element.addEventListener('touchend', function (e) {
  const touch = e.changedTouches[0];
  const deltaX = Math.abs(touch.clientX - startX);
  const deltaY = Math.abs(touch.clientY - startY);
  if (deltaX < 10 && deltaY < 10) {
    console.log('tap事件');
  }
}, { passive: true });

常见问题与踩坑

问题原因解决方案
1px边框在高清屏变粗DPR≥2时1个CSS像素由2×2物理像素渲染使用transform:scaleY(0.5)伪元素方案或viewport缩放方案
iOS底部内容被Home Indicator遮挡iPhone X+底部有34px安全区padding-bottom: env(safe-area-inset-bottom),配合viewport-fit=cover
安卓键盘弹起遮挡输入框软键盘弹出改变visual viewport监听resize或使用visualViewportAPI,滚动输入框到可见区域
软键盘收起页面不回弹iOS WebView键盘收起后页面偏移键盘收起时执行window.scrollTo(0, 0)window.scrollTo(0, scrollY)
iOS橡皮筋效果导致页面溢出Safari默认overscroll行为overscroll-behavior: none或监听touchmove阻止默认行为
长按选中文字/弹出菜单浏览器默认长按行为-webkit-touch-callout: none; -webkit-user-select: none;
固定定位在键盘弹起时错位iOS Safari中fixed元素随键盘移动将fixed改为absolute,或使用visualViewportAPI动态调整
点击元素闪灰iOS点击高亮-webkit-tap-highlight-color: transparent;
图片模糊1x图在2x/3x屏幕上被拉伸使用srcset或image-set提供2x/3x图
rem方案首屏闪烁JS加载前font-size为默认16px内联flexible脚本到<head>中,或使用vw方案替代
横屏后布局错乱未处理orientation变化使用CSS媒体查询orientation或JS监听orientationchange
安卓低版本不支持vwAndroid 4.3-不支持vw单位降级使用rem方案或postcss-px-to-viewport的viewportUnit回退

安卓键盘弹起遮挡输入框的详细解决方案:

// 方案一:使用visualViewport API(推荐)
if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', function () {
    var activeEl = document.activeElement;
    if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {
      // 将活动输入框滚动到可视区域
      activeEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
    }
  });
}

// 方案二:监听focus事件
document.addEventListener('DOMContentLoaded', function () {
  var inputs = document.querySelectorAll('input, textarea');
  inputs.forEach(function (input) {
    input.addEventListener('focus', function () {
      // 延迟执行,等键盘弹起
      setTimeout(function () {
        input.scrollIntoView({ block: 'center' });
      }, 300);
    });
  });
});

iOS键盘收起页面不回弹的详细解决方案:

// 监听输入框blur事件,强制回弹
document.addEventListener('DOMContentLoaded', function () {
  var inputs = document.querySelectorAll('input, textarea');
  inputs.forEach(function (input) {
    input.addEventListener('blur', function () {
      setTimeout(function () {
        var scrollHeight = document.documentElement.scrollHeight;
        var scrollTop = document.documentElement.scrollTop;
        if (scrollTop > 0) {
          window.scrollTo(0, scrollTop - 1);
          window.scrollTo(0, scrollTop);
        }
      }, 100);
    });
  });
});

最佳实践

  • viewport meta标签必须写,所有移动端页面第一步就是设置理想视口
  • 优先选择vw方案:纯CSS方案无JS依赖,SSR友好,性能最优
  • 大屏限制:使用clamp()或媒体查询限制最大宽度/字号,防止在平板上等比放大过度
  • 1px边框统一用transform方案:封装成mixin/function全局复用,按DPR选缩放比
  • 安全区域用env()适配:配合viewport-fit=cover,constant()做iOS 11.0兼容
  • 图片至少提供2x资源:使用srcset或image-set,优先WebP/AVIF格式
  • touch-action: manipulation消除300ms延迟:比引入FastClick库更轻量
  • 滚动监听设passive: true:提升滚动性能,Chrome对touchmove默认passive
  • 避免在移动端使用fixed定位:iOS Safari键盘弹起时fixed行为异常,改用absolute或方案规避
  • 字号用px而不用rem/vw:正文字号14px-16px在所有设备上应保持可读,不应等比缩放到过大或过小
  • 使用overscroll-behavior: none:防止页面过度滚动和橡皮筋效果
  • 内联关键CSS:适配相关样式(如html的font-size)应内联到<head>中避免闪烁

面试题

Q1: 移动端适配方案有哪些?各自的原理是什么?

主要有四种方案:

  1. rem方案:通过JS动态设置htmlfont-size为屏幕宽度的1/10,所有元素用rem单位(相对于html字号),从而实现等比缩放。配合PostCSS插件开发时用px编写;
  2. vw方案:利用CSS的vw单位(1vw = 视口宽度的1%),通过PostCSS插件将px自动转vw,无需JS,纯CSS实现;
  3. vw+rem组合:用vw设置html的font-size,其他元素用rem。兼具两者优点,方便限制极端屏幕;
  4. 媒体查询:针对不同屏幕宽度写不同的CSS规则,实现断点适配,精确但不连续。

Q2: rem和vw适配方案的区别是什么?怎么选择?

核心区别

  • rem需要JS动态计算根字号,vw是CSS原生单位无需JS;
  • rem兼容性更好(Android 2.1+),vw需要Android 4.4+;
  • rem方案首屏可能闪烁(JS未加载时字号为默认16px),vw无此问题;
  • vw支持SSR(服务端渲染),rem依赖客户端JS不适合SSR;
  • rem可以通过JS设置最大最小字号限制极端屏幕,vw需要配合clamp()或媒体查询实现。

选择建议:新项目优先选vw方案(无JS依赖、性能好、SSR友好);需要兼容Android 4.3以下选rem方案;需要精确控制缩放范围选vw+rem组合。

Q3: 什么是1px边框问题?有哪些解决方案?

问题:在DPR≥2的高清屏上,CSS写的1px边框实际由2×2(DPR=2)或3×3(DPR=3)个物理像素渲染,视觉上比设计稿的1物理像素粗。

解决方案

  1. viewport缩放:将页面按1/DPR缩放,再通过rem将内容放大DPR倍还原,这样1px边框就对应1物理像素;
  2. transform:scaleY(0.5):用伪元素画1px线,再沿Y轴缩放0.5(DPR=3时0.333),最常用;
  3. border-image:用1px高的图片(base64)做边框,简单但颜色不灵活、不支持圆角;
  4. SVG:用SVG画0.5高度的线做border-image,矢量不失真;
  5. meta全局缩放:动态设置viewport的scale为1/DPR,一劳永逸但需要全部用rem写尺寸。

Q4: DPR是什么?它和CSS像素、物理像素的关系?

**DPR(Device Pixel Ratio)**是设备像素比,等于物理像素数除以CSS像素数: DPR = 物理像素 / CSS像素

  • iPhone 6/7/8:物理分辨率750×1334,CSS分辨率375×667,DPR=2
  • iPhone X/11/12:物理分辨率1125×2436,CSS分辨率375×812,DPR=3
  • 普通显示器:DPR=1

DPR决定了1个CSS像素由多少物理像素渲染:DPR=2时1个CSS像素=2×2=4个物理像素。这导致:1px CSS边框在DPR=2设备上实际占2个物理像素宽度(视觉上变粗),1x图片在DPR=2设备上1个CSS像素需要4个物理像素来渲染(拉伸模糊,需要2x图)。

Q5: viewport有哪几种?分别解释?

三种viewport

  1. 布局视口(Layout Viewport):浏览器用于计算CSS布局的区域。移动浏览器默认宽度通常为980px(各家不同),导致桌面网页在手机上缩小显示。通过document.documentElement.clientWidth获取;
  2. 视觉视口(Visual Viewport):用户当前在屏幕上看到的页面区域,会随用户缩放而变化。通过window.innerWidth获取。缩放时视觉视口大小改变,但布局视口不变;
  3. 理想视口(Ideal Viewport):最理想的布局视口尺寸,即与设备屏幕CSS宽度一致。通过设置<meta name="viewport" content="width=device-width">实现,此时布局视口=理想视口。这是移动端适配的前提。

Q6: iOS安全区域怎么处理?env()和constant()有什么区别?

处理步骤

  1. 设置viewport-fit=cover让页面内容延伸到安全区域外;
  2. env(safe-area-inset-*)获取四个方向的安全区内边距,设置给需要避让的元素;
  3. 常见场景:顶部导航栏padding-top: env(safe-area-inset-top)避让刘海,底部固定栏padding-bottom: env(safe-area-inset-bottom)避让Home Indicator。

env()和constant()区别

  • constant()是iOS 11.0(Safari 11.0)引入的初始语法;
  • env()是iOS 11.2(Safari 11.2)及之后的标准语法,取代了constant()
  • 为了兼容两个版本,需要同时写两行,constant()在前、env()在后,浏览器会忽略不支持的;
  • 代码:padding-top: constant(safe-area-inset-top); padding-top: env(safe-area-inset-top);

Q7: 移动端300ms点击延迟的原因和解决方案?

原因:早期移动浏览器为了支持双击缩放(double tap to zoom),在检测到第一次tap后需要等待约300ms判断用户是否会再次tap(双击)。如果300ms内没有第二次tap,才触发click事件。

解决方案

  1. 设置viewport的user-scalable=no:禁用缩放后浏览器无需等待双击判断,但影响无障碍(iOS10+忽略此属性);
  2. 设置touch-action: manipulation(推荐):只允许平移和缩放手势,禁用双击缩放,轻量无副作用;
  3. FastClick库:在touchend时主动触发click事件,跳过300ms等待。但现代浏览器已不再需要;
  4. 使用pointer事件pointerdown/pointerup替代click,无延迟;
  5. 现代浏览器已自动优化:Chrome 32+在设置了width=device-width的页面上自动取消300ms延迟;Safari在iOS 9.3+也对不可缩放页面取消延迟。

Q8: 如何做横竖屏适配?有哪些方案?

方案

  1. CSS媒体查询orientation:用@media (orientation: portrait/landscape)为横竖屏写不同样式,最常用。适合布局需要重排的场景;
  2. Screen Orientation API锁定方向screen.orientation.lock('portrait')强制竖屏,部分浏览器不支持(需要全屏模式);
  3. 横屏提示遮罩:横屏时显示全屏遮罩提示用户旋转设备,最简单有效的方案,几乎所有H5活动页都使用;
  4. 监听orientationchange事件:JS检测方向变化,动态调整布局或显示提示。注意用screen.orientation.angle代替已废弃的window.orientation
  5. 响应式布局:使用Flex/Grid布局让页面自动适应不同宽高比,无需特别区分横竖屏;
  6. viewport单位vh/vw:利用视口单位让尺寸自动适应,但注意横屏时vw和vh含义互换可能需要额外处理。

相关链接: