今天,我在昨天的数据持久化基础上,做了多格式导出的实现,包含了逻辑重构及细节优化,结合blocknote 原生API 与自定义工具函数完成开发,以下是开发过程的复盘与反思,菜鸟一枚,路过的大佬见谅…
一、开发前期准备与核心目标
今日的核心工作是顺着已完成的indexedDB 数据持久化,实现文件导出到本地。
导出功能涉及三个核心环节:
- 数据拉取 (Fetch):从 IndexedDB 异步读取完整的 JSON 树。
- 格式转换 (Transform):将内存中的对象转换为目标格式(如 .json, .md 或 .txt)。
- 触发下载 (Trigger):利用浏览器 API 模拟文件保存动作。
我看了blocknote的docs后发现,blocknote 原生 API 已经支持多种格式的导出,只是相关 API 比较分散。
接下来我要做的,就是把 blocknote 自带的各类导出 API,通过 hook、utils 工具函数与 constant 建表维护,把这些范式化的重复代码统一封装,根据 key 做统一识别与调用。
在这之前,我先采用antd组件库做了menu的UI的搭建。
二、导出功能核心逻辑设计
回到逻辑层面,File 模块需要进行 UI 渲染。
为了保证组件职责单一、便于问题追溯,我在 Constant 中统一维护了所有支持导出的原生 API。常量定义方面,使用联合类型可以让类型定义更严谨,key 采用 typeof 约束也会更规范。我在常量中把 mime 类型、扩展名 ext、展示标签 label 统一配置成表,以 key 作为唯一标识。
export interface ExportFormat {
label: string;
ext: string;
mime: string;
}
export const EXPORT_CONFIG: Record<string, ExportFormat> = {
"7-1": {
label: "DOCX",
ext: ".docx",
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
"7-2": {
label: "PDF",
ext: ".pdf",
mime: "application/pdf",
},
"7-3": {
label: "Markdown",
ext: ".md",
mime: "text/markdown",
},
};
Hook 负责封装导出的核心逻辑,自定义 utils 作为纯函数,专门处理 blob 下载与文件名生成。
import { EXPORT_PROCESSORS } from "../exporters";
import { EXPORT_CONFIG } from "../constants/exportConfig";
import { fileExport } from "../utils/fileExport";
interface BlockContent {
text?: string;
}
interface Block {
content?: BlockContent[];
}
const formatFileName = (document: Block[]): string => {
if (!document || document.length === 0) {
return "无标题";
}
const firstBlock = document[0];
const rawText = firstBlock?.content?.[0]?.text || "无标题";
const sanitized = rawText.replace(/[\\/:*?"<>|]/g, "_");
return sanitized;
};
const useFileExport = (editor: any) => {
const exportFile = async (key: string) => {
const processor = EXPORT_PROCESSORS[key as keyof typeof EXPORT_PROCESSORS];
const config = EXPORT_CONFIG[key as keyof typeof EXPORT_CONFIG];
if (!processor || !config) {
console.error(`未找到对应的导出处理器,Key: ${key}`);
return;
}
try {
const blob = await processor(editor);
const ext = config.ext;
const fileName = `${formatFileName(editor.document)}${ext}`;
fileExport(blob, fileName);
} catch (error) {
console.error("文件导出失败:", error);
}
};
return { exportFile };
};
export default useFileExport;
导出工具
export const fileExport = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
};
整个逻辑流转的核心是全局唯一的 editor 实例,它在根组件 APP 中创建并渲染,所有数据操作都基于该实例进行,其他组件需要使用时则通过实例调用。
菜单组件如何获取 editor 实例?
我采用了 props 注入的方式。由于当前逻辑嵌套不深,只需传递两层:先传到 toolbar 组件,再传递到对应的菜单组件即可。
三、技术细节、重构与特殊场景兼容
在文本解析方面用到了 blob(浏览器二进制文件对象)。
在浏览器中,文件通常有三种表现形式:
类型
说明
常见场景
File
用户选择的文件对象
input 上传
Blob
浏览器中的二进制文件对象
下载 / 预览
Base64
字符串形式的文件数据
图片上传
这里文件的导出,采用先转为 blob 再触发下载。
还有一个细节:MIME类型需要正确对应,我已经在constant中统一配置完成。
- 下载逻辑与数据链路
浏览器触发下载的本质是原生 blob 下载行为:创建 a 标签并生成下载链接,这部分逻辑主要参考了通用实现。
整体核心数据链路为:
获取数据 -> 序列化 -> 格式化文件名 -> 生成下载任务
(Blob 创建、URL 生成、A 标签模拟、点击、Revoke)
- UX 细节处理
还有一些 UX 细节处理:如果文件未命名,或第一行是图片等特殊内容,做了对应兼容;图片上传按官方文档做了模拟后端处理,先上传到临时地址,后续再接入自研后端 API;针对无标题或非法文件名的情况,使用正则表达式对标题做非法字符过滤。
四、开发适配与过程难点
整体思路并不复杂,难点在于实现第一个可用版本。在实现第一个以后,我再做了代码重构,整体逻辑可复用,保持各个代码文件各司其职。
跑通第一个之后,再去梳理官方 API 就顺畅很多。官方提供的示例有些是基于 Node.js 后端的,并不完全适用于纯前端场景。
纯前端的分工如下:
- Hook 负责业务链路。
- Utils 负责具体的 API 操作。
- Menu 负责触发。
菜单的逻辑分发我没有使用 switch case,而是根据 menu 的 key 前缀做匹配,直接调用对应 hook 实现分发。
const { exportFile } = useFileExport(editor);
const onMenuClick: MenuProps["onClick"] = ({ key }) => {
if (key.startsWith("7-")) {
exportFile(key);
}
};
其中 PDF 数据提取与前端导出使用了 react-pdf,这部分我暂时还没深入细看…
五、小结
今天我觉得自己有进步的地方:
- 我对 antd 的图标和下拉菜单组件做了组合封装,一个很实用的方法就是把沙盒里的源码拉下来,直接参考 demo 的写法再做微调。另外还做了一个小优化:接入了 GitHub 跳转链接。我想起刚开始写静态网页时,对
target="_blank"新开标签页、URL 指向这些基础知识点还觉得很难,现在回头看已经有了成长,继续坚持。 - 采用blocknote支持的api生态进行开发,学着理解开源组件的代码demo。
整体来看,本次开发核心完成了多格式导出功能的实现与逻辑重构,代码部分有一部分是参考与 AI 生成,但使用时自己也在做判断,ai出现幻觉的时候一通乱说,不能被带跑,避免直接套用导致项目结构混乱。
在实现第一个 markdown 导出功能时,完整跑通了 md 导出流程,并做到无副作用的数据持久化,后续通过梳理相似逻辑完成统一重构与配置抽离,完成了这个开发导出文件的小功能!