包括文件上传和低代码平台组件,都有使用到拖拽功能。其实利用拖拽功能,还可以跨越浏览器的边界,实现数据共享。
利用 Transmat 库就可以实现上述功能。还可以实现一些比较好玩的功能,比如针对不同的可释放目标,做出不同的响应。
功能和使用场景
Transmat 是一个围绕 DataTransfer API 的小型库 ,它使用 drag-drop 和 copy-paste 交互简化了在 Web 应用程序中传输和接收数据的过程。 DataTransfer API 能够将多种不同类型的数据传输到用户设备上的其他应用程序,包括 text/plain、text/html 和 application/json 等数据。
其主要的应用场景如下:
- 想以便捷的方式与外部应用程序集成。
- 希望为用户提供与其他应用程序共享数据的能力,即使是那些不知道的应用程序。
- 希望外部应用程序能够与 Web 应用程序深度集成。
- 想让应用程序更好地适应用户现有的工作流程。
实战
发送方:
<script src="https://unpkg.com/transmat/lib/index.umd.js"></script>
<div id="draggable-ele" draggable="true" tabindex="0">我是可拖拽元素</div>
<style>
#draggable-ele {
background: #eef;
border: solid 1px rgba(0, 0, 255, 0.2);
border-radius: 8px;
cursor: move;
display: inline-block;
margin: 1em;
padding: 4em 5em;
}
</style>
<script >
const { Transmat, addListeners, TransmatObserver } = transmat;
const draggableEle = document.getElementById("draggable-box");
addListeners(draggableEle, "transmit", (event) => {
const transmat = new Transmat(event);
transmat.setData({
"text/plain": "我是可拖拽元素",
"text/html": `
<h1>edemao</h1>
<p>聚焦全栈,专注分享前端等技术干货。
<a href="edemao.html">访问我的主页</a>!
</p>
`,
"text/uri-list": "https://juejin.cn/user/111111",
"application/json": {
name: "edemao",
wechat: "edemao",
},
});
});
</script>
上述代码,利用 transmat 这个库提供的 addListeners
函数为 div#draggable-box
元素,添加了 transmit
的事件监听。在对应的事件处理器中,我们先创建了 Transmat
对象,然后调用该对象上的 setData
方法设置不同 MIME 类型的数据:
text/plain
:表示文本文件的默认值,一个文本文件应当是人类可读的,并且不包含二进制数据。text/html
:表示 HTML 文件类型,一些富文本编辑器会优先从dataTransfer
对象上获取text/html
类型的数据,如果不存在的话,再获取text/plain
类型的数据。text/uri-list
:表示 URI 链接类型,大多数浏览器都会优先读取该类型的数据,如果发现是合法的 URI 链接,则会直接打开该链接。如果不是的合法 URI 链接,对于 Chrome 浏览器来说,它会读取text/plain
类型的数据并以该数据作为关键词进行内容检索。application/json
:表示 JSON 类型。
接收方:
<script src="https://unpkg.com/transmat/lib/index.umd.js"></script>
<div id="target-pos" tabindex="0">存放拖拽元素</div>
<style>
body {
text-align: center;
font: 1.2em Helvetia, Arial, sans-serif;
}
#target {
border: dashed 1px rgba(0, 0, 0, 0.5);
border-radius: 8px;
margin: 1em;
padding: 4em;
}
.drag-active {
background: rgba(255, 255, 0, 0.1);
}
.drag-over {
background: rgba(255, 255, 0, 0.5);
}
</style>
<script >
const { Transmat, addListeners, TransmatObserver } = transmat;
const target = document.getElementById("target-pos");
addListeners(target, "receive", (event) => {
const transmat = new Transmat(event);
/** 判断是否含有"application/json"类型的数据及事件类型是否为drop或paste事件 */
if (transmat.hasType("application/json")
&& transmat.accept()
) {
const jsonString = transmat.getData("application/json");
const data = JSON.parse(jsonString);
target.textContent = jsonString;
}
});
const obs = new TransmatObserver((entries) => {
for (const entry of entries) {
const transmat = new Transmat(entry.event);
if (transmat.hasType("application/json")) {
entry.target.classList.toggle("drag-active", entry.isActive);
entry.target.classList.toggle("drag-over", entry.isTarget);
}
}
});
obs.observe(target);
</script>
同样利用 transmat 库提供的 addListeners
函数为 div#target
元素,添加了接收消息的 receive
事件监听。在对应的事件处理器中,通过 transmat
对象的 hasType
方法过滤了 application/json
的消息,然后通过 JSON.parse
方法进行反序列化获得对应的数据,同时把对应 jsonString
的内容显示在 div#target
元素内。而且,当把可拖拽的元素,拖拽至自定义的释放目标时,会产生高亮效果,这个效果是利用 transmat 这个库提供的 TransmatObserver
类来实现,该类可以响应用户的拖拽行为。
源码分析
接下来将围绕上文中使用的 addListeners
、Transmat
、TransmatObserver
这三个 “函数” 来介绍 transmat 的核心功能。
addListeners 函数
该函数用于设置监听器,调用该函数后会返回一个用于移除事件监听的函数。其函数签名:
/**
* src/transmat.ts
* @params target:表示监听的目标,它的类型是 Node 类型。
* type:表示监听的类型,该参数的类型 TransferEventType 是一个联合类型 —— 'transmit' | 'receive'。
* listener:表示事件监听器,它支持的事件类型为 DataTransferEvent,该类型也是一个联合类型 ——
* DragEvent | ClipboardEvent,即支持拖拽事件和剪贴板事件。
* options:表示配置对象,用于设置是否允许拖拽和复制、粘贴操作。
*/
function addListeners<T extends Node>(
target: T,
type: TransferEventType,
listener: (event: DataTransferEvent, target: T) => void,
options = {dragDrop: true, copyPaste: true}
): () => void;
export function addListeners<T extends Node>(
target: T,
type: TransferEventType,
listener: (event: DataTransferEvent, target: T) => void,
options = {dragDrop: true, copyPaste: true}
): () => void {
const isTransmitEvent = type === 'transmit';
let unlistenCopyPaste: undefined | (() => void);
let unlistenDragDrop: undefined | (() => void);
if (options.copyPaste) {
/** 1. 可拖拽源监听 cut 和 copy 事件,可释放目标监听 paste 事件 */
const events = isTransmitEvent ? ['cut', 'copy'] : ['paste'];
const parentElement = target.parentElement!;
unlistenCopyPaste = addEventListeners(parentElement, events, event => {
if (!target.contains(document.activeElement)) {
return;
}
listener(event as DataTransferEvent, target);
if (event.type === 'copy' || event.type === 'cut') {
event.preventDefault();
}
});
}
if (options.dragDrop) {
/** 2. 可拖拽源监听 dragstart 事件,可释放目标监听 dragover 和 drop 事件 */
const events = isTransmitEvent ? ['dragstart'] : ['dragover', 'drop'];
unlistenDragDrop = addEventListeners(target, events, event => {
listener(event as DataTransferEvent, target);
});
}
/** 3. 返回函数对象,用于移除已注册的事件监听 */
return () => {
unlistenCopyPaste && unlistenCopyPaste();
unlistenDragDrop && unlistenDragDrop();
};
}
从代码可以看出:
- 步骤 1:根据
isTransmitEvent
和options.copyPaste
的值,注册剪贴板相关的事件。 - 步骤 2:根据
isTransmitEvent
和options.dragDrop
的值,注册拖拽相关的事件。 - 步骤 3:返回函数对象,用于移除已注册的事件监听。
addListeners 中的事件监听最终是通过调用 addEventListeners 函数来实现,在该函数内部会循环调用 addEventListener
方法来添加事件监听。
根据实战中的例子,在对应的事件处理回调函数内部,会以 event
事件对象为参数,调用 Transmat
构造函数创建 Transmat
实例。
Transmat 类
该类的构造函数含有一个类型为 DataTransferEvent
的只读参数 event
:
/** src/transmat.ts */
export class Transmat {
public readonly event: DataTransferEvent;
public readonly dataTransfer: DataTransfer;
/** type DataTransferEvent = DragEvent | ClipboardEvent */;
constructor(event: DataTransferEvent) {
this.event = event;
this.dataTransfer = getDataTransfer(event);
}
setData(
typeOrEntries: string | {[type: string]: unknown},
data?: unknown
): void {
if (typeof typeOrEntries === 'string') {
this.setData({[typeOrEntries]: data});
} else {
/** 处理多种类型的数据 */
for (const [type, data] of Object.entries(typeOrEntries)) {
const stringData = typeof data === 'object' ? JSON.stringify(data) : `${data}`;
this.dataTransfer.setData(normalizeType(type), stringData);
}
}
}
getData(type: string): string | undefined {
return this.hasType(type)
? this.dataTransfer.getData(normalizeType(type))
: undefined;
}
}
在 Transmat
构造函数内部还会通过 getDataTransfer
函数来获取 DataTransfer
对象并赋值给内部的 dataTransfer
属性。DataTransfer
对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。getDataTransfer
函数的具体实现如下:
/** src/data_transfer.ts */
export function getDataTransfer(event: DataTransferEvent): DataTransfer {
/** 先判断是否为剪贴板事件,如果是的话就会从 clipboardData 属性获取 DataTransfer 对象。否则,就会从 dataTransfer 属性获取 */
const dataTransfer =
(event as ClipboardEvent).clipboardData ??
(event as DragEvent).dataTransfer;
if (!dataTransfer) {
throw new Error('No DataTransfer available at this event.');
}
return dataTransfer;
}
export function normalizeType(input: string) {
const result = input.toLowerCase();
switch (result) {
case 'text':
return 'text/plain';
case 'url':
return 'text/uri-list';
default:
return result;
}
}
对于可拖拽源,在创建完 Transmat
对象之后,我们就可以调用该对象上的 setData
方法保存一项或多项数据。比如实战例子中设置了不同类型的多项数据。
在 setData
方法内部最终会调用 dataTransfer.setData
方法来保存数据。dataTransfer
对象的 setData
方法支持两个字符串类型的参数:format
和 data
。它们分别表示要保存的数据格式和实际的数据。如果给定数据格式不存在,则将对应的数据保存到末尾。如果给定数据格式已存在,则将使用新的数据替换旧的数据。dataTransfer.setData
方法的兼容性如下:
Transmat 类也有 getData
方法,用于获取已保存的数据。getData
方法支持一个字符串类型的参数 type
,用于表示数据的类型。在获取数据前,会调用 hasType
方法判断是否含有该类型的数据。如果有包含的话,就会通过 dataTransfer
对象的 getData
方法来获取该类型对应的数据。
此外,在调用 getData
方法前,还会调用 normalizeType
函数,对传入的 type
类型参数进行标准化操作。
TransmatObserver 类
该类的作用是可以帮助我们响应用户的拖拽行为,可用于在拖拽过程中高亮放置区域。其构造函数支持一个类型为 TransmatObserverCallback
的参数 callback
:
/** src/transmat_observer.ts */
export interface TransmatObserverEntry {
target: Element;
/** type DataTransferEvent = DragEvent | ClipboardEvent */
event: DataTransferEvent;
/** 在 window 中是否激活了transfer操作. */
isActive: boolean;
/** 元素是否是激活目标(dragover) */
isTarget: boolean;
}
export type TransmatObserverCallback = (
entries: ReadonlyArray<TransmatObserverEntry>,
observer: TransmatObserver
) => void;
export class TransmatObserver {
/** 观察的目标集合 */
private readonly targets = new Set<Element>();
/** 保存前一次的记录 */
private prevRecords: ReadonlyArray<TransmatObserverEntry> = [];
private removeEventListeners = () => {};
constructor(private readonly callback: TransmatObserverCallback) {}
private addEventListeners() {
const listener = this.onTransferEvent as EventListener;
this.removeEventListeners = addEventListeners(
document,
['dragover', 'dragend', 'dragleave', 'drop'],
listener,
true
);
}
private onTransferEvent = (event: DataTransferEvent) => {
const records: TransmatObserverEntry[] = [];
for (const target of this.targets) {
/** 当光标离开浏览器时,对应的事件将会被派发到 body 或 html 节点 */
const isLeavingDrag =
event.type === 'dragleave' &&
(event.target === document.body ||
event.target === document.body.parentElement);
/** 判断页面上是否有拖拽行为发生, 即除了当拖拽操作结束时触发 dragend 事件 和 当元素或选中的文本在可释放目标上被释放时触发 drop 事件,以及没有离开拖拽即isLeavingDrag === true*/
const isActive = event.type !== 'drop'
&& event.type !== 'dragend' && !isLeavingDrag;
/** 判断可拖拽的元素是否被拖到 target 元素上 */
const isTargetNode = target.contains(event.target as Node);
const isTarget = isActive && isTargetNode
&& event.type === 'dragover';
records.push({
target,
event,
isActive,
isTarget,
});
}
/** 仅当记录发生变化的时候,才会调用回调函数 */
if (!entryStatesEqual(records, this.prevRecords)) {
this.prevRecords = records as ReadonlyArray<TransmatObserverEntry>;
this.callback(records, this);
}
}
observe(target: Element) {
/** private readonly targets = new Set<Element>(); */
this.targets.add(target);
if (this.targets.size === 1) {
this.addEventListeners();
}
}
/** 返回最近已触发记录 */
takeRecords() {
return this.prevRecords;
}
/** 移除所有目标及事件监听器 */
disconnect() {
this.targets.clear();
this.removeEventListeners();
}
}
function entryStatesEqual(
a: ReadonlyArray<TransmatObserverEntry>,
b: ReadonlyArray<TransmatObserverEntry>
): boolean {
if (a.length !== b.length) {
return false;
}
/** 如果有一项不匹配,则立即返回false。*/
return a.every((av, index) => {
const bv = b[index];
return av.isActive === bv.isActive && av.isTarget === bv.isTarget;
});
}
TransmatObserverCallback
函数类型接收两个参数:entries
和 observer
。其中 entries
参数的类型是一个只读数组(ReadonlyArray),数组中每一项的类型是 TransmatObserverEntry。
在 observe
方法内部,会把需观察的元素保存到 Set 集合 targets
中。当 targets
集合的大小等于 1 时,就会调用当前实例的 addEventListeners
方法来添加事件监听。
在 addEventListeners
方法内部,会利用 utils.ts 中的 addEventListeners 函数来为 document
元素批量添加以下与拖拽相关的事件监听。
dragover
:当元素或选中的文本被拖到一个可释放目标上时触发;dragend
:当拖拽操作结束时触发(比如松开鼠标按键);dragleave
:当拖拽元素或选中的文本离开一个可释放目标时触发;drop
:当元素或选中的文本在可释放目标上被释放时触发。
onTransferEvent 方法中使用了 node.contains(otherNode)
方法来判断可拖拽的元素是否被拖到 target
元素上。当 otherNode
是 node
的后代节点或者 node
节点本身时,返回 true
,否则返回 false
。 此外,为了避免频繁地触发回调函数,在调用回调函数前会先调用 entryStatesEqual
函数来检测记录是否发生变化。
与 MutationObserver 一样,TransmatObserver 也提供了用于获取最近已触发记录的 takeRecords
方法和用于 “断开” 连接的 disconnect
方法。
总结
transmat 是谷歌的开源项目,本文介绍了其应用场景、使用方式及相关源码。通过对源码分析,能对 MutationObserver API 能有更深刻的理解。同时,今后若遇到类似的场景可以参考 TransmatObserver
类来实现自己的 Observer
类。
虽然自定义负载(自定义 JSON 数据)对于你控制的应用程序之间的通信很有用,但它也限制了将数据传输到外部应用程序的能力。要解决这个问题,你可以考虑使用轻量的 JSON-LD(Linked Data)数据格式,它对应的 MIME 类型是 'application/ld+json'
。利用该数据格式,可以更好地组织和链接数据,从而创建更好的 Web 应用。