【山竹记账前端-vue】5.动画、SVG Sprites 与滑动手势


大纲链接 §

[toc]


0. 展示 git 操作,切换代码版本 ⇧

拿到代码地址:https://github.com/FrankFang/vue-mangosteen/tree/1008fb833e235f9f8c86ca84277de993ca3162bd

  • 下载 初始代码
  • 可以先 fork 一份到自己的仓库再操作

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    # 1. 克隆整个仓库到本地 4d7b4c 文件夹
    git clone https://github.com/FrankFang/vue-mangosteen.git 4d7b4c
    
    # 2. 进入进入克隆下来的项目目录
    cd 4d7b4c
    
    # 3. 切换到指定的 Commit ID 65cd653 完成欢迎界面动画
    git checkout 65cd65383adbc34368bc5b75e0ee3e96bbedceac
    
    # 4。 打印最近几条提交记录 按上下箭头翻页;按q退出
    git log
    
    # 5. 找到 完成欢迎界面动画 提交哈希 65cd65,在终端中展示提交内容
    git show 65cd6
    
    # 6. commit 变补丁: generate a git patch from a commit
    git format-patch -1 65cd6
    # 0001-.patch 可以看到多了一个文件 0001-.patch
    ls -a
    # 移动到最初代码的克隆仓库
    mv 0001-.patch ../magosteen-fe-3
    cd ../magosteen-fe-3
    # 重置不需要提交的更改
    git state
    git reset --hard HEAD
    # 将补丁应用到最初代码的克隆仓库
    git am < 0001-.patch
    #Applying: 完成欢迎界面动画
    # 删除多余补丁文件
    rm -rf 0001-.patch
  • git show HEAD 显示最新一次提交的内容

  • git diff 展示当前没有提交的变动内容


多重 RouterView(命名视图) 与 Transition 实现

1. 插槽函数改写 RouterView ⇧

搜索 vue3 router transition animation

SFC 写法

1
2
3
<router-view v-slot="{ Component }">
  <component :is="Component" />
</router-view>

使用插槽函数改写 @/views/WelcomeView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { type FunctionalComponent, h, } from 'vue'
import { RouterView } from 'vue-router'
import { cn } from 'cn'

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    <header class={s.title}>{/* ... */}</header>
    
    <main
      class={cn([
        s.main,
        'bg-welcomeCardBg mb-62px ml-16px mr-16px rounded-lg',
        'flex flex-col flex-grow items-center justify-around',
      ])}>
      <RouterView>
        { {/* (option: any) => h(option.Component) */} }
        { (option: any) => <option.Component/> }
      </RouterView>
    </main>
    
    <section class={s['to-last-page']}>跳过</section>
  </div>
)
  • <component :is="Component" /> 相当于 { (option: any) => h(option.Component) }
    • 通过 h(option.Component) 来渲染
    • 还可以写成 { (option: any) => <option.Component/> }
    • option.Component 的类型即为 VNode
    • 当然还可以用标签包裹 { (option: any) => <div class="frank"><option.Component/><div> }
    • 换成 trasition 即 { (option: any) => <transition class="frank"><option.Component/><transition> }
  • 点击进入查看 RouterView 的类型,看到默认插槽函数的参数类型,可解构出插槽组件 Component 和 route (待用)

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    declare const RouterView: {
    new (): {
    $props: AllowedComponentProps & ComponentCustomProps & VNodeProps & RouterViewProps;
    $slots: {
      default?: ({ Component, route }: {
        Component: VNode;
        route: RouteLocationNormalizedLoaded;
      }) => VNode[];
    };
    };
    };
    

重构一下写法 @/views/WelcomeView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import type {  FunctionalComponent,  VNode } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
import { cn } from 'cn'

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    {/* ... */}
    <main
      class={cn([
        s.main,
        'bg-welcomeCardBg mb-62px ml-16px mr-16px rounded-lg',
        'flex flex-col flex-grow items-center justify-around',
      ])}>
      <RouterView>
        { ({Component, route}: {Component: VNode, route: RouteLocationNormalizedLoaded}) => (
          /* ❌ <component is={Component} key={route.path} /> */
          /* ❌ <Component key={route.path} /> */ /* 并且将类型强行声明为 {Component: FunctionalComponent, ...} */
          /* ✅ cloneVNode 是对 VNode 对象的直接操作,不会触发二次错误的组件实例化 */
          Component && cloneVNode(Component, { key: route.path })
        ) }
      </RouterView>
    </main>

    <section class={s['to-last-page']}>跳过</section>

  </div>
)
  • 解构默认插槽组件和路由信息 ({Component, route}) => ()
  • 声明类型 ({Component, route}: {Component: VNode, route: RouteLocationNormalizedLoaded}) => ()
  • 在 TSX 中无法使用内置组件 <component>,将 Component 作为 is 属性传入,并且必须在标签形式上绑定 key
    • 即 <component is={Component} key={route.path} /> ❌
  • Vue Router 4/5 在 <RouterView> 插槽中通过 Component 参数传递出来的并不是组件类/函数,而是已经实例化的虚拟节点对象 (VNode)
    • 当执行<Component /> 即 h(Component) 时,实际上是在把一个 VNode 对象当作组件类型传给 h() 创建新的 VNode
    • 这会导致 Vue 运行时无法正确解析组件生命周期和属性,可能触发控制台警告、路由过渡动画失效或渲染异常
    • 后果:Vue 运行时会报错(例如渲染出 [object Object] 或者白屏),
    • 或者每次路由切换时,整个组件树都会被强行销毁并完全重新创建,导致页面状态丢失、生命周期重复触发等严重的运行时 Bug
  • 可转而使用 cloneVNode
    • Component 本身已经是一个完整的、包含了组件实例信息的 VNode
    • 想为它动态注入、克隆或者修改属性(比如绑定 key 或者是传递给 <Transition> 时的特殊属性),Vue 官方提供的标准 API 就是 cloneVNode
  • 当未来需要配合 <Transition>(路由动画) 或 <KeepAlive>(路由缓存) 使用时,
    • cloneVNode 是 TSX 语法下的唯一完美解法
    • 因为 Vue 的 <Transition> 要求内部必须是一个直接的、连续的 VNode 节点
  • 功能完备:cloneVNode 能够精确地为已有虚拟节点附加 key 属性,确保路由在不同参数或子路径切换时正确触发重渲染与动画

收益与代价权衡

方案 编译期表现 运行时安全性 列维护成本与系统效用2
行伪造签名 + <Component /> 无报错(虚假安全) 存在风险(VNode 二次包装) 低效用:增加了隐性 Bug 排查成本,违背运行时真实数据结构
标准 cloneVNode 方案 完全类型安全 100% 正确(官方推荐) 高效用:消除歧义,保障路由切换与过渡效果的最高稳定性

参考


使用 Vue Router 命名视图,改写路由 ⇧

@/router/routes.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import type { RouteRecordRaw } from 'vue-router'
import { WelcomeView } from '@/views/WelcomeView.tsx'
import { Welcome1stPage } from '@/modules/welcome/Welcome1stPage.tsx'
import { Welcome2ndPage } from '@/modules/welcome/Welcome2ndPage.tsx'
import { Welcome3rdPage } from '@/modules/welcome/Welcome3rdPage.tsx'
import { Welcome4thPage } from '@/modules/welcome/Welcome4thPage.tsx'

export const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/welcome/1',
  },
  {
    path: '/welcome',
    name: 'welcome',
    component: WelcomeView,
    children: [
      // { path: '', name: 'default', component: WelCome1stPage },
      { path: '', name: 'welcomePig', redirect: '/welcome/1' },
      { path: '1', components: { main: Welcome1stPage } },
      { path: '2', components: { main: Welcome2ndPage } },
      { path: '3', components: { main: Welcome3rdPage } },
      { path: '4', components: { main: Welcome4thPage } },
    ],
  },
]
  • components: { main: Welcome1stPage }
  • 可以对应多重 RouterView <RouterView name="main">

@/views/WelcomeView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { cloneVNode, type FunctionalComponent, type VNode } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
import { cn } from 'cn'
import s from '@/modules/welcome/WelcomeView.module.scss'
import logo from '@/assets/icons/mangosteen.svg'

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    <header class={s.title}>
      <img src={logo} alt="logo" />
      <h1>山竹记账</h1>
    </header>
    <main
      class={cn([
        s.main,
        'bg-welcomeCardBg mb-62px ml-16px mr-16px rounded-lg',
        'flex flex-col flex-grow items-center justify-around',
      ])}>
      <RouterView name="main">
        {({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) =>
          /* <Component key={route.path} /> */
          Component && cloneVNode(Component, { key: route.path })
        }
      </RouterView>
    </main>

    <section class={s['to-last-page']}>跳过</section>

  </div>
)

WelcomeView.displayName = 'WelcomeView'

路由切换main部分添加Transition ⇧

原SFC内置过渡组件

1
2
3
4
5
<router-view v-slot="{ Component }">
  <transition>
    <component :is="Component" />
  </transition>
</router-view>

注意引入内置组件大驼峰形式 import { Transition } from 'vue':@/views/WelcomeView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// import ...

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    <header class={s.title}>
      <img src={logo} alt="logo" />
      <h1>山竹记账</h1>
    </header>
    <main
      class={cn([
        s.main,
        'bg-welcomeCardBg mb-62px ml-16px mr-16px rounded-lg',
        'flex flex-col flex-grow items-center justify-around',
      ])}>
      <RouterView name="main">
        {({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) => (
        <Transition name="fade">
          { Component && cloneVNode(Component, { key: route.path }) }
        </Transition>
        )}
      </RouterView>
    </main>

    <section class={s['to-last-page']}>跳过</section>

  </div>
)

WelcomeView.displayName = 'WelcomeView'

引入动画样式 @/modules/welcome/WelcomeView.module.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
.wrapper {
  // ...
}

:global {
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity 0.5s ease;
  }

  .fade-enter-from,
  .fade-leave-to {
    opacity: 0;
  }

}
  • 暂时使用全局样式
  • 内置组件设置了名称 <Transition name="fade">,标记动画样式前缀
    • 添加四个动画类名 .fade-enter-active、.fade-leave-active、.fade-enter-from、.fade-leave-to、
  • 过渡动画样式设置成功,但不是左右滑动的样式

修改自定义动画

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// import ...

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    <header class={s.title}>
      <img src={logo} alt="logo" />
      <h1>山竹记账</h1>
    </header>
    <main
      class={cn([
        s.main,
        // ...
      ])}>
      <RouterView name="main">
        {({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) => (
        <Transition name="slide-fade">
          { Component && cloneVNode(Component, { key: route.path }) }
        </Transition>
        )}
      </RouterView>
    </main>
  </div>
)

修改样式 @/modules/welcome/WelcomeView.module.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// ...

:global {
  // 正在进入
  .slide-fade-enter-active {
    transition: all 1.2s ease-out;
  }

  // 正在移出
  .slide-fade-leave-active {
    transition: all 1.2s cubic-bezier(1, 0.5, 0.8, 1);
  }

  // 自哪里进入、移出到哪里
  .slide-fade-enter-from,
  .slide-fade-leave-to {
    transform: translateX(20px);
    opacity: 0;
  }
}
  • 延长时间查看效果
  • main 容器下同时出现两个卡片内容,元素位置互不影响需要使用定位样式:
    • main 使用相对定位
    • 内容元素使用绝对定位

修改样式 @/modules/welcome/WelcomeView.module.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ...

:global {
  .slide-fade-enter-active,
  .slide-fade-leave-active {
    transition: all .8s ease-out;
    /*
    position: absolute;
    left: 0;
    top: 0;
    width: 100%
    height: 100%;
    */
  }

  .slide-fade-enter-from {
    transform: translateX(100vw);
    //opacity: 0;
  }

  .slide-fade-leave-to {
    transform: translateX(-100vw);
    //opacity: 0;
  }
}
  • 从右侧距离一个屏幕宽度的地方进入 .slide-fade-enter-from { transform: translateX(100vw); }
  • 从左侧距离一个屏幕宽度的地方移出 .slide-fade-leave-to { transform: translateX(-100vw); }
  • 合并正在进入和离开时的过渡样式

@/views/WelcomeView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// import ...

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    <header class={s.title}>{/* ... */}</header>
    
    <main
      class={cn([
        s.main,
        'mb-62px mx-16px',
        'position-relative'
      ])}>
      <RouterView name="main">
        {({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) => (
        <Transition name="slide-fade">
          <div
            key={route.path}
            class={cn([
              'bg-welcomeCardBg rounded-lg',
              'flex flex-col flex-grow items-center justify-around',
              'position-absolute top-0 left-0 h-100% w-100%',
            ])}>
            {Component}
          </div>

        </Transition>
        )}
      </RouterView>
    </main>

    <section class={s['to-last-page']}>跳过</section>

  </div>
)
  • 注意 Transition 里包裹的元素必须有根节点,添加一层 div
  • 将部分定位相关样式和 key 属性都放到这个节点上

回顾过渡动画过程

  • 需要让 RouterView 里面 使用 Transition 包住 Component
  • 在 Transition 给动画去一个名称 slide-fade,作为动画类样式前缀
  • 补充四个过渡类样式

参考


使用 CSS Module 改写 :global 全局动画 ⇧

防止重名的动画类样式

  • Ctrl + B 查看 Transition 的参数声明

TransitionProps

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export interface TransitionProps extends BaseTransitionProps<Element> {
  name?: string;
  type?: AnimationTypes;
  css?: boolean;
  duration?: number | {
    enter: number;
    leave: number;
  };
  enterFromClass?: string;
  enterActiveClass?: string;
  enterToClass?: string;
  appearFromClass?: string;
  appearActiveClass?: string;
  appearToClass?: string;
  leaveFromClass?: string;
  leaveActiveClass?: string;
  leaveToClass?: string;
}
  • 可以使用额外的动画钩子属性
    • enterActiveClass
    • leaveActiveClass
    • enterFromClass
    • leaveToClass
  • 使用 CSS Module 的类样式传入这些动画钩子属性

@/modules/welcome/WelcomeView.module.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
.wrapper {
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  background-image: linear-gradient(
    to bottom,
    var(--welcome-card-start) 0%,
    var(--welcome-card-end) 100%
  );

  & > .title {
    padding-top: 10%;
    font-size: 2em;
    color: var(--title-text);
    flex: 0 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    margin: 32px 0;
  }

  & > main.main {
    flex: 1 1;
    display: flex;
    flex-direction: column;
    max-height: calc(100% - 360px);
  }

  & > section.to-last-page {
    font-size: 1.5em;
    color: var(--title-text);
    position: fixed;
    right: 0.5em;
    top: 0.5em;
  }
}

.slide-fade-enter-active,
.slide-fade-leave-active {
  transition: all .8s ease-out;
}

.slide-fade-enter-from {
  transform: translateX(100vw);
  //opacity: 0;
}

.slide-fade-leave-to {
  transform: translateX(-100vw);
  //opacity: 0;
}

@/views/WelcomeView.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { Transition, type FunctionalComponent, type VNode } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
import { cn } from 'cn'
import s from '@/modules/welcome/WelcomeView.module.scss'
import logo from '@/assets/icons/mangosteen.svg'

export const WelcomeView: FunctionalComponent = () => (
  <div class={s.wrapper}>
    <header class={s.title}>
      <img src={logo} alt="logo" />
      <h1>山竹记账</h1>
    </header>
    <main
      class={cn([
        s.main,
        'mb-62px mx-16px',
        'position-relative'
      ])}>
      <RouterView name="main">
        {({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) => (
          <Transition
            name="slide-fade"
            enterActiveClass={s['slide-fade-enter-active']}
            leaveActiveClass={s['slide-fade-leave-active']}
            enterFromClass={s['slide-fade-enter-from']}
            leaveToClass={s['slide-fade-leave-to']}>
            <div
              key={route.path}
              class={cn([
                'bg-welcomeCardBg rounded-lg',
                'flex flex-col flex-grow items-center justify-around',
                'position-absolute top-0 left-0 h-100% w-100%',
              ])}>
              {Component}
            </div>
          </Transition>
        )}
      </RouterView>
    </main>

    <section class={s['to-last-page']}>跳过</section>

  </div>
)

WelcomeView.displayName = 'WelcomeView'
  • 目前只做到了前进方向的动画,后退暂时不做
    • 思路是可以根据解构出来的 route 信息添加判断来动态引入不同的模块样式来做

总结路由与动画

  • 重构路由结构,使用 vue-router 高级功能 命名视图
    • <RouterView name="main">{/* ... */}</RouterView>
  • vue内置组建在TSX中都为大驼峰写法引入 import { Transition} from 'vue'
  • TSX中插槽为函数,将SFC写法转为TSX写法
    • 解构出插槽组件 Component,放到 Trasition 组件中
  • 将动画部分放入 <Transition name="slide_fade">{Component}</Transition>
    • 在 Transition 上添加模块化类样式
    • enterFromClass={s.slide_fade_enter_from}
    • enterActiveClass={s.slide_fade_enter_active}
    • leaveToClass={s.slide_fade_leave_to}
    • leaveActiveClass={s.slide_fade_leave_active}>

还原之前的四个欢迎页面,不使用插槽写法 ⇧

  • 去除使用插槽的 WelcomeLayout.tsx

@/modules/welcome/WelcomePage.module.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.logo {
  margin-top: 25%;
  transform: translateY(1em);
}

.description {
  display: flex;
  flex-direction: column;
  align-items: center;
  font-size: 2em;
}

.to-next-page {
  color: var(--welcome-text-primary-color);
  margin-bottom: 84px;
  font-size: 2em;
  font-weight: bold;
}

@/modules/welcome/Welcome1stPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import type { FunctionalComponent } from 'vue'
import pig from '@/assets/icons/pig.svg'
import { RouterLink } from 'vue-router'
import wp from '@/modules/welcome/WelcomePage.module.scss'
import { cn } from 'cn'

export const Welcome1stPage: FunctionalComponent = () => (
  <>
    <img src={pig} alt="pig" class={cn(wp.logo, 'translate-y-[1em]')} />
    <div class={wp.description}>
      <h2>会挣钱</h2>
      <h2>还要会省钱</h2>
    </div>
    <div class={wp['to-next-page']}>
      <RouterLink to="/welcome/2">下一页</RouterLink>
    </div>
  </>
)

Welcome1stPage.displayName = 'Welcome1stPage'

存在的问题:

  • 图标第一次加载不流畅
  • 加载多个图标,发起多个资源请求

下一章处理:Vite SVG Sprites


·未完待续·

参考文章

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名