Vue 之在子组件中改变父组件的状态

一个 Vue 应用是由一个通过 new Vue 创建的根实例和可选的嵌套的、可复用的组件树组成。比如,一个应用可能是这样的:

1
2
3
4
5
6
7
8
根实例
└─ TodoList
├─ TodoItem
│ ├─ DeleteTodoButton
│ └─ EditTodoButton
└─ TodoListFooter
├─ ClearTodosButton
└─ TodoListStatistics

在这里,组件树节点之间形成了上下级的关系,也就是存在父组件与子组件这样的关系。如果我们想将父组件中的某些状态(数据)传递到子组件中,那么可以使用 v-bind 指令(或者 : 缩写)将父组件中的值绑定到子组件上,子组件使用 props 属性来接收这些传过来的值。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<Child :foo="foo" />
</template>

<script>
import Child from './Child'

export default {
name: 'Parent',
components: {
Child
},
data() {
return {
foo: 'hello'
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<p>I am a child component.{{ foo }}</p>
</template>

<script>
export default {
name: 'Child',
props: ['foo'],
data() {
return {}
}
}
</script>

需要注意的是,父组件与子组件之间的数据是单向下行绑定的,即父组件数据的更新会自动向下流动到子组件中,但是反过来则不行。这样可以防止子组件意外变更父组件的状态,从而导致应用的数据流向难以理解。同时,每次父组件发生变更时,子组件中所有的 prop 都会刷新为最新的值,这意味着我们不应该在一个子组件中直接修改 prop,如果直接修改,Vue 也会在浏览器的控制台中发出警告。

1
2
[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. 
Instead, use a data or computed property based on the prop's value ...

在 Vue 官网中给出了两种常见的试图变更一个 prop 的情形。其中一种情况是,porp 传递的是一个初始值,这个子组件在接下来希望将其作为一个本地的 prop 数据来使用,这时最好是定义一个本地的 data 属性并将这个 prop 用作其初始值,比如:

1
2
3
4
5
6
props: ['initialCounter'],
data: function () {
return {
counter: this.initialCounter
}
}

另外一种情况是,prop 以一种原始的值传入且需要进行转换,这时最好使用这个 prop 的值来定义一个计算属性,比如:

1
2
3
4
5
6
props: ['size'],
computed: {
normalizedSize: function () {
return this.size.trim().toLowerCase()
}
}

同时,官网也给出了一个警告信息,这个信息很重要,笔者之前就在这上面狠狠摔了一跤。官网写道:

注意在 JavaScript 中对象和数组是通过引用传入的,所以对于一个数组或对象类型的 prop 来说,在子组件中变更这个对象或数组本身将会影响到父组件的状态。

这句话的意思是说,如果父组件传递给子组件的值是一个对象或数组,那么如果在子组件中直接修改这个对象的属性或者数组的元素,父组件中的状态也会跟着变化。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div>
我是父组件的值:{{ foo }}
<Child :foo="foo" />
</div>
</template>

<script>
import Child from './Child'

export default {
name: 'Parent',
components: {
Child
},
data() {
return {
foo: {
bar: 'hello'
}
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
<p>我是子组件中的值:{{ foo }}</p>
<input v-model="foo.bar">
</div>
</template>

<script>
export default {
name: 'Child',
props: ['foo'],
data() {
return {}
}
}
</script>

当然这种直接修改父组件值的方式也是不推荐使用的,因为这种方式直接破坏了我们单向数据流的设定,容易造成数据流向难以理解的问题。一种更清晰的方式是:在子组件想要修改父组件的状态时,通过事件通知的方式告诉父组件我要修改哪个值,以及新值是多少,在父组件中需要预先定义好事件触发时对应触发的修改值的方法。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<div>
我是父组件的值:{{ foo }}
<Child :foo="foo" v-on:update:bar="updateBar" />
</div>
</template>

<script>
import Child from './Child'

export default {
name: 'Parent',
components: {
Child
},
data() {
return {
foo: {
bar: 'hello'
}
}
},
methods: {
// 父组件提供的修改 bar 值的方法
updateBar(val) {
this.foo.bar = val
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div>
<p>我是子组件中的值:{{ foo }}</p>
<input v-model="obj.bar">
</div>
</template>

<script>
export default {
name: 'Child',
props: ['foo'],
data() {
return {
obj: this.foo
}
},
watch: {
'obj.bar': function(val) {
// 监听 bar 值的变动,发生变动时触发当前实例上的 update:bar 事件
// 将新值传给事件监听器回调
this.$emit('update:bar', val)
}
}
}
</script>

当然这里还有更简便的写法,就是直接将更新的逻辑写到绑定事件上,同时使用 v-on 的语法糖简化写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div>
我是父组件的值:{{ foo }}
<Child :foo="foo" @update:bar="foo.bar = $event" />
</div>
</template>

<script>
import Child from './Child'

export default {
name: 'Parent',
components: {
Child
},
data() {
return {
foo: {
bar: 'hello'
}
}
}
}
</script>

这种父子组件进行“双向绑定”的模式比较固定,因此在 vue 2.3.0 就专门给这种模式新增了一个语法糖,即 .sync 修饰符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div>
我是父组件的值:{{ foo }}
<Child :foo.sync="foo" />
</div>
</template>

<script>
import Child from './Child'

export default {
name: 'Parent',
components: {
Child
},
data() {
return {
foo: {
bar: 'hello'
}
}
}
}
</script>

这种写法可以在我们用一个对象给子组件同时设置多个 prop 的时候,把对象中的每一个属性(比如这里的 bar 属性)都作为独立的 prop 传进去,然后各自添加用于更新的 v-on 监听器。