在之前的文章中,我们在代码里都使用了setup的语法糖,写起来十分简洁方便,但是有些小伙伴对它的用法不是很了解,私信说希望能讲一讲;本文我们就结合typescript,详细讲透setup语法糖的一些用法。
简介
我们知道,setup函数是vue3中的一大特性函数,是组合式API的入口,我们在模板中用到的数据和函数都需要在里面定义,并且最后通过setup函数导出后才能在template中使用:
vue
<script>
import { ref } from 'vue'
import Card from './components/Card';
export default {
components: {
Card,
},
setup(props, ctx){
const count = ref(0);
const add = () => {
count.value ++
}
const sub = () => {
count.value ++
}
return {
count,
add,
sub,
}
}
}
</script>
但是setup函数使用起来比较臃肿,所有的逻辑都写在一个函数中定义;我们发现这样简单的变量和函数,需要频繁的定义导出,再次定义导出,在实际项目开发中会很麻烦,我们写的时候也是需要不断的来回切换,而且变量一多还容易搞混。
于是更好用的setup语法糖出现了,将setup属性添加到<script>
标签,上面的变量和函数可以通过语法糖简写成如下:
vue
<script setup>
import { ref } from 'vue';
const count = ref(0)
const add = () => {
count.value ++
}
const sub = () => {
count.value ++
}
</script>
通过上面的一个简单的小案例,我们就发现setup语法糖不需要显示的定义和导出了,而是直接定义和使用,使代码更加简洁、高效和可维护,使代码更加清晰易读,我们接着来看下还有哪些用法。
基本用法
上面的案例我们已经知道了在setup语法糖中,不需要再繁琐的进行手动导出;不过setup语法糖不支持设置组件名称name,如果需要设置,可以使用两个script标签:
vue
<script>
export default {
name: 'HomeView',
};
</script>
<script setup>
import { ref } from 'vue';
// ...
</script>
如果设置了lang属性,script标签和script setup标签需要设置成相同的属性。
生命周期
Vue3中取消了create的生命周期函数,在其他的生命周期函数前面加上了on,例如onMounted、onUpdated;同时新增了setup函数替代了create函数,setup函数比mounte函数更早执行,因此我们可以在代码中导入函数钩子,并使用它们:
vue
<script lang="ts" setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onActivated,
onDeactivated,
onErrorCaptured,
} from "vue";
onBeforeMount(()=>{
// ...
})
</script>
和vue2的8个生命周期函数相比,在setup函数中,排除了beforeCreate和created,加上onActivated和onDeactivated2个在keep-alive中使用的函数钩子,和一个onErrorCaptured异常捕获钩子,一共有9个生命周期的函数钩子可供使用。
响应式
响应式是vue3和vue2比较大的一处不同之处,vue2在data中定义的数据会自动劫持成为响应式,而vue3默认返回的数据不是响应式的,需要通过ref和reactive来定义数据,ref定义简单的数据类型,而reactive定义复杂数据类型,使之成为响应式:
vue
<script lang="ts" setup>
import { ref, reactive } from 'vue';
const count = ref(0);
const person = reactive({
name: 'jone',
age: 18,
})
</script>
虽然ref是用来定义简单数据类型,不过对于对象和数组的复杂数据类型也能使用,不过使用时都需要加上.value:
vue
<script lang="ts" setup>
import { ref, reactive } from 'vue';
const list = ref([]);
const person = ref({
name: 'jone',
age: 18,
});
list.value.push(23);
console.log(person.value.name)
// 报错
// 类型"number"的参数不能赋给类型"object"的参数。
const count = reactive(2)
</script>
ref和reactive看起来用法是相同的,但使用ref时,操作变量值的时候需要用.value
,因此适用零散的单个变量;如果是多个相关联的变量,比如用户的一系列信息,姓名、性别、住址等,使用ref定义单个变量较为麻烦,就可以使用reactive组合成对象。
如果我们想要用到复杂数据类型中的某个属性,还想要和原来的数据保持关联,比如person中的name或者age,只通过解构的方式,数据响应性会丢失,页面并不会改变:
vue
<template>
<div>{{ name }} {{ age }}</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
const person = reactive({
name: 'jone',
age: 18,
});
const { name, age } = person;
setTimeout(() => {
// 页面上的数据并不会响应改变
person.name = "hello";
}, 1500);
</script>
这个时候,我们就可以使用toRef()
函数来关联两个变量,这个函数的功能相当于创建了一个ref对象,并将其值指向对象中的某个属性:
vue
<script lang="ts" setup>
import { ref, reactive, toRef } from 'vue';
const person = reactive({
name: 'jone',
age: 18,
});
const name = toRef(person, 'name');
setTimeout(() => {
// 页面上的数据随之响应
person.name = "hello";
// 或者直接更改name变量
name = "hello";
}, 1500);
</script>
这样,我们更改person中的属性或者直接更改name变量,两者都会随对方的改变而改变;我们发现toRef一次只能创建一个ref对象,如果同时有数个变量,效率不够高,就需要用到toRefs()
:
vue
<script lang="ts" setup>
import { toRefs } from 'vue';
const person = reactive({
name: 'jone',
age: 18,
});
console.log(toRefs(person));
console.log(person);
</script>
toRef只是创建一个ref变量,而toRefs则是创建了一堆ref变量,它的作用是将响应式对象上所有的属性都转换为ref,然后再将这些变量组合成一个对象,因此我们可以打印出来看下,发现toRefs后的数据也只是一个普通的对象,只不过对象中有很多的ref变量:
虽然toRef可以将响应式数据的属性转换成ref对象,不过当toRef和props结合使用的时候,是不允许修改ref对象的值的,因为这样等于直接修改props的数据,这种情况下可以使用下面介绍的带有get/set的computed函数。
vue
<script setup>
const title = toRef(props, "title");
const clickChange = () => {
// 报警:
// Set operation on key "title" failed: target is readonly.
title.value = "new title";
};
</script>
我们可以将title改成computed的形式:
vue
<script setup>
const title = computed({
get: () => props.title,
set: (val) => {
emits("update:title", val);
},
});
</script>
此外,对于一些复用性高的数据和业务逻辑,我们可以将其封装到组合函数中,所谓的组合式函数,官方的解释如下:
在Vue应用的概念中,"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。
比如对于分页请求的列表数据tableList和页码等多数页面会用到的复用性高的数据,我们可以选择将其提取到组合式函数中来,这个时候就可以利用toRefs
函数将响应式数据转换成多个ref,同时也不失去响应性:
vue
<script setup>
import { reactive, toRefs } from "vue";
export function usePage() {
const state = reactive({
pageNo: 1,
pageSize: 10,
tableList: [],
});
const addPageNo = () => {
state.pageNo++;
};
const getTableList = () => {
// 异步获取列表数据
};
return { ...toRefs(state), addPageNo, getTableList };
}
</script>
我们在页面上引入usePage函数,同时解构出其中的数据和函数:
vue
<script setup>
import { usePage } from "@/hooks/page";
const { tableList, pageNo, pageSize, addPageNo } = usePage();
</script>
computed
computed是基于依赖进行缓存的一种属性,用于派生出或者计算出一个值;我们在setup中使用时,需要先引入computed
vue
<script lang="ts" setup>
import { ref, computed } from 'vue';
const count = ref(10);
const double = computed(() => count.value * 2);
</script>
我们给computed函数传入一个箭头函数,箭头函数的返回值作为computed的计算返回;不过此时的double是一个只读属性,在setup中通过.value
获取其值,如果强行改变其值会报错;computed也可以接收一个options,动态设置依赖值:
vue
<script lang="ts" setup>
import { ref, computed } from 'vue';
const count = ref(10);
const double = computed({
get: () => count.value * 2,
set: (val) =>{
count.value = val / 2
}
});
// 这样会触发double的set函数
double.value = 16
</script>
watch和watchEffect
在Vue3中,watch和watchEffect都是用来侦听数据源并执行相应操作的函数;其中watch
函数是用来侦听特定的数据源,并在数据源改变时执行回调函数:
vue
<script setup lang="ts">
const name = ref("aa");
watch(name, (newVal, oldVal) => {
// ...
});
</script>
对于reactive对象中的属性,很多小伙伴理所应当的认为这样写就可以了:
vue
<script setup lang="ts">
const person = reactive({
name: "cc",
});
watch(person.name, (newVal, oldVal) => {
// ...
}
);
</script>
如果按照上面写法,则会报以下告警信息:
A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.
这是因为person.name变量存放的是一个固定的字符串值,watch拿到的参数只是一个字符串,但是字符串并不具备任何响应式的属性;因此上述的报错信息提示了,可以传入一个getter函数、ref值、reactive对象或者以上类型的数组,因此我们可以有以下两种修改方式:
vue
<script setup>
const person = reactive({
name: "cc",
});
// 第一种,直接监听对象
watch(person, (newVal, oldVal) => {
// ...
}
);
// 第二种,通过getter函数包裹
watch(() => person.name, (newVal, oldVal) => {
// ...
}
);
</script>
同样的,我们如果要监听多个属性,也可以传入一个数组:
vue
<script setup>
watch([() => person.name, count], (val) => {
console.log("val", val);
});
</script>
那么,有趣的事情来了,如果我们将情况变得更加复杂一些,person中的属性是多层嵌套的复杂对象:
vue
<script setup>
const person = reactive({
a: {
b: {
c: "22",
},
},
});
watch(
person,
(val) => {
// ...
},
);
setTimeout(() => {
person.a.b.c = "33";
}, 1.5 * 1000);
</script>
如果使用watch监听person中的属性,还是能监听到改变,因为watch会自动对reactive对象开启深度监听;但是用getter函数包裹的嵌套属性,还能吗?
vue
<script setup>
const person = reactive({
a: {
b: {
c: "22",
},
},
});
watch(
() => person.a.b,
(val) => {
// ...
},
);
setTimeout(() => {
person.a.b.c = "33";
}, 1.5 * 1000);
</script>
很遗憾,这样并不能监听到,我们需要对多级的属性手动开启深度监听:
vue
<script setup>
watch(
() => person.a.b,
(val) => {
// ...
},
{
deep: true,
}
);
</script>
watchEffect函数
则是vue3新增的一个api,用于侦听响应式数据源,发送改变后自动重新运行函数;watchEffect可以观察到函数中所有的响应式数据,并且在这些数据发送改变后自动重新运行函数:
vue
<script setup>
const person = reactive({
first: "aa",
last: "bb",
});
watchEffect(() => {
console.log(person.first);
console.log(person.last);
});
setTimeout(() => {
person.first = "bb";
}, 1 * 1000);
setTimeout(() => {
person.last = "cc";
}, 2 * 1000);
</script>
watchEffect监听的任意数据发生变化都会触发函数。
获取组件实例
在有些情况下,我们需要获取元素的dom节点或者子组件的实例对象,比如canvas画图传入dom节点或者调用子组件内部的函数等等,都需要获取节点;在vue2中是通过this.$refs
的方式,vue3中需要通过ref:
vue
<template>
<div ref="myRef"></div>
<my-component ref="myDom"></my-component>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import MyComponent from '@/component/MyComponent';
const myRef = ref(null);
const myDom = ref(null);
onMounted(() => {
// dom元素
console.log(myRef.value);
// 组件节点
myDom.value.reload();
});
</script>
我们发现组件import导入后,在模板中就可以直接使用了,不需要再进行注册;给需要操作的节点绑定ref属性,名称和下面ref定义的保持一致;不过需要注意的是,操作dom元素需要在页面mounted之后。
对于for循环中的多个节点,我们可以将ref属性接收一个函数,函数的参数代表了当前循环的元素,将其存储下来,就可以获取多个节点的列表:
vue
<template>
<div v-for="item in list" :key="item" :ref="setRef"></div>
</template>
<script lang="ts" setup>
import { ref , onMounted } from 'vue';
const list = ref([1,2,3,5,7,8]);
const refList = ref([]);
const setRef = (el) =>{
refList.value.push(el)
}
</script>
Props
对setup的基础用法有了一定了解,我们来看看setup语法糖的更多用法;首先就是父子组件传数据,子组件需要定义props,通过defineProps
指定props的数据类型,主要有三种写法方式:
vue
<script setup>
import { defineProps } from 'vue';
// 第一种
defineProps(['title']);
// 第二种
defineProps({
title: String,
count: Number,
});
// 第三种
defineProps({
title: {
type: String,
default: '',
required: true,
}
}),
</script>
接收到的props可以直接在模板中使用;对于复杂数据类型,比如对象和数组,我们在为其设置默认值的时候,如果只写一个空数组,就会报错:
vue
<script setup>
import { defineProps } from 'vue';
// 报错:
// Type of the default value for 'list' prop must be a function.
defineProps({
list: {
type: Array,
default: [],
},
}),
</script>
正确的方式是通过函数的方式返回:
vue
<script setup>
import { defineProps } from 'vue';
defineProps({
list: {
type: Array,
default: () => [],
},
data: {
type: Object,
default: () => ({}),
},
}),
</script>
对于组合类型的props,可以通过中括号,使用逗号进行分割:
vue
<script setup>
defineProps({
title: {
type: [String, Number],
},
}),
</script>
上面的写法,根据官方的说明,称为运行时声明
,也就是在项目运行时才会校验参数的类型是否正确;而使用了typescrip,可以基于类型声明
,这样我们在IDE中传入参数时,立刻就能进行类型推断和检查:
运行时声明和基于类型声明不可同时使用。
vue
<script setup lang="ts">
interface Props {
title: string;
count?: number;
flag?: boolean;
list?: Array<ListItem>;
obj: ListItem;
}
interface ListItem {
id: number;
name: string;
}
const props = defineProps<Props>();
</script>
使用defineProps进行基于类型声明的缺点就是不能给props提供默认值,这里还需要用到一个withDefaults
函数进行默认赋值:
vue
<script setup lang="ts">
const props = withDefaults(defineProps<Props>(), {
title: "",
count: 0,
flag: false,
list: () => [],
obj: () => ({ id: 0, name: "" }),
});
</script>
每次用到defineProps
,都需要从vue中引入,这样比较麻烦;很多文章中都会说这是一个宏函数,不需要导入,直接使用;所谓的宏函数也叫编译宏函数,是在作用域内没有定义,而在编译过程中自动注入的工具函数;实际项目中eslint会校验失败,我们需要在eslint配置中开启编译宏:
javascript
// .eslintrc.js
module.exports = {
env: {
// 新增以下
"vue/setup-compiler-macros": true,
},
};
修改完后需要重启服务器,这样,下面的defineEmits、defineExpose等函数都可以直接使用。
Emits
defineEmits
函数是一个用于定义组件的自定义事件的API,通常用于子组件中;它接受一个参数,可以是一个数组或对象,用于指定需要定义的自定义事件。
如果传入的是一个数组,数组的每个元素就是一个字符串,表示一个自定义事件的名称:
vue
<script setup>
const emits = defineEmits(["add", "sub"]);
const count = ref(0);
const clickAddBtn = () => {
emits("add", count.value++);
};
const clickSubBtn = () => {
emits("sub", count.value++);
};
</script>
在父组件中我们就可以定义使用@add
和@sub
的回调函数了。而如果我们传入一个对象,对象的键就是自定义事件的名称,值可以是一个函数,用于验证自定义事件的参数类型。
vue
<script setup>
const emits = defineEmits({
customEvent: (res) => {
console.log("事件数据:", res);
return res > 5;
},
});
const clickBtn = () => {
emits("customEvent", Math.floor(Math.random() * 10));
};
</script>
在上面的代码中,我们定义了自定义事件customEvent
;当该事件被触发时,就会调用customEvent后面定义的函数,打印出负载数据,同时,我们可以在customEvent函数中返回一个Boolean类型,对响应数据进行校验,如果返回false,数据校验不通过,会在控制台进行提示:
[Vue warn]: Invalid event arguments: event validation failed for event "customEvent".
defineEmits写法也分为运行时声明和基于类型声明,使用基于类型声明同样需要在函数后面跟上数据类型,使用e声明函数的名称:
vue
<script lang="ts" setup>
const emits = defineEmits<{
(e: "click", data: number, data1: number): void;
(e: "custom"): void;
}>();
const clickBtn = () => {
emits("click", 2, 3);
};
</script>
不过,这样的写法不是很友好,而vue3.3引入了一种更符合人体工程学
的声明方式,写法更加友好:
vue
<script lang="ts" setup>
const emit = defineEmits<{
foo: [id: number]
bar: [name: string, ...rest: any[]]
}>()
</script>
Expose
在vue2中,如果父组件需要调用子组件的方法,直接使用this.$refs.child.getData(),就可以调用;但是在vue3中,子组件默认都不会暴露任何数据和方法,需用通过defineExpose
函数定义后才能拿到:
vue
// Child.vue
<script setup>
const count = ref(2);
const fn = () => {
console.log("1");
};
defineExpose({
fn,
count,
});
</script>
父组件通过上面ref的方式获取组件实例,即可调用子组件暴露的方法;
vue
// Parent.vue
<script lang="ts" setup>
const childRef = ref(null);
onMounted(() => {
childRef.value.fn();
console.log(childRef.value.count);
});
</script>
同样的,defineExpose也支持基于类型声明:
vue
<script lang="ts" setup>
import { Ref } from 'vue';
const count = ref(2);
const fn = () => {
return 2;
};
interface FORM {
id: number;
name: string;
note: string;
}
const form = reactive<FORM>({
id: 0,
name: "",
note: "",
});
defineExpose<{
count: Ref;
form: FORM;
fn: () => number;
}>({
form,
count,
fn,
});
</script>
自定义指令
我们回顾一下,在vue2中,挂载全局指令通过directive
函数,直接挂载到Vue对象:
vue
<script>
Vue.directive('auth', {
// ...
})
</script>
而在vue3中,通过createApp
创建实例,因此通过app.directive
函数进行挂载全局指令:
vue
<script>
app.directive("focus", {
mounted(el: HTMLElement) {
el?.focus();
},
});
</script>
而在setup语法糖中引入自定义指令,我们需要将引入的指令名称定义成v
为前缀的小驼峰形式,引入后不用注册,直接在模板中通过小写的中划线连接使用即可:
vue
<template>
<input type="text" v-input-focus />
</template>
<script lang="ts" setup>
import vInputFocus from "@/directive/focus";
</script>
slots和attrs
Vue中插槽slot是一种特殊的内置标签,它允许父组件向子组件内部插入自定义的html内容,使得父组件可以在不修改子组件的情况下,非常灵活向子组件中动态的添加修改内容;在vue2使用this.$slots
对象来获取插槽,而在setup语法糖中,我们就要用到useSlots
函数。
useSlots
函数可能很多小伙伴比较陌生,大部分场景下我们直接使用<slot />
标签即可;而在一些特殊的渲染场景下,就需要useSlots在JSX中渲染插槽数据;比如一些组件的属性支持JSX代码,我们可以用来渲染一些插槽:
vue
// Child.vue
<script setup>
import { ref, useSlots, } from "vue";
const slots = useSlots();
import { NDataTable } from "naive-ui";
const columns = ref([
{
title: "Action",
key: "action",
render(row) {
return h("div", null, slots.title ? slots.title() : slots.default());
},
},
]);
</script>
我们通过useSlots获取slots
对象,默认会有一个default属性,就是我们的默认插槽;如果我们向子组件中插入其他命名插槽,slots对象会有相应的属性,比如这里我们在父组件使用title插槽,
vue
<template>
<Child>
<p> i am default slot</p>
<template #title>
<p>i am title slot</p>
</template>
</Child>
</template>
打印slots对象查看,我们发现有两个属性:
javascript
Proxy(Object) {
default: (...args) => {...},
title: (...args) => {...}
}
回到上面的案例代码,我们可以判断slots.title
属性是否存在,也就是插槽是否存在,然后通过h函数渲染slots.title()
。
另外一个有些类似的属性就是attrs,可以用来捕获任何我们没有在组件中声明的参数,我们在setup语法糖中也是使用useAttrs
来获取它:
vue
// Parent.vue
<template>
<Child title="ceshi" msg="msg11"></Child>
</template>
<script setup>
import Child from './Child.vue'
</script>
vue
// Child.vue
<script setup>
import { useAttrs } from "vue";
const attrs = useAttrs();
console.log("attrs", attrs.title, attrs.msg);
</script>
如果我们在Child.vue将title定义到props中后,attrs就不会出现title属性。
总结
本文整理总结了setup语法糖的一些用法,主要包括响应式、props、emit、expose和slot,由于篇幅的限制,响应式中还有很多函数,包括isRef、unref、toRaw等这里不再详细介绍;setup语法糖的优势在于能够使得代码更简洁,可读性强,同时可以将复杂的逻辑和状态管理通过组合式函数拆分为小的、可复用模块,使得代码更加模块化。因此在vue3中,掌握并合理的利用setup语法糖可以帮助我们更好的组织和管理代码,提高开发效率。