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

整个周末就这样子泡汤了,难受呀!😖 要命的是这个四月全是单休,需要加班两个周六,还有一个五一周日加班,感觉命比此刻喝的咖啡都苦。😩
那么,回到正题,本次要分享的是关于拖动布局的功能,效果如下,请诸君按需食用哈。

需求背景
最近,小编在做一个仪表盘业务需求,我负责开发核心的拖拽布局功能。与常见的通用型仪表盘不同,这个项目需要高度定制化的交互设计,所以用不了一些现成的拖拽组件库😕(如vue-grid-layout、vue-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');
});
}
});

网格内元素拖动或交换位置
当在网格中拖拽已存在的元素时,要实现能自由拖动元素到其他网格内,如果目标单元格已有元素,咱们还要实现了两者交换位置的功能。
操作过程大致如下:
- 获取拖动源所在单元格(
sourceCell
)和目标单元格(targetCell
); - 如果目标单元格无元素,则直接移动元素。
- 如果目标单元格已存在元素,则将两个元素分别从各自单元格中移除,然后交换位置插入对方。
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;
}
随机排列与数字排序
随机排列:该功能需要遍历当前所有已放置元素,将它们随机分布到网格空白单元格中,操作流程如下:
- 获取所有元素与所有单元格。
- 清空所有单元格。
- 对每个元素随机选取一个未占用的单元格,然后放入并添加动画。
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版本
至此,本篇文章就写完啦,撒花撒花。
