Vue3深入组件

206 阅读2分钟

一、组件注册

方式:全局注册和局部注册。

1、全局注册

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

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

存在几点问题:

全局注册,但并没有被使用的组件无法在生产打包时被自动移除 (也叫“tree-shaking”)。

全局注册在大型项目中使项目的依赖关系变得不那么明确。

2、局部注册

优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好

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

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

<template>
  <ComponentA />
</template>

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

3、组件名格式

建议使用 <PascalCase />作为组件名的注册格式

二、Props

1、Props 声明

// <script setup> 使用 defineProps() 宏来声明:
vue
<script setup>
const props = defineProps(['foo'])

console.log(props.foo)
</script>

// 非 <script setup> 使用 props 选项来声明:
js
export default {
  props: ['foo'],
  setup(props) {
    // setup() 接收 props 作为第一个参数
    console.log(props.foo)
  }
}

// 使用对象的形式:
js
// 使用 <script setup>
defineProps({
  titleString,
  likesNumber
})
js
// 非 <script setup>
export default {
  props: {
    titleString,
    likesNumber
  }
}

2、Prop 名字格式

使用 camelCase 形式

3、使用一个对象绑定多个 prop

使用没有参数的 v-bind将一个对象的所有属性都当作 props 传入

js
const post = {
  id1,
  title'My Journey with Vue'
}
以及下面的模板:

template
<BlogPost v-bind="post" />
// 而这实际上等价于:
template
<BlogPost :id="post.id" :title="post.title" />

js
defineProps({
  // 基础类型检查
  // (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
  propANumber,
  // 多种可能的类型
  propB: [StringNumber],
  // 必传,且为 String 类型
  propC: {
    typeString,
    requiredtrue
  },
  // Number 类型的默认值
  propD: {
    typeNumber,
    default100
  },
  // 对象类型的默认值
  propE: {
    typeObject,
    // 对象或数组的默认值
    // 必须从一个工厂函数返回。
    // 该函数接收组件所接收到的原始 prop 作为参数。
    default(rawProps) {
      return { message'hello' }
    }
  },
  // 自定义类型校验函数
  propF: {
    validator(value) {
      // The value must match one of these strings
      return ['success''warning''danger'].includes(value)
    }
  },
  // 函数类型的默认值
  propG: {
    typeFunction,
    // 不像对象或数组的默认,这不是一个
    // 工厂函数。这会是一个用来作为默认值的函数
    default() {
      return 'Default function'
    }
  }
})

TIP

1、defineProps() 宏中的参数不可以访问 <script setup> 中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。

另外,type 也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配。例如:

js
class Person {
  (firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }
}
你可以将其作为一个 prop 的类型:
js
defineProps({
  author: Person
})

2、Boolean 类型转换

为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则。以带有如下声明的  组件为例:

js
defineProps({
  disabled: Boolean
})
该组件可以被这样使用:
template
<!-- 等同于传入 :disabled="true" -->
<MyComponent disabled />

<!-- 等同于传入 :disabled="false" -->
<MyComponent />
当一个 prop 被声明为允许多种类型时,例如:
js
defineProps({
  disabled: [Boolean, Number]
})

无论声明类型的顺序如何,Boolean 类型的特殊转换规则都会被应用。

三、组件事件

1、触发与监听事件

和原生 DOM 事件不一样,组件触发的事件没有冒泡机制,只能监听直接子组件触发的事件。

平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案。

2、声明触发的事件

组件可以显式地通过 defineEmits() 宏来声明它要触发的事件,必须直接放置在 <script setup> 的顶级作用域下。

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

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

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

使用setup 函数,事件需要通过 emits 选项来定义,emit 函数也被暴露在 setup() 的上下文对象上 与 setup() 上下文对象中的其他属性一样,emit 可以安全地被解构

js
export default {
  emits: ['inFocus''submit'],
  setup(props, ctx) {
    ctx.emit('submit')
  }
}

emits 选项还支持对象语法,它允许我们对触发事件的参数进行验证:

vue
<script setup>
const emit = defineEmits({
  submit(payload) {
    // 通过返回值为 `true` 还是为 `false` 来判断
    // 验证是否通过
  }
})
</script>

TIP

如果一个原生事件的名字 (例如 click) 被定义在 emits 选项中,则监听器只会监听组件触发的 click 事件而不会再响应原生的 click 事件。

3、事件校验

和对 props 添加类型校验的方式类似,所有触发的事件也可以使用对象形式来描述。

要为事件添加校验,那么事件可以被赋值为一个函数,接受的参数就是抛出事件时传入 emit 的内容,返回一个布尔值来表明事件是否合法。

vue
<script setup>
const emit = defineEmits({
  // 没有校验
  clicknull,

  // 校验 submit 事件
  submit({ email, password }) => {
    if (email && password) {
      return true
    } else {
      console.warn('Invalid submit event payload!')
      return false
    }
  }
})

function submitForm(email, password) {
  emit('submit', { email, password })
}
</script>

四、组件 v-model

1、v-model的实现

当使用在一个组件上时,v-model 会被展开为如下的形式:

template
<CustomInput v-model="searchText" />

template
<CustomInput
  :modelValue="searchText"
  @update:modelValue="newValue => searchText = newValue"
/>
vue
<!-- CustomInput.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 getter 和 setter 的 computed 属性。get 方法需返回 modelValue prop,而 set 方法需触发相应的事件:

vue
<!-- CustomInput.vue -->
<script setup>
import { computed } from 'vue'

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})
</script>

<template>
  <input v-model="value" />
</template>

2、v-model 的参数

默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件。

通过给 v-model 指定一个参数来更改这些名字:

v-model:title="bookTitle"

子组件应声明一个 title prop,并通过触发 update:title 事件更新父组件值:

3、多个 v-model 绑定

组件上的每一个 v-model 都会同步不同的 prop,而无需额外的选项:

template
<UserName
  v-model:first-name="first"
  v-model:last-name="last"
/>
vue
<script setup>
defineProps({
  firstName: String,
  lastName: String
})
defineEmits(['update:firstName', 'update:lastName'])
</script>

4、  处理 v-model 修饰符

组件的 v-model 上所添加的修饰符,可以通过 modelModifiers prop 在组件内访问到

template
<MyComponent v-model.capitalize="myText" />

vue
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

const emit = defineEmits(['update:modelValue'])

function emitValue(e) {
  let value = e.target.value
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>

<template>
  <input type="text" :value="modelValue" @input="emitValue" />
</template>

对于又有参数又有修饰符的 v-model 绑定,生成的 prop 名将是 arg + "Modifiers"。举例来说:

template
<MyComponent v-model:title.capitalize="myText">

相应的声明应该是:
js
const props = defineProps(['title', 'titleModifiers'])
defineEmits(['update:title'])

console.log(props.titleModifiers) // { capitalize: true }

五、透传 Attributes

1、Attributes 继承

透传 attribute”指的是传递给一个组件,却没有被该组件声明为 props 或 emits 的 attribute 或者 v-on 事件监听器。最常见的例子就是 class、style 和 id。

当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。

2、对 class 和 style 的合并

如果一个子组件的根元素已经有了 class 或 style attribute,它会和从父组件上继承的值合并。

template
<MyButton @click="onClick" />

click 监听器会被添加到 <MyButton> 的根元素,即那个原生的 <button> 元素之上。

当原生的 <button> 被点击,会触发父组件的 onClick 方法

如果原生 button 元素自身也通过 v-on 绑定了一个事件监听器,则这个监听器和从父组件继承的监听器都会被触发。

3、禁用 Attributes 继承

在组件选项中设置 inheritAttrs: false。

如果使用了

vue
<script>
// 使用普通的 <script> 来声明选项
export default {
  inheritAttrs: false
}
</script>
<script setup>
// ...setup 部分逻辑
</script>

透传进来的 attribute 可以在模板的表达式中直接用 $attrs 访问到。

$attrs 对象包含了除组件所声明的 props 和 emits 之外的所有其他 attribute,例如 class,style,v-on 监听器等等。

template
<span>Fallthrough attribute: {{ $attrs }}</span>

注意:

透传 attributes 在 JavaScript 中保留了它们原始的大小写,所以像 foo-bar 这样的一个 attribute 需要通过 $attrs['foo-bar'] 来访问。

像 @click 这样的一个 v-on 事件监听器将在此对象下被暴露为一个函数 $attrs.onClick。

现在我们要再次使用一下之前小节中的 <MyButton> 组件例子。有时候我们可能为了样式,需要在 <button> 元素外包装一层 <div>

template
<div class="btn-wrapper">
  <button class="btn" v-bind="$attrs">click me</button>
</div>

想要所有像 class 和 v-on 监听器这样的透传 attribute 都应用在内部的 <button> 上而不是外层的 <div> 上,可以通过设定 inheritAttrs: false 和使用 v-bind="$attrs" 来实现:

4、多根节点的 Attributes 继承

和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。

如果 $attrs 没有被显式绑定,将会抛出一个运行时警告。

template
<header>...</header>
<main>...</main>
<footer>...</footer>

// 如果 $attrs 被显式绑定,则不会有警告:
template
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

在 JavaScript 中访问透传 Attributes

如果需要,你可以在

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

const attrs = useAttrs()
</script>
如果没有使用 <script setup>,attrs 会作为 setup() 上下文对象的一个属性暴露:
js
export default {
  setup(props, ctx) {
    // 透传 attribute 被暴露为 ctx.attrs
    console.log(ctx.attrs)
  }
}

注意: 虽然这里的 attrs 对象总是反映为最新的透传 attribute,但它并不是响应式的 (考虑到性能因素)。不能通过侦听器去监听它的变化。

如果需要响应性:

1、可以使用 prop。

2、使用 onUpdated() 使得在每次更新时结合最新的 attrs 执行副作用。

六、插槽 Slots

1、渲染作用域

插槽内容可以访问到父组件的数据作用域,无法访问子组件的数据,因为插槽内容本身是在父组件模板中定义的。

举例来说:

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

这里的两个 {{ message }} 插值表达式渲染的内容都是一样的。

2、动态插槽名

动态指令参数在 v-slot 上也是有效的,即可以定义下面这样的动态插槽名:

template
<base-layout>
  <template v-slot:[dynamicSlotName]>
    ...
  </template>

  <!-- 缩写为 -->
  <template #[dynamicSlotName]>
    ...
  </template>
</base-layout>

注意这里的表达式和动态指令参数受相同的语法限制。

3、作用域插槽

当需要接收插槽 props 时,默认插槽和具名插槽的使用方式有一些小区别。

默认插槽,通过子组件标签上的 v-slot 指令,直接接收到了一个插槽 props 对象:

template
<!-- <MyComponent> 的模板 -->
<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>
template
<MyComponent v-slot="slotProps">
  {{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

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

4、具名作用域插槽

插槽 props 可以作为 v-slot 指令的值被访问到:v-slot:name="slotProps"

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

向具名插槽中传入 props:

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

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

template
<template>
  <MyComponent>
    <!-- 使用显式的默认插槽 -->
    <template #default="{ message }">
      <p>{{ message }}</p>
    </template>
  </MyComponent>
</template>

5、高级列表组件示例

你可能想问什么样的场景才适合用到作用域插槽,这里我们来看一个 组件的例子。它会渲染一个列表,并同时会封装一些加载远端数据的逻辑、使用数据进行列表渲染、或者是像分页或无限滚动这样更进阶的功能。然而我们希望它能够保留足够的灵活性,将对单个列表元素内容和样式的控制权留给使用它的父组件。我们期望的用法可能是这样的:

template
<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):

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

6、无渲染组件

一些组件可能只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件,这种类型的组件称为无渲染组件。

七、依赖注入

Prop 逐级透传问题,图示:

provide 和 inject 可以解决这一问题。

1、Provide (提供)

为组件后代提供数据,使用到 provide() 函数

接收两个参数,其中注入名,可以是一个字符串或是一个 Symbol,第二个参数是提供的值,值可以是任意类型,包括响应式的状态,比如一个 ref

一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

import { ref, provide } from 'vue'

const count = ref(0)
// 注入进来的会是该 ref 对象,而不会自动解包为其内部的值,提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。
provide('key', count) 

// 如果不使用 <script setup>,请确保 provide() 是在 setup() 同步调用的:
js
import { provide } from 'vue'

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

2、应用层 Provide

除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖:

js
import { createApp } from 'vue'

const app = createApp({})
app.provide(/ 注入名 / 'message', / 值 / 'hello!')

3、Inject (注入)

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

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

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

同样的,如果没有使用

import { inject } from 'vue'

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

4、注入默认值

如果在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值,和 props 类似:

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>

5、使用 Symbol 作注入名

至此,我们已经了解了如何使用字符串作为注入名。但如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

我们通常推荐在一个单独的文件中导出这些注入名 Symbol:

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

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

const injected = inject(myInjectionKey)

八、  异步组件

1、基本用法

js
import { defineAsyncComponent } from 'vue'

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

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

类似 Vite 和 Webpack 这样的构建工具也支持此语法 (并且会将它们作为打包时的代码分割点),可以用它来导入 Vue 单文件组件:

js
import { defineAsyncComponent } from 'vue'
异步组件可以使用 app.component() 全局注册:
js
app.component('MyComponent', defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
))

也可以直接在父组件中直接定义它们:

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

const AdminPage = defineAsyncComponent(() =>
  import('./components/AdminPageComponent.vue')
)
</script>

<template>
  <AdminPage />
</template>

2、加载与错误状态

js
const AsyncComp = defineAsyncComponent({
  // 加载函数
  loader: () => import('./Foo.vue'),
  // 加载异步组件时使用的组件
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  delay: 200,
  // 加载失败后展示的组件
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 3000
})

3、搭配 Suspense 使用

异步组件可以搭配内置的 <Suspense> 组件一起使用