列表排序算法在前端项目中的实战优化技巧

Prog.诗谣 交互 阅读 3,020
赞 18 收藏
二维码
手机扫码查看
反馈

先上代码,看完你就懂了

做前端这些年,最烦的不是写样式,是那种“看着简单,一做就崩”的交互。比如列表排序——用户拖一下就能重新排,看起来不就是个 drag & drop?可真动手的时候,你会发现:顺序存不住、动画卡顿、移动端不响应……一堆坑。

列表排序算法在前端项目中的实战优化技巧

我最近在做一个后台管理页,需要让用户手动调整菜单顺序,数据要实时同步到后端。一开始想用现成的库,但引入一个 10KB 的插件只为做这个功能,太重了。最后决定自己手撸一套,亲测有效,今天把核心方案分享出来。

// 假设你有一个待排序的列表
let list = [
  { id: 1, name: '首页' },
  { id: 2, name: '产品' },
  { id: 3, name: '关于' },
  { id: 4, name: '联系' }
];

// 核心排序函数
function reorder(list, startIndex, endIndex) {
  const result = Array.from(list);
  const [removed] = result.splice(startIndex, 1);
  result.splice(endIndex, 0, removed);
  return result;
}

这段代码是我从 React 官方拖拽库 react-beautiful-dnd 源码里扒出来的逻辑,别小看这几行,它处理了数组移动中最常见的边界问题。比如你从第 0 位拖到第 3 位,或反过来,都能正确插入。

实际项目中,我把这个 reorder 函数封装成了工具方法,放在 utils/array.js 里,以后哪儿要用直接 import。

DOM 层怎么做?HTML5 Drag API 最省事

很多人一上来就想用 mousemove + position 计算,折腾半天发现移动端根本没法用。我建议:能用原生 Drag API 就用,兼容性够用,代码也干净。

<ul class="sortable-list">
  <li 
    draggable="true" 
    data-id="1"
    ondragstart="handleDragStart(event)"
    ondragover="handleDragOver(event)"
    ondrop="handleDrop(event)"
    ondragend="handleDragEnd(event)"
  >
    首页
  </li>
  <li 
    draggable="true" 
    data-id="2"
    ondragstart="handleDragStart(event)"
    ondragover="handleDragOver(event)"
    ondrop="handleDrop(event)"
    ondragend="handleDragEnd(event)"
  >
    产品
  </li>
  <!-- 更多 item -->
</ul>
let dragSrcEl = null;

function handleDragStart(e) {
  dragSrcEl = this;
  e.dataTransfer.effectAllowed = 'move';
  e.dataTransfer.setData('text/plain', this.getAttribute('data-id'));
  this.classList.add('dragging');
}

function handleDragOver(e) {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'move';
  if (this !== dragSrcEl) {
    this.classList.add('drag-over');
  }
  return false;
}

function handleDrop(e) {
  e.stopPropagation();
  if (this !== dragSrcEl) {
    const srcId = parseInt(dragSrcEl.getAttribute('data-id'), 10);
    const destId = parseInt(this.getAttribute('data-id'), 10);

    const srcIndex = list.findIndex(item => item.id === srcId);
    const destIndex = list.findIndex(item => item.id === destId);

    list = reorder(list, srcIndex, destIndex);
    renderList(); // 重新渲染视图
  }
  return false;
}

function handleDragEnd() {
  document.querySelectorAll('.dragging, .drag-over')
    .forEach(el => el.classList.remove('dragging', 'drag-over'));
}

这里注意下,我踩过好几次坑:一定要在 handleDragOver 里调 e.preventDefault(),否则 ondrop 不触发。MDN 文档写得很隐晦,但我记得第一次是因为漏了这句,调试了快一个小时才定位到问题。

另外,draggable="true" 必须加在每个 li 上,不然整个列表都不会进入拖拽状态。

样式细节不能忽略

光有功能不行,用户体验得跟上。比如拖动时那个半透明影子,默认很难看,而且你没法控制它的内容。解决办法是自己画个占位元素,或者干脆接受默认表现——毕竟大多数后台系统用户更关心功能而不是动画。

我选择加点 CSS 让视觉反馈更明确:

.sortable-list .dragging {
  opacity: 0.5;
  background-color: #f0f0f0;
}

.sortable-list .drag-over {
  background-color: #e6f7ff;
  border-top: 2px solid #1890ff;
}

这样当用户拖到某个项上方时,会看到一条蓝色上线边框,直观表示“即将插入到这里”。比单纯改背景色更清晰。

移动端怎么办?Touch 事件替代方案

上面这套在 PC 端没问题,但在手机上完全不工作。我又踩坑了,客户拿着 iPad 一点没反应,当场尴尬。

后来补了个 touch 版本,原理类似,监听 touchstarttouchmovetouchend,然后动态计算位置。

核心思路是:手指按住不放超过 300ms 触发拖拽模式,期间根据 Y 坐标判断当前悬停在哪个 item 上。

代码略长,贴关键部分:

let touchTimeout;
let draggingItem = null;
const threshold = 300; // 毫秒

document.querySelectorAll('.sortable-list li').forEach(item => {
  item.addEventListener('touchstart', function(e) {
    const touch = e.touches[0];
    dragStartY = touch.clientY;

    touchTimeout = setTimeout(() => {
      draggingItem = this;
      this.classList.add('dragging');
    }, threshold);
  });

  item.addEventListener('touchmove', function(e) {
    if (!draggingItem) return;
    clearTimeout(touchTimeout);

    const touch = e.touches[0];
    const currentY = touch.clientY;
    const items = document.querySelectorAll('.sortable-list li');

    // 找到当前应该交换的位置
    for (let i = 0; i < items.length; i++) {
      const box = items[i].getBoundingClientRect();
      const center = box.top + box.height / 2;
      if (currentY > center && items[i] !== draggingItem) {
        // 视觉交换位置(可优化为仅移动 DOM)
        if (i > Array.from(items).indexOf(draggingItem)) {
          items[i].parentNode.insertBefore(draggingItem, items[i].nextSibling);
        } else {
          items[i].parentNode.insertBefore(draggingItem, items[i]);
        }
        break;
      }
    }
  });

  item.addEventListener('touchend', function() {
    clearTimeout(touchTimeout);
    if (draggingItem) {
      const fromIndex = list.findIndex(...); // 获取原始索引
      const toIndex = getCurrentDomOrder(); // 获取当前 DOM 顺序
      list = reorder(list, fromIndex, toIndex);
      renderList(); // 强制统一数据与视图
      draggingItem = null;
    }
  });
});

这里有个大坑:touchmove 频率太高,直接操作 DOM 会导致卡顿。我最初的实现每 move 一次就 re-render 整个列表,滑两下页面就卡死了。后来改成只调整 DOM 顺序,最后一次性同步数据,才恢复正常。

但这还不是最优解。如果你项目复杂度高,建议直接上第三方库,比如 SortableJS,支持 PC 和移动端,API 简洁,压缩后才 7KB 左右。

异步保存和防抖

排序完总得存吧?我一开始是每次拖完立刻发请求:

fetch('https://jztheme.com/api/menu-order', {
  method: 'POST',
  body: JSON.stringify({ order: list.map(i => i.id) })
})

结果用户连拖五次,发了五个请求,后端直接报错“频率超限”。于是加上防抖:

let saveTimer;
function scheduleSave() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    fetch('https://jztheme.com/api/menu-order', {
      method: 'POST',
      body: JSON.stringify({ order: list.map(i => i.id) })
    });
  }, 800);
}

现在只要用户连续操作,就不会频繁提交。等他松手 800ms 后再保存。实测效果很好,既保证了及时性,又避免了压力。

踩坑提醒:这三点一定注意

  • 不要依赖 DOM 顺序来维护数据顺序 —— 我试过直接读取 li 的顺序作为最终结果,结果发现某些情况下 React 重新渲染导致 DOM 错乱,数据就错了。一定要以 JS 数据源为准,DOM 只是视图。
  • 移动端慎用长时间按压触发 —— 有些安卓机自带长按菜单,会冲突。你可以考虑加个“编辑模式”开关,开启后才能拖拽,避免误触。
  • IE11 支持有限 —— 虽然 Drag API 在 IE11 有基本支持,但 dataTransfer.setData 只支持 Text 类型,其他类型会报错。如果还要兼容 IE,建议降级为点击上下箭头调整顺序。

拓展玩法:带分组的列表排序

有个需求是左右两个栏,左边未启用,右边已启用,允许互相拖拽。其实本质一样,只是 ondrop 时判断来源容器。

关键是在 dataTransfer 里多塞一个 from-container 标记,目标容器根据这个字段决定是否接收。

进阶技巧:可以给不同类型的 item 设置不同的 effectAllowed,比如只允许复制不允许移动,提升语义化。

总结

以上是我个人对列表排序的完整实践总结。核心就是那几行 reorder 数组操作 + 原生 Drag API,轻量、可控、不依赖框架。

当然也有不足:比如动画不够顺滑,移动端体验不如原生 App。但对大多数管理系统来说,够用了。

这个技巧的拓展用法还有很多,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论