普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月9日首页

今日开发反思:编辑器大纲跳转与数据持久化实践

作者 im_AMBER
2026年3月8日 23:44

今天来复盘一下我在编辑器项目中的开发过程与思考,主要做了两件核心事情:大纲跳转功能实现和编辑区域数据持久化,过程中踩了不少坑,也对架构设计有了更直观的理解,纯真实开发心路......菜鸟,大佬见谅...

一、大纲功能跳转编辑器主页面

首先是实现大纲点击跳转编辑器主页面的功能。

一开始我想直接使用 BlockNote 原生的 API 来完成跳转,但是失败了。最终的方案还是采用了外部的 API,直接修正后进行 scrollIntoView。

我在想一开始 BlockNote 原生是否支持这个操作,后面失败了——因为 BlockNote 底层的逻辑里是不支持空标题跳转,但实际上 UX(用户体验)的设计里面,空标题也应该也要跳转。

这是一个很小的内容,用常规修正的方法,之前我已经写过文章了,这一部分已经总结好了。

这次真正花时间的是:为了实现需求,不得不跳出 React 框架的抽象层,采用浏览器的原生 DOM API。

问了 AI 之后我才更清晰:操作原生 DOM 其实是我们降级(Fallback)的方案,但是基于业务需求,框架原生 API 不能完全覆盖所有交互场景,那我们只能降级用 DOM API 来直接操纵节点。

而且 BlockNote 本身并不自带大纲视图,我现在用的大纲 UI 都是自己手写的。

我看了一下,现在做这个跳转逻辑其实非常简单,一共就两三行代码:

const handleJump = (block: Block) => {
  // 通过属性选择器定位到具体的 DOM 节点
  const element = document.querySelector(`[data-id="${block.id}"]`);
  // 使用 ScrollIntoView 实现平滑滚动至视口中心
  element?.scrollIntoView({ behavior: "smooth", block: "center" });
};

回头看才发现,之前一直在查 API 查了很久,其实都没抓到关键点,最后用最朴素的浏览器 API 就解决了。

二、数据持久化:刷新不丢失编辑内容

今天着重做的地方是数据持久化,刷新以后,当前的编辑区域文字不能丢失,页面不能丢失。

持久化本质就是把内存中的状态(State)同步到非易失性存储(Non-volatile Storage),这里我采用的是存入 IndexedDB,按专业的话来讲就是在组件挂载(Mounting)阶段将其恢复。

核心原则:UI 的渲染要纯粹,数据访问层(Data Access Layer)与 UI 渲染必须分离,避免逻辑耦合。

副作用管理:useEffect 是 React 中处理副作用(Side Effects)的标准手段。在处理大量数据或异步操作时,我考虑了竞态条件(Race Condition)的问题——即异步操作的响应顺序可能与预期不符。结论依然是:应将数据同步逻辑与视图渲染逻辑彻底解耦。

这里我今天做的持久化分了两层:

  • 编辑区的文本:因为后续有云端导出的功能需求,数据量大且需要事务支持,所以采用 IndexedDB(MDN 定义:浏览器内置的基于键值对的非关系型数据库,适合存储大量结构化数据)。
  • 左侧边栏文档 / 大纲的选择:状态简单,仅需存储少量布尔值或字符串,用 localStorage 存储(MDN 定义:浏览器 Web Storage API,提供持久化的 Key-Value 存储,数据无过期时间,仅在同源策略下可见)。

我现在做的代码文件干了两个事情:一个是定义好了数据库的事务操作、初始化,还有一个是用 TypeScript 定义好类型(Type Definition)。

另外也要自定义一个 Hook,把持久化的逻辑封装好,让 UI 组件实现关注点分离(Separation of Concerns),仅负责渲染。但是 UI 怎样拿到 ID?怎样联通这个 Hook?是我今天做的比较久的一个部分。

最后的解决办法也只是在官方 BlockNote 持久化的 API 里面,把那一段代码文件找出来,然后再喂给 AI,叫 AI 帮我写…

三、对代码、Hook、架构的理解

现在来看一下这个代码文件是怎么做的。

import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteEditor } from "@blocknote/core";
import { useCallback } from "react";

interface EditorProps {
  editor: BlockNoteEditor;
  onSave?: (content: any) => void;
  noteId?: string;
}

export default function Editor({ editor, onSave, noteId }: EditorProps) {
  // 使用 useCallback 缓存函数引用,避免在组件重渲染时触发不必要的子组件更新
  const handleSave = useCallback(() => {
    if (onSave && noteId) {
      onSave({
        id: noteId,
        content: editor.document,
        updatedAt: Date.now(),
      });
    }
  }, [onSave, noteId, editor.document]);

  return (
    <BlockNoteView
      editor={editor}
      onChange={handleSave}
    />
  );
}

原来的编辑器只是单纯渲染了一个视口,之前只是调用了 Quick Start 里面如何渲染出编辑器界面并处理内容变化,没有接上 Hook,没有涉及到 Data 的持久化链路。

现在是当用户在编辑器中进行修改的时候,handleSave 这个函数会被调用,采用的 useCallback 将最新的内容传给父组件以便保存。

这个最终代码并不是我写的,但我更深一步理解好了 Hook。我看了 React 原版文章的章节,我自己的理解是:

  • Hook 是一个处理状态逻辑(State Logic)的函数。
  • 如果这个逻辑不涉及状态管理或生命周期,那它就不叫 Hook。
  • Hook 抽离出来,是本身被复用的、单独的、复杂的一种响应式逻辑(Reactive Logic),是容器化的手段。
  • Hook 不是存储状态本身,它只是封装了一套状态变更的逻辑链路。

接下来我的做法是先做好初始化的存储协议,再做好 Hook 的定义。我今天比较大的收获应该就是:数据层整个架构应该怎样组织,但具体代码我自己还写不来,但是我能看得明白。

还有一个就是 TS 里面的类型定义,我虽然没有很仔细地每一行代码都敲过去,但是通过类型断言(Type Assertion)、显式类型(Explicit Type),我稍微感受到了一点工程上对于类型的严谨。

四、工程化细节:Loading 状态与数据流

Hook 的设计逻辑是去管理副作用,这里 AI 开始乱七八糟,一直在做死循环。今天的 AI 不是很聪明,我用得也不是很熟练。

我只是稍微明白了一点:工程模式在没有拿到 data 的时候,要有一个 Loading 中间状态来避免报错或者初始化的失败,就是要定义哪几个状态我比较清楚了,但具体怎么实现我还是不会写…

后面还有一个重渲染的问题:在保存的时候不需要加载状态,如果一直在触发状态更新的话,就会导致循环保存。重置后也不需要 Loading。

梳理好的完整数据流:

用户输入 → BlockNote 内部状态 change 触发 → save 函数执行 → 写入 IndexedDB → IndexedDB 初始化 → 从 data 中加载数据 → 回显到编辑器 UI

五、今日总结

我的天…这样看下来,我今天一天都在忙活什么?总结下来,自己亲手搓代码也不是很多,无非就是我在传各种 state,再传各种变量进去而已,那我今天在干什么?!!

但冷静下来想,这一天并不是无效忙碌:

  • 学会了不强行依赖库原生 API:必要时用原生 DOM 降级解决(实用开发思路)。
  • 真正理解了数据层、服务层、UI 层分离的意义:工程化思维提升。
  • 搞懂了自定义 Hook 的职责与设计思路:React 基础能力巩固。
  • 理清了编辑器从输入到持久化的完整数据流:业务逻辑梳理能力提升。
  • 对 TS 类型、Loading 状态、避免多余重渲染有了工程化意识:细节把控进步。

虽然很多代码还不能完全独立从零实现,但至少看得懂、理得清、知道为什么这么写,这对我来说就是进步!

参考文献

❌
❌