diff --git a/libs/extension/readme.md b/libs/extension/readme.md index 52e952cc..b54fecf2 100644 --- a/libs/extension/readme.md +++ b/libs/extension/readme.md @@ -79,6 +79,11 @@ type passData = { ## 滚动动态渲染 Tree 考虑到组件树可能很大,所以并不适合一次性全部渲染出来,可以通过滚动渲染的方式减少页面 dom 的数量。我们可以把树看成有不同缩进长度的列表,动态渲染滚动列表的实现可以参考谷歌的这篇文章:https://developers.google.com/web/updates/2016/07/infinite-scroller 这样,我们需要的组件树数据可以由树结构转变为数组,可以减少动态渲染时对树结构进行解析时的计算工作。 +## 虚拟列表针对 UI 框架的优化 +列表中增减不同 key 项意味着 dom 增删,我们需要让框架尽可能减少 dom 操作。 +- 不管渲染列表项怎么变化,应该始终以 index 作为 key,这样只会更新 dom 的属性,不会有 dom 增删操作。 +- 如果在滚动过程中,一个 item 没有被移出渲染列表,它在列表中的 key 值不应该发生变化,由于 item 本身的数据没有变化,所以渲染的 children 也不会发生变化。结合上条的结论,它的属性值也不会变化,所以该 item 对应的 dom 都不会更新。 + ## 开发者页面打开场景 - 先有页面,然后打开开发者工具:工具建立连接,发送通知,页面hook收到后发送VNode树信息给工具页面 - 已经打开开发者工具,然后打开页面:业务页面渲染完毕,发送VNode树信息给工具页面 diff --git a/libs/extension/src/components/VList.tsx b/libs/extension/src/components/VList.tsx deleted file mode 100644 index 73036fae..00000000 --- a/libs/extension/src/components/VList.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// TODO:当前的 item 渲染效果较差,每次滚动所有项在数组中的位置都会发生变更。 -// 建议修改成选项增加减少时,未变更项在原数组中位置不变更 - -import { useState, useRef, useEffect } from 'horizon'; -import styles from './VList.less'; - -interface IProps { - data: T[], - width: number, // 暂时未用到,当需要支持横向滚动时使用 - height: number, // VList 的高度 - children: any, // horizon 组件,组件类型是 T - itemHeight: number, - scrollToItem?: T, // 滚动到指定项位置,如果该项在可见区域内,不滚动,如果不在,则滚动到中间位置 - onRendered: (renderInfo: renderInfoType) => void; - filter?(data: T): boolean, // false 表示该行不显示 -} - -export type renderInfoType = { - visibleItems: T[], - skipItemCountBeforeScrollItem: number, -}; - -export function VList(props: IProps) { - const { - data, - height, - children, - itemHeight, - scrollToItem, - filter, - onRendered, - } = props; - const [scrollTop, setScrollTop] = useState(data.indexOf(scrollToItem) * itemHeight); - const renderInfoRef: { current: renderInfoType } = useRef({ visibleItems: [], skipItemCountBeforeScrollItem: 0 }); - const containerRef = useRef(); - useEffect(() => { - onRendered(renderInfoRef.current); - }); - - useEffect(() => { - if (scrollToItem) { - const renderInfo = renderInfoRef.current; - // 在滚动区域,不滚动 - if (!renderInfo.visibleItems.includes(scrollToItem)) { - const index = data.indexOf(scrollToItem); - // top值计算需要减掉filter条件判定不显示项 - const totalCount = index - renderInfoRef.current.skipItemCountBeforeScrollItem; - // 显示在页面中间 - const top = totalCount * itemHeight - height / 2; - containerRef.current.scrollTo({ top: top }); - } - } - }, [scrollToItem]); - - const handleScroll = (event: any) => { - const scrollTop = event.target.scrollTop; - setScrollTop(scrollTop); - }; - const showList: any[] = []; - let totalHeight = 0; - // 顶部冗余 - const startShowTopValue = Math.max(scrollTop - itemHeight * 4, 0); - // 底部冗余 - const showNum = Math.floor(height / itemHeight) + 4; - // 如果最后一个显示不全,不统计在显示 ids 内 - const maxTop = scrollTop + height - itemHeight; - // 清空记录的上次渲染的数据 - renderInfoRef.current.visibleItems.length = 0; - const scrollItemIndex = data.indexOf(scrollToItem); - renderInfoRef.current.skipItemCountBeforeScrollItem = 0; - data.forEach((item, i) => { - if (filter && !filter(item)) { - if (scrollItemIndex > i) { - renderInfoRef.current.skipItemCountBeforeScrollItem++; - } - return; - } - if (totalHeight >= startShowTopValue && showList.length <= showNum) { - showList.push( -
- {children(i, item)} -
- ); - if (totalHeight >= scrollTop && totalHeight < maxTop) { - renderInfoRef.current.visibleItems.push(item); - } - } - totalHeight += itemHeight; - }); - - return ( -
- {showList} -
-
- ); -} diff --git a/libs/extension/src/components/VList/ItemMap.ts b/libs/extension/src/components/VList/ItemMap.ts new file mode 100644 index 00000000..fde33342 --- /dev/null +++ b/libs/extension/src/components/VList/ItemMap.ts @@ -0,0 +1,62 @@ +// 用于在滚动的过程中,对比上一次渲染的结果和本次需要渲染项 +// 确保继续渲染项在新渲染数组中的位置和旧渲染数组中的位置不发生改变 + +export default class ItemMap{ + + // 不要用 indexOf 进行位置计算,它会遍历数组 + private lastRenderItemToIndexMap: Map; + + constructor(){ + this.lastRenderItemToIndexMap = new Map(); + } + + public calculateReSortedItems(nextItems: T[]): (T|undefined)[] { + if (this.lastRenderItemToIndexMap.size === 0) { + nextItems.forEach((item, index) => { + this.lastRenderItemToIndexMap.set(item, index); + }); + return nextItems; + } + const nextRenderItems: T[] = []; + const length = nextItems.length; + const nextRenderItemToIndexMap = new Map(); + const addItems = []; + // 遍历 nextItems 找到复用 item 和 新增 item + nextItems.forEach(item => { + const lastIndex = this.lastRenderItemToIndexMap.get(item); + // 处理旧 item + if (lastIndex !== undefined) { + // 使用上一次的位置 + nextRenderItems[lastIndex] = item; + // 记录位置 + nextRenderItemToIndexMap.set(item, lastIndex); + } else { + // 记录新增的 item + addItems.push(item); + } + }); + + // 处理新增 item + // 翻转数组,后面在调用pop时拿到的是最后一个,以确保顺序 + addItems.reverse(); + for(let i = 0; i < length; i++) { + // 优先将新增 item 放置在空位置上 + if (!nextRenderItems[i]) { + const item = addItems.pop(); + nextRenderItems[i] = item; + nextRenderItemToIndexMap.set(item, i); + } + } + // 剩余新 item 补在数组后面 + for(let i = addItems.length - 1; i >= 0; i--) { + const item = addItems[i]; + nextRenderItemToIndexMap.set(item, nextRenderItems.length); + nextRenderItems.push(item); + } + // 如果 nextRenderItems 中存在空 index, nextItems 已经耗尽,不用处理 + // 确保新旧数组中 item 的 index 值不会发生变化 + this.lastRenderItemToIndexMap = nextRenderItemToIndexMap; + return nextRenderItems; + } + +} \ No newline at end of file diff --git a/libs/extension/src/components/VList.less b/libs/extension/src/components/VList/VList.less similarity index 100% rename from libs/extension/src/components/VList.less rename to libs/extension/src/components/VList/VList.less diff --git a/libs/extension/src/components/VList/VList.tsx b/libs/extension/src/components/VList/VList.tsx new file mode 100644 index 00000000..0879fbf4 --- /dev/null +++ b/libs/extension/src/components/VList/VList.tsx @@ -0,0 +1,119 @@ +// 内部只记录滚动位置状态值 +// data 数组更新后不修改滚动位置, +// 只有修改scrollToItem才会修改滚动位置 + +import { useState, useRef, useEffect, useMemo } from 'libs/extension/src/components/VList/node_modules/horizon'; +import styles from './VList.less'; +import ItemMap from './ItemMap'; + +interface IProps { + data: T[], + width: number, // 暂时未用到,当需要支持横向滚动时使用 + height: number, // VList 的高度 + children: any, // horizon 组件,组件类型是 T + itemHeight: number, + scrollToItem?: T, // 滚动到指定项位置,如果该项在可见区域内,不滚动,如果不在,则滚动到中间位置 + onRendered: (renderInfo: renderInfoType) => void; + filter?(data: T): boolean, // false 表示该行不显示 +} + +export type renderInfoType = { + visibleItems: T[]; +}; + +function parseTranslate(data: T[], itemHeight: number) { + const map = new Map(); + data.forEach((item, index) => { + map.set(item, index * itemHeight); + }); + return map; +} + +export function VList(props: IProps) { + const { + data, + height, + children, + itemHeight, + scrollToItem, + onRendered, + } = props; + const [scrollTop, setScrollTop] = useState(Math.max(data.indexOf(scrollToItem), 0) * itemHeight); + const renderInfoRef: { current: renderInfoType } = useRef({ + visibleItems: [], + }); + // 每个 item 的 translateY 值固定不变 + const itemToTranslateYMap = useMemo(() => parseTranslate(data, itemHeight), [data]); + const itemIndexMap = useMemo(() => new ItemMap(), []); + const containerRef = useRef(); + useEffect(() => { + onRendered(renderInfoRef.current); + }); + + useEffect(() => { + if (scrollToItem) { + const renderInfo = renderInfoRef.current; + // 在显示区域,不滚动 + if (!renderInfo.visibleItems.includes(scrollToItem)) { + const index = data.indexOf(scrollToItem); + // 显示在页面中间 + const top = Math.max(index * itemHeight - height / 2, 0); + containerRef.current.scrollTo({ top: top }); + } + } + }, [scrollToItem]); + + // 滚动事件会频繁触发,通过框架提供的代理会有大量计算寻找 dom 元素。 + // 直接绑定到原生事件上减少计算量 + useEffect(() => { + const handleScroll = (event: any) => { + const scrollTop = event.target.scrollTop; + setScrollTop(scrollTop); + }; + const container = containerRef.current; + container.addEventListener('scroll', handleScroll); + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }, []); + + const totalHeight = itemHeight * data.length; + const maxIndex = data.length; // slice 截取渲染 item 数组时最大位置不能超过自身长度 + // 第一个可见 item index + const firstInViewItemIndex = Math.floor(scrollTop / itemHeight); + // 可见区域前最多冗余 4 个 item + const startRenderIndex = Math.max(firstInViewItemIndex - 4, 0); // index 不能小于0 + // 最多可见数量 + const maxInViewCount = Math.floor(height / itemHeight); + // 最后可见item index + const lastInViewIndex = Math.min(firstInViewItemIndex + maxInViewCount, maxIndex); + // 记录可见 items + renderInfoRef.current.visibleItems = data.slice(firstInViewItemIndex, lastInViewIndex); + // 可见区域后冗余 4 个 item + const lastRenderIndex = Math.min(lastInViewIndex + 4, maxIndex); + // 需要渲染的 items + const renderItems = data.slice(startRenderIndex, lastRenderIndex); + // 给 items 重新排序,确保未移出渲染数组的 item 在新的渲染数组中位置不变 + // 这样在diff算法比较后,这部分的 dom 不会发生更新 + const nextRenderList = itemIndexMap.calculateReSortedItems(renderItems); + const list = nextRenderList.map((item, i) => { + if (!item) { + return null; + } + return ( +
+ {children(item)} +
+ ); + }); + + return ( +
+ {list} +
+
+ ); +} diff --git a/libs/extension/src/components/VList/index.ts b/libs/extension/src/components/VList/index.ts new file mode 100644 index 00000000..d0154206 --- /dev/null +++ b/libs/extension/src/components/VList/index.ts @@ -0,0 +1 @@ +export { VList, renderInfoType } from './VList'; diff --git a/libs/extension/src/components/VTree.tsx b/libs/extension/src/components/VTree.tsx index 05c9d4e3..a41bcd79 100644 --- a/libs/extension/src/components/VTree.tsx +++ b/libs/extension/src/components/VTree.tsx @@ -160,23 +160,26 @@ function VTree(props: { return true; }; + const showList = data.filter(filter); + return ( {(width: number, height: number) => { return ( - {(index: number, item: IData) => { - // 如果存在下一个节点,并且节点缩进比自己大,说明下个节点是子节点,节点本身需要显示展开收起图标 - const nextItem = data[index + 1]; - const hasChild = nextItem && nextItem.indentation > item.indentation; + {(item: IData) => { + const isCollapsed = collapseNode.includes(item); + const index = showList.indexOf(item); + // 如果收起,一定有 child + // 不收起场景,如果存在下一个节点,并且节点缩进比自己大,说明下个节点是子节点,节点本身需要显示展开收起图标 + const hasChild = isCollapsed || (showList[index + 1]?.indentation > item.indentation); return (