在 7年技术写作,分享6点心得体会 这篇文章中,阿宝哥介绍了自己经常使用的一款不错的在线绘图工具 — Excalidraw。使用它你可以轻松地绘制各种漂亮的手绘示意图,目前在 Github 上 Excalidraw 的 Star 数已达 23.9 K,因此它也是一个很不错的开源项目。
在平时使用 Excalidraw 的时候,阿宝哥发现了该在线工具提供了一些不错的功能。比如保存 *.excalidraw 文件到指定目录、拖拽打开 *.excalidraw 文件并保存至当前文件、复制图片到剪贴板、分享只读链接和实时协作等功能。
提示:上图演示了拖拽打开 *.excalidraw 文件并保存至当前文件的功能
上述的这些功能,很多都是跟文件操作相关。关于文件处理,阿宝哥之前写了 文件上传,搞懂这8种场景就够了 和 文件下载,搞懂这9种场景就够了 这两篇文章。而第三篇文章,阿宝哥就带大家来分析一下 Excalidraw 背后与文件操作相关的技术。
了解并掌握了这些相关技术之后,在今后的工作中也许就会有用武之地,特别是对于一些在线 Web 编辑器的场景,利用这些技术将会大大提高产品的用户体验。比如在支持相关 Web 技术的平台上,你们开发的在线编辑器就能完美支持 打开->编辑->保存 这个常见的文件处理流程。
话不多说,我们马上步入正题,这里我们先来分析 保存 .excalidraw 文件到指定目录 的功能。
一、保存文件到指定目录
提示:本文所有演示示例使用的 Chrome 版本为:版本 92.0.4515.159(正式版本) (x86_64)
以上 Gif 动图演示了保存文件到指定目录的过程,因为 Excalidraw 这个在线工具是开源的,所以通过分析它的源码,我们找到了实现 保存文件到指定目录 功能的实现函数:
- // https://github.com/excalidraw/excalidraw/blob/master/src/data/json.ts#L31
- import { fileOpen, fileSave } from "browser-fs-access";
- export const saveAsJSON = async (
- elements: readonly ExcalidrawElement[],
- appState: AppState,
- ) => {
- const serialized = serializeAsJSON(elements, appState);
- const blob = new Blob([serialized], {
- type: MIME_TYPES.excalidraw,
- });
- const fileHandle = await fileSave(
- blob,
- {
- fileName: `${appState.name}.excalidraw`,
- description: "Excalidraw file",
- extensions: [".excalidraw"],
- },
- isImageFileHandle(appState.fileHandle) ? null : appState.fileHandle,
- );
- return { fileHandle };
- };
由以上代码可知,在 saveAsJSON 函数内部是通过调用 fileSave 函数来保存文件。fileSave 函数是从 browser-fs-access 这个第三库导入的。该库封装了 File_System_Access_API,该 API 为开发者提供了 读、写文件和文件管理 的能力。而 保存文件到指定目录 的功能,就是通过 showSaveFilePicker 方法来实现的。在 showSaveFilePicker 方法出现之前,在客户端实现保存文件的功能,比较常见的方案是使用 a 标签 或 FileSaver.js 这个库。
- const saveFile = async (blob, filename) => {
- const a = document.createElement('a');
- a.download = filename;
- a.href = URL.createObjectURL(blob);
- a.addEventListener('click', (e) => {
- setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
- });
- a.click();
- };
提示:如果你想了解其他的文件下载方式,可以阅读 文件下载,搞懂这9种场景就够了 这篇文章。
对于前面介绍的客户端文件保存的方案来说,它最大的问题就是没有办法实现 打开->编辑->保存 这种常见的文件操作流程。因为我们没有办法覆盖原始的文件,只能创建一个新的文件。而使用新的 File_System_Access_API 就可以解决上述的问题,比如我们可以使用 window.showOpenFilePicker 方法来打开文件,在文件编辑完成之后,再使用 window.showSaveFilePicker 来保存文件。
(文本编辑器地址:https://googlechromelabs.github.io/text-editor/)
下面我们来介绍一下 showSaveFilePicker API,它是 Window 接口中定义的方法,调用该方法后会显示允许用户选择保存路径的文件选择器。该方法的签名如下所示:
- let FileSystemFileHandle = Window.showSaveFilePicker(options);
showSaveFilePicker 方法支持一个对象类型的可选参数,可包含以下属性:
excludeAcceptAllOption:布尔类型,默认值为 false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(由下面的 types 选项启用)。将此选项设置为 true 意味着 types 选项不可用。
types:数组类型,表示允许保存的文件类型列表。数组中的每一项是包含以下属性的配置对象:
- description(可选):用于描述允许保存文件类型类别。
- accept:是一个对象,该对象的 key 是 MIME 类型,值是文件扩展名列表。
调用 showSaveFilePicker 方法之后,会返回一个 FileSystemFileHandle 对象。有了该对象,你就可以调用该对象上的方法来操作文件。比如调用该对象上的 createWritable 方法之后,就会返回 FileSystemWritableFileStream 对象,就可以把数据写入到文件中。具体的使用方式如下所示:
- async function saveFile(blob, filename) {
- try {
- const handle = await window.showSaveFilePicker({
- suggestedName: filename,
- types: [
- {
- description: "PNG file",
- accept: {
- "image/png": [".png"],
- },
- },
- ],
- });
- const writable = await handle.createWritable();
- await writable.write(blob);
- await writable.close();
- return handle;
- } catch (err) {
- console.error(err.name, err.message);
- }
- }
- saveFile(imgBlob, "face.png");
当你使用以上的 saveFile 函数,来保存图片时,就会显示以下保存文件选择器:
看到这里是不是觉得 showSaveFilePicker API 功能挺强大的,不过可惜的是该 API 目前的兼容性还不是很好,具体如下图所示:
(图片来源:https://caniuse.com/?search=showSaveFilePicker)
showSaveFilePicker 是 File System Access API 中定义的方法,除了 showSaveFilePicker 之外,还有 showOpenFilePicker 和 showDirectoryPicker 等方法。接下来,阿宝哥来简单介绍一下另外这两个比较有用的 API。
showOpenFilePicker API,它是 Window 接口中定义的方法,调用该方法后会显示一个允许用户选择一个或多个文件的文件选择器。该方法的签名如下所示:
- let FileSystemHandles = Window.showOpenFilePicker();
showOpenFilePicker 方法支持一个对象类型的可选参数,可包含以下属性:
multiple:布尔类型,默认值为 false。若设置为 true,则允许选择多个文件。
excludeAcceptAllOption:布尔类型,默认值为 false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(由下面的 types 选项启用)。将此选项设置为 true 意味着 types 选项不可用。
- types:数组类型,表示允许保存的文件类型列表。数组中的每一项是包含以下属性的配置对象:
- description(可选):用于描述允许保存文件类型类别。
accept:是一个对象,该对象的 key 是 MIME 类型,值是文件扩展名列表。
调用 showOpenFilePicker 方法之后,会返回 FileSystemHandles 即 FileSystemFileHandle 对象数组。有了 FileSystemFileHandle 对象,你就可以调用该对象上的方法来操作文件。下面我们来举一个简单的使用示例:
- <div>
- <textarea id="container" rows="5" cols="30"></textarea>
- </div>
- <button onclick="openFile()">打开文件</button>
- <script>
- const container = document.querySelector("#container");
- async function openFile() {
- let [fileHandle] = await window.showOpenFilePicker();
- const file = await fileHandle.getFile();
- const contents = await file.text();
- container.value = contents;
- }
- </script>
在以上示例中,当用户点击 打开文件 按钮时,就会显示一个文件选择器。在选择文本文件之后,就会把文件中的内容,显示在 textarea#container 文本框中。对于非文本文件,你可以通过调用 arrayBuffer 方法来读取文件中的二进制内容。
(图片来源:https://caniuse.com/?search=showOpenFilePicker)
由上图可知,目前 showOpenFilePicker API 的兼容性还比较差。但如果你想在支持 File System Access API 的平台中,优先使用这些 API 的话,可以考虑使用 GoogleChromeLabs 开源的 browser-fs-access 这个库,该库可以让你在支持 File System Access API 的平台上更方便地使用 File System Access API,而对于不支持的平台会自动降级使用 <input type="file"> 和 <a download> 的方式。
除了选择文件之外,我们也可以选择目录。针对这种场景,我们就可以使用 showDirectoryPicker API。它是 Window 接口中定义的方法,调用该方法后会显示一个允许用户选择目录的选择器。该方法的签名如下所示:
- var FileSystemDirectoryHandle = Window.showDirectoryPicker();
与前面介绍的 showOpenFilePicker 方法不同的是,调用 showDirectoryPicker 方法后是,返回的是 FileSystemDirectoryHandle 对象。利用该对象,我们就可以执行一些目录的相关操作操作。比如读取目录的信息、读取目录下的指定文件、删除目录下的指定文件或在目录下新建文件等。同样,我们也来举一些简单的示例。
读取目录的信息
- async function readDirectory() {
- const dirHandle = await window.showDirectoryPicker();
- for await (const entry of dirHandle.values()) {
- console.log(entry.kind, entry.name);
- }
- }
读取目录下的指定文件
- const container = document.querySelector("#container");
- async function readFile() {
- const dirHandle = await window.showDirectoryPicker();
- const fileHandle = await dirHandle.getFileHandle("hello.txt");
- const file = await fileHandle.getFile();
- const contents = await file.text();
- container.value = contents;
- }
删除目录下的指定文件
- async function removeFile() {
- const dirHandle = await window.showDirectoryPicker();
- const result = await dirHandle.removeEntry("hello.copy.txt");
- container.value = `删除hello.copy.txt文件${
- typeof result == "undefined" ? "成功" : "失败"
- }`;
- }
需要注意的是,removeEntry 方法除了支持删除指定文件之外,还可以支持删除指定目录。
创建指定文件
- async function createFile() {
- const dirHandle = await window.showDirectoryPicker();
- const fileHandle = await dirHandle.getFileHandle("hello.new.txt", {
- create: true,
- });
- container.value = "hello.new.txt文件创建成功!";
- const writable = await fileHandle.createWritable();
- await writable.write(new Blob(["大家好,我是阿宝哥!"]));
- await writable.close();
- }
在以上代码中,我们通过调用 getFileHandle 方法来获取指定文件,对应的 FileSystemFileHandle 对象。create: true 表示如果在当前目录下未找到指定文件,则创建新的文件。了解完以上的示例,是不是觉得浏览器的文件处理能力越来越强大了。同样,我们也来看一下 showDirectoryPicker API 的兼容性:
(图片来源:https://caniuse.com/?search=showDirectoryPicker)
二、拖拽打开 *.excalidraw 文件并保存至当前文件
以上 Gif 动图演示了拖拽打开 *.excalidraw 文件并保存至当前文件的过程,可以发现在编辑完文件之后,我们只需确认是否保存文件,而无需选择文件的保存路径,在大大提高了用户的使用体验。
- class App extends React.Component<AppProps, AppState> {
- // 省略大部分代码
- const file = event.dataTransfer?.files[0];
- if (
- file?.type === MIME_TYPES.excalidrawlib ||
- file?.name?.endsWith(".excalidrawlib")
- ) {
- // 处理导入的控件库的逻辑
- } else {
- this.setState({ isLoading: true });
- if (fsSupported) { // 判断是否支持File System Access API
- try {
- const item = event.dataTransfer.items[0];
- // 关键点:获取FileSystemHandle对象
- (file as any).handle = await (item as any).getAsFileSystemHandle();
- } catch (error) {
- console.warn(error.name, error.message);
- }
- }
- // 加载.excalidraw文件到Canvas
- await this.loadFileToCanvas(file);
- }
- };
- }
以上代码的关键点是,调用 DataTransferItem.getAsFileSystemHandle() 方法来获取 FileSystemFileHandle 对象。拥有该对象之后,我们就可以对文件进行读、写操作。具体的使用方式如下所示:
读文件示例
- async function getTheFile() {
- // open file picker
- [fileHandle] = await window.showOpenFilePicker(pickerOpts);
- // get file contents
- const fileData = await fileHandle.getFile();
- }
写文件示例
- async function writeFile(fileHandle, contents) {
- // Create a FileSystemWritableFileStream to write to.
- const writable = await fileHandle.createWritable();
- // Write the contents of the file to the stream.
- await writable.write(contents);
- // Close the file and write the contents to disk.
- await writable.close();
- }
三、复制图片到剪贴板
- // https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts
- export const copyBlobToClipboardAsPng = async (blob: Blob) => {
- await navigator.clipboard.write([
- new window.ClipboardItem({ "image/png": blob }),
- ]);
- };
在以上代码中,copyBlobToClipboardAsPng 函数支持一个 blob 参数,在该函数内部会调用 navigator.clipboard.write 方法,来实现把图片复制到剪贴板。而对于普通文本来说,你可以通过 navigator.clipboard.writeText 方法,把它们写入到系统的剪贴板。
其实 navigator.clipboard.write 和 navigator.clipboard.writeText 方法是 Clipboard 接口定义的方法,该接口实现了 Clipboard API,如果用户授予了相应的权限,就能提供系统剪贴板的读写访问。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。该 API 用于取代通过 document.execCommand API 来实现剪贴板的操作。
在实际工作中,我们不需要手动创建 Clipboard 对象,而是通过 navigator.clipboard 来获取 Clipboard 对象:
在获取 Clipboard 对象之后,我们就可以利用该对象提供的 API 来访问剪贴板。比如,通过 navigator.clipboard.readText 方法来读取剪贴板的内容:
- navigator.clipboard.readText().then(
- clipText => document.querySelector(".editor").innerText = clipText
- );
以上代码将 HTML 中含有 .editor 类的第一个元素的内容替换为剪贴板的内容。如果剪贴板为空,或者不包含任何文本,则元素的内容将被清空。这是因为在剪贴板为空或者不包含文本时,readText 方法会返回一个空字符串。
异步剪贴板 API 是一个相对较新的 API,浏览器仍在逐渐实现它。由于潜在的安全问题和技术复杂性,大多数浏览器正在逐步集成这个 API。目前 Navigator API: clipboard 的兼容性如下图所示:
(图片来源:https://caniuse.com/mdn-api_navigator_clipboard)
对于浏览器扩展来说,你可以请求 clipboardRead 和 clipboardWrite 权限以使用 clipboard.readText() 和 clipboard.writeText()。如果你对 Clipboard 其他 API 感兴趣的话,可以阅读 想要复制图像?Clipboard API 了解一下 这篇文章。
其实除了上面介绍的技术, Excalidraw 还使用了其他 Web API 来实现特定的功能。比如利用 window.crypto API 来实现导出只读链接时,对画布数据进行加密保护。利用 WebSocket API 来实现协同编辑和利用 Share API 实现文件共享的功能,感兴趣的小伙伴可以阅读一下 Excalidraw 的相关源码。
四、总结
本文阿宝哥分析了 Excalidraw 这款在线绘图工具,所提供的一些不错功能背后使用的技术。希望阅读完本文后,你对 File_System_Access_API 中定义的 window.showOpenFilePicker、window.showSaveFilePicker、window.showDirectoryPicker 和 DataTransferItem.getAsFileSystemHandle 这些方法都有一定的了解。
由于目前 File_System_Access_API 的兼容性还不是很好,如果你想在项目中使用它的话。建议你使用 GoogleChromeLabs 开源的 browser-fs-access 这个库,该库不仅为我们提供了更简洁的 API,而且还提供了自动降级的方案。在今后的项目中,有机会的话,小伙伴们可以尝试一下。
五、参考资源
- web.dev — excalidraw-and-fugu
- web.dev — browser-fs-access
- web.dev — file-system-access
- MDN — File_System_Access_API
- MDN — FileSystemFileHandle