12 KiB
12 KiB
name, overview, todos
| name | overview | todos | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| popover-zero-wrapper-refactor | 为 Popover 组件增加显式 unwrapped 开关的"零包裹"模式(事件注入到用户触发器 vnode),并将 Tooltip/Dropdown/Select/DatePicker/RangePicker 及 AppSidebar、AppTabs、结构树等全部调用方迁移到零包裹,删除所有 .popover-trigger 相关补丁样式与 stretch-trigger 业务用法,全程保持现有行为不回归。 |
|
用户需求
为 fms-vue 的 Popover 组件实现"零包裹"(antdv 式事件注入)模式,并全量迁移派生组件与外部调用点,同时保证不引入性能开销。
执行策略:直接迁移(非渐进)。项目处于初期阶段,迁移成本低,不做"迁移一个验证一个",派生组件与外部使用点一次性全部开启
unwrapped,最后统一跑验证。unwrappedprop 保留默认false作为 API 兜底,但本次全量调用点均显式开启。
核心功能
- Popover 新增显式
unwrappedprop(默认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 中:
- 新增 prop:
unwrapped: { type: Boolean, default: false }。 - 归一化 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 同步到现有triggerElref(定位锚点、外部点击contains、followTriggerWidth的offsetWidth三处复用,语义不变);mounted时若已打开(如 contextmenu/受控)补一次updatePosition;unmounted按 el 比对清理,不覆盖用户自己的 ref。- 判定规则(O(1) 浅检查):
vnode.type为'string'(原生元素)或'object'(组件)→ 注入;'symbol'(文本/注释)或数组多根 → 返回null走包裹层。
- 模板双分支:
<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)依赖清单,防止迁移遗漏回归点。 - 预期产出:完整的调用点清单,与计划中的迁移文件逐一核对,确保"都改掉"无遗漏。