Vue3 - 组件之间的几种常用通信方式

394 阅读5分钟

本文介绍了 Vue3 组件之间的 8 种通信方式
随着最新vue版本升级带来的变化,本文还会持续更新~

  • props
  • emit
  • v-model(比较 vue2/vue3 的不同写法,以及3.3+新增的 defineModel)
  • provide / inject
  • refs
  • eventBus(事件总线通信)
  • pinia
  • slot(插槽)

这里的 Demo 都使用 Composition API 和 语法糖 <script setup>(vue3.2开始支持),这样写起来自由又方便,不用每次 return

props

用于父组件向子组件传值,是由上而下的单向数据流。官网的图示:

prop-drilling.11201220.png

父组件:

<template>
  <div>
    <button @click="handleClick">点我,打个招呼</button>
    <ChildComp :said="words" />
  </div>
</template>

<script setup name="ParentComp">
  import { ref } from 'vue'
  import ChildComp from './ChildComp.vue'
  
  const words = ref('')
  const handleClick = () => {
    words.value = '嘿!朋友'  
  }
</script>

子组件:

<template>
  <p>{{said}}</p>
</template>

<script setup name="ChildComp">
  import { defineProps } from 'vue'
  const props = defineProps({
    said: String
  })
</script>

对于数组和对象类型的 prop,由于 js 中对于他们是通过引用传入的,如果误操作在组件内修改了这些 prop 会影响父组件的状态而引发意外的 bug,而 Vue 是不会提示的,所以千万别这么干

emit

和 props 刚好相反,通过 emit 向父组件触发自定义事件 可以由下而上从子组件向父组件传值

子组件:

<template>
  <button @click="send">点我</button>
</template>

<script setup name="ChildComp">
  import { defineEmits } from 'vue'
  const emit = defineEmits(['change'])
  const send = () => {
    const times = `${Date.now()}`
    emit('change', times)
  }
</script>

父组件:

<template>
  <div>
    <ChildComp @change="handleChange" />
    <div>当前时间:{{dateStr}}</div>
  </div>
</template>

<script setup name="ParentComp">
  import { ref } from 'vue'
  import dayjs from 'dayjs'
  import ChildComp from './ChildComp.vue'
  
  const dateStr = ref('')
  const handleChange = (times) => {
    dateStr.value = dayjs(times).format('YYYY-MM-DD HH:mm:ss')
  }
</script>

v-model

1.先回顾下 Vue2 中 v-model 的写法

<ChildComp :value="siteList" @input="siteList = $event" />
<!-- 以下是简写: -->
<ChildComp v-model="siteList" />

在组件上使用 v-model,相当于绑定属性 value 并触发 input 事件
默认是 value,如果想改变属性名,可以使用 model 选项绑定自定义属性名和事件名(v2.2.0+)

子组件 ChildComp

export default {
  model: {
    prop: 'siteList', // v-model绑定的属性
    event: 'change'   // v-model绑定的自定义事件名
  },
  props: {
    siteList: {
      type: Array,
      // 数据结构:[{ id: '01', checked: false }, ...]
      default: () => ([])
    }
  },
  methods: {
    toggleSiteItem(id) {
      if (!this.siteList.length) return
      const _siteList = this.siteList.map((item) => {
        if (item.id === id) {
          return { ...item, checked: !item.checked }
        }
        return item
      })
      // 触发v-model绑定的自定义事件'change',并传值
      this.$emit('change', _siteList)
    }
  }
}

父组件 中使用:

<template>
  <button @click="checkAllSiteList">全选</button>
  <ChildComp v-model="siteList" />
</template>

<script>
import ChildComp from './ChildComp'
export default {
  components: { ChildComp },
  data() {
    return {
      siteList: [
        { id: '01', checked: false },
        { id: '02', checked: false },
      ]
    }
  },
  methods: {
    // 全选
    checkAllSiteList() {
      this.siteList = this.siteList.map((item) => {
        if (!item.checked) return { ...item, checked: true }
        return item
      })
    }
  }
}
</script>

这样在子组件和父组件中都能修改 siteList

2. Vue3 的 v-model
默认使用 modelValue 替代了 2.× 的 valueupdate:modelValue 替代了 2.× 的 input

<ChildComp v-model="pageTitle" />
<!-- 是以下的简写: -->
<ChildComp :modelValue="pageTitle" @update:modelValue="pageTitle = $event"/>

可以将 modelValue 换成自定义属性名,这里换成 title

<ChildComp v-model:title="pageTitle" />
<!-- 是以下的简写: -->
<ChildComp :title="pageTitle" @update:title="pageTitle = $event" />

v-bind-instead-of-sync.png

修改下上面代码,子组件 ChildComp

<template>
  <div
    v-for="item in siteList"
    class="site"
    :class="{ 'checked': item.checked }"
    :key="item.id"
    @click="toggleSiteItem(item.id)"
  >
    {{item.id}}
  </div>
</template>

<script setup name="ChildComp">
  import { defineProps, defineEmits } from 'vue'

  const props = defineProps({
    siteList: {
      type: Array,
      default: () => ([])
    }
  })

  const emit = defineEmits(['update:siteList'])

  const toggleSiteItem = (id) => {
    if (!props.siteList.length) return
    const _siteList = props.siteList.map((item) => {
      if (item.id === id) {
        return { ...item, checked: !item.checked }
      }
      return item
    })
    emit('update:siteList', _siteList)
  }
</script>

ChildComp 定义了属性 siteList,然后 emit 自定义事件 update:siteList 即可

父组件 中使用:

<template>
  <div>
    <button @click="checkAllSiteList">全选</button>
    <ChildComp v-model:siteList="siteList" />
  </div>
</template>

<script setup name="ParentComp">
  import { ref } from 'vue'
  import ChildComp from './ChildComp.vue'

  let siteList = ref([
    { id: '01', checked: false },
    { id: '02', checked: false },
  ])

  // 全选
  const checkAllSiteList = () => {
    siteList.value = siteList.value.map((item) => {
      if (!item.checked) return { ...item, checked: true }
      return item
    })
  }
</script>

3. Vue3 可以在同一个组件上使用多个 v-model 绑定,像这样:

<ChildComp
  v-model:title="pageInfo.title"
  v-model:content="pageInfo.content"
/>
<!-- 是以下的简写: -->
<ChildComp
  :title="pageInfo.title"
  @update:title="pageInfo.title = $event"
  :content="pageInfo.content"
  @update:content="pageInfo.content = $event"
/>
const pageInfo = reactive({
  title: '标题',
  content: '主体内容'
})

4. Vue3.3+ 版本多了个新编译器宏 defineModel ,提供了另一种使得 v-model 更简洁的写法

子组件 ChildComp:

<template>
  <button @click="handleClick">按钮</button>
</template>

<script setup>
const modelValue = defineModel()

const handleClick = () => {
  modelValue.value++
}
</script>

父组件:

<template>
  <div @click="changeCount">{{count}}</div>
  <ChildComp v-model="count" />
</template>

<script setup>
import { ref } from 'vue'
import ChildComp from './ChildComp.vue'

const count = ref(1)

const changeCount = () => {
  count.value = '9999'
}
</script>

provide / inject

简单的父子组件之间通信使用 propsemit 就可以了,但对于多层嵌套组件的通信用这种方法会很麻烦,provide / inject 依赖注入 就是用来解决这个问题的

components_provide.png

上层组件用 provide 提供数据:

<script setup>
  import { reactive, provide } from 'vue'
  const userInfo = reactive({
    name: 'Stephen',
    birthday: '1988-3-14'
  })
  
  provide('userInfo', userInfo)
  provide('message', 'hello')
</script>

下层组件 inject 注入数据来使用:

<template>
  <div>{{message}}, {{userInfo.name}}</div>
</template>

<script setup>
  import { inject } from 'vue'
  const userInfo = inject('userInfo')
  const message = inject('message')
</script>

还可以为整个应用层 prodive 数据

// main.js
const app = createApp(App)
app.provide('userInfo', { name: 'Jack', age: 3 })

// 在组件中使用
const userInfo = inject('userInfo')

refs

如果想访问组件的某些数据和内置方法,可以通过在子组件使用 defineExpose 向外暴露数据,在父组件访问子组件的 ref 获取数据,比如 Vue2 中常用的 this.$refs.dialog.open()

子组件:

import { ref, defineExpose } from 'vue'

const dialogVisible = ref(false)
const openDialog = () => dialogVisible.value = true
// 向外暴露
defineExpose({
  msg: '123',
  open: openDialog
})

父组件:

<template>
  <button @click="handleClick">弹出对话框</button>
  <Dialog ref="dialogRef" />
</template>

<script setup>
  import { ref } from 'vue'
  import Dialog from '@/components/Dialog.vue'
  
  const dialogRef = ref(null)
  
  const handleClick = () => {
    dialogRef.value.open()
  }
</script>

eventBus(事件总线通信)

用于跨组件通信

bus.js

import mitt from 'mitt'
const bus = mitt()
export default bus

组件派发信息:

<template>
  <button @click="handleClick">按钮</button>
</template>

<script setup>
import bus from '@/utils/bus'

const handleClick = () => {
  bus.emit('sendMsg', 'Hello!');
}
</script>

另一个组件接收信息:

<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import bus from '@/utils/bus'

const onSendMeg = () => { console.log(msg) }

onMounted(() => {
  bus.on('sendMsg', onSendMeg)
})

onBeforeUnmount(() => {
  bus.off('sendMsg', onSendMeg) // 组件销毁前移除监听
})
</script>

pinia

官方的 Vue 状态管理工具,类似 Vuex,但用起来更方便,摒弃了 mutations 的概念 比较像 React 的 Mobx

上个简单 Demo,用了插件 pinia-plugin-persist

store/user.js

// 封装的用户相关的ajax请求
import { accountLogin, fetchUserInfo } from '@/services/user'

export const useUserStore = defineStore({
  id: 'global',
  state: () => ({
    userInfo: null,      // 用户信息
    loginState: false,   // 登录状态
  }),
  actions: {
    // 更新用户信息
    updateUserInfo(data) {
      this.userInfo = _.assign({}, this.userInfo, data)
    },

    // 清除用户信息
    clearUserInfo() {
      this.userInfo = null
    },

    // 设置登陆状态
    setLoginState(bool) {
      this.loginState = bool
    },

    // 清除登录信息
    clearLoginInfo(showMsg = false) {
      this.clearUserInfo()
      this.setBreadcrumbsList([])
      this.setLoginState(false)
      sessionStorage.removeItem('token') // 清除token
    },

    // ======================== effects ========================
    login(username, password) {
      return new Promise(async (resolve) => {
        const data = await accountLogin(username, password)

        if (data && data.token && data.userId) {
          sessionStorage.setItem('token', data.token)
          const userInfo = await fetchUserInfo(data.userId)

          if (userInfo && Object.keys(userInfo).length > 0) {
            this.updateUserInfo(userInfo)
            this.setLoginState(true)
            resolve(true)
          } else {
            resolve(false)
          }
        } else {
          resolve(false)
        }
      })
    }
  },
  // 插件
  persist: {
    enabled: true,
    strategies: [
      {
        key: 'user_info',
        storage: sessionStorage,
        paths: ['userInfo']
      },
      {
        key: 'state',
        storage: sessionStorage,
        paths: ['loginState']
      },
    ]
  },
})

store/index.js

import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persist'

const store = createPinia()
store.use(piniaPersist)

export default store

在组件中使用:

<template>
  <div>{{userStore.loginState}}</div>
</template>

<script setup>
  import { useUserStore } from '@/store/global'
  const userStore = useUserStore()
</script>

pinia 更多的用法在官方上有详细说明 pinia.vuejs.org


2022.04.15 更新~~~~

slot(插槽)

父组件还可以向子组件传递模板内容,插槽就是干这个的

子组件:

<template>
  <div class="child-comp">
    <slot />
  </div>
</template>

父组件:

<template>
  <div class="parent-comp">
    <ChildComp>
      <p>信息:{{message}}</p>
    </ChildComp>
  </div>
</template>

<script setup>
  import { ref } from 'vue'
  import ChildComp from './ChildComp.vue'
  const message = ref('今天还要做核酸吗')
</script>

渲染出来的HTML:

<div class="parent-comp">
  <div class="child-comp"><p>信息:今天还要做核酸吗</p></div>
</div>

1. 设置默认内容,如果不传模板内容 就显示设置的默认内容

<div class="child-comp">
  <slot>默认信息</slot>
</div>

2. 具名插槽

当一个组件需要多个插槽口,可以在 <slot> 上增加 name 属性来标识,如果不写 name 默认为 name="default",比如一个简化的弹窗组件:

<template>
  <div class="dialog">
    <div class="dialog-hd">
      <slot name="header">提示</slot>
    </div>
    <div class="dialog-bd">
      <slot name="default" />
    </div>
    <div class="dialog-ft">
      <slot name="footer">
        <button>取消</button>
        <button>确定</button>
      </slot>
    </div>
  </div>
</template>

<script setup name="Dialog">
 // ...
</script>

父组件中使用:

<Dialog>
  <template #header>
    删除操作
  </template>
  <template #default>
    是否确认删除改条数据?
  </template>
</Dialog>

<template> 来包裹要插入的模板内容,#headerv-slot:header 的简写,参数为子组件具名插槽相应的 name

当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容

比如这里的 <p>描述一</p><p>最好别删哦</p> 会被插入 <slot name="default"></slot> 中,但从可维护性角度来看 还是建议用 <template #default></template> 包裹

<Dialog>
  <template #header>
    删除操作
  </template>
  <p>描述一</p>
  <p>最好别删哦</p>
</Dialog>

插槽名还可以是动态的,修改下上面的例子:

<template>
  <Dialog>
    <template #[slotName]>
      删除操作
    </template>
    <template #default>
      是否确认删除改条数据?
    </template>
  </Dialog>
</template>

<script setup>
  import { ref } from 'vue'
  const slotName = ref('header')
</script>

3. 插槽使用子组件的数据

默认情况下插槽是无法访问子组件中数据,但可以在子组件的 <slot> 中传递 props 让父组件中插槽拿到数据

子组件:

<template>
  <div class="child-comp">
    <slot nickName="Curry" :age="age" />
  </div>
</template>

<script setup name="ChildComp">
  import { ref } from 'vue'
  const age = ref(18)
</script>

父组件通过 v-slot 获取属性:

<div class="parent-comp">
  <ChildComp v-slot="slotProps">
    <div>{{slotProps.nickName}}</div>
    <div>{{slotProps.age}}</div>
  </ChildComp>
</div>

也可以直接在 v-slot 中解构

<div class="parent-comp">
  <ChildComp v-slot="{ nickName, age }">{{nickName}}</ChildComp>
</div>

具名插槽使用子组件数据时,和默认插槽有些区别,直接看下面 demo

子组件:

<template>
  <div class="child-comp">
    <header>
      <slot name="header" title="提示" :content="content" />
    </header>
    <div>
      <slot name="default" />
    </div>
  </div>
</template>

<script setup>
  import { ref } from 'vue'
  const content = ref('朋友~ 别再内卷了')
</script>

父组件模板中使用:

<div class="parent-comp">
  <ChildComp>
    <template #header="headerProps">
      <p>一条消息:</p>
      <p>{{headerProps.title}},{{headerProps.content}}</p>
    </template>
  </ChildComp>
</div>

渲染出来的结果:

微信截图_20220415145015.png

先这样了~ 以上七种组件之间通信的方式 可以应对大多数情况