react-activation实现keepAlive,支持返回传参(react-router v5最终版)

2,789 阅读7分钟

更新

2022.08.11

    1. 改用一个全局变量去记录前一个页面 fromPathCache
    1. 为了避免useEffect闭包的问题,我把history.listen需要执行的方法抽出来,然后写了一个useLatestFn自定义hook,保证监听里面拿到的数据总是最新的
    1. 不知道为什么新版本的react-activation中的useAliveController执行完得到的是一个空对象,导致我无法清除keepAlive缓存,我把react-activation的版本锁定为0.9.12就好了

2022.08.28

aliveController?.refreshScope 改成了 aliveController?.dropScope,否则离开的页面相当于重新加载一遍,里面的请求也会执行。

const aliveController = useAliveController()

...
    if (nameKey) {
      aliveController?.dropScope && aliveController.dropScope(nameKey) // aliveController?.refreshScope 改成了 aliveController?.dropScope
    } else {
      aliveController?.clear && aliveController.clear()
    }
...

2022.08.30

react18里有一个未开放的api <Offscreen>,这个是官方将要推出的keepAlive,目前还没有稳定版
参考资料:github.com/reactwg/rea…

2022.11.07

往github上传了一个demo,项目是一个微前端项目,react-keep-alive用在子应用mall-web中,可以搜索下一下react-keep-alive关键字找到使用的地方

github.com/jiqishoubi/…

原文

介绍1

后台管理系统,列表页跳转详情页,想要实现类似vue keep-alive的效果。

具体表现为:

  • 从列表页A跳转A的详情页,列表页A缓存
    • 详情页没做任何操作,跳回列表页A,列表页A不刷新,列表页A页码不变
    • 详情页进行了编辑操作,跳回列表页A,列表页A刷新,列表页A页码不变
    • 详情页进行了新建操作,跳回列表页A,列表页A刷新,列表页A页码变为1
  • 从列表页A跳转列表页B,列表页A不缓存,即各个列表之间跳转不缓存

总结就是,一个页面只有跳转指定页面的时候才缓存,并且当返回这个被缓存的页面时,可以控制是否刷新(返回传参)。

介绍2

上一篇文章中,是配合状态管理实现的,但是这样感觉耦合比较大,这个版本不依赖状态管理。

所需依赖

"react": "17.x",
"react-dom": "17.x",
"react-router-dom": "5.3.3",
"react-activation": "0.9.12", // 2022.08.11 锁定版本

注意:这里react-router的版本是5
注意:这里react-router的版本是5
注意:这里react-router的版本是5

完整代码

components/react-keep-alive

components
└───react-keep-alive
        index.js
        useLatestFn.js // 2022.08.11 新增
        utils.js

react-keep-alive/index.js

import { useEffect, useRef } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import KeepAlive, { AliveScope, useAliveController, useActivate } from 'react-activation'
import { treeToList } from './utils'
import useLatestFn from './useLatestFn'

let allFlatRoutes = []
let keepAliveRoutes = []
let HISTORY_UNLISTEN = null
let fromPathCache = '' // 记录前一个页面

// 初始化 allFlatRoutes keepAliveRoutes
function initRoutes(routes) {
  allFlatRoutes = treeToList(routes) // 所有路由
  keepAliveRoutes = allFlatRoutes.filter((item) => item.meta?.keepAlive) // keepAlive的路由
}

// 判断props.children是否需要被 <KeepAlive> 包裹
function KeepAliveWrapper(props) {
  const location = useLocation()
  const curPath = location.pathname
  const routeItem = keepAliveRoutes.find((item) => item.path == curPath)

  let dom = props.children
  if (routeItem) {
    dom = (
      <KeepAlive
        id={curPath} // id 用于多个keepAlive // id 一定要加 否则 keepAlive的页面 跳转 另一个keepAlive的页面 会有问题
        name={curPath} // name 用于手动控制缓存
      >
        {props.children}
      </KeepAlive>
    )
  }
  return dom
}

// 监听路由 手动控制 keepAlive缓存
function useKeepAlive() {
  const location = useLocation()
  const history = useHistory()
  const aliveController = useAliveController()

  // 清除keep alive缓存 
  function clearKeepAlive(nameKey) {
    setTimeout(() => {
      try {
        const cachingNodes = aliveController?.getCachingNodes()
        console.log('cachingNodes', cachingNodes)
        if (nameKey) {
          aliveController?.dropScope && aliveController.dropScope(nameKey)
        } else {
          aliveController?.clear && aliveController.clear()
        }
      } catch (e) {
        console.log(e)
      }
    }, 10)
  }

  const listenCallbackRef = useLatestFn((to, type) => {
    const toPath = to.pathname
    const fromPath = fromPathCache || toPath
    console.log('fromPath 当前页面', fromPath)
    console.log('toPath', toPath)
    fromPathCache = toPath

    const routeItem = keepAliveRoutes.find((item) => item.path == fromPath) // from页面 是一个需要keepAlive的页面
    const curPathIsKeepAliveToPath_ParentItem = keepAliveRoutes.find((item) => item.meta?.keepAlive?.toPath == fromPath) // from页面 是一个 需要keepAlive的页面的toPath

    // 控制keepAlive缓存
    if (routeItem) {
      console.log('from页面 是一个需要keepAlive的列表页面', routeItem)
      if (toPath == routeItem.meta?.keepAlive.toPath) {
        console.log('toPath 正好是当前这个路由的 keepAlive.toPath')
      } else {
        console.log('toPath 不是当前这个路由的 keepAlive.toPath,清除')
        clearKeepAlive(fromPath)
      }
    }

    // from页面 是一个 需要keepAlive的页面的toPath
    if (curPathIsKeepAliveToPath_ParentItem) {
      console.log('from页面 是某个keepAlive的页面 的toPath')
      if (curPathIsKeepAliveToPath_ParentItem.path == toPath) {
        console.log('所去的 页面是 parentItem.path,不做什么')
      } else {
        console.log('清除 parentItem.path keepAlive')
        clearKeepAlive(curPathIsKeepAliveToPath_ParentItem.path)
      }
    }
  })

  useEffect(() => {
    HISTORY_UNLISTEN = history.listen((to, type) => {
      listenCallbackRef.current && listenCallbackRef.current(to, type)
    })
    return () => {
      HISTORY_UNLISTEN && HISTORY_UNLISTEN()
    }
  }, [])

  return KeepAliveWrapper
}

/**
 *
 * activate 传参
 */

const KEEP_ALIVE_OPTIONS_KEY = (key) => {
  return window.location.origin + '_KEEP_ALIVE_OPTIONS_KEY_' + key
}

// 页面activate的时候触发 一般就是从detail页返回
function useActivateWithOptions(activatePageKey, callback) {
  useActivate(() => {
    let options = {}
    try {
      let optionsJsonStr = localStorage.getItem(KEEP_ALIVE_OPTIONS_KEY(activatePageKey))
      const obj = JSON.parse(optionsJsonStr)
      if (Object.prototype.toString.call(obj) === '[object Object]') {
        options = obj
      }
    } catch {}
    localStorage.removeItem(KEEP_ALIVE_OPTIONS_KEY(activatePageKey))

    callback && callback(options)
  })
}

function setActivateOptions(activatePageKey, options = {}) {
  const optionsJsonStr = JSON.stringify(options)
  localStorage.setItem(KEEP_ALIVE_OPTIONS_KEY(activatePageKey), optionsJsonStr)
}

export {
  initRoutes,
  AliveScope,
  KeepAliveWrapper,
  useKeepAlive,
  //
  useActivateWithOptions as useActivate,
  setActivateOptions,
}

react-keep-alive/useLatestFn.js

import { useEffect, useRef } from 'react'

// 2022.08.11 新增
function useLatestFn(fn) {
  const fnRef = useRef(null)
  useEffect(() => {
    fnRef.current = fn
  })
  return fnRef
}

export default useLatestFn

react-keep-alive/utils.js

// tree扁平化
export function treeToList(tree, childrenKey = 'routes') {
  let arr = []

  tree.forEach((item) => {
    if (item[childrenKey] && item[childrenKey].length > 0) {
      arr = [...arr, ...treeToList(item[childrenKey], childrenKey)]
    } else {
      arr.push(item)
    }
  })

  return arr
}

解析/使用

1、给路由增加meta

这个项目使用的是集中式配置路由,我增加了meta属性,meta.keepAlive存在表示这是一个需要被keepAlive的路由,meta.keepAlive.toPath表示只有当前往这个路由的时候,需要缓存

const routes = [
    ...
    {
        name: '商品管理', 
        path: '/web/supplier/goods/mallgoodsmgr',
        component: './supplier/goods/goodsManage',
        meta: {
          keepAlive: {
            toPath: '/web/supplier/goods/mallgoodsmgr/detail', // 只有去详情页的时候 才需要缓存 商品管理(列表页)路由
          },
        },
    }
    ...
]

2、根组件中代码

import { observer } from 'mobx-react'
import { Card } from 'antd'
import ContentLayout from '@/components/layout/ContentLayout'
import HeaderAccount from '@/components/HeaderAccount'
import { initRoutes, AliveScope, KeepAliveWrapper, useKeepAlive } from '@/components/react-keep-alive'
import routes from '../../../config/routes'
import styles from './index.less'

initRoutes(routes) // 这里先初始化一下routes

function Index(props) {
  useKeepAlive()
  
  return (
      <div className={styles.main_wrap}>
        <AliveScope>
          <Card bordered={false} bodyStyle={{ padding: 10 }} style={{ minHeight: '50vh' }}>
            <KeepAliveWrapper>{props.children}</KeepAliveWrapper>
          </Card>
        </AliveScope>
      </div>
  )
}

export default observer(Index)

(1) initRoutes

initRoutes传入树形的routes,通过tree的扁平化计算,拿到全部的路由allFlatRoutes,和需要keepAlive的(即有meta.keepAlive属性)路由keepAliveRoutes,这两个变量后面会用到

// 初始化 allFlatRoutes keepAliveRoutes
function initRoutes(routes) {
  allFlatRoutes = treeToList(routes) // 所有路由
  keepAliveRoutes = allFlatRoutes.filter((item) => item.meta?.keepAlive) // keepAlive的路由
}

(2) <AliveScope/><KeepAliveWrapper>

在根组件中,用<AliveScope/>包裹整个应用,react-activation中需要缓存的页面要用<KeepAlive/>包裹。文档中这部分写在<App/>中,因为我的项目是umi所以写在了layouts里。

我封装了一个<KeepAliveWrapper>,其实里面就是做了一个判断,如果当前的页面可以从keepAliveRoutes中找到,说明当前页面是一个需要keepAlive的页面,就用<KeepAlive>包裹

// 判断props.children是否需要被 <KeepAlive> 包裹
function KeepAliveWrapper(props) {
  const location = useLocation()
  const curPath = location.pathname
  const routeItem = keepAliveRoutes.find((item) => item.path == curPath)

  let dom = props.children
  if (routeItem) {
    dom = (
      <KeepAlive
        id={curPath} // id 用于多个keepAlive // id 一定要加 否则 keepAlive的页面 跳转 另一个keepAlive的页面 会有问题
        name={curPath} // name 用于手动控制缓存
      >
        {props.children}
      </KeepAlive>
    )
  }
  return dom
}

(3) useKeepAlive 主要用于监听路由的变化,手动控制keepAlive的缓存

介绍概念:

  • fromPath指从哪个页面跳转的,即当前所在页面
  • toPath指跳转的页面

我用fromPathCache这个变量记录前一个页面。 之后就是用fromPath和toPath,去判断是否清除fromPath的keepAlive缓存

3、从详情页返回列表时传参,控制列表页是否刷新

react-activation提供的useActivate勾子是不支持传参,这里我们优化一下。

我的思路是,在详情页把参数options保存在本地缓存,返回列表的时候会触发react-activation的useActivate,读取本地缓存中的options返出来,然后立即localStorage.removeItem掉,这样就不会污染本地缓存环境。

然后我还增加了一个activatePageKey,指定是给哪个keepAlive的页面传参


const KEEP_ALIVE_OPTIONS_KEY = (key) => {
  return window.location.origin + '_KEEP_ALIVE_OPTIONS_KEY_' + key
}

// 页面activate的时候触发 一般就是从detail页返回
function useActivateWithOptions(activatePageKey, callback) {
  useActivate(() => {
    let options = {}
    try {
      let optionsJsonStr = localStorage.getItem(KEEP_ALIVE_OPTIONS_KEY(activatePageKey))
      const obj = JSON.parse(optionsJsonStr)
      if (Object.prototype.toString.call(obj) === '[object Object]') {
        options = obj
      }
    } catch {}
    localStorage.removeItem(KEEP_ALIVE_OPTIONS_KEY(activatePageKey))

    callback && callback(options)
  })
}

function setActivateOptions(activatePageKey, options = {}) {
  const optionsJsonStr = JSON.stringify(options)
  localStorage.setItem(KEEP_ALIVE_OPTIONS_KEY(activatePageKey), optionsJsonStr)
}

使用——列表页

useActivateWithOptions导出的时候我修改了名字,直接引入useActivate就行

export {
  initRoutes,
  AliveScope,
  KeepAliveWrapper,
  useKeepAlive,
  //
  useActivateWithOptions as useActivate,
  setActivateOptions,
}
import { useActivate } from '@/components/react-keep-alive'

function Index() {
    ...
    // 从详情页返回的时候 会触发
    useActivate('supplier_goodsMng', (options) => {
        console.log('supplier_goodsMng active options', options)
    })
    ...
}

export default Index

使用——详情页

在新建/编辑成功的时候setActivateOptions设置options,然后正常返回就可以了

import { setActivateOptions } from '@/components/react-keep-alive'
...

...
      .finally(() => {
        setSubmitLoading(false)
      })
      .then(() => {
        message.success('操作成功')
        setActivateOptions('supplier_goodsMng', isEdit ? { isEditSuccess: true } : { isAddSuccess: true })
        history.goBack()
      })
...

相关文档

关于react-router v6

最近在一个react-router v6的项目上尝试增加react-activation,没有成功。。。0.0,我看react-activation的issues中说是支持v6,但我的项目中,被<KeepAlive>包裹的页面总是缓存不下来。

后来了解到react-router v6提供了一个useOuLet,过段时间研究一下是否可以用这个api实现。。

思考

经过这段时间的探索,我产生了一个疑问,为什么vue拥有了很久的keep-alive组件,react实现起来要这么麻烦,需要依赖第三方的插件,官方的想法是怎样的,为什么不推出一个类似keepAlive的功能呢?

我找到了这几个对react keepAlive进行讨论的帖子,总结了一下,react不推出keepalive的原因主要有这几点:

  • 页面很多/很重的情况,可能会引起内存泄漏
  • keepAlive的功能会破坏生命周期,引入了keepalive之后,原有的生命周期会失效,为了解决就需要增加和keepalive相关的勾子,比如useActivate,为了解决一个东西,增加了更多的东西
  • 不符合react的设计理念,react不推崇缓存vnode,推崇缓存状态,这个我想应该是最重要的,可以在react核心开发者dan参与回答的issues中可见一斑

react核心开发者dan参与回答的issues推荐阅读
vue的keep-alive非常实用,为什么angular和react都不跟进呢?
Vue中keep-alive对体验提升的作用与问题及解决方案

结论

可见react貌似不会给出类似keepalive的功能,dan给出的解决方案是:

  • 要么保存状态(state)
  • 要么直接使用最简洁的display:none;