携手创作,共同成长!这是我参与「掘金日新计划 · 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 状态才会开启批量更新,简单来说"
- 凡是
React
可以管控的地方,他就是异步批量更新。比如事件函数,生命周期函数中,组件内部同步代码。 - 凡是
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')
},[])
复制代码
查找资料发现:
解决方法:
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;
}
复制代码
最后效果
譬如说自定义某个元素高度的这个并没有去实现,也比较简单,就是在存入缓存数组时判断一下是否有自定义高度即可
剩下了交给大佬们自行补充了