2

一些关于react的keep-alive功能相关知识在这里(下)

 2 years ago
source link: https://segmentfault.com/a/1190000041683421
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

一些关于react的keep-alive功能相关知识在这里(下)

     本篇承接上篇内部, 所以是从第九点开始

九、保留页面scroll

     比如页面上的table里有100条数据, 我们想看第100条数据, 那就要滚动不少距离, 不少场景这种滚动距离也是有必要保留的。

     这里使用的方法其实比较传统啦, 首先在KeepAliveProvider 下发一个处理滚动的方法:

    const handleScroll = useCallback(
        (cacheId, event) => {
            if (catheStates?.[cacheId]) {
                const target = event.target
                const scrolls = catheStates[cacheId].scrolls
                scrolls[target] = target.scrollTop
            }
        },
        [catheStates]
    )

     在Keeper组件里面接收并执行:

    const { dispatch, mount, handleScroll } = useContext(CacheContext)

    useEffect(() => {
        const onScroll = handleScroll.bind(null, cacheId)
        (divRef?.current as any)?.addEventListener?.('scroll', onScroll, true)
        return (divRef?.current as any)?.addEventListener?.('scroll', onScroll, true)
    }, [handleScroll])

     在Keeper里面将滚动属性赋予元素:

    useEffect(() => {
        const catheState = catheStates[cacheId]
        if (catheState && catheState.doms) {
            const doms = catheState.doms
            doms.forEach((dom: any) => {
              (divRef?.current as any)?.appendChild?.(dom)
            })

        // 新增
        doms.forEach((dom: any) => {
            if (catheState.scrolls[dom]) {
                dom.scrollTop = catheState.scrolls[dom]
            }
        })
        } else {
            mount({
                cacheId,
                reactElement: props.children
            })
        }
    }, [catheStates])

     这里如果不主动增加赋予scroll的方法的话, 滚动距离是不会被保存的, 因为Keeper每次都是新的。

十、KeepAliveProvider内部 Keeper子组件内部的CacheContext

    我们是把组件渲染在 KeepAliveProvider 里面, 那么如果某个Provider是在 KeepAliveProvider 内部定义的, 则KeepAliveProvider级别的组件是无法使用 Consumer 拿到这个值的。

    这里就引出一个问题, 如何将 KeepAliveProvider 中的组件的上下文, 修改为Keeper组件的上下文。

    这里演示一下最直接的方式, 让用户传入Provider与其value值。

 <Keeper 
   cacheId="home" 
   context={{ Provider: Provider, value: value }}>
    <Home />
 </Keeper>

     我们拿到这两个值后直接在Keeper中修改reactElement的结构:

    mount({
        cacheId,
        reactElement: context ? 
          <context.Provider 
             value={context.value}>{props.children}</context.Provider> : 
          props.children
    })

    当检测到context有值则直接在 props.children 外面套一层, 当然这里存在一个多层Provider嵌套的问题没有去解决, 因为逐渐复杂起来它的实用性已经在下降了, 接下来还有新的bug来袭。

十一、需要传值的组件

     大家有没有发现上述组件所有逻辑, 都是直接写在Keeper标签里面的, 并没有任何的传值, 但是比较常见的一种场景是下面这样的:

function Root (){
   const [n, setN] = useState(1)
 return 
   (
    <>
       <button onClick={()=>setN(n+1)}>n+1</button>
        <Keeper>
          <Home n={n} />
        </Keeper>
    </>
 )
}

    这个nKeeper外层传递给Home组件的, 这种写法下会导致n虽然变化了但是Home里面不会响应。

    这个bug我是这样发现的, 当我把这个插件用在我们团队的项目里的一个表格为主的页面时 , table一直显示是空的, 并且输入框也无法输入值, 经过测试发现其实值是有变化的, 只是没有展示在组件的dom上。

    尝试了好久后试了下react-activation 很遗憾它也有相同的问题, 那其实就说明这个bug很可能无法解决或者就是这个插件本身的架构存在的问题。

十二、为何这么奇怪的bug场景

    当时这个bug折磨了我一天半的时间, 最后定位到外界的传参已经不能算是这个组件本身的参数了, 我们组件的实际渲染位置是 KeepAliveProvider 的第一层, 而Keeper的外层还在KeepAliveProvider的更内层, 这就导致这些值的变化其实是没有能够影响到组件。

    可以理解为这些值的变化, 比如n的变化就如同window.n的改变一样, react组件是不会去响应这个变化的。

    那其实我们要做的就是让外层传入的值的变化, 可以带动组件的样式变化 (逐渐入坑!)。

十三、将props单独拿出来

     我借鉴了网上另一种keep-alive组件的写法, 把Keeper组件改为一个keeper的方法, 这个方法返回一个组件看, 这样就可以接收一个props了, 也就把变量圈定在props这个范围:

const Home = keeper(HomePage, { cacheId: 'home' })


function Root(){
 const [n, setN] = useState(1)
  return (
    <>
     <button onClick={()=>setN(n+1)}>n+1</button>
     <Home n={n}> // 此处可以传值了
    </>
  )
}

     这样做的目的是让开发者把能够影响组件状态的参数一口气传进来, 比如之前一个Keeper里面可以有多个组件, 这种情况就不好控制哪些参数变化会导致哪些组件更新, 但以组件的方式可以明显得知组件接收到的props里面的值的改变会导致组件更新。

     我想到的方案是, 在KeepAliveProvider里面新建propsObj, 用来专门储存每个缓存组件的props, 之所以如此设计将其单独拿出来, 是要把传参与组件的逻辑拆分开, 不少逻辑会监控catheStates的变化而执行, 但是props的变化没有必要触发这些。

  const [propsObj, setPropsObj] = useState<any>();
    return (
    <CacheContext.Provider value={{ setPropsObj, propsObj }}>
      {props.children}
      
   //.... 略
   

     KeepAliveProvider 里面的渲染需要变一个形式, reactElement 变成组件了, 别忘了名字要变成大写的。

    // 旧的
    // {reactElement}
    
    // 新的
    {propsObj && 
      <ReactElement {...propsObj[cacheId]}></ReactElement>}

     改装一下Keeper文件, 首先要把文件名改为 keeper, 导出的方法要进行一下更改。

 export default function (
   RealComponent: React.FunctionComponent<any>, { cacheId = '' }) {
   
   return function Keeper(props: any) {
 // ... 略

     Keepermount方法的使用也稍作调整:

    mount({
        cacheId,
        ReactElement: RealComponent
    })

     关键的来了, 我们要在Keeper里面监测props的变化, 来更新propsObj:

const { propsObj, setPropsObj } = useContext(CacheContext)

    useEffect(() => {
        setPropsObj({
            ...propsObj,
            [cacheId]: props
        })
    }, [props])

十四、缓存失效的bug

     上述我们已经把插件改装了形式, 并且发现可以让如下场景正常渲染, Home组件的props是外界传入的:

const Home = keeper(HomePage, { cacheId: 'home' })

const RootComponent: React.FC = () => {
    return (
        <KeepAliveProvider>
            <Router>
                <Routes>
                    <Route path={'/'} element={<Mid />} />
                </Routes>
            </Router>
        </KeepAliveProvider>
    )
}
function Mid() {
    const [n, setN] = useState(1)
    return (
        <div>
            <button onClick={() => setN(n + 1)}>n+1</button>
            <Home n={n}></Home>
        </div>
    )
}

function HomePage(props: { n: number }) {
    return <div>home {props.n}</div>
}

    但是此时如果切换页面后再返回home页面, home页面的缓存是会失效的。

    其实是因为我们实时监控props的变化, 下次重新渲染时会导致props变化, 然后值就会被初始化了, 导致组件也恢复到了早期的配置, 可是.... 这不就是缓存失败了吗?

    每次组件props被重置就会导致组件的相关数据被重置, 尝试把home组件做如下更改:

function HomePage(props: { n: number }) {
    const [x, setX] = useState(1)
    return (
        <div>
            <button onClick={() => setX(x + 1)}>x + 1</button>
            <div>home {props.n}</div>
            <div>home: x {x}</div>
        </div>
    )
}

     上述写法会导致每次激活home组件, 只能保留x的值, n的值会与传入的相同。

     这种变化可能会导致bug, 假设只有 n > 2 才能让 x > 3, 此时我们通过点击事件让 n = 5 , x = 4了, 此时切换到其他页面再回来, 就变成了n = 1, x=4, 违背了我们的初始限制条件, 以此类推在真实复杂的开发环境中此现象会导致各种奇怪的问题。

十五、认知的代价

    上面的场景可以通过开发人员自己来控制, 理想情况是keep-alive插件只用来处理不需要外界传参, 以及不会被外界参数的变化影响的组件, 但这就开始麻烦了。

    这类问题导致开发者在插件身上要花的学习成本提高, 使用成本提高, 并且如果某个组件本来不需要传参, 我们用keep-alive包裹起来了, 后续又需要传参了, 改变的成本想想都麻烦。

    网上现有(2022年04月10日17:16:22)组件的官网基本是没有认真的对用户讲述相关的问题, 往往都是以介绍"使用方法"与阐述自己的优势为主, 这就导致用户被莫名其妙的bug折磨。

    传递 Provider 的方法也有问题, 需要传递可能不是本页代码的Provider, 难受的了啊。

    想要解决keep-alive相关问题的思路可以换一下, 最好是在react源码里支持一波, 比如可以指定某些组件不被销毁, 其实我们可以关注一下react18的后续版本, 现在这个时间段react18发布了正式版。

十六、如何升级到react18

方式一: create-react-app 创建新项目

     现阶段直接使用下面的命令, 就可创建react18项目:

npx create-react-app my_react

image.png

     下面这种使用 --template 指定模板的还不行, 因为模板代码还没更新:

npx create-react-app my_react --template typescript

     这里可以查看所有react项目的模板 create-react-app项目可指定的模板

方式二: 老项目改装

     首先直接把依赖里面的reactreact-dom的版本号改成 "^18.0.0"即可。

两种方式都需要修改 index.js

     启动项目会有报错信息:
image.png

     旧版的index.js
image.png

     新版的index.js

image.png

     其他的没有太多更改了。

十七、react18 Offscreen 组件的用法

    Offscreen 允许 React 通过隐藏组件而不是卸载组件来保持这样的状态, React 将调用与卸载时相同的生命周期钩子, 但它也会保留 React 组件和 DOM 元素的状态。

    React Activation 中也推荐大家关注这个属性:

image.png

    Offscreen 是什么的官方说法可以看这篇文章里的翻译: React v18.0新特性官方文档[中英文对照

image.png

    Offscreen的测试用例:

image.png

    遗憾的是 Offscreen 组件并没有在当前版本推出, 其还处于不稳定阶段, 但我们可以通过 react18 里面的测试用例来预览一下其用法:

image.png

    通过上述写法还无法看出 Offscreen 到底如何使用, 只知道它可能是以组件的形式出现, 并且需要传入一个mode属性, 更多用法期待官方尽快推出吧。

     让我们一起期待 react18 来解决keep-alive这个问题吧, 这次就是这样, 希望与你一起进步。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK