阅读视图

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

Next.js第七章(路由组)

路由组

路由组也是一种基于文件夹的约定范式,可以让我们开发者,按类别或者团队组织路由模块,并且不影响 URL 路径。

用法:只需要通过(groupName)包裹住文件夹名即可,例如(shop)(user)等,名字可以自定义。

image.png

定义多个根布局

这种一般是大型项目使用的,例如我们需要把,后台管理系统前台的门户网站,放到一个项目就可以使用这种方法实现。

image.png

使用方法:

  1. 先把app目录下的layout.tsx 文件删除
  2. 在每组的目录下创建layout.tsx文件,并且定义html,body标签。

image.png

Next.js第六章(平行路由)

平行路由

平行路由指的是在同一布局layout.tsx中,可以同时渲染多个页面,例如teamanalytics等,这个东西跟vuerouter-view类似。

image.png

基本用法

平行路由的使用方法就是通过@ + 文件夹名来定义,例如@team@analytics等,名字可以自定义。

平行路由也不会影响URL路径。

image.png

定义完成之后,我们就可以在layout.tsx中使用teamanalytics来渲染对应的页面,他会自动注入layout的props里面

注意:例子中我们使用了解构的语法,这里面的名称team,analytics需跟文件夹名称一致。

export default function RootLayout({children,team,analytics}: 
{children: React.ReactNode,team: React.ReactNode,analytics: React.ReactNode}
) {
    return (
        <html>
            <body>
                {team}
                {children}
                {analytics}
            </body>
        </html>
    )
}

独立路由

当我们使用了平行路由之后,我们为其单独定义loading,error,等组件使其拥有独立加载和错误处理的能力。

image.png

image.png

default.tsx

首先我们先认识一下子导航,每一个平行路由下面还可以接着创建对应的路由,例如@team下面可以接着创建@team/setting@team/user等。

那我们的目录结构就是:

├── @team
│   ├── page.tsx
│   ├── setting
│   │   └── page.tsx
└── @analytics
│    └── page.tsx
└── layout.tsx   
└── page.tsx

然后我们使用Link组件跳转子导航setting页面

import Link from "next/link"
export default function RootLayout({children,team,analytics}: 
{children: React.ReactNode,team: React.ReactNode,analytics: React.ReactNode}) {
    return (
        <html>
            <body>
                {team}
                {children}
                {analytics}
                <Link className="text-blue-500 block" href="/setting">Setting</Link>
            </body>
        </html>
    )
}

2.gif

观察上图我们发现,子导航使用Link组件跳转setting页面时,是没有问题的,但是我们在跳转之后刷新页面,就出现404了,这是怎么回事?

  • 当使用软导航Link组件跳转子页面的时候,这时候@analyticschildren 依然保持活跃,所以他只会替代@team里面的内容。
  • 而当我们使用硬导航浏览器页面刷新,此时@analyticschildren 已经失活,因为它的底层原理其实是同时匹配@team@analyticschildren 目录下面的setting 页面,但是只有@team 有这个页面,其他两个没有,所以导致404

解决方案:使用default.tsx来进行兜底,确保不会404

  • @analytics/default.tsx 定义default.tsx文件
  • app/default.tsx 定义default.tsx文件

3.gif

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 定制化道路上的坚实基石和灵感源泉。祝你创造愉快!

一个超级真实的Three.js树🌲生成器插件

前言

分享一个基于Three.js封装的树生成器插件,可以实现创建不同类型且渲染效果真实的3D树

11111111111111111111111.gif

说实话,第一次在这个插件官网看到这个效果时我一度以为这只是一个视频,树的内容不仅仅是动态的而且整体的渲染效果也十分真实。

在three.js中使用起来也是非常的简单的仅仅需几行代码就可以搞定,下面给大家简单的介绍一下。

安装

通过 npm/pnpm 安装到项目本地即可

npm i @dgreenheck/ez-tree

pnpm add @dgreenheck/ez-tree

使用

使用起来也是非常简单的,只需要将插件import 引入然后在 new 实例化出来 在添加到 场景中就可以了

最后在一个requestAnimationFrame 动画函数中更新的内容就行了

import { Tree } from '@dgreenheck/ez-tree';

createTree(){

      const tree = new Tree();
      tree.generate();
      // 设置一下位置
      tree.position.set(0, 0, 0);
      // 设置一下大小缩放
      tree.scale.set(0.1, 0.1, 0.1);
      // 添加到场景中
      this.scene.add(tree);
      
}

  sceneAnimation(): void {
    // 确保动画循环持续进行
    this.renderAnimation = requestAnimationFrame(() => this.sceneAnimation());

      // 更新时钟
      const elapsedTime = this.clock.getElapsedTime();


      // 更新控制器 如果当前是第一人称控制器则不更新
      if (!this.pointerLockControls) {
        this.controls.update();
      }

      // 更新 Tree 动态效果(风动效果等)
      if (this.tree) {
        this.tree.update(elapsedTime);
      }
      // 渲染场景
      this.renderer.render(this.scene, this.camera);
  }

本地项目效果

因为本地项目对光照等参数没有专门调试所以和官网展示的效果有一定的差距

image.png

将相机放大查看树渲染的效果细节处理个人觉得是非常nice的,十分真实

image.png

参数

该插件还提供了创建不同类型树的方法,通过官网的在线调试就可以看到效果了

创建一个别的类型树

image.png

修改树枝的方向

image.png

树叶的多少

image.png

项目地址

该项目插件是一个外国大佬开发,如果你的项目或者个人网站需要丰富一下页面内容,那么这个插件或许是个不错的选择

官网:www.eztree.dev/

项目地址:github.com/dgreenheck/…

npm scripts的高级玩法:pre、post和--,你真的会用吗?

image.png

我们每天的开发,可能都是从一个npm run dev开始的。npm scripts对我们来说,天天用它,但很少去思考它。

不信,你看看你项目里的package.json,是不是长这样👇:

"scripts": {
  "dev": "vite",
  "build": "rm -rf dist && tsc && vite build", // 嘿,眼熟吗?
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",
  "test": "vitest",
  "test:watch": "vitest --watch",
  "preview": "vite preview"
}

这能用吗?当然能用。

但这专业吗?在我看来,未必!

一个好的scripts,应该是原子化的、跨平台的。而上面这个,一个build命令就不行,而且rm -rf在Windows上还得装特定环境才能跑🤷‍♂️。

今天,我就来聊聊,如何用prepost--,把你的脚本,升级成专业的脚本。


prepost:命令的生命周期钩子

prepost,是npm内置的一种钩子机制。

它的规则很简单:

  • 当你执行npm run xyz时,npm自动先去找,有没有一个叫prexyz的脚本,有就先执行它。
  • xyz执行成功后,npm自动再去找,有没有一个叫postxyz的脚本,有就最后再执行它。

这个自动的特性,就是神一般的存在。

我们来改造那个前面👆提到的build脚本。

业余写法 (用&&手动编排)

"scripts": {
  "clean": "rimraf dist", // rimraf 解决跨平台删除问题
  "lint": "eslint .",
  "build:tsc": "tsc",
  "build:vite": "vite build",
  "build": "npm run clean && npm run lint && npm run build:tsc && npm run build:vite"
}

你的build脚本,它必须记住所有的前置步骤。如果哪天你想在build前,再加一个test,你还得去修改build的定义。这违反了单一职责

专业写法 (用pre自动触发)

"scripts": {
  "clean": "rimraf dist",
  "lint": "eslint .",
  "test": "vitest run",
  "build:tsc": "tsc",
  "build:vite": "vite build",

  // build的前置钩子
  "prebuild": "npm run clean && npm run lint && npm run test", 
  
  // build的核心命令
  "build": "npm run build:tsc && npm run build:vite",
  
  // build的后置钩子
  "postbuild": "echo 'Build complete! Check /dist folder.'"
}

看到区别了吗?

现在,当我只想构建时,我依然执行npm run build。

npm会自动帮我执行prebuild(清理、Lint、测试)👉 然后执行build(编译、打包)👉 最后执行postbuild(打印日志)。

我的build脚本,只关心构建这件事。而prebuild脚本,只关心前置检查这件事

这就是单一职责和关注点分离。

你甚至可以利用这个特性,搞点骚操作😁:

"scripts": {
  // 当你执行npm start时,它会自动先执行npm run build
  "prestart": "npm run build", 
  "start": "node dist/server.js"
}

-- (双短线):脚本参数

--是我最爱的一个特性。它是一个参数分隔符

它的作用是:告诉npm,我的npm参数到此为止了,后面所有的东西,都原封不动地,传给我要执行的那个底层命令。”

我们来看开头👆那个脚本:

"scripts": {
  "test": "vitest",
  "test:watch": "vitest --watch"
}

为了一个--watch参数,你复制了一个几乎一模一样的脚本。如果明天你还想要--coverage呢?再加一个test:coverage?这叫垃圾代码💩

专业写法 (用--动态传参)

"scripts": {
  "test": "vitest"
}

就这一行,够了。

等等,那我怎么跑watch和coverage?

答案,就是用--🤷‍♂️:

# 1. 只跑一次
$ npm run test -- --run
# 实际执行: vitest --run

# 2. 跑watch模式
$ npm run test -- --watch
# 实际执行: vitest --watch

# 3. 跑覆盖率
$ npm run test -- --coverage
# 实际执行: vitest --coverage

# 4. 跑某个特定文件
$ npm run test -- src/my-component.test.ts
# 实际执行: vitest src/my-component.test.ts

--就像一个参数隧道 ,它把你在命令行里,跟在--后面的所有参数,原封不动地扔给了vitest命令。


一个专业的CI/CD脚本

好了,我们把pre/post--结合起来,看看一个专业的package.json是长什么样子👇。

"scripts": {
  // 1. Lint
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",

  // 2. Test
  "test": "vitest",
  "pretest": "npm run lint", // 在test前,必须先lint

  // 3. Build
  "build": "tsc && vite build",
  "prebuild": "npm run test -- --run", // 在build前,必须先test通过
  
  // 4. Publish (发布的前置钩子)
  // prepublishOnly 是一个npm内置的、比prepublish更安全的钩子
  // 它只在 npm publish 时执行,而在 npm install 时不执行
  "prepublishOnly": "npm run build" // 在发布前,必须先build
}

看看我们构建了怎样一条自动化脚本:

  1. 你兴高采烈地敲下npm publish,准备发布。
  2. npm一看,有个prepublishOnly,于是它先去执行npm run build
  3. npm一看,build有个prebuild,于是它又先去执行npm run test -- --run
  4. npm一看,test有个pretest,于是它又双叒叕先去执行npm run lint

最终的执行流是:Lint -> Test -> Build -> Publish

这些脚本,被pre钩子,自动地、强制地串联了起来。你作为开发者,根本没有机会犯错。你不可能发布一个连Lint都没过或者测试未通过的包😁。


npm scripts,它不是一个简单的脚本快捷方式。它是一个工作流(Workflow)的定义

prepost,定义了你工作流的执行顺序依赖,保证了代码检查等功能,而--是确保你工作流中的脚本参数

现在,马上去打开你项目的package.json,看看它,是专业的,还是业余的呢?🤣

😱一行代码引发的血案:展开运算符(...)竟让图表功能直接崩了!

前言:一个看似简单的 bug

Hello~大家好。我是秋天的一阵风

最近在负责开发我司的一个图表功能时,遇到了一个令人困惑的问题。用户反馈在特定操作下会出现 Maximum call stack size exceeded 错误,但这个问题只在特定条件下出现:选择少量参数正常,但添加大量参数后就会崩溃。

经过深入调试,我发现问题的根源竟然是一行看似无害的代码

const rightYMinMax = [Math.min(...rightYData), Math.max(...rightYData)];

rightYData 数组包含 189,544 个元素时,这行代码导致了堆栈溢出。

这个Bug让我不禁开始思考:在 JavaScript 开发中,简洁的代码一定是最好的代码吗?

为了彻底搞清楚问题本质并找到最优解决方案,我在家中编写了完整的复现案例,通过对比不同实现方式的性能表现,总结出一套可落地的优化方案。

一、问题本质:为什么展开运算会导致栈溢出?

要解决问题,首先要理解问题的根源。在 JavaScript 中,Math.min()Math.max() 是全局函数,它们接收的是可变参数列表(而非数组),而展开运算符(...) 的作用是将数组元素「拆解」成一个个独立参数传递给函数。

1. 调用栈的限制

JavaScript 引擎对函数调用栈的深度有严格限制,不同浏览器和环境下的限制略有差异(通常在 1 万 - 10 万级别的参数数量)。当我们使用 Math.min(...largeArray) 时,相当于执行:

Math.min(1.23, 4.56, 7.89, ..., 999.99); // 参数数量 = 数组长度

当参数数量超过引擎的调用栈阈值时,就会触发「栈溢出」错误。在我们的项目中,

这个阈值大约是 18 万个参数 —— 这也是为什么少量参数正常,18 万 + 参数崩溃的核心原因。

2. 代码层面的隐藏风险

很多开发者喜欢用展开运算符处理数组,因为代码简洁直观。但这种写法在「小数据量」场景下看似无害,一旦数据量增长(比如图表数据、列表数据),就会瞬间暴露风险。更隐蔽的是,这种问题在开发环境中很难复现(开发环境数据量小),往往要等到生产环境才会爆发。

二、复现案例:三种方案的性能对比

为了验证不同实现方式的稳定性和性能,我编写了完整的测试代码(基于 Vue3 + TypeScript),通过「展开运算符」「循环遍历」「Reduce 方法」三种方案,在 10 万、50 万、100 万级数据量下进行对比测试。

1. 核心测试代码

<script setup lang="ts">
import { ref, onMounted } from 'vue';

// 测试数据大小(10万、50万、100万)
const testSizes = ref([100000, 500000, 1000000]);

// 定义测试结果类型
interface TestResult {
  method: string;
  success: boolean;
  result: number[] | null;
  time: number;
  error: string | null;
}
interface TestData {
  size: number;
  tests: TestResult[];
}
const results = ref<TestData[]>([]);

// 生成随机测试数据
const generateTestData = (size: number) => {
  return Array.from({ length: size }, () => Math.random() * 1000);
};

// 方案1:原始方法(展开运算符,会栈溢出)
const getMinMaxWithSpread = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    const result = [Math.min(...data), Math.max(...data)]; // 风险代码
    const end = performance.now();
    return {
      method: '展开运算符',
      success: true,
      result,
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: '展开运算符',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 方案2:优化方案(循环遍历)
const getMinMaxWithLoop = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    let min = data[0];
    let max = data[0];
    for (let i = 1; i < data.length; i++) {
      if (data[i] < min) min = data[i];
      if (data[i] > max) max = data[i];
    }
    const end = performance.now();
    return {
      method: '循环遍历',
      success: true,
      result: [min, max],
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: '循环遍历',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 方案3:优化方案(Reduce方法)
const getMinMaxWithReduce = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    const result = data.reduce(
      (acc, curr) => [Math.min(acc[0], curr), Math.max(acc[1], curr)],
      [data[0], data[0]]
    );
    const end = performance.now();
    return {
      method: 'Reduce方法',
      success: true,
      result,
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: 'Reduce方法',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 执行测试
const runTests = () => {
  console.log('### 开始测试栈溢出问题...');
  results.value = [];
  
  testSizes.value.forEach(size => {
    console.log(`### 测试数据大小: ${size.toLocaleString()}`);
    const testData = generateTestData(size);
    
    // 执行三种方案的测试
    const testResult = {
      size,
      tests: [
        getMinMaxWithSpread(testData),
        getMinMaxWithLoop(testData),
        getMinMaxWithReduce(testData)
      ]
    };
    
    results.value.push(testResult);
    
    // 打印控制台结果
    testResult.tests.forEach(test => {
      if (test.success && test.result) {
        console.log(`### ${test.method}: 成功 - 耗时 ${test.time.toFixed(2)}ms - 结果: [${test.result[0].toFixed(2)}, ${test.result[1].toFixed(2)}]`);
      } else {
        console.log(`### ${test.method}: 失败 - ${test.error}`);
      }
    });
    console.log('### ---');
  });
};

// 页面挂载时执行测试
onMounted(() => {
  runTests();
});
</script>

<template>
  <div style="padding: 20px; font-family: Arial, sans-serif;">
    <h1>Math.min/max 栈溢出问题测试</h1>
    
    <div style="margin: 20px 0;">
      <h2>问题描述</h2>
      <p>当数组元素数量过大时,使用展开运算符 <code>Math.min(...array)</code> 会导致栈溢出错误。</p>
      <p>原因:展开运算符会将所有数组元素作为参数传递给函数,超出JavaScript引擎的调用栈限制。</p>
    </div>
    
    <div style="margin: 20px 0;">
      <button @click="runTests" style="padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
        重新运行测试
      </button>
    </div>
    
    <div style="margin: 20px 0;">
      <h2>测试结果</h2>
      <div v-for="result in results" :key="result.size" style="margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px;">
        <h3>数据大小: {{ result.size.toLocaleString() }} 个元素</h3>
        <div v-for="test in result.tests" :key="test.method" style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 4px;">
          <div style="display: flex; justify-content: space-between; align-items: center;">
            <strong>{{ test.method }}</strong>
            <span :style="{ color: test.success ? 'green' : 'red' }">
              {{ test.success ? '✓ 成功' : '✗ 失败' }}
            </span>
          </div>
          <div v-if="test.success" style="margin-top: 5px;">
            <div>耗时: {{ test.time.toFixed(2) }}ms</div>
            <div v-if="test.result">结果: [{{ test.result[0].toFixed(2) }}, {{ test.result[1].toFixed(2) }}]</div>
          </div>
          <div v-else style="margin-top: 5px; color: red;">
            错误: {{ test.error }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
image.png

2. 测试结果分析(Chrome 浏览器环境)

通过实际运行测试代码,我得到了以下关键结果(数据为多次测试平均值):

数据量 展开运算符 循环遍历 Reduce 方法
10 万元素 成功(1.6ms) 成功(0.2ms) 成功(1.4ms)
50 万元素 失败(栈溢出错误) 成功(0.4ms) 成功(4.4ms)
100 万元素 失败(栈溢出错误) 成功(0.6ms) 成功(23.9ms)

从结果中可以得出两个核心结论:

  1. 稳定性:展开运算符在数据量超过 10 万后就会触发栈溢出,而循环遍历和 Reduce 方法在 100 万级数据下仍能稳定运行;

  2. 性能:循环遍历的性能最优(耗时最短),Reduce 方法略逊于循环(函数调用有额外开销),展开运算符在小数据量下表现尚可,但稳定性极差。

三、解决方案:从修复到预防

针对「Math.min/max 处理大数据数组」的问题,我们可以从「即时修复」和「长期预防」两个层面制定方案。

方案 1:循环遍历(性能最优)

适用于对性能要求高的场景(如大数据图表、实时计算),代码如下:

const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  for (let i = 1; i < data.length; i++) {
    min = Math.min(min, data[i]);
    max = Math.max(max, data[i]);
  }
  return [min, max];
};const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  for (let i = 1; i < data.length; i++) {
    min = Math.min(min, data[i]);
    max = Math.max(max, data[i]);
  }
  return [min, max];
};

方案 2:Reduce 方法(代码简洁)

适用于代码风格偏向函数式编程的场景,代码如下:

const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  return data.reduce(
    (acc, curr) => [Math.min(acc[0], curr), Math.max(acc[1], curr)],
    [data[0], data[0]]
  );
};

方案 3:分批处理(超大数据量)

当数据量达到「千万级」时,即使循环遍历也可能有内存压力,此时可以分批处理:

const getMinMaxBatch = (data: number[], batchSize = 100000): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  
  // 分批处理
  for (let i = 0; i < data.length; i += batchSize) {
    const batch = data.slice(i, i + batchSize);
    for (const num of batch) {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
  
  return [min, max];
};

方案 4:长期预防:建立编码规范

为了避免类似问题再次发生,我们还可以考虑在团队中建立以下编码规范:

  1. 禁止用展开运算符处理未知大小的数组:如果数组长度可能超过 1 万,坚决不用 Math.min(...array) 或 Math.max(...array);
  1. 优先选择循环遍历处理大数据:在性能敏感场景(如数据可视化、列表筛选),优先使用 for 循环而非 Reduce 或其他函数式方法;
  1. 添加数据量校验:对输入数组的长度进行限制,超过阈值时给出警告或分批处理;
  1. 单元测试覆盖边界场景:在单元测试中加入「大数据量」场景(如 10 万、100 万元素),提前暴露问题。

四、总结:跳出「简洁代码」的陷阱

这个看似简单的栈溢出问题,给我们带来了三个深刻的启示:

  1. 简洁不等于优质:很多开发者追求「一行代码解决问题」,但忽略了代码的稳定性和性能。在 JavaScript 中,展开运算符、eval、with 等语法虽然简洁,但往往隐藏着风险;
  1. 关注数据量变化:前端开发不再是「小数据时代」,随着图表、大数据列表、实时数据流的普及,我们必须在代码设计阶段就考虑「大数据场景」;
  1. 重视边界测试:开发环境中的「小数据测试」无法覆盖生产环境的「大数据场景」,必须通过边界测试(如最大数据量、空数据、异常数据)验证代码稳定性。

最后,用一句话总结:优秀的前端工程师,不仅要写出「能运行」的代码,更要写出「稳定、高效、可扩展」的代码。这个栈溢出问题,正是我们从「会写代码」到「写好代码」的一次重要成长。

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

前言

目前成熟的白板工具已经很多了,想探索下内部的实现原理,为远程团队协作、在线教育、设计评审和头脑风暴场景设计,通过高效的 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 公式: 支持动态公式编辑

性能优化

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

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

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

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

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

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

React 如何处理高频的实时数据?

同步至个人站点:React 如何处理高频的实时数据?

069.png

最近,我遇到了一个很有意思的 React 问题。

我需要开发一个实时的日志查看器,功能上需要实时展示服务运行的日志。因为这个项目是内部的,我这里大概抽象一下:

后端使用 SSE(Server-Sent Events) 技术,源源不断地把日志推送给前端。

当日志一条一条、不紧不慢地过来时,一切正常。

但是,当我预览一个已经完成的任务日志时,网页卡顿了一下。浏览器控制台显示了一个 React 开发者很熟悉的错误:

Uncaught Error: Maximum update depth exceeded... (错误:超过最大更新深度)

这个错误通常意味着,存在什么组件陷入了无限循环。比如,组件的渲染函数里直接调用了 setState,导致“渲染 → 更新状态 → 触发渲染 → ...”的死循环。

比如这样:

export default function Demo() {
  const [count, setCount] = useState(0)
  setCount(count + 1)
  return <h1>Count: {count}</h1>
}

但我的代码并没有这样的逻辑,该使用 useEffect 的地方都使用了。我只是在 SSE 的事件回调里更新状态。

// 示意代码
const source = new EventSource("/api/logs")
source.addEventListener("log", (event) => {
  // 每来一条日志,就调用 set 函数
  appendLog(event.data)
})

那么,问题出在哪里呢?

问题的根源:高频更新

起初我以为是哪里的更新逻辑不对,让 claude 排查很久都没找到具体问题。在给现有函数增加了不少缓存,比如useMemouseCallback,甚至 React.memo 都使用上了,仍旧没有解决这个报错。

代码没有问题,那么问题就应该出现在一些极端场景导致的高频渲染。比如网络?我才打开控制台的网络部分,看到几乎在很短时间内,上百条的 log 被推送过来!

到这里问题就和清晰了:当服务器在短时间内(比如 1 秒内)推送上百条日志时,每一个 log 都触发了 React 进行重新渲染,这里触发了 React 的某些机制,React 对这种行为发出了报错。

React 内部有一个“嵌套更新计数器”,用来防止无限循环。

简单说,如果在一次渲染(Render)的过程中,又因为某些原因触发了新的状态更新,这就叫“嵌套更新”。当这个次数短时间内超过一个阈值(通常是 50 次),React 就会认为你“可能”写了一个 Bug,于是主动抛出错误,终止程序。

我们的问题就出在这里。SSE 的事件回调来得太快了。

当服务器在 1 秒内推送 150 条日志时,浏览器的事件循环会疯狂执行回调:

  1. SSE 事件 1 抵达 → appendLog() → 触发 React 更新(第 1 次)
  2. React 还没来得及渲染,SSE 事件 2 抵达 → appendLog() → 触发 React 更新(第 2 次)
  3. ...
  4. SSE 事件 50 抵达 → appendLog() → 触发 React 更新(第 50 次)
  5. SSE 事件 51 抵达 → appendLog() → 触发 React 更新(第 51 次)

在 React 看来,这 51 次更新几乎是“同时”发生的,它无法分辨这是“51 条独立日志”还是“一个死循环”。为了保护自己,它选择了报错。

问题的本质是:数据接收的频率(高频)和 React 状态更新的频率(低频)不匹配。

我们不能每收到一条数据,就立刻更新一次状态。

后续我了解到 React 18 版本对高频渲染的问题进行了优化,但它目前仅适用于 React 事件处理函数内的同步更新。对于 SSE 回调、fetch 回调、setInterval 等异步事件源触发的更新,仍需手动实现批处理。

解决方案:批处理(Batching)

既然不能一条一条地更新,那很自然就想到,能不能把日志“攒一下”,再一次性提交给 React?

这就是“批处理”(Batching)思想。

我们不再是“来一条,更新一次”,而是“来 N 条,更新一次”。

实现这个功能的关键,是需要一个“缓冲区”(Buffer)和一个“定时器”(Timer)。

  1. 缓冲区:需要一个地方暂存日志,但这个地方本身不能是 React 的 state(否则又触发渲染了)。useRef 是最合适的人选。
  2. 定时器:需要一个机制,在“攒”日志的间隙,把它们统一提交。setTimeout(..., 0) 是这里的法宝。

代码实现

我们来改造一下 log 事件的处理。

首先,在组件里定义缓冲区和定时器:

export default function LogPage() {
  // 1. 从 store 获取批量更新的方法
  const appendLogs = useLogStore((state) => state.appendLogs)

  // 2. 批处理缓冲区(使用 ref 不会触发渲染)
  const batchBufferRef = useRef([])

  // 3. 定时器引用(保证只有一个定时器在运行)
  const batchTimerRef = useRef(null)

  // ...
}

其次,实现一个“提交缓冲区”的函数 flushBatch

// 4. 批量提交函数
const flushBatch = useCallback(() => {
  // 如果缓冲区有数据
  if (batchBufferRef.current.length > 0) {
    // 一次性提交给 store
    appendLogs(batchBufferRef.current)
    // 清空缓冲区
    batchBufferRef.current = []
  }
  // 重置定时器引用
  batchTimerRef.current = null
}, [appendLogs]) // 依赖 appendLogs

最后,修改 SSE 的事件处理函数 handleLogEvent

// 5. 新的 SSE 事件处理函数
const handleLogEvent = useCallback(
  (event) => {
    const entry = {
      /* ...解析日志... */
    }

    // 重点:不再直接调用 appendLog
    // 而是将日志加入缓冲区
    batchBufferRef.current.push(entry)

    // 如果还没有计划批处理,则在下一个事件循环中执行
    if (batchTimerRef.current === null) {
      batchTimerRef.current = window.setTimeout(flushBatch, 0)
    }
  },
  [flushBatch] // 依赖 flushBatch
)

为什么是 setTimeout(..., 0)

你可能会问,为什么是 setTimeout(..., 0)

这是一个很巧妙的技巧。它并不是真的“延迟 0 毫秒”,而是告诉浏览器:“请在当前这一轮事件循环(Event Loop)的同步代码都执行完之后,再执行这个 flushBatch 函数。”

当 150 条日志在短时间内涌入时,会发生什么?

  1. 事件 1 抵达 → push 到缓冲区 → setTimeout 注册一个 flushBatch 回调。
  2. 事件 2 抵达 → push 到缓冲区 → 检查定时器,发现已有,跳过。
  3. 事件 3 抵达 → push 到缓冲区 → 跳过。
  4. ...
  5. 事件 150 抵达 → push 到缓冲区 → 跳过。
  6. (当前宏任务结束,所有同步代码执行完毕)
  7. 浏览器从任务队列中取出 flushBatch 回调,执行。
  8. flushBatch 函数将 150 条日志一次性提交给 React。

于是,150 次 setState 调用,被神奇地合并成了 1 次。应用流畅如初。

(完)

一文读懂 Uniapp 小程序登录流程

一、微信小程序登录原理

登录的标准流程如下:

步骤 动作 说明
1 小程序端调用 uni.login() 获取 code(临时登录凭证)
2 小程序端把 code 发给自己的服务器 通常是 /api/login
3 服务器请求微信接口 https://api.weixin.qq.com/sns/jscode2session 用 appid + secret + code 换取 openid 和 session_key
4 服务器生成自己的 token 返回前端 对应平台用户身份
5 小程序保存 token 并登录成功 下次启动可直接使用

二、前端开发(UniApp + Vue3)

登录页面代码

/pages/login/login.vue

<template>
  <view class="container">
    <view class="title">欢迎使用小程序</view>
    <button @click="handleLogin" class="login-btn">微信一键登录</button>
  </view>
</template>

<script setup>
import { ref } from 'vue'

const token = ref('')
const user = ref({})

// 微信登录
const handleLogin = async () => {
  try {
    // 1. 获取 code
    const { code } = await new Promise((resolve, reject) => {
      uni.login({
        provider: 'weixin',
        success: res => resolve(res),
        fail: err => reject(err)
      })
    })
    console.log('小程序登录凭证 code:', code)

    // 2. 发送到后端
    const [err, res] = await uni.request({
      url: 'http://localhost:3000/api/login', // 后端接口地址
      method: 'POST',
      data: { code }
    })
    if (err) throw new Error('请求失败')

    // 3. 处理后端返回数据
    token.value = res.data.token
    user.value = res.data.user

    uni.setStorageSync('token', token.value)
    uni.setStorageSync('userInfo', user.value)

    uni.showToast({ title: '登录成功', icon: 'success' })
    console.log('登录结果:', res.data)
  } catch (e) {
    console.error(e)
    uni.showToast({ title: '登录失败', icon: 'none' })
  }
}
</script>

<style>
.container {
  margin-top: 200rpx;
  text-align: center;
}
.title {
  font-size: 40rpx;
  margin-bottom: 80rpx;
}
.login-btn {
  background-color: #07c160;
  color: #fff;
  width: 80%;
  border-radius: 12rpx;
}
</style>

✅ 点击按钮会自动执行微信登录流程。


三、后端实现(Node.js)

1️⃣ 环境搭建

创建一个 server 文件夹,执行命令:

npm init -y
npm install express axios jsonwebtoken cors

2️⃣ 创建 server.js

const express = require('express')
const axios = require('axios')
const jwt = require('jsonwebtoken')
const cors = require('cors')

const app = express()
app.use(cors())
app.use(express.json())

// 你的微信小程序信息
const APPID = '你的小程序AppID'
const SECRET = '你的小程序AppSecret'

// token密钥
const JWT_SECRET = 'my_secret_key'

// 模拟数据库
const users = {}

// 登录接口
app.post('/api/login', async (req, res) => {
  const { code } = req.body
  if (!code) return res.status(400).json({ message: '缺少code' })

  try {
    // 请求微信API换取openid和session_key
    const response = await axios.get(
      `https://api.weixin.qq.com/sns/jscode2session`, {
        params: {
          appid: APPID,
          secret: SECRET,
          js_code: code,
          grant_type: 'authorization_code'
        }
      }
    )

    const { openid, session_key, errcode, errmsg } = response.data
    if (errcode) {
      return res.status(400).json({ message: '微信登录失败: ' + errmsg })
    }

    console.log('用户openid:', openid)

    // 模拟创建/更新用户
    if (!users[openid]) {
      users[openid] = { openid, createTime: new Date() }
    }

    // 生成自定义 token
    const token = jwt.sign({ openid }, JWT_SECRET, { expiresIn: '7d' })

    return res.json({
      token,
      user: users[openid]
    })
  } catch (err) {
    console.error(err)
    res.status(500).json({ message: '服务器错误' })
  }
})

// 验证 token 接口(示例)
app.get('/api/profile', (req, res) => {
  const auth = req.headers.authorization
  if (!auth) return res.status(401).json({ message: '请先登录' })

  try {
    const decoded = jwt.verify(auth.replace('Bearer ', ''), JWT_SECRET)
    res.json({ user: users[decoded.openid] })
  } catch {
    res.status(401).json({ message: 'token无效' })
  }
})

// 启动服务器
app.listen(3000, () => {
  console.log('✅ Server running on http://localhost:3000')
})

✅ 启动命令:

node server.js

四、运行测试

  1. 启动后端:

    node server.js
    
  2. 打开 HBuilderX → 运行 → 运行到微信开发者工具

  3. 点击 “一键登录”,查看控制台输出

    • 小程序端日志会打印 code
    • 后端会打印 openid
  4. Toast 提示 “登录成功” ✅


五、持久化登录

登录成功后:

  • 将 token 存到 uni.setStorageSync('token', token)
  • 下次启动时判断本地是否有 token,有则不再重复登录。
// 在 App.vue 的 onLaunch 中
onLaunch() {
  const token = uni.getStorageSync('token')
  if (token) {
    console.log('用户已登录:', token)
  } else {
    uni.reLaunch({ url: '/pages/login/login' })
  }
}

六、重点总结

模块 工具 功能
前端 uni.login() 获取 code
前端 uni.request() 调后端登录接口
后端 axios 请求微信接口 用 code 换 openid
后端 jsonwebtoken 生成自定义 token
前端 uni.setStorageSync 保存 token

✅ 最终效果:

  • 点击登录按钮 → 自动通过微信授权登录
  • 后端生成 token 并返回
  • 前端保存登录状态
  • 可扩展为用户注册、绑定手机号等功能。

面试过别人后,我对面试祛魅了

由于公司老员工走了一些,我一不小心变成最老的前端了

所以有幸能够担任公司前端一面的工作

和每位应聘者交流的过程,也是我的学习过程,从中发现一些不一样的视角的东西,写下来记录一下

老了后回来再看看掘金,也是自己坚持写文章的原因之一,到时候抱着孩子说,看爷爷当年写的文章,这么多人点赞嘞!

沟通表达能力非常重要

坦白讲,你的技术实力有时候不如表达能力有竞争力,即使你的技术再NB,问个问题,半天没有表达清楚,怎么和复杂的实际工作中沟通呢

但是这个沟通表达能力有点邪乎,没有明确的标准

遇到过什么难点,怎么解决

这个问题不是很好回答,但是一定不要回答没有遇到什么难点,其实问这个目的 一是正好看你的技术深度,二是看一下表达能力,即使真的没有很难的点,也讲一下自己认为比较费劲实现的 功能,清晰流畅准确的表达出来也是很加分的

八股还是会问的

AI冲击下问八股文好像没啥意义了,不会的都问一下AI就行了,但是多多少少还是有意义的

一个节流防抖使用场景都说不上来的人,即使在AI的加持下,其实力我也持怀疑态度

刚毕业的校招生说不上来也就算了,工作三年不知道节流防抖好像多多少少有点说不过去了

学历还是好使

坦白讲,遇到高学历的,对其技术要求确实放松了条件,我也想说程序员最重要的是技术实力,学历不重要

但是真正轮到自己的时候,技术实力有时候不如学历那么明显,可量化

以及领导一听XX大学的,他也认可,不然还得证明这人不错

所以你看,只要你能证明你的实力优势大于学历的劣势,那么学历完全不是问题

只可惜对大部分人来讲实力劣势与学历劣势共存(别骂了别骂了)

在线简历、Blog、Github

简历中有在线简历、Blog、Github等,还是比较加分的,可能对于大公司来讲,你的github没有很高star,别人不觉得你优秀

但是对于小公司来时,你有Github,至少证明你 用心一些,以及自己想探索一些项目

面试真就是碰运气

面试官也是在繁忙的工作中与你沟通,可能面试不过就是不和面试官的“胃口”,所以大家千万别灰心丧气

END

以上是我自己的一些看法,祝大家一些顺利

Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

一、序 言

在分布式系统中,网络请求的可靠性直接决定了服务质量。想象一下,当你的支付系统因第三方API超时导致订单状态不一致,或因瞬时网络抖动造成用户操作失败,这些问题往往源于HTTP客户端缺乏完善的超时控制和重试策略。Golang标准库虽然提供了基础的HTTP客户端实现,但在高并发、高可用场景下,我们需要更精细化的策略来应对复杂的网络环境。

二、超时控制的风险与必要性

2024年Cloudflare的网络报告显示,78%的服务中断事件与不合理的超时配置直接相关。当一个HTTP请求因目标服务无响应而长时间阻塞时,不仅会占用宝贵的系统资源,更可能引发级联故障——大量堆积的阻塞请求会耗尽连接池资源,导致新请求无法建立,最终演变为服务雪崩。超时控制本质上是一种资源保护机制,通过设定合理的时间边界,确保单个请求的异常不会扩散到整个系统。

超时配置不当的两大典型风险:

  • DoS攻击放大效应:缺乏连接超时限制的客户端,在遭遇恶意慢响应攻击时,会维持大量半开连接,迅速耗尽服务器文件描述符。
  • 资源利用率倒挂:当ReadTimeout设置过长(如默认的0表示无限制),慢请求会长期占用连接池资源。Netflix的性能数据显示,将超时时间从30秒优化到5秒后,连接池利用率提升了400% ,服务吞吐量增长2.3倍。

三、超时参数示例

永远不要依赖默认的http.DefaultClient,其Timeout为0(无超时)。生产环境必须显式配置所有超时参数,形成防御性编程习惯。

以下代码展示如何通过net.Dialer配置连接超时和keep-alive策略:

transport := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,  // TCP连接建立超时
        KeepAlive: 30 * time.Second, // 连接保活时间
        DualStack: true,             // 支持IPv4/IPv6双栈
    }).DialContext,
    ResponseHeaderTimeout: 5 * time.Second, // 等待响应头超时
    MaxIdleConnsPerHost:   100,             // 每个主机的最大空闲连接
}
client := &http.Client{
    Transport: transport,
    Timeout:   10 * time.Second, // 整个请求的超时时间
}

四、基于context的超时实现

context.Context为请求超时提供了更灵活的控制机制,特别是在分布式追踪和请求取消场景中。与http.Client的超时参数不同,context超时可以实现请求级别的超时传递,例如在微服务调用链中传递超时剩余时间。

4.1 上下文超时传递

如图所示,context通过WithTimeout或WithDeadline创建超时上下文,在请求过程中逐级传递。当父context被取消时,子context会立即终止请求,避免资源泄漏。

4.2 带追踪的超时控制

func requestWithTracing(ctx context.Context) (*http.Response, error) {
    // 从父上下文派生5秒超时的子上下文
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 确保无论成功失败都取消上下文
    
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    if err != nil {
        return nil, fmt.Errorf("创建请求失败: %v", err)
    }
    
    // 添加分布式追踪信息
    req.Header.Set("X-Request-ID", ctx.Value("request-id").(string))
    
    client := &http.Client{
        Transport: &http.Transport{
            DialContext: (&net.Dialer{
                Timeout: 2 * time.Second,
            }).DialContext,
        },
        // 注意: 此处不设置Timeout,完全由context控制
    }
    
    resp, err := client.Do(req)
    if err != nil {
        // 区分上下文取消和其他错误
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("请求超时: %w", ctx.Err())
        }
        return nil, fmt.Errorf("请求失败: %v", err)
    }
    return resp, nil
}

关键区别:context.WithTimeout与http.Client.Timeout是叠加关系而非替代关系。当同时设置时,取两者中较小的值。

五、重试策略

网络请求失败不可避免,但盲目重试可能加剧服务负载,甚至引发惊群效应。一个健壮的重试机制需要结合错误类型判断、退避算法和幂等性保证,在可靠性和服务保护间取得平衡。

5.1 指数退避与抖动

指数退避通过逐渐增加重试间隔,避免对故障服务造成二次冲击。Golang实现中需加入随机抖动,防止多个客户端同时重试导致的波峰效应

以下是简单的重试实现示例:

type RetryPolicy struct {
    MaxRetries    int
    InitialBackoff time.Duration
    MaxBackoff    time.Duration
    JitterFactor  float64 // 抖动系数,建议0.1-0.5
}


// 带抖动的指数退避
func (rp *RetryPolicy) Backoff(attempt int) time.Duration {
    if attempt <= 0 {
        return rp.InitialBackoff
    }
    // 指数增长: InitialBackoff * 2^(attempt-1)
    backoff := rp.InitialBackoff * (1 << (attempt - 1))
    if backoff > rp.MaxBackoff {
        backoff = rp.MaxBackoff
    }
    // 添加抖动: [backoff*(1-jitter), backoff*(1+jitter)]
    jitter := time.Duration(rand.Float64() * float64(backoff) * rp.JitterFactor)
    return backoff - jitter + 2*jitter // 均匀分布在抖动范围内
}


// 通用重试执行器
func Retry(ctx context.Context, policy RetryPolicy, fn func() error) error {
    var err error
    for attempt := 0; attempt <= policy.MaxRetries; attempt++ {
        if attempt > 0 {
            // 检查上下文是否已取消
            select {
            case <-ctx.Done():
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            default:
            }
            
            backoff := policy.Backoff(attempt)
            timer := time.NewTimer(backoff)
            select {
            case <-timer.C:
            case <-ctx.Done():
                timer.Stop()
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            }
        }
        
        err = fn()
        if err == nil {
            return nil
        }
        
        // 判断是否应该重试
        if !shouldRetry(err) {
            return err
        }
    }
    return fmt.Errorf("达到最大重试次数 %d: %w", policy.MaxRetries, err)
}

5.2 错误类型判断

盲目重试所有错误不仅无效,还可能导致数据不一致。shouldRetry函数需要精确区分可重试错误类型:

func shouldRetry(err error) bool {
    // 网络层面错误
    var netErr net.Error
    if errors.As(err, &netErr) {
        // 超时错误和临时网络错误可重试
        return netErr.Timeout() || netErr.Temporary()
    }
    
    // HTTP状态码判断
    var respErr *url.Error
    if errors.As(err, &respErr) {
        if resp, ok := respErr.Response.(*http.Response); ok {
            switch resp.StatusCode {
            case 429, 500, 502, 503, 504:
                return true // 限流和服务器错误可重试
            case 408:
                return true // 请求超时可重试
            }
        }
    }
    
    // 应用层自定义错误
    if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrServiceUnavailable) {
        return true
    }
    
    return false
}

行业最佳实践:Netflix的重试策略建议:对5xx错误最多重试3次,对429错误使用Retry-After头指定的间隔,对网络错误使用指数退避(初始100ms,最大5秒)。

六、幂等性保证

重试机制的前提是请求必须是幂等的,否则重试可能导致数据不一致(如重复扣款)。实现幂等性的核心是确保多次相同请求产生相同的副作用,常见方案包括请求ID机制和乐观锁。

6.1 请求ID+Redis实现

基于UUID请求ID和Redis的幂等性检查机制,可确保重复请求仅被处理一次:

type IdempotentClient struct {
    redisClient *redis.Client
    prefix      string        // Redis键前缀
    ttl         time.Duration // 幂等键过期时间
}


// 生成唯一请求ID
func (ic *IdempotentClient) NewRequestID() string {
    return uuid.New().String()
}


// 执行幂等请求
func (ic *IdempotentClient) Do(req *http.Request, requestID string) (*http.Response, error) {
    // 检查请求是否已处理
    key := fmt.Sprintf("%s:%s", ic.prefix, requestID)
    exists, err := ic.redisClient.Exists(req.Context(), key).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等检查失败: %v", err)
    }
    if exists == 1 {
        // 返回缓存的响应或标记为重复请求
        return nil, fmt.Errorf("请求已处理: %s", requestID)
    }
    
    // 使用SET NX确保只有一个请求能通过检查
    set, err := ic.redisClient.SetNX(
        req.Context(),
        key,
        "processing",
        ic.ttl,
    ).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等锁失败: %v", err)
    }
    if !set {
        return nil, fmt.Errorf("并发请求冲突: %s", requestID)
    }
    
    // 执行请求
    client := &http.Client{/* 配置 */}
    resp, err := client.Do(req)
    if err != nil {
        // 请求失败时删除幂等标记
        ic.redisClient.Del(req.Context(), key)
        return nil, err
    }
    
    // 请求成功,更新幂等标记状态
    ic.redisClient.Set(req.Context(), key, "completed", ic.ttl)
    return resp, nil
}

关键设计:幂等键的TTL应大于最大重试周期+业务处理时间。例如,若最大重试间隔为30秒,处理耗时5秒,建议TTL设置为60秒,避免重试过程中键过期导致的重复处理。

6.2 业务层幂等策略

对于写操作,还需在业务层实现幂等逻辑:

  • 更新操作:使用乐观锁(如UPDATE ... WHERE version = ?)
  • 创建操作:使用唯一索引(如订单号、外部交易号)
  • 删除操作:采用"标记删除"而非物理删除

七、性能优化

高并发场景下,HTTP客户端的性能瓶颈通常不在于网络延迟,而在于连接管理和内存分配。通过合理配置连接池和复用资源,可显著提升吞吐量。

7.1 连接池配置

http.Transport的连接池参数优化对性能影响巨大,以下是经过生产验证的配置:

func NewOptimizedTransport() *http.Transport {
    return &http.Transport{
        // 连接池配置
        MaxIdleConns:        1000,  // 全局最大空闲连接
        MaxIdleConnsPerHost: 100,   // 每个主机的最大空闲连接
        IdleConnTimeout:     90 * time.Second, // 空闲连接超时时间
        
        // TCP配置
        DialContext: (&net.Dialer{
            Timeout:   2 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        
        // TLS配置
        TLSHandshakeTimeout: 5 * time.Second,
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: false,
            MinVersion:         tls.VersionTLS12,
        },
        
        // 其他优化
        ExpectContinueTimeout: 1 * time.Second,
        DisableCompression:    false, // 启用压缩
    }
}

Uber的性能测试显示,将MaxIdleConnsPerHost从默认的2提升到100后,针对同一API的并发请求延迟从85ms降至12ms,吞吐量提升6倍。

7.2 sync.Pool内存复用

频繁创建http.Request和http.Response会导致大量内存分配和GC压力。使用sync.Pool复用这些对象可减少90%的内存分配:

var requestPool = sync.Pool{
    New: func() interface{} {
        return &http.Request{
            Header: make(http.Header),
        }
    },
}


// 从池获取请求对象
func AcquireRequest() *http.Request {
    req := requestPool.Get().(*http.Request)
    // 重置必要字段
    req.Method = ""
    req.URL = nil
    req.Body = nil
    req.ContentLength = 0
    req.Header.Reset()
    return req
}


// 释放请求对象到池
func ReleaseRequest(req *http.Request) {
    requestPool.Put(req)
}

八、总结

HTTP请求看似简单,但它连接着整个系统的"血管"。忽视超时和重试,就像在血管上留了个缺口——平时没事,压力一来就大出血。构建高可靠的网络请求需要在超时控制、重试策略、幂等性保证和性能优化之间取得平衡。

记住,在分布式系统中,超时和重试不是可选功能,而是生存必需。

扩展资源:

往期回顾

  1. RN与hawk碰撞的火花之C++异常捕获|得物技术

  2. 得物TiDB升级实践

  3. 得物管理类目配置线上化:从业务痛点到技术实现

  4. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  5. RAG—Chunking策略实战|得物技术

文 /梧

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

PerformanceObserver 性能条目类型(Entry Types)

PerformanceObserver 是 Web 性能监测的核心 API,通过指定 entryTypes 可以观察不同类型的性能指标。以下是所有支持的条目类型及其详细说明。


一、核心 Entry Types 列表

1.  'navigation'  — 页面导航性能

说明:测量主文档的导航和加载时间,提供完整的页面加载生命周期数据。

常用指标

  • entry.duration:总加载时间
  • entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart:DOM 解析时间
  • entry.loadEventEnd - entry.loadEventStart:资源加载时间

使用场景:分析页面整体加载性能,计算 DNS、TCP、TTFB 等关键指标

2.  'resource'  — 资源加载性能

说明:测量页面上所有资源的加载时间,包括图片、CSS、JavaScript、XHR/Fetch 请求等。

关键属性

  • entry.name:资源 URL
  • entry.initiatorType:资源类型(img, script, link, xmlhttprequest, fetch
  • entry.duration:加载耗时
  • entry.decodedBodySize:资源大小

使用场景:定位慢资源、监控 CDN 效果、优化资源加载策略

3.  'paint'  — 渲染性能

说明:测量关键渲染时间点,主要用于 FCP(首次内容绘制)。

事件名称

  • entry.name === 'first-contentful-paint':FCP 时间

使用场景:Core Web Vitals 核心指标监控

4.  'largest-contentful-paint'  — 最大内容绘制

说明:作为独立条目类型提供更精确的 LCP 数据,包含详细的元素信息。

关键属性

  • entry.startTime:LCP 发生时间
  • entry.element:触发 LCP 的 DOM 元素
  • entry.url:图片资源的 URL(如适用)

使用场景:精准定位影响 LCP 的元素,优化最大内容加载

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(' LCP 详情:', {
      time: entry.startTime.toFixed(2) + 'ms',
      element: entry.element?.tagName,
      url: entry.url
    });
  }
});
observer.observe({ entryTypes: ['largest-contentful-paint'], buffered: true });

5.  'first-input'  — 首次输入延迟

说明:测量用户首次交互(点击、触摸、按键)到浏览器响应的延迟时间,是 Core Web Vitals 核心指标。

关键属性

  • entry.name:交互类型(click, pointerdown, keydown
  • entry.duration:总延迟时间
  • entry.processingStart / entry.processingEnd:事件处理时间

使用场景:评估用户对应用响应性的第一印象

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('首次交互延迟:', {
      type: entry.name,
      delay: entry.duration + 'ms'
    });
  }
});
observer.observe({ entryTypes: ['first-input'], buffered: true });

6.  'layout-shift'  — 布局偏移(CLS)

说明:测量页面元素的意外移动,用于计算 Cumulative Layout Shift (CLS)

关键属性

  • entry.value:单次布局偏移得分
  • entry.hadRecentInput:是否由用户输入触发(通常只关注 false 的情况)

使用场景:提升视觉稳定性,优化用户体验

7.  'event'  — 用户事件延迟

说明:测量所有用户交互事件(点击、键盘输入)的延迟时间。

关键属性

  • entry.name:事件类型(click, keydown 等)
  • entry.duration:事件处理耗时

使用场景:监控交互响应性,发现事件处理瓶颈

8.  'longtask'  — 长任务监控

说明:识别主线程上耗时超过 50 毫秒的任务,这些任务会阻塞用户交互。

关键属性

  • entry.duration:任务耗时
  • entry.attribution:导致长任务的代码信息

使用场景:发现 JavaScript 性能瓶颈,优化主线程执行

9.  'long-animation-frame'  — 长动画帧(LoAF)

说明新一代长任务监控,比 'longtask' 更细粒度,提供完整的调用栈和脚本归因。

关键属性

  • entry.duration:帧耗时
  • entry.scripts:详细的脚本执行信息数组

使用场景:精确诊断阻塞动画和交互的脚本

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('长动画帧:', {
      duration: entry.duration + 'ms',
      scripts: entry.scripts.map(s => ({
        source: s.sourceURL,
        function: s.sourceFunctionName,
        duration: s.duration + 'ms'
      }))
    });
  }
});
observer.observe({ entryTypes: ['long-animation-frame'], buffered: true });

10.  'element'  — 元素级性能

说明:观察特定 DOM 元素的渲染时间,用于更精确的 LCP 测量。

使用要求:元素需添加 elementtiming 属性

<img src="hero.jpg" elementtiming="hero-image" />

使用场景:监控关键业务元素的渲染性能

11.  'mark'  — 自定义时间戳标记

说明:通过 performance.mark() 创建命名时间戳,标记业务关键节点。

使用方式

performance.mark('user_login_start');
// ... 执行代码
performance.mark('user_login_end');

使用场景:自定义业务逻辑性能监控,如组件初始化时间、API 调用耗时等

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntriesByType('mark')) {
    console.log('标记点:', entry.name, entry.startTime + 'ms');
  }
});
observer.observe({ entryTypes: ['mark'], buffered: true });

12.  'measure'  — 自定义时间区间测量

说明:通过 performance.measure() 计算两个 mark 之间的时间差,是业务性能监控的黄金标准。

使用方式

performance.measure('login_duration', 'user_login_start', 'user_login_end');

使用场景:精确测量业务操作耗时,生成自定义性能指标

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntriesByType('measure')) {
    console.log('测量区间:', entry.name, entry.duration + 'ms');
  }
});
observer.observe({ entryTypes: ['measure'], buffered: true });

13.  'visibility-state'  — 页面可见性变化

说明:监控页面从可见到隐藏、从隐藏到可见的状态转换,对移动端性能优化至关重要。

关键属性

  • entry.prevState:之前的状态(visible, hidden, prerender
  • entry.currState:当前的状态

使用场景:页面隐藏时暂停非关键任务,节省 CPU 和电量

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('可见性变化:', {
      from: entry.prevState,
      to: entry.currState
    });
    if (entry.currState === 'hidden') {
      pauseBackgroundTasks();
    }
  }
});
observer.observe({ entryTypes: ['visibility-state'], buffered: true });

二、浏览器兼容性说明

动态检查支持类型

通过 PerformanceObserver.supportedEntryTypes 获取当前浏览器支持的类型:

console.log(PerformanceObserver.supportedEntryTypes);
// 现代浏览器输出 (13):
// ['element', 'event', 'first-input', 'largest-contentful-paint', 
//  'layout-shift', 'long-animation-frame', 'longtask', 'mark', 
//  'measure', 'navigation', 'paint', 'resource', 'visibility-state']

注意:该属性是静态只读属性,返回数组reactnative.dev/docs/next/g…

兼容性差异

  • 现代浏览器 (Chrome 120+, Edge 120+):支持全部 13 种类型
  • Safari:支持基础类型,对新类型(如 long-animation-frame)支持较慢
  • Firefox:支持核心类型,部分实验性类型不支持
  • IE:完全不支持 PerformanceObserver

推荐做法

const supportedTypes = PerformanceObserver.supportedEntryTypes || [];
const desiredTypes = [
  'navigation', 'resource', 'paint', 'first-input', 'layout-shift',
  'longtask', 'event', 'largest-contentful-paint', 'long-animation-frame'
];
const entryTypes = desiredTypes.filter(type => supportedTypes.includes(type));

if (entryTypes.length > 0) {
  observer.observe({ entryTypes, buffered: true });
}

三、完整使用示例

监控多种性能指标

// 创建观察者实例
const observer = new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  
  entries.forEach(entry => {
    console.log(`类型: ${entry.entryType}, 名称: ${entry.name}, 耗时: ${entry.duration}ms`);
    
    // 根据类型分别处理
    switch(entry.entryType) {
      case 'navigation':
        console.log('页面加载完成:', entry.duration + 'ms');
        break;
      case 'resource':
        if (entry.duration > 200) {
          console.warn('慢资源:', entry.name);
        }
        break;
      case 'largest-contentful-paint':
        console.log('LCP:', entry.startTime.toFixed(2) + 'ms');
        break;
      case 'first-input':
        console.log('🖱️ 首次交互延迟:', entry.duration + 'ms');
        break;
      case 'layout-shift':
        if (!entry.hadRecentInput) {
          console.log('CLS 累积:', entry.value);
        }
        break;
      case 'long-animation-frame':
        console.error('长动画帧:', entry.duration + 'ms');
        break;
      case 'visibility-state':
        console.log('可见性变化:', entry.currState);
        break;
    }
  });
});

// 开始观察(推荐设置 buffered: true 获取历史数据)
observer.observe({
  entryTypes: [
    'navigation',
    'resource',
    'largest-contentful-paint',
    'first-input',
    'layout-shift',
    'long-animation-frame',
    'visibility-state'
  ],
  buffered: true
});

关键点

  • buffered: true :收集 observe() 调用之前已发生的性能条目,防止遗漏
  • disconnect() :不再需要时调用,释放资源

五、总结矩阵

# Entry Type 主要指标 典型阈值 所属标准 监控场景
1 navigation 页面加载时间 < 3s Navigation Timing 整体加载性能
2 resource 资源加载耗时 < 200ms Resource Timing 慢资源定位
3 paint FCP < 1.8s Paint Timing 首次渲染
4 largest-contentful-paint LCP < 2.5s LCP API 最大内容渲染
5 first-input FID < 100ms Event Timing 首次交互响应
6 layout-shift CLS < 0.1 Layout Instability 视觉稳定性
7 event 交互延迟 < 100ms Event Timing 所有事件延迟
8 longtask 主线程阻塞 > 50ms Long Tasks API 卡顿检测
9 long-animation-frame 长动画帧阻塞 > 50ms LoAF API 精细化卡顿分析
10 element 元素渲染时间 业务自定义 Element Timing 关键元素监控
11 mark 自定义时间戳 - User Timing 业务埋点
12 measure 自定义时间区间 业务自定义 User Timing 区间测量
13 visibility-state 页面可见性变化 - Page Visibility 节能优化

通过合理组合这些 entry types,可以构建全面的前端性能监控体系,为优化提供数据支撑

Cocos Creator 3.8 实现指定Node节点截图功能教程

Cocos Creator 3.8 实现指定Node节点截图功能教程

一、前言

在游戏开发中,截图功能是一项常见且实用的需求,它可以用于生成分享图片、保存游戏成就或创建预览图等。本教程将详细介绍如何在 Cocos Creator 3.8 中实现指定 Node 节点的截图功能,并将结果渲染到 Sprite 上。


二、功能实现原理

截图功能的核心原理是利用 Cocos Creator 的 Camera 组件和 RenderTexture(渲染纹理)。Camera 会将指定节点的内容渲染到 RenderTexture 上,然后我们可以从 RenderTexture 中读取像素数据,最终将其转换为图片或显示在 UI 上

关键组件:

  • Camera:负责捕获节点视觉内容
  • RenderTexture:作为渲染目标,存储图像数据
  • SpriteFrame:将渲染纹理转换为可显示的精灵帧

三、场景配置步骤

1. 配置目标节点

  • 将需要截图的节点连接到脚本的targetNode属性
  • 自定义一个capturelayer, 并将目标节点的layer设置为capture,同时主相机的Visibility也需要添加该layer
  • 确保节点在相机范围内可见

2. 设置预览节点:

  • 创建一个UI节点并添加Sprite组件
  • 将这个节点连接到脚本的previewNode属性

3. 创建截图专用相机:

  • 在目标节点中添加一个新Camera节点,创建到目标节点可以保证节点移动的时候相机会跟随移动
  • 调整相机位置和参数,使其能完整看到目标节点
  • 将相机组件的Target Texture属性留空(代码中动态设置)
  • 并将相机组件的Visibility设置为capture这一个,保证最终截图的只显示想要的内容

节点截图预览

相机参数截图预览

四、核心代码

需要注意的问题

  1. 图像缩放问题
    • 最终的宽高计算需要注意缩放的问题
  2. 截图空白或不全的问题
    • 相机的orthoHeight 设置为高度的一半(截图代码写的是宽度一半只有正方形的才不会有问题,已更正)

五、注意事项与优化建议

  1. 图像翻转问题

    • Cocos Creator渲染的图像数据是倒置的,需要通过代码手动翻转
  2. 渲染时机

    • 使用scheduleOnce延迟读取像素数据,确保渲染完成后再进行读取操作

Dart 3.10中的新的lint规则

前几天,Flutter 3.38 & Dart 3.10正式发布,详情参考Flutter3.38 带来了什么。这其中包括了一项新的lint:

remove_deprecations_in_breaking_versions

说人话就是:在主要版本中移除弃用功能

欢迎关注我的微信公众号:OpenFlutter,谢谢

当你想要在软件包中移除某些 API 时,你不会立即执行,而是会分阶段地逐渐淘汰。一旦您决定某个功能将在某个时间点移除,您就会在其上加上 @Deprecated 注解:

@Deprecated('This sucks. Use Class2')  
class Class1 {}  
  
class Class2 {}

然后,任何使用您软件包的用户,只要使用了被这样注解(即 @Deprecated)的任何功能,就会收到一条 deprecated_member_use 诊断消息。

那么,何时应该移除您之前声明已弃用的 API 呢? 这就是版本号约定发挥作用的时候了。最常见的约定被称为 SemVer(语义化版本控制) ,其定义可在 https://semver.org 查阅。

SemVer 规定,版本号必须包含三个数字:主版本号 (Major).次版本号 (Minor).修订号 (Patch) 。例如,Dart 的当前版本是 3.10.0

  • 3 是主版本号。
  • 10 是次版本号。
  • 0 是修订号。

当您发布一个新版本,它只修复了错误,而没有破坏任何您的软件包用户可能依赖的东西时,您应该增加修订号 (Patch) 。例如,Dart 的上一个版本是 3.9.2,这意味着他们发布了三个 3.9.x 版本,其中最后两个修复了一些错误。

当您发布一个新版本,它增加了新功能,但仍然没有破坏任何您的软件包用户可能依赖的东西时,您应该增加次版本号 (Minor) 。例如,从 3.9.23.10.0,Dart 增加了您的代码可以使用的新功能,但保证所有原本可以在 3.9.2 上构建的代码仍然可以在 3.10.0 上构建。

当您发布一个不兼容版本(breaking version) 时,您需要增加主版本号 (Major)

Dart 社区采纳了这一标准,但增加了一个额外的限制。尽管标准不保证第一个稳定版本 (1.0.0) 之前的版本之间有任何兼容性,但 Dart 仍然语义化地处理这些版本:在 1.0.0 之前,次版本号 (minor) 的增加意味着不兼容的变更 (breaking changes) 。这使得我们在宣布稳定版本之前,可以多次发出不兼容版本的信号,以改进我们的软件包。

不兼容版本对您的用户来说简直是地狱,因为除非他们在 pubspec.yaml 中明确允许,否则它不会自动被拉入他们的项目。他们使用的一些软件包可能需要您的新主版本,而其他软件包可能与它不兼容。这就是依赖地狱 (dependency hell) 。您必须避免过于频繁地发布新的主版本。

这就是为什么如果您计划进行不兼容的更改,您通常希望通过一个主版本尽可能多地一次性进行所有破坏性更改,以便您的用户只需适应一次。

因此,如果您发布了一个新的主版本,却忘记移除您原本想移除的东西,那将是非常可惜的。

因此,引入了新的代码风格检查工具(lint)来帮助您解决这个问题。

如果在您的 pubspec.yaml 中,您设置了不兼容的版本号x.0.00.x.0),这个 lint 就会标记出您可能仍然拥有的所有 @Deprecated 注解。

一个有趣的推论是:您不应该在一个不兼容版本中引入新的弃用。如果您这样做,lint 会标记它,您将不得不忽略它。一旦您决定在某处忽略它,您就有可能忽略那些应该被移除但被您遗忘的旧弃用功能所引发的合理警告。

因此,请首先发布一个正常的不兼容版本(其中不包含任何弃用) ,然后在下一个版本中弃用一些东西。顺便说一句,如果您引入了弃用功能,您应该增加次版本号 (Minor) ,而不仅仅是修订号 (Patch)。

这是因为您软件包的一些用户可能在他们的持续集成(CI)中设置了静态分析,并且有一个规则是不使用任何弃用的代码。当您弃用某个功能时,这个 CI 就会中断。因此,SemVer 标准中有一项约定,即修订号 (patch) 更新不会中断这种检查。

何时应该使用这个 linter 规则呢? 显然,对于那些构建并交付给用户的常规应用程序来说,它没有任何意义,因为这些用户看不到或使用任何代码。这条规则仅对可发布的软件包(publishable packages)有意义

Vue 3 defineModel 完全指南

Vue 3 defineModel 完全指南

目录

  1. 概述
  2. 基础概念
  3. 基本用法
  4. 高级特性
  5. TypeScript 支持
  6. 实战案例
  7. 最佳实践
  8. 常见问题
  9. 与传统方式的对比
  10. 总结

概述

defineModel 是 Vue 3.3 引入、3.4 稳定的一个编译器宏,用于简化组件的双向数据绑定实现。它让开发者能够更轻松地创建支持 v-model 的组件,减少了样板代码,提高了开发效率。

为什么需要 defineModel?

在 Vue 3.3 之前,实现一个支持 v-model 的组件需要:

// 传统方式 - 需要定义 props 和 emits
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})

而使用 defineModel 后:

// defineModel 方式 - 简洁明了
const modelValue = defineModel()

主要优势

  • 简化代码:减少重复的样板代码
  • 类型安全:更好的 TypeScript 支持
  • 修饰符支持:内置对 v-model 修饰符的支持
  • 性能优化:编译时优化,运行时开销更小

基础概念

什么是编译器宏?

defineModel 是一个编译器宏,这意味着它会在构建阶段被 Vue 编译器处理,转换为相应的运行时代码。编译器宏只在 <script setup> 中使用,不需要导入。

双向数据绑定原理

Vue 的 v-model 本质上是语法糖:

<!-- 父组件使用 -->
<ChildComponent v-model="count" />

<!-- 等价于 -->
<ChildComponent
  :model-value="count"
  @update:model-value="newValue => count = newValue"
/>

defineModel 自动处理这种 prop 和 emit 的配对关系。


基本用法

1. 简单的模型绑定

<!-- ChildComponent.vue -->
<script setup>
const modelValue = defineModel()
</script>

<template>
  <input
    v-model="modelValue"
    placeholder="输入内容..."
  />
</template>
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const message = ref('Hello World')
</script>

<template>
  <ChildComponent v-model="message" />
  <p>当前值: {{ message }}</p>
</template>

2. 命名模型

<!-- 自定义模型名称 -->
<script setup>
// 声明名为 'title' 的模型
const title = defineModel('title')

// 声明名为 'count' 的模型
const count = defineModel('count')
</script>

<template>
  <input v-model="title" placeholder="标题" />
  <input v-model="count" type="number" placeholder="数量" />
</template>
<!-- 父组件中使用 -->
<script setup>
import { ref } from 'vue'
import CustomModelComponent from './CustomModelComponent.vue'

const postTitle = ref('')
const postCount = ref(1)
</script>

<template>
  <CustomModelComponent
    v-model:title="postTitle"
    v-model:count="postCount"
  />
  <p>标题: {{ postTitle }}</p>
  <p>数量: {{ postCount }}</p>
</template>

3. 设置默认值和类型

<script setup>
// 带类型和默认值的模型
const name = defineModel({
  type: String,
  default: '匿名用户'
})

const age = defineModel({
  type: Number,
  default: 0
})

const isActive = defineModel({
  type: Boolean,
  default: false
})
</script>

<template>
  <div>
    <label>姓名: <input v-model="name" /></label>
    <label>年龄: <input v-model="age" type="number" /></label>
    <label>
      <input v-model="isActive" type="checkbox" />
      激活状态
    </label>
  </div>
</template>

高级特性

1. 模型修饰符

defineModel 内置对 v-model 修饰符的支持:

<!-- TextInput.vue -->
<script setup>
// 获取模型值和修饰符对象
const [modelValue, modelModifiers] = defineModel()

// 监听修饰符
watch(modelModifiers, (newModifiers) => {
  console.log('当前修饰符:', newModifiers)
})

// 使用修饰符处理数据
const processedValue = computed({
  get: () => modelValue.value,
  set: (value) => {
    if (modelModifiers.trim) {
      value = value.trim()
    }
    if (modelModifiers.upper) {
      value = value.toUpperCase()
    }
    modelValue.value = value
  }
})
</script>

<template>
  <input v-model="processedValue" />
  <p>修饰符: {{ JSON.stringify(modelModifiers) }}</p>
</template>
<!-- 使用修饰符 -->
<script setup>
import TextInput from './TextInput.vue'
const text = ref('')
</script>

<template>
  <TextInput v-model.trim.upper="text" />
  <p>处理后的值: {{ text }}</p>
</template>

2. 自定义修饰符

<!-- NumberInput.vue -->
<script setup>
const [modelValue, modelModifiers] = defineModel({
  // 自定义 getter/setter
  get(value) {
    // 处理自定义修饰符
    if (modelModifiers.currency) {
      return new Intl.NumberFormat('zh-CN', {
        style: 'currency',
        currency: 'CNY'
      }).format(value)
    }
    if (modelModifiers.percentage) {
      return `${value}%`
    }
    return value
  },
  set(value) {
    // 清理格式化后的值
    if (modelModifiers.currency) {
      return parseFloat(value.replace(/[¥,]/g, ''))
    }
    if (modelModifiers.percentage) {
      return parseFloat(value.replace('%', ''))
    }
    return parseFloat(value) || 0
  }
})
</script>

<template>
  <input v-model="modelValue" />
</template>

3. 复杂对象模型

<!-- UserProfile.vue -->
<script setup>
// 复杂对象的模型绑定
const user = defineModel({
  type: Object,
  default: () => ({
    name: '',
    email: '',
    age: 0,
    avatar: ''
  })
})

// 计算属性验证
const isFormValid = computed(() => {
  return user.value.name &&
         user.value.email &&
         user.value.email.includes('@') &&
         user.value.age > 0
})

// 方法
const resetForm = () => {
  user.value = {
    name: '',
    email: '',
    age: 0,
    avatar: ''
  }
}
</script>

<template>
  <div class="user-profile">
    <div class="form-group">
      <label>姓名:</label>
      <input v-model="user.name" />
    </div>

    <div class="form-group">
      <label>邮箱:</label>
      <input v-model="user.email" type="email" />
    </div>

    <div class="form-group">
      <label>年龄:</label>
      <input v-model="user.age" type="number" />
    </div>

    <div class="form-group">
      <label>头像URL:</label>
      <input v-model="user.avatar" />
    </div>

    <button @click="resetForm">重置</button>
    <p v-if="!isFormValid" class="error">
      请填写完整的用户信息
    </p>
  </div>
</template>

<style scoped>
.user-profile {
  max-width: 400px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
}

.form-group input {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.error {
  color: red;
}
</style>

TypeScript 支持

1. 基本类型注解

<script setup lang="ts">
// 基本类型注解
const text = defineModel<string>()
const number = defineModel<number>()
const boolean = defineModel<boolean>()

// 数组类型
const items = defineModel<string[]>({ default: () => [] })

// 对象类型
interface User {
  id: number
  name: string
  email: string
}

const user = defineModel<User>({
  type: Object as PropType<User>,
  required: true
})
</script>

2. 复杂类型定义

<script setup lang="ts">
import type { PropType } from 'vue'

interface FormField {
  id: string
  label: string
  type: 'text' | 'email' | 'number' | 'textarea'
  value: string | number
  required?: boolean
  placeholder?: string
  validation?: {
    min?: number
    max?: number
    pattern?: string
  }
}

const formFields = defineModel<FormField[]>({
  type: Array as PropType<FormField[]>,
  default: () => [],
  // 自定义验证函数
  validator: (value: FormField[]) => {
    return Array.isArray(value) &&
           value.every(field => field.id && field.label && field.type)
  }
})

// 获取修饰符的类型
const [modelValue, modelModifiers] = defineModel<string, {
  trim?: boolean
  uppercase?: boolean
  required?: boolean
}>()

// 类型安全的方法
const updateField = (index: number, value: string) => {
  if (formFields.value[index]) {
    formFields.value[index].value = value
  }
}
</script>

3. 泛型组件

<script setup lang="ts">
// 泛型模型组件
interface GenericModelProps<T> {
  value: T
  options?: T[]
}

// 使用泛型
const modelValue = defineModel<T>({
  type: [String, Number, Object, Array] as PropType<T>,
  required: true
})

// 类型推断
type ModelType = typeof modelValue extends { value: infer T } ? T : never
</script>

实战案例

案例1:自定义输入组件库

<!-- BaseInput.vue -->
<script setup lang="ts">
interface Props {
  type?: 'text' | 'email' | 'password' | 'number' | 'tel'
  placeholder?: string
  disabled?: boolean
  readonly?: boolean
  maxlength?: number
  minlength?: number
}

// 支持多种修饰符
const [modelValue, modelModifiers] = defineModel<string, {
  trim?: boolean
  uppercase?: boolean
  lowercase?: boolean
  number?: boolean
}>()

const props = withDefaults(defineProps<Props>(), {
  type: 'text',
  placeholder: '',
  disabled: false,
  readonly: false
})

const emit = defineEmits<{
  focus: [event: FocusEvent]
  blur: [event: FocusEvent]
  input: [event: Event]
  change: [event: Event]
}>()

// 处理修饰符
const processedValue = computed({
  get: () => {
    let value = modelValue.value || ''

    if (modelModifiers.uppercase) {
      value = value.toUpperCase()
    }
    if (modelModifiers.lowercase) {
      value = value.toLowerCase()
    }

    return value
  },
  set: (value: string) => {
    if (modelModifiers.trim) {
      value = value.trim()
    }
    if (modelModifiers.number) {
      value = value.replace(/[^\d.-]/g, '')
    }
    modelValue.value = value
  }
})

// 事件处理
const handleInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  processedValue.value = target.value
  emit('input', event)
}

const handleChange = (event: Event) => {
  const target = event.target as HTMLInputElement
  processedValue.value = target.value
  emit('change', event)
}
</script>

<template>
  <div class="base-input">
    <input
      :type="type"
      :value="processedValue"
      :placeholder="placeholder"
      :disabled="disabled"
      :readonly="readonly"
      :maxlength="maxlength"
      :minlength="minlength"
      @input="handleInput"
      @change="handleChange"
      @focus="$emit('focus', $event)"
      @blur="$emit('blur', $event)"
      class="input-field"
    />
  </div>
</template>

<style scoped>
.base-input {
  position: relative;
  display: inline-block;
}

.input-field {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.2s;
  box-sizing: border-box;
}

.input-field:focus {
  outline: none;
  border-color: #409eff;
}

.input-field:disabled {
  background-color: #f5f7fa;
  cursor: not-allowed;
}

.input-field:readonly {
  background-color: #f5f7fa;
}
</style>

案例2:可编辑表格组件

<!-- EditableTable.vue -->
<script setup lang="ts">
interface TableColumn {
  key: string
  title: string
  width?: string
  type?: 'text' | 'number' | 'select'
  options?: Array<{ label: string; value: any }>
  required?: boolean
}

interface TableRow {
  [key: string]: any
}

interface Props {
  columns: TableColumn[]
  data: TableRow[]
  editable?: boolean
  addable?: boolean
  deletable?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  editable: true,
  addable: true,
  deletable: true
})

// 双向绑定表格数据
const tableData = defineModel<TableRow[]>('data', {
  type: Array as PropType<TableRow[]>,
  required: true
})

// 计算属性
const hasData = computed(() => tableData.value && tableData.value.length > 0)

// 方法
const addRow = () => {
  const newRow: TableRow = {}
  props.columns.forEach(col => {
    newRow[col.key] = col.type === 'number' ? 0 : ''
  })
  tableData.value = [...tableData.value, newRow]
}

const deleteRow = (index: number) => {
  tableData.value = tableData.value.filter((_, i) => i !== index)
}

const updateCell = (rowIndex: number, columnKey: string, value: any) => {
  const newData = [...tableData.value]
  newData[rowIndex][columnKey] = value
  tableData.value = newData
}

// 验证
const validateRow = (row: TableRow): boolean => {
  return props.columns.every(col => {
    if (col.required && (!row[col.key] || row[col.key] === '')) {
      return false
    }
    return true
  })
}

const validateTable = (): boolean => {
  return tableData.value.every(row => validateRow(row))
}
</script>

<template>
  <div class="editable-table">
    <table class="table">
      <thead>
        <tr>
          <th v-for="column in columns" :key="column.key" :style="{ width: column.width }">
            {{ column.title }}
            <span v-if="column.required" class="required">*</span>
          </th>
          <th v-if="deletable" class="action-column">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(row, rowIndex) in tableData" :key="rowIndex" :class="{ 'invalid-row': !validateRow(row) }">
          <td v-for="column in columns" :key="column.key">
            <template v-if="editable">
              <input
                v-if="column.type === 'text' || !column.type"
                v-model="row[column.key]"
                @input="updateCell(rowIndex, column.key, ($event.target as HTMLInputElement).value)"
                type="text"
                class="cell-input"
              />
              <input
                v-else-if="column.type === 'number'"
                v-model.number="row[column.key]"
                @input="updateCell(rowIndex, column.key, ($event.target as HTMLInputElement).value)"
                type="number"
                class="cell-input"
              />
              <select
                v-else-if="column.type === 'select'"
                v-model="row[column.key]"
                @change="updateCell(rowIndex, column.key, ($event.target as HTMLSelectElement).value)"
                class="cell-select"
              >
                <option v-for="option in column.options" :key="option.value" :value="option.value">
                  {{ option.label }}
                </option>
              </select>
            </template>
            <template v-else>
              {{ row[column.key] }}
            </template>
          </td>
          <td v-if="deletable" class="action-column">
            <button @click="deleteRow(rowIndex)" class="delete-btn">删除</button>
          </td>
        </tr>
      </tbody>
    </table>

    <div v-if="addable" class="table-actions">
      <button @click="addRow" class="add-btn">添加行</button>
    </div>

    <div v-if="!validateTable()" class="validation-error">
      请填写所有必填字段
    </div>
  </div>
</template>

<style scoped>
.editable-table {
  width: 100%;
}

.table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 1rem;
}

.table th,
.table td {
  border: 1px solid #ebeef5;
  padding: 8px 12px;
  text-align: left;
}

.table th {
  background-color: #f5f7fa;
  font-weight: 600;
}

.required {
  color: #f56c6c;
}

.cell-input,
.cell-select {
  width: 100%;
  padding: 4px 8px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
}

.cell-input:focus,
.cell-select:focus {
  outline: none;
  border-color: #409eff;
}

.invalid-row {
  background-color: #fef0f0;
}

.action-column {
  width: 100px;
  text-align: center;
}

.delete-btn {
  padding: 4px 8px;
  background-color: #f56c6c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.delete-btn:hover {
  background-color: #f78989;
}

.table-actions {
  margin-bottom: 1rem;
}

.add-btn {
  padding: 8px 16px;
  background-color: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.add-btn:hover {
  background-color: #66b1ff;
}

.validation-error {
  color: #f56c6c;
  font-size: 14px;
  margin-top: 8px;
}
</style>

案例3:搜索筛选组件

<!-- SearchFilter.vue -->
<script setup lang="ts">
interface FilterOption {
  label: string
  value: any
  type?: 'text' | 'select' | 'date' | 'number'
  options?: Array<{ label: string; value: any }>
}

interface SearchFilterProps {
  filters: FilterOption[]
  placeholder?: string
  debounceTime?: number
}

const props = withDefaults(defineProps<SearchFilterProps>(), {
  placeholder: '搜索...',
  debounceTime: 300
})

// 搜索关键词模型
const searchKeyword = defineModel<string>('keyword', {
  default: ''
})

// 筛选条件模型
const filterValues = defineModel<Record<string, any>>('filters', {
  default: () => ({})
})

// 搜索状态
const isSearching = ref(false)

// 防抖搜索
const debouncedSearch = useDebounceFn(() => {
  isSearching.value = true
  setTimeout(() => {
    isSearching.value = false
  }, 500)
}, props.debounceTime)

// 监听搜索关键词变化
watch(searchKeyword, () => {
  debouncedSearch()
})

// 监听筛选条件变化
watch(filterValues, () => {
  debouncedSearch()
}, { deep: true })

// 重置搜索
const resetFilters = () => {
  searchKeyword.value = ''
  Object.keys(filterValues.value).forEach(key => {
    filterValues.value[key] = ''
  })
}

// 活跃筛选数量
const activeFilterCount = computed(() => {
  return Object.values(filterValues.value).filter(value =>
    value !== '' && value !== null && value !== undefined
  ).length + (searchKeyword.value ? 1 : 0)
})
</script>

<template>
  <div class="search-filter">
    <!-- 搜索输入框 -->
    <div class="search-input-wrapper">
      <div class="search-icon">
        <svg viewBox="0 0 24 24" width="16" height="16">
          <path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
        </svg>
      </div>
      <input
        v-model="searchKeyword"
        type="text"
        :placeholder="placeholder"
        class="search-input"
      />
      <div v-if="searchKeyword" class="clear-icon" @click="searchKeyword = ''">
        <svg viewBox="0 0 24 24" width="16" height="16">
          <path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
        </svg>
      </div>
    </div>

    <!-- 筛选条件 -->
    <div v-if="filters.length > 0" class="filters-wrapper">
      <div v-for="filter in filters" :key="filter.label" class="filter-item">
        <label class="filter-label">{{ filter.label }}:</label>

        <template v-if="filter.type === 'select'">
          <select v-model="filterValues[filter.label]" class="filter-select">
            <option value="">全部</option>
            <option
              v-for="option in filter.options"
              :key="option.value"
              :value="option.value"
            >
              {{ option.label }}
            </option>
          </select>
        </template>

        <template v-else-if="filter.type === 'date'">
          <input
            v-model="filterValues[filter.label]"
            type="date"
            class="filter-input"
          />
        </template>

        <template v-else-if="filter.type === 'number'">
          <input
            v-model.number="filterValues[filter.label]"
            type="number"
            class="filter-input"
          />
        </template>

        <template v-else>
          <input
            v-model="filterValues[filter.label]"
            type="text"
            :placeholder="filter.label"
            class="filter-input"
          />
        </template>
      </div>
    </div>

    <!-- 操作按钮 -->
    <div class="filter-actions">
      <div v-if="activeFilterCount > 0" class="active-filters">
        已选择 {{ activeFilterCount }} 个筛选条件
      </div>
      <button
        v-if="searchKeyword || activeFilterCount > 0"
        @click="resetFilters"
        class="reset-btn"
      >
        重置
      </button>
    </div>

    <!-- 搜索状态指示器 -->
    <div v-if="isSearching" class="searching-indicator">
      搜索中...
    </div>
  </div>
</template>

<style scoped>
.search-filter {
  background-color: #f8f9fa;
  padding: 16px;
  border-radius: 8px;
  margin-bottom: 16px;
}

.search-input-wrapper {
  position: relative;
  margin-bottom: 12px;
}

.search-icon,
.clear-icon {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  color: #6c757d;
}

.search-icon {
  left: 12px;
}

.clear-icon {
  right: 12px;
  cursor: pointer;
}

.clear-icon:hover {
  color: #495057;
}

.search-input {
  width: 100%;
  padding: 8px 40px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 14px;
}

.search-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.filters-wrapper {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
  margin-bottom: 12px;
}

.filter-item {
  display: flex;
  flex-direction: column;
}

.filter-label {
  font-size: 12px;
  color: #6c757d;
  margin-bottom: 4px;
}

.filter-input,
.filter-select {
  padding: 6px 8px;
  border: 1px solid #ced4da;
  border-radius: 4px;
  font-size: 14px;
}

.filter-input:focus,
.filter-select:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.filter-actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.active-filters {
  font-size: 12px;
  color: #007bff;
}

.reset-btn {
  padding: 6px 12px;
  background-color: #6c757d;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 12px;
  cursor: pointer;
}

.reset-btn:hover {
  background-color: #5a6268;
}

.searching-indicator {
  margin-top: 8px;
  font-size: 12px;
  color: #007bff;
  text-align: center;
}
</style>

最佳实践

1. 组件设计原则

<!-- ✅ 好的实践:单一职责 -->
<script setup>
// 每个组件只负责一个主要功能
const value = defineModel<string>()

// 简单的数据处理逻辑
const processedValue = computed({
  get: () => value.value?.trim() || '',
  set: (val) => value.value = val.trim()
})
</script>

<!-- ❌ 避免的实践:过度复杂 -->
<script setup>
// 避免在同一个组件中定义太多模型
const model1 = defineModel('model1')
const model2 = defineModel('model2')
const model3 = defineModel('model3')
// ... 太多模型会导致组件难以维护
</script>

2. 类型安全

<!-- ✅ 推荐的做法:完整的类型定义 -->
<script setup lang="ts">
interface UserForm {
  name: string
  email: string
  age: number
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

const userForm = defineModel<UserForm>({
  type: Object as PropType<UserForm>,
  required: true,
  validator: (value: UserForm) => {
    return value.name &&
           value.email.includes('@') &&
           value.age > 0
  }
})
</script>

<!-- ❌ 不推荐的做法:缺少类型约束 -->
<script setup>
// 缺少类型定义,容易出现运行时错误
const data = defineModel() // 类型为 unknown
</script>

3. 默认值处理

<!-- ✅ 推荐的做法:合理的默认值 -->
<script setup>
// 对于复杂对象,使用函数返回默认值
const user = defineModel({
  type: Object,
  default: () => ({
    name: '',
    email: '',
    age: 0
  })
})

// 对于数组,使用空数组作为默认值
const items = defineModel({
  type: Array,
  default: () => []
})
</script>

<!-- ❌ 避免的做法:可变引用的默认值 -->
<script setup>
// 可能导致多个组件实例共享同一个对象引用
const user = defineModel({
  type: Object,
  default: { name: '', email: '' } // ❌ 错误:共享引用
})
</script>

4. 验证和错误处理

<script setup>
const email = defineModel<string>({
  type: String,
  required: true,
  validator: (value: string) => {
    // 自定义验证逻辑
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    return emailRegex.test(value)
  }
})

// 错误状态管理
const errorMessage = computed(() => {
  if (email.value && !validator(email.value)) {
    return '请输入有效的邮箱地址'
  }
  return ''
})

// 实时验证
const isValid = computed(() => {
  return !errorMessage.value
})
</script>

<template>
  <div class="form-group">
    <label>邮箱:</label>
    <input v-model="email" type="email" />
    <div v-if="errorMessage" class="error-message">
      {{ errorMessage }}
    </div>
  </div>
</template>

5. 性能优化

<script setup>
// ✅ 使用计算属性进行复杂的数据处理
const [modelValue, modelModifiers] = defineModel<string>()

const processedValue = computed({
  get: () => {
    let value = modelValue.value || ''

    // 复杂的格式化逻辑
    if (modelModifiers.currency) {
      value = Number(value).toLocaleString('zh-CN', {
        style: 'currency',
        currency: 'CNY'
      })
    }

    return value
  },
  set: (value: string) => {
    // 处理用户输入
    if (modelModifiers.currency) {
      value = value.replace(/[¥,]/g, '')
    }

    modelValue.value = value
  }
})

// ❌ 避免在模板中进行复杂计算
<template>
  <!-- 不推荐:在模板中进行复杂计算 -->
  <input :value="formatCurrency(modelValue)" @input="handleInput" />

  <!-- 推荐:使用计算属性 -->
  <input v-model="processedValue" />
</template>
</script>

常见问题

1. 为什么 defineModel 不工作?

问题:组件中使用 defineModel 但没有响应式效果

原因

  • Vue 版本低于 3.3
  • 没有在 <script setup> 中使用
  • 父组件没有正确绑定 v-model

解决方案

<!-- 确保使用 Vue 3.3+ -->
<script setup>
// ✅ 正确使用
const modelValue = defineModel()
</script>

<!-- 父组件正确绑定 -->
<ChildComponent v-model="data" />
<!-- 而不是 -->
<ChildComponent :model-value="data" /> <!-- ❌ 缺少双向绑定 -->

2. 如何处理默认值的同步问题?

问题:子组件设置了默认值,但父组件没有提供值时出现同步问题

解决方案

<script setup>
// ✅ 推荐做法:使用本地状态管理默认值
const props = defineProps({
  modelValue: String
})

const emit = defineEmits(['update:modelValue'])

// 使用计算属性处理默认值
const internalValue = computed({
  get: () => props.modelValue || '默认值',
  set: (value) => emit('update:modelValue', value)
})

// 或者使用 defineModel 的 default 选项
const modelValue = defineModel({
  type: String,
  default: '默认值'
})
</script>

3. TypeScript 类型推断失败

问题:TypeScript 无法正确推断 defineModel 的类型

解决方案

<script setup lang="ts">
// ✅ 显式类型注解
const text = defineModel<string>()

interface FormData {
  name: string
  email: string
}

const formData = defineModel<FormData>({
  type: Object as PropType<FormData>
})

// ✅ 使用泛型约束
const model = defineModel<T extends string | number ? T : string>({
  type: [String, Number] as PropType<T>
})
</script>

4. 修饰符不生效

问题:自定义修饰符没有按预期工作

解决方案

<script setup>
// ✅ 正确使用修饰符
const [modelValue, modelModifiers] = defineModel({
  // 在 set 函数中处理修饰符
  set(value) {
    if (modelModifiers.trim) {
      return value.trim()
    }
    return value
  }
})
</script>

<!-- 使用时添加修饰符 -->
<ChildComponent v-model.trim="data" />

5. 如何避免无限更新循环?

问题:在 getter 和 setter 中设置值时导致无限循环

解决方案

<script setup>
const [modelValue, modelModifiers] = defineModel()

// ✅ 避免在 getter 中修改原值
const processedValue = computed({
  get: () => {
    // 只读取,不修改
    let value = modelValue.value || ''

    if (modelModifiers.uppercase) {
      return value.toUpperCase()
    }

    return value
  },
  set: (value) => {
    // 在 setter 中进行实际修改
    let finalValue = value

    if (modelModifiers.trim) {
      finalValue = value.trim()
    }

    // 直接设置,避免触发 getter
    modelValue.value = finalValue
  }
})
</script>

与传统方式的对比

1. 代码量对比

<!-- ❌ 传统方式:需要大量样板代码 -->
<script setup>
const props = defineProps({
  modelValue: String,
  title: String,
  count: Number
})

const emit = defineEmits([
  'update:modelValue',
  'update:title',
  'update:count'
])

const value = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})

const titleValue = computed({
  get: () => props.title,
  set: (value) => emit('update:title', value)
})

const countValue = computed({
  get: () => props.count,
  set: (value) => emit('update:count', value)
})
</script>

<!-- ✅ defineModel 方式:简洁明了 -->
<script setup>
const modelValue = defineModel()
const title = defineModel('title')
const count = defineModel('count')
</script>

2. 修饰符支持对比

<!-- ❌ 传统方式:需要手动处理修饰符 -->
<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: {
    type: Object,
    default: () => ({})
  }
})

const emit = defineEmits(['update:modelValue'])

const value = computed({
  get: () => props.modelValue,
  set: (value) => {
    if (props.modelModifiers.trim) {
      value = value.trim()
    }
    emit('update:modelValue', value)
  }
})
</script>

<!-- ✅ defineModel 方式:内置修饰符支持 -->
<script setup>
const [modelValue, modelModifiers] = defineModel({
  set(value) {
    if (modelModifiers.trim) {
      return value.trim()
    }
    return value
  }
})
</script>

3. TypeScript 支持对比

<!-- ❌ 传统方式:类型推断不够直观 -->
<script setup lang="ts">
interface Props {
  modelValue?: string
}

const props = defineProps<Props>()
const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const value = computed<string>({
  get: () => props.modelValue || '',
  set: (value) => emit('update:modelValue', value)
})
</script>

<!-- ✅ defineModel 方式:类型推断更直接 -->
<script setup lang="ts">
const value = defineModel<string>({
  default: ''
})
</script>

总结

defineModel 是 Vue 3 生态系统中的一个重要改进,它显著简化了组件双向数据绑定的实现。通过本文档的学习,我们了解到:

核心优势

  1. 简化开发:大幅减少样板代码,提高开发效率
  2. 类型安全:优秀的 TypeScript 支持,减少运行时错误
  3. 性能优化:编译时优化,运行时开销更小
  4. 功能丰富:内置修饰符支持和复杂的配置选项

适用场景

  • 表单组件:输入框、选择器、日期选择器等
  • 数据展示组件:需要编辑功能的数据表格
  • 配置面板:各种设置和配置界面
  • 搜索筛选:复杂的搜索和筛选组件

学习建议

  1. 从基础开始:先掌握基本的 v-model 绑定
  2. 逐步深入:学习修饰符和高级配置
  3. 实践项目:在实际项目中应用 defineModel
  4. 对比学习:了解传统方式的差异,更好地理解 defineModel 的优势

注意事项

  • 确保 Vue 版本为 3.3 或更高
  • 必须在 <script setup> 中使用
  • 注意默认值的处理方式
  • 合理使用 TypeScript 类型约束

defineModel 代表了 Vue.js 持续改进组件开发体验的努力,掌握这一特性将帮助开发者构建更简洁、更可维护的 Vue 应用程序。

前端小白Express入门:初识Web框架与项目搭建

前端小白Express入门:初识Web框架与项目搭建

前言:前端为什么需要学习Express?

在开始学习Express之前,很多前端同学可能会问:我已经会React/Vue了,为什么还要学习后端框架?前端工程师不是应该专注于界面和用户体验吗?

答案在于:现代前端开发已经远远超越了"切图写界面"的范畴。全栈能力正在成为前端工程师的核心竞争力,而Express是理解Web开发全貌的最佳入口。

一、原生HTTP模块 vs Express:直观对比

让我们通过一个简单的例子来感受两者的区别。

1.1 使用Node.js原生HTTP模块创建服务器

const http = require('http');

// 创建HTTP服务器
const server = http.createServer((req, res) => {
  // 手动解析URL和请求方法
  const { url, method } = req;
  
  // 简单的路由判断
  if (url === '/' && method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<h1>欢迎来到首页</h1>');
  } else if (url === '/api/users' && method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ users: ['张三', '李四'] }));
  } else {
    res.writeHead(404, { 'Content-Type': 'text/html' });
    res.end('<h1>页面未找到</h1>');
  }
});

// 启动服务器
server.listen(3000, () => {
  console.log('服务器运行在 http://localhost:3000');
});

痛点分析:

  • 需要手动解析URL和请求方法
  • 路由处理变得复杂且难以维护
  • 缺少中间件等高级功能
  • 代码组织不够直观

1.2 使用Express实现相同功能

const express = require('express');
const app = express();
const PORT = 3000;

// 路由处理变得极其简单
app.get('/', (req, res) => {
  res.send('<h1>欢迎来到首页</h1>');
});

app.get('/api/users', (req, res) => {
  res.json({ users: ['张三', '李四'] });
});

// 404处理
app.use('*', (req, res) => {
  res.status(404).send('<h1>页面未找到</h1>');
});

app.listen(PORT, () => {
  console.log(`Express服务器运行在 http://localhost:${PORT}`);
});

Express优势:

  • 简洁的路由定义
  • 内置的响应方法(send, json等)
  • 清晰的错误处理
  • 易于扩展的中间件体系

二、快速创建Express项目

2.1 环境准备

首先确保你的系统已经安装Node.js(建议版本14+):

node --version  # 检查Node.js版本
npm --version   # 检查npm版本

2.2 初始化项目

# 创建项目目录
mkdir my-express-app
cd my-express-app

# 初始化package.json
npm init -y

# 安装Express
npm install express

2.3 项目结构规划

建议采用清晰的项目结构:

my-express-app/
├── src/
│   ├── app.js          # Express应用实例
│   ├── routes/         # 路由文件
│   ├── middleware/     # 自定义中间件
│   └── public/         # 静态资源
├── package.json
└── README.md

2.4 使用Express应用生成器(脚手架)

对于更复杂的项目,推荐使用官方生成器:

# 全局安装express-generator
npm install -g express-generator

# 创建项目
express --view=ejs my-advanced-app

# 安装依赖
cd my-advanced-app
npm install

# 启动项目
npm start

生成的项目结构更加完整:

my-advanced-app/
├── bin/
│   └── www            # 服务器启动文件
├── public/            # 静态资源
├── routes/            # 路由文件
├── views/             # 模板文件
├── app.js             # 主应用文件
└── package.json

三、中间件概念初探:Express的核心灵魂

3.1 什么是中间件?

中间件(Middleware) 是Express中最核心的概念。你可以把它想象成一个处理流水线,每个中间件就像是流水线上的一个工作站,对请求进行加工处理。

3.2 中间件的基本结构

每个中间件函数都遵循相同的模式:

function middleware(req, res, next) {
  // 对请求进行处理
  console.log(`${req.method} ${req.url}`);
  
  // 调用next()将控制权交给下一个中间件
  next();
}

3.3 实际应用示例

让我们创建一个完整的示例来理解中间件的工作流程:

const express = require('express');
const app = express();

// 1. 日志记录中间件
app.use((req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next(); // 必须调用next()!
});

// 2. 静态文件服务中间件
app.use(express.static('public'));

// 3.  body解析中间件
app.use(express.json()); // 解析JSON格式的请求体
app.use(express.urlencoded({ extended: true })); // 解析表单数据

// 4. 自定义认证中间件(模拟)
app.use('/api', (req, res, next) => {
  // 检查API请求的认证信息
  const token = req.headers.authorization;
  
  if (!token) {
    return res.status(401).json({ error: '未提供认证令牌' });
  }
  
  // 模拟token验证
  if (token !== 'Bearer valid-token') {
    return res.status(403).json({ error: '认证失败' });
  }
  
  console.log('认证通过');
  next();
});

// 5. 业务路由
app.get('/', (req, res) => {
  res.send('欢迎来到首页!');
});

app.post('/api/data', (req, res) => {
  console.log('接收到的数据:', req.body);
  res.json({ message: '数据接收成功', data: req.body });
});

// 6. 404处理中间件(放在最后)
app.use('*', (req, res) => {
  res.status(404).send('页面未找到');
});

// 7. 错误处理中间件(四个参数)
app.use((err, req, res, next) => {
  console.error('发生错误:', err);
  res.status(500).json({ error: '服务器内部错误' });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

3.4 中间件的执行顺序

理解中间件的执行顺序至关重要:

请求进入
    ↓
日志中间件(记录请求信息)
    ↓
静态文件中间件(如果是静态文件请求,直接返回)
    ↓
Body解析中间件(解析请求体数据)
    ↓
认证中间件(检查API认证)
    ↓
路由处理(业务逻辑)
    ↓
404处理(如果没有匹配的路由)
    ↓
错误处理(如果发生错误)

四、最佳实践与常见陷阱

4.1 项目初始化最佳实践

// package.json 建议配置
{
  "name": "my-express-app",
  "version": "1.0.0",
  "description": "Express实战项目",
  "main": "src/app.js",
  "scripts": {
    "start": "node src/app.js",
    "dev": "nodemon src/app.js",
    "test": "jest"
  },
  "keywords": ["express", "nodejs"],
  "author": "你的名字",
  "license": "MIT"
}

4.2 常见陷阱及解决方案

陷阱1:忘记调用next()

// 错误示例
app.use((req, res, next) => {
  console.log('这个中间件会阻塞整个应用!');
  // 忘记调用next(),请求会一直挂起
});

// 正确做法
app.use((req, res, next) => {
  console.log('正确处理请求');
  next(); // 必须调用!
});

陷阱2:中间件顺序错误

// 错误顺序 - 404处理放在前面
app.use('*', (req, res) => {
  res.status(404).send('页面未找到');
});

// 后面的路由永远不会执行
app.get('/', (req, res) => {
  res.send('这个页面永远不会被访问到');
});

// 正确顺序
app.get('/', (req, res) => {
  res.send('首页');
});

app.use('*', (req, res) => {  // 404处理放在最后
  res.status(404).send('页面未找到');
});

五、实战练习

为了巩固理解,请尝试完成以下练习:

  1. 创建一个Express服务器,实现以下路由:

    • GET /:返回"首页欢迎语"
    • GET /about:返回"关于我们"
    • POST /contact:接收并返回联系人信息
  2. 添加自定义中间件

    • 请求日志记录
    • API请求时间计算
    • 简单的请求频率限制
  3. 组织项目结构

    • 将路由分离到单独的文件中
    • 创建自定义中间件目录

总结

通过本文的学习,你应该已经掌握了:

  • ✅ Express相对于原生HTTP模块的优势
  • ✅ 如何从零搭建Express项目
  • ✅ 中间件的核心概念和工作原理
  • ✅ 合理的项目结构规划
  • ✅ 常见的陷阱和最佳实践

Express的精髓在于"约定优于配置" - 它提供了一套优雅的约定,让Web开发变得更加愉快和高效。

在下一篇文章中,我们将深入探讨Express的路由系统,学习如何构建复杂的API接口和RESTful服务。


动手时间:按照文中的示例代码,亲手搭建你的第一个Express应用。遇到问题欢迎在评论区留言讨论!

提示:记得安装nodemon用于开发时的自动重启:npm install -D nodemon

ES2025新特性实战:5分钟get前端高频实用语法

ES2025 作为 JavaScript 标准的第 16 版,带来了一批直击开发者痛点的新特性。不用再等 Babel 插件兼容,现在主流浏览器和 Node.js 已逐步支持。本文精选 5 个最能提升开发效率的语法,5 分钟就能上手实战!🚀

1. Promise.try():同步异步错误一把抓

(配图建议:左侧同步代码报错无法捕获的红色警告示意图,右侧Promise.try()统一捕获错误的绿色流程示意图,对比展示)

痛点:同步函数抛出的错误无法直接被 Promise.catch 捕获,必须额外嵌套 try/catch

新写法


function mightThrow() {
  if (Math.random() > 0.5) throw new Error('Oops');
  return 'Success';
}

// 统一捕获同步/异步错误
Promise.try(mightThrow)
  .then(console.log)  // 成功输出 'Success'
  .catch(console.error); // 失败捕获所有错误

优势:混合同步异步操作(如图形渲染、数据库查询)时,代码嵌套减少 30%,错误逻辑更统一。 实战场景:接口请求前需校验参数合法性(同步),校验通过后发起请求(异步),用Promise.try()可避免嵌套try/catch:


const fetchData = (params) => {
  // 同步参数校验
  if (!params.id) throw new Error('缺少ID');
  // 异步请求
  return fetch(`/api/data/${params.id}`).then(res => res.json());
};

// 统一错误处理
Promise.try(() => fetchData({}))
  .then(data => console.log(data))
  .catch(err => alert(err.message)); // 直接捕获"缺少ID"错误

2. Set 集合运算:告别 Lodash 依赖

(配图建议:两个重叠的彩色圆圈分别代表userTags和hotTags,交集部分用高亮颜色标注,旁侧标注union、difference运算结果)

痛点:求交集、并集还要手动实现循环?Lodash 体积又嫌大?

新写法


const userTags = new Set(['js', 'node', 'web']);
const hotTags = new Set(['web', 'react', 'ts']);

// 交集:共同标签
const common = userTags.intersection(hotTags); // Set {'web'}
// 差集:推荐标签
const recommended = hotTags.difference(userTags); // Set {'react', 'ts'}
// 并集:所有标签
const all = userTags.union(hotTags); // Set {'js','node','web','react','ts'}

优势:原生实现基于哈希表,时间复杂度 O(min(n,m)),标签系统、权限管理场景直接用。 实战场景:权限控制中,筛选用户拥有的权限与页面所需权限的交集,判断是否有权访问:


const userPermissions = new Set(['view', 'edit', 'delete']);
const pageRequired = new Set(['view', 'export']);

// 检查是否拥有所有必需权限
const hasAll = pageRequired.difference(userPermissions).size === 0;
console.log(hasAll); // false(缺少export权限)

// 获取用户可操作的权限
const availableActions = userPermissions.intersection(pageRequired);
console.log([...availableActions]); // ['view']

3. 模式匹配:if-else 杀手来了

(配图建议:左侧杂乱的if-else分支流程图,右侧模式匹配的清晰分支树状图,视觉对比突出简洁性)

痛点:多条件分支用 if-else 写得像面条?switch 又不够灵活?

新写法


function processResponse(response) {
  return match (response) {
    when ({ status: 200, data }) -> ({ success: true, data })
    when ({ status: 404 }) -> ({ success: false, error: 'Not found' })
    when ({ status: s if s >= 500 }) -> ({ success: false, error: 'Server error' })
    default -> ({ success: false, error: 'Unknown error' })
  };
}

// 数组长度分支也优雅
function handleArray(arr) {
  return match (arr) {
    when (()) -> "空数组"
    when ((first)) -> `仅一个元素: ${first}`
    when ((a, b, ...rest)) -> `前两个: ${a},${b},剩余${rest.length}个`
  };
}

优势:条件逻辑可视化,代码行数减少 40%,复杂分支维护成本骤降。 实战场景:处理不同类型的用户信息,根据用户角色返回对应操作菜单:


function getUserMenu(user) {
  return match (user) {
    when ({ role: 'admin', status: 'active' }) -> ['dashboard', 'user-manage', 'settings']
    when ({ role: 'editor', status: 'active' }) -> ['dashboard', 'article-edit']
    when ({ status: 'inactive' }) -> ['profile', 'activate-account']
    default -> ['profile']
  };
}

const adminMenu = getUserMenu({ role: 'admin', status: 'active' });
console.log(adminMenu); // ['dashboard', 'user-manage', 'settings']

4. 管道运算符:函数组合像写流水线

(配图建议:数据从左到右流过多个处理节点的流水线示意图,每个节点标注trim、parseFloat等函数,最终输出结果)

痛点:嵌套函数调用(如 round(abs(sqrt(x))))读起来像"从右往左"的套娃。

新写法


const userInput = " 3.1415926 ";

// 自左向右的数据处理流水线
const result = userInput
  |> String.trim(%)
  |> parseFloat(%)
  |> Math.sqrt(%)
  |> Math.round(%); // 结果:2

// 异步流水线也支持
const fetchUsers = async (url) =>
  url
  |> fetch(%)
  |> await %.json()
  |> (% => %.filter(u => u.active))
  |> (% => %.slice(0, 10));

优势:数据流向清晰如流程图,复杂函数组合可读性提升 60%。 实战场景:处理后端返回的时间戳数据,转换为格式化的日期字符串:


const formatTime = (timestamp) =>
  timestamp
  |> new Date(%)
  |> (date => date.toLocaleDateString('zh-CN'))
  |> (str => str.replace(/\//g, '-'))
  |> (str => `日期:${str}`);

console.log(formatTime(1734567890000)); // 日期:2024-12-19

5. Record & Tuple:原生不可变数据

(配图建议:左侧普通对象引用赋值的箭头指向同一内存地址,右侧Record对象结构相等的双箭头指向不同地址但标注值相等)

痛点:用 Object.freeze() 做不可变对象?性能差还不能深冻结?

新写法


// Record:不可变对象(#{} 语法)
const user = #{
  id: 1,
  name: "张三",
  tags: #["js", "react"] // Tuple:不可变数组(#() 语法)
};

// 结构相等性判断(告别引用陷阱)
const user1 = #{ id: 1, name: "张三" };
const user2 = #{ id: 1, name: "张三" };
console.log(user1 === user2); // true!

// React 中简化依赖对比
const UserComponent = ({ user }) => {
  const memoized = useMemo(() => #{...user}, [user]); 
  // 无需深比较,引用直接相等
};

优势:原生不可变+结构相等,Redux/React 状态管理场景性能飙升。 实战场景:Redux状态中存储用户配置,避免因引用不变导致的组件不更新问题:


// Redux reducer
const configReducer = (state = #{theme: 'light', layout: 'grid'}, action) => {
  switch (action.type) {
    case 'UPDATE_THEME':
      // 返回新的Record,结构变化触发订阅更新
      return #{...state, theme: action.payload};
    default:
      return state;
  }
};

// 比较状态是否变化
const prevState = #{theme: 'light', layout: 'grid'};
const newState = #{theme: 'dark', layout: 'grid'};
console.log(prevState !== newState); // true(结构不同,触发更新)

💡 兼容性与学习建议

  • 浏览器:Chrome 120+、Firefox 115+ 已支持大部分特性

  • Node.js:v20+ 需开启 --experimental-features 标志

升级了SOLO,对SOLO的一些建议!👍SOLO真的很不错!

福利

好消息!天大的好消息!TRAE SOLO 现在全面开放!

有个限时福利,所有人 —— 不管是不是会员,都能免费体验 SOLO Coder 和 SOLO Builder!

这波羊毛不薅都对不起自己,赶紧冲!

solo4.gif

更新Trae,即可马上领取,能体验到啥时候不知道 ??,之前显示到11月15就没了,现在又能到11月18了,赶紧来体验一波!

image.png

更新

以下是SOLO的更新:

solo.gif

solo2.gif

solo3.gif

之前也写过两篇关于Trae的文章,当时就感觉进步很大,不知道这次有什么不一样的体验:

  1. Trae 初体验 - vscode要被替代了!强推Trae!
  2. Trae让我在同学面前装了一波,但是太晚了!

使用

1、 界面

这个SOLO的界面有点不好评价,就是感觉用着不习惯,文件跑最右边去了!

image.png

而且切换的地方也有点离谱,点击左上角的图标去切换的

image.png

把东西都关闭后,只留SOLO的界面就还是比较好看的

image.png

2、 SOLO Coder

这里既然说了SOLO Coder可以应付复杂任务,那我把我现在做的公司项目,问一下难点不过分吧?😂

image.png

这里可以开启Plan,意思就是AI会先帮你整理你问的话是什么意思,然后规划成几条任务,分别执行,这一点真的很不错!

运行直接卡死了😓,不知道是不是git太多了

image.png

算了,直接跳过,让它看看项目中的难点是否分析正确(这里后面又有一个git命令卡住了😓,但是跳过也没啥关系,还是生成了下面的内容)

image.png

分析得大差不差,大致是知道项目的难点的,关于具体细节可能需要单独去文件里面问。

建议

现在的AI基本都是只会看着代码一行一行解释,而不会跟着上下文去理解,特别是跨多个文件或者引入了多个文件,就开始乱回答了;其次就是会因为描述太过于不清楚,而让人难以理解,可以让我们自己先简述一下业务逻辑,再去解析,这样会不会更加好解释?

然后区分大佬写的还是我写的不是很正确,好像是按照它自己理解的难易来区分,而非代码风格!

image.png

这里面其实:vxe、jsx是我写的😂

我们会的,AI应该怎么做?

这里菜鸟之所以希望借助 AI 来找项目的难点,是因为现实开发里,大多数时候我们接触的不是新项目,而是别人已经写好、跑了几年的老项目。

新项目自己从零搭建,目录结构、框架选型、业务逻辑都是自己定的,自然不会存在“看不懂”的问题。

真正麻烦的是接手别人写的代码:业务逻辑不熟、模块分布混乱、命名风格不统一、历史包袱一堆,这些都会让人一脸问号。

在这种情况下,我们更需要的是:

AI 能帮我们快速识别项目的复杂点、业务链路、潜在风险点,以及那些容易踩坑的地方。 这样我们才能省掉大量试探和摸索的时间,把精力放在真正的维护和改进上。

所以对我来说,AI 的真正价值从来不在于 “替我写多少代码”

毕竟每个人写代码都有自己的风格、自己的习惯、自己的思路,AI 再强也不可能完全理解我的意图,我也不可能始终按照它的逻辑去思考。

真正有价值的地方在于:AI 能帮我精准地找出问题所在,帮我快速熟悉陌生项目的结构和业务脉络。

写代码还是得我们自己来,但接手别人写的、逻辑复杂又混乱的老项目,最耗时间的是 “看懂” 和 “定位问题”,而这些恰恰是 AI 最能减少痛苦的部分。

换句话说:
AI 是帮我打开迷雾,不是帮我造房子。
我只需要它让道路清晰,我自然能走下去。

也正因为这样,我更希望未来的 AI 重点能放在 “理解与思考” 层面,而不是继续把精力放在 “帮我们搭个项目、写几个公用方法” 这种低价值重复劳动上。

毕竟公用方法库早就有现成的,框架搭建也有无数教程,真正缺的不是这些工具,每个人也都有自己的编码风格,让 AI 来替我写并不能真正解决问题。

我们更需要的是:
AI 能告诉我们一个公用库是怎么写出来的;
AI 能带着我们去解读一个库的设计思路;
AI 能帮助我们快速吃透一段陌生的业务代码,理解为什么要这样写、这样写的好处和风险是什么。

这些才是能真正提升开发者能力的地方,也是我们在实际工作中最痛、最需要、最花时间的环节。

3、SOLO builder

这个建项目功能,之前的Trae就已经可以创建简单的游戏之类的了,所以菜鸟想用其做一个更大的项目、有逻辑的项目。

最近菜鸟被项目管理搞得焦头烂额,感觉全部的bug管理平台都不太好用,所以不如让Trae给我写一个?

image.png

image.png

其实SOLO这边也可以输入,反正就是两边都可以操作,让程序继续运行下去!

然后到这一步,进行不下去了,菜鸟能力有限,不知道是个啥:

image.png

不过倒是比之前进步了不少,之前是直接出来一个最原始的这个界面就没有了

image.png

现在生成了这个之后,就让你做上面的那些授权操作,可能做完了就可以继续生成!

问题

这里我是在打开的项目中问的,结果AI不问问我要不要新建一个项目,而是直接在我原有的项目里面又建了一个项目,整得有点无语啊!

SOLO Builder 适合新开一个文件夹使用!

我们不会的,AI应该怎么做?

我们用 AI 不就是为了让事情更简单吗?我只是个前端,我找 AI 帮我生成一个带后端的网站,那前提肯定就是:我不懂后端,所以才需要它来搞定这些东西。

那 AI 至少应该给我一个 能直接跑起来的项目基础结构,让我点一下就能启动、有东西能看、有页面能访问。
这样我才能在跑起来的基础上去学习、去修改、去调整。

否则如果连最起码能运行的成品都没有,只给一堆零散的代码片段,那还不如我自己慢慢查文档。

AI 的意义就在于:

生成可用的底稿,而不是让我从废稿里拼。

4、SOLO 来找找我的代码漏洞

做项目很明显就是怕bug,那我们可以用SOLO来找到我们代码的潜在bug吗?

image.png

image.png

AI 生成的内容确实已经很不错了,很多明显的问题也能帮我们指出来,这点不可否认。但说实话,真正复杂、深层次的逻辑问题,它往往还是看不出来。

不过这些 “简单问题” 本身也确实容易被人忽略,所以能自动检查出来,已经算是非常强的能力了。

有些人可能会问: “AI 都能把问题找出来了,那程序员是不是迟早要失业?”

菜鸟的感觉是 —— 找到问题是一回事,怎么改、怎么重构、怎么根据业务背景做判断,那是程序员自己的责任。

AI 即便指出了问题,它也无法完全替我们改,因为改动牵涉到需求、业务、上下文、团队规范、架构取舍,这些都是需要人来思考的。

这也是为什么我前面说: AI 真正应该发展的方向,不是简单帮我们“写点东西”,而是让我们真正理解代码、理解逻辑、理解架构,因为真正决定代码质量和业务稳定性的,是“理解” —— 不是“生成”

修复

那我们可以直接让AI帮我们修复吗?

image.png

这里看了一下其修复的代码,感觉修改得还行,它发现的问题倒是都改得不错

xss问题:

image.png

image.png

image.png

开发和生产问题:

image.png

image.png

5、重试第三条,让其用nodejs做后端

这次菜鸟聪明了,还是得新建一个文件用SOLO Builder 模式了!

image.png

和上面一样,你需要去按照提示,输入一些东西,保证build可以继续进行!

image.png

image.png

这里AI会自己解决跑不出来的报错,这个感觉就很nice,就应该先搭建一个可以跑起来的东西才对,就算只完成部分功能或一点功能都行,我们需要的就是在 生成的可用的底稿 上进行完善!

image.png

这里是真的强,就是当你运行起来后,点击进去有报错,AI会继续帮你解决

image.png

在反复的挣扎和修改中,AI最终还是没能实现一个复杂项目,仅仅完成为这个样子,且点击登录都没法跳转

image.png

image.png

感觉AI暂时还是只能实现写公用方法、一些纯前端或者后端的小型项目,对复杂的逻辑需求根本无法完成,至少对我这个AI小白来说是这样,可能有大佬更会用,但是菜鸟感觉大部分人对于AI的使用都和我差不多,而不是去细致的研究

继续让其解决

image.png

最后还是不行,停留在了上面的界面,无法解决,但菜鸟也确实看到了 SOLO 的进步,不可否认,国产编译器也在不断听取反馈、持续进化,也希望官方可以持续保持,不忘初心!

❌