Vue通用缩放容器

张开发
2026/4/16 14:06:04 15 分钟阅读

分享文章

Vue通用缩放容器
背景与目标在不改 DOM 层级前提下为任意视图提供放大/还原能力。放大态对齐指定目标容器支持平滑过渡动画。保证复杂页面中不串层、不透出、不污染布局。支持用户偏好持久化自动恢复上次放大状态。收益高复用业务接入成本低适配任意 slot 内容。低侵入不依赖 Teleport不改页面结构。稳定性放大/还原具备完整样式回滚机制。方案概览交互层hover 时在右上角显示放大按钮放大后始终显示还原按钮。几何层记录源容器相对offsetParent的矩形。计算目标容器相对同一参照系的矩形。将源容器的布局切换到absolute。样式动画top/left/width/height过渡。布局隔离层锁定目标容器当前高度避免父布局变化。将目标容器内其他兄弟子视图压缩为height: 0overflow: hidden。还原时恢复目标容器与兄弟子视图原始样式。持久化层设置storageKey。放大写入1还原写入0。挂载时读取并自动决定是否展开。关键问题放大态遮挡背景异常透出锁定目标容器当前宽高保持外层布局稳定。再对目标容器内其他兄弟子视图做压缩处理height 设为 0避免视觉干扰。还原时逐项恢复原始样式确保无副作用。子视图放大后需要动态分配高度仅让源容器放大并不够内部区块也要可伸缩。将源容器的布局改为flex可分配结构让子视图可随着缩放来动态分配源容器的空间。具体实现通用缩放组件代码!-- ZoomableContainer: 通用缩放容器支持展开、收起、记录用户缩放状态等功能展示时会将嵌入的组件覆盖传入的目标容器同时将目标容器中的其他子视图的高度变为0并展示过渡动画 --templatedivrefrootRefclasszoomable-container:class{ is-expanded: isExpanded, is-animating: isAnimating }:stylerootStylemouseenterhandleMouseEntermouseleavehandleMouseLeavetransitionendhandleTransitionEndslot:is-expandedisExpanded/slotdivv-showisHovering || isExpandedclasszoomable-container__actionclick.stoptogglespanclassiconfont:classisExpanded ? icon--recover : icon--maximize/span/div/div/templatescriptsetuplangtsimport{computed,nextTick,onBeforeUnmount,onMounted,ref,type CSSProperties}fromvue;interfaceProps{targetSelector:string;disabled?:boolean;zIndex?:number;transitionDuration?:number;/** localStorage key传入后会持久化展开状态下次显示时自动恢复 */storageKey?:string;}constemitdefineEmits{/** 展开时触发 */expand:[];/** 恢复时触发 */restore:[];/** 当目标元素不存在时触发 */target-missing:[];}();constpropswithDefaults(definePropsProps(),{disabled:false,zIndex:10,transitionDuration:300,storageKey:,});lettargetEl:HTMLElement|nullnull;lettargetOriginalHeight;lettargetOriginalOverflow;constcollapsedSiblings:{el:HTMLElement;height:string;overflow:string}[][];constSTORAGE_PREFIXzoomable-container:;constrootRefrefHTMLElement();constisExpandedref(false);constisHoveringref(false);constisAnimatingref(false);constoriginalRectref{top:number;left:number;width:number;height:number}|null(null,);constanimatingStylerefCSSProperties|null(null);constrootStylecomputedCSSProperties((){constbase:CSSProperties{};if(animatingStyle.value){Object.assign(base,animatingStyle.value);}if(isExpanded.value||isAnimating.value){base.zIndexprops.zIndex;base.transitionPropertytop, left, width, height;base.transitionDuration${props.transitionDuration}ms;base.transitionTimingFunctionease;}returnbase;});onMounted((){if(loadExpandState()){expand();}});onBeforeUnmount((){if(isExpanded.value){// Synchronously reset without animationisExpanded.valuefalse;isAnimating.valuefalse;animatingStyle.valuenull;restoreTargetHeight();}});functionsaveExpandState(expanded:boolean):void{if(!props.storageKey)return;localStorage.setItem(${STORAGE_PREFIX}${props.storageKey},expanded?1:0);}functionloadExpandState():boolean{if(!props.storageKey)returnfalse;returnlocalStorage.getItem(${STORAGE_PREFIX}${props.storageKey})1;}functionhandleMouseEnter():void{if(!props.disabled){isHovering.valuetrue;}}functionhandleMouseLeave():void{if(!isExpanded.value){isHovering.valuefalse;}}functionresolveTarget():HTMLElement|null{consteldocument.querySelectorHTMLElement(props.targetSelector);if(!el){emit(target-missing);}returnel;}functiongetOffsetParentRect():DOMRect{constoprootRef.value?.offsetParentasHTMLElement|null;returnop?op.getBoundingClientRect():newDOMRect(0,0,window.innerWidth,window.innerHeight);}functioncaptureOriginalRect():{top:number;left:number;width:number;height:number}{constelRectrootRef.value!.getBoundingClientRect();constopRectgetOffsetParentRect();return{top:elRect.top-opRect.top,left:elRect.left-opRect.left,width:elRect.width,height:elRect.height,};}functioncomputeExpandedRect(target:HTMLElement):{top:number;left:number;width:number;height:number;}{consttRecttarget.getBoundingClientRect();constopRectgetOffsetParentRect();return{top:tRect.top-opRect.top,left:tRect.left-opRect.left,width:tRect.width,height:tRect.height,};}functiontoPx(rect:{top:number;left:number;width:number;height:number}):CSSProperties{return{position:absolute,top:${rect.top}px,left:${rect.left}px,width:${rect.width}px,height:${rect.height}px,};}asyncfunctionexpand():Promisevoid{if(isExpanded.value||isAnimating.value||props.disabled)return;consttargetresolveTarget();if(!target)return;// 缓存初始位置constorigincaptureOriginalRect();originalRect.valueorigin;constdestcomputeExpandedRect(target);// Start frame: absolute at original position (no visible jump)animatingStyle.valuetoPx(origin);isAnimating.valuetrue;awaitnextTick();// End frame: animate to target positionanimatingStyle.valuetoPx(dest);isExpanded.valuetrue;saveExpandState(true);// 锁定目标容器当前高度维持父层布局不变targetEltarget;targetOriginalHeighttarget.style.height;targetOriginalOverflowtarget.style.overflow;target.style.height${dest.height}px;target.style.overflowhidden;// 将目标容器内其他子元素高度设为 0防止透出constselfRootrootRef.value;for(constchildofArray.from(target.children)asHTMLElement[]){if(childselfRoot||child.contains(selfRoot!))continue;collapsedSiblings.push({el:child,height:child.style.height,overflow:child.style.overflow,});child.style.height0;child.style.overflowhidden;}emit(expand);}functionrestoreTargetHeight():void{// 恢复兄弟元素for(const{el,height,overflow}ofcollapsedSiblings){el.style.heightheight;el.style.overflowoverflow;}collapsedSiblings.length0;// 恢复目标容器样式if(targetEl){targetEl.style.heighttargetOriginalHeight;targetEl.style.overflowtargetOriginalOverflow;targetElnull;targetOriginalHeight;targetOriginalOverflow;}}asyncfunctionrestore():Promisevoid{if(!isExpanded.value||isAnimating.value||!originalRect.value)return;isAnimating.valuetrue;// Animate back to original rectanimatingStyle.valuetoPx(originalRect.value);isExpanded.valuefalse;saveExpandState(false);emit(restore);}functionhandleTransitionEnd(e:TransitionEvent):void{isAnimating.valuefalse;if(!isExpanded.value){// Restore complete: remove inline styles to return to document flowanimatingStyle.valuenull;originalRect.valuenull;restoreTargetHeight();}}functiontoggle():void{if(isExpanded.value){restore();}else{expand();}}defineExpose({toggle,isExpanded});/scriptstylescoped.zoomable-container{position:relative;}.zoomable-container.is-expanded, .zoomable-container.is-animating{overflow:hidden;}.zoomable-container__action{display:flex;position:absolute;z-index:1;top:4px;right:4px;align-items:center;justify-content:center;width:28px;height:28px;transition:opacity 0.2s;border-radius:4px;opacity:0.85;background:rgb(0 0 0 / 45%);cursor:pointer;}.zoomable-container__action:hover{opacity:1;}.zoomable-container__action .iconfont{color:#fff;font-size:16px;}.icon--maximize::before{content:□;}.icon--recover::before{content:■;}/style核心行为默认position: relativehover 时右上角浮现放大按钮点击放大记录原始位置 → 切到position: absolute在原位 →nextTick动画过渡到targetSelector指定容器的坐标和尺寸点击缩小动画回到原始位置 →transitionend后移除absolute恢复文档流放大/缩小均有transition: top/left/width/height 0.3s ease过渡动画不改 DOM 层级插槽内容实例和状态完整保留接入示例ZoomableContainertarget-selector#my-target-containerYourComponent//ZoomableContainer演示效果风险与约束offsetParent约束需要可覆盖源与目标位置否则定位偏差。目标容器策略压缩兄弟子视图可能影响它们内部动画或懒渲染逻辑。内部自适应子组件若固定高度放大后视觉不会“等比增长”需业务配合改 flex。多实例并发多个放大容器同时作用同一目标容器时需冲突策略当前未做互斥管理。

更多文章