订阅模式实现字符数统计

今天做的事比较少,主要任务是实现实时字数统计,通过 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)并非 "绕过原生变化",而是标准化的状态监听与响应机制,官方文档核心解释如下:

  1. 订阅模式的本质(摘自 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 执行触发),该回调会被调用。)

  1. 订阅模式的设计目的(摘自 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 流程,并仅暴露更新后的文档状态。)

  1. 关于 "中间商" 的官方解读(摘自 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 传递。

小结

新的内容是订阅机制,还有更仔细地关注数据的获取,但是因为开发中途打断了一会,隔了好几天再写,少了当时很多卡顿的地方。

果然还是需要趁热打铁啊!以后还是第一时间复盘,把握好珍贵的成长!

相关推荐
北寻北爱2 小时前
axios
开发语言·前端·javascript
Nuopiane2 小时前
Mypal3(9)
前端·javascript·数据库
筱璦2 小时前
期货软件开发 - 交易报表
前端·windows·microsoft·报表·期货
暴躁网友w2 小时前
掌握Fetch与Flask交互:让前端表单提交更优雅的动态之道
前端·flask·交互
木斯佳2 小时前
前端八股文面经大全:腾讯前端暑期提前批一、二、三面面经(上)(2026-03-04)·面经深度解析
前端
段帅星2 小时前
mac下sublime text优化
macos·编辑器·sublime text
嘉琪0012 小时前
Day4 完整学习包(this 指向)——2026 0313
前端·javascript·学习
前端小菜鸟也有人起2 小时前
Vue3父子组件通信方法总结
前端·javascript·vue.js