shenjunru / react-fiber-keep-alive

A component that maintains component state and avoids repeated re-rendering.
MIT License
60 stars 1 forks source link

keepAlive包裹B页面,A页面已加载,A->B->A->B,B页面状态正常;C页面未加载过,A->B->C->B,B页面确实已缓存,state什么都都正常,但是B页面的最外层元素 style="display:none", 经过反复测试,确认是个Bug. #6

Open MatrixAge opened 1 year ago

MatrixAge commented 1 year ago

image image

MatrixAge commented 1 year ago

image

通过这种方式或者ahooks的useInViewport来人为修复这个问题也不失为一个解决

MatrixAge commented 1 year ago

主要是考虑到这个库的实现更接近于可能的官方实现,而不是像react- activation那种重度hack,有一点小瑕疵,只要能通过其他方式来弥补,也没啥问题。

实现思路还是很值得学习的,加油.

shenjunru commented 1 year ago

display: none 应该是你某个组件,mount后加上的,unmount的时候没有还原,可以排查一下

shenjunru commented 1 year ago

也可能是第三方的UI库造成的副作用

shenjunru commented 1 year ago

本库的实现原理,只操作了 react fiber 和 dom 的装/卸载。并未涉及到样式操作

MatrixAge commented 1 year ago

上述问题是在umi4 react18 react-router6下表现的,今天在umi3上测试了一下(umi3用的是react17.x react-router5),切换页面虽然没有display: none,但有别的问题:

image

image

在进行路由切换的时候,会导致这个组件已卸载但还是执行setV导致的报错,当页面且回来之后,fiber节点可能还记录了卸载之后的setV操作(在已经切换之后记录的),这就导致还原的fiber节点和实际fiber不一致,导致setV反复执行,5,6,5,6这种结果.

这种边缘情况,以上,虽然在umi3中,针对这种情况也可以用ref或者变量截断的方式来避免出现。

用来复现的代码

import { useEffect, useState } from 'react'
import { keepAlive } from 'react-fiber-keep-alive'

const Index = () => {
  const [v, setV] = useState(0);

  console.log(v);

  useEffect(() => {
    const timer = setInterval(() => setV(v + 1), 1000);

    return () => {
          clearInterval(timer);

          console.log('out');

    };
  }, [v]);

  return <div>{v}</div>;
};

export default keepAlive(Index, () => 'A');
MatrixAge commented 1 year ago

观察了一下,发现在umi3中出现上述问题的原因可能是useEffect在组件卸载时,KeepAlive拿这个组件的fiber信息存入内存,然后卸载,但是fiber信息在存入内存之后还执行了一次timer,但是这个时候组件其实unmoumted了,就会报这个错。

不理解的是(相关逻辑细节),为什么还原的时候会出现反复setV。

MatrixAge commented 1 year ago

发现了华点,来回切换路由之后,竟然出现了切到别的页面,定时器未卸载,还一直在执行的情况:

import { useEffect, useState } from 'react'
import { keepAlive } from 'react-fiber-keep-alive'

const Index = () => {
  const [v, setV] = useState(0);

  console.log(v);

  useEffect(() => {
    let is_mounted = true;

    const timer = setInterval(() => {
      if (is_mounted) {
        console.log('setV');

        setV(v + 1);
      }
    }, 1000);

    return () => {
      is_mounted = false;

      clearInterval(timer);
    };
  }, [v]);

  return <div>{v}</div>;
};

export default keepAlive(Index, () => 'A');

image

MatrixAge commented 1 year ago

经过测试,上面这个问题不会在umi4 react18.1 react-router6项目中出现 (出现的是那个display: none) 的问题。

shenjunru commented 1 year ago

我用单纯的 react 17.0.2 / 18.1.0 + react-router 5.2.0 + react-fiber-keep-alive 0.7.1 和你的代码 并未复现你的问题,能否放一个 demo 到 https://codesandbox.io/ 我没用过 umi,不清楚是不是 umi 带来的副作用

shenjunru commented 1 year ago

umi 4 因该是使用了 React-18 的 <Offscreen> 组件 会执行 hideInstance() 操作加上 display: none https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMHostConfig.js#L636

shenjunru commented 1 year ago

抱歉,在 react 17.0.2 上复现了

MatrixAge commented 1 year ago

codesanbox真鸡儿难用,搞了半天写一点代码编译半天,换github demo了:

https://github.com/MatrixAge/codesandbox

总体上umi4用keepAlive是能用的,解决那个display: none就行了,umi3上是完全不可用的。

MatrixAge commented 1 year ago

umi 4 因该是使用了 React-18 的 <Offscreen> 组件 会执行 hideInstance() 操作加上 display: none https://github.com/facebook/react/blob/v18.2.0/packages/react-dom/src/client/ReactDOMHostConfig.js#L636

与umi4 和 react-router都没关系,是react18自己执行的hideInstance( instance ):

image

MatrixAge commented 1 year ago

https://github.com/facebook/react/blob/c8b778b7f44faba63d1d2eeb9f7d95da282e0a34/packages/react-reconciler/README.md#hideinstanceinstance

这个方法竟然是react-reconciler提供的,感觉可以直接引入react-reconciler,如果获取到是react18,使用react-reconciler的hideInstance等方法去维护需要被keepAlive的节点。

MatrixAge commented 1 year ago

https://github.com/facebook/react/blob/4ea064eb0915b355b584bff376e90dbae0e8b169/packages/react-reconciler/src/ReactFiberOffscreenComponent.js

看了一下官方的代码,他们正在实现这部分的功能,说不定在18.3就能看到了。

shenjunru commented 1 year ago

介于 react 17 的执行顺序问题,有一个阶段keep-alive无法介入其中。 可以使用 setTimeout 代替 setInterval,解决你的问题。

MatrixAge commented 1 year ago

搞了一个hooks用来弥补一些不足,针对react18+场景下的可使用方案:

import { useScroll } from 'ahooks'
import { useEffect, useRef } from 'react'
import { markEffectHookIsOnetime } from 'react-fiber-keep-alive'

export default () => {
    const el = useRef<HTMLDivElement>(null)
    const scroll = useScroll(el)

    useEffect(() => {
        if (!el.current) return
        if (el.current.style.display !== 'none') return

        el.current.style.setProperty('display', 'flex')
    }, [])

    useEffect(
        markEffectHookIsOnetime(() => {
            if (!scroll?.top) return

            el.current!.scrollTop = scroll.top
        })
    )

    return el
}

针对需要keepAlive的元素,直接绑定ref即可:

import { keepAlive } from 'react-fiber-keep-alive'

import data from '@/_Data_/treeview'
import { TreeView } from '@/components'
import { useKeepAlive } from '@/hooks'

const Index = () => {
    const page = useKeepAlive()

    return (
        <div className='_keepalive w_100 border_box flex flex_column' ref={page}>
            <TreeView data={data} />
        </div>
    )
}

export default keepAlive(Index, () => 'models')

由于这里只保存keepAlive目标容器的滚动进度,所以需要仅允许目标容器进行滚动:

._keepalive {
    height: 100%;
    overflow-y: scroll;
}
shenjunru commented 1 year ago

一般不用刻意手动保留 scroll 位置

MatrixAge commented 1 year ago

理论上是这样 但是react18加了 display: none,导致必须在切换回来时手动设置display: flex 来让dom显示,而这个操作会导致scrollTop变成0,所以就有恢复scrollTop的操作。

都是连带的问题。