vue3:组件、Props、自定义事件

335 阅读9分钟

组件介绍

  • 组件结构
  • 组件注册
  • 组件名

组件结构

在 Vue 中支持单文件组件,也就是一个文件对应一个组件,这样的文件以 .vue 作为后缀。

一个组件会包含完整的一套结构、样式以及逻辑

<template>
  <button @click="count++">Count is: {{ count }}</button>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<style scoped>
button{
  padding: 15px;
}
</style>

setup

在 Vue3 初期,需要返回一个对象,该对象中包含模板中要用到的数据状态以及方法。

import { ref } from 'vue'
export default {
  setup() {
    // 在这里面定数据和方法
    const count = ref(0)
    function add() {
      count.value++
    }
    return {
      count,
      add
    }
  }
}

从 Vue3.2 版本开始,推出了 setup 标签,所有定义的数据状态以及方法都会自动暴露给模板使用,从而减少了样板代码。

另外 setup 标签语法还有一些其他的好处:

  • 有更好的类型推断
  • 支持顶级 await

scoped

定义组件私有的 CSS 样式,也就是说写的样式只对当前组件生效。如果不书写 scoped,那么样式就是全局生效。

除了单文件组件的形式来定义组件外,还可以使用对象的形式来定义组件:

export default {
  setup(){
    // 定义数据
    const count = ref(0)
    return { count }
  },
  template: `<div>{{count}}</div>`
}

下面是一个具体的例子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app"></div>
    <template id="my-template-element">
        <div>
            <h1>{{ count }}</h1>
            <button @click="count++">Increment</button>
        </div>
    </template>
    <script src="https://unpkg.com/vue@3.2.31"></script>
    <script>
      const { createApp, ref } = Vue;
      const App = {
        setup() {
          const count = ref(0);
          return { count };
        },
        template: "#my-template-element",
      };
      createApp(App).mount("#app");
    </script>
  </body>
</html>

组件注册

组件注册分为两种:

  • 全局注册
  • 局部注册

全局注册

使用 Vue 应用实例的 .component( ) 方法来全局注册组件,所注册的组件全局可用。

import { createApp } from 'vue'

const app = createApp({})

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

Component 方法是可以链式调用的

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

局部注册

局部注册就是在哪个组件里面用到了 TestCom 这个组件,那么就在当前的组件里面引入它,然后通过 components 配置项进行注册一下即可。

<template>
  <button @click="add">Count is: {{ count }}</button>
  <TestCom />
</template>

<script>
import { ref } from 'vue'
import TestCom from './components/TestCom.vue'
export default {
  // 局部注册
  components: {
    TestCom
  },
  setup() {
    // 在这里面定数据和方法
    const count = ref(0)
    function add() {
      count.value++
    }
    return {
      count,
      add
    }
  }
}
</script>

<style scoped>
button {
  padding: 15px;
}
</style>

如果是 setup 标签语法糖,那么只需要导入组件即可,不需要使用 components 配置项来进行注册,因为导入后在模板中使用时会自动注册:

<template>
  <button @click="add">Count is: {{ count }}</button>
  <TestCom />
</template>

<script setup>
import { ref } from 'vue'
import TestCom from './components/TestCom.vue'
// 在这里面定数据和方法
const count = ref(0)
function add() {
  count.value++
}
</script>

<style scoped>
button {
  padding: 15px;
}
</style>

实际开发的时候,推荐使用局部注册

  1. 全局注册无法很好的 tree-shaking
  2. 全局注册的组件在大型项目中无法很好的看出组件之间的依赖关系

组件名

推荐使用大驼峰作为组件名。

但是大驼峰在 DOM 内模板中无法使用

组件在应用层面,有三个核心知识点:

  1. Props
  2. 自定义事件
  3. 插槽

Props

  • Props
  • 自定义事件
  • 插槽

所谓 Props,其实就是外部在使用组件的时候,向组件传递数据。

下面我们定义了一个 UserCard.vue 组件:

<template>
  <div class="user-card">
    <img :src="user.avatarUrl" alt="用户头像" class="avatar" />
    <div class="user-info">
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  </div>
</template>

<script setup>
// defineProps 是一个宏,用于声明组件接收哪些 props
const user = defineProps({
  name: String,
  email: String,
  avatarUrl: String
})
</script>

<style scoped>
.user-card {
  display: flex;
  align-items: center;
  background-color: #f9f9f9;
  border: 1px solid #e0e0e0;
  border-radius: 10px;
  padding: 10px;
  margin: 10px 0;
}

.avatar {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  margin-right: 15px;
}

.user-info h2 {
  margin: 0;
  font-size: 20px;
  color: #333;
}

.user-info p {
  margin: 5px 0 0;
  font-size: 16px;
  color: #666;
}
</style>

在该组件中,接收 name、email 以及 avatrUrl 这三个 prop,使用 defineProps 来定义要接收的 props,defineProps 是一个宏,会在代码实际执行之前进行一个替换操作。

之后 App.vue 作为父组件,在父组件中使用上面的 UserCard.vue 组件(子组件)

<template>
  <div class="app-container">
    <!-- 父组件在使用 UserCard 这个组件的时候,向内部传递数据 -->
    <UserCard name="张三" email="123@gamil.com" avatar-url="src/assets/yinshi.jpg" />
    <UserCard name="莉丝" email="456@gamil.com" avatar-url="src/assets/jinzhu.jpeg" />
  </div>
</template>

<script setup>
import UserCard from './components/UserCard.vue'
</script>

<style scoped>
.app-container {
  max-width: 500px;
  margin: auto;
  padding: 20px;
}
</style>

使用细节

  1. 命名方面

组件内部在声明 props 的时候,推荐使用驼峰命名法:

defineProps({
  greetingMessage: String
})
<span>{{ greetingMessage }}</span>

不过父组件在使用子组件,给子组件传递属性的时候,推荐使用更加贴近 HTML 的书写风格:

<MyComponent greeting-message="hello" />
  1. 动态的 Props

在上面的快速入门示例中,我们传递的都是静态的数据:

<UserCard name="张三" email="123@gamil.com" avatar-url="src/assets/yinshi.jpg" />

所谓动态的 Props,指的就是父组件所传递的属性值是和父组件本身的状态绑定在一起的:

UserCard.vue

// defineProps 是一个宏,用于声明组件接收哪些 props
defineProps({
  user: {
    type: Object,
    required: true
  }
})
// App.vue
<template>
  <div class="app-container">
    <!-- 父组件在使用 UserCard 这个组件的时候,向内部传递数据 -->
    <UserCard :user="user" />
    <div class="input-group">
      <input type="text" placeholder="请输入新的名字" v-model="newName" />
      <button @click="changeName">确定修改</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UserCard from './components/UserCard.vue'
// 父组件所维护的一份数据
const user = ref({
  name: '张三',
  email: '123@gamil.com',
  avatarUrl: 'src/assets/yinshi.jpg'
})
const newName = ref('')

// 根据用户输入的新名字
// 更新 user 这个数据
function changeName() {
  if (newName.value.trim()) {
    user.value.name = newName.value
  }
}
</script>

<style scoped>
.app-container {
  max-width: 500px;
  margin: auto;
  padding: 20px;
}
.input-group {
  display: flex;
  margin-top: 20px;
}

input {
  flex: 1;
  padding: 10px;
  margin-right: 10px;
  font-size: 16px;
  border: 1px solid #ddd;
  border-radius: 5px;
}

button {
  padding: 10px 15px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  background-color: #0056b3;
}
</style>

还需要注意一个细节:如果想要向组件传递 非字符串 类型的值,例如 number、boolean、array... 必须通过动态 Props 的方式来传递,不然组件内部拿到的是一个字符串。

  1. 单向数据流

Props 会因为父组件传递的数据的更新而自身发生变化,这种数据的流向是从父组件流向子组件的,这个流向是单向的,这意味着你在子组件中不应该修改父组件传递下来的 Props 数据。

如果你强行修改,Vue 会在控制台抛出警告:

const props = defineProps(['foo'])

// ❌ 警告!prop 是只读的!
props.foo = 'bar'

有些时候,我们期望子组件在局部保存一份父组件传递下来的数据,这种情况下,就在子组件中新定义一个子组件的数据存储 Props 上面的值即可。

import {ref} from 'vue'
// defineProps 是一个宏,用于声明组件接收哪些 props
const prop = defineProps(['user', 'age'])
// 在子组件中,使用 ref 创建一个响应式数据
// 值为父组件传递过来的 props 值
const age = ref(prop.age)

还有一些时候,可能需要对父组件传递过来的数据进行二次计算,这个也是可以的,前提是在子组件内部自己创建一个计算属性,仅仅使用父组件传递的 props 值来做二次计算。

const props = defineProps(['size'])

// 该 prop 变更时计算属性也会自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase())

校验

子组件在声明 Props 的时候,是可以书写校验要求的,如果父组件在传递值的时候不符合 Props 值的要求,开发阶段 Vue 会在控制台给出警告信息。

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' }
    }
  },
  // 自定义类型校验函数
  // 在 3.4+ 中完整的 props 作为第二个参数传入
  propF: {
    validator(value, props) {
      // The value must match one of these strings
      return ['success', 'warning', 'danger'].includes(value)
    }
  },
  // 函数类型的默认值
  propG: {
    type: Function,
    // 不像对象或数组的默认,这不是一个
    // 工厂函数。这会是一个用来作为默认值的函数
    default() {
      return 'Default function'
    }
  }

例如我们对上面的 UserCard.vue 添加一个自定义的校验规则:

defineProps({
  user: {
    type: Object,
    required: true,
    // 自定义校验规则
    validator: (value) => {
      return value.name && value.email && value.avatarUrl
    }
  },
  age: {
    type: [Number, String],
    default: 18
  }
})

自定义事件

自定义事件的核心思想,子组件传递数据给父组件。

另外,父组件传递给子组件的数据,子组件不能去改, 此时子组件也可以通过自定义事件的形式,去通知父组件,让父组件即时的更新数据。

示例

这里以评分组件为例:

<template>
  <div class="rating-container">
    <span v-for="star in 5" :key="star" class="star" @click="setStar(star)">
      {{ rating >= star ? '★' : '☆' }}
    </span>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const rating = ref(0) // 表示几颗星

const emits = defineEmits(['update-rating'])

function setStar(newStar) {
  rating.value = newStar
  // 我们需要将最新的星星状态的值传递给父组件
  // 触发父组件的 update-rating 事件
  emits('update-rating', rating.value)
}
</script>

<style scoped>
.rating-container {
  display: flex;
  font-size: 24px;
  cursor: pointer;
}

.star {
  margin-right: 5px;
  color: gold;
}

.star:hover {
  color: orange;
}
</style>

在上面的评分组件中,我们需要将子组件 rating 的状态值传递给父组件,通过触发父组件所绑定的 update-rating 事件来进行传递。

<template>
  <div class="app-container">
    <h1>请对本次服务评分:</h1>
    <Rating @update-rating="handleRating" />
    <p v-if="rating > 0">你当前的评价为 {{ rating }} 颗星</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import Rating from './components/Rating.vue'
const rating = ref(0)

function handleRating(newRating) {
  // 更新父组件的数据就可以了
  rating.value = newRating
}
</script>

<style scoped>
.app-container {
  max-width: 600px;
  margin: auto;
  text-align: center;
  font-family: Arial, sans-serif;
}

p {
  font-size: 18px;
  color: #333;
}
</style>

父组件在使用子组件的时候,就为子组件绑定了自定义事件,本例中是 update-rating 事件,当子组件触发该事件时,就会执行该事件所对应的事件处理函数 handleRating. 事件处理函数的形参就能够接收到子组件传递过来的数据。

事件相关细节

在组件的模板中,可以直接使用 $emit 去触发自定义事件。例如上面的评分的组件例子,可以这么写:

<span v-for="star in 5" :key="star" class="star" @click="$emit('update-rating', star)">
  {{ rating >= star ? '★' : '☆' }}
</span>

和前面所介绍的 Props 类似,自定义事件也是支持校验的。这里的校验主要是对子组件要传递给父组件的值进行校验。

要添加校验,需要书写为对象的形式,对象的键是事件名,值是校验函数,校验函数所接收的参数就是 emit 触发事件时要传递给父组件的参数,需要返回一个布尔值来说明传递的值是否通过了校验。

<script setup>
const emit = defineEmits({
  // 没有校验
  click: null,

  // 校验 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>

例如还是拿刚才的评分组件来举例:

defineProps(['rating'])
const emits = defineEmits({
  'update-rating': (value) => {
    if (value < 1 || value > 5) {
      console.warn('传递的值有问题!!!')
      return false
    }
    return true
  }
})

function setStar(newStar) {
  // 我们需要将最新的星星状态的值传递给父组件
  // 触发父组件的 update-rating 事件
  emits('update-rating', 100)
}

组件相关链接:组件基础 | Vue.js

props相关链接:Props | Vue.js

自定义事件相关链接:组件事件 | Vue.js