修复 iPhone 13 iOS 16 Safari 中的 Bug

开始

某天,有个用户反馈到技术群,发了个视频说发现他的 wiki 站中的选项卡在 iOS 上出现了问题。

选项卡的正常状态

打开我的苹果手机一开,果然无法复现。在排除了各种干扰项、询问了具体环境之后,是时候打开 BrowserStack 了。稳定复现了,可喜可贺可喜可贺。

什么环境?不是写在标题内了吗?

切换选项卡后切换前的选项文字会消失

初步检查

先来大概看看代码,整体大概长这样,源代码比此更加复杂:

源代码: TemplateWidget

<head>
	<style>
		.d-tab .hidden {
			display: none;
		}
		.d-tab-titles .d-tab-delimiter:last-child {
			display: none;
		}
	</style>
</head>
<body>
	<div class="d-tab">
		<div class="d-tab-titles">
			<span class="d-tab-title active">乾</span>
			<span class="d-tab-title">坤</span>
		</div>
		<div class="d-tab-contents">
			<div class="tab-content active">乾为天:元亨,利贞。</div>
			<div class="tab-content hidden">
				坤为地:元亨,利牝马之贞。君子有攸往,先迷后得主,利西南得朋,东北丧朋。安贞,吉。
			</div>
		</div>
		<script>
			function dTab() {
				const singleSwitch = `<!--{$singleSwitch}-->` ?? false;
				//  console.log("singleSwitch=",singleSwitch);
				document
					.querySelectorAll(".d-tab-titles .d-tab-title")
					.forEach((tab, i) => {
						tab.addEventListener("click", () => {
							// 找到当前 tab 所在的标题列表
							const titleListContainer =
								tab.closest(".d-tab-titles");
							//console.log(titleListContainer)
							if (!titleListContainer) return;

							const titleList = Array.from(
								titleListContainer.querySelectorAll(
									".d-tab-title"
								)
							);

							// 找到对应的 .d-tab 容器和内容列表
							const tabsContainer =
								titleListContainer.closest(".d-tab");
							const tabContents = Array.from(
								tabsContainer.querySelectorAll(
									":scope > .d-tab-contents > .tab-content"
								)
							);

							// 如果只有一个 tab,直接切换 active 类
							if (titleList.length === 1 && singleSwitch) {
								if (
									titleList[0].classList.contains(
										"active"
									)
								) {
									titleList[0].classList.remove("active");
									tabContents[0].classList.remove(
										"active"
									);
									tabContents[0].classList.add("hidden");
								} else {
									titleList[0].classList.add("active");
									tabContents[0].classList.add("active");
									tabContents[0].classList.remove(
										"hidden"
									);
								}
								return;
							}
							// 移除其他 tab 的 active 类
							titleList.forEach(t =>
								t.classList.remove("active")
							);
							tab.classList.add("active");

							// 获取当前 tab 的索引
							const index = titleList.indexOf(tab);

							// 关闭所有 tab 内容
							tabContents.forEach(content =>
								content.classList.remove("active")
							);
							tabContents.forEach(content =>
								content.classList.add("hidden")
							);

							// 打开对应索引的 tab 内容
							if (tabContents[index]) {
								tabContents[index].classList.remove(
									"hidden"
								);
								tabContents[index].classList.add("active");
							}
						});
					});
			}

			if (document.readyState === "loading") {
				document.addEventListener("DOMContentLoaded", dTab);
			} else {
				dTab();
			}
		</script>
		<style>
			.d-tab > .d-tab-titles {
				background: #414858;
				display: flex;
				border-bottom: 2px solid #414858;
				color: white;
			}
			.d-tab > .d-tab-titles > .d-tab-title.active {
				color: #fff1c5;
				box-shadow: none;
			}
			.d-tab > .d-tab-titles > .d-tab-title {
				width: 25%;
				padding: 7px 0;
				font-size: 16px;
				position: relative;
				border-radius: 0;
				text-align: center;
			}
			.d-tab > .d-tab-titles > .d-tab-title.active:after {
				content: "";
				position: absolute;
				height: 2px;
				width: calc(100% - 10px);
				background: #fff1c5;
				left: 5px;
				bottom: 0;
			}
		</style>
	</div>
</body>

根据报告人说,在删除了其中这段代码后便能恢复正常:

.d-tab > .d-tab-titles > .d-tab-title.active:after {
	content: "";
	position: absolute;
	height: 2px;
	width: calc(100% - 10px);
	background: #fff1c5;
	left: 5px;
	bottom: 0;
}

这不合理,但确实恢复了……

先大概看一眼各个的计算样式,似乎也没啥问题。那先确认是渲染 / CSS 上的问题吧:选择文本消失的选项元素 $0.innerText""$0.textContent"乾")。

很好,确认了,反正 JS 不多,直接单步调试走一遍吧。

深入调查

我使用了 Chrome DevTools(至少对于 Bug,用哪个区别不大),然后发现……单步调试的时候似乎无法复现 Bug,那很明显是渲染上的 Bug 了。

于是我直接在所有的 class 更改的操作后面增加了对应的 ele.offsetTop; 来尝试强制触发重排并触发重绘

好吧没用,不过我相信不是我的问题而是 Safari 在重绘上的问题。

那换种方法,将所有 ele.offsetTop; 改成 console.log(ele.innerText);

我们可以发现在这之后文字就消失了:

// 移除其他 tab 的 active 类
titleList.forEach(t =>
	t.classList.remove("active")
);
tab.classList.add("active");

至此,我们已经定位到了具体位置,大概确定了原因,那么是时候解决了。

修复

首先我想到的是在上面那段代码之后刷新一下 DOM,例如使用 ele.innerHTML,但考虑到可能的元素绑定,感觉需要更加麻烦的办法……

正当我苦思冥想之际(并不),我突发奇想,试了一下使用微任务setTimeout(() => tab.classList.add("active"), 0); 就成功修复了……

结语

至此也差不多结束了,为什么修复了?我不知道。

然后要吐槽一下 BrowserStack 的调试真的很难用,有很多 Bug。但也要感谢 GitHub Student Developer Pack,申请又方便东西又多,例如和 BrowserStack 合作的学生计划