阅读视图

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

AI协同写作应用-TipTap基础功能

前言

系列教程和源码在飞书文档编写。

本章概述

在本章中,我们将快速上手 Tiptap,从零开始创建一个功能完整的富文本编辑器。你将学会如何安装、配置和使用 Tiptap 的基础功能。

学习目标:

  • 创建一个新的前端项目
  • 安装 Tiptap 及其依赖
  • 创建第一个可用的编辑器
  • 使用 StarterKit 快速添加功能
  • 理解基本配置选项
  • 添加简单的工具栏

前置知识:

  • Node.js 和 npm/pnpm 基础
  • HTML、CSS、JavaScript 基础
  • 基础的命令行操作

预计学习时间: 30-45 分钟


1. 环境准备

1.1 检查 Node.js 版本

Tiptap 需要 Node.js 16+ 版本。

# 检查 Node.js 版本
node --version
# 应该显示 v16.0.0 或更高版本

# 检查 npm 版本
npm --version

如果版本过低,请访问 nodejs.org 下载最新的 LTS 版本。

1.2 选择包管理器

本教程推荐使用 pnpm,它比 npm 更快、更节省磁盘空间。

# 安装 pnpm(如果还没有)
npm install -g pnpm

# 验证安装
pnpm --version

当然,你也可以使用 npm 或 yarn:

# 使用 npm
npm install

# 使用 yarn
yarn add

💡 提示: 本教程的所有命令都使用 pnpm,如果你使用其他包管理器,请相应替换命令。


2. 创建项目

2.1 使用 Vite 创建项目

我们使用 Vite 创建一个 React + TypeScript 项目。

# 创建项目
pnpm create vite tiptap-demo --template react-ts

# 进入项目目录
cd tiptap-demo

# 安装依赖
pnpm install

为什么选择 Vite?

  • ⚡ 极快的启动速度
  • 🔥 热更新(HMR)快速
  • 📦 开箱即用的 TypeScript 支持
  • 🛠️ 现代化的构建工具

2.2 项目结构

创建完成后,项目结构如下:

tiptap-demo/
├── node_modules/
├── public/
├── src/
│   ├── App.css
│   ├── App.tsx
│   ├── main.tsx
│   └── vite-env.d.ts
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts

2.3 启动开发服务器

pnpm dev

打开浏览器访问 http://localhost:5173,你应该能看到 Vite 的欢迎页面。


3. 安装 Tiptap

3.1 安装核心包

pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit

包的说明:

包名 版本 大小 说明
@tiptap/react ^2.x ~15KB React 集成包,提供 Hooks 和组件
@tiptap/pm ^2.x ~200KB ProseMirror 核心依赖
@tiptap/starter-kit ^2.x ~30KB 常用扩展集合(15+ 扩展)

总大小: ~245KB(未压缩),~80KB(gzip 压缩后)

3.2 验证安装

检查 package.json 文件,应该能看到:

{
  "dependencies": {
    "@tiptap/pm": "^2.x.x",
    "@tiptap/react": "^2.x.x",
    "@tiptap/starter-kit": "^2.x.x",
    "react": "^18.x.x",
    "react-dom": "^18.x.x"
  }
}

4. 创建第一个编辑器

4.1 清理默认代码

首先,清理 Vite 生成的默认代码。

修改 src/App.tsx

// src/App.tsx
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
    </div>
  )
}

export default App

修改 src/App.css

/* src/App.css */
.app {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

h1 {
  margin-bottom: 2rem;
  color: #333;
}

4.2 创建编辑器组件

创建 src/Tiptap.tsx 文件:

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

function Tiptap() {
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: '<p>Hello World! 🌍</p>',
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

代码解析:

  1. 导入必要的模块

    import { useEditor, EditorContent } from '@tiptap/react'
    import StarterKit from '@tiptap/starter-kit'
    
    • useEditor: React Hook,用于创建编辑器实例
    • EditorContent: React 组件,用于渲染编辑器
    • StarterKit: 包含 15+ 个常用扩展
  2. 创建编辑器实例

    const editor = useEditor({
      extensions: [StarterKit],
      content: '<p>Hello World! 🌍</p>',
    })
    
    • extensions: 配置编辑器使用的扩展
    • content: 初始内容(HTML 格式)
  3. 渲染编辑器

    return <EditorContent editor={editor} />
    
    • EditorContent 组件接收编辑器实例并渲染

4.3 在 App 中使用

修改 src/App.tsx

// src/App.tsx
import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

4.4 添加基础样式

src/App.css 中添加编辑器样式:

/* src/App.css */

/* ... 之前的样式 ... */

/* 编辑器容器样式 */
.tiptap {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 1rem;
  min-height: 200px;
  outline: none;
  background-color: white;
}

/* 编辑器获得焦点时的样式 */
.tiptap:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

/* 段落样式 */
.tiptap p {
  margin: 0.75rem 0;
  line-height: 1.6;
}

/* 第一个段落不需要上边距 */
.tiptap p:first-child {
  margin-top: 0;
}

/* 最后一个段落不需要下边距 */
.tiptap p:last-child {
  margin-bottom: 0;
}

4.5 测试编辑器

保存所有文件,浏览器应该自动刷新。你应该能看到:

  • 一个带边框的编辑区域
  • 初始内容 "Hello World! 🌍"
  • 可以输入、删除文字
  • 可以使用快捷键(Ctrl+B 加粗、Ctrl+Z 撤销等)

测试清单:

  • ✅ 输入文字
  • ✅ 删除文字
  • ✅ 换行(按 Enter)
  • ✅ 撤销(Ctrl+Z)
  • ✅ 重做(Ctrl+Shift+Z)
  • ✅ 加粗(Ctrl+B)
  • ✅ 斜体(Ctrl+I)

5. 理解 StarterKit

5.1 StarterKit 包含的扩展

StarterKit 是一个扩展集合,包含了最常用的 15+ 个扩展:

Nodes(节点):

  • Document - 文档根节点
  • Paragraph - 段落
  • Text - 文本
  • Heading - 标题(H1-H6)
  • Blockquote - 引用块
  • CodeBlock - 代码块
  • BulletList - 无序列表
  • OrderedList - 有序列表
  • ListItem - 列表项
  • HardBreak - 硬换行
  • HorizontalRule - 水平分割线

Marks(标记):

  • Bold - 加粗
  • Italic - 斜体
  • Strike - 删除线
  • Code - 行内代码

Extensions(功能):

  • History - 撤销/重做
  • Dropcursor - 拖放光标
  • Gapcursor - 间隙光标

5.2 测试 StarterKit 功能

让我们测试一下这些功能。修改初始内容:

const editor = useEditor({
  extensions: [StarterKit],
  content: `
    <h1>欢迎使用 Tiptap</h1>
    <p>这是一个<strong>功能强大</strong>的<em>富文本编辑器</em>。</p>
    <h2>主要特性</h2>
    <ul>
      <li>支持多种文本格式</li>
      <li>可扩展的架构</li>
      <li>优秀的性能</li>
    </ul>
    <blockquote>
      <p>Tiptap 让编辑器开发变得简单而有趣。</p>
    </blockquote>
    <pre><code>const editor = useEditor({ ... })</code></pre>
  `,
})

现在你应该能看到:

  • 标题(H1、H2)
  • 加粗和斜体文字
  • 无序列表
  • 引用块
  • 代码块

5.3 自定义 StarterKit

你可以禁用某些扩展或自定义配置:

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      // 禁用某些扩展
      heading: false,
      
      // 自定义扩展配置
      bulletList: {
        HTMLAttributes: {
          class: 'my-bullet-list',
        },
      },
      
      // 自定义标题级别
      heading: {
        levels: [1, 2, 3],  // 只允许 H1、H2、H3
      },
    }),
  ],
  content: '<p>Hello World!</p>',
})

6. 添加工具栏

现在让我们添加一个简单的工具栏,让用户可以点击按钮来格式化文字。

6.1 创建工具栏组件

修改 src/Tiptap.tsx

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      {/* 工具栏 */}
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      {/* 编辑器 */}
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

代码解析:

  1. 空值检查

    if (!editor) return null
    

    首次渲染时编辑器可能为 null,需要检查。

  2. Commands 链式调用

    editor.chain().focus().toggleBold().run()
    
    • chain(): 开始链式调用
    • focus(): 让编辑器获得焦点
    • toggleBold(): 切换加粗状态
    • run(): 执行命令链
  3. 检查激活状态

    editor.isActive('bold')
    

    用于高亮当前激活的按钮。

  4. 检查命令可用性

    editor.can().undo()
    

    用于禁用不可用的按钮。

6.2 添加工具栏样式

创建 src/Tiptap.css 文件:

/* src/Tiptap.css */

.editor-container {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
  background-color: white;
}

/* 工具栏样式 */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}

.toolbar button {
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #374151;
  transition: all 0.2s;
}

.toolbar button:hover:not(:disabled) {
  background-color: #f3f4f6;
  border-color: #9ca3af;
}

.toolbar button.is-active {
  background-color: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.toolbar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.toolbar .divider {
  width: 1px;
  background-color: #e5e7eb;
  margin: 0 0.25rem;
}

/* 编辑器内容样式 */
.editor-container .tiptap {
  padding: 1rem;
  min-height: 300px;
  outline: none;
  border: none;
}

.editor-container .tiptap:focus {
  box-shadow: none;
}

/* 标题样式 */
.tiptap h1 {
  font-size: 2rem;
  font-weight: 700;
  margin: 1.5rem 0 1rem;
  line-height: 1.2;
}

.tiptap h2 {
  font-size: 1.5rem;
  font-weight: 600;
  margin: 1.25rem 0 0.75rem;
  line-height: 1.3;
}

.tiptap h3 {
  font-size: 1.25rem;
  font-weight: 600;
  margin: 1rem 0 0.5rem;
  line-height: 1.4;
}

/* 列表样式 */
.tiptap ul,
.tiptap ol {
  padding-left: 1.5rem;
  margin: 0.75rem 0;
}

.tiptap li {
  margin: 0.25rem 0;
}

/* 引用块样式 */
.tiptap blockquote {
  border-left: 3px solid #3b82f6;
  padding-left: 1rem;
  margin: 1rem 0;
  color: #6b7280;
  font-style: italic;
}

/* 代码块样式 */
.tiptap pre {
  background-color: #1f2937;
  color: #f9fafb;
  padding: 1rem;
  border-radius: 6px;
  margin: 1rem 0;
  overflow-x: auto;
}

.tiptap code {
  background-color: #f3f4f6;
  color: #ef4444;
  padding: 0.2rem 0.4rem;
  border-radius: 3px;
  font-size: 0.9em;
  font-family: 'Courier New', monospace;
}

.tiptap pre code {
  background-color: transparent;
  color: inherit;
  padding: 0;
}

/* 水平分割线样式 */
.tiptap hr {
  border: none;
  border-top: 2px solid #e5e7eb;
  margin: 2rem 0;
}

6.3 测试工具栏

保存文件后,你应该能看到:

  • 一个漂亮的工具栏
  • 点击按钮可以格式化文字
  • 激活的按钮会高亮显示
  • 不可用的按钮会被禁用

测试步骤:

  1. 选中一些文字
  2. 点击 "B" 按钮,文字应该变粗
  3. 按钮应该高亮显示
  4. 再次点击,文字恢复正常

7. 基本配置选项

7.1 常用配置

const editor = useEditor({
  // 扩展配置
  extensions: [StarterKit],
  
  // 初始内容
  content: '<p>Hello World!</p>',
  
  // 是否可编辑
  editable: true,
  
  // 是否自动获取焦点
  autofocus: false,
  
  // 事件回调
  onUpdate: ({ editor }) => {
    console.log('内容已更新', editor.getHTML())
  },
  
  onCreate: ({ editor }) => {
    console.log('编辑器已创建')
  },
  
  onFocus: ({ editor }) => {
    console.log('编辑器获得焦点')
  },
  
  onBlur: ({ editor }) => {
    console.log('编辑器失去焦点')
  },
})

7.2 配置选项说明

选项 类型 默认值 说明
extensions Extension[] 必需 编辑器使用的扩展数组
content string | JSONContent '' 初始内容(HTML 或 JSON)
editable boolean true 是否可编辑
autofocus boolean | 'start' | 'end' false 自动获取焦点
onUpdate function - 内容更新时触发
onCreate function - 编辑器创建时触发
onFocus function - 获得焦点时触发
onBlur function - 失去焦点时触发

8. 完整源码

📄 src/Tiptap.tsx

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

📄 src/App.tsx

import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

9. 本章总结

在本章中,我们学习了:

✅ 环境准备

  • 检查 Node.js 版本
  • 安装包管理器(pnpm)
  • 创建 Vite 项目

✅ 安装 Tiptap

  • 安装核心包(@tiptap/react、@tiptap/pm、@tiptap/starter-kit)
  • 理解包的作用和大小

✅ 创建编辑器

  • 使用 useEditor Hook
  • 渲染 EditorContent 组件
  • 添加基础样式

✅ StarterKit

  • 包含 15+ 个常用扩展
  • 自定义配置
  • 禁用特定扩展

✅ 添加工具栏

  • Commands 链式调用
  • 检查激活状态
  • 检查命令可用性
  • 添加工具栏样式

✅ 基本配置

  • 常用配置选项
  • 事件回调

🎯 关键知识点

1. useEditor Hook

const editor = useEditor({
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
})

2. Commands 链式调用

editor.chain().focus().toggleBold().run()

3. 检查状态

editor.isActive('bold')
editor.can().undo()

10. 下一步

现在你已经创建了第一个 Tiptap 编辑器!接下来我们将:

第 3 章:Tiptap 与 React 集成

  • 深入理解 useEditor Hook
  • 使用 EditorProvider
  • 实现自动保存
  • 处理 Next.js SSR

第 4 章:框架集成 - Vue/其他

  • Vue 集成
  • Angular 集成
  • Vanilla JavaScript

准备好继续学习了吗?🚀


11. 练习题

练习 1:添加更多按钮

在工具栏中添加以下按钮:

  • H3 标题
  • 引用块(Blockquote)
  • 代码块(CodeBlock)
  • 水平分割线(HorizontalRule)
💡 提示
<button
  onClick={() => editor.chain().focus().toggleBlockquote().run()}
  className={editor.isActive('blockquote') ? 'is-active' : ''}
>
  引用
</button>

练习 2:添加字符计数

在编辑器下方显示当前字符数。

💡 提示
const characterCount = editor.state.doc.textContent.length

<div className="character-count">
  {characterCount} 字符
</div>

练习 3:实现只读模式

添加一个切换按钮,可以切换编辑器的可编辑状态。

💡 提示
const [editable, setEditable] = useState(true)

useEffect(() => {
  if (editor) {
    editor.setEditable(editable)
  }
}, [editor, editable])

12. 常见问题

Q1: 为什么编辑器是 null?

A: 首次渲染时,编辑器还未初始化。解决方案:

if (!editor) return null

Q2: 如何获取编辑器内容?

A: 使用 getHTML()getJSON() 方法:

const html = editor.getHTML()
const json = editor.getJSON()

Q3: 如何设置编辑器内容?

A: 使用 setContent() 方法:

editor.commands.setContent('<p>新内容</p>')

Q4: 快捷键不工作?

A: 确保编辑器有焦点:

editor.chain().focus().toggleBold().run()

13. 扩展阅读


【JavaScript面试题-算法与数据结构】手写一个 LRU(最近最少使用)缓存类,支持 `get` 和 `put` 操作,要求时间复杂度 O(1)

一、数据结构设计

为了实现 get 和 put 操作的时间复杂度 O(1) ,我们组合了两种数据结构:

  1. 哈希表Map 或普通对象)

    • 存储键到双向链表节点的映射。
    • 作用:通过 key 直接定位到节点,实现 O(1) 的查找。
  2. 双向链表Node 类实现)

    • 维护所有节点的 使用顺序:链表头部(head 之后)是最近使用的节点,链表尾部(tail 之前)是最久未使用的节点。
    • 作用:在 O(1) 时间内完成节点的 移动到头部删除尾部 等操作。

此外,使用两个 哨兵节点(伪头 head 和伪尾 tail),避免处理链表为空或只有一个节点时的边界条件,使插入和删除操作更简洁。

以下是 JavaScript 手写的 LRU 缓存类,使用哈希表 + 双向链表实现,确保 get 和 put 操作时间复杂度为 O(1):

javascript

class LRUCache {
    /**
     * @param {number} capacity 缓存容量
     */
    constructor(capacity) {
        this.capacity = capacity;
        this.cache = new Map(); // 键 → 节点
        // 创建哨兵头尾节点,简化边界处理
        this.head = new Node(0, 0);
        this.tail = new Node(0, 0);
        this.head.next = this.tail;
        this.tail.prev = this.head;
    }

    /**
     * 获取键对应的值,并将该节点移动到链表头部(最近使用)
     * @param {number} key
     * @return {number}
     */
    get(key) {
        if (!this.cache.has(key)) {
            return -1;
        }
        const node = this.cache.get(key);
        this._moveToHead(node);
        return node.value;
    }

    /**
     * 插入或更新键值对,并将节点置于头部(最近使用)
     * 若容量超限,删除尾部节点(最久未使用)
     * @param {number} key
     * @param {number} value
     * @return {void}
     */
    put(key, value) {
        if (this.cache.has(key)) {
            // 已存在:更新值并移到头部
            const node = this.cache.get(key);
            node.value = value;
            this._moveToHead(node);
        } else {
            // 不存在:新建节点
            if (this.cache.size === this.capacity) {
                // 容量已满,删除尾部节点(最久未使用)
                const tailNode = this.tail.prev;
                this._removeNode(tailNode);
                this.cache.delete(tailNode.key);
            }
            const newNode = new Node(key, value);
            this.cache.set(key, newNode);
            this._addToHead(newNode);
        }
    }

    /**
     * 将节点从原位置移除,并添加到头部
     * @param {Node} node
     */
    _moveToHead(node) {
        this._removeNode(node);
        this._addToHead(node);
    }

    /**
     * 从链表中移除节点
     * @param {Node} node
     */
    _removeNode(node) {
        const prev = node.prev;
        const next = node.next;
        prev.next = next;
        next.prev = prev;
    }

    /**
     * 将节点插入到哨兵头节点之后(头部)
     * @param {Node} node
     */
    _addToHead(node) {
        node.prev = this.head;
        node.next = this.head.next;
        this.head.next.prev = node;
        this.head.next = node;
    }
}

/**
 * 双向链表节点
 */
class Node {
    constructor(key, value) {
        this.key = key;
        this.value = value;
        this.prev = null;
        this.next = null;
    }
}

使用示例

javascript

const lru = new LRUCache(2);
lru.put(1, 1);      // 缓存: {1=1}
lru.put(2, 2);      // 缓存: {1=1, 2=2}
console.log(lru.get(1)); // 返回 1,并移动 1 到头部 → 缓存顺序: 2,1
lru.put(3, 3);      // 容量已满,删除尾部 2 → 缓存: {1=1, 3=3}
console.log(lru.get(2)); // 返回 -1 (未找到)
lru.put(4, 4);      // 容量已满,删除尾部 1 → 缓存: {3=3, 4=4}
console.log(lru.get(1)); // 返回 -1
console.log(lru.get(3)); // 返回 3
console.log(lru.get(4)); // 返回 4

复杂度说明

  • get: 哈希表查找 O(1) + 链表移动 O(1) → 总体 O(1)
  • put: 哈希表插入/更新 O(1) + 可能删除尾部 O(1) + 链表操作 O(1) → 总体 O(1)

AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?

今天在学习promise的时候,看到一些比较早的教程,其中提到有一个重要的概念就是AJAX

尽管也许现代的做法更常见的是用Fetch API ,但是我也可以了解一下旧版实现里的做法,也能够帮助理解早期的异步 API,理解老项目的代码是如何做的。

关于异步JS(Promise)的前置知识,有关细节补充可阅读文档:异步 JavaScript 简介

我理解为promise的出现是异步编程中防止传统回调嵌套函数写法(回调地狱)。promise是现代 JavaScript 异步编程的基础。

常常见到的await async等其实是一种语法糖,使得写法简洁易读,并且有关try catch 错误异常的捕获和管理会比较方便(对比于原先采用catch统一管理错误的办法...)。这样的写法看起来是同步代码的长相,其实底层是异步编程。

早期异步Web API: XMLHttpRequest(AJAX)

AJAX全称为Asynchronous JavaScript and XML(异步JavaScript和XML),是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。

它通过在后台与服务器进行少量数据交换,使得网页可以实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

示例:

const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
  log.textContent = "";
  const xhr = new XMLHttpRequest();
  xhr.addEventListener("loadend", () => {
    log.textContent = `${log.textContent}完成!状态码:${xhr.status}`;
  });
  xhr.open(
    "GET",
    "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
  );
  xhr.send();
  log.textContent = `${log.textContent}请求已发起\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
  log.textContent = "";
  document.location.reload();
});
<button id="xhr">点击发起请求</button>
<button id="reload">重载</button>

<pre readonly class="event-log"></pre>

点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest 并监听它的 loadend 事件。loadend 事件在请求完成时总会触发,无论成功还是失败。如果需要区分成功和失败,可以分别监听 load(成功)和 error(失败)事件。

而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。

AJAX的工作原理基于一系列现有的互联网标准,主要包括以下几个方面:

  • XMLHttpRequest对象:这是AJAX的核心,它提供了在网页加载后从服务器请求数据的能力。
  • JavaScript/DOM:用于动态显示和交互的信息。
  • CSS:用于定义数据的样式。
  • XML:作为数据传输的格式,尽管现在JSON格式更为常用。

XMLHttpRequest

XMLHttpRequest API 使 web 应用能够通过 JavaScript 向 web 服务器发起 HTTP 请求并接收响应。这使得网站能够仅更新页面中的部分内容(使用服务器返回的数据),而无需跳转至全新页面。这种做法有时也被称为 AJAX

Fetch API 是取代 XMLHttpRequest API 的更灵活、更强大的方案。

Fetch API 使用 promise 替代事件机制处理异步响应,对 service worker 支持良好,并支持 HTTP 的高级特性,如跨源资源共享控制

基于这些优势,现代 web 应用通常采用 Fetch API 替代 XMLHttpRequest

XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。XMLHttpRequest(XHR)对象用于与服务器交互。通过 XMLHttpRequest 可以在不刷新页面的情况下请求特定 URL,获取数据。这允许网页在不影响用户操作的情况下,更新页面的局部内容。

AJAX能允许网页在不影响用户操作的情况下,与服务器进行数据交换和更新。例如Google地图、新浪微博等,依托核心还是XMLHttpRequest。

实现AJAX

通常需要以下几个步骤:

  1. 创建XMLHttpRequest对象:这是所有AJAX请求的起点。

  2. 发送请求到服务器:使用*open()send()*方法,可以指定请求的类型(如GET或POST),URL以及是否异步。

  3. 处理服务器响应:通过监听onreadystatechange事件,可以在请求的不同阶段执行不同的操作。当readyState属性变为4,且status属性表示请求成功时,可以处理响应数据。

  4. 更新网页内容:使用JavaScript操作DOM,可以根据服务器的响应更新网页的特定部分。

跨域问题和解决方法

在使用AJAX时,可能会遇到跨域问题,即浏览器出于安全考虑,限制了来自不同源的HTTP请求。解决跨域问题的方法包括:

CORS(Cross-Origin Resource Sharing):通过服务器设置适当的HTTP响应头,可以允许特定的外部域访问资源。

JSONP(JSON with Padding):通过动态创建*

AJAX的优势和注意事项

AJAX的主要优势在于提高了用户体验,通过异步更新可以减少等待时间,使得Web应用程序更加快速和响应。然而,也需要注意一些问题,例如:

浏览器兼容性:不同浏览器对AJAX的支持程度可能不同,需要进行充分的测试。

用户体验:需要合理设计用户界面,以便在数据加载过程中给予用户适当的反馈。

网络延迟:应考虑到网络延迟对用户体验的影响,并采取相应的优化措施。

总的来说,AJAX技术使得Web开发进入了一个新的阶段,它允许开发者创建出更加动态和交互性强的网页应用。


使用Fetch API与Promise

如何使用 Promise

MDN的教程已经讲解的非常好了,我们一起来跟着学一学,现代使用Fetch API 的做法。

在基于 Promise 的 API 中,异步函数会启动操作并返回一个 Promise 对象。

首先,Promise 有三种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch() 认为服务器返回一个错误(如 404 Not Found)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。

有时我们用已敲定(settled)这个词来同时表示已兑现(fulfilled)和已拒绝(rejected)两种情况。

如果一个 Promise 已敲定,或者如果它被“锁定”以跟随另一个 Promise 的状态,那么它就是已解决(resolved)的。

(关于术语:Let's talk about how to talk about promises


然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
  console.log(`已收到响应:${response.status}`);
});

console.log("已发送请求……");
  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量。
  2. 紧接着,输出 fetchPromise 变量,输出结果应该像这样:Promise { <state>: "pending" }。这告诉我们有一个 Promise 对象,它有一个 state属性,值是 "pending""pending" 状态意味着操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。
  4. 输出一条信息,说明我们已经发送了这个请求。
Promise { <state>: "pending" }
已发送请求……
已收到响应:200

与之前的 XMLHttpRequest 不同的是,事件处理程序并不是添加在 XMLHttpRequest 的对象中,我们这一次将处理程序传递到返回的promise对象的then方法里面。

Promise链

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
  const jsonPromise = response.json();
  jsonPromise.then((json) => {
    console.log(json[0].name);
  });
});

等等!还记得上一篇文章吗?我们好像说过,**在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?**这不是也一样吗,只不过变成了用 then() 调用而已?

当然如此。但 Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

官方教程划重点:Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

所以以上代码等价于:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data[0].name);
  });

我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

错误捕获

const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  })
  .catch((error) => {
    console.error(`无法获取产品列表:${error}`);
  });

catch处理函数的输出错误。

  • 注意fetch() 只有在网络层面失败时才会进入 catch。服务器返回 404 或 500 状态码时,Promise 依然是 fulfilled 状态,需要通过 response.ok 手动判断。

合并使用多个promise

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

Promise.all()

Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序与被传入 all() 的 Promise 的顺序相同。
  • 会被拒绝——如果数组中有任何一个 Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

promise.all用于批量处理不是相互依赖的promise,这样提高了效率,但是弊端是只有全部成功才会成功,如果有一个失败(rejected)则所有all包含在内的promise都不能被兑现。此时错误会用catch抛出。

Promise.any()

有时,你可能需要一组 Promise 中的某一个 Promise 的兑现,而不关心是哪一个。在这种情况下,你需要 Promise.any()

这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。

在这种情况下,我们无法预测哪个获取请求会先被兑现。

const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((response) => {
    console.log(`${response.url}${response.status}`);
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

async 和 await

async function fetchProducts() {
  try {
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const promise = fetchProducts();
promise.then((data) => console.log(data[0].name));

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try...catch 块来处理错误,就像我们在写同步代码时一样。

但请注意,这个写法只在异步函数中起作用。异步函数总是返回一个 Promise。也就意味着async 函数总是返回一个 Promise。即使你返回一个普通值,它也会被自动包装成 Promise。

小结与更多Promise

Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch 语句的错误处理方式。

asyncawait 关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写同步代码那样编写异步代码。

Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。

在这篇文章中,我们没有涉及到所有的 Promise 功能,只是介绍了最有趣和最有用的那一部分。随着你开始学习更多关于 Promise 的知识,你会遇到更多有趣的特性。

许多现代 Web API 是基于 Promise 的,包括 WebRTCWeb Audio API媒体捕捉与媒体流等等。

开源一年,我的 AI 全栈项目 AI 协同编辑器终于有 1.1 k star了 😍😍😍

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

📖 简介

DocFlow 是一款面向团队协作的块级文档编辑器。它融合了 Notion 的灵活性与飞书的协作能力,通过块级内容架构、实时协同编辑和 AI 辅助功能,帮助团队高效完成文档创作与知识管理。

我们希望通过技术手段减少协作摩擦,让文档编辑更接近团队的真实工作流。无论是产品规划文档、技术方案设计,还是会议记录整理,DocFlow 都能提供流畅的创作体验。

✨ 核心特性

DocFlow 参考了 Notion 与飞书的设计理念,将内容以块为单位进行组织。每个块都是独立的编辑单元,可以灵活组合与调整,同时支持实时协作与 AI 辅助。

  • 🧱 块级编辑器:支持文本、标题、列表、代码块、表格、图片、视频等 20+ 种内容类型,通过拖拽即可调整块级元素的顺序与层级关系。

  • ⚡ 实时协作:基于 Yjs CRDT 算法实现多人同步编辑,自动处理编辑冲突。支持实时光标跟踪、成员在线状态与历史版本回溯。

  • 🤖 AI 功能:内置 AI 助手,支持头脑风暴、内容润色、文档续写与智能问答。可根据上下文生成结构化内容建议。

技术选型

DocFlow 采用全栈 TypeScript 架构,前端基于 Next.js 构建,后端使用 NestJS 框架。通过统一的类型系统和现代化的工程实践,保证了代码质量与开发效率。

🎨 前端架构 (Client-side)

Next.js

项目基于 Next.js App Router 架构,利用 React Server Components 优化首屏渲染性能。通过 Server Actions 实现前后端通信,确保类型安全的同时简化了数据流转。

Tiptap

编辑器核心采用 Tiptap 框架,基于 ProseMirror 构建。通过扩展机制实现了丰富的块级编辑能力,支持自定义节点与快捷命令,为用户提供接近 Notion 的编辑体验。

Yjs

协作功能基于 Yjs CRDT 算法实现,能够自动处理多人编辑时的冲突,保证数据最终一致性。配合 Awareness 模块,实现了实时光标追踪与在线状态同步。

⚙️ 后端架构 (Server-side)

NestJS & Prisma

后端使用 NestJS 模块化框架,通过依赖注入实现业务逻辑解耦。Prisma ORM 提供类型安全的数据访问层,支持高效的数据库查询与迁移管理。

Hocuspocus

Hocuspocus 作为 Yjs 的 WebSocket 服务端,负责协调文档协作会话,处理客户端连接与数据同步。通过拦截器机制实现权限控制与数据持久化。

Prometheus & Grafana

集成 Prometheus 进行指标采集,通过 Grafana 可视化展示系统运行状态。监控包括 API 响应时间、数据库查询性能、WebSocket 连接数等核心指标。

20260203091658

Grafana 监控面板实时展示系统各项性能指标,包括请求量、响应时间、错误率等关键数据,帮助快速定位性能瓶颈。

ELK Stack (Elasticsearch & Kibana)

使用 Elasticsearch 存储和检索日志数据,Kibana 提供日志分析与可视化能力。支持全文搜索、日志聚合与异常检测,便于问题排查与系统审计。

日志分析系统

Kibana 日志分析界面,支持按时间、日志级别、服务模块等维度查询和过滤日志,提供结构化的问题排查路径。

MinIO & RabbitMQ

MinIO 提供对象存储服务,用于存储用户上传的图片、视频等文件。RabbitMQ 作为消息队列,处理异步任务如图片压缩、邮件发送等,避免阻塞主业务流程。

功能介绍

DocFlow 将 AI 能力集成到编辑器中,通过理解文档上下文来辅助内容创作。AI 不是简单的文本生成工具,而是能够理解语义、提供决策建议的智能助手。

AI 头脑风暴

当你有一个初步想法但不知如何展开时,AI 头脑风暴可以帮助拓展思路。输入核心概念后,AI 会从不同角度生成 3-6 个结构化方案,每个方案都包含具体的实施思路。

AI 头脑风暴输入界面

在编辑器中输入头脑风暴主题,AI 会基于输入内容理解你的需求场景。

AI 头脑风暴结果展示

AI 生成的多个方案以卡片形式展示,每个方案都有清晰的标题和详细说明。你可以选择任意方案插入到文档中,或者继续优化调整。

这不只是简单的内容生成,AI 会根据上下文理解你的意图。无论是产品功能设计、内容分类规划,还是业务流程优化,AI 都能提供可行的思路参考,帮助快速决策。

AI 文本润色

AI 文本润色功能

选中需要优化的文本段落,AI 会分析文本结构与表达方式,提供更清晰、更专业的改写建议。支持调整语气风格,如正式、简洁、友好等。

AI 续写

AI 续写功能会根据前文内容自然延续写作。当前文内容较长时,系统通过 RAG (检索增强生成) 技术,从文档中检索相关段落,确保续写内容与上下文保持逻辑一致,避免偏离主题。

AI 续写功能演示

AI 续写时会参考前文的写作风格、用词习惯和逻辑结构,生成连贯自然的后续内容。你可以继续编辑生成的文本,或者重新生成。

AI 聊天

目前 AI 聊天功能作为独立页面存在,后续会集成到编辑器侧边栏,与文档内容深度关联。未来计划实现 Agent 模式,类似 Cursor 那样能够自动编辑文档内容。

7a8ba58a4ab3b592bb7fae1b45634648

协同编辑

多人协同编辑

多人同时编辑时,每个用户都有独立的光标颜色标识。文档修改实时同步,冲突自动合并。右侧显示当前在线成员列表与他们的编辑位置。

未来计划

DocFlow 将持续优化协作体验与 AI 能力,同时加强工程化建设,提升系统可扩展性。

🏗️ 工程化体系深度重构

  • 迈向 Monorepo 架构:计划基于 pnpm workspaces 和 Turborepo 将项目重构为 Monorepo。前后端代码分离,共享类型定义与工具函数,提升代码复用率与构建效率。

  • 组件库与插件生态开放:将 Tiptap 自定义扩展(如代码沙箱、交互式图表等)提取为独立 npm 包,开放给社区使用。同时建立插件开发规范,支持第三方开发者扩展编辑器能力。

🎙️ 多维协同体验升级

  • 集成 LiveKit 实时音视频:在文档协作场景中引入实时音视频通话。团队成员可以边看文档边讨论,提升复杂决策场景下的沟通效率。

LiveKit 集成方案

  • 实时群聊系统:在文档侧边栏集成实时聊天功能,支持针对文档内容发起讨论。消息可以关联到具体的文档块,形成完整的协作反馈闭环。

🤖 智能内核的跨越式进化

  • 基于 RAG 的私有知识库:引入 RAG (Retrieval-Augmented Generation) 技术,让 AI 能够检索用户的历史文档。AI 回答问题时会参考团队沉淀的知识资产,提供更精准的决策支持。

  • 从 Copilot 迈向 Agent:探索 AI Agent 在文档场景的应用。未来 AI 将能够自主执行任务,例如从会议纪要中提取待办事项,自动同步到第三方工具,实现从辅助创作到自动化办公的升级。

🚀 快速开始

环境要求

  • Node.js >= 24
  • pnpm >= 10.28.2

本地开发

  1. 克隆仓库
git clone https://github.com/xun082/DocFlow.git
cd DocFlow
  1. 安装依赖
pnpm install
  1. 启动开发服务器
pnpm dev
  1. 打开浏览器访问
http://localhost:3000

🐳 Docker 部署

方式一:使用 Docker Compose(推荐)

# 使用预构建镜像
docker-compose up -d

# 访问应用
http://localhost:3000

方式二:手动构建

  1. 构建镜像
docker build -t docflow:latest .
  1. 运行容器
docker run -d \
  --name docflow \
  -p 3000:3000 \
  -e NODE_ENV=production \
  docflow:latest
  1. 访问应用
http://localhost:3000

健康检查

容器内置健康检查端点:

curl http://localhost:3000/api/health

🤝 贡献指南

欢迎提交 Issue 和 Pull Request!

在提交代码前,请确保:

  • 运行 pnpm type-check 通过类型检查

  • 运行 pnpm lint 通过代码检查

  • 运行 pnpm format 格式化代码

  • 遵循项目的代码规范和提交规范

详见 CONTRIBUTING.md

📬 联系方式

infer,TS 类型系统的手术刀

在 TypeScript 的高级玩法里,infer 经常让初学者感到头大。它长得像关键字,用起来像正则表达式的“捕获组”,还必须寄生在 extends 条件语句里。

要把这东西彻底搞清楚,我们得先拆解它的核心逻辑,再看看它在实战中到底解决了什么问题。

一、 核心概念:infer 到底是什么?

简单来说,infer 就是 “类型系统里的临时变量”

在常规的泛型中,是你告诉 TypeScript 具体的类型;而在使用 infer 的场景下,是 TypeScript 自动推断出某个位置的类型,并把它存到一个变量里供你后续使用。

语法规则:

  1. 只能在 extends 条件类型的“真”分支中使用。
  2. 配合模式匹配使用。 你给出一个“模版”(比如函数结构、数组结构),让 TS 去匹配并提取其中的零件。

二、 语义纠偏:extends 的“变脸”

很多人的困惑源于 extends 这个词。在 Class 里它是“继承”,但在类型定义(尤其是配合 infer)时,它其实是 “模式匹配(Pattern Matching)”

  • Class 中的 extends:我是你的后代,我继承你的基因。
  • 类型中的 extends:我能不能塞进你这个形状的盒子里?

当你在写 T extends (infer R)[] ? R : never 时,你实际上是在对 TS 说:

“帮我看看 T 是不是一个数组。如果是,顺便把数组里装的那个东西的类型抠出来,起个临时名字叫 R。如果匹配成功,我就要这个 R。”


三、 实战场景:它能解决什么痛苦?

如果没有 infer,类型系统就是静态的、死板的。有了它,类型系统就具备了“解剖”和“重组”的能力。

1. 经典的“解包” (Unpacking)

这是最常见的用途。比如从 PromiseArrayMap 中提取内部类型。

// 提取 Promise 内部的类型
type Unbox<T> = T extends Promise<infer U> ? U : T;

type Str = Unbox<Promise<string>>; // 得到 string

2. 函数全家桶 (Function Extraction)

你可以轻松拿到一个函数的返回类型、参数类型,甚至是构造函数的参数。

// 提取函数第一个参数的类型
type FirstParam<T> = T extends (arg1: infer P, ...args: any[]) => any ? P : never;

function saveUser(id: number, name: string) {}
type IDType = FirstParam<typeof saveUser>; // number

3. 字符串模板的“手术刀”

这是 TS 4.1 之后的黑科技。你可以用它来拆分字符串,做一些像“驼峰转下划线”之类的类型转换。

type GetExtension<T> = T extends `${string}.${infer Ext}` ? Ext : never;

type FileExt = GetExtension<"config.json">; // "json"

四、 总结:什么时候该用它?

你不需要在每一处代码都写 infer,但在以下场景,它是无可替代的神器:

  • 处理第三方库:当你拿不到某个库内部定义的具体接口,但你能拿到它的函数或实例时,可以用 infer 反向推导出它的类型。
  • 减少重复定义:不想为了一个返回值再去手动写一遍复杂的 interface
  • 编写通用工具库:它是构建自动化、高适配性类型系统的基石。

虽然 infer 很好用,但它会显著增加类型的理解成本。对于团队协作项目,建议只在底层工具类型(Utils)中使用它,业务代码中还是尽量保持类型声明的直观和显式。

🪝 别再重复造轮子了!教你偷懒:在 React 自定义 Hook

前言

React 组件时,你是不是总感觉有些逻辑似曾相识?

  • 比如,每次都要写一遍判断组件是否挂载的逻辑
  • 又比如,监听元素 hover 状态的代码复制了一次又一次
  • 再比如,组件挂载和卸载时的操作也总是那几行

React官方给出了许多hook供我们使用,比如我们常见的useEffectuseState等等,但光靠这些是不够的,今天分享一些自定义的hook,方便又高效!

🎯 场景一:我只是想知道组件 “活” 没活

你有没有遇到过这种情况:组件里的setTimeout还没跑完,组件就已经被卸载了,控制台立刻给你甩一个警告,仿佛在说 “你操作了一个不存在的组件”

别慌,咱们用useMountedState这个自定义 Hook 就能完美解决。

// useMountedState.js
import { useRef, useEffect } from 'react'

export default function useMountedState() {
    const mounted = useRef(false);
    const get = () => mounted.current;
    useEffect(() => {
        mounted.current = true;
        return () => {
            mounted.current = false;
        }
    }, [])
    return get;
}

在组件里用起来就像给组件装了个 “生命检测仪”

// App.jsx
import React, { useState, useEffect } from 'react'
import useMountedState from './hooks/useMountedState'

export default function App() {
    const isMounted = useMountedState();
    const [num, setNum] = useState(0);
    useEffect(() => {
        setTimeout(() => {
            // 先检查组件是否还活着,再更新状态
            if (isMounted()) {
                setNum(1);
            }
        }, 1000);
    }, []);

    return (
        <div>
            {isMounted() ? '组件挂载完成 🎉' : '组件还在编译 🛠️'}
        </div>
    )
}

刚打开浏览器(显示还在编译):

image.png

过几秒(挂载完成):

image.png

有了它,你再也不用担心在异步操作里更新一个已经 “去世” 的组件了。

🎬 场景二:组件的 “登场” 与 “谢幕” 要仪式感

组件挂载卸载时,我们经常需要做一些初始化和清理工作。

  • 比如页面埋点、订阅事件、定时器清理等
  • 直接用useEffect写虽然也行,但每次都要写return总觉得有点麻烦

这时候useLifecycles就派上用场了,它把组件的 “生命周期” 打包成了一个简单的接口

// useLifecycles.js
import { useEffect } from 'react'

export default function useLifecycles(onMount, onUnmount) {
    useEffect(() => {
        if (onMount) {
            onMount();
        }
       return () => {
            if (onUnmount) {
                onUnmount();
            }
        }
    }, []);
}

用起来就像给组件安排了 “入场” 和 “退场” 的节目单

// App2.jsx
import React, { useState } from 'react'
import useLifecycles from './hooks/useLifecycles';

const Child = () => {
    useLifecycles(
        () => {
            console.log('child组件挂载🎬');
        },
        () => {
            console.log('child组件卸载👋');   
        }
    )
    return <h1>child组件</h1>
}

export default function App2() {
    const [show, setShow] = useState(true);
    return (
        <div>
            <h1 onClick={() => setShow(!show)}>App2</h1>
            {
                show && <Child></Child>
            }
        </div>
    )
}

刚打开浏览器一定会打印child组件挂载🎬

image.png

当点击App2时,child组件消失 (卸载),打印child组件卸载 👋

image.png

✋ 场景三:元素 hover 状态的 “小雷达”

实现元素hover效果是前端的家常便饭,传统写法需要给元素绑定onMouseEnteronMouseLeave事件。

  • 逻辑不复杂,但写多了也烦
  • 咱们可以用useHover把这个逻辑封装成一个 Hook
// useHover.jsx
import { useState, cloneElement } from 'react'

export default function useHover(element) {
    const [state, setState] = useState(false);
    const onMouseEnter = (originalOnMouseEnter) => {
        return (event) => {
            originalOnMouseEnter?.(event);
            setState(true);
        }
    };
    const onMouseLeave = (originalOnMouseLeave) => {
        return (event) => {
            originalOnMouseLeave?.(event);
            setState(false);
        }
    };
    if (typeof element === 'function') {
        element = element(state);
    }
    const el = cloneElement(element, {
        onMouseEnter: onMouseEnter(element.props.onMouseEnter),
        onMouseLeave: onMouseLeave(element.props.onMouseLeave),
    })
    return [el, state];
}

在组件里使用时,就像给元素装了个 “小雷达”

// App3.jsx
import useHover from './hooks/useHover.jsx';

export default function App3() {
    const element = (hovered) => {
        return <div>
            Hover me! {hovered && 'Thanks!'}
        </div>
    }
    const [hoverable, hovered] = useHover(element);
    return (
        <div>
            {hoverable}
            {hovered ? 'yes ✅' : 'no ❌'}
        </div>
    )
}

鼠标不在Hover me!上面的时候(显示no ❌):

image.png

当鼠标🖱️移动到Hover me!上面的时候(显示yes ✅):

image.png

鼠标悬停时,元素会显示 “Thanks!”,下方也会同步显示状态,交互体验直接拉满!

🚀 用别人写好的库

大家应该发现了,上面的组件都是我自己手搓的,其实已经有很多人写好了,我们只需下载然后就可以使用了。我给大家推荐一个:

地址: www.npmjs.com/package/rea…

下载:npm i react-use

里面有许多已经封装好了的hook组件,包括上面介绍的,只需引入即可:

import { useMountedState } from 'react-use';
import { useHover } from 'react-use';
import { useLifecycles } from 'react-use';

结语

自定义 Hook 就像 React 世界里的 “乐高积木”,把零散的逻辑拼成一个个可复用的模块。

  • 它不是什么高大上的魔法,就是把你本来要重复写的代码打包了一下
  • 不仅能让你的代码更干净,还能让你开发时少掉几根头发

下次再遇到重复逻辑时,别再 cv 了,动手写个自定义 Hook 吧!毕竟,优秀的程序员都是会 “偷懒” 的艺术家

🎯 DOM 事件:onclick VS addEventListener('click')区别

🎯 DOM 事件:onclick vs addEventListener('click') 区别

特性 .on 事件(如 onclick addEventListener('click')
绑定数量 只能绑 1 个(后面覆盖前面) 可以绑 多个(按顺序执行)
移除方式 el.onclick = null 需要 removeEventListener,且必须传同一个函数引用
事件阶段 只能在 冒泡阶段 触发 可以选择 捕获 / 冒泡 阶段(第三个参数)
标准级别 DOM 0 级(老写法) DOM 2 级(现代标准推荐)

区别详解

绑定数量

onclick:只能绑 1 个,后面覆盖前面

const btn = document.getElementById('btn');

btn.onclick = function() {
  console.log('第一次点击'); // 不会执行!被覆盖了
};

btn.onclick = function() {
  console.log('第二次点击'); // 只有这个会执行
};

 addEventListener:多个都执行

const btn = document.getElementById('btn');

function fn1() {
  console.log('第一次点击'); // 会执行
}

function fn2() {
  console.log('第二次点击'); // 也会执行!按顺序来
}

btn.addEventListener('click', fn1);
btn.addEventListener('click', fn2);

移除事件

onclick 移除:直接设为 null

btn.onclick = function() { alert('点击了'); };
// 移除
btn.onclick = null; 

addEventListener 移除:必须传同一个函数

⚠️ 注意:如果用匿名函数,是无法移除的!

//✅ 正确写法(用命名函数)
function myClick() {
  console.log('点击了');
}

btn.addEventListener('click', myClick);
// 移除(必须传同一个函数名)
btn.removeEventListener('click', myClick);

//❌ 错误写法(无法移除)
btn.addEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

// 没用!因为这是两个不同的函数引用
btn.removeEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

事件阶段

// 第三个参数:
// true → 在捕获阶段触发
// false(默认)→ 在冒泡阶段触发

el.addEventListener('click', fn, true); // 捕获阶段
el.addEventListener('click', fn, false); // 冒泡阶段
简单理解事件流:

假设 HTML 是 body > div > button

  1. 捕获阶段:从外到内(body → div → button
  2. 目标阶段:到达 button
  3. 冒泡阶段:从内到外(button → div → body

onclick 只能在冒泡阶段触发,而 addEventListener 可以自由选择。

所以我用哪个呢?

  1. 90% 的场景:用 addEventListener

    • 更灵活,能绑多个事件
    • 现代标准,功能强大
    • 团队协作推荐
    • 不知道用啥就用它
  2. 简单快速测试 / 临时写个小功能:可以用 onclick

    • 代码少,写得快
    • 移除简单(直接 null

手写 Zustand:从零实现 React 轻量级状态管理库

为什么选择 Zustand?

在 React 开发中,组件间通信一直是个令人头疼的问题。当组件层级复杂时,通过 props 层层传递状态不仅代码冗余,维护成本也直线上升。这时候就需要一个中央状态管理库来解决这个痛点。

相比老牌的 Redux,Zustand 的优势非常明显:

  • 极简 API:没有繁琐的 reducer、action、dispatch 概念
  • 零样板代码:不需要包裹 Provider,直接创建 store 即用
  • 性能优秀:基于订阅机制实现精准更新,避免无效渲染
  • 体积小巧:核心代码仅 1KB 左右

正因如此,Zustand 在 GitHub 上已经收获了 4 万+ Star,成为近年来最受欢迎的 React 状态管理方案之一。

核心原理拆解

要手写 Zustand,首先需要理解其三大核心机制:

1. 状态存储与管理

Zustand 采用闭包方式存储状态,通过 createStore 创建一个独立的状态容器:

javascript

const createStore = (createState) => {
  let state;  // 闭包变量,存储状态
  const getState = () => state;
  // ... 其他方法
}

这种设计让状态完全脱离 React 组件树,既可以在组件内使用,也可以在组件外直接操作。

2. 订阅发布模式

这是 Zustand 的灵魂所在。当状态改变时,如何通知所有使用该状态的组件更新?答案是观察者模式:

  • 发布者(Store) :维护一个订阅者列表 listeners
  • 订阅者(组件) :通过 subscribe 注册监听函数
  • 状态更新时:遍历执行所有订阅者的回调

javascript

const listeners = new Set();

const subscribe = (listener) => {
  listeners.add(listener);
  return () => listeners.delete(listener);  // 返回取消订阅函数
}

const setState = (partial, replace = false) => {
  // 更新状态后通知所有订阅者
  listeners.forEach(listener => listener(state, previousState));
}

3. 选择器(Selector)与精准更新

这是 Zustand 性能优化的关键。通过 selector 函数,组件可以只订阅自己关心的状态切片:

javascript

const count = useCounterStore((state) => state.count);

state.text 改变时,只订阅 count 的组件不会重新渲染。实现原理是在订阅回调中比较 selector 返回值:

javascript

api.subscribe((state, previousState) => {
  const newObj = selector(state);
  const oldObj = selector(previousState);
  if (newObj !== oldObj) {
    forceRender(Math.random());  // 仅当关心的状态变化才强制更新
  }
})

完整实现详解

第一步:创建 Store

createStore 函数负责初始化状态并返回操作 API:

javascript

const createStore = (createState) => {
  let state;
  const listeners = new Set();
  
  const getState = () => state;
  
  const setState = (partial, replace = false) => {
    const nextState = typeof partial === 'function' 
      ? partial(state) 
      : partial;
    
    if (!Object.is(nextState, state)) {
      const previousState = state;
      if (!replace) {
        // 默认浅合并,保留未修改的字段
        state = Object.assign({}, state, nextState);
      } else {
        // replace 模式直接替换整个 state
        state = nextState;
      }
      listeners.forEach(listener => listener(state, previousState));
    }
  }
  
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }
  
  const api = { setState, getState, subscribe };
  state = createState(setState, getState, api);
  return api;
}

关键细节:

  • Object.is() 判断状态是否真正改变,避免无效更新
  • setState 支持传入函数,方便基于旧状态计算新值
  • subscribe 返回取消订阅函数,符合 React useEffect 清理机制

第二步:实现 Hook 适配层

useStore 将订阅机制桥接到 React 组件:

javascript

const useStore = (api, selector) => {
  const [, forceRender] = useState(0);
  
  useEffect(() => {
    const unsubscribe = api.subscribe((state, previousState) => {
      const newObj = selector(state);
      const oldObj = selector(previousState);
      if (newObj !== oldObj) {
        forceRender(Math.random());  // 强制重渲染
      }
    });
    return unsubscribe;  // 组件卸载时自动取消订阅
  }, []);
  
  return selector(api.getState());
}

这里用了一个巧妙的技巧:通过修改 state 触发组件更新,而不是直接操作 DOM。

第三步:暴露便捷的 create API

javascript

export const create = (createState) => {
  const api = createStore(createState);
  
  const useBoundStore = (selector) => useStore(api, selector);
  
  // 将 API 方法挂载到 Hook 上,支持在组件外调用
  Object.assign(useBoundStore, api);
  
  return useBoundStore;
}

Object.assign 这一步很关键,它让我们可以:

  • 组件内:通过 useCounterStore(selector) 使用
  • 组件外:通过 useCounterStore.setState() 直接操作状态

实战验证

基于上面的实现,我们创建一个计数器和文本编辑器共存的案例:

javascript

const useCounterStore = create((set) => ({
  count: 0,
  text: '初始文本',
  increment: () => set((state) => ({ count: state.count + 1 })),
  updateText: (newText) => set({ text: newText }),
}));

CountDisplay 组件只订阅 count:

javascript

const CountDisplay = () => {
  console.log('CountDisplay 渲染了');
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  
  return (
    <div>
      <p>当前计数: {count}</p>
      <button onClick={increment}>增加</button>
    </div>
  );
};

TextDisplay 组件只订阅 text:

javascript

const TextDisplay = () => {
  console.log('TextDisplay 渲染了');
  const text = useCounterStore((state) => state.text);
  const updateText = useCounterStore((state) => state.updateText);
  
  return (
    <div>
      <p>当前文本: {text}</p>
      <input value={text} onChange={(e) => updateText(e.target.value)} />
    </div>
  );
};

验证结果:

  • 修改文本时,控制台只打印 TextDisplay 渲染了
  • 点击计数按钮时,控制台只打印 CountDisplay 渲染了

这证明了精准更新机制生效!没有使用的组件不会重新渲染,性能得到保障。

进阶:API 直接调用

得益于 Object.assign,我们可以在任何地方直接操作状态:

javascript

const handleBatchUpdate = () => {
  useCounterStore.setState((prev) => ({ 
    count: prev.count + 10, 
    text: '批量修改完成!' 
  }));
  
  // 同步读取最新状态(不触发渲染)
  console.log(useCounterStore.getState());
};

这在处理异步逻辑非 React 环境(如 WebSocket 回调)时非常有用。

源码阅读的价值

通过手写 Zustand,我们收获了什么?

1. 设计模式的实战应用

  • 观察者模式:订阅发布机制
  • 闭包:状态隔离与持久化
  • 高阶函数:create 返回定制化 Hook

2. React 性能优化技巧

  • 通过 selector 避免无效渲染
  • Object.is() 精准判断状态变化
  • useEffect 清理函数自动取消订阅

3. 框架设计思路

为什么 Zustand 这么简单?因为它:

  • 没有引入中间件、异步处理等复杂概念
  • 直接利用 JS 闭包和 React Hooks,没有额外抽象
  • API 设计符合直觉,学习成本极低

总结

Zustand 的核心只有 200 行代码,却解决了 React 状态管理的本质问题。通过手写实现,我们深刻理解了:

  • 状态管理 = 存储 + 订阅 + 通知
  • 性能优化 = 精准订阅 + 浅比较
  • 好的 API = 隐藏复杂度 + 暴露灵活性

当你下次在项目中使用 Zustand 时,不妨打开 DevTools 观察组件的渲染次数,你会发现这个 1KB 的小库,背后有着极其精妙的设计哲学。

企业级Claw落地避坑指南:70%项目失败的真实原因

openclaw 吹了这么久,实际落地就卡在两个点,一个是费用确实不低,另一个是安全边界不清晰。很多团队想用,又不敢大规模接入,这也是为什么会出现像 Finclaw 这种更偏企业级方案的原因。

传送门

企业级AI平台的落地需要系统性的技术实施,核心在于建立可控、可靠、可规模化的技术基础。最近也关注到了市面上出现了很多号称企业级Claw的产品,下图我做了一个对比:

下面我们就展开聊聊,怎样避坑,成功落地适合自己企业发展路径的企业级Claw:

评估阶段:需求分析与场景选择

企业部署AI的第一步是明确业务需求技术可行性。错误的起点将导致整个项目偏离方向。评估阶段的目标是回答:我们为什么需要AI?我们能用AI做什么?我们是否有能力做好?

超过70%的AI项目失败源于需求不明确或技术能力评估不足。切勿跳过或简化评估阶段。

业务需求调研

需求调研需要回答几个关键问题:当前业务的核心痛点是什么?哪些痛点可以通过AI解决?AI解决这些问题能带来什么可量化的价值?实施的难度和成本是否在可接受范围内?

调研方法论

  • 深度访谈:与业务部门负责人进行1对1访谈,聚焦具体业务流程和痛点
  • 流程观察:实地观察现有工作流程,记录非结构化操作和重复性劳动
  • 数据分析:分析现有业务数据,识别效率瓶颈和优化机会点
  • 行业对标:参考同行业头部企业的AI应用实践,评估技术成熟度

适用场景识别

高优先级场景特征

  • 流程标准化程度高
  • 数据质量良好且可获取
  • 任务重复性强、耗时多
  • 错误率有明确改进空间
  • ROI(投资回报率)可量化

不适用场景警示

低优先级或高风险场景

  • 业务流程高度非结构化
  • 涉及重大安全或合规风险
  • 数据稀疏或质量极差
  • 决策逻辑复杂且依赖专家经验
  • 实施成本远超预期收益

技术能力评估

在确定业务需求后,必须客观评估企业的技术基础。技术能力评估包括四个维度:基础设施技术团队数据基础集成能力

基础设施评估指标

  • 计算资源:现有服务器的CPU/GPU配置、内存容量、存储IOPS
  • 网络环境:内网带宽、外网出口、延迟要求、安全策略
  • 软件栈:操作系统版本、容器平台、中间件、数据库兼容性
  • 云服务:如采用云部署,评估云厂商的服务等级协议(SLA)和区域覆盖

企业部署AI需要评估技术团队的核心能力,包括云原生架构理解、容器化部署经验、安全治理框架设计能力等。

风险控制考量

AI部署涉及安全风险合规风险业务风险技术风险四类主要风险。必须在评估阶段就进行识别和制定控制策略。

风险识别与评估矩阵

  1. 安全风险:数据泄露、权限滥用、模型投毒、逆向工程

    1. 控制策略:实施四层隔离架构、最小权限原则、输入输出过滤
  2. 合规风险:违反数据保护法规(如GDPR、个人信息保护法)、行业监管要求

    1. 控制策略:数据分类分级、隐私计算、审计追溯、合规性自检
  3. 业务风险:AI决策错误导致业务损失、业务流程中断、用户信任受损

    1. 控制策略:人工复核机制、A/B测试、渐进式上线、回滚预案
  4. 技术风险:系统性能不足、集成复杂度高、技术债务累积

    1. 控制策略:技术选型评估、架构评审、容量规划、技术债管理

规划阶段:架构设计与技术选型

规划阶段将评估阶段的输出转化为可执行的蓝图。核心任务包括确定部署模式、设计技术架构、规划资源需求

部署模式选择标准

企业需要根据业务场景安全合规要求成本预算技术能力综合选择部署模式。

公有云部署

适用场景

  • 互联网业务、初创企业
  • 对成本敏感、需要快速上线
  • 业务流量波动大,需要弹性伸缩

优势

  • 部署速度快,按需付费
  • 免运维基础设施
  • 全球节点,低延迟访问

挑战

  • 数据出境合规风险
  • 厂商锁定风险
  • 定制化程度有限

私有云部署

适用场景

  • 金融、政务、医疗等强监管行业
  • 数据敏感性高,要求完全控制
  • 已有成熟的私有云基础设施

优势

  • 数据完全自主可控
  • 深度定制化能力
  • 符合严格合规要求

挑战

  • 初期投资成本高
  • 运维复杂度高
  • 扩展性受硬件限制

混合云部署

适用场景

  • 既有敏感数据又有弹性需求
  • 全球化业务,需要多地部署
  • 逐步从私有云向公有云迁移

优势

  • 灵活的数据和计算分布
  • 成本与安全的平衡
  • 灾备和业务连续性保障

挑战

  • 架构复杂度最高
  • 网络和安全管理困难
  • 需要专业的跨云管理工具

技术架构设计原则

企业级Claw的技术架构设计遵循安全优先弹性可扩展易于运维三大原则。

多租户架构设计要点

  • 租户隔离:确保不同租户(部门/团队)的数据、资源、配置完全隔离
  • 资源共享:在隔离基础上实现计算、存储、网络资源的池化和动态调度
  • 权限模型:基于RBAC(角色权限控制)的细粒度权限管理,支持组织架构同步
  • 计量计费:按租户、按资源、按时长等多维度使用计量,支持成本分摊

安全架构核心组件

  1. 四层隔离:MicroVM(硬件虚拟化)→ 容器(进程隔离)→ 系统沙箱(系统调用过滤)→ 运行时(语言级沙箱)
  2. 网络策略:默认拒绝所有出口流量,基于白名单的精细化网络控制
  3. 密钥管理:密钥不落日志,审计前自动脱敏,支持硬件安全模块(HSM)集成
  4. 审计追溯:全链路操作日志,不可篡改记录,支持第三方审计系统对接

可扩展性设计策略

  • 水平扩展:无状态服务支持通过增加Pod/节点实现近乎线性的性能扩展
  • 垂直扩展:有状态服务支持硬件升级(CPU/内存/GPU)提升单点性能
  • 弹性伸缩:基于CPU使用率、并发请求数、自定义指标自动扩缩容
  • 服务治理:服务发现、负载均衡、熔断降级、限流控制等微服务治理能力

系统集成方案

AI平台需要与现有企业IT系统无缝集成,才能发挥最大价值。

身份认证集成方案

  1. 单点登录(SSO)集成:支持SAML 2.0、OAuth 2.0、OIDC协议,与企业AD/LDAP/统一身份平台对接
  2. 组织架构同步:定期从HR系统同步组织架构和人员信息,支持增量同步和冲突解决
  3. 权限映射:将企业RBAC权限模型映射到AI平台的权限体系,确保权限一致性
  4. 审计日志对接:将AI平台操作日志推送到企业统一日志平台,支持集中审计和分析

数据源集成模式

  • 直接连接:通过JDBC/ODBC直接连接关系型数据库(MySQL/PostgreSQL/Oracle)
  • API集成:通过RESTful API/gRPC集成业务系统,支持认证、限流、熔断
  • 消息队列:通过Kafka/RabbitMQ集成异步系统,支持发布订阅模式
  • 文件系统:通过NFS/SMB/对象存储接口访问企业文件存储

业务流程集成策略

  1. 工作流触发:在OA/BPM系统中添加AI任务节点,触发AI处理流程
  2. 审批流嵌入:在审批流程中嵌入AI辅助决策,提供数据支持和建议
  3. 通知渠道集成:支持邮件、企业微信、钉钉、飞书等多渠道结果通知
  4. 报表数据对接:将AI处理结果推送到BI系统,生成可视化报表和Dashboard

安全配置要求

安全配置是确保AI平台安全运行合规使用的基础。

网络层安全配置

  • 防火墙规则:遵循最小权限原则,只开放必要的端口和服务
  • 网络分段:生产环境、测试环境、管理网络物理或逻辑隔离
  • 访问控制列表:基于源IP、目的IP、端口、协议的精细化访问控制
  • VPN/零信任:远程访问必须通过VPN或零信任网络,支持多因素认证

应用层安全配置

  1. HTTPS强制:所有Web访问强制使用HTTPS,配置HSTS头,使用TLS 1.3
  2. API安全:API密钥管理、请求签名、频率限制、输入验证、输出过滤
  3. 会话管理:安全的会话Cookie设置(HttpOnly、Secure、SameSite)
  4. CSRF/XSS防护:启用CSRF令牌,配置内容安全策略(CSP)

数据安全配置

  • 加密存储:敏感数据在存储时加密,使用企业密钥管理系统(KMS)
  • 传输加密:所有数据传输使用TLS加密,禁用弱密码套件
  • 数据脱敏:在开发、测试环境使用脱敏数据,防止敏感信息泄露
  • 备份加密:备份数据加密存储,备份介质安全保管

运营阶段:治理与优化

系统上线后进入持续运营阶段。核心目标是通过日常运维安全审计性能优化,确保系统稳定、安全、高效运行。

日常运维流程

日常运维确保系统7×24小时稳定运行,及时发现和处理异常。

监控管理体系

基础设施监控

  • 服务器监控:CPU使用率、内存使用率、磁盘IO、网络流量
  • 容器监控:Pod状态、资源限制、重启次数、就绪检查
  • 服务监控:服务端点健康状态、响应时间、错误率
  • 存储监控:存储容量、IOPS、延迟、可用性

业务层面监控

  • 用户行为监控:活跃用户数、会话时长、功能使用频率
  • AI服务监控:模型推理延迟、准确率、调用次数、成本
  • 业务流程监控:流程完成率、平均处理时间、异常中断率
  • 业务价值监控:ROI指标、效率提升比例、错误减少率

安全审计机制

安全审计确保系统持续合规,及时发现安全威胁

合规性审计框架

  1. 定期合规扫描:每月执行一次全面的合规性检查,包括配置核查、漏洞扫描、权限审计
  2. 合规报告生成:自动生成合规性报告,标注不合规项、风险等级、整改建议
  3. 合规整改跟踪:建立合规问题跟踪表,明确整改责任人、整改期限、验证方法
  4. 合规证据留存:所有合规性证据(扫描报告、整改记录、审计日志)安全存储,保留至少6个月

安全操作审计要点

  • 用户访问审计:记录所有用户的登录时间、IP地址、操作行为、登出时间
  • 特权操作审计:管理员操作、权限变更、配置修改等特权操作必须详细记录
  • 数据访问审计:敏感数据的查询、修改、导出操作需要记录操作者、时间、内容
  • 安全事件审计:所有安全相关事件(登录失败、权限拒绝、异常访问)需要记录和分析

风险持续评估方法

  • 威胁建模:每季度更新一次系统威胁模型,识别新的威胁和脆弱性
  • 渗透测试:每半年委托第三方进行渗透测试,发现深层次安全漏洞
  • 红蓝对抗:每年组织一次内部红蓝对抗演练,检验安全防御体系有效性
  • 风险指标监控:建立安全风险指标体系,持续监控风险变化趋势

性能优化策略

性能优化是一个持续迭代的过程,目标是提升系统效率用户体验

资源使用优化

  1. 资源利用率分析:识别资源使用瓶颈(CPU密集型、IO密集型、内存密集型)
  2. 资源调度优化:调整Kubernetes调度策略,优化Pod放置,减少资源碎片
  3. 弹性伸缩优化:基于实际业务负载调整HPA参数,避免过度伸缩或伸缩不足
  4. 成本优化:分析资源使用模式,采用预留实例、竞价实例等降低成本

AI模型性能优化

  • 模型压缩:使用量化、剪枝、知识蒸馏等技术减少模型大小,提升推理速度
  • 缓存优化:实现多级缓存(内存缓存、分布式缓存),减少重复计算
  • 批处理优化:对小请求进行批处理,提高GPU利用率,降低单次推理成本
  • 模型版本管理:建立模型版本管理流程,支持A/B测试、灰度发布、快速回滚

系统级性能调优

优化方向 具体措施 预期效果
数据库优化 索引优化、查询重写、读写分离、分库分表 查询性能提升30-50%
网络优化 TCP参数调优、连接池优化、CDN加速、协议优化 网络延迟降低20-40%
存储优化 SSD缓存、数据压缩、冷热数据分离、RAID优化 IOPS提升50-100%
应用优化 代码性能剖析、异步处理、内存管理优化、垃圾回收调优 应用响应时间缩短30-60%

扩展阶段:规模化与创新

当核心系统稳定运行后,进入扩展阶段。目标是将AI能力从试点项目扩展到全企业范围,从工具应用升级到平台创新

能力扩展路径

能力扩展遵循从核心到外围从简单到复杂的路径。

功能扩展策略

  1. 核心功能强化:基于用户反馈优化现有功能,提升准确率、响应速度、易用性
  2. 新功能开发:根据业务需求开发新的AI能力,如图像识别、语音处理、文档理解
  3. 第三方集成:集成优秀的第三方AI服务,补充平台能力,快速满足业务需求
  4. 开放平台建设:提供API和SDK,允许业务部门自主开发AI应用,构建生态系统

容量扩展规划

  • 容量预测:基于历史增长数据和业务规划,预测未来6-12个月的容量需求
  • 扩展方案:制定详细的扩展方案,包括硬件采购、软件许可、人员配置时间表
  • 扩展测试:扩展前进行容量测试,验证扩展方案的可行性和性能表现
  • 扩展执行:在业务低峰期执行扩展操作,确保业务连续性

服务扩展方向

横向扩展

  • 服务地域扩展:从单一数据中心扩展到多地域、多可用区部署
  • 服务用户扩展:从试点部门扩展到全公司,从内部员工扩展到合作伙伴
  • 服务时间扩展:从工作时间支持扩展到7×24小时服务
  • 服务渠道扩展:从Web端扩展到移动端、API、消息机器人等多渠道

纵向扩展

  • 服务深度扩展:从简单问答扩展到复杂决策支持、自动化流程
  • 服务智能扩展:从规则驱动升级到机器学习驱动,提升智能化水平
  • 服务集成扩展:从独立系统升级到与业务系统深度集成,嵌入业务流程
  • 服务价值扩展:从效率工具升级到业务创新平台,创造新业务价值

场景扩展方法

将AI能力应用到更多业务场景,最大化AI投资回报。

横向场景扩展模式

  • 部门间复制:将在一个部门验证成功的场景模式复制到其他类似部门
  • 流程链延伸:将AI能力从单个流程环节扩展到整个端到端业务流程
  • 数据类型扩展:从处理结构化数据扩展到处理非结构化数据(文本、图像、语音)
  • 业务领域扩展:从单一业务领域(如客服)扩展到多业务领域(如营销、风控、运营)

纵向场景深化策略

  1. 从辅助到自主:从人工复核的辅助决策升级到完全自主的自动化决策
  2. 从单点到体系:从解决单点问题升级到构建完整的AI解决方案体系
  3. 从执行到优化:从执行既定任务升级到持续优化业务流程和策略
  4. 从工具到伙伴:从被动响应的工具升级到主动建议的智能伙伴

技术实施要点总结

企业级Claw的成功落地需要关注以下核心技术要点

技术要素:架构与集成的基石

合理的架构设计是技术成功的首要条件。企业级Claw必须采用多租户架构,支持海量用户并发访问;必须实现四层安全隔离,确保数据安全和隐私保护;必须提供完整的可观测性,支持故障排查和性能优化。

与现有系统的无缝集成决定了AI平台能否融入企业IT生态。身份认证必须与企业统一身份平台对接,确保单点登录和权限一致;数据源必须支持企业各类数据库和API,确保数据可访问;业务流程必须与现有工作流系统集成,确保AI能力嵌入实际工作。

安全可靠的技术实现是企业信任的基础。系统必须通过严格的安全测试,包括渗透测试、代码审计、漏洞扫描;必须提供完善的容灾备份方案,确保业务连续性;必须实现细粒度的权限控制和审计追溯,满足合规要求。

最终结论:企业级AI平台的落地需要从技术架构安全治理部署模式三个维度进行系统性设计。只有建立可控、可靠、可规模化的技术基础,才能将AI从概念验证转化为可持续的生产力。

最后我准备了一份企业级Claw的技术白皮书, 点击领取

React-彻底搞懂 Redux:从单向数据流到 useReducer 的终极抉择

前言

在 React 生态中,状态管理一直是开发者绕不开的话题。Redux 以其严谨的“单向数据流”闻名,虽然有一定的学习成本,但它为大型项目带来的可预测性和可调试性是无可替代的。本文将带你深度复盘 Redux 的核心逻辑。

一、 Redux 的核心

Redux 的核心思想是将应用的所有状态(State)集中存储在一个唯一的 Store 中,并遵循严格的规则进行更新,让状态变化可追踪、可调试,大幅降低复杂应用的状态维护成本。

1. 三大核心概念

Redux 的运行逻辑完全围绕三大核心模块展开,各司其职、互不干扰,构建了清晰的单向数据流:

1. Store(数据仓库)

  • 定位:应用状态的唯一存储容器,整个应用有且仅有一个 Store
  • 作用:承载全局状态、派发 Action、监听状态变化、整合 Reducer,是连接视图和数据的核心枢纽
  • 特性:独立于组件生命周期,不会随组件销毁而消失,状态持久稳定

2. Action(动作描述)

  • 定位改变 State 的唯一途径,是一个普通的 JavaScript 对象
  • 结构:必须包含 type 属性(字符串类型,描述动作类型),可选携带 payload 属性(传递更新状态所需的数据)
  • 示例{ type: 'UPDATE_USER_NAME', payload: '李四' }
  • 本质:只描述“要做什么”,不负责“怎么做”,属于指令载体

3. Reducer(状态处理器)

  • 定位纯函数(固定输入必然得到固定输出,无副作用、不修改入参)
  • 参数:接收两个参数——当前旧 State(prevState)、派发的 Action 对象
  • 逻辑:根据 Action 的 type 类型,匹配对应的更新逻辑,绝不直接修改旧 State
  • 规则:Redux 强制要求 State 不可变(Immutable),必须返回全新的 State 对象,保证状态变化可回溯、可调试

二、 Redux 的工作流程:闭环的单向流

Redux 的数据流转遵循严格的循环路径,确保了状态变化的可追踪性:

  1. 用户触发操作:用户在页面执行交互行为(点击按钮、输入内容、路由跳转等),组件内触发状态更新需求

  2. 派发 Action:通过 Redux 提供的 dispatch 方法,将封装好的 Action 对象派发出去

  3. Reducer 处理:Store 自动将当前旧 State 和派发的 Action 传递给 Reducer,Reducer 根据 type 执行对应逻辑,返回新 State

  4. Store 更新状态:Store 接收 Reducer 返回的新 State,替换内部旧状态

  5. 组件同步数据:所有订阅了 Store 状态的组件,会自动感知状态变化,重新渲染视图,完成数据同步

注意:禁止直接修改 Store 中的 State,必须通过 dispatch 派发 Action → Reducer 生成新 State 的方式更新,这是 Redux 可预测性的核心保障。


三、 Redux vs useReducer:我该选哪个?

很多开发者会混淆这两者,虽然它们都使用了 action/reducer 模式,但在应用范围上有本质区别。

维度 useReducer Redux
存储位置 组件内部(Local State) 独立的全局 Store(Global State)
作用域 仅限当前组件及其子组件 整个应用,任意组件均可访问
生命周期 随组件销毁而消失 独立于组件,持久存在
跨组件通信 需配合 useContext 并提升组件层级 天然支持,无需透传 Props

场景选择:

  • 使用 useReducer:逻辑复杂(有很多 if/else 或 switch),但只在单个组件或其嵌套子组件中使用。
  • 使用 Redux:数据需要全局共享。例如:用户信息需要同步更新导航栏、侧边栏和个人中心;或者需要将状态持久化到 localStorage 并在刷新后恢复。

四、 总结

Redux 的本质是牺牲了一定的代码简便性,换取了极致的状态可预测性。在处理跨页面同步、复杂业务逻辑以及需要状态回溯的场景下,Redux 依然是前端状态管理的王者。

React-路由监听 / 跳转 / 守卫全攻略(附实战代码)

前言

React Router 是 React 单页应用的核心路由库,除了基础的路由配置,日常开发中还会高频用到路由监听、编程式跳转、路由守卫等进阶功能。本文从实战角度拆解这三大核心能力,涵盖实现方式、场景对比、避坑要点,基于 React Router v6+ 版本(主流稳定版)讲解,新手也能快速落地!

一、 路由监听:如何捕捉 URL 的变化?

在 React 中,React 监听路由变化的本质是监听 URL 相关属性(pathname/search/params 等)的变化,触发自定义回调函数。以下是 3 种常用实现方式:

1. 核心方案:useLocation + useEffect

这是最通用的监听方式。通过 useLocation 获取当前路由完整信息(pathname/search/state 等),结合 useEffect 监听 location 对象变化,触发回调函数。

import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';

const App = () => {
  const location = useLocation();

  useEffect(() => {
    // 每次路由切换时执行
    console.log('当前路径:', location.pathname);
    console.log('搜索参数:', location.search);
  }, [location]); 
};

2. 精准监听:useParamsuseSearchParams

如果你只关心某个特定的动态参数(如 id),直接监听参数对象会更高效。

  • useParams:监听动态路由参数(如 /user/:id 中的 id);

  • useSearchParams:监听 URL 搜索参数(如 ?page=1&size=10);

  • 适用场景:

    • 仅关注动态路由参数或搜索参数变化的场景(如详情页 ID 切换、列表页分页参数变化)。

3.监听原生路由事件(不推荐)

  • 通过 window.addEventListener 监听 popstate(History 模式)或 hashchange(Hash 模式)事件,直接捕获 URL 变化。

  • 缺点:React Router 已封装原生事件,手动监听易出现重复触发、状态不一致问题,仅建议特殊场景(如兼容老代码)使用。


二、 路由跳转

React Router 提供多种跳转方式,适配「点击跳转」「编程式跳转」「导航栏高亮」等不同场景:

1. 声明式导航:<Link><NavLink>(最常用)

  • <Link> :基础跳转,React Router 核心跳转组件,替代原生 <a> 标签(避免页面刷新),核心属性如下:

    • to:必传,目标路径(支持字符串 / 对象格式);

    • replace:默认 false(新增历史记录),true 则替换当前记录(跳转后无法回退);

    • state:传递自定义状态(不显示在 URL 中,通过 useLocation().state 获取)。

  • <NavLink> :专为导航栏设计,新增激活状态相关配置,适合导航栏场景:

    • isActive:可根据激活状态动态设置样式类名。

    • end:精准匹配模式,防止 / 匹配到所有子路由(如 /home 不匹配 /home/detail)。

2. 编程式导航:useNavigate

适用于点击按钮后的逻辑处理或异步请求后的跳转,通过 useNavigate Hook 获取导航函数,实现非点击触发的跳转(如接口请求后、条件判断后)。

const navigate = useNavigate();

// 基础跳转,带状态
navigate('/profile', { replace: true, state: { from: 'home' } });

// 历史记录操作
navigate(-1); // 后退一步

三、 路由守卫:在 React 中如何“拦截”?

Vue 有原生的 beforeEach/afterEach 路由守卫,但 React Router 无专属 API,核心通过监听路由 + 条件判断实现,分为 3 类场景:

1. 全局路由守卫

实现逻辑

在路由根组件(如 App.jsx)中监听 location 变化,执行全局校验(如登录状态、白名单),拦截非法跳转。

实战代码

import { useLocation, useNavigate, useEffect } from 'react-router-dom';
import { isLogin } from '@/utils/auth'; // 自定义登录校验函数

// 全局路由守卫组件
const GlobalRouterGuard = () => {
  const location = useLocation();
  const navigate = useNavigate();
  // 无需登录的白名单路由
  const whiteList = ['/login', '/register'];

  useEffect(() => {
    // 未登录且不在白名单 → 跳转到登录页
    if (!isLogin() && !whiteList.includes(location.pathname)) {
      navigate('/login', { 
        replace: true,
        state: { from: location.pathname } // 记录来源路径,登录后跳转回去
      });
    }
  }, [location.pathname, navigate]);

  return null; // 守卫组件无需渲染 DOM
};

// 在根路由中引入
// <BrowserRouter>
//   <GlobalRouterGuard />
//   <Routes>...</Routes>
// </BrowserRouter>

2. 组件内路由守卫

实现逻辑

在组件内通过 useEffect 实现进入守卫(组件挂载时校验),通过 useEffect 的返回函数实现离开守卫(组件卸载时执行)。

实战代码

import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { hasPermission } from '@/utils/permission'; // 自定义权限校验

const UserDetail = () => {
  const navigate = useNavigate();

  // 进入守卫:组件挂载时校验权限
  useEffect(() => {
    if (!hasPermission('user:view')) {
      navigate('/403', { replace: true });
    }
  }, [navigate]);

  // 离开守卫:组件卸载时执行(如保存表单、提示未提交内容)
  useEffect(() => {
    return () => {
      console.log('离开用户详情页,执行清理逻辑');
      // 业务逻辑:保存草稿、关闭WebSocket等
    };
  }, []);

  return <div>用户详情页</div>;
};

3. 路由独享守卫

  • 概念: 不影响全局其他路由,只针对这一个页面(或这一组页面)进行特殊的准入检查。 例如整个应用都可以访问,但只有/admin页面需要检查管理员权限

  • 实现”先创建一个守卫组件AdminGuard,这个组件专门负责检查当前用户是不是管理员,然后将需要单独检查的Admin路由套在这个守卫组件AdminGuard里面

实战代码:

// 守卫组件:AdminGuard.jsx
import { useNavigate } from 'react-router-dom';
import { isAdmin } from '@/utils/auth';

const AdminGuard = ({ children }) => {
  const navigate = useNavigate();
  
  // 管理员权限校验
  if (!isAdmin()) {
    navigate('/403', { replace: true });
    return null;
  }

  // 权限通过,渲染子组件
  return children;
};

// 路由配置中使用
import { Routes, Route } from 'react-router-dom';
import SettingsPage from '@/pages/Settings';

const RouterConfig = () => {
  return (
    <Routes>
      {/* 独享守卫:仅 /settings 路由触发管理员校验 */}
      <Route 
        path="/settings" 
        element={
          <AdminGuard>
            <SettingsPage />
          </AdminGuard>
        } 
      />
    </Routes>
  );
};

4.扩展:离开时的路由拦截(useBlocker)

useBlocker 是 React Router v6 新增 Hook,用于拦截所有路由跳转行为(包括 <Link>navigate、浏览器前进 / 后退)。

import { useBlocker } from 'react-router-dom';

// blockerFn:返回 true 拦截跳转,false 放行
// when:是否启用拦截(可选,默认 true)
useBlocker((tx) => {
  console.log('即将跳转到:', tx.location.pathname);
  return true; // 拦截跳转
}, when);

实战场景:表单未提交拦截

import { useBlocker } from 'react-router-dom';

const FormPage = () => {
  const [formDirty, setFormDirty] = useState(false); // 表单是否修改

  // 表单未提交时拦截跳转
  useBlocker((tx) => {
    if (formDirty) {
      const confirm = window.confirm('表单内容未保存,是否确认离开?');
      return !confirm; // 点击取消 → 拦截(返回 true)
    }
    return false; // 放行
  }, formDirty); // 仅表单修改时启用拦截

  return (
    <form onChange={() => setFormDirty(true)}>
      <input type="text" placeholder="输入内容..." />
    </form>
  );
};

四、 总结与最佳实践

  1. 优先使用原生 Link:对于简单的跳转,<Link> 的性能和 SEO 优于 useNavigate
  2. 善用 State 传参:如果不想 URL 变得太长,利用 location.state 传递对象是最佳选择。
  3. 守卫逻辑模块化:不要在 App.js 里写一堆 if-else,将权限校验封装成独立的 Guard 组件。

面了3个人后我发现:AI用得最溜的,未必是我最想要的工程师

这两周,我面试了 3 个人,技术岗。

现在聊技术面试,很多人默认一个前提:

大家都想要那种 AI 用得很溜的人。

但我实际聊下来,发现真正需要的,未必只是这种人。

真正稀缺的,是有判断力的人。

原因很简单。

怎么用 AI,其实不难。

真正难的,是你能不能想到:这东西原来还能这么玩。

所以我面试一个人,看的已经不只是会不会写代码了。

我会更关注两件事。

第一,他有没有真正搞懂自己的项目到底是怎么跑起来的。

第二,他借助 AI,已经能做到什么程度。

因为 AI 工具换得太快了。

今天你熟这个,明天可能就冒出来一个更强的。

产品化的 AI 工具,上手门槛其实非常低。很多时候根本不需要培训一两天,可能装上就能开始干活。真正的门槛,反而变成了愿不愿意用,或者怎么把它装起来。

我面试的一个人,就是很典型的例子。

5 年经验,使用 AI 写代码非常快,效率也确实高,已经能干过去两个自己干的活。

乍一看,这种人很亮眼。

但我往下问了一层,问题就出来了。

工具是公司给的。

他对 AI 为什么能做到这件事,几乎不知道。

再继续问项目本身是怎么运转起来的,也没有想象中那么清楚。

更关键的是,在 AI 带来的提效之后,他原本的技术成长,反而停住了。

而且这一停,就是 1 年。

最后呈现出来的状态就是:能力和年限不匹配。

他能提效的核心原因,不是自己突然变得特别厉害,而是公司提供的工具真的很好用。

换句话说,他没有形成真正属于自己的能力。

但现在这个环境,资历浅的开发工程师,如果还想单靠传统技术路径往上升,其实已经越来越难了。

不是说技术不重要了。

而是现实情况是,很多人已经没有那么多机会,去亲手解决那些真正有深度的问题。

就好比每次课后作业,你都可以参考班里那个学习最好的同学,老师也只要求你把结果交上去就行。

在这种环境里,你当然能完成任务。

但如果哪天碰到一道连那个好学生都不会的题,你真的还能指望自己上场就更强吗?

相比之下,我面试的另外一个人,反倒是我更愿意高看一眼的类型。

8 年经验,因为行业限制,他没有很深入地接触 AI。

如果只看这个点,他不算亮眼。

但他的技术底子很扎实,解决问题的思路也很清楚。很多东西你一问,就知道底子在,很多能力是实打实沉淀过的。

更重要的是,他不是排斥 AI。只是之前没有足够多的机会,系统地去用。

我问了他一个问题:如果你有机会提效,但是要付出成本。你每个月愿意最多出多少预算?

每个人心里有自己的一杆秤。他的回答我认同。

所以我不会因为他现在 AI 这块不够亮眼,就刻意把评价压低。

相反,我会按照他真实的技术能力去评价他。

原因也很简单。

AI 这件事,学起来没有很多人想得那么玄。

尤其对于一个技术基本功强的人来说,他理解 AI、接住 AI、用好 AI 的成本,只会比别人更低。

这类人最缺的,很多时候不是能力,而是一个真正拥抱 AI 的决心。

很多高级工程师的问题,也不在能力,而在心理上。

这类人太容易相信自己过去那套路径了,也太容易依赖原有经验了。

结果就是,AI 他们不是不会用,而是总用得别别扭扭,总比别人慢半拍。

说白了,就是不太愿意把自己重新当成新人。

但偏偏,AI 时代最强的一批高级工程师,恰恰是那些愿意重新归零的人。

你越强,越要学会承认一件事:

有些问题,AI 解决得就是比你快,比你广,比你不知疲倦。

这不丢人。

跟计算器较劲的人,最后往往不是数学家,而是算盘手。

那么问题来了。

绝大多数人并没有很强的技术功底,以后又越来越少有机会,去独立解决复杂问题。

那怎么办?

我觉得,未来评判一个工程师,至少会越来越看重两个标准。

第一,一个人借助 AI,自己能代替过去多少人古法手搓的活。

第二,一个人借助 AI,能够帮助多少人提升效率。

一个是给自己提效。

一个是给别人提效。

显然,第一个方向更适合大部分人。

它有点像在美国大平原种田。

当机械化工具来了之后,就不再需要那么多传统农民了。一个农场主,可能就能干过去 100 个农民的工作量。

放到工程师身上,其实也是一样的。

借助 AI,一个人未来能吃下的工作量,一定会越来越大。

但这个方向也更卷,更残酷。

因为这个行业里的“农民”太多了,而未来未必还需要这么多人。

甚至很多后发的人,反而可能更有优势。

因为他们天生包袱更少,也总能更快接住新工具,精力也更充沛。

给自己提效还有一个思路,就是把手往上游和下游伸。

去抢那些其他岗位的饭碗,比如产品经理、项目主管、测试、运维等这些角色。

说得再直接一点:

如果你能做他们做的事情,还能把整条链路的事情一起做完,那企业为什么还要按原来的方式配那么多人?

这其实就是一种降维打击。

写到这里,其实我真正想说的,只有一句话:

AI 时代当然要学 AI。

但别把“会用 AI”,误以为是全部。

因为工具会越来越强,教程会越来越多,门槛会越来越低。

真正拉开差距的,依然还是那些更底层的东西。

比如你的判断力。

比如你的问题拆解能力。

比如你的架构思维。

比如你的学习能力。

以及最重要的一点:

你有没有勇气,重新把自己当成一个新人。

真正厉害的人,不是手里拿着多少个 AI 工具的人。

而是即使工具天天在变,也总能把问题解决掉的人。

这种人,放在什么时候,都不会太差。

2026 年前端面试问什么

背景

由于所在的外企撤出中国,我再次开始了面试之旅。这次我没有选择大厂和小公司,而是主要聚焦在外企和中厂。经过一段时间的面试,我发现 2026 年的前端面试已经发生了显著的变化,特别是 AI 相关的内容占比大幅提升。

面试内容分布

根据我的面试经历,2026 年前端面试的内容分布大致如下(本人接近十年工作经验,仅供参考):

  • coding 20% (LeetCode 算法题和手写代码各一半吧)
  • 八股文:20%(主要是 React Fiber 等核心原理)
  • 项目经历:30%
  • 系统设计:10%(如设计一个支付页面)
  • AI相关问题:20%(这是 2026 年的新重点)

可以看到,相比几年前,AI 相关的内容已经成为面试的重要组成部分。

常见 AI 面试问题

在我面试的过程中,几乎每家公司都会问到以下问题:

  1. 你的日常 AI 工作流是什么?
  2. 如何保证 AI 生成代码的质量?
  3. 你使用哪些 AI 工具?各自的优势是什么?

下面我会详细讲解我的答案。

我的 AI 工作流

我将 AI 工作流分为五个阶段,每个阶段都有明确的目的和技术方案:

1. 需求前:Context 优化

目的:让 AI 充分理解项目上下文,提供高质量的代码生成

技术方案

  • 生成并维护项目 Rule 文件,定义代码规范和架构约束
  • 配置 MCP 服务,提供项目特定的上下文
  • 配置 Skills,为特定任务提供专业知识
  • 生成 Onboarding 文档和 README,帮助 AI 快速理解项目
  • 设定清晰的输入输出规范
  • 定期更新项目总结文档,保持 AI 对项目状态的同步

2. 需求分析:定义问题和约束

目的:明确需求,分析技术方案,设定实现步骤

技术方案

  • 使用 AI 进行需求拆解和分析
  • 让 AI 识别潜在的技术风险和约束
  • 生成详细的实现步骤和 TODO 列表
  • 评估不同技术方案的优劣

3. 需求实现:AI 生成代码

目的:高效生成高质量代码

技术方案

  • UI to Code:从设计稿直接生成组件代码
  • 组件生成:生成可复用的 React 组件
  • 逻辑实现:生成业务逻辑和状态管理代码
  • 测试生成:自动生成单元测试和集成测试
  • 任务拆分:将大任务拆分为小任务,逐步实现
  • 设计优先:让 AI 先设计架构,再实现细节

4. 需求验证:自动验证

目的:确保代码质量和功能正确性

技术方案

  • 静态检查:ESLint、TypeScript、Prettier
  • 自动测试:Jest、React Testing Library、Playwright
  • CI Pipeline:GitHub Actions、GitLab CI
  • 代码审查:AI 辅助的 Code Review

5. 上线与优化:持续优化

目的:持续改进代码质量和 AI 使用效率

技术方案

  • Code Review 反馈:收集团队对 AI 生成代码的反馈
  • 线上监控:监控 AI 生成代码的运行表现
  • Prompt 优化:根据反馈优化 AI 提示词
  • 知识库更新:将最佳实践沉淀到知识库

AI 代码质量保障体系

面试官通常会追问:如何保证 AI 生成的代码质量?我的答案是建立四个阶段的质量保障体系:

开发前:规范与架构约束

  • 制定详细的代码规范(Rule 文件)
  • 定义架构约束和设计模式
  • 配置 AI 的上下文和知识库
  • 设定代码生成的边界条件

开发中:静态质量检查

  • 高质量的 Prompt:清晰、具体、包含上下文
  • 充分的上下文:提供相关代码、文档、历史记录
  • 可复用的 Skill:沉淀常见任务的最佳实践
  • 实时反馈:及时纠正 AI 的错误方向

高质量 Prompt 的要素

一个好的 Prompt 应该包含:

  1. 明确的目标:要实现什么功能
  2. 具体的约束:技术栈、代码规范、性能要求
  3. 充分的上下文:相关代码、接口定义、业务逻辑
  4. 期望的输出:代码、文档、测试等
  5. 质量要求:类型安全、错误处理、可访问性

提交时:自动化测试与 Code Review

  • 运行完整的测试套件
  • 执行静态代码分析
  • AI 辅助的 Code Review
  • 人工 Review 关键代码

运行时:监控与反馈

  • 错误监控和告警
  • 性能指标追踪
  • 用户行为分析
  • 持续优化迭代

我使用的 AI 工具栈

面试官通常会问你使用哪些 AI 工具。我的回答是:

  1. Cursor:主力 IDE,集成了 AI 编程助手

    • 用于:日常编码、代码补全、重构、生成测试
    • 优势:深度集成开发环境,理解项目上下文
    • 使用频率:每天 80% 的编码时间
  2. Claude / GPT-4:用于复杂问题的分析和方案设计

    • 用于:架构设计、技术方案评估、复杂问题分析
    • 优势:强大的推理能力,能够处理复杂的上下文
    • 使用场景:需求分析、技术选型、疑难问题解决
  3. ChatGPT:学习和快速查询

    • 用于:新技术学习、API 查询、快速问答
    • 优势:响应快速,适合碎片化学习

每个工具都有其适用场景,关键是要知道什么时候用什么工具。

如何看待 AI 与程序员的关系

这是面试官经常会问的一个开放性问题。我的回答是:

AI 是放大器,不是替代品

  • AI 让优秀的工程师更加高效,但不能让不合格的工程师变得合格
  • AI 擅长执行明确的任务,但不擅长理解模糊的需求
  • AI 可以生成代码,但不能做出架构决策
  • AI 可以提供建议,但不能承担责任

我的使用原则

  1. AI 负责执行,人负责决策

    • 架构设计、技术选型由人来做
    • 具体实现、测试生成由 AI 来做
  2. AI 负责初稿,人负责精修

    • AI 生成代码的初稿
    • 人进行 Review 和优化
  3. AI 负责重复,人负责创新

    • 重复性的 CRUD、样板代码由 AI 生成
    • 创新性的解决方案由人来设计
  4. 持续学习,保持竞争力

    • AI 在进化,我们也要进化
    • 学习如何更好地使用 AI
    • 学习 AI 无法替代的能力(架构、业务理解、团队协作)

其他面试内容

Coding(20%)

约 70% 的公司会有 coding 环节。速度很关键,如果你在这一环节耗时太多(超过 20 分钟),你的面试大概率就失败了。

LeetCode 算法题(10%):

  • 数组和字符串操作(高频)
  • DFS 和 BFS(中频)
  • 动态规划基础题(低频)
  • 难度:Medium 为主,偶尔有 Easy 或 Hard

手写代码(10%):

  • 手写 Promise、Promise.all、Promise.race
  • 手写数组拍平
  • 手写promise并发

准备建议

  • LeetCode 刷 100-150 题即可,重点是高频题
  • 手写代码要能讲清楚原理,不是背答案
  • 可以使用 AI 帮你理解算法原理,但要自己手写实现

八股文(20%)

主要集中在 React 核心原理,这部分是外企和中厂都很看重的:

React Fiber 架构(高频必考):

  • Fiber 是什么?为什么需要 Fiber?
  • Fiber 的工作原理:双缓冲、时间切片
  • Fiber 的两个阶段:render 阶段和 commit 阶段
  • 优先级调度机制
  • 可中断渲染的实现原理

Hooks 原理(高频):

  • useState 的实现机制:链表结构、闭包
  • useEffect 的执行时机和清理机制
  • useCallback 和 useMemo 的区别和使用场景
  • 自定义 Hook 的设计原则
  • Hooks 的规则和原因(为什么不能在条件语句中使用)

并发特性(中频):

  • Concurrent Mode 的原理和优势
  • Suspense 的使用场景和实现原理
  • Transitions 和 useTransition 的应用
  • 自动批处理(Automatic Batching)

状态管理(中频):

  • Redux vs Zustand 的对比
  • 什么时候需要全局状态管理
  • Context API 的性能问题和优化
  • 服务端状态管理(React Query / SWR)

性能优化(高频):

  • React.memo、useMemo、useCallback 的使用场景和区别
  • 虚拟列表的实现原理
  • 代码分割和懒加载
  • 如何分析和优化 React 应用的性能

准备建议

  • 不要死记硬背,要理解原理
  • 准备好代码示例,能够现场讲解
  • 可以让 AI 帮你梳理知识点,但要自己消化理解
  • 关注 React 19 的新特性(Server Components、Actions 等)

项目经历(30%)

这是面试的重头戏,也是最能展示你能力的部分。建议准备 3-5 个项目,覆盖不同维度:

  1. 技术深度项目:展示你对某个技术的深入理解

  2. 项目管理项目:展示你的规划和推动能力

  3. 失败的项目:展示你如何应对挫折

  4. 团队协作项目:展示你的沟通和协作能力

回答框架(CARL 模型)

每个项目准备好 CARL 模型的回答:

  • Context(背景):项目背景、面临的挑战、为什么重要
  • Action(行动):你具体做了什么、如何做的、为什么这样做
  • Result(结果):最终的成果、量化的数据、业务影响
  • Learning(收获):学到了什么、如何应用到后续工作

示例:技术深度项目

Context:
公司的管理后台有一个包含 10 万条数据的表格,用户反馈滚动卡顿,
体验很差。传统的分页方案不满足产品需求,需要支持无限滚动。

Action:
1. 性能分析:使用 React DevTools Profiler 定位性能瓶颈
2. 技术调研:对比 react-window、react-virtualized 等方案
3. 方案设计:选择 react-window + 自定义 hooks 实现虚拟滚动
4. 实现细节:
   - 动态行高计算
   - 滚动位置保持
   - 数据预加载
   - 搜索和筛选优化
5. 测试验证:性能测试、兼容性测试

Result:
- 首屏渲染时间从 3 秒降低到 0.3 秒(提升 90%)
- 滚动帧率从 20fps 提升到 60fps
- 内存占用从 500MB 降低到 50MB(降低 90%)
- 用户满意度从 60% 提升到 95%

Learning:
- 深入理解了浏览器渲染机制
- 学会了使用 Performance API 进行性能分析
- 认识到性能优化要基于数据,而不是猜测
- 虚拟化是处理大数据渲染的有效方案

系统设计(10%)

这是外企比较看重的部分。

常见题目

  • 设计一个支付页面
  • 设计一个图片上传和裁剪系统
  • 设计一个实时协作编辑器
  • 设计一个电商购物车系统
  • 设计一个新闻推荐系统

答题框架(RADIO 原则)

  1. Requirements(需求分析)

    • 功能需求:核心功能有哪些?
    • 非功能需求:性能、可用性、国际化等
  2. Architecture(架构设计)

    • 前端架构:组件结构、状态管理
    • 后端架构:API 设计、数据库设计
    • 关键组件及其交互
  3. Data Model(数据模型)

    • 数据结构设计
    • 数据流设计
    • 状态管理方案
  4. Integration(集成方案)

    • API 接口设计
    • 第三方服务集成
    • 前后端通信协议
  5. Optimization(优化方案)

    • 性能优化:缓存、懒加载、CDN
    • 可扩展性:负载均衡、分库分表
    • 可靠性:容错、降级、监控
  6. Deep Dive(深入讨论)

    • 根据面试官的兴趣深入某个技术点

示例:设计一个支付页面

1. Requirements
   - 功能:支持多种支付方式(信用卡、支付宝、微信)
   - 安全:PCI DSS 合规,敏感信息加密
   - 性能:3 秒内完成支付流程
   - 可用性:99.9% 可用性

2. Architecture
   - 前端:React + TypeScript + Tailwind CSS
   - 状态管理:Zustand
   - 表单验证:React Hook Form + Zod
   - 支付 SDK:集成第三方支付网关

3. Data Model
   - 订单信息:订单号、金额、商品信息
   - 支付信息:支付方式、支付状态、交易流水号
   - 用户信息:用户 ID、收货地址

4. Integration
   - POST /api/orders/create - 创建订单
   - POST /api/payments/process - 处理支付
   - GET /api/payments/status - 查询支付状态
   - Webhook 接收支付结果通知

5. Optimization
   - 性能:预加载支付 SDK、使用 CDN
   - 安全:HTTPS、CSP、输入验证、防重放攻击
   - 可靠性:支付失败重试、超时处理、降级方案

6. Deep Dive
   - 如何防止重复支付?使用幂等性设计
   - 如何处理支付超时?轮询 + Webhook 双保险
   - 如何保证支付安全?Token 化、加密传输、风控系统

答题技巧

  1. 先问清楚需求:不要上来就开始设计,先问面试官关于规模、重点等问题

    • 预期的用户规模是多少?
    • 重点关注哪个方面?(性能、安全、可扩展性)
    • 是前端系统设计还是全栈系统设计?
  2. 画图辅助说明:在白板或纸上画出架构图、数据流图

    • 组件结构图
    • 数据流图
    • 系统架构图
  3. 从高层到细节:先讲整体架构,再深入某个模块

    • 不要一开始就陷入实现细节
    • 根据面试官的反馈调整深度
  4. 讨论权衡取舍:展示你的思考深度

    • 方案 A 的优势和劣势
    • 为什么选择方案 B
    • 在什么情况下会选择方案 C

准备资源

  • 《System Design Interview》by Alex Xu
  • YouTube: Grokking the System Design Interview
  • 前端系统设计博客和文章

总结

2026 年的前端面试已经发生了显著变化,AI 工作流成为了重要的考察点。但本质上,面试官想要的仍然是:

  1. 扎实的基础:JavaScript、React、工程化
  2. 解决问题的能力:分析问题、设计方案、实现落地
  3. 持续学习的能力:拥抱新技术、适应变化
  4. 工程素养:代码质量、团队协作、项目管理
  5. AI 时代的新能力:高效使用 AI、保证质量、持续优化

我的核心观点

AI 只是新增的一个维度,它让优秀的工程师更加高效,但不能替代工程师的核心能力。在 AI 时代,我们需要:

  • 更强的架构能力:AI 能生成代码,但不能设计架构
  • 更深的业务理解:AI 能实现需求,但不能理解业务
  • 更好的判断力:AI 能提供方案,但不能做出决策
  • 更高的工程素养:AI 能写代码,但不能保证质量

最后的建议

面试是一个展示自己的机会,也是一个学习的机会。每次面试后,我都会:

  • 记录面试中的问题和自己的回答
  • 分析哪些地方回答得好,哪些地方需要改进
  • 补充不会的知识点
  • 优化下次面试的策略

希望这篇文章能帮助到正在准备面试的你。如果你有任何问题或想要交流面试经验,欢迎在评论区留言!

祝大家都能找到满意的工作!

前端工程化 + AI 赋能,从需求到运维一条龙怎么搭 ❓❓❓

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

企业级前端工程化的本质,是把"人肉重复、靠经验兜底"的开发方式,收敛成可复用、可度量、可演进的一套体系。从零搭建前端时,先想清楚要解决什么、要什么结果,再选工具和流程,会少走很多弯路。

工程化主要针对三类问题:

image.png

把这三块从"人治"变成"机制",工程化才算真正落地。

落到团队层面,能带来几件事。流程上,标准化、能自动化的尽量自动化,关键环节可以借 AI 提效,结果上,开发成本下来、迭代速度上去,代码质量和可维护性提高,bug 和线上风险更容易被提前拦住。这些都不是单点工具能完成的,需要从需求到上线的整条链路一起设计。

接下来我们按常见阶段展开,依次是需求与规范、开发与联调、测试与优化、构建与部署、运维与监控。每个阶段会写目标、推荐流程、常用工具、典型场景,以及适合用 AI 或自动化做得更好的地方。

前端工程化总览

整条链路可以概括为五个阶段,从需求规范到运维监控依次串联。整体追求三个结果,稳(高可用、可回滚)、快(敏捷交付、自动化流水线)、省(低成本工具链、资源复用),下面用一张流程图把阶段关系画清楚。

20260225100028

各阶段侧重不同。需求规范阶段重在建立统一标准、预防潜在风险、提升协作效率,常见动作包括需求与接口规范、文档沉淀与知识库、以及用 AI 做文档自动化。开发联调阶段和测试优化阶段共同指向高效协作、减少阻塞、保障代码质量,前者覆盖基础框架与脚手架、组件与物料库管理、工程化工具链、前后端接口联调与 Mock,后者覆盖单元与 E2E 自动化测试、性能与体积优化、合规与安全扫描、埋点与数据上报。构建部署阶段和运维监控阶段则共同强调高效交付、稳定发布、灵活回滚,构建部署侧重构建与打包优化、CI/CD 部署方案、灰度发布与一键回滚,运维监控侧重性能与可用性监控、异常与错误追踪、用户行为与转化分析、大屏可视化与告警,目标是实时感知风险、快速定位原因、持续优化体验。

下图是同一套阶段与目标的示意,便于对照查阅。

image.png

需求规范阶段

需求规范阶段是整条链路的起点。先把这一步打牢,后面的开发联调、测试优化才不会一路踩坑。这里要做的事,本质上是把团队里各自为战的习惯和经验,沉淀成一套大家都认可的统一标准,既预防潜在风险,又减少日常协作里的摩擦。为了方便梳理,可以把这一阶段拆成三块,对应代码与接口、文档与知识库,以及用 AI 做文档自动化。

下图是这一阶段的手绘示意,可以当作后文三小节的导航来对照着看。

image.png

需求与接口规范

落到开发这侧,最直观的感受就是,大家写出来的代码和提交流程要像是一个团队,而不是各写各的。第一步是把代码规范和协作流程统一,用一套约定来消除协作摩擦。代码这一块,可以用 ESLintPrettier 配合 Husky 去强制约束代码风格,缩进、命名这些细节交给工具,提交前自动跑一遍,不通过就推不上去,讨论就能更多回到设计和实现本身。

协作流程方面,建议一开始就说清楚 Git 分支策略(例如简化版 Git Flow)和 Commit 信息格式,例如用 Commitizen 这样的工具来规范提交说明。久而久之,提交历史会变成一本可以查账的项目日记,谁在什么时间、因为什么调整了哪些代码,一目了然。

这里有两类问题,最好在一开始就通过规范挡住。一类是随手写 fix bugupdate 这类没有信息量的提交信息,事后谁也看不出当时改动的动机。另一类是没经过 Code Review 就把改动直接合进主分支,质量风险一路带到线上。有些团队会要求,所有人都基于 master 拉分支开发,在 testuatrelease 这些共享环境分支以及 master 上都禁止直接 push,只能通过合并请求进入,这样一旦出问题,也能顺着合并记录快速定位到具体改动。

文档沉淀与知识库

文档沉淀这块,目标是打破信息孤岛,让新人靠看文档也能尽量还原当时的需求背景和取舍过程。需求如果只散落在聊天记录里,过一阵子连原作者自己都很难说清楚当时为什么要这么定。比较实用的做法,是用语雀、飞书文档把业务需求拆成技术方案,把功能边界和验收标准写清楚,再准备一套固定的需求文档模板,背景、原型、接口定义这些模块都预留好位置,后面类似需求直接套用,既省事又不容易漏。

接口和设计的配合,同样可以通过工具来固化。可以用 Apifox 维护接口文档,后端接口还没完全就绪时,前端先基于 Mock 数据开发,不必干等。与此同时,联动 Figma、即时设计这类工具里的设计稿标注,让 API 与设计稿保持同步,很多本来要靠口头解释的细节,直接在文档和设计稿里就能对齐。

AI 赋能文档自动化

如果完全手写,一份中等复杂度的技术文档,往往要花上两到四个小时,写着写着还容易走神。现在可以把这种重复性工作交给 AI。例如用 Writely(飞书 AI),输入 PRD 里的关键词(例如"用户管理系统"),让它先生成一份大致合理的技术文档目录和示例代码片段,你再根据实际业务补充细节、删掉不适用的部分。

实际体验下来,传统纯手写从零到一可能要两到四个小时,而让 AI 先搭好骨架、再人工完善,大多数情况下半小时左右就能收工。这样的方式尤其适合需求说明、接口说明、技术方案骨架这类重复度很高的文档,一方面整体结构更统一,另一方面也把时间留给那些必须由人来判断的业务决策和权衡。

开发联调阶段

开发联调阶段是前端工程化真正动手写代码、跑起来的那一段,目标很清晰,就是高效协作、减少阻塞、保障代码质量,让前后端和设计之间尽量无缝衔接。下面按基础框架、物料复用、工程化流水线、前后端协作四块来说,最后补几条联调时容易踩的坑。

image.png

基础框架搭建

框架选型决定了团队未来几年的技术底座,选好了能少踩很多坑。轻量一点、迭代快的项目,可以用 Vue 3ReactVite,开发体验好、上手也快,Vite 后续会集成 Rust 实现的 Rolldown,生产构建会更快。业务比较复杂、偏中后台或需要 SSR 的,可以看 Next.jsRsbuild 等,Next.js 开发环境已支持 Turbopack,大仓冷启和 HMR 更猛。超大体量或需要兼容现有 Webpack 生态的,可以看 Rust 系的 Rspack。运行时除了 Node.js,也可按需选 Bun 做脚本和工具链。要是还有小程序、H5 等多端需求,可以看 TaroUni-App 这类跨端方案,一套代码多端跑。

选完框架,最好再准备一套模板仓库,新项目直接基于模板拉,而不是每次从零配。例如预置好 ESLintPrettier 的脚手架,或者用 Next.jsRsbuild 等自带的脚手架快速生成项目,再按需加权限、数据流等。如果团队里会有多个应用、共享组件库或公共包需要一起维护,可以提前考虑是否采用 Monorepo 架构(例如用 pnpm workspace、TurborepoNx 等),把多包放在一个仓库里统一依赖和构建,能减少后期拆仓、版本对齐的折腾。这一步也可以交给 AI 省时间,例如在 Cursor 里输入"创建 NextJs + TypeScript 项目",让它生成基础配置。

物料库管理

组件、工具函数、页面模板如果能复用,重复开发会少很多。有条件的团队会做企业自研组件库,常见两条路。一条是在 Ant DesignElement Plus 这类开源组件库上做二次封装,贴合自家业务和设计规范,再用 Bit 这类工具管理组件版本和依赖,甚至支持私有化部署。

另一条是,如果团队已经在用 Tailwind CSS,并且用过 shadcn/ui 这类"拷贝即用"的组件方案,可以在现有基础上做二开,例如统一品牌色、间距和圆角等设计 token,把常用变体收拢成团队约定,再补一份内部文档(哪些组件可直接用、哪些改过、使用示例和注意事项),这样既保留 Tailwind 的灵活度,又有一致的设计和可维护的物料沉淀。Tailwind CSS v4 已发布,构建更快、配置更简单,新项目可以直接上 v4。工具函数这块,用 lodashdayjs 等成熟库即可,不必自己造。

AI 在这块也能帮上忙。例如即时 AI 可以把 Figma 设计稿转成 VueReact 组件代码,减少从设计到代码的重复劳动。CodeGeeX 可以根据组件的 Props 描述自动生成单元测试用例。当然,小团队或小公司不一定要自建组件库和物料体系,先把业务跑稳、再按需沉淀组件和模板,会更现实。

工程化系统

工程化系统说白了就是通过工具链把创建项目、检查、构建、部署串成一条流水线,减少人工操作。创建项目阶段,现在普遍用 Vite 创建 VueReact 项目(create-vite 或各框架官方模板),或用 Next.js 自带的脚手架起手,预置好规范与配置即可。到了持续集成和部署,可以用 GitHub ActionsGitLab CI 在提交后自动跑代码检查、构建和部署,或者用 Jenkins 做更复杂的多环境流水线。如果希望需求、开发、部署都在一个平台里完成,可以选阿里云效这类一站式 DevOps 平台,功能全、上手相对简单,也支持私有化部署,不少团队的实际项目就是用云效搭的流水线。

前后端协作

前后端联调最容易出问题的地方,往往是接口约定不一致、文档滞后、环境对不齐。接口文档建议用 ApifoxApidog 这类工具维护,支持 OpenAPI 规范、自动 Mock 和接口测试。很多平台还能根据接口文档自动生成前端的请求代码和 TypeScript 类型,文档一改、类型跟着变,减少手写接口定义。后端接口还没好时,前端可以先用 Mock.jsFaker.js 生成贴近真实的测试数据,或者用 MSW(Mock Service Worker)在浏览器层做请求拦截,配合 TypeScript 做类型安全的 Mock,适合单测和本地联调。全栈都是 TypeScript 的项目,还可以考虑 tRPC 或更轻量的 Hono RPC,前后端共享类型定义,服务端改接口、客户端立刻有类型提示,无需单独维护一份接口文档和类型。Hono RPChc 客户端加 Zod 校验即可实现类型安全,适合前后端同仓或协作紧密的团队。

当接口多了、前端需要聚合多个接口或按需拉字段时,可以加一层 BFF(Backend For Frontend),用 Node.js 中间层(例如 NestJSMidway.jsExpress)聚合多接口,或者用 GraphQL(如 Apollo Server)让前端按需定制响应字段。BFF 可以由后端团队维护,也可以由前端团队自建,实现真正的接口层解耦。

接口文档若能通过统一协议进到开发环境里,前后端对接会轻松很多。可以把后端的 OpenAPI 规范用 MCP(Model Context Protocol)暴露出来,例如用 OpenAPI MCP Server 把接口定义转成 MCP 的 tools、resources,在 Cursor、VS Code 等 IDE 里配置好 MCP 后,就能在写代码时直接读到最新接口文档、让 AI 按文档生成请求代码或类型,避免文档和实现脱节。

阿里云、腾讯云等也有 OpenAPI MCP Server,适合把云产品 API 接到 IDE。自建后端可以用 @reapi/mcp-openapi、FastMCP 的 from_openapi() 等从 OpenAPI 规范生成 MCP 服务,前后端共用同一份文档,联调时接口变更能更快同步到前端。

AI 也能参与进来,例如 ApifoxAI 可以根据接口文档自动生成 Mock 规则和测试用例,CodeGeeX 可以根据现有 RESTful 接口生成一层 GraphQL 包装代码,减少手写胶水代码。

联调时还有几点值得注意。一是接口变更要及时同步,用 Apifox 这类工具把最新接口定义推给前端,或通过 OpenAPI 自动生成类型,避免文档和实现各说各的。二是开发、测试、生产环境要隔离,用 .env.development.env.production 等把配置拆开,别在本地写死生产地址。三是依赖版本要锁死,用 pnpm 等包管理器严格锁定依赖,能少很多"在我机器上是好的"这类问题。

测试优化阶段

测试优化阶段的目标很明确,就是提前暴露风险、保障线上稳定、提升用户体验,用分层测试把核心场景兜住,减少漏测和线上事故。从人工点点点到自动化、再配合 AI 生成用例,测试效率会明显上去。

20260226091000

自动化测试

建议按单元测试、E2E、视觉回归三层建体系,而不是一上来就全押 E2E。单元测试负责验证组件逻辑和工具函数,用 JestVitestReact Testing Library 即可,VitestVite 同源、冷启和 HMR 更快,适合在每次提交时跑。组件层若要在真实浏览器里跑,可用 Vitest 的 Browser Mode 配 Playwright 驱动。例如下面这段,用 render 渲染按钮组件、screen.getByRole 找到按钮并模拟点击,再断言传入的 onClick 被调用了一次,用来保证点击回调不会丢。

test("Button 点击触发事件", async () => {
  const handleClick = vi.fn<[], void>();
  render(<MyButton onClick={handleClick} />);
  await userEvent.click(screen.getByRole("button"));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

E2E 测试覆盖真实用户路径,在浏览器里跑完整流程。Playwright 支持 Chromium、WebKit、Firefox 多端,自带录制回放,适合做跨浏览器回归。Cypress 的可视化调试和时间旅行对复杂交互(例如购物车、多步表单)很友好,按团队习惯二选一或搭配用即可。

视觉回归测试解决的是"功能没坏、但界面悄悄变了"的问题。改了一处样式或依赖升级导致组件渲染异常,单测和 E2E 不一定能发现,视觉回归通过对比页面或组件的截图,先拍一份基准快照,后续每次跑用例时再拍一张,和基准做像素或区域对比,有差异就报出来,由人确认是预期改动还是误伤。可以用 BackstopJS 在本地或 CI 里跑,配置好要截的 URL 或组件,生成基准后纳入版本管理,以后每次 PR 自动跑一遍对比。组件库或设计系统也可以用 ChromaticPercy 这类托管服务,和 Storybook 结合,每个 Story 自动做视觉回归。适合对 UI 稳定性要求高的首页、关键流程页和公共组件,基准图多了之后要注意维护,避免无关改动带来大量噪点。

AI 也能参与测试用例的生成和验证。一类是依据行为数据生成脚本,例如 Testin AI 分析用户行为日志,把高频操作转成 E2E 用例,先覆盖核心路径再补边缘场景。另一类是让 AI 直接连上真实浏览器做调试和验证,例如 Chrome 官方的 Chrome DevTools MCP,在 Cursor、Claude 等里配置好 MCP 后,AI 可以调 DevTools 能力做性能追踪、网络与 Console 排查、DOM 与样式检查、表单与用户行为模拟,并在浏览器里实时验证改动的效果,相当于"边写代码边在真机里跑一遍"。和 Playwright MCP 搭配时,Playwright 负责 UI 自动化与用例执行,DevTools MCP 补足性能与运行时观测,适合做智能回归和 Core Web Vitals 等自动化检查。

性能优化

测试通过之后,还要保证页面秒开、交互不卡。可以给自己定一个简单目标,例如首屏可交互 FCP 控制在 1.5 秒内、首次输入延迟 FID 在 100ms 以内。性能检测方面,用 Lighthouse CI 把跑分集成进 CI 流水线,分数低于阈值(例如 90)就拦掉合并,避免性能劣化代码进主干。真实用户数据用 Google Analytics 4 或阿里云 ARMS 采集 Web Vitals,看线上实际表现而不是只看本机。

优化手段按资源、代码、分发来拆。资源上,构建阶段自动压缩图片,例如用 vite-plugin-imagemin 在打包时处理;代码上,用 React.lazySuspense 做路由级懒加载,首屏只拉当前路由需要的 chunk。分发上,静态资源扔到阿里云 OSS 再挂 CDN,利用全球节点做加速。AI 也能参与,例如阿里云 ARMS 的智能诊断会根据性能数据推荐优化项(如未压缩图片列表),部分构建工具已支持基于预测的 Tree Shaking 策略,进一步剔除无效代码。

合规与安全

合规与安全要从代码和数据两头抓,避免法律风险和用户隐私问题。代码侧,用 SonarQube 做静态扫描,揪出 XSS、SQL 注入等常见漏洞。依赖侧,用阿里云安全中心等扫描已知漏洞(例如 Log4j、老旧版本的 lodash),有风险就升级或替换。隐私合规方面,用腾讯云合规助手这类工具检查隐私政策是否满足 GDPR、个保法等要求。日志里对手机号、身份证号等做脱敏,例如通过 log4js 等插件的过滤规则自动打码,避免敏感数据落盘。

AI 可以辅助安全扫描,例如用大模型扫描代码里的敏感信息(如硬编码的 API 密钥)。部分 AI 代码助手能自动把不安全写法替换成更安全的实现(如将 eval 改为 Function),适合在 Code Review 前跑一遍。

数据埋点

埋点做得好,产品迭代才有数据支撑,否则容易变成"盲人摸象"。埋点大致分无埋点和自定义埋点。若注重隐私或希望数据自托管,可以用 Umami 这类开源方案,无 Cookie、符合 GDPR,脚本轻量(约 2KB),支持页面浏览与自定义事件,可 Docker 自建或使用官方云,适合中小站点和不想依赖第三方统计的场景。

无埋点还可用 GrowingIO 等方案自动采集页面点击、曝光等事件,接入简单、覆盖面大。自定义埋点用神策数据等 SDK 在关键行为(如按钮点击、表单提交)上手动上报,灵活、可针对业务做分析。数据进来之后,用 Metabase 这类开源 BI 做 SQL 自助分析,或用阿里 DataV 做大屏展示核心指标(如 DAU、转化率)。AI 也能参与,例如 GrowingIO 的智能推荐会根据用户路径建议高价值埋点事件,神策的聚类分析能自动识别用户分群(如高流失风险用户),方便做精细化运营。

测试与优化阶段还有几点容易踩坑。一是别盲目追求 100% 测试覆盖率,优先把核心链路(登录、支付等)兜住,再按需补边缘场景。二是性能优化别撒胡椒面,内部管理后台等低频页面不必死磕,把资源留给用户高频访问的页面。三是埋点必须拿到用户授权,禁止收集设备 ID、IMEI 等敏感信息,否则会踩数据隐私的雷。

构建部署阶段

构建与部署阶段是前端工程化的交付出口,目标是高效交付、稳定发布、灵活回滚,让代码从开发环境到生产环境顺畅流转。下面按构建优化、部署方案、灰度与回滚三块说。

20260226094131

构建优化

构建工具在技术选型阶段通常已经定好了(例如 ViteWebpack 5RspackNext.js),这里侧重在既定工具上的优化策略。Vite 新版本已接入 Rust 实现的 Rolldown 做生产打包,构建耗时明显下降。选 Next.js 的可以用 Turbopack 做开发和生产构建,冷启和增量构建更快。Rspack 等 Rust 系方案在大仓下同样有优势。

优化时先把 Tree Shaking 开满,在库和业务里合理配置 sideEffects: false,让打包器删掉未引用代码。代码拆分用动态 importReact.lazy 把非首屏做成按需加载,再用 manualChunks 把大依赖(如 monaco-editor、图表库)单独拆包,避免首屏 chunk 过大。产物体积可用 rollup-plugin-visualizervite-plugin-perfsee 做分析,一眼看出谁在占空间。线上传输用 vite-plugin-compression 做 Gzip 或 Brotli,Nginx 侧开 gzip_static 即可。

部分构建工具已支持基于 AI 的智能缓存和构建日志分析,自动推荐合并重复 Chunk、优化依赖顺序等,可在 CI 里跑一遍看报告。

部署方案

部署从手动发包走向一键发布、多环境隔离,才能做到分钟级回滚。静态资源托管最常见,用阿里云 OSS 挂 CDN 按量付费、支持缓存刷新,或选托管平台:Vercel 和 Git 深度集成、推分支即发布,适合 Next.js。Cloudflare Pages 边缘节点多、免费带宽大,已支持 Docker 和 @opennextjs/cloudflare 跑 Next,还有 Workers AI 做边缘推理。Netlify 在组合式架构和 CMS 集成上比较顺手。需要极快 git 部署、少建站过程的可以看 Deno Deploy,代码直传边缘、无需拉机子做长构建,适合接口或中间层。

需要跑 Node 或做 SSR 的,用 Docker 多阶段构建把镜像压到几十 MB,再配合 Kubernetes(如阿里云 ACK)做集群。不想管机器的用 Serverless,阿里云函数计算、Vercel Edge Functions 等按需执行、边缘就近跑。

AI 也能参与,例如 GitLab Code Suggestions 可根据项目生成 DockerfileCI 脚本,观测云等能根据资源负载推荐扩缩容策略。

灰度与回滚

发布要可控,灰度把风险压到最小,回滚要能快速切回去。灰度本质是流量逐步切到新版本,常见做法有 Nginx 按 IP 或 Cookie 分流,先给 5%~10% 用户上新版,观察一段时间再放量。阿里云 EDAS 支持全链路灰度,应用和数据库都能隔离。云原生 API 网关也支持蓝绿、金丝雀发布,按比例或规则切流量。除了流量灰度,还可以用特性开关(Feature Flags),在代码里用开关控制功能是否露出,用 ConfigCat、LaunchDarkly 等或自研,发版和上线解耦,随时可关。

灰度期间要有可观测,接 Prometheus、Grafana 或现有监控,盯错误率、响应时间,一旦超阈值(例如错误率 >0.5%)自动回滚或告警。回滚要提前准备好,在 GitLab CI 或 GitHub Actions 里做基于版本 Tag 的回滚脚本,出问题一键切回上一版。静态资源用 OSS 版本控制保留历史,回滚时改 CDN 回源即可。

AI 也能参与,例如阿里云 AHAS 可根据历史流量推荐灰度比例,Sentry 等可在错误率突增时自动触发回滚或通知,减少人工判断时间。

运维监控阶段

运维与监控是前端工程化的最后一道防线,目标是实时感知风险、快速定位原因、持续优化体验,让线上系统稳定、用户行为可观测。下面按性能监控、异常监控、用户行为分析、可视化与告警四块说,最后补一版低成本与大型企业的工具链参考,以及几条容易踩的坑。

20260226095212

性能监控

性能监控要保障 Web Vitals 等核心体验指标达标,并持续发现瓶颈。核心指标用 Google Analytics 4 或阿里云 ARMS 等采集真实用户数据(RUM),关注 LCP(最大内容绘制)、INP(交互到下一帧,已逐步替代 FID)、CLS(累计布局偏移)等,可配合 web-vitals 库在端上采集后上报。除了平台自动采集,关键链路可以加自定义性能埋点,例如在页面加载完成后取 performance.timing 算出加载耗时并上报,方便按页面或版本对比。下面示例在 load 事件后计算从导航开始到加载结束的耗时,并通过自有 SDK 上报,用于做首屏性能趋势分析。

const timing: PerformanceTiming = performance.timing;
const loadTime: number = timing.loadEventEnd - timing.navigationStart;
SDK.report({ type: "page_load", duration: loadTime });

资源侧可以看 CDN 日志分析请求成功率、缓存命中率(如阿里云 CDN)。接口耗时用 SkyWalking、Zipkin 或 OpenTelemetry 做链路追踪,约定 P99 等目标(例如 500ms 以内)。Sentry 等已支持与 OpenTelemetry 对接,前端错误和接口链路可以串成一条 trace,排查时从页面一路跟到后端。AI 也能参与,例如阿里云 ARMS 智能诊断会关联 JS 错误与接口超时,给出根因建议。New Relic 等可根据历史数据预测流量峰值,辅助提前扩容。

异常监控

异常监控要争取分钟级发现线上问题,把 MTTR(平均修复时间)压下去。错误追踪用 Sentry 捕获前端 JS 错误、自动聚合相似问题,并支持 SourceMap 解析还原源码位置。国内团队也可以用支持微信、钉钉实时告警的国产方案,和现有协作习惯对齐。日志分析用阿里云 SLS 做 Nginx 访问日志的实时分析,快速发现 5xx 突增等异常。自建可选 Loki 配 Grafana,资源占用比传统 ELK 小,用 LogQL 查"近 1 小时 404 TOP10"这类问题很顺手。

AI 可以辅助降噪和归因,例如 Sentry 的智能聚类能把大量错误归成少量根因(如未捕获的 TypeError)。基于 Elasticsearch Machine Learning 或类似能力可以做日志模式异常检测,例如发现突然出现大量非常规 UA 或异常请求路径,提前发现爬虫或攻击。

用户行为分析

用户行为数据用来驱动产品优化和转化率提升。无埋点用 GrowingIO 等自动采集页面点击、跳转、停留时长,并生成热力图。自定义分析用神策等做事件与漏斗(如注册流程各步转化)。关键业务节点需要自定义埋点时,在按钮或流程节点上打点上报事件和业务参数,例如下单按钮点击时上报商品 ID 和价格,便于后续做转化和营收分析。下面示例在购买按钮点击时上报事件名和业务字段,接入方替换成实际 SDK 即可。

document.getElementById("buy-button")?.addEventListener("click", () => {
  SDK.track("purchase_click", { product_id: "123", price: 299 });
});

AI 能参与分析结论的产出,例如神策的智能路径分析用户流失点并给出优化建议,GrowingIO 可根据行为聚类生成推荐或运营策略参数。

可视化与告警

监控数据要通过大屏和告警变成可执行的决策。可视化用 Grafana 做自定义监控面板,或用阿里云 DataV 搭实时运维大屏。告警用 Prometheus 配 Alertmanager 配置阈值(如 CPU 使用率 >80% 持续 5 分钟),告警事件通过钉钉、飞书机器人推到协作群,并支持 @ 负责人。AI 可以用于智能阈值和降噪,例如根据历史数据动态计算合理阈值(如凌晨自动放宽延迟阈值),或把重复告警合并成一条,减少告警风暴。

工具链参考

中小团队、预算有限时,可以组合:监控用 Prometheus 自建 + Grafana,告警接微信或钉钉。日志用 Loki 替代 ELK,资源消耗更低。再搭配阿里云 ARMS 免费版做基础性能分析、或开源组件的异常检测能力,整体月成本可控。对高可用要求高、数据量大的团队,可以用阿里云 ARMS 做全链路、SLS 做 PB 级日志,配合 DataV 大屏和自研或第三方 AI 分析平台。

运维监控还有几点要注意。一是避免过度监控,只采核心业务相关指标,否则存储和告警成本都会上去。二是告警要设静默期,同一类告警在 30 分钟内不重复推送,减少告警疲劳。三是日志必须脱敏,避免原始敏感数据泄露。

React-深度拆解 React路由:从实战进阶到底层原理

前言

在单页面应用(SPA)开发中,前端路由是核心基石。它让我们可以根据 URL 的变化,在不刷新页面的情况下切换组件。本文将带你从 React Router 的基本使用出发,深入其底层的浏览器实现机制。

一、 React 路由配置实战

1. 基础环境搭建

首先,我们需要安装 React 路由的核心库:

npm install react-router-dom

2. 注入路由容器

需要在应用最顶层(如 main.jsx/App.jsx)包裹路由核心容器,决定使用 Hash 模式 还是 History 模式,二者的核心区别是 URL 是否带 #,以及服务端配置要求。

两种模式核心区别:

特性 History 模式 Hash 模式
URL 形式 https://xxx.com/home(无 #) https://xxx.com/#/home(带 #)
服务端配置 需要 Nginx配置兜底 无需服务端配置
兼容性 依赖 HTML5 History API 兼容所有浏览器
场景 =现代浏览器 老项目兼容
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, HashRouter } from 'react-router-dom';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));

// 方式1:History 模式(推荐,URL 无 #,美观)
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

// 方式2:Hash 模式(兼容老浏览器/无需后端配置,URL 带 #)
// root.render(
//   <HashRouter>
//     <App />
//   </HashRouter>
// );

3. 定义路由映射

通过 <Routes><Route> 定义「URL 路径 → 组件」的映射关系,用 <Link> 替代原生 <a> 标签实现无刷新导航。

import { Routes, Route, Link } from 'react-router-dom';
// 导入页面组件(需提前创建)
import Home from './pages/Home';
import About from './pages/About';
import User from './pages/User';
import NotFound from './pages/NotFound';

function App() {
  return (
    <div className="App">
      <nav style={{ margin: '10px 0' }}>
        <Link to="/" style={{ marginRight: '10px' }}>首页</Link>
        <Link to="/about" style={{ marginRight: '10px' }}>关于我们</Link>
        <Link to="/user/123">用户123</Link>
      </nav>

      {/* 路由匹配规则:Routes 是 v6 新增的容器(替代旧版 Switch) */}
      <Routes>
        {/* 基础路由:路径完全匹配时渲染对应组件 */}
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        {/* 动态路由:匹配 /user/任意值,通过 params 传参 */}
        <Route path="/user/:id" element={<User />} />
        {/* 404 路由:匹配所有未定义的路径(必须放最后) */}
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}

export default App;

二、 深度解析路由两种模式底层原理

React Router 的本质是基于浏览器原生能力的封装,核心作用是根据 URL 路径匹配对应的组件并渲染,实现单页面应用(SPA)的无刷新页面切换。。

2. Hash 模式实现原理

Hash 模式依赖 URL 中 # 后的哈希值,核心逻辑如下:

  • URL 变化监听:基于 window.hashchange 事件监听哈希值变化;

  • 设置 URL 值:通过 location.hash = '新路径' 修改哈希值;

  • 获取当前 URL 值:通过 location.hreflocation.hash 获取;

  • URL 页面跳转:通过 location.assign('#/新路径') 实现跳转;

  • 核心优势# 后的内容不会发送到服务器,所有请求都指向 域名/index.html,服务端无需额外配置。

3. History 模式实现原理

History 模式依赖 HTML5 新增的 History API,核心逻辑如下:

  • URL 变化监听:基于 window.popstate 事件监听浏览器前进 / 后退导致的 URL 变化;

  • 新增 / 替换历史记录

    • history.pushState(stateObj, title, url):新增一条历史记录(无刷新跳转);
    • history.replaceState(stateObj, title, url):替换当前历史记录(不新增记录);
  • 获取当前 URL 值:通过 window.location.pathname 获取;

  • 页面前进 / 后退:通过 history.go(number)(前进 / 后退指定步数)、history.back()(后退一步)实现;

  • 获取自定义状态:通过 history.state 获取 pushState 传入的自定义状态对象;

  • 核心注意:URL 无 #,但刷新页面时浏览器会向服务器发送对应路径的请求,需服务端配置兜底,否则会 404。


四、 注意:History 模式的服务器配置

当你使用 History 模式 部署到 Nginx 时,最常见的问题是:点击页面内的 Link 没问题,但一按刷新就 404

原因:History 模式下,浏览器会向服务器请求 /home 这个物理路径。服务器上并没有这个文件,因此报错。

解决方案:在 Nginx 配置中增加 try_files 指令,让所有找不到的请求都重定向到 index.html,交给 React Router 去处理。

location / {
     root   /usr/share/nginx/html;   # 前端打包文件的存放目录
     index  index.html index.htm;    # 默认访问文件
     try_files $uri $uri/ /index.html;  # 核心配置:如果找不到资源,统统指向 index.html
}

📝 总结

  • React 路由需先安装 react-router-dom,顶层包裹 BrowserRouter(History 模式)或 HashRouter(Hash 模式);

  • 核心使用 <Routes>/<Route> 定义路由规则,<Link> 实现导航;

  • Hash 模式无需服务端配置,History 模式需在 Nginx 中配置 try_files 兜底,避免刷新 404;

  • React Router 本质是监听 URL 变化(Hash 监听 hashchange,History 监听 popstate),匹配并渲染对应组件。


React-手把手带你实现 Keep-Alive 效果

前言

在 Vue 中,我们可以通过 <keep-alive> 轻松缓存组件实例。但在 React 中,组件卸载(Unmount)意味着状态和 DOM 的彻底销毁。如何在 React 中实现“切换页面不丢失滚动位置、不重置表单”?本文将为你拆解三种主流方案。

一、 Keep-Alive 的本质是什么?

在实现 React 版缓存组件前,先明确 Vue keep-alive 的核心逻辑,才能精准复刻:

  • 核心本质:缓存组件实例,保留组件内部状态(如输入框内容、滚动位置);
  • 行为特征:组件切换时不销毁 / 重建,仅通过「隐藏 / 显示」控制渲染状态;
  • React 目标:实现和 Vue 一致的效果 —— 组件切走不丢状态,切回来能恢复。

二、 React 实现 keep-alive 的 3 种方案

方案 1:CSS 隐藏 + 不卸载组件(最简单)

核心思路:

通过 display: none 隐藏不活跃的组件,保留组件的 DOM 节点和内部状态,仅切换 display 属性控制显示 / 隐藏,不触发组件的卸载 / 重新挂载生命周期。

适用场景

  • 少量组件切换(2-3 个,如 tab 标签页);
  • 简单业务场景(如表单页、列表页切换)。

优缺点

✅ 优点:实现简单,无额外依赖,状态保留完整;

❌ 缺点:所有组件都会挂载在 DOM 树中,组件数量多(如 5 个以上)会增加 DOM 节点数量,可能影响页面渲染性能。

import { useState } from 'react';

// 模拟 Tab 切换场景
const KeepAliveByCSS = () => {
  // 控制当前激活的标签
  const [activeKey, setActiveKey] = useState('tab1');

  return (
    <div>
      <div className="tab-header">
        <button onClick={() => setActiveKey('tab1')}>标签1</button>
        <button onClick={() => setActiveKey('tab2')}>标签2</button>
      </div>
      <div className="tab-content">
        {/* 始终挂载,仅通过 CSS 隐藏 */}
        <div style={{ display: activeKey === 'tab1' ? 'block' : 'none' }}>
          <Tab1 />
        </div>
        <div style={{ display: activeKey === 'tab2' ? 'block' : 'none' }}>
          <Tab2 />
        </div>
      </div>
    </div>
  );
};

// 带状态的子组件
const Tab1 = () => {
  // 切换标签后,输入框内容不会丢失
  const [inputVal, setInputVal] = useState('');
  return <input value={inputVal} onChange={(e) => setInputVal(e.target.value)} placeholder="标签1输入框" />;
};

const Tab2 = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
};

方案 2:使用 react-activation 第三方库(推荐)

这是目前社区内最成熟的方案,它通过将组件渲染到“外部容器”再动态挂载回来的方式,模拟了 Vue 的行为。

核心功能

  • 提供 <KeepAlive> 容器组件,包裹需要缓存的组件即可生效;

  • 内置 useActivate/useDeactivate 钩子函数,分别在组件「激活」和「失活」时触发;

  • 支持缓存控制(如指定缓存 Key、条件缓存);

  • 能保留 DOM 状态(如滚动条位置、输入框焦点)。

使用示例

import { KeepAlive, useActivate, useDeactivate } from 'react-activation';
import { useState } from 'react';

const KeepAliveByLib = () => {
  const [show, setShow] = useState(true);

  return (
    <div>
      <button onClick={() => setShow(!show)}>
        {show ? '隐藏组件' : '显示组件'}
      </button>
      {/* 用 KeepAlive 包裹需要缓存的组件 */}
      {show && (
        <KeepAlive id="cached-component">
          <CachedComponent />
        </KeepAlive>
      )}
    </div>
  );
};

// 被缓存的组件
const CachedComponent = () => {
  const [inputVal, setInputVal] = useState('');
  const [scrollTop, setScrollTop] = useState(0);

  // 组件激活时触发(显示时)
  useActivate(() => {
    console.log('组件被激活');
  });

  // 组件失活时触发(隐藏时)
  useDeactivate(() => {
    console.log('组件被失活');
  });

  // 模拟滚动条状态保留
  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div>
      <input 
        value={inputVal} 
        onChange={(e) => setInputVal(e.target.value)} 
        placeholder="缓存的输入框" 
      />
      <div 
        style={{ height: '200px', overflow: 'auto', marginTop: '10px' }}
        onScroll={handleScroll}
      >
        {Array.from({ length: 50 }).map((_, index) => (
          <p key={index}>滚动测试行 {index + 1}</p>
        ))}
      </div>
      <p>当前滚动位置:{scrollTop}</p>
    </div>
  );
};

方案 3:全局状态管理 + 状态回显(Redux)

如果不想引入第三方库,也可以通过全局状态缓存实现核心效果,本质是组件卸载前存状态,重新挂载时取状态。

核心思路

在组件 useEffect 的清理函数中,将关键数据(输入框、计数、滚动位置等)保存到全局 Store;重新挂载时再读取初始化。

  • 优点:符合 React 数据流规范,内存占用可控。

  • 缺点

    1. 无法恢复 DOM 状态:如页面的滚动位置、输入框的焦点、已播放的视频进度,需单独编写逻辑处理。
    2. 开发成本高:每个需要缓存的组件都要手动编写保存/恢复逻辑。
  • 补救措施:若要恢复滚动位置,需手动在卸载前记录 scrollTop,并在渲染后通过 window.scrollTo 还原。


三、缓存 React Router 路由组件

实际开发中,最常见的场景是切换路由不丢失页面状态(如列表页滚动位置、表单输入内容),可结合 React Router + react-activation 实现。

核心代码如下:

import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { KeepAlive } from 'react-activation';
import Profile from './pages/Profile';
import Home from './pages/Home';
import Settings from './pages/Settings';

// 路由容器组件(获取当前路由路径)
const RouterContainer = () => {
  const location = useLocation();
  const currentPath = location.pathname;

  return (
    <Routes>
      {/* 普通路由(不缓存) */}
      <Route path="/" element={<Home />} />
      <Route path="/settings" element={<Settings />} />
      
      {/* 缓存路由组件:when 控制是否缓存,id 为缓存标识 */}
      <Route
        path="/profile"
        element={
          <KeepAlive id="profile" when={currentPath === "/profile"}>
            <Profile />
          </KeepAlive>
        }
      />
    </Routes>
  );
};

// 根组件
const App = () => {
  return (
    <BrowserRouter>
      <RouterContainer />
    </BrowserRouter>
  );
};

四、 方案选择建议

方案 优点 缺点 适用场景
CSS 隐藏 实现简单、无依赖 DOM 节点冗余、性能一般 少量组件(2-3 个)、简单 tab 切换
react-activation 功能完整、支持 DOM 状态缓存 新增第三方依赖 中大型项目、需完整 keep-alive 效果
全局状态缓存 无额外依赖、贴合状态管理 仅恢复数据、需手动处理 DOM 已用全局状态库、仅需数据缓存

五、总结

  1. React 无原生 keep-alive,但可通过CSS 隐藏、react-activation 库、全局状态缓存3 种方案模拟核心效果;
  2. 简单场景用 CSS 隐藏,中大型项目优先选 react-activation(兼顾易用性和完整性);
  3. 路由组件缓存可结合 React Router + react-activation 实现,核心是通过 KeepAlive 包裹路由元素并指定缓存标识。

Vue - @ 事件指南:原生 / 内置 / 自定义事件全解析

前言

在 Vue 开发中,@v-on 指令的简写,是绑定事件监听的核心语法。很多新手容易混淆不同类型的 @ 事件用法,本文整理了 Vue 中所有常用的 @ 事件类型,包括原生 DOM 事件、内置组件事件、自定义事件,以及提升开发效率的事件修饰符,看完就能直接上手用!

一、 Vue @ 事件的核心分类

Vue 中的 @ 事件本质是对 DOM 事件 / 组件事件的封装,核心分为三大类:

  • 原生 DOM 事件:浏览器自带的基础交互事件
  • Vue 内置组件事件:Vue 官方组件专属的状态监听事件
  • 自定义事件:组件间通信的核心自定义事件

二、原生 DOM 事件

这类事件是浏览器原生支持的 DOM 事件,Vue 可直接通过 @ 绑定,覆盖绝大部分交互场景,按类型整理如下:

1. 鼠标事件

事件语法 说明 常用场景
@click 点击事件(最常用) 按钮点击、卡片跳转
@dblclick 双击事件 列表项编辑、文件重命名
@mouseenter 鼠标进入(不冒泡) 悬浮提示、菜单展开
@mouseleave 鼠标离开(不冒泡) 悬浮提示隐藏、菜单收起
@mousemove 鼠标移动 拖拽跟随、坐标监听
@mousedown 鼠标按下 拖拽开始、按住触发
@mouseup 鼠标松开 拖拽结束、松开停止
@contextmenu 右键菜单事件 自定义右键菜单

2. 键盘事件

事件语法 说明 注意点
@keydown 键盘按下时触发 可监听组合键(如 @keydown.ctrl.s
@keyup 键盘松开时触发 常用 @keyup.enter 监听回车
@keypress 键盘按压时触发 已逐步废弃,推荐用 keydown 替代

3. 表单事件

事件语法 说明 触发时机对比
@input 输入框内容变化 实时触发(每输入一个字符都触发)
@change 表单值变化 失去焦点 / 选择完成后触发(如下拉框选值)
@submit 表单提交事件 点击提交按钮 / 按回车触发
@focus 元素获取焦点 输入框激活、下拉框展开
@blur 元素失去焦点 输入框失活、表单校验

4. 移动端触摸事件

事件语法 说明 适用场景
@touchstart 触摸开始 移动端点击、滑动开始
@touchend 触摸结束 移动端点击完成、滑动结束

5. 页面 / 窗口事件

事件语法 说明 优化建议
@scroll 滚动事件 监听页面滚动加载、导航栏吸顶
@resize 窗口大小变化 响应式布局适配、画布重绘

6.使用示例

<template>
  <div>
    <!-- 点击事件 -->
    <button @click="handleClick">普通点击</button>
    <!-- 键盘事件(监听回车) -->
    <input @keyup.enter="handleSearch" placeholder="按回车搜索" />
    <!-- 表单输入事件 -->
    <input @input="handleInput" @blur="handleBlur" placeholder="实时输入监听" />
  </div>
</template>

<script setup>
const handleClick = () => console.log('按钮被点击');
const handleSearch = () => console.log('执行搜索');
const handleInput = (e) => console.log('实时输入:', e.target.value);
const handleBlur = () => console.log('输入框失活,可做校验');
</script>


三、 Vue 内置组件事件:监听生命周期

Vue 的内置组件(如动画、路由)拥有自己独特的“生命周期事件”,让我们能精准控制交互细节。

内置组件 常用事件 触发时机
<transition> @before-enter / @enter 进入动画开始前与执行中
@after-enter 动画完全结束,常用于清理工作
@leave / @after-leave 离开动画的相关节点
<router-link> @click 点击跳转(Vue Router 内部处理)
@navigate (Vue Router 4+) 导航正式开始时触发

四、 自定义事件:父子通信核心

自定义事件是 Vue 父子组件通信的重要方式,子组件通过 emit 触发事件,父组件通过 @ 监听事件并接收参数。

  1. 子组件触发:使用 emit 抛出事件和数据。

  2. 父组件监听:通过 @ 绑定回调。

<!-- 子组件 Child.vue -->
<template>
  <button @click="sendData">向父组件传值</button>
</template>

<script setup>
  // 定义可触发的自定义事件
  const emit = defineEmits(['custom-event']);

  const sendData = () => {
    // 触发事件并传递参数
    emit('custom-event', { name: 'Vue', version: '3.x' });
  };
</script>

<!-- 父组件 Parent.vue -->
<template>
  <!-- 监听子组件自定义事件 -->
  <Child @custom-event="handleCustomEvent" />
</template>

<script setup>
  import Child from './Child.vue';

  const handleCustomEvent = (data) => {
    console.log('接收子组件数据:', data); // 输出:{ name: 'Vue', version: '3.x' }
  };
</script>


五、 扩展:事件修饰符

Vue 提供事件修饰符简化事件处理逻辑,无需手动调用 e.preventDefault()/e.stopPropagation(),常用修饰符如下:

1. 流程控制

  • .stop阻止冒泡。相当于 e.stopPropagation()
  • .prevent阻止默认行为。常用于 <a> 标签和 <form> 提交。
  • .capture:使用捕获模式触发事件。

2. 触发频率与性能

  • .once只触发一次。之后再点击将失效。

  • .passive提升性能(移动端必用)

3. 按键与鼠标修饰符

  • .enter / .esc / .space:特定按键触发。
  • .left / .right / .middle:限制特定的鼠标按键。

RAG-如何对文档分块

上文我们讲了RAG是如何进行数据加载的,那么文档加载完数据就能直接喂给大模型进行问答吗,答案是否定的。因为把所有的文档都一并喂给大模型,那么大模型接受的上下文是非常巨大的,这会超出大模型所支持的最大token,而且每次会话,都要把上下文喂给大模型才能回答我们问的问题,这使得大模型的响应速度会变得很慢,如果是调用在线的大模型API的话,一次问答会消耗很多的token,钱包顶不住啊。所以要将文档数据加载后,进行数据分块、向量嵌入、存入向量数据库,通过向量检索将有用的数据喂给大模型,最后生成结果返回。这一篇我们着重说明数据分块是怎么做的。

在展示文本分块前说明下什么是token

  • 在英文里,一个单词可能是一个token,也可能被拆成多个。例如:playing 可能拆成 play + ing
  • 在中文里,通常一个汉字常常接近1个token, 但也不绝对
  • 标点、空格、换行也可能占token
文档分块方法
字符分块

用单一分隔符进行文档分块,代码如下:

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import CharacterTextSplitter

# ---------- 1. 加载 PDF ----------
loader = PyMuPDFLoader("../document/企业财务报表分析-图表.pdf")
documents = loader.load()

# ---------- 2. 文档分块 ----------
# chunk_size: 每块最大字符数;chunk_overlap: 块与块之间的重叠字符数,避免语义被截断
text_splitter = CharacterTextSplitter(chunk_size=200, chunk_overlap=50)
chunks = text_splitter.split_documents(documents)

# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
print(f"  CharacterTextSplitter      -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  Character:  最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:CharacterTextSplitter切分的文本

1773908015462.png (C:\Users\yd-19\AppData\Roaming\Typora\typora-user-images\1773908015462.png)

文中的CharacterTextSplitter是按照字符长度切分文本,其配置是:

  • chunk_size=500: 每块最多约为200字符
  • chunk_overlap=50: 相邻块重叠50字符,减少语言被截断
  • 不考虑语义,只看长度

这里有个问题就是虽然我们配置的文本块约为200个字符,但看返回的结果最大的文本块是1266个字符,远超200字符。这是为什么呢。因为CharacterTextSplitter的工作方式是:

  • 先用sparator把文本切开(默认是“\n\n”
  • 然后把切出来的小段尝试合并,合并到接近chunk_size为止
  • 但如果某一段本身就超过了chunk_size,它就不会再进一步切割

因为样例PDF里“\n\n”很少,CharacterTextSplitter按照“\n\n”切完块后,每段本身就很长,也不会对超长段再做二次切分。所以分块出来的结果最大文本块超过了200字符,并且切割出来的字符很不均匀。接下来我们介绍另一种分块方法。

递归分块

多级,按优先级递归分隔符,代码如下:

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"

loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=50,
)

chunks = recursive_splitter.split_documents(documents)


# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
print(f"  RecursiveCharacterTextSplitter      -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  Character:  最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:RecursiveCharacterTextSplitter切出的文本

1773911123241.png 通过结果我们可以看出,RecursiveCharacterTextSplitter切出来的文本更多,更加均匀,更接近我们设置的字符数。RecursiveCharacterTextSplitter切割分隔符是通过递归:\n\n\n空格字符,对于超长块的处理,会自动降级到更细的分隔符继续切。

1773921209044.png

我们继续观察结果得知,切出来的内容语义并不完整,一段完整的话被切成两个分块,所以也要根据文中的内容进行策略分块。

分块思想
分层分块

按照文档的章节结构、句子边界进行分块,优先保留完整的句子,在元数据中加入页码、章节、分块数量。代码如下:

import re
from copy import deepcopy

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"
MAX_CHUNK_SIZE = 500
CHUNK_OVERLAP = 50

CHAPTER_RE = re.compile(r"(?=(?:^|\n)[一二三四五六七八九十]+、)")
SECTION_RE = re.compile(r"(?=(?:^|\n)([一二三四五六七八九十]+))")

fallback_splitter = RecursiveCharacterTextSplitter(
    chunk_size=MAX_CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
    separators=["。", ";", "\n", ",", " ", ""],
    keep_separator=True,
)


def extract_heading(text: str, pattern: re.Pattern) -> str:
    """从块开头提取标题行。"""
    first_line = text.strip().split("\n")[0].strip()
    if pattern.search("\n" + first_line):
        return first_line
    return ""


def split_by_regex(text: str, pattern: re.Pattern) -> list[str]:
    """按正则切分,保留分隔符在各段开头。"""
    parts = pattern.split(text)
    result = []
    for p in parts:
        stripped = p.strip()
        if stripped:
            result.append(stripped)
    return result if result else [text]


def hierarchical_chunk(docs: list[Document]) -> list[Document]:
    full_text = "\n\n".join(doc.page_content for doc in docs)
    base_meta = docs[0].metadata if docs else {}

    chapters = split_by_regex(full_text, CHAPTER_RE)
    chunks: list[Document] = []

    for chapter_text in chapters:
        chapter_heading = extract_heading(chapter_text, CHAPTER_RE)

        sections = split_by_regex(chapter_text, SECTION_RE)

        for section_text in sections:
            section_heading = extract_heading(section_text, SECTION_RE)

            meta = deepcopy(base_meta)
            meta["chapter"] = chapter_heading
            meta["section"] = section_heading

            if len(section_text) <= MAX_CHUNK_SIZE:
                chunks.append(Document(page_content=section_text.strip(), metadata=meta))
            else:
                sub_chunks = fallback_splitter.split_text(section_text)
                for idx, sub in enumerate(sub_chunks):
                    sub_meta = deepcopy(meta)
                    sub_meta["sub_chunk"] = f"{idx + 1}/{len(sub_chunks)}"
                    chunks.append(Document(page_content=sub.strip(), metadata=sub_meta))

    # 过小的块(如纯章节标题)合并到下一块,避免碎片
    MIN_CHUNK_SIZE = 50
    merged: list[Document] = []
    carry = ""
    for chunk in chunks:
        if len(chunk.page_content) < MIN_CHUNK_SIZE:
            carry += chunk.page_content + "\n"
        else:
            if carry:
                chunk.page_content = carry + chunk.page_content
                carry = ""
            merged.append(chunk)
    if carry and merged:
        merged[-1].page_content += "\n" + carry.strip()

    return merged


# ---------- 执行 ----------
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = hierarchical_chunk(documents)

# ---------- 打印结果 ----------
print(f"=== 分层分块结果(共 {len(chunks)} 块)===\n")
char_lens = [len(c.page_content) for c in chunks]
print(f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens) // len(char_lens)}\n")

for i, chunk in enumerate(chunks, 1):
    ch = chunk.metadata.get("chapter", "")
    sec = chunk.metadata.get("section", "")
    sub = chunk.metadata.get("sub_chunk", "")
    label = f"[{ch}]" if ch else ""
    if sec:
        label += f" [{sec}]"
    if sub:
        label += f" (子块 {sub})"

    content = chunk.page_content
    preview = content[:200] + "..." if len(content) > 200 else content
    print(f"--- 第 {i}/{len(chunks)}{label} (长度: {len(content)}) ---")
    print(preview)
    print("-" * 80)
print()

返回的部分结果:

1773921947941.png 这种分块的方法能保留语义的完整性,切出来的块自带章节的标签,定位精准

滑动窗口分块

滑动窗口分块不看标点、不看换行、不看章节,纯按字符位置滑动。

  • 优点:块大小完全均匀,覆盖无死角(每个字符至少出现在 1~2 个块里)
  • 缺点:会从句子/词中间切断,语义完整性最差

代码如下:

from copy import deepcopy

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document

PDF_PATH = "../document/企业财务报表分析-图表.pdf"
WINDOW_SIZE = 300
STEP_SIZE = 200


def sliding_window_chunk(docs: list[Document], window: int, step: int) -> list[Document]:
    """
    滑动窗口分块:固定窗口大小,按步长向前滑动。
    window - step = 重叠字符数(本例 300 - 200 = 100 字符重叠)
    """
    chunks: list[Document] = []
    for doc in docs:
        text = doc.page_content
        if not text.strip():
            continue

        start = 0
        chunk_idx = 0
        while start < len(text):
            end = start + window
            segment = text[start:end].strip()
            if segment:
                meta = deepcopy(doc.metadata)
                meta["chunk_index"] = chunk_idx
                meta["char_start"] = start
                meta["char_end"] = min(end, len(text))
                chunks.append(Document(page_content=segment, metadata=meta))
                chunk_idx += 1
            start += step

    return chunks


# ---------- 执行 ----------
loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = sliding_window_chunk(documents, window=WINDOW_SIZE, step=STEP_SIZE)

# ---------- 打印结果 ----------
print(f"=== 滑动窗口分块结果(window={WINDOW_SIZE}, step={STEP_SIZE}, overlap={WINDOW_SIZE - STEP_SIZE})===\n")
print(f"  共 {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}\n")

for i, chunk in enumerate(chunks, 1):
    content = chunk.page_content
    preview = content[:200] + "..." if len(content) > 200 else content
    start = chunk.metadata["char_start"]
    end = chunk.metadata["char_end"]
    print(f"--- 第 {i}/{len(chunks)} 块 [字符 {start}~{end}] (长度: {len(content)}) ---")
    print(preview)
    print("-" * 80)
print()

返回部分结果:

1773922983844.png

句子边界优先分块

按照标点符号将整段文本拆成一句一句的,再把句子一句一句的往块里放,快满了就输出一块。输出一块后,不是从零开始。而是从前一块末尾回带几句(总字符数 ≤ chunk_overlap=50)作为新块的开头。回带也是以整句为单位,不会把句子劈开。

  • 优点:每个块里的句子都是完整的,embedding 质量好,检索到的上下文读起来通顺。
  • 缺点:不感知文档结构(章节/标题),可能把不同章节的内容拼到同一个块里。
import re
from copy import deepcopy

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"
CHUNK_SIZE = 300
CHUNK_OVERLAP = 50


def split_sentences_zh(text: str) -> list[str]:
    """按中文句号/问号/感叹号/分号切句,尽量保留句子语义完整。"""
    text = text.strip()
    if not text:
        return []
    parts = re.split(r"(?<=[。!?;!?;])\s*", text)
    return [p.strip() for p in parts if p.strip()]


def sentence_aware_chunk_documents(
    docs: list[Document],
    chunk_size: int,
    chunk_overlap: int,
) -> list[Document]:
    """先按句切,再按句合并;超长句再兜底按字符切分。"""
    fallback_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        keep_separator=True,
    )

    chunks: list[Document] = []
    overlap_chars = max(0, chunk_overlap)

    for doc in docs:
        sentences = split_sentences_zh(doc.page_content)
        if not sentences:
            continue

        current_sentences: list[str] = []
        current_len = 0

        for sentence in sentences:
            sent_len = len(sentence)

            # 单句本身超长,先把当前块落盘,再对超长句做兜底切分
            if sent_len > chunk_size:
                if current_sentences:
                    content = "".join(current_sentences).strip()
                    if content:
                        chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))
                    current_sentences = []
                    current_len = 0

                for sub in fallback_splitter.split_text(sentence):
                    sub = sub.strip()
                    if sub:
                        chunks.append(Document(page_content=sub, metadata=deepcopy(doc.metadata)))
                continue

            # 如果加上当前句会超长,则先输出当前块,再按 overlap 回带末尾句子
            if current_sentences and (current_len + sent_len > chunk_size):
                content = "".join(current_sentences).strip()
                if content:
                    chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))

                # 按字符数控制 overlap(以句子为单位回带,避免把句子切开)
                overlap_buf: list[str] = []
                overlap_len = 0
                for prev in reversed(current_sentences):
                    if overlap_len >= overlap_chars:
                        break
                    overlap_buf.insert(0, prev)
                    overlap_len += len(prev)

                current_sentences = overlap_buf
                current_len = sum(len(s) for s in current_sentences)

            current_sentences.append(sentence)
            current_len += sent_len

        if current_sentences:
            content = "".join(current_sentences).strip()
            if content:
                chunks.append(Document(page_content=content, metadata=deepcopy(doc.metadata)))

    return chunks


loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()
chunks = sentence_aware_chunk_documents(
    docs=documents,
    chunk_size=CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
)

# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果(句子边界优先)===\n")
print(f"  Sentence-aware splitter -> {len(chunks)} 块")
char_lens = [len(c.page_content) for c in chunks]
print(
    f"  长度统计: 最小={min(char_lens)}, 最大={max(char_lens)}, 平均={sum(char_lens)//len(char_lens)}"
)
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

返回的部分结果:

1773923355591.png

通过返回结果看,分块内的句子是完整的。这个方法与分层分块结合效果更好

父子文本分块

将文本切成子块和父块,其检索流程是,用子块向量搜索,命中子块后回溯拿到它对应的父块,把父块拼成上下文喂给LLM。

  • 子块:切的更小,用来做向量检索(更容易精准命中)。
  • 父块:比子块更大,用来给LLM作为更完整的上下文(避免只拿到碎片)。

代码如下:

import uuid
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

PDF_PATH = "../document/企业财务报表分析-图表.pdf"

loader = PyMuPDFLoader(PDF_PATH)
documents = loader.load()

parent_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=50)

parent_chunks = parent_splitter.split_documents(documents)

all_children = []
for parent in parent_chunks:
    parent_id = str(uuid.uuid4())[:8]
    parent.metadata["parent_id"] = parent_id

    children = child_splitter.split_documents([parent])
    for child in children:
        child.metadata["parent_id"] = parent_id
    all_children.extend(children)

# ---------- 打印父块 ----------
print(f"=== 父块(共 {len(parent_chunks)} 块,chunk_size=800)===\n")
for i, p in enumerate(parent_chunks, 1):
    pid = p.metadata["parent_id"]
    preview = p.page_content[:150] + "..." if len(p.page_content) > 150 else p.page_content
    print(f"[父块 {i}] id={pid}  长度={len(p.page_content)}")
    print(f"  {preview}")
    print()

# ---------- 打印子块(只展示前 3 个父块对应的子块)----------
print("=" * 80)
print(f"=== 子块(共 {len(all_children)} 块,chunk_size=200)===\n")

shown_parents = set()
for child in all_children:
    pid = child.metadata["parent_id"]
    if pid not in shown_parents:
        shown_parents.add(pid)
        if len(shown_parents) > 3:
            break
        print(f"  ┌─ 父块 id={pid}")

    siblings = [c for c in all_children if c.metadata["parent_id"] == pid]
    for j, sib in enumerate(siblings, 1):
        preview = sib.page_content[:100] + "..." if len(sib.page_content) > 100 else sib.page_content
        print(f"  │  子块 {j}/{len(siblings)}  长度={len(sib.page_content)}")
        print(f"  │  {preview}")
    print(f"  └─ 共 {len(siblings)} 个子块")
    print()

返回的部分结果:

1773924400688.png

1773924412258.png

检索时拿小块的 parent_id 回溯到父块,把父块的完整内容交给 LLM。

实现文本分块后的问答

说完分块思想,接下来让我们通过分块后的文本做个简单的RAG系统。实现流程如下:

RAG最小实现流程.png 在做RAG之前,有必要说明下嵌入模型和向量库。

嵌入模型

嵌入模型是把文本变成一组数字(向量)的模型,让计算机能“理解”文本的语义。

如人看到"营业收入增长"和"营收提升"会知道意思差不多,但计算机只认数字。嵌入模型的作用就是:

"营业收入增长"  →  [0.12, -0.33, 0.87, ..., 0.07]   (一个 1024 维的向量)
"营收提升"      →  [0.11, -0.31, 0.85, ..., 0.08]   (和上面很接近)
"今天天气不错"  →  [0.78,  0.42, -0.15, ..., 0.63]  (和上面离得远)
  • 语义相近->向量距离近
  • 语义无关->向量距离远

嵌入模型VS大语言模型(LLM)

嵌入模型 大语言模型(LLM)
输入 一段文本 一段文本(提示/对话)
输出 一个向量(一组数字) 文本(回答/续写)
用途 计算文本相似度、检索 理解问题、生成回答
RAG 中的角色 负责找到相关文档片段 负责根据片段回答问题

我用的线上嵌入模型是BAAI/bge-large-zh-v1.5,支持最大512个的token输入长度。

1773909383550.png

向量数据库

专门用来存储向量,按相似度搜索向量的数据库。文本切成块之后就会被嵌入模型转成向量,存入向量数据库。

传统数据库 向量数据库
存什么 行、列、文本、数字 向量(一组浮点数)
怎么查 WHERE name = '张三'(精确匹配) "找最像这个向量的 Top-K"(相似度匹配)
核心算法 B-tree 索引 ANN(近似最近邻)索引
实现代码
"""
基于 PDF 的 RAG 问答脚本:
加载 PDF → 分块 → 将分块内容作为上下文 → 使用 LLM 回答用户问题。
"""

import os
from dotenv import load_dotenv

load_dotenv()

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore

# ---------- 1. 加载 PDF ----------
loader = PyMuPDFLoader("../document/企业财务报表分析-图表.pdf")
documents = loader.load()

# ---------- 2. 文档分块 ----------
# chunk_size: 每块最大字符数;chunk_overlap: 块与块之间的重叠字符数,避免语义被截断
text_splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
chunks = text_splitter.split_documents(documents)

# ---------- 3. 打印分块内容 ----------
print("=== 文档分块结果 ===\n")
for i, chunk in enumerate(chunks, 1):
    print(f"--- 第 {i} 块 (共 {len(chunks)} 块) ---")
    print(chunk.page_content.strip() or "(空)")
    if chunk.metadata:
        print(f"[元数据] {chunk.metadata}")
    print("-" * 50)
print()

# ---------- 4. 配置 LLM(代理地址与 API Key 从 .env 读取) ----------
llm = ChatOpenAI(
    model=os.getenv("PROXY_AI_MODEL", "gemini-2.5-flash"),
    base_url=os.getenv("PROXY_AI_BASE_URL"),
    api_key=os.getenv("PROXY_AI_API_KEY"),
    temperature=0.3,
    max_tokens=1024,
)

embeddings = OpenAIEmbeddings(
    model="BAAI/bge-large-zh-v1.5",
    api_key=os.getenv("SILICONFLOW_API_KEY"),
    base_url="https://api.siliconflow.cn/v1",
    chunk_size=32,
)

vector_store = InMemoryVectorStore.from_documents(chunks, embeddings)

# ---------- 5. 构建提示与调用链 ----------
# 系统消息中注入 PDF 上下文,用户消息为问题
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "你是一个助手。请仅根据下面「PDF 内容」回答用户问题,不要编造。回答简洁。\n\nPDF 内容:\n{context}",
        ),
        ("human", "{question}"),
    ]
)
chain = prompt | llm

# ---------- 6. 交互式问答 ----------
print("基于 PDF 的问答(输入空行回车退出)\n")
while True:
    question = input("你的问题: ").strip()
    if not question:
        break
    # 把问题做成向量检索
    retrieved = vector_store.similarity_search(question, k=8)
    context = "\n\n".join(doc.page_content for doc in retrieved)
    answer = chain.invoke({"context": context, "question": question})
    print(f"回答: {answer.content}\n")

返回部分结果: 1773927282417.png

回答的结果对比文档出处:

1773927410613.png

1773927360666.png

1773927327431.png

总结
  • 字符分块:按一个分隔符切一次,超长也不管。
  • 递归分块:多级分隔符递归切,尽量控制块大小。
  • 句子边界:以句子为最小单位,不在句中截断。
  • 层级分块:先按章节结构切,再对超长段做二次切。
  • 滑动窗口:按固定字符数滑窗,重叠一段,块大小均匀。
  • 父子分块:小块检索、大块回答,检索细、回答有上下文。
结尾

文本分块的目的,是让每块内容更聚焦、语义更完整,从而提升RAG系统的检索准确度。好了,文档分块的内容就分享到这儿。在座的彦祖、亦菲们有什么好的文档分块方法,也欢迎到评论区讨论哦!

Vue-Vue2与Vue3核心差异与进化

前言

从 Vue 2 到 Vue 3,不仅仅是版本的跳跃,更是底层思想的革新。从 Object.definePropertyProxy,从 Options API 到 Composition API,Vue 3 在性能和开发体验上都实现了质的飞跃。本文将带你系统梳理两者的核心区别。

一、 响应式原理:从“属性拦截”到“对象代理”

响应式系统的升级是 Vue 3 性能提升的关键。

1. Vue 2:Object.defineProperty

  • 原理:初始化时通过递归遍历 data,为每个属性设置 gettersetter

  • 局限性

    • 无法检测到对象属性的新增删除
    • 无法直接监听数组索引的变化和 length 属性。
    • 必须使用 this.$set 等特有 API 来弥补。
    • 递归过程在处理大数据量时存在性能瓶颈。

2. Vue 3:ES6 Proxy

  • 原理:直接监听整个代理对象,拦截所有操作(如 get, set, deleteProperty, has 等)。

  • 优势

    • 原生支持:自动支持动态增删属性、数组下标修改。
    • 懒代理(Lazy Tracking) :只有当访问到深层属性时,才会动态将其转为响应式,大大提升了初始化速度。
    • 性能更好:省去了初始化时繁琐的递归遍历。

二、 编写模式:从“碎片化”到“模块化”

代码组织方式的改变直接影响了大型项目的维护成本。

1. Vue 2:选项式 API (Options API)

  • 痛点:逻辑被强行拆分在 datamethodscomputed 等固定选项中。当一个组件功能复杂时,同一个功能的代码会散落在各处,导致开发者反复上下滚动查找,难以维护。

2. Vue 3:组合式 API (Composition API)

  • 优势:通过 <script setup>,开发者可以按照功能逻辑将代码组织在一起。

  • 逻辑复用:可以轻松地将逻辑抽离成独立的 useHooks 函数,解决了 Vue 2 中 mixin 命名冲突和来源不明的问题。


三、 Vue 3 核心新特性与语法糖

1. 响应式新成员:ref vs reactive

  • ref:万能型。支持基本类型和引用类型,通过 .value 访问(模板中自动解包)。
  • reactive:对象型。仅支持引用类型,直接操作属性,无需 .value

2. defineModel:双向绑定的“减法”

在 Vue 3.4+ 中引入的 defineModel 极大地简化了父子组件通信:

  • Vue 2 做法:需要 props 接收值 + this.$emit('update:xxx') 触发更新。
  • Vue 3 新语法:子组件直接使用 const model = defineModel(),修改 model 的值会自动同步到父组件,代码量骤减。

3. 多根节点模板

  • Vue 2:模板内必须有一个唯一的根节点(通常是 <div>),否则报错。
  • Vue 3:原生支持多个根节点,减少了不必要的 DOM 层级,使 HTML 结构更简洁。

4. 异步处理神器:<Suspense>

  • 新增内置组件,专门用于处理异步组件的加载状态。它提供了 defaultfallback 两个插槽,可以优雅地展示“加载中”和“加载完成”的 UI 切换。

四、 总结:为什么要升 Vue 3?

类别 Vue2 Vue3
响应式原理 Object.defineProperty 逐个属性劫持 Proxy 代理整个对象,懒加载
编写模式 选项式API(Options API) 组合式API(Composition API +
模板规范 仅支持单个根节点 支持多个根节点
数据监听 无法监听对象增删、数组索引 原生支持对象增删、数组下标修改
组件双向绑定 props + emit 手动实现 defineModel 语法糖简化
异步加载 手动处理加载状态 内置 Suspense 组件
❌