Vue通用缩放容器

背景与目标

  1. 在不改 DOM 层级前提下,为任意视图提供放大/还原能力。
  2. 放大态对齐指定目标容器,支持平滑过渡动画。
  3. 保证复杂页面中不串层、不透出、不污染布局。
  4. 支持用户偏好持久化(自动恢复上次放大状态)。

收益

  1. 高复用:业务接入成本低,适配任意 slot 内容。
  2. 低侵入:不依赖 Teleport,不改页面结构。
  3. 稳定性:放大/还原具备完整样式回滚机制。

方案概览

  1. 交互层:hover 时在右上角显示放大按钮,放大后始终显示还原按钮。
  2. 几何层:
    • 记录源容器相对 offsetParent 的矩形。
    • 计算目标容器相对同一参照系的矩形。
    • 将源容器的布局切换到absolute
    • 样式动画:top/left/width/height 过渡。
  3. 布局隔离层:
    • 锁定目标容器当前高度,避免父布局变化。
    • 将目标容器内其他兄弟子视图压缩为 height: 0 + overflow: hidden
    • 还原时恢复目标容器与兄弟子视图原始样式。
  4. 持久化层:
    • 设置 storageKey
    • 放大写入 1,还原写入 0
    • 挂载时读取并自动决定是否展开。

关键问题

  1. 放大态遮挡背景异常透出:
    • 锁定目标容器当前宽高,保持外层布局稳定。
    • 再对目标容器内其他兄弟子视图做压缩处理(height 设为 0),避免视觉干扰。
    • 还原时逐项恢复原始样式,确保无副作用。
  2. 子视图放大后需要动态分配高度:仅让源容器放大并不够,内部区块也要可伸缩。将源容器的布局改为flex可分配结构,让子视图可随着缩放来动态分配源容器的空间。

具体实现

通用缩放组件代码:

html 复制代码
<!-- ZoomableContainer: 通用缩放容器,支持展开、收起、记录用户缩放状态等功能,展示时会将嵌入的组件覆盖传入的目标容器,同时将目标容器中的其他子视图的高度变为0,并展示过渡动画 -->
<template>
  <div
    ref="rootRef"
    class="zoomable-container"
    :class="{ 'is-expanded': isExpanded, 'is-animating': isAnimating }"
    :style="rootStyle"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
    @transitionend="handleTransitionEnd"
  >
    <slot :is-expanded="isExpanded"></slot>
    <div
      v-show="isHovering || isExpanded"
      class="zoomable-container__action"
      @click.stop="toggle"
    >
      <span
        class="iconfont"
        :class="isExpanded ? 'icon--recover' : 'icon--maximize'"
      ></span>
    </div>
  </div>
</template>

<script setup lang="ts">
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, type CSSProperties } from 'vue';

  interface Props {
    targetSelector: string;
    disabled?: boolean;
    zIndex?: number;
    transitionDuration?: number;
    /** localStorage key,传入后会持久化展开状态,下次显示时自动恢复 */
    storageKey?: string;
  }

const emit = defineEmits<{
    /** 展开时触发 */
    expand: [];
    /** 恢复时触发 */
    restore: [];
    /** 当目标元素不存在时触发 */
    'target-missing': [];
  }>();

const props = withDefaults(defineProps<Props>(), {
    disabled: false,
    zIndex: 10,
    transitionDuration: 300,
    storageKey: '',
  });
let targetEl: HTMLElement | null = null;
let targetOriginalHeight = '';
let targetOriginalOverflow = '';
const collapsedSiblings: { el: HTMLElement; height: string; overflow: string }[] = [];
const STORAGE_PREFIX = 'zoomable-container:';

const rootRef = ref<HTMLElement>();
const isExpanded = ref(false);
const isHovering = ref(false);
const isAnimating = ref(false);
const originalRect = ref<{ top: number; left: number; width: number; height: number } | null>(
    null,
  );
const animatingStyle = ref<CSSProperties | null>(null);

const rootStyle = computed<CSSProperties>(() => {
    const base: CSSProperties = {};
    if (animatingStyle.value) {
      Object.assign(base, animatingStyle.value);
    }
    if (isExpanded.value || isAnimating.value) {
      base.zIndex = props.zIndex;
      base.transitionProperty = 'top, left, width, height';
      base.transitionDuration = `${props.transitionDuration}ms`;
      base.transitionTimingFunction = 'ease';
    }
    return base;
  });

onMounted(() => {
    if (loadExpandState()) {
      expand();
    }
  });
onBeforeUnmount(() => {
    if (isExpanded.value) {
      // Synchronously reset without animation
      isExpanded.value = false;
      isAnimating.value = false;
      animatingStyle.value = null;
      restoreTargetHeight();
    }
  });

function saveExpandState(expanded: boolean): void {
    if (!props.storageKey) return;
    localStorage.setItem(`${STORAGE_PREFIX}${props.storageKey}`, expanded ? '1' : '0');
  }
function loadExpandState(): boolean {
    if (!props.storageKey) return false;
    return localStorage.getItem(`${STORAGE_PREFIX}${props.storageKey}`) === '1';
  }
function handleMouseEnter(): void {
    if (!props.disabled) {
      isHovering.value = true;
    }
  }
function handleMouseLeave(): void {
    if (!isExpanded.value) {
      isHovering.value = false;
    }
  }
function resolveTarget(): HTMLElement | null {
    const el = document.querySelector<HTMLElement>(props.targetSelector);
    if (!el) {
      emit('target-missing');
    }
    return el;
  }
function getOffsetParentRect(): DOMRect {
    const op = rootRef.value?.offsetParent as HTMLElement | null;
    return op
      ? op.getBoundingClientRect()
      : new DOMRect(0, 0, window.innerWidth, window.innerHeight);
  }
function captureOriginalRect(): { top: number; left: number; width: number; height: number } {
    const elRect = rootRef.value!.getBoundingClientRect();
    const opRect = getOffsetParentRect();
    return {
      top: elRect.top - opRect.top,
      left: elRect.left - opRect.left,
      width: elRect.width,
      height: elRect.height,
    };
  }
function computeExpandedRect(target: HTMLElement): {
    top: number;
    left: number;
    width: number;
    height: number;
  } {
    const tRect = target.getBoundingClientRect();
    const opRect = getOffsetParentRect();
    return {
      top: tRect.top - opRect.top,
      left: tRect.left - opRect.left,
      width: tRect.width,
      height: tRect.height,
    };
  }
function toPx(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`,
    };
  }
async function expand(): Promise<void> {
    if (isExpanded.value || isAnimating.value || props.disabled) return;
    const target = resolveTarget();
    if (!target) return;
    // 缓存初始位置
    const origin = captureOriginalRect();
    originalRect.value = origin;
    const dest = computeExpandedRect(target);
    // Start frame: absolute at original position (no visible jump)
    animatingStyle.value = toPx(origin);
    isAnimating.value = true;
    await nextTick();
    // End frame: animate to target position
    animatingStyle.value = toPx(dest);
    isExpanded.value = true;
    saveExpandState(true);
    // 锁定目标容器当前高度,维持父层布局不变
    targetEl = target;
    targetOriginalHeight = target.style.height;
    targetOriginalOverflow = target.style.overflow;
    target.style.height = `${dest.height}px`;
    target.style.overflow = 'hidden';
    // 将目标容器内其他子元素高度设为 0,防止透出
    const selfRoot = rootRef.value;
    for (const child of Array.from(target.children) as HTMLElement[]) {
      if (child === selfRoot || child.contains(selfRoot!)) continue;
      collapsedSiblings.push({
        el: child,
        height: child.style.height,
        overflow: child.style.overflow,
      });
      child.style.height = '0';
      child.style.overflow = 'hidden';
    }
    emit('expand');
  }
function restoreTargetHeight(): void {
    // 恢复兄弟元素
    for (const { el, height, overflow } of collapsedSiblings) {
      el.style.height = height;
      el.style.overflow = overflow;
    }
    collapsedSiblings.length = 0;
    // 恢复目标容器样式
    if (targetEl) {
      targetEl.style.height = targetOriginalHeight;
      targetEl.style.overflow = targetOriginalOverflow;
      targetEl = null;
      targetOriginalHeight = '';
      targetOriginalOverflow = '';
    }
  }
async function restore(): Promise<void> {
    if (!isExpanded.value || isAnimating.value || !originalRect.value) return;
    isAnimating.value = true;
    // Animate back to original rect
    animatingStyle.value = toPx(originalRect.value);
    isExpanded.value = false;
    saveExpandState(false);
    emit('restore');
  }
function handleTransitionEnd(e: TransitionEvent): void {
    isAnimating.value = false;
    if (!isExpanded.value) {
      // Restore complete: remove inline styles to return to document flow
      animatingStyle.value = null;
      originalRect.value = null;
      restoreTargetHeight();
    }
  }
function toggle(): void {
    if (isExpanded.value) {
      restore();
    } else {
      expand();
    }
  }

defineExpose({ toggle, isExpanded });
</script>

<style scoped>
.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: relative,hover 时右上角浮现放大按钮
  • 点击放大:记录原始位置 → 切到 position: absolute 在原位 → nextTick 动画过渡到 targetSelector 指定容器的坐标和尺寸
  • 点击缩小:动画回到原始位置 → transitionend 后移除 absolute恢复文档流
  • 放大/缩小均有 transition: top/left/width/height 0.3s ease 过渡动画
  • 不改 DOM 层级,插槽内容实例和状态完整保留

接入示例:

html 复制代码
<ZoomableContainer target-selector="#my-target-container">
  <YourComponent />
</ZoomableContainer>

演示效果:

风险与约束

  1. offsetParent 约束:需要可覆盖源与目标位置,否则定位偏差。
  2. 目标容器策略:压缩兄弟子视图可能影响它们内部动画或懒渲染逻辑。
  3. 内部自适应:子组件若固定高度,放大后视觉不会"等比增长",需业务配合改 flex。
  4. 多实例并发:多个放大容器同时作用同一目标容器时需冲突策略(当前未做互斥管理)。
相关推荐
Hello--_--World2 小时前
VUE:逻辑复用
前端·javascript·vue.js
陶甜也2 小时前
3D智慧城市:blender建模、骨骼、动画、VUE、threeJs引入渲染,飞行视角,涟漪、人物行走
前端·3d·vue·blender·threejs·模型
患得患失9492 小时前
【前端websocket】企业级功能清单
前端·websocket·网络协议
落魄江湖行2 小时前
基础篇四 Nuxt4 全局样式与 CSS 模块
前端·css·typescript·nuxt4
禅思院2 小时前
前端性能优化:从"术"到"道"的完整修炼指南
前端·架构·前端框架
叫我一声阿雷吧2 小时前
JS 入门通关手册(43):async/await 原理与异常处理(实战 + 面试,彻底搞懂)
javascript·异常处理·promise·前端面试·async/await·generator·异步编程
架构师老Y3 小时前
003、Python Web框架深度对比:Django vs Flask vs FastAPI
前端·python·django
小陈工6 小时前
Python Web开发入门(十七):Vue.js与Python后端集成——让前后端真正“握手言和“
开发语言·前端·javascript·数据库·vue.js·人工智能·python
xiaotao13110 小时前
第九章:Vite API 参考手册
前端·vite·前端打包