本文是《前端大屏原理系列》第二篇:拖拽组件到页面。
本系列所有技术点,均经过本人开源项目 react-big-screen 实际应用,欢迎 star (*´▽`)ノノ.
一、效果演示

二、拖拽创建
拖拽创建简单的理解:拖拽一个元素模板,将其放置在画板上时创建一个实例
。按照这个思路,我们来了解其中的实现细节。
2.1 开始拖拽
有两种拖拽方案:绑定drag事件
、模拟drag事件
。不管使用哪种方案都可以。我们在拖拽开始时,可以获取组件模板信息并传递。
html5 Drag
伪代码
tsx
templateDom.addEventListener('dragstart', e => {
e.dataTransfer.setData("text/plain", JSON.stringify({
// 组件模板信息
// ...
}));
})
模拟 Drag
伪代码
tsx
useVirtualDrag(domRef, {
type: 'create-component',
data: {
// 组件模板信息
// ...
}
})
2.2 拖拽放置
放置时,我们需要计算两种信息:组件实例对象
、放置坐标
。计算组件实例对象,直接通过接收的模板进行创建即可。计算组件的放置坐标,这一步很容易出错。常规思路:画布设置 position:relative,拖拽到画布时取 e.offsetX、e.offsetY作为放置坐标 。这时你可能会遇到问题:如果放置到 position:relative
的组件上方时怎么计算坐标(此时offsetX/Y取相对于组件的位置而非画布)?
思考:如果大屏缩放至50%时该如何计算?
此处我使用一种无需依赖定位属性
的计算方式:鼠标位置减去画布左上角位置,即放置位置。

实现代码:
js
...
onDrop (e) {
...
// 根据模板信息计算组件实例对象
const componentNode = createComponentNode(...);
// 计算画布的视口位置信息
const containerRect = container.getBoundingClientRect();
// 计算画布创建新组件的放置坐标
//(鼠标坐标与视口坐标的差值,就是放置坐标)
const coordinate = {
x: e.x - containerRect.x,
y: e.y - containerRect.y
};
...
}
...
2.3 创建实例
这一步很简单,将组件实例对象
和创建坐标
组合并渲染到画布上。
tsx
// react-big-screen的实现(伪代码)
...
// 放置事件回调函数
onDrop (e) {
...
// 创建组件实例对象
const componentNode = createComponentNode(...);
...
// 绑定放置坐标
componentNode.x = coordinate.x;
componentNode.y = coordinate.y;
// 添加实例(触发页面渲染)
engine.componentNode.add(componentNode);
// 添加实例后,再选中实例
setTimeout(() => {
// 第二个参数true,表示清空其他选中
engine.instance.select(componentNode.id, true);
})
...
}
...
三、优化体验
3.1 为何不使用 html5 Drag 事件及衍生库?
目的是为了流畅的用户体验
。拖放是 HTML5 的标准之一,任何元素都能够拖放,其可以作为大屏拖拽创建的一种实现方式。drag事件有一个容易忽略的点:如果在一个未添加ondragover事件及e.preventDefault()的元素上方
松开鼠标取消拖拽,拖拽元素会有一段回归的延迟。这个延迟让你不能立即开始对原始元素开始新的拖拽。其他基于html5 Drag事件的第三方库,也会存在这种类似的延迟。

测试代码:
html
<!DOCTYPE html>
<html>
<head>
<style>
.block {
width: 300px;
height: 200px;
color: gray;
display: flex;
margin-top: 100px;
align-items: center;
justify-content: center;
border: 1px solid black;
background-color: whitesmoke;
}
</style>
</head>
<body style="padding: 100px">
<img src="./img/1.jpg" width="300" draggable />
<div class="block">监听 ondragover</div>
<script>
const dom = document.querySelector(".block");
dom.addEventListener("dragover", (e) => {
e.preventDefault();
});
</script>
</body>
</html>
3.2 实现基于 mouse 事件的 useVirtualDrag
为了实现松开鼠标能立刻重新开始拖拽
,我选择开发useVirtualDrag
、useVirtualDrop
这两个 react hook。实现原理是:鼠标开始拖拽时保存拖拽信息,鼠标松开取消拖拽时读取拖拽信息并处理
。需要注意的是,鼠标按住移动才算作拖拽。

使用示例:
tsx
// ----------------- 开始拖拽 hook ----------------
// 开始创建组件类型的拖拽
useVirtualDrag(domRef, {
type: "create-component", // 拖拽类型
data: {
component,
},
});
tsx
// ----------------- 拖拽放置 hook ----------------
// 结束拖拽时创建新组件
useVirtualDrop(domRef, {
accept: ['create-component'], // 匹配拖拽类型
onDrop: (e: MouseEvent, dragOptions) => {
// ...
// 此处执行"页面创建新组件"的逻辑
})
实现 useVirtualDrag:
tsx
import { useEffect, useRef } from "react";
// 拖拽信息
export let dragOptions: VirtualDragOptions & {
isDragging: boolean; // 是否正在拖拽
};
// 拖拽信息
export interface VirtualDragOptions {
type: string; // 拖拽类型
data?: Record<string, any>; // 拖拽携带数据
}
type Unmount = () => void;
// 模拟拖拽
function dragElement(
dom: HTMLElement,
options: {
onStart?: () => void;
onEnd?: () => void;
}
): Unmount {
let isDragging = false; // 拖拽中变量,只有开始拖拽才传输
dom.addEventListener("mousedown", mousedown);
function mousedown(e: MouseEvent) {
dom.addEventListener("mousemove", mousemove); // 在待拖拽元素上鼠标移动,才算拖拽
window.addEventListener("mouseup", mouseup);
}
function mousemove(e: MouseEvent) {
if (isDragging) return; // 开始拖拽只触发一次
isDragging = true;
options?.onStart?.();
}
function mouseup(e: MouseEvent) {
options?.onEnd?.();
}
// 清除拖拽中和结束的事件监听器
function clear() {
if (!isDragging) return;
isDragging = false;
dom.removeEventListener("mousemove", mousemove);
window.removeEventListener("mouseup", mouseup);
}
return () => {
dom.removeEventListener("mousedown", mousedown);
clear();
};
}
// 虚拟拖拽hook
function useVirtualDrag<T extends HTMLElement>(
domRef: React.RefObject<T>,
options: VirtualDragOptions
) {
// 获取最新的options,避免闭包问题。
const optionsRef = useRef<VirtualDragOptions>(options);
optionsRef.current = options;
useEffect(() => {
const dom = domRef.current;
if (!dom) {
throw new Error("dom must be set.");
}
return dragElement(dom, {
onStart() {
// 开始拖拽
// 1. 设置拖拽信息
// 2. 全局设置拖拽图标
// 3. 设置 isDragging = true
dragOptions = {
isDragging: true,
...optionsRef.current,
};
},
onEnd() {
// 结束拖拽
// 1. 全局恢复拖拽图标
// 2. 设置 isDragging = false
dragOptions.isDragging = false;
},
});
}, []);
}
实现 useVirtualDrop:
tsx
import React, { useEffect, useRef } from "react";
import { VirtualDragOptions, dragOptions } from "./useVirtualDrag";
interface VirtualDropOptions {
// 匹配拖拽类型(不设置则匹配任意类型)
accept?: string[];
// 拖拽放置监听回调
onDrop?: (e: MouseEvent, dragOptions: VirtualDragOptions) => void;
}
export function useVirtualDrop<T extends HTMLElement>(
domRef: React.RefObject<T>,
options: VirtualDropOptions
) {
// 保存实时值
const optionsRef = useRef<VirtualDropOptions>(options);
optionsRef.current = options;
useEffect(() => {
const { accept } = options;
if (!Array.isArray(accept)) {
throw new Error("accept must be a array.");
}
const dom = domRef.current;
if (!dom) {
throw new Error("dom must be set.");
}
const mouseup = (e: MouseEvent) => {
// 非拖拽中,则不接收
if (!dragOptions?.isDragging) {
return;
}
// 匹配拖拽类型才会相应接收
if (accept && !accept?.includes?.(dragOptions.type || "")) {
return;
}
optionsRef.current?.onDrop?.(e, dragOptions);
};
dom.addEventListener("mouseup", mouseup);
return () => {
dom.removeEventListener("mouseup", mouseup);
};
}, []);
}
【前端大屏原理系列】
react-big-screen 是一个从0到1使用React开发的前端拖拽大屏开源项目。此系列将对大屏的关键技术点一一解析。包含了:拖拽系统实现、自定义组件、收藏夹、快捷键、可撤销历史记录、加载远程组件/本地组件、自适应预览页、布局容器组件、多组件联动(基于事件机制)、成组/取消成组、多子页面切换、i18n国际化语言、鼠标范围框选、... ... 等等。
演示地址:点击访问