前言
近期翻新了部落格的首页,并调整了一些体验上的问题:
- 更恰当的画面留白
- 添加手机板互动体验与样式
- 更直白的标题

其中一个充满趣味的元素拖曳功能暗藏了许多细节值得分享。最初是看到 Glitch 网站 图示像冰箱磁铁可以随意挪动的功能觉得充满趣味,背后是使用 react-draggable 套件制作。
不过考虑到速度与维护性,部落格不打算因为一页特效就引入额外套件,所以从头用 DOM API 手刻了相关功能。
实做拖动画面元素
结果可以到首页查看或以下 CodePen 重现:
See the Pen Drag Text by Riceball (@riecball) on CodePen.
结构
<span class="relative inline-flex"> <!-- !Hack: Since change element to absolute will cause layout rebuild and weird first grab position, so I create 2 same element here, one is invisible for layout, another one is absolute positioned for user to grab --> <!-- Placeholder --> <span class="invisible hover:-rotate-2 inline-flex items-center gap-1 md:gap-2 border md:border-2 p-1 pr-2 border-current/60 whitespace-nowrap cursor-grab hover:bg-primary/20 hover:text-primary transition"> <!-- icon... --> Design <span class="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 -translate-x-1/2 left-0 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 right-0 translate-x-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-0 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> </span> <!-- Real --> <span id="draggable" class="absolute hover:-rotate-2 inline-flex items-center gap-1 md:gap-2 border md:border-2 p-1 pr-2 border-current/60 whitespace-nowrap cursor-grab hover:bg-primary/20 hover:text-primary transition"> <!-- icon... --> Design <span class="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 -translate-x-1/2 left-0 size-1 md:size-2 bg-current/60"></span> <span class="absolute top-1/2 right-0 translate-x-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-0 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> <span class="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2 size-1 md:size-2 bg-current/60"></span> </span> </span>
多复制了一個一樣的编辑框,加上 invisible
隐藏来防止显示与屏幕阅读器解读以及占位,是因为发现使用 JS 动态改变元素的布局属性势必会让画面布局整个重新计算,如果要有「自然拿起来」的效果,该元素势必要原先就浮动在画面上。虽然是有点 Hacky 的作法,但能有效的改善拿取时画面抖动的体验。
逻辑
const draggable = document.getElementById("draggable");
let posX = 0;let posY = 0;
const grabbingStyle = [ "bg-primary/20", "text-primary", "-rotate-2", "cursor-grabbing"];
function getClientX(e) { return e.touches ? e.touches[0].clientX : e.clientX;}
function getClientY(e) { return e.touches ? e.touches[0].clientY : e.clientY;}
function moveElement(e) { e.preventDefault(); draggable.style.left = `${getClientX(e) - posX}px`; draggable.style.top = `${getClientY(e) - posY}px`;}
function dragStart(e) { e.preventDefault(); draggable.classList.add(...grabbingStyle); posX = getClientX(e) - draggable.offsetLeft; posY = getClientY(e) - draggable.offsetTop;
window.addEventListener("mousemove", moveElement, false); window.addEventListener("touchmove", moveElement, { passive: false });}function dragEnd() { draggable.classList.remove(...grabbingStyle); window.removeEventListener("mousemove", moveElement, false); window.removeEventListener("touchmove", moveElement, false);}
draggable.addEventListener("mousedown", dragStart, false);draggable.addEventListener("touchstart", dragStart, { passive: false });
window.addEventListener("mouseup", dragEnd, false);window.addEventListener("touchend", dragEnd, false);
由于行动装置并不存在 hover 假元素,自然没办法用 CSS 的方式去制作其样式,但还是可以借由 DOM 的 touch 事件展开监听并添加样式。
先拿到目标选取框再设置 mousedown
与 touchstart
事件监听,并且确认滑动时也要设置 mousemove
与 touchmove
事件监听,中途留意抓取的动画效果添加与移除。
从事件监听传下来的事件 (e
) 会经过特制的函式判断「是否为触控事件返回对应的坐标数值」。
function getClientX(e) { return e.touches ? e.touches[0].clientX : e.clientX;}
function getClientY(e) { return e.touches ? e.touches[0].clientY : e.clientY;}
计算「点击位置距离元素左上角的差距」,将点选元素的左上角坐标作为基准。
posX = getClientX(e) - draggable.offsetLeft;
在移动时透过 preventDefault
来阻止预设事件触发行为,例如:阻止选取文字、阻止触控装置上的页面滚动。
设置元素左与上方定位「根据目前点击位置让元素顺着当初点下的位置被拖动」。
function moveElement(e) { e.preventDefault(); draggable.style.left = `${getClientX(e) - posX}px`; draggable.style.top = `${getClientY(e) - posY}px`;}
总结
透过 JS 动态监听与改变元素的样式达成可互动的 DOM 元素特效,并且用程序式程序的方式制作。我想很多人学习前端就是希望可以自制玩玩看前端特效,实际上从头用原生 API 撰写还蛮繁杂且没效率的😅,可以感受到用木棍盖高楼的感觉。
- 可以想像未来如果有多个元素需要监听使用,可以考虑 OOP 或 FP 的方式确保扩充性。
- 效能上使用
transform
丢给 GPU 运行画面或许能获得更好的效能而非直接改动left
、top
。 - 事件频繁触发 throttle 降低更新次数或
requestAnimationFrame()
或许能帮上忙。