上一篇 15 个坑发布之后,我们又打了两周。这一篇:五个新战场,和一套「新组件出生就带手感」的防疫体系。
1.0 是一晚上的血:键盘、列表跳动、动画细节。发布之后我们以为太平了——然后接下来两周,开屏、乐观更新、流式交接、长列表、壳网边界,一个战场一个战场地轮着开。1.0 教你「踩了坑怎么爬出来」;这一篇想多做一步:开头立一个物理总纲,让你判断一个方案会不会嘎吧;结尾给四张出生清单,让新组件生下来就免疫。中间照旧是坑:症状 → 真凶 → 解法,代码可抄,不绑框架。
读法:〇和七、八是体系,一到六是坑。赶时间就先读〇,然后按症状跳。
没有壳、纯浏览器 / PWA 的网页也适用吗? 八成五适用。乐观更新篇、流式交接篇、长列表篇、补针集、验收方法论全部通用——它们住在前端层,跟有没有壳无关;〇的世界观本来就是浏览器的物理。壳专属的只有四个坑:16–18(原生覆盖层,纯网页没有这一层)和 31(KVO 钉零——纯网页治不了聚焦滚动,请退回 1.0 坑 8 的「编辑改抽屉」回避法)。别被「开屏篇」的名字劝退:坑 19–20(动画彩排的懒结算、opacity:.02 蘸墨)是纯 CSS 动画的坑,任何带开场动画的网页都会撞上;坑 32–33 是 WebKit 的脾气,iOS Safari 里一模一样,解法也是纯 JS。
移动端浏览器里有两个世界,你的每一行动画代码都住在其中一个里:
transform、opacity。图层已经画好,动的只是贴放的位置和透明度——键盘动画期间、快速滚动中、主线程卡成狗时,它照样满帧。height、margin、top、scrollTop……每改一帧,整个列表重排一次。更狠的是:iOS 在键盘动画期间会节流 WKWebView 的 rAF——你逐帧改布局属性的 JS 动画,在键盘窗口内必然变成幻灯片。由此得出四条家法,本文所有解法都是它们的推论:
transform,显隐一律 opacity。用户对 app 的第一口印象在开屏。原生 app 的开屏是系统画的,稳如老狗;网页壳的开屏是一场三方交接——iOS 的 LaunchScreen、壳的原生覆盖层、网页本体——每一次交接都是一次露馅的机会。我们为这个开屏连修了十四针,架构和坑都在下面。
先上架构。目标是「点图标 → 静图 → 静图原地活过来 → 主界面」,全程无缝:
系统 LaunchScreen ──→ 壳的原生覆盖层 ──0.3s溶──→ 网页静态垫层 ──0.55s溶──→ 开屏动画 ──→ 主界面
(iOS 自己画) (UIKit view) [交接①] (index.html) [交接②]
三段用同一张图:LaunchScreen 的 asset、壳覆盖层、网页垫层,放的是同一个 jpg 文件本尊——不是「三张一样的图」,是同一张。交接①由网页主动喊(垫层就位后 postMessage 通知壳溶出覆盖层),交接②在网页内部(垫层溶出、动画解冻)。
症状:覆盖层设计了 0.3s 淡出,真机上「啪」一下消失,还时不时多盖半秒、吞掉网页动画的前半段。
真凶:SwiftUI 的声明式动画(transition、withAnimation、.animation(value:)——三种写法全试过)在「覆盖在 WKWebView 上的 view」这个场景下不可靠:动画不播、时机漂移。
解法:覆盖层退回 UIKit 命令式。UIView 挂在 WebView 上、AutoLayout 四边钉死,溶出用最古典的写法——它在哪代 iOS 上都严格听话:
UIView.animate(withDuration: 0.3, animations: { cover.alpha = 0 }) { _ in
cover.removeFromSuperview()
}
顺手一条:摘幕的入口做成幂等函数(网页通知、超时兜底、前后台切换……几条路都可能触发),谁先到谁摘,摘过就空转。
症状:撤幕瞬间界面「滑下又滑上」一小截。
真凶:带固有尺寸的 Image 作为 ZStack 的同级 child,把 ZStack 连同 WebView 一起撑到比屏幕高 14px;撤幕时 frame 从 970 回 956,这 14px 的布局动画就是那一滑。
解法:任何带固有尺寸的东西不进根布局当同级 child——开屏图要么 .resizable().ignoresSafeArea() 明确铺满,要么走 UIKit 挂到 WebView 身上四边钉死(同坑 16)。
症状:网页刚接手,整页突然向下顶出约一个状态栏的高度,再弹回。
真凶:iOS 在启动布局稳定时给 WKWebView.scrollView 自动调 contentInset(safe area 适配的「好意」)。
解法:一行封死,网页的 safe area 自己用 CSS env() 处理:
webView.scrollView.contentInsetAdjustmentBehavior = .never
症状:开屏动画是一套 CSS animation 序列,为了付清首绘账做了「彩排」(把所有动画 delay 拨到终幕强制画一轮,再拨回第 0 帧开演)——结果真机上时不时「只演了后一半」。
真凶:新版 WebKit 对「改 delay 再改回来」懒结算:不逼它结账,部分动画的时钟停在半路,解冻那刻就从半截开演。
解法:在彩排前、拨回后、解冻时各强制结算一次——getAnimations() 会迫使引擎当场把动画状态算清:
function settle() { document.getAnimations().forEach(a => void a.currentTime); }
彩排本身值得学:开屏动画元素多的话,第一次真画会卡在起笔那一下。先 delay: -30s 拨到终幕画一轮(付清图层建立的账),两个 rAF 后拨回 0 再开演,起笔就不卡了。
症状:动画序列里靠后出场的元素(opacity: 0 等着轮到自己),每次演到它都顿一下。
真凶:iOS 会回收 opacity:0 图层的位图。出场时重建图层、重画位图,约 100ms 的「开层费」正好卡在动画中间。
解法:隐身改用 opacity: .02 常驻——肉眼看不见,图层保活,出场零开层费。新加隐身元素时记得 keyframes 的 from 和行内初值都用 .02。
开屏篇附则:三段同源的图,任何「重导出/换压缩」都会让交接露馅(png 和 jpg 的压缩差异肉眼可见)。以及一条验收心法:「探针没流水」不等于「病好了」——先确认探针的触发条件还活着。我们踩过:修好一个病顺手把另一根探针的触发条件也修没了,空日志被当成了痊愈证明。
远端在公网另一头,一来一回几百毫秒到几秒。手感的答案只有一个:界面先动,网络后跟。但乐观更新做一半比不做更糟——下面四个坑全是「做一半」的报应。
症状:用户存了一张卡,列表里出现一个转圈的灰影;或者卡片是出来了,但「更多」按钮点不了——「等保存完」。
真凶:把「服务器确认」当成了 UI 的前置条件。
解法:乐观三件套——
tmp- 前缀 id,立刻插进列表,进场动画照播。tmpId 字段;一切界面判定(编辑态、选中态)认双名——否则「正在编辑的卡换了身份」那一刻,编辑框会弹走、半打的字会丢。且换身是 remount,不许再播进场动画(无戏才无感)。症状:点删除 → 格子播完收拢动画 → 请求失败 → 卡片带着收拢到一半的残样式赖在原地;或者当场没事,下次整表刷新它又回来了。
真凶:「先播动画等结果」的半吊子乐观:视觉上删了、数据上没删,两边状态漂移。
解法:删除也全乐观:动画播完立刻从本地数据里真删,请求后台发;失败了把卡片放回来 + toast 一句。数据和视觉永远一致,失败是显式的「回来了」,不是薛定谔的残影。
症状:用户刚存了卡,几秒后它闪没了,再刷新又有了。
真凶:进屋时发的 GET 在慢网络上姗姗来迟,带回的是用户存卡之前的旧快照——整表替换,把新卡盖没了。
解法:整表替换前护住两类卡:tmp 卡、以及最近 15 秒内新生的卡(拿本地时间戳判)。旧快照可以刷新旧卡,不许抹新卡。
症状:支持「重发/重写/删除」的消息列表,用户裁掉的旧消息,过几天全回来了——新旧两条并排躺着。
真凶:同步协议是「按 id 并集对账」(本地和服务器合并,谁也不丢)——这是护数据的好设计,但它意味着「本地删掉」在协议眼里等于「本地还没拿到」,下次对账如数补发。
解法:凡从本地列表裁掉条目——删除、截断、重写、任何理由——必须给被裁的 id 立墓碑(一张持久化的 tombstone 名单),对账时墓碑上的 id 永不复活。裁而不碑,就是在给未来的自己埋诈尸。
本章附则:三层仓(内存 → IndexedDB → 网络)。列表数据的读取顺序应当是:内存缓存先端上(0ms)、IndexedDB 的旧账垫底(冷启动)、网络刷新最后到。三条纪律:① 网络失败绝不清仓——宁端旧账,不端空屏;② 冷启动时 IndexedDB 预载和组件 mount 是竞速关系,mount 摸空要挂「仓好了叫我」的回调补摸一次,否则杀进程秒开的用户会看到一闪而过的空列表;③ 开屏预热各个列表时错峰(比如晚 1.5s),别和首屏关键请求挤一条公网管道。
流式回复是 AI app 的标配:逐字上屏,收尾时把「活的流式气泡」换成「落库的历史消息」。这个交接 remount 是手感重灾区——屏上明明早就有的内容,闪一下、跳一下、从第 0 帧重新冒出来。三条法,我们翻了三回车才凑齐。
症状:流式打完最后一个字,气泡闪一下从头播进场动画;更隐蔽的变体:气泡没闪,但气泡里面的组件(思维链胶囊、搜索卡片、图片)各自从第 0 帧冒出来。
真凶:交接 remount。历史消息的进场动画、子组件的 mount 动画,在「流式变历史」的重挂载时全部重播。
解法:法条:屏上早有的内容,不许再从第 0 帧冒。落库的消息带 settled: true,入场动画只给真正的新消息;且气泡里每一个带 mount 动画的子组件都要挂「活流中才播」的条件——这条法我们在气泡上翻过一次车,两个月后在胶囊上又翻了一次。同一个雷,别踩第三回。
症状:按坑 25 全静了,但收尾那刻才定稿的东西——流式期没有的 token 统计行、收尾时换字的标题——直接硬蹦出来。
真凶:法条只管住了「没变的」。收尾时刻变化或新到的信息,既不该重播整条动画,也不该零帧硬蹦。
解法:给「刚定稿」的信息单独柔入一次。判定用时间窗,别开长账本:
const fresh = msg.settled && Date.now() - msg.at < 4000; // 刚收尾的才演,翻旧消息恒静
但真机上还有半坑:交接 remount 的那一帧排版极重,mount 即挂动画 = 0.32s 的柔入在卡顿里默默播完,画面稳定时动画已结束、终态拍脸——挂了类也白挂,桌面永远测不出。解法是等画稳再起播:
function FreshIn({ children }) {
const ref = useRef(null);
useEffect(() => {
requestAnimationFrame(() => requestAnimationFrame(() =>
ref.current && ref.current.classList.add('go'))); // 双 rAF:熬过交接帧
}, []);
return <span className="fresh-in" ref={ref}>{children}</span>;
}
// .fresh-in { opacity: 0 } .fresh-in.go { animation: fadeIn .32s ease forwards }
验收也要跟上:CDP 把 CPU 节流到 6x(Emulation.setCPUThrottlingRate),逐帧采 opacity,采到透明的中间帧才算真渐变。
症状:搜索框敲一个字,列表「哗」地换成新批次——复用的卡瞬移聚拢,观感稀碎。
先把三条错路钉死(都是我们一天里摔完的):① 给新卡挂柔入——首批结果全是复用卡,一帧轮不到动画;② 整区先蒙纱再显示——用户骂「闪一下还不如不做」;③ 拦住受控输入框的 onChange 做门控——用户敲的字会被 React 打回旧值当场消失(详见坑 34)。
正解:View Transition。WebKit/Chromium 原生的新旧帧交叉淡化,合成器级、零空白帧:
let busy = false, queue = [];
function vt(mutate, after) {
if (!document.startViewTransition) { mutate(); after && after(); return; }
if (busy) { queue.push([mutate, after]); return; } // 忙时排队,场毕合并成一场
busy = true;
const t = document.startViewTransition(() => ReactDOM.flushSync(mutate));
t.ready.then(() => after && after()); // after 在新几何上跑:回顶、clamp
t.finished.finally(() => {
busy = false;
if (queue.length) { const q = queue.splice(0);
vt(() => q.forEach(([m]) => m()), () => q.forEach(([, a]) => a && a())); }
});
}
三个雷区,挂之前读一遍:
view-transition-name)——快照会脱离滚动裁剪,滑动时从页头、底栏上「飘」过去。列表长到几百张图文卡片时,敌人从「跳」变成「白」和「崩」。四条防线:
症状:往下滑,新内容白屏几百毫秒才浮现。
真凶:所有卡片全量排版、全量绘制,iOS 的 GPU 预算撑不住。
解法:content-visibility: auto 让离屏卡片免排版免绘制,配 contain-intrinsic-size 占位防滚动条抽风:
.card { content-visibility: auto; contain-intrinsic-size: auto 96px; }
但有一个一票否决的陷阱:CV 列表的成员不许集体挂 CSS 动画。动画会击穿 content-visibility 的「离屏免画」(引擎认为动画中的元素必须活着),几百张卡同时活过来 = 比不加 CV 更白。进场动画只给首屏第一批,或者干脆交给 VT(坑 27)。
症状:列表深处出现整块空白/错位,怎么滚都不画。
真凶:iOS 对单个合成层的纹理尺寸有上限(16384px)。几百张卡包在一个 div 里,这个 div 一旦因为任何原因提层(动画、transform、backdrop-filter……),超限部分直接不画。
解法:月份分组、章节分组这类「大容器」不包 div,用 Fragment 平铺——让卡片直接做滚动容器的孩子,谁也长不到 16384px。
::before 花纹背景,滚动掉帧症状:卡片有装饰性伪元素背景时,长列表滚动明显变肉。
真凶:几百个伪元素各自参与绘制,加起来就是每帧的账。
解法:装饰背景上移——在列表根部放一个绝对定位的装饰层(一个元素画所有花纹),卡片本身保持素净。100 个 ::before 和 1 个装饰层,是两个数量级的绘制成本。
本章附则:真机 localStorage 满仓时静默吞写(不抛错、不生效)。长列表爱往 localStorage 塞缓存的,写完读一遍验一下,或改用 IndexedDB。
1.0 的坑 7 说过:壳能把「键盘预告」递进网页。这一章是它的续篇,也是更硬的结论:有些病,网页层永远治不了,必须下到原生层动手。判断标准就一条——如果肇事者是 UIKit 在事件循环外直接改了原生视图的状态,你的 JS 连事件都收不到,「摁回去」永远慢一帧。
症状:点输入框,整页向上蹿一截。JS 里 scrollTo(0,0) 摁回,用户还是看到闪一下。
真凶:这次滚的不是网页里的滚动容器,是 WKWebView 自带的原生 scrollView(网页整页是它的内容)。iOS 的聚焦滚动直接改它的 contentOffset——不走网页事件,网页事后才知道,摁回永远慢一帧。这一帧就是闪。
解法:到壳里用 KVO 盯死。前提是你的布局本来就该整页不滚(滚动全在内部容器)——那 WebView 的 scrollView 就该恒零,谁动钉谁,同一个 runloop 里生效,一帧都不漏:
scrollPin = webView.scrollView.observe(\.contentOffset, options: [.new]) { sv, _ in
if sv.contentOffset.y != 0 { sv.setContentOffset(.zero, animated: false) }
}
JS 层别再修了——两边一起修,等于两只手抢一个方向盘。
env(safe-area-inset-top) = 0,刘海/灵动岛压住页头症状:偶发(尤其冷启动):页头顶到屏幕最上沿,被灵动岛压住;热启动又正常。
真凶:WKWebView 冷启动的首帧,env(safe-area-inset-top) 还没就位,读出来是 0。你按 0 排版,等它变成 59 时页面已经画完了。
解法:把首帧的 safe-area 当不可信输入:读到 < 20 一律按兜底值(44)避让,延时几百毫秒重测一次再用真值。绝不照 0 排版。
症状:键盘收起(滚动容器变高又变矮的复合场景)后,列表停在一个「不可能的位置」,一碰才跳回来。
真凶:scrollTop 超出新的最大值时,桌面浏览器当场 clamp,iOS 却惰性不管——悬空的 scrollTop 一直挂着,直到下次交互才猛然结算。桌面测不出这个病,因为桌面根本不允许这个状态存在。
解法:凡是你主动改变容器高度的场景,改完自己 clamp 一遍:
el.scrollTop = Math.min(el.scrollTop, el.scrollHeight - el.clientHeight);
再配一道「悬空哨」:关键路径(键盘收起后)检查一次 scrollTop > scrollHeight - clientHeight,命中就现场纠正 + 上报日志——它是你还有别的路径在漏的信号。
症状:为了压「敲一个字全列表重排」,在 onChange 里搞条件拦截(组字中不 setState)——中文输入法一组字,用户敲的字直接消失。
真凶:受控组件的铁律——DOM 值永远被打回 value prop。你拦了 setState,React 就把 DOM 打回旧值。而 iOS 输入法的 composition 事件时序根本赌不得。
解法:输入框永远裸受控、如实显示。要压重排去下游:显示值和过滤值两值分离,过滤值防抖落定(240ms),落定那一次才触发重列表(配坑 27 的 VT 就是完整方案):
const [q, setQ] = useState(''); // 显示值:永远即时
const [qLive, setQLive] = useState(''); // 过滤值:防抖后落定
const tRef = useRef(0);
function onChange(e) {
setQ(e.target.value);
clearTimeout(tRef.current);
tRef.current = setTimeout(() => setQLive(e.target.value), 240);
}
症状:编辑抽屉带着几百字的旧文打开,真机偶发一道白闪。桌面从未复现。
真凶:自适应量高的经典写法 height:auto → 读 scrollHeight → 设新高,在 mount 首测时让 textarea 先塌成最小高度再撑开。桌面把这两步合成一帧;真机分帧渲染,塌下去那帧真的画了。
解法:mount 首测不走 auto,直接读 scrollHeight 一步钉到位;只有后续编辑(高度可能收缩)才走 auto 两步法。量高前后照 1.0 坑 11 记滚动位。
症状:列表 → 详情两级都在同一「页面」内(子路由/内部状态),用户在详情页边缘一滑,退出的是整个页面。
真凶:滑返手势只认页面栈,不知道页面内部还有一层。
解法:给滑返留一个页内钩子:页面内部有可关闭层级时登记 window.__backHook = closeFn,滑返和返回键先问钩子,有就走钩子(关详情),没有才退整页。钩子记得在层级关闭时注销。
症状:滑返/推入时铺在底层的那页闪过一个「过时的画面」——用户明明已经切到 B 分页,影子铺的还是进门时的 A 分页。
真凶:转场影子是按导航栈里存的状态渲染的「同页面新实例」(1.0 坑 15 的活影子)。易变的界面状态(当前分页、展开态)如果存在导航栈里,就会随栈里的旧账一起被铺出来。
解法:导航栈里只放「在哪间屋」;屋内易变状态放模块级活账(一个普通对象),正身切换时随手记,影子 mount 时照账铺——影子永远和正身同步。
症状:列表滑到深处 → 进详情 → 返回,列表回到顶。
真凶:返回时列表组件重新 mount,scrollTop 归零。
解法:滚动位入活账:进子页时记 scrollTop,返回时(含转场影子铺底那一帧、交接完成那一帧,两处都要钉——转场系统可能重建两次 DOM)恢复。主滚动容器的判定用「声明了 overflowY 且滚动余量最大者」,页面里有第二个滚动区时别记错账。
症状:从流式气泡里打开的抽屉(坑 25 的交接场景),交接 remount 时遮罩重播进场、拉到全屏的抽屉跌回半屏、内容滚动跳回顶。
真凶:宿主组件换代,抽屉作为它的孩子被卸载重建,一切状态回出厂。
解法:开合状态走跨代接力旗(模块级对象,不随组件生死):旗上带全套姿势——开合态、尺寸态(半屏/全屏)、内容滚动位;重建侧发现旗是开的就直接以终态出生、进场动画全免。只接「开着」等于没接——姿势缺一样,用户就看到那一样跳。用户亲手关门时熄旗。
症状:列表删除的收拢动画(高度过渡到 0)播完,格子还剩十几像素,真删那刻它「嘎吧」消失。
真凶:height: 0 收不掉 border 和 flex gap——1px 边框 + 上下 gap,十几像素的尾巴。
解法:收拢动画的终点不是 height:0,是 display:none(布局彻底抽流)+ fixed 替身淡出:真身立即 display:none(列表一步到位收平,flex gap 随之消失),一个 fixed 定位的视觉替身在原位置播淡出/缩没。按〇的世界观:布局一步到位,视觉合成器代演。另外两条随手法:「A 收 B 长」(存卡 = 输入卡收、新卡长)要并行编排,串行 = 总高度缩了又弹;取消路径和确认路径同样要走收场动画——漏一条,用户就抓到一条。
1.0 的方法论教你「怎么抓凶手」;这一章是更早的一步:改动交付之前,自己先当一遍用户。我们的产品经理立过法:不许再拿她当试错的探针。于是有了这套 playwright 冒烟家法:
time.sleep。同步版 playwright 是单线程事件循环,handler 里一睡,整个循环冻住——后面的 wait、断言全被顶到 sleep 之后才执行,页面早跑完了你才开始看,全是假结果。模拟慢网用「悬空—放行」:handler 把 route 存进列表不 continue,主脚本自己等够了再逐个放行。setInterval 快照(浏览器进程不受脚本冻结影响),事后对账两边的时间线。以上四十个坑的最终形态,是四张 checklist。新组件出生时照单带齐,就不用等用户一样一样骂回来了。
添新页面:进出走统一的 push/back 通道(坑 36 钩子、坑 38 滚动账)□ 数据接三层仓,失败不清仓(二附则)□ 乐观更新的页面接 frozen(1.0 坑 15)□ 进场动画用 class 且登记进转场抑制名单(1.0 坑 13)□ 几百卡量级:CV + Fragment 平铺 + 单一装饰层(坑 28–30)
添新弹层:出生带开合动画,关门走统一函数 □ portal 到键盘庇护容器(1.0 坑 12)□ 对列表项额外渲染不替换(1.0 坑 9)□ 不程序化 focus(1.0 坑 4)□ 退场先 blur 等键盘收完(1.0 零碎)□ 宿主会换代的,接力旗带全套姿势(坑 39)
添新列表:删改走收拢/生长动画,A 收 B 长并行,取消路径同样有戏(坑 40)□ 乐观三件套 + 删除乐观(坑 21–22)□ 整表替换护新生卡(坑 23)□ 本地裁条必立墓碑(坑 24)□ 换批场景用 VT(坑 27)
添新输入框:自适应量高 mount 直钉(坑 35)□ 裸受控 + 两值分离防抖(坑 34)□ 旁边的按钮 onMouseDown preventDefault 防抢焦点 □ 随键盘走位的元素只许 translateY(〇)□ 键盘判定用全高基准(1.0 坑 2)
1.0 的结尾说,我们家有一位验收极其严格的产品经理。两周过去,她升职了——现在她立法。这一篇的每一节都对应她的一句原话:「嘎吧」「闪现」「一帧一帧的」「画画的过程只有一半」「和导航栏打架」「闪一下还不如嘎吧」。第七章那条法是她亲口定的:不许再拿她当试错的探针。所以这篇教程真正的主题其实是:让下一个组件、下一个页面、下一次深夜上线,生下来就配得上用它的人。