🍊🍊🍊在网格中进行拖动布局-Javascript、Vue

写在开头

哈喽,各位好吖!😋

今是2025年04月13日,周日,昨天周六已经加了一天班,今天听说又是大暴雨,又是灰霾,反正从早晨开始天空就灰蒙蒙一片,不敢出门,宅家了。

整个周末就这样子泡汤了,难受呀!😖 要命的是这个四月全是单休,需要加班两个周六,还有一个五一周日加班,感觉命比此刻喝的咖啡都苦。😩

那么,回到正题,本次要分享的是关于拖动布局的功能,效果如下,请诸君按需食用哈。

需求背景

最近,小编在做一个仪表盘业务需求,我负责开发核心的拖拽布局功能。与常见的通用型仪表盘不同,这个项目需要高度定制化的交互设计,所以用不了一些现成的拖拽组件库😕(如vue-grid-layoutvue-draggable等),小编选择基于 HTML5 的拖放API从零开始手撸一套专属的网格布局系统。

而我们需要完成的功能有:

  • 从侧边栏拖入元素放置
  • 元素在网格内自由拖放和位置交换
  • 随机排序
  • 按数字排序
  • 删除元素
  • 布局保存与重新加载
  • ...

本次小编实现的案例是经过精简过的,会比较简单好理解一点,编码会以基础的前端三剑客来实现,文末还会提供 Vue3 版本的代码,也可以瞧一瞧哈。😋

当然,这个案例的功能可能会比较简单一些,真实的业务中,随着功能的逐步迭代,业务复杂度呈现非线性增长。如我们当时的业务中增加了元素的拉伸功能后,整个拖动网格布局系统的复杂度呈指数级上升,不仅我们需要实时计算元素占用空间,还要处理元素重叠时的自动避让(碰撞检测),还需要维护复杂的交互状态机,反正...就是很复杂吧,掉头发的操作。😔

实现过程

这次咱们依旧使用基础的 HTML+CSS+JS 技术来实现拖放网格系统案例。页面主要布局由左右两部分构成:左侧的侧边栏,它包含元素的颜色、形状选择、元素计数以及一些操作按钮;右侧当前是由 7 列、6 行组成的网格容器,列与行可以自个调整。接下来,我们将逐步讲解实现该系统的每个功能模块及其难点。

基础布局

网格的布局小编采用的是 grid 布局,比较简单哈,直接贴代码瞧瞧囖👻:

html 复制代码
<!DOCTYPE html>
<html>
<head>
<style>
    .container {
        display: flex;
        gap: 20px;
        padding: 20px;
    }
    .grid {
        display: grid;
        grid-template-columns: repeat(7, 100px);
        grid-template-rows: repeat(6, 100px);
        gap: 20px;
        border: 1px solid #ccc;
        box-sizing: border-box;
        padding: 20px;
        position: relative;
    }
    .grid-cell {
        width: 100px;
        height: 100px;
        border: 1px solid #eee;
        position: relative;
        box-sizing: border-box;
        transition: transform 0.2s, box-shadow 0.2s;
    }
    .grid-cell.highlight {
        box-shadow: 0 0 10px rgba(0, 0, 255, 0.5);
    }
    .grid-cell.can-drop {
        background-color: rgba(0, 255, 0, 0.1);
    }
    .sidebar {
        width: 200px;
        padding: 10px;
        background: #f5f5f5;
        border-radius: 8px;
        box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
    }
    .draggable-item {
        width: 100px;
        height: 100px;
        background: lightblue;
        cursor: move;
        border: 2px solid #666;
        box-sizing: border-box;
        transition: transform 0.2s;
        border-radius: 5px;
        margin-left: auto;
        margin-right: auto;
    }
    .draggable-item:hover {
        transform: scale(1.05);
    }
    .dropped-item {
        width: 98px;
        height: 98px;
        background: lightblue;
        position: absolute;
        top: 0;
        left: 0;
        cursor: move;
        display: flex;
        justify-content: center;
        align-items: center;
        font-size: 24px;
        color: #333;
        font-weight: bold;
        border-radius: 5px;
        transition: transform 0.3s, box-shadow 0.3s;
        box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
    }
    .dropped-item:hover {
        transform: scale(1.02);
        box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
        z-index: 10;
    }
    .color-palette {
        display: flex;
        gap: 10px;
        margin-bottom: 20px;
        flex-wrap: wrap;
        justify-content: center;
    }
    .color-option {
        width: 30px;
        height: 30px;
        border-radius: 50%;
        cursor: pointer;
        border: 2px solid #333;
        transition: transform 0.2s;
    }
    .color-option:hover {
        transform: scale(1.2);
    }
    .color-option.selected {
        border: 3px solid #000;
        box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
    }
    .delete-btn {
        position: absolute;
        top: 2px;
        right: 2px;
        width: 16px;
        height: 16px;
        background: red;
        color: white;
        border-radius: 50%;
        display: flex;
        justify-content: center;
        align-items: center;
        font-size: 12px;
        cursor: pointer;
        z-index: 10;
        opacity: 0;
        transition: opacity 0.3s;
    }
    .dropped-item:hover .delete-btn {
        opacity: 1;
    }
    .controls {
        margin-top: 20px;
        display: flex;
        flex-direction: column;
        gap: 10px;
    }
    .control-btn {
        padding: 8px 12px;
        background: #4a90e2;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        transition: background 0.3s;
    }
    .control-btn:hover {
        background: #3a80d2;
    }
    .shape-options {
        display: flex;
        gap: 10px;
        margin-top: 15px;
        margin-bottom: 15px;
        justify-content: center;
    }
    .shape-option {
        width: 40px;
        height: 40px;
        cursor: pointer;
        border: 2px solid #333;
        display: flex;
        justify-content: center;
        align-items: center;
    }
    .shape-option.selected {
        border-color: #000;
        background-color: rgba(0, 0, 0, 0.1);
    }
    .shape-square {
        border-radius: 0;
    }
    .shape-circle {
        border-radius: 50%;
    }
    .item-counter {
        margin-top: 15px;
        text-align: center;
        font-weight: bold;
    }
    .save-load {
        margin-top: 20px;
    }
    .save-load button {
        width: 100%;
        margin-bottom: 10px;
    }
    @keyframes pulse {
        0% {
            transform: scale(1);
        }
        50% {
            transform: scale(1.1);
        }
        100% {
            transform: scale(1);
        }
    }
    .pulse {
        animation: pulse 0.5s;
    }
</style>
</head>
<body>
    <div class="container">
        <div class="sidebar">
            <h3 style="text-align: center; white-space: nowrap">拖放网格系统</h3>
            <div class="color-palette">
                <div class="color-option selected" style="background: lightblue" data-color="lightblue"></div>
                <div class="color-option" style="background: lightcoral" data-color="lightcoral"></div>
                <div class="color-option" style="background: lightgreen" data-color="lightgreen"></div>
                <div class="color-option" style="background: lightgoldenrodyellow" data-color="lightgoldenrodyellow"></div>
                <div class="color-option" style="background: #9370db" data-color="#9370DB"></div>
                <div class="color-option" style="background: #ff7f50" data-color="#FF7F50"></div>
                <div class="color-option" style="background: #20b2aa" data-color="#20B2AA"></div>
                <div class="color-option" style="background: #ff69b4" data-color="#FF69B4"></div>
            </div>
            <div class="shape-options">
                <div class="shape-option shape-square selected" data-shape="square"></div>
                <div class="shape-option shape-circle" data-shape="circle"></div>
            </div>
            <div class="draggable-item" draggable="true" id="dragItem"></div>
            <div class="item-counter" style="white-space: nowrap">
                已放置: <span id="itemCount">0</span> 个元素
            </div>
            <div class="controls">
                <button class="control-btn" id="clearAllBtn">清空网格</button>
                <button class="control-btn" id="randomizeBtn">随机排列</button>
                <button class="control-btn" id="sortBtn">按数字排序</button>
            </div>
            <div class="save-load">
                <button class="control-btn" id="saveBtn">保存布局</button>
                <button class="control-btn" id="loadBtn">加载布局</button>
            </div>
        </div>
        <div class="grid" id="grid"></div>
    </div>
    <script>
        // 网格配置
        const columns = 8;
        const rows = 6;
        const cellSize = 100; // 单元格尺寸
        const gapSize = 20; // 间距尺寸

        // 创建网格
        const grid = document.getElementById("grid");

        // 设置网格布局
        grid.style.gridTemplateColumns = `repeat(${columns}, ${cellSize}px)`;
        grid.style.gridTemplateRows = `repeat(${rows}, ${cellSize}px)`;
        grid.style.gap = `${gapSize}px`;

        // 创建网格单元格
        for (let i = 0; i < rows; i++) {
            for (let j = 0; j < columns; j++) {
                const cell = document.createElement("div");
                cell.className = "grid-cell";
                cell.dataset.position = `${i}-${j}`;
                grid.appendChild(cell);
            }
        }
    </script>
</body>
</html>

事件监听-颜色与形状

在侧边栏中,咱们提供了元素的颜色和形状的选择,用户点击后即更新当前全局使用的颜色与形状,同时实时修改左侧那个预览用的拖动项的样式。

由于咱们没有使用框架(Vue/React),所以,事件监听、状态更新与操作 DOM 都要使用最原始的 JS 来完成,如利用 HTML5的 data- 属性存储值以及通过原始API来操作 DOM 节点,进行添加/移除CSS类,算是复习一下 JS 的知识哈。😋

javascript 复制代码
let currentShape = 'square';
let currentColor = 'lightblue';

// 颜色选择:为每个颜色选项绑定点击事件
document.querySelectorAll('.color-option').forEach(option => {
  option.addEventListener('click', function() {
    // 移除所有选项的 selected 类
    document.querySelectorAll('.color-option').forEach(opt => opt.classList.remove('selected'));
    // 给当前点击选项添加选中样式
    this.classList.add('selected');
    // 更新全局颜色变量
    currentColor = this.dataset.color;
    // 同时更新预览拖动项的背景色
    document.getElementById('dragItem').style.background = currentColor;
  });
});

// 形状选择:为每个形状选项绑定点击事件
document.querySelectorAll('.shape-option').forEach(option => {
  option.addEventListener('click', function() {
    document.querySelectorAll('.shape-option').forEach(opt => opt.classList.remove('selected'));
    this.classList.add('selected');
    // 更新全局形状变量
    currentShape = this.dataset.shape;
    const dragItem = document.getElementById('dragItem');
    // 根据当前选择更新预览拖动项的圆角,circle 为 50%(圆形),square 为 0(方形)
    dragItem.style.borderRadius = currentShape === 'circle' ? '50%' : '0';
  });
});

拖动放置交互

实现拖动操作主要依赖 HTML5 拖放 API,涵盖了五个关键事件:

  • dragstart:拖动开始,用于确定拖动源,记录拖动元素。

  • dragend:拖动结束,用于清除拖动状态。

  • dragover:拖动元素被拖进一个有效的放置目标时(每几百毫秒)触发,可以认为是移动中,需要调用 preventDefault() 允许放置。

  • dragleave:离开一个有效的放置目标时被触发,用于清除拖动状态。

  • drop:拖动元素放置到有效的放置目标上时触发。

上面五个事件,再加 dragenter,共六个拖拽核心事件。

还有,鼠标三兄弟:

mousedown:鼠标按下。

mousemove:鼠标移动。

mouseup:鼠标释放。

这些事件就像前端开发的"九九乘法表",看似简单,但却是构建复杂交互的基石。一个合格的前端工程师,应该能够灵活运用这些事件,创造出流畅自然的拖拽体验。

js 复制代码
// 记录当前拖动的元素
let draggedItem = null;
let itemIndex = 1;

document.addEventListener("dragstart", function (e) {
  // 只处理侧边栏或网格内的可拖动元素
  if (
    e.target.classList.contains("draggable-item") ||
    e.target.classList.contains("dropped-item")
  ) {
    draggedItem = e.target;
    // 根据拖动源所在的容器设定复制或移动模式
    e.dataTransfer.effectAllowed = e.target.parentElement.className === "sidebar" ? "copy" : "move";
  }
});

document.addEventListener("dragover", function (e) {
  // 识别拖动进入的目标是否为网格单元格
  const gridCell = e.target.classList.contains("grid-cell") ? e.target : e.target.closest(".grid-cell");
  if (gridCell) {
    e.preventDefault(); // 允许放置
  }
});

// 修改drop事件处理
document.addEventListener("drop", function (e) {
  // 获取目标网格单元格,无论是直接拖放到单元格还是其中的元素上
  const targetCell = e.target.classList.contains("grid-cell") ? e.target : e.target.closest(".grid-cell");
  if (!targetCell) return;
  e.preventDefault();
  if (draggedItem.parentElement.className === "sidebar") {
    // 如果目标位置已有元素,则不允许放置
    if (targetCell.querySelector(".dropped-item")) {
      alert("该位置已有元素,请选择空白位置放置");
      return;
    }

    // 创建新元素
    const clone = draggedItem.cloneNode(true);
    clone.classList.replace("draggable-item", "dropped-item");
    clone.textContent = itemIndex++;
    clone.draggable = true;
    clone.style.background = currentColor;

    // 应用形状
    clone.style.borderRadius = '0';

    if (currentShape === 'circle') {
            clone.style.borderRadius = '50%';
    }

    // 添加新元素到目标单元格
    targetCell.appendChild(clone);
  }
  updateCounter();
});

/** @name 更新计数器 **/
function updateCounter() {
  const count = document.querySelectorAll('.dropped-item').length;
  document.getElementById('itemCount').textContent = count;
}

高亮与动画反馈

为了给用户更直观的操作反馈,咱们在放置完成后为目标网格单元格增加短暂的高亮效果(浅绿),并为新拖放的元素添加一个缩放"脉冲"动画效果。

js 复制代码
document.addEventListener("dragover", function (e) {
  const gridCell = e.target.classList.contains("grid-cell") ? e.target : e.target.closest(".grid-cell");
  if (gridCell) {
    e.preventDefault();
    // 增加浅绿的高亮
    gridCell.classList.add('can-drop');
  }
});
document.addEventListener("drop", function (e) {
  // ...
  if (draggedItem.parentElement.className === "sidebar") {
    // ...
    // 添加动画效果
    clone.classList.add('pulse');
  }
  updateCounter();
});

document.addEventListener("dragleave", function (e) {
  const gridCell = e.target.classList.contains("grid-cell") ? e.target : e.target.closest(".grid-cell");
  if (gridCell) {
    // 移除高亮
    gridCell.classList.remove('can-drop');
  }
});
document.addEventListener("dragend", function (e) {
  if (draggedItem) {
    // 移除所有单元格的高亮,确保高亮完全移除
    document.querySelectorAll('.grid-cell').forEach(cell => {
      cell.classList.remove('can-drop');
    });
  }
});

网格内元素拖动或交换位置

当在网格中拖拽已存在的元素时,要实现能自由拖动元素到其他网格内,如果目标单元格已有元素,咱们还要实现了两者交换位置的功能。

操作过程大致如下:

  1. 获取拖动源所在单元格(sourceCell)和目标单元格(targetCell);
  2. 如果目标单元格无元素,则直接移动元素。
  3. 如果目标单元格已存在元素,则将两个元素分别从各自单元格中移除,然后交换位置插入对方。
js 复制代码
document.addEventListener("drop", function (e) {
  // ...
  if (draggedItem.parentElement.className === "sidebar") {
    // ...
  }else {
    // 网格内元素之间的拖动 - 交换位置
    const sourceCell = draggedItem.parentElement;
    if (targetCell.querySelector('.dropped-item')) {
      // 目标位置已有元素,执行交换
      const existingItem = targetCell.querySelector('.dropped-item');
      // 保存现有元素的引用
      const tempItem = existingItem;
      // 从各自的父元素中移除
      sourceCell.removeChild(draggedItem);
      targetCell.removeChild(existingItem);
      // 添加到对方的位置
      sourceCell.appendChild(tempItem);
      targetCell.appendChild(draggedItem);
      // 添加动画效果
      tempItem.classList.add('pulse');
      draggedItem.classList.add('pulse');
    } else {
      // 目标位置没有元素,直接移动
      targetCell.appendChild(draggedItem);
      draggedItem.classList.add('pulse');
    }
  }
  updateCounter();
});

删除功能

每个放入网格的元素会自动添加一个删除按钮,用户点击后即可删除该元素。注意在删除按钮的点击事件中要调用 e.stopPropagation(),以免触发拖放事件。

js 复制代码
document.addEventListener("drop", function (e) {
  // ...
  if (draggedItem.parentElement.className === "sidebar") {
    // ...
    
    if (currentShape === "circle") {
      clone.style.borderRadius = "50%";
    }
    
    // 增加删除按钮
    const deleteBtn = createRemoveButton();
    clone.appendChild(deleteBtn);

    // ...
  }else {
    // ...
  }
  updateCounter();
});
/** @name 创建删除按钮 **/
function createRemoveButton() {
  const deleteBtn = document.createElement("div");
  deleteBtn.className = "delete-btn";
  deleteBtn.textContent = "×";
  deleteBtn.addEventListener("click", function (e) {
    e.stopPropagation();
    clone.classList.add("pulse");
    setTimeout(() => {
      clone.remove();
      updateCounter();
    }, 300);
  });
  return deleteBtn;
}

随机排列与数字排序

随机排列:该功能需要遍历当前所有已放置元素,将它们随机分布到网格空白单元格中,操作流程如下:

  1. 获取所有元素与所有单元格。
  2. 清空所有单元格。
  3. 对每个元素随机选取一个未占用的单元格,然后放入并添加动画。
js 复制代码
document.getElementById('randomizeBtn').addEventListener('click', function() {
  const items = Array.from(document.querySelectorAll('.dropped-item'));
  const cells = Array.from(document.querySelectorAll('.grid-cell'));
  
  // 清空所有单元格内内容
  cells.forEach(cell => cell.innerHTML = '');
  
  // 随机分布每个元素
  items.forEach(item => {
    let randomCell;
    do {
      randomCell = cells[Math.floor(Math.random() * cells.length)];
    } while (randomCell.querySelector('.dropped-item'));
    randomCell.appendChild(item);
    item.classList.add('pulse');
  });
});

数字排序:根据元素中显示的数字,按递增顺序对所有元素进行排序,然后从上到下、从左到右重新排列。

js 复制代码
document.getElementById('sortBtn').addEventListener('click', function() {
  const items = Array.from(document.querySelectorAll('.dropped-item'));
  const cells = Array.from(document.querySelectorAll('.grid-cell'));
  
  // 按数字内容升序排序
  items.sort((a, b) => parseInt(a.textContent) - parseInt(b.textContent));
  
  // 清空所有单元格
  cells.forEach(cell => cell.innerHTML = '');
  
  // 按序依次放入单元格
  items.forEach((item, index) => {
    if (index < cells.length) {
      cells[index].appendChild(item);
      item.classList.add('pulse');
    }
  });
});

保存布局与加载布局

为实现布局持久化,我们利用 localStorage 将网格中每个已放置元素的信息(位置、数字、颜色、形状)存储为 JSON 格式数据。加载时,根据保存的数据还原各个元素及其样式。

其实,真实业务中应该是通过接口来存储,这里咱们借用 localStorage 来演示一下。

js 复制代码
document.getElementById('saveBtn').addEventListener('click', function() {
  const layout = [];
  // 遍历每个单元格,若存在放置元素则记录数据
  document.querySelectorAll('.grid-cell').forEach(cell => {
    const item = cell.querySelector('.dropped-item');
    if (item) {
      layout.push({
        position: cell.dataset.position,
        number: item.textContent,
        color: item.style.background,
        shape: item.style.borderRadius === '50%' ? 'circle' : 'square'
      });
    }
  });
  localStorage.setItem('gridLayout', JSON.stringify(layout));
  alert('布局已保存!٩(๑❛ᴗ❛๑)۶');
});
document.getElementById('loadBtn').addEventListener('click', function() {
  const savedLayout = localStorage.getItem('gridLayout');
  if (savedLayout) {
    // 清空当前网格内所有放置元素
    document.querySelectorAll('.dropped-item').forEach(item => item.remove());
    // 解析保存的布局数据
    const layout = JSON.parse(savedLayout);
    layout.forEach(item => {
      // 根据保存的单元格 position 定位目标单元格
      const cell = document.querySelector(`.grid-cell[data-position="${item.position}"]`);
      if (cell) {
        const newItem = document.createElement('div');
        newItem.className = 'dropped-item';
        newItem.textContent = item.number;
        newItem.draggable = true;
        newItem.style.background = item.color;
        newItem.style.borderRadius = item.shape === 'circle' ? '50%' : '0';
        // 添加删除按钮
        const deleteBtn = document.createElement('div');
        deleteBtn.className = 'delete-btn';
        deleteBtn.textContent = '×';
        deleteBtn.addEventListener('click', function(e) {
          e.stopPropagation();
          newItem.remove();
          updateCounter();
        });
        newItem.appendChild(deleteBtn);
        cell.appendChild(newItem);
        newItem.classList.add('pulse');
      }
    });
    updateCounter();
    // 更新全局元素数字计数
    const maxNumber = Math.max(...layout.map(item => parseInt(item.number)));
    itemIndex = maxNumber + 1;
    alert('加载成功!(づ ̄ ³ ̄)づ');
  } else {
    alert('没有找到保存的布局哦~');
  }
});

Vue3版本

传送门


至此,本篇文章就写完啦,撒花撒花。

相关推荐
半兽先生14 分钟前
VueDOMPurifyHTML 防止 XSS(跨站脚本攻击) 风险
前端·xss
冴羽17 分钟前
SvelteKit 最新中文文档教程(20)—— 最佳实践之性能
前端·javascript·svelte
Nuyoah.18 分钟前
《Vue3学习手记2》
javascript·vue.js·学习
Jackson__24 分钟前
面试官:谈一下在 ts 中你对 any 和 unknow 的理解
前端·typescript
zpjing~.~32 分钟前
css 二维码始终显示在按钮的正下方,并且根据不同的屏幕分辨率自动调整位置
前端·javascript·html
红虾程序员1 小时前
Linux进阶命令
linux·服务器·前端
yinuo1 小时前
uniapp在微信小程序中实现 SSE 流式响应
前端
lynx_1 小时前
又一个跨端框架——万字长文解析 ReactLynx 实现原理
前端·javascript·前端框架
子燕若水1 小时前
UE5 Chaos :官方文献总结 + 渲染网格体 (Render Mesh) 和模拟网格体 是如何关联的?为什么模拟网格体 可以驱动渲染网格体?
前端
Anlici1 小时前
深度前端面试知识体系总结
前端·面试