React中实现返回列表时保持滚动位置scrollTop不变(二)

上一篇: React中实现返回列表时保持滚动位置scrollTop不变(一)

根据上一篇得知,我们需要处理的是:

  1. 保证列表页的滚动容器是body
  2. 缓存列表页的数据到状态管理,返回列表页的时候,直接使用缓存的数据
  3. 处理React中前进(跳转)时,总会保持scrollTop的问题

1. useMobxAntdTable 处理问题2

我的项目是后台管理系统,主要使用的是antd4 + ahooks的useAntdTable 绘制列表页

这里我封装了一个 useMobxAntdTable 用来处理缓存列表数据,用法和useAntdTable一模一样,具体用法详见注释

原理

通过mobx缓存数据,页面载入的时候,通过 location.state.stateType 判断是使用缓存数据,还是使用新请求的数据

以下是源码:

结构:

useMobxAntdTable/_pageStore.ts:

ts 复制代码
import { autorun, makeAutoObservable, runInAction } from 'mobx'

interface IOnePageState {
  formValues: any
  pageState: {
    current: number
    pageSize: number
    total: number
  }
  tableData: any[]
}

class _PageStore {
  constructor() {
    makeAutoObservable(this, {}, { autoBind: true })
  }

  state: Record<string, IOnePageState> = {}

  // actions
  setState = (pageKey: string, key2: string, v: any) => {
    if (!this.state[pageKey]) {
      this.state[pageKey] = {
        formValues: {},
        pageState: {
          current: 1,
          pageSize: 10,
          total: 0,
        },
        tableData: [],
      }
    }
    this.state[pageKey][key2] = v
  }
  removeState = (pageKey: string) => {
    delete this.state[pageKey]
  }
}

const _pageStore = new _PageStore()

export default _pageStore

useMobxAntdTable/index.ts:

ts 复制代码
import { useAntdTable, useLatest, useUpdateEffect } from 'ahooks'
import { useEffect, useRef } from 'react'
import _pageStore from './_pageStore'
import { AntdTableOptions, AntdTableResult, Data, Params, Service } from 'ahooks/lib/useAntdTable/types'
import { useLocation, useNavigate } from 'react-router-dom'

type IStateType = undefined | 'allNew' | 'refresh'

/**
 *
 * @description 用法和 useAntdTable 一样
 * 但是会保存 form 数据和 table 数据到 mobx (注:在mobx中,这个页面的state是以pathname作为key的)
 *
 * 重新访问页面时,默认使用缓存数据,不刷新!(不重新请求table)
 *
 * 如果需要刷新,需要在跳转的时候,传递 state.stateType = 'allNew' | 'refresh'
 * 1. allNew: 使用新的form数据、页码数据,刷新table
 * 2. refresh: 保持form数据、页码数据,刷新table
 *
 * @example
 * navigate(-1) // 返回上一页,使用缓存数据,不刷新
 * navigate(`/web/company/goods/goodsmgr`) // 如果打开过这个页,使用缓存数据,不刷新
 * navigate(`/web/company/goods/goodsmgr`, { state: { stateType: 'allNew' } }) // 使用新的form数据、页码数据,刷新table
 * navigate(`/web/company/goods/goodsmgr`, { state: { stateType: 'refresh' } }) // 保持form数据、页码数据,刷新table
 *
 */
export default function useMobxAntdTable<TData extends Data, TParams extends Params>(
  service: Service<TData, TParams>,
  _options?: AntdTableOptions<TData, TParams>
): AntdTableResult<TData, TParams> {
  const options = _options ?? {}
  const form = options?.form

  const navigate = useNavigate()
  const location = useLocation()
  const locationRef = useLatest(location)
  const stateType = location.state?.stateType as IStateType
  const thisPageKey = location.pathname

  const isUseStoreData = useRef(false) // 标记

  const result = useAntdTable(
    (...args) => {
      if (form) {
        // 每次请求的时候,保存form数据
        const values = form.getFieldsValue()
        _pageStore.setState(thisPageKey, 'formValues', values)
        // 通过 tableProps.onChange 触发时,会缺失 formData 数据,这里传上
        if (!args[1]) {
          args[1] = values
        }
      }

      // 使用缓存数据 不刷新
      if (isUseStoreData.current) {
        isUseStoreData.current = false
        return new Promise((resolve) => {
          return resolve({
            // @ts-ignore
            list: _pageStore.state[thisPageKey]?.tableData ?? [],
            total: _pageStore.state[thisPageKey]?.pageState.total ?? 0,
          })
        })
      }

      // 请求
      return service(...args)
    },
    {
      ...options, //
      manual: true,
    }
  )

  const { tableProps, search, refresh, mutate } = result

  // 初始加载
  useEffect(() => {
    if (stateType === 'allNew') {
      // 不用回显 form 数据,和 table 数据
      _pageStore.removeState(thisPageKey)
      search.submit()
    } else {
      // undifined | 'refresh' 都会回显 form 数据
      // 回显 form 数据
      if (form) {
        if (_pageStore.state[thisPageKey]?.formValues && JSON.stringify(_pageStore.state[thisPageKey].formValues) !== '{}') {
          form.setFieldsValue(_pageStore.state[thisPageKey].formValues)
        }
      }

      // 根据 undifined | 'refresh' 两种情况 选择是否回显 table 数据
      if (_pageStore.state[thisPageKey]?.tableData && _pageStore.state[thisPageKey].tableData.length > 0) {
        // 表格有缓存数据

        if (stateType === 'refresh') {
          isUseStoreData.current = false
        } else {
          isUseStoreData.current = true
        }

        tableProps.onChange?.(_pageStore.state[thisPageKey].pageState) // 使用缓存页码
      } else {
        // 如果表格之前是空的,总要刷新一下
        search.submit()
      }
    }

    // 最后,重置 state.stateType
    if (locationRef.current.state?.stateType && locationRef.current.pathname === location.pathname) {
      navigate(location.pathname, {
        state: {
          ...location.state,
          stateType: undefined,
        },
      })
    }
  }, [])

  ////
  /// 因为 manual=true, 所以 refreshDeps 会失效, 这里写一个 useEffect 来处理
  useUpdateEffect(() => {
    search.submit()
  }, [...(options.refreshDeps ?? [])])

  /// 保存页码和table数据
  useEffect(() => {
    _pageStore.setState(thisPageKey, 'pageState', tableProps.pagination)
  }, [tableProps.pagination])
  useEffect(() => {
    _pageStore.setState(thisPageKey, 'tableData', tableProps.dataSource)
  }, [tableProps.dataSource])

  return result
}

2. useScrollToTopByNavigationType 处理问题3

使用:在详情页调用这个hooks就行

原理:使用 react-router 的useNavigationType 判断跳转类型(是浏览器的返回,还是跳转)

源码:

useScrollToTopByNavigationType.ts:

ts 复制代码
import { useEffect } from 'react'
import { useNavigationType } from 'react-router-dom'

export default function useScrollToTopByNavigationType() {
  const navigationType = useNavigationType()

  useEffect(() => {
    // POP 代表是通过浏览器的前进后退按钮触发的页面切换 或者 navigate(-1) navigate(1)
    if (navigationType !== 'POP') {
      window.scrollTo(0, 0)
    }
  }, [])
}

3. 效果展示

3.1 列表页=>详情页,详情页回到顶部,详情页返回,列表不刷新且滚动位置不变

3.2 如果不想用缓存的数据,可以在 location的state中增加 stateType: allNew

我左侧的菜单跳转都增加了state.stateType = allNew ,所以都是重新加载数据,并且scrollTop回到顶部

效果:

3. 如果不想和useAntdTable耦合, useMobxState

可以参考 useMobxState 这个hooks,仅控制单个变量

源码:

useMobxState/_store.ts:

ts 复制代码
import { autorun, makeAutoObservable, runInAction } from 'mobx'

class _Store {
  constructor() {
    makeAutoObservable(this, {}, { autoBind: true })
  }

  state: {
    [pageKey in string]: {
      [stateName in string]: any
    }
  } = {}

  // actions
  setState = (pageKey: string, stateName: string, v: any) => {
    if (!this.state[pageKey]) {
      this.state[pageKey] = {}
    }
    this.state[pageKey][stateName] = v
  }
  removeState = (pageKey: string, stateName: string) => {
    if (this.state[pageKey]) {
      delete this.state[pageKey][stateName]
    }
  }
}

const _store = new _Store()

export default _store

useMobxState/index.ts:

ts 复制代码
import { Dispatch, SetStateAction, useState, useEffect, useDeferredValue } from 'react'
import _store from './_store'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLatest } from 'ahooks'

/**
 *
 * @description 用法基本和 useState 一样
 * 多一个参数 stateName
 * 保存数据到 mobx (注:在mobx中,这个state是以 pathname + stateName 作为key的)
 *
 * 重新访问页面时,默认使用缓存数据
 *
 * 只有在跳转的时候,传递 state.stateType = 'allNew' 时,才使用新数据
 * 一般和 useMobxAntdTable 配合使用
 */

export default function useMobxState<S>(initialState: S | (() => S), stateName: string): [S, Dispatch<SetStateAction<S>>] {
  const navigate = useNavigate()
  const location = useLocation()
  const locationRef = useLatest(location)
  const stateType = location.state?.stateType as undefined | 'allNew'
  const thisPageKey = location.pathname

  const [state, setState] = useState(() => {
    function getDefaultInitialState() {
      return typeof initialState === 'function' ? (initialState as any)() : initialState
    }

    if (stateType === 'allNew') {
      // 清空 _store
      _store.removeState(thisPageKey, stateName)
      return getDefaultInitialState()
    } else {
      if (_store.state[thisPageKey] && _store.state[thisPageKey][stateName] !== undefined) {
        return _store.state[thisPageKey][stateName]
      } else {
        return getDefaultInitialState()
      }
    }
  })

  // setState
  const mySetState: Dispatch<SetStateAction<S>> = (newValue) => {
    setState((cur) => {
      const _newValue = typeof newValue === 'function' ? (newValue as any)(cur) : newValue
      _store.setState(thisPageKey, stateName, _newValue) // 更新 _store
      return _newValue as S
    })
  }

  useEffect(() => {
    // 最后,重置 state.stateType
    if (locationRef.current.state?.stateType && locationRef.current.pathname === location.pathname) {
      navigate(location.pathname, {
        state: {
          ...location.state,
          stateType: undefined,
        },
      })
    }
  }, [])

  return [state, mySetState]
}
相关推荐
卸任6 小时前
Vibe coding工具使用心得
前端·ai编程
brien97 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance7 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M7 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写7 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李7 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易8 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马8 小时前
前沿视界
前端
kyriewen8 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易8 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员