
目标:系统梳理移动端 H5 的常见兼容问题,针对 iOS Safari/WKWebView 与 Android Chrome/WebView 的差异给出工程化解决方案与代码片段,可直接落地使用。
这篇文章更像是我的移动端 H5 兼容“实战手记”。过去几年在支付、活动页、内容页等不同场景里,不同设备和宿主的坑基本都踩过一遍。为了便于查阅,我按“问题→原理→落地”组织内容,力求每个片段都能直接拷贝使用,不讲空话、只给能跑的方案。如果你也在做移动端 H5,希望它能帮你少走弯路。
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">.page {
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);
}
@supports (padding: constant(safe-area-inset-top)) {
.page {
padding-top: constant(safe-area-inset-top);
padding-bottom: constant(safe-area-inset-bottom);
padding-left: constant(safe-area-inset-left);
padding-right: constant(safe-area-inset-right);
}
}100vh包含浏览器工具栏,导致视图溢出;使用 dvh/svh/lvh 与降级:.full-height {
min-height: 100dvh;
}
@supports not (height: 100dvh) {
.full-height { min-height: 100vh; }
}function setVH() {
const h = window.innerHeight
document.documentElement.style.setProperty('--vh', `${h * 0.01}px`)
}
setVH(); window.addEventListener('resize', setVH)
// 使用:height: calc(var(--vh) * 100)font-size >= 16px:input, textarea { font-size: 16px; }function withKeyboardAware(footerEl) {
const onResize = () => {
const viewport = window.visualViewport
if (!viewport) return
const bottomInset = (window.innerHeight - viewport.height - viewport.offsetTop)
footerEl.style.transform = bottomInset > 0 ? `translateY(-${bottomInset}px)` : ''
}
window.visualViewport && window.visualViewport.addEventListener('resize', onResize)
}position: fixed 在输入聚焦时抖动,可在键盘期禁用 fixed,改用 transform 过渡。.scroll {
overflow: auto;
-webkit-overflow-scrolling: touch;
}html, body { overscroll-behavior: none; }
.modal { overscroll-behavior: contain; }window.addEventListener('touchmove', handler, { passive: true }).area { touch-action: pan-y; }viewport 或用 pointer-events 统一事件模型。button:active { opacity: .7 }<video src="x.mp4" muted playsinline webkit-playsinline autoplay></video>document.addEventListener('click', () => audio.play(), { once: true })playsinline 与 wx.ready 后触发。a[download] 支持有限,推荐 Blob 方案:async function download(url, name) {
const res = await fetch(url)
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = name
a.click()
URL.revokeObjectURL(a.href)
}Chrome/WebView 版本号。window.addEventListener('pageshow', e => {
if (e.persisted) {
// 恢复状态与重新拉取必要数据
}
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// 重新同步数据/刷新时钟
}
})input[type=date/time] 支持不一致,业务上使用统一选择组件。input[type=number] { -moz-appearance: textfield; }
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }.hairline {
position: relative;
}
.hairline::after {
content: '';
position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
background: #e5e5e5; transform: scaleY(0.5); transform-origin: bottom;
}position: fixed 元素放在开启 transform 的父级内,或将 fixed 元素提升到根节点(Portal)。position: sticky 在 overflow: hidden 容器内表现不稳定,谨慎使用或改为监听滚动。function lazyLoad(imgs) {
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) { const el = e.target; el.src = el.dataset.src; io.unobserve(el) } })
})
imgs.forEach(img => io.observe(img))
} else {
const onScroll = () => {
imgs.forEach(img => {
const rect = img.getBoundingClientRect()
if (rect.top < window.innerHeight && rect.bottom > 0) img.src = img.dataset.src
})
}
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
}
}preload/prefetch 与缓存策略,降低弱网抖动。const support = {
dvh: CSS.supports('height', '100dvh'),
passive: false,
}
try { window.addEventListener('test', () => {}, Object.defineProperty({}, 'passive', { get() { support.passive = true } })) } catch {}const ua = navigator.userAgent
const isIOS = /iP(hone|od|ad)/.test(ua)
const isAndroid = /Android/.test(ua)viewport-fit=cover + safe-area padding100dvh,旧端用 --vh 动态变量visualViewport 监听,移动底部栏或滚动到可视区overscroll-behavior 控制,必要时锁定背景滚动muted + playsinline + 用户手势a[download]const el = document.querySelector('#search')
let composing = false
el.addEventListener('compositionstart', () => composing = true)
el.addEventListener('compositionend', () => { composing = false; onChange(el.value) })
el.addEventListener('input', () => { if (!composing) onChange(el.value) })keydown 与 compositionend 两条路径:active 才生效document.addEventListener('touchstart', function(){}, { passive: true })pointer 事件避免点击与触摸分裂document.addEventListener('pointerup', function(e){ })html { -webkit-text-size-adjust: 100% }body { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility }.overlay { position: fixed; left: 0; top: 0; right: 0; bottom: 0 }visualViewport 动态位移<input type="file" accept="image/*" capture="camera"><video muted playsinline webkit-playsinline></video>const img = new Image()
img.crossOrigin = 'anonymous'
img.src = urlif ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js')window.addEventListener('orientationchange', function(){ })function postMsg(name, payload){
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers[name]) {
window.webkit.messageHandlers[name].postMessage(payload)
} else if (window.AndroidBridge && window.AndroidBridge[name]) {
window.AndroidBridge[name](JSON.stringify(payload))
}
}.card { will-change: transform, opacity }transform 与 opacityfunction ready(fn){ if (window.wx && wx.ready) wx.ready(fn); else document.addEventListener('WeixinJSBridgeReady', fn) }SameSite=None; Secure,并准备 Token 方案作为回退function persistState(key, value){ sessionStorage.setItem(key, value) }
function restoreState(key){ return sessionStorage.getItem(key) }async function share(data){ if (navigator.share) await navigator.share(data) }
async function copy(text){ await navigator.clipboard.writeText(text) }function lockScroll(){ document.body.style.overflow = 'hidden' }
function unlockScroll(){ document.body.style.overflow = '' }html { -webkit-text-size-adjust: 100%; box-sizing: border-box }
*, *::before, *::after { box-sizing: inherit }
body { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility }
* { -webkit-tap-highlight-color: rgba(0,0,0,0); -webkit-touch-callout: none }
::selection { background: rgba(0,0,0,.06) }async function fetchWithRetry(url, opt={}, times=2){
for(let i=0;i<=times;i++){
try{ const c = new AbortController(); const t = setTimeout(()=>c.abort(), 8000)
const res = await fetch(url, { ...opt, signal: c.signal }); clearTimeout(t); if(res.ok) return res
}catch(e){ if(i===times) throw e }
}
}function markStart(){ performance.mark('start') }
function markEnd(){ performance.mark('end'); performance.measure('boot', 'start', 'end') }image-set 提升清晰度.bg { background-image: image-set(url(a@1x.png) 1x, url(a@2x.png) 2x) }<input inputmode="numeric">
<input type="password" autocomplete="current-password">const isIOS = /iP(hone|od|ad)/.test(navigator.userAgent)
const isAndroid = /Android/.test(navigator.userAgent)
function onVisible(fn){ document.visibilityState === 'visible' ? fn() : document.addEventListener('visibilitychange', ()=>{ if(document.visibilityState==='visible') fn() }, { once: true }) }
function nextFrame(fn){ requestAnimationFrame(()=>requestAnimationFrame(fn)) }function enabled(flag){ return localStorage.getItem(flag)==='1' }移动端兼容不是一次性的“专项战役”,更像是与设备、宿主和网络条件的长期拉扯。我的做法是先保证核心链路可用(首屏、登录、支付、上传等),再在不影响交付节奏的前提下逐步把体验补齐。