技术思考 Vue3【项目思考】

216 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

Vue3 项目总结

效果实现

组件封装

创建引用

自定义封装一个组件,以按钮为例。首先先创建一个子组件。

<script setup lang="ts">
// 步骤:
// 1. 准备静态结构
// 2. 分析按钮组件的自定义属性
// 3. `defineProps` 定义 Props 接收值
// 4. 模板中使用父组件传过来的值设置按钮样式
</script><template>
  <button class="xtx-button ellipsis" :class="">
    <slot></slot>
  </button>
</template>

<style scoped lang="less">
// 基于类名定义一些和定制样式无关的样式
.xtx-button {
  appearance: none;
  border: none;
  outline: none;
  background: #fff;
  text-align: center;
  border: 1px solid transparent;
  border-radius: 4px;
  cursor: pointer;
}
// ---------大小类名-------------
// 大
.large {
  width: 240px;
  height: 50px;
  font-size: 16px;
}
// 中
.middle {
  width: 180px;
  height: 50px;
  font-size: 16px;
}

// ---------颜色类名----------
// 默认色
.default {
  border-color: #e4e4e4;
  color: #666;
}
// 确认
.primary {
  border-color: @xtxColor;
  background-color: @xtxColor;
  color: #2cb4b4;
}

观察代码,发现封装的实质就是通过传参调用不同的类名实现不同的样式效果。

在父组件中引入使用。

<script setup lang="ts">
import MyButtonVue from './components/MyButton.vue';
</script>

<MyButtonVue></MyButtonVue>

设置TS类型

接下来结合 ts 设置子组件传参的类型,使得使用子组件时有更好的代码提示。

  • 方法一:PropType 方法(通用写法)

    子组件中引入 vuePropType 方法,通过 defineProps 设置传参的类型与变量。

    defineProps({
      size: {
        type: String as PropType<'middle' | 'large' | 'mini' | 'small'>,
        default: 'small',
      },
      type: {
        type: String as PropType<'gray' | 'plain' | 'primary' | 'default'>,
        default: 'primary',
      },
    });
    

    这样就能设置两个参数 sizetype 的数据类型为字符串和各自的默认值。

    优点是适用于组合式和选项式的 API ,缺点是写法繁琐。

  • 方法二:泛型 方法(实验性写法)

    通过泛型的写法,设置变量的类型。通过问号来表示可传属性。

    defineProps<{
      type?: 'gray' | 'plain' | 'primary' | 'default';
      size?: 'middle' | 'large' | 'mini' | 'small';
    }>();
    

    但是我们发现虽然可以检查给出报错信息,但是没有默认属性了,官方文档 TS with Composition 中给出了解决方法,用解构的思路附默认值。

    const { type = 'default', size = 'large' } = defineProps<{
      type?: 'gray' | 'plain' | 'primary' | 'default';
      size?: 'middle' | 'large' | 'mini' | 'small';
    }>();
    

    但是又出现了报错,而这次是 ESlint 的错误,需要手动关闭。去到 .eslint.cjs 文件中规则部分关闭。

    module.exports = {
      root: true,
      // eslint规则
      rules: {
        'vue/multi-word-component-names': 'off',
        'vue/no-setup-props-destructure': 'off',
      },
    };
    

    重新打开子组件,发现没报错了,去实验一下,发现依旧没效果,为什么呢?重新回去看一下文档,发现下面还有一段小字:此行为当前需要明确的 opt-in

    点击进去查看,上面描述了详细的需求,比如 vite 版本,还要添加一条设置。复制代码去 vite.config.ts 文件粘贴即可。

    // vite.config.js
    export default {
      plugins: [
        vue({
          reactivityTransform: true
        })
      ]
    }
    

    重启项目,最终能够成功实现。我们可以在优化一下,把泛型里的类型单独提取出来,设置成一个类型对象接口,使用时再引入。

    interface Prop {
      type?: 'gray' | 'plain' | 'primary' | 'default';
      size?: 'middle' | 'large' | 'mini' | 'small';
    }
    const { type = 'default', size = 'large' } = defineProps<Prop>();
    
  • 方法三:withDefaults 方法

    这个方法一开始使用的比较多,现在官方文档把这个方法从 TS with Composition 中剔除了,隐藏在其他地方中。

    interface Props {
      // 按钮大小
      size?: "mini" | "small" | "middle" | "large";
      // 按钮颜色
      type?: "gray" | "plain" | "primary" | "default";
    }
    
    withDefaults(defineProps<Props>(), {
      size: 'mini',
      type: 'primary',
    });
    

父子传值

v-model 是一个语法糖。

  • vue2 中,v-model 实际上的作用是 :value 动态绑定输入框的数据, @input 为输入框绑定修改事件修改其值。
  • vue3 中,双向绑定语法糖其实由 :modelValue@update:modelValue 两部分组合而成。

父传子

父组件中:ref 声明一个变量,注册子组件并用 v-model 传值。

<script setup lang="ts">
import MyCount from './components/MyCount.vue';
const count = ref(1);
</script><MyCount v-model="count"></MyCount>

子组件中,通过实验性方法接收数据 modelValue ,并通过 :value 把数据绑定给输入框 input ,让其显示。

<script setup lang="ts">
const prop = defineProps<{
  modelValue: number;
}>();
</script><template>
  <button>-</button>
  <input type="text" :value="modelValue" />
  <button @click="add">+</button>
</template>

子传父

通过 defineEmits 方法,定义事件 update:modelValue ,为按钮声明点击事件,事件处理函数中,调用 emit 声明好的事件,传值。

<script setup lang="ts">
const { modelValue } = defineProps<{
  modelValue: number;
}>();

const emit = defineEmits<{
  (event: 'update:modelValue', val: number): void;
}>();

const add = () => {
  // 为了遵循单项数据流,这里不宜直接 += 1,而是用一个中间变量来接收新值和传值
  let temp = modelValue + 1;
  emit('update:modelValue', temp);
};
</script>

<template>
  <button>-</button>
  <input type="text" :value="modelValue" />
  <button @click="add">+</button>
</template>

踩坑处理

路由

路由缓存

分类路由 category 是固定的,单纯id动态路由发生变化,因此vue3会把路由缓存起来,如果路由没发生变化则读取缓存的数据,造成点击导航不发请求的现象。

解决方法:在二级路由挂载的地方添加 :key ,绑定他们各自的路由 $route.fullPath ,只要动态路由发生变化, :key 值也会发生变化,就重新发送请求。

<RouterView :key="$route.fullPath"></RouterView>

路由滚动行为

vue 是单页面,因此路由切换时滚动条会在原来的位置,需要添加属性让其滚动条返回顶部。通过官方文档可查到相应的代码。

注意:

这里如果上网百度,很有可能会查到过时的、旧的解决方法,如 return 内设置的是 y ,那是 vue2 的处理方法,不适用于 vue3 。一切请以官方文档为准。

// 使用createRouter创建路由实例
const router = createRouter({
  ...
  // 始终滚动到顶部
  scrollBehavior: () => {
      return {  top : 0  }
  }
})

ts文件使用路由

在做登录效果时,为了方便管理,特地把路由跳转的那部分代码写在 actions 函数中,如下所示。

import { useRouter } from 'vue-router';

const useMemberStore = defineStore('member', {
  state: () => ({
    profile: {} as Profile,
  }),
  actions: {
    async loginAccount(form: { account: string; password: string }) {
      const res = await http<Profile>('post', '/login', form);
      // console.log(res.data.result);
      this.profile = res.data.result;
      const router = useRouter();
      console.log(router);
    },
  },
});

发现跳转不成功,打印 router ,发现打印出来的是 undefined ,并且控制台还出现这么一条警告:

[Vue warn]: inject() can only be used inside setup() or functional components.

翻译过来就是,我们不能在 setup 以外的地方使用 useRouter ,怎么办呢?在之前做路由封装时有导出过路由,可以直接引入使用。

import router from '@/router';

const useMemberStore = defineStore('member', {
  actions: {
    async loginAccount(form: { account: string; password: string }) {
      const res = await http<Profile>('post', '/login', form);
      // console.log(res.data.result);
      this.profile = res.data.result;
      router.push('/');
      message({ type: 'success', text: '登录成功~' });
    },
  },
});

ts文件使用pinia

情况同路由一致,pinia 在非 setup 的使用方法不太一样。查看官方文档 pinia

image.png

官方文档解释的很清楚,在非 setup 文件中,代码会有解析的顺序问题,如果过早的使用会出现一些报错。正确的使用方式是哪里要使用就在哪里引入。

import useStore from '@/store';

instance.interceptors.request.use(
  (config) => {
    // 在发送请求前做什么
    const { member } = useStore();
    const { token } = member.profile;
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  }
);

这样就能够在ts文件中获取到 pinia 内的 token 来封装请求头了。

项目优化

图片懒加载

当视图滚动到对应位置再发送请求,使用 @vueuse/core 中的 useIntersectionObserver 来实现监听组件进入可视区域行为,实现图片懒加载,优化。

官方文档: useIntersectionObserver

先分析下这个useIntersectionObserver 函数:

🔔核心单词解释:

  • useIntersectionObserver 检查元素是否进入可视区函数
  • target 目标元素,🎯需配合模板 ref 使用
  • isIntersecting 是否进入可视区(布尔值)
  • stop 用于停止检测的函数

因此,可以在函数内添加一个 if 判断,判断 isIntersecting 值是否为真,为真则说明此时已滚动到可视范围,执行获取数据的函数事件,实现懒加载。

执行完数据获取的事件后别忘记调用 stop 方法停止侦听追踪,不然用户滑来滑去还要发好几次请求,得不偿失。

<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core';
import { ref } from 'vue';

// 准备目标元素(DOM节点或组件,需配合模板 ref 使用)
const target = ref(null);

const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
  // 需求:如果目标元素进入可视区,就发送请求,并停止检测
  if (isIntersecting) {
    // 当目标元素进入可视区域时,才发送请求
    console.log('进入可视区,需要发送请求');
    // 请求已发送,主动停止检查
    stop();
  }
});
</script>

<template>
  <div style="height: 2000px"></div>
  <!-- 🎯目标元素需添加模板 ref  -->
  <div ref="target">
    <h1>Hello world</h1>
  </div>
  <div style="height: 2000px"></div>
</template>

步骤总结:

  1. 通过 ref 属性获取组件的实例
  2. 使用useIntersectionObserver监听函数
  3. 判断isIntersecting 属性,如果为真则说明视图已滚动到相应位置,发送请求
  4. 请求发送一次后调用 stop 停止监听

复用

import { useIntersectionObserver } from "@vueuse/core";
import { ref } from "vue";

/**
 * 请求按需加载
 * @param apiFn 发送请求函数
 * @returns  🚨 target 用于模板绑定
 */
export const useObserver = (apiFn: () => void) => {
  // 准备个 ref 用于绑定模板中的某个目标元素(DOM节点或组件)
  const target = ref(null);
  const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
    console.log("是否进入可视区域", isIntersecting);
    if (isIntersecting) {
      // 当目标元素进入可视区域时,才发送请求
      apiFn();
      // 请求已发送,主动停止检查
      stop();
    }
  });
  // 🚨返回 ref 用于模板绑定,建议返回对象格式支持解构获取
  return { target };
};

持久化存储

通过 pinia 的插件 pinia-plugin-persistedstate 实现快速本地存储。

插件官方文档:插件

  1. 下载

    yarn add pinia-plugin-persistedstate
    
  2. 引入注册

    注意:

    这里是给 pinia 注册插件,而不是给 vue

    +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
    
    const pinia = createPinia();
    +pinia.use(piniaPluginPersistedstate);
    app.use(pinia);
    
  3. 模块开启持久化

    const useHomeStore = defineStore("home",{
    +  persist: true
      // ...省略
    });
    

进阶

不想所有数据都持久化处理,需要选择按需持久化所需数据,可以用配置式写法,按需缓存某些模块的数据。

查看官网的代码片段,可通过 paths 数组内设置相应变量来实现按需保存。

import { defineStore } from 'pinia'export const useStore = defineStore('main', {
  // 所有数据持久化
  // persist: true,
  // 持久化存储插件其他配置
  persist: {
    // 修改存储中使用的键名称,默认为当前 Store的 id
    key: 'storekey',
    // 修改为 sessionStorage,默认为 localStorage
    storage: window.sessionStorage,
    // 部分持久化状态的点符号路径数组,[]意味着没有状态被持久化(默认为undefined,持久化整个状态)
    paths: ['nested.data'],
  },
})

登录跳转对应页面

作为一个电商网站,用户可能会在长久的商品挑选后才决定登录购买,此时如果在登录业务中写死 router.push('/') ,极其不方便用户体验。

为了解决这个方法,可以分两步走:

  1. 让登录路由路径动态保存跳转过来的页面路径,点击登录按钮后不是固定写死跳转到首页,而是获取保存的路径动态跳转。
  2. 动态获取用户点击去登录时当前的路由路径,并保存到动态路由中。

动态跳转

在去登录的 to 属性先写死一个路由。

<RouterLink to="/login?target=/category/1013001">请先登录</RouterLink>

此时在登录页可以看到路径上保存着相应的路由信息,怎么获取呢?我们此时能依靠的只有一开始做页面跳转时引入的 router

打印 router 看看有啥东西。

image.png

可以发现,有很多属性方法,其中,我们需要的路径参数在 currentRoute 下的 query 中,获取参数,把参数赋值给 router.push() 中,即可实现动态跳转。

actions: {
  async loginAccount(form: { account: string; password: string }) {
    const res = await http<Profile>('post', '/login', form);
    this.profile = res.data.result;

    // 动态解构出路径参数
    const { target = '/' } = router.currentRoute.value.query;
    // 为了ts不报类型错误,把类型固定写死为字符串
    router.push(target as string);
    message({ type: 'success', text: '登录成功~' });
  },
},

注意:

如果用户一开始进入的就是登录页面,此时动态路由是没有参数保存的,即 undefined ,这种情况我们给个默认值让其登录后去到首页即可。

动态获取

当然,我们不能直接写死一个路由,用户很有可能从任何地方跳转,因此需要动态获取路由。获取方式简单,只需要 $route.fullPath 就能获取成功。

<RouterLink :to="`/login?target=${$route.fullPath}`"
  >请先登录</RouterLink
>

Tab栏切换

点击 Tab 栏可以切换下方内容,显示对应的内容,有三种方法可以实现:

  • v-if
  • v-show
  • keppalive

一般情况下, Tab栏业务切换需要保留用户之前输入的信息,这个时候第三种方法显然是最合适的。

查看 vue.js 的官方文档,告诉我们 keppalive 的使用方式,就是通过 keppalive 包裹 component 标签,通过设置 is 属性来实现切换效果。

image.png

使用 keppalive 最大的好处在于它能够实现缓存,即使切换上一次的数据也不会被销毁,而是缓存起来在后台,用户频繁切换也不会需要频繁生成,极大的提高了性能。