阅读视图

发现新文章,点击刷新页面。

别再死磕框架了!你的技术路线图该更新了

先说结论:

前端不会凉,但“只会几个框架 API”的前端,确实越来越难混
这两年“前端要凉了”“全栈替代前端”的声音此起彼伏,本质是门槛重新洗牌:

  • 简单 CRUD、纯样式开发被低代码、模板代码和 AI 模型快速蚕食;
  • 复杂业务、工程体系、跨端体验、AI 能力集成,反而需要更强的前端工程师去撑住。

如果你对“前端的尽头是跑路转管理”已经开始迷茫,那这篇就是给你看的:别再死磕框架版本号,该更新的是你的技术路线图。


一、先搞清楚:2025 的前端到底在变什么?

框架红海:从“会用”到“用得值”

React、Vue、Svelte、Solid、Qwik、Next、Nuxt……Meta Framework 一大堆,远远超过岗位需求。
现在企业选型更关注:

  • 生态成熟度(如 Next.js 的 SSR/SSG 能力)
  • 框架在应用生命周期中的角色(渲染策略、数据流转、SEO、部署)

趋势:

  • 框架 Meta 化(Next.js、Nuxt)将路由、数据获取、缓存策略整体纳入规范;
  • 约定优于配置,不再是“一个前端库”,而是“一套完整解决方案”。

以前是“你会 Vue/React 就能干活”,现在是“你要理解框架在整个应用中的角色”。


工具有 AI,开发方式也在变

AI 工具(如 Cursor、GitHub Copilot X)可以显著提速,甚至替代重复劳动。
真正拉开差距的变成了:

  • 你能给 AI 写出清晰、可实现的需求描述(Prompt);
  • 你能判断 AI 生成代码的质量、潜在风险、性能问题;
  • 你能基于生成结果做出合理抽象和重构。

AI 不是来抢饭碗,而是逼你从“码农”进化成“架构和决策的人”。


业务侧:前端不再是“画界面”,而是“做体验 + 做增长”

  • B 端产品:交互工程师 + 低代码拼装师 + 复杂表单处理专家;
  • C 端产品:与产品运营深度捆绑,懂 A/B 测试、埋点、Funnel 分析、广告投放链路;
  • 跨平台:Web + 小程序 + App(RN/Flutter/WebView)混合形态成为常态。

那些还在喊“切图仔优化 padding”的岗位确实在消失,但对“懂业务、有数据意识、能搭全链路体验”的前端需求更高。


二、别再死磕框架 API:2025 的前端核心能力长什么样?

基石能力:Web 原生三件套,得真的吃透

重点不是“会用”,而是理解底层原理:

  • JS:事件循环、原型链、Promise 执行模型、ESM 模块化;
  • 浏览器:渲染流程(DOM/CSSOM/布局/绘制/合成)、HTTP/2/3、安全防护(XSS/CSRF)。

这块扎实了,你在任何框架下都不会慌,也更能看懂“框架为什么这么设计”。


工程能力:从“会用脚手架”到“能看懂和调整工程栈”

Vite、Rspack、Turbopack 等工具让工程构建从“黑魔法”变成“可组合拼装件”。
你需要:

  • 看懂项目的构建配置(Vite/Webpack/Rspack 任意一种);
  • 理解打包拆分、动态加载、CI/CD 流程;
  • 能排查构建问题(路径解析、依赖冲突)。

如果你在团队里能主动做这些事,别人对你的“级别判断”会明显不一样。


跨端和运行时:不只会“写 Web 页”

2025 年前端视角的关键方向:

  • 小程序/多端框架(Taro、Uni-app);
  • 混合方案(RN/Flutter/WebView 通信机制);
  • 桌面端(Electron、Tauri)。

建议:

  • 至少深耕一个“跨端主战场”(如 Web + 小程序 或 Web + Flutter)。

数据和状态:从“会用 Vuex/Redux”到“能设计状态模型”

现代前端复杂度 70% 在“数据和状态管理”。
进阶点在于:

  • 设计合理的数据模型(本地 UI 状态 vs 服务端真相);
  • 学会用 Query 库、State Machine 解耦状态与视图。

当你能把“状态设计清楚”,你在复杂业务团队里会非常吃香。


性能、稳定性、可观测性:高级前端的硬指标

你需要系统性回答问题,而不是“瞎猜”:

  • 性能优化:首屏加载(资源拆分、CDN)、运行时优化(减少重排、虚拟列表);
  • 稳定性:错误采集、日志上报、灰度发布;
  • 工具:Lighthouse、Web Vitals、Session Replay。

这块做得好的人往往是技术骨干,且很难被低代码或 AI 直接替代。


AI 时代的前端:不是“写 AI”,而是“让 AI 真正跑进产品”

你需要驾驭:

  • 基础能力:调用 AI 平台 API(流式返回处理、增量渲染);
  • 产品思维:哪些场景适合 AI(智能搜索、文档问答);如何做权限控制、错误兜底。

三、路线图别再按“框架学习顺序”排了,按角色来选

初中级:从“会用”到“能独立负责一个功能”

目标:

  • 独立完成中等复杂度模块(登录、权限、表单、列表分页)。

建议路线:

  • 夯实 JS + 浏览器基础;
  • 选择 React/Vue + Next/Nuxt 做完整项目;
  • 搭建 eslint + prettier + git hooks 的开发习惯。

进阶:从“功能前端”到“工程前端 + 业务前端”

目标:

  • 优化项目、推进基础设施、给后端/产品提技术方案。

建议路线:

  • 深入构建工具(Webpack/Vite);
  • 主导一次性能优化或埋点方案;
  • 引入 AI 能力(如智能搜索、工单回复建议)。

高级/资深:从“高级前端”到“前端技术负责人”

目标:

  • 设计技术体系、推动长期价值。

建议路线:

  • 明确团队技术栈(框架、状态管理、打包策略);
  • 主导跨部门项目、建立知识分享机制;
  • 评估 AI/低代码/新框架的引入价值。

四、2025 年不要再犯的几个错误

  1. 只跟着热点学框架,不做项目和抽象

    • 选一个主战场 + 一个备胎(React+Next.js,Vue+Nuxt.js),用它们做 2~3 个完整项目。
  2. 完全忽略业务,沉迷写“优雅代码”

    • 把重构和业务迭代绑一起,而不是搞“纯技术重构”。
  3. 对 AI 持敌视和逃避态度

    • 把重复劳动交给 AI,把时间投到架构设计、业务抽象上。
  4. 把“管理”当成唯一出路

    • 做前端架构、性能优化平台、低代码平台的技术专家,薪资和自由度不输管理岗。

五、一个现实点的建议:给自己的 2025 做个“年度规划”

Q1:

  • 选定主技术栈(React+Next 或 Vue+Nuxt);
  • 做一个完整小项目(登录、权限、列表/详情、SSR、部署)。

Q2:

  • 深入工程化方向(优化打包体积、搭建监控埋点系统)。

Q3:

  • 选一个业务场景引入 AI 或配置化能力(如智能搜索、低代码表单)。

Q4:

  • 输出和沉淀(写 3~5 篇技术文章、踩坑复盘)。

最后:别问前端凉没凉,先问问自己“是不是还停在 2018 年的玩法”

  • 如果你还把“熟练掌握 Vue/React”当成简历亮点,那确实会焦虑;
  • 但如果你能说清楚:
    • 在复杂项目里主导过哪些工程优化;
    • 如何把业务抽象成可复用的组件/平台;
    • 如何在产品里融入 AI/多端/数据驱动;
      那么,在 2025 年的前端市场,你不仅不会“凉”,反而会成为别人眼中的“稀缺”。

别再死磕框架了,更新你的技术路线图,从“写页面的人”变成“打造体验和平台的人”。这才是 2025 年前端真正的进化方向。

Tiptap 深度教程(四):终极定制 - 从零创建你的专属扩展

引言

欢迎来到《Tiptap 深度教程》系列的第四篇,也是最具深度的一章。在前几篇教程中,我们探索了如何利用 Tiptap 强大的开箱即用功能和丰富的扩展生态来快速构建编辑器。然而,Tiptap 的真正威力并不仅限于此,它最核心的优势在于其近乎无限的可扩展性。当标准功能无法满足你独特的产品需求时,你需要的能力,是创造

本篇教程将是一次深入 Tiptap 核心的旅程。我们将不再仅仅是 Tiptap 功能的"消费者",而是成为其功能的"创造者"。我们将一起揭开 Tiptap 扩展系统背后的神秘面纱,赋予你从零开始构建任何可以想象到的编辑器功能的能力。

为什么需要自定义扩展?

在实际项目开发中,你可能会遇到这些场景,而官方扩展无法完全满足:

  • 🎨 产品特色需求:需要独特的"警告框"、"提示卡片"等品牌化组件,体现产品个性
  • 💼 业务逻辑集成:评论系统的 @提及、文档协作的批注功能、工单系统的状态标签
  • 🏥 行业特殊需求:法律文档的条款自动编号、医疗记录的结构化字段、教育平台的互动题目
  • ⚡ 性能极致优化:为特定场景定制轻量级扩展,移除不必要的功能,优化包体积
  • 🔧 深度定制交互:实现符合用户习惯的特殊编辑行为,如特定的快捷键、自动补全逻辑

当你遇到这些情况时,自定义扩展就是你的"超级武器"。

📋 本章学习目标

完成本章学习后,你将能够:

理解扩展本质:深入掌握 Node、Mark、Extension 三种扩展类型的底层原理和使用场景 ✅ 创建自定义 Node:从零构建块级节点(如 Callout 提示框),掌握文档结构定制 ✅ 创建自定义 Mark:实现行内标记(如彩色高亮),掌握文本格式扩展 ✅ 掌握高级 API:灵活运用命令、输入规则、Storage 等高级能力 ✅ 构建复杂扩展:完成生产级 Mention 扩展的完整实现,整合所有知识点

学习路径

在这趟旅程中,我们将遵循一条从理论到实践,从基础到专家的学习路径:

  • 🔍 探究底层原理:深入剖析 Tiptap 与其底层引擎 ProseMirror 之间的关系,为你建立坚实的理论基础

  • ✍️ 实践创造:逐行代码,从无到有地创建自定义的 Node(节点)和 Mark(标记),亲手体验扩展开发的全过程

  • 🚀 掌握高级 API:学习如何通过命令(Commands)、输入规则(Input Rules)和状态管理(Storage)等高级 API,为扩展注入强大的交互能力

  • 🏗️ 构建终极案例:将所学知识融会贯通,构建一个生产级别的、完全交互式的 @mention(提及)扩展

准备好迎接挑战,开启你的 Tiptap 大师之路!

第一节:Tiptap 扩展的解剖学:超越基础

在动手编写代码之前,我们必须建立一个清晰且准确的心智模型。理解 Tiptap 扩展的本质、其与底层引擎 ProseMirror 的关系,以及不同类型扩展的职责划分,是进行高级定制的前提。

ProseMirror 的连接:Tiptap 的引擎室

要真正理解 Tiptap,就必须认识到它是一个“无头(headless)”的编辑器框架,它本身并不提供用户界面,而是专注于编辑器逻辑。其强大的功能构建在一个名为 ProseMirror 的工具集之上。你可以将 ProseMirror 想象成一个高性能的汽车引擎,而 Tiptap 则是围绕这个引擎精心设计的底盘、传动系统和一套对开发者更友好的驾驶舱(API)。

Tiptap 巧妙地封装了 ProseMirror 的复杂性,提供了更易于理解和使用的 API。然而,当我们需要进行深度定制时,仅仅了解 Tiptap 的 API 是不够的。我们必须深入引擎室,理解 ProseMirror 的核心概念,例如:

  • Schema(模式):这是文档的“语法规则”,定义了哪些类型的内容(节点和标记)是合法的,以及它们之间如何嵌套。创建自定义

    NodeMark 的本质,就是在修改这个 Schema。

  • State(状态):一个不可变的(immutable)对象,包含了编辑器的所有信息,包括文档内容、当前选区、激活的标记等。编辑器的每一次变更都会产生一个新的 State。

  • Plugins(插件):它们是 ProseMirror 的“事件监听器”和“行为干预器”,可以观察并响应编辑器的各种变化,实现如协同编辑、输入快捷方式等复杂功能 8。

Tiptap 的设计哲学可以看作是一种“渐进式披露”。对于常规需求,你只需使用 Tiptap 的高层 API。但当你需要极致的控制力时,Tiptap 会为你打开通往底层 ProseMirror 的大门。本教程也将遵循这一哲学,从 Tiptap 的便捷 API 开始,逐步深入到更强大的 ProseMirror 概念中。

Node、Mark 和 Extension:职责明确的三驾马车

Tiptap 的一切皆为扩展,但根据其核心职责,我们可以将其分为三种基本类型 10。理解它们的区别至关重要,因为它决定了你在实现特定功能时应该选择哪种类型的扩展。

  • Nodes(节点):它们是构成文档结构的“积木” 11。想象一下,一篇文章由标题、段落、图片、代码块等组成,这些都是节点。节点可以是块级元素(

    block),如段落(Paragraph);也可以是行内元素(inline),如表情符号(Emoji)或图片(Image)12。它们是文档内容的承载者。

  • Marks(标记):它们用于为节点内的文本添加“行内样式”或“元数据”,而不会改变文档的结构 14。例如,将一段文字加粗(

    Bold)、设置为斜体(Italic)或添加超链接(Link),这些都是通过 Mark 实现的 13。Mark 就像是给文字涂上的高光,它依附于文字,但文字本身依然在段落(Node)中。

  • Generic Extensions(通用扩展):这类扩展不直接向文档的 Schema 中添加新的内容类型。它们的职责是增强编辑器的功能或行为 10。例如,

    TextAlign 扩展通过添加命令和属性来控制文本对齐,但它并没有创造一个新的“居中段落”节点 10。其他例子还包括监听编辑器更新事件(

    onUpdate)、添加全局键盘快捷键或集成复杂的 ProseMirror 插件。

为了更清晰地理解这三者的区别,下表提供了一个快速参考:

类型 (Type) 主要目的 (Primary Purpose) 对 Schema 的影响 (Impact on Schema) 常见示例 (Common Examples)
Node 定义文档的结构性内容块。 修改 Schema,添加新的内容类型。 Paragraph, Heading, Image, CodeBlock
Mark 为文本添加行内格式或元数据。 修改 Schema,添加新的格式类型。 Bold, Italic, Link, Highlight
Extension 增强编辑器功能、行为和交互。 不修改 Schema History (undo/redo), Placeholder, CharacterCount

这个表格清晰地揭示了一个核心原则:Schema 为王。当你需要定义一种新的内容类型时,你必须选择 NodeMark。当你只需要添加行为逻辑时,Extension 则是正确的选择。这个看似简单的区分,是构建健壮、可维护的自定义扩展的基石。

扩展 API:核心 Schema 定义

无论是创建哪种类型的扩展,我们都将从 Tiptap 提供的 create 方法开始,例如 Node.create({})Mark.create({}) 。在这个核心对象中,有几个属性是定义 Schema 的关键:

  • name: 扩展的唯一标识符,必须是字符串。这个名字至关重要,后续的命令调用、状态存储访问都将依赖它 11。

  • group: 定义了该节点所属的类别,例如 'block''inline' 8。这个属性直接影响到 ProseMirror 的内容表达式如何解析该节点,决定了它能出现在文档的什么位置 6。

  • content: 仅用于 Node 类型,这是一个“内容表达式”字符串,定义了该节点可以包含哪些子节点。例如,'inline*' 表示可以包含零个或多个行内节点,而 'paragraph+' 表示必须包含至少一个段落节点 5。这是 ProseMirror Schema 规则的直接体现,也是保证文档结构合法性的关键 19。

  • parseHTML: 定义了如何将一段 HTML 代码解析成当前扩展所代表的节点或标记。当用户粘贴内容或从数据库加载 HTML 时,这个函数会被调用,它就像是“输入转换器” 。

  • renderHTML: 定义了如何将编辑器内部状态中的节点或标记渲染成 HTML。当你需要保存文档内容或在只读模式下显示时,这个函数会被调用,它就像是“输出转换器” 。

掌握了这些基础概念,我们就拥有了与 Tiptap 核心对话的语言。接下来,我们将通过亲手实践,将这些理论知识转化为具体的、功能强大的自定义扩展。

第二节:从零到 Node:构建一个自定义“Callout”块

理论知识是基础,但真正的掌握源于实践。在本章中,我们将一步步地创建一个功能完整的自定义块级 Node——一个“Callout”组件。这种组件在文档中非常常见,用于高亮显示提示、警告或重要信息。通过这个例子,我们将把上一章的概念付诸实践。

步骤一:搭建 Node 的骨架

万丈高楼平地起。我们首先要用 Node.create 方法定义 Callout 节点的基本结构 10。创建一个新文件

Callout.js

import { Node, mergeAttributes } from '@tiptap/core';

export const Callout = Node.create({
  name: 'callout', // 1. 唯一名称
  
  group: 'block', // 2. 属于块级节点组

  content: 'paragraph+', // 3. 内容必须是至少一个段落

  defining: true, // 4. 这是一个定义边界的节点

  //... 更多配置将在这里添加
});

让我们来解析这段骨架代码:

  1. name: 'callout': 为我们的节点提供一个全局唯一的名称 11。

  2. group: 'block': 声明这是一个块级元素,它会独占一行,不能和普通文本混排 11。

  3. content: 'paragraph+': 这是对节点内容最核心的约束。它规定了 Callout 内部必须包含一个或多个段落(paragraph)节点 6。这确保了 Callout 内部内容的结构规范,避免了直接在其中放置裸露文本或其他不合规的块级节点。

  4. defining: true: 这是一个非常重要的属性。它告诉编辑器,这个节点是一个独立的“定义单元”。这意味着用户的光标无法部分选中 Callout 的内容和外部内容,也无法轻易地通过按回车或删除键将其与其他节点合并或拆分。这对于保持 Callout 结构的完整性至关重要。

步骤二:序列化(HTML 与编辑器状态的桥梁)

现在我们有了节点的内部定义,但 Tiptap 还不知道如何将它显示为 HTML,也不知道如何从 HTML 中识别它。这就是 renderHTMLparseHTML 的工作。

2.1 最简单的渲染实现

让我们从最基础的版本开始:

// 在 Callout.js 的 Node.create({}) 内部添加

renderHTML() {
  // 最简版本:只渲染一个 div 标签
  // 0 表示子内容的插入位置
  return ['div', {}, 0];
},

2.2 添加类型标识

为了让 HTML 更具语义化,我们添加一个 data-type 属性来标识这是 Callout 节点:

renderHTML() {
  // 添加 data-type 属性标识节点类型
  return ['div', { 'data-type': 'callout' }, 0];
},

2.3 完整版本:合并属性

最终版本需要能够接收并合并动态属性(后面会用到):

renderHTML({ HTMLAttributes }) {
  // 使用 mergeAttributes 合并默认属性和传入的属性
  return [
    'div',
    mergeAttributes(HTMLAttributes, { 'data-type': 'callout' }),
    0  // 子内容渲染位置
  ];
},

💡 渲染数组格式说明

  • 第一个元素:HTML 标签名('div'
  • 第二个元素:标签属性对象
  • 第三个元素:0 是特殊占位符,表示子内容应该被渲染到这里

2.4 配置解析规则

现在添加相反的逻辑——如何从 HTML 识别 Callout 节点:

parseHTML() {
  return [
    {
      tag: 'div[data-type="callout"]', // 匹配带有特定属性的 div 标签
    },
  ];
},

工作原理

  • renderHTML:编辑器状态 → HTML(用于保存和显示)
  • parseHTML:HTML → 编辑器状态(用于加载和粘贴)

步骤三:添加动态属性

一个静态的 Callout 不够灵活。我们希望能够创建不同类型的 Callout,比如“提示(info)”、“警告(warning)”和“危险(danger)”,并通过 CSS 为它们应用不同的样式。这需要用到属性(Attributes)。

添加属性是一个闭环操作,需要三步:定义、渲染和解析。这体现了属性数据的双向流动性:从编辑器状态到 HTML,再从 HTML 回到编辑器状态。遗漏任何一环都会导致数据在保存或加载时丢失。

  1. 定义属性:使用 addAttributes 方法。

    // 在 Node.create({}) 内部添加
    
    addAttributes() {
      return {
        calloutType: {
          default: 'info', // 默认类型是 'info'
        },
      };
    },
    

    这里我们定义了一个名为 calloutType 的属性,并为其设置了默认值 'info' 5。

  2. 渲染属性:修改 renderHTML,将属性值写入 DOM。

    // 修改 renderHTML 方法
    
    renderHTML({ HTMLAttributes }) {
      // HTMLAttributes 中会自动包含 calloutType
      return [
        'div',
        mergeAttributes(HTMLAttributes, { 'data-type': 'callout' }),
        0
      ];
    },
    

    Tiptap 会自动将 addAttributes 中定义的属性(如 calloutType)映射到 HTMLAttributes 对象中,并以 data- 前缀的形式渲染到 HTML 标签上。最终生成的 HTML 会是 <div data-type="callout" data-callout-type="info">...</div>

  3. 解析属性:修改 parseHTML,从 DOM 中读取属性值。

    // 修改 parseHTML 方法
    
    parseHTML() {
      return [
        {
          tag: 'div[data-type="callout"]',
          getAttrs: (element) => ({
            calloutType: element.getAttribute('data-callout-type'),
          }),
        },
      ];
    },
    

    我们在解析规则中添加了 getAttrs 函数。当匹配到 div 标签时,此函数会执行,读取 data-callout-type 属性的值,并将其赋值给我们节点状态中的 calloutType 属性。

现在,我们的 Callout 节点已经具备了动态样式的能力。你可以通过 CSS 选择器 div[data-callout-type="warning"] 来为其定义独特的样式。

步骤四:创建命令

如果用户只能通过手动编写 HTML 来创建 Callout,那体验就太糟糕了。我们需要提供编程式的接口——命令(Commands),以便通过按钮或其他 UI 元素来操作 Callout 扩展。

// 在 Node.create({}) 内部添加
// 别忘了在文件顶部引入 declare module '@tiptap/core'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    callout: {
      /**
       * 设置或切换 Callout 块
       */
      toggleCallout: (attributes: { calloutType: string }) => ReturnType,
    }
  }
}

//...

addCommands() {
  return {
    toggleCallout: (attributes) => ({ commands }) => {
      // 使用 toggleBlock 来在段落和 Callout 之间切换
      return commands.toggleBlock(this.name, 'paragraph', attributes);
    },
  };
},

我们使用 addCommands 方法来定义命令。这里我们创建了一个 toggleCallout 命令。我们巧妙地利用了 Tiptap 内置的 toggleBlock 命令,它可以智能地在两种块类型之间切换。如果当前选区是段落,它会将其转换为 callout;如果已经是 callout,则会将其转换回段落。我们还通过 attributes 参数,允许在创建 Callout 时动态指定其类型。

通过 TypeScript 的 declare module,我们将自定义命令注入到了 Tiptap 的全局命令接口中,这能为我们带来极佳的类型提示和自动补全体验。

步骤五:集成到编辑器与样式定制

5.1 集成扩展

最后一步,将我们精心打造的 Callout 扩展集成到 Tiptap 编辑器实例中:

// 在你的编辑器配置文件中
import { Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Callout } from './Callout.js'; // 引入我们的扩展

const editor = new Editor({
  extensions: [
    StarterKit,
    Callout,  // 添加 Callout 扩展
  ],
  //... 其他配置
});

5.2 完整的 CSS 样式

现在让我们为 Callout 添加美观且实用的样式,实现不同类型的视觉效果:

/* Callout 基础样式 */
.tiptap div[data-type="callout"] {
  padding: 1rem;
  border-radius: 0.5rem;
  margin: 1rem 0;
  border-left: 4px solid;
  background-color: #f8fafc;
  transition: all 0.2s ease;
}

/* Info 类型 - 蓝色主题 */
.tiptap div[data-callout-type="info"] {
  background-color: #eff6ff;
  border-left-color: #3b82f6;
}

.tiptap div[data-callout-type="info"]::before {
  content: 'ℹ️ 提示';
  display: block;
  font-weight: 600;
  color: #1e40af;
  margin-bottom: 0.5rem;
}

/* Warning 类型 - 黄色主题 */
.tiptap div[data-callout-type="warning"] {
  background-color: #fefce8;
  border-left-color: #eab308;
}

.tiptap div[data-callout-type="warning"]::before {
  content: '⚠️ 警告';
  display: block;
  font-weight: 600;
  color: #a16207;
  margin-bottom: 0.5rem;
}

/* Danger 类型 - 红色主题 */
.tiptap div[data-callout-type="danger"] {
  background-color: #fef2f2;
  border-left-color: #ef4444;
}

.tiptap div[data-callout-type="danger"]::before {
  content: '🚨 危险';
  display: block;
  font-weight: 600;
  color: #b91c1c;
  margin-bottom: 0.5rem;
}

/* Success 类型 - 绿色主题 */
.tiptap div[data-callout-type="success"] {
  background-color: #f0fdf4;
  border-left-color: #22c55e;
}

.tiptap div[data-callout-type="success"]::before {
  content: '✅ 成功';
  display: block;
  font-weight: 600;
  color: #15803d;
  margin-bottom: 0.5rem;
}

/* Callout 内部段落样式 */
.tiptap div[data-type="callout"] p {
  margin: 0;
  line-height: 1.6;
}

.tiptap div[data-type="callout"] p + p {
  margin-top: 0.5rem;
}

5.3 使用示例

现在,你就可以在编辑器 UI 中添加一个按钮,点击时调用命令:

// 在你的工具栏组件中
<button
  onClick={() => editor.commands.toggleCallout({ calloutType: 'warning' })}
  className={editor.isActive('callout', { calloutType: 'warning' }) ? 'is-active' : ''}
>
  ⚠️ 警告框
</button>

<button
  onClick={() => editor.commands.toggleCallout({ calloutType: 'info' })}
  className={editor.isActive('callout', { calloutType: 'info' }) ? 'is-active' : ''}
>
  ℹ️ 提示框
</button>

5.4 功能验证清单

测试你的 Callout 扩展是否完整实现:

✅ 通过命令创建 Callout 块 ✅ 切换不同的 calloutType(info、warning、danger、success) ✅ 验证属性正确序列化到 HTML ✅ 从 HTML 粘贴能正确解析为 Callout ✅ CSS 样式正确应用到不同类型 ✅ 在 Callout 内部可以正常编辑段落内容

🎉 恭喜! 你已经成功创建了第一个功能完整、样式精美的自定义节点扩展!

第三节:从零到 Mark:打造一个带颜色的自定义“高亮”

掌握了 Node 的创建之后,Mark 的创建就变得轻车熟路了。Mark 用于实现行内格式,如加粗、链接等。在本章中,我们将创建一个比 Tiptap 内置高亮更强大的版本:一个可以自定义高亮颜色的 coloredHighlight 标记。这个过程将巩固我们对属性和命令的理解,并引入新的概念,如键盘快捷键。

步骤一:搭建 Mark 的骨架

Node 类似,我们使用 Mark.create 方法开始 10。创建一个新文件

ColoredHighlight.js

import { Mark, mergeAttributes } from '@tiptap/core';

export const ColoredHighlight = Mark.create({
  name: 'coloredHighlight', // 1. 唯一名称

  spanning: false, // 2. 默认情况下,标记不能跨越块级节点

  //... 更多配置
});
  1. name: 'coloredHighlight': 同样,一个唯一的名称是必不可少的。

  2. spanning: false: 这个属性默认为 false,意味着标记不能跨越不同的块级节点。例如,如果用户选中了两个段落的部分文本,应用此标记后,它会分别在两个段落内生效,而不会形成一个单一的、跨越段落边界的标记。在大多数情况下,这是我们期望的行为 15。

步骤二:序列化与属性

我们的核心需求是能够自定义颜色。这自然需要一个 color 属性。与 Node 一样,我们需要完成定义、渲染、解析三部曲。

2.1 配置选项(Options)

首先,我们需要为扩展添加配置选项,定义默认颜色:

// 在 Mark.create({}) 内部添加

addOptions() {
  return {
    color: '#FFFF00',  // 默认颜色为黄色
  }
},

💡 Options vs Attributes

  • Options:扩展级别的配置,在扩展实例化时设置,影响所有该扩展的实例
  • Attributes:节点级别的数据,每个节点实例可以有不同的值

2.2 定义属性

addAttributes() {
  return {
    color: {
      default: this.options.color,  // 使用 options 中的默认颜色
      parseHTML: element => element.style.backgroundColor || '',
      renderHTML: attributes => {
        if (!attributes.color) {
          return {};
        }
        return {
          style: `background-color: ${attributes.color}`,
        };
      },
    },
  };
},

2.3 配置序列化

parseHTML() {
  return [
    {
      tag: 'mark',  // 匹配 <mark> 标签
      getAttrs: (element) => {
        // 从 style 属性中解析背景颜色
        const color = element.style.backgroundColor;
        return color ? { color } : {};
      },
    },
  ];
},

renderHTML({ HTMLAttributes }) {
  // mergeAttributes 会自动处理 color 属性转为 style
  return ['mark', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},

工作原理解析

  • addOptions:定义扩展级别的默认配置
  • addAttributes:定义节点级别的动态数据,引用 options
  • parseHTML:从 HTML 的 style 提取背景色
  • renderHTML:将 color 属性渲染为内联 style

步骤三:创建一套完整的命令

一个优秀的扩展应该提供一个完整、可预测的编程接口(API),方便 UI 调用。这不仅仅是"让按钮工作",而是精心设计扩展的外部交互方式。对于高亮标记,我们需要设置、切换和取消三种操作 14。

// 在 ColoredHighlight.js 中

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    coloredHighlight: {
      /**
       * 设置高亮并指定颜色
       */
      setHighlight: (attributes: { color: string }) => ReturnType,
      /**
       * 切换高亮状态
       */
      toggleHighlight: (attributes: { color: string }) => ReturnType,
      /**
       * 取消高亮
       */
      unsetHighlight: () => ReturnType,
    }
  }
}

// 在 Mark.create({}) 内部添加
addCommands() {
  return {
    setHighlight: (attributes) => ({ commands }) => {
      return commands.setMark(this.name, attributes);
    },
    toggleHighlight: (attributes) => ({ commands }) => {
      return commands.toggleMark(this.name, attributes);
    },
    unsetHighlight: () => ({ commands }) => {
      return commands.unsetMark(this.name);
    },
  };
},

我们再次使用了 Tiptap 内置的命令助手:setMarktoggleMarkunsetMark。它们极大地简化了逻辑。通过提供这一整套命令,我们让 UI 层的开发变得异常简单:

  • 颜色选择器可以选择一个颜色,然后调用 editor.commands.setHighlight({ color: '#FFC0CB' })

  • 一个开关按钮可以调用 editor.commands.toggleHighlight({ color: '#FFFF00' })

  • 一个“清除格式”按钮可以调用 editor.commands.unsetHighlight()

通过 declare module 再次扩展 TypeScript 接口,我们确保了这套 API 是完全类型安全且具备自动补全的,极大地提升了开发体验 20。

步骤四:添加键盘快捷键

为了提升效率,我们可以为最常用的命令绑定键盘快捷键。addKeyboardShortcuts 方法让这一切变得简单。

// 在 Mark.create({}) 内部添加

addKeyboardShortcuts() {
  return {
    'Mod-Shift-H': () => this.editor.commands.toggleHighlight({ color: this.options.color }),
  };
},

这段代码将 Cmd+Shift+H (在 Mac 上) 或 Ctrl+Shift+H (在 Windows 上) 绑定到了 toggleHighlight 命令上 14。当用户按下快捷键时,它会使用我们在

addAttributes 中定义的默认颜色来切换高亮。

至此,我们的 coloredHighlight 扩展已经完成。它不仅能实现基本的文本高亮,还能自定义颜色,提供了一套完整的命令 API,并支持键盘快捷键。通过这个例子,我们进一步巩固了对 Tiptap 扩展核心概念的理解,并为进入更高级的主题做好了准备。

第四节:高级能力 - 让你的扩展活起来

我们已经掌握了如何定义扩展的“骨骼”(Schema)和“肌肉”(Commands)。现在,是时候为它们注入“神经系统”了。本章将探索 Tiptap 提供的高级 API,它们能让你的扩展具备动态行为、状态管理和智能自动化能力,从而极大地提升用户体验。

使用输入和粘贴规则实现自动化

addInputRulesaddPasteRules 是两个极为强大的 UX 增强工具。它们允许扩展监听用户的输入和粘贴行为,并根据预设的模式自动触发相应的操作,例如实现流行的 Markdown 快捷语法。

  • addInputRules:实时输入转换

    输入规则会在用户键入时实时匹配文本模式。我们将为第二章创建的 Callout 节点添加一个输入规则:当用户在新的一行输入 >> (大于号加空格) 时,自动将该段落转换为一个 Callout 块。

    // 在 Callout.js 的 Node.create({}) 内部添加
    import { nodeInputRule } from '@tiptap/core';
    
    //...
    addInputRules() {
      return [
        nodeInputRule({
          find: /^>>\s$/,
          type: this.type,
        }),
      ];
    },
    

    我们使用了 Tiptap 提供的 nodeInputRule 帮助函数。它接收一个配置对象,

    find 属性是一个正则表达式,用于匹配触发模式;type 属性则指定了匹配成功后要创建的节点类型,this.type 在这里就指向 Callout 节点本身。现在,用户无需点击任何按钮,只需输入简单的快捷符,就能创建 Callout,效率大增。

  • addPasteRules:智能粘贴处理

    粘贴规则与输入规则类似,但它作用于用户粘贴内容时。我们将为第三章的 coloredHighlight 标记添加一个粘贴规则:当用户粘贴形如 ==被高亮的文本== 的内容时,自动为其应用高亮标记。

    // 在 ColoredHighlight.js 的 Mark.create({}) 内部添加
    import { markPasteRule } from '@tiptap/core';
    
    //...
    addPasteRules() {
      return [
        markPasteRule({
          find: /==(.*?)==/g,
          type: this.type,
        }),
      ];
    },
    

    这里我们使用了 markPasteRule 帮助函数 22。

    find 正则表达式中的 g (global) 标志至关重要,它确保了如果粘贴的内容中有多处匹配,规则会对每一处都生效 22。这个小小的功能,使得从其他支持类似 Markdown 语法的应用(如 Obsidian, Notion)中复制内容到我们的编辑器时,格式能够被无缝保留。

使用 addStorage 管理内部状态

在开发复杂扩展时,我们经常需要存储一些数据。Tiptap 提供了两种状态存储机制:addAttributesaddStorage。理解它们的区别是设计高级扩展的关键。

这是一个关于状态二元性的核心概念:文档状态 vs. 运行时状态

  • addAttributes 用于存储 文档状态。这些数据是文档内容的一部分,需要被序列化(保存到 HTML 或 JSON),并在加载时恢复。例如,一个链接的 href 地址,或者我们 Callout 的 calloutType。这些数据必须是可序列化为 JSON 的简单值。

  • addStorage 用于存储 运行时状态。这些数据只存在于当前编辑器实例的生命周期中,不会被保存到文档内容里 17。它可以是任何类型的数据,比如一个函数的引用、一个复杂的对象、一个计时器 ID,或者用于分析的计数器。

让我们创建一个简单的扩展来演示 addStorage 的用法。这个扩展将统计编辑器内容被更新了多少次。

import { Extension } from '@tiptap/core';

// 为存储添加 TypeScript 类型,增强代码健壮性
declare module '@tiptap/core' {
  interface ExtensionStorage {
    updateCounter: {
      count: number,
    }
  }
}

export const UpdateCounter = Extension.create({
  name: 'updateCounter',

  addStorage() {
    return {
      count: 0, // 初始化存储
    };
  },

  onUpdate() {
    this.storage.count += 1; // 在每次更新时修改存储
    console.log('Editor updated', this.storage.count, 'times.');
  },
});

在这个例子中:

  1. 我们使用 addStorage 返回一个对象,作为这个扩展的初始状态 18。

  2. onUpdate 生命周期钩子中,我们通过 this.storage 访问并修改这个状态 18。

  3. 这个 count 值是临时的,刷新页面后就会重置。

我们也可以从扩展外部访问这个存储,只需通过 editor.storage.extensionName 18:

const count = editor.storage.updateCounter.count;

通过 declare module 为存储定义类型,可以让我们在访问 editor.storage.updateCounter 时获得完整的 TypeScript 类型支持,避免拼写错误和类型滥用 20。

扩展的生命周期与副作用

Tiptap 扩展拥有一套丰富的生命周期钩子(Lifecycle Hooks),允许我们在编辑器的关键时刻执行代码,处理副作用。

常用的钩子包括:

  • onCreate: 编辑器实例创建并准备就绪时触发。

  • onUpdate: 编辑器内容发生变化时触发。

  • onSelectionUpdate: 编辑器选区变化时触发。

  • onTransaction: 每一次状态变更(Transaction)发生时触发。这是最底层的变化监听。

  • onFocus / onBlur: 编辑器获得或失去焦点时触发。

  • onDestroy: 编辑器实例被销毁前触发,适合用于清理工作。

重要陷阱:生命周期钩子中的无限循环

一个常见的错误是在 onUpdate 或 onTransaction 钩子中直接调用 editor.commands 来修改编辑器状态。这会导致一个新的更新事件,从而再次触发钩子,形成一个无限循环,最终导致浏览器崩溃 8。

错误的做法

onUpdate({ editor }) {
  // 危险!这会造成无限循环!
  editor.commands.setNode('paragraph');
}

正确的做法:在事务(Transaction)层面思考

这些钩子通常会提供一个 transaction 对象(简写为 tr)。如果你确实需要在这些钩子中修改状态,你应该直接操作这个 tr 对象,而不是派发一个新的命令。ProseMirror 会将这些修改合并到当前的事务中,从而避免了循环。

onTransaction({ transaction }) {
  if (someCondition) {
    // 安全的做法:直接修改当前事务
    transaction.setNodeMarkup(...);
  }
}

虽然直接操作 tr 属于更高级的 ProseMirror API,但理解这个原则至关重要:生命周期钩子是用于“响应”变化的,而不是“创造”新的变化

为了帮助你快速查阅这些高级 API,下表总结了它们的核心用途:

方法 (Method) 目的 (Purpose) 用例 (Use Case Example)
addCommands 定义扩展的编程接口,供 UI 或其他逻辑调用。 toggleHighlight() 命令用于切换高亮。
addKeyboardShortcuts 绑定键盘快捷键到特定命令。 Mod-B 绑定到 toggleBold() 命令。
addInputRules 根据用户输入实时转换文本(Markdown 语法)。 输入 * 自动创建无序列表。
addPasteRules 根据粘贴的内容自动转换文本。 粘贴 (url) 自动创建链接。
addStorage 管理扩展的、非持久化的、运行时的内部状态。 存储一个 debounce 函数或用于分析的计数器。
addNodeView (高级) 使用框架组件(如 React/Vue)完全自定义节点的渲染和交互。 创建一个带可编辑标题和交互按钮的视频嵌入节点。
addProseMirrorPlugins (最高级) 注入底层的 ProseMirror 插件,以获得对编辑器行为的完全控制。 实现提及(Mention)功能的建议弹出框。

掌握了这些“超能力”,你就拥有了构建几乎任何复杂交互的工具。它们是 Tiptap 便捷 API 和底层 ProseMirror 强大功能之间的桥梁。在下一章,我们将把所有这些能力集于一身,挑战一个终极案例。

第五节:终极案例研究:构建一个交互式提及(Mention)扩展

现在,我们将踏上本次教程的顶峰。我们将综合运用前面所有章节学到的知识——Node 定义、属性、命令、Node ViewProseMirror 插件——来构建一个功能完整、高度交互、生产级别的 @mention(提及或标签)扩展。

这个案例之所以是“终极”,因为它完美地展示了构建复杂 Tiptap 扩展所需的三位一体架构:

  1. 数据模型 (Node):定义“提及”在文档中如何存储。

  2. 视图渲染 (Node View):定义“提及”在编辑器中如何显示为一个漂亮的、不可编辑的“胶囊”UI。

  3. 交互逻辑 (ProseMirror Plugin):定义当用户输入 @ 时,如何触发、显示和处理建议列表的弹出框。

步骤一:架构设计

在动手之前,我们先规划好架构。我们的 Mention 扩展将由以下几个部分组成:

  1. Mention.js: 这是扩展的主文件,它将:

    • 使用 Node.create 定义 mention 节点的数据结构。

    • 使用 addNodeView 将节点的渲染委托给一个 React (或 Vue) 组件。

    • 使用 addProseMirrorPlugins 注入一个自定义插件来处理建议弹出框的逻辑。

  2. MentionComponent.jsx: 一个 React 组件,负责渲染“提及胶囊”的 UI。

  3. suggestion.js: 一个辅助文件,包含创建和管理建议弹出框(我们将使用(atomiks.github.io/tippyjs/) 库)的 ProseMirror 插件逻辑。

步骤二:构建 Mention 节点(数据模型)

首先,我们来定义 mention 节点本身。它是一个行内(inline)节点,用于在文本流中表示一个提及。

// Mention.js
import { Node, mergeAttributes } from '@tiptap/core';

export const Mention = Node.create({
  name: 'mention',
  group: 'inline',
  inline: true,
  selectable: false,
  atom: true, // 关键!

  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: element => element.getAttribute('data-id'),
        renderHTML: attributes => {
          if (!attributes.id) {
            return {};
          }
          return { 'data-id': attributes.id };
        },
      },
      label: {
        default: null,
        parseHTML: element => element.getAttribute('data-label'),
        renderHTML: attributes => {
          if (!attributes.label) {
            return {};
          }
          return { 'data-label': attributes.label };
        },
      },
    };
  },

  parseHTML() {
    return [{ tag: 'span[data-type="mention"]' }];
  },

  renderHTML({ node, HTMLAttributes }) {
    return [
      'span',
      mergeAttributes({ 'data-type': 'mention' }, HTMLAttributes),
      `@${node.attrs.label}`
    ];
  },

  //... addNodeView 和 addProseMirrorPlugins 将在这里添加
});

这里的关键是 atom: true。这个属性告诉 ProseMirror,这个节点是一个不可分割的“原子”单元。用户不能将光标移动到它的内部,也不能编辑它的内容。ProseMirror 会将整个节点的管理权完全交给我们的 Node View 5。

addAttributes 定义了我们需要存储的数据:被提及用户的唯一 id 和显示的 labelrenderHTML 提供了一个简单的后备方案,用于在不支持 JavaScript 的环境中(如发送邮件)也能正确显示提及内容。

步骤三:使用 addNodeView 进行自定义渲染(视图渲染)

现在,我们要用一个交互式的 React 组件来取代 renderHTML 的静态渲染。这就是 addNodeView 的用武之地 5。

// 在 Mention.js 的 Node.create({}) 内部添加
import { ReactNodeViewRenderer } from '@tiptap/react';
import MentionComponent from './MentionComponent.jsx';

//...
addNodeView() {
  return ReactNodeViewRenderer(MentionComponent);
},

addNodeView 返回一个 ReactNodeViewRenderer(或 VueNodeViewRenderer),它将我们的 MentionComponent 组件与 mention 节点绑定起来 5。

现在,我们来创建 MentionComponent.jsx

// MentionComponent.jsx
import React from 'react';
import { NodeViewWrapper } from '@tiptap/react';

export default (props) => {
  return (
    <NodeViewWrapper as="span" className="mention">
      @{props.node.attrs.label}
    </NodeViewWrapper>
  );
};

这个组件非常简单。它使用了 Tiptap 提供的 NodeViewWrapper,它会渲染一个容器元素(我们指定为 <span>),并处理好所有 ProseMirror 需要的 DOM 属性和事件 5。组件通过

props.node.attrs 可以访问到我们在 addAttributes 中定义的 idlabel,从而渲染出我们想要的“胶囊”UI。你可以随意为 .mention 类添加 CSS 样式。

步骤四:使用 addProseMirrorPlugins 实现建议引擎(交互逻辑)

这是最核心、最复杂的部分。当用户输入 @ 时,我们需要一个弹出框来显示用户列表。Tiptap 的标准 API 无法直接实现这种复杂的、与 UI 紧密耦合的交互,因此我们必须深入底层,编写一个 ProseMirror 插件 8。

这是一个高度简化的实现思路,完整的代码会更长,但核心逻辑如下:

// suggestion.js (这是一个简化的逻辑概览)
import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import tippy from 'tippy.js';

export const suggestionPlugin = (options) => {
  return new Plugin({
    key: new PluginKey('mention_suggestion'),

    state: {
      init: () => ({ active: false, range: {}, query: '' }),
      apply: (tr, value) => {
        //... 在每次事务中,检查光标前的文本是否匹配触发符,如 /@(\w*)$/
        // 如果匹配,更新插件状态,记录 active=true, range 和 query
        // 如果不匹配,重置状态
        return newValue;
      },
    },

    view: (editorView) => {
      let popup;

      return {
        update: (view, prevState) => {
          const currentState = this.key.getState(view.state);
          const previousState = this.key.getState(prevState);

          // 如果状态从 inactive 变为 active,创建并显示 tippy 弹出框
          if (currentState.active &&!previousState.active) {
            // popup = tippy('body', {...配置... });
            // 在弹出框中渲染用户列表,列表数据可以根据 currentState.query 过滤
          }

          // 如果状态从 active 变为 inactive,销毁弹出框
          if (!currentState.active && previousState.active) {
            // popup.destroy();
          }
        },
        destroy: () => {
          // popup?.destroy();
        },
      };
    },
  });
};

这个插件的核心工作流程是:

  1. state.apply: 在每次编辑器状态更新时,检查光标前的文本。如果匹配 @ 触发符,就更新插件自己的内部状态,记录下触发的位置(range)和查询词(query)。

  2. view.update: 监听插件状态的变化。当状态变为“激活”时,它会创建一个 Tippy.js 弹出框,并根据查询词渲染建议列表。当状态变为“非激活”时,它会销毁弹出框。

  3. 命令交互: 在建议列表的 UI 中,当用户点击或回车选择一个用户时,UI 组件会调用一个 Tiptap 命令,例如 editor.commands.insertContent(...),用一个完整的 mention 节点替换掉触发文本(如 @john)。

最后,我们将这个插件集成到我们的 Mention.js 扩展中:

// 在 Mention.js 的 Node.create({}) 内部添加
import { suggestionPlugin } from './suggestion.js';

//...
addProseMirrorPlugins() {
  return [
    suggestionPlugin({
      editor: this.editor,
      //... 其他配置,如获取用户列表的函数
    }),
  ];
},

步骤五:最终集成

通过以上步骤,我们已经将数据模型(Node)、视图渲染(Node View)和交互逻辑(Plugin)这三个部分完美地结合在了一个单一的 Mention.js 扩展文件中。开发者在使用时,只需像注册任何其他扩展一样,将 Mention 添加到编辑器的 extensions 数组中,一个功能强大的提及系统就此诞生。

这个案例充分证明了 Tiptap 的分层设计思想。对于简单的需求,你可以使用高层 API;而对于像建议弹出框这样复杂的交互,Tiptap 也为你保留了通往底层 ProseMirror 的通道,让你拥有实现任何功能的终极自由。

💡 完整代码获取 Mention 扩展的完整实现代码较长,建议参考:

第六节:⚠️ 常见陷阱与调试技巧

在开发自定义扩展的过程中,你可能会遇到一些常见的问题。本节将帮助你快速识别和解决这些陷阱。

陷阱 1:生命周期钩子中的无限循环

问题现象: 浏览器卡死、内存占用飙升、控制台大量重复日志

错误示例

// ❌ 危险!会造成无限循环
onUpdate({ editor }) {
  editor.commands.setNode('paragraph');  // 这会触发新的 update
}

onTransaction({ transaction }) {
  this.editor.commands.insertContent('text');  // 同样会无限循环
}

正确做法

// ✅ 在事务层面思考,直接修改当前事务
onTransaction({ transaction }) {
  if (someCondition) {
    // 直接修改当前事务,不派发新命令
    transaction.setNodeMarkup(pos, type, attrs);
  }
}

// ✅ 或者添加条件检查避免重复触发
onUpdate({ editor }) {
  if (!editor.isActive('paragraph')) {
    editor.commands.setParagraph();
  }
}

⚠️ 核心原则:生命周期钩子用于"响应"变化,不是"创造"新变化

陷阱 2:属性序列化丢失

问题现象: 扩展的自定义属性在保存/加载后丢失

常见原因

// ❌ 只定义了 addAttributes,但没有配置 parseHTML 和 renderHTML
addAttributes() {
  return {
    customData: {
      default: null,
    },
  };
},

// 缺少这两个关键方法导致属性无法序列化
parseHTML() { ... }
renderHTML() { ... }

完整解决方案

// ✅ 完整的属性闭环:定义 → 渲染 → 解析
addAttributes() {
  return {
    customData: {
      default: null,
      // 2. 解析:从 HTML 读取
      parseHTML: element => element.getAttribute('data-custom'),
      // 3. 渲染:写入 HTML
      renderHTML: attributes => {
        if (!attributes.customData) return {};
        return { 'data-custom': attributes.customData };
      },
    },
  };
},

💡 记忆口诀:属性三步走 - 定义、渲染、解析,一个都不能少

陷阱 3:Schema 冲突导致的渲染错误

问题现象

  • 内容显示不正确
  • 某些节点无法创建
  • 控制台报 Schema 相关错误

常见原因

// ❌ content 表达式与实际内容不匹配
export const CustomBlock = Node.create({
  name: 'customBlock',
  content: 'paragraph+',  // 要求至少一个段落

  // 但 renderHTML 允许空内容
  renderHTML() {
    return ['div', { class: 'custom' }, 0];  // 0 允许任意内容
  },
})

解决方法

// ✅ 确保 content 定义与实际使用一致
export const CustomBlock = Node.create({
  name: 'customBlock',
  content: 'paragraph+',  // 明确要求段落

  // 创建时提供默认内容
  addCommands() {
    return {
      setCustomBlock: () => ({ commands }) => {
        return commands.insertContent({
          type: this.name,
          content: [
            { type: 'paragraph', content: [] }  // 提供默认段落
          ],
        });
      },
    };
  },
})

陷阱 4:命令执行顺序混乱

问题现象: 链式命令不按预期执行

错误示例

// ❌ focus() 应该在其他命令之前
editor.chain().toggleBold().focus().run();

正确做法

// ✅ focus() 放在链的开头
editor.chain().focus().toggleBold().run();

// ✅ 或者分步执行关键命令
editor.chain().focus().run();
editor.chain().toggleBold().run();

调试技巧与工具

1. 使用 ProseMirror DevTools

import { Editor } from '@tiptap/core';

const editor = new Editor({
  extensions: [/* ... */],
  // 开启开发者模式
  enableDebugMode: true,
})

// 在控制台访问编辑器状态
window.editor = editor;

// 查看当前文档结构
console.log(editor.state.doc.toJSON());

// 查看当前选区
console.log(editor.state.selection);

2. 监听关键事件

const DebugExtension = Extension.create({
  name: 'debugger',

  onCreate({ editor }) {
    console.log('📝 编辑器已创建', editor.getJSON());
  },

  onUpdate({ editor, transaction }) {
    console.log('🔄 内容更新', {
      docChanged: transaction.docChanged,
      steps: transaction.steps.length,
      content: editor.getJSON(),
    });
  },

  onSelectionUpdate({ editor }) {
    console.log('👆 选区变化', editor.state.selection.toJSON());
  },

  onTransaction({ transaction }) {
    if (transaction.steps.length > 0) {
      console.log('⚙️ 事务步骤', transaction.steps.map(s => s.toJSON()));
    }
  },
})

3. 验证扩展完整性检查清单

在发布自定义扩展前,使用此清单验证:

基础功能

  • 扩展名称唯一且语义化
  • Schema 定义完整(group、content、marks 等)
  • parseHTML 和 renderHTML 配对正确

属性管理

  • addAttributes 定义完整
  • 每个属性都有 parseHTML 和 renderHTML
  • 默认值设置合理

命令系统

  • 提供完整的命令集(set/toggle/unset)
  • TypeScript 声明完整
  • 命令可以正确执行和撤销

用户体验

  • 键盘快捷键不冲突
  • 输入规则不影响正常输入
  • 粘贴规则正确处理各种格式

性能与稳定性

  • 无内存泄漏(正确清理事件监听)
  • 无无限循环风险
  • 大文档下性能可接受

🔍 调试黄金法则:从简单到复杂,逐步排查。先验证 Schema,再检查属性,最后调试命令和交互。

本章核心成就

通过本章深入学习,你已经掌握了 Tiptap 扩展开发的完整技能树:

技能点 掌握程度 实际应用
扩展理论 ✅ 完成 理解 Node/Mark/Extension 本质区别与 ProseMirror 关系
自定义 Node ✅ 完成 创建 Callout 块级节点,掌握文档结构定制
自定义 Mark ✅ 完成 实现 ColoredHighlight 标记,掌握文本格式扩展
高级 API ✅ 完成 灵活运用命令、输入规则、Storage、生命周期钩子
复杂扩展 ✅ 完成 构建 Mention 交互系统,整合 NodeView 和 Plugin
问题排查 ✅ 完成 识别常见陷阱,掌握调试技巧和验证方法

🔑 核心概念速查表

扩展创建模板

// Node 创建模板
const CustomNode = Node.create({
  name: 'customNode',
  group: 'block',
  content: 'paragraph+',

  addAttributes() {
    return {
      attrName: {
        default: null,
        parseHTML: element => element.getAttribute('data-attr'),
        renderHTML: attributes => ({ 'data-attr': attributes.attrName }),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'div[data-type="custom"]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'custom' }), 0];
  },

  addCommands() {
    return {
      setCustomNode: (attributes) => ({ commands }) => {
        return commands.insertContent({ type: this.name, content: [{ type: 'paragraph' }] });
      },
    };
  },
})

// Mark 创建模板
const CustomMark = Mark.create({
  name: 'customMark',

  addOptions() {
    return {
      HTMLAttributes: {},
    };
  },

  addAttributes() {
    return {
      color: {
        default: null,
        parseHTML: element => element.style.color,
        renderHTML: attributes => ({ style: `color: ${attributes.color}` }),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'span[data-custom-mark]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
  },

  addCommands() {
    return {
      setCustomMark: (attributes) => ({ commands }) => {
        return commands.setMark(this.name, attributes);
      },
      toggleCustomMark: (attributes) => ({ commands }) => {
        return commands.toggleMark(this.name, attributes);
      },
      unsetCustomMark: () => ({ commands }) => {
        return commands.unsetMark(this.name);
      },
    };
  },
})

🚀 进阶学习路径

掌握了本章知识后,你可以探索以下高级主题:

  1. NodeView 深度定制

    • 使用 React/Vue 组件渲染复杂节点
    • 实现拖拽、调整大小等高级交互
    • 构建完全自定义的编辑体验
  2. Collaboration 协同编辑

    • 集成 Y.js 实现实时协作
    • 处理冲突解决和用户光标
    • 构建类似 Google Docs 的体验
  3. ProseMirror 插件系统

    • 深入理解 Plugin State
    • 自定义 DecorationSet 实现高亮
    • 构建复杂的编辑器交互逻辑
  4. 性能优化进阶

    • 虚拟滚动处理大文档
    • 懒加载和按需渲染策略
    • 节流和防抖优化编辑器响应

结论

我们已经走完了一段漫长而收获颇丰的旅程。从剖析 Tiptap 与 ProseMirror 的底层关系,到亲手构建自定义的 NodeMark;从掌握 addCommandsaddInputRules 等高级 API,到最终将所有知识融会贯-通,构建出一个复杂的、生产级别的 @mention 扩展。你现在所拥有的,已经不仅仅是使用 Tiptap 的能力,更是创造和扩展 Tiptap 的能力。

通过本教程的学习,我们揭示了几个关键的、超越代码本身的设计思想:

  • Tiptap 的渐进式披露:它允许开发者从简单的高层 API 入手,在需要时逐步深入到底层 ProseMirror,实现了易用性与强大功能之间的完美平衡。

  • Schema 为王:我们认识到,创建 NodeMark 的本质是设计文档的“语法”,这是一种比“添加功能”更深刻的思考方式。

  • 状态的二元性:我们区分了需要持久化的“文档状态”(attributes)和临时的“运行时状态”(storage),这是构建健壮扩展的架构基石。

  • 高级交互的三位一体:对于复杂的交互式节点,我们掌握了结合 atom 属性、addNodeViewaddProseMirrorPlugins 的核心架构模式。

掌握了这些知识,你就拥有了解锁 Tiptap 全部潜能的钥匙。你不再受限于 Tiptap 官方或社区提供的扩展,你的编辑器现在是一块真正的画布,而扩展就是你手中的画笔,可以随心所欲地描绘出你产品所需的用户体验 2。

下一步行动

  • 深入探索:官方文档永远是最好的老师。我们强烈建议你花时间深入阅读 Tiptap 和 ProseMirror 的官方文档,那里有更详尽的 API 参考和示例。

  • 动手实践:知识只有在实践中才能真正内化。尝试为你自己的项目构建一个独特的扩展,解决一个实际问题。

  • 拥抱社区:Tiptap 拥有一个活跃的社区。如果你想将自己的扩展分享给更多人,可以使用官方提供的 CLI 工具

    npm init tiptap-extension 来快速创建一个标准化的、可发布的扩展项目。

感谢你跟随本系列教程走到这里。希望这篇深度指南能够成为你在 Tiptap 定制化道路上的坚实基石和灵感源泉。祝你创造愉快!

揭秘高性能协同白板:轻松实现多人实时协作(一)

前言

目前成熟的白板工具已经很多了,想探索下内部的实现原理,为远程团队协作、在线教育、设计评审和头脑风暴场景设计,通过高效的 Konva 渲染引擎和 Yjs 协同算法,实现多人实时操作的无缝协同体验,同时保持高性能的图形处理能力,流畅的操作体验。

现存的白板工具仍有些小问题,例如:难以集成到项目中二开难度大开发者不友好等,本应用意在提供完整白板功能的基础上,解决上诉提到的难点,并创新添加一些新特性,用于丰富白板的数据展示能力。

demo.gif

数据结构设计

在应用开发之前,我们先设计一下应用的数据存储结构,一个好的数据结构,可以为我们省去很多麻烦。

  1. 为了实现图形节点附加文本的效果(双击添加文本),我们利用 konva Group 组的概念,将图形节点和文本节点,添加到组中,以实现整体的平移、缩放、变换等效果。
const group = new Konva.Group();
const shape = new Konva.Rect({...});
const text = new Konva.Text({...});
group.add(shape, text);
  1. 本例采用 Yjs 分布式协同特性,因此,整个应用 AppData 设计为 Y.Map,如下:
// 定义整个应用的数据,key 为 shapeID,value 为 ShapeItem
export type AppData = Map<ShapeItem>;

// 每一个 Konva 图形的属性
export interface ShapeItem {
id: string; // 图形id
type: string; // 图形类型 - 用于判断图形类型 'group' | 'rect'|  'circle' | 'ellipse' | 'image' | 'text' | ...
locked?: boolean; // 是否锁定
visible?: boolean; // 是否可见
children?: Array<string>; // 子节点ID数组(用于组合节点) - 用于判断图形是否为组合图形
isCombined?: boolean; // 标记是否被组合
attrs?: Record<string, unknown>; // 拓展属性
group: GroupConfig; // Step 1 render 时,先根据 group 创建一个 group
shape: ShapeConfig; // Step 2 render 时,根据 shape 创建一个 shape
text: TextConfig; // Step 3 render 时,根据 text 创建一个 text
}
  1. 抽象数据操作示例如下:
// 根节点
const appData = this.doc.getMap('appData');

// 添加一个图形
const rectConfig = ShapeController.createShape('rect', {...});
appData.set(rectConfig.id, rectConfig)

// 删除一个图形
appData.delete(rectConfig.id);

// 修改图形属性
appData.set(rectConfig.id, {...rectConfig, x: 100, y: 100 });

注意: 修改属性请使用 map.set() 而不是 shape.x = 100,这种模式不能引起 Y.Map 的数据变化回调。

协同控制中心

在协同应用场景,应当包含以下几个协同模块:

用户感知

用户感知是协同中的重要部分,可以通过感知获取其他用户的位置信息及实时操作状态

// 创建 awareness 实例
this.awareness = new Awareness(this.doc);

// 添加本地感知状态
const { userId, userName, userColor } = this.user.getUserInfo();
this.awareness.setLocalState({ userId, userName, userColor });
// 监听感知状态
encodeAwarenessUpdate(
this.awareness,
Array.from(this.awareness.getStates().keys())
);
this.awareness.on('update', this.handleAwarenessUpdate.bind(this));

除了用户信息,还可以将用户光标位置等信息,一同发送给其他客户端,因此需要提供更新光标的方法:

/**
 * @description 更新感知状态
 * @param { [key: string]: any  } 更新的数据
 * @returns
 */
public updateAwareness(data: Record<string, unknown> = {}) {
this.awareness.setLocalState({
...this.awareness.getLocalState(),
...data,
});
}

后续的具体光标实现,到绘制图层时,在详细说明哈

提供程序

提供程序是协同的重点,不同的提供程序对协同设计有着不同的效果,选择合适的提供程序,也是协同时效性、稳定性的考验

// websocket 方式实现协同 - 提供程序默认支持传递 awareness
this.provider = new WebsocketProvider(url, roomname, this.doc, {
awareness,
params,
});

撤销管理

协同应用,较难的就是分布式撤销,Yjs内置的 Y.UndoManager 支持分布式撤销,这为我们协同设计提供了便利

this.undoManager = new UndoManager(doc, {
captureTimeout: 500,
 // 添加用户源(特别注意这里,需要与 transaction origin保持一致!)
trackedOrigins: new Set([origin]),
});

Y.UndoManager 直接关联的就是 history 历史记录,具体如下:

constructor(collaborateManager: CollaborateManager) {
this.undoManager = collaborateManager.getUndoManager();
}

public undo() {
// 如果不可撤销
if (!this.undoManager.canUndo()) return console.warn('⚠️ 不可撤销!');
this.undoManager.undo();
}

public redo() {
// 如果不可重做
if (!this.undoManager.canRedo()) return console.warn('⚠️ 不可重做!');
this.undoManager.redo();
}

用户系统

用户系统指的是当前协同的用户信息,包括用户ID 、userName、以及用户协同颜色,同时,在撤销管理和协同事件源中,都有一个origin,是与当前用户相关联的信息,也将其纳入用户信息管理中

this.userId = userId;
this.userName = userName;
this.userColor = userColor;

const originId = generateKey();
this.origin = { originId, userId: this.userId };

协同入口

入口文件提供必要的方法,其中最重要的,就是 transaction 事务执行方法,将对数据的操作,封装到事务中,便于 UndoManager 记录

public transaction = (fn: () => void) => {
// 使用与 UndoManager 中一致的 origin 对象
const origin = this.user.getOrigin();
this.doc.transact<void>(fn, origin);
};

绘制实现

既然是白板应用,那相关的绘制实现肯定是最重要,也是最难的,下面将这部分详细讲解下。Konva 是以树形结构组织的结构,stage 就是根节点,所有的图层都需要添加到 stage 上

// 初始化 konva
this.stage = this.initStage();

为了实现精细化管理,针对图层、图形、数据部分进行抽象及封装,架构如下: 在这里插入图片描述

  1. 协同层主要维护应用数据,监听数据变化,驱动视图更新,同时处理 UndoManager,实现撤销管理;
  2. 用户的页面操作,会映射为图形的具体操作,例如添加图形、更新图形、删除图形,用户并非真实在操作图形,而是通过操作代理在操作数据,引发 appData 的变化;
  3. 数据变化后,会调用 render 进行视图更新,在这个方法内,就需要真实的操作 Konva 类,将数据转换为页面上的图形。

图层管理

为了后续对图层的操作更便捷,应该将图层的相关方法抽离为独立的模块:

  • backgroundLayer:背景图层,实现背景颜色、网格线绘制;
  • shapeLayer:形状图层,实现元素的绘制、缩放、平移等操作区;
  • toolbarLayer: 工具图层,用于绘制协同光标、选区、可视区、其他辅助信息。 请添加图片描述

这样,后续的所有图层操作,都可以精细到具体的图层,例如,修改背景颜色、绘制用户光标、绘制对齐线等

图形管理

本例采用的是数据驱动的形式实现的图形绘制,因此,需要劫持用户对图形的操作,将其转换为数据处理,如下:

  • 原模式:直接操作konva进行绘制
const rect = new Konva.Rect({...})
layer.add(rect)
  • 数据驱动模式:对数据进行维护,使得数据驱动视图更新
const appData = this.doc.getMap()
appData.observeDeep(this.draw.render)

// 这里会返回 具体的配置项 而不是具体的图形
const rectConfig = ShapeController.createShape('rect') 

// 将返回的配置项添加到 Y.Map 中
appData.set(rectConfig.id, rectConfig)

// 数据变化后,会引起 render 绘制
function render(){
// 根据 appData 真实渲染 konva 图形
}

这种模式有一个好处,只需要关心数据的变化,在 render 中进行统一进行konva图形绘制处理即可。同时,还能劫持用户对图形的操作,实现更多拓展功能。

数据驱动

数据驱动是本例重要实现,需要根据 Y.Map 的数据,渲染出当前画布结构:

// 监听数据更新,驱动视图渲染
private appDataUpdateHandler(event: YMapEvent<ShapeItem>) {
// 这个 key 是当前发生变化的 shapeItem.id
event.changes.keys.forEach((change, key) =>
this.draw.render(change, key)
);
}

public render(change: YMapChange, id: string) {
console.log('✨️ patch render');

// 1. 获取当前图形 id 在数据中的配置
const appData = this.collaborateManager.gettAppData();
const shapeConfig = appData.get(id);

// 2. 获取当前图层画布
const shapeLayer = this.layerController.getShapeLayer();

// 3. 通过 change 识别当前的操作类型 add | update | delete
if (change.action === 'add' && shapeConfig) {
shapeLayer.addShape(shapeConfig);
} else if (change.action === 'update' && shapeConfig) {
shapeLayer.updateShape(shapeConfig);
} else if (change.action === 'delete') {
shapeLayer.deleteShape(id);
}
// 4. 重新渲染图层
this.stage.batchDraw();
}

在这里插入图片描述

同时,在 render 函数中,还可以做一些绘制优化,例如:实现增量更新(脏矩形渲染)、视口剔除(视口裁剪)等,可以在一定程度上减少大画布场景下的渲染压力

事件委托

本例采用数据驱动更新,因此图形可能随时在渲染更新,如果将事件绑定到具体的图形上,那么,我们就需要处理事件解绑及绑定的时机,处理起来比较麻烦。因此,本例将事件统一绑定到 stage 上,使用 stage 进行统一的事件处理。

const stage = this.draw.getStage();
stage.on('click', (e) => console.log('stage click', e));

在这里插入图片描述

在事件源中,currentTarget 始终指向事件源绑定的对象,也就是当前的 stage,而 target 则指向当前触发事件的对象,也就是当前点击click,是点击到谁上面,evt 是原生事件源,type 指向当前事件的类型,在多事件中,需要做区分使用。

图形操作

插入形状

插入图形实现原理就是在Y.Map 中插入一条数据,驱动试图更新,流程如下:

// 创建矩形 - 这里得到的是关于这个矩形的所有可执行配置
const rectConfig = shapeCntroller.createShape('rect', {
x: 100,
y: 100,
width: 100,
height: 100,
fill: 'red',
stroke: 'black',
strokeWidth: 2,
});

const appData = this.collaborateManager.gettAppData();

// 通过事务执行,将图形添加到 appData 中
this.collaborateManager.transaction(() => {
appData.set(config.id, JSON.parse(JSON.stringify(config)));
});

上面执行后,就会引起视图更新,通过视图去真实创建 Konva.Rect:

// 这里解析 group shape text 属性
const { groupConfig, shapeConfig } = config;

// 创建真实 group
const groupNode = new Group({ ...groupConfig, draggable: true });

// 创建真实 shape 后续需要工厂模式实现图形创建
const shape = new Rect({ ...shapeConfig, draggable: false });

groupNode.add(shape);

this.layer.add(groupNode);

更新属性

更新图形属性,无非就是将更新后的 config 重新setMap 上,引发更新:

// 不然获取当前图形的属性,拼接为 ShapeItem 进行更新
const groupConfig = target.getAttrs();
const id = target.id();
const type = groupConfig.type;

// 获取shapeConfig
const shapeConfig = target.children[0].getAttrs();

// 获取textConfig
const textConfig = target.children[1]?.getAttrs();

// 触发数据更新
const config = {id, type, groupConfig, shapeConfig, textConfig};

// 触发更新
const appData = this.collaborateManager.gettAppData();
this.collaborateManager.transaction(() => {
appData.set(config.id, JSON.parse(JSON.stringify(config)));
});

试图更新方法:

// 这里有几个属性需要更新,group shape text
const { groupConfig, shapeConfig, id } = config;
const groupNode = this.findOne<Group>(`#${id}`);
if (!groupNode) return;

groupNode.setAttrs(groupConfig);
const shape = groupNode.children[0];
shape.setAttrs(shapeConfig);

删除图形

删除图形,则直接调用 Map.delete 方法即可

// 不然执行删除
this.collaborateManager.transaction(() => {
appData.delete(id);
});

试图更新方法:

this.findOne<Group>(`#${id}`)?.destroy();

在这里插入图片描述

大家好好理解一下架构,就可以理解上面的代码了哈 在这里插入图片描述

实现动态绘制

我们需要在页面上实现动态绘制,这里有几个注意事项:

  1. 不能在 mouse-xxx 事件执行过程中进行数据操作;
  2. 需要中继画布实现暂时绘制

什么意思呢?我们知道,每对一次数据操作,都会引发 Y.Map 的更新,同时,也会向 UndoManager 历史记录里插入操作记录,如果我们在 mousemove 过程中,频繁添加操作记录,那么撤销时,就会导致异常。因此,我们需要通过 最后的 mouseup 插入一条数据即可。

mousedown 主要是记录初始位置,并将必要的参数赋给 stage

// 记录下当前鼠标的位置
const { x, y } = draw.getStage().getPointerPosition()!;

const menuType = useStore().getState('activeMenu');

// 向 stage 添加属性
const stage = draw.getStage();
stage.setAttrs({ startX: x, startY: y, menuType, mousedownFlag: true, });

mousemove 处理移动中的位置以及实际的绘制参数

// 获取当前鼠标的位置
const { x, y } = stage.getPointerPosition()!;

// 向 toolbarLayer 添加属性,实现暂时绘制,最终的判断在 mouseup 事件中实现
const layerController = draw.getLayerController();
const toolbarLayer = layerController.getToolbarLayer();

// 设置绘制参数
const { menuType, startX = 0, startY = 0 } = stage.getAttrs();

toolbarLayer.setDrawParams({
menuType,
startX,
startY,
endX: x,
endY: y,
});

toolbarLayer 通过工具类图层绘制展现效果,此时不会执行数据操作,也就不会添加记录

this.layer = new Layer({ id: 'toolbarLayer', listening: false });

// 通过矩形绘制实现
const rect = new Rect({
id: 'toolbarRect',
sceneFunc: this.handleSceneFunc.bind(this),
});

// handleSceneFunc 函数内,就是原生 canvas 操作了

mouseup 中,就是根据实际的绘制参数,进行操作代理:

// 不然根据 menuType 调用操作代理,获取真实的图形配置,添加到 Y.Map 上
const operationProxy = draw.getOperationProxy();

let shapeConfig: ShapeItem | null = null;

switch (menuType) {
case 'rect':
shapeConfig = operationProxy.createShape('rect', {
x: startX,
y: startY,
width,
height,
});
break;
}

if (shapeConfig) operationProxy.addShape(shapeConfig);

这样,就能实现在 mouseup 事件中,执行一次数据操作,只会生成一次历史记录。 在这里插入图片描述

总结

本文详细介绍了基于 Konva 和 Yjs 的协同白板应用的整体架构设计与核心实现。通过数据驱动的方式,将用户操作抽象为对 Y.Map 的数据操作,实现了多人实时协同的图形编辑功能。关键设计包括:

  • 分层架构:将协同层、操作代理层和渲染层分离,确保数据流清晰可控

  • 数据驱动渲染:通过监听 Y.Map 变化实现视图自动更新

  • 事件委托机制:在 Stage 级别统一处理事件,简化事件管理

  • 动态绘制优化:通过中继画布避免频繁数据操作,保证撤销重做的正确性

  • 完整的协同生态:集成用户感知、撤销管理、实时同步等协同核心功能

这一架构为后续功能扩展奠定了坚实基础,既保证了多人协同的实时性,又提供了良好的开发体验。

在下一篇文章中,我们将深入探讨白板的高级交互功能实现:

Konva 图形控制

  • 选中与变换:实现图形的单选、多选、旋转、缩放控制点

  • 对齐吸附:智能对齐线和网格吸附系统

  • 层级管理:前置、后置、置顶、置底等层级操作

分组与组合

  • 图形分组:多选图形创建分组,支持嵌套分组

  • 组合解组:临时组合与永久组合的实现策略

  • 组内编辑:在组内直接编辑单个图形的能力

高级视觉效果

  • 滤镜系统:模糊、阴影、颜色调整等实时滤镜

  • 渐变填充:线性渐变、径向渐变的动态配置

  • 纹理图案:自定义图案填充和背景纹理

数据可视化增强

  • 图表集成:集成 VCharts 等图表库实现数据图表

  • 手绘风格:模拟手绘效果的笔刷和图形渲染

  • Latex 公式: 支持动态公式编辑

性能优化

  • 视口裁剪:大画布下的渲染性能优化

  • 增量更新:脏矩形渲染减少重绘区域

  • 内存管理:图形缓存和垃圾回收策略

通过这些功能的实现,白板将从一个简单的绘图工具升级为功能丰富的协同创作平台,满足教育、设计、会议等多样化场景的需求。

期待与您在下一篇文章中继续探索白板开发的精彩世界! 🎨✨

欢迎感兴趣的小伙伴,一起加入白板的开发,目前正在稳步推进,欢迎加入哦~

下一代组件的奥义在此!headless 组件构建思想探索!

前言

这里并不是想引起所谓 ant-designelement plus 这种样式,dom结构,javascript在一起的传统组件库更好,还是国外 github start 都要接近 100k 的组件库 shadcn/ui (谷歌的 mui 已经在做 headless 版本了,你可以简单理解 headless 就是无样式组件的意思)更好!

而是提供一个更宽广的视角,都说 headless 组件库拓展性很好,可维护性很好,到底为什么这么说?(当然缺点也很明显,技术一般的人 hold 不住在拓展 headless 组件中复杂功能的实现)。

还有,国内没有特别有含金量的实战文章,比如 从 0 到 1 实现一个比较典型能说明 headless 组件优点的文章,又因为我自己在做 headless 组件库( github地址官网github宣传地址), 也看了一些这方面的文章,受益颇深, 希望能跟大家交流 headless 的思想。

对你的帮助

这篇文章读完,能确保你了解到:

  • 具备自己的 headless 组件的思路,无论是写组件库,还是实现业务组件的抽象,提供思路
  • 想了解 shadcn/ui, reach ui, radix-ui 等等这些可组合的 headless 组件内部是如何构建的

Let's get started!

什么是好的组件

在你工作中,假设你的负责人让你开发一个 手风琴 组件,如下,点击 Accordion 1 或者 2, 3的标题,其折叠在内的文字会展开。你可能会这样做。’

accordin.jpg

首先用法如下,传入 accordionData

const accordionData = [
    { id: 1, headingText: 'Heading 1', panel: 'Panel1 Content' },
    { id: 2, headingText: 'Heading 2', panel: 'Panel2 Content' },
    { id: 3, headingText: 'Heading 3', panel: 'Panel3 Content' },
]

const SomeComponent = () => {
    const [activeIndex, setActiveIndex] = useState(0)

    return (
        <div>
            <Accordion
                data={accordionData}
                activeIndex={activeIndex}
                onChange={setActiveIndex}
            />
        </div>
    )
}

然后,组件内部通过你传入的 data,使用 map 函数渲染,接着给组件的 button(用来装标题的),绑定一个 onClick 事件,当点击的时候,就看看就会 onChange 当前点击的 activeIndex 是否就是自己的 index,从而在展开内容的 <div hidden={activeIndex !== idx}>{item.panel}</div> 部分判断,是否展开自己的文字部分。


function Accordion({ data, activeIndex, onChange }) {
    return (
        <div>
            {data.map((item, idx) => (
                <div key={item.id}>
                    <button onClick={() => onChange(idx)}>
                        {item.headingText}
                    </button>
                    <div hidden={activeIndex !== idx}>{item.panel}</div>
                </div>
            ))}
        </div>
    )
}

这种封装在我们日常业务中司空见惯,但假设需求发生了变化,现在您需要在手风琴按钮和标题中添加对图标的支持,并能添加一些样式。你一般就会这样做:

- function Accordion({ data, activeIndex, onChange }) {
+ function Accordion({ data, activeIndex, onChange, displaySomething }) {
  return (
    <div>
      {data.map((item, idx) => (
        <div key={item.id}>
          <button onClick={() => onChange(idx)}>
            {item.headingText}
+            {item.icon? (
+              <span className='someClassName'>{item.icon}</span>
+            ) : null}
          </button>
-          <div hidden={activeIndex !== idx}>{item.panel}</div>  
+          <div hidden={activeIndex !== idx}>
+            {item.panel}
+            {displaySomething}
+          </div>
        </div>
      ))}
    </div>
  )
}

对于每一个不断变化的需求,你都需要重构你的组件,以满足业务的需求。这似乎很正常,需求变了难到组件不变吗?你仔细想想,似乎也不太对,就跟组件库一样,难道需求变了,组件库内部也需要不断变吗?这些组件库都是第三方的,很难要求他们根据你的需求去变化?

所以怎么可能有万能的组件库,dom 结构跟得上千变万化的业务呢?这也说明了传统 ant-design, element-plus 组件库的局限。

所以我们想想有什么办法能解决?

现在目光转移到一个简单的问题上来,想想 HTML 中的 <select><option> 元素,分开来说,这两个元素能做的很有限,可当他们组合起来的时候,通过共享一些状态,可以组合成下拉框组件,这就复合组件的概念,复杂组件组装而来,而不是一味的隐藏黑盒和过度封装。

复合组件

根据上面复合组件的思想,我们改装之前的 手风琴 组件:

<Accordion>
    <AccordionItem>
        <AccordionButton>Heading 1</AccordionButton>
        <AccordionPanel>
            Panel 1
        </AccordionPanel>
    </AccordionItem>
    <AccordionItem>
        <AccordionButton>Heading 2</AccordionButton>
        <AccordionPanel>
           Panel 2
        </AccordionPanel>
    </AccordionItem>
    <AccordionItem>
        <AccordionButton>Heading 3</AccordionButton>
        <AccordionPanel>
           Panel 3
        </AccordionPanel>
    </AccordionItem>
</Accordion>

这里,我们把整个手风琴组件拆分为 <Accordion><AccordionItem><AccordionButton>, <AccordionPanel> 四部分。

这里有同学会说了,你这例如 <AccordionButton> 都把 html 标签设置死了,拓展性也不强呀。(其实这个感觉还好,因为 css 自定义的情况下,完全可以自己改展示元素的样式,就已经脱离了标签本身样式的限制了),但为了做的更好,我们设置用户可以自定义标签类型。

例如 <AccordionButton> 元素我们这样封装:

const AccordionButton = forwardRef(function (
  { children, as: Comp = "button", ...props }: AccordionButtonProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-button="">
      {children}
    </Comp>
  );
});

可以看到,上面有 as 属性,可以自定义标签类型。

其它组件也是类似:

const Accordion = forwardRef(function (
  { children, as: Comp = "div", ...props }: AccordionProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion="">
      {children}
    </Comp>
  );
});

const AccordionItem = forwardRef(function (
  { children, as: Comp = "div", ...props }: AccordionItemProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-item="">
      {children}
    </Comp>
  );
});

const AccordionPanel = forwardRef(function (
  { children, as: Comp = "div", ...props }: AccordionPanelProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-panel="">
      {children}
    </Comp>
  );
});

需要说明如下:

  • Accordion 组件是由四部分组成
  • 每个部分都包装在 forwardRef 中,所有外界都可以获取到对应的 dom 元素的实例。
  • as 属性可以自定义渲染元素的是什么
  • data-* 属性用于在测试这个组件的时候,咱们设置一个独一无二的属性选择器,好让测试框架选中

组合起来,我们可以这样使用手风琴组件:

  <Accordion>
    <Accordion.Item>
      <Accordion.Button>Button 1</Accordion.Button>
      <Accordion.Panel>Panel 1</Accordion.Panel>
    </Accordion.Item>
    <Accordion.Item>
      <Accordion.Button>Button 2</Accordion.Button>
      <Accordion.Panel>Panel 2</Accordion.Panel>
    </Accordion.Item>
    <Accordion.Item>
      <Accordion.Button>Button 3</Accordion.Button>
      <Accordion.Panel>Panel 3</Accordion.Panel>
    </Accordion.Item>
  </Accordion>

到此,大家仔细看看,之前我们有个增加 Icon 的功能,其实我们只需要在<Accordion.Button> 中增加 Icon 组件就可以了。

  <Accordion.Button><自定义 Icon 组件 /> Button 1</Accordion.Button>

是不是很简单就拓展了 dom 原来的 Accordion 组件也完全不用改,这属于用户自定义行为。

当然这里有个坑我们后面解决,就是,每个 <Accordion.Item> 可能都需要一个 indexdisabled 参数,index 是告诉我这个面板的索引是多少,disabled是告诉这个面板是否是禁用状态。

而这两个参数,可能我们自定义的 Icon 组件需要,比如在 disabeld 状态下,样式会变化。所以这个悬念后面我们解决。

然后因为手风琴组件,大家要共享选中和关闭状态,例如有哪些面板被选中,这里我们使用 Context API 来实现。

const AccordionContext = createContext({});
const AccordionItemContext = createContext({});

const useAccordionContext = () => {
  const context = useContext(AccordionContext);
  if (!context) {
    throw Error("useAccordionContext must be used within Accordion.");
  }
  return context;
};
.......
const Accordion = forwardRef(function (
  .....  
  return (
    <AccordionContext.Provider value={{}}>
     .....
    </AccordionContext.Provider>
  );
});
  • 我们创建了一个上下文共享状态的 context: AccordionContext 以及对应的的 useContext 钩子:`useAccordionContext``

  • AccordionContext 用来共享全局的状态,例如关闭和打开哪个 <Accordion.Item>(面板) 组件的索引。

小总结

这个 AccordionContext 好处是,如果组件库导出了这个 context,那么你可以在这个里面自己添加任何组件,通过调用useAccordionContext, 就能共享手风琴里的所有共享信息,所以此时这个组件库已经超越了传统组件库不能定制样式和不能定制 dom 结构的问题了。

其实还需要一个 conext,例如 <Accordion.Item>(面板)可以单独传入 disabled 参数,表示是否禁用当前面板,所以在这个<Accordion.Item>下,我们如果自定义的组件,也需要共享到这个 disabled 状态,所以单独导出一个 <Accordion.Item> 共享的 context

在下面的案例里也会有这个 useAccordionItemContext,大家大概明白是什么意思就行。这篇文章主要是讲解 headless 组件构建思路。

到这里其实就解决之前的疑问,如何让面板共享状态给我们自定义的 Icon 组件,思想使用 Context API.

其实还有一种方式,就是使用 React.cloneElement 语法,这种方式也有其用武之地,但在这里明显不如 Context API 灵活,为啥呢,因为我们自定义 Icon,我们可以用 Context API 获取到共享状态,而 React.cloneElement 只能给组件中已知的,例如 <Accordion.Button> 传递状态。比如这样

const AccordionItem = forwardRef(function (
  { children, as: Comp = "div", disabled, ...props }: AccordionItemProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-item="">
     ```
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, { 
            disabled
          });
        }
        return child;
      })}
    </Comp>
  );
});

缺点之前也说过了,这里不赘述,大家可以看做另一种方案。

我们接下来给组件增加一些功能。

继续深入:无状态组件和有状态组件

无状态组件典型的就是 <input> 元素,你输入任何值,它自己单独记录,你不用管。有状态组件就是你要单独自己设置 state 去管理,例如:

<input value={someValue} />

说白了,无状态组件,你不需要单独传参数去控制它最终显示的值,有状态组件就需要。

同时需要记住,一个组件要么是有状态组件,要么是无状态的,只能二选一。如果都有的话,那么就是有状态组件,无状态会被无视。

最后一个无状态组件的关键点是可以传入 defaultValue

<input defaultValue='John Doe' /> 

我们为了丰富之前手风琴组件的效果,支持传入如下参数:

  • index: 可选,类型是 number 或者 number 数组,代表当手风琴面板的索引, 应该跟 onChange 配合使用。

  • onChange: 可选,类型是函数,(index: number) => void,用法是当手风琴里的子元素打开或者关闭时触发此事件。

  • collapsible:可选,类型是 boolean,默认 false. 它决定了是否允许用户关闭所有面板,有些产品要求至少有一个面板是展开的,所以增加了这个参数。此参数仅对非受控组件(即没有 index 和 onChange 属性的组件)有效。在受控组件中,面板的打开与关闭状态完全由父组件通过 index 属性控制。

  • defaultIndex,可选,类型是 number 或者 number 数组,代表打开面板的索引默认值,如果 collapsible 设置为 true,没有设置 defaultIndex,那么所有面板初始化都是关闭的。否则,默认第一个面板打开。

  • multiple,可选,类型是 boolean,默认 false,在非受控组件的情况下,是否允许同时打开多个面板

  • readOnly,可选,类型是 boolean,默认 false,手风琴组件是否是可读状态,也就意味着用户是否可以切换面板状态。

改造组件如下:

const Accordion = forwardRef(function (
  {
    children,
    as: Comp = "div",
    defaultIndex,
    index: controlledIndex,
    onChange,
    multiple = false,
    readOnly = false,
    collapsible = false,
    ...props
  }: AccordionProps,
  forwardedRef
) {
....

const AccordionItem = forwardRef(function (
  {
    children,
    as: Comp = "div",
    disabled = false,
    ...props
  }: AccordionItemProps,
  forwardedRef
) {
....

增加无状态组件功能

涉及无状态组件功能参数包括:defaultIndexmultiplecollapsible, 但是不包括 indexonChange

然后我们也会给 AccordionItem 一个 index 参数,表示每个面板的索引

  <Accordion defaultIndex={[0, 1]} multiple collapsible>
    <Accordion.Item index={0}>   // <= index
      ....
    </Accordion.Item>
    <Accordion.Item index={1}>  // <= index
      ....
    </Accordion.Item>
      .... 
  </Accordion>

然后我们继续丰富组件内容,以下不是完整代码,主要是帮助大家快速理解 headless 组件构建思路。

const Accordion = (...) => {
  const [openPanels, setOpenPanels] = useState(() => {
    // 根据 multiple, collapsible 参数设置初始化展开哪些面板
  });

  const onAccordionItemClick = (index) => {
    setOpenPanels(prevOpenPanels => { // 更新面板展开或者关闭逻辑 })
  }

  const context = {
    openPanels,
    onAccordionItemClick
  };

  return (
    <AccordionContext.Provider value={context}>
     ....
    </AccordionContext.Provider>
  );
};

const AccordionItem = ({ index, ...props }) => {
  const { openPanels } = useAccordionContext();

  const state = openPanels.includes(index) ? 'open' : 'closed'

  const context = {
    index,
    state,
  };

  return (
    <AccordionItemContext.Provider value={context}>
        ....
    </AccordionItemContext.Provider>
  );
};

const AccordionButton = () => {
  const { onAccordionItemClick } = useAccordionContext();
  const { index } = useAccordionItemContext();

  const handleTriggerClick = () => {
    onAccordionItemClick(index);
  };

  return (
    <Comp
      ....
      onClick={handleTriggerClick}
    >
      {children}
    </Comp>
  );
};

const AccordionPanel = (...) => {
  const { state } = useAccordionItemContext();

  return (
    <Comp
      ....
      hidden={state === 'closed' }
    >
      {children}
    </Comp>
  );
});

增加有状态功能

有状态组件是很简单的,因为是用户自己传入参数来控制。我们增加 indexonChange 参数,从而让用户能更新内部状态。

const Accordion = forwardRef(function ({
+  index: controlledIndex,
+  onChange,
....
  const onAccordionItemClick = useCallback(
    (index: number) => {
+     onChange && onChange(index);

      setOpenPanels((prevOpenPanels) => {
       ...
  );

  const context = {
+    openPanels: controlledIndex ? controlledIndex : openPanels,
    .....
  };

如上,,受控状态 controlledIndex 按预期覆盖了 openPanels 中的非受控状态。(openPanels 是前面我们在Accordion组件内定义记录当前打开的是哪些面板的 state)。

关于 onChange,它并不决定我们的组件是受控还是非受控。无论是否传递了受控的 index 属性,都可以传递 onChangeonChange 属性的目的是向父组件通知状态变更。

其实到这里差不多就结束了,其实很多组件库,把无状态和有状态都合并为了一个 hooks 这样可以解决用条件去判断(如我们上面)的繁琐。

这个函数我的 t-ui组件库也有借鉴(求一个 start, 致力于打造最好的组件库教程网站 ,github地址)源码如下:

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';

export function useMergeValue<T>(
  defaultStateValue: T,
  props?: {
    defaultValue?: T;
    value?: T;
  },
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
  const { defaultValue, value } = props || {};
  const firstRenderRef = useRef(true);
  const prevPropsValue = usePrevious(props?.value);

  const [stateValue, setStateValue] = useState<T>(
    !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
  );

  // 受控转为非受控的时候,需要做转换处理
  useEffect(() => {
    if (firstRenderRef.current) {
      firstRenderRef.current = false;
      return;
    }
    if (value === undefined && prevPropsValue !== value) {
      setStateValue(value);
    }
  }, [value]);

  const mergedValue = isUndefined(value) ? stateValue : value;

  return [mergedValue, setStateValue, stateValue];
}

这里简单解释以下,其实就是你传了 value 我就认为你是受控组件,然后 value 就透传出去,我这个 hooks 不管,如果有 defaultValue 或者组件库想默认给个默认值, 我会用其初始化 stateValue 然后传出去。并且 setStateValue 方法能改变其值。

setStateValue 其实在传入 value 的情况下,也没什么用,因为改变不了 value 的值。

前端图形引擎架构设计:可扩展双渲染引擎架构设计-支持自定义渲染器

ECS渲染引擎架构文档

写在前面

之前写过一篇ECS文章,为什么还要再写一个,本质上因为之前的文档,截止到目前来说,变化巨大,底层已经改了很多很多,所以有必要把一些内容拎出来单独去说。

由于字体文件较大,加载时间会比较久😞,可以把项目clone下来本地跑会比较快。

另外如果有性能问题,我会及时修复,引擎改造时间太仓促,只要不是内存泄漏,暂时没去处理。

还有很多东西要做。

体验地址:baiyuze.github.io/design/#/ca…

image.png

image.png

image.png

项目概览

Duck-Core 是一个基于 ECS(Entity-Component-System)架构构建的高性能 Canvas 渲染引擎,专为复杂图形编辑场景设计。引擎的核心特色在于双渲染后端架构插件化系统设计极致的渲染性能优化

核心技术栈

  • CanvasKit-WASM - Google Skia 图形库的 WebAssembly 移植版
  • Canvas2D API - 浏览器原生渲染接口

架构核心亮点

ECS 架构模式 - 数据驱动的实体组件系统,实现逻辑与数据完全解耦

双引擎架构 - Canvas2D 与 CanvasKit 双渲染后端,运行时无缝切换

插件化设计 - 开放式扩展点,支持自定义渲染器、系统和组件

极致性能 - 颜色编码拾取、离屏渲染、渲染节流等多重优化


整体架构设计

整个引擎采用分层架构,从底层的渲染抽象到顶层的用户交互,每一层职责清晰且可独立替换。

graph TB
    subgraph "应用层"
        A[React 组件] --> B[Canvas 画布组件]
    end
    
    subgraph "引擎核心层"
        B --> C[Engine 引擎实例]
        C --> D[Core 状态管理器]
        C --> E[Camera 相机控制]
        C --> F[Entity Manager 实体管理]
    end
    
    subgraph "系统层 - System"
        C --> G[EventSystem 事件系统]
        G --> H[InputSystem 输入系统]
        G --> I[RenderSystem 渲染系统]
        G --> J[PickingSystem 拾取系统]
        G --> K[DragSystem 拖拽系统]
        G --> L[SelectionSystem 选择系统]
        G --> M[ZoomSystem 缩放系统]
        G --> M[FpsSystem FPS]
    end
    
    subgraph "渲染层 - Renderer"
        I --> N[RendererManager 渲染管理器]
        N --> O{选择渲染后端}
        O -->|Canvas2D| P[Canvas2D 渲染器组]
        O -->|CanvasKit| Q[CanvasKit 渲染器组]
        P --> R[RectRender]
        P --> S[EllipseRender]
        P --> T[TextRender]
        Q --> U[RectRender]
        Q --> V[EllipseRender]
        Q --> W[TextRender]
    end
    
    subgraph "数据层 - Component"
        X[StateStore 状态仓库]
        X --> Y[Position]
        X --> Z[Size]
        X --> AA[Color]
        X --> AB[Rotation]
        X --> AC[Selected]
    end
    
    D <--> X
    I --> X
    J --> X
    K --> X
    L --> X
    
    style C fill:#4A90E2,color:#fff
    style N fill:#E94B3C,color:#fff
    style X fill:#6ECB63,color:#fff
    style G fill:#F39C12,color:#fff

ECS 架构深度解析

什么是 ECS 架构?

ECS(Entity-Component-System)是一种源自游戏引擎的设计模式,它彻底改变了传统面向对象的继承体系,转而采用组合优于继承的理念。

三大核心概念:

  1. Entity(实体) - 仅是一个唯一 ID,不包含任何数据和逻辑
  2. Component(组件) - 纯数据结构,描述实体的属性(如位置、颜色、大小)
  3. System(系统) - 纯逻辑处理单元,操作特定组件组合的实体
graph TB
    subgraph "传统 OOP 继承方式"
        A1[GameObject]
        A1 --> A2[Rectangle]
        A1 --> A3[Circle]
        A1 --> A4[Text]
        A2 --> A5[DraggableRectangle]
        A3 --> A6[SelectableCircle]
        style A1 fill:#ff9999
    end
    
    subgraph "ECS 组合方式"
        B1[Entity 123] -.拥有.-> B2[Position]
        B1 -.拥有.-> B3[Size]
        B1 -.拥有.-> B4[Color]
        
        B5[Entity 456] -.拥有.-> B6[Position]
        B5 -.拥有.-> B7[Font]
        B5 -.拥有.-> B8[Selected]
        
        B9[RenderSystem] --> B2 & B3 & B4
        B10[DragSystem] --> B2
        B11[SelectionSystem] --> B8
        
        style B1 fill:#99ccff
        style B5 fill:#99ccff
        style B9 fill:#99ff99
        style B10 fill:#99ff99
        style B11 fill:#99ff99
    end

ECS 架构的核心优势

1. 极致的解耦性

传统 OOP 中,功能通过继承链紧密耦合。而 ECS 中,系统只依赖组件接口,实体的行为完全由组件组合决定。

// ❌ 传统方式:紧耦合的继承链
class Shape {
  render() { /* ... */ }
}
class DraggableShape extends Shape {
  drag() { /* ... */ }
}
class SelectableDraggableShape extends DraggableShape {
  select() { /* ... */ }
}

// ✅ ECS 方式:组件自由组合
const rect = createEntity()
addComponent(rect, Position, { x: 100, y: 100 })
addComponent(rect, Size, { width: 200, height: 150 })
addComponent(rect, Draggable, {})  // 可拖拽
addComponent(rect, Selected, {})   // 可选中
2. 强大的可扩展性

新增功能无需修改现有代码,只需添加新的组件和系统:

image.png

3. 天然的并行处理能力

系统之间无共享状态,可以安全地并行执行:

// 多个系统可以同时读取同一个组件
async function updateFrame() {
  await Promise.all([
    physicsSystem.update(),   // 读取 Position
    renderSystem.update(),    // 读取 Position
    collisionSystem.update(), // 读取 Position
  ])
}
System 系统架构

系统负责处理逻辑,通过查询 StateStore 获取需要的组件数据:

abstract class System {
  abstract update(stateStore: StateStore): void
}

class RenderSystem extends System {
  update(stateStore: StateStore) {
    // 查询所有拥有 Position 组件的实体
    for (const [entityId, position] of stateStore.position) {
      const size = stateStore.size.get(entityId)
      const color = stateStore.color.get(entityId)
      const type = stateStore.type.get(entityId)
      
      // 根据类型调用对应的渲染器
      this.renderMap.get(type)?.draw(entityId)
    }
  }
}

系统完整列表:

graph TB
    A[EventSystem<br/>事件总线] --> B[InputSystem<br/>输入捕获]
    A --> C[HoverSystem<br/>悬停检测]
    A --> D[ClickSystem<br/>点击处理]
    A --> E[DragSystem<br/>拖拽逻辑]
    A --> F[SelectionSystem<br/>选择管理]
    A --> G[ZoomSystem<br/>缩放控制]
    A --> H[ScrollSystem<br/>滚动平移]
    A --> I[PickingSystem<br/>图形拾取]
    A --> J[RenderSystem<br/>渲染绘制]
    A --> K[FpsSystem<br/>性能监控]
    
    style A fill:#F39C12,color:#fff
    style J fill:#E74C3C,color:#fff
    style I fill:#3498DB,color:#fff

双引擎架构设计

架构设计理念

不同的应用场景对渲染引擎有不同的需求:

  • 简单场景:需要快速启动、体积小、兼容性好
  • 复杂场景:需要高性能、丰富特效、大量图形

传统方案通常只支持单一渲染后端,难以兼顾两者。本引擎采用双引擎可切换架构,在运行时动态选择最优渲染后端。

graph TB
    A[应用启动] --> B{检测场景复杂度}
    B -->|简单场景<br/>< 100 图形| C[Canvas2D 引擎]
    B -->|复杂场景<br/>> 100 图形| D[CanvasKit 引擎]
    B -->|用户手动指定| E[用户选择]
    
    C --> F[浏览器原生 API]
    D --> G[Skia WASM 引擎]
    
    C --> H[渲染输出]
    D --> H
    
    I[运行时切换] -.->|热切换| C
    I -.->|热切换| D
    
    style C fill:#90EE90
    style D fill:#87CEEB
    style H fill:#FFD700

渲染后端对比

特性 Canvas2D CanvasKit (Skia)
启动速度 ⚡️ 即时(0ms) 🐢 需加载 WASM(~2s)
包体积 ✅ 0 KB ⚠️ ~1.5 MB
浏览器兼容性 ✅ 100% ⚠️ 需支持 WASM
渲染性能 🟡 中等 🟢 优秀
复杂路径渲染 🟡 一般 🟢 优秀
文字渲染 🟡 质量一般 🟢 亚像素级
滤镜特效 ❌ 有限 ✅ 丰富
离屏渲染 ✅ 支持 ✅ 支持
最佳场景 简单图形、快速原型 复杂设计、高性能需求

RendererManager 渲染管理器

RendererManager 是双引擎架构的核心枢纽,负责渲染器的注册、切换和调度:

class RendererManager {
  rendererName: 'Canvas2D' | 'Canvaskit' = 'Canvaskit'
  
  // 渲染器映射表
  renderer: {
    rect: typeof RectRender
    ellipse: typeof EllipseRender
    text: typeof TextRender
    img: typeof ImgRender
    polygon: typeof PolygonRender
  }
  
  // 切换渲染后端
  setRenderer(name: 'Canvas2D' | 'Canvaskit') {
    this.rendererName = name
    
    if (name === 'Canvas2D') {
      this.renderer = Canvas2DRenderers
    } else {
      this.renderer = CanvaskitRenderers
    }
  }
}

渲染器切换流程:

sequenceDiagram
    participant U as 用户操作
    participant E as Engine
    participant RM as RendererManager
    participant RS as RenderSystem
    participant R1 as Canvas2D Renderer
    participant R2 as CanvasKit Renderer
    
    U->>E: setRenderer('Canvas2D')
    E->>RM: setRenderer('Canvas2D')
    RM->>RM: 加载 Canvas2D 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 Canvas2D.RectRender
    RS->>R1: 调用 draw() 方法
    R1->>R1: 使用 ctx.fillRect()
    
    Note over U,R2: 用户再次切换引擎
    
    U->>E: setRenderer('Canvaskit')
    E->>RM: setRenderer('Canvaskit')
    RM->>RM: 加载 CanvasKit 渲染器组
    RM-->>E: 切换完成
    
    E->>RS: 触发重新渲染
    RS->>RM: 获取 rect 渲染器
    RM-->>RS: 返回 CanvasKit.RectRender
    RS->>R2: 调用 draw() 方法
    R2->>R2: 使用 canvas.drawRect()

渲染器统一接口

所有渲染器实现相同的接口,保证可替换性:

abstract class BaseRenderer extends System {
  constructor(protected engine: Engine) {
    super()
  }
  
  // 统一的渲染接口
  abstract draw(entityId: string): void
  
}

自定义渲染器扩展

引擎支持用户自定义渲染器,只需实现 System 接口:

// 1. 创建自定义渲染器
class CustomStarRender extends System {
  draw(entityId: string) {
    const points = this.getComponent<Polygon>(entityId, 'polygon')
    const color = this.getComponent<Color>(entityId, 'color')
    
    // 自定义绘制逻辑
    const ctx = this.engine.ctx
    ctx.beginPath()
    points.points.forEach((p, i) => {
      i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)
    })
    ctx.closePath()
    ctx.fillStyle = color.fill
    ctx.fill()
  }
}
const customRenderMap = {
  star: CustomStarRender
}
// 2. 注册到引擎
new RendererRegistry().register({
  "custom": customRenderMap
})


字体渲染优化

CanvasKit 需要预加载字体文件,引擎实现了字体管理器:

async function loadFonts(CanvasKit: any) {
  const fontsBase = import.meta.env?.MODE === 'production' 
    ? '/design/fonts/' 
    : '/fonts/'

  const [robotoFont, notoSansFont] = await Promise.all([
    fetch(`${fontsBase}Roboto-Regular.ttf`).then(r => r.arrayBuffer()),
    fetch(`${fontsBase}NotoSansSC-VariableFont_wght_2.ttf`).then(r => r.arrayBuffer()),
  ])

  const fontMgr = CanvasKit.FontMgr.FromData(robotoFont, notoSansFont)
  return fontMgr
}

// 在 CanvasKit 初始化时调用
export async function createCanvasKit() {
  const CanvasKit = await initCanvasKit()
  const FontMgr = await loadFonts(CanvasKit)
  return { CanvasKit, FontMgr }
}

引擎工厂模式

使用工厂函数创建不同配置的引擎实例:

export function createCanvasRenderer(engine: Engine) {
  // Canvas2D 引擎创建器
  const createCanvas2D = (config: DefaultConfig) => {
    const canvas = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    canvas.style.width = config.width + 'px'
    canvas.style.height = config.height + 'px'
    canvas.width = config.width * dpr
    canvas.height = config.height * dpr
    
    const ctx = canvas.getContext('2d', {
      willReadFrequently: true,
    }) as CanvasRenderingContext2D
    ctx.scale(dpr, dpr)
    
    config.container.appendChild(canvas)
    
    return { canvasDom: canvas, canvas: ctx, ctx }
  }

  // CanvasKit 引擎创建器
  const createCanvasKitSkia = async (config: DefaultConfig) => {
    const { CanvasKit, FontMgr } = await createCanvasKit()
    const canvasDom = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    
    canvasDom.style.width = config.width + 'px'
    canvasDom.style.height = config.height + 'px'
    canvasDom.width = config.width * dpr
    canvasDom.height = config.height * dpr
    canvasDom.id = 'canvasKitCanvas'
    
    config.container.appendChild(canvasDom)
    
    const surface = CanvasKit.MakeWebGLCanvasSurface('canvasKitCanvas')
    const canvas = surface!.getCanvas()
    
    return {
      canvasDom,
      surface,
      canvas: canvas,
      FontMgr: FontMgr,
      ck: CanvasKit,
    }
  }

  return {
    createCanvas2D,
    createCanvasKitSkia,
  }
}

Engine 引擎核心

Engine 类是整个渲染系统的中枢,协调所有子系统的运行:

class Engine implements EngineContext {
  camera: Camera = new Camera()
  entityManager: Entity = new Entity()
  SystemMap: Map<string, System> = new Map()
  rendererManager: RendererManager = new RendererManager()
  
  canvas!: Canvas  // 渲染画布(类型取决于渲染后端)
  ctx!: CanvasRenderingContext2D
  ck!: CanvasKit
  
  constructor(public core: Core, rendererName?: string) {
    // 初始化渲染器
    this.rendererManager.rendererName = rendererName || 'Canvaskit'
    this.rendererManager.setRenderer(this.rendererManager.rendererName)
  }
  
  // 添加系统
  addSystem(system: System) {
    this.system.push(system)
    this.SystemMap.set(system.constructor.name, system)
  }
  
  // 获取系统
  getSystemByName<T extends System>(name: string): T | undefined {
    return this.SystemMap.get(name) as T
  }
  
  // 清空画布(适配双引擎)
  clear() {
    const canvas = this.canvas as any
    if (canvas?.clearRect) {
      // Canvas2D 清空方式
      canvas.clearRect(0, 0, this.defaultSize.width, this.defaultSize.height)
    } else {
      // CanvasKit 清空方式
      this.canvas.clear(this.ck.WHITE)
    }
  }
}

插件化系统设计

系统即插件

引擎的所有功能都以 System 形式实现,每个 System 都是独立的插件。这种设计带来极高的灵活性:

graph TB
    A[Engine 核心] --> B{System Manager}
    
    B --> C[核心系统]
    B --> D[可选系统]
    B --> E[自定义系统]
    
    C --> C1[EventSystem<br/>必需]
    C --> C2[RenderSystem<br/>必需]
    
    D --> D1[DragSystem<br/>拖拽功能]
    D --> D2[ZoomSystem<br/>缩放功能]
    D --> D3[FpsSystem<br/>性能监控]
    
    E --> E1[UndoRedoSystem<br/>撤销重做]
    E --> E2[SnappingSystem<br/>吸附对齐]
    E --> E3[AnimationSystem<br/>动画播放]
    
    style C1 fill:#e74c3c,color:#fff
    style C2 fill:#e74c3c,color:#fff
    style D1 fill:#3498db,color:#fff
    style D2 fill:#3498db,color:#fff
    style D3 fill:#3498db,color:#fff
    style E1 fill:#2ecc71,color:#fff
    style E2 fill:#2ecc71,color:#fff
    style E3 fill:#2ecc71,color:#fff

核心系统详解

1. EventSystem - 事件总线

EventSystem 是整个引擎的调度中枢,协调所有其他系统的执行:

class EventSystem extends System {
  private eventQueue: Event[] = []
  
  update(stateStore: StateStore) {
    // 执行系统更新顺序
    this.executeSystem('InputSystem')      // 1. 捕获输入
    this.executeSystem('HoverSystem')      // 2. 检测悬停
    this.executeSystem('ClickSystem')      // 3. 处理点击
    this.executeSystem('DragSystem')       // 4. 处理拖拽
    this.executeSystem('ZoomSystem')       // 5. 处理缩放
    this.executeSystem('SelectionSystem')  // 6. 更新选择
    this.executeSystem('PickingSystem')    // 7. 更新拾取缓存
    this.executeSystem('RenderSystem')     // 8. 最后渲染
  }

}
2. RenderSystem - 渲染系统

RenderSystem 负责将实体绘制到画布:

class RenderSystem extends System {
  private renderMap = new Map<string, BaseRenderer>()
  
  constructor(engine: Engine) {
    super()
    this.engine = engine
    this.initRenderMap()
  }
  
  // 初始化渲染器映射
  initRenderMap() {
    Object.entries(this.engine.rendererManager.renderer).forEach(
      ([type, RendererClass]) => {
        this.renderMap.set(type, new RendererClass(this.engine))
      }
    )
  }
  
  async update(stateStore: StateStore) {
    // 清空画布
    this.engine.clear()
    
    // 应用相机变换
    this.engine.canvas.save()
    this.engine.canvas.translate(
      this.engine.camera.translateX,
      this.engine.camera.translateY
    )
    this.engine.canvas.scale(
      this.engine.camera.zoom,
      this.engine.camera.zoom
    )
    
    // 遍历所有实体进行渲染
    for (const [entityId, pos] of stateStore.position) {
      this.engine.canvas.save()
      this.engine.canvas.translate(pos.x, pos.y)
      
      const type = stateStore.type.get(entityId)
      await this.renderMap.get(type)?.draw(entityId)
      
      this.engine.canvas.restore()
    }
    
    this.engine.canvas.restore()
  }
}

DSL 配置系统


DSL 配置系统

设计目标

DSL(Domain Specific Language)模块的目标是将图形场景序列化为 JSON 格式,实现:

  1. 场景持久化 - 保存到数据库或本地存储
  2. 场景传输 - 前后端数据交换
  3. 场景快照 - 撤销/重做功能的基础
  4. 模板复用 - 创建可复用的图形模板

配置结构

interface DSLParams {
  type: 'rect' | 'ellipse' | 'text' | 'img' | 'polygon'
  id?: string
  position: { x: number; y: number }
  size?: { width: number; height: number }
  color?: { fill: string; stroke: string }
  rotation?: { value: number }
  scale?: { value: number }
  zIndex?: { value: number }
  selected?: { isSelected: boolean }
  // 形状特定属性
  font?: { family: string; size: number; weight: string }
  radius?: { value: number }
  polygon?: { points: Point[] }
}

DSL 解析器

class DSL {
  constructor(params: DSLParams) {
    this.type = params.type
    this.id = params.id || this.generateId()
    this.position = new Position(params.position)
    this.size = params.size ? new Size(params.size) : new Size()
    this.color = params.color ? new Color(params.color) : new Color()
    // ... 初始化其他组件
  }
  
  // 转换为纯数据对象
  toJSON(): DSLParams {
    return {
      type: this.type,
      id: this.id,
      position: { x: this.position.x, y: this.position.y },
      size: { width: this.size.width, height: this.size.height },
      color: { fill: this.color.fill, stroke: this.color.stroke },
      // ...
    }
  }
}

低耦合架构实践

依赖方向

整个引擎严格遵循依赖倒置原则:

graph TB
    A[应用层<br/>React 组件] --> B[引擎接口<br/>Engine API]
    B --> C[系统层<br/>System]
    C --> D[组件层<br/>Component]
    C --> E[实体层<br/>Entity]
    
    F[渲染层<br/>Renderer] --> G[渲染接口<br/>BaseRenderer]
    C --> G
    
    style B fill:#f39c12,color:#fff
    style G fill:#f39c12,color:#fff

关键设计:

  • 上层依赖接口,不依赖具体实现
  • System 不直接依赖 Renderer,通过 RendererManager 解耦
  • Component 纯数据,零依赖

总结

Duck-Core 前端渲染引擎通过以下设计实现了高性能、高扩展性:

核心优势

  1. ECS 架构 - 数据与逻辑完全分离,组件自由组合
  2. 双引擎架构 - Canvas2D 与 CanvasKit 可热切换,兼顾兼容性与性能
  3. 插件化系统 - 所有功能以 System 形式实现,按需加载
  4. 低耦合设计 - 接口隔离、依赖倒置、事件驱动
  5. 极致性能 - 渲染节流、离屏缓存、视口裁剪、内存优化

从面条代码到抽象能力:一个小表单场景里的前端成长四阶段

在日常业务开发里,有一类场景出现频率极高:

  • 页面里有一个子表单组件(用 ref 引用)
  • 提交前要让子表单先做一轮校验
  • 校验通过后,从当前组件的数据里组装一个 payload 发给后端

看起来再普通不过,比如下面这样一段代码:

if (this.reviewContent?.length) {
  // 先让子表单组件校验
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调接口……
  • 有内容就校验;
  • 校验不过就提示;
  • 校验通过就拼一个 payload 去提交。

很多前端的“表单生涯”,就是从这种线性、直接、带一点点面条味的代码开始的。

有意思的是:
同样一个需求,不同水平的前端,会写出完全不同层次的代码。
从这个小例子出发,我们刚好可以串一条:初级 → 中级 → 高级 → 架构 的成长路径。


一、初级前端:能把流程串起来,就是胜利 🎯

典型写法

还是这段最“朴素”的代码:

if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调用接口,比如:api.submit(payload)

逻辑完全按脑子里的流程来:

  1. 有自评内容吗?(this.reviewContent?.length
  2. 有的话就调用子表单的 validate()
  3. 校验没过就弹一条错误消息
  4. 校验通过,拼一个请求体对象
  5. 调接口

这个阶段的特点

  • ✅ 优点:

    • 写起来非常快;
    • 读起来也很直接——业务同学都能看懂。
  • ❌ 问题:

    • 强耦合在当前组件

      • 假设 $refs.reviewForm 一定存在;
      • 假设 validate() 返回 { ok, message }
      • 假设消息提示必须在这里做。
    • 逻辑、UI 提示、payload 结构全部糊在一起;

    • 若别的页面也要搞“先校验子表单再组装 payload”,往往是复制粘贴一份再改改字段。

初级阶段的核心目标其实只有一个: “我能把功能做出来。”
想到哪写到哪,是完全正常的。


二、中级前端:开始对“重复”和“耦合”过敏 🧩

当你写了第三、第四个类似的提交流程后,会开始皱眉头:

  • “怎么又是先 validate 再拼对象?”
  • “为什么到处都有同样的错误提示逻辑?”
  • “这要是修改字段名,不得全项目搜索一遍?”

这时候,就会自然走向第一步抽象:提取工具函数

第一步:按“模块”抽函数

比如我们为这块自评表单单独写一个工具文件:

// utils/reviewForm.js

// 校验自评表单
export function validateReview(vm) {
  if (!vm.reviewContent?.length) {
    // 没填内容,视为不需要校验
    return { ok: true };
  }

  const form = vm.$refs.reviewForm;
  if (!form || typeof form.validate !== 'function') {
    // 看业务需求,这里也可以认为是异常
    return { ok: true };
  }

  const res = form.validate();

  if (!res || res.ok === false) {
    const msg = res?.message || '自评信息校验未通过';
    vm.$message.error(msg);
    return { ok: false, message: msg };
  }

  return { ok: true };
}

// 构建自评 payload
export function buildReviewPayload(vm) {
  return {
    reviewContent: vm.reviewContent,
    attachmentList: vm.attachmentList,
  };
}

组件里就可以这样用:

import { validateReview, buildReviewPayload } from '@/utils/reviewForm';

async onSubmit() {
  const { ok } = validateReview(this);
  if (!ok) return;

  const payload = buildReviewPayload(this);
  await api.submit(payload);
}

中级前端的思维变化

  • 不再满足于“能跑”,开始追求“以后好改一点”;
  • 能意识到:可以把公共逻辑抽成函数,避免重复粘贴;
  • 但抽象粒度通常还是按业务模块划分
    reviewFormUtilsbaseInfoUtilspriceUtils……

封装有了,代码好了不少,但还停留在“每个业务模块一套”的阶段:
每加一个新表单,又是一组新的 validateXxx + buildXxxPayload


三、高级前端:从“封装”走向“抽象模式” 🧠

再往前走一步,你会开始问更有意思的问题:

  • 这些校验 + payload 的套路,本质上是不是一样的?
  • 哪些是“流程”,哪些是“策略/细节”?
  • 我能不能写一份通用逻辑,让所有表单都用?

1)提炼通用流程:getPayload

观察会发现,每个表单提交流程几乎都是:

  1. 根据某个 ref 拿到子表单组件;
  2. 调用 validate() 做校验;
  3. 如果失败 → 提示并中断;
  4. 如果成功 → 根据当前组件数据构造 payload。

于是可以写出一个只关心流程的函数:

// utils/getPayload.js

/**
 * @param {Vue} vm - 当前组件实例
 * @param {Function} buildPayload - (vm) => payload 对象
 * @param {String} refName - 子表单的 ref 名
 */
export function getPayload(vm, buildPayload, refName) {
  const form = vm.$refs?.[refName];

  // 没有这个表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    // 支持 Promise 风格的 validate
    if (maybe && typeof maybe.then === 'function') {
      return maybe.then(handle).catch((e) => {
        const msg = e?.message || '校验异常';
        vm.$message.error(msg);
        return { ok: false, payload: null, message: msg };
      });
    }

    // 同步返回
    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件使用示例:

import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => ({
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    }),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

此时,getPayload

  • 不再关心 payload 结构;
  • 不再关心具体业务,只负责:
    “找到表单 → 校验 → 错误处理 → 调用参数构造 payload”

2)这里已经有设计模式的影子

  • getPayload 像是一个小号的 模板方法模式(Template Method):

    • 固定了流程:校验 → 错误处理 → 构建 payload
    • 把“payload 怎么构”这一段留给调用方(作为“模板中的可变步骤”)。
  • buildPayload 实际上也符合 策略模式(Strategy)的思路:

    • 同一个处理流程,根据不同策略函数(buildXxxPayload),构建不同业务数据。

高级前端的特点是:

  • 不只是“会封装”,而是会从逻辑里识别出模式
  • 能把“稳定的部分”和“易变的部分”拆开,分别对待;
  • 会刻意让代码有可扩展点,而不是为了当前需求把东西都焊死。

四、前端架构:把“表单校验 + payload”升级成一种通用能力 🏗️

再往上一个段位,前端架构考虑的不只是“这段代码写得好不好看”,而是:

“这种模式在整个项目范围内,要怎么用、怎么演进?”

如果全站有 N 个子表单,每个都要:

  • ref + validate
  • 再拼各自的 payload

那么架构会更倾向于做一件事:

把这种模式正式命名、抽象成一类‘能力’,然后由所有页面共同复用。

1)用配置表达“表单 → payload”的映射关系

先把“这个 ref 对应什么 payload”抽成配置:

// config/formPayloadMap.js

export const formPayloadMap = {
  // 自评表单
  reviewForm(vm) {
    return {
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    };
  },

  // 基础信息表单
  baseForm(vm) {
    return {
      baseInfo: vm.baseInfo,
      projectId: vm.projectId,
    };
  },

  // 报价表单
  priceForm(vm) {
    return {
      priceList: vm.priceList,
    };
  },

  // ……
};

2)getPayload:只需要传 refName

现在改造 getPayload,变成只需要 vm + refName

// utils/getPayload.js
import { formPayloadMap } from '@/config/formPayloadMap';

export function getPayload(vm, refName) {
  const buildPayload = formPayloadMap[refName];

  if (!buildPayload) {
    console.warn(`[getPayload] 未配置 ref "${refName}" 对应的 payload 构造函数`);
    return { ok: false, payload: null, message: `未配置 ${refName} 映射` };
  }

  const form = vm.$refs?.[refName];

  // 没有表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    if (maybe && typeof maybe.then === 'function') {
      return maybe
        .then(handle)
        .catch((e) => {
          const msg = e?.message || '校验异常';
          vm.$message.error(msg);
          return { ok: false, payload: null, message: msg };
        });
    }

    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件里的使用体验就变成:

import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(this, 'reviewForm');
  if (!res.ok) return;

  await api.submit(res.payload);
}

调用方只需要关心:

  • “我这块用的是哪个 ref 的表单?”

至于:

  • 要不要校验;
  • 校验怎么提示;
  • payload 字段怎么组装;

全部由统一机制 + 配置来处理。

3)架构视角下,多了几件事要考虑

在这个阶段,思考重心变成了:

  • 一致性

    • 表单校验行为统一;
    • 错误提示统一;
    • payload 结构的调整有统一入口。
  • 可配置 / 可扩展

    • 新增表单只需要:

      1. 约定好 ref 名;
      2. formPayloadMap 里加一个构造函数;
    • 对核心流程无侵入。

  • 领域化

    • 不再把子表单当成“某个页面的实现细节”,
      而是当成领域里的一个“实体”:
      reviewFormbaseFormpriceForm……
    • 每个实体都有“校验 + 构造请求体”的统一接口。
  • 长期演进成本

    • 将来如果:

      • 改用新的 UI 表单库;
      • 数据结构升级;
      • Vue2 升 Vue3;
    • ——绝大多数改动都可以限制在小范围内完成。


五、同一个需求,不同段位的差别到底是什么?

用一句话概括每个阶段的心智模式:

  • 初级前端:

    “我能把这个流程串起来,让它跑起来。”

  • 中级前端:

    “这里有重复,我抽一个函数出来,大家都用。”

  • 高级前端:

    “这是一种模式。
    哪些是稳定流程?哪些是可变策略?
    我怎么设计抽象,让一份逻辑服务多个业务?”

  • 前端架构:

    “这不仅是个工具函数,而是一类‘通用能力’。
    我要用机制 + 配置,把它变成整个项目的基础设施。”

而最初那段看起来有点“面条味”的代码,其实只是起点。

当你开始嫌弃这类代码,开始思考“能不能抽象、能不能通用”的那一刻,你就已经在从“写代码的人”,向“设计代码的人”迈进了。


如果你现在项目里正好到处都是:

const res = this.$refs.xxx.validate();
// if (!res.ok) ...
// const payload = { ... }

不妨找一个最典型的提交流程,从:

直接写 → 提取函数 → 通用流程 + 策略 → 配置化

这四步路径里,选你觉得当前团队能接受的一步先落地。
技术成长很多时候不是换框架、追新库,而是搞定这种“看起来很小,但无处不在”的模式。


六、如果业务继续变复杂:往“建造者风格”进化 🧱

前面那套 getPayload + formPayloadMap,足以覆盖大部分常规业务表单场景:

  • 每个表单的 payload 结构相对固定;
  • 只要从 vm 上摘几个字段拼一下就行。

但真实项目有时候会长成这样:

  • 某些字段是「勾选了某个开关才需要拼进去」
  • 某些片段要按不同的业务类型组合(比如:普通流程 / 加急流程 / 审批流)
  • 有时还需要先异步拿一部分数据,再参与构建 payload

这时候,简单的:

formPayloadMap[refName](vm) {
  return { ... };
}

就可能开始变得又长又丑了:里面充满 if / switch / 三元运算符。

这个时候,就可以考虑往**“建造者风格(Builder-style)”**上进化:
把一个“大 payload”拆成多个可组合的构建步骤。

1)一个简化版 PayloadBuilder 示例

先来个最小可用的版本:

// builders/PayloadBuilder.js
export class PayloadBuilder {
  constructor(vm) {
    this.vm = vm;
    this.data = {};
  }

  withReview() {
    if (this.vm.reviewContent?.length) {
      this.data.reviewContent = this.vm.reviewContent;
    }
    return this;
  }

  withAttachments() {
    if (Array.isArray(this.vm.attachmentList)) {
      this.data.attachmentList = this.vm.attachmentList;
    }
    return this;
  }

  withBaseInfo() {
    if (this.vm.baseInfo) {
      this.data.baseInfo = this.vm.baseInfo;
    }
    return this;
  }

  // 你可以继续加更多 withXxx 模块…

  build() {
    return this.data;
  }
}

使用方式(举个场景):

import { PayloadBuilder } from '@/builders/PayloadBuilder';
import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => new PayloadBuilder(vm)
      .withReview()
      .withAttachments()
      .withBaseInfo()
      .build(),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

这里发生了几件事:

  • getPayload 仍然负责:
    找到 ref → 校验 → 错误处理

  • 具体 payload 构建过程交给 PayloadBuilder

    • 每个 withXxx() 负责一个独立模块;
    • build() 返回最终对象。

好处是:

  • 可以非常自然地按业务组合:

    • 某些场景只要 .withReview().withAttachments()
    • 另一些场景再 .withBaseInfo().withSomethingElse()
  • 单个 withXxx 内部逻辑变复杂也不怕,不会把一个函数搞成 200 行 if-else

2)什么时候才值得用 Builder 风格?

简单粗暴的判断:

  • 值得上 Builder 的情况

    • payload 真的是**「很多块拼起来」**的;
    • 不同业务场景需要「选择性启用某些块」;
    • 每块内部逻辑都可能变得很复杂(多条件、多分支、甚至异步)。
  • 没必要上 Builder 的情况

    • 只是把 3~5 个字段丢进对象里;
    • 变动很少,大部分字段都是 1:1 映射;
    • 没有复杂的组合逻辑。

换句话说:

Builder 风格的价值,在于把一个复杂构建过程拆成多个可组合的小模块
如果你的业务没有复杂到这个程度,
现在这套 formPayloadMap[refName](vm) + 少量 if,其实已经刚刚好。

3)和前面几级抽象的关系

可以把这几层理解成「渐进增强」:

  • Lv.3 高级前端:

    getPayload(vm, buildPayload, refName)

    • 通用流程 + 策略函数,已经很好用了。
  • Lv.4/架构阶段:

    配一个 formPayloadMap[refName] = buildPayload

    • 把“谁负责构建什么”集中管理。
  • Builder 风格:

    在单个 buildPayload(vm) 的实现内部,如果逻辑变复杂,再引入 PayloadBuilder

    • 是对某一个领域的构建细节做进一步拆分,而不是推翻整个体系。

也就是说,Builder 不是替代前面的抽象,而是为「某个复杂 payload」加的一层“精细化构建工具”。


七、这条路还能怎么走?一些扩展方向思路 🚀

最后顺带聊几个可以继续进化的方向,你可以根据项目实际情况慢慢加,不用一口吃胖子。

1)配合 TypeScript 做强类型约束

当前的写法都是 JS 靠自觉:

  • formPayloadMap 的 key/返回结构完全靠约定;
  • getPayload 的返回 { ok, payload } 也没有类型提示。

如果用 TS,可以做几件事:

  • formPayloadMap 建立一个统一的类型 Map:

    • 比如:type FormPayloadMap = { reviewForm: ReviewPayload; baseForm: BasePayload; ... }
  • getPayload 根据 refName 返回不同的 payload 类型(泛型 + 索引类型);

  • PayloadBuilder 每个 withXxx() 加上返回类型约束,防止漏字段/写错字段名。

好处是:一旦后端改了字段,TS 能第一时间把相关代码全标红,你就不需要靠“全局搜索 + 祈祷”。

2)统一成“多表单聚合”的流程

很多真实页面不是只有一个子表单,而是:

顶层页面 → N 个子块(基础信息、自评、报价、附件……)
最后统一点一个「提交」,要校验所有子块,组合所有 payload。

在现有基础上,可以设计一个“多表单聚合器”,伪代码例如:

async function collectAllPayloads(vm, configList) {
  const allPayload = {};

  for (const cfg of configList) {
    const { refName, mountPoint } = cfg;
    const res = await getPayload(vm, refName);
    if (!res.ok) return { ok: false };

    // mountPoint 决定这块 payload 挂在最终对象的哪里
    allPayload[mountPoint] = res.payload;
  }

  return { ok: true, payload: allPayload };
}

调用时:

const res = await collectAllPayloads(this, [
  { refName: 'baseForm', mountPoint: 'baseInfo' },
  { refName: 'reviewForm', mountPoint: 'review' },
  { refName: 'priceForm', mountPoint: 'price' },
]);

if (!res.ok) return;
await api.submit(res.payload);

这时:

  • getPayload单个表单的能力
  • collectAllPayloads多表单聚合的能力
  • 再配合 Builder,你就有了一条从“字段级 → 模块级 → 表单级 → 全页级”的构建链路。

3)把“校验 + 构建”做成可插拔中间件

现在,getPayload 里,校验逻辑顺序是写死的:

校验 → 错误提示 → 构建 payload

如果业务越来越复杂,可以考虑做成类似“中间件管线”的模式,比如:

const pipeline = [
  validateFormStep,
  extraAsyncCheckStep,
  normalizeDataStep,
  buildPayloadStep,
];

runPipeline(vm, refName, pipeline);

每个 step 接收 context(比如 { vm, form, payload }),按顺序处理。
这样你可以:

  • 在某些表单插入额外风控校验;
  • 在某些表单前面加数据归一化逻辑;
  • 保持主流程一致但允许个性化“插片”。

这就已经非常接近「前端领域里自己的 mini-framework」了。

4)跨框架 / 跨项目复用

你现在的设计,其实已经很容易跨栈:

  • ref 概念:React 可以用 useRef + forwardRef 来模拟;
  • validate:绝大多数表单库都有类似 API;
  • payload 构造:和框架无关,本身就是纯函数/Builder。

如果你有多个项目(Vue2/Vue3/React 混合),理论上可以:

  • 把“构建规则”和“校验规则”抽到一个独立 npm 包;
  • 每个项目只写一层很薄的“外壳”,适配自己的 ref/组件体系。

这就是从“一个项目里的抽象”,升级成“多项目共享的领域包”。


你现在这整套,从最开始那段:

if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}
const payload = { ... };

一路演进到:

  • 通用 getPayload
  • 配置化 formPayloadMap
  • 按需引入 PayloadBuilder 做复杂构建
  • 再往外是多表单聚合、类型约束、流水线、跨项目复用
❌