Props
所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。
另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告:
javascript
const props = defineProps(['foo'])
// ❌ 警告!prop 是只读的!
props.foo = 'bar'
导致你想要更改一个 prop 的需求通常来源于以下两种场景:
- prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性。在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可:
javascript
const props = defineProps(['initialCounter'])
// 计数器只是将 props.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
const counter = ref(props.initialCounter)
- 需要对传入的 prop 值做进一步的转换。在这种情况中,最好是基于该 prop 值定义一个计算属性:
javascript
const props = defineProps(['size'])
// 该 prop 变更时计算属性也会自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase())
更改对象 / 数组类型的 props
当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。
这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。
反模式
在 Vue 中,人们普遍认为改变props是反模式,而应该使用事件来通知父级它已被修改,以便数据流变化总是自上而下进行的。对于大多数简单情况都是如此,子组件只处理简单的输入。然而,在某些情况下,这种"反模式"并没有那么糟糕,实际上可能有更大的好处。
- 例子1
javascript
<!-- ✗ BAD -->
<template>
<div>
<input v-model="value" @click="openModal" >
</div>
</template>
<script>
export default {
props: {
value: {
type: String,
required: true
}
},
methods: {
openModal() {
this.value = 'test'
}
}
}
</script>
在子组件中这样做直接修改父组件传递的值显然会有问题,导致冲突。这样的做法显然是不被推荐的。
- 例子2
javascript
<!-- ✓ GOOD -->
<template>
<div>
<input v-model="value.id" @click="openModal">
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
required: true
}
},
methods: {
openModal() {
this.value.visible = true
}
}
}
</script>
例子2与例子1有很大不同。当你这样做时props.value.visible = true
以及v-model = props.valie.id
,你并不是在改变 props,而是在改变传递给 props 的内容。这意味着,对于父组件来说并不会更改 props 本身,而只会更改传递给 props 的内容。
Vue3官方文档
当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。
这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。
Vue2官方文档
注意在 JavaScript 中对象和数组是通过引用传入的,所以对于一个数组或对象类型的 prop 来说,在子组件中改变变更这个对象或数组本身将会影响到父组件的状态。
你可以注意到,在 Vue 3 中,这不是一个红色警告,而是一段提到在某些情况下是允许的。
简而言之,直接修改复杂类型的props的内容是有效的。
为什么有时反模式也是一个好的做法
javascript
const user = {
firstname: "John",
name: "Doe",
birth: {
year: 1993,
month: 1,
day: 1,
}
projects:[
{
name:"foo",
case: [
{
name: "case1",
tags: ["tag1", "tag2],
},
//...
]
},
//...
]
//...
}
想象一下,有一个组件 UserEditor
来使用 v-model 编辑用户信息,并使用一个包含完整用户信息的事件来从父组件更新它。但随着用户模型的增长,你希望使用子组件来编辑模型的某些部分(即使用子组件来进行模块划分,让逻辑更清晰):名称、出生日期、项目列表、案例、标签等等......
如果你采用"推荐"方式,你最终会让TagEditor
emit一个事件,该事件只会改变与组件相关的状态,替换相关标签。然后这个事件会被TagEditor
的父组件 CaseEditor
组件捕获,它应该再次转发自己的最新状态,依此类推直至UserEditor
组件,它会获得全新的内容,并且所有内容都会重新渲染。
糟糕的事情来了:要完成用户信息的编辑,你需要对每个事件转发函数都进行编码!每个子组件可能都有 2-3 个函数,并且都有同样多的机会犯错误并引入错误!令人沮丧,不是吗?
现在,考虑一下:如果每个组件都直接改变 props接收到的对象的内容(除了字符串、数字等简单类型)会怎样?
如果对象从一开始就是响应式的,那么就不再需要任何事件了!
- 举个例子
直接修改复杂类型的props(反模式):
javascript
<template>
<div>
<input type="range" v-model.number="point.x" >
<input type="range" v-model.number="point.y" >
</div>
</template>
<script>
export default {
props: {
point: {
type: Object,
required: true
}
},
}
</script>
严格遵循单向数据流:
javascript
<template>
<div>
<input type="range" v-model.number="x" >
<input type="range" v-model.number="y" >
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
required: true
}
},
data() {
return {
x: this.value.x,
y: this.value.y,
};
},
watch: {
// Duplicate; could create a `mounted` method and use `$watch` to watch everything
x(newVal) { this.$emit('input', { x: newVal, y: this.y } },
y(newVal) { this.$emit('input', { x: this.x, y: newVal } },
}
}
</script>
显然反模式更加清晰简单。
在Vue ESLint的no-mutating-props
规则中,也额外新增支持配置允许复杂类型的props突变。这也都是社区开发者们的诉求。
此特性得到许多开发者的支持:github.com/vuejs/eslin...
官方规则最后也支持了此配置:github.com/vuejs/eslin...
个人最佳实践
在以下情况下使用严格遵循单向数据流,使用event的方式更新数据:
- 正在编辑基础类型的值(数字、字符串等)。
- 对象或数组的结构非常简单,并且不存在子组件编辑对象或数组。
- 当组件应该是可重用的并且它正在编辑的值是真正原子的(即子字段在整个对象完成修改前不能单独修改)。
- 当复杂的修改不是由用户操作直接触发时(因为在这种情况下,在调试时确实需要确定修改发生的位置)。
何时使用突变props模式:
- 当组件被视为与其父组件紧密耦合且主要是"代码分割"的子组件时。
- 当状态的子字段可以修改并且不会影响其他字段。
- 当所有修改都是用户操作的直接结果时(如果组件在用户操作后没有直接更改父状态,则在出现错误时你可能无法跟踪修改发生的位置)。
单向数据流是有好处,但是在现实的项目中个人认为并不是所有场景都需要严格遵循单向数据流,某些场景反而会因此增加更多工作量甚至代码更加难以维护。
正如Medium博客上的一个经验丰富的开发者所说:
我作为自由开发者已经有 6 年了,作为业余爱好者也有 10 年了。我已经做过十几个 Vue 项目,Vue2 和 Vue3,无论是否有composition API。我见过许多"严格遵循单向数据流"而使用事件模式的项目最终都难以维护和重构。事件处理中反而总是存在一些微小的错误,导致错误难以发现、重现和修复......
我从 3 年前开始使用"突变props模式",我可以向你保证代码更简单了,短了 1.5倍,而且缺少事件跟踪从来都不是问题,因为我们从未遇到过使用"突变props模式"而不知道状态在哪里发生了变化的情况......此外,Vue所鼓励的拆分复杂的组件,因为"突变props模式"变得更加容易就能做到,只需复制粘贴父组件中的代码即可!子状态的响应性会在父状态中体现,拆分大组件基本上就是创建一个新文件,然后复制粘贴相关部分即可。
代码也更具可读性。唯一的缺点是向后辈解释我们为什么要这么做。不过好的文档应该可以解决这个问题。此外针对使用"突变props模式"可以增加诸如标签等形式来告诉开发者该props会被修改,将会发生变化。
当有人告诉你某事不好时,最重要的是理解原因。复杂类型的Props突变显然不是 Vue 最初的"功能",而更多的是一个偶然的副作用。但这个副作用确实可以简化你的代码。另外,你可以认为它是稳定的(新版本的 Vue 不会删除它),因为深度克隆每个 props 对性能来说将是灾难性的,最简单的就是按原样传递对象。
参考:
Vue.js props mutating : how an anti-pattern could be considered a good practice.