背景
在列表开发中,如果需要删除列表中的某一项,我们常用的删除方式就是删除该项后重新更新列表并渲染列表,会有一种生硬的感觉,如下:
初始效果
gif 中,删除 box_6 这个列表项之后,之后的列表项按位置直接替换了,这个时候,如果需要在删除列表某一项后,该项后续的模块顶上来的过程有个过渡的动画,这样就会顺滑多了
基本代码
// index.js
import React, { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import "./style.css";
const data = Array.from({ length: 10 }, (_, index) => ({
text: `box_${index + 1}`,
id: uuidv4()
}));
export default function App() {
const [list, setList] = useState(data);
const handleDeleteClick = index => {
setList(list.slice(0, index).concat(list.slice(index + 1)));
};
return (
<div className="card-list">
{list.map((item, index) => {
return (
<ListItem
key={item.id}
item={item}
index={index}
handleDeleteClick={handleDeleteClick}
/>
);
})}
</div>
);
}
export const ListItem = ({ item, index, handleDeleteClick }) => {
const itemRef = useRef(null);
return (
<div className="card" ref={itemRef}>
<div className="del" onClick={() => handleDeleteClick(index)} />
{item.text}
</div>
);
};
希望效果
如何实现
问题分析
删除列表中的某一项后,触发了列表的重新渲染,由于列表项是以唯一 uuid 作为列表项渲染的 key 根据 React DOM Diff 规则,删除项之前的列表项由于 key 不变,因此组件不会变化,而之后的列表项的 key 因为发生了错位变化所以只会进行一次删除操作和之后列表项数的移动操作,虚拟 DOM 的构建性能会比较好,但是 React Virtual DOM 转化为真实 DOM 的挂载过程还是会重新绘制,因此整个变化过程看起来会显得很生硬;
如何解决
在删除前后记录列表中的每一项的相对于列表的位置,加上 transform 动画即可,动画时间小于两次删除之前的时间间隔就行,主要问题在于,如何记录列表每一项在删除动作前后的相对位置
代码修改
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import { v4 as uuidv4 } from "uuid";
import "./style.css";
const data = Array.from({ length: 10 }, (_, index) => ({
text: `box_${index + 1}`,
id: uuidv4()
}));
export default function App() {
const [list, setList] = useState(data);
const listRef = useRef(null);
const handleDeleteClick = index => {
setList(list.slice(0, index).concat(list.slice(index + 1)));
};
return (
<div className="card-list" ref={listRef}>
{list.map((item, index) => {
return (
<ListItem
key={item.id}
item={item}
index={index}
handleDeleteClick={handleDeleteClick}
listRef={listRef}
/>
);
})}
</div>
);
}
const useSimpleFlip = ({ ref, infoInit = null, infoFn, effectFn }, deps) => {
const infoRef = useRef(
typeof infoInit === "function" ? infoInit() : infoInit
);
// useLayoutEffect hook 记录每次删除动作后, render 前的列表项当前相对列表容器的相对位置
// useOnceEffect 修正列表项首次渲染后的初始相对位置记录
useLayoutEffect(() => {
const prevInfo = infoRef.current;
const nextInfo = infoFn(ref, { prevInfo, infoRef });
const res = effectFn(ref, { prevInfo, nextInfo, infoRef });
infoRef.current = nextInfo;
return res;
}, deps);
useOnceEffect(
() => {
infoRef.current = infoFn(ref, { prevInfo: infoRef.current, infoRef });
},
true,
deps
);
};
const useOnceEffect = (effect, condition, deps) => {
const [once, setOnce] = useState(false);
useEffect(() => {
if (condition && !once) {
effect();
setOnce(true);
}
}, deps);
return once;
};
export const ListItem = ({ item, index, handleDeleteClick, listRef }) => {
const itemRef = useRef(null);
useSimpleFlip(
{
infoInit: null,
ref: itemRef,
infoFn: r => {
if (r.current && listRef.current) {
return {
position: {
left:
r.current.getBoundingClientRect().left -
listRef.current.getBoundingClientRect().left,
top:
r.current.getBoundingClientRect().top -
listRef.current.getBoundingClientRect().top
}
};
} else {
return null;
}
},
effectFn: (r, { nextInfo, prevInfo }) => {
if (prevInfo && nextInfo) {
const translateX = prevInfo.position.left - nextInfo.position.left;
const translateY = prevInfo.position.top - nextInfo.position.top;
const a = r.current.animate(
[
{ transform: `translate(${translateX}px, ${translateY}px)` },
{ transform: "translate(0, 0)" }
],
{
duration: 300,
easing: "ease"
}
);
return () => a && a.cancel();
}
}
},
[index]
);
return (
<div className="card" ref={itemRef}>
<div className="del" onClick={() => handleDeleteClick(index)} />
{item.text}
</div>
);
};
适用场景
- 瀑布流列表的删除与插入,无需提前知道列表卡片的尺寸,一切都是由 react dom 计算出来;