盘点vue3体系下的一些优秀实践

257 阅读8分钟

最近在工作中大量使用到了Vue3整个体系,这篇文章用来记录一下在开发过程中的一些优秀实践(仅代表个人)。

整体而言,Vue3相比Vue2在编程体验上有了质的提升,首先函数风格的API设计得到了更好的TS支持,其次在代码的组织复用上,不同于Vue2中差强人意的Mixin方式,Composition API的设计让我们在逻辑的组合和复用上有了更多的选择,尤其是与状态管理工具(pinia)的结合,让我们可以在store中自由的利用vue reactivity的能力,大大解放了我们自身的生产力创造力

TS相关

抛弃this,拥抱ts

如何在 template 中保持组件的类型推导以及获取其 props/event 类型?

以 ant-design-vue 组件库的使用为例

<template>
  <Upload
    action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
    :multiple="true"
    @change="handleChange"
  >
    <Button>
      <UploadOutlined />
    </Button>
  </Upload>
</template>

<script lang="ts" setup>
import { Upload, Button, UploadProps } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';

const handleChange: UploadProps['onChange'] = function (e) {
  // do something
};
</script>

直接从组件库中导出组件使用相比于注册全局组件的方式,主要有两点优势:

  • 更好的ts类型提示,无需额外的vscode插件
  • 对构建时的tree sharking更加友好

如何给 component ref 定义类型

平时开发过程中,经常会使用ref获取组件实例后,调用该组件实例上的方法,可以通过以下方式保持该方法的类型提示

<template>
    <ImportCodeModal ref="importCodeModalRef" />
</template>

<script setup lang="ts">
import ImportCodeModal from './importCodeModal.vue';

const importCodeModalRef1 = ref<InstanceType<typeof ImportCodeModal>>('importCodeModalRef');

// vue 3.5+
const importCodeModalRef2 =
  useTemplateRef<InstanceType<typeof ImportCodeModal>>('importCodeModalRef');
</script>

image.png

如何定义 props 的类型以及默认值

props中的类型可以由外部引入,对于props中的引用类型在定义默认值时,需要使用函数的方式

<script setup lang="ts">
import { ShowTableProps } from './showTable';
import { BasicInfo } from '@/stores/showManageStore/basicInfo';
import { ProjectInfo } from '@/types/project';

const props = withDefaults(defineProps<ShowTableProps>(), {
  rowDraggable: true,
  renderMode: 'edit',
  showHeader: true,
  showExpandColumn: true,
  showSorterTooltip: true,
  bordered: true,
  basicInfo: () => ({}) as BasicInfo, // 采用函数方式返回
  projectInfo: () => ({}) as ProjectInfo,
});
</script>

如何定义 emits 的类型

可以使用类似函数重载的方式,定义emits的类型

<script setup lang="ts">
const emits = defineEmits<{
  (e: 'selected-rows', selectedRows: ListTableRow[]): void;
  (e: 'sorted-columns', sorter: SorterResult | SorterResult[]): void;
  (e: 'update-list'): void;
}>();
</script>

如何给 provide/inject 定义类型

可以通过symbolInjectionKey定义的 key,来确保一组provide/inject的类型

import { inject, InjectionKey, provide, ref } from 'vue';

export type CellValidator = (callback: (err?: string) => void) => void;

const ValidatePrivodeKey = Symbol() as InjectionKey<{
  registerValidator: (fn: CellValidator) => void;
  unRegisterValidator: (fn: CellValidator) => void;
}>;

export function useValidate() {
  const validators = ref<CellValidator[]>([]);

  provide(ValidatePrivodeKey, {
    registerValidator: (fn: CellValidator) => {
      validators.value.push(fn);
    },
    unRegisterValidator: (fn: CellValidator) => {
      validators.value = validators.value.filter(validator => validator !== fn);
    },
  });
}

export function useValidator(fn: CellValidator) {
  const { registerValidator, unRegisterValidator } = inject(ValidatePrivodeKey)!;
  // ...
}

如何给接口数据做类型定义

对前端用到的属性,都必须定义其类型,可以由Record<string, unknown>扩展

其实,写类型定义的过程也有助于业务的理解和梳理

export interface ShowTableRow extends Record<string, unknown> {
  projectShowId: number;
  projectId: number;
  projectTicketId?: number | string; // string 类型 为前端生成的假id
  setNumber?: number; 
  nextSetNumber?: number; 
  ticketRGB: string;
  ticketPrice?: number; 
  ticketSort?: number; 
  nextTicketPrice?: number; 
  baseTicketId?: number | string; 
  showNextMaxBuyLimit?: number;
  sellPrice?: number;
  nextSellPrice?: number;
  ticketName?: string; 
  description?: string; 
  setDescription?: string; 
  specificTimeSale?: number; 
  // 交互控制
  renderMode?: RenderMode;
  isDeleted?: boolean;
}

Composition API相关

Composition API的设计,让我们可以脱离组件维度,以一种更灵活的方式去抽象组织相关的业务代码

使用 Object.assign 重置由 reactive 处理的响应式数据

reactive初始化的数据不能直接进行赋值,Object.assign方法可以不破坏变量的响应性的基础上,完成对引用类型的赋值(包括数组

function resetState() {
    Object.assign(basicInfo, {
      startTime: undefined,
      endTime: undefined,
      name: '',
      note: '',
      priority: undefined,
      source: 0,
    } as BasicInfo);

    nextTick(() => {
      drity.value = false;
    });
  }

在hooks中,使用toRef/toRefs保持props的响应性

hook设计中,经常需要将hooks函数的参数设计成Ref类型来表示这是一个响应性的变量,但组件的props或者reactive类型中的属性等等直接传递的话并非Ref类型,这时可以利用toRef/toRefs方法,保持其响应性

const { columns } = useColumns(
  toRef(props, 'projectInfo'),
  toRef(props, 'showInfo'),
);

如何实现插槽穿透?

Vue SFC中,当我们对基础组件进行一些能力扩展时,常常需要将基础组件支持的插槽重新对外暴露出去,这时可以利用useSlots不知道基础组件提供的插槽名称的情况下完成对外的暴露

<template>
  <Table
    v-bind="props"
    tableLayout="fixed"
    size="small"
    class="show-table__main"
  >
    <template v-for="slotName in Object.keys(slots)" :key="slotName" #[slotName]="scope">
      <slot :name="slotName" v-bind="scope"></slot>
    </template>
  </Table>
</template>

<script setup lang="ts">
import { Table } from 'ant-design-vue';

const slots = useSlots();

</script>

利用计算属性get/set完成数据格式的转换

后端小伙伴一般在定义接口数据类型时是不考虑前端是否容易处理的,这时往往需要前端自行做适配。

ant-design-vue的日期范围选择组件RangePicker为例,后端要求的格式是unix时间戳且分成开始日期结束日期两个字段传递,这时可以利用计算属性的 get/set 封装数据转换的细节。

<template>
  <RangePicker
    v-model:value="showTime"
    :format="dateFormat"
    show-time
    :placeholder="['开始日期', '结束日期']"
  />
</template>
</template>

<script setup lang="ts">
import { RangePicker } from 'ant-design-vue';
import { computed } from 'vue';
import dayjs, { Dayjs } from 'dayjs

const dateFormat = 'YYYY/MM/DD HH:mm';

const startTime = defineModel<number>('startTime');
const endTime = defineModel<number>('endTime');

const showTime = computed<[Dayjs, Dayjs] | undefined>({
  get() {
    if (!startTime.value || !endTime.value) {
      return undefined;
    }

    return [dayjs(startTime.value), dayjs(endTime.value)];
  },
  set(dateRange) {
    if (dateRange) {
      startTime.value = dayjs(dateRange[0]).valueOf();
      endTime.value = dayjs(dateRange[1]).valueOf();
    } else {
      startTime.value = undefined;
      endTime.value = undefined;
    }
  },
});
</script>

在一些基础能力的封装上,计算属性的get/set可以很好的隐藏数据获取和修改的细节。

利用 defineModel 自定义表单控件

在一些复杂表单场景下,每个表单项包含的业务逻辑可能非常多的,这时出于代码质量的考虑(ps: 强迫症而已)往往会将表单项单独封装成组件,组件支持v-model的双向绑定来完成数据修改。

<template>
  <Form
    ref="formRef"
    class="copy-show-form"
    :model="copyShowViewModel"
    :label-col="{ span: 5 }"
    :wrapper-col="{ span: 19 }"
  >
    <ShowName
      v-if="copyShowViewModel.showType !== 1"
      label="新场次名称"
      name="showName"
      v-model:show-name="copyShowViewModel.showName"
    />
    <NewShowDate
      label="新场次日期"
      name="newShowDate"
      v-model:new-show-date="copyShowViewModel.newShowDate"
      v-model:weeks="copyShowViewModel.weeks"
      :copyShowViewModel="copyShowViewModel"
    />
  </Form>
</template>

在组件内部可以使用defineModel接收并传递给基础表单控件

<template>
  <FormItem v-bind="props" class="copy-show__show-name" :rules="rules" auto-link>
    <Input class="show-name__input" v-model:value="showName" placeholder="请输入" />
  </FormItem>
</template>

<script setup lang="ts">
import { FormItem, FormItemProps, Input } from 'ant-design-vue';
import { RuleObject } from 'ant-design-vue/es/form';
import { ref } from 'vue';

const props = defineProps<FormItemProps>();

const showName = defineModel<string>('showName', { default: '' });

const rules = ref<RuleObject[]>([
  {
    required: true,
    message: '请输入场次名称',
  },
]);
</script>

利用 watchEffect onCleanup 来清除hooks中的副作用

虽然很多时候 onUnmounted 也可以达到同样的效果,但个人认为hooks的设计最好不要与组件生命周期绑定,相比之下,watchEffect onCleanup在响应式依赖改变和watcher销毁时都会重新运行,更适合处理hooks中的副作用

典型的副作用,比如:定时器/网络请求等等

export function myHooks() {
  // ...
  watchEffect(() => {
    const timer = setInterval(() => {
      // do something
    }, 1000)

    onWatcherCleanup(() => {
      clearInterval(timer)
    });
  });
}

利用key刷新第三方组件的状态

某些第三方组件(受控组件)可能没有对外暴露重置某个内部状态的方法时,可以给这个组件绑定一个key,需要重置状态时,更新这个key即可

<template>
    <ListTable
      :key="listTableRenderKey"
    />
</template>

<script setup lang="ts">
import { ref } from 'vue';

const listTableRenderKey = ref<string>(uuidV4());

function handleUpdateList(resetPagination?: boolean) {
  // 刷新table checkbox 状态
  listTableRenderKey.value = uuidV4();
}

状态管理相关

一直以来,vue2中的状态管理vuex都给我一种刻板固执的印象,Mutation/Action的区分在我看来并不实用,有点教条。在vue3中,pinia + Componsition API的组合可以让我们更好的利用vue reactivity的能力,大大提高了store的实用性,非常nice!!!

利用 Componsition API 的方式管理 store

import { ShowTableRow } from '@/components/goods/showTable/showTable';
import { defineStore, storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue';
import { ShowStatus, ValidateFn } from '.';
import { useBaseInfoStore } from '../baseInfoStore';

export const useTicketMangeStore = defineStore('showTicketManageStore', () => {
  // 集成其他store
  const projectBaseInfoStore = useBaseInfoStore();

  const { projectInfo } = storeToRefs(projectBaseInfoStore);

  const fullTicketDataSource = ref<ShowTableRow[]>([]);
  // 拦截代理 fullTicketDataSource 的修改和访问
  const ticketDataSource = computed<ShowTableRow[]>({
    get() {
      // 加删除逻辑控制
      return fullTicketDataSource.value.filter(rowData => !rowData.isDeleted);
    },
    set(data) {
      fullTicketDataSource.value = data;
    },
  });

  const drity = ref<boolean>(false);

  let validateFn: ValidateFn | undefined;
  // 数据变化监测
  watch(
    () => ticketDataSource,
    () => {
      if (!drity.value) {
        drity.value = true;
      }
    },
    {
      deep: true,
    }
  );


  function resetTicketManageState() {
    // ...
  }
  // 数据校验器的设置
  function setValidateFn(fn: ValidateFn) {
    validateFn = fn;
    return () => {
      validateFn = undefined;
    };
  }

  function saveTicketManageState(showStatus: ShowStatus) {
    return new Promise<void>((resolve, reject) => {
      if (!validateFn) {
        reject('校验函数不存在,保存失败');
        return;
      }

      validateFn().then(() => {
        drity.value = false;

        // ...
      }, reject);
    });
  }
  // 数据的增删改查方法
  function addTicketRowData(isPackageTicket: boolean) {
    // ...
  }

  function updateTicketRowData(ticketData: ShowTableRow, rowIdx: number) {
    // ...
  }

  function filterTicketRowData(callback: (ticket: ShowTableRow, idx: number) => boolean) {
    // ...
  }

  function initTicketDataState(projectShowId?: string) {
    // ...
  }

  return {
    drity,
    ticketDataSource,

    resetTicketManageState,
    setValidateFn,
    saveTicketManageState,
    addTicketRowData,
    filterTicketRowData,
    initTicketDataState,
    updateTicketRowData,
  };
});

利用 watch onTrigger 方法观测数据的变化

多人合作开发的模块中,我们很难避免其他人直接修改store中对外暴露的数据(不调用相应方法),如果代码量很大业务流程很复杂的话,我们很难发觉和定位这个问题,这时可以借助watch onTrigger方法进行debugger来找出问题。

import { useBaseInfoStore } from '@/stores/baseInfoStore';

const baseInfoStore = useBaseInfoStore();
const { projectInfo } = storeToRefs(baseInfoStore);

watch(
  () => projectInfo.value.projectId,
  () => {},
  {
    onTrigger() {
      debugger;
    },
  }
);

onTrigger方法触发时会保留执行栈的信息,大大提高了调试的效率

image.png

其他

promisify 的弹框/抽屉

在后台系统中,绝大部份的弹框/抽屉场景都是符合 打开弹框 -> 操作弹框内容 -> 点击确定按钮完成修改 -> 刷新外部页面 这种交互模式,这时可以通过一个封装 promisify的弹框/抽屉 来减少对父组件的侵入

<template>
  <Modal
    :open="visible"
    :maskClosable="false"
    @cancel="handleCancel"
    @ok="handleOk"
  >
    ...
  </Modal>
</template>

<script setup lang="ts">
import { Modal } from 'ant-design-vue';
import { ref } from 'vue';

const visible = ref(false);

interface PromiseCb {
  resolve?: () => void;
  reject?: (reason?: any) => void;
}

let promiseCb: PromiseCb = { resolve: undefined, reject: undefined };

function openModal() {
  return new Promise<void>((resolve, reject) => {
    promiseCb.resolve = resolve;
    promiseCb.reject = reject;

    context.value = ctx;

    visible.value = true;
  });
}

function handleCancel() {
  if (promiseCb.reject) {
    promiseCb.reject('cancel');
  }

  visible.value = false;
}

function handleOk() {
  // ...

  if (promiseCb.resolve) {
    promiseCb.resolve();
  }

  visible.value = false;
}

defineExpose({
  openModal,
});
</script>

在父组件中的使用

<template>
  <CumstomModal ref="customModalRef" />
  
  <span @click="handleOpen">打开弹框</span>
</template>

<script setup lang="ts">
import CustomModalRef from './customModal.vue';

const customModalRef = useTemplateRef<InstanceType<typeof CustomModalRef>>('customModalRef');

function handleOpen() {
  if (!customModalRef.value) return;

  customModalRef.value
    .openModal()
    .then(() => {
      ...
    });
}
</script>

使用 component is 动态渲染组件

拆分复杂组件时,比如在可编辑的表格场景下,如果需要自定义每一列的渲染组件,可以利用componentis属性,避免多余的v-if判断

<template>
  <Table
    v-bind="props"
    :data-source="dataSouce"
    :columns="columns"
    tableLayout="fixed"
    size="small"
    class="show-table__main"
  >
    <template #bodyCell="{ column, record, index }">
      <component
        :is="ColumnEditors[column.key as TableColumnsKey]"
        v-model:rowData="dataSource[index]"
        :renderMode="record.renderMode || renderMode"
        :column="column"
        :dataSource="dataSouce"
        :basicInfo="basicInfo"
        :projectInfo="projectInfo"
      ></component>
    </template>
  </Table>
</template>
<script setup lang="ts">
import ColumnEditors from './columnEditors/index';
//...
</script>

后记

未完待续...