一个hook搞定React项目中的平滑滚动

前言

如果你的项目没有引入 jquery,但是又想方便地控制滚动条,这时候,react-smooth-scroll-hoook可能会帮上忙。

useSmoothScroll

用法

useSmoothScroll 核心在于 scrollTo 方法,可传入目标节点或者滚动距离,以自定义的速度滚动到该节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export const Demo = () => {
const ref = useRef < HTMLDivElement > null
const { scrollTo } = useSmoothScroll({
ref
})

return (
<>
<button onClick={() => scrollTo('#y-item-20')}>
scrollTo('#y-item-20')
</button>
<button onClick={() => scrollTo(400)}>scrollTo(400)</button>

<div
id={'demo-stories'}
ref={ref}
style={{
overflowY: 'scroll',
maxHeight: '200px',
padding: '10px'
}}
>
{Array(100)
.fill(null)
.map((_item, i) => (
<div key={i} id={`y-item-${i}`}>
y-item-{i}
</div>
))}
</div>
</>
)
}

Demo

核心调用:requestAnimationFrame

浏览器事件循环

宏任务 => 微任务 => 重绘前执行rAF callback => GUI线程渲染

概念

是一个特别的异步任务,只是注册的方法不加入异步队列,而是加入渲染这一边的队列中,它在渲染的三个步骤之前被执行。通常用来处理渲染相关的工作。

  • 下次重绘之前执行,处于渲染循环的任务队列中,不属于宏任务或者微任务
  • 跟随浏览器的刷新频率
  • 非激活状态,动画暂停,节省性能开销。
  • 由系统来决定回调函数的执行时机, 如果屏幕刷新率是60Hz,那么回调函数就每16.7ms被执行一次,防止丢帧
  • 位于渲染队列,执行在微任务之后,下一个宏任务之前。

rAF的意义和作用

  • 使用递归结合rAF实现transition或者animation动画。
  • 弥补CSS3动画的一些缺陷,如不能做scrollTop的平滑滚动。
  • CSS3只支持部分曲线方程,需要自动以的话只能使用js动画解决即rAF。
  • 利用递归入渲染队列的任务,间隔时间根据重绘的频率而定,防止了定时器过快导致掉帧,过慢导致卡顿。

原理

  1. 初始化容器 dom 节点
  2. 监听滚动条的状态,如滚动条是否触达容器的两端。
  3. scrollTo 方法:通过 requestAnimationFrame 去设置容器的 scrollLeft/scrollTop,直到滚动到目标位置。

    处理细节

    传入目标节点,计算滚动距离

  • 如果滚动容器是根元素 html 或者 body,那么直接取当前目标节点的上边距(getBoundingClientRect().top/left),否则,还要减去父容器的上边距。
  • 使用requestAnimationFrame,递归执行滚动条位移,设置滚动终点,终止递归。

事件监听

  • 监听滚动条的属性变化,更新滚动容器的大小。
  • 监听窗口的 resize 事件,更新滚动容器的大小。
  • 监听滚动容器内子元素的 dom 变化,更新滚动条位置状态。
  • 监听容器的 scroll 事件,更新滚动条位置状态。

useScrollWatch

用法

useScrollWatch 用于解决类似导航栏定位的问题,可以获取当前滚动条位于传入 list 节点数组中的哪个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
export const ScrollConatainerMode = () => {
const ref = useRef < HTMLDivElement > null
const { scrollTop, curIndex, curItem } = useScrollWatch({
ref,
list: [
{
href: '#item-0'
},
{
href: '#item-10'
},
{
href: '#item-20'
}
],
offset: -10
})
return (
<>
<h2>Scroll Container Mode</h2>
<div>
<p>
<strong>scrollTop:</strong> {scrollTop}
</p>
<p>
<strong>curIndex:</strong> {curIndex}
</p>
<p>
<strong>curHref:</strong> {curItem?.href}
</p>
</div>
<div
style={{
padding: '10px',
maxHeight: '200px',
overflowY: 'scroll'
}}
ref={ref}
>
{Array(100)
.fill(null)
.map((_item, i) => (
<div key={i} id={`item-${i}`}>
item-{i}
</div>
))}
</div>
</>
)
}

Demo

原理

  1. 初始化节点 list
  2. 监听 scroll 事件和子元素的变化,根据滚动条当前的位置,计算出当前滚动条位于 list 中的哪个节点。

处理细节

  • 需要注意,滚动条的初始点不是父容器的顶部,而是父容器下第一个子元素的顶部(即需要考虑内边距对滚动容器起点的影响)

搭建文档

生成参数表格

使用 storybook,集成 react-docgen-typescript-loader ,轻松通过 TS 类型自动生成文档。具体配置如下见*.storybook/main.js *
**

将 stories 文件用于 CodeSandbox

我们可以将文档展示的 demo 一起用于 codesandbox 展示,具体见 ./example

编写测试

单元测试

为工具方法编写单元测试,具体见 ./test/specs ,执行 test 命令

1
tsdx test --passWithNoTests --config jest.unit.config.js

e2e 测试

对于组件渲染或者强交互的组件,我们需要通过端到端测试模拟,摆脱人工测试,实现自动化。具体见 ./test/e2e

测试滚动的有效性

我们通过到列表中的某个元素后,如果该元素的上一个元素在视口中不可见,确定滚动的有效性。

经过文档的查阅,我使用了 isIntersectingViewport 这个方法,通过该方法,可以判断某个元素是否真正离开屏幕或者在视口中不可见。

isIntersectingViewport 的缺陷

在这个过程中,我重写了官方的 isIntersectingViewport 方法,让其支持传入一个误差值。

为什么呢?由于滑动的距离是通过运算得出的,无可避免地产生了精度问题,导致某元素即使表面上完全不可见了,却由于精度问题, isIntersectingViewport 返回了错误的判断(例如期望值是 0,可能返回 0.000000001)。

以下为重写代码,可以重点关注增加的 threshold ,传入后 最终的显示比例会加上这个值进行精度修正。

该问题已经向 puppetter 提交了 pr,但尚未处理。https://github.com/puppeteer/puppeteer/pull/6497

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function isIntersectingViewport(
elm: ElementHandle<Element>,
options?: {
threshold?: number
}
): Promise<boolean> {
return await elm.evaluate<(element: Element, options) => Promise<boolean>>(
// 注意:回调函数在浏览器中执行,不能获取node中的变量
async (element, options) => {
const { threshold = 0 } = options || {}
const visibleRatio = await new Promise((resolve) => {
const observer = new IntersectionObserver((entries) => {
resolve(entries[0].intersectionRatio)
observer.disconnect()
})
observer.observe(element)
})
return Number(visibleRatio) + threshold > 0
},
options
)
}

在这里,我们在使用 puppeteer 提供的 api 的时候,需要注意,回调函数在浏览器环境下执行,而不是在 node 环境下,所以两边的环境变量不一样,是不能相互读取的。

发布

使用 github-action 完成发布,具体脚本,流程如下:

  • storybook.yml:在 stories/**example.** 或者 Reame.md 发生变更的时候,我们执行文档发布作业。
  • release.yml: 在 src/** 源码发生变更的时候,我们执行 npm 发布和文档发布作业。