Vue组件

浮生半日闲 发布于 2023-09-30 29 次阅读


组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。

1、定义组件

单文件组件(SFC)

当使用构建步骤时,我们一般会将 Vue 组件定义在一个单独的 .vue 文件中,这被叫做单文件组件 (简称 SFC),推荐为子组件使用 PascalCase 的标签名。

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

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

const count = ref(0)
</script>

javascript组件

当不使用构建步骤时,一个 Vue 组件以一个包含 Vue 特定选项的 JavaScript 对象来定义:

import { ref } from 'vue'

export default {
    setup() {
        const count = ref(0)
        return { count }
    },
    // 这里的模板是一个内联的 JavaScript 字符串,Vue 将会在运行时编译它
    template: `
      <button @click="count++">
        You clicked me {{ count }} times.
      </button>`
    // 也可以针对一个 DOM 内联模板:
    // template: '#my-template-element'
}

2、组件注册

全局注册

全局注册的组件可以在应用的任意组件的模板中使用。注册时使用vue应用实例的component方法即可:

import { createApp } from 'vue'
import App from './App.vue'
import MyComponent from './MyComponent.vue'

const app = createApp(App)
app.component('MyComponent', MyComponent)

局部注册

局部注册的组件需要在使用它的父组件中显式导入,并且只能在该父组件中使用。它使组件之间的依赖关系更加明确。

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

<template>
  <ComponentA />
</template>

3、Props声明

一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,声明方式如下:

<script setup lang="ts">
// 字符串数组
// const props = defineProps(['foo'])

// 对象形式
// const props = defineProps({
//     title: String
// })

// 类型声明
const props = defineProps<{
    title: String,
    age?: number,
    dispalyName?: String
}>()

// 将类型的声明移动到单独的接口中
// interface Props {
//     title: String,
//     age?: number
// }

// const props = defineProps<Props>()
</script>

父组件传递prop方式如下:

<template>
    <!--  静态传值  -->
    <DemoComponent title="标题" dispaly-name="显示名称"/>

    <!--  
        根据一个变量的值动态传入
    -->
    <DemoComponent :title="title" />

    <!--  
        1、支持表达式 
        2、任何类型的值都可以作为 props 的值被传递
        3、当传递数字、boolean、数组、对象常量的时候,也需要使用:进行绑定
    -->
    <DemoComponent :title="'title: ' + title" :age="15" :ids="[1, 2, 3]" :obj="{t: 1}" />

    <!--
        可以使用没有参数的 v-bind,传递一个对象来绑定多个prop、
        等价于:<DemoComponent title="title" :age="15" />
    -->
    <DemoComponent v-bind="{title: 'title', age: 15}" />
</template>

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

const title = ref('')
</script>

注意:

(1)prop的名称应使用 camelCase 形式;

(2)虽然prop名称是 camelCase 形式,但是组件传递props时应使用kebab-case 形式,主要是为了保证和 HTML attribute 对齐;

(3)所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递;

(4)子组件的prop是只读的,且每次父组件更新后,所有的子组件中的 props 都会被更新到最新值;因此,不能在子组件中去更改一个 prop;如果需要进行调整,最好是新定义一个局部数据属性,或者使用computed做进一步的转换。

prop校验方式:

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

校验选项中的 type 可以是:String、Number、Boolean、Array、Object、Date、Function、Symbol这些原生构造函数,也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }
}

defineProps({
  author: Person
})

4、事件

$emit

在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件 。

<button @click="$emit('beforeSubmit')">click me</button>

defineEmits

在<script setup>中触发事件,需要使用defineEmits来显示的声明需要触发的事件,不能使用$emit;

<script setup lang="ts">
// 数组字符串声明触发的事件名称
// const emit = defineEmits(["beforeSubmit"])

// 对触发事件的参数进行验证
// const emit = defineEmits({
//     // 没有校验
//     click: null,

//     // 通过返回值为 `true` 还是为 `false` 来判断验证是否通过
//     beforeSubmit: ({ email, password }) => {
//         if (email && password) {
//         return true
//         } else {
//         console.warn('Invalid submit event payload!')
//         return false
//         }
//     }
// })

/**
 * 基于类型进行申明
 * e:表示触发事件的名称
 * 后续参数表示事件的参数
 */
const emit = defineEmits<{
    (e: 'beforeSubmit'): void,
    (e: 'update', name: string): void
}>() 
</script>

父组件监听

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

<DemoComponent @before-submit="callback" />

注意:在子组件中,以 camelCase 形式命名事件;在父组件中,以kebab-case 形式来监听。

5、v-model

双向绑定

v-model 可以在组件上使用以实现双向绑定。默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件,但是我们可以通过给 v-model 指定一个参数来更改这些名字。

子组件写法:

<template>
    <div @click="updateModelValue">v-model双向绑定数据:{{ props.modelValue }}</div>

    <div @click="updateTitle">指定参数名称:{{ props.title }}</div>
</template>

<script setup lang="ts">
const props = defineProps(['modelValue', 'title'])
const emits = defineEmits(['update:modelValue', 'update:title'])

/**
 * 修改父组件指定的v-model的值
 */
const updateModelValue = () => {
    emits('update:modelValue', 'new Name');
}

/**
 * 改变指定参数名称的值
 */
const updateTitle = () => {
    emits('update:title', 'new title');
}
</script>

父组件:

<template>
    <!--  双向绑定  -->
    <DemoComponent v-model="name" />

    <!--  等价于上面的v-model="name"  -->
    <DemoComponent
        :modelValue="name"
        @update:modelValue="newValue => name = newValue"
    />

    <!--  指定参数名称  -->
    <DemoComponent v-model:title="title" />

    <!--  等价于上面的v-model:title="title"  -->
    <DemoComponent
        :title="title"
        @update:title="newValue => title = newValue"
    />

    <!--  可以同时有多个,会同步不同的 prop  -->
    <DemoComponent v-model="name" v-model:title="title"/>
</template>

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

const title = ref('')
const name = ref('')

</script>

定义修饰符

在处理v-model时,我们可以定义一些修饰符,为了能够处理这些修饰符,我们需要在子组件的prop中定义对应的参数来接收指定的修饰符。

父组件:

<template>
    <!--  处理修饰符  -->
    <DemoComponent v-model.capitalize="name" />

    <!--  带参数名称的处理修饰符  -->
    <DemoComponent v-model:title.capitalize="title" />
</template>

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

const title = ref('')
const name = ref('')

</script>

子组件:


<script setup lang="ts">
const props = defineProps({
    modelValue: String,
    // 接收v-model定义的修饰符
    modelModifiers: { default: () => ({}) },

    title: String,
    // 接收指定参数定义的修饰符,格式为:参数名 + "Modifiers"
    titleModifiers: { default: () => ({}) }
})

console.log(props.modelModifiers) // { capitalize: true }
console.log(props.titleModifiers) // { capitalize: true }
</script>

6、插槽

子组件:

<template>
    <div class="slot_wrapper">
        <slot :info="defaultInfo">
            <!--  可以没有默认内容  -->
            默认内容,当父组件没有提供任何插槽内容时,将显示该信息
        </slot>

        <!--  
            使用name属性,指定具名插槽,父组件使用时需指定插槽名称  
        -->
        <br />
        <slot name="header"></slot>
        <br />
        <slot name="content" :info="info" :obj="defaultInfo"></slot>

        <br />
        <!--
            作用域插槽
            可以将子组件的数据共享给父组件,像对组件传递 props 那样,向一个插槽的出口上传递 attributes
        -->
        <slot name="msg" :info="info" :obj="defaultInfo"></slot>
    </div>

    <p />
</template>

<script setup lang="ts">
const info = "子组件消息";
const defaultInfo = {
    text: '显示消息',
    value: 0
}

</script>

父组件:

<template>
    <!--  不提供插槽数据,此时会显示默认内容  -->
    测试一:<DemoComponent></DemoComponent>

    测试二:
    <DemoComponent>
        父组件提供了插槽数据,将不会显示默认内容
    </DemoComponent>

    测试三:
    <DemoComponent>
        <!--    
            指定插槽名称,使用具名插槽   
            格式为:v-slot:[插槽的名称]
        -->
        <template v-slot:header>
            我是header插槽内容
        </template>

        <!--  v-slot可以简写成#  -->
        <template #content>
            我是content插槽内容
        </template>

        <!--  
            #default代表默认插槽  
            并且当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容
        
        <template #default>
            我是默认插槽
        </template>
        -->

        <div>
            我是位于顶级的非 template 节点,视为默认插槽的内容,和#default写法意义相同
        </div>
    </DemoComponent>

    测试四:
    <DemoComponent>
        <!--  
            动态插槽名称
            注意这里的表达式和动态指令参数受相同的语法限制
        -->
        <template v-slot:[dynamicHeaderName]>
            我是header插槽内容
        </template>

        <template #[dynamicSlotName]>
            我是content插槽内容
        </template>
    </DemoComponent>

    测试五:
    <DemoComponent>
        <!--  接受所有具名插槽上的消息数据  -->
        <template #msg="info">
            子组件全部内容:{{ info }} -- {{ info.info }}
        </template>

        <!--  显示的指定需要接受消息的名称  -->
        <template #content="{ info }">
            子组件指定内容:{{ info }}
        </template>

        <!--  默认插槽内容  -->
        <template #default="{ info }">
            {{ info }}
        </template>
    </DemoComponent>

    测试六:
    <DemoComponent v-slot="{ info }">
        {{ info }}
        默认插槽传递的消息可以直接放在组件上进行接收。
        但是如有有其他的具名插槽需要展示的时候,则需要通过上面template方式进行接收。
    </DemoComponent>
</template>

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

const dynamicSlotName = "content";
const dynamicHeaderName = "header";
</script>

最终页面输出效果:

测试一:
默认内容,当父组件没有提供任何插槽内容时,将显示该信息


测试二:
父组件提供了插槽数据,将不会显示默认内容


测试三:
我是位于顶级的非 template 节点,视为默认插槽的内容,和#default写法意义相同

我是header插槽内容
我是content插槽内容
测试四:
默认内容,当父组件没有提供任何插槽内容时,将显示该信息
我是header插槽内容
我是content插槽内容
测试五:
{ "text": "显示消息", "value": 0 }

子组件指定内容:子组件消息
子组件全部内容:{ "info": "子组件消息", "obj": { "text": "显示消息", "value": 0 } } -- 子组件消息

测试六:
{ "text": "显示消息", "value": 0 } 默认插槽传递的消息可以直接放在组件上进行接收。 但是如有有其他的具名插槽需要展示的时候,则需要通过上面template方式进行接收。

7、依赖注入

通常情况下,我们需要将数组从父组件传递到子组件时,会使用props。但是比如有多层级组件依赖,如A-->B-->C--D...;这种情况下,如果使用props,就会存在下面问题:

(1)必须沿着组件链逐级传递下去,这会非常麻烦;

(2)链路中间组件(如B)根本就不关心这些props,但为了使C组件能访问到这些props,仍需要定义并向下进行传递;

(3)如果组件链路非常长,可能会影响到更多这条链路上的组件。

这种现象称作为prop逐级传透。依赖注入功能则是为了帮助我们解决这个问题。

<script setup lang="ts">
import { provide, inject, ref, readonly } from 'vue'
import type { InjectionKey } from 'vue'

/**
 * 使用provide函数为组件后代提供数据
 * 第一个参数: 注入名,可以是一个字符串或者一个Symbol,后代组件会用注入名查找期望注入的值
 * 第二个参数: 注入值,可以是任何类型,包括响应式的状态
 * 
 * 除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖:
 * const app = createApp({})
 * app.provide('message', 'hello!')
 * 在应用级别提供的数据在该应用内的所有组件中都可以注入。
 * 
 * 
 * 注意:
 * 1. 父组件的provide函数调用必须在render函数之前,否则无效
 * 2. 后代组件使用inject函数注入数据,注入名必须和父组件的provide函数的第一个参数一致
 * 3. 后代组件的inject函数调用必须在setup函数之前,否则无效
 * 4. 注入的数据是响应式的,如果注入的是一个对象,那么后代组件修改对象属性,父组件也会同步更新
 * 6. 不要在provide和inject函数中使用任何计算属性,它们只会增加复杂度,并且会创建一个依赖链,使得更新变量变得困难
 */
provide('message', 'hello');

/**
 * 尽可能将任何对响应式状态的变更都保持在供给方组件中,确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护
 */
const location = ref('North Pole')
function updateLocation() {
  location.value = 'South Pole'
}

provide('location', {
  location,
  updateLocation
})

/**
 * 类型标注
 * 
 * 1、最好使用 Symbol 来作为注入名以避免潜在的冲突
 * 2、建议将注入 key 的类型放在一个单独的文件中,这样它就可以被多个组件导入。
 */
const key = Symbol() as InjectionKey<string>
provide(key, 'foo')    

const foo = inject<string>(key);

/**
 * 使用readonly函数将一个响应式状态转换为只读状态,确保提供的数据不能被注入方的组件更改
 */
const count = ref(0)
provide('read-only-count', readonly(count))

/**
 * 使用inject函数获取注入的数据
 * 
 * 注意:
 * 1. 如果提供的值是一个 ref,注入进来的会是该 ref 对象,而不会自动解包为其内部的值。
 */
const message = inject('message')
</script>