返回笔记首页

6.1 移动端适配方案 - 多屏幕完美适配

主题配置

简历描述模板

版本一:注重技术广度

plain
负责移动端H5项目的多屏幕适配方案设计,覆盖iPhone 5至iPhone 14 Pro Max等30+主
流机型。采用vw+rem混合方案实现响应式布局,配合PostCSS插件自动转换单位,开发效
率提升40%。解决了1px边框在高清屏显示过粗的问题,使用scale+伪元素方案实现真实
物理像素渲染。针对刘海屏、水滴屏等异形屏适配了安全区域,使用env()函数动态获取
安全距离。处理了横竖屏切换场景,监听orientationchange事件重新计算布局。

版本二:强调工程化实践

plain
主导移动端适配方案的技术选型与落地,构建基于vw的弹性布局体系。开发了自动化适配
工具链,集成px2vw、viewport配置、设备检测于一体,设计稿还原度达99%。针对不同
DPR设备实现了多倍图自动加载策略,结合CDN的图片处理能力按需返回@2x/@3x资源。封
装了SafeArea组件处理异形屏适配,自动识别设备类型并注入对应的padding值。建立了
适配测试用例库,覆盖iOS/Android主流机型的自动化截图对比。

版本三:突出解决方案深度

plain
设计企业级移动端适配解决方案,支持iPhone、Android、iPad等多终端。深入研究了
viewport机制,根据设计稿动态设置initial-scale确保1:1还原。针对1px边框问题对比
了5种方案,最终选择transform: scale结合伪元素方案,兼容性好且无副作用。处理了
iOS安全区域适配的细节问题,包括导航栏、底部TabBar、弹窗等20+场景。实现了横竖屏
无缝切换,保持状态不丢失,动画流畅无卡顿。总结并制定了移动端适配规范文档。

SOP 标准回答

Q1: 你们的移动端适配方案是怎么选择的?

标准回答

plain
我们经历过三个阶段,最后定下来用vw+rem混合方案:

一开始用的rem适配,基于750px设计稿,根节点font-size设置为100px,这样1rem=100px,
设计稿上的750px宽度就是7.5rem。但有个问题,不同设备宽度不一样,需要JS动态计算
font-size。我们在HTML的head里加了段内联脚本,根据屏幕宽度算font-size,公式是
(clientWidth / 7.5) * 100。这个方案用了半年,主要问题是首屏会闪一下,因为JS执行
有延迟。

后来改成纯vw方案。750px设计稿,屏幕宽度是100vw,1px就等于(100/750)vw约等于
0.1333vw。这个不需要JS,纯CSS就能搞定。我们用PostCSS的postcss-px-to-viewport插
件自动转换,写代码时还是按px写,打包时自动转vw。但纯vw有个问题,字体也跟着屏幕
缩放,在大屏幕上字会特别大,用户体验不好。

现在用的是vw+rem混合。布局用vw,字体用rem。根节点font-size设置成5vw,这样在
375px屏幕上是18.75px,在414px屏幕上是20.7px,字体大小会随屏幕适度调整,但不会
像纯vw那样夸张。关键元素比如按钮高度、间距这些用vw,保证布局比例一致。字体用rem,
既能适配不同屏幕,又不会太离谱。

还有个细节是最大最小值限制。超大屏比如iPad,按vw算出来的尺寸会很夸张,我们用媒
体查询限制了最大宽度600px,超过这个宽度就固定布局居中显示。超小屏比如iPhone SE,
我们也设置了最小宽度320px,避免内容显示不全。

追问应对

  • "为什么不用百分比布局?" → 百分比基于父元素,嵌套层级深了很难计算,vw基于视口更直观
  • "vw有兼容性问题吗?" → iOS 8+和Android 4.4+都支持,覆盖了99%的用户
  • "PostCSS配置复杂吗?" → 很简单,就几行配置,讲具体配置项

Q2: 1px边框问题是怎么解决的?

标准回答

plain
这个是移动端很经典的问题,在Retina屏上,CSS的1px实际会显示成2个或3个物理像素,
看起来比较粗。我对比测试了5种方案:

第一种是0.5px。直接写border: 0.5px,在iOS 8+上可以显示真实的1物理像素,但
Android不支持,会显示成0,直接不见了。这个方案兼容性不好,放弃了。

第二种是border-image。用渐变图片模拟1px线,但只能做直线,圆角、虚线这些实现不
了,而且要引入图片资源,也不太好。

第三种是box-shadow。用box-shadow: 0 0 0 0.5px #000内阴影模拟边框,在某些设备
上显示效果不错,但颜色不够纯,有点模糊。

第四种是viewport+rem。把viewport的scale设置成1/dpr,比如在dpr=2的设备上设置
scale=0.5,这样整个页面缩小了,1px就是真实的1物理像素。但这个方案问题很大,会影
响第三方组件,字体也要跟着调整,风险太高。

最后用的是第五种,伪元素+transform。原理是用伪元素画一个200%或300%大小的边框,
然后用transform: scale缩小到原来的1/2或1/3。这个方案兼容性好,支持圆角、虚线等
各种边框样式,我们就用这个了。

具体实现是封装了个hairline的Mixin,根据设备dpr自动选择缩放比例。iPhone X这些
dpr=3的设备,伪元素宽高是300%,scale(0.333)缩回去。这样在任何设备上都能显示真
实的1物理像素边框。还处理了position定位、圆角这些细节,确保各种场景都能用。

Q3: 刘海屏的安全区域是怎么适配的?

标准回答

plain
刘海屏适配主要是处理安全区域,避免内容被刘海、Home指示器遮挡。iOS 11开始提供了
env()函数和viewport-fit属性来支持这个。

首先要在meta标签里设置viewport-fit=cover,让页面延伸到安全区域之外。默认是
contain,内容不会被遮挡,但会留白。我们要全屏显示,所以用cover。

然后用env()函数获取安全区域的边距。iOS提供了4个环境变量:
- safe-area-inset-top:顶部安全区域,刘海的高度
- safe-area-inset-bottom:底部安全区域,Home指示器的高度
- safe-area-inset-left:左边安全区域,横屏时的边距
- safe-area-inset-right:右边安全区域

我们封装了个SafeArea组件,自动给内容区域加上对应的padding。比如顶部导航栏,
padding-top要加上env(safe-area-inset-top),这样导航栏就不会被刘海遮挡。底部
TabBar也是一样的逻辑,padding-bottom加上env(safe-area-inset-bottom)。

有个坑是env()函数在非刘海屏设备上返回0px,这样是正常的。但如果写错了比如写成
env(safe-area-inset-tops),iOS会返回0px,Android会返回undefined,导致样式失效。
所以一定要提供fallback值,写成padding-top: calc(env(safe-area-inset-top) + 0px)。

还有个场景是全屏弹窗。弹窗内容要占满整个屏幕,但顶部底部不能被刘海和Home指示器
遮挡。我的做法是弹窗容器用fixed定位,top: 0; bottom: 0,内容区域再用SafeArea组
件包裹,这样既能全屏显示,又不会遮挡交互元素。

横屏的时候还要处理左右安全区域。iPhone X横屏时,刘海会在左边或右边,也要避开。
我监听了orientationchange事件,横屏时给容器加上left和right的安全边距,竖屏时
只加top和bottom。

难点与亮点分析

难点一:viewport 动态适配

问题背景: 不同设备的视口宽度差异很大,iPhone SE是320px,iPhone 14 Pro Max是428px,iPad 更大。如何在保证设计稿还原的同时,又能适配各种屏幕尺寸?

解决思路

  1. 根据设计稿宽度动态设置viewport的scale
  2. 使用媒体查询设置断点,不同尺寸采用不同策略
  3. 关键尺寸使用vw相对单位,配合max/min限制
  4. 字体采用rem单位,相对根元素缩放

技术实现

javascript
// utils/viewport.js

// 设计稿宽度
const DESIGN_WIDTH = 750

// 获取设备像素比
function getDevicePixelRatio() {
    return window.devicePixelRatio || 1
}

// 获取视口宽度
function getViewportWidth() {
    return window.innerWidth || document.documentElement.clientWidth
}

// 设置viewport
function setViewport() {
    const dpr = getDevicePixelRatio()
    const viewportWidth = getViewportWidth()

    // 计算缩放比例
    // 目标:让设计稿宽度等于视口宽度
    const scale = viewportWidth / DESIGN_WIDTH

    // 创建或更新viewport meta标签
    let metaEl = document.querySelector('meta[name="viewport"]')
    if (!metaEl) {
        metaEl = document.createElement('meta')
        metaEl.setAttribute('name', 'viewport')
        document.head.appendChild(metaEl)
    }

    // 设置viewport属性
    metaEl.setAttribute(
        'content',
        `
    width=${DESIGN_WIDTH},
    initial-scale=${scale},
    maximum-scale=${scale},
    minimum-scale=${scale},
    user-scalable=no,
    viewport-fit=cover
  `
            .replace(/\s+/g, ' ')
            .trim()
    )

    // 设置根元素字体大小(用于rem)
    // 基准:750px设计稿,根字体100px
    const baseFontSize = 100
    const rootFontSize = (viewportWidth / DESIGN_WIDTH) * baseFontSize

    // 限制字体大小范围
    const minFontSize = 12
    const maxFontSize = 24
    const finalFontSize = Math.max(
        minFontSize,
        Math.min(maxFontSize, rootFontSize)
    )

    document.documentElement.style.fontSize = finalFontSize + 'px'

    // 设置一些CSS变量供样式使用
    document.documentElement.style.setProperty(
        '--viewport-width',
        viewportWidth + 'px'
    )
    document.documentElement.style.setProperty(
        '--design-width',
        DESIGN_WIDTH + 'px'
    )
    document.documentElement.style.setProperty('--scale', scale)
    document.documentElement.style.setProperty('--dpr', dpr)
}

// 监听窗口变化
function watchResize() {
    let resizeTimer = null

    window.addEventListener('resize', () => {
        // 防抖处理
        clearTimeout(resizeTimer)
        resizeTimer = setTimeout(() => {
            setViewport()
        }, 300)
    })

    // 监听横竖屏切换
    window.addEventListener('orientationchange', () => {
        // orientationchange事件触发时,视口尺寸还没更新
        // 需要延迟一下
        setTimeout(() => {
            setViewport()
        }, 100)
    })
}

// 初始化
export function initViewport() {
    // 尽早设置viewport,避免页面闪烁
    setViewport()

    // 监听变化
    watchResize()

    // DOMContentLoaded后再次设置,确保准确
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', setViewport)
    }
}

// 获取当前rem基准值
export function getRemBase() {
    return parseFloat(document.documentElement.style.fontSize)
}

// px转rem
export function px2rem(px) {
    const remBase = getRemBase()
    return px / remBase
}

// rem转px
export function rem2px(rem) {
    const remBase = getRemBase()
    return rem * remBase
}

在main.js中初始化

javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { initViewport } from './utils/viewport'

// 优先初始化viewport
initViewport()

const app = createApp(App)
app.mount('#app')

难点二:1px 边框完美方案

问题背景: 在Retina屏幕上,CSS的1px会被渲染成多个物理像素,导致边框看起来很粗。需要实现真正的1物理像素边框。

解决方案

vue
<!-- components/Hairline.vue -->
<script setup>
import { computed } from 'vue'

const props = defineProps({
    // 边框位置:top/right/bottom/left/all
    position: {
        type: String,
        default: 'all',
    },
    // 边框颜色
    color: {
        type: String,
        default: '#e4e7ed',
    },
    // 边框样式:solid/dashed/dotted
    style: {
        type: String,
        default: 'solid',
    },
    // 是否圆角
    radius: {
        type: [Number, String],
        default: 0,
    },
})

// 边框类名
const borderClass = computed(() => {
    const classes = ['hairline']
    if (props.position === 'all') {
        classes.push('hairline--all')
    } else {
        classes.push(`hairline--${props.position}`)
    }
    return classes.join(' ')
})

// 边框样式
const borderStyle = computed(() => {
    return {
        '--hairline-color': props.color,
        '--hairline-style': props.style,
        '--hairline-radius':
            typeof props.radius === 'number'
                ? props.radius + 'px'
                : props.radius,
    }
})
</script>

<template>
    <div :class="borderClass" :style="borderStyle">
        <slot />
    </div>
</template>

<style scoped>
.hairline {
    position: relative;
}

/* 通用伪元素样式 */
.hairline::before,
.hairline::after {
    content: '';
    position: absolute;
    pointer-events: none;
    box-sizing: border-box;
}

/* 全边框 */
.hairline--all::before {
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border: 1px var(--hairline-style, solid) var(--hairline-color, #e4e7ed);
    border-radius: var(--hairline-radius, 0);
}

/* 1像素适配 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
    .hairline--all::before {
        width: 200%;
        height: 200%;
        transform: scale(0.5);
        transform-origin: 0 0;
        border-radius: calc(var(--hairline-radius, 0) * 2);
    }
}

@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
    .hairline--all::before {
        width: 300%;
        height: 300%;
        transform: scale(0.333);
        transform-origin: 0 0;
        border-radius: calc(var(--hairline-radius, 0) * 3);
    }
}

/* 顶部边框 */
.hairline--top::before {
    top: 0;
    left: 0;
    width: 100%;
    border-top: 1px var(--hairline-style, solid) var(--hairline-color, #e4e7ed);
}

@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
    .hairline--top::before {
        transform: scaleY(0.5);
        transform-origin: 0 0;
    }
}

@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
    .hairline--top::before {
        transform: scaleY(0.333);
        transform-origin: 0 0;
    }
}

/* 底部边框 */
.hairline--bottom::after {
    bottom: 0;
    left: 0;
    width: 100%;
    border-bottom: 1px var(--hairline-style, solid)
        var(--hairline-color, #e4e7ed);
}

@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
    .hairline--bottom::after {
        transform: scaleY(0.5);
        transform-origin: 0 100%;
    }
}

@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
    .hairline--bottom::after {
        transform: scaleY(0.333);
        transform-origin: 0 100%;
    }
}

/* 左边框 */
.hairline--left::before {
    top: 0;
    left: 0;
    height: 100%;
    border-left: 1px var(--hairline-style, solid) var(--hairline-color, #e4e7ed);
}

@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
    .hairline--left::before {
        transform: scaleX(0.5);
        transform-origin: 0 0;
    }
}

@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
    .hairline--left::before {
        transform: scaleX(0.333);
        transform-origin: 0 0;
    }
}

/* 右边框 */
.hairline--right::after {
    top: 0;
    right: 0;
    height: 100%;
    border-right: 1px var(--hairline-style, solid)
        var(--hairline-color, #e4e7ed);
}

@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
    .hairline--right::after {
        transform: scaleX(0.5);
        transform-origin: 100% 0;
    }
}

@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 3dppx) {
    .hairline--right::after {
        transform: scaleX(0.333);
        transform-origin: 100% 0;
    }
}
</style>

使用示例

vue
<template>
    <div class="demo">
        <!-- 全边框 -->
        <Hairline position="all" color="#1890ff" :radius="8">
            <div class="box">全边框</div>
        </Hairline>

        <!-- 底部边框 -->
        <Hairline position="bottom">
            <div class="item">列表项</div>
        </Hairline>

        <!-- 虚线边框 -->
        <Hairline position="all" style="dashed" color="#ff4d4f">
            <div class="box">虚线边框</div>
        </Hairline>
    </div>
</template>

难点三:安全区域智能适配

问题背景: 异形屏(刘海屏、水滴屏、挖孔屏)的安全区域需要特殊处理,导航栏、底部栏等关键元素不能被遮挡。

解决方案

vue
<!-- components/SafeArea.vue -->
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
    // 需要处理的位置:top/bottom/both
    position: {
        type: String,
        default: 'both',
    },
    // 额外的内边距
    extraPadding: {
        type: Number,
        default: 0,
    },
    // 是否固定定位
    fixed: {
        type: Boolean,
        default: false,
    },
    // 背景色
    bgColor: {
        type: String,
        default: 'transparent',
    },
})

// 是否为刘海屏
const isNotch = ref(false)

// 安全区域距离
const safeAreaInsets = ref({
    top: 0,
    bottom: 0,
    left: 0,
    right: 0,
})

// 检测是否为刘海屏
function detectNotch() {
    // 方法1:检测CSS env()是否有值
    if (CSS.supports('padding-top: env(safe-area-inset-top)')) {
        const testDiv = document.createElement('div')
        testDiv.style.paddingTop = 'env(safe-area-inset-top)'
        document.body.appendChild(testDiv)

        const computedTop = getComputedStyle(testDiv).paddingTop
        document.body.removeChild(testDiv)

        if (computedTop !== '0px') {
            isNotch.value = true
            return
        }
    }

    // 方法2:根据屏幕尺寸判断(已知刘海屏机型)
    const { width, height } = window.screen
    const ratio = window.devicePixelRatio

    // iPhone X/XS/11 Pro: 375x812
    // iPhone XR/11: 414x896
    // iPhone 12/13: 390x844
    // iPhone 12/13 Pro Max: 428x926
    // iPhone 14 Pro: 393x852
    // iPhone 14 Pro Max: 430x932
    const notchDevices = [
        { w: 375, h: 812 },
        { w: 414, h: 896 },
        { w: 390, h: 844 },
        { w: 428, h: 926 },
        { w: 393, h: 852 },
        { w: 430, h: 932 },
    ]

    isNotch.value = notchDevices.some((device) => {
        return (
            (width === device.w && height === device.h) ||
            (width === device.h && height === device.w)
        )
    })
}

// 获取安全区域距离
function getSafeAreaInsets() {
    const style = getComputedStyle(document.documentElement)

    safeAreaInsets.value = {
        top: parseInt(style.getPropertyValue('--sat') || 0),
        bottom: parseInt(style.getPropertyValue('--sab') || 0),
        left: parseInt(style.getPropertyValue('--sal') || 0),
        right: parseInt(style.getPropertyValue('--sar') || 0),
    }
}

// 计算样式
const wrapperStyle = computed(() => {
    const style = {
        backgroundColor: props.bgColor,
    }

    if (props.fixed) {
        style.position = 'fixed'
        style.left = '0'
        style.right = '0'
        style.zIndex = '100'
    }

    // 添加安全区域padding
    if (props.position === 'top' || props.position === 'both') {
        const topPadding = props.extraPadding
        style.paddingTop = `calc(${topPadding}px + env(safe-area-inset-top))`
    }

    if (props.position === 'bottom' || props.position === 'both') {
        const bottomPadding = props.extraPadding
        style.paddingBottom = `calc(${bottomPadding}px + env(safe-area-inset-bottom))`
    }

    return style
})

// 横竖屏切换
let orientationHandler = null

function handleOrientationChange() {
    setTimeout(() => {
        detectNotch()
        getSafeAreaInsets()
    }, 100)
}

onMounted(() => {
    detectNotch()
    getSafeAreaInsets()

    // 监听横竖屏切换
    orientationHandler = handleOrientationChange
    window.addEventListener('orientationchange', orientationHandler)
})

onUnmounted(() => {
    if (orientationHandler) {
        window.removeEventListener('orientationchange', orientationHandler)
    }
})

defineExpose({
    isNotch,
    safeAreaInsets,
})
</script>

<template>
    <div class="safe-area" :style="wrapperStyle">
        <slot />
    </div>
</template>

<style scoped>
.safe-area {
    width: 100%;
}

/* 为了让env()生效,需要设置viewport-fit=cover */
/* 这个在index.html的meta标签中设置 */
</style>

全局样式配置

css
/* styles/safe-area.css */

/* 定义CSS变量存储安全区域值 */
:root {
    --sat: env(safe-area-inset-top);
    --sab: env(safe-area-inset-bottom);
    --sal: env(safe-area-inset-left);
    --sar: env(safe-area-inset-right);
}

/* 安全区域相关的工具类 */
.safe-area-top {
    padding-top: env(safe-area-inset-top);
}

.safe-area-bottom {
    padding-bottom: env(safe-area-inset-bottom);
}

.safe-area-left {
    padding-left: env(safe-area-inset-left);
}

.safe-area-right {
    padding-right: env(safe-area-inset-right);
}

.safe-area-all {
    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);
}

/* 固定定位元素的安全区域处理 */
.navbar-fixed {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    padding-top: env(safe-area-inset-top);
}

.tabbar-fixed {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    padding-bottom: env(safe-area-inset-bottom);
}

/* 全屏弹窗的安全区域 */
.fullscreen-dialog {
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
}

.fullscreen-dialog .dialog-header {
    padding-top: calc(16px + env(safe-area-inset-top));
}

.fullscreen-dialog .dialog-footer {
    padding-bottom: calc(16px + env(safe-area-inset-bottom));
}

实际使用示例

vue
<template>
    <div class="page">
        <!-- 导航栏 -->
        <SafeArea position="top" :extra-padding="0" fixed bg-color="#fff">
            <div class="navbar">
                <div class="navbar-title">页面标题</div>
            </div>
        </SafeArea>

        <!-- 内容区域 -->
        <div class="content">
            <!-- 内容 -->
        </div>

        <!-- 底部TabBar -->
        <SafeArea position="bottom" :extra-padding="0" fixed bg-color="#fff">
            <div class="tabbar">
                <div class="tab-item">首页</div>
                <div class="tab-item">分类</div>
                <div class="tab-item">我的</div>
            </div>
        </SafeArea>
    </div>
</template>

<style scoped>
.page {
    min-height: 100vh;
    background: #f5f5f5;
}

.navbar {
    height: 44px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #fff;
    border-bottom: 1px solid #e4e7ed;
}

.content {
    padding: 44px 0 50px; /* 为固定导航栏和TabBar留出空间 */
}

.tabbar {
    height: 50px;
    display: flex;
    background: #fff;
    border-top: 1px solid #e4e7ed;
}

.tab-item {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
}
</style>

亮点:横竖屏切换无缝处理

设计思路: 横竖屏切换时,需要重新计算布局,保持状态不丢失,动画流畅。

核心实现

javascript
// composables/useOrientation.js
import { ref, computed, onMounted, onUnmounted } from 'vue'

export function useOrientation() {
    // 当前方向:portrait(竖屏) / landscape(横屏)
    const orientation = ref('portrait')

    // 屏幕尺寸
    const screenSize = ref({
        width: 0,
        height: 0,
    })

    // 是否为横屏
    const isLandscape = computed(() => {
        return orientation.value === 'landscape'
    })

    // 获取当前方向
    function getOrientation() {
        const { width, height } = window.screen

        // 根据宽高比判断
        if (width > height) {
            return 'landscape'
        } else {
            return 'portrait'
        }
    }

    // 更新屏幕信息
    function updateScreenInfo() {
        orientation.value = getOrientation()
        screenSize.value = {
            width: window.innerWidth,
            height: window.innerHeight,
        }
    }

    // 横竖屏切换处理
    function handleOrientationChange() {
        // orientationchange事件触发时,尺寸还未更新
        // 需要延迟一下
        setTimeout(() => {
            updateScreenInfo()
        }, 100)
    }

    // 监听resize(兼容方案)
    function handleResize() {
        const newOrientation = getOrientation()
        if (newOrientation !== orientation.value) {
            updateScreenInfo()
        }
    }

    // 锁定方向
    function lockOrientation(targetOrientation) {
        if (screen.orientation && screen.orientation.lock) {
            screen.orientation.lock(targetOrientation).catch((err) => {
                console.warn('屏幕方向锁定失败:', err)
            })
        }
    }

    // 解锁方向
    function unlockOrientation() {
        if (screen.orientation && screen.orientation.unlock) {
            screen.orientation.unlock()
        }
    }

    onMounted(() => {
        updateScreenInfo()

        // 监听方向变化
        window.addEventListener('orientationchange', handleOrientationChange)
        window.addEventListener('resize', handleResize)
    })

    onUnmounted(() => {
        window.removeEventListener('orientationchange', handleOrientationChange)
        window.removeEventListener('resize', handleResize)
    })

    return {
        orientation,
        screenSize,
        isLandscape,
        lockOrientation,
        unlockOrientation,
    }
}

在组件中使用

vue
<script setup>
import { watch } from 'vue'
import { useOrientation } from '@/composables/useOrientation'

const { orientation, isLandscape, screenSize } = useOrientation()

// 监听方向变化
watch(orientation, (newVal, oldVal) => {
    console.log(`屏幕方向从${oldVal}切换到${newVal}`)

    // 根据方向调整布局
    if (newVal === 'landscape') {
        // 横屏布局逻辑
        handleLandscapeLayout()
    } else {
        // 竖屏布局逻辑
        handlePortraitLayout()
    }
})

function handleLandscapeLayout() {
    // 横屏时的特殊处理
    console.log('切换到横屏布局')
}

function handlePortraitLayout() {
    // 竖屏时的特殊处理
    console.log('切换到竖屏布局')
}
</script>

<template>
    <div class="container" :class="{ landscape: isLandscape }">
        <div class="info">
            <p>当前方向: {{ orientation }}</p>
            <p>屏幕尺寸: {{ screenSize.width }} x {{ screenSize.height }}</p>
        </div>

        <!-- 根据方向显示不同布局 -->
        <div v-if="isLandscape" class="landscape-layout">横屏布局</div>
        <div v-else class="portrait-layout">竖屏布局</div>
    </div>
</template>

<style scoped>
.container {
    padding: 20px;
    transition: all 0.3s;
}

.landscape-layout {
    display: flex;
    flex-direction: row;
}

.portrait-layout {
    display: flex;
    flex-direction: column;
}

/* 横屏时的特殊样式 */
.container.landscape {
    padding: 10px 20px;
}

.container.landscape .info {
    font-size: 14px;
}
</style>

真实项目经验

经验一:PostCSS 自动转换配置

实际项目中,手动计算vw/rem很麻烦,使用PostCSS插件可以自动转换。

javascript
// postcss.config.js
module.exports = {
    plugins: {
        // px转vw
        'postcss-px-to-viewport': {
            viewportWidth: 750, // 设计稿宽度
            viewportHeight: 1334, // 设计稿高度(可选)
            unitPrecision: 5, // 转换后保留的小数位数
            viewportUnit: 'vw', // 转换的单位
            selectorBlackList: ['.ignore', '.hairline'], // 不转换的类名
            minPixelValue: 1, // 小于1px的不转换
            mediaQuery: false, // 媒体查询中的px是否转换
            exclude: [/node_modules/], // 排除node_modules
        },

        // 自动添加浏览器前缀
        autoprefixer: {
            overrideBrowserslist: ['iOS >= 9', 'Android >= 4.4'],
        },
    },
}

经验二:多倍图自动加载

根据设备DPR自动加载对应倍数的图片:

vue
<script setup>
import { computed } from 'vue'

const props = defineProps({
    src: String,
    alt: String,
})

// 获取设备像素比
const dpr = window.devicePixelRatio || 1

// 生成多倍图URL
const imageSrc = computed(() => {
    if (!props.src) return ''

    // 提取文件名和扩展名
    const lastDot = props.src.lastIndexOf('.')
    const basename = props.src.substring(0, lastDot)
    const ext = props.src.substring(lastDot)

    // 根据DPR选择图片
    if (dpr >= 3) {
        return `${basename}@3x${ext}`
    } else if (dpr >= 2) {
        return `${basename}@2x${ext}`
    } else {
        return props.src
    }
})
</script>

<template>
    <img :src="imageSrc" :alt="alt" />
</template>

经验三:设备检测工具

javascript
// utils/device.js

// 设备类型
export function getDeviceType() {
    const ua = navigator.userAgent.toLowerCase()

    if (/ipad/.test(ua)) {
        return 'iPad'
    } else if (/iphone/.test(ua)) {
        return 'iPhone'
    } else if (/android/.test(ua)) {
        return 'Android'
    } else {
        return 'Unknown'
    }
}

// 是否为iOS
export function isIOS() {
    return /iphone|ipad|ipod/.test(navigator.userAgent.toLowerCase())
}

// 是否为Android
export function isAndroid() {
    return /android/.test(navigator.userAgent.toLowerCase())
}

// 获取iOS版本
export function getIOSVersion() {
    const match = navigator.userAgent.toLowerCase().match(/os ([\d_]+)/)
    if (match) {
        return match[1].replace(/_/g, '.')
    }
    return null
}

// 是否为微信浏览器
export function isWeChat() {
    return /micromessenger/.test(navigator.userAgent.toLowerCase())
}

// 设备像素比
export function getDevicePixelRatio() {
    return window.devicePixelRatio || 1
}

// 屏幕信息
export function getScreenInfo() {
    return {
        width: window.screen.width,
        height: window.screen.height,
        availWidth: window.screen.availWidth,
        availHeight: window.screen.availHeight,
        dpr: getDevicePixelRatio(),
    }
}

面试常见追问

Q: vw和rem的区别是什么,为什么要混合使用? A: vw是相对视口宽度,1vw等于视口宽度的1%。rem是相对根元素字体大小。纯vw的问题是所有东西包括字体都跟屏幕等比缩放,在大屏上字会特别大。纯rem需要JS动态计算根字体大小,有闪烁问题。我们混合用,布局用vw,字体用rem,这样布局响应式,字体可控,两全其美。

Q: 1px边框用transform缩放会影响性能吗? A: transform不会触发重排,只会触发重绘甚至只是合成,性能影响很小。而且伪元素是独立图层,不影响主元素。我测试过,一个页面几十个1px边框,滚动帧率还是60fps。如果真担心性能,可以只对关键元素用1px方案,其他地方用普通border。

Q: 安全区域的env()函数兼容性怎么样? A: iOS 11+和Chrome 69+开始支持,覆盖了绝大部分用户。不支持的设备会返回0px,不影响正常显示,只是没有额外padding而已。要注意提供fallback值,写成padding-top: max(20px, env(safe-area-inset-top)),这样非刘海屏也有基础的20px padding。

Q: 横竖屏切换时如何保持状态不丢失? A: 用Pinia或localStorage存储关键状态。横竖屏切换本质上只是视图重新布局,数据层不会变。我会在watch中监听orientation变化,只调整布局不重置数据。如果有表单输入,用v-model双向绑定到状态,切换后自动恢复。动画要注意加transition,避免生硬跳变。