vue组件封装之v-model双向绑定单项数据流

653 阅读5分钟

探究v-model双向绑定单项数据流-组件封装

理论

所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。这样会防止从子组件意外变更父级组件的状态,从而导致你的应用的数据流向难以理解。 另外,每次父级组件发生变更时,子组件中所有的 prop 都将会刷新为最新的值。这意味着你不应该在一个子组件内部改变 prop。如果你这样做了,Vue 会在浏览器的控制台中发出警告。 Props 中的变量对于子组件来说是只读的,不能由子组件进行修改。

以下是我认为正确的单项数据流传递方法:

重点就在可以看清楚数据的传递走向(手动触发update:modelValue)

一、基础数据类型 (构造副本)

searchValue 和 props.modelValue 不是同一块内存地址

  1. prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性

在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可

官网写法:使用computed构建(除非特定条件,否则不推荐)

特点:

  • 双向同步关联
  • 时刻变化(改变了就emit到父组件)
 /** * @正确computed * @双向变化 **/
    // computed 构建一个 双向的
    const searchValue = computed({
      get() {
        return props.modelValue
      },
      set(v) {
        console.log(v)
        // 改变的时候 emits('update:modelValue', v)
      }
    })

官网写法: 使用ref 构建(推荐)

特点:

  • 主动通知
  • 灵活多变
 /** * @正确赋值 * @自由控制 **/
    const searchValue = ref(props.modelValue) // 简洁模式 
    //主动触发
    function search() {
      emits('update:modelValue', searchValue.value)
    }

二、引用类型 (创建新的引用连接)

理论

更改对象 / 数组类型的 props 当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。 这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该来通知父组件做出改变。

父组件

<template>
  <h1>分页</h1>
  <p>
    <e-page v-model="state.page" @getData="getData" />
    <!-- @update:model-value=" e.target.value =state.page " -->
  </p>
  <p> 父:{{ state.page }}</p>
</template>
<script lang="ts" setup>
  import { reactive } from 'vue'
  import EPage from '../../../components/e-page.vue'

  const state = reactive({
    page: {
      currentPage: 1,
      pageSize: 10,
      total: 100,
      obj: {
        name: '11',
        age: 22
      }
    }
  })
</script>

使用computed 构建传递

特点:

  • 双向同步关联
  • 时刻变化(改变了就通知父组件)

适用于表单的多层引用数据结构结构值,实时传递

  1. 实际看到emit事件的传递过程
  2. 通过陷阱函数 对内部的Proxy可读可写
 const pageState = computed({
    get() {
      return new Proxy(props.modelValue, {
        set(obj, name, val) {
          console.log(obj, name, val)
          console.log('触发向上改变')
          emits('update:modelValue', {
            ...obj,
            [name]: val
          })
          console.log('触发向上改变2222')
          return true
        },
        get(obj, key) {
          console.log('=======>obj[key]', obj[key])
          return obj[key]
        }
      })
    },
    set(val) {
      console.log(val) //始终不会触发
      emits('update:modelValue', val)
    }
  })

适用于表单的多层引用数据结构,实时传递(只是computed写法不动,侧重点不同)

  1. 实际看到emit事件的传递过程
  2. computed对象只读
const pageState = computed(() => {
  return new Proxy(props.modelValue, {
    get(obj, key) {
      return obj[key]
    },
    set(obj, name, val) {
      console.log('=======> proxy 的set')
      console.log(obj, name, val)
      console.log('触发向上改变')
      // 因为他只读 所以这个emits 上去改变了, 又传下来重新计算, 从而vm改变
      emits('update:modelValue', {
        ...obj,
        [name]: val
      })
      return true
    }
  })
})

使用reative 构建传递

使用deepClone 每次传递全新的对象,可以绑定多层嵌套引用结构

特点:

  • 接受初始化 不关联引用
  • 灵活性高
  • 保留对象模式
  • 多层引用类型结构好一些
 <template>
  <el-pagination
    v-model:current-page="pageState.currentPage"
    v-model:page-size="pageState.pageSize"
    :page-sizes="[20, 30, 300, 400]"
    v-model:total="pageState.total"
    layout="total, sizes, prev, pager, next, jumper"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
  <el-input v-model="pageState.obj.age" @keydown.enter="confirm" />
  子:{{ pageState }}
</template>

<script setup>
  import { deepClone } from '@/utils/filter'
  import { reactive } from 'vue'
  const emits = defineEmits(['update:modelValue', 'getData'])
  const props = defineProps({
    modelValue: {
      type: Object
    }
  })
  const pageState = reactive(deepClone(props.modelValue))

  function handleSizeChange(val) {
    // emits('update:modelValue', pageState.value)
  }

  function handleCurrentChange(val) {
    // currentPage.value = val
    // emits('update:modelValue', pageState.value)
    // emits('getData')
  }

  function confirm() {
    console.log('=======>是否相等', pageState === props.modelValue) // false
    emits('update:modelValue', deepClone(pageState))
  }
</script>

[🫡推荐]当你绑定的对象只有一层基础属性类型结构时

适用于知道绑定的对象的键,目的很明确

  • 接受初始化 不关联引用
  • 灵活性高
  • 拓展运算符号拷贝 单个副本
<template>
  <el-pagination
    v-model:current-page='pageState.currentPage'
    v-model:page-size='pageState.pageSize'
    v-model:total='pageState.total'
    :page-sizes='[20, 30, 300, 400]'
    layout='total, sizes, prev, pager, next, jumper'
    @size-change='handleSizeChange'
    @current-change='handleCurrentChange'
  />
  <el-input @keydown.enter='confirm' />
  子:{{ pageState }}
</template>

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

  const emits = defineEmits(['update:modelValue', 'getData'])
  const props = defineProps({
    modelValue: {
      type: Object
    }
  })

  const pageState = reactive({ ...props.modelValue })
  console.log('=======>是否相等', pageState === props.modelValue)

  function handleSizeChange (val) {
    // emits('update:modelValue', pageState.value)
  }

  function handleCurrentChange (val) {
    // emits('update:modelValue', pageState.value)
  }

  function confirm () {
    console.log('=======>是否相等', pageState === props.modelValue) // false
    emits('update:modelValue', { ...pageState })
  }
</script>

<style lang='scss' scoped>
  .e-icon {
    box-sizing: content-box;
  }
</style>

使用ref 构建传递

每一个都单独拿出来写(适用于知道绑定的对象的键,目的很明确)

  • 接受初始化 不关联引用
  • 灵活性高
  • 拷贝单个副本
  • 单层引用结构 (理解起来通顺)
<template>
  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :page-sizes="[20, 30, 300, 400]"
    v-model:total="total"
    layout="total, sizes, prev, pager, next, jumper"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
  <el-input v-model="age" @keydown.enter="confirm" />
  子:{{ pageState }}
</template>

<script setup>
  import { ref, computed } from 'vue'
  const emits = defineEmits(['update:modelValue', 'getData'])
  const props = defineProps({
    modelValue: {
      type: Object
    }
  })
  

  // 拷贝string副本  开辟新的内存空间
  const currentPage = ref(props.modelValue.currentPage)
  const pageSize = ref(props.modelValue.pageSize)
  const total = ref(props.modelValue.total)
  const age = ref(props.modelValue.obj.age)

  // 构造新的 对象组合  开辟新的内存空间
  const pageState = computed(() => {
    return {
      ...props.modelValue,
      currentPage: currentPage.value,
      pageSize: pageSize.value,
      total: total.value,
      obj: {
        ...props.modelValue.obj,
        age: age.value
      }
    }
  })

  function handleSizeChange(val) {
    // emits('update:modelValue', pageState.value)
  }

  function handleCurrentChange(val) {
    // emits('update:modelValue', pageState.value)
  }

  function confirm() {
    console.log('=======>是否相等', pageState.value === props.modelValue) // false
    emits('update:modelValue', pageState.value)
  }
</script>

<style lang="scss" scoped>
  .e-icon {
    box-sizing: content-box;
  }
</style>

最后还有一种 类似于 React数据写法,我就不赘述了。

数据拥有者才有改变数据的权利。

三、 最后和大家探讨的、也是存在疑惑的、关于绑定引用类型的用法:

以下是我认为可能正确但是我内心不认可写法

主要是重点是无法 看清楚数据的传递走向(手动触发update:modelValue)

一、computed 直接返回

  import { computed } from 'vue'
  const emits = defineEmits(['update:modelValue', 'getData'])
  const props = defineProps({
    modelValue: {
      type: Object
    }
  })
  const pageState = computed(() => {
    return props.modelValue
  })

  function handleSizeChange(val) {
    // emits('update:modelValue', pageState.value)
  }

  function handleCurrentChange(val) {
    // currentPage.value = val
    // emits('update:modelValue', pageState.value)
    // emits('getData')
  }

handleSizeChange handleCurrentChange中的emit没有用了,会直接改变。 本质上pageState的引用地址和prop.modelValue一样 虽然是由computed创建出来的relf对象内部使用proxy代理了一次, 但是本质上代理的还是prop.modelValue的引用地址的那个对象。也就是父亲的传递下来的page对象

一、watch 构建(本质我也觉得一样的😢)

let pageState = ref(null)
  watch(
    () => props.modelValue,
    () => {
      pageState = props.modelValue
    },
    {
      immediate: true
    }
  )

希望大家在评论区交流使用新的, 或者是更好的做法!

完结撒花🎉