如何实现 React 中的状态保存keepAlive[react-activation原理]

2,210 阅读2分钟

问题

最近在实现发帖论坛时,想实现这么一个效果 从列表页A跳转A的详情页,列表页A缓存

但在 React 中,我们通常会使用路由去管理不同的页面,而在切换页面时,路由将会卸载掉未匹配的页面组件,所以当用户从详情页退回列表页时,会回到列表页顶部,因为列表页组件被路由卸载后重建了,状态被丢失React 中并没有这个功能,曾经有人在官方提过功能 issues ,但官方认为这个功能容易造成内存泄露,表示暂时不考虑支持,所以我们需要自己想办法了。那么如何实现如何实现 React 中的状态保存呢?

解决

查阅资料后,发现使用react-activation可以符合此场景,并且探究了一下他的原理。

如何使用

1.用AliveScope包裹App组件(就类似于redux中的provider)

2.用KeepAlive包裹要缓存的组件。

import React, { useState } from 'react'
import { render } from 'react-dom'

import KeepAlive, { AliveScope } from './KeepAlive'

function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      count: {count}
      <button onClick={() => setCount((count) => count + 1)}>add</button>
    </div>
  )
}

function App() {
  const [show, setShow] = useState(true)
  return (
    <div>
      <button onClick={() => setShow((show) => !show)}>Toggle</button>
      {show && (
        <KeepAlive id="Test">
          <Counter />
        </KeepAlive>
      )}
    </div>
  )
}

render(
  <AliveScope>
    <App />
  </AliveScope>,
  document.getElementById('root')
)

image.png

原理

由于React会卸载掉处于固有组件层级内的组件,所以我们将<KeepAlive>中的组件,也就是其children属性抽取出来,渲染到一个不会被卸载的组件<AliveScope>内,也就是保存在<AliveScope>state状态里,再使用DOM操作dom.appendChild<AliveScope>内的真实内容移入对应<KeepAlive>,就可以实现此功能

代码模拟实现

在网上只找到了class版本,自己对照着原理用hooks实现了一遍,目前没发现什么bug

import React, { createContext, useEffect, useRef, useState } from 'react'
const { Provider, Consumer } = createContext()

// 为了把AliveScope里的keep属性加到KeepAlive组件上去
const withScope = (WrappedComponent) => (props) => (
  <Consumer>{(keep) => <WrappedComponent {...props} keep={keep} />}</Consumer>
)

export const AliveScope = (props) => {
  const nodes = useRef({});
  const [state, setState] = useState({});
  const resolvePromise = useRef();
  const firstUpdate = useRef(true);

  const keep = (id, children) => {
    const p = new Promise((resolve) => {
      setState({
        ...state,
        [id]: { id, children }
      });
      resolvePromise.current = () => resolve(nodes.current[id]);
    })
    return p;
  }

  // 这里要注意忽略首次更新所产生的useEffect,因为首次组件首次挂在时state为空,所以nodes这个ref也为空,取不到nodes.current[id]。
  useEffect(() => {
    if (firstUpdate.current) {
      firstUpdate.current = false;
      return;
    }
    resolvePromise.current();
  }, [state])

  return (
    <Provider value={keep}>
      {props.children}
      {
        Object.values(state).map(({ id, children }) => {
          return (
            <div
              key={id}
              id={id}
              ref={(node) => {
                // 这里用ref的函数形式,因为被keepAlive包裹的的组件不止一个
                nodes.current[id] = node
              }}
            >
              {children}
            </div>
          )
        })
      }

    </Provider>
  )
}

const KeepAlive = ({ id, children, keep }) => {
  const placeholder = useRef();

  useEffect(() => {
    (async () => {
      const realContent = await keep(id, children)
      placeholder.current.appendChild(realContent)
    })()
  }, [])

  return (
    <div
      ref={placeholder}
      className="keepAlive"
    />
  )
}
export default withScope(KeepAlive)