Skip to content

动画与复杂交互链路重构

这次整理的对象主要来自 11Vue学习/program/vue-sell-app 中的交互链路,尤其是购物车下落动画、详情弹层、头部信息层和多处 transition 配合的部分。和前面的案例一样,我们不直接修改这个独立 demo,而是把其中最值得长期复用的交互设计思路,沉淀成一篇案例文档。

和普通页面重构不同,这类案例的难点不只是“状态放哪”,还包括:

  • 一个交互会不会带动多个区域同时变化
  • 动画是视图增强,还是已经和业务状态耦合在一起
  • 进入、离开、展开、下落、浮层这些效果是否由统一状态驱动
  • 动画实现是结构清晰的,还是散落在多个组件里临时触发

如果这些边界不先理清,动画很容易从“体验加分项”变成“维护成本放大器”。

这次主要参考了下面这些本地文件:

  • 11Vue学习/program/vue-sell-app/src/components/shopcart/shopcart.vue
  • 11Vue学习/program/vue-sell-app/src/components/food/food.vue
  • 11Vue学习/program/vue-sell-app/src/components/header/header.vue

同时也参考了 motion-v 的设计思路,尤其是:

  • 动画值与状态分离
  • 过渡和手势能力尽量声明式表达
  • 高频动画优先交给 transform / motion value 驱动

这类场景真正的复杂度中心

动画与复杂交互场景里,最容易被低估的不是某个动画效果本身,而是“状态传播链路”。

以这个点餐页示例来说,一个用户操作可能会同时触发多件事:

  • 点击加号
  • 商品数量增加
  • 购物车总数变化
  • 购物车金额变化
  • 底部购物车状态变化
  • 小球下落动画启动
  • 列表弹层可能刷新
  • 详情弹层内按钮状态也要同步

如果这些变化没有统一结构,项目后面就会出现两个问题:

  • 动画逻辑散在多个组件里,谁都能触发
  • 业务状态和表现状态彼此污染,局部修改会牵连整条链路

所以这类重构的重点,不是先换动画库,而是先拆清“业务链路”和“表现链路”。

推荐的重构边界

更适合长期维护的结构,通常会把这类场景拆成下面几层:

  • 业务状态层:管理商品、购物车、详情数据等真实业务状态
  • 交互状态层:管理抽屉开关、当前选中项、展开折叠等界面状态
  • 动画协调层:管理一次动画何时触发、如何结束、是否排队
  • 展示组件层:只负责把状态转成动画和 UI

如果换成更具体的职责,大概可以这样理解:

  • useCart():负责商品数量、金额、选中项
  • useFoodDetail():负责当前详情弹层开关
  • useDropQueue():负责购物车下落动画请求队列
  • CartBar / FoodDetailDrawer / SellerHeader:只负责展示和事件入口

这套边界里最重要的一条原则是:

  • 动画不拥有业务状态
  • 动画只消费业务状态或动画事件

先把“业务状态”和“动画状态”明确区分

这一步是动画类项目最容易做错的地方。

例如在当前示例里:

属于业务状态的

  • 当前购物车里的条目
  • 商品数量
  • 商品详情是否打开
  • 商家信息详情是否展开

属于动画状态的

  • 当前是否有一个下落动画需要执行
  • 某个浮层的进入 / 离开阶段
  • 某个元素当前处于展开动画还是收起动画

如果不先区分,后面很容易出现这样的混乱:

  • 为了触发动画而修改业务对象结构
  • 把视觉过渡阶段混进业务 store
  • 一个动画结束时顺手去改了本不属于它的状态

更稳的做法,是把动画请求设计成显式结构。

ts
export interface DropAnimationRequest {
  sourceX: number
  sourceY: number
  target: 'cart'
}

有了这层之后,动画就是“一个独立请求”,而不是某个业务字段的副作用。

复杂交互页面最适合引入“动画协调层”

在普通页面里,一个按钮 hover 一下,可能不值得专门抽层。但在复杂交互场景里,如果已经存在:

  • 多个浮层
  • 多个进入离开动画
  • 列表和详情联动
  • 一次交互触发多个视觉反馈

那就非常适合引入动画协调层。

例如,购物车下落动画最适合写成队列:

ts
import { shallowRef } from 'vue'
import type { DropAnimationRequest } from './drop'

export function useDropQueue() {
  const queue = shallowRef<DropAnimationRequest[]>([])
  const active = shallowRef<DropAnimationRequest | null>(null)

  function push(request: DropAnimationRequest) {
    queue.value = [...queue.value, request]
    if (!active.value) {
      next()
    }
  }

  function next() {
    const [first, ...rest] = queue.value
    queue.value = rest
    active.value = first ?? null
  }

  function complete() {
    active.value = null
    if (queue.value.length) {
      next()
    }
  }

  return {
    queue,
    active,
    push,
    complete
  }
}

这类结构的好处非常明确:

  • 动画触发不再依赖某个组件内部临时状态
  • 多次快速点击时,动画请求可以排队
  • 动画结束如何收尾有统一出口

浮层开关要统一成“显式交互状态”,不要散在组件内部

当前示例里的 detailShowshowFlag 这类布尔值,方向是对的,但如果页面继续复杂化,单个组件自己维护就会越来越难统一。

更适合的方式,是把浮层和面板开关状态明确抽成 composable:

ts
export function useOverlayState() {
  const activeOverlay = ref<'food-detail' | 'seller-detail' | 'cart-list' | null>(null)

  function open(name: 'food-detail' | 'seller-detail' | 'cart-list') {
    activeOverlay.value = name
  }

  function close() {
    activeOverlay.value = null
  }

  return {
    activeOverlay,
    open,
    close
  }
}

这类抽法的意义不在于减少几个布尔值,而在于让“当前到底是谁在屏幕上”成为一条统一可控的状态线。

动画实现尽量只消费 transform 友好值

无论是原始 CSS transition 方案,还是后续迁移到 motion-v 这类声明式动画库,长期都应该遵守一条很稳的原则:

  • 位置、缩放、透明度优先通过 transform / opacity 表达
  • 少用会触发布局重排的动画属性去驱动高频效果

在复杂交互场景里,这点尤其重要。像购物车小球、详情浮层进入、列表弹出这些效果,本质上都非常适合用:

  • translate
  • scale
  • opacity

而不是每次都去改宽高、top、left 或触发大面积布局重排。

声明式动画比临时拼接过渡更稳

原始示例更多依赖 Vue 2 transition 和手写 class 切换,这在 demo 里完全可行。但从长期维护角度看,越复杂的交互越适合声明式表达动画规则。

例如可以把一个详情浮层的进入离开表达成:

vue
<script setup lang="ts">
import { motion } from 'motion-v'

const props = defineProps<{
  open: boolean
}>()
</script>

<template>
  <motion.div
    v-if="props.open"
    :initial="{ opacity: 0, x: 80 }"
    :animate="{ opacity: 1, x: 0 }"
    :exit="{ opacity: 0, x: 80 }"
    :transition="{ type: 'spring', stiffness: 220, damping: 24 }"
    class="food-detail-drawer"
  >
    <slot />
  </motion.div>
</template>

这种表达最大的优势是:

  • 动画规则集中
  • 状态和动画之间的映射很直观
  • 后续调整动画参数时,不需要在多处 CSS class 和 JS 状态里来回跳

页面级复杂交互要优先保证“状态先对,动画再美”

动画场景里最常见的一个误区,是过早追求效果,而忽略状态链路是否已经稳定。

更稳的顺序应该是:

  • 先让业务状态正确联动
  • 再把浮层和列表开关状态统一起来
  • 再为状态切换添加动画表达
  • 最后再优化缓动、节奏和视觉质感

这条顺序很重要。因为真正难维护的从来不是缓动曲线,而是“谁在什么时候触发了哪一次变化”。

复杂交互和页面容器的关系要重新切清

前面的页面型案例已经反复说明:页面容器应该更像装配器,而不是功能仓库。到了复杂交互场景,这条原则反而更重要。

页面容器更适合负责:

  • 装配业务状态 composable
  • 装配浮层状态 composable
  • 装配动画协调层 composable
  • 把这些状态和事件传给展示组件

而不应该负责:

  • 在模板里直接写大量动画时序逻辑
  • 直接操作多个浮层和多个动画状态
  • 在一个方法里同时处理业务更新和视觉反馈收尾

这类场景最常见的几个误区

结合本地示例和动画设计经验,这类项目最常见的误区通常有这些:

  • 把动画触发和业务状态更新写死在同一个方法里
  • 把多个浮层的开关散在不同组件里
  • 动画请求没有统一入口,谁都能直接触发
  • 高频动画依赖不适合的 CSS 属性
  • 页面容器为了做效果,越来越像一个交互脚本文件

这些问题的共同点是:都没有先把“交互结构”设计清楚。

这次重构真正沉淀下来的模式

这篇复杂交互案例最值得沉淀的,不是某个具体动画效果,而是下面这些更稳定的原则:

  • 先区分业务状态和动画状态
  • 先把多浮层交互统一成显式状态中心
  • 先给动画建立请求和协调层,再做视觉实现
  • 动画尽量只消费 transform 友好值
  • 复杂交互页面里,页面容器只负责装配,不直接承担全部时序

这次真正沉淀下来的经验有六个:

  • 动画不应该拥有业务状态
  • 多步视觉反馈适合用动画协调层统一管理
  • 浮层与面板开关应统一成显式交互状态
  • 声明式动画更适合长期维护
  • 高频动画优先使用 transform / opacity
  • 复杂交互要先保证状态链路稳定,再优化动画质感

以后再整理动画与复杂交互场景时,可以优先检查这几个问题:

  • 业务状态和动画状态是否已经分离
  • 多个浮层是否还各自维护开关
  • 动画请求是否已经有统一入口
  • 页面容器是否承担了太多交互脚本职责
  • 动画是否已经围绕可维护的状态映射来实现

只要这些问题还有几项答不清楚,这个复杂交互页面通常就还值得继续重构。

共 20 个模块,1301 篇 Markdown 文档。