如何利用 `Vue` 的 `provide`/`inject` 机制,设计一个跨组件的拖拽功能,支持复杂的数据传递?

大家好,我是你们今天的拖拽专家,今天我们要聊聊如何用 Vue 的 provide/inject 机制,搞定一个跨组件的拖拽功能,而且还得支持复杂的数据传递。别怕,这玩意儿听起来高大上,其实就是个纸老虎,咱们一步一步把它拆解了,保证你听完之后,也能撸起袖子就上。

开场白:为啥要用 provide/inject

首先,咱们得明白,为啥要选择 provide/inject 这对好基友。难道 Vuex 或者事件总线不香吗?当然香,但它们有各自的适用场景。

  • Vuex:适合管理全局状态,对于一些组件内部的临时状态,有点杀鸡用牛刀了。
  • 事件总线:简单粗暴,但组件多了容易乱,而且类型定义啥的也比较麻烦。

provide/inject 的优势在于:

  • 轻量级:只在需要的地方注入,不会污染全局。
  • 解耦:父组件不用关心子组件如何使用 provide 的数据,子组件也不用关心 provide 的数据来自哪里。
  • 灵活:可以传递任何类型的数据,包括对象、函数等等。

所以,对于一些组件内部的,跨组件传递的状态(比如拖拽状态),provide/inject 是个不错的选择。

第一部分:搭个骨架,先让它跑起来

咱们先来搭个最简单的骨架,让拖拽功能跑起来。

  1. Provider 组件 (拖拽容器)

这个组件负责提供拖拽相关的数据和方法。

<template>
  <div class="drag-container">
    <h2>Drag Container</h2>
    <slot></slot>
  </div>
</template>

<script>
import { ref, provide } from 'vue';

export default {
  setup() {
    const draggingItem = ref(null); // 存储当前正在拖拽的 item
    const startDrag = (item) => {
      draggingItem.value = item;
    };
    const endDrag = () => {
      draggingItem.value = null;
    };

    provide('dragContext', {
      draggingItem,
      startDrag,
      endDrag,
    });

    return {};
  },
};
</script>

<style scoped>
.drag-container {
  border: 2px dashed #ccc;
  padding: 20px;
  margin: 20px;
}
</style>

这个组件做了啥?

  • 定义了一个 draggingItemref,用来存储当前正在拖拽的 item。
  • 定义了 startDragendDrag 两个方法,分别用于开始和结束拖拽。
  • 使用 provide 将这些数据和方法提供给子组件,key 是 'dragContext'
  1. Draggable Item 组件 (可拖拽的 item)

这个组件就是可以被拖拽的 item。

<template>
  <div class="draggable-item" draggable="true" @dragstart="onDragStart" @dragend="onDragEnd">
    {{ item.name }}
  </div>
</template>

<script>
import { inject } from 'vue';

export default {
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  setup(props) {
    const dragContext = inject('dragContext');

    const onDragStart = (event) => {
      console.log('开始拖拽', props.item);
      dragContext.startDrag(props.item);
      event.dataTransfer.setData("text/plain", JSON.stringify(props.item)); // 设置拖拽数据
    };

    const onDragEnd = () => {
      console.log('结束拖拽');
      dragContext.endDrag();
    };

    return {
      onDragStart,
      onDragEnd,
    };
  },
};
</script>

<style scoped>
.draggable-item {
  border: 1px solid #999;
  padding: 10px;
  margin: 5px;
  cursor: move;
}
</style>

这个组件做了啥?

  • 使用 inject 注入了 dragContext
  • 监听了 dragstartdragend 事件,分别调用 dragContextstartDragendDrag 方法。
  • dragstart 事件中,将 item 的数据设置到 dataTransfer 中,方便在 drop 事件中使用。
  1. Dropzone 组件 (可放置的区域)

这个组件就是可以放置拖拽 item 的区域。

<template>
  <div class="dropzone" @dragover="onDragOver" @drop="onDrop">
    <h2>Dropzone</h2>
    <p>Drag items here</p>
    <ul>
      <li v-for="item in droppedItems" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

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

export default {
  setup() {
    const droppedItems = ref([]);

    const onDragOver = (event) => {
      event.preventDefault(); // 必须阻止默认行为,才能触发 drop 事件
    };

    const onDrop = (event) => {
      event.preventDefault();
      const itemData = event.dataTransfer.getData("text/plain");
      const item = JSON.parse(itemData);
      console.log('放置', item);
      droppedItems.value.push(item);
    };

    return {
      droppedItems,
      onDragOver,
      onDrop,
    };
  },
};
</script>

<style scoped>
.dropzone {
  border: 2px dashed #999;
  padding: 20px;
  margin: 20px;
}
</style>

这个组件做了啥?

  • 监听了 dragoverdrop 事件。
  • dragover 事件中,必须调用 event.preventDefault(),才能触发 drop 事件。
  • drop 事件中,从 dataTransfer 中获取 item 的数据,并将其添加到 droppedItems 数组中。
  1. App.vue (整合所有组件)
<template>
  <DragContainer>
    <DraggableItem v-for="item in items" :key="item.id" :item="item" />
  </DragContainer>
  <Dropzone />
</template>

<script>
import DragContainer from './components/DragContainer.vue';
import DraggableItem from './components/DraggableItem.vue';
import Dropzone from './components/Dropzone.vue';

export default {
  components: {
    DragContainer,
    DraggableItem,
    Dropzone,
  },
  data() {
    return {
      items: [
        { id: 1, name: 'Item 1' },
        { id: 2, name: 'Item 2' },
        { id: 3, name: 'Item 3' },
      ],
    };
  },
};
</script>

这个组件只是简单地将所有组件整合在一起,并提供了一些初始数据。

第二部分:数据传递,复杂场景也不怕

上面的例子只是传递了简单的数据,如果我们需要传递更复杂的数据,比如包含嵌套对象、函数等等,该怎么办呢?

其实,provide/inject 可以传递任何类型的数据,关键在于如何正确地处理这些数据。

假设我们的 item 对象包含一个函数:

{
  id: 1,
  name: 'Item 1',
  action: () => {
    alert('Hello from Item 1!');
  },
}
  1. Provider 组件 (拖拽容器)
<template>
  <div class="drag-container">
    <h2>Drag Container</h2>
    <slot></slot>
  </div>
</template>

<script>
import { ref, provide } from 'vue';

export default {
  setup() {
    const draggingItem = ref(null); // 存储当前正在拖拽的 item
    const startDrag = (item) => {
      draggingItem.value = item;
    };
    const endDrag = () => {
      draggingItem.value = null;
    };
    const executeAction = (item) => {
        if (item && item.action) {
            item.action();
        }
    }

    provide('dragContext', {
      draggingItem,
      startDrag,
      endDrag,
      executeAction
    });

    return {};
  },
};
</script>

<style scoped>
.drag-container {
  border: 2px dashed #ccc;
  padding: 20px;
  margin: 20px;
}
</style>
  1. Draggable Item 组件 (可拖拽的 item)
<template>
  <div class="draggable-item" draggable="true" @dragstart="onDragStart" @dragend="onDragEnd">
    {{ item.name }}
    <button @click="executeItemAction">Execute Action</button>
  </div>
</template>

<script>
import { inject } from 'vue';

export default {
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  setup(props) {
    const dragContext = inject('dragContext');

    const onDragStart = (event) => {
      console.log('开始拖拽', props.item);
      dragContext.startDrag(props.item);
      event.dataTransfer.setData("text/plain", JSON.stringify(props.item)); // 设置拖拽数据,注意函数无法序列化
    };

    const onDragEnd = () => {
      console.log('结束拖拽');
      dragContext.endDrag();
    };

    const executeItemAction = () => {
        dragContext.executeAction(props.item);
    }

    return {
      onDragStart,
      onDragEnd,
      executeItemAction
    };
  },
};
</script>

<style scoped>
.draggable-item {
  border: 1px solid #999;
  padding: 10px;
  margin: 5px;
  cursor: move;
}
</style>
  1. Dropzone 组件 (可放置的区域)
<template>
  <div class="dropzone" @dragover="onDragOver" @drop="onDrop">
    <h2>Dropzone</h2>
    <p>Drag items here</p>
    <ul>
      <li v-for="item in droppedItems" :key="item.id">{{ item.name }}</li>
    </ul>
    <button v-if="lastDroppedItem" @click="executeDroppedItemAction">Execute Last Dropped Item Action</button>
  </div>
</template>

<script>
import { ref, inject } from 'vue';

export default {
  setup() {
    const droppedItems = ref([]);
    const dragContext = inject('dragContext');
    const lastDroppedItem = ref(null);

    const onDragOver = (event) => {
      event.preventDefault(); // 必须阻止默认行为,才能触发 drop 事件
    };

    const onDrop = (event) => {
      event.preventDefault();
      const itemData = event.dataTransfer.getData("text/plain");
      const item = JSON.parse(itemData);
      console.log('放置', item);
      droppedItems.value.push(item);
      lastDroppedItem.value = item;
    };

    const executeDroppedItemAction = () => {
        if (lastDroppedItem.value) {
            //由于函数不能通过 dataTransfer 传递,所以这里无法直接调用 item.action()
            //console.log(lastDroppedItem.value);
            //alert("函数不能被序列化,所以不能被传递");
            dragContext.executeAction(lastDroppedItem.value);
        }
    }

    return {
      droppedItems,
      onDragOver,
      onDrop,
      lastDroppedItem,
      executeDroppedItemAction
    };
  },
};
</script>

<style scoped>
.dropzone {
  border: 2px dashed #999;
  padding: 20px;
  margin: 20px;
}
</style>

注意点:

  • 函数序列化问题dataTransfer 只能传递字符串类型的数据,这意味着我们无法直接将函数传递过去。 所以我们需要在 Provider 中提供一个 executeAction 方法,然后在 DraggableItem 中,点击的时候去调用这个方法。
  • 对象引用问题:如果传递的是对象,需要注意对象引用问题。如果多个组件共享同一个对象引用,修改其中一个组件的数据,会影响其他组件。可以使用 JSON.parse(JSON.stringify(item)) 来深拷贝对象,避免引用问题。 或者使用 cloneDeep 方法。

第三部分:优化和扩展

  1. 类型定义:使用 TypeScript 可以为 provide/inject 提供类型定义,提高代码的可维护性和可读性。
// DragContext.ts
import { InjectionKey, Ref } from 'vue';

export interface DragContext {
  draggingItem: Ref<any | null>;
  startDrag: (item: any) => void;
  endDrag: () => void;
  executeAction: (item: any) => void;
}

export const dragContextKey: InjectionKey<DragContext> = Symbol('dragContext');
// DragContainer.vue
<script lang="ts">
import { ref, provide } from 'vue';
import { DragContext, dragContextKey } from './DragContext';

export default {
  setup() {
    const draggingItem = ref<any | null>(null);
    const startDrag = (item: any) => {
      draggingItem.value = item;
    };
    const endDrag = () => {
      draggingItem.value = null;
    };
    const executeAction = (item: any) => {
        if (item && item.action) {
            item.action();
        }
    }

    const dragContext: DragContext = {
      draggingItem,
      startDrag,
      endDrag,
      executeAction
    };

    provide(dragContextKey, dragContext);

    return {};
  },
};
</script>
// DraggableItem.vue
<script lang="ts">
import { inject } from 'vue';
import { dragContextKey } from './DragContext';

export default {
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  setup(props) {
    const dragContext = inject(dragContextKey);

    const onDragStart = (event: DragEvent) => {
      console.log('开始拖拽', props.item);
      dragContext.startDrag(props.item);
      event.dataTransfer!.setData("text/plain", JSON.stringify(props.item)); // 设置拖拽数据
    };

    const onDragEnd = () => {
      console.log('结束拖拽');
      dragContext.endDrag();
    };

    const executeItemAction = () => {
        dragContext.executeAction(props.item);
    }

    return {
      onDragStart,
      onDragEnd,
      executeItemAction
    };
  },
};
</script>
// Dropzone.vue
<script lang="ts">
import { ref, inject } from 'vue';
import { dragContextKey } from './DragContext';

export default {
  setup() {
    const droppedItems = ref([]);
    const dragContext = inject(dragContextKey);
    const lastDroppedItem = ref(null);

    const onDragOver = (event: DragEvent) => {
      event.preventDefault(); // 必须阻止默认行为,才能触发 drop 事件
    };

    const onDrop = (event: DragEvent) => {
      event.preventDefault();
      const itemData = event.dataTransfer!.getData("text/plain");
      const item = JSON.parse(itemData);
      console.log('放置', item);
      droppedItems.value.push(item);
      lastDroppedItem.value = item;
    };

    const executeDroppedItemAction = () => {
        if (lastDroppedItem.value) {
            dragContext.executeAction(lastDroppedItem.value);
        }
    }

    return {
      droppedItems,
      onDragOver,
      onDrop,
      lastDroppedItem,
      executeDroppedItemAction
    };
  },
};
</script>
  1. 自定义事件:可以使用自定义事件来通知其他组件拖拽状态的变化。

  2. 拖拽反馈:在拖拽过程中,可以添加一些视觉反馈,比如改变 item 的背景颜色、显示拖拽图标等等,提高用户体验。

  3. 性能优化:对于大量 item 的拖拽,需要注意性能优化,比如使用虚拟列表、节流拖拽事件等等。

第四部分:总结

今天我们学习了如何使用 Vueprovide/inject 机制,设计一个跨组件的拖拽功能,并且支持复杂的数据传递。

  • provide/inject 是一种轻量级、解耦的跨组件通信方式。
  • 可以传递任何类型的数据,包括对象、函数等等。
  • 需要注意函数序列化问题和对象引用问题。
  • 可以使用 TypeScript 提供类型定义,提高代码的可维护性和可读性。
  • 可以通过自定义事件、拖拽反馈、性能优化等手段,进一步完善拖拽功能。

希望今天的讲解对你有所帮助,下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注