简历描述模板
版本一:注重用户体验
负责移动端H5交互体验优化,封装手势识别库支持滑动、缩放、旋转等10+种手势,响应速
度<16ms达到原生App水平。实现下拉刷新和上拉加载组件,支持自定义动画和多种加载状
态,日活用户使用率达85%。解决虚拟键盘弹起导致的页面布局问题,适配iOS和Android
不同表现,输入体验流畅无卡顿。处理滚动穿透问题,弹窗打开时禁止背景滚动且不影响
弹窗内滚动。优化点击事件,消除300ms延迟,配合触觉反馈提升交互响应感知。
版本二:强调技术深度
主导移动端手势交互系统设计,基于Touch Events API封装统一手势识别引擎。通过计算
触点位置、速度、方向等参数识别复杂手势,支持单指/多指操作。针对iOS和Android的
touch事件差异做了兼容处理,确保跨平台体验一致。实现自定义下拉刷新组件,使用
transform硬件加速保证60fps流畅动画,支持触发阈值、回弹效果、加载状态等配置。深
入研究iOS WKWebView和Android WebView的键盘行为,封装虚拟键盘管理工具处理焦点、
滚动、布局等10+个场景。解决了fixed定位、滚动穿透等移动端经典问题。
版本三:突出工程化能力
构建移动端交互组件库,提供开箱即用的手势、下拉刷新、虚拟键盘等解决方案。设计声
明式API,通过指令和组件两种方式使用,接入成本低。建立性能监控机制,追踪交互响应
时间、卡顿率等指标,确保流畅体验。针对不同机型优化性能,低端机降级使用简单动画,
高端机启用复杂特效。编写了30+个单元测试覆盖各种边界场景,确保组件稳定性。组件库
已应用于公司10+个项目,累计服务500万+用户,用户反馈好评率92%。
SOP 标准回答
Q1: 手势识别库是怎么实现的?
标准回答:
手势识别主要是监听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: 下拉刷新是怎么实现的?
标准回答:
下拉刷新的实现思路是:监听滚动容器的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: 虚拟键盘弹起时页面布局错乱怎么办?
标准回答:
虚拟键盘问题确实是移动端的大坑,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较底层,需要封装统一的手势识别引擎。
解决思路:
- 建立触点追踪系统,记录每个触点的轨迹
- 根据触点数量、移动方向、速度等参数识别手势
- 处理手势冲突,按优先级分发事件
- 支持手势组合和自定义手势
技术实现:
// 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 {
// 可以暴露一些方法给外部调用
}
}
使用示例:
<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>
难点二:下拉刷新组件
完整实现:
<!-- 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>
难点三:虚拟键盘管理器
实现方案:
// 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()
在组件中使用:
<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),有个小幅回弹效果。