一开始的思路是在列表页面, 利用useEffect钩子监听location, 在清除函数里获取滚动条的高度, 可惜获取时机好像不对, 一直获取的高度都是0, 通过打印bodyinnterHTML发现, 这时候路由已经开始渲染详情页了

然后开始尝试使用addEventListener监听scroll, 代码如下:

export function useSaveScroll(key: string) {
    const location = useLocation()
    const pathname = location.pathname

    useEffect(() => {
        const handleScroll = () => {
            ls.set(`scroll_path_${pathname}`, window.scrollY)
        }

        const scrollY = ls.get(`scroll_path_${pathname}`) || 0
        window.scrollTo(0, scrollY)
        ls.set(`scroll_path_${pathname}`, 0)

        window.addEventListener('scroll', handleScroll) // 添加滚动事件监听

        return () => {
            window.removeEventListener('scroll', handleScroll) // 组件卸载时移除事件监听
        }
    }, [])
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

结果发现还是有问题, removeEventListener的时机和跳转到详情后渲染的时机还是有先后的问题, 就是说开始渲染详情页时, 还没有把列表的监听滚动事件移除, 导致开始渲染详情页时, 滚动条会因页面高度不够, 自动变成0, 这个变成0还是被列表页的监听滚动事件捕抓到了

最终考虑, 在保存滚动条位置时, 添加防抖, 这个防抖不管怎么样都是必须加的, 不然滚动速度快的, 会频繁保存滚动位置

export function useSaveScroll(key: string) {
    const location = useLocation()
    const pathname = location.pathname

    useEffect(() => {
        const handleScroll = () => {
            if (window.$timeout[key])
                clearTimeout(window.$timeout[key])

            window.$timeout[key] = setTimeout(() => {
                console.log(window.scrollY)
                ls.set(`scroll_path_${pathname}`, window.scrollY)
            }, 200)
        }

        const scrollY = ls.get(`scroll_path_${pathname}`) || 0
        window.scrollTo(0, scrollY)
        ls.set(`scroll_path_${pathname}`, 0)

        window.addEventListener('scroll', handleScroll) // 添加滚动事件监听

        return () => {
            window.removeEventListener('scroll', handleScroll) // 组件卸载时移除事件监听
        }
    }, [])
}
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

当然, 只是加个防抖的话, 前面说的问题依然存在, 但是有这个延迟, 我们就可以在详情页做点什么了, 比如把最后一次的延迟清除了

if (window.$timeout.list)
        clearTimeout(window.$timeout.list)
1
2

最终代码如下:

// src/composables/index.ts

import { useLocation} from 'react-router-dom'
import ls from 'store2'

export function useSaveScroll(key: string) {
    const location = useLocation()
    const pathname = location.pathname

    useEffect(() => {
        const handleScroll = () => {
            if (window.$timeout[key])
                clearTimeout(window.$timeout[key])

            window.$timeout[key] = setTimeout(() => {
                console.log(window.scrollY)
                ls.set(`scroll_path_${pathname}`, window.scrollY)
            }, 200)
        }

        const scrollY = ls.get(`scroll_path_${pathname}`) || 0
        window.scrollTo(0, scrollY)
        ls.set(`scroll_path_${pathname}`, 0)

        window.addEventListener('scroll', handleScroll) // 添加滚动事件监听

        return () => {
            window.removeEventListener('scroll', handleScroll) // 组件卸载时移除事件监听
        }
    }, [])
}
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
// src/pages/topics/index.tsx

import { observer } from 'mobx-react-lite'
import { useAutoScroll } from '~/composables'

const Main = observer(() => {
    // ...其他代码

    useSaveScroll('list')

    // ...其他代码
})

export default Main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/pages/article/index.tsx

import { observer } from 'mobx-react-lite'

const PageArticle = observer(() => {
    if (window.$timeout.list)
        clearTimeout(window.$timeout.list)

    // ...其他代码
})

export default PageArticle
1
2
3
4
5
6
7
8
9
10
11
12

警告

以下方法在新版本reactreact-router中已经失效

需求: 1、需要路由切换动画, 2、从详情页返回列表页时, 需要滚动到历史位置

咋一看, 这不挺简单的吗?

使用redux做数据的临时存储, 在componentWillUnmount钩子里, 读取滚动条位置, 然后保存到本地存储

当从详情页或者其他页面返回列表页时, 判断redux里是否有数据, 如果有数据直接读取, 不再发起请求, 在componentDidMount钩子里读取本地存储的滚动条位置, 然后滚动到相应位置不就行了吗

上面的思路在没有路由动画时, 是可行的, 但是有路由动画的话, 就不行了, 你会发现在componentWillUnmount钩子里取到的滚动条位置, 总是不对的

主要原因是, 为了用户体验, 在详情页需要componentDidMount钩子里将滚动条设置为0, 不然在进入详情页时, 滚动条位置会不在顶部

再加上, 添加了路由动画后, 从列表页进入详情页, 有一小段时间, 列表页和详情页这两个组件会同时存在, 而且两个页面钩子的执行顺序为: 详情页的componentDidMount => 列表页的componentWillUnmount, 所以问题看的出来了

说了半天废话, 还是说解决方案吧, 依靠 react 的钩子是不行了, 但是还可以靠路由的Prompt组件

import { Prompt } from 'react-router-dom'

render() {
    return (
        <div className="main wrap">
            <Prompt
                when
                message={() => {
                    const path = this.props.location.pathname
                    const scrollTop = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop)
                    ls.set(path, scrollTop)
                    return true
                }}
            />
            <div>.........</div>
        </div>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

滚动条的位置是记录下来, 接下来的问题就是这么还原到对应位置, 这里以redux为例:

// 空数据时
this.props.topics => {
    data: [],
    hasNext: 0,
    page: 1,
    pathname: ''
}
// 有数据时
this.props.topics => {
    data: [{...}, {...}, {...}],
    hasNext: 0,
    page: 1,
    pathname: '/'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

1, redux 已存在列表数据

那么只要在componentDidMount读取滚动条位置, 并滚动即可

componentDidMount() {
    console.log(`topics: componentDidMount`)
    const pathname = this.props.location.pathname
    if (this.props.topics.pathname !== '') {
        const scrollTop = ls.get(pathname) || 0
        ls.remove(pathname)
        window.scrollTo(0, scrollTop)
    }
}
1
2
3
4
5
6
7
8
9

2, redux 不存在列表数据, 需要异步请求时, 通过componentDidUpdate钩子判断列表是否渲染完成

componentDidUpdate(prevProps) {
    const pathname = this.props.location.pathname
    if (this.props.topics.pathname !== '' &amp;&amp; this.props.topics.pathname !== prevProps.topics.pathname) {
        const scrollTop = ls.get(pathname) || 0
        ls.remove(pathname)
        window.scrollTo(0, scrollTop)
    }
}
1
2
3
4
5
6
7
8