Vue 学习笔记(二)

认识计算属性、侦听器、生命周期和组件

#Vue #学习笔记

计算属性

基础用法

计算属性是基于响应式依赖进行缓存的计算结果。当依赖的响应式状态发生变化时,计算属性会重新计算。

import { ref, computed } from 'vue'

const firstName = ref('zhang')
const lastName = ref('san')

// 创建一个计算属性
const fullName = computed(() => {
  return firstName.value + lastName.value
})

console.log(fullName.value) // 输出:zhangsan

// 修改依赖项会触发计算属性重新计算
firstName.value = 'li'
console.log(fullName.value) // 输出:lisan

我们在这里定义了一个计算属性 fullNamecomputed() 方法期望接收一个 getter 函数,返回值为一个 计算属性 ref。和其他一般的 ref 类似,我们可以通过 fullName.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

我们在表达式中像这样调用一个函数也会获得和计算属性相同的结果:

<p>{{ fullName() }}</p>
// 组件中
function fullName() {
  return firstName.value + lastName.value
}

若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 firstNamelastName 不改变,无论多少次访问 fullName 都会立即返回先前的计算结果,而不用重复执行 getter 函数,这可以节省很多不必要的计算。

可写的计算属性

计算属性默认是只读的。当尝试修改一个计算属性时,我们会收到一个运行时警告。只在某些特殊场景中你可能才需要用到“可写”的属性,我们可以通过同时提供 getter 和 setter 来创建:

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
  // getter
  get() {
    return firstName.value + ' ' + lastName.value
  },
  // setter
  set(newValue) {
    // 注意:我们这里使用的是解构赋值语法
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})
</script>

现在当我们再运行 fullName.value = 'John Doe' 时,setter 会被调用而 firstNamelastName 会随之更新。

侦听器

计算属性允许我们声明性地计算衍生值。然而在有些情况下,我们需要在状态变化时执行一些“副作用”:例如更改 DOM,或是根据异步操作的结果去修改另一处的状态,这时候就需要用到侦听器。Vue3 提供了两种侦听器 API: watchwatchEffect

watch()

watch 用于监听一个或多个响应式数据源,在每次响应式数据源发生变化时触发回调函数。

<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('Questions usually contain a question mark. ;-)')
const loading = ref(false)

// 可以直接侦听一个 ref
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.includes('?')) {
    loading.value = true
    answer.value = 'Thinking...'
    try {
      const res = await fetch('https://yesno.wtf/api')
      answer.value = (await res.json()).answer
    } catch (error) {
      answer.value = 'Error! Could not reach the API. ' + error
    } finally {
      loading.value = false
    }
  }
})
</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" :disabled="loading" />
  </p>
  <p>{{ answer }}</p>
</template>

watch 的第一个参数可以是不同形式的“数据源”,它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组。

const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, (newX) => {
  console.log(`x is ${newX}`)
})

// getter 函数
watch(
  () => x.value + y.value,
  (sum) => {
    console.log(`sum of x + y is: ${sum}`)
  }
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x is ${newX} and y is ${newY}`)
})

但是我们不能直接侦听响应式对象的属性值:

const obj = reactive({ count: 0 })

// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
  console.log(`Count is: ${count}`)
})

// 这里需要用一个返回该属性的 getter 函数:
watch(
  () => obj.count,
  (count) => {
    console.log(`Count is: ${count}`)
  }
)

watch 默认是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调,这时我们可以通过传入 immediate: true 选项来强制侦听器的回调立即执行:

watch(
  source,
  (newValue, oldValue) => {
    // 立即执行,且当 `source` 改变时再次执行
  },
  { immediate: true }
)

每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次,我们可以使用 once: true 选项:

watch(
  source,
  (newValue, oldValue) => {
    // 当 `source` 变化时,仅触发一次
  },
  { once: true }
)

watchEffect()

watchEffect() 允许我们自动跟踪回调的响应式依赖,在依赖变化时会重新执行。

import { ref, watchEffect } from 'vue'

const count = ref(0)
const message = ref('Hello')

watchEffect(() => {
  console.log(`Count: ${count.value}, Message: ${message.value}`)
})
// 立即输出:Count: 0, Message: Hello

// 修改依赖会触发 watchEffect 回调
count.value++
// 输出:Count: 1, Message: Hello

message.value = 'World'
// 输出:Count: 1, Message: World

这个例子中,回调会立即执行,不需要指定 immediate: true。在执行期间,它会自动追踪 count.valuemessage.value 作为依赖(和计算属性类似)。每当这两个值变化时,回调会再次执行。有了 watchEffect(),我们不再需要明确传递 count.valuemessage.value 作为源值。

停止侦听器

setup()<script setup> 中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,我们无需关心怎么停止一个侦听器。

一个关键点是,侦听器必须用同步语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,必须手动停止它,以防内存泄漏。如下方这个例子:

<script setup>
import { watchEffect } from 'vue'

// 它会自动停止
watchEffect(() => {})

// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

要手动停止一个侦听器,我们可以调用 watchwatchEffect 返回的函数:

const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()

生命周期钩子

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。下面是一些常用的生命周期钩子。

onMounted()

注册一个回调函数,在组件挂载完成后执行,即在组件完成初始渲染并创建 DOM 节点后运行代码。

<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  console.log('组件已挂载')
})
</script>

当调用 onMounted 时,Vue 会自动将回调函数注册到当前正被初始化的组件实例上。这意味着这些钩子应当在组件初始化时被同步注册。下面是错误做法:

setTimeout(() => {
  onMounted(() => {
    // 异步注册时当前组件实例已丢失
    // 这将不会正常工作
  })
}, 100)

onUpdated()

注册一个回调函数,在组件因为响应式状态变更而更新其 DOM 树之后调用。父组件的更新钩子将在其子组件的更新钩子之后调用。

这个钩子会在组件的任意 DOM 更新后被调用,这些更新可能是由不同的状态变更导致的,因为多个状态变更可以在同一个渲染周期中批量执行(考虑到性能因素)。这个钩子在服务器端渲染期间不会被调用。我们不应该在 updated 钩子中更改组件的状态,这可能会导致无限的更新循环。

<script setup>
import { ref, onUpdated } from 'vue'

const count = ref(0)

onUpdated(() => {
  // 文本内容应该与当前的 `count.value` 一致
  console.log(document.getElementById('count').textContent)
})
</script>

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

onUnmounted()

注册一个回调函数,在组件实例被卸载之后调用。可以在这个钩子中手动清理一些副作用,例如计时器、DOM 事件监听器或者与服务器的连接。这个钩子在服务器端渲染期间不会被调用。

<script setup>
import { onMounted, onUnmounted } from 'vue'

let intervalId
onMounted(() => {
  intervalId = setInterval(() => {
    // ...
  })
})

onUnmounted(() => clearInterval(intervalId))
</script>

其他常用钩子

onBeforeMount()

注册一个钩子,在组件被挂载之前被调用。当这个钩子被调用时,组件已经完成了其响应式状态的设置,但还没有创建 DOM 节点。它即将首次执行 DOM 渲染过程。这个钩子在服务器端渲染期间不会被调用。

onBeforeUpdate()

注册一个钩子,在组件即将因为响应式状态变更而更新其 DOM 树之前调用。这个钩子可以用来在 Vue 更新 DOM 之前访问 DOM 状态。在这个钩子中更改状态也是安全的。这个钩子在服务器端渲染期间不会被调用。

onBeforeUnmount()

注册一个钩子,在组件实例被卸载之前调用。当这个钩子被调用时,组件实例依然还保有全部的功能。这个钩子在服务器端渲染期间不会被调用。

组件

组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。在实际应用中,组件常常被组织成一个层层嵌套的树状结构。

将一个大型应用拆分成多个组件,既可以提高组件的复用性,也可以分离逻辑,让各组件专注于其对应的功能,这样能大大提高开发效率和代码的可维护性。

注册及使用组件

一个 Vue 组件在使用前需要先被“注册”,这样 Vue 才能在渲染模板时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。

全局注册

全局注册的组件可以在此应用的任意组件的模板中使用,所有的子组件也可以使用全局注册的组件,这意味着这全局注册的组件也都可以在彼此内部使用。

我们可以使用 Vue 应用实例的 .component() 方法,让组件在当前 Vue 应用中全局可用。

import { createApp } from 'vue'

const app = createApp({})

app.component(
  // 注册的名字
  'MyComponent',
  // 组件的实现
  {
    /* ... */
  }
)

如果使用单文件组件,我们可以注册被导入的 .vue 文件:

import MyComponent from './App.vue'

app.component('MyComponent', MyComponent)

.component() 方法可以被链式调用:

app
  .component('ComponentA', ComponentA)
  .component('ComponentB', ComponentB)
  .component('ComponentC', ComponentC)

局部注册

全局注册虽然很方便,但有以下几个问题:

  1. 全局注册,但并没有被使用的组件无法在生产打包时被自动移除(即“tree-shaking”)。如果全局注册了一个组件,即使它并没有被实际使用,它仍然会出现在打包后的 JS 文件中。
  2. 全局注册在大型项目中使项目的依赖关系变得不那么明确。在父组件中使用子组件时,不太容易定位子组件的实现。和使用过多的全局变量一样,这可能会影响应用长期的可维护性。

相比之下,局部注册的组件需要在使用它的父组件中显式导入,并且只能在该父组件中使用。它的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。

在使用 <script setup> 的单文件组件中,导入的组件可以直接在模板中使用,无需注册:

<script setup>
import ComponentA from './ComponentA.vue'
</script>

<template>
  <ComponentA />
</template>

如果没有使用 <script setup>,则需要使用 components 选项来显式注册:

import ComponentA from './ComponentA.js'

export default {
  components: {
    ComponentA
  },
  setup() {
    // ...
  }
}

Props

Props 的声明

props 是父组件向子组件传递数据的主要方式,它是一种特别的 attributes,我们可以在组件上声明注册。声明注册需要用到 defineProps 宏:

<script setup>
defineProps(['title'])
</script>

<template>
  <h4>{{ title }}</h4>
</template>

defineProps 是一个仅 <script setup> 中可用的编译宏命令,并不需要显式地导入。声明的 props 会自动暴露给模板。defineProps 会返回一个对象,其中包含了可以传递给组件的所有 props:

const props = defineProps(['title'])
console.log(props.title)

如果没有使用 <script setup>,props 必须以 props 选项的方式声明,props 对象会作为 setup() 函数的第一个参数被传入:

export default {
  props: ['title'],
  setup(props) {
    console.log(props.title)
  }
}

除了使用字符串数组来声明 props 外,还可以使用对象的形式:

<!-- ChildComponent.vue -->
<script setup>
const props = defineProps({
  title: String,
  likes: {
    type: Number,
    default: 0
  },
  isPublished: Boolean,
  commentIds: Array,
  author: Object
})

console.log(props.title) // 访问 props
</script>
<template>
  <h1>{{ title }}</h1>
  <p>点赞数: {{ likes }}</p>
</template>
<!-- ParentComponent.vue -->
<script setup>
import ChildComponent from './ChildComponent.vue'
</script>
<template>
  <ChildComponent
    title="Vue3 组件教程"
    :likes="42"
    :is-published="true"
    :comment-ids="[234, 266, 273]"
    :author="{ name: '张三', bio: '前端开发者' }"
  />
</template>

: 的是动态绑定,它可以传入一个动态改变的值,静态绑定只能传入字符串。

Props 校验

Vue 组件可以更细致地声明对传入的 props 的校验要求。比如我们上面已经看到过的类型声明,如果传入的值不满足类型要求,Vue 会在浏览器控制台中抛出警告来提醒使用者。这在开发给其他开发者使用的组件时非常有用。

要声明对 props 的校验,可以向 defineProps() 宏提供一个带有 props 校验选项的对象,例如:

defineProps({
  // 基础类型检查
  // (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
  propA: Number,
  // 多种可能的类型
  propB: [String, Number],
  // 必传,且为 String 类型
  propC: {
    type: String,
    required: true
  },
  // 必传但可为 null 的字符串
  propD: {
    type: [String, null],
    required: true
  },
  // Number 类型的默认值
  propE: {
    type: Number,
    default: 100
  },
  // 对象类型的默认值
  propF: {
    type: Object,
    // 对象或数组的默认值
    // 必须从一个工厂函数返回。
    // 该函数接收组件所接收到的原始 prop 作为参数。
    default(rawProps) {
      return { message: 'hello' }
    }
  },
  // 自定义类型校验函数
  // 在 3.4+ 中完整的 props 作为第二个参数传入
  propG: {
    validator(value, props) {
      // The value must match one of these strings
      return ['success', 'warning', 'danger'].includes(value)
    }
  },
  // 函数类型的默认值
  propH: {
    type: Function,
    // 不像对象或数组的默认,这不是一个
    // 工厂函数。这会是一个用来作为默认值的函数
    default() {
      return 'Default function'
    }
  }
})

事件

子组件可以通过触发事件的方式向父组件传递信息。在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件 (例如:在 v-on 的处理函数中):

<!-- MyComponent -->
<button @click="$emit('someEvent')">Click Me</button>

父组件可以通过 v-on(缩写为 @)来监听事件:

<MyComponent @some-event="callback" />

有时候我们会需要在触发事件时附带一个特定的值,这时我们可以给 $emit 提供一个额外的参数:

<button @click="$emit('increaseBy', 1)">
  Increase by 1
</button>

然后我们在父组件中监听事件,我们可以先简单写一个内联的箭头函数作为监听器,此函数会接收到事件附带的参数:

<MyButton @increase-by="(n) => count += n" />

或者,也可以用一个组件方法来作为事件处理函数:

<MyButton @increase-by="increaseCount" />

该方法也会接收到事件所传递的参数:

function increaseCount(n) {
  count.value += n
}

组件可以显式地通过 defineEmits() 宏来声明它要触发的事件:

<script setup>
defineEmits(['inFocus', 'submit'])
</script>

我们在 <template> 中使用的 $emit 方法不能在组件的 <script setup> 部分中使用,但 defineEmits() 会返回一个相同作用的函数供我们使用:

<script setup>
const emit = defineEmits(['inFocus', 'submit'])

function buttonClick() {
  emit('submit')
}
</script>

defineEmits() 宏不能在子函数中使用。如上所示,它必须直接放置在 <script setup> 的顶级作用域下。

插槽

一些情况下我们会希望能和 HTML 元素一样向组件中传递内容,这可以通过 Vue 的自定义 <slot> 元素来实现。

基本使用方式

举例来说,这里有一个 <FancyButton> 组件,可以像这样使用:

<FancyButton>
  Click me! <!-- 插槽内容 -->
</FancyButton>

<FancyButton> 的模板是这样的:

<button class="fancy-btn">
  <slot></slot> <!-- 插槽出口 -->
</button>

<slot> 元素是一个插槽出口(slot outlet),标示了父元素提供的插槽内容((slot content)将在哪里被渲染。

最终渲染出的 DOM 是这样:

<button class="fancy-btn">Click me!</button>

插槽内容可以是任意合法的模板内容,不局限于文本。例如我们可以传入多个元素,甚至是组件:

<FancyButton>
  <span style="color:red">Click me!</span>
  <AwesomeIcon name="plus" />
</FancyButton>

具名插槽

有时在一个组件中包含多个插槽出口是很有用的。对于这种场景,<slot> 元素可以有一个特殊的 attribute name,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容:

<!-- BaseLayout.vue -->
<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

这类带 name 的插槽被称为具名插槽(named slots)。没有提供 name<slot> 出口会隐式地命名为 default

在使用时,我们需要一种方式将多个插槽内容传入到各自目标插槽的出口。此时就需要用到具名插槽了。

要为具名插槽传入内容,我们需要使用一个含 v-slot 指令的 <template> 元素,并将目标插槽的名字传给该指令:

<BaseLayout>
  <template v-slot:header>
    <!-- header 插槽的内容放这里 -->
  </template>
</BaseLayout>

v-slot 有对应的简写 #,因此 <template v-slot:header> 可以简写为 <template #header>。其意思就是“将这部分模板片段传入子组件的 header 插槽中”。下面是一个完整例子:

<BaseLayout>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <template #default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容。所以上面也可以写成:

<BaseLayout>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <!-- 隐式的默认插槽 -->
  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

条件插槽

有时我们需要根据内容是否被传入了插槽来渲染某些内容,这时可以结合使用 $slots 属性与 v-if 来实现。

下面的示例中定义了一个卡片组件,它拥有三个条件插槽:headerfooterdefault。 当 header、footer 或 default 的内容存在时,我们希望包装它以提供额外的样式:

<template>
  <div class="card">
    <div v-if="$slots.header" class="card-header">
      <slot name="header" />
    </div>
    
    <div v-if="$slots.default" class="card-content">
      <slot />
    </div>
    
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>

作用域插槽

通过上面的学习我们知道,插槽的内容无法访问到子组件的状态。然而在某些场景下插槽的内容可能想要同时使用父组件域内和子组件域内的数据。要做到这一点,就需要一种方法来让子组件在渲染时将一部分数据提供给插槽。

我们可以像对组件传递 props 那样,向一个插槽的出口上传递 attributes:

<!-- <MyComponent> 的模板 -->
<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>

当需要接收插槽 props 时,默认插槽和具名插槽的使用方式有一些小区别。下面先展示默认插槽如何接受 props,通过子组件标签上的 v-slot 指令,直接接收到了一个插槽 props 对象:

<MyComponent v-slot="slotProps">
  {{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

子组件传入插槽的 props 作为了 v-slot 指令的值,可以在插槽内的表达式中访问。我们也可以在 v-slot 中使用解构:

<MyComponent v-slot="{ text, count }">
  {{ text }} {{ count }}
</MyComponent>

具名作用域插槽的工作方式也是类似的,插槽 props 可以作为 v-slot 指令的值被访问到:v-slot:name="slotProps"。当使用缩写时是这样:

<MyComponent>
  <template #header="headerProps">
    {{ headerProps }}
  </template>

  <template #default="defaultProps">
    {{ defaultProps }}
  </template>

  <template #footer="footerProps">
    {{ footerProps }}
  </template>
</MyComponent>

向具名插槽中传入 props:

<slot name="header" message="hello"></slot>

注意插槽上的 name 是一个 Vue 特别保留的 attribute,不会作为 props 传递给插槽。因此最终 headerProps 的结果是 { message: 'hello' }

如果同时使用了具名插槽与默认插槽,则需要为默认插槽使用显式的 <template> 标签。尝试直接为组件添加 v-slot 指令将导致编译错误。这是为了避免因默认插槽的 props 的作用域而困惑。

下面是作用域插槽的一个使用场景。

我们来看一个 <FancyList> 组件的例子。它会渲染一个列表,并同时会封装一些加载远端数据的逻辑、使用数据进行列表渲染、或者是像分页或无限滚动这样更进阶的功能。然而我们希望它能够保留足够的灵活性,将对单个列表元素内容和样式的控制权留给使用它的父组件。所以用法是这样的:

<FancyList :api-url="url" :per-page="10">
  <template #item="{ body, username, likes }">
    <div class="item">
      <p>{{ body }}</p>
      <p>by {{ username }} | {{ likes }} likes</p>
    </div>
  </template>
</FancyList>

<FancyList> 之中,我们可以多次渲染 <slot> 并每次都提供不同的数据 (注意这里使用了 v-bind 来传递插槽的 props):

<ul>
  <li v-for="item in items">
    <slot name="item" v-bind="item"></slot>
  </li>
</ul>

动态组件

有些场景会需要在两个组件间来回切换,比如 Tab 界面。Vue 提供了 <component> 元素和特殊的 is 属性来实现这个功能。

<script setup>
import { ref } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'
import TabArchive from './TabArchive.vue'

const currentTab = ref('Home')
const tabs = {
  Home: TabHome,
  Posts: TabPosts,
  Archive: TabArchive
}
</script>
<template>
  <div class="demo">
    <button
      v-for="(_, tab) in tabs"
      :key="tab"
      :class="['tab-button', { active: currentTab === tab }]"
      @click="currentTab = tab"
    >
      {{ tab }}
    </button>
    <component :is="tabs[currentTab]" class="tab"></component>
  </div>
</template>

在上面的例子中,被传给 :is 的值可以是以下几种:

  • 被注册的组件名
  • 导入的组件对象

我们也可以使用 is 属性来创建一般的 HTML 元素。当使用 <component :is="..."> 来在多个组件间作切换时,被切换掉的组件会被卸载。

异步组件

对于大型应用,我们可能需要将应用拆分为更小的代码块,并且只在需要时才加载组件。Vue提供了 defineAsyncComponent 函数来实现这一点:

<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./components/HeavyComponent.vue')
)
</script>
<template>
  <AsyncComp />
</template>

最后得到的 AsyncComp 是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。它会将接收到的 props 和插槽传给内部组件,所以我们可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。

与普通组件一样,异步组件可以使用 app.component() 全局注册:

app.component('MyComponent', defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
))

异步操作不可避免地会涉及到加载和错误状态,因此 defineAsyncComponent() 也支持在高级选项中处理这些状态:

const AsyncComp = defineAsyncComponent({
  // 加载函数
  loader: () => import('./Foo.vue'),

  // 加载异步组件时使用的组件
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  delay: 200,

  // 加载失败后展示的组件
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 3000
})

如果提供了一个加载组件,它将在内部组件加载时先行显示。在加载组件显示之前有一个默认的 200ms 延迟——这是因为在网络状况较好时,加载完成得很快,加载组件和最终组件之间的替换太快可能产生闪烁,反而影响用户感受。

如果提供了一个报错组件,则它会在加载器函数返回的 Promise 抛错时被渲染。我们还可以指定一个超时时间,在请求耗时超过指定时间时也会渲染报错组件。