组件通信:找到输入、输出和真正的 DOM
根实例可以有许多子实例。父组件拥有的状态通过 props 进入子组件;子组件通过 $emit(event, payload) 发出修改请求,父组件决定是否赋值。组件事件只通知注册在当前组件上的监听器,不会像 DOM 事件一样沿组件树自动冒泡。先顺着这个过程读一份旧输入组件,才容易定位为什么“输入框能打字,父组件却没更新”。
从完整组件观察一次回传
沿用实验环境,将下面内容保存为 components.html,在本地服务器打开这个文件。每个组件模板只有一个根元素;注册名使用短横线,避免浏览器 HTML 解析对大小写的处理造成混淆。
<!doctype html>
<meta charset="utf-8">
<div id="app">
<name-editor v-model="name" placeholder="姓名" @focus="focuses++">
<template v-slot:status="state">{{ state.length }} 个字符</template>
</name-editor>
<p id="result">{{ name }} / 聚焦 {{ focuses }} 次</p>
<button @click="name = ''">清空</button>
</div>
<script src="./node_modules/vue/dist/vue.js"></script>
<script>
Vue.component('name-editor', {
inheritAttrs: false,
props: { value: { type: String, default: '' } },
computed: {
inputListeners() {
return Object.assign({}, this.$listeners, {
input: event => this.$emit('input', event.target.value)
})
}
},
template: `<label>
姓名 <input v-bind="$attrs" :value="value" v-on="inputListeners">
<slot name="status" :length="value.length">等待输入</slot>
</label>`
})
new Vue({
el: '#app',
data() { return { name: 'Lin', focuses: 0 } }
})
</script>输入 Amy 后,原生 input 事件携带的是 DOM Event。inputListeners 中的适配函数把它转换成字符串,再发出组件 input 事件。父组件的 v-model 监听该事件,把 name 改成 Amy;新的 name 再作为 value prop 回到子组件。状态属于父组件,子组件没有第二份需要手动同步的 name。
Vue 2 默认的组件 v-model 相当于 :value="name" @input="name = $event"。如果旧组件设置 model: { prop: 'checked', event: 'change' },就改为 checked 输入与 change 输出;仍需显式声明 checked prop。Vue 3 的 modelValue/update:modelValue 不能直接替代此处名字,否则父组件会继续等待 input,产生“事件发了但没有人接”的故障。
包装组件为什么要分别处理属性和监听器
组件根元素是 label,而用户真正输入的是里面的 input。inheritAttrs: false 阻止未声明的普通属性自动落到根上;v-bind="$attrs" 将 placeholder 等属性送到 input。Vue 2 的 $attrs 不包含 class/style,也不包含已声明 props;class/style 的根元素合并规则单独存在,不能据此宣称组件所有样式都被转发到输入框。
$listeners 则持有父组件注册的组件监听器。这里复制它,再覆盖 input,目的是让 focus 原样转发,让 input 先转换负载。如果同时给 input 写 v-on="$listeners" 和一个额外的 input 回传函数,父组件可能收到原始 Event 与字符串两次通知。覆盖一个入口比叠加两条路径更容易保持“每次输入只写一次”的契约。
把父组件改成 @focus.native 并删除转发,会监听组件根 label 的原生 focus。输入框聚焦不等于 label 聚焦,focus 也不是普通冒泡事件,因此观察结果可能不再增加。.native 绑定的是根 DOM,而不是“自动找到组件里面想要的元素”。重构包装层后,显式转发能保持输入组件的外部契约。
插槽传的是渲染位置和参数
status 插槽由父组件提供内容,但子组件选择何时、在哪里调用它,并提供 length 参数。v-slot:status="state" 建立一个局部名字,state.length 来自子组件传入的值;父组件自己的 name 则仍按父作用域求值。这避免让父组件通过 $refs 偷读子实例内部字段。
v-slot 在 Vue 2.6 已可使用,旧的 slot/slot-scope 语法仍可能出现在存量代码中。把旧写法改成 v-slot 前,先确定名字属于哪个插槽、参数由谁提供,尤其不要把“子组件能看到的 data”当作父插槽模板里的自由变量。点击清空后 name 与 length 一同回到空值和 0;这也是检验作用域是否接对的简单变体。
初始副本、持续输入与显式保存
有些编辑表单需要“修改草稿,点击保存才提交”。此时 data() { return { draft: this.value } } 可以把 prop 用作初始值,但以后父 value 改变不会自动改 draft。维护时必须选择契约:外部更新是否覆盖草稿,还是保留用户修改并提示冲突。随手加一个双向 watch 容易把用户未保存内容覆盖掉。
.sync 是另一种显式赋值约定::title.sync="title" 等价于传入 title,并监听 update:title 后赋值。它不是允许子组件直接改 prop。对于对象 prop,即使只改了其中的嵌套字段,也可能直接改到父组件共享对象;没有开发警告不代表满足单向数据流。
跨越多个层级时,可使用 provide/inject 传递服务或已观察的对象。Vue 2 的 provide/inject 绑定本身不是响应式更新通道;若提供的是已被 data 观察的对象,读取它原有字段仍能响应变化。替换提供者的对象并不会让旧注入引用自动改指向新对象。因此维护时应先确认注入的是稳定服务还是共享状态,再决定在对象上修改还是重建实例。
把下面独立保存为 injection.cjs,用实例的 parent 选项建立父子关系。它不渲染 DOM,但可以观察注入引用与 computed 缓存:
const Vue = require('vue')
const parent = new Vue({
data() { return { session: { name: 'A' } } },
provide() { return { session: this.session } }
})
const child = new Vue({
parent,
inject: ['session'],
computed: { name() { return this.session.name } }
})
console.log(child.name) // A
parent.session.name = 'B'
console.log(child.name) // B:同一份已观察对象的字段变化
parent.session = { name: 'C' }
console.log(child.name, child.session === parent.session) // B false
parent.$destroy()子实例初始化时找到祖先提供的 session 并保存引用。第二次赋值创建了另一份 session,父实例切换到了新对象,但子实例手里的旧引用没有被重新注入。若期望保持响应,应修改稳定容器的已观察字段;若整个身份需要替换,则应设计一个稳定外壳,如 session.current,并让消费者读取这个字段。
练习:维护布尔开关契约
把组件改为可接受 checked 布尔值的开关:自定义 model 使用 checked/change,原生 checkbox 将 event.target.checked 作为负载。保留一个具名插槽显示“已选”或“未选”,并把 disabled 转发到真实 input。
初始 false,点击后父状态与原生 checked 都为 true;父组件再赋 false 后两者都复原。disabled=true 时真实用户点击不能切换;每次有效点击只发出一次布尔负载。不能把 'on' 或整个 Event 当成选中状态。