1 封装WelcomeLayout组件

Welcome页面的四个子组件代码基本一致,考虑将其整体抽离为一个布局组件

  • 属性 和 插槽

    • 都是父子组件传参的方式

    • 插槽传参是通过子组件的形式,而属性是通过标签属性来传递的

    • slot是setup函数中context参数的一个属性,在子组件定义与父组件使用插槽时,都要以函数形式声明

    • 父组件在使用时,注意slots对象中的属性都是返回值为jsx的回调函数,使用**v-slots属性**向子组件传参

用法详见:vuejs/babel-plugin-jsx: JSX for Vue 3 (github.com) Slot部分

  • 使用插槽

    • 先定义再使用
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    // First.tsx
    import ...
    export const First = defineComponent({
      setup(props, context) {
        const slots = {
          icon: () => <img src={pig} />,
          title: () => <h2>会赚钱<br />还要会省钱</h2>,
          buttons: () => <>...</>,
        };
        return () => <WelcomeLayout v-slots={slots} />;
      },
    });
    
    • 也可以插入标签内使用
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // First.tsx
    import ...
    export const First = defineComponent({
      setup(props, context) {
        return () => (
          <WelcomeLayout>
        	{{
          	icon: () => <img src={pig} />,
          	title: () => <h2>会赚钱<br />还要会省钱</h2>,
          	buttons: () => <>...</>,
          }}
        	</WelcomeLayout>
        )
      }
    });
    
  • 定义插槽

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// WelcomeLayout.tsx
import ...
export const WelcomeLayout = defineComponent({
  setup(props, context) {
    const { slots } = context;
    return () => (
      <div class={s.wrapper}>
        <div class={s.card}>
          {slots.icon?.()}
          {slots.title?.()}
        </div>
        <div class={s.actions}>{slots.buttons?.()}</div>
      </div>
    );
  },
});
  • 优化

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    import ...
    export const Second = () => {
      return (
        <WelcomeLayout>
          // 这里不是模板字符串,而是对象的含义
          {{
            icon: () => <img src={clock} />,
            title: () => <h2>每日提醒<br />不遗漏每一笔账单</h2>,
            buttons: () => <>...</>,
          }}
        </WelcomeLayout>
      );
    };
    
    • 优化WelcomeLayout.tsx,注意类型
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    import { FunctionalComponent } from "vue";
    import s from "./WelcomeLayout.module.scss";
    export const WelcomeLayout: FunctionalComponent = (props, context) => {
      const { slots: { icon, title, buttons } } = context;
      return (
        ...
        <div class={s.card}>
          {icon?.()}
          {title?.()}
        </div>   
      );
    };
    

2 动画、SVG Sprites与滑动手势

2.1 路由与动画

  • 需求:切换Welcome子路由时加上过渡动画

  • 为了让动画只应用于中间的路由画面,需要将子组件的card与footer分离(代码略)

  • GIt技巧——将远程仓库中的某次提交作为补丁应用到本地仓库

    1. git log复制某次commit id

    2. git format-patch -1 <id>生成这次commit的补丁

    3. mv 0001-.patch ../.././将补丁移入本地仓库

    4. git am < 0001-.patch 本地仓库应用这次补丁

  • 首先需要将子路由的components分离,vue-router支持单页面多重RouterView

1
2
3
4
5
6
7
8
9
// config/routes.tsx
...
component: Welcome,
children: [
  // 将components分为两部分,分别命名为main和footer
	{ path: '1', components: { main: First, footer: FirstActions }, }
	...
]
...
  • 在Welcome使用时指定RouterView的name属性即可
1
2
3
4
5
6
<main class={s.main}>
	<RouterView name="main" />
</main>
<footer>
	<RouterView name="footer" />
</footer>
  • TSX的Transition方案

过渡动效 | Vue Router (vuejs.org)

  • Template写法
1
2
3
4
5
6
<!-- 使用动态过渡名称 -->
<router-view v-slot="{ Component, route }">
  <transition :name="route.meta.transition">
    <component :is="Component" />
  </transition>
</router-view>
  • 针对TSX,我们可以参考slots的tsx写法
    • RouterViewslots接受一个函数,函数的参数包含渲染的路由组件路由属性,返回值为一个新组件(注意,返回的组件需要利用vue提供的h方法手动渲染,(好像也不需要…))。
1
2
3
4
5
<RouterView name="main">
	{
    ({ Component, route }) => h(Component)
  }
</RouterView>
  • 注意:在RouterView的类型定义中找到其slots函数的类型

  • 那么就可以在Component外包上过渡组件Transition,并指定四个状态的css

  • 注意:如果指定了Transition的name,那么动画的css需要写到全局样式中。这里我们使用自定义动画,此时样式就可以写为CSS Modules的形式了

Transition | Vue.js (vuejs.org)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Welcome.module.scss
// 激活路由切换后,滑入滑出的样式
.slide_fade_enter_active,
.slide_fade_leave_active {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  transition: all 0.5s ease-out;
}

// 进入和离开屏幕的位置
.slide_fade_enter_from {
  transform: translateX(100vw);
}
.slide_fade_leave_to {
  transform: translateX(-100vw);
}
  • 最终版
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<RouterView name="main">
	{({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) => (
		<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}
		>
			{Component}
		</Transition>
	)}
</RouterView>

2.2 SVG Sprites

  • 在渲染Welcome页面时,图片是先渲染再请求的,这就导致图片的加载存在卡顿现象

  • 安装依赖

    • pnpm i svgo 用于优化 SVG 文件

    • pnpm i svgstore 用于制作 SVG Sprites

  • 编写Vite插件

 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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// vite_plugins/svgstore.js
/* eslint-disable */
import path from "path";
import fs from "fs";
// 用于制作 SVG Sprites
import store from "svgstore";
// 用于优化 SVG 文件
import { optimize } from "svgo";

export const svgstore = (options = {}) => {
  const inputFolder = options.inputFolder || "src/assets/icons";
  return {
    name: "svgstore",
    // 仅起到标识作用,因为此时打包文件还不存在
    resolveId(id) {
      if (id === "@svgstore") {
        return "svg_bundle.js";
      }
    },
    load(id) {
      if (id === "svg_bundle.js") {
        const sprites = store(options);
        const iconsDir = path.resolve(inputFolder);
        // 遍历icons目录,将全部svg作为symbol,整合制作为一个大svg
        for (const file of fs.readdirSync(iconsDir)) {
          const filepath = path.join(iconsDir, file);
          const svgid = path.parse(file).name;
          let code = fs.readFileSync(filepath, { encoding: "utf-8" });
          sprites.add(svgid, code);
        }
        // 优化svg雪碧图代码
        const { data: code } = optimize(
          sprites.toString({ inline: options.inline }),
          {
            plugins: [
              "cleanupAttrs",
              "removeDoctype",
              "removeComments",
              "removeTitle",
              "removeDesc",
              "removeEmptyAttrs",
              {
                name: "removeAttrs",
                params: { attrs: "(data-name|data-xxx)" },
              },
            ],
          }
        );
        return `const div = document.createElement('div')
div.innerHTML = \`${code}\`
const svg = div.getElementsByTagName('svg')[0]
// 将大svg隐藏
if (svg) {
  svg.style.position = 'absolute'
  svg.style.width = 0
  svg.style.height = 0
  svg.style.overflow = 'hidden'
  svg.setAttribute("aria-hidden", "true")
}
// listen dom ready event
// 将大svg作为根节点第一个元素
document.addEventListener('DOMContentLoaded', () => {
  if (document.body.firstChild) {
    document.body.insertBefore(div, document.body.firstChild)
  } else {
    document.body.appendChild(div)
  }
})`;
      }
    },
  };
};

  • 需要做一些配置
1
2
3
4
5
6
// tsconfig.node.json
// 因为插件是运行在node环境中的,因此需要在tsconfig中配置
"include": [
	"vite.config.ts",
	"src/vite_plugins/**/*",
],
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// vite.config.ts
import { svgstore } from './src/vite_plugins/svgstore';

export default defineConfig({
    vueJsx({
      transformOn: true,
      mergeProps: true
    }),
    // 将插件导入并使用
    svgstore(),
  ]
})
1
2
3
// main.ts
// 在main.ts中引入
import '@svgstore'
  • 使用
1
2
3
4
5
6
// Welcome.tsx
{/* import logo from 'logo.svg' */}
{/* <img src={logo} /> */}
<svg>
	<use xlinkHref="#logo"></use>
</svg>

2.3 封装useSwipe Hook

  • 移动端的每个页面都要支持滑动事件,为此我们封装为useSwipe hook(也就是Vue中的Composition API)

  • hook/Composition API:界面中重复的部分连同其功能一起提取为可重用的代码段

  • useSwipe本质上就是给传入的组件添加触控事件监听器,通过计算滑动初始位置与结束位置的差,得到移动的距离和方向,进而执行对应的响应

 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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { computed, ComputedRef, onMounted, onUnmounted, Ref, ref } from "vue";
type Point = { x: number; y: number };
type UseSwipe = (el: Ref<HTMLElement | null>) => {
  isMoving: Ref<boolean>;
  distance: ComputedRef<{ x: number; y: number } | null>;
  direction: ComputedRef<"" | "right" | "left" | "down" | "up">;
};
export const useSwipe: UseSwipe = (mainRef) => {
  // 滑动开始坐标
  const startPosition = ref<Point | null>(null);
  // 滑动结束坐标
  const endPosition = ref<Point | null>(null);
  // 滑动标记
  const isMoving = ref(false);
  // 触控距离
  const distance = computed(() => {
    if (!startPosition.value || !endPosition.value) {
      return null;
    }
    return {
      x: endPosition.value.x - startPosition.value.x,
      y: endPosition.value.y - startPosition.value.y,
    };
  });
  // 触控方向
  const direction = computed(() => {
    if (!distance.value) {
      return "";
    }
    const { x, y } = distance.value;
    // 判断x方向移动的距离与y方向移动的距离
    if (Math.abs(x) > Math.abs(y)) {
      return x > 0 ? "right" : "left";
    } else {
      return y > 0 ? "down" : "up";
    }
  });
  // 三个事件的回调
  const onTouchStart = (e: TouchEvent) => {
    isMoving.value = true;
    endPosition.value = null;
    startPosition.value = {
      // 只取第一根手指(touches[0])的移动数据
      x: e.touches[0].screenX,
      y: e.touches[0].screenY,
    };
  };
  const onTouchMove = (e: TouchEvent) => {
    if (!startPosition.value) {
      return;
    }
    // endPosition实时更新
    endPosition.value = { x: e.touches[0].screenX, y: e.touches[0].screenY };
  };
  const onTouchEnd = (e: TouchEvent) => {
    isMoving.value = false;
  };

  // 在useSwipe所在的组件挂载后 添加touch的事件监听
  onMounted(() => {
    mainRef.value?.addEventListener("touchstart", onTouchStart);
    mainRef.value?.addEventListener("touchmove", onTouchMove);
    mainRef.value?.addEventListener("touchend", onTouchEnd);
  });
  // 卸载组件后移除事件绑定
  onUnmounted(() => {
    mainRef.value?.removeEventListener("touchstart", onTouchStart);
    mainRef.value?.removeEventListener("touchmove", onTouchMove);
    mainRef.value?.removeEventListener("touchend", onTouchEnd);
  });
  return {
    isMoving,
    distance,
    direction,
  };
};
  • 使用
1
2
3
4
5
setup(){
	const main = ref<HTMLElement | null>(null)
	const { distance, direction, isMoving } = useSwipe(main)
	return () => <div ref={main} />
}

2.4 滑动切换路由

  • 统一类型定义写法

    • 推荐
    1
    
    const a = ref<HTMLElement>()
    
    • 不推荐
    1
    
    const a = ref<HTMLElement | null>(null)
    
  • 为每个路由添加name,方便跳转

  • 在Welcome.tsx中监听main元素的滑动事件(useSwipe(main)

  • 扩展useSwipe,提供useSwipe的生命周期机制

 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
interface Options {
  // 生命周期
  beforeStart?: (e: TouchEvent) => void;
  afterStart?: (e: TouchEvent) => void;
  beforeMove?: (e: TouchEvent) => void;
  afterMove?: (e: TouchEvent) => void;
  beforeEnd?: (e: TouchEvent) => void;
  afterEnd?: (e: TouchEvent) => void;
}
type UseSwipe = (
  el: Ref<HTMLElement | null>,
  options?: Options
) => {
  isMoving: Ref<boolean>;
  distance: ComputedRef<{ x: number; y: number } | null>;
  direction: ComputedRef<"" | "right" | "left" | "down" | "up">;
};
export const useSwipe: UseSwipe = (mainRef, options?) => {
  ...
  // 三个事件的回调,生命周期hook贯穿其中
  const onTouchStart = (e: TouchEvent) => {
    options?.beforeStart?.(e);
    ...
    options?.afterStart?.(e);
  };
  const onTouchMove = (e: TouchEvent) => {
    options?.beforeMove?.(e);
    ...
    options?.afterMove?.(e);
  };
  const onTouchEnd = (e: TouchEvent) => {
    options?.beforeEnd?.(e);
    ...
    options?.afterEnd?.(e);
  };
	...
  • 现在就可以在滑动之前(beforeStart禁用浏览器的默认滑动事件
1
2
3
const { direction, isMoving } = useSwipe(mainRef, {
	beforeStart: (e) => e.preventDefault(),
});
  • 在**watchEffect监听**isMovingdirection的变化(自动监听),从而执行路由切换逻辑
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 路由切换逻辑
const pushRoute = () => {
	if (route.name === "Welcome1") {
		router.push("/welcome/2");
	} else if (route.name === "Welcome2") {
		router.push("/welcome/3");
	} else if (route.name === "Welcome3") {
		router.push("/welcome/4");
	} else if (route.name === "Welcome4") {
		router.push("/start");
	}
}

// 执行路由切换
watchEffect(() => {
	if (isMoving.value && direction.value === "left") {
		pushRoute();
	}
});
  • 经过测试后发现,由于滑动事件触发过于频繁,导致一瞬间就切换到了最后一页

  • 因此需要添加节流处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// shared/throttle.tsx
export const throttle = (fn: Function, delay: number) => {
  let timer: number | undefined = undefined;
  return (...args: any[]) => {
    if (timer) {
      return;
    } else {
      fn(...args);
      timer = setTimeout(() => {
        timer = undefined;
      }, delay);
    }
  };
};
  • 优化路由切换逻辑后的最终代码
 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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import { defineComponent, Transition, ref, VNode, watchEffect } from "vue";
import {
  RouteLocationNormalizedLoaded,
  RouterView,
  useRoute,
  useRouter,
} from "vue-router";
import s from "./Welcome.module.scss";
import { useSwipe } from "../hooks/useSwipe";
import { throttle } from "../shared/throttle";
// 定义每个路由名称与其下一个路由路径映射
const welcomeRouteMap: Record<string, string> = {
  Welcome1: "/welcome/2",
  Welcome2: "/welcome/3",
  Welcome3: "/welcome/4",
  Welcome4: "/start",
};
export const Welcome = defineComponent({
  setup(props, context) {
    const mainRef = ref<HTMLElement | null>(null);
    const { direction, isMoving } = useSwipe(mainRef, {
      beforeStart: (e) => e.preventDefault(),
    });
    const route = useRoute();
    const router = useRouter();
    // 将路由切换函数节流处理
    const pushRoute = throttle(() => {
      const routeName = route.name ? route.name.toString() : "Welcome1";
      // 使用replace代替push 防止回退
      // router.push(welcomeRouteMap[routeName]);
      router.replace(welcomeRouteMap[routeName]);
    }, 500);
    watchEffect(() => {
      // 每当isMoving或者direction的值发生变动,都会执行
      if (isMoving.value && direction.value === "left") {
        pushRoute();
      }
    });
    return () => (
      <div class={s.wrapper}>
        <header>
          <svg>
            <use xlinkHref="#mangosteen"></use>
          </svg>
          <h1>GS记账</h1>
        </header>
        <main ref={mainRef}>
          <RouterView name="main">
            {({
              Component,
              route,
            }: {
              Component: VNode;
              route: RouteLocationNormalizedLoaded;
            }) => (
              <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}
              >
                {Component}
              </Transition>
            )}
          </RouterView>
        </main>
        <footer>
          <RouterView name="footer" />
        </footer>
      </div>
    );
  },
});

3 业务与轮子

我的思想是对的