iOS中采用AMP规范时的scroll和position:fixed带来的bug
本文翻译自一位前辈的两篇文章,原文链接:
- AMP, iOS, Scrolling and Position Fixed
- AMP, iOS, Scrolling and Position Fixed Redo — the wrapper approach
首先,你需要先了解一下 AMP,点击这里
前言
我们对于 AMP 的目标是确保 document 文档在不同环境中都是可嵌入的,无论是单独查看还是在 webview 中或者在 iframe 中——总体而言,它在不同环境中的功能和行为表现都应该尽可能相同。我们将从一个简单的栗子开始,在这个栗子中,一个 AMP 文档通过 iframe 被嵌入了一个 web app。这听起来很正常,但是很实在的说,iframes 在最近已经很少有人用了。闲话少说,html 结构是这样的:
1 | <html> |
上面这段代码通常来说在移动设备上表现良好。然后我们有了一个新的想法,我们尝试将 iframe 调整到整个 document 的高度,同时使用 static 定位,从而将滚动委托给上一层的 window。然而,出于一些原因,我们放弃了这种方法:
- 当视口高度等于文档高度时,在嵌入的AMP文档中设置
“position: fixed”
是不会起作用的 - 计算文档高度容易出错,而且有延迟
当然,我们最终没有很好的解决方案。主要是,设置了“scrolling=yes”
的 iframe 会丢失一些移动设备的特性,比如滚动时隐藏地址栏。然而,我们仍然觉得这已经是一个很好的折衷方案了。除此之外,一些浏览器已经开始尝试将这些特性扩展到非 body 滚动的情况中。我们就这样美滋滋,直到我们遇到了 iOS。。
问题1:iOS 不支持 iframe 的 “scrollable=yes”
Bug:https://bugs.webkit.org/show_bug.cgi?id=149264 简单的说:ios中不能有可以滚动的iframe。然而,我们找到了解决这个 bug 的方法。参考这里ViewportBindingNaturalIosEmbed_。简短而言,我们让 document 中真正的<body>
元素滚动。这样的话,即使 iframe 自身不滚动,它里面的内容也会滚动。 我们按照上面方案修改后的 AMP 文档如下:
1 | <html AMP |
我们以为自己很牛逼,然而。。
问题2:现在 scrollTop,scrollLeft,scrollHeight,scrollWidth 不管用了
Bug:https://bugs.webkit.org/show_bug.cgi?id=106133 这是 webkit 中长期存在的一个 bug。scrollTop 和其他类似属性被分配给了 “document.body”
,但是却委托给了“document.documentElement”
。最终,当 “scrollingElement” 是文档里的大部分元素的时候,这个问题会被解决。同时,令人惊喜的是,这个 bug 不会对我们在问题1中提出的解决方案造成冲突。然而,“scrollTop”
将会一直是 0,从而导致其他连带属性也会受到影响,比如 “window.pageYOffset”
解决方案是添加一个滚动的元素到文档顶部。它的 “getBoundingClientRect().top”
就可以用来重新计算文档的滚动位置。 具体如下所示:
1 | <html AMP |
我们的 JS 代码:
1 | function getScrollTop() { |
从上面的代码可以看出,这个解决方案显得很蠢,但是它确实奏效了。类似的方法可用于“scrollLeft”,“scrollHeight” 以及剩余属性。 然而,我们又有了新的发现。。
问题3:“postion: fixed” 的元素在 “overflow: auto” 容器中会有很多 bug
Bug:https://bugs.webkit.org/show_bug.cgi?id=154399 如果一个 “position: fixed” 元素在一个 “overflow: auto” 的容器中,它的表现会让你很失望:滚动的时候,“position: fixed” 元素会跳远和闪现。它看起来像是稍微滚动一点然而又跳回到正确的位置。这个效果很差,可以通过这个视频演示看到这个 bug。 要哭了。我们通过各种 hack 解决了各种 bug,最后还是有一个 bug,我们如何解决这个?这里有一个很疯狂的 idea 貌似好使。我们可以添加一个虚拟元素到 “document.documentElement”(不是 “body”,所以它其实是 “body” 的兄弟元素)。我们把它叫做“固定层”。他将占据整个视口。我们将使用CSS来找到所有的可能是 “fixed” 的元素(希望不会有太多。。),如果在某些时候它们是确定 “fixed” 的,我们就通过正确的 “z-index” 属性将它们移动到“固定层” 你可能看晕了,直接上代码:
1 | <html AMP |
当我们找到一个确实 “fixed” 的元素的时候,我们将它移动到“固定层”,像这样:
1 | <div id="fixed-layer" |
因此,我们可以根据某元素是否 “fixed” 来将它在 “body” 中的原始位置和“固定层”之间移动。 这个方法就无懈可击了吗?很明显没有:
- 这代码看都看不懂!
- 计算 “z-index” 会相当痛苦
- 我们将失去一些 CSS 祖先选择器
但是它确实是有效的,可以看一下这条PR。还有别的 idea 吗? 准确来说是有的,下面是作者第二篇文章的译文:
回顾一下
简单回顾一下,AMP 文档经常在一个滚动的 iframe 中进行展示。它的 html 结构看起来像这样:
1 | <html> |
在大部分浏览器中,上面这段代码表现很正常。但是在 ios 中会有很多异常表现,我们尝试了很多方法,包括通过内容调整 iframe 大小和滚动主文档。但是他们都有一些性能问题,具体可以参见上面的问题描述。 根本而言,ios 的 safari 浏览器不支持滚动的 iframe。换句话说,“scrolling=yes”
这个属性被直接忽略了。看这个例子。这个 bug 由来已久,可以在这里发现。 我们在之前提到的一篇文章中发现了一个很原始的方案。简而言之,我们让真正的 “body” 元素滚动。于是,即使 iframe 它自身不滚动,iframe 中的内容也会滚动,AMP 文档如下:
1 | <html AMP |
现在我们 iframe 可以滚动了!这个 AMP 中的解决方案我们用了一年。然而,随着时间的流逝,我们发现了一系列的问题,这些问题在上一篇文章中已经详细介绍过了,这里再简单罗列一下: 给“ body” 添加 “position: absolute” 属性是作者不想看到的,会影响原始布局。另外一个副作用是我们不没办法在 “body” 元素上设置 margin body 的 scrollTop,scrollLeft,scrollHeight 和 scrollWidth 将不起作用。这个 bug 通过上面介绍的注入虚拟 dom 元素可以解决。 “position: fixed” 在 “-webkit-overflow-scrolling: touch” 容器中会有各种 bug 抵消 header 和 footer 需要给 body 设置边框,这个代价很昂贵,因为它缩小了滚动区域,同时可能会打破现有布局。而隐藏头部又会造成 UI 视觉的隔断和滚动的间断 那我们如何解决这个问题呢,我们的主角就要登场了。。
新的解决方案——wrapper 元素
这个方案已开源,可以点击这里查看源代码
DOM 结构
通俗来讲,wrapper 元素和滚动的 “body” 元素是类似的。iframe 在 ios 的 safari 浏览器中依然无法滚动,所以我们需要让 iframe 中的内容滚动。因为让<body>
滚动会有一系列问题,所以我们可以创建一个滚动的 wrapper,然后将它放在<html>
和<body>
中间。换句话说,我们将<body>
元素包装在一个可滚动的容器中。 现在的 dom 结构类似这样:
1 | <html AMP |
毫无疑问,这看起来很怪,但是它确实解决了原来的问题——它让 iframe 在 ios 的 safari 浏览器中可以滚动。此外,它也解决了上面描述的许多问题:
- 对于
<body>
元素没有任何强制要求:它仍然拥有原来的“position”属性,同时也可以拥有默认的“overflow: visible”属性。AMP 允许 dom 中的大多数 css 样式,这样可以减少对代码原作者样式的干扰 - 可滚动的 wrapper 元素可以用来获取 scrollTop,scrollLeft,scrollHeight 和 scrollWidth 属性,于是之前介绍过的虚拟元素将不再需要
- 不再需要给
<body>
设置边界来抵消 header 和 footer 了——只需要给 wrapper 元素添加 padding 就足够了
然而,“position: fixed” 的问题仍然存在,我们稍后再谈。
两个<html>
元素
我们采用了 wrapper 方案,然后很快就碰到了一个小问题。很多人喜欢 html>body 选择器,而我们在 <html>
和 <body>
中间插入了 i-amp-html-wrapper 元素。为了解决这个问题,我们将 i-amp-html-wrapper 作为另外一个 <html>
元素,最终的 dom 结构长这样:
1 | <html AMP |
加倍奇怪,加倍好玩。总而言之现在 html>body 选择器将正常起作用
实践
AMP runtime 会在启动时尽可能早的创建 wrapper 元素。而现有的 <body>
元素会作为子元素放到新建 wrapper 里面
1 | // Create wrapper. |
这段代码很简单,不过有一个细节——将 body 移到 wrapper 里面会将 document.body 重置为 null,因此我们需要将 document.body 重写回初始的 <body>
元素,可以通过 Object.defineProperty 来实现
position: fixed问题
尽管 wrapper 方案能够解决大部分问题,但是 position: fixed 的问题仍然存在 这个问题在上面那篇文章已经详细介绍过了,有关 ios 的 safari 浏览器 bug 可以点击这里查看 简而言之,一个 position: fixed 元素在一个 -webkit-overflow-scrolling: touch 容器中滚动时会出现跳跃和闪现的问题。它看起来像是稍微滚动一点然而又跳回到正确的位置。可以通过这个视频演示看到这个 bug。 在我们之前的解决方案中,我们将有 position: fixed 属性的元素放到了 <body>
外面,同时放到了一个虚拟“固定层”元素内部,这个“固定层”元素放在了 -webkit-overflow-scrolling: touch 容器外面 最终的 dom 结构:
1 | <html AMP |
于是,我们最终获得了两个<html>
元素和两个<body>
元素。看起来很疯狂,但是它确实解决了两个问题: iframe 不滚动和 position:fixed 元素闪现问题 很明显,我们将取得更好的效果如果存在已久的 ios safari 问题被修复。。
听说赞过就能年薪百万