likes
comments
collection
share

对 scroll 的认知和探索

作者站长头像
站长
· 阅读数 32

一、基础回顾:测量元素的尺寸和位置

由于scroll中会涉及到元素的尺寸和位置的计算,这里先借助网上的一张图来回顾一下。 图片来源自: 使用 CSSOM 测量元素尺寸和位置 。 对 scroll 的认知和探索



clientHeight

需要注意的是: clientHeight = 内容高度+上下padding-滚动条的粗度。 对于同一个元素来说,盒模型会影响clientHeight的值。 clientHeight返回的是整数。

#scroll-container2 {
  margin: 100px 0px 70px 50px;
  border: 7px solid black;
  box-sizing: border-box; // box-sizing的值会影响clientHeight、clientWidth的值
​
  height: 200.22px;
  width: 300.22px;
  padding: 10px;
  overflow: auto;
}
// 备注:该示例里没有水平滚动条
getClientWidth() {
  /**
   * (1)对于content-box,CSS height属性只包含内容的高度,不包含padding和border。
   *      内容高度contentHeight = 200.22
   *     clientHeight = CSS height + CSS 上下padding = 200.22+10+10=220.22 clientHeight会返回整数 220
   * 
   * (2)对于border-box,CSS height属性包含了内容的高度,padding和border。
   *     内容高度contentHeight = 200.22(CSS height)-7(上border)-7(下border)-10(上padding)-10(下padding)=166.22。
   *     clientHeight = 内容height+上下padding = 166.22+10+10=186.22 clientHeight会返回整数 186
   */
  const scrollContainer = document.getElementById('scroll-container2')!;
  console.log('clientHeight', scrollContainer?.clientHeight);
}



二、滚动的相关概念

  1. 当块级元素设置了 overflow: scrolloverflow: auto ,并且它包含的内容溢出了其有限的可视区时,就会显示滚动条,内容可滚动。
  2. 我们可以监听滚动事件,监听元素滚动到特定位置时做我们想做的事情。
  3. 我们也可以手动设置可滚动元素滚动指定的偏移量,比如滚动页面让指定元素进入可视区。 对 scroll 的认知和探索对 scroll 的认知和探索

一些无关紧要的示例代码:

<div style="display: flex">
  <div id="scroll-container1">内容很少,没有滚动条</div>
  <div id="scroll-container2">
    01. 内容很多,有滚动条<br />
    02. 内容很多,有滚动条<br />
    03. 内容很多,有滚动条<br />
    04. 内容很多,有滚动条<br />
    05. 内容很多,有滚动条<br />
    06. 内容很多,有滚动条<br />
    07. 内容很多,有滚动条<br />
    08. 内容很多,有滚动条<br />
    09. 内容很多,有滚动条<br />
    10. 内容很多,有滚动条<br />
    11. 内容很多,有滚动条<br />
    12. 内容很多,有滚动条<br />
    13. 内容很多,有滚动条<br />
    14. 内容很多,有滚动条<br />
    15. 内容很多,有滚动条<br />
    <div id="target">test scrollIntoView()</div>
    01. 内容很多,有滚动条<br />
    02. 内容很多,有滚动条<br />
    03. 内容很多,有滚动条<br />
    04. 内容很多,有滚动条<br />
    05. 内容很多,有滚动条<br />
    06. 内容很多,有滚动条<br />
    07. 内容很多,有滚动条<br />
    08. 内容很多,有滚动条<br />
    09. 内容很多,有滚动条<br />
    10. 内容很多,有滚动条<br />
    11. 内容很多,有滚动条<br />
    12. 内容很多,有滚动条<br />
    13. 内容很多,有滚动条<br />
    14. 内容很多,有滚动条<br />
    15. 内容很多,有滚动条<br />
  </div>
</div>
#scroll-container1,
#scroll-container2 {
  margin: 100px 0px 70px 50px;
  border: 7px solid black;
  box-sizing: border-box; // content-box

  height: 200.22px;
  width: 300.22px;
  padding: 10px;
  overflow: scroll; // auto
}

#target {
  height: 50px;
  padding: 10px;
  background-color: bisque;
  border: 1px solid black;
}

三、监听滚动事件

当用户滚动某个元素的内容时 scroll 事件将会被触发。 由于 scroll 事件可被高频触发,容易造成高性能的消耗。推荐使用 requestAnimationFrame()setTimeout()CustomEvent 给事件节流,比如:

const scrollContainer = document.getElementById('scroll-container')!;

scrollContainer.addEventListener('scroll', () => {
  requestAnimationFrame(() => {
    // do something here
    console.log('我滚动啦');
  });
});

显示器有固定的刷新频率,每秒最多只能重绘60次或75次,即60Hz或75Hz。requestAnimationFrame 充分利用显示器的刷新机制,与这个刷新频率保持同步,来进行页面重绘,从而节省系统资源,提高系统性能,改善视觉效果。

四、滚动的原生API

1. scroll()、scrollTo()、scrollBy()

element.scroll(x-coord, y-coord) :让滚动条滚动到指定容器的某个坐标。 element.scroll(options) :让滚动条滚动到指定容器的某个坐标,且可以指定滚动行为。 element.scrollTo() 同 element.scroll()。 

const scrollContainer = document.getElementById('scroll-container')!;
​
// 1. 内容垂直方向向上滚动50px
scrollContainer.scroll(0, 50);
​
// 2. 内容垂直方向向上滚动50px
scrollContainer?.scroll({
  top: 50,
  left: 0,
  behavior: 'smooth', // 指定滚动行为,支持参数 smooth(平滑滚动),默认值auto(瞬间滚动)
});

scroll、scrollTo、scrollBy 接收参数一样。区别: scroll 和 scrollTo 用法基本一致。 scrollTo 滚动的距离是 绝对 的,就是不管执行多少次,滚动的位置都是一样的。 scrollBy 滚动的距离是 相对 的,每执行一次就会在原来的滚动基础上加上相对的距离。

2. scrollIntoView()

滚动element的父容器,使element对用户可见。

element.scrollIntoView(alignToTop) :alignToTop可选。

  • 默认值为true。element的顶端将与其所在滚动区的可视区域的顶端对齐。(特殊的,如果element在其所在滚动区的可视区域的最底部,则element将与其所在滚动区的可视区域的底端对齐。)
  • 如果为false,element的底端将与其所在滚动区的可视区域的底端对齐。

element.scrollIntoView(scrollIntoViewOptions) :scrollIntoViewOptions对象可选。

  • behavior:滚动动画过渡效果,值可为auto,smooth。smooth表示平滑滚动。
  • block:垂直方向的对齐,值可为start,center(滚动到可视区域中间),end,nearest。默认为 start。
  • inline:水平方向的对齐, 值可为start,center,end,nearest。默认为 nearest。
// 滚动son的父容器,使son出现在可视区域。
const son = document.getElementById('son')!;
son.scrollIntoView();

3. scrollTop、scrollLeft

element.scrollTop :读取或设置一个元素的内容垂直滚动的距离。这个距离是元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离。 element.scrollLeft :读取或设置元素滚动条到元素左边的距离。 对 scroll 的认知和探索


// 获取
const scrollTop = scrollContainer.scrollTop;
console.log('scrollTop', scrollTop);
​
// 设置
scrollContainer.scrollTop = 77;

4. scrollHeight、scrollWidth

element.scrollHeight :element的内容高度,包括由于溢出导致的视图中不可见内容。没有垂直滚动条的情况下,scrollHeight 值与元素视图填充所有内容所需要的最小值 clientHeight 相同。scrollHeight 也包括 ::before::after 这样的伪元素。 注意:scrollHeight将会对值取整。如果需要小数值,使用 Element.getBoundingClientRect() 。 对 scroll 的认知和探索





5. scrollY、scrollX

window.scrollY 返回文档在垂直方向已滚动的像素值。 window.scrollX 返回文档在水平方向已滚动的像素值。

为了跨浏览器兼容,请使用 window.pageYOffset 代替 window.scrollY 。 

window.pageYOffset == window.scrollY; // 总是返回 true



6. 性能问题

需要注意的是,上述的滚动相关的API都会触发强制的回流。 相关概念:

  • 在修改元素的外观,比如background-color,会触发 重绘
  • 在获取或设置元素在视口中的位置或大小时,都会触发 回流(布局抖动)
  • 回流一定会引起重绘 。(因为在回流的时候,浏览器会使渲染树中受到影响的部分失效,并重新构造这部分渲染树,完成回流后,浏览器会重新绘制受影响的部分到屏幕中,该过程即是重绘)。
  • 重绘不一定会引起回流

五、滚动的应用

1. 判断元素是否完全出现在目标可视区?

方案一:使用getBoundingClientRect方法,但是每次调用时都会触发回流,严重影响性能。即使使用节流函数来控制也依然很影响性能。不推荐。 

const target = document.getElementById('target')!;
const scrollContainer = document.getElementById('scroll-container')!;
​
scrollContainer.addEventListener('scroll', () => {
  const targetRect = target.getBoundingClientRect();
  const scrollContainerRect = scrollContainer.getBoundingClientRect();
​
  const isTargetAllInScrollContainer =
        targetRect.left > scrollContainerRect.left &&
        targetRect.right < scrollContainerRect.right &&
        targetRect.top > scrollContainerRect.top &&
        targetRect.bottom < scrollContainerRect.bottom;
​
  if (isTargetAllInScrollContainer) {
    console.log('目标元素完全出现在目标容器中');
  } else {
    console.log('目标元素没有完全出现在目标容器中');
  }
});

方案二:使用 IntersectionObserver 交叉观察者。推荐。

const target = document.getElementById('target')!;
const scrollContainer = document.getElementById('scroll-container')!;
​
const callback = (entries: IntersectionObserverEntry[]) => {
  console.log('entries: ', entries);
​
  if (entries[0].intersectionRatio === 1) {
    // 目标元素有一点点在父容器的padding上都不算,必须完全出现在内容区
    console.log('目标元素完全出现在目标容器的可视区域中');
  } else {
    console.log('目标元素没有完全出现在目标容器的可视区域中');
  }
};
const options = {
  root: scrollContainer,
  rootMargin: '0px',
  threshold: 1,
};
const observer = new IntersectionObserver(callback, options);
observer.observe(target);



2. 滚动到指定元素的方案有哪些?

方案一:使用 element.scrollTo(ScrollToOptions) 让目标元素滚动到指定坐标点。 方案二:位置计算+设置scrollTop,比如我们组件库的 ScrollToService.scrollToElement() 。 方案三:使用 scrollIntoView(scrollIntoViewOptions) 让目标元素滚动到目标可视区的顶部、中央或底部。 方案四:通过点击锚链接 <a href="#target">跳转到指定锚点</a> 跳转到指定锚点。

六、修改滚动条样式

::-webkit-scrollbar CSS 伪类元素会影响设置了 overflow:scroll; 的元素的滚动条样式。通过以下选择器,我们可以修改滚动条或滚动槽的宽高颜色等样式。滚动条选择器有下面这些:

::-webkit-scrollbar 设置整个滚动条的样式,比如修改height或width等。

::-webkit-scrollbar-button 滚动条上的按钮(上下箭头)。

::-webkit-scrollbar-thumb 滚动条上的滚动滑块。

::-webkit-scrollbar-track 滚动条轨道。

::-webkit-scrollbar-track-piece 滚动条没有滑块的轨道部分。

::-webkit-resizer 出现在某些元素底角的可拖动调整大小的滑块。

::-webkit-scrollbar-corner当同时有垂直滚动条和水平滚动条时交汇的部分。通常是浏览器窗口的右下角。

七、scroll和wheel的区别

scroll 事件在滚动条滚动时被触发。 wheel 事件在鼠标滚轮滚动的时候被触发。

八、Angular CDK 之 scrolling

1. Map "字典“

Map基础回顾: 

/**
  * new Map()
  * Map是一种叫做字典的数据结构。
  * 它的特点是键值对的形式,键可以是任意类型,值也可以是任意类型。
  */
const map = new Map();

/**
  * set()
  * 设置键名key对应的键值为value,然后返回整个 Map 结构。
  * 如果key已经有值,则键值会被更新,否则就新生成该键。
  * 同时返回的是当前Map对象,可采用链式写法。
  */
map.set('第一站', '故宫');
map
  .set('第二站', '奥森公园')
  .set('第三站', '香山公园')
  .set('第四站', '长城')
  .set('第五站', '颐和园');

/**
  *  Map(5) {'第一站' => '故宫', 
  * '第二站' => '奥森公园', '第三站' => '香山公园', 
  * '第四站' => '长城', '第五站' => '颐和园'}
  */
console.log(map); 


// get() 读取key对应的键值,如果找不到key,返回undefined。
const getValue = map.get('第三站');  // 香山公园

// size 返回Map结构的成员总数。
const size = map.size; // 5

// has() 返回一个布尔值,判断某个键是否在当前Map字典中。
const hasName = map.has('第二站');  // true

// forEach((value, key) => {}); 遍历
map.forEach((value, key) => {
  console.log('value', value);
  console.log('key', key);
});

// delete() 删除某个键,返回true。如果删除失败,返回false。
const hasDelete = map.delete('第五站');  // true

// clear() 清除所有成员,没有返回值。
map.clear();  // Map(0) {size: 0}



2. 不同浏览器处理水平滚动的方式

当dir属性值为'rtl',即文本方向是从右到左时。各个浏览器对 scrollLeft 的取值方式不同。如下:

对 scroll 的认知和探索

示例一:观察到不同浏览器在以下版本下在RTL布局中读取scrollLeft属性值。

<div id="scroll-container2" [dir]="'rtl'">
  <div id="target" style="width: 500px">test 水平滚动条</div>
  01. 内容很多,有滚动条<br />
  02. 内容很多,有滚动条<br />
  03. 内容很多,有滚动条<br />
  04. 内容很多,有滚动条<br />
  05. 内容很多,有滚动条<br />
  06. 内容很多,有滚动条<br />
  07. 内容很多,有滚动条<br />
  08. 内容很多,有滚动条<br />
  09. 内容很多,有滚动条<br />
  10. 内容很多,有滚动条<br />
  11. 内容很多,有滚动条<br />
  12. 内容很多,有滚动条<br />
  13. 内容很多,有滚动条<br />
  14. 内容很多,有滚动条<br />
  15. 内容很多,有滚动条<br />
</div>
#scroll-container2 {
  margin: 100px 0px 70px 50px;
  border: 7px solid black;
  box-sizing: content-box; // box-sizing的值会影响clientHeight、clientWidth的值

  height: 200.22px;
  width: 300.22px;
  padding: 10px;
  overflow: auto;
}
#target {
  height: 50px;
  padding: 10px;
  background-color: bisque;
  border: 1px solid black;
}

对 scroll 的认知和探索

所以上述对应版本的浏览器都使用了 NEGATED 的方式来读取scrollLeft值的???

示例二:验证RTL布局下,NEGATED浏览器中,让容器的滚动条滚动一定偏移量;读取scrollLeft的值;读取容器的左右两侧的偏移量。

<div id="scroll-container2" [dir]="'rtl'">
  <div id="target" style="width: 500px">test 水平滚动条</div>
  01. 内容很多,有滚动条<br />
  02. 内容很多,有滚动条<br />
  03. 内容很多,有滚动条<br />
  04. 内容很多,有滚动条<br />
  05. 内容很多,有滚动条<br />
  06. 内容很多,有滚动条<br />
  07. 内容很多,有滚动条<br />
  08. 内容很多,有滚动条<br />
  09. 内容很多,有滚动条<br />
  10. 内容很多,有滚动条<br />
  11. 内容很多,有滚动条<br />
  12. 内容很多,有滚动条<br />
  13. 内容很多,有滚动条<br />
  14. 内容很多,有滚动条<br />
  15. 内容很多,有滚动条<br />
</div>
const scrollContainer = document.getElementById('scroll-container2')!;
console.log('scrollLeft: ', scrollContainer.scrollLeft);

 RTL布局中,默认水平滚动条会停留在右侧。读取到的scrollLeft值是0。 对 scroll 的认知和探索 让水平滚动条向左滚动:

// RTL布局下,NEGATED浏览器中,scrollLeft的值是负数或0。
// scrollLeft的绝对值等于容器右侧的偏移量
scrollContainer.scrollLeft = -100;

对 scroll 的认知和探索 读取该容器的左侧和右侧的偏移量:

console.log('容器右侧的偏移量: ', -scrollContainer.scrollLeft); // = -(-100) = 100

console.log('容器左侧的偏移量:', scrollWidth - clientWidth - Math.abs(scrollLeft));
  // 等于 scrollWidth - clientWidth - (-scrollLeft) 即是 542-271-(-(-100)) 
  // 等于 scrollWidth - clientWidth + scrollLeft 即是 542-271+(-100) = 171

3. CdkScrollable 指令

第一,给元素绑定cdkScrollable指令后,该scrollable实例会注册到ScrollDispatcher的scrollContainers”字典“中。当cdkScrollable指令被销毁后,该scrollable实例也将从scrollDispatcher的scrollContainers”字典“中移除,对应的滚动事件订阅也会被取消。 

ngOnInit() {
  this.scrollDispatcher.register(this);
}

ngOnDestroy() {
  this.scrollDispatcher.deregister(this);
}

第二,cdkScrollable指令的 elementScrolled 方法是一个可观察者 Observable ,它观察cdkScrollable指令绑定的元素的滚动事件。我们可以通过该方法来订阅滚动事件并做我们想做的事情。 

elementScrolled(): Observable<Event> {
  return new Observable((observer: Observer<Event>) =>
    this.ngZone.runOutsideAngular(() =>
      fromEvent(this.elementRef.nativeElement, 'scroll')
        .pipe(takeUntil(this._destroyed))
        .subscribe(observer),
    ),
  );
}

第三,如果我们自己使用原生的scrollTo来进行滚动指定偏移量的话,我们只能设置top和left偏移。在一些复杂的场景下(比如不同的文本布局下,不同的浏览器下),我们只能自己去计算这个top或left值,会比较麻烦和繁琐。 

// 原生scrollTo
element.scrollTo({
  top: number,
  left: number,
  behavior: 'smooth'|'auto',
});

cdkScrollable 指令给我们提供了更强大的 scrollTo(ExtendedScrollToOptions) 方法,它扩展了ScrollToOptions,支持我们传上、下、左、右、开始和结束的偏移量。

start和end的含义:在LTR布局中是左(start)和右(end)。在RTL布局中是右(start)和左(end)。

由于浏览器对于滚动 scrollLeft 在 RTL 中的含义并不一致,CdkScrollable的scrollTo方法对浏览器原生scrollTo方法进行了规范化:left 和 right 总是指滚动容器的左侧和右侧,而与布局方向无关。

// CdkScrollable的scrollTo
scrollTo(options: {
  top?: number;
  bottom?: number;
  left?: number;
  right?: number;
  start?: number;
  end?: number;
  behavior?: 'auto' | 'smooth';
}): void {
  // 复杂逻辑判断......
}

它里面进行了哪些复杂逻辑的判断和处理??? 经过梳理发现,它做了三件事: 第一件事:计算出顶部偏移量top。 

if (options.bottom != null) {
  options.top = el.scrollHeight - el.clientHeight - options.bottom;
}

第二件事:计算左偏移left。 兼容不同文本方向(ltr和rtl),同时兼容不同浏览器对scrollLeft的不同处理。

//(1)根据dir文本方向以及start和end的值,计算出left、right偏移量
if (options.left == null) {
  options.left = isRtl ? options.end : options.start;
}
if (options.right == null) {
  options.right = isRtl ? options.start : options.end;
}

// (2)兼容各个浏览器,因为浏览器对于滚动scrollLeft在 RTL 中的含义并不一致。
if (isRtl && getRtlScrollAxisType() != RtlScrollAxisType.NORMAL) {
  // 兼容dir='rtl'的情况
  if (options.left != null) {
    options.right = el.scrollWidth - el.clientWidth - options.left;
  }
  if (getRtlScrollAxisType() == RtlScrollAxisType.INVERTED) {
    // 兼容 INVERTED 浏览器
    options.left = options.right;
  } else if (getRtlScrollAxisType() == RtlScrollAxisType.NEGATED) {
    // 兼容 NEGATED 浏览器
    options.left = options.right ? -options.right : options.right;
  }
} else {
  // dir='ltr'的情况 或者 NORMAL 浏览器中
  if (options.right != null) {
    options.left = el.scrollWidth - el.clientWidth - options.right;
  }
}

第三件事:调用原生的scrollTo方法或者设置scrollTop、scrollLeft来实现滚动。

this._applyScrollToOptions(options);

private _applyScrollToOptions(options: ScrollToOptions): void {
  const el = this.elementRef.nativeElement;

  if (supportsScrollBehavior()) {
    el.scrollTo(options);
  } else {
    if (options.top != null) {
      el.scrollTop = options.top;
    }
    if (options.left != null) {
      el.scrollLeft = options.left;
    }
  }
}

第四,cdkScrollable 指令给我们提供了 测量相对于视口指定边缘的滚动偏移量 的方法。可以使用此方法代替直接检查 scrollLeft 或 scrollTop,因为浏览器对于滚动的scrollLeft在 RTL 中的含义并不一致。此方法返回的值被规范化,这样无论布局方向如何,from的 left 和 right 总是指滚动容器的左侧和右侧。

measureScrollOffset(from: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end'): number {
    // from 指定想要返回容器的哪一侧的偏移量。
    const LEFT = 'left';
    const RIGHT = 'right';
    const el = this.elementRef.nativeElement;

    if (from == 'top') {
      // 返回容器上侧的偏移量。
      return el.scrollTop;
    }
    if (from == 'bottom') {
      // 返回容器下侧的偏移量。
      return el.scrollHeight - el.clientHeight - el.scrollTop;
    }

    // ⭐️⭐️⭐️ 根据文本方向以及start和end的值来确定想要返回的是容器左侧还是右侧的偏移量。
    const isRtl = this.dir && this.dir.value == 'rtl';
    if (from == 'start') {
      // 如果是LTR布局,start表示想要返回容器左侧的偏移量,from=left。如果是RTL布局,start表示想要返回容器右侧的偏移量,from=right。
      from = isRtl ? RIGHT : LEFT;
    } else if (from == 'end') {
      // 如果是LTR布局,end表示想要返回容器右侧的偏移量,from=right。如果是RTL布局,end表示想要返回容器左侧的偏移量,from=left。
      from = isRtl ? LEFT : RIGHT;
    }

    // ⭐️⭐️⭐️ 根据不同浏览器在RTL情况下对scrollLeft的处理方式不同,来计算确定返回容器左侧或者右侧的偏移量。
    if (isRtl && getRtlScrollAxisType() == RtlScrollAxisType.INVERTED) {
      // 在RTL布局中,对于 INVERTED,当一直向左滚动时,scrollLeft 为 (scrollWidth - clientWidth),一直向右滚动时为 0。
      // 这里el.scrollLeft是正数,值等于容器的右侧偏移量。✅
      if (from == LEFT) {
        return el.scrollWidth - el.clientWidth - el.scrollLeft;
      } else {
        return el.scrollLeft;
      }
    } else if (isRtl && getRtlScrollAxisType() == RtlScrollAxisType.NEGATED) {
      // 在RTL布局中,对于 NEGATED,scrollLeft 在一直向左滚动时为 -(scrollWidth - clientWidth),在一直向右滚动时为 0。
       // 这里el.scrollLeft是负数,绝对值等于容器的右侧偏移量。✅
      if (from == LEFT) {
        return el.scrollLeft + el.scrollWidth - el.clientWidth;
      } else {
        return -el.scrollLeft;
      }
    } else {
      // 对于 NORMAL 以及非 RTL 布局,当一直向左滚动时 scrollLeft 为 0,当一直向右滚动时为 (scrollWidth - clientWidth)。
      // 这里el.scrollLeft是正数,值等于容器的左侧偏移量。✅
      if (from == LEFT) {
        return el.scrollLeft;
      } else {
        return el.scrollWidth - el.clientWidth - el.scrollLeft;
      }
    }
  }
}


第五,cdkScrollable 指令提供了getElementRef() 方法供我们获取该可滚动元素的引用。 

// 获取该可滚动元素的引用。
getElementRef(): ElementRef<HTMLElement> {
  return this.elementRef;
}

总的来说,CdkScrollable 把文本的布局和浏览器的兼容性问题都给我们考虑并处理好了。

  • 当你想获取该可滚动元素的引用? 调用 cdkScrollable.getElementRef() 即可。
  • 当你想监听该可滚动元素的滚动事件? 调用 cdkScrollable.elementScrolled() 进行订阅即可。
  • 当你想让元素滚动到指定位置? 调用 cdkScrollable.scrollTo(ExtendedScrollToOptions) 即可。
  • 当你想测量滚动的偏移量? 调用 cdkScrollable.measureScrollOffset(from) 即可。

4. ScrollDispatcher 服务

第一,scrollDispatcher里维护了一本装有 [scrollable引用]及其[scroll事件订阅]的映射 的字典,该字典名为 scrollContainers 。 

scrollContainers: Map<CdkScrollable, Subscription> = new Map();

第二,scrollDispatcher里会有一个通知“中转站” _scrolled,它即是一个observable(可观察者),也是一个 observer(观察者)。当有元素发生滚动时,它会发出“消息”。同时也可以通过它订阅元素的滚动”消息“。 当cdkScrollable绑定的元素发生滚动时,_scrolled会发射该scrollable实例。当全局监听的元素发生滚动时,它也会发射但不发任何东西。它会昭告天下:有元素发生了滚动。 

_scrolled = new Subject<CdkScrollable | void>();

第三,当元素上绑定了CdkScrollable指令,该指令会调用scrollDispatcher的register方法,将该scrollable引用及它对应的滚动事件订阅作为键值对注册到scrollContainers字典中。

register(scrollable: CdkScrollable): void {
  if (!this.scrollContainers.has(scrollable)) {
    this.scrollContainers.set(
      scrollable,
      scrollable.elementScrolled().subscribe(
        // 在scrollable元素发生滚动时,_scrolled发出该scrollable实例
        // Mark A: 这里监听的是当前的scrollable实例的滚动,_scrolled会发射该scrollable实例。
        () => this._scrolled.next(scrollable)
      ),
    );
  }
}

第四,当CdkScrollable指令被销毁,scrollDispatcher会根据scrollable引用,拿到字典中对应的滚动订阅并取消它,同时从字典中删除该映射。

deregister(scrollable: CdkScrollable): void {
  const scrollableReference = this.scrollContainers.get(scrollable);
  if (scrollableReference) {
    scrollableReference.unsubscribe();
    this.scrollContainers.delete(scrollable);
  }
}

第五, getAncestorScrollContainers():CdkScrollable[] 方法会从scrollContainers字典中找到所有包含所提供元素的scrollable并返回。 

getAncestorScrollContainers(elementOrElementRef: ElementRef | HTMLElement): CdkScrollable[] {
  const scrollingContainers: CdkScrollable[] = [];
​
  this.scrollContainers.forEach((_subscription: Subscription, scrollable: CdkScrollable) => {
    if (this._scrollableContainsElement(scrollable, elementOrElementRef)) {
      scrollingContainers.push(scrollable);
    }
  });
​
  return scrollingContainers;
}

第六, scrolled(): Observable<CdkScrollable | void> 方法里订阅了任何已注册的Scrollable引用(或 window, document, 或 body)触发的滚动事件并返回一个可观察者。 

scrolled(auditTimeInMs: number = DEFAULT_SCROLL_TIME): Observable<CdkScrollable | void> {
  return new Observable((observer: Observer<CdkScrollable | void>) => {
    if (!this._globalSubscription) {
      this._globalSubscription = this._ngZone.runOutsideAngular(() => {
        const window = this._getWindow();
        // Mark A: 这里监听的是全局元素的滚动,_scrolled发射了但没有发射任何东西。
        return fromEvent(window.document, 'scroll').subscribe(() => this._scrolled.next());
      });
    }
​
    // 订阅全局的scroll事件,观察者observer会在这里“盯着”_scrolled发射的内容并做想做的事情。
    const subscription =
      auditTimeInMs > 0
        ? this._scrolled.pipe(auditTime(auditTimeInMs)).subscribe(observer)
        : this._scrolled.subscribe(observer);
    this._scrolledCount++;
​
    return () => {
      subscription.unsubscribe();
      this._scrolledCount--;
      if (!this._scrolledCount) {
        if (this._globalSubscription) {
          // 取消全局监听scroll事件的订阅
          this._globalSubscription.unsubscribe();
          this._globalSubscription = null;
        }
      }
    };
  });
}

第七, ancestorScrolled(): Observable<CdkScrollable | void> 方法:订阅指定元素的祖先的滚动事件并返回一个可观察者。 

ancestorScrolled(
  elementOrElementRef: ElementRef | HTMLElement, // 指定要观察谁的祖先
  auditTimeInMs?: number, // 指定滚动事件节流(throttle)的时间
): Observable<CdkScrollable | void> { // 返回一个可观察者,具体内容是祖先scrollable实例
  const ancestors = this.getAncestorScrollContainers(elementOrElementRef);
​
  return this.scrolled(auditTimeInMs).pipe(
    filter(target => {
      // Mark A: target有值表示的是cdkScrollable元素滚动触发的。
      // Mark A: target为空则表示是全局监听到的元素的滚动。
      return !target || ancestors.indexOf(target) > -1;
    }),
  );
}



5. scrolling工具类

supportsScrollBehavior():boolean 判断元素是否支持滚动行为。 getRtlScrollAxisType(): RtlScrollAxisType 获取当前浏览器对于RTL布局下的scrollLeft的取值方式 NORMALNEGATEDINVERTED

九、参考学习

现代前端滚动实现的各种解读指南

问题与解决方案:判断元素是否滚动到底、判断元素是否能滚动、判定用户是否阅读过文本

使用 CSSOM 测量元素尺寸和位置

getBoundingClientRect()

Angular cdk scrolling