Files
workspace/code/fms/.codebuddy/plans/popover-zero-wrapper-refactor_f54580ca.md
2026-08-30 21:50:03 +08:00

12 KiB
Raw Permalink Blame History

name, overview, todos
name overview todos
popover-zero-wrapper-refactor 为 Popover 组件增加显式 unwrapped 开关的"零包裹"模式(事件注入到用户触发器 vnode),并将 Tooltip/Dropdown/Select/DatePicker/RangePicker 及 AppSidebar、AppTabs、结构树等全部调用方迁移到零包裹,删除所有 .popover-trigger 相关补丁样式与 stretch-trigger 业务用法,全程保持现有行为不回归。
id content status
core-popover-unwrapped 改造 popover.vue:加 unwrapped prop、cloneVNode 注入事件与锚点指令、双分支模板 completed
id content status dependencies
migrate-derived 迁移 Tooltip/Dropdown/menu/Select/DatePicker/RangePicker 开启 unwrapped,删除各 index.scss 的 .popover-trigger 补丁 completed
core-popover-unwrapped
id content status dependencies
migrate-usages 迁移外部使用点:AppSidebar/AppTabs 删 :deep 规则、结构树去 stretch-trigger、help-icon 开 unwrapped,用 [subagent:code-explorer] 核对全量清单 completed
migrate-derived
id content status dependencies
demo-tests demo/popover.vue 补 fallback/组件触发器/事件合并三用例,新增 popover vitest 单测 completed
core-popover-unwrapped
id content status dependencies
verify-all 跑 pnpm lint、fmt:check、build、test 全量验证,并人工回归四类触发场景 completed
migrate-derived
migrate-usages
demo-tests

用户需求

为 fms-vue 的 Popover 组件实现"零包裹"(antdv 式事件注入)模式,并全量迁移派生组件与外部调用点,同时保证不引入性能开销。

执行策略:直接迁移(非渐进)。项目处于初期阶段,迁移成本低,不做"迁移一个验证一个",派生组件与外部使用点一次性全部开启 unwrapped,最后统一跑验证。unwrapped prop 保留默认 false 作为 API 兜底,但本次全量调用点均显式开启。

核心功能

  • Popover 新增显式 unwrapped prop(默认 false,保持现有行为):触发器插槽为单根元素/组件 vnode 时,通过 cloneVNode 把 6 个内部事件 handler(click/contextmenu/mouseenter/mouseleave/focusin/focusout)合并进用户 vnode,并用注入指令获取根 DOM 作为定位锚点;多根 fragment / 文本节点自动回退到现有 span.popover-trigger 包裹层。
  • 派生组件全量迁移:Tooltip、Dropdown(含子菜单)、Select、DatePicker、RangePicker 内部显式开启 unwrapped,删除各自样式里的 .popover-trigger 布局补丁。
  • 外部使用点迁移:AppSidebar / AppTabs 删除 :deep(.popover-trigger) 规则,结构树移除 stretch-trigger,FormEditPanel / TableEditPanel 的帮助图标 Popover 一并开启 unwrapped。
  • demo 补充零包裹/fallback/事件合并用例;新增 Popover 组件单测(vitest + @vue/test-utils + jsdom)。
  • 性能约束:归一化走 computed 缓存、不新增任何 watcher/observer/全局监听、unwrapped=false 时零额外开销。

边界

  • fallback 路径保留 stretch-trigger 与 .popover-trigger 样式,不做破坏性删除。
  • 事件合并采用 Vue mergeProps 原生语义:用户 handler 先执行、内部 handler 后执行,两者都触发,不覆盖。
  • 零包裹模式下 stretch-trigger 无意义,静默忽略。

技术栈

  • 现有栈:Vue 3.5.41(<script setup> + SFC)+ SCSS;验证脚本 pnpm lint(oxlint --deny-warnings)、pnpm fmt:check(oxfmt)、pnpm build(vite)、pnpm test(vitest run)。
  • 复用 Vue 内置 cloneVNode / mergeProps / useSlots / 自定义指令机制,不引入任何新依赖。

实现思路

核心:popover.vue 零包裹模式

在 src/components/ui/popover/popover.vue 中:

  1. 新增 prop:unwrapped: { type: Boolean, default: false }。
  2. 归一化 computed(性能关键,响应式缓存):
const injectedTrigger = computed(() => {
  if (!props.unwrapped) return null
  const nodes = slots.trigger?.() ?? []
  const real = nodes.filter((n) => n.type !== Comment)
  if (real.length !== 1) return null
  const vnode = real[0]
  if (typeof vnode.type === 'symbol') return null // 文本/注释节点无法承载事件
  const injected = cloneVNode(vnode, triggerHandlers, true)
  // cloneVNode 在 props 变化时自动把 patchFlag 重置为 -1、清空 dynamicProps,
  // 避免 diff 按旧 flag 跳过属性更新导致注入事件静默失效
  return { ...injected, directives: [...(vnode.directives || []), { dir: triggerRefDirective }] }
})
  • triggerHandlers:{ onClick, onContextmenu, onMouseenter, onMouseleave, onFocusin, onFocusout },即现有 6 个函数,仅绑定位置从模板 span 改为注入 vnode;mergeProps 自动把用户已有 on* 合并为 [用户, 内部] 数组,两者都触发。
  • triggerRefDirective:mounted/updated 把用户 vnode 根 DOM 同步到现有 triggerEl ref(定位锚点、外部点击 contains、followTriggerWidth 的 offsetWidth 三处复用,语义不变);mounted 时若已打开(如 contextmenu/受控)补一次 updatePosition;unmounted 按 el 比对清理,不覆盖用户自己的 ref。
  • 判定规则(O(1) 浅检查):vnode.type 为 'string'(原生元素)或 'object'(组件)→ 注入;'symbol'(文本/注释)或数组多根 → 返回 null 走包裹层。
  1. 模板双分支:<component v-if="injectedTrigger" :is="injectedTrigger" />,v-else 保留现有 <span ref="triggerEl" class="popover-trigger" ...> 包裹层(unwrapped=false、多根、文本三种情况均走此分支)。injectedTrigger 非空即表示"unwrapped 且可注入",二者条件合并,无需额外判断。

性能保证(本次改造的硬约束)

  • 零新增运行时开销:不新增 watcher / ResizeObserver / 全局监听,全部复用现有 watch(isOpen)、onViewportChange、onDocumentPointerDown 等。
  • 归一化缓存:injectedTrigger 是 computed,slots.trigger 为响应式依赖,仅在插槽内容或 unwrapped 变化时重算;unwrapped=false 时直接返回 null,零 vnode 操作。
  • cloneVNode 成本:一次浅拷贝 + O(n) 属性合并(6 个 handler),仅 Popover 渲染时发生,微秒级;与 antdv / Element Plus 实现规格一致。
  • 锚点通过指令生命周期钩子同步,无轮询、无 DOM 查询。

派生组件迁移(一次性全量,全部显式开启 unwrapped)

以下 6 个文件在同一轮改动中全部迁移,不逐组件验证;配合"外部使用点迁移"一起完成后再统一跑 lint / fmt / build / test。

  • tooltip.vue:Popover 加 unwrapped(固定)。
  • dropdown.vue:新增 unwrapped: { type: Boolean, default: true } prop 透传给 Popover;dropdown/index.scss 删除 :deep(.popover-trigger) 补丁(L59-62),执行时确认 .dropdown-item 自身是否已撑满,缺失则补 width:100%。
  • dropdown/menu.vue:嵌套子菜单 Popover 加 unwrapped(trigger 为单根 span.dropdown-item)。
  • select.vue:Popover 加 unwrapped;select/index.scss 删除 :deep(.popover-trigger) 补丁(L10-13),.select-trigger 自带 width:100% 直接撑满 .select。
  • date-picker.vue / range-picker.vue:Popover 加 unwrapped;date/index.scss 删除全局 .popover-trigger { display:block; width:100% }(L15-18,非 scoped 的全局规则,删除同时消除其对其它组件的样式污染隐患)。

外部使用点迁移(与派生组件同一轮一次性完成)

  • AppSidebar.vue:删除 .fms-team :deep(.popover-trigger) 规则(L177-184);.fms-team-trigger 自带 width:100%,零包裹后按钮直接为容器子元素,天然撑满。
  • AppTabs.vue:删除 .fms-tabs-track :deep(.popover-trigger) 规则(L301-305);.fms-tab 自带 flex-shrink:0。
  • ModuleStructureTree.vue:Dropdown 移除 stretch-trigger;.mst-tree 自带 width:100%,零包裹后直接相对面板撑满,恢复"零配置撑满"。
  • FormEditPanel.vue / TableEditPanel.vue:帮助图标 Popover 加 unwrapped(单根 span.mm-edit__help-icon,行为不变)。

demo 与测试

  • demo/popover.vue 补充三用例:多根 fragment(验证 fallback 到 span 包裹层)、组件作触发器(Input)、触发器带用户 @click(验证合并顺序:用户先执行、toggle 后执行)。
  • 新增 src/components/ui/popover/__tests__/popover.spec.js(项目当前无测试文件,vitest 基建已就绪;文件头加 // @vitest-environment jsdom 声明环境)。用例:默认渲染 span、unwrapped 单根不渲染 span 且点击可开合、多根 fallback、用户与内部 click 均触发、组件触发器锚点生效。

架构图

flowchart TD
    A[popover.vue 双分支模板] --> B{injectedTrigger computed}
    B -->|unwrapped=true 且单根元素/组件| C[component :is=注入后 vnode<br/>cloneVNode+mergeProps 注入6事件<br/>patchFlag=-1 + 指令锚点]
    B -->|unwrapped=false 或 多根/文本| D[span.popover-trigger 包裹层<br/>stretch-trigger 保留]
    C --> E[派生组件全量迁移<br/>Tooltip/Dropdown/menu/Select/DatePicker/RangePicker]
    E --> F[外部使用点<br/>AppSidebar/AppTabs 删 :deep<br/>结构树去 stretch-trigger<br/>help-icon 开 unwrapped]
    D --> F

验证

  • pnpm lint(oxlint --deny-warnings 零告警)、pnpm fmt:check、pnpm build、pnpm test。
  • 手动回归点:团队切换下拉(AppSidebar)、页签右键菜单(AppTabs)、结构树右键菜单(ModuleStructureTree)、Select/DatePicker 宽度对齐、help-icon hover 提示。

目录结构

fms-vue/src/
├── components/ui/
│   ├── popover/popover.vue        # [MODIFY] 核心:unwrapped prop + 归一化 computed + 指令 + 双分支模板
│   ├── popover/index.scss         # [MODIFY] 仅保留 .popover-trigger 样式(fallback 用),结构不变
│   ├── popover/__tests__/popover.spec.js  # [NEW] vitest 单测(jsdom 环境)
│   ├── tooltip/tooltip.vue        # [MODIFY] Popover 加 unwrapped
│   ├── dropdown/dropdown.vue      # [MODIFY] 新增 unwrapped prop(默认 true)透传
│   ├── dropdown/menu.vue          # [MODIFY] 嵌套 Popover 加 unwrapped
│   ├── dropdown/index.scss        # [MODIFY] 删 :deep(.popover-trigger) 补丁
│   ├── select/select.vue          # [MODIFY] Popover 加 unwrapped
│   ├── select/index.scss          # [MODIFY] 删 :deep(.popover-trigger) 补丁
│   ├── date/date-picker.vue       # [MODIFY] Popover 加 unwrapped
│   ├── date/range-picker.vue      # [MODIFY] Popover 加 unwrapped
│   ├── date/index.scss            # [MODIFY] 删全局 .popover-trigger 规则
│   └── demo/popover.vue           # [MODIFY] 补 fallback/组件触发器/事件合并三用例
├── layouts/components/
│   ├── AppSidebar.vue             # [MODIFY] 删 :deep(.popover-trigger) 规则
│   └── AppTabs.vue                # [MODIFY] 删 :deep(.popover-trigger) 规则
└── views/system/module-management/
    ├── components/structure-tree/ModuleStructureTree.vue  # [MODIFY] Dropdown 去 stretch-trigger
    └── components/
        ├── form-edit/FormEditPanel.vue   # [MODIFY] help-icon Popover 加 unwrapped
        └── table-edit/TableEditPanel.vue # [MODIFY] help-icon Popover 加 unwrapped

Agent Extensions

Skill

  • antdv-next
  • 用途:参考 antdv 对触发器 vnode 的事件注入、锚点获取与 patchFlag 处理的成熟实现模式,确保零包裹实现与主流组件库对齐。
  • 预期产出:popover.vue 核心改造与单测断言与 antdv 语义一致(事件合并顺序、多根兜底、组件触发器)。

SubAgent

  • code-explorer
  • 用途:执行阶段扫描全项目确认 Popover/Tooltip/Dropdown/Select/DatePicker 的所有使用点与 :deep(.popover-trigger) 依赖清单,防止迁移遗漏回归点。
  • 预期产出:完整的调用点清单,与计划中的迁移文件逐一核对,确保"都改掉"无遗漏。