简历描述模板
版本一:注重技术广度
负责移动端H5项目的多屏幕适配方案设计,覆盖iPhone 5至iPhone 14 Pro Max等30+主
流机型。采用vw+rem混合方案实现响应式布局,配合PostCSS插件自动转换单位,开发效
率提升40%。解决了1px边框在高清屏显示过粗的问题,使用scale+伪元素方案实现真实
物理像素渲染。针对刘海屏、水滴屏等异形屏适配了安全区域,使用env()函数动态获取
安全距离。处理了横竖屏切换场景,监听orientationchange事件重新计算布局。
版本二:强调工程化实践
主导移动端适配方案的技术选型与落地,构建基于vw的弹性布局体系。开发了自动化适配
工具链,集成px2vw、viewport配置、设备检测于一体,设计稿还原度达99%。针对不同
DPR设备实现了多倍图自动加载策略,结合CDN的图片处理能力按需返回@2x/@3x资源。封
装了SafeArea组件处理异形屏适配,自动识别设备类型并注入对应的padding值。建立了
适配测试用例库,覆盖iOS/Android主流机型的自动化截图对比。
版本三:突出解决方案深度
设计企业级移动端适配解决方案,支持iPhone、Android、iPad等多终端。深入研究了
viewport机制,根据设计稿动态设置initial-scale确保1:1还原。针对1px边框问题对比
了5种方案,最终选择transform: scale结合伪元素方案,兼容性好且无副作用。处理了
iOS安全区域适配的细节问题,包括导航栏、底部TabBar、弹窗等20+场景。实现了横竖屏
无缝切换,保持状态不丢失,动画流畅无卡顿。总结并制定了移动端适配规范文档。
SOP 标准回答
Q1: 你们的移动端适配方案是怎么选择的?
标准回答:
我们经历过三个阶段,最后定下来用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边框问题是怎么解决的?
标准回答:
这个是移动端很经典的问题,在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: 刘海屏的安全区域是怎么适配的?
标准回答:
刘海屏适配主要是处理安全区域,避免内容被刘海、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 更大。如何在保证设计稿还原的同时,又能适配各种屏幕尺寸?
解决思路:
- 根据设计稿宽度动态设置viewport的scale
- 使用媒体查询设置断点,不同尺寸采用不同策略
- 关键尺寸使用vw相对单位,配合max/min限制
- 字体采用rem单位,相对根元素缩放
技术实现:
// 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中初始化:
// 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物理像素边框。
解决方案:
<!-- 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>
使用示例:
<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>
难点三:安全区域智能适配
问题背景: 异形屏(刘海屏、水滴屏、挖孔屏)的安全区域需要特殊处理,导航栏、底部栏等关键元素不能被遮挡。
解决方案:
<!-- 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>
全局样式配置:
/* 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));
}
实际使用示例:
<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>
亮点:横竖屏切换无缝处理
设计思路: 横竖屏切换时,需要重新计算布局,保持状态不丢失,动画流畅。
核心实现:
// 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,
}
}
在组件中使用:
<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插件可以自动转换。
// 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自动加载对应倍数的图片:
<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>
经验三:设备检测工具
// 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,避免生硬跳变。