大家好,我是 francecil。今天要和大家分享的主题是前端上传交互优化,以及如何利用剪切板粘贴功能实现文件上传。
背景:从上传表单图片说起
你是否遇到这样的情况:在填写表单时,需要上传图片,却只看到一个文件选择框,需要在无尽的文件列表中找到所需图片。
而如果你需要上传的图片是来自于网络、截图或聊天软件中的图片,还必须先将其保存到本地才能选择。
那么,有没有一种方法能够直接将目标图片复制并粘贴到表单中呢?
本文将介绍一种「剪贴板粘贴上传」的解决方案,支持图片等各种文件上传,兼容任何框架和组件。
- 如果你是一名网站开发者,并希望提高表单文件上传的用户体验,那么可以使用本方案提供的工具函数。
- 对于一般用户来说,如果想要提高文件上传的效率,可以尝试使用本文提供的油猴脚本。
具体详见 「如何使用」这一章节
DEMO 在线体验
- codesanbox demo
- 码上掘金 DEMO ↓ (加载较慢,请等待几秒)
名词解释
为了行文方便,我们对以下名词进行定义:
- 上传控件:指
input[type=file]元素 - 上传容器:指含有上传控件的 DOM 元素
技术实现
为了实现粘贴上传,用户需要执行以下三个交互步骤:
- 将文件保存到剪贴板中:可以手动复制,也可以通过软件截图来自动保存到剪贴板中。
- 将鼠标移至上传容器上:这可以告诉脚本代码当前选择的上传控件,对于存在多个上传控件时非常有用。
- 执行粘贴操作:执行键盘粘贴快捷键(Ctrl+V/Cmd+V),以便脚本代码可以获取粘贴事件中的文件内容并将其设置为上传控件的内容。
在实现这个过程之前,需要先考虑以下几个问题:
- 当表单中存在多个上传控件时,如何选择目标控件?
- 在执行粘贴操作时,如何获取剪贴板中的文件内容?能否确保浏览器正确读取剪贴板中的文件内容?
- 在读取到文件内容后,如何修改上传控件的值?
接下来,我们将逐一解决这些问题。
1. 如何选择目标控件
当页面有多个上传控件时,应该在执行粘贴操作时操作哪个控件呢?
最常见的方法是给每个上传控件绑定 paste 事件。这样,当用户在对应的上传控件上执行 paste 操作时,会触发相应的 paste 事件。但是,这种方法存在两个问题:
- 在触发 paste 事件之前,必须先将焦点设置在上传控件的元素上,通常需要用户手动点击上传控件。
- 对于供一般用户使用的通用脚本,我们很难为每个上传控件都绑定 paste 事件。
为了解决这个问题,我们可以使用鼠标移动事件。当鼠标移动到目标上传容器上时,记录下当前的上传控件。同时,全局监听 paste 事件,当用户执行粘贴操作时,使用之前记录的上传控件进行操作。
代码如下:
// 记录当前的 input[type=file] 元素
let currentInputFileDOM = null;
/**
* 处理鼠标移入事件,记录 input[type=file] 元素并为容器增加高亮样式
* @param e
*/
const handleMouseEnter = (el) => {
// 当前元素为 input[type=file] 时处理
if (el.nodeName === "INPUT" && el.type === "file") {
currentInputFileDOM = el;
} else {
const inputFiles = el.querySelectorAll("input[type=file]");
// 当前 DOM 节点有且仅有一个 input[type=file] 子元素时处理
if (inputFiles.length === 1) {
currentInputFileDOM = inputFiles[0];
}
}
};
/**
* 处理鼠标移出事件,移出高亮样式
* @param e
*/
const handleMouseLeave = (el) => {
currentInputFileDOM = null;
};
/**
* 处理粘贴事件
* @param e
*/
const handlePaste = (e, warn = console.log) => {
// ...TODO
};
const _handleMouseEnter = (e) => {
return handleMouseEnter(e.target);
};
const _handleMouseLeave = (e) => {
return handleMouseLeave(e.target);
};
/**
* 监听事件
* 由于绑定的是全局,这里使用 mouseover / mouseout 事件,子元素间移动才会触发事件
*/
document.addEventListener("mouseover", _handleMouseEnter);
document.addEventListener("mouseout", _handleMouseLeave);
document.addEventListener("paste", handlePaste);
需要注意的是,在寻找 currentInputFileDOM 时需要考虑多个上传控件存在于同一容器元素的情况,这种情况则视为寻找失败。
2. 如何获取剪切板中的文件内容
执行粘贴动作将得到一个 ClipboardEvent
document.addEventListener("paste", (e: ClipboardEvent) => {
console.log(e.clipboardData)
});
/*
dropEffect: "none"
effectAllowed: "uninitialized"
files: FileList {length: 0}
items: DataTransferItemList {length: 0}
types: []
*/
ClipboardEvent 对象中有一个只读的 clipboardData 属性,其为 DataTransfer 对象,包含用户剪切、复制或粘贴操作的数据及其 MIME 类型
故我们可以使用如下代码获取剪切板中的文件和图片
// 剪切板文件
const clipboardFile = e.clipboardData.files[0];
// 剪切板图片
const clipboardImage = Array.from(e.clipboardData.files).find((item) =>
item.type.includes("image")
);
console.log(clipboardImage)
那么我们是否一定能够读取到剪切板中的文件呢?如果我们在Google上搜索「文件粘贴上传」,会发现大多数文章都会告诉我们一个结论:Windows Chrome 无法从文件系统中复制文件数据。这个限制导致目前主流的上传组件没有内置粘贴功能。
不过,这个结论只适用于 Windows Chrome 92 版本之前的情况。从 2021 年 7 月的 Chrome 92 版本开始,该问题已经得到了修复,详见:
因此,现在我们可以放心地使用粘贴上传功能,未来各种组件库的上传组件也应该适配粘贴上传功能。
3. 如何修改上传控件取值
过去,由于一些已知的安全漏洞,开发者无法使用编程方式更改上传控件 input[type=file] 元素的 files 属性。但是,在现代浏览器(例如 Firefox 57+、Chrome 60+ 等 201707 之后发布的版本)中,这些漏洞已得到修复。
因此,现在我们可以手动修改 input[type=file] 元素的 files 属性。
// 创建一个 DataTransfer 对象
let newFilelist = new DataTransfer();
// 如果 input 元素多选,需要将旧值先进行复制
if (currentInputFileDOM.multiple) {
[...currentInputFileDOM.files].forEach((item) =>
newFilelist.items.add(item)
);
}
// 添加剪切板文件
newFilelist.items.add(clipboardFile);
// input 元素重新赋值
currentInputFileDOM.files = newFilelist.files;
console.log("修改 fileList 成功");
需要注意的是,编程方式修改 files 属性并不会触发 input 的 change 事件,因此我们还需要模拟触发 change 事件,以确保逻辑与手动选择文件上传的行为一致,方便后续「触发上传请求、文件二次校验」等操作。
// 手动触发 change 事件
currentInputFileDOM.dispatchEvent(
new Event("change", {
bubbles: true,
})
);
小结
- 当表单中存在多个上传控件时,如何选择目标控件?
记录鼠标移动事件,判定并记录容器是否含有唯一的上传控件,在执行粘贴操作时使用之前记录的上传控件
- 在执行粘贴操作时,如何获取剪贴板中的文件内容?是否能够确保浏览器能够正确读取剪贴板中的文件内容?
通过剪切板事件的
clipboardData.files属性即可获取文件内容;需要注意的是, Window Chrome 需要 92 版本以上才能读取文件系统复制的内容 - 在读取到文件内容后,如何修改上传控件的值?
通过另外创建一个带文件的
DataTransfer对象,再将DataTransfer的 files 属性赋值给上传控件
兼容性说明
剪切板粘贴上传功能的浏览器兼容性,主要考虑以下三个方面:
- 是否支持读取 clipboardData 的 files 数据:排除 Internet Explorer。
- 是否支持修改上传控件的 files 属性:排除较低版本的现代浏览器,包括 2017 年之前的浏览器版本。
- 是否支持读取从文件系统中复制的文件:排除 Windows Chrome 92 版本之前的浏览器版本。
整体来说,方案的兼容性较高,主流浏览器最新一年发布的浏览器版本均支持,生产环境中可以先进行版本判定确认是否支持「粘贴上传」功能
如何使用
下面将从网站开发者和普通用户两个角度出发,介绍如何应用「剪切板粘贴上传」功能。
对于网站开发者
我们提供了三个事件处理函数,分别处理容器的鼠标移入、移出和粘贴操作;以及一段高亮样式,方便观察当前选中的上传容器。
// ./paste.ts
/**
* 添加全局样式,鼠标 hover 高亮 input 容器
*/
const highlightCls = "__input--highlight";
export const addHighlightStyle = () => {
const styleDom = document.createElement("style");
styleDom.innerHTML = `.${highlightCls} { outline: 1.5px dashed rgba(0, 0, 0, 0.8) !important; \n background: rgb(154, 185, 227) !important; }`;
document.head.appendChild(styleDom);
};
addHighlightStyle()
// 记录当前的 input[type=file] 元素
let currentInputFileDOM = null;
/**
* 处理鼠标移入事件,记录 input[type=file] 元素并为容器增加高亮样式
* @param e
*/
export const handleMouseEnter = (el) => {
if (!el.classList) {
return;
}
// 当前元素为 input[type=file] 时处理
if (el.nodeName === "INPUT" && el.type === "file") {
currentInputFileDOM = el;
el.classList.add(highlightCls);
} else {
const inputFiles = el.querySelectorAll("input[type=file]");
// 当前 DOM 节点有且仅有一个 input[type=file] 子元素时处理
if (inputFiles.length === 1) {
currentInputFileDOM = inputFiles[0];
el.classList.add(highlightCls);
}
}
};
/**
* 处理鼠标移出事件,移出高亮样式
* @param e
*/
export const handleMouseLeave = (el) => {
if (!el.classList) {
return;
}
if (el.classList.contains(highlightCls)) {
currentInputFileDOM = null;
el.classList.remove(highlightCls);
}
};
/**
* 处理粘贴事件
* @param e
*/
export const handlePaste = (e, warn = console.log) => {
const clipboardFiles = e.clipboardData.files;
if (!clipboardFiles) {
warn("当前浏览器不支持 clipboardData");
return;
}
const clipboardFile = Array.from(clipboardFiles).find((item: any) =>
item.type.includes("image")
);
if (!clipboardFile) {
warn("当前剪切板内容非图片文件");
return;
}
if (!currentInputFileDOM) {
warn("未找到 input[type=file] 元素");
return;
}
// 修改文件输入框取值
let newFilelist = new DataTransfer();
// 如果 input 多选,需要将旧值先进行复制
if (currentInputFileDOM.multiple) {
[...currentInputFileDOM.files].forEach((item) =>
newFilelist.items.add(item)
);
}
newFilelist.items.add(clipboardFile as any);
currentInputFileDOM.files = newFilelist.files;
console.log("修改 fileList 成功");
// 手动触发 change 事件
currentInputFileDOM.dispatchEvent(
new Event("change", {
bubbles: true
})
);
};
在应用代码中,我们需要给上传容器绑定 mouseEnter 和 mouseLeave 事件,并监听粘贴事件。以下是一个 React 代码的例子:
import React, { useCallback, useEffect, useState } from "react";
import { Upload } from "antd";
import {
handleMouseLeave,
handleMouseEnter,
handlePaste
} from "./paste";
const App: React.FC = () => {
// 是否支持图片粘贴上传
const [allowPaste, setAllowPaste] = useState(false);
const _onPaste = useCallback((e) => {
return handlePaste(e, message.warning);
}, []);
// 监听粘贴事件
useEffect(() => {
if (allowPaste) {
// paste 绑定下全局,若绑定在元素上,需要元素有获得焦点
document.addEventListener("paste", _onPaste);
} else {
document.removeEventListener("paste", _onPaste);
}
}, [allowPaste, _onPaste]);
return (
<div>
<div
onMouseEnter={(e) => {
setAllowPaste(true);
handleMouseEnter(e.currentTarget);
}}
onMouseLeave={(e) => {
setAllowPaste(false);
handleMouseLeave(e.currentTarget);
}}
>
<Upload
{...{
name: "file",
action: "https://www.mocky.io/v2/5cc8019d300000980a055e76",
listType: "picture"
}}
>
</Upload>
</div>
</div>
);
};
export default App;
通过以上操作,我们就可以为 Ant Design 的 Upload 控件增加粘贴上传功能了。
完整代码可以参考:codesandbox.io/s/gao-ji-sh…
对于一般用户
如果你安装了油猴脚本插件,可以安装名为「为上传控件增加粘贴上传功能 」的脚本。
安装此脚本后,请刷新表单页面,然后可以在页面右下角找到一个按钮 - 开启粘贴上传。单击该按钮即可启用粘贴上传功能。当鼠标悬停在上传控件上时,你可以使用粘贴快捷键触发文件上传。
如果没有安装油猴插件,但想快速体验此功能,你也可以在控制台中注入以下代码。这将产生与油猴脚本相同的效果。
// ==UserScript==
// @name 为上传控件增加粘贴上传功能
// @namespace https://gahing.top/
// @version 0.1
// @description 为上传控件增加粘贴上传功能 / add paste upload function to input
// @author gahing
// @license MIT
// @match https://*/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// ==/UserScript==
(function () {
'use strict';
/**
* 添加全局样式,鼠标 hover 高亮 input 容器
*/
const highlightCls = "__input--highlight";
const addGlobalStyle = () => {
const styleDom = document.createElement("style");
styleDom.innerHTML = `.${highlightCls} { outline: 1.5px dashed rgba(0, 0, 0, 0.8) !important; \n background: rgb(154, 185, 227) !important; }`;
document.head.appendChild(styleDom);
};
// 记录当前的 input[type=file] 元素
let currentInputFileDOM = null;
/**
* 处理鼠标移入事件,记录 input[type=file] 元素并为容器增加高亮样式
* @param e
*/
const handleMouseEnter = (el) => {
if (!el.classList) {
return;
}
// 当前元素为 input[type=file] 时处理
if (el.nodeName === "INPUT" && el.type === "file") {
currentInputFileDOM = el;
el.classList.add(highlightCls);
} else {
const inputFiles = el.querySelectorAll("input[type=file]");
// 当前 DOM 节点有且仅有一个 input[type=file] 子元素时处理
if (inputFiles.length === 1) {
currentInputFileDOM = inputFiles[0];
el.classList.add(highlightCls);
}
}
};
/**
* 处理鼠标移出事件,移出高亮样式
* @param e
*/
const handleMouseLeave = (el) => {
if (!el.classList) {
return;
}
if (el.classList.contains(highlightCls)) {
currentInputFileDOM = null;
el.classList.remove(highlightCls);
}
};
/**
* 处理粘贴事件
* @param e
*/
const handlePaste = (e, warn = console.log) => {
const clipboardFiles = e.clipboardData.files;
if (!clipboardFiles) {
warn("当前浏览器不支持 clipboardData");
return;
}
const clipboardFile = Array.from(clipboardFiles).find((item) =>
item.type.includes("image")
);
if (!clipboardFile) {
warn("当前剪切板内容非图片文件");
return;
}
if (!currentInputFileDOM) {
warn("未找到 input[type=file] 元素");
return;
}
// 修改文件输入框取值
let newFilelist = new DataTransfer();
// 如果 input 多选,需要将旧值先进行复制
if (currentInputFileDOM.multiple) {
[...currentInputFileDOM.files].forEach((item) =>
newFilelist.items.add(item)
);
}
newFilelist.items.add(clipboardFile);
currentInputFileDOM.files = newFilelist.files;
console.log("修改 fileList 成功");
// 手动触发 change 事件
currentInputFileDOM.dispatchEvent(
new Event("change", {
bubbles: true
})
);
};
// 下面代码用于给外部脚本使用
const _handleMouseEnter = (e) => {
return handleMouseEnter(e.target);
};
const _handleMouseLeave = (e) => {
return handleMouseLeave(e.target);
};
/**
* 监听事件
*/
const listenEvent = () => {
// 由于绑定的是全局,这里使用 mouseover / mouseout 事件,子元素间移动才会触发事件
document.addEventListener("mouseover", _handleMouseEnter);
document.addEventListener("mouseout", _handleMouseLeave);
document.addEventListener("paste", handlePaste);
};
/**
* 重置状态
*/
const resetEvent = () => {
document.removeEventListener("mouseover", _handleMouseEnter);
document.removeEventListener("mouseout", _handleMouseLeave);
document.removeEventListener("paste", handlePaste);
};
/**
* 创建开关按钮
* @returns
*/
const createSwitchButton = () => {
const button = document.createElement('button')
button.innerText = '开启粘贴上传'
button.style = 'position: fixed; bottom: 20px; right: 20px;'
document.body.appendChild(button)
let enablePaste = false
button.addEventListener('click', () => {
enablePaste = !enablePaste
if (enablePaste) {
listenEvent()
button.innerText = '关闭粘贴上传'
} else {
resetEvent()
button.innerText = '开启粘贴上传'
}
})
}
addGlobalStyle();
createSwitchButton()
})();
总结与规划
目前大多数网站的上传控件不支持粘贴上传,这对用户效率造成了不小的影响。本文介绍了「粘贴上传」的技术方案,并从网站开发者和普通用户两个角度讲述了解决方案。
该方案的浏览器兼容性较高,支持大多数主流浏览器。希望本文能够启发众多开发者,优化开源生态上传组件,提升整体的上传交互体验。
不过,目前该方案仍存在一些不足之处。比如缺乏功能支持检测,上传控件判定不够高效等等。最后,为了更方便地使用这些工具函数,将来可能会将其封装成 npm 包。
我们将在未来的版本中对这些问题进行优化,敬请期待。