Vue 3 组件

Vue 的组件本质上是一个具有预定义选项的实例,我们使用小型的、独立和通常可复用的组件,通过层层拼装,最终形成了一个完整的页面。

components
components

组件必须先注册以便 Vue 应用能够识别,有两种组件的注册类型:

  • 全局注册
  • 局部注册

全局组件

(在根组件中)使用方法 app.component('component-Name', {}) 来注册全局组件,全局注册的组件可以在应用中的任何组件的模板中使用

第一个参数是组件名,推荐遵循 W3C 规范中的自定义组件名(避免与当前以及未来的 HTML 元素发生冲突),即字母全小写必须包含一个连字符

第二个参数是组件的配置选项

js
const app = Vue.createApp();
app.component('my-component', {
    template: `<h1>Hello World!</h1>`
});
const vm = app.mount('#app')
说明

需要在执行 app.mount('#app') 将应用挂载到页面之前进行全局组件的注册

注意

全局组件虽然可以方便地在各种组件中使用(包括其各自的内部),但是这可能造成构建项目时体积增大,导致用户浏览网页时下载的脚本的增大

局部组件

在一个(父)组件的 components 选项中注册局部组件。

这些子组件通过一个普通的 JavaScript 对象来定义,其中可配置的属性和全局组件一样,但是它们只能在该父组件中使用,称为局部组件。

对于 components 选项中的每个 property 来说,其 property 名称就是局部组件的名字,其 property 值就是这个组件的配置对象

js
const ComponentA = {
  /* ... */
}
const ComponentB = {
  /* ... */
}
const ComponentC = {
  /* ... */
}
js
// 然后在父组件的 `components` 选项中定义你想要使用的组件
const app = Vue.createApp({
  components: {
    'component-a': ComponentA,
    'component-b': ComponentB
  }
})

动态组件

内置组件 <component :is="componentName />" 用以动态显示不同的组件,通过控制绑定在属性 is 上的参数值,即可显示相应名称的组件。

属性 is 可以是:

提示

属性 is 还可以用于解决 HTML 元素嵌套的规则限制,具体可以查看另一篇笔记的相关内容

状态保存

动态组件切换后,被替换掉的组件实例会被销毁,所以这个默认行为会导致丢失其中所有已变化的状态,当这个组件再一次被显示时,会创建一个只带有初始状态的新实例。

有时候希望在切换时保存组件的状态(例如保留组件中的输入框的值),可以使用 Vue 所提供的内置组件 <KeepAlive></KeepAlive> 包裹该动态组件

当一个组件 <KeepAlive> 缓存时,即使实例从 DOM 上移除但仍作为组件树的一部分时,它将变为不活跃状态而不是被卸载。当一个组件实例作为缓存树的一部分插入到 DOM 中时,它将重新被激活。

vue
<template>
  <!-- 非活跃的组件将会被缓存! -->
  <KeepAlive>
    <component :is="activeComponent" />
  </KeepAlive>
</template>
说明

DOM 模板中使用时,应该被写为 <keep-alive>

一个持续存在的组件可以通过生命周期钩子 onActivated()onDeactivated() 监听两个状态并执行相应的操作,这两个钩子不仅适用于 <KeepAlive> 缓存的根组件,也适用于缓存树中的后代组件。

vue
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
  // 调用时机为首次挂载
  // 以及每次从缓存中被重新插入时
})
onDeactivated(() => {
  // 在从 DOM 上移除、进入缓存
  // 以及组件卸载时调用
})
</script>
注意

onActivated 生命周期钩子在组件挂载时也会调用,并且 onDeactivated 在组件卸载时也会调用。

<KeepAlive> 默认会缓存其包裹的所有组件实例,可以通过 includeexclude prop 来定制所需缓存(或排除缓存)的组件

这两个 prop 的值都可以是一个以逗号 , 分隔的字符串一个正则表达式,或是包含这两种类型的一个数组,然后会根据组件的 name 选项进行匹配

vue
<template>
  <!-- 以英文逗号分隔的字符串 -->
  <KeepAlive include="a,b">
    <component :is="view" />
  </KeepAlive>
  <!-- 正则表达式 (需使用 `v-bind`) -->
  <KeepAlive :include="/a|b/">
    <component :is="view" />
  </KeepAlive>
  <!-- 数组 (需使用 `v-bind`) -->
  <KeepAlive :include="['a', 'b']">
    <component :is="view" />
  </KeepAlive>
</template>
提示

所以组件如果想要支持条件性地被 KeepAlive 缓存,就必须显式声明一个 name 选项

而在 Vue 3.2.34 或以上的版本中,使用 <script setup> 的单文件组件会自动根据文件名生成对应的 name 选项,无需再手动声明

可以通过传入 max prop 来限制可被缓存的最大组件实例数。<KeepAlive> 的行为在指定了 max 后类似一个 LRU 缓存:如果缓存的实例数量即将超过指定的那个最大数量,则最久没有被访问的缓存实例将被销毁,以便为新的实例腾出空间。

异步组件

在大型项目中可能需要拆分应用为更小的块,并不立即渲染,仅在需要时再从服务器异步加载相关组件,以优化应用的加载和用户体验。Vue 提供了一个方法 defineAsyncComponent 来实现此功能

defineAsyncComponent() 方法定义一个异步组件,它的入参是一个匿名函数,而且该函数返回一个 Promise,可以在其中执行一些异步操作,例如从服务器获取组件

在 Promise 内应该 resolve({}) 一个对象,其中包含了构建组件相关配置项(也可以 reject(reason) 表示加载失败),只有当 Promise resolve(或 reject)才执行异步组件的处理(渲染到页面上)

js
import { defineAsyncComponent } from 'vue'
// 定义一个异步组件
const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // 一些异步操作,从服务器获取组件
    // 获取到的组件
    resolve({
      template: '<div>I am async!</div>'
    })
  })
})
提示

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

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

js
// 全局组件
import { defineAsyncComponent } from 'vue'
app.component('async-example', defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...
    resolve({
      template: '<div>I am async!</div>'
    })
  })
}))

也可以直接在父组件中定义局部组件

js
// 局部组件(注册在根组件上)
import { createApp, defineAsyncComponent } from 'vue'
createApp({
  // ...
  components: {
    AsyncComponent: defineAsyncComponent(() => {
      return new Promise((resolve, reject) => {
        resolve({
          template: '<div>I am async!</div>'
        })
      })
    })
  }
})
提示

ES 模块动态导入 import() 也会返回一个 Promise,所以多数情况下会将它和 defineAsyncComponent 搭配使用,可以十分方便地将一个普通的组件封装为异步组件,实现延迟加载(仅在页面需要它渲染时才会调用加载内部实际组件的函数)

js
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)

类似 Vite 和 Webpack 这样的构建工具也支持此语法,并且会将它们作为打包时的代码分割点

异步操作可能会涉及加载进行中的状态发生加载错误的状态,在 defineAsyncComponent() 方法中还可以设置其他参数以处理这些状态

js
const AsyncComp = defineAsyncComponent({
  // 加载函数
  loader: () => import('./Foo.vue'),
  // 加载异步组件时使用的组件,它将在内部组件加载时先行显示
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  // 默认就设置了延迟,因为在网络状况较好时,加载完成得很快
  // 加载组件和最终组件之间的替换太快可能产生闪烁,这样反而影响用户感受
  delay: 200,
  // 加载失败后展示的组件
  // 在加载器函数返回的 Promise 抛错时(或超出时间限制仍未 settle Promise)被渲染
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 3000
})

组件间传值

props

通过 Prop 向子组件传递数据。Prop 是在组件上注册的一些自定义 attribute。当一个值传递给一个 prop attribute 的时候,它就变成了那个组件实例的一个 property。

需要注意以下几点:

  • 如果在父组件中传入的数据是非字符串的,需要通过绑定 v-bind:propName 的方式来传递(即使传递纯数字这一类的静态值);否则直接传递数据都会转换为字符串
  • prop 应该遵循单向数据流,即不应该在子组件中修改 prop 值(因为这些数据是在父层的)

在组件中声明它可以接收的 props 时,即可以用字符串数组形式列出,也可以用对象形式列出(可指定 prop 接收的数据类型、默认值、是否必须、验证条件等)

js
// ...
props: {
  // 基础的类型检查
  // (如果类型设置为 `null` 或 `undefined` 则 Vue 会跳过对该 prop 的类型验证)
  propA: Number,
  // 有多个配置项可设
  propB: {
    type: String,
    required: true,
    default: 'abc',
    validator: func()   // 自定义验证函数,返回 true/false
  }
  // 带有默认值的对象
  propC: {
    type: Object,
    // 对象或数组默认值必须从一个工厂函数获取
    default() {
      return { message: 'hello' }
    }
  },
  // 具有默认值的函数
  propD: {
    type: Function,
    // 与对象或数组默认值不同,这不是一个工厂函数,而是一个函数,用作默认值
    default() {
      return defaultFunction() {...}
    }
  }
}

其中选项 type 可以对 prop 进行类型检查,支持原生类型和自定义构造函数(Vue 会通过 instanceof 进行检测):

  • String
  • Number
  • Boolean 为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则
    vue
    <template>
      <!-- 等同于传入 :disabled="true" -->
      <MyComponent disabled />
      <!-- 等同于传入 :disabled="false" -->
      <MyComponent />
    </template>
  • Array 默认值必须从一个工厂函数获取
  • Object 默认值必须从一个工厂函数获取
  • Date
  • Function
  • Symbol
  • 自定义构造函数
    js
    // 自定义构造函数
    function Person(firstName, lastName) {
      this.firstName = firstName
      this.lastName = lastName
    }
    app.component('blog-post', {
      props: {
        author: Person // 验证 author 的值是否是通过 new Person 创建的
      }
    })
注意

这些 prop 的验证会在一个组件实例创建之前进行,所以组件实例的一些 property(例如 datacomputed 等),在 prop 验证的选项 defaultvalidator 函数中是不可用的。

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

关于默认值的一些细节

  • Boolean 类型的 prop 外(它的默认值为 false),未传递的可选 prop 将会有一个默认值 undefined
  • Boolean 类型的未传递 prop 将被转换为 false,可以通过为它设置 default: undefined 来更改,将与非布尔类型的 prop 的行为保持一致
  • 如果声明了 default 值,那么在 prop 的值被解析为 undefined 时,无论 prop 是未被传递还是显式指明的 undefined都会改为 default
提示

如果希望将一个对象的所有属性分别作为 prop 都传给一个组件,可以使用不带参数的 v-bind,实现类似对象解构的效果

vue
<script>
  post: {
    id: 1,
    title: 'My Journey with Vue'
  }
</script>
<template>
  <!-- 调用组件 -->
  <!-- 直接传入一个对象 -->
  <blog-post v-bind="post"></blog-post>
  <!-- 等价,分别传入对象的属性 -->
  <blog-post v-bind:id="post.id" v-bind:title="post.title"></blog-post>
</template>
小技巧

除了传递基础数据类型,还可以传递函数作为 prop,这样就可以将在父级定义的功能传递给子组件使用

js
// 注册一个全局组件
app.component('date-component',{
  props:['getDate'],
  methods:{
    clickHandler(){
      this.getDate()  // 获取当前时间
    }
  },
  template:`<div @click="clickHandler">打印当前时间</div>`
})
js
const app = Vue.createApp({
  data() {
    return {
      getDate: () => {
        const today = new Date();
        console.log(today.toLocaleDateString())
      }
    }
  },
  template: `
    <h2>Date</h2>
    <date-component :getDate="getDate" />
  `
})

provide 和 inject

除了通过 props 从父组件向子组件传值,还可以用 provideinject 实现从祖先组件向其子孙后代组件传值,这种方法不论组件层次嵌套有多深都可以进行数据传递。

provide inject
provide inject

祖先组件有一个 provide 选项来提供数据,后代组件有一个 inject 选项来开始使用这个数据。

js
// 祖先组件
app.component('todo-list', {
  provide: {
    user: 'John Doe'
  },
})
// 后代组件
app.component('todo-list-statistics', {
  inject: ['user'],
  created() {
    console.log(`Injected property: ${this.user}`) // 注入 property: John Doe
  }
})
注意

如果要访问父组件实例的一些 property(如 data 选项的一些值),我们需要将 provide 转换为返回对象的函数

js
// 父组件
app.component('todo-list', {
  data() {
    return {
      todos: ['Feed a cat', 'Buy tickets']
    }
  },
  provide() {
    return {
      todoLength: this.todos.length // 访问 todos property
    }
  },
  template: `
    ...
  `
})

除了在一个组件为其后代组件提供依赖,还可以在整个应用层面提供依赖

在应用级别提供的数据在该应用内的所有组件中都可以注入,这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来提供值

js
import { createApp } from 'vue'
const app = createApp({})
app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
提示

默认情况下,provide/inject 绑定并具有响应式,可以通过组合式 API computed 来实现数据的响应式

或可以在 setup使用 ref property 或 reactive 对象包装变量,再传递给 provide 来实现响应式。

js
// 祖先组件
app.component('todo-list', {
  // ...
  provide() {
    return {
      // 通过 computed 进行包装实现响应式
      // 这样后代元素访问的 todoLength 就具有响应性
      todoLength: Vue.computed(() => this.todos.length)
    }
  }
})
// 后代组件
app.component('todo-list-statistics', {
  inject: ['todoLength'],
  created() {
    console.log(`Injected property: ${this.todoLength.value}`) // Injected property: 5
  }
})

当 provide / inject 时使用响应式的值时,建议尽可能将对响应式 property 的修改限制在定义 provide 的组件内

js
import { provide, reactive, ref } from 'vue'
import MyMarker from './MyMarker.vue'
export default {
  setup() {
    const location = ref('North Pole')
    const geolocation = reactive({
      longitude: 90,
      latitude: 135
    })
    provide('location', location)
    provide('geolocation', geolocation)
    return {
      location
    }
  },
  methods: {
    updateLocation() {
      this.location = 'South Pole'
    }
  }
}

如果要确保通过 provide 传递的数据,不会被 inject 接受数据的组件更改,建议在 provide 数据时,创建一个只读的 proxy 对象,使用 readonly 方法对 proxy 对象进行「保护」。

如果真的需要在注入数据的组件内部更新 inject 的数据,建议同时 provide 一个方法来负责改变值,这样可以将数据值的设定和数据修改的逻辑代码都依然集中放在祖父组件中,便于后续跟踪和维护。

js
import { provide, reactive, readonly, ref } from 'vue'
export default {
  setup() {
    const location = reactive({location: 'North Pole'})
    const geolocation = reactive({
      longitude: 100,
      latitude: 150
    })
    const resetGeoLocation = () => {
      geolocation.longitude = 90;
      geolocation.latitude = 135
    }
    // 提供一个只读数据,即在后代组件中只允许获取该数据,而不能对其修改
    provide('location', readonly(location))
    // 提供一个数据,同时提供修改它的方法
    provide('geolocation', geolocation)
    provide('resetGeoLocation', resetGeoLocation)
  }
}

emit

当组件需要修改父层控管的数据(这些数据通过 props 传递进来)时,基于单向数据流原则不能在组件内进行修改,而是需要通过方法 $emit('eventName', value) 向外抛出自定义的事件,通知父层进行数据修改,其中第二个参数 value 一般是向外传递的需要变动的数据。

说明

对于 setup() 声明的组件,则使用该函数的上下文对象 ctx(第二个参数)中所暴露出来的 emit 函数来抛出自定义事件

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

然后在父层通过 v-on:eventName 或简写 @eventName 来监听这个子组件抛出的自定义事件。

提示

自定义事件名推荐使用 kebab-case 方式(小写单词之间用连字符 - 连接,因为与组件和 prop 名称不同,Vue 不会对事件名进行任何大小写转换),以便于 HTML 正确识别

注意

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

如果需要在跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案,例如使用 Vue 3 官方推荐的状态管理库 Pinia

如果父级的事件处理函数是一个方法,抛出的数据会作为第一个参数传入这个方法

vue
<script>
  methods: {
    onEnlargeText(enlargeAmount) {
      this.postFontSize += enlargeAmount
    }
  }
</script>
<template>
  <blog-post @enlarge-text="onEnlargeText"></blog-post>
</template>
说明

如果父层使用的是内联事件处理器,同时要接收子组件抛出数据,那么可以通过 $event 访问到被抛出的这个值。

html
<blog-post @enlarge-text="postFontSize += $event"></blog-post>

Vue 3 目前为组件提供一个 emits 选项(它和选项 props 作用类似),用于定义/声明子组件可以向其父组件触发的事件类型

该选项可以采用数组(以事件名称作为元素)模式,也可以采用对象模式(和 props 定义里的验证器类似,在对象中可以为事件设置验证器)。

在对象模式中,每个 property 的值可以为 null 或验证函数,该函数将接收调用 $emit 时所抛出的数据。验证函数应返回布尔值,以表示事件是否合法。

js
const app = createApp({})
// 数组语法
app.component('todo-item', {
  emits: ['check'],
  created() {
    this.$emit('check')
  }
})
// 对象语法
app.component('reply-form', {
  emits: {
    // 没有验证函数
    click: null,
    // 带有验证函数
    // payload 为触发自定义事件的同时所抛出的数据
    submit: payload => {
      if (payload.email && payload.password) {
        return true
      } else {
        console.warn(`Invalid submit event payload!`)
        return false
      }
    }
  }
})
推荐

强烈建议使用选项 emits 记录每个组件可以触发的事件

因为 Vue 3 移除了 .native 修饰符,对于在子组件中的选项 emits 定义的事件,Vue 现在将把它们作为原生事件监听器(这一点和 Vue 2 不同,Vue 2 对于在使用组件时才添加的事件监听,都认为是监听自定义事件),添加到子组件的根元素中(除非在子组件的选项中设置了 inheritAttrs: false)。

vue
<template>
  <!-- 监听从组件触发的自定义事件 close 和原生事件 click -->
  <my-component
    v-on:close="handleComponentEvent"
    v-on:click="handleNativeClickEvent"
  />
</template>
<script>
// component
app.component('MyComponent', {
  emits: ['close']
})
</script>

如果使用的是 <script setup> 则可以通过组合式 API defineEmits() 来声明组件可触发的自定义事件

需要留意的一点是,defineEmits()不能在子函数中使用,它必须直接放置在 <script setup>顶级作用域

在组件上使用 v-model

在使用自定义的输入型组件时,Vue 为它们进行优化,也支持使用 v-model 实现双向绑定。

vue
<template>
  <custom-input v-model="searchText"></custom-input>
  <!-- 等价于以下代码 -->
  <custom-input
    :model-value="searchText"
    @update:model-value="searchText = $event"
  ></custom-input>
</template>

在外部(引用该组件时,在组件的标签上)通过指令 v-model 双向绑定需要同步的变量,它的数据默认作为一个名为 modelValue 的 prop 传递到组件内(因此记得在组件内部,需要在 props 选项里声明 modelValue 作为 prop),作为子组件内部表单元素的 value 属性的值。

只需要在这些组件的内部进行相应的处理:

  • 在组件内使用指令 v-bind表单元素的 value 属性绑定到以上声明的 prop 变量 modelValue 上,即 v-bind:value="modelValue"(这样就可以将外部传进来的值作为表单的值,实现与外部数据的「同步」)
  • 在组件内使用指令 v-on 监听表单元素 input 输入事件,并通过抛出 update:modelValue 事件 $emit('update:modelValue', $event.target.value) 向外传递相应的数据(如果表单是 checkbox 应该传递的数据是 $event.target.checked

然后外部就会接收抛出的事件,并对指令 v-model 绑定的变量基于抛出的数据进行改变(即数据的修改的操作还是在父层完成)

js
// component
app.component('custom-input', {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  template: `
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    >
  `
})
html
<custom-input v-model="searchText"></custom-input>
注意

Vue 2 提供了 .sync 修饰符,通过 v-bind:propName.sync="variable"$emit('update:propName', newValue) 实现类似双向绑定的功能;但在 Vue 3 中 .sync 修饰符已被移除,而现在 Vue 3 使用类似的语法(组件也是抛出以 update: 为前缀的事件)实现的。

默认传递的 prop 变量是 modelValue 默认监听抛出的事件是 update:modelValue,这种方法更通用,对于 type="text"type=checkbox 其语义都不会混淆。

如果需要修改 modelValue 这个默认值,在 Vue 2 中可以通过选项 model 进行配置,但在 Vue 3 中 model 选项已移除,作为替代可以在父级中v-model 添加参数,这个 argument 就是 prop 变量名和需要监听的事件名(加上 update: 前缀)

html
<!-- 使用组件 -->
<my-component v-model:title="bookTitle"></my-component>
<!-- 是以下的简写 -->
<my-component :title="bookTitle" @update:title="bookTitle = $event" />
js
// 组件
app.component('my-component', {
  props: ['title'],
  emits: ['update:title'],
  template: `
    <input
      type="text"
      :value="title"
      @input="$emit('update:title', $event.target.value)">
  `
})

在 Vue 3 中利用特定的 prop(通过调用组件时使用指令 v-model 的参数设置)和抛出相应事件,可以在单个组件上创建多个 v-model,从而实现多个 prop 的双向绑定

html
<!-- 使用组件,实现 first-name 和 last-name 的双向绑定 -->
<user-name
  v-model:first-name="firstName"
  v-model:last-name="lastName"
></user-name>
js
app.component('user-name', {
  props: {
    firstName: String,
    lastName: String
  },
  emits: ['update:firstName', 'update:lastName'],
  template: `
    <input
      type="text"
      :value="firstName"
      @input="$emit('update:firstName', $event.target.value)">
    <input
      type="text"
      :value="lastName"
      @input="$emit('update:lastName', $event.target.value)">
  `
})

在输入型组件上使用指令 v-model 时,不仅支持 Vue 内置的修饰符 .trim.number.lazy,Vue 3 还支持添加自定义的修饰符,添加到组件 v-model 的修饰符将通过 modelModifiers prop 提供给组件。

说明

修饰符应该在指令的参数后,而且修饰符支持多个链式使用

html
<!-- 使用自定义修饰符,实现字符串首字母大写 -->
<my-component v-model.capitalize="myText"></my-component>
js
// 组件
app.component('my-component', {
  props: {
    modelValue: String,
    // modelModifiers prop 这里我们提供了初始值(一个空对象)
    // 如果有设置自定义的修饰符,则可以在对象中获取到相应的属性
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    // 表单 input 事件的处理函数
    emitValue(e) {
      let value = e.target.value
      // 如果 v-model 有修饰符 capitalize 就对表单输入值实现首字母大写
      if (this.modelModifiers.capitalize) {
        value = value.charAt(0).toUpperCase() + value.slice(1)
      }
      // 抛出事件
      this.$emit('update:modelValue', value)
    }
  },
  template: `
    <input type="text"
      :value="modelValue"
      @input="emitValue">`,
  created() {
    // 当组件的 created 生命周期钩子触发时,会根据调用组件的情况对 modelModifiers prop 进行设置
    // 由于这个例子中 v-model 的修饰符为 capitalize,因此 modelModifiers 对象会包含 capitalize 属性
    console.log(this.modelModifiers) // { capitalize: true }
  }
})

如果 v-model 设置了参数,则添加到组件 v-model 的自定义的修饰符将通过 arg + Modifiers 形式的 prop 提供给组件。

html
<my-component v-model:description.capitalize="myText"></my-component>
js
app.component('my-component', {
  props: ['description', 'descriptionModifiers'],
  emits: ['update:description'],
  template: `
    <input type="text"
      :value="description"
      @input="$emit('update:description', $event.target.value)">
  `,
  created() {
    console.log(this.descriptionModifiers) // { capitalize: true }
  }
})

非 prop 属性

非 prop 的 attribute 是指未在组件的 propsemits 选项中显式声明的,或者 v-on 事件监听器,但在引用组件时传递给子组件的 attribute

这些 attribute 默认被添加到这个组件的根元素上,即在组件的模板中作为容器的第一层的 <tag>

如果希望 attribute 添加到组件的非根节点上,可以设定组件的选项 inheritAttrs: false,然后将特殊的变量 $attrs(这是一个包含所有非 prop 的 attribute 的对象)绑定 v-bind="$attrs" 到指定的节点上

也可指定某个 attribute 绑定到指定的元素 :attributeName="$attrs.propertyName"

js
app.component('date-picker', {
  inheritAttrs: false,
  template: `
    <div class="date-picker">
      <input type="datetime-local" v-bind="$attrs" />
    </div>
  `
})

还可以在模板的表达式中直接用 $attrs 访问到

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

如果使用了 <script setup> 需要一个额外的 <script> 块,采用选项式 API 来书写这个声明

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

除了在模板中通过 $attrs 访问到组件的所有透传 attribute,在 <script setup> 中也可以使用 useAttrs() API 来它

vue
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

而对于 setup() 透传的 attribute 会作为上下文对象 ctx 的一个属性暴露

js
export default {
  setup(props, ctx) {
    // 透传 attribute 被暴露为 ctx.attrs
    console.log(ctx.attrs)
  }
}

值得留意的一点是,虽然这里的 attrs 对象总是反映为最新的透传 attribute,但它并不是响应式的 (考虑到性能因素),所以不能通过侦听器 watch 去监听它的变化,作为替代方法可以使用 onUpdated() 使得在每次更新时结合最新的 attrs 执行副作用

说明

非 prop attribute 在 Vue 3 中除了包括 classstyle 这一类属性,还将 v-on(所添加的事件监听)也纳入其中,因此在 Vue 3 中移除$listeners 对象

vue
<template>
  <date-picker @change="submitChange"></date-picker>
</template>
js
app.component('date-picker', {
  created() {
    console.log(this.$attrs) // { onChange: () => {}  }
  }
})

关于 $attrs 有几点需要注意:

  • 和 props 有所不同,透传 attributes 在 JavaScript 中保留了它们原始的大小写,所以像 foo-bar 这样的一个 attribute 需要通过 $attrs['foo-bar'] 来访问。
  • @click 这样的一个 v-on 事件监听器将在此对象下被暴露为一个函数 $attrs.onClick
注意

由于 Vue 3 支持组件存在多个根节点,因此与 Vue 2 只允许单个根节点组件不同,具有多个根节点的组件不具有自动 attribute 回退行为,因此如果未显式绑定 $attrs,将发出运行时警告

js
// 这将发出警告
app.component('custom-layout', {
  template: `
    <header>...</header>
    <main>...</main>
    <footer>...</footer>
  `
})
// 没有警告,$attrs被传递到<main>元素
app.component('custom-layout', {
  template: `
    <header>...</header>
    <main v-bind="$attrs">...</main>
    <footer>...</footer>
  `
})

插槽

插槽 slot 是 Vue 实现的一套内容分发的 API,一般用于作为布局 layout 的组件中

在组件的模板中使用 <slot> 元素作为占位符

vue
<template>
  <!-- todo-button 组件 -->
  <button class="btn-primary">
    <slot></slot>
  </button>
</template>

然后在调用组件时,可以传递具体的内容到插槽指定的位置

vue
<todo-button>
  Add todo
</todo-button>

🔨 当组件渲染的时候,将会用给定的内容替换插槽,实现内容的分发

html
<!-- 最终渲染生成的 HTML -->
<button class="btn-primary">
  Add todo
</button>
提示

可以在插槽内预置一些默认内容,它只会在使用组件时,用户没有提供具体的内容时才被渲染,作为一种 fallback 后备方案

具名插槽

如果要将多个内容分发到组件的不同插槽中,可以在组件中设置多个具名插槽,即为插槽 <slot> 添加属性 name

提示

默认插槽的属性 name 的值为 default,也可以省略

BaseLayout.vue
vue
<template>
  <div class="container">
    <header>
      <!-- 具名插槽 header -->
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <!-- 具名插槽 footer -->
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

然后在使用组件时,通过 <template> 元素和 v-slot 指令,将内容分发到相应的插槽中

说明

如果在分发内容时没用指定插槽的名称,都会放到组件的默认插槽中

parent.vue
vue
<template>
  <base-layout>
    <!-- 将内容分发到具名插槽 header 中 -->
    <template v-slot:header>
      <h1>Here might be a page title</h1>
    </template>
    <!-- 将内容分发到默认插槽中 -->
    <template v-slot:default>
      <p>A paragraph for the main content.</p>
      <p>And another one.</p>
    </template>
    <!-- 将内容分发到具名插槽 footer 中 -->
    <template v-slot:footer>
      <p>Here's some contact info</p>
    </template>
  </base-layout>
</template>
提示

设置具名插槽的指令 v-slot:slotName简写形式 #slotName

parent.vue
vue
<template>
  <base-layout>
    <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>
  </base-layout>
</template>

然而,和其它指令如 v-bind:param 缩写 :paramv-on:eventName 缩写 @eventName 一样,该缩写只在其有参数的时候才可用,这意味着以下语法是无效的

vue
<template>
  <!-- This will trigger a warning -->
  <todo-list #="{ item }">
    <i class="fas fa-check"></i>
    <span class="green">{{ item }}</span>
  </todo-list>
  <!-- The right way -->
  <todo-list #default="{ item }">
    <i class="fas fa-check"></i>
    <span class="green">{{ item }}</span>
  </todo-list>
</template>

也支持在 v-slot 上使用动态指令参数,即在分发内容时,可以通过表达式/变量动态指定插槽的名称

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

作用域插槽

在父级组件中分化的内容,只能使用父级作用域中的数据,而不能访问子组件的数据。

如果要让分发的内容能够访问子组件中的数据,需要在子组件模板设置插槽时,将数据绑定到插槽标签 <slot> 的相应属性上,这些 attribute 称为插槽 props

todo-list.vue
vue
<template>
  <ul>
    <li v-for="( item, index ) in items">
      <slot :item="item"></slot>
    </li>
  </ul>
</template>

然后在父组件中,可以用指令 v-slot 的值来接收子组件「抛出」的数据,该值是包含所有插槽 prop 的一个对象,以下例子中将该对象命名为 slotProps 你可以使用其他名称

parent.vue
vue
<template>
  <todo-list>
    <template v-slot:default="slotProps">
      <span class="green">{{ slotProps.item }}</span>
    </template>
  </todo-list>
</template>

scoped slot
scoped slot

提示

如果组件只有一个插槽,即只有默认插槽时,在调用组件时可以v-slot 直接用在组件标签上,即把组件的标签当作插槽的模板 <template> 来使用,即以上例子父组件的简写形式

parent.vue
vue
<template>
   <!-- 简写形式 -->
  <todo-list v-slot:default="slotProps">
    <span class="green">{{ slotProps.item }}</span>
  </todo-list>
  <!-- 甚至省略 default 参数 -->
  <todo-list v-slot="slotProps">
    <span class="green">{{ slotProps.item }}</span>
  </todo-list>
</template>

但是以上简写只能将内容分发到一个插槽,只要出现多个插槽,请始终为所有的插槽使用完整的基于 <template> 的语法

提示

如果父组件在分发内容时只使用了插槽 prop 对象中某几个属性时,可以使用解构来简化代码

parent.vue
vue
<template>
  <todo-list v-slot="{ item }">
    <i class="fas fa-check"></i>
    <span class="green">{{ item }}</span>
  </todo-list>
  <!-- 解构也可以实现插槽 prop 属性的重命名,以下将 item 重命名为 todo -->
  <todo-list v-slot="{ item: todo }">
    <i class="fas fa-check"></i>
    <span class="green">{{ todo }}</span>
  </todo-list>
  <!-- 定义备用内容,用于插槽 prop 是 undefined 的情形 -->
  <todo-list v-slot="{ item = 'Placeholder' }">
    <i class="fas fa-check"></i>
    <span class="green">{{ item }}</span>
  </todo-list>
</template>

混入

混入 mixin 是指将组件选项中可复用的部分「抽取」出来成为一个对象(可以包含 computedmethodwatch 等),以供其他组件复用。

在组件(包括根组件)的选项 mixins 中设置该对象即可复用代码;或通过 app.mixin({}) 进行全局混入。

js
// 定义一个混入对象
const myMixin = {
  created: function () {
    this.hello()
  },
  methods: {
    hello: function () {
      console.log('hello from mixin!')
    }
  }
}
// 定义一个使用混入对象的组件
const Component = Vue.extend({
  mixins: [myMixin]
})
const component = new Component() // => "hello from mixin!"

当组件和混入对象含有同名选项时,这些选项将以恰当的方式进行「合并」(和 Vue2 规则一样)

提示

但是由于 Mixin 很容易引起 property 名冲突,而且不能向 mixin 传递任何参数而降低了它们的灵活性。

为了解决这些问题,让代码复用更高效,在 Vue 3 新增了一种通过逻辑关注点组织代码的新方法:组合式 API

模板引用

如果在使用组件时,为组件添加 ref 属性,则最后得到的响应式引用是组件的实例(而不是 DOM 节点)

注意

在父组件中获取到子组件的实例,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。

这使得在父组件和子组件之间创建紧密耦合,所以应该只在绝对需要时才使用组件引用。

大多数情况下应该首先使用标准的 props 和 emit 接口来实现父子组件交互。

有一个例外的情况,使用了 <script setup> 的组件是默认私有的,即一个父组件无法访问到一个使用了 <script setup> 的子组件中的任何东西,除非子组件在其中通过 defineExpose 宏显式暴露:

vue
<script setup>
import { ref } from 'vue'
const a = 1
const b = ref(2)
// 像 defineExpose 这样的编译器宏不需要导入
defineExpose({
  a,
  b
})
</script>

当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为 { a: number, b: number } (ref 都会自动解包,和一般的实例一样)。

生命周期函数

每个组件在被创建时都要经过一系列的初始化过程:设置数据监听、编译模板、将实例挂载到 DOM,并在数据变化时更新 DOM 等。

Vue 在这个过程中也会运行一些叫做生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会。

life cycle
life cycle

  • beforeCreate() 在实例生成之前
  • created() 在实例生成之后,可以访问响应性数据
  • beforeMount() 在模板渲染后,但还没挂载到 DOM 之前,可以访问 app.$el(其中 app 是 Vue 实例)虚拟 DOM
  • mounted() 实例挂载到 DOM 之后
  • beforeUpdate() 响应式数据发生变换,虚拟 DOM 执行重新渲染和更新前
  • updated() 响应式数据发生变换,虚拟 DOM 执行重新渲染和更新后
  • beforeUnmount() 即将要销毁 Vue 实例时,在销毁之前执行
  • unmounted() 销毁 Vue 实例之后执行
提示

可以通过调用函数 app.unmount() 销毁 Vue 实例(其中 app 是 Vue 实例),生命周期函数 beforeUnmount()unmounted() 将会执行

注意

生命周期钩子的 this 上下文指向调用它的当前活动的实例,一般是组件实例 vm,因此注意不要在这些生命周期函数的回调上(对于选项 property 而言也是)使用箭头函数,因为箭头函数并没有 this,导致 this 会作为变量一直向上级词法作用域查找,可能会导致错误。


Copyright © 2024 Ben

Theme BlogiNote

Icons from Icônes