前言
【Vue菜鸟日记】系列文章是一个前端菜鸟对 Vue 的日常学习与总结,属个人理解,过程及思路优先。如果存在错误,请仔细甄别与赐教。
背景
笔者在写一个配置 uniapp 应用的底部图标功能时,由于底部图标最多可以放置 5 个,笔者刚开始直接复制了 5 份,并绑定了 5 种不同的变量。这样一来,相同的代码没有进行组件化,导致页面看起来很乱,代码行数也很多。于是开始进行组件化,由于笔者之前并没有自己封装过组件,探索过程中出现了很多问题及疑问,特此记录。
过程
封装组件
如果对这部分内容有疑问,可以先阅读Vue 官方文档 https://v2.cn.vuejs.org/v2/guide/components.html
新建一个组件的 TabBarIconConfig.vue
文件,在文件的 template
标签中放置重复的代码内容(注意 template
下只能有一个节点),声明组件的名称及接收参数
<template>
<el-row>
<el-form-item label="页面路径 1" >
<el-select v-model="tabbar.pagePath1">
<el-option v-for="item in pagePathOptions" :key="item.key" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="该项是否显示 1" >
<el-switch v-model="tabbar.visible1" > </el-switch>
</el-form-item>
<el-form-item label="tab 上按钮文字 1" >
<el-input v-model="tabbar.text1" placeholder="请输入tab 上按钮文字 1" ></el-input>
</el-form-item>
<el-form-item label="图片路径 1" >
<el-select v-model="tabbar.iconPath1">
<el-option v-for="item in materialOptions" :key="item.key" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="选中时的图片路径 1" >
<el-select v-model="tabbar.selectedIconPath1">
<el-option v-for="item in materialOptions" :key="item.key" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-row>
</template>
<script>
export default {
name: 'TabBarIconConfig',
props: {
tabbar: Object, //这个是表单数据对象
materialOptions: Array, //这个select 选项数据来自于 api,是可以共用的,所以传入进来
pagePathOptions: Array //这个select 选项数据来自于 api,是可以共用的,所以传入进来
}
}
</script>
然后在父组件中引入和局部注册这个组件,并在父组件中使用子组件
<script>
//引入
import TabBarIconConfig from './components/TabBarIconConfig.vue'
//局部注册
export default {
components: {
TabBarIconConfig
},...
//使用组件替换原始内容
<tab-bar-icon-config
:tabbar="tabbar"
:pagePathOptions="pagePathOptions"
:materialOptions="materialOptions">
</tab-bar-icon-config>
写到这里刷新页面试了一下,内容能正常显示出来,同时修改向服务器提交了一下,功能竟然正常,但这并没有结束。仔细看组件代码中 的 template 部分:
<el-select v-model="tabbar.pagePath1">
<el-input v-model="tabbar.text1"
<el-switch v-model="tabbar.visible1"
<el-select v-model="tabbar.iconPath1">
<el-select v-model="tabbar.selectedIconPath1">
这里只能修改 5 个重复部分的 1 个区域,如果其他部分也使用<tab-bar-icon-config>
标签,其他部分的变量无法更新,于是对组件内容进行修改。
<template>
<el-row>
<el-form-item label="页面路径" >
<el-select v-model="pagePath">
<el-option v-for="item in pagePathOptions" :key="item.key" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="该项是否显示" >
<el-switch v-model="visible" > </el-switch>
</el-form-item>
<el-form-item label="tab 上按钮文字" >
<el-input v-model="text" placeholder="请输入tab 上按钮文字" ></el-input>
</el-form-item>
<el-form-item label="图片路径" >
<el-select v-model="iconPath">
<el-option v-for="item in materialOptions" :key="item.key" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="选中时的图片路径" >
<el-select v-model="selectedIconPath">
<el-option v-for="item in materialOptions" :key="item.key" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-row>
</template>
<script>
export default {
name: 'TabBarIconConfig',
props: {
pagePath: String,
visible: Boolean,
text: String,
iconPath: String,
selectedIconPath: String,
materialOptions: Array,
pagePathOptions: Array
}
}
修改父组件调用方式
<el-collapse-item title="图标 1" name="1">
<tab-bar-icon-config
:pagePath="tabbar.pagePath1"
:visible="tabbar.visible1"
:text="tabbar.text1"
:iconPath="tabbar.iconPath1"
:selectedIconPath="tabbar.selectedIconPath1"
:pagePathOptions="pagePathOptions"
:materialOptions="materialOptions">
</tab-bar-icon-config>
</el-collapse-item>
<el-collapse-item title="图标 2" name="2">
<tab-bar-icon-config
:pagePath="tabbar.pagePath2"
:visible="tabbar.visible2"
:text="tabbar.text2"
:iconPath="tabbar.iconPath2"
:selectedIconPath="tabbar.selectedIconPath2"
:pagePathOptions="pagePathOptions"
:materialOptions="materialOptions">
</tab-bar-icon-config>
</el-collapse-item>
刷新页面,进行数据修改测试,发现控制台弹出一个警告
[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. Prop being mutated: "text"
这里是说避免直接修改 prop 中的值,因为父组件重新渲染时 prop 会被覆盖。取而代之的应该使用 data 或者是计算属性,基于 prop 的值。被修改的属性是 text
为什么会产生问题?
这里主要有两个问题:
- 为什么会出现这个 Vue 警告?
- 为什么传入
tabbar
时没有产生这个警告?
由于笔者从未接触过此问题,还是有些懵,笔者通过搜索、询问 AI、翻阅 Vue文档等方式,最终有些理解。
原因:单向数据流
所以对于问题 1,在子组件中使用 ElementUI
的 <el-input v-model="text"
输入内容时,String
类型的text
被修改,违反了单向数据流,所以 Vue 发出了警告。
对于问题 2,传入Object
类型的tabbar
时,虽然没有警告,但其实是一样的。没有警告的原因是:对象和数组是引用类型,Vue 并不会阻止子组件修改,因为这种操作并不会破坏引用关系。Vue 的响应式系统基于 JavaScript 的特性实现。当你修改对象的某个属性时(例如 obj.key = newValue
),Vue 能够检测到这种变化并触发视图更新。因此,Vue 不会阻止这种行为。
尽管 Vue 不会发出警告,但这种行为可能会导致以下问题:
- 数据流不清晰
- 子组件直接修改了父组件传递的对象属性,这违反了单向数据流的原则。
- 当父组件重新渲染时,子组件的修改可能会被覆盖,导致不可预测的行为。
- 难以调试
- 如果多个子组件同时修改同一个对象的属性,可能会导致状态混乱,增加调试难度。
- 不符合最佳实践
- Vue 推荐将
props
视为只读数据,并通过$emit
通知父组件更新数据。
- Vue 推荐将
怎么解决问题
在 Vue 的警告中指出,应该用 data 或者计算属性来实现。
data 方式
- 将
props
传入的值保存在data
中的本地变量中 - 为了响应
props
可能的变化,需要监听props
的数据变化,并更新到本地变量中 - 在
template
部分使用本地变量 - 在本地变量发生变化时,使用
$emit
向父组件发出事件,并携带修改后的值;在这里监听变化可以使用ElementUI
中的@input
、@change
等事件 - 父组件监听子组件的事件,并更新数据
以 text
字段为例
data() {
return {
localText: this.text
watch: {
text(newVal) {
this.localText = newVal
},
<el-input v-model="localText"
<el-input v-model="localText" @input="updateText"
methods: {
updateText(value) {
this.$emit('update:text', value);
}
}
在父组件中
<tab-bar-icon-config v-on:update:text="tabbar.text1 = $event"
可以看到,代码量比较多(这只是 text 属性,还有其他 4 个属性),下面尝试减少一些代码量
//这里无法减少
data() {
return {
localText: this.text
//这里也无法减少
watch: {
text(newVal) {
this.localText = newVal
},
//这两部分可以定义一个通用的函数,然后所有属性调用,可以减少 其他4 个函数
<el-input v-model="localText" @input="updateText"
methods: {
updateText(value) {
this.$emit('update:text', value);
}
}
<el-input v-model="localText" @input="updateParent('text', $event)"
methods: {
updateParent(prop, value){
this.$emit(`update:prop`, value)
}
}
//或者,可以不再监听类似@input的事件,直接监听localText变量的变化,在变化中发出事件
//这样 5 个属性需要写 5 个 watch,但是代码比较集中,没有分散在 template 的多个地方
watch: {
localText(newVal) {
console.log('localText changed', newVal)
this.$emit(`update:text`, newVal)
}
}
在父组件中,可以使用 .sync
修饰符来简化,而不是使用v-on
,这是 vue 提供的语法糖(针对update:xxx
这类事件),本质是一样的
<tab-bar-icon-config :text.sync="tabbar.text1"
计算属性方式
- 定义计算属性,其中
get
方法直接返回传入的prop
的值 - 在
template
部分使用 计算属性 - 在计算属性的
set
方法中,使用$emit
向父组件发出事件 - 父组件监听子组件的事件,并更新数据
此方法中,不再需要存储到本地变量,也不需要监听父组件传入值的变化来更新本地变量,从代码量上要比 data 方法少
同样以 text
属性为例
computed: {
localText: {
get() {
return this.text
},
set(val) {
this.$emit(`update:text`, val)
}
},...
<el-input v-model="localText"
类似的,在父组件中接收变化(v-on
或 .sync
)
<tab-bar-icon-config :text.sync="tabbar.text1"
data vs. computed
特性 | 使用 data |
使用 computed |
---|---|---|
数据独立性 | 高(本地副本与父组件数据分离) | 中(依赖父组件的 props ) |
同步逻辑 | 需要手动监听 props 并同步到本地副本 |
自动响应 props 的变化 |
代码简洁性 | 较低(需要额外的 data 和 watch ) |
较高(计算属性结合 getter 和 setter ) |
灵活性 | 高(可以维护复杂的本地状态) | 低(只能通过 $emit 更新父组件的值) |
适用场景 | 复杂的状态管理或需要独立的本地副本 | 简单的双向绑定或直接修改父组件的值 |