在前端开发中,树形菜单是非常常见的一种组件。在某些场景下,用户需要自主拖拽菜单项来实现顺序的改变或者层级的修改等操作。本文将介绍如何利用 Custom Elements 实现可拖拽的树形菜单,并分享一些遇到的难点和解决方法。
Custom Elements 简介
Custom Elements 是一个 Web 标准,允许开发者扩展 HTML 元素来创建可重用的组件。
与开发其他 Web 组件的方式不同,利用 Custom Elements 可以定义自己的 HTML 元素,并将其看做一个组件进行使用。这个特性使得我们可以快速地开发可重用、可维护的组件,并从中获得代码复用和可扩展性的好处。
实现可拖拽的树形菜单
基本框架
我们的组件需要拥有下面的特点:
- 可以接受树形菜单的数据结构,并展示出来;
- 菜单项可以拖拽,行为包括限制拖拽到指定区域、拖动时菜单项随着鼠标移动、释放鼠标后菜单项在新的位置呈现等;
- 通过监听事件实现拖放操作。
下面我们将根据这些特点搭建组件的基本框架。
<drag-drop-tree> <ul class="tree"></ul> </drag-drop-tree>
在上面的示例中,我们定义了一个 drag-drop-tree
元素,并在其中添加了一个 ul
元素来展示树形菜单。接下来,我们需要构建树形菜单。在传统的 HTML 中,我们可以使用 <li>
和 <ul>
标签来构建树形结构,而在这里,我们需要通过 JavaScript 来实现它。
构建树形结构
我们可以使用 appendChild
方法将树形结构添加到 ul
元素中。在添加元素时,我们需要先遍历数据,然后将每个节点转化为菜单项。
// javascriptcn.com 代码示例 connectedCallback() { this.setAttribute('draggable', 'true') this.addEventListener('dragstart', this.handleDragStart) this.addEventListener('dragover', this.handleDragOver) this.addEventListener('drop', this.handleDrop) this.addEventListener('dragend', this.handleDragEnd) if (this.data.length) { this.tree = this.renderTree(this.data) this.shadowRoot.querySelector('.tree').appendChild(this.tree) } } renderTree(data) { const ul = document.createElement('ul') ul.classList.add('ul') data.forEach(item => { const li = document.createElement('li') li.draged = false li.innerHTML = ` <div class="item" draggable="true" data-id="${item.id}"> <span>${item.text}</span> </div> ` if (Array.isArray(item.children) && item.children.length > 0) { const children = this.renderTree(item.children) li.appendChild(children) li.classList.add('has-children') } ul.appendChild(li) }) return ul }
以上的代码逻辑比较简单。我们首先使用 connectedCallback
方法绑定事件和设置元素属性。接下来,在非空的情况下,我们调用 renderTree
方法来渲染树形结构。renderTree
方法遍历数据,并将每个节点转化为菜单项。如果节点包含子节点,那么递归调用 renderTree
方法,将子节点渲染到 li
中。最后将 li
添加到 ul
中,并返回它。
实现拖拽行为
接下来,我们需要为菜单项实现拖拽的行为。在 HTML 中,我们可以将 draggable
属性设置为 true
来使元素可以拖拽。我们还可以通过绑定事件处理程序来跟踪拖拽事件,比如 dragstart
、dragover
、dragend
等。
下面是拖拽行为的具体实现代码:
// javascriptcn.com 代码示例 /** * 拖拽开始时保存被拖拽元素的索引和父元素索引,并将拖拽标记记为 True */ handleDragStart(e) { this.startIndex = this.getIndex(e.target), this.startParentIndex = this.getParentIndex(e.target) e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', null) e.dataTransfer.dropEffect = 'move' e.target.style.opacity = '0.4' this.dragged = e.target e.stopPropagation() } /** * 拖拽进入目标元素时,禁止默认行为并高亮目标元素 */ handleDragOver(e) { if (e.preventDefault) { e.preventDefault() } e.dataTransfer.dropEffect = 'move' e.target.style.visibility = 'hidden' e.stopPropagation() return false } /** * 在目标元素上释放鼠标时,将拖拽标记记为 False,计算出被拖拽节点在新位置处的索引并更新数据 */ handleDrop(e) { if (e.stopPropagation) { e.stopPropagation() } if (this.dragged !== e.target && e.target.tagName === 'DIV') { const newIndex = this.getIndex(e.target), newParentIndex = this.getParentIndex(e.target) const dragData = { oldIndex: this.startIndex, oldParentIndex: this.startParentIndex, newIndex, newParentIndex, dragged: this.dragged } this.dragged.parentNode.removeChild(this.dragged) e.target.style.visibility = '' const dropzone = e.target.parentNode const nextSibling = e.target.nextSibling if (nextSibling) { dropzone.insertBefore(this.dragged, nextSibling) } else { dropzone.appendChild(this.dragged) } this.updateData(dragData) } return false } /** * 在拖拽结束时,清除样式和拖拽标记 */ handleDragEnd(e) { e.target.style.opacity = '' this.dragged = null e.stopPropagation() } getParentIndex(target) { let i = 0 while (target) { if (target.tagName.toLowerCase() === 'li') { break } target = target.parentNode } return Array.from(target.parentNode.children).indexOf(target) } getIndex(target) { let i = 0 while (target) { if (target.previousElementSibling === null) { break } target = target.previousElementSibling i++ } return i } updateData(dragData) { const { oldIndex, newIndex, dragged, oldParentIndex, newParentIndex } = dragData const oldParent = this.findParent(this.data, oldParentIndex) const newParent = this.findParent(this.data, newParentIndex) const item = oldParent.children.splice(oldIndex, 1)[0] const newAfterIndex = this.getNewAfterIndex(newParent.children, newIndex, item) newParent.children.splice(newAfterIndex, 0, item) } findParent(children, parentIndex) { let parent children.forEach(item => { if (parentIndex === 0) { parent = children } if (Array.isArray(item.children) && item.children.length > 0) { parent = this.findParent(item.children, parentIndex - 1) } }) return parent } getNewAfterIndex(children, newIndex, item) { let i = newIndex if (children[newIndex] && children[newIndex].sortOrder < item.sortOrder) { i -- } if (i < 0) { i = 0 } else if (i > children.length) { i = children.length } return i }
在本例中,我们在 handleDragStart
方法中保存了被拖拽元素的索引和父元素索引,同时将拖拽标记 dragged
标为 true
。接下来,在目标元素上拖拽时,我们阻止了默认行为,将 dropEffect
设为 move
,并高亮目标元素。当鼠标松开时,我们计算出被拖拽节点在新位置处的索引并更新数据,并将拖拽标记 dragged
标为 false
。
我们还提供了一些辅助函数来获取位置、计算新的索引和更新数据等。这些函数都是一些较为基础、常见的辅助函数,此处不做详细介绍。
遇到的难点和解决方法
防止拖拽嵌套
遇到的一个问题是,对于含有嵌套子项的菜单,拖拽时会出现拖拽嵌套的情况,此时不能正常拖拽。
为了解决这个问题,我们需要根据当前拖拽的元素,获取其最近的父元素,并判断是否为同一级菜单项。如果拖拽的菜单项是其它菜单项的一项子菜单,那么该元素应该移动到子菜单的父级中。我们可以通过对 DOM 树进行递归遍历,找到该元素对应的父菜单项,获取其索引,并将其移动到新位置。
实时更新数据
当拖拽菜单项时,根据新的位置,我们需要实时更新数据,以便存储到服务器或本地存储中。
为了满足这个需求,我们可以提供一个设置数据的函数,并从该函数中修改数据,同时调用渲染函数来重新渲染整个树形菜单。在实际应用中,我们可以在此处向服务器发送请求,更新菜单项的排序和层级等信息。
示例代码
下面是完整示例代码,其中包含了定义元素、绑定数据、渲染树形结构以及拖拽行为等功能。
// javascriptcn.com 代码示例 class DragDropTree extends HTMLElement { constructor() { super() this.shadowRoot = this.attachShadow({mode: 'open'}) this.dragged = null this.startIndex = -1 this.startParentIndex = -1 this.addEventListener('DOMContentLoaded', () => { console.log('DOM content has been loaded') }) } static get observedAttributes() {return ['data']} get data() { let raw = this.getAttribute('data') return JSON.parse(raw) } set data(val) { return val } attributeChangedCallback(name, oldVal, newVal) { if (oldVal !== newVal) { this.data = JSON.parse(newVal) } } connectedCallback() { this.setAttribute('draggable', 'true') this.addEventListener('dragstart', this.handleDragStart) this.addEventListener('dragover', this.handleDragOver) this.addEventListener('drop', this.handleDrop) this.addEventListener('dragend', this.handleDragEnd) if (this.data.length) { this.tree = this.renderTree(this.data) this.shadowRoot.querySelector('.tree').appendChild(this.tree) } } renderTree(data) { const ul = document.createElement('ul') ul.classList.add('ul') data.forEach(item => { const li = document.createElement('li') li.draged = false li.innerHTML = ` <div class="item" draggable="true" data-id="${item.id}"> <span>${item.text}</span> </div> ` if (Array.isArray(item.children) && item.children.length > 0) { const children = this.renderTree(item.children) li.appendChild(children) li.classList.add('has-children') } ul.appendChild(li) }) return ul } handleDragStart(e) { this.startIndex = this.getIndex(e.target), this.startParentIndex = this.getParentIndex(e.target) e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', null) e.dataTransfer.dropEffect = 'move' e.target.style.opacity = '0.4' this.dragged = e.target e.stopPropagation() } handleDragOver(e) { if (e.preventDefault) { e.preventDefault() } e.dataTransfer.dropEffect = 'move' e.target.style.visibility = 'hidden' e.stopPropagation() return false } handleDrop(e) { if (e.stopPropagation) { e.stopPropagation() } if (this.dragged !== e.target && e.target.tagName === 'DIV') { const newIndex = this.getIndex(e.target), newParentIndex = this.getParentIndex(e.target) const dragData = { oldIndex: this.startIndex, oldParentIndex: this.startParentIndex, newIndex, newParentIndex, dragged: this.dragged } this.dragged.parentNode.removeChild(this.dragged) e.target.style.visibility = '' const dropzone = e.target.parentNode const nextSibling = e.target.nextSibling if (nextSibling) { dropzone.insertBefore(this.dragged, nextSibling) } else { dropzone.appendChild(this.dragged) } this.updateData(dragData) } return false } handleDragEnd(e) { e.target.style.opacity = '' this.dragged = null e.stopPropagation() } getParentIndex(target) { let i = 0 while (target) { if (target.tagName.toLowerCase() === 'li') { break } target = target.parentNode } return Array.from(target.parentNode.children).indexOf(target) } getIndex(target) { let i = 0 while (target) { if (target.previousElementSibling === null) { break } target = target.previousElementSibling i++ } return i } updateData(dragData) { const { oldIndex, newIndex, dragged, oldParentIndex, newParentIndex } = dragData const oldParent = this.findParent(this.data, oldParentIndex) const newParent = this.findParent(this.data, newParentIndex) const item = oldParent.children.splice(oldIndex, 1)[0] const newAfterIndex = this.getNewAfterIndex(newParent.children, newIndex, item) newParent.children.splice(newAfterIndex, 0, item) } findParent(children, parentIndex) { let parent children.forEach(item => { if (parentIndex === 0) { parent = children } if (Array.isArray(item.children) && item.children.length > 0) { parent = this.findParent(item.children, parentIndex - 1) } }) return parent } getNewAfterIndex(children, newIndex, item) { let i = newIndex if (children[newIndex] && children[newIndex].sortOrder < item.sortOrder) { i -- } if (i < 0) { i = 0 } else if (i > children.length) { i = children.length } return i } } window.customElements.define('drag-drop-tree', DragDropTree)
总结
Custom Elements 是一个十分实用的标准,它为 Web 开发带来了更多的可能性。利用 Custom Elements,我们可以更快地构建可重用、可扩展的组件,同时实现更丰富的交互效果。本文介绍了如何使用 Custom Elements 实现一个可拖拽的树形菜单,并总结了在开发过程中使用到的一些技巧和经验。
当然,本文所举的例子只是 Custom Elements 的冰山一角,如果你想深入了解 Custom Elements,还需要更多的实践和学习。我们希望本文能给你带来一些科学的指导,使你在利用 Custom Elements 进行 Web 开发时更加得心应手。
来源:JavaScript中文网 ,转载请注明来源 本文地址:https://www.javascriptcn.com/post/652f73417d4982a6eb094e9d