Props 声明
一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute (关于透传 attribute,我们会在专门的章节中讨论)。
defineProps() 宏
<script setup>
import B from "./B.vue"
</script>
<template>
<B foo="123" />
</template>
<script setup>
const props = defineProps(['foo'])
console.log(props.foo) // => 123
</script>
<template>
<h1>B.vue {{ props.foo }}</h1>
</template>
在使用 <script setup>
的单文件组件中,props
可以使用 defineProps()
宏来声明。
通过 defineProps() 宏来声明的 props 会自动注入到组件实例中,我们可以直接在 template 中访问这些 props。
props 选项
A.vue 的内容保持不变:
<script setup>
import B from "./B.vue"
</script>
<template>
<B foo="123" />
</template>
<script>
export default {
props: ['foo']
}
</script>
<template>
<h1>B.vue {{ foo }}</h1>
</template>
在没有使用 <script setup>
的组件中,prop
可以使用 props
选项来声明。
传递给 defineProps() 的参数和提供给 props 选项的值是相同的,两种声明方式背后其实使用的都是 prop 选项。
通过这种非 setup 的写法添加的属性,也会自动注入到组件实例身上,因此,最终我们看到的页面渲染结果并没有发生变化,在模板 template 中我们可以直接通过属性名 foo 获取到父组件 A.vue 传递过来的属性值 123。
若想要在 setup 函数中获取到 props,那么我们可以通过 setup 函数的第一个参数来接收:
<script>
export default {
props: ['foo'],
setup(props) { // setup() 接收 props 作为第一个参数
console.log(props.foo) // => 123
return {
f: props.foo
}
}
}
</script>
<template>
<h1>B.vue</h1>
<h2>f: {{ f }}</h2>
<h2>foo: {{ foo }}</h2>
</template>
对象
<script setup>
import B from "./B.vue"
</script>
<template>
<B title="test" :likes="123" />
</template>
<script setup>
const {
title,
likes
} = defineProps({
title: String,
likes: Number
})
console.log(typeof title) // => string
console.log(typeof likes) // => number
</script>
<template>
<h1>B.vue</h1>
<h2>title: {{ title }}</h2>
<h2>likes: {{ likes }}</h2>
</template>
<script>
export default {
props: {
title: String,
likes: Number
}
}
</script>
<template>
<h1>B.vue</h1>
<h2>title: {{ title }}</h2>
<h2>likes: {{ likes }}</h2>
</template>
除了使用字符串数组来声明 prop 外,还可以使用对象的形式。
对于以对象形式声明中的每个属性:
- key 是 prop 的名称
- value 是该 prop 预期类型的构造函数
比如,如果要求一个 prop 的值是 number 类型,则可使用 Number 构造函数作为其声明的值。
对象形式的 props 声明不仅可以一定程度上作为组件的文档,而且如果其他开发者在使用你的组件时传递了错误的类型,也会在浏览器控制台中抛出警告。我们将在本章节稍后进一步讨论有关 prop 校验的更多细节。
传递 prop 的细节
Prop 名字格式
如果一个 prop 的名字很长,应使用 camelCase 形式,因为它们是合法的 JavaScript 标识符,可以直接在模板的表达式中使用,也可以避免在作为属性 key 名时必须加上引号。
<script setup>
defineProps({
greetingMessage: String
})
</script>
<template>
<h1>B.vue {{ greetingMessage }}</h1>
</template>
不推荐
<script setup>
import B from "./B.vue"
</script>
<template>
<B greetingMessage="hello" />
</template>
译:greeting 问候语
虽然理论上你也可以在向子组件传递 props 时使用 camelCase 形式,但实际上为了和 HTML attribute 对齐,我们通常会将其写为 kebab-case 形式
推荐
<script setup>
import B from "./B.vue"
</script>
<template>
<B greeting-message="hello" />
</template>
对于组件名我们推荐使用 PascalCase,因为这提高了模板的可读性,能帮助我们区分 Vue 组件和原生 HTML 元素。然而对于传递 props 来说,使用 camelCase 并没有太多优势,因此我们推荐更贴近 HTML 的书写风格。
静态 vs. 动态 Prop
静态写死
静态:<BlogPost title="My journey with Vue" />
这种写法 title="xxx"
是静态绑定 prop,相当于直接将写死的 xxx 字符串作为 title 的属性传递过去
动态变量
动态:<BlogPost :title="post.title" />
这种写法 :title="xxx"
或者 v-bind:title="xxx"
是动态绑定 prop,这种写法传递的内容是 xxx 变量或具体的值(不一定是字符串),它可以是动态的变量,也可以是写死的字面量。
与静态绑定 prop 不同的是:这种写法绑定的值 xxx 不一定是字符串;若采用静态的写法,那么 xxx 一定是字符串。
<script setup>
import B from "./B.vue"
const post = {
likes: 123,
isPublished: true,
commentIds: [11, 22, 33],
author: {
name: "xxx",
company: "hahaha",
},
}
</script>
<template>
<div style="display: flex">
<!-- 不传递任何值,默认 default -->
<B />
<!-- 都使用字面量传递 -->
<B
title="字面量"
:likes="42"
:is-published="false"
:comment-ids="[234, 266, 273]"
:author="{
name: 'Veronica',
company: 'Veridian Dynamics',
}"
/>
<!-- 多使用变量传递 -->
<B
title="变量"
:likes="post.likes"
:is-published="post.isPublished"
:comment-ids="post.commentIds"
:author="post.author"
/>
<!-- 直接使用 v-bind 来传递 -->
<B title="v-bind" v-bind="post" />
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: "default",
},
likes: {
type: Number,
default: 0,
},
isPublished: {
type: Boolean,
default: false,
},
commentIds: {
type: Array,
validator: (value) =>
Array.isArray(value) && value.every((v) => typeof v === "number"),
default: () => [],
},
author: {
type: Object,
validator: (value) =>
value &&
typeof value === "object" &&
"name" in value &&
"company" in value,
default: () => ({ name: "", company: "" }),
},
})
</script>
<template>
<div>
<h1>{{ title }}</h1>
<pre>{{ props }}</pre>
</div>
</template>
<style scoped>
pre {
width: 300px;
text-align: left;
border: 1px solid #ddd;
padding: 1rem;
border-radius: 1rem;
color: #fff;
background: #292d3e;
}
</style>
<!-- 写法1:多使用变量传递 -->
<B
title="变量"
:likes="post.likes"
:is-published="post.isPublished"
:comment-ids="post.commentIds"
:author="post.author"
/>
<!-- 写法2:直接使用 v-bind 来传递 -->
<B title="v-bind" v-bind="post" />
写法 1 和 写法 2 是完全等效的,其中写法 2 是:使用一个对象绑定多个 prop,如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind
单向数据流
所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。
另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告:
<script setup>
import B from "./B.vue"
</script>
<template>
<B foo="123" />
</template>
<script setup>
const props = defineProps(['foo'])
// ❌ 警告!prop 是只读的!
props.foo = 'bar'
</script>
<template>
<h1>B.vue {{ props.foo }}</h1>
</template>
记住一句话:谁的数据谁负责
这里的“谁”代指“组件”,“负责”代指“写”,简言之就是,组件 A 的数据只能由组件 A 修改,如果组件 A 将数据作为属性传递给了其它组件,比如组件 B、C,那么组件 B、C 只由读的份,没有写的份。
修改 prop
导致你想要更改一个 prop 的需求通常来源于以下两种场景:
- prop 被用于传入初始值
- 需要对传入的 prop 值做进一步的转换
(1)prop 被用于传入初始值
prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性。在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可:
<script setup>
import B from "./B.vue"
</script>
<template>
<B :initial-counter="0" />
</template>
<script setup>
import { ref } from "vue"
const props = defineProps(['initialCounter'])
// 计数器只是将 props.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
const counter = ref(props.initialCounter)
</script>
<template>
<h1>B.vue {{ counter }}</h1>
<button @click="counter++">increase</button>
</template>
const counter = ref(props.initialCounter)
将父组件传递过来的 initialCounter 属性的值取出来,丢到 counter 变量中,然后维护 counter 变量,这是非常常见的做法,并且这么做是完全没问题的。
- initialCounter 是 A 的数据
- counter 是 B 的数据
我们并没有直接修改 initialCounter,而是修改 counter,符合单向数据流规范。
(2)需要对传入的 prop 值做进一步的转换
需要对传入的 prop 值做进一步的转换。在这种情况中,最好是基于该 prop 值定义一个计算属性
<script setup>
import B from "./B.vue"
</script>
<template>
<B size=" Size " />
</template>
<script setup>
import { computed } from "vue"
const props = defineProps(['size'])
// 该 prop 变更时计算属性也会自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase())
</script>
<template>
<h1>B.vue {{ normalizedSize }} {{ normalizedSize.length }} {{ size.length }}</h1>
</template>
引用类型 prop
当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。
这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。
反模式
<script setup>
import B from "./B.vue"
import C from "./C.vue"
import { ref } from "vue"
const dataA = ref({
a: 1,
b: 2,
})
</script>
<template>
<B :data="dataA" />
<C :data="dataA" />
</template>
<script setup>
defineProps(['data'])
</script>
<template>
<h1>B.vue {{ data }}</h1>
</template>
<script setup>
defineProps(["data"])
</script>
<template>
<h1>C.vue {{ data }}</h1>
<button
@click="
() => {
data.a = 11
console.log('C clicked', data)
}
"
>
data.a = 11
</button>
</template>
上述提供的 demo 是一个反模式,不符合单向数据流,因为我们在 C 组件中,直接修改了来自父组件 A 传递过来的数据 data。
最佳实践
正确的做法应该是:
- 监听按钮点击事件
- 按钮被点击后通知父组件 A(因为数据来源自 A)
- 由 A 来决定是否修改 data
<script setup>
defineProps(["data"])
defineEmits(['updateData'])
</script>
<template>
<h1>C.vue {{ data }}</h1>
<button
@click="$emit('updateData', 'a', 11)"
>
data.a = 11
</button>
</template>
<script setup>
import B from "./B.vue"
import C from "./C.vue"
import { ref } from "vue"
const dataA = ref({
a: 1,
b: 2,
})
const handleUpdate = (k, v) => {
console.log(k, v)
// 由 A 决定是否修改 data
dataA.value[k] = v
}
</script>
<template>
<B :data="dataA" />
<C :data="dataA" @update-data="handleUpdate" />
</template>
从最终结果来看都是一样的,但是后者这种做法才是符合单向数据流规范的。试想一下其实不难理解,dataA 数据存在于 A 组件中,该数据是如何被使用的,只有 A 组件最为清楚,我们如果在某个子组件中直接把它给修改了,如果这个数据正在被其它组件使用,那么很可能会影响到其它组件造成影响。这种行为所导致的问题,调试起来成本极高,是不符合规范的。
Prop 校验
校验选项
Vue 组件可以更细致地声明对传入的 props 的校验要求。比如我们上面已经看到过的类型声明,如果传入的值不满足类型要求,Vue 会在浏览器控制台中抛出警告来提醒使用者。这在开发给其他开发者使用的组件时非常有用。
要声明对 props 的校验,你可以向 defineProps() 宏提供一个带有 props 校验选项的对象,例如:
<script setup>
const props = 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'
}
}
})
</script>
<template>
<h1>B.vue</h1>
<pre>{{ props }}</pre>
</template>
<style scoped>
pre {
width: 300px;
text-align: left;
border: 1px solid #ddd;
padding: 1rem;
border-radius: 1rem;
color: #fff;
background: #292d3e;
}
</style>
<script setup>
import B from "./B.vue"
</script>
<template>
<B :propA='1' propC='123' propF='success' />
</template>
当 prop 的校验失败后,Vue 在开发模式下会抛出一个控制台警告
<B />
如果缺少必填 required 属性,那么控制台会显示如下 Missing required prop 警告信息:
<B propC='123' propF='' />
如果填写的属性无法通过校验,那么控制台会提示我们 Invalid prop 无效属性:
<B propA='1' propC='123' propF='success' />
如果我们传递的数据类型错误,会提示类似 Expected Number with value 1, got String with value “1”.(期待的类型是 Number 类型 1,但是获取到的是一个 String 类型 “1”) 这样的警告信息
:::success
TIP**defineProps()**
宏中的参数不可以访问 **<script setup>**
中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。
:::
细节:
- 所有 prop 默认都是可选的,除非声明了
required: true
- 除
Boolean
外的未传递的可选 prop 将会有一个默认值undefined
Boolean
类型的未传递 prop 将被转换为false
- 这可以通过为它设置
default
来更改 - 例如:设置为
default: undefined
将与非布尔类型的 prop 的行为保持一致
- 这可以通过为它设置
- 如果声明了
default
值,那么在 prop 的值被解析为undefined
时,无论 prop 是未被传递还是显式指明的undefined
,都会改为default
值
如果使用了基于类型的 prop 声明 ,Vue 会尽最大努力在运行时按照 prop 的类型标注进行编译。举例来说,defineProps<{ msg: string }>
会被编译为 { msg: { type: String, required: true }}
。
校验选项中的 type 可以是下列这些原生构造函数:
- String
- Number
- Boolean
- Array
- Object
- Date
- Function
- Symbol
instanceof
另外,type 也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配。
export class Person {
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
}
<script setup>
import B from "./B.vue"
</script>
<template>
<B author="123" />
</template>
<script setup>
import { Person } from "./Person.js"
defineProps({
author: Person
// Vue 会通过 instanceof Person 来校验 author prop 的值是否是 Person 类的一个实例。
})
</script>
<template>
<h1>B.vue {{ author }}</h1>
</template>
<script setup>
import B from "./B.vue"
</script>
<template>
<B :author="{
firstName: 'da',
lastName: 'huyou'
}" />
</template>
<script setup>
import { Person } from "./Person.js"
import B from "./B.vue"
</script>
<template>
<B :author="new Person('da', 'huyou')" />
</template>
Boolean 类型转换
为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则。以带有如下声明的 组件为例:
<script setup>
defineProps({
disabled: Boolean
})
</script>
<template>
<h1>B.vue {{ disabled }}</h1>
</template>
<script setup>
import B from "./B.vue"
</script>
<template>
<!-- 等同于传入 :disabled="true" -->
<B disabled />
<!-- 等同于传入 :disabled="false" -->
<B />
</template>
<script setup>
defineProps({
disabled: [Boolean, Number]
})
</script>
<template>
<h1>B.vue {{ disabled }}</h1>
</template>
页面的最终渲染结果保持不变,无论声明类型的顺序如何,Boolean 类型的特殊转换规则都会被应用。