diff --git a/packages/opendesign/src/_utils/is.ts b/packages/opendesign/src/_utils/is.ts index 305b6ca7efc60d32b1dae4b94ce39c149569dd0f..bb94071d72ff1126dd893864954d040d7954ace0 100644 --- a/packages/opendesign/src/_utils/is.ts +++ b/packages/opendesign/src/_utils/is.ts @@ -32,7 +32,7 @@ export function isEmptyArray(val: unknown): val is Array { return isArray(val) && val.length === 0; } -export function isArrayEqual(arr1: Array, arr2: Array): boolean { +export function isArrayEqual(arr1: Array, arr2: Array, order = false): boolean { if (!isArray(arr1) || !isArray(arr2)) { return false; } @@ -41,9 +41,18 @@ export function isArrayEqual(arr1: Array, arr2: Array): boolean { return false; } - for (let i = 0; i < len; i++) { - if (!arr2.includes(arr1[i])) { - return false; + if (order) { + for (let i = 0; i < len; i++) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + } else { + const arr2Set = new Set(arr2); + for (let i = 0; i < len; i++) { + if (!arr2Set.has(arr1[i])) { + return false; + } } } diff --git a/packages/opendesign/src/_utils/vue-utils.ts b/packages/opendesign/src/_utils/vue-utils.ts index f1e33db4482a2827726a5373dfecfeb98fcdb616..0f082373d1eec58e0354817a21fe49ada3c9bef2 100644 --- a/packages/opendesign/src/_utils/vue-utils.ts +++ b/packages/opendesign/src/_utils/vue-utils.ts @@ -1,5 +1,25 @@ -import { Component, onMounted, ref, Slots, Slot, VNode, VNodeTypes, Comment, ComponentPublicInstance, watchEffect, Ref, isRef, watch } from 'vue'; -import { isArray } from './is'; +import { + type Component, + onMounted, + ref, + Slots, + type Slot, + VNode, + VNodeTypes, + Comment, + ComponentPublicInstance, + Ref, + isRef, + watch, + isVNode, + type VNodeNormalizedChildren, + type ComponentInternalInstance, + shallowRef, + nextTick, + getCurrentInstance, + onBeforeUnmount, +} from 'vue'; +import { isArray, isArrayEqual, isString } from './is'; import { isHtmlElement } from './dom'; import { log } from './log.ts'; @@ -36,7 +56,7 @@ export const isTextElement = (vnode: VNode) => { * @param vnode vnode节点 * @param type 组件信息 */ -export function isComponent(vnode: VNode, _type?: VNodeTypes): _type is Component { +export function isComponent(vnode: VNode, _type?: VNodeTypes): _type is Component & { __name?: string; name?: string } { return Boolean(vnode && vnode.shapeFlag & ShapeFlags.COMPONENT); } /** @@ -170,9 +190,7 @@ const queryElement = (el: string | HTMLElement | null | undefined): HTMLElement } return null; }; -export const resolveHtmlElement = ( - elRef: Ref | ElementQuery -): Promise => { +export const resolveHtmlElement = (elRef: Ref | ElementQuery): Promise => { return new Promise((resolve) => { const resolveElement = (el: ElementQuery) => { if (isComponentPublicInstance(el)) { @@ -190,7 +208,9 @@ export const resolveHtmlElement = ( resolveElement(el); closeWatch(); } else { - log.warn(`resolveHtmlElement: elRef value is falsy, this might be a bug and could cause the promise to remain pending. Please check elRef.value: ${oldEl} -> ${el}`); + log.warn( + `resolveHtmlElement: elRef value is falsy, this might be a bug and could cause the promise to remain pending. Please check elRef.value: ${oldEl} -> ${el}` + ); } }); } @@ -229,3 +249,122 @@ export function filterSlots(slots: Slots, slotNames: { [key: string]: string }) const r = keys.filter((item) => names.includes(item)); return r || []; } + +/** 判断该 VNode 是否由该 type 创建的 */ +export const isTheTypeVNode = (vn: VNode, type: Component | string) => { + if (isComponent(vn, vn.type) && isString(type)) { + return (vn.type.name || vn.type.__name) === type; + } + return vn.type === type; +}; +/** 从虚拟节点树中获取指定组件生成的虚拟节点 */ +export const flatComponentVNode = (vn: VNodeNormalizedChildren | VNode[] | VNode, type: Component | string) => { + const res: VNode[] = []; + const _vn = isArray(vn) ? vn : [vn]; + + _vn.forEach((child) => { + if (isArray(child)) { + res.push(...flatComponentVNode(child, type)); + return; + } + if (!isVNode(child)) { + return; + } + if (isTheTypeVNode(child, type)) { + res.push(child); + } + if (child.component?.subTree) { + res.push(...flatComponentVNode(child.component.subTree, type)); + } else if (child.children) { + res.push(...flatComponentVNode(child.children, type)); + } + }); + + return res; +}; + +const tickJobs = new Set<() => any>(); +/** 对同一个函数只会执行一次 */ +export const runOnceNextTick = (fn: () => any) => { + if (tickJobs.size === 0) { + nextTick(() => { + tickJobs.forEach((item) => item()); + tickJobs.clear(); + }); + } + tickJobs.add(fn); +}; + +export type PublicChildT = { + uid: number; + getVNode: () => VNode; +} & T; +type ComponentInternalInstanceWithRender = ComponentInternalInstance & { + // render 函数可能来源于sfc模板编译, + // 也可能 `template` 属性的运行时编译, + // 还可能来源于 setup 返回的函数 + render: () => any; +}; + +/** + * 按模板顺序维护指定类型后代组件的排序 + * + * @description + * 在 Vue 中,当使用 Teleport 或动态注册组件时,子组件的渲染顺序可能与模板书写顺序不一致。 + * 该 Hook 通过监听 DOM 节点变化,确保子组件始终按模板中的书写顺序排列。 + * + * @param vm - 父组件实例 + * @param childType - 需要排序的后代组件类型 + * + * @note + * - 排序操作会在下一帧执行 + * - SSR 环境下无法工作(依赖响应式变量触发重渲染) + * - 适用于需要严格保持子组件顺序的场景 + */ +export const useSortedChildren = (vm: ComponentInternalInstance, childType: Component | string) => { + const children: Record = {}; + const sortedChildren = shallowRef([]); + const parentVms = new WeakSet(); + + const sortChildren = () => { + const newSortedChildren: T[] = []; + flatComponentVNode(vm.subTree, childType).forEach((child) => { + if (child.component?.uid && children[child.component.uid]) { + newSortedChildren.push(children[child.component.uid]); + } + }); + if (!isArrayEqual(sortedChildren.value, newSortedChildren, true)) { + sortedChildren.value = newSortedChildren; + } + }; + + const removeChild = (uid: number) => { + const child = children[uid]; + if (child) { + runOnceNextTick(sortChildren); + delete children[uid]; + } + }; + + const addChild = (child: T) => { + const childVm = getCurrentInstance()!; + const parentVm = childVm.parent as ComponentInternalInstanceWithRender; + if (!parentVms.has(parentVm)) { + const originRender = parentVm.render; + if (originRender) { + parentVm.render = function (...args) { + runOnceNextTick(sortChildren); + return originRender.apply(this, args); + }; + } + parentVms.add(parentVm); + } + onBeforeUnmount(() => { + removeChild(child.uid); + }); + children[child.uid] = child; + runOnceNextTick(sortChildren); + }; + + return { children: sortedChildren, childMap: children, addChild }; +}; diff --git a/packages/opendesign/src/tab/OTab.vue b/packages/opendesign/src/tab/OTab.vue index c19655b7b448625f90b86634ef01df2cd4458bd2..122aa7df935543a7fb5a3635cff339cbe37efbe2 100644 --- a/packages/opendesign/src/tab/OTab.vue +++ b/packages/opendesign/src/tab/OTab.vue @@ -1,18 +1,21 @@ + diff --git a/packages/opendesign/src/tab/OTabPane.vue b/packages/opendesign/src/tab/OTabPane.vue index 840c6dd46e6f268cf7b2f66592f6f81975755856..b30e8bdce878dab95567ace1f1d510c045a5ac54 100644 --- a/packages/opendesign/src/tab/OTabPane.vue +++ b/packages/opendesign/src/tab/OTabPane.vue @@ -1,33 +1,29 @@ -