今天做的事比较少,主要任务是实现实时字数统计,通过 BlockNote 内部维护的 editor 实例监听实时数据。
简单复习一下,这个功能不是一口气做下来的,间隔的思考有些忘了。
复盘内容有点少,仅作记录。
一、实现思路
通过 onChange 事件获取 transaction 相关内容,但transaction的封装有点复杂,我没怎么看懂。onChange这个api是官方给的,可以监听输入内容的变化。
接下来使用 useEffect 实现字数受控,不直接解析原生 DOM 节点拿data。
BlockNote 基于节点设计,文档结构为节点数组,根据node 的type ,从中提取 content 内容,
在 BlockNote 中,block.content 的结构通常如下所示(基于 ProseMirror 的 TextNode 抽象):
{ "type": "text", "text": "Hello, " }, { "type": "text", "text": "world", "styles": { "bold": true } }\]。
1.1 核心依赖引入
ts
import type { Block } from "@blocknote/core";
import { useState, useEffect } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { debounce } from "lodash";
1.2 递归统计字数函数
基于 BlockNote 的节点结构,编写递归函数统计所有 block 及子节点的文字数量:
ts
// 递归统计所有 block 及子节点的文字数量
const countWordsInBlocks = (blocks: Block[]): number => {
let count = 0;
for (const block of blocks) {
if (block.content && Array.isArray(block.content)) {
block.content.forEach((node: any) => {
if (node.type === "text" && node.text) {
count += node.text.trim().length;
}
});
}
// 处理嵌套的子节点
if (block.children && block.children.length > 0) {
count += countWordsInBlocks(block.children);
}
}
return count;
};
1.3 自定义 Hook 封装
将字数统计逻辑封装为自定义 Hook:
ts
// 自定义 Hook 封装字数统计逻辑
export const useWordCount = (editor: BlockNoteEditor | undefined) => {
const [wordCount, setWordCount] = useState(0);
useEffect(() => {
if (!editor) return;
// 防抖处理:避免频繁更新 UI
const updateCount = debounce(() => {
const count = countWordsInBlocks(editor.document);
setWordCount(count);
}, 300);
// BlockNote 订阅模式:监听编辑器内容变化
const unsub = editor.onChange(updateCount);
// 初始化统计一次
updateCount();
// 清理订阅和防抖函数(避免内存泄漏)
return () => {
unsub();
updateCount.cancel();
};
}, [editor]);
return wordCount;
};
二、性能优化方案
为提升性能、避免页面卡顿,需将计算逻辑与渲染逻辑分离。一开始我有一个认知偏差需要明确:在前端,遍历几千个字符串节点的性能开销远小于 React 渲染 1000 个 DOM 节点的开销。只要计算逻辑不在渲染循环里直接执行(或者做了 Memoization),计算本身几乎是瞬时的。
性能瓶颈不在字数统计,而在于 UI 频繁更新,因此在代码中使用 lodash 的 debounce 实现防抖(替代原生 setTimeout 更简洁),对应核心代码片段:
ts
// 防抖处理:避免频繁更新 UI
const updateCount = debounce(() => {
const count = countWordsInBlocks(editor.document);
setWordCount(count);
}, 300);
为了实现"不卡顿",需要采用防抖(Debounce)或者计算分离策略,而不是试图在 onChange 的主线程里直接更新 React 状态。
三、自定义 Hook 。订阅模式
我封装了上述自定义 Hook,解析 Change 时遍历 content 数组处理渲染依赖。由于编辑器实例引用地址不变,将 editor 加入useEffect 依赖数组不会重新触发副作用,因此改用 BlockNote 订阅模式。
在工程实现上,不需要"手动"去写复杂的监听逻辑,BlockNote 的 editor 实例暴露了标准的订阅接口:const cleanup = editor.onChange(() => { // 你的字数统计逻辑 });,
一开始,我以为订阅模式就是绕过原生变化的内容,采用一个中间商(自定义hook utils api 等逻辑),不直接处理发布的内容。
答案是这样的:
BlockNote 基于 ProseMirror 架构设计,其订阅模式(Subscription Pattern)并非 "绕过原生变化",而是标准化的状态监听与响应机制,官方文档核心解释如下:
- 订阅模式的本质(摘自 BlockNote > API > Editor > Events)
The editor.onChange() method is the primary way to subscribe to changes in the editor's content or state. It registers a callback function that will be invoked every time the editor's document or selection changes (triggered by user input, programmatic updates, or transaction execution).
(译:editor.onChange() 方法是订阅编辑器内容 / 状态变化的核心方式。它注册一个回调函数,每当编辑器的文档或选区发生变化时(用户输入、程序化更新、transaction 执行触发),该回调会被调用。)
- 订阅模式的设计目的(摘自 BlockNote > Core Concepts > State Management)
BlockNote abstracts the low-level ProseMirror transaction system into a simple subscription API to avoid direct manipulation of internal transaction objects. Instead of parsing raw transactions (which contain granular change details like node insertions/deletions), developers use editor.onChange() to react to final state changes --- the editor handles transaction processing internally and exposes only the updated document state.
(译:BlockNote 将底层 ProseMirror transaction 系统抽象为简洁的订阅 API,避免开发者直接操作内部 transaction 对象。开发者无需解析原始 transaction(包含节点增删等细粒度变更细节),而是通过 editor.onChange() 响应最终的状态变化 --- 编辑器内部处理 transaction 流程,并仅暴露更新后的文档状态。)
- 关于 "中间商" 的官方解读(摘自 BlockNote > Guides > Real-time Updates)
Custom hooks/utils built on top of editor.onChange() are not "middlemen that bypass native changes" --- they are standard extensions of the subscription system. The editor's native change events (backed by ProseMirror transactions) are still the source of truth; custom logic simply processes the exposed state (e.g., counting words, debouncing UI updates) without altering the core change flow.
(译:基于 editor.onChange() 构建的自定义 hook / 工具函数并非 "绕过原生变化的中间商",而是订阅系统的标准扩展。编辑器的原生变更事件(由 ProseMirror transaction 支撑)仍是唯一可信源;自定义逻辑仅处理暴露的状态(如字数统计、UI 防抖),不改变核心变更流程。)
-
订阅模式不是 "绕过" 原生变化,而是标准化接收原生变化的结果 --- transaction 是编辑器内部的变更记录,订阅 API 帮你屏蔽了 transaction 的复杂解析,直接拿到可使用的最终文档状态。
-
自定义 hook 的角色:不是 "中间商",而是订阅回调的封装 --- 你的 useWordCount Hook 本质是把 "监听变更→统计字数→更新状态" 的逻辑封装复用,底层仍依赖 BlockNote 原生的 onChange 订阅机制。
-
官方核心原则:BlockNote 订阅模式的设计目标是 "简化状态响应,而非替换原生变更",所有自定义逻辑都应基于 editor.onChange() 实现,确保与编辑器核心状态保持一致。
感觉有点绕,订阅模式大概是做了"专业人做专业事",提供了直接可用的接口。
ts
// BlockNote 订阅模式:监听编辑器内容变化
const unsub = editor.onChange(updateCount);
四、订阅模式理解与收尾
订阅模式即监听 onChange 变化,通过订阅机制响应数据变更,同时需要做好订阅清理:return () => cleanup();
ts
// 清理订阅和防抖函数(避免内存泄漏)
return () => {
unsub();
updateCount.cancel();
};
这部分我理解得不够深入。
完成防抖处理后,最终统计状态通过 props 传递。
小结
新的内容是订阅机制,还有更仔细地关注数据的获取,但是因为开发中途打断了一会,隔了好几天再写,少了当时很多卡顿的地方。
果然还是需要趁热打铁啊!以后还是第一时间复盘,把握好珍贵的成长!