不正经写React的家伙,写了个不正经的React版虚拟列表

1,060 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

前言

为了即将到来的新环境,打算写了React版的虚拟列表,复习一下React的知识,好说不说终究还是重温了许多的知识。

由于经常写Vue的,突然拿键盘写起React,就感觉完全失忆了。那个useState、useEffect怎么用来的,完全处于掉线状态,于是赶紧找官网看看。

useState

在一般我们定义基本的数据的时候是这样的

const [count,setCount] = useState(0)
const [status,setStatus] = useState(false)

可以看出在我们使用的是就是直接使用count、status,因为react并不像Vue那样有数据双向绑定,修改的时候并不能直接修改,而是要通过这样去修改

setCount((count)=>count+1)
setStatus((status)=>!status)

但是我们的引用类型的数据的时候呢? 掉的坑就在这里了,因为忘记了要纯函数的

譬如:

const [list,setList] = useState([])

一开始的时候我修改是这样的

const obj = {
 name:"周星星",
 age:18
}
list.push(obj)
setList(()=>list)

好家伙,页面并没有更新,苦思不得其。 因为你修改了list,它现在不纯了。 在react中修改数组需要创建一个新的干净的数组对象,如果你返回之前的状态是一样的,那么我就不更新页面, 这里存的是引用类型,底层做了一个浅比较,它发现你现在返回的地址值和之前的那个地址值是一样的,他就不会进行页面的更新。

 纯函数:
  1、一类特别的函数:只要是同样的输入(实参),必定得到同样的输出(返回)
  2、必须遵守以下的约束
       1)不得改写参数数据
       2)不会产生任何的副作用、例如网络请求、输入和输出设备
       3)不能调用Date.now()或者Math.random()等不纯的方法
  3、redux的reducer函数必须是一个纯函数
  
  

正确写法:

const obj = {
   name:"周星星",
   age:18
}

const newList = [...list]
newList.push(obj)
setList([...newList])

还有一个问题,就是当使用setCount这种去更新的时候,发现并不是同步更新的,这个因为 state 的变化 React 内部遵循的是批量更新原则。

所谓异步批量是指在一次页面更新中如果涉及多次 state 修改时,会合并多次 state 修改的结果得到最终结果从而进行一次页面更新。

关于批量更新原则也仅仅在合成事件中通过开启 isBatchUpdating 状态才会开启批量更新,简单来说"

  1. 凡是React可以管控的地方,他就是异步批量更新。比如事件函数,生命周期函数中,组件内部同步代码。
  2. 凡是React不能管控的地方,就是同步批量更新。比如setTimeout,setInterval,源生DOM事件中,包括Promise都是同步批量更新。

在 React 18 中通过 createRoot 中对外部事件处理程序进行批量处理,换句话说最新的 React 中关于 setTimeout、setInterval 等不能管控的地方都变为了批量更新。

解决办法就是使用useRef

useEffect

1. 模拟生命周期

1.1 仅初始化执行,模拟 componentDidMount

依赖空数组,由于依赖为空,不会变化,只执行一次

useEffect(() => {
    console.log('hello world')
}, [])

1.2 仅更新执行,模拟 componentDidUpdate

依赖为具体变量,每次变量变化,都执行一次

useEffect(() => {
    console.log('info: ', name, age)
}, [name, age])

1.3 初始化和更新都执行,模拟 componentDidMount 和 componentDidUpdate

没有依赖,与依赖为空不同,这个每次都会执行

useEffect(() => {
    console.log('every time')
})

1.4 卸载执行,模拟 componentWillUnmount

在useEffect中返回一个函数,则在组件卸载时,会执行改函数

 useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
}, []);

2. 执行async函数

useEffect的回调函数,不能是async,可以将async写在回调里面,单独调用

//不可以
useEffect(async()=>{
    const res = await fetchData(id)
    setData(res.data)
},[id])


//推荐
useEffect(()=>{
    const getData = async() => {
        const res = await fetchData(id)
        setData(res.data)
    }
    getData()
},[id])

3. useEffect执行顺序

useEffect的执行时机,是在react的commit阶段之后。

当父子组件都有useEffect,则先执行子组件的useEffect,再执行父组件的useEffect

遇到问题

在React 18中,发现这种情况下的log会执行两次

useEffect(()=>{
  console.log('hi')
},[])

查找资料发现:

17903226-18aa5265c6f58fd4.png

解决方法:

1.  取消严格模式
2.  使用useRef
3.  自定义Hooks

方式一:严格模式

简单粗暴,一般是StrictMode导致的,就是main页面的代码:

<React.StrictMode>
    <App />
</React.StrictMode>

只需要去掉React.StrictMode标签就行了。

方式二:使用useRef

import { useRef, useEffect } from 'react'

const Index = () => {
  // 重要!!!
  const renderRef = useRef(true)

  useEffect(() => {
    // 重要!!!
    if (renderRef.current) {
      renderRef.current = false
      return 
    }
    console.log('abc')
  }, [])

  return (<h1>hello world</h1>)
}

export default Index

方式三:自定义Hooks

const useEffectOnce = (effect: () => void | (() => void)) => {
  const destroyFunc = useRef<void | (() => void)>();
  const effectCalled = useRef(false);
  const renderAfterCalled = useRef(false);
  const [val, setVal] = useState<number>(0);

  if (effectCalled.current) {
    renderAfterCalled.current = true;
  }

  useEffect(() => {
    // only execute the effect first time around
    if (!effectCalled.current) {
      destroyFunc.current = effect();
      effectCalled.current = true;
    }

    // this forces one render after the effect is run
    setVal((val) => val + 1);

    return () => {
      // if the comp didn't render since the useEffect was called,
      // we know it's the dummy React cycle
      if (!renderAfterCalled.current) {
        return;
      }
      if (destroyFunc.current) {
        destroyFunc.current();
      }
    };
  }, []);
};

使用:

useEffectOnce( ()=> {
    console.log('my effect is running');
    return () => console.log('my effect is destroying');
});

虚拟列表组件----ListView

还是熟悉的偏方,但是这次针对不固定高度的情况下做了优化,比这个应该好用了很多虚拟列表:你有勇气就给我10万,我就有本事展示给你看

在Vue2版的虚拟列表中,面对不固定高度时,采用的解决方法是高度使用min-height,但是实际这个元素的高度并不一致。这导致在获取可视窗口的第一项和最后一项的时候并不准确。因为在缓存的数据中存的是默认高度也就是min-height

代码具体如下:

import React, { useEffect, useRef, useState } from "react";
import "./index.less";

function useFirstMountState(): boolean {
  const isFirst = useRef(true);
  if (isFirst.current) {
    isFirst.current = false;
    return true;
  }
  return isFirst.current;
}

const useEffectOnce = (effect: () => void | (() => void)) => {
  const destroyFunc = useRef<void | (() => void)>();
  const effectCalled = useRef(false);
  const renderAfterCalled = useRef(false);
  const [val, setVal] = useState<number>(0);

  if (effectCalled.current) {
    renderAfterCalled.current = true;
  }

  useEffect(() => {
    // 只在第一次执行效果
    if (!effectCalled.current) {
      destroyFunc.current = effect();
      effectCalled.current = true;
    }

    // 这将强制在效果运行后进行一次渲染
    setVal((val) => val + 1);

    return () => {
      //如果comp在调用useEffect后没有呈现,
      //我们知道这是一个虚拟的React循环
      if (!renderAfterCalled.current) {
        return;
      }
      if (destroyFunc.current) {
        destroyFunc.current();
      }
    };
  }, []);
};

interface listViewScroll {
  topItemIndex: number;
  bottomItemIndex: number;
  listTotalHeight: number;
  scrollTop: number;
}

export default function Index({
  list,
  scroll,
  children,
}: {
  list: any[];
  scroll: (options: listViewScroll) => void;
  children?: any;
}) {
  // console.log("虚拟列表更新了")
  const defaultHeight = 45;
  const wrapperRef = useRef<HTMLDivElement>(null);
  const itemOffsetCache = useRef<{ height: number; offset: number }[]>([]);
  const topItemIndexRef = useRef(0);
  const [listView, setListView] = useState<any[]>([]);
  const [listTotalHeight, setListTotalHeight] = useState(0);
  const [translateYHeight, setTranslateYHeight] = useState(0);

  const refreshView = () => {
    const scrollTop = wrapperRef.current!.scrollTop;
    const { height: viewHeight } = getComputedStyle(
      wrapperRef.current as HTMLDivElement
    );
    const topItemIndex = findItemIndexByOffset(scrollTop);
    const bottomItemIndex = findItemIndexByOffset(scrollTop + viewHeight);
    // console.log(topItemIndex, bottomItemIndex, scrollTop,viewHeight, itemOffsetCache);
    topItemIndexRef.current = topItemIndex;
    const listView = list.slice(topItemIndex, bottomItemIndex);
    setListView([...listView]);

    //列表的总高度
    /**
     * 暂时先使用默认高度
     * 若提供了默认item高度(defaultItemHeight),
     * 则高度 = 已计算item的高度总合 + 未计算item数 * 默认item高度;
     * 否则全部使用计算高度
     * 这里已计算过的item会缓存,所有item只会计算一次
     */
    const listTotalHeight =
      getItemInfo(itemOffsetCache.current.length - 1).offset +
      (list.length - itemOffsetCache.current.length) * defaultHeight;

    setListTotalHeight(listTotalHeight);
    //设置偏移量
    // 控制translateY使可视列表位置保持在可视窗口
    setTranslateYHeight(getItemInfo(topItemIndex - 1).offset);

    scroll({
      topItemIndex,
      bottomItemIndex,
      listTotalHeight,
      scrollTop,
    });
  };
  // 根据offset获取item的在列表中的index
  const findItemIndexByOffset = (offset: number) => {
    //如果offset大于缓存数组的最后项,按序依次往后查找(调用getItemInfo的过程也会缓存数组)
    if (offset >= getItemInfo(itemOffsetCache.current.length - 1).offset) {
      for (
        let index = itemOffsetCache.current.length;
        index < list.length;
        index++
      ) {
        if (getItemInfo(index).offset > offset) {
          return index;
        }
      }
      return list.length;
    } else {
      // 如果offset小于缓存数组的最后项,那么在缓存数组中二分法查找
      let begin = 0;
      let end = itemOffsetCache.current.length - 1;
      while (begin < end) {
        let mid = (begin + end) >> 1;
        let midOffset = getItemInfo(mid).offset;
        if (midOffset === offset) {
          return mid;
        } else if (midOffset > offset) {
          end = mid - 1;
        } else {
          begin = mid + 1;
        }
      }
      if (
        getItemInfo(begin).offset < offset &&
        getItemInfo(begin + 1).offset > offset
      ) {
        begin = begin + 1;
      }
      return begin;
    }
  };
  //根据index获取item在itemOffsetCache缓存的信息
  const getItemInfo: (index: number) => { height: number; offset: number } = (
    index
  ) => {
    if (index < 0 || index > list.length - 1) {
      return {
        height: 0,
        offset: 0,
      };
    }

    let cache = itemOffsetCache.current[index];
    //如果没有缓存
    if (!cache) {
      //直接使用默认高度
      //自定义高度可以后边自行修改
      let height = defaultHeight;
      cache = {
        height,
        offset: getItemInfo(index - 1).offset + height,
      };
      const list = [...itemOffsetCache.current];
      list[index] = cache;
      itemOffsetCache.current = [...list];
    }
    // console.log("-----",cache)
    return cache;
  };

  useEffectOnce(() => {
    // console.log(children);
    refreshView();
    return () => console.log("my effect is destroying");
  });

  const getComputedStyle: (dom: HTMLDivElement) => {
    width: number;
    height: number;
  } = (dom) => {
    if (wrapperRef.current) {
      const { width, height } = window.getComputedStyle(dom);
      return { width: parseInt(width), height: parseInt(height) };
    } else {
      return { width: 0, height: 0 };
    }
  };
  const listViewDomRef = useRef<NodeListOf<ChildNode> | undefined>(undefined);
  useEffect(() => {
    let listViewDom = wrapperRef.current?.childNodes[1].childNodes;
    if (listViewDom?.length !== 0) {
      listViewDom?.forEach((item, index) => {
        updateItemInfo(index);
      });
      // console.log("最后更新的缓存数组",listViewDom?.length,topItemIndexRef.current,itemOffsetCache.current)
    }
    listViewDomRef.current = listViewDom;
  }, [listView]);

  const updateItemInfo = (idx: number) => {
    const index = topItemIndexRef.current + idx;
    let cache = itemOffsetCache.current[index];
    if (!cache || idx <= 0 || index <= 0) {
      return {
        height: 0,
        offset: 0,
      };
    }
    const itemInfo = (
      listViewDomRef.current![idx] as HTMLDivElement
    ).getBoundingClientRect();
    cache = {
      height: itemInfo.height,
      offset: itemOffsetCache.current[index - 1].offset + itemInfo.height,
    };
    const list = [...itemOffsetCache.current];
    list[index] = cache;
    itemOffsetCache.current = [...list];
  };
  return (
    <div className="m-list-wrapper" onScroll={refreshView} ref={wrapperRef}>
      <div
        className="m-list-listTotalHeight"
        style={{ height: listTotalHeight + "px" }}
      ></div>
      <div className="m-list-view">
        {listView.length !== 0 &&
          listView.map((item: any, index: number) => {
            return (
              <div
                className="m-list-item"
                key={index}
                style={{
                  minHeight: defaultHeight + "px",
                  transform: `translateY(${translateYHeight}px)`,
                  color: item.color,
                }}
              >
                我是虚拟列表--{item.no}
                {/* {JSON.stringify(itemOffsetCache)} */}
                {children && children(item)}
              </div>
            );
          })}
      </div>
    </div>
  );
}

其中就是通过useEffect去监听listView的数据变化,然后去获取在容器下渲染的列表元素,通过getBoundingClientRect获取元素的高度等实际信息,然后重新修改缓存内的信息。

代码如下:

...
const listViewDomRef = useRef<NodeListOf<ChildNode> | undefined>(undefined);
  useEffect(() => {
    let listViewDom = wrapperRef.current?.childNodes[1].childNodes;
    if (listViewDom?.length !== 0) {
      listViewDom?.forEach((item, index) => {
        updateItemInfo(index);
      });
      // console.log("最后更新的缓存数组",listViewDom?.length,topItemIndexRef.current,itemOffsetCache.current)
    }
    listViewDomRef.current = listViewDom;
  }, [listView]);

  const updateItemInfo = (idx: number) => {
    const index = topItemIndexRef.current + idx;
    let cache = itemOffsetCache.current[index];
    if (!cache || idx <= 0 || index <= 0) {
      return {
        height: 0,
        offset: 0,
      };
    }
    const itemInfo = (
      listViewDomRef.current![idx] as HTMLDivElement
    ).getBoundingClientRect();
    cache = {
      height: itemInfo.height,
      offset: itemOffsetCache.current[index - 1].offset + itemInfo.height,
    };
    const list = [...itemOffsetCache.current];
    list[index] = cache;
    itemOffsetCache.current = [...list];
  };
...

样式布局:

.m-list-wrapper {
  width: 100%;
  height: 100%;
  overflow: auto;
  position: relative;
  margin: 0;
  padding: 0;
  border: none;
  .m-list-listTotalHeight {
    width: 100%;
    padding: 0;
    margin: 0;
  }
  .m-list-view {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    padding: 0;
    margin: 0;
  }
}

在App页面中使用:

import { useEffect, useMemo, useRef, useState } from "react";
import "./App.css";
import ListView from "./components/listView";

function App() {
  const [list, setList] = useState<any[]>([]);
  const page = useRef(0);
  const getData = () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        const baseIndex = page.current * 50;
        resolve(
          new Array(50).fill(0).map((i, index) => {
            const height = Math.floor(Math.random() * (120 - 45)) + 45;
            return {
              no: baseIndex + index,
              color: ["#33d", "#3d3", "#d33", "#333"][(Math.random() * 4) | 0],
              height,
            };
          })
        );
      }, 100);
    });
  };
  const isFirstRender = useRef(true);
  useEffect(() => {
    // 这是一个来自React18本身的问题。基本上,即使在React18中卸载之后,核心团队仍在试图改变组件的状态。useEffect两次被调用与此功能有关。
    if (isFirstRender.current) {
      getList();
      // console.log("执行了两次");
      isFirstRender.current = false;
    }
  }, []);

  const getList = async () => {
    const data: any = await getData();
    /**
     * 在react中修改数组需要创建一个新的干净的数组对象,
     * 如果你返回之前的状态是一样的,那么我就不更新页面
     * 这里存的是引用类型,底层做了一个浅比较,它发现你现在返回的地址值和之前的那个地址值是一样的,他就不会进行页面的更新
     * 纯函数:
     * 1、一类特别的函数:只要是同样的输入(实参),必定得到同样的输出(返回)
     * 2、唏嘘遵守以下的约束
     *  1)不得改写参数数据
     *  2)不会产生任何的副作用、例如网络请求、输入和输出设备
     *  3)不能调用Date.now()或者Math.random()等不纯的方法
     * 3、redux的reducer函数必须是一个纯函数
     */
    setList([...list, ...data]);
    page.current += 1;
  };
  const _getting = useRef(false);
  const handleScroll = (data: any) => {
    // console.log("发生滚动后返回的数据", data);
    if (!_getting.current && data.bottomItemIndex >= list.length - 3) {
      console.log("你过来呀!到底了重复更新");
      _getting.current = true;
      getData().then((d: any) => {
        const newList = [...list, ...d];
        setList(newList);
        page.current += 1;
        _getting.current = false;
      });
    }
  };

  const changeItem = (item: any) => {
    if (item.no % 2 == 0) {
      return (
        <div className="m-1-height" style={{ height: item.height + "px" }}>
          动态高度{JSON.stringify(item)}
        </div>
      );
    } else {
      return <div>aaaaa{JSON.stringify(item)}</div>;
    }
  };

  let memoListView = useMemo(() => {
    return (
      <ListView list={list} scroll={handleScroll}>
        {(item: any) => changeItem(item)}
      </ListView>
    );
  }, [list]);

  return <>{list.length !== 0 && memoListView}</>;
}

export default App;

//App.css

html,body,#root{
  width: 100%;
  height: 100%;
  padding: 0;
  margin: 0;
}

最后效果

React版虚拟列表

譬如说自定义某个元素高度的这个并没有去实现,也比较简单,就是在存入缓存数组时判断一下是否有自定义高度即可

剩下了交给大佬们自行补充了