vue巢狀元件傳參

老毛發表於2022-04-09
本文以一個vue遞迴元件為例,探究多層巢狀後事件無法觸發的問題,你可以檢視一下 Demo,便於快速瞭解這個例子。
假設我們已經瞭解vue元件常見的有父子元件通訊,兄弟元件通訊。而父子元件通訊很簡單,父元件會通過 props 向下傳資料給子元件,當子元件有事情要告訴父元件時會通過 $emit 事件告訴父元件。那麼當兩個元件之間不是父子關係,怎樣傳遞資料呢?

先來看一下這個例子:

遞迴巢狀元件引數傳遞

我們封裝了一個名為 NestedDir 的子元件(巢狀目錄的意思),內容如下(用到了element ui元件):

<!-- NestedDir.vue -->
<template>
  <ul class="nest_wrapper">
    <li v-for="(el, index) in nested" :key="index">
      <div v-if="el.type ==='dir'" class="dir">
        <p>{{el.name}}</p>
        <div class="btn_group">
          <el-button type="warning" size="mini" @click="add({id: el.id, type: 'dir'})">新增目錄</el-button>
          <el-button type="warning" size="mini" @click="add({id: el.id, type: 'file'})">新增檔案</el-button>
        </div>
      </div>
      <div v-if="el.type ==='file'" class="file">
        <p>{{el.name}}</p>
      </div>
      <NestedDir v-if="el.children" :nested="el.children"/>
    </li>
  </ul>
</template>

<script>
export default {
  name: "NestedDir",
  props: {
    nested: {
      type: Array,
    }
  },
  methods: {
    add(el) {
      this.$emit('change', el)
    }
  }
}
</script>

可以看出這個 NestedDir 接收父級傳來的 nested 陣列型別的資料,並且它的內部點選 新增目錄、新增檔案,可以觸發 父級 監聽的 change 事件。比較特殊的是這個元件中呼叫了自己:

<NestedDir v-if="el.children" :nested="el.children"/>

不過要注意的是呼叫自己的時候我們並沒有在它上面監聽它內部傳來的change事件,這也是導致二級目錄點選新增按鈕無效的原因。

我們傳遞給它的 nested 資料結構大概是下面的樣子:

[{
    "id": 1,
    "name": "目錄1",
    "type": "dir",
    "children": [{
        "id": 2,
        "name": "目錄3",
        "type": "dir",
        "children": [],
        "pid": 1
    }, {
        "id": 3,
        "name": "檔案2",
        "type": "file",
        "pid": 1
    }]
}, {
    "id": 4,
    "name": "目錄2",
    "type": "dir",
    "children": []
}, {
    "id": 5,
    "name": "檔案1",
    "type": "file",
    "children": []
}]

父元件中呼叫 NestedDir:

<!-- directory.vue -->
<template>
  <div style="width: 50%;box-shadow: 0 0 4px 2px rgba(0,0,0,.1);margin: 10px auto;padding-bottom: 10px;">
    <!-- 頂部按鈕組 -->
    <div class="btn_group">
      <el-button type="warning" size="mini" @click="showDialog({type: 'dir'})">新增目錄</el-button>
      <el-button type="warning" size="mini" @click="showDialog({type: 'file'})">新增檔案</el-button>
    </div>
    <!-- 巢狀元件 -->
    <NestedDir :nested="catalog" @change="handleChange"/>
    <!-- 新增彈出框 -->
    <el-dialog :title="title" :visible.sync="dialogFormVisible" width="300px">
      <el-form :model="form">
        <el-form-item label="名稱">
          <el-input v-model="form.name" autocomplete="off"></el-input>
        </el-form-item>
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button @click="dialogFormVisible = false">取 消</el-button>
        <el-button type="primary" @click="confirm">確 定</el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script>
import NestedDir from "./NestedDir";

export default {
  name: "directory",
  components: {
    NestedDir
  },
  created() {
    this.catalog = this.getTree()
  },
  computed: {
    maxId() {
      return this.arr.lastIndex + 2
    },
    topNodes() {
      this.arr.forEach(item => {
        if (item.children) item.children = []
      })
      return this.arr.filter(item => !item.pid)
    }
  },
  data() {
    return {
      arr: [
        {id: 1, name: '目錄1', type: 'dir', children: []},
        {id: 2, name: '目錄3', type: 'dir', children: [], pid: 1},
        {id: 3, name: '檔案2', type: 'file', pid: 1},
        {id: 4, name: '目錄2', type: 'dir', children: []},
        {id: 5, name: '檔案1', type: 'file'},
      ],
      title: '',
      dialogFormVisible: false,
      form: {
        id: '',
        name: '',
        type: '',
        pid: ''
      },
      catalog: []
    }
  },
  methods: {
    handleChange(el) {
      this.showDialog(el)
    },
    confirm() {
      this.arr.push({...this.form})
      this.dialogFormVisible = false
      this.catalog = this.getTree()
      this.form = {
        id: '',
        name: '',
        type: '',
        pid: '' , // 父級的id
      }
    },
    showDialog(el) {
      if (el.type === 'dir') {
        this.title = '新增目錄'
        this.form.children = []
        this.form.type = 'dir'
      } else {
        this.title = '新增檔案'
        this.form.type = 'file'
      }
      if (el.id) {
        this.form.pid = el.id
        this.form.id = this.maxId
      } else {
        this.form.id = this.maxId
      }
      this.dialogFormVisible = true
    },
    getTree() {
      this.topNodes.forEach(node => {
        this.getChildren(this.arr, node.children, node.id)
      })
      return this.topNodes
    },
    getChildren(data, result, pid) {
      for (let item of data) {
        if (item.pid === pid) {
          const newItem = {...item, children: []}
          result.push(newItem)
          this.getChildren(data, newItem.children, item.id)
        }
      }
    }
  }
}
</script>

<style scoped>
.btn_group {
  padding: 20px 10px;
  background-color: rgba(87, 129, 189, 0.13);
}
</style>

渲染出的頁面是下面的樣子:

遞迴元件

深層遞迴元件事件丟失

我們構造出了一個理論上可以無限巢狀的目錄結構,但是經過測試發現 在二級目錄上的 新增按鈕 點選是沒有任何反應的,原因是我們在 NestedDir 中呼叫了它自己並沒有監聽內部的change事件(上邊提到過),所以它無法觸發 父級的-父級 的監聽事件。

如何解決?

  1. 在遞迴呼叫的時候也監聽一下change事件,並間接傳遞到最外層元件(這個是最容易想到的方法,但是如果元件巢狀很深,簡直就是個噩夢)
  2. EventBus(事件匯流排)

EventBus

什麼事EventBus?

它其實就是一個Vue例項,有$emit、$on、$off方法,允許從一個元件向另一元件傳遞資料,而不需要藉助父元件。具體做法是在一個元件$emit,在另一個元件$on,可以像下面這樣做:

// main.js
import Vue from 'vue'
import App from './App.vue'

export const eventBus = new Vue(); // creating an event bus.

new Vue({
  render: h => h(App),
}).$mount('#app')

這樣我們來改造一下 directory.vue,只需要改動srcipt部分:

<script>
import NestedDir from "./NestedDir";
import { eventBus } from "../main";

export default {
  name: "directory",
  components: {
    NestedDir
  },
  created() {
    this.catalog = this.getTree()

    eventBus.$on('change', function (data) {
      // todo 向之前一樣處理即可
    })
  },
  destroyed() {
    eventBus.$off('change')
  },
  computed: {
    maxId() {
      return this.arr.lastIndex + 2
    }
  },
  data() {
    return {
      arr: [
        {id: 1, name: '目錄1', type: 'dir', children: []},
        {id: 2, name: '目錄3', type: 'dir', children: [], pid: 1},
        {id: 3, name: '檔案2', type: 'file', pid: 1},
        {id: 4, name: '目錄2', type: 'dir', children: []},
        {id: 5, name: '檔案1', type: 'file'},
      ],
      title: '',
      dialogFormVisible: false,
      form: {
        id: '',
        name: '',
        type: '',
        pid: ''
      },
      catalog: []
    }
  },
  methods: {
    handleChange(el) {
      this.showDialog(el)
    },
    confirm() {
      this.arr.push({...this.form})
      this.dialogFormVisible = false
      this.catalog = this.getTree()
      this.form = {
        id: '',
        name: '',
        type: '',
        pid: '' , // 父級的id
      }
    },
    showDialog(el) {
      if (el.type === 'dir') {
        this.title = '新增目錄'
        this.form.children = []
        this.form.type = 'dir'
      } else {
        this.title = '新增檔案'
        this.form.type = 'file'
      }
      if (el.id) {
        this.form.pid = el.id
        this.form.id = this.maxId
      } else {
        this.form.id = this.maxId
      }
      this.dialogFormVisible = true
    },
    getTree() {
      this.topNodes.forEach(node => {
        this.getChildren(this.arr, node.children, node.id)
      })
      return this.topNodes
    },
    getChildren(data, result, pid) {
      for (let item of data) {
        if (item.pid === pid) {
          const newItem = {...item, children: []}
          result.push(newItem)
          this.getChildren(data, newItem.children, item.id)
        }
      }
    }
  }
}
</script>

引入了 import { eventBus } from "../main";

在頁面建立時加入事件監聽,銷燬時移除事件監聽:

created() {
  eventBus.$on('change', function (data) {
    this.handleChange(data)
  })
},
destroyed() {
  eventBus.$off('change')
}

NestedDir.vue 中也需要做相應改動,只需要修改methods中的add方法:

import { eventBus } from "../main";

//...略

methods: {
  add(el) {
    // this.$emit('change', el)
    eventBus.$emit('change', el)
  }
}

這樣點選二級目錄的新增按鈕,就可以正常觸發彈出框了。

上面的eventBus只在Vue2中有效,Vue3中已經移除了$on, $off 這些方法,所以下一篇文章打算自己做一個Vue的外掛來處理這種類似於Pub/Sub的情況。

下篇 《EventBus 在vue的實現》

相關文章