在过去的一年新项目开发中,我都采用了 Vue 3+TypeScript 的技术栈。从去年刚了解Vue3和TypsScript 时的懵懵懂懂;到开发时使用时的别扭,随时想转过头去写Vue2;再到后来熟悉和适应它们,而到现在我已经离不开它们了。回头看这一年来的使用经历,我觉得有必要沉淀一下,因为在这当中有很多的疑惑是一直困扰我的,相信大家也会有类似的问题。
下面是内容会结合官方文档进行展开,对于 Vue 3的一些原理性的东西这里不会去做分析。
前排提示:内容很干,请备好茶水,读完一定会有收获。
目标人群:使用Vue3 、TypeScript开发过项目的开发者。
响应式 API⭐️
在 Vue 3 的 响应式 API 中有两对 API,它们的功能相近,看起来能取代对方。而对于它们的选择,每个开发者可能都会有自己的想法。
ref 还是 reactive?
响应式基础是捕获值的改变,Vue 3 使用了 Proxy 实现了值的代理,使值的读写能够进行被监听,进而触发副作用。 Vue 3基于这个原理,提供了两个 API 来获取响应式对象,那么它们有什么区别,而我们应该选择哪个呢?
javascript
// 类型
function ref<T>(value: T): Ref<UnwrapRef<T>>
interface Ref<T> {
value: T
}
// 示例
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
// 类型
function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
// 示例
const obj = reactive({ count: 0 })
obj.count++
这两个API 相同点就是功能相同,都能将值转换为一个可修改的响应式对象。ref接受任意的一个值,返回的响应式对象需要通过 .value访问;reactive一个接受一个对象,返回的响应式对象能够直接访问。它们的主要区别有两点:
-
入参类型不同。
-
返回的响应式对象使用方式不同,ref 返回的响应式对象使用较为繁琐。
-
ref能作为组件引用。
就第一点区别来说,ref的能力更加强大,非对象类型的数据只能用 ref,而第二点区别来说, reactive 的返回值使用起来更加方便。
如果让我来选择的话,我会选择只用 ref 。 对我而言, ref 需要通过 value 属性访问到对象反而是优点,它会明确的告诉我,当前正在用的对象是一个响应式对象而不是普通对象,而且computed属性也是徐彤通过value属性进行访问的,使用 ref 行为更为统一。更何况 ref 的适用面更广。(至于说会忘记 使用 value属性来访问,麻烦开启TS检查。)
Vue 3 实验提供响应式语法糖,这样由编译器来为ref对象的访问添加 .value 。不过现在已经废弃了,原因大家猜! |
---|
watch 还是 watchEffect?
Vue 3提供了一个新的API -- watchEffect ,用来进行丰富依赖追踪。那么这个新的API 它和watch有什么区别呢?什么时候用watch什么时候用watchEffect呢?
watch接受一个数据源和回调函数,数据源发生变化会触发回调函数;而 watchEffect 接受一个回调函数,不需要指定数据源,自动并收集回调函数里的所有依赖,同时创建监听器会立即执行一次回调。所以它们的区别主要有两点:
-
依赖收集方式不同。
-
回调是否会立即执行。
watch的功能比 watchEffect更加强大:
• 懒执行副作用;
• 更加明确是应该由哪个状态触发侦听器重新执行;
• 可以访问所侦听状态的前一个值和当前值。
js
HTML
<template>
{{ refValue }}
</template>
<script setup lang="ts">
import { ref, watch, watchEffect } from "vue";
const refValue = ref(0);
watch(refValue, (value) => {
console.log("watch", value);
});
watchEffect(async () => {
console.log("watchEffect", refValue.value);
});
refValue.value = 1;
</script>
上述代码会输出:
Bash watchEffect 0 watch 1 watchEffect 1 |
---|
watchEffect 的依赖关系可能不会那么明确。我们需要通读代码,通过检查有没有读取响应式数据的值,判断它是否会被监听。例如,下面这段代码,refValue1会被当做依赖收集,而refValue2不是回调函数的依赖。
js
<template>
{{ refValue1 }}
</template>
<script setup lang="ts">
import { nextTick, ref, watchEffect } from "vue";
const refValue1 = ref(1);
const refValue2 = ref(0);
watchEffect(() => {
refValue2.value = refValue1.value * 2;
refvalue2.value = obj.a
});
</script>
虽然合理的使用watchEffect,能够让代码更加简洁。但是 watchEffect 依赖关系是根据代码推断出来的,对于Reviewer 需要读完整段代码才能确定完整的依赖关系,所以不太建议使用 watchEffect。
函数的两种声明模式⭐️
通过函数声明可以限制函数的入参类型,确定函数的返回类型。 Vue 3的API都提供了两种声明方式:运行时声明或是类型声明,对应的,我们会有两种用法。
运行时声明:函数从参数推到出它的类型。
类型声明:泛型,在函数定义是传入一个类型作为返回类型
可以这样理解 运行时声明,函数根据传入的参数类型推断返回值类型;而类型声明,则由开发者指定泛型,编译器会尽可能地尝试根据类型参数推导出等价的运行时选项。两者在运行效率上没有优劣之分。更多是写法和智能提示的区别,运行时声明不需要指定类型,写法接近JavaScript,能够提供有限的智能提示;类型声明语义化更好、表达更明确,而且编辑器能提供更加完整的智能提示,所以建议使用类型声明。
下面将通过 defineProps 和defineEmits 两个API来介绍这两种类型声明使用的区别。
defineProps
js
<script setup lang="ts">
// 运行时声明 1
const props = defineProps({
foo: { type: String, required: true },
bar: Number
})
// 运行时声明 2
const props = defineProps({
foo: '',
bar: 0
})
// 运行不声明 1
const props = defineProps(['foo', 'bar'])
// 类型声明
const props = defineProps<{
foo: string
bar?: number
}>()
</script>
defineProps的用法可以说是非常多了,足足支持有四种完全不一样的写法。其中 运行不声明 1 的写法是完全获取不到类型提示的,所以非常不推荐。运行时声明 2 的写法可能能满足大部分的情景,它能根据传入值的实参类型推断出期望的类型,但是不能做复杂的类型判断。
运行时声明 1 写法是上一种写法的进阶版,它需要开发者提供一个校验选项。这种写法能够提供完整的类型校验,并提供智能提示。
但是有 TypeScript 这样的类型系统为什么不用呢?与 运行时声明 1 相比 类型声明 的写法符合 TypeScript 对类型的定义,没有学习成本,属性的定义也可以抽取成 interfece 进行复用。
综上,对于 defineProps ,推荐使用 类型声明 的写法。
defineProps的类型写法的唯一缺点就是函数不能直接设置默认值,需要借助withDefatult 编译器宏来设置默认值。Vue 也在尝试用新的语法糖来简化写法(响应式语法糖)。
js
interface Props {
msg?: string
labels?: string[]
}
const props = withDefaults(defineProps<Props>(), {
msg: 'hello',
labels: () => ['one', 'two']
})
事实上defineProps类型声明最终会被编译成defineProps运行时声明 1。 |
---|
defineEmits
在 <script setup>
中,defineEmits API可以有两种写法。
js
<script setup lang="ts">
// 运行时
const emits = defineEmits(['change', 'update'])
// 基于类型
const emits = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
</script>
defineEmits 比起defineProps就写法就少很多了。结论也显而易见。
运行时写法,入参是事件名,这样定义出来的emits不可能得到提示。而基于类型的写法,需要指明调用时的参数类型,这样在父组件接受事件时,就有足够的提示来推约束处理函数。
所以,推荐使用 类型声明 的写法。
js
<template>
<li>
<span>{{ todoItem.id }}</span>
<span>{{ todoItem.message }}</span>
<span>{{ todoItem.status }}</span>
<button @click="emits('itemDone', todoItem.id)" v-if="todoItem.status === 'pending'" >
done
</button>
</li>
</template>
<script setup lang="ts">
defineProps<{
todoItem: {
id: number;
status: string;
message: string;
};
}>();
const emits = defineEmits<{
(e: "itemDone", id: number): void;
}>();
</script>
其他
Vue中几乎所有API都支持两种声明模式,并不是所有的API都应该使用类型声明的方式。运行时声明能够简化我们的代码,如果类型可以通过入参明确确定,指定泛型反而是画蛇添足,所以这就需要去分析到时什么时候该用类型声明。
举个例子, toRef这个API,它接受一个reactive 对象和它的一个属性的key,返回类型就是该属性的类型。在这个API中,返回的参数就很明确,且就是和入参强相关,这种情况用运行时声明就没什么问题。
总结了一下,在下列情况下使用类型声明 :
-
入参的类型不定,需要约束的。例如 ref ,可以传任意类型作为参数,而当希望它只为某种类型时,就可以提供一个类型来声明。
-
需要类型提示的。例如 defineEmits ,使用类型声明能够获得类型提示。
js
interface Props {
msg?: string;
labels?: string[];
}
const props = withDefaults(defineProps<Props>(), {
msg: "hello",
labels: () => ["one", "two"],
});
const emits = defineEmits<{
(e: "change", id: number): void;
(e: "update", value: string): void;
}>();
const number = ref<number>(0);
const numberPlusOne = computed<number>(() => {
return number.value + 1;
});
interface State {
foo: number;
bar: number;
}
const state = reactive<State>({
foo: 1,
bar: 2,
});
const fooRef = toRef(state, "foo");
const fooRefs = toRefs(state);
defineExponse({
number
})
<script setup>
在script块中,我们通过指定setup参数,Vue编译时会自动把这个块编译成setup函数,并将里面定义的函数和属性暴露给模版,不需要手动写返回。
它与setup函数相比,具有更多优势:
• 更少的样板内容,更简洁的代码。
• 能够使用纯 TypeScript 声明 props 和自定义事件。
• 更好的运行时性能 (其模板会被编译成同一作用域内的渲染函数,避免了渲染上下文代理对象)。
• 更好的 IDE 类型推导性能 (减少了语言服务器从代码中抽取类型的工作)。
所以,选择<script setup>
是毋庸置疑的。
如果我们的一个组件需要在一个异步操作得到响应后才能进行渲染,那我们应该怎么写?
js
<template>
<span v-if="preLoaded"> suspense: {{ showValue }} </span>
</template>
<script setup lang="ts">
import { ref } from "vue";
const showValue = ref<number>();
const preLoaded = ref(false);
async function asyncFun() {
await new Promise((resolve: (res: number) => void) => {
console.log("async start", Date.now());
setTimeout(resolve, 2000, Math.random());
}).then((res) => {
preLoaded.value = true;
showValue.value = res;
console.log("async res" + res, Date.now());
});
}
asyncFun();
console.log("under asyncFun()");
</script>
如果在下面还有逻辑是依赖于某个异步的结果的,那么我们就得继续在这个async函数中加代码。而如果直接在setup函数里写await,页面是不会渲染的。
能否直接让setup函数就支持await呢?Vue 3提供了suspense抽象组件:
js
<template>
<span v-if="preLoaded"> suspense: {{ showValue }} </span>
</template>
<script setup lang="ts">
...
await asyncFun();
...
</script>
<script setup lang="ts">
import SuspenseCompVue from "./components/SuspenseComp.vue";
</script>
<template>
<header>
<suspense>
<suspense-comp-vue />
</suspense>
</header>
</template>
借助 suspense 抽象组件,我们能够让 setup 函数能够阻塞组件的渲染,进行异步操作。
在vue2的选项式模式中,可以给自定义组件设置一个name属性,它在以下情况下会被使用到:
-
组件模板递归地调用自身。(写递归组件时)
-
便于调试。(vue-devtools)
-
keep-alive时,include和exclude 是通过name来匹配的。
Vue 3 的 <setup script>
可以有个同级的 <scrpit>
,在这里可以像vue2 一样导出一个对象,它最终会和 <setup script>
返回做合并。
js
<script>
// 普通 <script>, 在模块作用域下执行 (仅一次)
runSideEffectOnce()
// 声明额外的选项
export default {
name: 'myName',
customOptions: {}
}
</script>
<script setup>
/* eslint-disable import/first */
// 在 setup() 作用域中执行 (对每个实例皆如此)
</script>
inheritAttrs属性用于控制是否启用默认的组件 attribute 透传行为,默认开启。也可以通过这样的方式来进行关闭inheritAttrs的行为。
js
<script>
export default {
inheritAttrs: false
}
</script>
<script setup>
</script>
自定义组件
defineExponse
我们在ref调用子组件的方法时,希望IDE能够提示组件导出的方法有哪些入参以及返回值是什么,可以这样写。
js
// app.vue
<script setup lang="ts">
import { ref } from "vue";
import InputValidateVue from "./components/InputValidate.vue";
const inputValidateRef = ref<InstanceType<typeof InputValidateVue> | null>(null);
</script>
<template>
<InputValidateVue ref="inputValidateRef" />
</template>
js
// inputvalidate.vue
<template>
<input type="text" v-model="inputStr" />
</template>
<script setup lang="ts">
import { ref } from "vue";
const inputStr = ref("");
function validate() {
const inputStrVal = +inputStr.value;
if (Number.isInteger(inputStrVal)) {
return true;
}
return false;
}
function getInputVal() {
return inputStr.value;
}
defineExpose({
validate,
getInputVal,
});
</script>
在父组件中定义给子组件的 ref 时,指定 InstanceType<typeof InputValidateVue> | null
这样的一个类型,编辑器便能提示 子组件实例呢内置指令和通过 defineExponse 导出的属性和方法。
InstanceType 是 TypeScript提供的一个预设类型,它会返回指定的类的实例类型。而在setup里定义ref 时,组件是还没挂载的,所以还需要指定一个 联合类型 null。www.typescriptlang.org/docs/handbo... |
---|
v-model 的使用
v-model是vue提供的一个语法糖,它既能作用于普通表单元素上,又可以作用在组件上。
Vue 2和Vue 3在组件上的v-model是很大区别的:
• 破坏性更新:在自定义组件上使用v-model 时,默认绑定的属性名和事件名变了。
○ prop: value -> modelValue
○ event: input -> update:modelValue
• 破坏性更新:v-bind的 .sync写法和组件的model选项改为用v-model
• 新特性: 同个组件可以绑定多个 v-model
• 新特性: 支持自定义修饰符
可以通过例子看他们的区别。
• 自定义组件上使用v-model
js
<!-- vue2 -->
<ChildComponent v-model="pageTitle" />
<!-- === -->
<ChildComponent :value="pageTitle" @input="pageTitle = $event" />
<!-- Vue 3 -->
<ChildComponent v-model="pageTitle" />
<!-- would be shorthand for: -->
<ChildComponent
:modelValue="pageTitle"
@update:modelValue="pageTitle = $event"
/>
• v-bind的.sync 写法和 组件的model选项改为用v-model
js
<!-- vue2 -->
<ChildComponent :title.sync="pageTitle" />
<!-- Vue 3 -->
<ChildComponent v-model:title="pageTitle" />
• 同个组件可以绑定多个 v-model
• 支持自定义修饰符
父组件会将自定义的修饰符通过名为 arg + "Modifiers"属性传到子组件,子组件能够判断属性中是否包含自定义修饰符做对应的操作。
js
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) }
})
defineEmits(['update:modelValue'])
console.log(props.modelModifiers) // { capitalize: true }
</script>
<template>
<input
type="text"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
Vue 3把v-bind的.sync 写法删除了,所以现在在vue中进行双向绑定只有v-model一种写法。 |
---|
最佳实践 表单绑定:
有一个表单数据obj,它有 a~d 4个属性,其中 a 属性是不能改变的,b~c属性由 组件A来修改,d属性由组件B来修改,f 属性计算依赖a~c属性。
js
<template>
<ModelAVue v-if="obj" v-model:b="obj.b" v-model:c="obj.c" /> //只对组件能修改的做绑定
<ModelBVue v-if="obj" v-model:d="obj.d" :obj="obj" /> //obj只支持查看
</template>
<script lang="ts" setup>
import ModelAVue from "./ModelA.vue";
import ModelBVue from "./ModelB.vue";
import { ref } from "vue";
const obj = ref<{
a: string;
b: string;
c: string;
d: string;
}>();
</script>
// ModelA.vue
<template>
{{ c }}
</template>
<script lang="ts" setup>
defineProps<{
b: string;
c: string;
}>();
defineEmits<{
(e: "update:b", data: string): void;
(e: "update:c", data: string): void;
}>();
</script>
// ModelB.vue
<template>
{{ d }}
</template>
<script lang="ts" setup>
defineProps<{
obj: {
a: string;
b: string;
c: string;
};
d: string;
}>();
defineEmits<{
(e: "update:d", data: string): void;
}>();
</script>
其他
组合式函数⭐️
Vue 3最显著的特点是,支持组合式(Composition)API。同时,Vue 3也鼓励我们使用组合式函数来抽象一些通用逻辑。
官方文档是这样定义的:"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。需要注意的是,组合式函数和一般的工具函数是有区别的,组合式函数需要带状态,而工具函数是不带状态的。
约定
命名:组合式函数约定用驼峰命名法命名,并以"use"作为开头。
输入参数:没有要求,可以使ref也可以是普通参数。
返回值:一般是包含多个ref的普通非响应式对象
在日常开发中,我们可能需要在 dom元素上添加原生事件监听器,那么,在改dom元素被卸载时,我们需要回收掉,可以写出如下代码:
js
<template>
<div id="specialDiv" ref="specialDiv">Click Me</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue";
const specialDiv = ref<HTMLDivElement>();
function specialDivClickHandler(event: Event) {
console.log("specialDiv been click", event.timeStamp);
}
function specialDivMouseOverHandle(event: Event) {
console.log("specialDiv been mouse over", event.timeStamp);
}
onMounted(() => {
if (specialDiv.value) {
specialDiv.value.addEventListener("click", specialDivClickHandler);
specialDiv.value.addEventListener("mouseover", specialDivMouseOverHandle);
}
});
onUnmounted(() => {
if (specialDiv.value) {
specialDiv.value.removeEventListener("click", specialDivClickHandler);
specialDiv.value.removeEventListener(
"mouseover",
specialDivMouseOverHandle
);
}
});
</script>
每新增一个原生事件监听器,便要多重复写一次添加和卸载逻辑,能否让代码能自动在回收原生监听器呢?
可以通过组合式函数抽象回收的公共逻辑:
js
// useEventListener.ts
import { onUnmounted } from "vue";
export function useEventListener(
target: HTMLDivElement,
type: string,
listener: (event: Event) => void
) {
target.addEventListener(type, listener);
onUnmounted(() => {
target.removeEventListener(type, listener);
});
}
js
<template>
<div id="specialDiv" ref="specialDiv">Click Me</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { useEventListener } from "./useEventListener";
const specialDiv = ref<HTMLDivElement>();
onMounted(() => {
if (specialDiv.value) {
useEventListener(specialDiv.value, "click", function (event: Event) {
console.log("specialDiv been click", event.timeStamp);
});
useEventListener(specialDiv.value, "mouseover", function (event: Event) {
console.log("specialDiv been mouse over", event.timeStamp);
});
}
});
</script>
vueuse ,这个仓库里实现了很多有用的组合式函数,可以学习或者copy到项目中使用。
在开发的过程中,我们应该多思考、多做提取,减少重复编码,提高开发效率。对于不带状态的逻辑,提取为普通函数,带状态的,提取为组合式函数。
多根节点和class⭐️
在Vue 2编码中,我们在写template时会觉得很繁琐,除了用template标签包裹,还得额外定义一个标签来套住所写的内容,否则编译器会报错。
js
<template>
<div class="header"></div>
<div class="body"></div>
<div class="footer"></div>
</template>
warn:
- Component template should contain exactly one root element. If you are using v-if on multiple elements, use v-else-if to chain them instead.
而在 Vue 3的 template 里可以直接写多个标签。
js
<template>
<li v-for="item of list" :key="item">{{ item }}</li>
<button @click="handleClick">Click</button>
</template>
<script lang="ts" setup>
defineProps<{
list: number[];
}>();
function handleClick() {
console.log("clicked");
}
</script>
js
<script setup lang="ts">
import MultiRootVue from "./components/MultiRoot.vue";
</script>
<template>
<header>
<ul>
<MultiRootVue :list="[1, 2, 3, 4]" class="wrapper" />
</div>
</header>
</template>
<style>
.wrapper {
color: red;
}
</style>
还是报了warn,同时 wrapper 的样式也没有生效。这是因为 class 属性是一个 "非props"属性,如果不做特殊处理的话,它会被当成原生属性透传到子组件的唯一根元素上,而 Vue 3 的多根template让编译器不知道该将这些属性挂载在哪个元素上,所以直接就进行告警。
"透传 attribute"指的是传递给一个组件,却没有被该组件声明为 props 或 emits 的 attribute 或者 v-on 事件监听器。最常见的例子就是 class 、 style 和 id 。当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。
所以,我们该如何将这些属性进行显性绑定呢?Vue 3提供了模版变量 $attrs 和 API useAttrs() 帮助我们获到当前所有透传的属性。只要将这些属性显性绑定到能接受这些属性的元素上就能消除警告。
如果绑定的元素是自定义组件,还是不能消费掉这个属性,那么还是会告警。
js
<template>
<li :class="$attrs.class" v-for="item of list" :key="item">{{ item }}</li>
</template>
js
<template>
<li v-bind="$attrs" v-for="item of list" :key="item">{{ item }}</li>
</template>
// **v-bind不指定属性则会解构对象并绑定所有的属性**
我们也可以通过设置inheritAttrs属性来关闭透传行为来消除警告,但是这样无异于掩耳盗铃,因为属性还是被丢弃了。
js
export default {
inheritAttrs: false
}
对于多根节点,我们需要注意透传属性要做进行显性绑定。 有时我们会发现在父组件使用v-show控制子组件的显示隐藏不生效,而 v-if 却能隐藏元素,这就和透传行为相关。
或许Vue未来可以做点优化,多根节点的父节点存在透传属性时,批量设置透传属性。 |
---|
总结
Vue 3相比于Vue 2的变动是非常巨大的。推出组合式API、全面拥抱TypeScript、setup函数,甚至对于相同的问题提供了几套开发选型来选择,像选项式API和组合式API、watch和watchEffect、运行时声明和类型声明;当然还有许多附加的新的 API。
这些改变或颠覆了让开发者看花了眼,也产生了巨大的压力和畏难心理,所以会有人道Vue 2 就已经足够了。曾经我也是这样认为的,但是一眨眼,抬头望去早已变了天。
希望以上总总会对你有帮助。
当然,上述只是我一些浅略的拙见,如有疏漏之处烦请见谅。