2026-09-18.md 24 KB

2026-09-18

cleaning.vue 组件拆分(Phase 0 完成)

  • 方案文档:cleaning-refactor-plan.md(仓库根),已获用户确认。
  • 用户拍板的决策:

    1. 模块间通信走 方案 B:provide/inject + 领域 composable(不用 props/emits 爆炸式传递)
    2. 路由不动 → cleaning.vue 保留为入口文件,子模块放同级 cleaning/ 目录
    3. 每阶段停下来给用户 review,确认后才进下一阶段
    4. 样式保持全局 less(非 scoped),不用 <style scoped>
    5. 13 个清洗规则参数表单拆成独立文件
    6. 业务逻辑一律不许动,只做搬家/抽取
  • Phase 0 已完成(零逻辑改动):把 cleaning.vue 的 3819 行 <style lang="less"> 拆到 ai-frontend/src/case/views/data/cleaning/styles/

    • variables.less(@prefix-cls)
    • page.less 2734 行(巨型块 8214-10922 + body.is-resizing-layout + keyframes)
    • modals.less 1097 行(Modal/Popover 全局样式 10924-11360 / 11368-12018)
    • index.less(汇总入口)
    • cleaning.vue 的 style 块改为 @import './cleaning/styles/index.less';
    • cleaning.vue: 12029 → 8214 行
    • 验证:pnpm build 退出码 0,产物 dist/assets/cleaning-*.css 86.0 kB,抽查 17 个代表性选择器全部存在

项目关键约定(踩坑记录)

  • less 全局变量注入方式:build/theme/modifyVars.ts 里的 hack: 'true; @import (reference) "src/core/design/var/index.less";',通过 vite less modifyVars 注入。 所以 @modal-header-bg-color 这类变量在任意 .vue style 块和任意 .less 文件里都能直接用。 → 把 SFC 的 style 抽成独立 .less 再 @import 回来是安全的(同一编译单元)。
  • ant-design-vue 的 Modal / Popover / Select 会 teleport 到 body,scoped 样式打不进去。 页面用 wrapClassName="cleaning-config-modal add-column-modal" 配合全局样式命中。
  • 路由配置在 ai-frontend/src/core/router/routes/index.ts,cleaning 页是独立全屏路由(hideMenu/hideTab)。
  • 构建命令:pnpm build(在 ai-frontend/ 下),约 2 分 20 秒。
  • 本机 bash 环境的 ls/grep/wc 等命令不可用(shim 报 dirname: command not found), PowerShell 工具的 stdout 也不回显 → 需要看输出时一律把结果写进临时文件再用 Read 读。

Phase 1 完成(类型 / 常量 / 纯函数下沉,零逻辑改动)

  • cleaning.vue: 8214 → 6122 行;新增 ai-frontend/src/case/views/data/cleaning/ 下 11 个文件:
    • types.ts 246 行(25 个类型定义)
    • constants.ts 175 行(17 个常量/选项)
    • utils/text.ts 167、utils/treeUtils.ts 136、utils/tableRuleBuild.ts 207、utils/formatValidate.ts 313
    • utils/ruleSerialize.ts 455、utils/headerRow.ts 78、utils/recognition.ts 195
    • utils/columnSplit.ts 88、utils/domScroll.ts 31、utils/mockData.ts 134
  • 抽取方式:脚本按「行区间」机械搬运 + 自动补 export / 补 import(跨文件内部依赖 + cleaning.vue 原有外部依赖), 逐行原样搬运,只去掉统一 2 空格缩进,业务逻辑零改动。
  • 验证:pnpm type:check 中 cleaning 相关报错 0 条(仓库现有 89 条报错全部来自 call/、trans/、graph/ 等无关文件的既有问题); pnpm build 退出码 0,✓ built in 1m 58s,产出 dist/assets/cleaning-*.js。
  • 回滚点:E:\workspace\zsjz-ai\_bak\cleaning.vue.phase0(8214 行基线,后续 Phase 出错可直接覆盖回来)。

抽取脚本的三个坑(下次写类似脚本要注意)

  1. 抽出的顶层声明必须补 export,否则报 TS2459 declares X locally, but it is not exported / TS2306 File is not a module (无 import 也无 export 的 .ts 会被 TS 当全局脚本,不是模块)。
  2. 抽出的文件用到 cleaning.vue 原有外部 import(../../../dm/types/Func 的 Fun* 类、FileStatusEnum、TableInfo 等)时必须按新目录层级重算相对路径 (cleaning/ 深 1 层、cleaning/utils/ 深 2 层)。漏了会报 TS2304 Cannot find name 'FunReplace' 之类。
  3. 依赖扫描要用原文,不要用 strip(去注释/字符串) 后的文本:`${fn(x)}` 这种模板字符串插值会被整段当成字符串抹掉, 导致漏 import。改用原文扫描,注释/字符串里的同名标识符最多只多出一个无害的 import。

Phase 2 完成(三个独立 Modal 组件化)

  • cleaning.vue: 6123 → 5369 行。新增 7 个文件:
    • cleaning/context.ts(20 行):CleaningContextKey + useCleaningContext()
    • cleaning/components/AddColumnModal.vue 118 行、SplitColumnModal.vue 71 行、TemplateSelectModal.vue 187 行
    • cleaning/composables/useAddColumnModal.ts 203 行、useSplitColumnModal.ts 116 行、useTemplateModal.ts 167 行
  • 架构:Modal 专属状态/逻辑 → composable(由组件在 setup 里实例化,所以每个 Modal 一份状态); 页面级共享状态 → cleaning.vue 里 provide(CleaningContextKey, {...}),子组件/composable 用 useCleaningContext() 按名取用。
    • provide 放在 setup 末尾,避免 const 声明顺序造成的 TDZ
    • 页面自己的代码一行没改(不做解构、不改调用点),所以零回归风险
  • 划分算法(脚本):种子=Modal 模板引用到的页面声明 → 传递闭包 → 不动点收缩(反复把「被留在页面的代码引用」的声明踢出可搬集合,4 轮收敛)
  • 验证:pnpm type:check cleaning 相关报错 0 条;pnpm build exit 0,✓ built in 1m 50s,cleaning-*.js 118.68 kB
  • 回滚点:_bak/cleaning.vue.phase1(6123 行)/ _bak/cleaning.vue.phase0(8214 行)

Phase 2 的两个关键教训

  1. 「闭包内」不等于「可搬走」:Modal 的 handler 会碰页面状态,而页面的 open/close/watch/onMounted 又反向引用 Modal 的状态。 第一版把整个闭包当可搬集合 → 把页面还要用的声明误搬走。必须用「只把真正搬走的声明从页面文本里挖掉,再看还有谁引用」的不动点收缩。
  2. 页面级 let/var 标量不能注入:注入是按值快照,搬走的代码和页面会各改一份副本(templatePreviewRequestToken 这种请求序号守卫会直接失效)。 规则:凡直接引用页面 let/var 的声明一律不搬。
  3. 实际只搬动 36 个声明 / 433 行 script(方案预期 −1100 行)。剩下的都因为「页面代码反向引用」被不动点挡住 —— 要真正大幅瘦身,必须让页面自己也把状态搬进 composable 并在 setup 顶层解构(名字不变、调用点不变),那是 Phase 3-5 的活。

Phase 3 完成(右栏清洗规则面板,最大一块)

  • cleaning.vue: 5370 → 3921 行(本轮 −1449)。累计 12029 → 3921(−67%)。
  • 规则面板 = 模板 L577-L1160(584 行),内部 4 段:rule-col(规则库) / rule-op(操作按钮) / rule-col(已选规则) / rule-config(参数配置)。
  • 关键发现:规则面板引用的 60 个页面声明,与数据预览表/列操作工具条的交集是 0 —— 域边界天然干净。
  • 搬走 113 个声明 / 872 行 → composables/useCleaningRules.ts(948 行);只留 7 个依赖(createMessage / rightTableHeaders / addedColumns / sourceTableRows / tablePreviewRows / currentColumn / headerRowIndex);页面同名解构 59 个名字。
  • 新增:composables/useCleaningRules.ts、components/CleaningRulePanel.vue(56) / CleaningRuleLibrary.vue(27) / SelectedRuleList.vue(32) / RuleConfigForm.vue(527,13 类规则表单暂内联,远低于 900 上限);context.ts 扩成带类型的 CleaningContext
  • 验证:pnpm type:check cleaning 相关 0 报错;pnpm build exit 0,✓ built in 2m 12s,cleaning-*.js 102.80 kB
  • 回滚点:_bak/cleaning.vue.phase2(5370 行)

Phase 3 定的新架构(关键,后续阶段照这个来)

composable 由「页面」实例化,页面同名解构接住返回值。

const cleaningRules = useCleaningRules({ /* 7 个依赖 */ });
const { selectRules, selectedRules, runRulePipeline, ... } = cleaningRules;  // 页面代码/模板零改动
provide(CleaningContextKey, { ...原有一堆, cleaningRules });                  // 子组件从 context 取

这样「页面自己也引用」不再是不能搬的理由(Phase 2 的不动点收缩就是被这条卡住的): Phase 2 只搬得动 36 个声明 / 433 行,Phase 3 直接搬到 113 个 / 872 行。

Phase 3 踩的 4 个坑(都值得记住)

  1. 模板取词必须保留 ${...} 插值。strip() 会把整个模板字符串抹掉,`is-${currentRuleScopeType}` 里的标识符就丢了 → 组件少一个绑定(TS2551)。改成只剥普通字符串('…' / "…"),遇到反引号时把 ${...} 内容取出来。
  2. 类型循环会让 deps 退化成 Record<string, any>。页面里「留在页面的函数,其签名/返回值依赖解构出来的 composable 返回值」时, TS 无法推 Deps,直接退回约束类型 → 注入项全是 any → 而 Object.entries(<any>) 在这个 TS 版本返回 [string, unknown][] → 下游一片 targetMeta is of type 'unknown'。解法:deps 必须显式声明类型(interface CleaningRulesDeps), 不要让 TS 从实参反推;ref 的类型可以从页面声明里自动抠出来(ref<...> 按尖括号配对;ref([...X]) 用 Ref<(typeof X)[number][]>)。
  3. 判定「setup 顶层立即求值」不能把末尾的 provide(...) 算进去。它本身就是顶层语句且列了一堆名字, 会被判成「wiring 之前被立即引用」,把一大批声明错误地退回页面 → wiring 位置被推到很晚 → 反而引发一串 TS2448。 正确做法:只扫描 wiring 位置之前的行,并且「可搬集合 ↔ wiring 位置」互相影响,必须迭代到收敛。 另外 watch([...一堆 ref]) 是顶层语句、数组在 setup 期立即求值 → 整条 watch 语句都要算「立即」。
  4. 声明解析不要用括号深度。多行泛型 const x = ref<\n Record<\n string,\n {...}\n >\n>({}) 会让深度在中途归零, 按 endsWith('}') 提前收尾 → 把 splitColumnConfig 切掉一半(语法错误)。改成「找下一个顶层起点」(2 空格缩进的声明或第 0 列的代码)。

Phase 4 完成(中栏三个面板 + 空态)

  • cleaning.vue: 3921 → 2459 行(本轮 −1462)。累计 12029 → 2459(−80%)。
  • 中栏结构(原 L80-453,374 行):
    • mapping-table-card(98-228) → FieldMappingCard.vue 97 行 + OriginFieldPopover.vue 70 行(Popover #content 插槽 149-200)
    • recognition-panel(237-394) → RecognitionRulePanel.vue 175 行
    • child-template-panel(396-408) → ChildTemplatePanel.vue 29 行
    • middle-empty-shell(413-452) → MiddleEmptyState.vue 56 行(v-else 留在父级:<MiddleEmptyState v-else />)
    • 保留在页面:匹配条(81-94)、template-foot-panel 外层 wrapper(230-236/409)
  • 3 个 composable:useFieldMapping.ts(759,53 个声明 680 行)、useRecognitionRules.ts(262,23/163)、useTemplateLibrary.ts(375,33/301)
    • 共搬走 109 个声明 / 1144 行;跨域共享 24 个名字留在页面注入给多个域
  • 验证:pnpm type:check cleaning 0 报错;pnpm build exit 0,✓ built in 1m 53s
  • 回滚点:_bak/cleaning.vue.phase3(3921 行)

Phase 4 踩的 3 个坑

  1. 算「页面还要解构哪些名字」时漏了未抽取区域:我原来只扫 script + 中栏之外的模板,把「中栏里保留的部分」(匹配条、template-foot-panel wrapper) 漏掉了 → 页面用到的 hasTemplateSelected / openTemplateModal / showRecognitionPanel 没被解构出来。正确做法:整份模板 minus 被抽走的块。
  2. Phase 3 产生的「解构型页面绑定」类型提不出来 → deps 里变成 any → 又是 Object.entries(any) → [string, unknown][] → 一片 unknown。 解法:若某依赖是页面里 const { x } = cleaningRules; 解构出来的,就把它写成 ReturnType<typeof useCleaningRules>['x'](并 import 那个 composable 的类型)。 另外 deps 类型文本里出现的类型名(如 Ref<TemplateTab> 里的 TemplateTab)也要补 import。
  3. 页面级 let/var 标量不能注入(Phase 2 已经踩过,这次又忘了):搬走的代码要 token += 1,注入后是 const 直接报错。 规则:凡直接引用页面 let/var 的声明一律不搬,把标量留在页面。

重要的验证教训

pnpm type:check 抓不到 Vue 模板标签不平衡! 这次 child-template-panel 的结束行我多算了一行(408 结束、409 是外层 wrapper 的闭合), 结果把外层 <div> 的闭合标签删掉了 —— type:check 报 0 个 cleaning 错误,但 pnpm build 直接失败(El ement is missing end tag)。 → 每阶段必须 type:check + build 双验证,而且抽块时要用「配对的 </div> 行」当边界,不能凭缩进猜。

Phase 5 + Phase 6 尝试(未交付,已回滚)

结论:没有完成。当前仓库停在 Phase 4 的状态(cleaning.vue 2458 行),已确认 type:check 0 报错 + build exit 0。 Phase 5/6 的生成器(_phase56.cjs,已删除)反复调试后仍然不成立,原因不是脚本 bug 而是架构约束,记录如下:

为什么做不下去

  1. 新域和已有域之间存在真实的环。useFileTree 想要的底层状态(tablePreviewRows / rightTableHeaders / addedColumns …) 也是已有 composable(cleaningRules / recognitionApi / mappingApi / templateApi)的依赖;反过来已有域拥有的名字又被新域需要。 于是「wiring 顺序」必须做拓扑排序,而图里真有环。
  2. 破环只有两条路,都不好:
    • 精确退单条边(把造成回边的名字退回页面)→ 在多域环里来回震荡,20 轮不收敛
    • 环内新域整体退回页面 → 能收敛,但 useFileTree / usePreviewData / useNodeStateCache / useCaseDataCleaningPage 全部退化成 19 行的空壳(垃圾文件),页面只从 2459 掉到 2260
  3. 中途曾跑出过 850 行的结果(type:check + build 都过),但那次是依赖边解析不全(ownerOfName 漏了查已有 handle 的解构名)导致图里没有环, 属于「侥幸正确」,不可信 —— 这种结果不能交付。
  4. 还踩了两个坑:① 顶层起点识别要包含 watch( / provide( / 生命周期调用,否则上一个声明的范围会把它们整段吞掉; ② wiring 要插在锚点行之后(锚点是依赖声明的最后一行,插前面会插进声明内部)。

正确的做法(下次)

不要再让「多个平级 composable 互相注入」硬解 —— 应该先抽出唯一一个 useCaseDataCleaningShared(owns 所有底层共享状态), 让 tree / preview / columnOps / rules / recognition / mapping / template 全部以它为 dep 单向依赖,环自然消失。 这是「设计一步」而不是「自动搬运一步」,值得单独一轮 review。

本阶段的可用沉淀

  • 中栏两个模板块(aside 左栏树 58 行、table-mock 预览表 89 行)的抽取边界已核实清楚(L19-76 / L149-237)
  • 生成器里可复用的部分:声明解析(找下一个顶层起点)、模板取词(保留 ${...})、依赖类型显式化、provide 收敛、 以及「页面实例化 + 同名解构」的接线模式 —— 这些在 Phase 3/4 已验证,Phase 5/6 可以直接照搬

Phase 5(shared store 版)完成 —— 架构解锁

用户拍板「按这个思路单独做一轮」。成功,双验证通过:

  • cleaning.vue: 2459 → 2170 行(累计 12029 → 2170,−82%)
  • 新增 composables/useCaseDataCleaningShared.ts(356 行):底层共享状态唯一 store(54 个声明 / 297 行)
  • 验证:pnpm type:check cleaning 0 报错(89 条既有);pnpm build exit 0,✓ built in 1m 33s
  • 回滚点:_bak/cleaning.vue.phase4

落地规则(这次消环的关键,后续阶段照用)

  1. useCaseDataCleaningShared 认领「页面里除壳之外的声明」,但必须剔掉两类:
    • 引用了已有 handle 产物的声明(否则 shared ↔ handle 成环)→ 本次剔掉 64 个,退回页面当 shared 的 dep
    • 在 wiring 锚点之前被「顶层立即求值」引用的声明 → 本次剔掉 8 个(rightTableHeaders 这类不受影响, 但 templateModalOpen / activeCategory 因为出现在顶层 watch 列表里被退回)
  2. 锚点 = 依赖(留在页面的名字)里最晚的声明结束行,不能用「所有剩余声明」的最大行 —— 后者会把锚点推到文件末尾, 导致立即求值检测几乎全量误判
  3. 立即求值检测要跳过该名字自己声明的行,否则 const x = ref(0) 会因为引用自己而误判
  4. 认领时要排除 4 个已有 handle 名本身(它们是 wiring 声明,会被重复发射 → TS2451)
  5. 权重顺序(本次验证可行):sharedApi → 已有 4 个 handle; 下一步加叶子域时必须放在 handle 之后(叶子域同时依赖 shared 和 handle 是允许的,全是单向)→ 无环

下一步(未做,设计已明确)

把「引用了 handle 产物的 64 个声明」按域拆出去,且这些域要放在 4 个 handle 之后: useFileTree / usePreviewData / useColumnOps / useNodeStateCache + 抽 FileTreePanel.vue(L19-76) / DataPreviewTable.vue(L149-237)。 因为叶子域依赖 shared 和 handle 都是单向,不会再出现环 —— 这一轮才是把页面压到 ≤400 行的关键。

Phase 5b 完成(叶子动作域 + 两个面板组件)

成功,双验证通过:cleaning.vue 2170 → 944 行(累计 12029 → 944,−92%);type:check cleaning 0 报错;build exit 0(1m 39s)。

  • 新增 composables/useCaseDataCleaningActions.ts(1310 行):45 个声明 / 1088 行的叶子逻辑 (树匹配度、预览数据集载入、列规则恢复、列操作、扫描触发、文件导入、模板选择入口…)
  • 新增 components/FileTreePanel.vue(75 行,原 aside L19-76)、components/DataPreviewTable.vue(102 行,原 table-mock L149-237)
  • 依赖顺序:useCaseDataCleaningShared → 4 个 handle → useCaseDataCleaningActions(单向,无环)
  • 21 个声明因「顶层立即求值」退回页面(主要出现在页面顶层的 watch([...refs]) 列表里)
  • 回滚点:_bak/cleaning.vue.phase5a(2170)

关键做法(本轮奏效的原因)

  • 叶子域放在所有上游之后,所以它可以直接用 handle 的产物 → 不需要做「引用 handle 产物就退回」的剔除(上一轮那一步砍掉了 64 个声明的搬动机会)
  • 锚点直接用「已有 wiring 块的结束行」,插在其后即可 —— 不用再算拓扑/锚点
  • 相对路径 rebase 必须按目标目录算(composable 与 component 目录层级不同),../../../dm/types/Func 这类要单独处理

还没到 ≤400 的原因与下一步

✅ 业务逻辑等价性验证(已做,结论:零改动)

用 git show HEAD:.../cleaning.vue 取原始版,与「cleaning.vue + cleaning/** 整棵树」做逐行多集合比对 (归一化:去 export 前缀 / 折叠空白 / 去行尾 ,;;跳过注释与 import)。结果:

  • 【script】丢失/被改动的行:0 —— 原始 6704 行脚本代码,每一行在当前树里的出现次数都 ≥ 原始次数,没有任何一行被改写或丢失
  • 【script】新增 502 行,全部是脚手架,且逐条核查过:
    • deps interface 的字段类型行(addedColumns: Ref<string[]>、ReturnType<typeof useCleaningRules>['x'])
    • const { / } = deps / 6 条 wiring 行 / 3 个 Phase-2 composable 的 function useXxx() { / CleaningContextKey
    • 各 composable、interface 多出来的闭合 }(+23)
  • 【template】只有 1 行「差异」:<div v-else class="middle-empty-shell"> → 设计为 v-else 上提到父级 (<MiddleEmptyState v-else />)+ 组件根保留 <div class="middle-empty-shell">,两者都在,语义等价
  • 【style】只少了 <style> / </style> 两个包裹标签,样式内容全量进了 styles/*.less

✅ lint / 格式化收口(已做)

用户要求:清未使用 import + 修 ESLint: Insert ·· (prettier/prettier)。最优路径就是用项目自己的 eslint --fix, 因为 prettier/prettier 是 eslint 规则,一把就能把格式和未使用 import 都修掉:

cd ai-frontend
pnpm exec eslint --fix "src/case/views/data/cleaning.vue" "src/case/views/data/cleaning/**/*.{ts,vue}"
pnpm exec prettier --write "src/case/views/data/cleaning/**/*.less"
  • eslint(不带 --fix)复检:0 problems;prettier --check(less):通过
  • 未使用 import:我自己写脚本又清了一遍 cleaning.vue 里残留的 24 个绑定 (nextTick / DownOutlined / UpOutlined / Checkbox / InputNumber / Popover / Radio / Select / Switch / Tooltip / EmptyState / BasicTree / FileInfo / tplCategoryList / tplCheckTableName / tplPage / rawPreview / FileStatusEnum / getVisibleOriginOptionList / scrollSelectedTreeNodeIntoView / loadStoredFileList / mockFileList / mockTemplateList / OriginFieldPopover) —— composables 与 components 都是按需生成的,一个多余的都没有
  • 最终:type:check cleaning 0 报错(89 条既有);pnpm build exit 0

两个要知道的副作用

  1. cleaning.vue 行数 944 → 1431:prettier 按 printWidth 把 12 行「接线管线」折成了每行一个名字 (6 条 const api = useXxx({ ...80 个依赖... }) + 6 条 const { ...80 个名字... } = api;)≈ +490 行。 不是代码变多,是格式化的必然结果。想让行数回落要把这种大解构改掉(比如让 composable 返回分组对象),属于设计变更。
  2. styles/*.less 需要 dedent:从 .vue 的 <style> 块抽成独立 .less 时保留了 2 空格缩进(.vue 里合法), 独立 .less 要求从 0 开始 → page.less / modals.less 被 prettier 重新缩进了一遍(纯格式)。

证明「eslint --fix 只改格式」的方法(已验证)

格式化前把整棵树快照到临时目录,然后逐文件比较去掉所有空白后的字符流: 结果 41 个文件里 16 个完全一致、24 个仅差 prettier 补/去的尾逗号、1 个(context.ts)是 eslint 自动移除了一条已失效的 eslint-disable 注释 → 零逻辑改动。 (这个「空白无关字符流比对」比行级比对更适合校验格式化,因为 prettier 会重排换行。)

做这类验证的坑(下次注意):

  1. PowerShell 5.1 的 > 重定向写 UTF-16 → 用 node execSync(..., {encoding:'buffer'}) 取字节流(cmd /c 被安全策略拦)
  2. SFC 切分时 template 的结束点是 <script 而不是 <style(原文件是 template→script→style 顺序)
  3. 不要跳过 export 开头的行,要在归一化时去掉 export 前缀再比
  4. 扫描「当前树」时只能圈 cleaning 自己那一棵,别把 data/ 下其它页面(importList/cleanProgress/index)算进来,否则「新增」会虚高 3500+
  5. 原始 HEAD 是 12002 行,而开工时工作区是 12029 行 → 说明开工前工作区有约 27 行未提交改动;验证以 HEAD 为基准,未发现逻辑型差异(若以后要严格验证,开工前应先 git stash / 备份原始文件) 页面还剩 ~944 行 = 模板(~110) + import(~100) + 壳(22 个声明 ~180) + 21 个因 watch 列表退回的声明 + wiring/provide。 要再压:
  6. 把顶层 watch([...]) 语句移进 composable(watcher 在 composable 内注册仍绑定到页面实例),那 21 个声明就能搬走
  7. 顺手用脚本清一遍未使用 import(注意:匹配标识符时要允许 ... 展开,我上次因为排除了 . 误删了 2 个 import)
  8. 把 useCaseDataCleaningActions(1310 行)按域拆成 useFileTree / usePreviewData / useColumnOps(都依赖 shared + handle,彼此不互相依赖)