【Vue菜鸟日记】组件封装、子组件更改父组件数据

前端菜鸟学习Vue - Vue组件封装 - 子组件更改父组件数据

前言

【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

为什么会产生问题?

这里主要有两个问题:

  1. 为什么会出现这个 Vue 警告?
  2. 为什么传入 tabbar 时没有产生这个警告?

由于笔者从未接触过此问题,还是有些懵,笔者通过搜索、询问 AI、翻阅 Vue文档等方式,最终有些理解。

原因:单向数据流

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 的警告中指出,应该用 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 的变化
代码简洁性 较低(需要额外的 datawatch 较高(计算属性结合 gettersetter
灵活性 高(可以维护复杂的本地状态) 低(只能通过 $emit 更新父组件的值)
适用场景 复杂的状态管理或需要独立的本地副本 简单的双向绑定或直接修改父组件的值