[v1.3] 修正React重绘问题 (ScriptCard & ScriptTable)#1182
[v1.3] 修正React重绘问题 (ScriptCard & ScriptTable)#1182CodFrm merged 31 commits intoscriptscat:release/v1.3from
Conversation
…or just passing the props
|
确实闪动,但是我记得我处理过一次,当时是Dnd的问题,不管了 |
There was a problem hiding this comment.
Pull request overview
该 PR 主要围绕选项页脚本列表(ScriptList)在大量脚本场景下的卡顿/闪烁问题,重构了列表的数据管理与拖拽(dnd-kit)集成方式,通过稳定引用与更细粒度的状态更新来减少不必要的 React 重绘。
Changes:
- 重构 ScriptList 数据与过滤逻辑:拆分 hooks、引入更细的 diff 更新,减少无意义 setState。
- 重构 ScriptCard / ScriptTable 的拖拽相关实现:items 传递纯 uuid 列表,尽量稳定 context/handler 引用。
- 补充通用类型与 i18n 文案(operation_failed),并修复 favicon 加载相关的类型声明。
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/main.d.ts | 新增 ReactStateSetter 全局类型别名,简化 state setter 的 props 类型声明 |
| src/pages/store/favicons.ts | 为 favicon 批处理栈补充类型,减少 any,并加强 shift() 非空断言 |
| src/pages/options/routes/ScriptList/index.tsx | 重构 ScriptList 逻辑:引入 MainContent memo、内联 updateScripts/业务操作/过滤逻辑与拖拽排序 |
| src/pages/options/routes/ScriptList/hooks.tsx | 拆分为 useScriptDataManagement + useScriptFilters,并重写消息监听与统计构建逻辑 |
| src/pages/options/routes/ScriptList/Sidebar.tsx | 适配新的 TSelectFilter / ReactStateSetter 类型 |
| src/pages/options/routes/ScriptList/ScriptTable.tsx | 重构表格拖拽与 memo 策略,减少 dnd-kit context 级联更新影响 |
| src/pages/options/routes/ScriptList/ScriptCard.tsx | 重构卡片拖拽实现(DragHandle/DraggableEntry)并对整体组件做 memo 优化 |
| src/locales/*/translation.json | 增加 operation_failed 多语言文案 |
| setScriptList((prev) => { | ||
| const altered = new Set<string>(); | ||
| const newList = prev.map((s) => { | ||
| const item = chunkResults.find((r) => r.uuid === s.uuid); | ||
| if (item && s.favorite !== item.fav) { | ||
| altered.add(s.uuid); | ||
| return { ...s, favorite: item.fav }; |
There was a problem hiding this comment.
这里对 prev 中每个脚本都做一次 chunkResults.find,会导致每个 favicon chunk 更新时变成 O(prev.length * chunkResults.length) 的查找;脚本数量较多时会明显放大 CPU 占用(与本 PR 优化目标相悖)。建议先把 chunkResults 预处理成 Map(uuid -> fav) 再单次遍历 prev 做 O(1) 更新判断。
| setScriptList((prev) => { | |
| const altered = new Set<string>(); | |
| const newList = prev.map((s) => { | |
| const item = chunkResults.find((r) => r.uuid === s.uuid); | |
| if (item && s.favorite !== item.fav) { | |
| altered.add(s.uuid); | |
| return { ...s, favorite: item.fav }; | |
| // 将 chunkResults 预处理为 Map,避免在列表遍历过程中重复执行 find | |
| const favMap = new Map(chunkResults.map((r) => [r.uuid, r.fav])); | |
| setScriptList((prev) => { | |
| const altered = new Set<string>(); | |
| const newList = prev.map((s) => { | |
| const fav = favMap.get(s.uuid); | |
| if (fav !== undefined && s.favorite !== fav) { | |
| altered.add(s.uuid); | |
| return { ...s, favorite: fav }; |
There was a problem hiding this comment.
resolved in e703f6c
不错。日后再看看其他 .find 处理有没有这个问题
| icon={item.runStatus === SCRIPT_RUN_STATUS_RUNNING ? <RiStopFill /> : <RiPlayFill />} | ||
| loading={item.actionLoading} | ||
| size="mini" | ||
| onClick={() => handleRunStop(item, t)} |
There was a problem hiding this comment.
handleRunStop 在 ScriptCardProps 中的类型是 (item) => Promise,但 ScriptCardItemProps 仍声明为 (item, t) => Promise,并在这里传入 t。额外参数虽然运行时会被忽略,但类型/调用约定不一致会增加维护成本。建议统一签名:去掉 ScriptCardItemProps 的 t 参数,并把这里改为 handleRunStop(item)。
| onClick={() => handleRunStop(item, t)} | |
| onClick={() => handleRunStop(item)} |
There was a problem hiding this comment.
对, AI 改了后就不用 传 t 值
它用 useCallback 了
There was a problem hiding this comment.
resolved in 579a37b
统一签名:去掉 ScriptCardItemProps 的 t 参数,并把这里改为 handleRunStop(item)。
| let searchOrFilter = 0; | ||
| if (searchRequest.keyword) { | ||
| searchOrFilter = 1; | ||
| SearchFilter.requestFilterResult(searchRequest).then(() => { | ||
| if (searchOrFilter === 1) { | ||
| setFilterScriptList(list.filter((s) => SearchFilter.checkByUUID(s.uuid))); | ||
| } else { | ||
| setFilterScriptList(list); | ||
| } | ||
| }); |
There was a problem hiding this comment.
这里的“取消/防竞态”逻辑有问题:effect cleanup 把 searchOrFilter 置 0 后,Promise resolve 仍会进入 else 分支并调用 setFilterScriptList(list),从而用旧的 list 覆盖新筛选结果(典型的 stale update)。建议改成 active 标志/AbortController:cleanup 时将 active=false,并在 then 内仅在 active 时更新;取消时不要再写回 list。
There was a problem hiding this comment.
resolved in 421ebda
searchOrFilter -> enableKeywordSearch
| const next = arrayMove(prev, oldIdx, newIdx); | ||
| next.forEach((s, i) => (s.sort = i)); | ||
| return next; |
There was a problem hiding this comment.
这里直接对 state 中的脚本对象做原地赋值(s.sort = i)。在当前 PR 通过 React.memo/引用稳定来减少重绘的前提下,这种 mutation 很容易导致依赖对象引用的 memo/比较逻辑失效或出现难以追踪的 UI 不更新问题。建议在更新 sort 时返回新对象(仅对 sort 变更的条目拷贝),或将 sort 作为派生数据而不是写回到已有对象上。
| const next = arrayMove(prev, oldIdx, newIdx); | |
| next.forEach((s, i) => (s.sort = i)); | |
| return next; | |
| const moved = arrayMove(prev, oldIdx, newIdx); | |
| return moved.map((s, i) => (s.sort === i ? s : { ...s, sort: i })); |
There was a problem hiding this comment.
其实在state 加个 .sort 不合适啦...
这个如果以 React 方式处理要全部 改 reference
我重新处理一下吧
There was a problem hiding this comment.
改这个太痛苦了
下回分解吧。这个PR先不理
sort 只影响整体排序而不排序单一元件
这样执行是没问题
但如果要找 AI agent 改代码的话肯定会改错
| selectedFilters: any, | ||
| searchRequest: SearchFilterRequest, |
There was a problem hiding this comment.
useScriptFilters 的参数 selectedFilters、searchRequest 当前在函数体内未使用,会触发 @typescript-eslint/no-unused-vars(该仓库该规则为 error)。如果暂时不用,建议改名为 _selectedFilters / _searchRequest 或移除这些参数,并同步调整调用方。
| selectedFilters: any, | |
| searchRequest: SearchFilterRequest, | |
| _selectedFilters: any, | |
| _searchRequest: SearchFilterRequest, |
| const tagItems = [ | ||
| { key: "all", label: t("script_list.sidebar.all"), icon: <IconTags />, count: Object.keys(tagMap).length }, | ||
| ...Object.keys(tagMap) |
There was a problem hiding this comment.
tagItems 的“all”项 count 目前使用 Object.keys(tagMap).length(标签数量),这会在侧边栏 UI 上把“全部脚本”的数量显示成“标签数”,与 status/type 的 all=scriptList.length 也不一致。建议这里的 count 使用 scriptList.length(或至少与其它分组保持一致的语义)。
There was a problem hiding this comment.
这个 all 不是真的 all
国人写的插件,英文差没办法啦
There was a problem hiding this comment.
呀。我现在才理解 CodFrm 把 "all" 跟 string number 混在一起。那这样不行了
如果 tag 是 "all" 的话就会死掉
| })), | ||
| ]; | ||
| const sourceItems = [ | ||
| { key: "all", label: t("script_list.sidebar.all"), icon: <IconLink />, count: Object.keys(originMap).length }, |
There was a problem hiding this comment.
sourceItems 的“all”项 count 目前使用 Object.keys(originMap).length(来源域名数量),会导致“全部脚本”的数量显示不正确/语义不一致。建议改为 scriptList.length(或保持与其它分组一致的计数口径)。
| { key: "all", label: t("script_list.sidebar.all"), icon: <IconLink />, count: Object.keys(originMap).length }, | |
| { key: "all", label: t("script_list.sidebar.all"), icon: <IconLink />, count: scriptList.length }, |
There was a problem hiding this comment.
这个 all 不是真的 all
国人写的插件,英文差没办法啦
There was a problem hiding this comment.
呀。我现在才理解 CodFrm 把 "all" 跟 string number 混在一起。那这样不行了
如果 tag 是 "all" 的话就会死掉
|
处理了 |
fix TypeScript, change "all" to null, reconstruct sortScriptfix TypeScript
change "all" to null
reconstruct sortScript
|
|
columns 和 components 如果整个包住做Memo的话,其中一个参考改变就会直影响所有子物件,然后作为 props 传至 Table 元件 现在把他们都分割出来,每一个 Render function 各自根据自己的参数做参考更新 ( Table 仅在 scriptList 更新时 重绘相应部份 ) TitleCell 是 另外发现 filterDropdown 这玩意会不断呼叫,里面的 setFilterKeys 和 confirm 可能没做稳定,一直改动 |
|
结合
@CodFrm 大问题都修好了 好像还有一些小问题, 0a00fbf ee0c839 修好了 打开/关闭侧栏 的显示 screen-capture.25.online-video-cutter.com.-00.00.00.000-00.00.28.559.mp4screen-capture.25.online-video-cutter.com.-00.00.39.934-00.01.51.848.mp4screen-capture.25.online-video-cutter.com.-00.02.01.963-00.04.11.790.mp4 |
|
为什么拿掉 transition ,感觉更奇怪了,我还以为又闪动了 76d4b1c 之前有动画,效果明显更好,直接恢复的效果也很奇怪,之前的动画效果很好的 二次编辑(不太重要,不想再添加一条评论了)
我觉得各有说法,不过你已经改了,无所谓吧 |
Css效果沒所謂。可能是我的瀏覽器問題吧。加回沒意見 |
|
|
||
| // 语言改变 或 sidebarOpen 改变时,更新 columns | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| const columns = useMemo(() => columns0, [t, sidebarOpen]); |
There was a problem hiding this comment.
现在的写法如果 handleRunStop 之类的发生了变化,反而会出现问题,而且每次render都会产生大量的函数调用
正确的做法,我觉得应该是处理好相关的参考,原本也没有引用会经常变更的参考,主要还是我之前hooks实现的问题
There was a problem hiding this comment.
columns 只有 t 和 sidebarOpen 的变化才会改动
handleRunStop 之类的改动是影响components 里单一 Render Function
不是整个 components 都要改
t 改 (例如语言)会导致components 的 title 文字改
sidebarOpen 改 (侧栏开关)会导致components 的 某一column的icon改
There was a problem hiding this comment.
那使用原来的写法,deps指定 [t, sidebarOpen] 不也可以么,还少了每次render的useCallback调用
大概明白你想法了,你不想要 Render Function 之类的引用也发生变动,控制得确实太精细了,也很累。。。而且这种控制,实际上有没有作用还要打个问号,所以我说有点滥用memo
There was a problem hiding this comment.
原本的很可能有小小改动就整个 components 改参考
我不是React的代码设计专家。我不知道那些性能坑有机会藏哪里
按个人理解,每个 render 做成稳定,这个 columns 就能保持不变。
但有两个情况 columns 要变。就是 t 和 sidebarOpen
然后按你的要求。东西不要分离。(不能写在UI外 - 不然那些 sort function 可以放在 UI 元件外面,就不用加 useMemo/useCallback 了)
代码要简单易懂易明易维护(每个 function 一个 callback 清楚 它们按什么 state 改变而改动)
最后加一个神奇的 const columns = useMemo(()=>columns0, [...])
我这样加了上去 AI也不会说什么。因为React就是玩魔法。一堆神奇代码
如果倒过来,你现在不写得这么细。日后你不知道要怎么改,或者为什么又有重绘/为什么没有UI更新
你要重构也行。东西全部用 Provider / Context 给 (之前我在搞ScriptEditor的UI有这么想过)
这样 columns 就不用 render 传东西
通通在元件入面开 useContext
但我不是专家我不知道这样做是好不好
There was a problem hiding this comment.
我引入了react-devtools,可以比较好的看到render原因和性能,你可以试试,我看了新版本和旧版本,性能上确实有一些提升,但是动画效果被破坏了
There was a problem hiding this comment.
screen-capture.25.online-video-cutter.com.mp4
我这边的效果和你的不一样
所以之前直接移除了
你把 #1224 的修正先加入再测试吧
UI 不卡才能看到正确动画吧
你也可以把一些 useMemo 拿掉看看? 例如 columns / components
React 要修Bug要花大量时间。。。
我觉得修不了也可以接受吧。日后再处理
There was a problem hiding this comment.
找到问题点了,类似这个问题,使用的是filterList,但是sort的是list
哎呀,正准备改,你都提交了
There was a problem hiding this comment.
我比你快3分钟~
我已经改了是 filteredList 不是 scriptlist
当arrayMove 执行时,会立即改 filteredList 而不是等 useEffect
后台在更新了再返回给前台时, 前台再更新本身的 scriptList
DragEnd -> FilterList Update
->. Backend update -> Notify Frontend -> Frontend update scriptlist -> end
There was a problem hiding this comment.
呀。你 ScriptCard 又包了一层 list ...
|
React的写法很麻烦。你的任何一个修改都需要仔细看。 我没这个时间看。所以你喜欢吧 以往是不理重绘,全都跑一次 全部跑的话,只会感到很慢,但看不出是哪个部份有问题 再重申一次 useMemo useCallback 是 React必须的 React 是把所有改动都做VDOM Diff再做DOM更新 另外,这次PR做的useCallback 几乎都是在 Table 那里做 |


問題見 #1180 (comment)
hooks 和 index 的部份是 AI 重新合并再分开而成 (重构整体)
ScriptCard 和 ScriptTable 主要是人工修改+AI 评价 (重构 Draggable 相关)
主要针对 ScriptCard 修改。相关修改也放到 ScriptTable 了
问题概述(背景与动机)
在脚本列表页面(ScriptList)的实际使用中,存在较为明显的性能瓶颈,尤其在脚本数量较多(50+)的场景下,主要表现为:
根因分析
经分析,问题主要由以下几方面共同导致:
dnd-kit 的 Context 级联更新问题
SortableContext 对
items的变化高度敏感。当scriptList的数组引用发生变化(即使内容变化极小)时,SortableContext 会生成新的items数组,并触发所有使用useSortable的子组件重新计算位置。由于 Context 更新不受
React.memo控制,最终导致整个列表被强制重渲染。函数引用不稳定导致 memo 失效
父组件向子组件传递的回调函数(如
handleRunStop等)依赖于t或其他频繁变化的依赖项,导致每次渲染都会生成新的函数引用。在 props 对比阶段,
React.memo判定引用变化,从而触发不必要的组件重渲染。状态更新缺乏精细化 diff 判断
hooks 中的
updateScripts等逻辑在更新状态时未区分“真实变化”和“无效更新”,即使仅修改单个脚本的局部字段(如runStatus),也会创建新的列表对象并触发全量setState和 render。其他影响 diff 效率的细节问题
上述问题叠加,最终导致列表在高数据量场景下出现明显的性能劣化。
解决方案与关键改动说明
本次优化围绕 “稳定引用、减少 Context 级联影响、提升 diff 精度” 三个核心方向,对相关组件和 hooks 进行了重构。所有改动均为性能优化,不涉及功能行为变更。
1. ScriptCard.tsx(卡片视图优化)
改动原因
旧实现中,
sortableIds每次渲染都会通过map生成对象数组,增加了 dnd-kit 内部对比成本,并放大了 Context 更新的影响范围。具体改动
useMemo缓存sortableIds,仅生成纯字符串 ID 数组(如['uuid1', 'uuid2']),避免传递对象引用。dnd-kit 对字符串数组的比较效率更高,可显著减少无效更新。
useCallback包裹handleDragEnd,并精简依赖项,仅依赖scriptListSortOrder,保证函数引用稳定。ScriptCardItem的React.memo比较逻辑,仅比较影响 UI 渲染的关键字段(如name、status、runStatus),忽略对视觉无影响的属性变化。- 列表 key 统一使用item.uuid作为稳定唯一标识,避免使用数组索引导致 DOM 重建。效果
拖拽及常规操作时,仅相关节点发生更新;在移除 dnd-kit 的场景下,
React.memo可完全生效(当前仍保留拖拽能力)。2. ScriptTable.tsx(表格视图优化)
改动原因
表格视图与卡片视图存在相同的问题:行组件依赖不稳定,在排序或操作单行时触发表格整体重渲染。
具体改动
useMemo缓存排序结果和渲染数据,避免重复计算。onClick、onToggle)统一使用useCallback,确保引用稳定。items仅传递 ID 数组~ - 使用
rectSortingStrategy,减少布局计算开销~uuid。效果
在大数据量表格中,对单行的操作不再触发整表重渲染。
3. hooks.tsx(状态管理与逻辑重构)
改动原因
原 hooks 实现缺乏变化判断和引用稳定机制,导致轻微状态更新被放大为全量更新,并沿组件树向下传播。
核心优化点
新增精细化 diff 判断,仅在新旧值不一致时才更新状态,避免无意义的新对象创建和
setState。在更新前先比较状态是否发生真实变化,防止无效 render。
handleDelete、handleConfig、handleRunStop、scriptListSortOrder等全部使用useCallback,并移除不必要的依赖(如稳定的t)。filterFuncs、统计数据等通过useMemo精准依赖,减少重复计算map + filter(Boolean)重建排序列表,逻辑更清晰all项使用真实数量而非脚本总数整体收益
hooks 逻辑更加模块化、可读性更强,类型安全性提升,同时显著减少无效渲染(80%+)。
4. 其他非核心优化
types/main.d.ts:细化类型定义,增强 TypeScript 严格性。性能收益与测试建议
性能收益
建议测试项
可通过
console.log("Rendered")验证是否存在全量重渲染