react如何拥有自己的keep-alive

644 阅读6分钟

先看场景

场景1

1.png

如上图所示,当在数万级别的数据中,选择一条,点击查看,跳转到当前数据的详情页,当点击按钮返回返回来,或者是浏览器前进后退等其他操作,返回到列表页的时候。要记录当前列表的位置。也就是要还原点击查看查看前的页面。但是当点击tab菜单按钮的时候,要清除页面信息。

场景2

2.png

如上图所示,当我们编辑内容的时候,一些数据可能从其他页面获得,所以要求,无论切换路由,切换页面,当前页面的编辑信息均不能被置空,只有点击确定 ,重置,表单才内容置空。

场景3

场景一 + 场景二 是更复杂的缓存页面信息场景。

什么是keep-alive

对于上述场景,vue有比较好的解决方案,就是keep-alive组件,这是vue的内置组件,看看官方说明(vue/keep-alive

3.png

react实际开发中保存页面状态的常用方式

  1. 将页面状态保存到redux中,回到该页面时,获取store中该页面的状态,填充到页面中
  2. 将页面状态保存在localStorage中,回到页面从localStorage获取状态

但这样做的弊端也比较多:

  1. 表单组件是非受控组件,是无法缓存下来的;
  2. dom状态是缓存不了的,比如手动添加的一些样式等,常见的滚动条位置也无法回到原来位置;
  3. 还有就是实际情况比较复杂,有富文本组件,是无法直接获取绑定的state的;
  4. 对于页面一些数据,还得重新发请求获取。

react-router根据路由对页面的处理方式

react-router主要是通过Route组件和Switch组件控制页面的切换的,看一下route.js源码中控制页面展示的核心代码:

const match = this.props.computedMatch
            ? this.props.computedMatch 
            : this.props.path
            ? matchPath(location.pathname, this.props)
            : context.match;
const props = { ...context, location, match };

return (
    <RouterContext.Provider value={props}>
        {props.match
            ? children
                ? typeof children === "function"
                ? __DEV__
                    ? evalChildrenDev(children, props, this.props.path)
                    : children(props)
                : children
                : component
                ? React.createElement(component, props)
                : render
                ? render(props)
                : null
            : typeof children === "function"
            ? __DEV__
                ? evalChildrenDev(children, props, this.props.path)
                : children(props)
            : null
        }
    </RouterContext.Provider>
)

computedMatch是Switch组件为了挺高效率,计算好匹配结果,然后直接传给route组件的props中,通过match结果,判断如何渲染children。

如果没有Switch组件包裹,则Route组件通过props中path判断是否匹配,如果没有匹配,再进一步判断children是不是function,如果是,会执行function,否则就返回null。

具体优先级如下:

  • match
    • children === function ? children(props): children
    • component ? React.createElement(component, props)
    • render ? render(props) : null
  • unmatch
    • children === function ? children() : null

知道了Route控制渲染的逻辑,再看看Switch是怎么通过computedMatch匹配组件的

    React.Children.forEach(this.props.children, child => {
        if (match == null && React.isValidElement(child)) {
            element = child;

            const path = child.props.path || child.props.from;

            match = path
            ? matchPath(location.pathname, { ...child.props, path })
            : context.match;
        }
    });

    return match
        ? React.cloneElement(element, { location, computedMatch: match })
        : null;

这里的匹配比较简单,遍历children的节点,matchPath匹配该节点pathname,将结果赋值到computedMatch传递给Route组件。

结论

  1. 对于Switch直接包裹的Route组件是优先根据props中的computedMatch来判断是否渲染children;
  2. 对于单独的Route组件,有path的情况下,是根据children是否是function来进行判断,是function会执行children方法。

基于Route组件和Switch组件的处理方式进行扩展

对于Switch的核心代码的扩展

child = React.cloneElement(element, {
    location,
    computedMatch: match,
    ...(isNull(match)
        ? {
            computedMatchForCacheRoute: {
            [COMPUTED_UNMATCH_KEY]: true
            }
        }
        : null)
})

对于没有匹配到的组件,给Route组件props增加一个参数computedMatchForCacheRoute,用以未匹配的Route组件进行判断

对应Route组件的改变

if (computedMatchForCacheRoute) {
   props.computedMatch = computedMatchForCacheRoute
}

判断有computedMatchForCacheRoute属性,就覆盖computedMatch的值,Route组件中的this.props.computedMatch就都有值,所有Switch的子Route都会保留。

这样对于Switch包裹的Route组件实际上都是匹配成功的,后续对于未匹配的页面进行隐藏即可;

对于Route组件,不管匹配结果如何,只要我们将children组件用函数配置,最终都会通过children函数来控制渲染结果。

对比如下图:

4.png

通过Route的children函数来控制组件的展示

参考react-router-cache-route源码

页面缓存涉及的关键点:

  1. 对于未匹配的组件,实际上是保留了dom,对于未匹配的组件,将组件进行隐藏;
  2. 缓存生命周期的注入,用以控制缓存页面的副作用;
  3. 也可以给每个组件添加cachekey,手动控制缓存组件;
  4. 对于动态路由,为了页面性能,需要控制缓存组件的数量;

关于缓存生命周期的注入和调用

参考vue生命周期函数,对应添加useDidCache,useDidRecover生命周期函数;

  • function组件会自动执行这两个函数进行注册;
import React, { useEffect } from 'react'
import { useDidCache, useDidRecover } from 'react-router-cache-route'

function HomePage(props) {
    useDidCache(() => {
        console.log('List did cache')
    })

    useDidRecover(() => {
        console.log('List did recover')
    })

    return (
        ...
    )
}
  • 对于Class组件,需要手动调用,使用CacheRoute的组件将会得到一个名为cacheLifecycles的属性,里面包含两个额外生命周期的注入函数 didCache 和 didRecover,分别在组件被缓存和被恢复时触发。

以下是Class组件注入缓存生命周期方式

import React, { Component } from 'react'

export default class List extends Component {
  constructor(props) {
    super(props)

    props.cacheLifecycles.didCache(this.componentDidCache)
    props.cacheLifecycles.didRecover(this.componentDidRecover)
  }

  componentDidCache = () => {
    console.log('List cached')
  }

  componentDidRecover = () => {
    console.log('List recovered')
  }

  render() {
    return (
      ...
    )
  }
}

看看注入缓存生命周期函数源码:

// CacheComponent
import { Provider as CacheRouteProvider } from './context'
.
.
cacheLifecycles = {
    __listener: {}, // Class
    __didCacheListener: {},
    __didRecoverListener: {},
    on: (eventName, func) => {
      const id = Math.random()
      const listenerKey = `__${eventName}Listener`
      this.cacheLifecycles[listenerKey][id] = func

      return () => {
        delete this.cacheLifecycles[listenerKey][id]
      }
    },
    // class组件
    didCache: listener => {
      this.cacheLifecycles.__listener['didCache'] = listener
    },
    didRecover: listener => {
      this.cacheLifecycles.__listener['didRecover'] = listener
    }
}
render () {
    return (
        <CacheRouteProvider value={this.cacheLifecycles}>
          {children(this.cacheLifecycles)}
        </CacheRouteProvider>
    )
}

cacheLifecycles注入到component实例中

// CacheRoute
<CacheComponent {...props}>
    {cacheLifecycles => (
      <Updatable when={isMatch(props.match)}>
         {() => {
             Object.assign(props, { cacheLifecycles })

             if (component) {
                return React.createElement(component, props)
             }
             return (render || children)(props)
         }
         }
      </Updatable>
    )}
</CacheComponent>

关于function函数缓存生命周期的注入

// context.js
import { useEffect, useContext } from 'react'
import createContext from 'mini-create-react-context'

import { isArray, isFunction, run } from '../helpers'

const context = createContext()

export default context
export const { Provider, Consumer } = context

function useCacheRoute(lifecycleName, effect) {
  if (!isFunction(useContext)) {
    return
  }

  const cacheLifecycles = useContext(context)
  useEffect(() => {
    const off = cacheLifecycles.on(lifecycleName, effect)

    return () => off()
  }, [])
}
export const useDidCache = useCacheRoute.bind(null, 'didCache')
export const useDidRecover = useCacheRoute.bind(null, 'didRecover')

缓存组件内部通过添加matched状态决定调用缓存生命周期函数

缓存组件unmatch => setState({ matched: false }) => 触发componentDidUpdate => 执行this.cacheLifecycles.__didCacheListener()

手动控制缓存组件

缓存容器: 5.png

添加CacheKey属性,CacheComponent就会保存组件引用

<CacheRoute path="/home" component={Home} cacheKey="home" />

通过manager.register保存cacheKey和组件实例

同时对于动态路由,保存的是该cacheKey多个组件的集合对象,pathname作为key

// CacheComponent.js
constructor(props, ...args) {
    super(props, ...args)
    const { cacheKey, multiple } = props
    if (cacheKey) {
        if (multiple) {
        const { pathname } = props
        manager.register(cacheKey, {
            ...manager.getCache()[cacheKey],
            [pathname]: this
        })
        } else {
        manager.register(cacheKey, this)
        }
    }
}
// manager.js
import CacheComponent from './CacheComponent'
import { get, run } from '../helpers'

const __components = {}

export const register = (key, component) => {
  __components[key] = component
}

const dropComponent = component => run(component, 'reset')

export const dropByCacheKey = key => {
  const cache = get(__components, [key])

  if (!cache) {
    return
  }

  if (cache instanceof CacheComponent) {
    dropComponent(cache)
  } else {
    Object.values(cache).forEach(dropComponent)
  }
}

同时通过dropByCacheKey执行缓存组件的reset方法,清除缓存组件的dom实例

组件中setState({ cached: false })控制render函数返回null

动态路由的缓存限制

对于动态路由,为了保证页面性能,得控制缓存数量上限

// CacheRoute
if (multiple && isMatchCurrentRoute) {
    this.cache[currentPathname + currentSearch] = {
        updateTime: Date.now(),
        pathname: currentPathname,
        search: currentSearch,
        render: renderSingle
    }

    Object.entries(this.cache)
        .sort(([, prev], [, next]) => next.updateTime - prev.updateTime) // 降序排列,删除最早缓存的数据
        .forEach(([pathname], idx) => {
        if (idx >= maxMultipleCount) {
            delete this.cache[pathname]
        }
        })
}

添加multiple属性,限制缓存上限,超过上限,将最早缓存的移除

总结

对于要求保存页面完整状态的基本需求是能达到了,但是加大了页面的负担,对于未匹配的缓存组件处理只是单独的进行了隐藏处理,后续这块应该是首要优化点,对于未匹配的组件是否能移除dom结构,同时避免移除带来的负面影响。