最近在工作中大量使用到了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>
如何定义 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 定义类型
可以通过symbol
和InjectionKey
定义的 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
方法触发时会保留执行栈的信息,大大提高了调试的效率
其他
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 动态渲染组件
在拆分复杂组件时,比如在可编辑的表格场景下,如果需要自定义每一列的渲染组件,可以利用component
的is
属性,避免多余的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>
后记
未完待续...