# 实现滚动框的懒加载

## 需求背景

在某些场景下需要前端在选项框或搜索框中展示海量数据，这时候如果将数据直接渲染，那么就很有可能造成页面的卡死。目前最佳的做法是采用懒加载的方式，将数据一页一页的渲染，只有当用户滚动到底部，才会加载下一页的数据，这样避免浏览器同时渲染海量节点导致页面卡顿。

下图使用原生HTML标签实现了一个滚动选项框，它与实现生产场景下的下拉框类似，本文基于它来模拟实际实现懒加载的场景。

HTML代码如下

```html
<div id="app">
  <!-- 模拟实现一个select下拉框 -->
  <select id="select" multiple size="5"></select>
</div>
```

接下来实现一个从后端获取分页数据的函数，在这个函数中会根据数据自动生成节点并渲染到DOM树中，以此来模拟MVVM框架自动将数据转换成视图的行为。

```jsx
const selectEl = document.querySelector('#select');

let current = 1;
function queryData(current, pageSize = 10) {
  // 模拟后端数据
  const data = [];
  for (let i = (current - 1) * pageSize; i < current * pageSize; i++) {
    const optionData = {
      id: i + 1,
      value: i + 1,
      label: `选项${i + 1}`,
    };
    data.push(optionData);

    // 将选项添加到DOM中
    const selectOptionEl = document.createElement('option');
    selectOptionEl.innerText = optionData.label;
    selectOptionEl.value = optionData.value;
    selectEl.appendChild(selectOptionEl);
  }
}
queryData(current)
```

## 功能实现

### 1. 通过比较子节点高度之和实现

#### 实现方案

第一种思路是计算滚动框的总高度（即滚动的高度加屏幕上的高度），然后和每项option节点的高度之和进行比较，\*\*如果滚动框的滚动量大于等于所有option节点的高度之和，那么说明滚动框已经到达底部，\*\*此时就可以加载下一页了。

这样的依据是，滚动框的高度包括自身高度和滚动折叠的高度，两者加起来后就是滚动框的总高度（即包含滚动折叠了的节点和屏幕中的节点），而滚动条内部主要是option节点撑起的高度，因此可以通过这些数据大致的判断滚动框是否已经滚动到最底部，而**计算的时机自然是滚动框滚动的时候**。

要实现这个方案还需要解决两个问题：

1. 获得滚动框的可视高度和滚动高度
2. 获取所有option节点的高度之和

第一个问题可以通过`scrollTop`属性和`clientHeight`属性解决。MDN中描述这两个属性：

*   `scrollTop`

    一个元素的 `scrollTop` 值是这个元素的**内容顶部**（卷起来的）到它的视口可见内容（的顶部）的距离的度量
*   `clientHeight`

    只读属性 **`Element.clientHeight`** 对于没有定义 CSS 或者内联布局盒子的元素为 0；否则，它是元素内部的高度（以像素为单位），包含内边距，但不包括边框、外边距和水平滚动条（如果存在）。

即`scrollTop`是滚动框的滚动折叠部分的高度，而`clientHeight` ，是滚动框不包含折叠高度的实际展示高度，因此两者相加大致上等于滚动框内部撑起来的高度。

接下来需要计算出滚动框所有子节点(即option元素节点)的高度之和，在这个例子中，由于每个option元素高度相同，因此只需要获取第一个option高度，然后再获取所有option节点的个数，相乘的结果就是所有option节点的高度之和。

因此最后可以得出计算公式：

```jsx
// 滚动框折叠高度 + 可视高度 - 子元素高度之和
(selectEl.scrollTop + selectEl.clientHeight ) - (current * pageSize * optionHeight) >= 0
```

具体实现代码为：

```jsx
/**
 * 通过比较子节点之和实现
 */
const optionHeight = selectEl.firstChild.offsetHeight;
selectEl.addEventListener('scroll', function (e) {
  if (
    selectEl.scrollTop +
      selectEl.clientHeight -
      current * pageSize * optionHeight >=
    0
  ) {
    alert('加载下一页');
    queryData(++current, pageSize);
  }
});
```

#### 缺点

这种方法的缺点是要求所有option节点高度相同，假如option高度并不是相同的，那么每次就需要重新计算所有option节点高度之和，或者是缓存计算结果后再重新计算，但是不管怎么样都会比较麻烦，可以用其他几种方法来替代本方案实现懒加载。

此外还有一个很大的问题在于，这种方法要求滚动框内部必须是由子节点撑起来的高度，例如对滚动框添加了内边距padding，那么滚动框的内部高度就是子元素高度之和+内边距，这就会导致计算不准确。

总而言之，这种方法计算较为麻烦，限制比较多，实际使用可以用后面几种方案。

### 2. 通过计算滚动量实现

#### 实现方案

第二种方法是通过元素的`scrollHeight`、`scrollTop`和`clientHeight` 这三个属性来实现的，计算公式如下：

```jsx
Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) < 1
```

这种方案的依据是，`scrollHeight`属性是元素包含溢出部分的内容高度，也就是包括所有折叠高度和自身可见高度，而`clientHeight`是元素自身可见高度，scrollTop属性是元素的滚动折叠高度，因此很容易知道，如果滚动框滚动到底部时，滚动框的`element.scrollHeight ≈ (element.clientHeight + element.scrollTop)` 。

具体代码如下：

```jsx
/**
 * 通过计算滚动量实现
 */
selectEl.addEventListener('scroll', function (e) {
  if (Math.abs(selectEl.scrollHeight - selectEl.clientHeight - selectEl.scrollTop) < 1) {
    alert('加载下一页');
    queryData(++current, pageSize);
  }
});
```

### 3. 通过计算getBoundingClientRect差值实现

#### 实现方案

我们知道当滚动框未滚动到底部时，下面溢出的节点会被折叠，此时如果通过调用下面溢出折叠的节点的`getBoundingClientRect()`方法，就可以得到其相对于屏幕顶部的位置信息，这个时候如果调用滚动框节点的`getBoundingClientRect()` 方法，就可以获得滚动框相对于屏幕顶部的位置信息，两者都以屏幕顶部作为参照物。假设最后一个子节点的底部距离屏幕顶部的高度为x，滚动框底部距离屏幕顶部的高度为y，那么当未滚动到底部时，滚动框最后一个子节点应当为出现在滚动框视口中，也就是说`x>y`,而当滚动框滚动到底部时，最后子节点应当出现在滚动框视口中，也就是`x≤y`。

从上图中很容易看出，当滚动框的底部距离屏幕顶部的高度(即`selectEl.getBoundingClientRect().bottom`)小于等于最后一项子节点的底部距离屏幕顶部的高度(即`selectEl.lastChild.getBoundingClientRect().bottom`)时，就说明已经滚动到底了，此时可以加载下一页了。

具体代码如下：

```tsx
/**
 * 通过getBoundingClientRect实现
 */
selectEl.addEventListener('scroll', function (e) {
  const selectElInfo = e.target.getBoundingClientRect();
  const lastOptionInfo = e.target.lastChild.getBoundingClientRect();
  if (lastOptionInfo.bottom - selectElInfo.bottom <= 1) {
    alert('加载下一页');
    current++;
    queryData(current, pageSize);
  }
});
```

#### 缺点

和计算子节点高度之和的方法一样，假如滚动框内部高度并非完全由子节点撑起来的，比如为滚动框添加内边距，那么就会导致计算不准确，计算的时候就需要将内边距、边框等因素一并进行计算。

如果给滚动框添加了边框、内边距，那么当`lastOptionInfo.bottom - selectElInfo.bottom <= 1` 时，也就是说最后一个节点的底部超过了滚动框的底部，但是此时仍然被边框遮挡，并没有出现在可视视口中，从用户的角度来看，此时并为滚动到最后一项就已经加载下一页了，因此这种情况需要将边框和内边距等因素一并进行计算。那么计算公式就是`lastOptionInfo.bottom - selectElInfo.bottom + 边框大小 <= 1` 。

### 4. 通过InteSectionObserver实现

#### 实现方案

我们知道，当滚动框滚动到最底部时，滚动框的最后一个子节点也显示在屏幕上，前面有几种方法就是通过判断最后一个子节点什么时候出现在屏幕上来判断是否滚动到底，而判断元素是否出现在屏幕上Web API中有一个很实用的方法，那就是IntersectionObserver API。

具体思路是，每次加载数据渲染时拿到最后一个子元素节点，然后observe监听其是否出现在屏幕上，如果是，则说明滚动到底部，则加载下一页。此外，每次监听都应该只监听滚动框当前的最后一个子元素，因此每次调用observe前都需要将上一页的最后子元素的监听给移除（unobserve）。

具体代码如下：

```jsx
const selectEl = document.querySelector('#select');

// 监听滚动框最后一项子元素出现在屏幕上
const ob = new IntersectionObserver(([e]) => {
  console.log(e);
  // 监听的元素不管是出现还是消失在屏幕时都会触发此回调函数
	// 因此需要通过isIntersecting属性判断是否是出现在屏幕时触发的
  if (e.isIntersecting) {
    alert('加载下一页');
    current++;
    queryData(current, pageSize);
  }
});

let current = 1;
let pageSize = 10;
let observeElement = null;
function queryData(current, pageSize) {
  // 模拟后端数据
  const data = [];
  for (let i = (current - 1) * pageSize; i < current * pageSize; i++) {
    const optionData = {
      id: i + 1,
      value: i + 1,
      label: `选项${i + 1}`,
    };
    data.push(optionData);

    // 将选项添加到DOM中
    const selectOptionEl = document.createElement('option');
    selectOptionEl.innerText = optionData.label;
    selectOptionEl.value = optionData.value;
    selectEl.appendChild(selectOptionEl);
	
		// 当渲染最后一个子元素时，监听
    if (i === current * pageSize - 1) {
			// 即将加载新的一页，之前的最后一项将不再是最后一项
			// 因此要删除该元素的监听
      if (observeElement) {
        ob.unobserve(observeElement);
      }

      observeElement = selectOptionEl;
			// 监听最后一个子元素
      ob.observe(observeElement);
    }
  }
}
```

## 最终效果

[https://stackblitz.com/edit/js-trhg6h?file=index.js](https://stackblitz.com/edit/js-trhg6h?file=index.js)

## 优化点

### 防抖

上述的大部分实现方案都是在滚动框滚动时判断是否到了底部，如果滚动到底部就继续加载下一页，因此需要监听滚动框的`scroll`滚动时间，但是倘若不做额外的处理，那么这个滚动事件会触发的十分频繁。针对这种场景，一般会使用防抖技术来减少事件处理程序触发的频率。

### 移出事件处理程序

有些浏览器（尤其是IE）在移除DOM节点后仍会保存其事件处理程序的引用，因此最好手动清除一下事件处理程序。

### 加载状态

出于性能考虑，当所有数据加载完毕后，用户继续下拉时不应该继续加载下一页，此外当正在加载时，用户继续下拉也不应该发送更多请求。如果不做这些限制，那么前端可能会发送很多无用的请求，增加服务器压力，占用网络资源，影响用户体验。
