【前端】position:sticky解析 这次应该大结局了

7,930 阅读14分钟

前言

  1. 为什么还来重复写一篇 “sticky” 解析?

之前觉得自己懂了,搞了个总结:【前端】sticky粘性定位 。然后跟人对线,发现自己的确有疏漏,没杠过,所以又来了。

  1. 本文有什么改进?

MDN上的资料感觉多少还是有点模糊,所以这次直接到w3c CSS Positioned Layout Module Level 3 去做阅读理解了。而且不随便乱加自己的猜测了,尽量基于规范来推导。

  1. 本文中的测试是基于 chrome 87 做的,不探讨其他环境和sticky的兼容性。

  2. 期待对sticky和布局体系的名词已有一定认知,这里不再重复叙述

sticky 定义

简述sticky

Relative positioning, which visually shifts a box relative to its laid-out location.

Sticky positioning, which visually shifts a box relative to its laid-out location in order to keep it visible when a scrollable ancestor would otherwise scroll it out of sight.

sticky会把元素进行“视觉转移”,使其尽可能留在最近滚动祖先元素内。

这里把 position:relative 的简单概述也搬过来了,对比两者之间的描述:个人推测浏览器底层中对sticky的实现,就是监听滚动事件,并为sticky元素动态设置 position:relative 和其它属性实现的,两者的其他行为“十分相似”(如百分比width/height等)

严格定义 sticky

总体来看,sticky 的“视觉位置”主要由 “sticky view rectangle” 、 “containing block” 决定。

sticky view rectangle

Sticky positioning is similar to relative positioning except the offsets are automatically calculated in reference to the nearest scrollport.

For a sticky positioned box, the inset properties represent insets from the respective edges of the nearest scrollport, defining the sticky view rectangle used to constrain the box’s position. (For this purpose an auto value represents a zero inset.) If this results in a sticky view rectangle size in any axis less than the size of the border box of the sticky box in that axis, then the effective end-edge inset in the affected axis is reduced (possibly becoming negative) to bring the sticky view rectangle’s size up to the size of the border box in that axis.

如上所述,position:stickyposition:relative 基本一致,只是 position:sticky 是根据 “nearest scrollport”(最近滚动祖先) 进行定位的。

后面主要阐述三个点:

  1. 对于一个 position:sticky 元素,inset properties(即topbottomleftright属性) 会根据 nearest scrollport 对应元素的 content area 生成一个 “sticky view rectangle”(粘性约束矩形),用以约束 position:sticky 元素的位置。
  2. 如果 inset properties 的值为auto(默认值),那么sticky view rectangle 对应的边是“零”值(意思为没有约束,或者说对应的边在无穷远)
  3. 如果生成的 sticky view rectangle 大小不足以完全包含 position:sticky 元素的 border box,那么“end-edge”的 inset properties 会被减少,直到 sticky view rectangle 可以完全包含 position:sticky 元素。

第一点追加解析:最近滚动祖先发生了滚动后,对sticky view rectangle的大小和位置没有影响。sticky view rectangle 的各边到最近滚动祖先content area 的各边的距离为定值

第二点中“零”值理解的主要依据为规范中的一段Node:

Note: A sticky positioned element with a non-auto top value and an auto bottom value will only ever be pushed down by sticky positioning; it will never be offset upwards.

如果 top 不是auto ,但 bottomauto,那么 position:sticky 元素只会向下偏移(相对其初始位置),永远不会向上偏移 。leftright同理。

第三点的问题比较多。首先是“end-edge” 在规范中似乎没有被严格定义。但根据规范文档的习惯,个人推测它想表达的意思为“布局方向结束的那一边”,一般而言是指rightbottom,但某些情况下可能会是left/top,比如direction:rtl; unicode-bidi:bidi-override;(会使文字从右到左)。

目前无法在chrome验证“end-edge”的含义,因为chrome对整个第三点的支持都有问题,建议暂时不要使用该特性。详情可见:

sticky 交互效果

For each side of the box, if the corresponding inset property is not auto, and the corresponding border edge of the box would be outside the corresponding edge of the sticky view rectangle, then the box must be visually shifted (as for relative positioning) to be inward of that sticky view rectangle edge, insofar as it can while its position box remains contained within its containing block. The position box is its margin box, except that for any side for which the distance between its margin edge and the corresponding edge of its containing block is less than its corresponding margin, that distance is used in place of that margin.

规范的句子有点长不太会翻译了,所以这里也按逻辑拆成三点:

  1. 如果position:sticky 元素的某一边inset property不为auto ,且对应的 border edge (边框的外面的那一条边)不在 sticky view rectangle 的内部,那么对该元素添加“视觉偏移”(与 position:relative 表现一致,不影响其他元素的布局),使该元素仍然处于sticky view rectangle 的内部。
  2. 第一点的“添加视觉偏移”存在一个前提条件:position:sticky 元素偏移后的 position box 不得超出它的 containing block
  3. position box 一般是指它的 margin box,但存在“例外”(此时是 border box)。这个“例外”是指:某一边的 margin edgecontaining block 对应边的距离小于 对应的margin值。

关于 containing block

The containing block of a static, relative, or sticky box is as defined by its formatting context

position:sticky 元素的 containing block 由它所在的 formatting context 决定。

虽然不同的display属性有不同的 formatting context ,但通常一个元素的 containing block 就是“块级父元素”的 content area ,如常见的display:blockdisplay:inline-blockdisplay:flex 等都是。注意 display:inline 这些行内元素是没有为子元素生成 containing block 的。

滚动至sticky元素

For the purposes of any operation targetting the scroll position of a sticky positioned element (or one of its descendants), the sticky positioned element must be considered to be positioned at its initial (non-offsetted) position.

这里比较简单,如果你通过 http://xxx.com/#elementId 或者 element.scrollIntoView() 来让浏览器滚动至position:sticky 元素或其子元素,那么浏览器应该滚动到 position:sticky 元素的初始位置(position:sticky 元素没有位置偏移)

理解sticky定义

这一段就开始说人话吧。注意,如果接下来我表述含糊不清、有矛盾,还请翻看上面的严格定义。

图形理解

  • 红色:sticky view rectangle
  • 绿色:containing block
  • 橙色:position:sticky 元素的初始位置
  • 蓝色:position:sticky 元素的发生偏移后的最终位置

  • 发生滚动后,position:sticky 元素的初始位置的 border-top 这条边超出了 sticky view rectangle,那么浏览器底层对其添加适当的 position:relative 效果,使元素依然位于 sticky view rectangle 内部。

  • 继续滚动到达一定值后,由于存在前提条件 “position:sticky 元素不超出 containing block ”,所以 position:sticky 元素就贴着 containing block 的底边一起向上滚动了。也可以说是 containing block 限制了 position:sticky 元素的最大偏移值,所以表现如此。

  • 同上,position:sticky 元素初始位置的 border-bottom 这条边超出了 sticky view rectangle,那么使其向上偏移,使其尽可能位于sticky view rectangle内部。

总结:sticky view rectangle 决定position:sticky 元素是否需要偏移和偏移多少,containing block 则限制了position:sticky 元素的最大偏移值。

算法理解

  1. 获取position:sticky 元素的最近滚动祖先
function getStickyParent(node) {
  const { parentElement } = node;
  if (!parentElement) return null;
  const computedStyle = window.getComputedStyle(parentElement);

  // thead、tbody、tfoot、tr无影响
  const display = computedStyle.display;
  if (display.indexOf('table-row') === 0) return getStickyParent(parentElement);
  if (display.indexOf('table-header') === 0) return getStickyParent(parentElement);
  if (display.indexOf('table-footer') === 0) return getStickyParent(parentElement);

  const textArr = computedStyle.overflow.split(' ');
  if (textArr.some(text => text !== 'visible')) {
    if (parentElement === document.body) {
      // 处理html、body与viewport的特殊关系
      const htmlTextArr = window.getComputedStyle(document.documentElement).overflow.split(' ');
      return htmlTextArr.some(text => text !== 'visible') ? document.body : null;
    }
    return parentElement;
  }
  return getStickyParent(parentElement);
}
  1. 根据第一点的结果和position:sticky 元素的topbottomleftright,计算出 sticky view rectangle
  2. 获取position:sticky 元素的containing block
  3. 计算containing block各边到 position:sticky 元素的初始位置各边的距离blockTopDistanceblockBottomDistanceblockLeftDistanceblockRightDistance
  4. 计算sticky view rectangle 各边到 position:sticky 元素的初始位置各边的距离 stickyTopDistance...
  5. 向“inset”(内部)方向的距离为正,向“outer”方向的距离为负,如果stickyTopDistance...存在负值,代表需要对position:sticky 元素添加偏移
  6. 如果需要添加偏移:以“top”边为例,realOffsetTop= Math.min(-stickyTopDistance, blockBottomDistance),然后底层为该元素设置偏移效果 position:relative;top:realOffsetTop
  7. 监听最近滚动元素的滚动事件,执行步骤5-7

注意:我没有看浏览器底层代码,上面伪算法是我简单推导的结果

实践中的疑问和解析

规范中定义的规则就上面那些,看起来并不复杂。然而搭上其他规范中的特性,那是真痛苦

与 position:fixed 的关系?

这里当初有点头疼,因为MDN给出这么一句:

粘性定位可以被认为是相对定位和固定定位的混合。元素在跨越特定阈值前为相对定位,之后为固定定位。

然而 position:fixed 本身有一些特性,比如它的百分比宽高一般是相对viewport的,还有脱离文档流等特性,和w3c规范中定义的sticky行为并不一样(规范中只定义了sticky和relative是相似的)。

硬要说混合的话,个人感觉是指 “position:sticky 元素的最近滚动祖先是viewport” 这个特定情况。因为这个时候抛开其他position:fixed的特性来看,的确像相对定位和固定定位的混合。

怎么确定最近滚动祖先

debug中经常遇到的问题,参考上面伪算法中的 getStickyParent 函数。一般而言只要往父级查找,遇到的第一个 overflow 不是visible的元素就是最近滚动祖先(scrollport)。找不到时,viewport 就是最近滚动祖先。

  • 为什么找不到时就是viewport?

这个同样在w3c规范中有定义 Overflow Viewport Propagation

If visible is applied to the viewport, it must be interpreted as auto. If clip is applied to the viewport, it must be interpreted as hidden.

规范中指明如果overflow:visible被应用于 viewport,那么要被解释为 overflow:auto。所以viewport始终是可以滚动的,也就是它会充当一个作为“兜底”的最近滚动祖先

自己写demo发现始终相对viewport定位?

很可能是<body/><html/>搞的鬼。同样是这个蛋疼的规范:Overflow Viewport Propagation

UAs must apply the overflow-* values set on the root element to the viewport. However, when the root element is an [HTML] html element (including XML syntax for HTML) whose overflow value is visible (in both axes), and that element has a body element as a child, user agents must instead apply the overflow-* values of the first such child element to the viewport. The element from which the value is propagated must then have a used overflow value of visible.

The overflow values are not propagated to the viewport if no boxes are generated for the element whose overflow values would be used for the viewport (for example, if the root element has display: none).

viewport的overflow来自:

  1. 如果<html/>overflow不是visible,那么这个属性实际上会被应用到viewport
  2. 如果<html/>overflowvisible,那么<body/>overflow属性实际上会被应用到viewport

然后最蛋疼的一句是 user agents must instead apply the overflow-* values of the first such child element to the viewport ,它是 “instead”,也就是说即使getComputedStyle(document.body).overflow 拿到的是 hidden,body也不一定真的会溢出隐藏,你看到hidden很可能是来自viewport的限制而不是body的。这也是上面getStickyParent算法中增加特殊判断的依据。

建议不要用body写demo,真要用body那就把样式设置成这样html {overflow:auto}; body {overflow: ...},让viewport“偷走”<html/>overflow属性。

为什么overflow:hidden也算最近滚动祖先?

However, the content must still be scrollable programatically, for example using the mechanisms defined in [CSSOM-VIEW], and the box is therefore still a scroll container.

还是那个规范,是否“可以滚动”的判断依据并不是有无滚动条,而是指在内容溢出时能否实现“编程性滚动”。

一个overflow:hidden的元素,如果内容溢出了,那么你依然可以调用scrollToscrollBy等api滚动其内部的内容。或者为子元素动态设置边距,也可以实现“内容滚动”,手段多样。

containing block完全包含“margin box”还是“border box”?

sticky 交互效果 的第2、3小点,这里来举例了。

  • sticky元素初始位置的margin box的bottom到containing block的bottom大于margin-bottomcontaining block 需要包含 margin box的bottom

demo: sticky-contain-margin

  • sticky元素初始位置的margin box的bottom到containing block的bottom小于margin-bottomcontaining block 只需要完全包含 border box的bottom

demo: sticky-not-contain-margin

sticky top:0 时其他内容会超出滚动元素 ?

这是符合预期的。滚动时内容可以出现在scrollport的padding区域,而 sticky view rectangle 是从scrollport的 content area 开始计算 top:0

简单的解法是设置 top 为负数即可,这样sticky view rectangle 的top这条边就会在更上方,sticky元素也就可以在更上的地方进行“粘性吸顶”

sticky元素一直跟着滚动?

仔细确认 sticky view rectanglecontaining block的大小和位置,一般都是理解它们有误(🤣)。

sticky作用于table相关元素

由于自己对table这个东西的规范还不熟,但平常却用的很多,踩了不少坑,就单独拎出来作一小节了。

Table规范(Not Ready For Implementation):CSS Table Module Level 3

table的width、height

In CSS 2.1, the effect of 'min-height' and 'max-height' on tables, inline tables, table cells, table rows, and row groups is undefined.

If the table-root’s width property has a computed value (resolving to resolved-table-width) other than auto, the used width is the greater of resolved-table-width, and the used min-width of the table.

The height of a table is the sum of the row heights plus any cell spacing or borders. If the table has a height property with a value other than auto, it is treated as a minimum height for the table grid, and will eventually be distributed to the height of the rows if their collective minimum height ends up smaller than this number. If their collective size ends up being greater than the specified height, the specified height will have no effect.

  • css2.1中未定义min/max height/width对table相关元素的行为。
  • css3 table上设置的width/height属性,作用等价于min-width、min-height

table 的 overflow

无法限制<table/>的width/height,意味着它永远不会出现 overflow。因此要使table出现滚动条,只能在外面套一层div等标签,让table在它们里面进行滚动。

这是这并不代表 overflow 对于<table/>是无效属性。如果<table/>overflow 值不是 visible<table/>同样可以作为scrollport而影响后代元素的position:sticky

table的containing block

An anonymous table-wrapper box must be generated around each table-root. Its display type is inline-block for inline-table boxes and block for table boxes. The table wrapper box establishes a block formatting context. The table-root box (not the table-wrapper box) is used when doing baseline vertical alignment for an inline-table. The width of the table-wrapper box is the border-edge width of the table grid box inside it. Percentages which would depend on the width and height on the table-wrapper box’s size are relative to the table-wrapper box’s containing block instead, not the table-wrapper box itself.

规范中指示浏览器需要为每一个 <table/> 生成一个匿名的 table-wrapper box,同时这个 table-wrapper box 就是普通的 display:block 或者 display:inline-block。如前面所描述,这两个 formatting context 会让子代元素具有 containing block

简而言之,可以认为 <table/> 也会为后代元素生成 containing block

tr、thead、tbody、tfoot

这里在规范的找不到具体的说明,搜索后找到一个比较合理的说法:Does a table cell have a containing block?

即规范没有定义,也没有禁止,所以目前浏览器怎么实现都是合理的。

结合 Issue 702927: position: sticky does not work on <thead> or <tr> :

For tables, position: sticky defers to the position: relative spec. At this time, Blink only supports CSS 2.1 for positioned elements, and the position: relative CSS 2.1 spec says that it does not apply to <thead> and <tr> elements.

Until Blink supports CSS3 positioning, I don't think we can properly support sticky on <thead> and <tr>, as it would require (from memory) changing the definition of container() for those elements (and for <th>).

For a CSS 2.1 compliant workaround, you should be able to apply sticky to the <th> elements and it should work properly.

chrome的维护人员提到的说法是:作为一个css2.1的兼容性方案,<td><th>元素的 position:sticky属性仍然是有效并正常工作的。

从上面issue的讨论和实际测试来看,目前chrome中 <td><th> 设置position:sticky后,会以<table>content area作为它的 containing block,同时 trtheadtbodytfoot 元素不会造成任何“拦截”(即使它们设置了overflow:hidden)。

这里也是 getStickyParent 算法中对table相关元素添加特殊判断的依据。


Issue 417223: Implement relative positioning for table rows 。chrome似乎是卡在这里了,上面issues 702927提到这个issues是它的前置依赖(position:relative对table rows无效)。所以个人推测sticky是基于relative实现的,因此也受到影响。

th、td

找不到规范说明,但经实际测试,其height、width实际作用为min-height、min-width,会为其后代元素创建 containing block,设置overflow为非visible时就会成为一个scrollport。 即与potision:sticky有关的逻辑同<table>,不再重复叙述。

最后