Vue3笔记——组件(下)

122 阅读6分钟

插槽

插槽内容和出口

适用于为子组件传递一些模板片段。子组件定义插槽出口,父组件传递插槽内容。

举例来说,这里有一个 <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>

渲染作用域

插槽内容可以访问到父组件的数据作用域,因为插槽内容本身是在父组件模板中定义的。举例来说:

<span>{{ message }}</span>
<FancyButton>{{ message }}</FancyButton>

这里的两个 {{ message }} 插值表达式渲染的内容都是一样的,来自FancyButton的父组件。插槽内容无法访问子组件的数据——除非使用作用域插槽。

默认内容

在外部没有提供任何内容的情况下,可以为插槽指定默认内容。

<!-- <SubmitButton> -->
<button type="submit">
  <slot>
    Submit <!-- 默认内容 -->
  </slot>
</button>

具名插槽

在一个 <BaseLayout> 组件中:

<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 插槽中”。

具名插槽图示

当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <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>

当然,插槽名可以是动态的,<slot name="[slotName]"></slot>的形式定义插槽出口。

作用域插槽

在渲染作用域中提到,插槽内容在父组件作用域中渲染,无法访问子组件的数据。要想让父组件在插槽内容可以使用子组件数据,可以像传prop一样,在子组件定义插槽出口时传attributes。

 <slot text="hello" :count="count"></slot>

上面传了两个attributes,text是静态的,count是动态的,父组件可以介绍的这两个值。

匿名作用域插槽

子组件模板:

<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>

父组件模板:

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

scoped slots diagram

在线运行

具名作用域插槽

子组件MyComponent:

<script>
export default {
  data() {
    return {
      greetingMessage: 'hello'
    }
  }
}
</script>

<template>
  <div>
  	<slot name="content" :text="greetingMessage" :count="1"></slot>
	</div>
</template>

父组件App:

<script setup>
import MyComponent  from './MyComponent.vue'
import { ref } from 'vue'

const msg = ref('具名作用域插槽')
</script>

<template>
  <h1>{{ msg }}</h1>
  <MyComponent v-slot:content="slotProps">{{slotProps}}</MyComponent>
  <MyComponent v-slot:content="{text, count}">{{text}} -- {{ count}}</MyComponent>
  <MyComponent>
    <template #content="{text, count}">{{text}} -- {{ count}}</template>
  </MyComponent>
</template>

注意:插槽上的 name 不会作为 props 传递给插槽。

在线运行

混用匿名和具名作用域插槽

子组件BaseLayout:

<script>
export default {
  data() {
    return {
      greetingMessage: 'hello'
    }
  }
}
</script>

<template>
  <div class="container">
    <header>
      <slot name="header" :text="greetingMessage"></slot>
    </header>
    <main>
      <slot :text="greetingMessage"></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
</div>
</template>

父组件App:

<script setup>
import MyComponent  from './MyComponent.vue'
import { ref } from 'vue'

const msg = ref('混用匿名和具名作用域插槽')
const text = ref('parent')
</script>

<template>
  <h1>{{ msg }}</h1>
  <h2>{{text}}</h2>
  <!-- 方式一 -->
  <MyComponent>
    <template #header="{text}">header: {{text}}</template>
    <template #default="{text}">default: {{text}} —— 来自子组件</template>
    <template #footer>footer: {{text}}</template>
  </MyComponent>
  <hr />
  <!-- 方式二 -->
  <MyComponent>
    <template #header="{text}">header:{{text}}</template>
    default: {{text}} —— 来自父组件
    <template #footer>footer: {{text}}</template>
  </MyComponent>
</template>

父子组件都有一个text,子组件将text通过插槽传递。可以看到,方式一的默认插槽接收了子组件的text,显示的是hello;而方式二的默认插槽没有接收子组件的text,显示的是parent。

image.png

需要注意的是,混用具名插槽与默认插槽时,不能在MyComponent上使用v-slot,可以将默认插槽放在template里面。

<!-- 该模板无法编译 -->
<template>
  <MyComponent v-slot="{ text }">
    <p>{{ message }}</p>
    <template #header>
      <p>{{ text }}</p>
    </template>
  </MyComponent>
</template>

在线运行

依赖注入

Prop逐级透传

很深的子组件DeepChild需要接收祖先组件Root的prop,从Root到DeepChild链路上的节点需要接收和向下传递prop,虽然链路上的这些节点并不需要这个prop。这一问题被称为“prop 逐级透传”,我们希望尽量避免。

Prop 逐级透传的过程图示

provideinject 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

Provide/inject 模式

Provide

要为组件后代提供数据,需要使用到provide 函数:

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

provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
</script>

如果不使用 <script setup>,请确保 provide() 是在 setup() 同步调用的:

import { provide } from 'vue'

export default {
  setup() {
    provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
  }
}

provide() 函数接收两个参数。第一个参数被称为注入名,可以是一个字符串或是一个 Symbol。后代组件会用注入名来查找期望注入的值。一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。 第二个参数是提供的值,值可以是任意类型,包括响应式的状态,比如一个 ref。

还可以在整个应用层面提供依赖:

import { createApp } from 'vue'

const app = createApp({})

app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')

Inject

要注入上层组件提供的数据,需使用 inject 函数:

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

const message = inject('message')
</script>

如果提供的值是一个 ref,注入进来的会是该 ref 对象,而不会自动解包为其内部的值。这使得注入方组件能够通过 ref 对象保持了和供给方的响应性链接。

如果没有使用 <script setup>inject() 需要在 setup() 内同步调用:

import { inject } from 'vue'

export default {
  setup() {
    const message = inject('message')
    return { message }
  }
}

注入默认值

inject 传入的注入名由某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,则会抛出一个运行时警告。如果在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值。

// 如果没有祖先组件提供 "message"
// `value` 会是 "这是默认值"
const value = inject('message', '这是默认值')

在一些场景中,默认值可能需要通过调用一个函数或初始化一个类来取得。为了避免在用不到默认值的情况下进行不必要的计算或产生副作用,我们可以使用工厂函数来创建默认值:

const value = inject('key', () => new ExpensiveClass())

和响应式数据配合使用

当提供 / 注入响应式的数据时, 尽可能将任何对响应式状态的变更都保持在供给方组件中。如果需要注入方组件需要更改注入的数据,那么将更改数据的方法也在供给方组件内提供。

<!-- 在供给方组件内 -->
<script setup>
import { provide, ref } from 'vue'

const location = ref('North Pole')

function updateLocation() {
  location.value = 'South Pole'
}

provide('location', {
  location,
  updateLocation
})
</script>
<!-- 在注入方组件 -->
<script setup>
import { inject } from 'vue'

const { location, updateLocation } = inject('location')
</script>

<template>
  <button @click="updateLocation">{{ location }}</button>
</template>

最后,如果你想确保提供的数据不能被注入方的组件更改,你可以使用 readonly 来包装提供的值。

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

const count = ref(0)
provide('read-only-count', readonly(count))
</script>

使用 Symbol 作注入名

最好使用 Symbol 来作为注入名以避免潜在的冲突,可以在一个单独的文件中导出这些注入名 Symbol:

// keys.js
export const myInjectionKey = Symbol()
// 在供给方组件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, { /*
  要提供的数据
*/ });
// 注入方组件
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const injected = inject(myInjectionKey)

异步组件

基本用法

使用defineAsyncComponent异步加载组件,该方法接收一个返回 Promise 的加载函数。

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...从服务器获取组件
    resolve(/* 获取到的组件 */)
  })
})
// ... 像使用其他一般组件一样使用 `AsyncComp`

ES 模块动态导入也会返回一个 Promise,所以多数情况下我们会将它和 defineAsyncComponent搭配使用:

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)

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

加载和错误状态

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

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

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

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

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

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