一、今日开发回顾
今天,我实现了文档的导入功能。
blocknote生态已有API支持markdown的导入(由于blocknote是基于块的编辑器(源码是JSON),markdown格式传输有损)。
我仿照昨天做的导出逻辑重新设置了一个hook实现,因不考虑复用导入别的格式,直接hardcode为仅支持md格式的导入。
初期实现单文件导入,后来又引入了文件夹的导入。在做此前补充了文档清空功能和新建窗口功能,相关逻辑就方便复用了!
以下是开发复盘,菜鸟一枚,路过的大佬们见谅...
二、导入流程与内容处理
我在一开始有一个疑问,怎样让hook拿到editor实例的存在?
因为editor实例是在全局的根组件里面创建的。首先要确认一个数据流,理解这个文件导入的整个过程,其实就是分为两步:
- 解析 (Parse):把 String (Markdown) 变成 Blocks (Array)。
- 写入 (Update):把 Blocks 塞进 editor 实例。
解析使用官方格式转换API,写入通过button在UI层绑定事件,通过props传递editor实例。
当前编辑区已有内容时,导入有两种处理方式:
- 追加内容:将新内容拼接在现有内容之后。
- 替换内容:清空现有内容后再导入新内容。
我选择2,因为blocknote的md导入有损,其实格式是不完美衔接的。
BlockNote:有损转换
- "The functions to import / export to and from HTML / Markdown are considered "lossy"; some information might be dropped when you export Blocks to those formats."
- "To serialize Blocks to a non-lossy format (for example, to store the contents of the editor in your backend), simply export the built-in Block format using JSON.stringify(editor.topLevelBlocks)."
所以我又补充了文档清空功能,同时实现新建窗口功能------在UI层绑定事件跳转新窗口,新建逻辑为清空当前文本,使用blocknote原生API replace替换所有代码块内容。 并且注意交互:导入新建(清空)操作有ant design的modal警示弹窗。
三、编辑器优化
ts
import { BlockNoteEditor } from "@blocknote/core";
export const clearEditor = (editor: BlockNoteEditor) => {
const blockIds = editor.document.map((block) => block.id);
editor.replaceBlocks(blockIds, [{ type: "paragraph" }]);
editor.focus();
};
在之前的新建逻辑中,我简单而粗暴地直接调replace传入空数组,但是这会带来副作用:编辑器无内容、光标无法定位报错。传入空数组会因无block出错,实际可传入空paragraph块。最终采用手动清空,操作editor的document属性,从底层存储层清空重置所有block(editordocument是包含所有块对象的顶层array),避免因无活跃块导致渲染报错、页面卡死。
还有一个小细节,光标优化,采用editor的focus方法让用户打开页面即可输入,在UX上称为0摩擦。
四、hook实现与类型处理
自定义useFileImport hook的逻辑:先校验文件后缀(仅支持markdown,防止上传无法解析的文件),再读取内容。
ts
import { BlockNoteEditor } from "@blocknote/core";
import { clearEditor } from "../utils/clearEditor";
import { useCallback } from "react";
export const useFileImport = (editor: BlockNoteEditor | null) => {
const importFile = useCallback(
async (file: File) => {
if (!editor) return;
if (!file.name.endsWith(".md")) {
alert("请选择一个Markdown文件进行导入");
return;
}
try {
const content = await file.text();
const blocks = await editor.tryParseHTMLToBlocks(content);
clearEditor(editor);
editor.replaceBlocks(
editor.document.map((b) => b.id),
blocks,
);
} catch (error) {
console.error("导入失败:", error);
}
},
[editor],
);
return { importFile };
};
原本以为文档导入可直接用原生API,实际上,需要结合浏览器API操作原生dom(如 <input type="file">),需跳出react框架,直接操作DOM,也就是我之前似是而非学的Ref,还有react的脱围机制,这一块我重新学习了文档。
Ref与脱围机制
- "ref 是一种脱围机制,用于保留不用于渲染的值。你不会经常需要它们。"
- "当你的组件需要'走出' React 并与外部 API 通信时,你会用到 ref ------ 通常是不会影响组件外观的浏览器 API。例如:存储和操作 DOM 元素。"
- "将 ref 视为脱围机制。当你使用外部系统或浏览器 API 时,ref 很有用。如果你很大一部分应用程序逻辑和数据流都依赖于 ref,你可能需要重新考虑你的方法。"
单独提取了一个import相关的useFilePicker hook,防止操作dom的逻辑和ui耦合,代码职责更清晰,并且后续这个文件选择器hook逻辑正好要全局可复用,支持单文件和文件夹导入(文件夹模块暂未开发)。
ts
import React, { useRef } from "react";
type PickerMode = "file" | "directory";
interface DirectoryInputElement extends HTMLInputElement {
webkitdirectory: boolean;
directory: boolean;
}
export const useFilePicker = (onFilesSelected: (files: File[]) => void) => {
const inputRef = useRef<HTMLInputElement>(null);
const openPicker = (mode: PickerMode = "file") => {
if (inputRef.current) {
const input = inputRef.current as DirectoryInputElement;
if (mode === "directory") {
input.webkitdirectory = true;
input.directory = true;
} else {
input.webkitdirectory = false;
input.directory = false;
}
inputRef.current.click();
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
onFilesSelected(files);
e.target.value = "";
}
};
return {
Picker: (
<input
type="file"
ref={inputRef}
onChange={handleFileChange}
style={{ display: "none" }}
accept=".md"
/>
),
openPicker,
};
};
关于区分:在hook中通过webkitdirectory、directory属性区分文件夹与单文件。这个过程中,遇到了 TypeScript 的类型冲突问题------因为 webkitdirectory 并非所有浏览器都支持的官方标准属性,直接使用会导致类型报错。最终的解决方案是扩展接口,为输入元素定义了一个更包容的类型,从而保证了类型安全。
扩展DOM类型 "TypeScript 允许我们通过声明合并的方式来扩展接口。这意味着我们可以在多处定义同一个接口,然后将它们合并成一个单一的接口。"
五、总结
以上是文件导入功能的完整开发过程,整体采用交互层、触发层、逻辑层三层架构,目前文件处理模块已基本可用。
回头看,只是这样一个小功能,我似乎又学习到了不少:我对hook的封装,纯函数的理解在实践中更明确,并且回顾了之前根本没仔细读的docs,还有在写代码前想好数据流的走向,web api的操作和开源库原生api的阅读源码和使用,小细节上有类型安全。
虽然大部分代码并非完全原创,有之前的文件导入逻辑参考,依葫芦画瓢仿写,但这样我认为自己在开发中的参与度变高了,收获也就变多了。