Vue

Vue3 基础

响应式

Vue2 响应式

实现原理:

  • 对象类型:通过 Object.defineProperty()对属性的读取、修改、拦截
  • 数组类型:通过重写数组原型上的一系列方法实现拦截

存在问题:

  • 新增属性、删除属性,界面不会更新
  • 直接通过下标修改数组,界面不会更新

解决以上问题:

// 给对象新增属性
this.$set(this.person, 'sex', '女')

// 删除对象的name属性
this.$delete(this.person, 'name')

// 修改数组的第一个元素
this.$set(this.arr, 0, 'xxx')

Vue3 响应式

实现原理:

  • 通过 Proxy(代理):拦截对象中任意属性的变化,包括属性的读写、添加、删除等
  • 通过 Reflect(反射):对被代理对象的属性进行操作

组件传值

组件上的 v-model 默认对应 modelValue prop 和 update:modelValue 事件。子组件可以使用 defineModel()(Vue 3.4+),也可以显式声明 prop 和事件。

父组件:

<template>
  <Child v-model="isShow" />
</template>

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

const isShow = ref(true);
</script>

子组件:

<template>
  <button @click="submit">{{ modelValue ? '关闭' : '打开' }}</button>
</template>

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

const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>();

const submit = () => {
  emit('update:modelValue', !props.modelValue);
};
</script>

ref 与 reactive

数据定义

  • ref 适合基本类型,也适合需要整体替换的对象或数组;通过 .value 的读取和赋值追踪变化。
  • reactive 使用 Proxy 包装对象或数组,适合直接修改其属性或元素。
  • ref 包装对象时,内部值默认会被转换为深层响应式对象。

使用

  • 在脚本中读写 ref 使用 .value;在模板中通常会自动解包。
  • 模板引用可以获取 DOM 元素,需在组件挂载后读取。
<script setup lang="ts">
import { onMounted, ref } from 'vue';

const myRef = ref<HTMLElement | null>(null);

onMounted(() => {
  console.log(myRef.value);
});
</script>

<template>
  <div ref="myRef">获取单个 DOM 元素</div>
</template>

reactive 存在的问题

整体替换 reactive 变量会断开原代理的引用;数组的 concat() 会返回新数组,不会修改原数组。
import { reactive, ref } from 'vue';

const items = reactive<number[]>([]);
const response = [2, 3, 4, 5];

items.splice(0, items.length, ...response); // 修改原代理,保持响应式

const replaceableItems = ref<number[]>([]);
replaceableItems.value = response; // 需要整体替换时使用 ref

reactive 返回的是代理对象。若把变量重新指向普通数组,已有的代理引用不会跟着变化;用 const 声明时重新赋值还会直接报错。需要替换整个数组时,使用 ref 的 .value;需要保留代理时,使用 splice() 等原地修改方法。两者通常都可以用 const 声明。

computed

import { computed, reactive } from 'vue';

const person = reactive({ firstName: 'Li', lastName: 'Li' });

// 简写
const fullName = computed(() => {
  return person.firstName + " " + person.lastName;
});

需要修改计算属性时,使用带 get 和 set 的写法(延续上例的 person):

const fullName = computed({
  get() {
    return person.firstName + " " + person.lastName;
  },
  set(val) {
    let names = val.split(" ");
    person.firstName = names[0];
    person.lastName = names[1];
  }
});

watch

直接监视 reactive 对象时会隐式深度监听。由内部属性变更触发时,newValue 与 oldValue 指向同一个对象,不能用它们比较变更前后的快照;immediate 首次回调的旧值为 undefined。
import { reactive, ref, watch } from 'vue';

const sum = ref(0);
const msg = ref('');
const person = reactive({ name: 'Li', job: { title: '前端工程师' } });

watch(sum, (newValue, oldValue) => console.log(newValue, oldValue));
watch([sum, msg], ([newSum, newMsg]) => console.log(newSum, newMsg));

watch(person, (newValue, oldValue) => {
  console.log(newValue, oldValue); // 内部属性变化时,两者是同一个代理对象
});

watch(() => person.job.title, (newTitle, oldTitle) => {
  console.log(newTitle, oldTitle);
});

watch(() => person.job, (newJob) => {
  console.log(newJob);
}, { deep: true }); // 需要追踪 job 内部属性时显式开启

watchEffect

watchEffect 会立即执行,并自动追踪回调运行时读取的响应式属性;它不会无条件深度监听整个对象。

不需要预先列出监听源,回调中实际读取的属性会成为依赖。

watchEffect(() => {
  console.log(sum.value, msg.value);
});

watchEffect 与 computed 的区别:

  • computed 注重计算出来的值(回调函数的返回值),必须要写返回值
  • watchEffect 注重过程(回调函数的函数体),不需要写返回值

生命周期

选项式 APIHook inside setup
beforeCreateNot needed*
createdNot needed*
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
errorCapturedonErrorCaptured
renderTrackedonRenderTracked
renderTriggeredonRenderTriggered
activatedonActivated
deactivatedonDeactivated
export default {
  setup() {
    // mounted
    onMounted(() => {
      console.log("Component is mounted!");
    });
  }
};

异步组件 Suspense

在大型应用中,我们可能需要将应用分割成小一些的代码块,并且只在需要的时候才从服务器加载一个模块。为了实现这个效果,Vue 有一个 defineAsyncComponent 方法。

<template>
  <div>
    {{ list }}
  </div>
</template>

<script setup lang="ts">
// 模拟http请求
const httpRequest = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const arr = [1, 2, 3, 4];
      resolve(arr);
    }, 3000);
  });
};

// 在setup最外层使用await不需要用async
const list = await httpRequest();
</script>
<template>
  <div class="bg-slate-50 h-5">
    <!-- 使用Suspense包裹异步组件,组件A加载完才会显示 -->
    <Suspense>
      <!-- 组件加载成功后显示 -->
      <template #default> <A></A></template>

      <!-- 在A组件加载中时显示 -->
      <template #fallback>
        <div>loading...</div>
      </template>
    </Suspense>
  </div>
</template>

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

// 将组件A以异步组件的方式导入
const A = defineAsyncComponent(() => import("./components/A.vue"));
</script>

传送组件 Teleport

to 可以是 CSS 选择器或 DOM 元素。目标元素必须在 Teleport 挂载时已经存在;h1 这类选择器只要能匹配到元素,也是有效的。

<!-- 将内容移动到 id 为 popup 的元素下 -->
<Teleport to="#popup">
  <video src="./my-movie.mp4" controls></video>
</Teleport>

keep-alive 缓存组件

组件首次挂载时仍会调用 mounted。被 <KeepAlive> 缓存后,在显示与隐藏之间切换会触发 activated 和 deactivated,而不会每次都重新挂载或卸载。

主要用于保留组件状态或避免重新渲染。

<!-- 基本 -->
<keep-alive>
  <component :is="view"></component>
</keep-alive>

<!-- 多个条件判断的子组件 -->
<keep-alive>
  <comp-a v-if="a > 1"></comp-a>
  <comp-b v-else></comp-b>
</keep-alive>

<!-- 和 `<transition>` 一起使用 -->
<transition>
  <keep-alive>
    <component :is="view"></component>
  </keep-alive>
</transition>

transition 过渡组件

  • 属性
    • enter-from-class 进入过渡的开始状态的类名
    • enter-to-class 进入过渡的结束状态的类名
    • enter-active-class 进入过渡生效时的状态的类名
    • leave-from-class 离开过渡的开始状态的类名
    • leave-active-class 离开过渡生效时的状态的类名
    • leave-to-class 离开过渡的结束状态的类名
  • 搭配 animate.css 使用 主要使用 enter-active-class leave-active-class
<template>
  <button @click="flag = !flag">切换按钮</button>

  <transition
    enter-active-class="animate__animated animate__rubberBand"
    leave-active-class="animate__animated animate__rubberBand"
  >
    <div class="w-20 h-20 bg-red-200" v-show="flag"></div>
  </transition>
</template>

transition-group 列表过渡

用法与 transition 基本一致

<transition-group> 提供了多个元素/组件的过渡效果。默认情况下,它不会渲染一个 DOM 元素包裹器,但是可以通过 tag attribute 来定义。

注意,每个 <transition-group> 的子节点必须有独立的 key,动画才能正常工作。

当一个子节点被更新,从屏幕上的位置发生变化,这个子节点就会产生动画效果。

<transition-group
  tag="ul"
  enter-active-class="animate__animated animate__rubberBand"
  leave-active-class="animate__animated animate__rubberBand"
>
  <li v-for="item in items" :key="item.id">
    {{ item.text }}
  </li>
</transition-group>

Provide&Inject

父组件有一个 provide 选项来提供数据,子组件有一个 inject 选项来开始使用这些数据。

祖先组件中:

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

let num = ref<number>(1);
// 为xxx提供一个响应式数据
provide("xxx", num);
</script>

孙组件中:

<script setup lang="ts">
import { inject, type Ref } from "vue";

// 将数据xxx注入,并赋值给data
const data = inject("xxx");

// 点击孙组件的按钮,改变祖先组件的数据
const add = () => {
  (data as Ref<number>).value++;
};
</script>

全局函数&变量

由于 Vue3 没有Prototype 属性 使用 app.config.globalProperties 代替 然后去定义变量和函数

// Vue 2
Vue.prototype.$http = () => {};

// Vue3
const app = createApp({});
app.config.globalProperties.$http = () => {};

举个例子: main.ts 中定义

// ts中需要类型声明
declare module "@vue/runtime-core" {
  export interface ComponentCustomProperties {
    $hello: () => string;
  }
}

// 挂载方法
app.config.globalProperties.$hello = () => {
  return "hello";
};

使用全局方法:

<template>
  <div>{{ $hello() }}</div>
</template>

<script lang="ts" setup>
import { getCurrentInstance, ComponentInternalInstance } from "vue";

//getCurrentInstance获取的是当前vue实例
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
console.log(proxy?.$hello());
</script>

单文件组件 <script setup>

<script setup> 是在单文件组件 (SFC) 中使用组合式 API 的编译时语法糖。相比于普通的 <script> 语法,它具有更多优势:

  • 更少的样板内容,更简洁的代码。
  • 能够使用纯 Typescript 声明 props 和抛出事件。
  • 更好的运行时性能 (其模板会被编译成与其同一作用域的渲染函数,没有任何的中间代理)。
  • 更好的 IDE 类型推断性能 (减少语言服务器从代码中抽离类型的工作)。

基本语法

要使用这个语法,需要将 setup attribute 添加到 <script> 代码块上:

<script setup>
console.log("hello script setup");
</script>

<script setup> 中的代码会在每次组件实例被创建的时候执行。

顶层的绑定会被暴露给模板

任何在 <script setup> 声明的顶层的绑定 (包括变量,函数声明,以及 import 引入的内容) 都能在模板中直接使用:

<script setup>
// 变量
const msg = "Hello!";

// 函数
function log() {
  console.log(msg);
}
</script>

<template>
  <div @click="log">{{ msg }}</div>
</template>

import 导入的内容也会以同样的方式暴露。意味着可以在模板表达式中直接使用导入的 helper 函数,并不需要通过 methods 选项来暴露它:

<script setup>
import { capitalize } from "./helpers";
</script>

<template>
  <div>{{ capitalize("hello") }}</div>
</template>

响应式

响应式状态需要明确使用响应式APIs来创建。和从 setup() 函数中返回值一样,ref 值在模板中使用的时候会自动解包:

<script setup>
import { ref } from "vue";

const count = ref(0);
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

使用组件

<script setup> 范围里的值也能被直接作为自定义组件的标签名使用:

<script setup>
import MyComponent from "./MyComponent.vue";
</script>

<template>
  <MyComponent />
</template>

将 MyComponent 看做被一个变量所引用。如果你使用过 JSX,在这里的使用它的心智模型是一样的。其 kebab-case 格式的 <my-component>同样能在模板中使用。不过,我们强烈建议使用 PascalCase 格式以保持一致性。同时也有助于区分原生的自定义元素。

动态组件

由于组件被引用为变量而不是作为字符串键来注册的,在 <script setup> 中要使用动态组件的时候,就应该使用动态的 :is 来绑定:

<script setup>
import Foo from "./Foo.vue";
import Bar from "./Bar.vue";
</script>

<template>
  <component :is="Foo" />
  <component :is="someCondition ? Foo : Bar" />
</template>

请注意组件是如何在三元表达式中被当做变量使用的。

递归组件

一个单文件组件可以通过它的文件名被其自己所引用。例如:名为 FooBar.vue 的组件可以在其模板中用 <FooBar/> 引用它自己。

请注意这种方式相比于 import 导入的组件优先级更低。如果有命名的 import 导入和组件的推断名冲突了,可以使用 import 别名导入:

import { FooBar as FooBarChild } from "./components";

命名空间组件

可以使用带点的组件标记,例如 <Foo.Bar> 来引用嵌套在对象属性中的组件。这在需要从单个文件中导入多个组件的时候非常有用:

<script setup>
import * as Form from "./form-components";
</script>

<template>
  <Form.Input>
    <Form.Label>label</Form.Label>
  </Form.Input>
</template>

自定义指令

全局注册的自定义指令将以符合预期的方式工作,且本地注册的指令可以直接在模板中使用,就像上文所提及的组件一样。

本地自定义指令需要以 vNameOfDirective 的形式命名,才能直接在模板中使用。

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

const vBackground: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    el.style.background = binding.value;
  },
  updated(el, binding) {
    el.style.background = binding.value;
  }
};
</script>

<template>
  <div v-background="'red'">示例</div>
</template>
<script setup lang="ts">
import type { Directive } from 'vue';

// 函数简写会在 mounted 和 updated 时调用
const vBackground: Directive<HTMLElement, string> = (el, binding) => {
  el.style.background = binding.value;
};
</script>

defineProps 和 defineEmits

在 <script setup> 中必须使用 defineProps 和 defineEmits API 来声明 props 和 emits ,它们具备完整的类型推断并且在 <script setup> 中是直接可用的:

<script setup>
const props = defineProps({
  foo: String
});

const emit = defineEmits(["change", "delete"]);
// setup code
</script>
  • defineProps 和 defineEmits 都是只在 <script setup> 中才能使用的编译器宏。他们不需要导入且会随着 <script setup> 处理过程一同被编译掉。
  • defineProps 接收与 props 选项相同的值,defineEmits 也接收 emits 选项相同的值。
  • defineProps 和 defineEmits 在选项传入后,会提供恰当的类型推断。
  • 传入到 defineProps 和 defineEmits 的选项会从 setup 中提升到模块的范围。因此,传入的选项不能引用在 setup 范围中声明的局部变量。这样做会引起编译错误。但是,它可以引用导入的绑定,因为它们也在模块范围内。

如果使用 TypeScript,也可以通过类型声明 props 和 emits。

defineExpose

使用 <script setup> 的组件是默认关闭的,也即通过模板 ref 或者 $parent 链获取到的组件的公开实例,不会暴露任何在 <script setup> 中声明的绑定。

为了在 <script setup> 组件中明确要暴露出去的属性,使用 defineExpose 编译器宏:

<script setup>
import { ref } from "vue";

const a = 1;
const b = ref(2);

defineExpose({
  a,
  b
});
</script>

当父组件通过模板 ref 的方式获取到当前组件的实例,获取到的实例会像这样 { a: number, b: number } (ref 会和在普通实例中一样被自动解包)

useSlots 和 useAttrs

在 <script setup> 使用 slots 和 attrs 的情况应该是很罕见的,因为可以在模板中通过 $slots 和 $attrs 来访问它们。在你的确需要使用它们的罕见场景中,可以分别用 useSlots 和 useAttrs 两个辅助函数:

<script setup>
import { useSlots, useAttrs } from "vue";

const slots = useSlots();
const attrs = useAttrs();
</script>

useSlots 和 useAttrs 是真实的运行时函数,它会返回与 setupContext.slots和 setupContext.attrs 等价的值,同样也能在普通的组合式 API 中使用。

与普通的 <script> 一起使用

<script setup> 可以和普通的 <script> 一起使用。普通的 <script> 在有这些需要的情况下或许会被使用到:

  • 无法在 <script setup> 声明的选项,例如 inheritAttrs 或通过插件启用的自定义的选项。
  • 声明命名导出。
  • 运行副作用或者创建只需要执行一次的对象。
<script>
// 普通 <script>, 在模块范围下执行(只执行一次)
runSideEffectOnce();

// 声明额外的选项
export default {
  inheritAttrs: false,
  customOptions: {}
};
</script>

<script setup>
// 在 setup() 作用域中执行 (对每个实例皆如此)
</script>
WARNING 该场景下不支持使用 render 函数。请使用一个普通的 <script> 结合 setup 选项来代替。

顶层 await

<script setup> 中可以使用顶层 await。结果代码会被编译成 async setup():

<script setup>
const post = await fetch(`/api/post/1`).then((r) => r.json());
</script>

另外,await 的表达式会自动编译成在 await 之后保留当前组件实例上下文的格式。

包含顶层 await 的组件会变成 async setup(),需要由 <Suspense> 包裹。<Suspense> 仍属于实验性特性,升级 Vue 时应核对其行为。

仅限 TypeScript 的功能

仅限类型的 props/emit 声明

props 和 emits 都可以使用传递字面量类型的纯类型语法做为参数给 defineProps 和 defineEmits 来声明:

const props = defineProps<{
  foo: string;
  bar?: number;
}>();

const emit = defineEmits<{
  (e: "change", id: number): void;
  (e: "update", value: string): void;
}>();
  • defineProps 或 defineEmits 只能是要么使用运行时声明,要么使用类型声明。同时使用两种声明方式会导致编译报错。
  • 使用类型声明的时候,静态分析会自动生成等效的运行时声明,以消除双重声明的需要并仍然确保正确的运行时行为。
    • 在开发环境下,编译器会从类型推断运行时校验。例如 foo: string 会推断为 foo: String;无法静态分析的复杂类型会退化为 null。
    • 在生产模式下,编译器会生成数组格式的声明来减少打包体积 (这里的 props 会被编译成 ['foo', 'bar'])。
    • 生成的代码仍然是有着类型的 Typescript 代码,它会在后续的流程中被其它工具处理。
  • Vue 3.3 起支持在 defineProps 中引用导入的类型,但基于 AST 的转换仍无法处理某些需要完整类型分析的复杂类型表达式。

使用类型声明时的默认 props 值

仅限类型的 defineProps 声明的不足之处在于,它没有可以给 props 提供默认值的方式。为了解决这个问题,提供了 withDefaults 编译器宏:

interface Props {
  msg?: string;
  labels?: string[];
}

const props = withDefaults(defineProps<Props>(), {
  msg: "hello",
  labels: () => ["one", "two"]
});

上面代码会被编译为等价的运行时 props 的 default 选项。此外,withDefaults 辅助函数提供了对默认值的类型检查,并确保返回的 props 的类型删除了已声明默认值的属性的可选标志。