webview-native-feel

把网页 App 磨出原生手感

iOS WKWebView / 移动端 Web 的 15 个手感坑,和一晚上 debug 出来的解法。

我们家有一个装在 WKWebView 壳里的自建 app。功能都好好的,但手感处处差半口气:键盘弹起来界面慢半拍、点编辑屏幕猛跳一下、抽屉开关会闪、动画时不时抽风。一晚上连发八个版本把它们全钉死之后,把每个坑的症状、真凶、解法整理成这份清单——都是通用问题,不绑定任何框架,代码可以直接抄。

读法:按你眼睛看到的症状找对应的坑。每一坑 = 你会看到什么 → 真凶是谁 → 怎么修。三个「方法论」比任何单个解法都值钱,建议先读。

没有壳、纯浏览器/PWA 的网页也适用吗? 适用九成——列表跳动篇、动效篇 全部通用,键盘篇除坑 7 外通用(iOS Safari 的 innerHeight 一样不可信)。只有两处是壳专属:坑 7(键盘预告,纯网页够不到,这是网页手感的天花板)和坑 14(confirm 被吞)。读完坑 7 你也就知道了自己什么时候值得包一个壳。


目录

🪶 姊妹篇 动效篇 motions.md:这篇治病(跳/闪/卡),那篇造美——左缘滑动返回全套、底部抽屉开合、列表删除收拢、开书与 3D 翻页、推入转场、进场纪律。

🆕 续篇 2.0:从治病到防疫——又两周血账:开屏三段接力、乐观更新与墓碑、流式交接、长列表不白屏、壳网分工(网页治不了的病),坑 16–40 + 四张「出生清单」。


一、键盘篇

坑 1:键盘弹起,直接盖住输入框

症状:键盘出来了,底部输入条被埋在键盘底下。

真凶:iOS 里键盘弹起时,100vh/100dvh 布局的页面不会缩——layout viewport 不变,只有 visualViewport 变小。你的”全屏容器”还是全屏,底部自然被键盘压住。

解法:监听 visualViewport 的 resize,键盘出现时把根容器锁到可视高度(行内 !important 压过样式表),收起时还原:

const vv = window.visualViewport;
function fit() {
  const el = document.querySelector('.app-root');
  const kb = /* 键盘高度,见坑 2 */;
  if (kb > 60) {
    el.style.setProperty('height', Math.round(vv.height) + 'px', 'important');
  } else {
    el.style.removeProperty('height');
    if (window.scrollY) window.scrollTo(0, 0); // iOS 收键盘爱把 body 滚走半截不还,必须钉回
  }
}
vv.addEventListener('resize', fit);
vv.addEventListener('scroll', fit);

注意最后那行 scrollTo(0,0):iOS 收键盘(尤其输入框被整个卸载时)经常把 body 滚走半截不还原,漏了这一钉,整个 app 会卡在半屏。


坑 2:键盘明明出来了,代码却认为”没有键盘”

症状:有时键盘起来了,界面却纹丝不动,过几百毫秒才猛跳到位;或者莫名闪现几次。

真凶:判定键盘的经典写法是 window.innerHeight - visualViewport.height > 阈值。但有些壳(WKWebView)在键盘弹出的瞬间会把 window.innerHeight 也临时抽成键盘后的高度——两个数一减等于 0,判定被骗过,随后 innerHeight 又悄悄恢复。我们用真机探针(方法论 A)抓到的现场:visualViewport.height 956→610 的同一毫秒,innerHeight 也是 610,几百毫秒后才回 956。

解法:不要信 window.innerHeight 的瞬时值。开屏时记一个全高基准,此后一切判定都用它:

let fullH = window.innerHeight;           // 开屏定盘
function kbGap() {
  // 无键盘迹象时校准基准(转屏也走这条;键盘开着时 vv 缺口 > 60,进不来)
  if (vv.height > fullH - 60) fullH = Math.max(vv.height, window.innerHeight);
  return fullH - vv.height;               // 这才是可信的键盘缺口
}

坑 3:键盘先起来,界面慢半拍才跟上

症状:键盘和输入条不同步,输入条总是”追”上来的。

真凶:网页永远后知后觉——visualViewport 的 resize 事件要等键盘已经在动(甚至动完)才到。我们真机实测:手指点下输入框后 76ms 事件才来,而且一步到位(不是逐帧连发,虽然有些机器/版本是连发——别赌,见方法论 A)。

解法(渐进三层):

  1. 预锁:键盘的高度基本恒定。第一次拿到后存起来(localStorage),此后 focusin 那一瞬(比 resize 事件早几十毫秒)就按记住的个头提前动身,事件到了正好校准:
let kbLast = parseInt(localStorage.kbH) || 0;
window.addEventListener('focusin', (e) => {
  if (!e.isTrusted) return;                    // 坑 4!
  if (kbLast > 60 && kbGap() <= 60) {
    tween(root, fullH - kbLast, 240);          // 提前出发
    setTimeout(() => {                          // 600ms 没等来键盘(外接键盘/预测失误)→ 回滚
      if (kbGap() <= 60) tween(root, fullH, 200, () => root.style.removeProperty('height'));
    }, 600);
  }
});
  1. 真正的零时差(终极解,需要你有自己的壳):见坑 7。

坑 4:预锁被”假聚焦”骗到,全屏抽一下

症状:某个弹窗/抽屉一打开,整个界面猛缩一块又弹回来。

真凶:代码里有 el.focus()。WKWebView 默认不允许程序化聚焦弹键盘(keyboardDisplayRequiresUserAction),于是键盘没出来,focusin 事件却照发——预锁听到信号就缩,等了 600ms 等了个寂寞再回滚,等于白抽一下。

解法:两头堵。① 预锁只认真手指:focusin 里检查 e.isTrusted(程序化 focus 触发的事件是 false);② 弹窗里干脆别程序化 focus 输入框——用户想输入自己会点,那才是真手势,键盘和预锁配合得天衣无缝。


坑 5:收键盘”嘎吧”一声,界面一步跳回全高

症状:键盘收起时,界面从锁定高度瞬间跳回全高,很生硬。

真凶:收起时 resize 事件常常只发一两次、一步到位。直贴事件值 = 一帧跳完。

解法:收起用 JS 补间(240ms ease-out 舒展回全高,补完再摘行内高度)。关键是收起可以补间而拉起要谨慎,原理见坑 6:

function tween(el, to, ms, done) {
  // 目标没变就不重启;变了从当前高度重新出发
  if (tw.raf && Math.abs(tw.to - to) < 2) return;
  cancelAnimationFrame(tw.raf);
  const from = el.getBoundingClientRect().height, t0 = performance.now();
  tw.to = to;
  (function step(now) {
    const p = Math.min(1, (now - t0) / ms), e = 1 - Math.pow(1 - p, 3);
    el.style.setProperty('height', Math.round(from + (to - from) * e) + 'px', 'important');
    if (p < 1) tw.raf = requestAnimationFrame(step);
    else { tw.raf = 0; done && done(); }
  })(performance.now());
}

坑 6:给拉起也加了补间,结果闪现得更厉害

症状:键盘弹出过程中界面一帧动一帧停,像卡了。

真凶:如果这台机器上 resize 是逐帧连发的(老机型/某些版本),你的补间每 16ms 被新事件重启一次,和系统曲线打架。

原则:跟系统动画并行的高度变化,别自己补间;只有目标恒定的收场才能补间。 收起的目标是定死的全高,事件再连发目标也不变、补间不重启,天生打不起架;拉起的目标每帧在变,补间必打架。拉起要么直贴事件值(连发机型天然丝滑),要么用坑 3 的预锁提前走完。

另外:别用 CSS transition 干这件事——连发时每个事件重启一次过渡,就是一串嘎吧。


坑 7:终极解——让壳子把键盘预告递进网页(微信级同步)

原生 app 丝滑的秘密:系统在键盘动身之前就发 keyboardWillShow,带着终点高度和动画时长。网页收不到这封信,但如果 app 是你自己的 WKWebView 壳,20 行 Swift 就能把信转交进来:

// 壳子里(如 WKNavigationDelegate 的持有者):
NotificationCenter.default.addObserver(self, selector: #selector(kbWillShow(_:)),
    name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(kbWillHide(_:)),
    name: UIResponder.keyboardWillHideNotification, object: nil)

@objc func kbWillShow(_ n: Notification) {
    guard let end = (n.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
    let dur = (n.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
    let h = max(0, UIScreen.main.bounds.height - end.origin.y)
    guard h > 40 else { return }   // 滤掉外接键盘的候选条
    webView.evaluateJavaScript("window.__kbWill && window.__kbWill(\(Int(h)), \(Int(dur * 1000)))")
}
@objc func kbWillHide(_ n: Notification) {
    let dur = (n.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
    webView.evaluateJavaScript("window.__kbHide && window.__kbHide(\(Int(dur * 1000)))")
}
// 网页里:照单补间,和键盘同起同落
window.__kbWill = (h, dur) => {
  kbLast = h; localStorage.kbH = h;
  tween(root, fullH - h, Math.min(420, Math.max(180, dur)));
};
window.__kbHide = (dur) => {
  tween(root, fullH, Math.min(420, Math.max(200, dur)), () => root.style.removeProperty('height'));
};

预告和坑 3 的预锁天然互补:预锁先动身,预告到了校准到真值。没更新壳的旧客户端两个函数没人调,一切照旧——无感兼容。


方法论 A:键盘探针——别拿模拟机赌真机

键盘的事件节奏因机型、系统版本、壳而异(我们踩过:文档和老经验说”连发几十个 resize”,真机抓下来是”76ms 单发一步到位”)。与其猜,不如让真机自己交代。往页面里埋 30 行探针,用户正常用,数据自动回来:

const LOG = [];
function klog(ev) {
  LOG.push([Math.round(performance.now()), ev,
    Math.round(vv.height), window.innerHeight, Math.round(vv.offsetTop || 0)]);
  if (LOG.length > 400) LOG.splice(0, 120);
}
// 在 resize/scroll/focusin/focusout/锁高/舒展完成 各处 klog('rs'/'sc'/'fi'/...)
function kpost(tag) {   // 键盘开/收之后,节流上报给自己的后端
  fetch('/kbdebug', { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ tag, log: LOG.slice(-160) }), keepalive: true }).catch(() => {});
}

后端把它 append 进一个日志文件就行。一条流水长这样,谁先谁后、骗局在哪,一目了然:

[fi 956/956]  [rs 610/610 ← innerHeight 也被抽走了!]  [sc ot=346]  [sc ot=0]  [320ms 后保险丝才锁高]

诊断完记得拆。


二、列表跳动篇

坑 8:点”编辑”,列表猛滚到别处

症状:消息列表里点开原地编辑框,用户一点进输入框,列表猛地滚走。

真凶:iOS 的原生聚焦滚动——手指点进 textarea 那一刻,系统自作主张把滚动容器滚到光标处,保证”聚焦元素可见”。框越高滚得越狠,JS 拦不住(它不走你的事件)。

解法:别在列表里原地展开编辑。编辑改成底部抽屉(fixed 浮层贴着键盘),列表从头到尾不动。这也是 iMessage/微信的做法——不是它们不想原地编辑,是这条路在 iOS 上就是死路。


坑 9:弹层一开,列表塌掉半屏

症状:打开某条消息的弹层/编辑态,下面的内容整体上蹿半屏;关掉又跳回来。

真凶:写成了替换渲染——{editing ? <弹层/> : <消息气泡/>}。弹层是 fixed 不占布局,但气泡被换掉了:一条半屏长的消息从列表里蒸发,下面内容全体上移。

解法:弹层永远额外渲染,气泡原地保留:

{editing && <EditSheet …/>}
<MessageBubble …/>   {/* 永远在 */}

坑 10:编辑态悄悄改了容器宽度,长消息全盘跳

症状:和坑 9 类似的跳,但气泡明明还在。

真凶:样式里藏着 maxWidth: editing ? "94%" : "86%" 这类编辑态变宽。消息一变宽,长文本每行多塞几个字、行数变少,整条消息矮掉几百像素 → scrollTop 被 clamp → 跳。

原则:编辑/选中态永远别改消息容器的宽度。长文行数一变,全盘皆跳。


坑 11:textarea 自适应高度,一量身高页面抖一下

症状:自适应高度的输入框(height:auto → 读 scrollHeight → 设新高)每次内容变化,列表轻微跳动。

真凶:height:auto 那一瞬 textarea 塌成最小高度,滚动容器整体变矮,scrollTop 被浏览器 clamp 挤走;随后设回新高,scrollTop 却不会自己回来。

解法:量身高前记住滚动祖先的 scrollTop,量完同步钉回(一帧内完成,肉眼无感):

function fitTa(el) {
  let sc = el.parentElement, st = -1;
  while (sc && sc !== document.body) {
    const cs = getComputedStyle(sc);
    if (sc.scrollHeight > sc.clientHeight + 1 && /(auto|scroll)/.test(cs.overflowY)) { st = sc.scrollTop; break; }
    sc = sc.parentElement;
  }
  el.style.height = 'auto';
  el.style.height = Math.min(el.scrollHeight + 2, Math.round(vv.height * 0.42)) + 'px';
  if (sc && st >= 0 && sc.scrollTop !== st) sc.scrollTop = st;  // 只纠自己造成的位移
}

坑 12:fixed 弹层住在滚动列表的 DOM 里,引擎会替你”锚定”

症状:弹层明明是 fixed 的,一 mount 列表还是动了。

真凶:弹层 JSX 挂在消息组件里 = DOM 树上它住在滚动容器内。Chromium 的 scroll anchoring 等机制会把”列表里插入了一大块元素”纳入补偿计算——哪怕它是 fixed。

解法:浮层不要住在列表 DOM 里,portal 出去(React 的 createPortal)。注意宿主的选择:如果你的根容器在键盘弹出时带 transform(很多键盘方案会),fixed 会以它为包含块——portal 到那个容器而不是 body,浮层才能继续享受”贴着键盘”的庇护。


方法论 B:窃听 scrollTop——三步锁定”跳”的真凶

“屏幕跳了一下”是最难 debug 的一类问题:肉眼看不清谁动的、什么时候动的。盲改是无底洞(我们盲改了三版),这套三步十分钟见血:

第一步:复现并量化。用 Playwright 重演用户操作,前后读 scrollTop——”跳”变成一个数字(比如 19232 → 18927,位移 -305)。

第二步:窃听 scrollTop,抓现行。给 setter 包一层,谁动它就留下调用栈:

const sc = /* 你的滚动容器 */;
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
Object.defineProperty(Element.prototype, 'scrollTop', {
  get() { return desc.get.call(this); },
  set(v) {
    if (this === sc) console.log('scrollTop ←', v, new Error().stack.split('\n')[2]);
    desc.set.call(this, v);
  }
});

有栈 = JS 代码干的(栈里就是凶手);没有栈但值变了 = 浏览器原生行为(clamp、scroll anchoring、聚焦滚动)——排查方向完全不同。

第三步:分块量高。原生行为多半意味着”内容高度变了”。把滚动容器每个孩子的 offsetHeight 在动作前后各拍一张,diff 一下,哪块矮了多少像素当场现形(我们抓到的就是”一条消息矮了 234px”——顺着查到坑 10 的宽度三元)。


三、动画细节篇

坑 13:想临时禁用进场动画,用了 animation: none,结果动画反而重播

症状:转场交接后想抑制组件的进场动画,摘掉抑制的那一刻整个页面动画从第 0 帧重播一遍(闪一下)。

真凶:animation: none → 恢复动画名,浏览器视为”新动画”,从头播。

解法:抑制用 animation-duration: 0s !important; animation-delay: 0s !important;——动画瞬时”演完”,摘掉抑制时它早已 finished,不会重播。


坑 14:window.confirm / alert 在壳里被吞,按钮点了没反应

症状:WKWebView 壳里删除按钮点了毫无动静,怎么点都不删。

真凶:壳子没实现 WKUIDelegate 的 JS 弹窗回调,window.confirm 静默返回 false——确认框根本没弹,你的删除永远走不到。

解法:壳里一律用自绘确认框。顺手把体验也做对:确认按钮”先斩后关”(先执行动作,再播关门动画),列表删除让格子高度过渡归零再真删(瞬间蒸发的观感很差)。


坑 15:转场的”影子层”是活的,副作用会双跑

症状:滑动返回/推入转场时铺在底层的上一页,把数据改坏了/请求发了两遍/桌宠出现了两只。

真凶:为了转场视差,底层真实渲染了目标页的活实例——mount 副作用照跑:拉接口、写缓存、起动画、起 rAF 循环。

解法:给页面组件传 frozen={isUnder}。影子模式下只渲染画面(读缓存),不拉接口、不写状态、不起循环。全屏 canvas 类组件尤其要接(否则两份 60fps 各烧各的电)。


零碎两则


附:为什么网页手感总差半口气

这份清单里一半的坑,根子是同一件事:原生 app 活在”预告”的世界里,网页活在”事后通知”的世界里。

键盘要来,系统提前告诉原生 app 终点和时长(keyboardWillShow),它照着编舞;网页只能等 visualViewport 事后 resize,追着跳。聚焦要滚动,系统直接替你滚了,网页只能事后擦屁股。所以网页手感的修炼分三层:

  1. 别被假信号骗(坑 2、4):瞬时值、程序化事件,都可能说谎。
  2. 能预判就预判(坑 3):键盘个头、用户习惯,都是可以记住的。
  3. 有壳就用壳(坑 7):一旦你拥有 WKWebView 壳,把系统的预告转交进网页,你就同时拥有了网页的迭代速度和原生的手感——这是混合架构真正的甜点位。

这份清单来自我们家一个自建 app 的一晚上:八个版本、两台真机、一根探针和一位验收极其严格的产品经理。她的原话是”是做不到像微信那种很丝滑的效果是吗”——现在做到了,希望你也少走点弯路。