三个小技巧优雅更新vue3父组件状态

2,123 阅读5分钟

Props are readonly

最近开发项目的时候有用到element-plus,有些业务组件对element进行了二次封装,比如Dialog弹窗,将Dialog和具体的业务抽成一个单独组件,如下

<template>
  <el-dialog v-model="modelValue" title="Warning" width="30%" center>
    <span>Child Component</span>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="modelValue = false">Cancel</el-button>
        <el-button type="primary" @click="modelValue = false">Confirm</el-button>
      </span>
    </template>
  </el-dialog>
</template>
<script>
import { defineComponent, ref, watch, computed } from 'vue'

export default defineComponent({
  props: {
    modelValue: {
      type: Boolean,
      default: false,
    },
  }
})
</script>


在需要用到该组件的父页面引入,传入v-model属性控制弹窗的显示和隐藏

<template>
  <div>
    <el-button type="text" @click="isDialogShow = true">Click to open the Dialog</el-button>
    <my-dialog v-model="isDialogShow" />
  </div>
</template>
<script setup>
import { ref } from 'vue'
import MyDialog from '@/components/Dialog'

const isDialogShow = ref(true)
</script>

</script>

点击按钮,弹窗可以正常显示,点击 取消 或者 确定 不能关闭弹窗,查看控制台,发下有如下警告:

[Vue warn]: Attempting to mutate prop "modelValue". Props are readonly.

另外eslint也有error提示

2:23  error  Unexpected mutation of "modelValue" prop  vue/no-mutating-props

原因是父组件传到子组件的modelValue属性在组件my-dialog内部被element修改了,而在vue中是单向数据流,父组件状态发生变更时,子组件中所有的 prop 都将会刷新为最新的值,因此应避免在子组件内部修改父组件状态(即使有时候控制台没有警告信息)

如何避免在子组件中更改父组件传入的props

官网给出了两种方式

  1. 定义一个本地的 data property 并将这个 prop 作为其初始值
// vue2
props: ['isDialogShow'],
data() {
  return {
    mv: this.modelValue
  }
}

// vue3
setup(props) {
  const mv = ref(props.modelValue)
  return {
    mv
  }
}
  1. 使用这个props定义一个computed属性
// vue2
computed: {
  mv() {
    return this.modelValue
  }
}

// vue3
setup() {
  const mv = computed(() => {
    return props.modelValue
  })
}

测试了第一种方式在非绑定v-model的场景是可行的,第二种方法computed如果直接返回props.modelValue,在弹窗关闭时,vue还是会在控制台抛出警告:

Write operation failed: computed value is readonly

这样看来子组件还是直接修改了父组件的modelValue

针对第二种computed有没有其它方法可以解决上面的警告呢?答案是有的。

同步父子组件状态

computed setter

由于vue计算属性计算属性默认只有 getter,没有setter,导致父组件的属性直接被el-dialog更改,vue抛出警告提示;我们可以给computed添加一个setter,进行拦截,当el-dialog关闭时,由setter负责更新同步父组件状态或者触发事件,从而避免el-dialog直接在子组件中更改弹窗的状态。

setup(props, ctx) {
  const changeEvent = (value) => {
    ctx.emit('update:modelValue', value)
    nextTick(() => {
      ctx.emit('close', value)
    })
  }
  
  const mv = computed({
    get() {
      return props.modelValue
    },
    set(val) {
      changeEvent(val)
    }
  })
  
  return {
    mv
  }
}

provide 和 inject

在父组件提供 modelValuechangeEvent

<template>
  <div>
    <el-button type="text" @click="modelValue = true">Click to open the Dialog</el-button>
    <my-dialog v-model="modelValue" />
  </div>
</template>
<script>
import { defineComponent, ref, provide } from 'vue'
import MyDialog from '@/components/Dialog'

export default defineComponent({
  components: {
    MyDialog
  },
  setup(props, ctx) {
    const modelValue = ref(false)

    const changeEvent = () => {
      modelValue.value = false
    }

    provide('modelValue', modelValue)
    provide('changeEvent', changeEvent)
    return {
      modelValue
    }
  }
})
</script>

在子组件进行inject modelValuechangeEvent

<template>
  <el-dialog v-model="mv" title="Warning" width="30%" center>
    <span>Child Component</span>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="changeEvent">Cancel</el-button>
        <el-button type="primary" @click="changeEvent">Confirm</el-button>
      </span>
    </template>
  </el-dialog>
</template>
<script>
import { defineComponent, inject } from 'vue'

export default defineComponent({
  setup() {
    const mv = inject('modelValue', false)
    const changeEvent = inject('changeEvent')
    return {
      mv,
      changeEvent
    }
  }
})
</script>

上面子组件点击 确定 按钮时,调用父组件changeEvent方法,由父组件进行状态的更改;然而,点击弹窗遮罩时,并未调用changeEvent方法,弹窗也正常关闭,父的modelValue也更新了,这是因为我在setup里面提供了一个响应式的属性const modelValue = ref(false),而且这个属性没有被定义为只读const modelValue = readonly(ref(false)),是可以被子组件更改的; 修改响应式-property

const modelValue = ref(false)

因此在子组件点击遮罩关闭弹窗并没有抛出警告,而是顺利的更改了弹窗的状态。官方文档不推荐直接在子组件中更改父组件provide的数据,而是在父组件中更改状态的方法,在子组件中inject进行调用,由父组件进行统一的状态管理,便于管理和维护。

基于上面几点,如果不想点击遮罩,父组件的状态直接被更改,还是得用第一种computed的方法进行拦截

data/ref/reactive

这种就是前面说的,定义一个本地的 data property 并将这个 prop 作为其初始值

setup(props) {
  const mv = ref(props.modelValue)
  return {
    mv
  }
}

然后触发自定义事件,在父组件更新状态,这里不在赘述...

provide 和 inject运用场景

父子通讯一般用propsemit基本够用,provideinject适合深度嵌套的组件,深层的子组件需要父组件部分内容的场景。

element-plus中,复选框el-checkbox可以单独使用

<el-checkbox v-model="checked1" label="Option 1" size="large"></el-checkbox>

<script setup>
import { ref } from 'vue'

const checked1 = ref(true)
</script>

也可以和el-checkbox-group组合使用

<template>
  <el-checkbox-group v-model="checkList">
    <el-checkbox label="Option A" />
    <el-checkbox label="Option B" />
    <el-checkbox label="Option C" />
  </el-checkbox-group>
</template>

<script lang="ts" setup>
import { ref } from 'vue'

const checkList = ref(['Option A'])
</script>

单独el-checkboxel-checkbox-group都可以使用v-model,且可以互相通信,细看源码,其实也是用到了上面讲的computed setterprovideinject等特性,以下是部分源码:

// checkbox-group.vue 非完整代码
<template>
  <div class="el-checkbox-group" role="group" aria-label="checkbox-group">
    <slot></slot>
  </div>
</template>

export default defineComponent({
  setup(props, ctx) {
    const changeEvent = (value) => {
      ctx.emit('update:modelValue', value)
      nextTick(() => {
        ctx.emit('change', value) // 向父组件emit change事件
      })
    }
  
    const modelValue = computed({
      get() {
        return props.modelValue
      },
      // input checkbox modelValue 更改时,触发setter
      set(val) {
        changeEvent(val)
      }
    })
   
    // 对嵌套的深层子组件checkbox.vue提供CheckboxGroup对象
    // 里面包含modelValue,以及更改状态的changeEvent方法
    // 非完整代码
    provide('CheckboxGroup', {
      name: 'ElCheckboxGroup',
      modelValue,
      changeEvent
    })
  }
})
// checkbox.vue
<template>
  <input
    v-model="model"
    class="el-checkbox__original"
    type="checkbox"
    :name="name"
    @change="handleChange"
  />
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useCheckbox } from './useCheckbox'

export default defineComponent({
  name: 'ElCheckbox',
  setup(props) {
    return useCheckbox(props)
  }
})
</script>

useCheckbox 非完整代码

export const useCheckbox = (props: IUseCheckboxProps) => {
  const { model } = useModel(props)
  const { handleChange } = useEvent(props, { isLimitExceeded })

  return {
    model,
    handleChange,
  }
}

useModel 非完整代码

const useModel = (props: IUseCheckboxProps) => {
  const selfModel = ref(false)
  const { emit } = getCurrentInstance()
  const { isGroup, checkboxGroup } = useCheckboxGroup() 
  const store = computed(() => 
      checkboxGroup ? checkboxGroup.modelValue?.value : props.modelValue
  )
  const model = computed({
    get() {
      return isGroup.value ? store.value : props.modelValue ?? selfModel.value
    },

    set(val: unknown) {
      if (isGroup.value && Array.isArray(val)) {
        isLimitExceeded.value =
          checkboxGroup.max !== undefined &&
          val.length > checkboxGroup.max.value
        isLimitExceeded.value === false && checkboxGroup?.changeEvent?.(val)
      } else {
        emit('update:modelValue', val)
        selfModel.value = val as boolean
      }
    },
  })

  return {
    model
  }
}

useCheckboxGroup 非完整代码

export const useCheckboxGroup = () => {
  const checkboxGroup = inject<ICheckboxGroupInstance>('CheckboxGroup', {})
  const isGroup = computed(
    () => checkboxGroup && checkboxGroup?.name === 'ElCheckboxGroup'
  )
  return {
    isGroup,
    checkboxGroup
  }
}

通过useCheckboxGroup() 注入checkbox-group.vue 提供的CheckboxGroup对象,以此判断是单独使用el-checkbox还是是使用el-checkbox-group.

computed里面对modelValue添加了gettersetter方法,并对单独使用复选框el-checkbox和使用复选框组el-checkbox-group进行了区别处理,单独的checkbox用法只会触发更新父级modelValue,而checkbox-group用法,在checkbox.vue中则会触发checkboxGroup?.changeEvent?.(val)方法,也就是checobox-group.vue里面的changeEvent方法

const changeEvent = (value) => {
  ctx.emit('update:modelValue', value)
  nextTick(() => {
    ctx.emit('change', value)
  })
}

不出意外,当我们使用下面的复选框组demo,并勾选Option B选项时,控制台会输出 ['Option A', 'Option B']

<template>
  <el-checkbox-group v-model="checkList" @change="change">
    <el-checkbox label="Option A" />
    <el-checkbox label="Option B" />
    <el-checkbox label="Option C" />
  </el-checkbox-group>
</template>

<script setup>
import { ref } from 'vue'

const checkList = ref(['Option A'])
const change = (value) => {
  console.log(value)
}
</script>

以上就是element-plus实现el-checkboxel-checkbox-group通过computedprovideinjectemit等特性实现嵌套组件的状态更新和同步的部分源码,当然里面还涉及到跟el-form-itemel-form的一些通讯,感兴趣的可以去看下源码。