├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── examples │ ├── DivReverseScroller.tsx │ ├── DivScroller.tsx │ ├── WindowScroller.tsx │ └── utils.ts ├── index.tsx ├── lib │ └── InfiniteScroll.tsx └── react-app-env.d.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea 26 | yarn.lock 27 | package-lock.json 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Haixiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 造一个 react-infinite-scroller 轮子 2 | 3 | > 文章源码: https://github.com/Haixiang6123/my-react-infinite-scroller 4 | > 5 | > 预览链接: http://yanhaixiang.com/my-react-infinite-scroller/ 6 | > 7 | > 参考轮子: https://www.npmjs.com/package/react-infinite-scroller 8 | 9 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/037c57bcf4f54845943e9309ef3251db~tplv-k3u1fbpfcp-zoom-1.image) 10 | 11 | **无限滚动**是一个开发时经常遇到的问题,比如 ant-design 的 List 组件里就推荐使用 [react-infinite-scroller](https://www.npmjs.com/package/react-infinite-scroller) 配合 List 组件一起使用。 12 | 13 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a0a2395b60bf41438c930b83a5bfa439~tplv-k3u1fbpfcp-zoom-1.image) 14 | 15 | 假如我们想自己实现无限滚动,难免要去查 `scroll` 事件,还要搞清 `offsetHeight`, `scrollHeight`, `pageX` 这些奇奇怪怪变量之间的关系,真让人脑袋大。今天就带大家造一个 reac-infinite-scroller 的轮子吧。 16 | 17 | ## offset 公式 18 | 19 | 无限滚动的原理很简单:只要 `很长元素总高度 - 窗口距离顶部高度 - 窗口高度 < 阈值` 就加载更多,前面那一堆下称为 `offset`,表示**还剩多少 px 到达底部**。 20 | 21 | 然后就懵逼了:`scrollY`, `pageY`, `scrollTop`, `offsetTop`, `clientHeight` 这一堆的变量到底用哪个来计算呢?对于大部分人来说,这些变量简直是噩梦一般的存在,总是会傻傻搞不清。 22 | 23 | 这里直接给出计算 offset 的公式,免得大家去查了: 24 | 25 | ```ts 26 | const offset = 很长元素总高度 - 窗口距离顶部高度 - 窗口高度 = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight 27 | 28 | if (offset < this.props.threshold) { 29 | this.props.loadMore() 30 | } 31 | ``` 32 | 33 | 简单说一下这些变量都是个啥: 34 | 35 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/882a4a0c1c0444ba9f8bdcf723b2903f~tplv-k3u1fbpfcp-zoom-1.image) 36 | 37 | * **[scrollHeight](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollHeight): 这个只读属性是一个元素的内容高度,包括由于溢出导致的视图中不可见内容。相当于上面的 “很长元素总高度”** 38 | * **[scrollTop](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/scrollTop): 可以获取或设置一个元素的内容垂直滚动的像素数。相当于上面的 “窗口距离顶部的高度”** 39 | 40 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/110cb6ebde8549f488a67432caa26907~tplv-k3u1fbpfcp-zoom-1.image) 41 | 42 | * **[clientHeight](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/clientWidth): 仅仅包括 padding 的元素高度。相当于上面的 “窗口高度”** 43 | 44 | 总结一下,上面公式里的 `offset` 表示距离底部的 px 值,只要 `offset < threshold` 说明滚动到了底部,开始 `loadMore()`。 45 | 46 | ## 最小实现 47 | 48 | 下面为使用用例,定义 delay 函数用于 mock 延时效果,`fetchMore` 为获取更多数据的函数。 49 | 50 | ```ts 51 | let counter = 0 52 | 53 | const delay = (asyncFn: () => Promise) => new Promise(resolve => { 54 | setTimeout(() => { 55 | asyncFn().then(() => resolve) 56 | }, 1500) 57 | }) 58 | 59 | const App = () => { 60 | const [items, setItems] = useState([]); 61 | 62 | const fetchMore = async () => { 63 | await delay(async () => { 64 | const newItems = [] 65 | 66 | for (let i = counter; i < counter + 50; i++) { 67 | newItems.push(`Counter: ${i} |||| ${new Date().toISOString()}`) 68 | } 69 | setItems([...items, ...newItems]) 70 | 71 | counter += 50 72 | }) 73 | } 74 | 75 | useEffect(() => { 76 | fetchMore().then() 77 | }, []) 78 | 79 | return ( 80 |
81 |
82 | Loading ...
} 86 | > 87 | {items.map(item =>
{item}
)} 88 | 89 |
90 | 91 | ) 92 | } 93 | ``` 94 | 95 | 轮子最简单的实现如下: 96 | 97 | ```ts 98 | interface Props { 99 | loadMore: Function // 加载更多的回调 100 | loader: ReactNode // “加载更多”的组件 101 | threshold: number // 到达底部的阈值 102 | hasMore?: boolean // 是否还有更多可以加载 103 | pageStart?: number // 页面初始页 104 | } 105 | 106 | class InfiniteScroll extends Component { 107 | private scrollComponent: HTMLDivElement | null = null // 当前很很长的内容 108 | private loadingMore = false // 是否正在加载更多 109 | private pageLoaded = 0 // 当前加载页数 110 | 111 | constructor(props: Props) { 112 | super(props); 113 | this.scrollListener = this.scrollListener.bind(this) // scrollListener 用到了 this,所以要 bind 一下 114 | } 115 | 116 | // 滚动监听顺 117 | scrollListener() { 118 | const node = this.scrollComponent 119 | if (!node) return 120 | 121 | const parentNode = node.parentElement 122 | if (!parentNode) return 123 | 124 | // 核心计算公式 125 | const offset = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight 126 | 127 | if (offset < this.props.threshold) { 128 | parentNode.removeEventListener('scroll', this.scrollListener) // 加载的时候去掉监听器 129 | 130 | this.props.loadMore(this.pageLoaded += 1) // 加载更多 131 | this.loadingMore = true // 正在加载更多 132 | } 133 | } 134 | 135 | componentDidMount() { 136 | this.pageLoaded = this.props.pageStart || 0 137 | // Mount 的时候就添加监听器 138 | if (this.scrollComponent && this.scrollComponent.parentElement) { 139 | this.scrollComponent.parentElement.addEventListener('scroll', this.scrollListener) 140 | } 141 | } 142 | 143 | componentDidUpdate() { 144 | // 到达底部时会把监听器临时移除,组件更新的时候,这里再加回来 145 | if (this.scrollComponent && this.scrollComponent.parentElement) { 146 | this.scrollComponent.parentElement.addEventListener('scroll', this.scrollListener) 147 | } 148 | } 149 | 150 | componentWilUnmount() { 151 | // Mount 的时候就添加监听器 152 | if (this.scrollComponent && this.scrollComponent.parentElement) { 153 | this.scrollComponent.parentElement.addEventListener('scroll', this.scrollListener) 154 | } 155 | } 156 | 157 | render() { 158 | const {children, loader} = this.props 159 | 160 | // 获取滚动元素的核心代码 161 | return ( 162 |
this.scrollComponent = node}> 163 | {children} 很长很长很长的东西 164 | {loader} “加载更多” 165 |
166 | ) 167 | } 168 | } 169 | ``` 170 | 171 | 上面就是一个最小实现,有以下注意点: 172 | * scrollListener 用到了 this,所以要 `bind` this,不然 this 为 `undefined` 173 | * parentElement 上添加/移除监听器 174 | * 组件 mount 的时候添加监听器,`offset < threshold` 的时候移除监听器,组件更新后再次添加监听器,unmount 前移除监听器 175 | 176 | 上面添加/移除监听器的代码有点冗余,封装一下: 177 | 178 | ```ts 179 | class InfiniteScroll extends Component { 180 | ... 181 | 182 | // 滚动监听顺 183 | scrollListener() { 184 | const node = this.scrollComponent 185 | if (!node) return 186 | 187 | const parentNode = node.parentElement 188 | if (!parentNode) return 189 | 190 | // 核心计算公式 191 | const offset = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight 192 | 193 | if (offset < this.props.threshold) { 194 | this.detachScrollListener() // 加载的时候去掉监听器 195 | 196 | this.props.loadMore(this.pageLoaded += 1) // 加载更多 197 | this.loadingMore = true // 正在加载更多 198 | } 199 | } 200 | 201 | getParentElement(el: HTMLElement | null): HTMLElement | null { 202 | return el && el.parentElement 203 | } 204 | 205 | attachScrollListener() { 206 | const parentElement = this.getParentElement(this.scrollComponent) 207 | 208 | if (!parentElement) return 209 | 210 | const scrollEl = this.props.useWindow ? window : parentElement 211 | 212 | scrollEl.addEventListener('scroll', this.scrollListener) 213 | scrollEl.addEventListener('resize', this.scrollListener) 214 | } 215 | 216 | detachScrollListener() { 217 | const parentElement = this.getParentElement(this.scrollComponent) 218 | 219 | if (!parentElement) return 220 | 221 | parentElement.removeEventListener('scroll', this.scrollListener) 222 | parentElement.removeEventListener('resize', this.scrollListener) 223 | } 224 | 225 | componentDidMount() { 226 | this.attachScrollListener() 227 | } 228 | componentDidUpdate() { 229 | this.attachScrollListener() 230 | } 231 | componentWillUnmount() { 232 | this.detachScrollListener() 233 | } 234 | 235 | render() { 236 | const {children, loader} = this.props 237 | 238 | // 获取滚动元素的核心代码 239 | return ( 240 |
this.scrollComponent = node}> 241 | {children} 很长很长很长的东西 242 | {loader} “加载更多” 243 |
244 | ) 245 | } 246 | } 247 | ``` 248 | 249 | 上面首先将获取 `parentElement` 的动作抽象出来,再把 `attachScrollListener` 和 `detachScrollListener` 抽象出来。同时,上面还对 resize 事件绑定了监听器,因为当用户 resize 的时候也会出现 `offset < threshold` 的可能,这个时候也需要 `loadMore`。 250 | 251 | 还有一个问题:刚进页面的时候,高度为 0,假如此时 `offset < threshold` 理应触发“加载更多”,然而这个时候用户并没有做任何滚动,滚动事件不会被触发,“加载更多”也不会被触发,这其实并不符合我们的预期。 252 | 253 | 因此,这里可以加一个 `initialLoad` 的 props 指定添加监听器的时候就自动触发一次监听器的代码。 254 | 255 | ```ts 256 | interface Props { 257 | ... 258 | initialLoad?: boolean // 是否第一次就加载 259 | } 260 | 261 | class InfiniteScroll extends Component { 262 | ... 263 | 264 | attachListeners() { 265 | const parentElement = this.getParentElement(this.scrollComponent) 266 | 267 | if (!parentElement) return 268 | 269 | parentElement .addEventListener('scroll', this.scrollListener, this.eventOptions) 270 | parentElement .addEventListener('resize', this.scrollListener, this.eventOptions) 271 | 272 | if (this.props.initialLoad) { 273 | this.scrollListener() 274 | } 275 | } 276 | } 277 | ``` 278 | 279 | ## useWindow 280 | 281 | 上面对 `parentElement` 的限制是比较死的,可以添加 `getParentElement` 这个 props 让开发者自己指定 `parentElement`,这样轮子就会更灵活些。 282 | 283 | ```ts 284 | interface Props { 285 | loadMore: Function 286 | loader: ReactNode 287 | threshold: number 288 | getScrollParent?: () => HTMLElement 289 | } 290 | 291 | class InfiniteScroll extends Component { 292 | ... 293 | 294 | getParentElement(el: HTMLElement | null): HTMLElement | null { 295 | const scrollParent = this.props.getScrollParent && this.props.getScrollParent() 296 | 297 | if (scrollParent) { 298 | return scrollParent 299 | } 300 | 301 | return el && el.parentElement 302 | } 303 | 304 | ... 305 | } 306 | ``` 307 | 308 | 此时们不禁想到,要是开发者想传 `document.body` 作为 `parentElement`,上面的代码还能继续使用么?当然是不行的。`document.body` 和很长很长的元素往往存在很多层嵌套,这些复杂的嵌套关系有时候并不会是我们希望的那样。 309 | 310 | 而在全局 (window) 做无限滚动的例子又比较常见,为了实现全局滚动的功能,这里加一个 `useWindow` props 来表示是否用 `window` 作为滚动的容器。 311 | 312 | ```ts 313 | interface Props { 314 | ... 315 | getScrollParent?: () => HTMLElement // 获取 parentElement 的回调 316 | useWindow?: boolean // 是否以 window 作为 scrollEl 317 | } 318 | ``` 319 | 320 | 如果用全局作为滚动容器,我们需要另一套算计方法来算 `offset` 了,下面给出新的计算公式: 321 | 322 | ```ts 323 | offset = 很长元素总高度 - 窗口距离顶部高度 - 窗口高度 = (当前窗口顶部与很长元素顶部的距离 + offsetHeight) - window.pageYOffset - window.innerHeight 324 | ``` 325 | 326 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/81ee1d678375421cad2e3c9929781fc9~tplv-k3u1fbpfcp-zoom-1.image) 327 | 328 | * [offsetHeight](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/offsetWidth): 是一个只读属性,返回一个元素的布局高度 329 | * [window.pageYOffset](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/scrollY): 其实就是 scrollY 的别名,返回文档在垂直方向已滚动的像素值 330 | * [window.innerHeight](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/innerHeight): 为浏览器窗口的视口的高度 331 | 332 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/31ab27f937b34b35ba9e97a216006392~tplv-k3u1fbpfcp-zoom-1.image) 333 | 334 | 上面公式里“当前窗口顶部与很长元素顶部的距离 + offsetHeigh”在页面里是定死的,而 `window.pageYOffset - window.innerHeight` 会随着滚动而改变,两者相减则为 `offset`。图示: 335 | 336 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a7c940a2f33f40e5a313419e4e0e9c15~tplv-k3u1fbpfcp-zoom-1.image) 337 | 338 | **不过,这里的 “当前窗口顶部与很长元素顶部的距离” 这一步并不能通过变量来获得,只能用 JS 来获取:** 339 | 340 | ```ts 341 | // 元素顶部到页面顶部的距离 342 | calculateTopPosition(el: HTMLElement | null): number { 343 | if (!el) return 0 344 | 345 | return el.offsetTop + this.calculateTopPosition(el.offsetParent as HTMLElement) 346 | } 347 | ``` 348 | 349 | 利用 `calculateTopPosition` 函数,计算 `offset` 的函数为: 350 | 351 | ```ts 352 | // 计算 offset 353 | calculateOffset(el: HTMLElement | null, scrollTop: number) { 354 | if (!el) return 0 355 | 356 | return this.calculateTopPosition(el) + (el.offsetHeight - scrollTop - window.innerHeight) 357 | } 358 | ``` 359 | 360 | 整理上面函数到轮子里: 361 | 362 | ```ts 363 | class InfiniteScroll extends Component { 364 | ... 365 | 366 | scrollListener() { 367 | const node = this.scrollComponent 368 | if (!node) return 369 | 370 | const parentNode = this.getParentElement(node) 371 | if (!parentNode) return 372 | 373 | let offset; 374 | 375 | if (this.props.useWindow) { 376 | const doc = document.documentElement || document.body.parentElement || document.body // 全局滚动容器 377 | const scrollTop = window.pageYOffset || doc.scrollTop // 全局的 "scrollTop" 378 | 379 | offset = this.calculateOffset(node, scrollTop) 380 | } else { 381 | offset = node.scrollHeight - parentNode.scrollTop - parentNode.clientHeight 382 | } 383 | 384 | if (offset < this.props.throttle) { 385 | node.removeEventListener('scroll', this.scrollListener) 386 | 387 | this.props.loadMore(this.pageLoaded += 1) 388 | this.loadingMore = true 389 | } 390 | } 391 | 392 | calculateOffset(el: HTMLElement | null, scrollTop: number) { 393 | if (!el) return 0 394 | 395 | return this.calculateTopPosition(el) + el.offsetHeight - scrollTop - window.innerHeight 396 | } 397 | 398 | calculateTopPosition(el: HTMLElement | null): number { 399 | if (!el) return 0 400 | 401 | return el.offsetTop + this.calculateTopPosition(el.offsetParent as HTMLElement) 402 | } 403 | 404 | attachScrollListener() { 405 | const parentElement = this.getParentElement(this.scrollComponent) 406 | 407 | if (!parentElement) return 408 | 409 | const scrollEl = this.props.useWindow ? window : parentElement 410 | 411 | scrollEl.addEventListener('scroll', this.scrollListener) 412 | } 413 | 414 | detachScrollListener() { 415 | const parentElement = this.getParentElement(this.scrollComponent) 416 | 417 | if (!parentElement) return 418 | 419 | const scrollEl = this.props.useWindow ? window : parentElement 420 | 421 | scrollEl .removeEventListener('scroll', this.scrollListener) 422 | } 423 | 424 | ... 425 | } 426 | ``` 427 | 428 | 上面改动的点有: 429 | 1. 添加和移除监听器时,如果 `useWindow === true`,以 `window` 为 `scrollEl` 430 | 2. 添加计算 topPosition 和 offset 的函数: `calculateTopPosition` 和 `calculateOffset` 431 | 3. 监听器里判断是否 `useWindow`,如果 `true`,使用上面的 `calculateOffset` 计算 offset 432 | 433 | 至此,无限滚动最核心的滚动已经实现了。 434 | 435 | ## isReverse 436 | 437 | 除了向下无限滚动,我们还要考虑无限向上滚动的情况。有人就会问了:一般都是无限向下的呀,哪来的无限向上?很简单,翻找微信的聊天记录不就是无限向上滚动的嘛。 438 | 439 | 首先,在 props 加一个 `isReverse` 用于指定向下还是向上无限滚动。 440 | 441 | ```ts 442 | interface Props { 443 | ... 444 | isReverse?: boolean // 是否为相反的无限滚动 445 | } 446 | ``` 447 | 448 | 那 `isReverse` 会影响哪个部分呢?第一反应肯定是 `loader` 的位置变了: 449 | 450 | ```ts 451 | render() { 452 | const {children, loader, isReverse} = this.props 453 | 454 | const childrenArray = [children] 455 | 456 | if (loader) { 457 | // 根据 isReverse 改变 loader 的插入方式 458 | isReverse ? childrenArray.unshift(loader) : childrenArray.push(loader) 459 | } 460 | 461 | return ( 462 |
this.scrollComponent = node}> 463 | {childrenArray} 464 |
465 | ) 466 | } 467 | ``` 468 | 469 | 然后 `offset` 的计算也要变了。对于向上无限滚动,`offset` 的计算反而变简单了,直接 `offset = scrollTop`。在 `scrollListener` 里修改 `offset` 的计算: 470 | 471 | ```ts 472 | scrollListener() { 473 | const el = this.scrollComponent 474 | if (!el) return 475 | 476 | const parentElement = this.getParentElement(el) 477 | if (!parentElement) return 478 | 479 | let offset; 480 | 481 | if (this.props.useWindow) { 482 | const doc = document.documentElement || document.body.parentElement || document.body 483 | const scrollTop = window.pageYOffset || doc.scrollTop 484 | 485 | offset = this.props.isReverse ? scrollTop : this.calculateOffset(el, scrollTop) 486 | } else { 487 | offset = this.props.isReverse 488 | ? parentElement.scrollTop 489 | : el.scrollHeight - parentElement.scrollTop - parentElement.clientHeight 490 | } 491 | 492 | // 是否到达阈值,是否可见 493 | if (offset < (this.props.threshold || 300) && (el && el.offsetParent !== null)) { 494 | this.detachListeners() 495 | this.beforeScrollHeight = parentElement.scrollHeight 496 | this.beforeScrollTop = parentElement.scrollTop 497 | 498 | if (this.props.loadMore) { 499 | this.props.loadMore(this.pageLoaded += 1) 500 | this.loadingMore = true 501 | } 502 | } 503 | } 504 | ``` 505 | 506 | 我们还要考虑一个问题:向上滚动加载更多内容后,滚动条的位置不应该还停留在 scrollY = 0 的位置,不然会一直加载更多,比如此时滚动到了顶部: 507 | 508 | ``` 509 | 3 <- 到顶部了,开始加载 510 | 2 511 | 1 512 | 0 513 | ``` 514 | 515 | 加载更多后 516 | 517 | ``` 518 | 6 <- 不应该停留在这个位置,因为会再次触发无限滚动,用户体验不友好 519 | 5 520 | 4 521 | 3 <- 应该停留在原始的位置,用户再向上滚动才再次加载更多 522 | 2 523 | 1 524 | 0 525 | ``` 526 | 527 | 为了达到这个效果,我们要记录上一次的 `scrollTop` 和 `scrollHeight`,然后在组件更新的时候更新 `parentElemnt.scrollTop`: 528 | 529 | ```ts 530 | // 当前 scrollTop = 当前 scrollHeight - 上一次的 scrollHeight + 上一交的 scrollTop 531 | parentElement.scrollTop = parentElement.scrollHeight - this.beforeScrollHeight + this.beforeScrollTop 532 | ``` 533 | 534 | 以上面的例子举例: 535 | * parentElement.scrollHeight: 6 - 0 的高度 536 | * beforeScrollHeight: 3 - 0 的高度 537 | * beforeScrollTop: 高度为 0 538 | 539 | 最后更新 `parentElement.scrollTop` 为 3 - 0 的高度,滚动条会停留在 3 这个位置。 540 | 541 | 实现时,首先声明 `beforeScrollHeight` 和 `beforeScrollTop`,并在 `scrollListener` 里进行赋值: 542 | 543 | ```ts 544 | class InfiniteScroll extends Component { 545 | ... 546 | // isReverse 后专用参数 547 | private beforeScrollTop = 0 // 上次滚动时 parentNode 的 scrollTop 548 | private beforeScrollHeight = 0 // 上次滚动时 parentNode 的 scrollHeight 549 | 550 | ... 551 | 552 | scrollListener() { 553 | const el = this.scrollComponent 554 | if (!el) return 555 | 556 | const parentNode = this.getParentElement(el) 557 | if (!parentNode) return 558 | 559 | let offset; 560 | 561 | if (this.props.useWindow) { 562 | const doc = document.documentElement || document.body.parentNode || document.body 563 | const scrollTop = window.pageYOffset || doc.scrollTop 564 | 565 | offset = this.props.isReverse ? scrollTop : this.calculateOffset(el, scrollTop) 566 | } else if (this.props.isReverse) { 567 | offset = parentNode.scrollTop 568 | } else { 569 | offset = el.scrollHeight - parentNode.scrollTop - parentNode.clientHeight 570 | } 571 | 572 | if (offset < (this.props.throttle || 300)) { 573 | this.detachScrollListener() 574 | this.beforeScrollTop = parentNode.scrollTop // 记录上一次的 scrollTop 575 | this.beforeScrollHeight = parentNode.scrollHeight // 记录上一次的 scrollHeight 576 | 577 | if (this.props.loadMore) { 578 | this.props.loadMore(this.pageLoaded += 1) 579 | this.loadingMore = true 580 | } 581 | } 582 | } 583 | 584 | ... 585 | } 586 | ``` 587 | 588 | 然后在 `componentDidUpdate` 里计算并更新滚动条的位置: 589 | 590 | ```ts 591 | componentDidUpdate() { 592 | if (this.props.isReverse && this.props.loadMore) { 593 | const parentElement = this.getParentElement(this.scrollComponent) 594 | 595 | if (parentElement) { 596 | // 更新滚动条的位置 597 | parentElement.scrollTop = parentElement.scrollHeight - this.beforeScrollHeight + this.beforeScrollTop 598 | this.loadingMore = false 599 | } 600 | } 601 | this.attachScrollListener() 602 | } 603 | ``` 604 | 605 | 至此,向上滚动也被我们实现了。 606 | 607 | ## mousewheel 事件 608 | 609 | 在 [Stackoverflow 这个帖子](https://stackoverflow.com/questions/47524205/random-high-content-download-time-in-chrome/47684257#47684257) 中说到:Chrome 下做无限滚动时可能存在加载时间变得超长的问题。 610 | 611 | 目前猜测因为 passive listener 的特性所引发的,帖子里也给出了解决方法:在 mousewheel 里 `e.preventDefault` 就好。 612 | 613 | ```ts 614 | class InfiniteScroll extends Component { 615 | ... 616 | 617 | mousewheelListener(e: Event) { 618 | // 详见: https://stackoverflow.com/questions/47524205/random-high-content-download-time-in-chrome/47684257#47684257 619 | // @ts-ignore mousewheel 事件里存在 deltaY 620 | if (e.deltaY === 1) { 621 | e.preventDefault() 622 | } 623 | } 624 | 625 | attachListeners() { 626 | const parentElement = this.getParentElement(this.scrollComponent) 627 | 628 | if (!parentElement || !this.props.hasMore) return 629 | 630 | const scrollEl = this.props.useWindow ? window : parentElement 631 | 632 | scrollEl.addEventListener('scroll', this.scrollListener) 633 | scrollEl.addEventListener('resize', this.scrollListener) 634 | scrollEl.addEventListener('mousewheel', this.mousewheelListener) 635 | } 636 | 637 | detachMousewheelListener() { 638 | const scrollEl = this.props.useWindow ? window : this.scrollComponent?.parentElement 639 | 640 | if (!scrollEl) return 641 | 642 | scrollEl.removeEventListener('mousewheel', this.mousewheelListener) 643 | } 644 | 645 | detachListeners() { 646 | const scrollEl = this.props.useWindow ? window : this.getParentElement(this.scrollComponent) 647 | 648 | if (!scrollEl) return 649 | 650 | scrollEl.removeEventListener('scroll', this.scrollListener) 651 | scrollEl.removeEventListener('resize', this.scrollListener) 652 | } 653 | 654 | componentWillUnmount() { 655 | this.detachListeners() 656 | this.detachMousewheelListener() 657 | } 658 | 659 | render() { 660 | ... 661 | } 662 | } 663 | ``` 664 | 665 | 上面同时把 `attachScrollListener` 改为 `attachListeners`,并在里面添加 mousewheel 的监听器,在 `componentWillUnmount` 里移除 mousewheel 的监听器。 666 | 667 | ## passive listener 668 | 669 | 上面提到了 passive listener,当监听器添加了 [passive 属性](https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener#%E4%BD%BF%E7%94%A8_passive_%E6%94%B9%E5%96%84%E7%9A%84%E6%BB%9A%E5%B1%8F%E6%80%A7%E8%83%BD) 后,它就是 passive listener(被动监听器)。对 touch 和 mouse 的事件监听不会阻塞页面的滚动,可提高页面滚动性能。[详情可见这篇文章](https://cloud.tencent.com/developer/article/1004401)。 670 | 671 | 这里的两个监听器都可以设置 passive: true 来提高滚动性能,不过我们第一步是要检测当前浏览器是否支持被动监听器。 672 | 673 | ```ts 674 | isPassiveSupported() { 675 | let passive = false 676 | 677 | const testOptions = { 678 | get passive() { 679 | passive = true 680 | return true 681 | } 682 | } 683 | 684 | try { 685 | const testListener = () => { 686 | } 687 | document.addEventListener('test', testListener, testOptions) 688 | // @ts-ignore 仅用于测试是否可以使用 passive listener 689 | document.removeEventListener('test', testListener, testOptions) 690 | } catch (e) { 691 | } 692 | 693 | return passive 694 | } 695 | ``` 696 | 上面给一个“假的”事件添加了一个“假的”被动监听器,并带个 `testOptions` 作为第三个参数。`testOptions` 利用 ES6 Proxy 的特性判断当前浏览器是否会读取 `passive` 属性,读取了说明支持 passive listener,返回 `true`。 697 | 698 | 再造一个函数获取监听器的 `options`,这个 `options` 包含了 `passive` 和 `useCapture`,前者为是否开启 passive 特性,后者为是否捕获。 699 | 700 | ```ts 701 | interface EventListenerOptions { 702 | useCapture: boolean // 是否捕获 703 | passive: boolean // 是否 passive 704 | } 705 | ``` 706 | 707 | ```ts 708 | class InfiniteScroll extends Component { 709 | ... 710 | private eventOptions = {} // 注册事件的选项 711 | 712 | ... 713 | 714 | isPassiveSupported() { // 当前是否支持 passive 715 | ... 716 | } 717 | 718 | mousewheelListener(e: Event) { 719 | // 详见: https://stackoverflow.com/questions/47524205/random-high-content-download-time-in-chrome/47684257#47684257 720 | // @ts-ignore mousewheel 事件里存在 deltaY 721 | if (e.deltaY === 1 && !this.isPassiveSupported()) { 722 | e.preventDefault() 723 | } 724 | } 725 | 726 | getEventListenerOptions() { // 获取监听器的 options 727 | const options: EventListenerOptions = {useCapture: this.props.useCapture || false, passive: false} 728 | 729 | if (this.isPassiveSupported()) { 730 | options.passive = true 731 | } 732 | 733 | return options 734 | } 735 | 736 | attachListeners() { 737 | const parentElement = this.getParentElement(this.scrollComponent) 738 | 739 | if (!parentElement || !this.props.hasMore) return 740 | 741 | const scrollEl = this.props.useWindow ? window : parentElement 742 | 743 | scrollEl.addEventListener('mousewheel', this.mousewheelListener, this.eventOptions) // 使用 eventOptions 744 | scrollEl.addEventListener('scroll', this.scrollListener, this.eventOptions) // 使用 eventOptions 745 | scrollEl.addEventListener('resize', this.scrollListener, this.eventOptions) // 使用 eventOptions 746 | 747 | if (this.props.initialLoad) { 748 | this.scrollListener() 749 | } 750 | } 751 | 752 | detachMousewheelListener() { 753 | const scrollEl = this.props.useWindow ? window : this.scrollComponent?.parentElement 754 | 755 | if (!scrollEl) return 756 | 757 | scrollEl.removeEventListener('mousewheel', this.mousewheelListener, this.eventOptions) // 使用 eventOptions 758 | } 759 | 760 | detachListeners() { 761 | const scrollEl = this.props.useWindow ? window : this.getParentElement(this.scrollComponent) // 使用 eventOptions 762 | 763 | if (!scrollEl) return 764 | 765 | scrollEl.removeEventListener('scroll', this.scrollListener, this.eventOptions) // 使用 eventOptions 766 | scrollEl.removeEventListener('resize', this.scrollListener, this.eventOptions) // 使用 eventOptions 767 | } 768 | 769 | ... 770 | } 771 | ``` 772 | 773 | **注意:被动监听器里是不能有 `e.preventDefault` 的,因此在 `mousewheelListener` 里要做 `isPassiveSupported` 的判断,如果支持了 passive,就不执行 `e.preventDefault`。** 774 | 775 | ## 优化 render 函数 776 | 777 | 最后,`render` 函数还可以再进一步优化。首先,在 props 里添加 `element` 和 `ref`,前者为容器的 tagName,后者为获取滚动元素的回调: 778 | 779 | ```ts 780 | interface Props { 781 | ... 782 | element?: string // 元素 tag 名 783 | ref?: (node: HTMLElement | null) => void // 获取要滚动的元素 784 | } 785 | ``` 786 | 787 | 然后改写 `render` 788 | 789 | ```ts 790 | render() { 791 | const { 792 | // 内部 props 793 | children, element, hasMore, isReverse, loader, loadMore, initialLoad, 794 | pageStart, ref, threshold, useCapture, useWindow, getScrollParent, 795 | // 需要 pass 的 props 796 | ...props 797 | } = this.props 798 | 799 | const childrenArray = [children] 800 | 801 | if (hasMore && loader) { 802 | isReverse ? childrenArray.unshift(loader) : childrenArray.push(loader) 803 | } 804 | 805 | const passProps = { 806 | ...props, 807 | ref: (node: HTMLElement | null) => { 808 | this.scrollComponent = node 809 | if (ref) { 810 | ref(node) 811 | } 812 | } 813 | } 814 | 815 | return createElement(element || 'div', passProps, childrenArray) 816 | } 817 | ``` 818 | 819 | 这一步主要优化了 3 个点: 820 | 1. 将 tagName (`element` props) 也作为 props 暴露出来 821 | 2. 将剩下的 props 透传给滚动元素 822 | 3. 在 `passProps` 里添加 `ref`,开发者可以通过 `ref` 获取滚动元素 823 | 824 | ## 总结 825 | 826 | 这篇文章主要带大家过了一遍 [react-infinite-scroller](https://www.npmjs.com/package/react-infinite-scroller) 的源码,从 0 到 1 地实现了一遍源码。 827 | 828 | 核心部分为 `offset < threshold` 则加载更多,`offset` 的计算规则如下: 829 | 830 | 1. 向下滚动:`el.scrollHeight - parentElement.scrollTop - parentElement.clientHeight` 831 | 2. 下上滚动:`parentElement.scrollTop` 832 | 3. window 向下滚动:` calculateTopPosition(el) + (el.offsetHeight - scrollTop - window.innerHeight)` 833 | 1. 其中 calculateTopPosition 为递归地计算元素顶部到浏览器窗口顶部的距离 834 | 4. window 向上滚动:`window.pageYOffset || doc.scrollTop` 835 | 1. 其中 doc 为 `doc = document.documentElement || document.body.parentElement || document.body` 836 | 837 | 当然,这个轮子还有很多细节值得我们注意: 838 | 839 | 1. 除了 scroll 事件,resize 事件也应该触发加载更多 840 | 2. 在 mount 和 update 的时候添加 listener,在 unmounte 和 `offset < threshold` 时移除 listener。还有一点,在添加 listener 的时候可以触发一次 listener 作为 `initialLoad` 841 | 3. 向上滚动的时候,在 `componentDidUpdate` 里要把滚动条设置为上一次停留的地方,否则滚动条会一直在顶部,一直触发“加载更多” 842 | 4. 在 mousewheel 里 `e.preventDefault` 解决“加载更多”时间超长的问题 843 | 5. 添加被动监听器,提高页面滚动性能 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-react-infinite-scroller", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/jest": "^26.0.15", 10 | "@types/node": "^12.0.0", 11 | "@types/react": "^17.0.0", 12 | "@types/react-dom": "^17.0.0", 13 | "react": "^17.0.2", 14 | "react-dom": "^17.0.2", 15 | "react-infinite-scroller": "^1.2.4", 16 | "react-scripts": "4.0.3", 17 | "typescript": "^4.1.2", 18 | "web-vitals": "^1.0.1" 19 | }, 20 | "homepage": "https://yanhaixiang.com/my-react-infinite-scroller", 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject", 26 | "deploy": "npm run build && gh-pages -d build" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "react-app/jest" 32 | ] 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "gh-pages": "^3.1.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/my-react-infinite-scroller/21811756da3dd395ebfc901797ae78c2de3eee64/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/my-react-infinite-scroller/21811756da3dd395ebfc901797ae78c2de3eee64/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixiangyan/my-react-infinite-scroller/21811756da3dd395ebfc901797ae78c2de3eee64/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import DivScroller from './examples/DivScroller' 3 | import WindowScroller from './examples/WindowScroller' 4 | import DivReverseScroller from "./examples/DivReverseScroller"; 5 | 6 | type Pane = '1' | '2' | '3' 7 | 8 | const App = () => { 9 | const [pane, setPane] = useState('1'); 10 | 11 | return ( 12 |
13 |
14 | 15 | 16 | 17 |
18 | 19 |
20 | {pane === '1' && } 21 | {pane === '2' && } 22 | {pane === '3' && } 23 |
24 |
25 | ) 26 | } 27 | 28 | export default App 29 | -------------------------------------------------------------------------------- /src/examples/DivReverseScroller.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react' 2 | import InfiniteScroll from '../lib/InfiniteScroll' 3 | import {delay} from './utils' 4 | 5 | let counter = 0 6 | 7 | const DivReverseScroller = () => { 8 | const [items, setItems] = useState([]); 9 | 10 | const fetchMore = async () => { 11 | await delay(async () => { 12 | const newItems = [] 13 | 14 | for (let i = counter; i < counter + 50; i++) { 15 | newItems.push(`Counter: ${i} |||| ${new Date().toISOString()}`) 16 | } 17 | setItems([...items, ...newItems]) 18 | 19 | counter += 50 20 | }) 21 | } 22 | 23 | useEffect(() => { 24 | fetchMore().then() 25 | }, []) 26 | 27 | return ( 28 |
29 | Loading ...
} 35 | > 36 | {items.slice().reverse().map(item =>
{item}
)} 37 | 38 | 39 | ) 40 | } 41 | 42 | export default DivReverseScroller 43 | -------------------------------------------------------------------------------- /src/examples/DivScroller.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react' 2 | import InfiniteScroll from '../lib/InfiniteScroll' 3 | import {delay} from './utils' 4 | 5 | let counter = 0 6 | 7 | const DivScroller = () => { 8 | const [items, setItems] = useState([]); 9 | 10 | const fetchMore = async () => { 11 | await delay(async () => { 12 | const newItems = [] 13 | 14 | for (let i = counter; i < counter + 50; i++) { 15 | newItems.push(`Counter: ${i} |||| ${new Date().toISOString()}`) 16 | } 17 | setItems([...items, ...newItems]) 18 | 19 | counter += 50 20 | }) 21 | } 22 | 23 | useEffect(() => { 24 | fetchMore().then() 25 | }, []) 26 | 27 | return ( 28 |
29 | Loading ...
} 34 | > 35 | {items.map(item =>
{item}
)} 36 | 37 | 38 | ) 39 | } 40 | 41 | export default DivScroller 42 | -------------------------------------------------------------------------------- /src/examples/WindowScroller.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react' 2 | import InfiniteScroll from '../lib/InfiniteScroll' 3 | import {delay} from './utils' 4 | 5 | let counter = 0 6 | 7 | const DivScroller = () => { 8 | const [items, setItems] = useState([]); 9 | 10 | const fetchMore = async () => { 11 | await delay(async () => { 12 | const newItems = [] 13 | 14 | for (let i = counter; i < counter + 150; i++) { 15 | newItems.push(`Counter: ${i} |||| ${new Date().toISOString()}`) 16 | } 17 | setItems([...items, ...newItems]) 18 | 19 | counter += 150 20 | }) 21 | } 22 | 23 | useEffect(() => { 24 | fetchMore().then() 25 | }, []) 26 | 27 | return ( 28 |
29 | Loading ...
} 34 | > 35 | {items.map(item =>
{item}
)} 36 | 37 | 38 | ) 39 | } 40 | 41 | export default DivScroller 42 | -------------------------------------------------------------------------------- /src/examples/utils.ts: -------------------------------------------------------------------------------- 1 | type AsyncFn = () => Promise 2 | 3 | export const delay = (asyncFn: AsyncFn) => new Promise(resolve => { 4 | setTimeout(() => { 5 | asyncFn().then(() => resolve) 6 | }, 1500) 7 | }) 8 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /src/lib/InfiniteScroll.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Component, createElement, ReactNode} from 'react' 3 | 4 | interface EventListenerOptions { 5 | useCapture: boolean 6 | passive: boolean 7 | } 8 | 9 | interface Props { 10 | loadMore: (pageLoaded: number) => void // 加载更多的回调 11 | loader: ReactNode // 显示 Loading 的元素 12 | initialLoad?: boolean // 是否第一次就加载 13 | element?: string // 元素 tag 名 14 | ref?: (node: HTMLElement | null) => void // 获取要滚动的元素 15 | threshold?: number // offset 临界值,小于则开始加载 16 | isReverse?: boolean // 是否为相反的无限滚动 17 | hasMore?: boolean // 是否还有更多可以加载 18 | pageStart?: number // 页面初始页 19 | getScrollParent?: () => HTMLElement // 获取 parentElement 的回调 20 | useWindow?: boolean // 是否以 window 作为 scrollEl 21 | useCapture?: boolean // 是否注册为捕获事件 22 | } 23 | 24 | class InfiniteScroll extends Component { 25 | private scrollComponent: HTMLElement | null = null // 当前滚动的组件 26 | private loadingMore = false // 是否正在加载更多 27 | private pageLoaded = 0 // 当前加载页数 28 | private eventOptions = {} // 注册事件的选项 29 | // isReverse 后专用参数 30 | private beforeScrollTop = 0 // 上次滚动时 parentNode 的 scrollTop 31 | private beforeScrollHeight = 0 // 上次滚动时 parentNode 的 scrollHeight 32 | 33 | // 默认 props 34 | static defaultProps = { 35 | initialLoad: true, 36 | element: 'div', 37 | threshold: 300, 38 | isReverse: false, 39 | hasMore: true, 40 | pageStart: 0, 41 | getScrollParent: null, 42 | useWindow: true, 43 | useCapture: false 44 | } 45 | 46 | constructor(props: Props) { 47 | super(props); 48 | 49 | this.scrollListener = this.scrollListener.bind(this) 50 | this.getEventListenerOptions = this.getEventListenerOptions.bind(this) 51 | this.mousewheelListener = this.mousewheelListener.bind(this) 52 | } 53 | 54 | componentDidMount() { 55 | this.pageLoaded = this.props.pageStart || 0 56 | this.eventOptions = this.getEventListenerOptions() 57 | this.attachListeners() 58 | } 59 | 60 | componentDidUpdate() { 61 | if (this.props.isReverse && this.loadingMore) { 62 | const parentElement = this.getParentElement(this.scrollComponent) 63 | 64 | if (parentElement) { 65 | parentElement.scrollTop = parentElement.scrollHeight - this.beforeScrollHeight + this.beforeScrollTop 66 | this.loadingMore = false 67 | } 68 | } 69 | this.attachListeners() 70 | } 71 | 72 | componentWillUnmount() { 73 | this.detachListeners() 74 | this.detachMousewheelListener() 75 | } 76 | 77 | isPassiveSupported() { 78 | let passive = false 79 | 80 | const testOptions = { 81 | get passive() { 82 | passive = true 83 | return true 84 | } 85 | } 86 | 87 | try { 88 | const testListener = () => { 89 | } 90 | document.addEventListener('test', testListener, testOptions) 91 | // @ts-ignore 仅用于测试是否可以使用 passive listener 92 | document.removeEventListener('test', testListener, testOptions) 93 | } catch (e) { 94 | } 95 | 96 | return passive 97 | } 98 | 99 | getEventListenerOptions() { 100 | const options: EventListenerOptions = {useCapture: this.props.useCapture || false, passive: false} 101 | 102 | if (this.isPassiveSupported()) { 103 | options.passive = true 104 | } 105 | 106 | return options 107 | } 108 | 109 | scrollListener() { 110 | const el = this.scrollComponent 111 | if (!el) return 112 | 113 | const parentElement = this.getParentElement(el) 114 | if (!parentElement) return 115 | 116 | let offset; 117 | 118 | if (this.props.useWindow) { 119 | const doc = document.documentElement || document.body.parentElement || document.body 120 | const scrollTop = window.pageYOffset || doc.scrollTop 121 | 122 | offset = this.props.isReverse ? scrollTop : this.calculateOffset(el, scrollTop) 123 | } else { 124 | offset = this.props.isReverse 125 | ? parentElement.scrollTop 126 | : el.scrollHeight - parentElement.scrollTop - parentElement.clientHeight 127 | } 128 | 129 | // 是否到达阈值,是否可见 130 | if (offset < (this.props.threshold || 300) && (el && el.offsetParent !== null)) { 131 | this.detachListeners() 132 | this.beforeScrollHeight = parentElement.scrollHeight 133 | this.beforeScrollTop = parentElement.scrollTop 134 | 135 | if (this.props.loadMore) { 136 | this.props.loadMore(this.pageLoaded += 1) 137 | this.loadingMore = true 138 | } 139 | } 140 | } 141 | 142 | mousewheelListener(e: Event) { 143 | // 详见: https://stackoverflow.com/questions/47524205/random-high-content-download-time-in-chrome/47684257#47684257 144 | // @ts-ignore mousewheel 事件里存在 deltaY 145 | if (e.deltaY === 1 && !this.isPassiveSupported()) { 146 | e.preventDefault() 147 | } 148 | } 149 | 150 | calculateOffset(el: HTMLElement | null, scrollTop: number) { 151 | if (!el) return 0 152 | 153 | return this.calculateTopPosition(el) + (el.offsetHeight - scrollTop - window.innerHeight) 154 | } 155 | 156 | calculateTopPosition(el: HTMLElement | null): number { 157 | if (!el) return 0 158 | 159 | return el.offsetTop + this.calculateTopPosition(el.offsetParent as HTMLElement) 160 | } 161 | 162 | getParentElement(el: HTMLElement | null): HTMLElement | null { 163 | const scrollParent = this.props.getScrollParent && this.props.getScrollParent() 164 | 165 | if (scrollParent) { 166 | return scrollParent 167 | } 168 | 169 | return el && el.parentElement 170 | } 171 | 172 | attachListeners() { 173 | const parentElement = this.getParentElement(this.scrollComponent) 174 | 175 | if (!parentElement || !this.props.hasMore) return 176 | 177 | const scrollEl = this.props.useWindow ? window : parentElement 178 | 179 | scrollEl.addEventListener('mousewheel', this.mousewheelListener, this.eventOptions) 180 | scrollEl.addEventListener('scroll', this.scrollListener, this.eventOptions) 181 | scrollEl.addEventListener('resize', this.scrollListener, this.eventOptions) 182 | 183 | if (this.props.initialLoad) { 184 | this.scrollListener() 185 | } 186 | } 187 | 188 | detachMousewheelListener() { 189 | const scrollEl = this.props.useWindow ? window : this.scrollComponent?.parentElement 190 | 191 | if (!scrollEl) return 192 | 193 | scrollEl.removeEventListener('mousewheel', this.mousewheelListener, this.eventOptions) 194 | } 195 | 196 | detachListeners() { 197 | const scrollEl = this.props.useWindow ? window : this.getParentElement(this.scrollComponent) 198 | 199 | if (!scrollEl) return 200 | 201 | scrollEl.removeEventListener('scroll', this.scrollListener, this.eventOptions) 202 | scrollEl.removeEventListener('resize', this.scrollListener, this.eventOptions) 203 | } 204 | 205 | render() { 206 | const { 207 | // 内部 props 208 | children, element, hasMore, isReverse, loader, loadMore, initialLoad, 209 | pageStart, ref, threshold, useCapture, useWindow, getScrollParent, 210 | // 需要 pass 的 props 211 | ...props 212 | } = this.props 213 | 214 | const childrenArray = [children] 215 | 216 | if (hasMore && loader) { 217 | isReverse ? childrenArray.unshift(loader) : childrenArray.push(loader) 218 | } 219 | 220 | const passProps = { 221 | ...props, 222 | ref: (node: HTMLElement | null) => { 223 | this.scrollComponent = node 224 | if (ref) { 225 | ref(node) 226 | } 227 | } 228 | } 229 | 230 | return createElement(element || 'div', passProps, childrenArray) 231 | } 232 | } 233 | 234 | export default InfiniteScroll 235 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------