基于 Stimulus 实现滚动加载组件

滚动加载是一种常见的分页交互方式,经常用在 Timeline 之类希望体验平滑又不需要跳跃浏览的场景。

本文介绍一种基于 Stimulus 实现滚动加载的方法。

前置知识:Intersection Observer API

首先考虑一个问题,当用户浏览到列表底部的时候,用什么方法触发内容加载。

以前的方法是监听浏览器窗口的滚动事件,在滚动位移量接近窗口底部的时候触发加载动作。

现代的浏览器提供了一个专门 API 实现这类需求,这就是 Intersection Observer API。这个 API 用于检测两个元素是否相交,在元素有相交部分时,执行回调。

创建 observer 的代码如下:

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

其中参数的含义为:

  • root:参照的可视区域,默认为浏览器视窗。
  • rootMargin:可视区域的边距,target 进入边距范围内就认为相交。
  • threshold:回调触发的阈值,可以是 0...1 之间的值,也可以是数组。例如 0.5 就 targetroot 内有 50% 相交时触发。

启动 observer 的代码如下:

let target = document.querySelector('#listItem');
observer.observe(target);

开始监视后,target 在进入 root 范围内达到相应阈值时就会启动 callback。注意,无论 threshold 设置什么值,都会在 observe 开始时触发一次回调,这时相交比例为 0。

callback 中,可以获得监视对象的具体属性,实现业务逻辑:

let callback = (entries, observer) => {
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed target element:
    // entry.boundingClientRect
    // entry.intersectionRatio
    // entry.intersectionRect
    // entry.isIntersecting
    // entry.rootBounds
    // entry.target
    // entry.time
  });
};

更多关于 Intersection Observer API 的内容可以查看 https://developer.mozilla.org/zh-CN/docs/Web/API/Intersection_Observer_API

在了解这个 API 之后,可以设计滚动加载的逻辑如下:

  1. 在列表底部放一个元素用作 target
  2. 找到列表的可滚动父级元素作为 root(默认是浏览器窗口)。
  3. 用 Intersection Observer API 监视 targetroot 的相交状态,当 target 进入 rootrootMargin 范围内时,触发加载事件。

结构和样式

首先假设要处理的列表结构如下:

<div id="list" class="list">
  <div class="list__item">List Item 1</div>
  <div class="list__item">List Item 2</div>
  <div class="list__item">List Item 3</div>
  <a class="list__next-link" href="#">Loading...</a>
</div>

添加样式:

.list__item {
  padding: 16px;
  border: 1px solid #ccc;
  list-style: none;
  background: white;
}

.list__next-link {
  display: block;
  padding: 16px;
  text-align: center;
  color: #999;
  text-decoration: none;
}

目前看起来是这个样子:

截屏2021-09-12 21.03.45.png

现在我们能直接看到下一页的链接,下面要实现当这个链接出现在视图里时,自动加载链接的内容,并且拼接到列表尾部。

用 Stimulus 绑定行为

给列表加上 Stimulus 的标记:

<div id="list" class="list" data-controller="pagination">
  <!-- 省略 -->
  <a class="list__next-link" href="#" data-pagination-target="nextPageLink">Loading...</a>
</div>

创建 controller

// pagination_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "name", "output" ]
}

当组件加载时,监视 nextPageLink 目标:

  connect() {
    this.observeNextPageLink()
  }

  observeNextPageLink() {
    // nextPageLinkTarget 存在时才监控
    if (this.hasNextPageLinkTarget) {
      let observer = new IntersectionObserver((entries, observer) => {
        // observer 开始监视时就会触发 callback,所以要检查监视对象是否已经出现在目标区域。
        if (entries[0].isIntersecting) {
          // 关闭监视
          observer.disconnect()
          // 加载下一页
          this.loadNextPage()
        }
      }, {
        // 监视参数
        root: null,
        rootMargin: '40px',
      })
      // 开始监视
      observer.observe(this.nextPageLinkTarget)
    }
  }

接下来实现 loadNextPage。具体怎么加载下一页内容需要和后端配合,这里有很多方法,例如后端返回 json 数据,前端渲染;后端返回 HTML 片段,前端拼接;使用 Turbo Stream 等等。

这里使用的方法是,复用后端已有的分页输出,前端获取下一页内容后,提取其中 ID 相同的部分,将它拼接到当前内容的尾部。这样后端逻辑不需要为这个前端组件添加新的接口,只要确保各个分页的内容有相同的 ID 就行。

具体实现为:

  loadNextPage() {
    // 获取 nextPageLink 的链接内容
    fetch(this.nextPageLinkTarget.href)
      .then((response) => { return response.text()})
      .then((html) => {
        let parser = new DOMParser()
        let doc = parser.parseFromString(html, 'text/html')
        let content = doc.getElementById(this.element.id)
        // 将新内容替换到 nextPageLink 的位置
        this.nextPageLinkTarget.outerHTML = content.innerHTML
        // 旧的 nextPageLinkTarget 已经移除,监控下一个 link。
        this.observeNextPageLink()
      })
  }

以上就是滚动加载的实现。示例的完整代码可以看下方链接:

https://codepen.io/chloerei/pen/OJggLrz


参考资料

1