返回笔记首页

6.3 移动端交互优化 - 流畅的触控体验

主题配置

简历描述模板

版本一:注重用户体验

plain
负责移动端H5交互体验优化,封装手势识别库支持滑动、缩放、旋转等10+种手势,响应速
度<16ms达到原生App水平。实现下拉刷新和上拉加载组件,支持自定义动画和多种加载状
态,日活用户使用率达85%。解决虚拟键盘弹起导致的页面布局问题,适配iOS和Android
不同表现,输入体验流畅无卡顿。处理滚动穿透问题,弹窗打开时禁止背景滚动且不影响
弹窗内滚动。优化点击事件,消除300ms延迟,配合触觉反馈提升交互响应感知。

版本二:强调技术深度

plain
主导移动端手势交互系统设计,基于Touch Events API封装统一手势识别引擎。通过计算
触点位置、速度、方向等参数识别复杂手势,支持单指/多指操作。针对iOS和Android的
touch事件差异做了兼容处理,确保跨平台体验一致。实现自定义下拉刷新组件,使用
transform硬件加速保证60fps流畅动画,支持触发阈值、回弹效果、加载状态等配置。深
入研究iOS WKWebView和Android WebView的键盘行为,封装虚拟键盘管理工具处理焦点、
滚动、布局等10+个场景。解决了fixed定位、滚动穿透等移动端经典问题。

版本三:突出工程化能力

plain
构建移动端交互组件库,提供开箱即用的手势、下拉刷新、虚拟键盘等解决方案。设计声
明式API,通过指令和组件两种方式使用,接入成本低。建立性能监控机制,追踪交互响应
时间、卡顿率等指标,确保流畅体验。针对不同机型优化性能,低端机降级使用简单动画,
高端机启用复杂特效。编写了30+个单元测试覆盖各种边界场景,确保组件稳定性。组件库
已应用于公司10+个项目,累计服务500万+用户,用户反馈好评率92%。

SOP 标准回答

Q1: 手势识别库是怎么实现的?

标准回答

plain
手势识别主要是监听touchstart、touchmove、touchend三个事件,通过计算触点的位置、
距离、速度等参数来判断手势类型。

我们支持的手势包括:tap(点击)、press(长按)、swipe(滑动)、pan(拖拽)、
pinch(缩放)、rotate(旋转)这几种。实现原理各不相同:

tap是最简单的,touchstart到touchend时间小于200ms,移动距离小于10px就是tap。但
要注意跟press区分,长按超过500ms才算press。

swipe滑动需要计算速度,touchmove时记录每次的位置和时间,touchend时用最后两个点
计算速度,速度超过阈值就是swipe。还要判断方向,通过起点和终点的x、y坐标差来确定
是左右滑还是上下滑。

pan拖拽比较特殊,要实时跟踪手指位置,touchmove时不断触发事件,把位置信息传给外
部。我们用throttle做了节流,避免事件触发太频繁。

pinch缩放和rotate旋转要用到多点触控,至少要两根手指。touchstart时记录两个触点
的初始距离和角度,touchmove时计算当前距离和角度的变化,距离变化就是缩放,角度变
化就是旋转。还要处理惯性效果,手指离开后根据速度继续滚动或缩放一段距离。

有个坑是iOS和Android的touch事件表现不一致。iOS的touchend之后立即触发click,
Android有300ms延迟。我们用了FastClick库解决延迟问题,或者直接在touchend里阻止
默认行为,不触发click事件。

还有就是性能优化。touchmove事件触发非常频繁,如果处理不当会导致卡顿。我们用了
passive: true告诉浏览器不会阻止默认行为,浏览器可以优化滚动性能。复杂计算用
requestAnimationFrame包裹,保证不掉帧。

这个手势库封装好后,业务开发只需要传入回调函数,不用关心底层的touch事件处理,开
发效率提升很多。

Q2: 下拉刷新是怎么实现的?

标准回答

plain
下拉刷新的实现思路是:监听滚动容器的touchstart、touchmove、touchend事件,根据
下拉距离显示不同状态,触发阈值后执行刷新回调。

具体流程是这样的:

1. touchstart时记录起始位置,判断是否在顶部。如果scrollTop不是0,说明不在顶部,
直接return不处理。

2. touchmove时计算下拉距离,这个距离不是直接用手指移动的距离,而是做了阻尼效果。
比如手指下拉100px,实际只移动50px,给用户一种"拉着橡皮筋"的感觉。阻尼公式是:
实际距离 = 下拉距离 / (1 + 下拉距离 / 100)。

3. 根据下拉距离设置不同状态:
   - 距离 < 60px:提示"下拉刷新"
   - 距离 >= 60px:提示"释放刷新"
   - 触发刷新:提示"加载中..."

4. touchend时判断,如果距离超过阈值(比如60px),触发刷新回调,显示loading状态。
否则回弹到初始位置。

5. 刷新完成后,通过组件暴露的finish方法关闭loading,回弹动画。

有几个技术细节:

一是要用transform做位移,不能用top。transform硬件加速,性能更好,能保持60fps。

二是要禁止系统默认的下拉刷新。iOS有个橡皮筋效果,Android有个波纹效果,这些会和
我们自己的下拉刷新冲突。在touchmove里调用event.preventDefault()可以禁止。但要注
意,禁止了默认行为后,弹窗内的滚动也会失效,需要特殊处理。

三是回弹动画要流畅。我们用CSS transition做回弹,cubic-bezier曲线选的是(0.25,
0.46, 0.45, 0.94),看起来比较自然。回弹时间300ms左右比较合适。

四是loading状态的动画。我们用的是SVG做的转圈动画,CSS animation控制旋转,帧率稳
定,不掉帧。

五是要处理边界情况。比如刷新还没完成,用户又下拉,这时应该忽略。比如网络很慢,
一直loading,这时要有个超时机制,10秒后自动关闭。

这个组件做好后,产品经理非常喜欢,因为可以自定义loading动画,放品牌logo,看起来
很有品牌感。用户反馈也不错,交互体验跟原生App差不多。

Q3: 虚拟键盘弹起时页面布局错乱怎么办?

标准回答

plain
虚拟键盘问题确实是移动端的大坑,iOS和Android表现还不一样,踩了好多坑才搞定。

主要有三个问题:

第一个是fixed定位失效。iOS的WKWebView键盘弹起时,fixed元素会跟着页面一起移动,
底部的按钮会被键盘挡住。Android没这个问题,fixed还是固定的。我的解决方案是监听
输入框的focus事件,键盘弹起时把fixed改成absolute,键盘收起时再改回fixed。

具体实现是用一个全局的focusManager,监听所有input和textarea的focus和blur事件。
focus时记录当前焦点元素的位置,把fixed元素改成absolute,并且滚动页面让输入框不
被键盘遮挡。blur时恢复fixed定位,滚回原来的位置。

第二个是页面高度变化。Android键盘弹起时,viewport的高度会变小,页面会自动向上滚。
但iOS不会,viewport高度不变,只是内容被键盘遮住了。这导致一些基于视口高度的布局
会出问题。我的做法是监听resize事件,键盘弹起时记录高度变化,调整页面布局。但
resize事件在iOS上不太可靠,有时候不触发,我改用visualViewport API,更准确。

第三个是输入框被遮挡。有些表单页面,输入框很多,靠下的输入框focus时会被键盘完全
遮住,用户看不到自己在输入什么。我的解决方案是focus时用scrollIntoView把输入框滚
到可视区域。但要注意scrollIntoView的参数,behavior设成smooth有个平滑滚动效果,
体验更好。block设成center让输入框居中显示。

还有一些小坑:

- iOS键盘收起有延迟,blur事件触发了但键盘还没收,这时页面布局已经恢复了,会闪一
下。我加了个200ms的延迟,等键盘完全收起再恢复布局。

- Android某些输入法会遮住输入框的光标,用户看不到光标在哪。这个没太好的办法,只
能提示用户换个输入法。

- 表单提交时如果键盘还开着,提交按钮可能被遮住。我在表单提交前先blur所有输入框,
强制收起键盘。

整体方案就是这样,核心是监听focus/blur事件,动态调整布局。虽然代码不多,但要考虑
的细节很多,不同机型、不同输入法表现都不一样,需要大量真机测试。

难点与亮点分析

难点一:手势识别引擎

问题背景: 移动端需要支持多种复杂手势(滑动、缩放、旋转、长按等),原生touch事件API较底层,需要封装统一的手势识别引擎。

解决思路

  1. 建立触点追踪系统,记录每个触点的轨迹
  2. 根据触点数量、移动方向、速度等参数识别手势
  3. 处理手势冲突,按优先级分发事件
  4. 支持手势组合和自定义手势

技术实现

javascript
// composables/useGesture.js
import { onMounted, onUnmounted } from 'vue'

export function useGesture(elementRef, options = {}) {
    const {
        onTap = null,
        onPress = null,
        onSwipe = null,
        onPan = null,
        onPinch = null,
        onRotate = null,
        pressTime = 500,
        swipeThreshold = 50,
        swipeVelocity = 0.3,
    } = options

    // 触点信息
    let touches = []
    let startTime = 0
    let startPos = { x: 0, y: 0 }
    let currentPos = { x: 0, y: 0 }
    let pressTimer = null
    let isPressed = false
    let isPanning = false

    // 获取触点位置
    function getTouchPos(touch) {
        return {
            x: touch.clientX,
            y: touch.clientY,
            id: touch.identifier,
        }
    }

    // 计算两点距离
    function getDistance(p1, p2) {
        const dx = p1.x - p2.x
        const dy = p1.y - p2.y
        return Math.sqrt(dx * dx + dy * dy)
    }

    // 计算两点角度
    function getAngle(p1, p2) {
        return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI
    }

    // 计算滑动方向
    function getDirection(startPos, endPos) {
        const dx = endPos.x - startPos.x
        const dy = endPos.y - startPos.y
        const angle = (Math.atan2(dy, dx) * 180) / Math.PI

        if (angle >= -45 && angle < 45) return 'right'
        if (angle >= 45 && angle < 135) return 'down'
        if (angle >= 135 || angle < -135) return 'left'
        return 'up'
    }

    // 处理touchstart
    function handleTouchStart(e) {
        const touch = e.touches[0]
        startTime = Date.now()
        startPos = getTouchPos(touch)
        currentPos = { ...startPos }
        touches = Array.from(e.touches).map(getTouchPos)
        isPressed = false
        isPanning = false

        // 设置长按定时器
        if (onPress) {
            pressTimer = setTimeout(() => {
                isPressed = true
                onPress({
                    type: 'press',
                    position: currentPos,
                })
            }, pressTime)
        }

        // 多点触控
        if (touches.length === 2 && (onPinch || onRotate)) {
            const distance = getDistance(touches[0], touches[1])
            const angle = getAngle(touches[0], touches[1])

            touches.startDistance = distance
            touches.startAngle = angle
        }
    }

    // 处理touchmove
    function handleTouchMove(e) {
        const touch = e.touches[0]
        currentPos = getTouchPos(touch)
        touches = Array.from(e.touches).map(getTouchPos)

        // 移动超过10px取消长按
        const moveDistance = getDistance(startPos, currentPos)
        if (moveDistance > 10 && pressTimer) {
            clearTimeout(pressTimer)
            pressTimer = null
        }

        // 单指拖拽
        if (touches.length === 1 && onPan && !isPressed) {
            if (!isPanning && moveDistance > 10) {
                isPanning = true
            }

            if (isPanning) {
                const deltaX = currentPos.x - startPos.x
                const deltaY = currentPos.y - startPos.y

                onPan({
                    type: 'pan',
                    deltaX,
                    deltaY,
                    position: currentPos,
                })
            }
        }

        // 双指缩放/旋转
        if (touches.length === 2) {
            const currentDistance = getDistance(touches[0], touches[1])
            const currentAngle = getAngle(touches[0], touches[1])

            if (onPinch && touches.startDistance) {
                const scale = currentDistance / touches.startDistance
                onPinch({
                    type: 'pinch',
                    scale,
                    center: {
                        x: (touches[0].x + touches[1].x) / 2,
                        y: (touches[0].y + touches[1].y) / 2,
                    },
                })
            }

            if (onRotate && touches.startAngle !== undefined) {
                const rotation = currentAngle - touches.startAngle
                onRotate({
                    type: 'rotate',
                    rotation,
                    center: {
                        x: (touches[0].x + touches[1].x) / 2,
                        y: (touches[0].y + touches[1].y) / 2,
                    },
                })
            }
        }
    }

    // 处理touchend
    function handleTouchEnd(e) {
        if (pressTimer) {
            clearTimeout(pressTimer)
            pressTimer = null
        }

        const endTime = Date.now()
        const duration = endTime - startTime
        const distance = getDistance(startPos, currentPos)

        // Tap点击
        if (
            !isPressed &&
            !isPanning &&
            duration < 200 &&
            distance < 10 &&
            onTap
        ) {
            onTap({
                type: 'tap',
                position: currentPos,
            })
        }

        // Swipe滑动
        if (!isPressed && distance > swipeThreshold && onSwipe) {
            const velocity = distance / duration

            if (velocity > swipeVelocity) {
                const direction = getDirection(startPos, currentPos)
                onSwipe({
                    type: 'swipe',
                    direction,
                    distance,
                    velocity,
                    duration,
                })
            }
        }

        // 重置状态
        touches = []
        isPressed = false
        isPanning = false
    }

    // 绑定事件
    onMounted(() => {
        const element = elementRef.value
        if (!element) return

        element.addEventListener('touchstart', handleTouchStart, {
            passive: false,
        })
        element.addEventListener('touchmove', handleTouchMove, {
            passive: false,
        })
        element.addEventListener('touchend', handleTouchEnd, { passive: false })
        element.addEventListener('touchcancel', handleTouchEnd, {
            passive: false,
        })
    })

    // 解绑事件
    onUnmounted(() => {
        const element = elementRef.value
        if (!element) return

        element.removeEventListener('touchstart', handleTouchStart)
        element.removeEventListener('touchmove', handleTouchMove)
        element.removeEventListener('touchend', handleTouchEnd)
        element.removeEventListener('touchcancel', handleTouchEnd)

        if (pressTimer) {
            clearTimeout(pressTimer)
        }
    })

    return {
        // 可以暴露一些方法给外部调用
    }
}

使用示例

vue
<script setup>
import { ref } from 'vue'
import { useGesture } from '@/composables/useGesture'

const boxRef = ref(null)
const position = ref({ x: 0, y: 0 })
const scale = ref(1)
const rotation = ref(0)

useGesture(boxRef, {
    onTap: (e) => {
        console.log('点击:', e.position)
    },

    onPress: (e) => {
        console.log('长按:', e.position)
    },

    onSwipe: (e) => {
        console.log('滑动:', e.direction, e.velocity)
    },

    onPan: (e) => {
        position.value.x += e.deltaX
        position.value.y += e.deltaY
    },

    onPinch: (e) => {
        scale.value *= e.scale
    },

    onRotate: (e) => {
        rotation.value += e.rotation
    },
})
</script>

<template>
    <div
        ref="boxRef"
        class="gesture-box"
        :style="{
            transform: `translate(${position.x}px, ${position.y}px) scale(${scale}) rotate(${rotation}deg)`,
        }"
    >
        手势测试区域
    </div>
</template>

难点二:下拉刷新组件

完整实现

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

const props = defineProps({
    // 触发刷新的距离阈值
    threshold: {
        type: Number,
        default: 60,
    },
    // 最大下拉距离
    maxDistance: {
        type: Number,
        default: 150,
    },
    // 阻尼系数
    damping: {
        type: Number,
        default: 2,
    },
    // 是否禁用
    disabled: {
        type: Boolean,
        default: false,
    },
})

const emit = defineEmits(['refresh'])

// refs
const wrapperRef = ref(null)
const contentRef = ref(null)

// 状态
const status = ref('normal') // normal/pulling/enough/refreshing
const pullDistance = ref(0)
const touching = ref(false)

// 触点信息
let startY = 0
let currentY = 0
let scrollTop = 0

// 状态文本
const statusText = computed(() => {
    const texts = {
        normal: '下拉即可刷新...',
        pulling: '下拉即可刷新...',
        enough: '释放即可刷新...',
        refreshing: '加载中...',
    }
    return texts[status.value]
})

// 计算实际移动距离(应用阻尼)
function getActualDistance(distance) {
    if (distance < 0) return 0
    if (distance > props.maxDistance) return props.maxDistance

    // 阻尼公式
    return distance / (1 + distance / (props.maxDistance * props.damping))
}

// 检查是否可以下拉
function checkCanPull() {
    if (props.disabled || status.value === 'refreshing') {
        return false
    }

    // 必须在顶部
    const wrapper = wrapperRef.value
    scrollTop = wrapper ? wrapper.scrollTop : 0

    return scrollTop === 0
}

// touchstart
function handleTouchStart(e) {
    if (!checkCanPull()) return

    startY = e.touches[0].clientY
    currentY = startY
    touching.value = true
}

// touchmove
function handleTouchMove(e) {
    if (!touching.value || !checkCanPull()) return

    currentY = e.touches[0].clientY
    const distance = currentY - startY

    // 只处理下拉
    if (distance < 0) return

    // 阻止默认滚动
    e.preventDefault()

    // 计算实际移动距离
    pullDistance.value = getActualDistance(distance)

    // 更新状态
    if (pullDistance.value >= props.threshold && status.value !== 'enough') {
        status.value = 'enough'
    } else if (
        pullDistance.value < props.threshold &&
        status.value !== 'pulling'
    ) {
        status.value = 'pulling'
    }
}

// touchend
function handleTouchEnd() {
    if (!touching.value) return

    touching.value = false

    // 触发刷新
    if (status.value === 'enough') {
        status.value = 'refreshing'
        pullDistance.value = props.threshold

        emit('refresh', finish)
    } else {
        // 回弹
        resetPull()
    }
}

// 重置下拉
function resetPull() {
    pullDistance.value = 0
    status.value = 'normal'
}

// 完成刷新
function finish() {
    setTimeout(() => {
        resetPull()
    }, 300)
}

// 绑定事件
onMounted(() => {
    const wrapper = wrapperRef.value
    if (!wrapper) return

    wrapper.addEventListener('touchstart', handleTouchStart, { passive: false })
    wrapper.addEventListener('touchmove', handleTouchMove, { passive: false })
    wrapper.addEventListener('touchend', handleTouchEnd, { passive: false })
})

onUnmounted(() => {
    const wrapper = wrapperRef.value
    if (!wrapper) return

    wrapper.removeEventListener('touchstart', handleTouchStart)
    wrapper.removeEventListener('touchmove', handleTouchMove)
    wrapper.removeEventListener('touchend', handleTouchEnd)
})

defineExpose({
    finish,
})
</script>

<template>
    <div ref="wrapperRef" class="pull-refresh">
        <!-- 下拉指示器 -->
        <div
            class="pull-refresh-indicator"
            :style="{
                height: pullDistance + 'px',
                opacity: pullDistance > 0 ? 1 : 0,
            }"
        >
            <div class="indicator-content">
                <div v-if="status === 'refreshing'" class="loading-spinner" />
                <svg
                    v-else
                    class="arrow-icon"
                    :class="{ rotate: status === 'enough' }"
                    viewBox="0 0 24 24"
                >
                    <path d="M7 10l5 5 5-5z" fill="currentColor" />
                </svg>
                <span class="status-text">{{ statusText }}</span>
            </div>
        </div>

        <!-- 内容区域 -->
        <div
            ref="contentRef"
            class="pull-refresh-content"
            :style="{
                transform: `translateY(${pullDistance}px)`,
                transition: touching
                    ? 'none'
                    : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
            }"
        >
            <slot />
        </div>
    </div>
</template>

<style scoped>
.pull-refresh {
    height: 100%;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
}

.pull-refresh-indicator {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    overflow: hidden;
    transition: opacity 0.3s;
}

.indicator-content {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 16px;
    color: #666;
    font-size: 14px;
}

.arrow-icon {
    width: 20px;
    height: 20px;
    transition: transform 0.3s;
}

.arrow-icon.rotate {
    transform: rotate(180deg);
}

.loading-spinner {
    width: 20px;
    height: 20px;
    border: 2px solid #e0e0e0;
    border-top-color: #1890ff;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

@keyframes spin {
    to {
        transform: rotate(360deg);
    }
}

.pull-refresh-content {
    min-height: 100%;
}
</style>

难点三:虚拟键盘管理器

实现方案

javascript
// utils/keyboard-manager.js

class KeyboardManager {
    constructor() {
        this.focusedElement = null
        this.originalFixedElements = []
        this.keyboardHeight = 0
        this.isKeyboardShow = false

        this.init()
    }

    init() {
        // 监听输入框焦点
        document.addEventListener('focusin', this.handleFocus.bind(this), true)
        document.addEventListener('focusout', this.handleBlur.bind(this), true)

        // 监听视口变化(检测键盘)
        if (window.visualViewport) {
            window.visualViewport.addEventListener(
                'resize',
                this.handleViewportResize.bind(this)
            )
        } else {
            window.addEventListener('resize', this.handleResize.bind(this))
        }
    }

    handleFocus(e) {
        const target = e.target

        // 只处理输入类元素
        if (!this.isInputElement(target)) return

        this.focusedElement = target

        // 延迟执行,等待键盘弹起
        setTimeout(() => {
            this.onKeyboardShow()
            this.scrollToElement(target)
        }, 300)
    }

    handleBlur(e) {
        if (!this.isInputElement(e.target)) return

        this.focusedElement = null

        // 延迟执行,等待键盘收起
        setTimeout(() => {
            this.onKeyboardHide()
        }, 200)
    }

    handleViewportResize() {
        const viewport = window.visualViewport
        const windowHeight = window.innerHeight
        const viewportHeight = viewport.height

        // 键盘高度
        this.keyboardHeight = windowHeight - viewportHeight

        if (this.keyboardHeight > 100) {
            this.isKeyboardShow = true
            this.onKeyboardShow()
        } else {
            this.isKeyboardShow = false
            this.onKeyboardHide()
        }
    }

    handleResize() {
        // 兼容旧浏览器的方案
        const currentHeight = window.innerHeight
        if (!this.originalHeight) {
            this.originalHeight = currentHeight
        }

        const diff = this.originalHeight - currentHeight

        if (diff > 100) {
            this.keyboardHeight = diff
            this.isKeyboardShow = true
            this.onKeyboardShow()
        } else {
            this.keyboardHeight = 0
            this.isKeyboardShow = false
            this.onKeyboardHide()
        }
    }

    onKeyboardShow() {
        // 处理fixed元素
        this.handleFixedElements('keyboard-show')

        // 触发自定义事件
        document.dispatchEvent(
            new CustomEvent('keyboardshow', {
                detail: { height: this.keyboardHeight },
            })
        )
    }

    onKeyboardHide() {
        // 恢复fixed元素
        this.handleFixedElements('keyboard-hide')

        // 触发自定义事件
        document.dispatchEvent(new CustomEvent('keyboardhide'))
    }

    handleFixedElements(action) {
        // 查找所有fixed元素
        const fixedElements = document.querySelectorAll(
            '[data-fixed-on-keyboard]'
        )

        if (action === 'keyboard-show') {
            this.originalFixedElements = []

            fixedElements.forEach((el) => {
                const style = window.getComputedStyle(el)

                this.originalFixedElements.push({
                    element: el,
                    position: style.position,
                    top: style.top,
                    bottom: style.bottom,
                })

                // 改为absolute定位
                el.style.position = 'absolute'
            })
        } else {
            // 恢复fixed定位
            this.originalFixedElements.forEach(
                ({ element, position, top, bottom }) => {
                    element.style.position = position
                    element.style.top = top
                    element.style.bottom = bottom
                }
            )

            this.originalFixedElements = []
        }
    }

    scrollToElement(element) {
        if (!element) return

        // 计算元素位置
        const rect = element.getBoundingClientRect()
        const viewportHeight = window.visualViewport
            ? window.visualViewport.height
            : window.innerHeight

        // 如果元素被键盘遮挡
        if (rect.bottom > viewportHeight - 20) {
            element.scrollIntoView({
                behavior: 'smooth',
                block: 'center',
            })
        }
    }

    isInputElement(element) {
        return (
            element.tagName === 'INPUT' ||
            element.tagName === 'TEXTAREA' ||
            element.contentEditable === 'true'
        )
    }

    // 强制隐藏键盘
    hideKeyboard() {
        if (this.focusedElement) {
            this.focusedElement.blur()
        }
    }
}

export default new KeyboardManager()

在组件中使用

vue
<script setup>
import { onMounted, onUnmounted } from 'vue'
import keyboardManager from '@/utils/keyboard-manager'

function handleKeyboardShow(e) {
    console.log('键盘弹起,高度:', e.detail.height)
    // 调整布局
}

function handleKeyboardHide() {
    console.log('键盘收起')
    // 恢复布局
}

onMounted(() => {
    document.addEventListener('keyboardshow', handleKeyboardShow)
    document.addEventListener('keyboardhide', handleKeyboardHide)
})

onUnmounted(() => {
    document.removeEventListener('keyboardshow', handleKeyboardShow)
    document.removeEventListener('keyboardhide', handleKeyboardHide)
})
</script>

<template>
    <div class="page">
        <div class="content">
            <input type="text" placeholder="输入内容" />
        </div>

        <!-- fixed元素添加data-fixed-on-keyboard属性 -->
        <div class="bottom-bar" data-fixed-on-keyboard>
            <button>提交</button>
        </div>
    </div>
</template>

面试常见追问

Q: 300ms点击延迟是怎么回事,如何解决? A: 这是移动浏览器为了判断双击缩放而设置的延迟。用户点击后,浏览器等300ms看是否有第二次点击,没有才触发click事件。解决方案有三种:一是用touch事件代替click,touchend比click快300ms。二是用FastClick库,原理是在touchend里阻止默认行为并手动触发click。三是设置viewport的user-scalable=no禁用缩放,浏览器会取消延迟。我们用的第三种,简单且兼容性好。

Q: 滚动穿透问题怎么解决? A: 滚动穿透就是弹窗打开时,滑动弹窗内容会带动背景页面一起滚动。解决方案是弹窗打开时给body设置position: fixed和top值,关闭时恢复。但要注意保存和恢复scrollTop,不然页面会跳到顶部。我封装了个useScrollLock的hook,自动处理这些细节,弹窗打开调用lock(),关闭调用unlock()就行。

Q: 手势识别库的性能怎么优化? A: 主要两点。一是事件节流,touchmove触发特别频繁,我用requestAnimationFrame做节流,保证16ms只执行一次。二是计算优化,复杂的几何计算比如角度、距离,缓存中间结果避免重复计算。还有就是事件监听加passive: true,告诉浏览器不会阻止默认行为,浏览器可以优化滚动性能。这些优化后,即使快速滑动也能保持60fps。

Q: 下拉刷新的阻尼效果是怎么实现的? A: 阻尼就是模拟橡皮筋的效果,拉得越长越费力。我用的公式是:实际距离 = 下拉距离 / (1 + 下拉距离 / 最大距离)。这是个递减函数,开始拉的时候很轻松,越往下越难拉。还可以调整阻尼系数,系数越大越难拉。这个公式看起来简单,但效果很自然,跟原生App的手感很像。回弹动画用的CSS transition,cubic-bezier曲线是(0.25, 0.46, 0.45, 0.94),有个小幅回弹效果。