📕
余烬的小册
数据结构与算法GitHub
  • 总述
  • 经验记录
    • 经验总结
      • web component
      • 前端性能优化总结与分析
      • 我的长列表优化方案
      • 双向通讯解决方案
      • 🔧基于istanbul实现代码测试覆盖率工具
      • 表单系统(低代码表单)
      • 跨端小程序
      • 设计一个即时聊天功能
      • 跨页面通讯 3658699fe4cb4d0bbe22b0881390bacd
    • 踩坑记录
      • HTML踩坑记录
      • Flutter踩坑记录
      • CSS踩坑记录
  • 源码解析
    • Vue源码解析
      • Vue2源码解析系列-响应式原理
      • Vue2源码解析系列-模板编译
      • Vue2源码解析系列-渲染系统(待更新)
        • Patch
      • Vue2源码解析系列-调度系统(todo)
      • Vue2组件更新流程(todo)
      • 如何学习Vue源码
      • Vue3源码解析系列-响应系统
      • Vue3源码解析系列-渲染系统
      • Vue3源码解析系列-组件化和渲染优化(todo)
      • Vue router源码解析(todo)
    • React源码解析(todo)
    • 微前端
      • qiankun源码解析(todo)
    • Vite源码解析
      • Vite Client源码
      • Vite Server源码(todo)
  • 前端技术
    • javaScript
      • ES6
        • 变量声明
        • 模块化
        • 箭头函数
        • 你不知道的for...of
        • 新的数据结构Set和Map
        • JavaScript异步编程终极解决方案
        • ES6 Class 3a0c0a225a534984aabe9a943c5df975
      • JavaScript Error
      • JavaScript浅拷贝和深拷贝
      • JavaScript闭包
      • JavaScript最佳实践
      • JavaScript设计模式
      • async函数的polyfill
    • 深入理解JavaScript系列
      • JavaScript中的继承
      • JavaScript原始类型和引用类型
      • JavaScript浅拷贝和深拷贝
      • JavaScript手写系列
      • JavaScript之this
      • 词法环境和环境记录
      • JavaScript内存泄漏
      • 执行上下文
      • 从ECMAScript规范中学习this
    • TypeScript
      • TypeScript基础教程
      • Typescript高级操作
      • TypeScript工具类型
      • Typescript手写实现工具类型
      • Typescript总结(思维导图)
    • 浏览器原理
      • 页面渲染原理
      • 浏览器存储
      • JavaScript事件循环
      • 事件循环
      • 跨域
      • DOM事件流
      • 从输入url到页面渲染
      • 判断节点之间的关系及根据节点关系查找节点
      • history API
    • 跨端技术
      • Flutter
        • Flutter布局组件
    • 前端工程化
      • Babel插件开发指南
      • 循环依赖
      • pm2
    • React
      • React 状态管理
      • React组件通讯
      • Redux入门
      • Flux
      • React Hook(todo)
      • Effect
  • 服务器端
    • 计算机网络
      • 应用层
      • 运输层
      • 物理层
      • 数据链路层
      • HTTP缓存
      • HTTPS
      • 网络层
    • NodeJs
      • Node.js
      • nodejs最佳实践
      • 《深入浅出Nodejs》小结
      • mongoose填充(populate)
      • node事件循环
      • Node子进程
      • nestjs从零开始
      • nodejs流
      • Nodejs调试
      • Koa源码解析
    • 服务器
      • 操作系统
      • Linux
      • nginx常用指令
      • nginx常用配置
    • 数据库
      • Mysql常见语法
      • MongoDB Indexes索引
  • 前端安全与性能优化
    • 前端安全
      • 跨站脚本攻击(XSS)
      • 跨站点请求伪造(CSRF)
      • 点击劫持
      • 中间人攻击
      • 越权攻击与JWT
    • 前端性能优化
      • 前端监控系统
      • 前端性能优化总结与分析 7348bba0918645b1899006dc842a64c1
      • 衡量性能的核心指标 0dc15ef127cf4f4a9f1137c377420292
      • 图片懒加载
  • 杂项
    • 其他
      • Git
      • web component框架
      • 实现滚动框的懒加载
      • Stencil指南
    • CSS
      • 定位和层叠上下文
      • BFC
      • 盒模型
      • css选择器
      • css变量
由 GitBook 提供支持
在本页
  • 需求背景
  • 功能实现
  • 1. 通过比较子节点高度之和实现
  • 2. 通过计算滚动量实现
  • 3. 通过计算getBoundingClientRect差值实现
  • 4. 通过InteSectionObserver实现
  • 最终效果
  • 优化点
  • 防抖
  • 移出事件处理程序
  • 加载状态
在GitHub上编辑
  1. 杂项
  2. 其他

实现滚动框的懒加载

需求背景

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

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

HTML代码如下

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

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

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节点的高度之和。

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

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

具体实现代码为:

/**
 * 通过比较子节点之和实现
 */
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 这三个属性来实现的,计算公式如下:

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

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

具体代码如下:

/**
 * 通过计算滚动量实现
 */
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)时,就说明已经滚动到底了,此时可以加载下一页了。

具体代码如下:

/**
 * 通过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)。

具体代码如下:

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);
    }
  }
}

最终效果

优化点

防抖

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

移出事件处理程序

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

加载状态

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

上一页web component框架下一页Stencil指南

最后更新于1年前

https://stackblitz.com/edit/js-trhg6h?file=index.js