阅读视图

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

连载04-最重要的Skill---一起吃透 Claude Code,告别 AI coding 迷茫

screenshot-20260410-194956.png

别再直接 Fork 别人的 Claude Skill:公开模板只是原材料,项目规则才是成品

AI Coding 系列第 05 篇 · 核心工具

我第一次批量导入公开 Skill 模板的时候,是真的以为自己走了捷径。

GitHub 上一堆 star 很高的仓库,code review、需求分析、文档编写、调研、拆任务,看起来什么都有。我当时的想法特别简单:既然别人已经把常见工作流整理好了,我直接 fork 一份,全量导入,不就能让 Claude 立刻更稳、更懂项目吗?

结果用了几天,我反而越来越不放心。

它每次都输出得很像那么回事。格式完整,措辞专业,检查项也不少。可真正让我在项目里反复吃亏的那几件事,它一次都没替我盯住。异步链里是不是又漏了 await,这次 migration 有没有回滚方案,新同学是不是又顺手写了 throw new Error(),数据库 schema 改了之后 Prisma 类型是不是也一起更新了。

它会提醒一堆“大家普遍都应该注意”的东西,却不懂“我们团队到底最怕什么”。

后来我才慢慢明白,问题不是 Skill 机制不好,而是我导入的根本不是自己的 Skill,只是别人整理好的经验。

这些经验当然有用,但它们解决的是共性问题,不会天然长成你项目里的“肌肉记忆”。

这篇文章就讲一件事:

怎么把公开模板当原材料,而不是成品;怎么从自己的项目里,提炼出一个真正会被反复复用、而且越用越准的 Skill。

如果你只想先记住一句话,那就是:

公开 Skill 模板是素材库,不是最终产品。


先给你一个判断框架

为了后面不绕,我先把最核心的判断放前面:

  • 对所有任务都生效的规则,放 CLAUDE.md
  • 只对某一类任务生效的规则,做成 Skill
  • 只对这一次任务有效的约束,写进 Prompt
  • 只有“输入相对稳定、输出有共同模式、而且容易漏步骤”的任务,才值得沉淀成 Skill
  • 第一个 Skill 不要选最关键的任务,先拿中等风险任务练手

如果你现在就卡在“这条到底该写哪”,后面大部分内容其实都可以用这五条往回推。


一、为什么很多公开 Skill 模板,一开始觉得香,后来却越用越别扭

我现在反而会对“看起来很全”的公开 Skill 模板保持一点警惕。

不是因为它们没用,而是因为它们太容易制造一种错觉:好像什么都覆盖到了,但真正最重要的东西其实没进去。

公开模板最常见的问题,不是方向错,而是下面这三种。

1. 太宽泛

它什么都管一点,但什么都不够深。

它会告诉你“注意异常处理”“注意性能”“注意安全”,这些当然没错。但这些话本身不构成你项目里的工作流。它不知道你们统一用的是 AppError,不知道你们数据库变更必须检查回滚,也不知道你们哪几个目录历史包袱最重。

2. 太嘈杂

50 行模板里,真正有价值的可能只有 5 行。

剩下的 45 行不是完全没用,而是在和那 5 行争夺 Claude 的注意力。对于 agent 来说,规则不是越多越强。很多时候,8 行写透项目约束的 Skill,比 50 行“样样都提一点”的模板更有用。

3. 太不像你的项目

这点才是最致命的。

公开模板知道“大家普遍应该注意什么”,但不知道“你们团队反复死在哪些地方”。而真正有价值的 Skill,恰恰应该把那些项目特有、团队高频踩坑的东西固化下来。

说得更直白一点:你把一个新同事扔进团队,给他一份行业通用培训材料,当然比什么都不给强;但如果你不告诉他“我们团队最容易出错的是哪三件事”,他依然干不好你最在意的活。

所以正确姿势不是“找一个最全的模板直接用”,而是:

先借鉴,再裁剪,最后只留下真正属于你项目的那几条。


二、先把 Prompt、CLAUDE.md、Skill 这三件事分清楚

很多人不是不会写 Skill,而是一开始就把这三件事混在一起了。

判断方法其实很简单,只问一个问题的三个变体:

  • 这个要求对所有任务都成立吗?如果是,放 CLAUDE.md
  • 这个要求只对某一类任务成立吗?如果是,做成 Skill
  • 这个要求只对这一次成立吗?如果是,写进 Prompt

举几个特别典型的例子:

“所有 throw 必须是 AppError
这是全局规则。不管你是在写新功能、修 bug,还是做重构,都要遵守。它应该进 CLAUDE.md

“代码审查时按固定顺序检查数据库、异步和错误处理”
这只在 code review 这种任务里才触发,它不是全局规则,而是任务模板,所以应该做成 Skill

“这次先只分析原因,不要动代码”
这只对当前这次任务有效,应该写进 Prompt

最容易搞混的是 CLAUDE.mdSkill。它们都能约束 Claude 的行为,但本质完全不同:

  • CLAUDE.md 是永远生效的规则
  • Skill 是遇到对应任务才触发的模板

如果要打个比方:

  • CLAUDE.md 是交通规则
  • Skill 是导航路线
  • Prompt 是你这次上车前临时交代的一句话

这三层一旦分清楚,后面很多混乱都会自动消失。


三、什么时候一个任务值得被沉淀成 Skill

不是所有重复任务都值得沉淀。

我现在给自己的标准其实很克制,就一句话:

同一类任务做了三次以上,而且每次都要重新给 Claude 解释背景。

反过来说,如果某个任务每次背景和目的都完全不同,就不值得沉淀。比如“写文档”这个动作本身很常见,但公司文档、API 文档、用户手册的写法完全不同,它们应该是三个不同的 Skill,而不是一个叫“写文档”的通用模板。

在真正开始写之前,我会先做三个检查。

1. 输入是否稳定

“根据 Figma 设计稿生成 React 组件”这种任务,输入格式相对稳定,比较适合沉淀。

“根据 SQL 查询结果生成图表”这种任务,每次数据格式和图表类型都可能差很多,Skill 会很难写得稳。

2. 输出是否有共同模式

“写 Pull Request 描述”很适合,因为它天然就有固定框架:改了什么、为什么改、怎么测试。

但“和 AI 讨论技术方案”这种任务,每次深度、重点、结论都不同,就不太适合硬沉淀成一个模板。

3. 有没有容易漏掉的关键步骤

最值得沉淀成 Skill 的任务,通常不是“最复杂”的任务,而是那些不特别提醒就容易漏一步的任务。

Skill 最有价值的地方,不是让 Claude 变得更聪明,而是把你每次最容易忘的检查项,固化成默认动作。

所以一个任务如果同时满足下面三点:

  • 输入相对稳定
  • 输出有共同模式
  • 总有一两步容易漏

它就很值得沉淀成 Skill。


四、一个真正好用的 Skill,内容层通常只需要四个部分

很多人一开始会把 Skill 写得很重,像在写规范文档。但实际用起来之后你会发现,真正好用的 Skill 通常很短。

它一般只需要四个部分。

1. 触发条件

什么时候用,一句话说清楚。

❌ 代码审查
✅ 当我提交 PR 前,检查我的实现是否符合项目约定

2. 执行步骤

按什么顺序做,列出来。尽量不要超过五步。

1. 读完整个改动的 diff
2. 检查是否用了禁用的库或模式
3. 检查异步操作的错误处理
4. 检查是否有 SQL 注入的风险
5. 给出修改建议

3. 输出格式

不要写“请清晰输出”。这种话几乎没有约束力。直接给模板。

❌ 用清晰的格式列出所有问题

✅ 给个模板:
Found 3 issues:
🔴 Critical: ...
🟡 Warning: ...
✅ Suggestion: ...

4. 注意事项

说清楚边界。什么情况不适用,有哪些常见陷阱。

- 不适用于新增功能的初始实现,只适用于 PR 前的最终检查
- 不关注 UI 层细节
- 改动超过 500 行,先拆成多个 Skill 请求

Skill 不是规范手册,更不是把所有经验一次性塞进去。它本质上是一个高频任务的最小可执行模板。


五、真正落到 SKILL.md 文件层,哪些字段最值得你花心思

讲完“内容怎么提炼”,还得讲“文件怎么写”。

很多人第一次写 SKILL.md 会卡在另一个地方:字段太多,不知道哪些真有用,哪些只是“看起来高级”。

一个完整的 SKILL.md,通常会长这样:

---
name: code-review
description: 提交 PR 前的代码审查
when_to_use: 当用户要求 review 代码或提交 PR 前检查时
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
arguments:
  - target
---

# Code Review

## 步骤
1. 读取 ${target} 的改动 diff
2. 检查错误处理:所有 throw 必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题

## 输出格式
🔴 Critical: ...
🟡 Warning: ...
✅ 通过: ...

这里最值得你认真写的,其实是下面几个字段。

name
名字别太抽象。要让人一眼知道它是做什么的。

description
一句话说清这个 Skill 的用途,不要写成空泛口号。

when_to_use
这是最容易被低估的字段之一。它不是装饰,它直接影响 Claude 在什么场景下会想到这个 Skill。

allowed-tools
它决定这个 Skill 具备哪些能力。这个字段后面我会在源码部分展开讲,因为它比很多人想象的更“硬”。

arguments
让 Skill 接受参数,比如目标文件、目录、分支名。${target} 会在正文里被替换成你传进去的实际值。

还有几个很好用,但不是每次都要上的字段。

argument-hint
告诉调用者这个 Skill 期待什么参数。

model: haiku
简单任务可以指定更轻量的模型,直接省成本。像格式化、重命名、简单改写这类工作,很多时候没必要上更重的模型。

paths
让 Skill 只在某些路径下激活。适合模块边界明确的项目。

context: fork
高风险操作放进独立上下文,避免污染主会话。

大多数 Skill 根本不需要把字段填满。真正实用的思路不是“功能全”,而是“正好够用”。

如果一个 Skill 只是做常规代码审查,前四五个字段通常就够了。只有当你真的遇到参数化、模块隔离、上下文隔离这些需求时,再往上加。


六、如果只停在经验层,这篇其实还差半口气:我后来去翻了源码

前面这些判断,靠经验其实也能总结出来。

但我后来还是不太满足。因为有几个问题如果不看实现,心里总会悬着:

  • when_to_use 到底是不是自动触发的关键?
  • allowed-tools 到底只是提示,还是硬限制?
  • paths 到底是真过滤,还是只是写给人看的说明?

我后来去翻了一遍源码,结论是:这些字段比我一开始以为的更“硬”。

1. Claude 只在启动时读 frontmatter,Skill 正文是懒加载的

loadSkillsDir.ts 里有一个函数 estimateSkillFrontmatterTokens,注释写得非常直接:

/**
 * Estimates token count for a skill based on frontmatter only
 * (name, description, whenToUse) since full content is only loaded on invocation.
 */
export function estimateSkillFrontmatterTokens(skill: Command): number {
  const frontmatterText = [skill.name, skill.description, skill.whenToUse]
    .filter(Boolean)
    .join(' ')
  return roughTokenCountEstimation(frontmatterText)
}

这段代码背后的意思非常重要。

Claude Code 启动时,主要只把每个 Skill 的 namedescriptionwhen_to_use 这些 frontmatter 信息算进上下文。Skill 正文不是一开始就全量塞进去,而是在你真正触发它的时候才加载。

这直接解释了两件事。

第一,when_to_use 写得越具体,自动命中的效果就越稳定。Claude 不是先把你整篇 Skill 读完再判断要不要触发,它先看的就是前面这几行。

第二,你有十个 Skill 还是三个 Skill,对启动时上下文的占用差距没你想的那么大。真正的成本在触发时才发生。

所以 when_to_use 不能写成“代码相关任务时使用”这种空话。它要写成“当用户要求 review TypeScript 后端代码或提交 PR 前做最终检查时”这种具体到能命中的描述。

这也是为什么我现在越来越重视 frontmatter。以前我会把心思都放在正文步骤上,后来才发现,前面几行写虚了,后面写得再好都不一定有机会被用上。

2. allowed-tools 是系统层权限,不是给 Claude 的礼貌性建议

这一点是我看源码之后感受最强的一处。

Skill 执行时,getPromptForCommand 会在返回内容之前把 allowedTools 写进工具权限上下文:

getAppState() {
  const appState = toolUseContext.getAppState()
  return {
    ...appState,
    toolPermissionContext: {
      ...appState.toolPermissionContext,
      alwaysAllowRules: {
        ...appState.toolPermissionContext.alwaysAllowRules,
        command: allowedTools,
      },
    },
  }
}

这说明 allowed-tools 不是“提醒 Claude 尽量这样做”,而是权限层的强制限制。

比如一个 code review Skill 只开放 ReadGrepGlobBash(git diff *),那它就不是“理论上不该写文件”,而是从架构上根本没有写文件的能力Bash(git diff *) 这种写法也不是装饰,它真的只允许 git diff 开头的命令,其他 Bash 调用会被挡住。

这让我对 allowed-tools 的理解完全变了。它不是“不信任模型”,而是最小权限设计。就像你给数据库只读账号只开 SELECT 权限,不是因为你怀疑这账号会作恶,而是因为这个任务本来就不该拥有写权限。

3. paths 不是文档字段,它会把 Skill 放进条件激活区

这一点也比表面上看起来更硬。

源码里,带 paths 的 Skill 在加载时会被单独分流到一个 conditionalSkills Map:

// Separate conditional skills (with paths frontmatter) from unconditional ones
for (const skill of deduplicatedSkills) {
  if (skill.type === 'prompt' && skill.paths && skill.paths.length > 0
      && !activatedConditionalSkillNames.has(skill.name)) {
    newConditionalSkills.push(skill)
  } else {
    unconditionalSkills.push(skill)
  }
}

// Store conditional skills for later activation when matching files are touched
for (const skill of newConditionalSkills) {
  conditionalSkills.set(skill.name, skill)
}

// 最后只返回无条件的 Skill
return unconditionalSkills

这段逻辑的含义是:带 paths 的 Skill,根本不会像普通 Skill 一样直接进入启动时上下文。它会先待在一个“条件激活区”里,只有当你在会话里碰到了匹配路径的文件,它才会被真正激活。

这点对复杂项目非常有价值。

比如你给支付模块写一个 paths: src/payment/** 的 Skill,在你处理用户系统、文章系统、管理后台时,这个 Skill 对 Claude 几乎是隐身的。只有当你真的进入 src/payment/ 相关文件,它才“出现”。

这也是我现在很认同的一种团队实践:不要在根目录堆一个什么都想管的大 Skill 集合,而是让复杂模块在自己的目录附近维护自己的 Skill。

4. Skill 发现是沿目录向上找的,而且离文件越近优先级越高

还有一个很容易被忽略,但工程上非常实用的机制:Claude Code 会从当前文件所在目录一路向上寻找 .claude/skills

源码大概是这样:

// Walk up to cwd but NOT including cwd itself
while (currentDir.startsWith(resolvedCwd + pathSep)) {
  const skillDir = join(currentDir, '.claude', 'skills')
  // ...check if exists, then load
  currentDir = dirname(currentDir)
}

// Sort by path depth (deepest first) so skills closer to the file take precedence
return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)

这里最关键的是最后一行:deepest first。也就是说,越靠近当前文件的 Skill,优先级越高。

这意味着你放在 src/auth/.claude/skills/ 里的 Skill,可以自然覆盖根目录下更通用的同名 Skill。对 monorepo 或大仓库来说,这个机制非常好用:

  • packages/api/.claude/skills/ 可以放 API 专属 Skill
  • packages/web/.claude/skills/ 可以放前端专属 Skill
  • 根目录只保留真正的全局规则

如果把上面四点放在一起看,设计 Skill 的顺序其实会变得很清楚:

  • 先把 frontmatter 写准,再去打磨正文步骤
  • 先按最小权限收紧 allowed-tools,再考虑要不要给更多能力
  • 只有模块边界明确时再上 paths,不要为了“高级”硬加
  • 多目录项目优先做“离代码更近”的局部 Skill,而不是维护一个大而全的总模板

七、完整案例:把一个通用 code review 模板,提炼成你项目真正需要的 Skill

上面说了这么多抽象原则,不如走一遍完整例子。

假设你们团队每周都做后端代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

第一步:先确认痛点到底是什么

这一步别着急写模板,先把“你们到底在反复出什么问题”说清楚。

很多团队的问题不是“没有 code review”,而是每次 review 的注意力都被分散了。真正高频出错的点,永远是那几类项目特有的约束。

所以要沉淀的不是“代码审查”这四个字,而是你们团队在代码审查里最容易漏掉的那几类检查。

第二步:从公开模板里提取真正有用的部分

这时候公开模板就有用了,但它的用途不是直接上生产,而是当素材库。

假设你找到一个 50 行的通用 code review 模板。你真正该提取的,可能只有下面这几类东西:

  • 逻辑正确性,尤其是异步操作
  • 项目约定的遵守,比如 AppError、错误处理模式
  • 数据库相关的风险,比如 N+1、索引、查询范围
  • 改动范围是否聚焦,不要顺手改不相干文件

剩下那些跟你们项目关系不大的部分,就应该果断删掉。

第三步:把它压缩成一个真正能用的 Skill

最后落地出来的 Skill,应该更像这样:

# Code Review Skill

## When to use
在提交 PR 前,请 Claude 做最后的 code review。只用于 TypeScript 后端代码。

## Steps
1. 读 diff,确认改动是否只涉及这个 PR 的范围(不要顺手改无关文件)
2. 检查错误处理:所有 throw 都必须是 throw new AppError(),不能 throw new Error()
3. 检查异步操作:Promise 链是否有遗漏的 await,错误是否被正确 catch
4. 检查数据库查询:是否有 SELECT * 的懒惰写法,是否明显的 N+1 查询,关键查询是否 explain 过

## Output Format
Issues found (Critical → Warning → Info):

🔴 **Line 45**: Missing `.select()` in Prisma query - this will fetch unnecessary columns
🟡 **Line 67**: Potential N+1: loop inside `posts.map()` should use `Promise.all()`**No AppError violations** — all errors properly handled

Summary: 1 critical issue to fix before merge.

## Caveats
- 不审查 UI 层代码(只关心后端逻辑)
- 不关注代码风格(那是 prettier 的事)
- 如果一个改动涉及多个不相关功能,分别提交 PR 再 review

你会发现,到这一步之后,Skill 就不再是“通用模板的中文版”了,而是你们项目真正有用的一个局部工作流。

50 行公开模板,最后可能只剩下 4 个真正属于你项目的核心关注点。但恰恰是这 4 个点,才决定它到底值不值得用。

第四步:在真实使用里继续迭代

Skill 从来不是一次写完的。

比如你用了两周之后,又发现一个常见问题:改了数据库 schema,但忘记更新 Prisma 类型。那就把它加进去:

4.5. 检查 Prisma 类型:如果改了数据库,Prisma schema 和生成的 types 是否都已更新

这时候你会发现,Skill 真正的价值不是“第一次写出来”,而是在真实工作里被持续打磨。


八、Skill 的维护节奏,比第一次写出来更重要

Skill 不是写好就扔。

如果你写完之后三个月不看,它很快就会从“项目经验”重新退化成“历史遗留文档”。

我更推荐一个很轻的维护节奏:

第一个月
高频使用,快速迭代。每次用完就问自己三个问题:步骤是不是太复杂?输出是不是太啰嗦?有没有漏掉今天刚踩到的新坑?

之后每个月
回顾一次。看看最近有没有经常被遗漏的步骤,有没有新的痛点需要加入。

每个季度
系统清理一次。把已经不再是问题的注意事项删掉,把那些已经变成全局共识的规则移进 CLAUDE.md

Skill 应该越用越精炼,而不是越写越臃肿。


九、一个特别反直觉,但很重要的经验:第一个 Skill,故意别写最重要的任务

这条我非常想单独拿出来讲。

因为很多人第一次沉淀 Skill,会本能地想挑一个最关键的任务,比如“生产环境发布前检查”“数据库迁移前审查”“支付流程改动 review”。

但工程上更稳的做法,其实正好相反。

大多数人写的第一个 Skill,质量都不会太高。触发条件偏模糊,步骤偏啰嗦,输出格式也不够稳定。这很正常,因为你第一次做这件事时,对“什么是好的 Skill”还没有直觉。

如果你一开始就把它用在最关键的任务上,一旦写得不够好,伤害会非常直接:要么关键场合出问题,要么你从此对这套机制失去信心。

更好的策略是:先拿一个重要程度中等、容错率比较高的任务练手。

比如:

  • 写周报
  • 生成 PR 描述
  • 做一次常规 code review

先跑两周,迭代两三次,等你对 Skill 的节奏有感觉了,再去沉淀真正关键的流程。

第一个 Skill 的目的,不是直接解决最大的问题,而是让你学会怎么写 Skill。


十、如果你今天就想开始,可以直接做这三个动作

别先想着搭一整套系统,直接从最小动作开始:

任务一:列任务
列出你最近一个月里重复做过三次以上的任务。

任务二:做分类
用“三问判断法”确认它该放进 CLAUDE.mdSkill 还是 Prompt

任务三:先写一个 5 到 10 行版本
先写触发条件、执行步骤、输出格式、注意事项。不要追求完美,先拿去用一次,再立刻改第一轮。

真正好的 Skill,几乎都不是第一次就写对的,而是在实际使用里慢慢长出来的。


下篇预告

第 06 篇:Sub-agents 实战——什么时候应该拆任务,怎么设计子任务边界

单个 Claude 实例有上下文上限,复杂任务拆成多个子任务让 Sub-agents 并行处理,理论上能大幅提速。但什么时候值得拆?拆错了会有什么代价?下一篇会拆开 Sub-agents 的真实适用场景,以及最常见的过度设计陷阱。


写在最后

公开 Skill 模板当然有用,但它的价值更像脚手架,而不是成品。

真正管用的 Skill,不是 star 最多的那个,也不是字段最全的那个,而是最贴近你项目真实工作流的那个。

你不需要一个很复杂的 Skill 系统。

你需要的,往往只是把团队最容易反复犯错的那几件事,提前写下来,让 Claude 每次都替你盯住。

这才是 Skill 真正应该发挥的作用。

如果你已经开始写 Skill 了,我反而建议你先检查一个问题:

你现在最卡住的,到底是“写不出规则”,还是“根本没分清哪些该放 CLAUDE.md、哪些该做成 Skill”?

这两个问题看起来很像,但解法完全不同。


AI Coding 系列持续更新。用别人的 Skill 模板是起点,不是终点。真正管用的 Skill,只有你自己的项目才能提炼出来。

每日一题-三个相等元素之间的最小距离 I🟢

给你一个整数数组 nums

如果满足 nums[i] == nums[j] == nums[k],且 (i, j, k) 是 3 个 不同 下标,那么三元组 (i, j, k) 被称为 有效三元组 

有效三元组 的 距离 被定义为 abs(i - j) + abs(j - k) + abs(k - i),其中 abs(x) 表示 x 的 绝对值 

返回一个整数,表示 有效三元组 的 最小 可能距离。如果不存在 有效三元组 ,返回 -1

 

示例 1:

输入: nums = [1,2,1,1,3]

输出: 6

解释:

最小距离对应的有效三元组是 (0, 2, 3) 。

(0, 2, 3) 是一个有效三元组,因为 nums[0] == nums[2] == nums[3] == 1。它的距离为 abs(0 - 2) + abs(2 - 3) + abs(3 - 0) = 2 + 1 + 3 = 6

示例 2:

输入: nums = [1,1,2,3,2,1,2]

输出: 8

解释:

最小距离对应的有效三元组是 (2, 4, 6) 。

(2, 4, 6) 是一个有效三元组,因为 nums[2] == nums[4] == nums[6] == 2。它的距离为 abs(2 - 4) + abs(4 - 6) + abs(6 - 2) = 2 + 2 + 4 = 8

示例 3:

输入: nums = [1]

输出: -1

解释:

不存在有效三元组,因此答案为 -1。

 

提示:

  • 1 <= n == nums.length <= 100
  • 1 <= nums[i] <= n

三个相等元素之间的最小距离 I

方法一:暴力

思路与算法

本题是「3741. 三个相等元素之间的最小距离 II」的数据简化版,由于数据范围较小,可以直接使用暴力求解。

首先观察要求的绝对值距离之和计算公式:可以发现实际上这就是一个广义三角形的三边之和,不管选取的三个点顺序如何,长度一定等于两倍的端点构成的线段的长度;换而言之,设最右侧点的下标是 $k$,最左侧点的下标是 $i$,所求的距离就是 $2 \times (k - i)$。

故使用三重循环暴力枚举所有不同的顺序三元组,若 $\textit{nums}$ 中对应位置的元素相同,则根据上述分析计算距离,最后取全局最小值即为所求。

代码

###C++

class Solution {
public:
    int minimumDistance(vector<int>& nums) {
        int n = nums.size();
        int ans = n + 1;

        for (int i = 0; i < n - 2; i++) {
            for (int j = i + 1; j < n - 1; j++) {
                if (nums[i] != nums[j]) {
                    continue;
                }
                for (int k = j + 1; k < n; k++) {
                    if (nums[j] == nums[k]) {
                        ans = std::min(ans, k - i);
                        break;
                    }
                }
            }
        }

        return ans == n + 1 ? -1 : ans * 2;
    }
};

###JavaScript

var minimumDistance = function (nums) {
    let ans = nums.length + 1;

    for (let i = 0; i < nums.length - 2; i++) {
        for (let j = i + 1; j < nums.length - 1; j++) {
            if (nums[i] !== nums[j]) {
                continue;
            }
            for (let k = j + 1; k < nums.length; k++) {
                if (nums[j] === nums[k]) {
                    ans = Math.min(ans, k - i);
                    break;
                }
            }
        }
    }

    if (ans === nums.length + 1) {
        return -1;
    } else {
        return ans * 2;
    }
};

###TypeScript

function minimumDistance(nums: number[]): number {
    let ans = nums.length + 1;
    for (let i = 0; i < nums.length - 2; i++) {
        for (let j = i + 1; j < nums.length - 1; j++) {
            if (nums[i] !== nums[j]) {
                continue;
            }
            for (let k = j + 1; k < nums.length; k++) {
                if (nums[j] === nums[k]) {
                    ans = Math.min(ans, k - i);
                    break;
                }
            }
        }
    }

    if (ans === nums.length + 1) {
        return -1;
    } else {
        return ans * 2;
    }
}

###Java

class Solution {
    public int minimumDistance(int[] nums) {
        int n = nums.length;
        int ans = n + 1;

        for (int i = 0; i < n - 2; i++) {
            for (int j = i + 1; j < n - 1; j++) {
                if (nums[i] != nums[j]) {
                    continue;
                }
                for (int k = j + 1; k < n; k++) {
                    if (nums[j] == nums[k]) {
                        ans = Math.min(ans, k - i);
                        break;
                    }
                }
            }
        }

        return ans == n + 1 ? -1 : ans * 2;
    }
}

###C#

public class Solution {
    public int MinimumDistance(int[] nums) {
        int n = nums.Length;
        int ans = n + 1;

        for (int i = 0; i < n - 2; i++) {
            for (int j = i + 1; j < n - 1; j++) {
                if (nums[i] != nums[j]) {
                    continue;
                }
                for (int k = j + 1; k < n; k++) {
                    if (nums[j] == nums[k]) {
                        ans = Math.Min(ans, k - i);
                        break;
                    }
                }
            }
        }

        return ans == n + 1 ? -1 : ans * 2;
    }
}

###Go

func minimumDistance(nums []int) int {
n := len(nums)
ans := n + 1

for i := 0; i < n-2; i++ {
for j := i + 1; j < n-1; j++ {
if nums[i] != nums[j] {
continue
}
for k := j + 1; k < n; k++ {
if nums[j] == nums[k] {
if dist := k - i; dist < ans {
ans = dist
}
break
}
}
}
}

if ans == n+1 {
return -1
}
return ans * 2
}

###Python

class Solution:
    def minimumDistance(self, nums: List[int]) -> int:
        n = len(nums)
        ans = n + 1

        for i in range(n - 2):
            for j in range(i + 1, n - 1):
                if nums[i] != nums[j]:
                    continue
                for k in range(j + 1, n):
                    if nums[j] == nums[k]:
                        ans = min(ans, k - i)
                        break

        return -1 if ans == n + 1 else ans * 2

###C

int minimumDistance(int* nums, int numsSize) {
    int ans = numsSize + 1;

    for (int i = 0; i < numsSize - 2; i++) {
        for (int j = i + 1; j < numsSize - 1; j++) {
            if (nums[i] != nums[j]) {
                continue;
            }
            for (int k = j + 1; k < numsSize; k++) {
                if (nums[j] == nums[k]) {
                    if (k - i < ans) {
                        ans = k - i;
                    }
                    break;
                }
            }
        }
    }

    return ans == numsSize + 1 ? -1 : ans * 2;
}

###Rust

impl Solution {
    pub fn minimum_distance(nums: Vec<i32>) -> i32 {
        let n = nums.len();
        let mut ans = n + 1;

        if n < 3 {
           return -1;
        }

        for i in 0..n - 2 {
            for j in i + 1..n - 1 {
                if nums[i] != nums[j] {
                    continue;
                }
                for k in j + 1..n {
                    if nums[j] == nums[k] {
                        ans = ans.min(k - i);
                        break;
                    }
                }
            }
        }

        if ans == n + 1 {
            -1
        } else {
            (ans * 2) as i32
        }
    }
}

复杂度分析

  • 时间复杂度:$O(n^3)$,其中 $n$ 是 $\textit{nums}$ 的长度,求解用到三重循环,每重循环需要 $O(n)$,故总时间复杂度是 $O(n^3)$。

  • 空间复杂度:$O(1)$,只声明了常数个变量。

3740. 三个相等元素之间的最小距离 I

解法一

思路和算法

当三个不同下标 $i$、$j$ 和 $k$ 组成有效三元组时,三个下标的任意排列对应的有效三元组的距离都是相等的,因此可以规定 $i < j < k$,则有效三元组的距离是 $|i - j| + |j - k| + |k - i| = (j - i) + (k - j) + (k - i) = 2(k - i)$。

数组 $\textit{nums}$ 的长度是 $n$。遍历 $0 \le i < j < k < n$ 的所有三元组 $(i, j, k)$,当 $\textit{nums}[i] = \textit{nums}[j] = \textit{nums}[k]$ 时,三元组 $(i, j, k)$ 是有效三元组,其距离是 $2(k - i)$,使用该距离更新有效三元组的最小距离。遍历结束之后即可得到数组 $\textit{nums}$ 的有效三元组的最小距离。

如果数组 $\textit{nums}$ 中不存在三个不同下标的元素相等,则数组 $\textit{nums}$ 中不存在有效三元组,答案是 $-1$。

代码

###Java

class Solution {
    public int minimumDistance(int[] nums) {
        int minDistance = Integer.MAX_VALUE;
        int n = nums.length;
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                if (nums[j] == nums[i]) {
                    for (int k = j + 1; k < n; k++) {
                        if (nums[k] == nums[j]) {
                            int distance = (k - i) * 2;
                            minDistance = Math.min(minDistance, distance);
                        }
                    }
                }
            }
        }
        return minDistance != Integer.MAX_VALUE ? minDistance : -1;
    }
}

###C#

public class Solution {
    public int MinimumDistance(int[] nums) {
        int minDistance = int.MaxValue;
        int n = nums.Length;
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                if (nums[j] == nums[i]) {
                    for (int k = j + 1; k < n; k++) {
                        if (nums[k] == nums[j]) {
                            int distance = (k - i) * 2;
                            minDistance = Math.Min(minDistance, distance);
                        }
                    }
                }
            }
        }
        return minDistance != int.MaxValue ? minDistance : -1;
    }
}

复杂度分析

  • 时间复杂度:$O(n^3)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历的三元组个数是 $O(n^3)$。

  • 空间复杂度:$O(1)$。

解法二

思路和算法

见题解「3741. 三个相等元素之间的最小距离 II」。

代码

###Java

class Solution {
    public int minimumDistance(int[] nums) {
        int minDistance = Integer.MAX_VALUE;
        Map<Integer, List<Integer>> numToIndices = new HashMap<Integer, List<Integer>>();
        int n = nums.length;
        for (int i = 0; i < n; i++) {
            numToIndices.putIfAbsent(nums[i], new ArrayList<Integer>());
            numToIndices.get(nums[i]).add(i);
        }
        Set<Map.Entry<Integer, List<Integer>>> entries = numToIndices.entrySet();
        for (Map.Entry<Integer, List<Integer>> entry : entries) {
            List<Integer> indices = entry.getValue();
            int size = indices.size();
            for (int i = 2; i < size; i++) {
                int distance = (indices.get(i) - indices.get(i - 2)) * 2;
                minDistance = Math.min(minDistance, distance);
            }
        }
        return minDistance != Integer.MAX_VALUE ? minDistance : -1;
    }
}

###C#

public class Solution {
    public int MinimumDistance(int[] nums) {
        int minDistance = int.MaxValue;
        IDictionary<int, IList<int>> numToIndices = new Dictionary<int, IList<int>>();
        int n = nums.Length;
        for (int i = 0; i < n; i++) {
            numToIndices.TryAdd(nums[i], new List<int>());
            numToIndices[nums[i]].Add(i);
        }
        foreach (KeyValuePair<int, IList<int>> pair in numToIndices) {
            IList<int> indices = pair.Value;
            int size = indices.Count;
            for (int i = 2; i < size; i++) {
                int distance = (indices[i] - indices[i - 2]) * 2;
                minDistance = Math.Min(minDistance, distance);
            }
        }
        return minDistance != int.MaxValue ? minDistance : -1;
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历数组将每个元素的下标列表存入哈希表,然后需要遍历哈希表计算有效三元组的最小距离,每次遍历的时间都是 $O(n)$。

  • 空间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。哈希表的空间是 $O(n)$。

从“连接失败”到丝滑登录:我用 ethers.js v6 搞定 MetaMask 钱包连接的全过程

背景

上个月,我接手了一个 NFT 艺术平台的 MVP 开发。核心功能很简单:用户连接钱包,查看自己的 NFT,并进行铸造。产品经理说:“登录就用最经典的 MetaMask 连接,简单点。” 我想,这还不简单?用 ethers.js 几行代码的事。结果,从“简单连接”到“稳定可用的登录流程”,我花了整整一天半的时间,踩了好几个意想不到的坑。这篇文章,就是把我解决问题的过程原原本本地记录下来。

问题分析

我的第一版代码非常“教科书”:

import { ethers } from 'ethers';

const connectWallet = async () => {
  if (window.ethereum) {
    const provider = new ethers.BrowserProvider(window.ethereum);
    const signer = await provider.getSigner();
    const address = await signer.getAddress();
    console.log('Connected:', address);
    return address;
  } else {
    alert('请安装 MetaMask!');
  }
};

看起来没问题,对吧?但在实际测试中,问题接踵而至:

  1. 第一次点击连接,弹窗一闪而过,但状态没更新。 第二次点击才能成功。
  2. 用户如果拒绝了连接请求,页面没有任何反馈,就像什么都没发生。
  3. 用户在 MetaMask 里切换了账户或网络,我的前端页面完全感知不到,显示的还是旧信息。
  4. 代码里到处是 window.ethereum 的类型断言 as any,TypeScript 疯狂报红。

我意识到,我实现的只是一个“一次性连接动作”,而不是一个“可持续管理的钱包连接状态”。真正的生产环境需要的是一个健壮的、能应对各种用户操作和钱包状态变化的登录系统。

核心实现

第一步:安全地获取 Provider 和 处理类型

首先,要解决 window.ethereum 的类型问题。直接使用 any 会丢失类型安全和 IDE 提示。ethers.js v6 推荐从 window.ethereum 创建 BrowserProvider

这里有个坑: window.ethereum 可能不存在(用户没装钱包),也可能是数组(多个钱包注入)。我们需要安全地处理。

// utils/ethers.ts
import { BrowserProvider, Eip1193Provider } from 'ethers';

// 声明全局的 ethereum 类型
declare global {
  interface Window {
    ethereum?: Eip1193Provider;
  }
}

/**
 * 获取安全的 Ethers BrowserProvider
 * @returns {BrowserProvider | null} 返回 Provider 或 null
 */
export const getEthersProvider = (): BrowserProvider | null => {
  // 检查 window.ethereum 是否存在
  if (typeof window !== 'undefined' && window.ethereum) {
    try {
      // ethers v6 使用 BrowserProvider 包装 window.ethereum
      return new BrowserProvider(window.ethereum);
    } catch (error) {
      console.error('创建 Provider 失败:', error);
      return null;
    }
  }
  console.warn('未检测到钱包扩展(如 MetaMask)。');
  return null;
};

第二步:实现核心连接函数,处理用户拒绝

连接钱包的核心是请求账户访问权限。provider.send('eth_requestAccounts', []) 这个方法会触发 MetaMask 弹窗。这里有个关键细节: 必须妥善处理用户点击“拒绝”的情况。

// hooks/useWallet.ts
import { useState, useCallback } from 'react';
import { getEthersProvider } from '../utils/ethers';

export const useWallet = () => {
  const [account, setAccount] = useState<string | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError(null); // 清除旧错误

    const provider = getEthersProvider();
    if (!provider) {
      setError('请安装 MetaMask 钱包扩展。');
      setIsConnecting(false);
      return;
    }

    try {
      // 关键步骤:请求账户访问,这会弹出 MetaMask 授权窗口
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      
      if (currentAccount) {
        setAccount(currentAccount);
        console.log('钱包连接成功:', currentAccount);
      } else {
        setError('未获取到有效账户。');
      }
    } catch (err: any) {
      // **重点:处理用户拒绝等错误**
      console.error('连接钱包失败:', err);
      if (err.code === 4001) {
        // 4001 是用户拒绝连接的错误码
        setError('您拒绝了钱包连接请求。');
      } else {
        setError(`连接失败: ${err.message || '未知错误'}`);
      }
    } finally {
      setIsConnecting(false);
    }
  }, []);

  return { account, isConnecting, error, connectWallet };
};

第三步:监听账户和网络变化

用户不会一直待在同一个账户或网络上。他们可能在 MetaMask 里切换账户,或者从以太坊主网切换到 Polygon。我们的前端必须能实时响应这些变化。

注意这个细节: 监听事件要在连接成功后设置,并且在组件卸载时清理,防止内存泄漏。

// 在 useWallet 的 connectWallet 函数成功连接后,添加监听逻辑
const setupEventListeners = useCallback((provider: BrowserProvider) => {
  // 注意:ethers v6 的 provider 底层是 EIP-1193 的 provider
  const ethereum = window.ethereum;
  if (!ethereum) return;

  // 监听账户变化
  const handleAccountsChanged = (accounts: string[]) => {
    console.log('账户变化:', accounts);
    if (accounts.length === 0) {
      // 用户锁定了钱包或切换了所有账户
      setAccount(null);
      setError('钱包已断开连接。');
    } else if (accounts[0] !== account) {
      // 切换到新账户
      setAccount(accounts[0]);
    }
  };

  // 监听链ID变化(网络切换)
  const handleChainChanged = (_chainId: string) => {
    // 根据规范,当链发生变化时,应重置页面状态或重新加载
    // 一个常见的做法是提示用户或自动刷新
    console.log('网络已切换,链ID:', _chainId);
    // 简单处理:直接重置账户,需要用户重新连接(或设计更优雅的流程)
    setAccount(null);
    window.location.reload(); // 许多 DApp 选择刷新页面
  };

  // 添加监听
  ethereum.on('accountsChanged', handleAccountsChanged);
  ethereum.on('chainChanged', handleChainChanged);

  // 返回清理函数
  return () => {
    ethereum.removeListener('accountsChanged', handleAccountsChanged);
    ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, [account]);

// 然后在 connectWallet 成功连接后调用
// const cleanup = setupEventListeners(provider);
// 注意:需要在 React useEffect 或组件卸载逻辑中执行 cleanup()

在实际的 React Hook 实现中,我们需要使用 useEffect 来管理这些副作用的生命周期。

第四步:整合成可复用的 React Hook

将以上所有逻辑整合到一个完整的、易于使用的自定义 Hook 中。

// hooks/useWallet.ts (完整版)
import { useState, useCallback, useEffect, useRef } from 'react';
import { BrowserProvider } from 'ethers';
import { getEthersProvider } from '../utils/ethers';

export const useWallet = () => {
  const [account, setAccount] = useState<string | null>(null);
  const [chainId, setChainId] = useState<bigint | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  // 使用 ref 存储清理函数,避免重复绑定事件
  const cleanupRef = useRef<(() => void) | null>(null);

  // 1. 初始化:检查是否已授权连接
  useEffect(() => {
    const checkIfWalletIsConnected = async () => {
      const provider = getEthersProvider();
      if (!provider) return;

      try {
        const accounts = await provider.send('eth_accounts', []); // 静默获取,不弹窗
        if (accounts.length > 0) {
          setAccount(accounts[0]);
          const network = await provider.getNetwork();
          setChainId(network.chainId);
          // 为已连接的账户设置监听
          setupEventListeners(provider);
        }
      } catch (err) {
        console.warn('检查已连接账户时出错:', err);
      }
    };

    checkIfWalletIsConnected();
  }, []);

  // 2. 设置事件监听器的函数
  const setupEventListeners = useCallback((provider: BrowserProvider) => {
    const ethereum = window.ethereum;
    if (!ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('accountsChanged:', accounts);
      if (accounts.length === 0) {
        setAccount(null);
        setError('钱包已断开。');
      } else {
        setAccount(accounts[0]);
        setError(null);
      }
    };

    const handleChainChanged = (_chainId: string) => {
      console.log('chainChanged:', _chainId);
      // 网络切换后,建议刷新页面或重新获取所有数据
      window.location.reload();
    };

    ethereum.on('accountsChanged', handleAccountsChanged);
    ethereum.on('chainChanged', handleChainChanged);

    // 存储清理函数
    cleanupRef.current = () => {
      ethereum.removeListener('accountsChanged', handleAccountsChanged);
      ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, []);

  // 3. 核心连接函数
  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError(null);

    const provider = getEthersProvider();
    if (!provider) {
      setError('请安装 MetaMask。');
      setIsConnecting(false);
      return;
    }

    // 先清理旧监听(如果存在)
    if (cleanupRef.current) {
      cleanupRef.current();
      cleanupRef.current = null;
    }

    try {
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      
      if (currentAccount) {
        setAccount(currentAccount);
        const network = await provider.getNetwork();
        setChainId(network.chainId);
        // 设置新监听
        setupEventListeners(provider);
      }
    } catch (err: any) {
      console.error('连接失败:', err);
      if (err.code === 4001) {
        setError('连接请求被拒绝。');
      } else {
        setError(err.message || '未知连接错误');
      }
    } finally {
      setIsConnecting(false);
    }
  }, [setupEventListeners]);

  // 4. 断开连接(对于 MetaMask,更多是前端状态清除)
  const disconnectWallet = useCallback(() => {
    setAccount(null);
    setChainId(null);
    setError(null);
    if (cleanupRef.current) {
      cleanupRef.current();
      cleanupRef.current = null;
    }
    console.log('钱包已断开(前端状态)');
    // 注意:MetaMask 无法通过代码真正“断开”,只能前端清除状态。
    // 用户需要自己在 MetaMask 中切换账户或锁定钱包。
  }, []);

  // 5. 组件卸载时清理监听
  useEffect(() => {
    return () => {
      if (cleanupRef.current) {
        cleanupRef.current();
      }
    };
  }, []);

  return {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
    isConnected: !!account, // 便捷的布尔状态
  };
};

完整代码

这是一个可以直接在 React 项目中使用的完整示例组件。

// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';

const WalletConnector: React.FC = () => {
  const {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
    isConnected,
  } = useWallet();

  // 格式化地址:0x1234...5678
  const formatAddress = (addr: string | null) => {
    if (!addr) return '';
    return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`;
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>Web3 钱包连接示例</h2>
      
      {error && (
        <div style={{ color: 'red', marginBottom: '10px', padding: '10px', background: '#ffe6e6' }}>
          <strong>错误:</strong> {error}
        </div>
      )}

      <div style={{ marginBottom: '15px' }}>
        <strong>连接状态:</strong> 
        {isConnected ? (
          <span style={{ color: 'green' }}>已连接</span>
        ) : (
          <span style={{ color: 'orange' }}>未连接</span>
        )}
      </div>

      {isConnected && account ? (
        <div>
          <div style={{ marginBottom: '10px' }}>
            <strong>账户地址:</strong> 
            <code>{formatAddress(account)}</code> ({account})
          </div>
          <div style={{ marginBottom: '15px' }}>
            <strong>当前链ID:</strong> 
            <code>{chainId?.toString() || '未知'}</code>
          </div>
          <button
            onClick={disconnectWallet}
            style={{
              padding: '10px 20px',
              background: '#ff6b6b',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            断开连接
          </button>
          <p style={{ fontSize: '0.9em', color: '#666', marginTop: '10px' }}>
            (提示:此操作仅清除前端状态。如需完全断开,请在 MetaMask 中锁定钱包。)
          </p>
        </div>
      ) : (
        <button
          onClick={connectWallet}
          disabled={isConnecting}
          style={{
            padding: '12px 24px',
            background: isConnecting ? '#ccc' : '#4CAF50',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: isConnecting ? 'not-allowed' : 'pointer',
            fontSize: '16px',
          }}
        >
          {isConnecting ? '连接中...' : '连接 MetaMask 钱包'}
        </button>
      )}

      {!window.ethereum && (
        <div style={{ marginTop: '20px', padding: '15px', background: '#fff3cd', borderRadius: '4px' }}>
          <p>⚠️ 未检测到 Web3 钱包。</p>
          <p>
            请安装 <a href="https://metamask.io/" target="_blank" rel="noopener noreferrer">MetaMask</a> 或其他兼容的以太坊钱包扩展。
          </p>
        </div>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. ethers.providers.Web3Provider 在 v6 中已废弃

    • 报错: ethers.providers.Web3Provider is not a constructor
    • 原因: 我一开始照着 v5 的文档写,但项目安装的是 v6。
    • 解决: 在 ethers.js v6 中,应使用 new ethers.BrowserProvider(window.ethereum)
  2. 用户拒绝连接后,再次连接无反应

    • 现象: 用户第一次点击“拒绝”后,再次点击连接按钮,MetaMask 不再弹窗。
    • 原因: MetaMask 会“记住”用户的拒绝操作。eth_requestAccounts 在用户拒绝后,短时间内再次调用不会触发弹窗。
    • 解决: 在 UI 上明确提示用户“您已拒绝,如需连接请刷新页面或手动在 MetaMask 中授权”,或者引导用户点击 MetaMask 扩展图标重新授权。这是一个产品层面的设计选择。
  3. 事件监听器重复绑定导致内存泄漏和多次触发

    • 现象: 切换账户时,控制台打印了多次 accountsChanged 日志。
    • 原因: 每次调用 connectWallet 或组件重新渲染时,没有清理旧的事件监听器,导致同一个函数被绑定了多次。
    • 解决: 使用 useRef 存储清理函数,在绑定新监听前执行旧的清理函数,并在组件卸载时确保清理。
  4. TypeScript 类型 window.ethereum 报错

    • 报错: Property 'ethereum' does not exist on type 'Window & typeof globalThis'.
    • 解决: 在全局声明文件中(或当前文件顶部)使用 declare global 扩展 Window 接口,并赋予其 Eip1193Provider 类型(这是 ethers v6 推荐的类型)。这提供了完美的类型安全和代码提示。

小结

通过这次实战,我深刻体会到,一个生产级的 Web3 钱包连接,远不止调用一个 API 那么简单。它需要健壮的错误处理、实时的状态监听、清晰的用户反馈和安全的类型定义。现在,我把这个打磨好的 useWallet Hook 放进了我的项目工具箱里,下次遇到类似需求,就能从容应对了。当然,这只是一个起点,后续还可以在此基础上集成更多功能,比如自动切换至指定测试网、获取用户签名消息、与后端进行登录验证等。

前端工程师也要懂的服务器部署知识:从 Nginx 到 CI/CD

引言

很多前端同学可能会觉得:“部署服务器是后端和运维的事,我只管写页面就好了。” 但现实是,随着前后端分离、微前端、Serverless 等架构的普及,前端项目越来越复杂,部署和运维的门槛也越来越高。前端工程师需要掌握基本的服务器知识,才能高效地解决线上问题、优化性能、实现自动化部署,甚至参与 DevOps 流程。

本文将从前端视角出发,带你系统学习服务器部署的必备知识:从 Nginx 配置、静态资源优化,到 CI/CD 自动化流程,再到底层协议(LSP/DAP)的理解。掌握这些,你不仅能写出优雅的代码,还能自信地把项目部署上线。


一、服务器基础:告别黑盒

1.1 服务器类型

  • 物理机:直接运行在硬件上的服务器,性能强但维护成本高。
  • 虚拟机:通过虚拟化技术(如 VMware、KVM)划分出的独立操作系统,资源隔离性好。
  • 容器:基于 Docker 等技术的轻量级虚拟化,共享宿主机内核,启动快、资源利用率高。

前端部署最常接触的是 Linux 虚拟机容器(如 Docker 镜像)。

1.2 常用 Linux 命令

# 文件操作
ls -l            # 查看文件列表
cd /var/www      # 进入目录
cp -r dist/ /usr/share/nginx/html/   # 复制构建产物

# 权限管理
chmod 755 script.sh   # 修改权限
chown -R www:www ./   # 修改所有者

# 进程查看
ps aux | grep nginx   # 查看 nginx 进程
netstat -tlnp         # 查看端口占用

1.3 网络基础

  • IP 地址:服务器的唯一标识。
  • 端口:服务监听的入口(80 为 HTTP,443 为 HTTPS)。
  • DNS:将域名解析到 IP。
  • HTTP/HTTPS:应用层协议,HTTPS 需要 SSL 证书。

二、Nginx 配置详解:前端的得力助手

Nginx 是一款高性能的 Web 服务器和反向代理服务器,几乎是前端部署的标配。

2.1 正向代理与反向代理

代理类型 作用 示例
正向代理 代理客户端,隐藏客户端真实 IP 科学上网、公司内网访问外网
反向代理 代理服务端,隐藏服务端真实 IP 负载均衡、统一入口

反向代理配置示例

server {
    listen 80;
    server_name example.com;

    location /api/ {
        proxy_pass http://backend_server:8080/;  # 将 /api 转发到后端
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        root /usr/share/nginx/html;   # 静态文件目录
        try_files $uri $uri/ /index.html;  # 单页应用路由支持
    }
}

2.2 负载均衡

当后端服务有多个实例时,Nginx 可以将请求分发到不同服务器,提升可用性。

upstream backend {
    server 192.168.1.10:8080 weight=3;  # weight 越大,权重越高
    server 192.168.1.11:8080;
    server 192.168.1.12:8080 backup;    # 备份服务器
}

server {
    location /api/ {
        proxy_pass http://backend;
    }
}

常见负载均衡策略:

  • 轮询(默认):依次分发。
  • 最少连接:优先分配给连接数最少的服务器。
  • IP 哈希:根据客户端 IP 分配固定服务器,解决 session 问题。

2.3 静态资源优化

Nginx 可以配置缓存、压缩等,提升静态资源加载速度。

location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
    expires 365d;                 # 强缓存一年
    add_header Cache-Control "public, immutable";
    gzip on;                      # 启用 gzip
    gzip_types text/plain text/css application/javascript image/svg+xml;
}

2.4 HTTPS 配置(SSL 证书)

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /etc/nginx/ssl/example.com.crt;
    ssl_certificate_key /etc/nginx/ssl/example.com.key;

    # 其他配置...
}

2.5 安全与限流

  • 限制连接数limit_conn_zone 防止恶意攻击。
  • IP 黑名单deny 192.168.1.100;

三、静态资源优化方案:让页面飞起来

前端打包后的静态资源(HTML、CSS、JS、图片)需要经过精心优化,才能达到最佳加载性能。

3.1 缓存策略

  • 强缓存:通过 Cache-Control: max-age=31536000 设置超长缓存,浏览器直接从本地读取,不请求服务器。
  • 协商缓存:通过 ETagLast-Modified 让服务器验证资源是否修改,未修改则返回 304。

最佳实践:对带 hash 的文件设置强缓存,对 index.html 设置协商缓存或不缓存

3.2 内容摘要(Hash)实现精确缓存控制

使用 Webpack、Vite 等打包工具时,为文件名添加内容 hash(如 app.8f3d7c.js)。文件内容变化时,hash 变化,浏览器自然请求新文件。

// webpack 配置示例
output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist')
}

3.3 CDN 加速

内容分发网络(CDN)将静态资源部署到全球边缘节点,用户就近获取资源,极大减少延迟。

接入 CDN 步骤

  1. 将静态资源上传到 CDN 服务商(如阿里云 OSS + CDN、七牛云)。
  2. 配置 CNAME 指向 CDN 域名。
  3. 设置回源策略:当 CDN 节点未命中缓存时,从源站拉取资源。

3.4 非覆盖式发布

传统的覆盖式发布(直接替换文件)可能导致用户访问到新旧混合的资源(如 HTML 引用了旧 CSS)。解决方案:非覆盖式发布,即每次发布生成新 hash 的文件,并更新 HTML 中的引用。这样新旧版本同时存在,用户始终访问完整资源,实现平滑升级。

3.5 压缩

  • GzipBrotli 压缩文本资源,通常可减少 70% 体积。
  • Nginx 启用 Brotli 需安装模块,性能比 gzip 更好。

四、CI/CD 工作流:自动化部署解放双手

4.1 什么是 CI/CD?

  • 持续集成(CI):代码合并到主干后自动构建、测试,保证质量。
  • 持续部署(CD):通过 CI 的代码自动部署到服务器。

4.2 常用工具

  • GitLab CI:与 GitLab 深度集成,通过 .gitlab-ci.yml 配置。
  • GitHub Actions:GitHub 自家的 CI/CD 服务。
  • Jenkins:老牌开源工具,插件丰富,适合复杂场景。

4.3 一个典型的前端 CI/CD 流程

以 GitHub Actions 为例,每次 push 到 main 分支自动构建并部署到服务器。

name: Deploy to Server

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8
      - name: Install dependencies
        run: pnpm install
      - name: Build
        run: pnpm run build
      - name: Deploy to Server
        uses: easingthemes/ssh-deploy@v4
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
          remote-host: ${{ secrets.HOST }}
          remote-user: ${{ secrets.USER }}
          remote-path: /var/www/html
          source: dist/

4.4 高级部署策略

  • 蓝绿部署:维护两套环境(蓝和绿),切换流量实现零停机。
  • 灰度发布:让部分用户先体验新版本,逐步扩大范围。

五、底层技术:LSP 与 DAP

虽然 LSP(Language Server Protocol)和 DAP(Debug Adapter Protocol)更多是编辑器相关的协议,但了解它们有助于前端工程师深入理解开发工具和远程开发场景。

5.1 LSP(语言服务器协议)

LSP 由微软提出,旨在统一编辑器与语言服务器的通信。简单来说,它为编辑器提供了“智能提示、跳转定义、错误检查”等功能,且每种语言只需实现一次语言服务器,即可被所有支持 LSP 的编辑器(VSCode、Vim、Sublime 等)使用。

前端应用:当你使用 VSCode 开发 TypeScript 时,背后就是 TypeScript 语言服务器通过 LSP 与编辑器通信。

5.2 DAP(调试适配器协议)

类似 LSP,DAP 将调试器与编辑器解耦。编辑器通过 DAP 与调试适配器通信,实现断点、单步、变量查看等功能。

远程开发:结合容器或远程服务器,你可以使用 DAP 调试运行在远程环境中的代码(如 Docker 容器内的 Node.js 应用)。


六、监控与日志:让问题无处遁形

6.1 前端监控

  • 错误监控:Sentry、Fundebug 捕获前端 JS 报错,自动上报。
  • 性能监控:Web Vitals、Lighthouse 指标采集,优化用户体验。

6.2 服务器日志

  • 访问日志:Nginx 的 access.log 记录所有请求。
  • 错误日志error.log 记录服务器错误。
  • 日志分析:ELK(Elasticsearch + Logstash + Kibana)堆栈可集中分析日志。
tail -f /var/log/nginx/access.log   # 实时查看最新请求

七、总结与建议

前端工程师掌握服务器部署知识,不仅能让项目更快、更稳地运行,还能提升自己的全栈能力,在团队中承担更多责任。本文从 Nginx 配置、静态资源优化、CI/CD、底层协议到监控日志,为你构建了一个完整的知识体系。

实战建议

  1. 在自己的云服务器上搭建一个 Nginx,部署一个 React/Vue 项目。
  2. 尝试配置缓存、压缩、反向代理。
  3. 使用 GitHub Actions 实现自动化部署。
  4. 接入 Sentry 监控线上错误。

记住:部署不是终点,而是应用生命周期的开始。希望你能从这篇文章出发,开启你的 DevOps 之旅!


参考链接

如果你觉得文章有帮助,欢迎点赞、收藏、评论,分享给更多前端小伙伴!

超级好用的三原后台管理v1.0.0发布🎉(Vue3 + Ant Design Vue + Java Spring Boot )附源码

好久没更新文章了,确实有AI后好像大家都不太关注技术了。好像大家的关注点都是那个AI 模型又又又增强。

但是我建议看到这篇文章的同学,还是要持续学习,你面试的时候不能告诉面试官我会用某个AI?如果AI模型花钱包月就能使用那么你的优势又是哪里。 流水不争先,争的是滔滔不绝 持续学习吧,至少现在出去面试还是问你的技术决定你的待遇呢,等真到某天我去面试,面试官查看我使用消耗的TOKEN,那我觉得就可以不用学了哈哈哈~

博主之前更新了一篇java的学习,是的java有所小成了,java spring boot基本上都可以写了,因为有一定的编程思想,也就只有语法不熟练了。

最近看了好多后台管理系统,有免费的RuoYI、纯前端的Naive UI Pro,收费的丰富了大家可以自行查找。

客观评价一下优秀开源作品RuoYi(仅仅个人观点):

  • 优点:功能成熟,可靠、稳定。
  • 缺点:vue2(现在大多都vue3了) 界面不是那么美观,后台代码应该是写的非常不错(我毕竟刚入门不好评价),但是不适合学习,如果你是专业后端我觉得上手难度还可以,如果偏前端的我觉得还是我的这个框架更合适。

Sanyuan VS RuoYi(客观评价)

  • 不足:后端因为我java刚入门,有编程思想,毕竟没那么多的经验,肯定好多地方没那么完善,但是博主再这里说明下,后续会继续学习多多学习。前端:我经验很足,一些细节也都考虑到了,也借鉴了一些别的系统,技术选型啥的肯定没问题。

  • 优点:因为我的java刚入门,没有引入什么特殊的插件,所以想学习或者写项目的同学更好使用。前端:支持Vue3、国际化、界面相对美观些吧(这个每人审美都不一样)

访问地址

访问地址: sanyuan.website/ 用户名:admin 密码:123456

仓库地址: gitee.com/hejingyuan0…

  • 角色管理
  • 日志管理
  • 菜单管理
  • 用户管理
  • 字典管理
  • 暗色主题适配切换
  • 现支持2种布局方式,未来会支持更多常用的布局已经在规划中~
  • 支持适配主题色、色弱模式、灰色模式
  • 支持4种路由过渡动画
  • 支持国际化,内涵轻松适配各种语言的方案
  • 支持权限控制
  • 支持路由缓存
  • 支持菜单外链、内嵌自定义
  • 支持两种标签栏定制

功能演示

  • 主题切换 image.pngimage.png

水平布局、垂直布局

image.png

国际化

image.png

还有一些别的自定义,大家自己去系统中使用吧

国际化

这里使用的是vite-auto-i18n-plugin 传送门 可以直接抽代码,配置好了自动翻译

image.png

使用方式如下图,别忘记再main.js引入生成的lang资源哈

image.png

动态菜单

其实做动态菜单有两种:

一种是再router.js中定义好code,然后根据后端的返回的code,在router.beforeEach中去拦截,遇到每权限的就next(/403)页面。这种实现简单,挺好用的,我们有些项目也在使用这种。

第二种是使用router.addRoute动态的去插入到路由里面,比如RuoYI等后台管理系统都是用的这种方式,再页面上选图标、输入路由路径、对应的前端组件路径。 然后把信息返回给前端,前端去router.addRoute。优点是可以在界面上自动配置,配置完后也可以给权限分配去使用了。如下图:

image.png

再用vue3 router.addRoute的时候也遇到过几个问题,第一点就是怎么匹配前端的组件资源:

const getRouterLoadView = (menu) => {
   //VUE3 要使用import.meta.glob 获取使用 不能使用() => import(`@/views/**/*.vue`)
  const modules = import.meta.glob('@/views/**/*.vue')
  const filePath = `/src/views${menu.component}`
  const componentLoader = modules[filePath]
  return componentLoader
}

还遇到一个问题就是因为我router在没有获取到权限数据的时候是就一些默认的(403、404、500、登录)。再访问https://sanyuan.website/home路径的时候,我在用router.beforeEach(async (to, _, next) => {}) 这个时候的router.to的信息已经是404的路由信息。 那么我怎么知道是Home 路由呢,通过URL的路径定位如下:

function getRoutePath(url = window.location.href) {
  const urlObj = new URL(url)
  return urlObj.pathname || '/'
}

完整的router如下:

router.beforeEach(async (to, _, next) => {
  NProgress.start()
  const { closeGlobalLoading } = useGlobalLoading()
  if (to.path === '/login') {
    next()
    closeGlobalLoading()
    return
  }

  // 没有token 去登录页面
  if (!localStorage.getItem('token')) {
    next('/login')
    return
  }

  const userStore = useUserStore()
  if (!userStore.userInfo.id) {
    // 判断是不是首次登录,首次登录获取用户信息
    try {
      // 这里获取用户信息、用户权限、用户动态菜单
      const { initPreposition } = usePreposition()
      await initPreposition()
      // ps: 因为to的信息这个时候已经变成404的路由信息了,用URL上的路由做为跳转
      closeGlobalLoading()
      next(getRoutePath(), { replace: true })
      return
    } catch (error) {
      console.log(error, '获取用户信息失败')
      localStorage.removeItem('token')
      next('/login')
      return
    }
  } else {
    next()
  }
})

路由缓存

使用keep-alive做缓存include动态做缓存,如果我不是动态的就比较好实现。例如我想要在User、Menu做缓存。我只需要在对应的页面文件定义 defineOptions({name: 'UserPage'})、defineOptions({name: 'MenuPage'}) 然后如下这么使用就生效:

<router-view v-slot="{ Component }">
      <transition :name="appStore.appConfig.transitionName" mode="out-in">
        <keep-alive :include="['UserPage', 'MenuPage']">
          <component :is="Component" :key="currentComponentKey" />
        </keep-alive>
      </transition>
    </router-view>

但是做成动态的就又有点不一样了如下图让用户自己在配置页面可选:

image.png

并且页面还有个刷新按钮

image.png

原本做刷新很简单只需要给component绑定一个动态的KEY,再点击的时候刷新这个动态key就行了。但是需要做keep-alive那这个key 就不能是纯动态了,比如缓存了2个页面,你刷新之后把key变了,做的缓存也失效了。

完整的实现如下: 1、路由中在meta保留缓存标识,因为我的是页面配置的,是在服务端直接处理好返回的noCache如下

{
    "name": "home",
    "path": "/home",
    "hidden": false,
    "component": "/home/index.vue",
    "alwaysShow": null,
    "meta": {
        "title": "首页",
        "titleEn": "Home",
        "frameType": null,
        "icon": "AppstoreOutlined",
        "noCache": true,
        "isFrame": false
    }
}

2、使用router.beforeResolveto里面收集到需要缓存的PageName如下:

import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { defineStore } from 'pinia'
import { uniq } from 'lodash'

function getRouteComponentName(route) {
  const currentRoute = route.matched[route.matched.length - 1]
  const currentRouteComponent = currentRoute?.components?.default
  return currentRouteComponent?.name
}

export const useRouteCacheStore = defineStore('route-cache', () => {
  const router = useRouter()
  const cachedViews = ref([])

  const setRouteCachedViews = (route) => {
    if (!route.meta.noCache) {
      // 需要缓存
      const componentName = getRouteComponentName(route)
      cachedViews.value = uniq([...cachedViews.value, componentName])
    }
  }

  // ps: 【缺陷】 如果首次刷新刚好在缓存的页面, 不会被收集到
  router.beforeResolve((to) => {
    setRouteCachedViews(to)
  })

  return { cachedViews }
})

3、解决刷新影响到keep-alive的问题,做缓存如下:


<template>
  <div class="app-content">
    <router-view v-slot="{ Component }">
      <transition :name="appStore.appConfig.transitionName" mode="out-in">
      // cachedViews 就是缓存收集到的需要 keep-alive的page
        <keep-alive :include="routeCacheStore.cachedViews">
          <component :is="Component" :key="currentComponentKey" />
        </keep-alive>
      </transition>
    </router-view>

    <slot name="footer"></slot>
  </div>
</template>

<script setup>
import { RouterView, useRouter } from 'vue-router'
import { computed, defineOptions, watch, ref } from 'vue'

import { useLayoutStore } from '@/stores/layout'
import { useAppStore } from '@/stores/app.js'
import { useRefreshStore } from '@/stores/refresh'
import { useRouteCacheStore } from '@/stores/route-cache.js'

import { useThemeToken } from '../hooks/use-theme-token.js'

defineOptions({name: 'AppContent',})

const router = useRouter()
const routeCacheStore = useRouteCacheStore()
const { colorBgLayout } = useThemeToken()
const layoutStore = useLayoutStore()
const appStore = useAppStore()
const refreshStore = useRefreshStore()


// 缓存每次的key, 在刷新的时候只更新当前的key
const cachedMap = ref({})

const currentComponentKey = computed(() => {
  const name = router.currentRoute.value.name
  if (cachedMap.value[name]) {
    return cachedMap.value[name]
  }
  cachedMap.value[name] = name
  return name
})

watch(
  () => refreshStore.refreshKey,
  () => {
    const name = router.currentRoute.value.name
    cachedMap.value[name] = `${name}_${refreshStore.refreshKey}`
  },
)
</script>

外链内嵌

可以看到我菜单配置页面有一个外链内嵌也是动态可配置的,这个要怎么实现呢?

1、实现一个iframe内嵌的组件:

<template>
  <a-spin tip="加载中..." :spinning="loading">
    <ProLayout>
      <ProLayoutMain>
        <iframe class="size-full border-none overflow-hidden" :src="url" @load="loading = false" />
      </ProLayoutMain>
    </ProLayout>
  </a-spin>
</template>

<script setup>
import { ProLayout, ProLayoutMain } from '@/components/pro-layout/index'
import { ref } from 'vue'

defineProps({
  url: {
    type: String,
    required: true,
  },
})

const loading = ref(true)
</script>

2、在处理菜单路由的时候把components改成这个使用vue render包裹的,然后把url传给ifrmae组件

import { ref, h } from 'vue'
const renderIframe = (url) => {
  return h(Iframe, { url })
}

function generateDynamicRoutes(menus) {
  if (!menus || !menus.length) return []

  return menus.map((menu) => {
    // 【iframe 外链】 内嵌模式的数据 isFrame = true, frameType = 2
    /**
     * 【iframe 外链】
     * 处理path 默认生成使用/iframe/xxxx 模式
     */
    const isFrameAdnInline = menu?.meta?.frameType === '2' && menu?.meta?.isFrame
    const route = {
      ...menu,
      path: isFrameAdnInline ? `/iframe/${menu.meta.titleEn}` : menu.path,
      name: menu.name || menu.path,
      meta: {
        ...menu.meta,
        title: isEN ? menu.meta?.titleEn : menu.meta?.title,
        icon: menu.meta?.icon ? h(ProIcon, { name: menu.meta.icon }) : '',
        isFrame: isFrameAdnInline ? false : menu.meta.isFrame,
      },
      component: isFrameAdnInline ? renderIframe(menu.path) : getRouterLoadView(menu),
    }
    if (menu.children && menu.children.length > 0) {
      route.children = generateDynamicRoutes(menu.children)
    }
    return route
  })
}

总结

做这个后台管理还是有不少知识点的,像路由过渡动画、缓存、动态菜单路由等等,大家具体可以查询源码。

问答

Q: 现在是vue3 + js写的,为啥不用ts?
A: 因为现在好多单位还是使用的js,相比较来说还是js更方便,编码更快, 只想做简单好用的框架,不增加负担

Q: 现在用的Ant Design Vue UI,后续会不会出Element Plus、Naiveui?
A: 这个要看系统使用的情况,如果比较受大家欢迎,后续会考虑出别的UI的后台管理系统

前端必看!JS高频实用案例(单行代码+实战场景+十大排序)

一、JS单行高频实用案例(25个,直接复制可用)

1. 变量值交换(不使用临时变量)

适用场景:快速交换两个变量的值,无需额外声明临时变量,简化代码,适用于简单值交换场景。

// 核心单行代码(适用于数字、字符串等基本类型)
let a = 10, b = 20;
[a, b] = [b, a]; // 解构赋值实现交换
// 结果: a = 20, b = 10

// 补充案例(字符串交换)
let str1 = 'hello', str2 = 'world';
[str1, str2] = [str2, str1];
// 结果: str1 = 'world', str2 = 'hello'

总结:利用数组解构赋值,简洁实现两个变量值的交换,无需临时变量,代码简洁易读,仅适用于基本数据类型;若为引用类型(对象、数组),交换的是引用地址,原数据会受影响。

2. 快速生成随机数(指定范围)

适用场景:生成指定区间内的随机整数(如随机抽奖、随机排序、模拟随机数据),前端高频使用场景。

// 核心单行代码(min=最小值,max=最大值,包含min和max)
const getRandomNum = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
// 案例1:生成1-10的随机整数
const random1 = getRandomNum(1, 10);
// 结果: 1-10之间的任意整数(如5、8、10)

// 案例2:生成100-200的随机整数
const random2 = getRandomNum(100, 200);
// 结果: 100-200之间的任意整数(如156、199、100)

总结:Math.random()生成0-1(不包含1)的随机小数,乘以(max-min+1)可将范围扩展到0-(max-min),Math.floor()取整后加min,即可得到指定区间内的随机整数,灵活适配各类随机场景。

3. 浅克隆对象(复制顶层属性)

适用场景:快速复制对象的顶层属性,生成新对象,适用于简单对象(无嵌套对象)的复制,避免修改原对象。

const originalObj = { name: '张三', age: 24 }; // 修正原文档拼写错误(original0bj→originalObj)
const clonedObj = { ...originalObj }; // 核心单行代码
// 结果: clonedObj = { name: '张三', age: 24 }
// 验证:修改clonedObj.age = 25,originalObj.age仍为24

总结:利用扩展运算符(...)复制对象的所有可枚举自身属性,生成新对象。注意:若对象包含嵌套对象,嵌套对象仍为引用类型,修改嵌套对象会影响原对象,此时需使用深克隆。

4. 合并对象

适用场景:将多个对象合并为一个新对象,重复属性会被后一个对象覆盖。

const obj1 = { name: '张三' };
const obj2 = { age: 22 };
const mergedObj = { ...obj1, ...obj2 }; // 核心单行代码
// 结果: mergedObj = { name: '张三', age: 22 }

总结:扩展运算符可快速合并多个对象,若存在重复属性,后面的对象属性会覆盖前面的。例如:const obj3 = { name: '李四', gender: '男' }; 合并后mergedObj = { name: '李四', age:22, gender: '男' }。

5. 清理数组(删除所有假值)

适用场景:快速过滤数组中的假值,保留有效数据。

const arr = [0, 1, false, 2, '', 3, null, NaN, undefined]; // 补充完整假值案例
const cleanedArray = arr.filter(Boolean); // 核心单行代码
// 结果: cleanedArray = [1, 2, 3]

总结:Array.prototype.filter() 结合Boolean函数,自动过滤所有假值(0、false、null、''、NaN、undefined),无需手动判断,高效简洁。

6. 将NodeList转换为数组

适用场景:获取DOM元素集合(NodeList)后,需使用数组方法(如map、filter)操作时。

// 修正原文档语法疏漏(补充扩展运算符包裹)
const nodesArray = [...document.querySelectorAll('div')]; 
// 结果: nodesArray 为包含所有div元素的数组,可使用map、filter等方法

总结:扩展运算符可将类数组(NodeList、arguments等)转换为真正的数组,从而使用数组的所有方法。例如:nodesArray.map(div => div.style.color = 'red'),可批量修改所有div的字体颜色。

7. 检查数组是否满足指定条件

适用场景:判断数组中是否存在满足条件的元素(some),或所有元素是否都满足条件(every)。

// 案例1:检查数组中是否存在负数
const arr1 = [1, 2, 3, -5, 4];&#xA;const hasNegativeNumbers = arr1.some(num => num < 0);
// 结果: hasNegativeNumbers = true

// 案例2:检查数组所有元素是否均为正数
const allPositive = arr1.every(num => num > 0);
// 结果: allPositive = false

// 补充案例:检查数组中是否有大于10的元素
const arr2 = [5, 8, 12, 3];
const hasGreaterThan10 = arr2.some(num => num > 10);
// 结果: hasGreaterThan10 = true

总结:some() 只要有一个元素满足条件就返回true,every() 需所有元素满足条件才返回true,两者均为短路操作(找到符合条件/不符合条件的元素后立即停止遍历)。

8. 将文本复制到剪贴板

适用场景:实现点击按钮复制文本、复制链接等功能。

// 核心单行代码(异步操作,可结合async/await使用)
navigator.clipboard.writeText('Text to copy'); // 修正原文档拼写(Textto→Text to)

// 完整示例(结合按钮点击)
document.querySelector('#copyBtn').addEventListener('click', async () => {
  await navigator.clipboard.writeText('要复制的文本');
  alert('复制成功!');
});

总结:使用Clipboard API实现文本复制,比传统的“创建input复制”更简洁,但需注意浏览器兼容性(IE不支持),且需在HTTPS协议或本地环境下使用。

9. 删除数组重复项

适用场景:快速去重,适用于基本数据类型(数字、字符串、布尔值等)的数组。

const arr = [1, 2, 2, 3, 4, 4, 5, 5, 5];
const unique = [...new Set(arr)]; // 核心单行代码
// 结果: unique = [1, 2, 3, 4, 5]

总结:利用Set对象“值唯一”的特性,结合扩展运算符将Set转换为数组,实现快速去重。注意:若数组包含对象,此方法无法去重(对象引用不同),需额外处理。

10. 取两个数组的交集

适用场景:获取两个数组中共同存在的元素。

const arr1 = [1, 2, 3, 4];
const arr2 = [2, 4, 6, 8];
const intersection = arr1.filter(value => arr2.includes(value)); // 核心单行代码
// 结果: intersection = [2, 4]

// 补充案例(字符串数组交集)
const arr3 = ['a', 'b', 'c'];
const arr4 = ['b', 'c', 'd'];
const strIntersection = arr3.filter(value => arr4.includes(value));
// 结果: strIntersection = ['b', 'c']

总结:通过filter()遍历第一个数组,使用includes()判断元素是否存在于第二个数组中,筛选出共同元素。若数组元素较多,可先将第二个数组转为Set,提升查询效率:const arr2Set = new Set(arr2); const intersection = arr1.filter(value => arr2Set.has(value))。

11. 求数组元素的总和

适用场景:快速计算数组中所有基本数据类型(数字)的总和。

const arr = [1, 2, 3, 4];
const sum = arr.reduce((total, value) => total + value, 0); // 核心单行代码
// 结果: sum = 10

// 补充案例(含负数和小数)
const arr2 = [1.5, 2.5, -3, 4];
const sum2 = arr2.reduce((total, value) => total + value, 0);
// 结果: sum2 = 5

总结:reduce() 方法接收回调函数和初始值,回调函数中的total为累加值,value为当前元素,遍历数组并累加所有元素。初始值设为0,避免数组为空时返回undefined。

12. 根据指定条件,给对象的属性赋值

适用场景:根据条件动态给对象添加属性,避免冗余的if-else语句。

const condition = true;
const value = '你好, 世界';
const newObject = { ...(condition && { key: value }) }; // 核心单行代码
// 结果: newObject = { key: '你好, 世界' }

// 补充案例(条件为false)
const condition2 = false;
const newObject2 = { ...(condition2 && { key: value }) };
// 结果: newObject2 = {}

总结:利用短路求值(&&),当条件为true时,返回{key: value},并通过扩展运算符添加到新对象中;当条件为false时,短路返回false,扩展运算符会忽略false,不添加任何属性。

13. 使用变量作为对象的键

适用场景:动态设置对象的键名(键名不确定,需通过变量指定)。

const dynamicKey = 'name';
const value = '张三';
const obj = { [dynamicKey]: value }; // 核心单行代码(计算属性名)
// 结果: obj = { name: '张三' }

// 补充案例(动态切换键名)
const dynamicKey2 = 'age';
const obj2 = { [dynamicKey2]: 24 };
// 结果: obj2 = { age: 24 }

总结:通过计算属性名(方括号包裹变量),可将变量的值作为对象的键名,灵活适配动态场景,例如根据接口返回值动态设置对象键名。

14. 离线状态检查器

适用场景:检测用户浏览器的网络连接状态,提示用户当前是否在线。

const isOnline = navigator.onLine ? '在线' : '离线'; // 核心单行代码
// 结果: 网络正常时返回'在线',断开时返回'离线'

// 完整示例(实时监听网络状态)
window.addEventListener('online', () => console.log('网络已连接,当前状态:在线'));
window.addEventListener('offline', () => console.log('网络已断开,当前状态:离线'));

总结:利用navigator.onLine属性结合三元运算符,快速判断网络状态。注意:navigator.onLine仅能检测是否有网络连接,无法判断网络是否能正常访问互联网(如连接了无网络的WiFi)。

15. 离开页面弹出确认对话框

适用场景:防止用户误操作关闭页面,导致未保存的数据丢失(如表单填写、编辑内容)。

// 核心单行代码
window.onbeforeunload = () => '你确定要离开吗?未保存的内容将丢失!'; // 补充提示信息

// 补充:现代浏览器对提示文本的限制(部分浏览器不显示自定义文本,仅显示默认提示)
window.onbeforeunload = (e) => {
  e.preventDefault();
  e.returnValue = '';
  return '你确定要离开吗?';
};

总结:监听window的onbeforeunload事件,当用户关闭页面、刷新页面或跳转页面时,会弹出确认对话框。注意:现代浏览器为了安全,可能会忽略自定义提示文本,仅显示浏览器默认提示。

16. 对象数组,根据对象的某个key求对应值的总和

适用场景:统计对象数组中,指定属性的所有值的总和(如统计订单金额、商品数量等)。

const arrayOfObjects = [{ x: 1 }, { x: 2 }, { x: 3 }];
// 核心单行函数(可复用)
const sumBy = (arr, key) => arr.reduce((acc, obj) => acc + obj[key], 0);
const total = sumBy(arrayOfObjects, 'x'); // 传入数组和指定key
// 结果: total = 6

// 补充案例(统计订单金额)
const orders = [{ amount: 100 }, { amount: 200 }, { amount: 150 }];
const totalAmount = sumBy(orders, 'amount');
// 结果: totalAmount = 450

总结:封装sumBy函数,利用reduce()遍历对象数组,累加指定key对应的属性值,可灵活复用,适用于各种对象数组的统计场景。

17. 将URL问号后面的查询字符串转为对象

适用场景:快速解析URL中的查询参数,方便获取参数值(如页面跳转传参、接口请求参数解析)。

const query = 'name=John&age=30&gender=male'; // 补充多参数案例
// 核心单行代码(修正原文档拼写错误:0bject→Object)
const parseQuery = query => Object.fromEntries(new URLSearchParams(query));
const queryObj = parseQuery(query);
// 结果: queryObj = { name: 'John', age: '30', gender: 'male' }

总结:URLSearchParams用于解析查询字符串,返回可迭代对象,再通过Object.fromEntries()将其转换为对象,解析后的参数值均为字符串,若需数字类型,需手动转换(如Number(queryObj.age))。

18. 将秒数转换为时间格式的字符串(HH:MM:SS)

适用场景:将秒数转换为标准时间格式,如视频时长、倒计时显示等。

const seconds = 3661; // 1小时1分1秒(修正原文档注释错误:3600秒为1小时)
// 核心单行代码(修正原文档拼写错误:toIsostring→toISOString、substr→slice)
const toTimeString = seconds => new Date(seconds * 1000).toISOString().slice(11, 19);
const timeStr = toTimeString(seconds);
// 结果: timeStr = '01:01:01'

// 补充案例(不足1小时、不足1分钟)
const seconds2 = 61; // 1分1秒
const timeStr2 = toTimeString(seconds2);
// 结果: timeStr2 = '00:01:01'

const seconds3 = 5; // 5秒
const timeStr3 = toTimeString(seconds3);
// 结果: timeStr3 = '00:00:05'

总结:将秒数乘以1000(转换为毫秒),创建Date对象,再通过toISOString()获取标准时间字符串,最后截取时间部分(HH:MM:SS),适用于所有正秒数的转换。

19. 求某对象所有属性值的最大值

适用场景:快速获取对象中所有属性值的最大值(如统计最高分、最大金额等)。

const scores = { math: 95, chinese: 99, english: 88 }; // 数学、语文、英语成绩
// 核心单行代码(修正原文档拼写错误:0bject→Object、max0bjectValue→maxObjectValue)
const maxObjectValue = obj => Math.max(...Object.values(obj));
const maxScore = maxObjectValue(scores);
// 结果: maxScore = 99

// 补充案例(数字属性值含小数)
const prices = { apple: 5.9, banana: 3.5, orange: 4.8 };
const maxPrice = maxObjectValue(prices);
// 结果: maxPrice = 5.9

总结:Object.values(obj)提取对象所有属性值,生成数组,再通过扩展运算符将数组元素作为Math.max()的参数,获取最大值。注意:对象属性值必须为数字类型,否则会返回NaN。

20. 判断对象的值中是否包含某个值

适用场景:检查对象的所有属性值中,是否存在指定的值。

const person = { name: '张三', age: 30, gender: '男' }; // 补充多属性案例
// 核心单行代码(修正原文档拼写错误:0bject→Object)
const hasValue = (obj, value) => Object.values(obj).includes(value);
// 案例1:判断是否包含30
const has30 = hasValue(person, 30); // 结果: true
// 案例2:判断是否包含'女'
const hasFemale = hasValue(person, '女'); // 结果: false

总结:Object.values(obj)获取对象所有属性值的数组,再通过includes()判断指定值是否在数组中,适用于快速检查对象值的存在性。

21. 安全访问深度嵌套的对象属性

适用场景:访问嵌套层级较深的对象属性,避免因中间属性不存在导致的TypeError错误。

// 案例1:中间属性存在
const user = { profile: { name: '张三' } };
const userName = user.profile?.name ?? '匿名'; // 核心单行代码
// 结果: userName = '张三'

// 案例2:中间属性不存在(profile为undefined)
const user2 = { };
const userName2 = user2.profile?.name ?? '匿名';
// 结果: userName2 = '匿名'

// 补充:区分??和||的差异
const user3 = { profile: { name: '' } };
const userName3 = user3.profile?.name ?? '匿名'; // 结果: ''(空字符串不是null/undefined,不触发默认值)
const userName4 = user3.profile?.name || '匿名'; // 结果: '匿名'(空字符串是假值,触发默认值)

总结:可选链运算符(?.):当中间属性为null/undefined时,短路返回undefined,避免报错;空值合并运算符(??):仅当左侧为null/undefined时,返回右侧默认值,不影响其他假值(如''、0)。两者结合,可安全访问深度嵌套属性并设置默认值。

22. 条件执行语句

适用场景:无需if语句,简洁实现“条件为真时执行函数/赋值”。

// 案例1:条件执行函数
const isEligible = true;
const performAction = () => console.log('执行操作');
isEligible && performAction(); // 核心单行代码(条件为真时执行)
// 结果: 输出'执行操作'

// 案例2:条件赋值(修正原文档语法,补充括号)
const isEligible2 = true;
let value = '';
isEligible2 && (value = '条件达成'); // 核心单行代码
// 结果: value = '条件达成'

// 补充案例:条件为false时不执行
const isEligible3 = false;
isEligible3 && performAction(); // 无输出

总结:利用逻辑AND(&&)的短路特性,左侧为true时,才执行右侧的函数或赋值语句;左侧为false时,直接短路,不执行右侧代码。适用于简单的条件判断场景,简化代码。

23. 创建包含指定数字范围的数组

适用场景:快速生成连续数字数组(如分页页码、循环计数等)。

// 案例1:创建1-5的数组
const range1 = Array.from({ length: 5 }, (_, i) => i + 1); // 核心单行代码
// 结果: range1 = [1, 2, 3, 4, 5]

// 案例2:创建5-10的数组
const range2 = Array.from({ length: 6 }, (_, i) => i + 5);
// 结果: range2 = [5, 6, 7, 8, 9, 10]

// 案例3:创建0-4的数组(简化)
const range3 = Array.from({ length: 5 }, (_, i) => i);
// 结果: range3 = [0, 1, 2, 3, 4]

总结:Array.from()接收两个参数:类数组对象(设置length指定数组长度)和映射函数,映射函数通过索引(i)生成指定范围的数字。下划线(_)表示未使用的参数(当前元素),是JS中的常用惯例。

24. 提取文件扩展名

适用场景:获取文件名中的扩展名(如判断文件类型、上传文件校验等)。

const fileName1 = 'example.png';
const fileName2 = 'document.pdf';
const fileName3 = 'image.jpg';
const fileName4 = 'text'; // 无扩展名
// 核心单行代码(修正原文档拼写错误:lastIndex0f→lastIndexOf、getFileExtension返回值错误)
const getFileExtension = str => str.slice(((str.lastIndexOf(".") - 1) >>> 0) + 2);
// 案例验证
console.log(getFileExtension(fileName1)); // 结果: 'png'
console.log(getFileExtension(fileName2)); // 结果: 'pdf'
console.log(getFileExtension(fileName3)); // 结果: 'jpg'
console.log(getFileExtension(fileName4)); // 结果: ''(无扩展名返回空字符串)

总结:通过lastIndexOf(".")找到最后一个点号的位置,使用位运算符(>>>)确保即使未找到点号(返回-1),操作也安全,最终截取点号后面的字符串作为扩展名。

25. 切换元素的class

适用场景:动态添加/移除元素的class(如菜单显示/隐藏、按钮选中/取消、表单状态切换等)。

// 核心代码(修正原文档语法错误,补充括号)
const element = document.querySelector('.myelement');
const toggleClass = (el, className) => el.classList.toggle(className);
// 切换class(存在则删除,不存在则添加)
toggleClass(element, 'active');

// 完整示例(点击按钮切换元素class)
document.querySelector('#toggleBtn').addEventListener('click', () => {
  toggleClass(element, 'active');
});

总结:classList.toggle()方法自动判断元素是否包含指定class,包含则删除,不包含则添加,无需手动判断,简洁高效,是前端开发中动态控制元素样式的常用方法。

二、JS实战高频案例(18个,覆盖前端常用场景)

1. 防抖函数(避免高频事件频繁触发)

适用场景:防止输入框输入、窗口resize、滚动等高频事件频繁触发函数,适用于搜索联想、输入验证等场景。

// 核心单行防抖函数(简化版)
const debounce = (fn, delay = 500) => {
  let timer = null;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
};
// 使用示例(输入框搜索)
const search = (value) => console.log('搜索:', value);
const debouncedSearch = debounce(search, 300);
// 输入框输入时调用,仅在停止输入300ms后触发
document.querySelector('#searchInput').addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

2. 节流函数(限制函数触发频率)

适用场景:限制函数在指定时间内只能触发一次,适用于滚动加载、按钮点击防重复提交等场景。

// 核心单行节流函数(简化版)
const throttle = (fn, interval = 1000) => {
  let lastTime = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastTime >= interval) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
};
// 使用示例(滚动加载)
const loadMore = () => console.log('加载更多数据');
const throttledLoadMore = throttle(loadMore, 2000);
// 滚动时调用,每2000ms只能触发一次
window.addEventListener('scroll', throttledLoadMore);

3. 深克隆对象(解决嵌套对象复制问题)

适用场景:复制包含嵌套对象的复杂对象,确保修改克隆对象不影响原对象,适用于数据备份、复杂数据处理。

// 核心单行深克隆(简单场景,不支持函数、Symbol等特殊类型)
const deepClone = obj => JSON.parse(JSON.stringify(obj));
// 示例
const originalObj = {
  name: '张三',
  age: 24,
  address: { city: '北京', area: '朝阳' } // 嵌套对象
};
const clonedObj = deepClone(originalObj);
clonedObj.address.city = '上海'; // 修改克隆对象的嵌套属性
console.log(originalObj.address.city); // 结果: '北京'(原对象不受影响)

// 补充:复杂场景深克隆(支持函数、Symbol,需使用递归)
const deepCloneAdvanced = (obj) => {
  if (obj === null || typeof obj !== 'object') return obj;
  const clone = Array.isArray(obj) ? [] : {};
  for (let key in obj) {
    clone[key] = deepCloneAdvanced(obj[key]);
  }
  return clone;
};

4. 精准检查数据类型

适用场景:精准判断数据类型(如区分数组、对象、null、函数等),避免typeof的局限性,适用于数据校验场景。

// 核心单行函数
const getType = (data) => Object.prototype.toString.call(data).slice(8, -1).toLowerCase();
// 案例验证
console.log(getType(123)); // 结果: 'number'
console.log(getType('abc')); // 结果: 'string'
console.log(getType([])); // 结果: 'array'
console.log(getType({})); // 结果: 'object'
console.log(getType(null)); // 结果: 'null'
console.log(getType(() => {})); // 结果: 'function'
console.log(getType(new Date())); // 结果: 'date'

5. 数组扁平化(多维转一维)

适用场景:将多维数组转为一维数组,适用于数据处理、数组遍历等场景,简化数据操作。

// 案例1:二维数组扁平化(核心单行代码)
const arr1 = [1, [2, 3], [4, [5, 6]]];
const flatArr1 = arr1.flat(1); // 参数1表示扁平化1层
// 结果: [1, 2, 3, 4, [5, 6]]

// 案例2:多维数组扁平化(不限层级)
const flatArr2 = arr1.flat(Infinity); // Infinity表示扁平化所有层级
// 结果: [1, 2, 3, 4, 5, 6]

// 补充案例(手动实现扁平化,不使用flat方法)
const flatArr3 = arr1.reduce((acc, item) => acc.concat(Array.isArray(item) ? flatArr3(item) : item), []);
// 结果: [1, 2, 3, 4, 5, 6]

6. 数组排序(数字/字符串通用)

适用场景:对数组进行升序/降序排序,适用于数据展示、排名统计等场景,解决sort默认字符串排序的问题。

// 案例1:数字数组升序排序
const nums = [3, 1, 4, 1, 5, 9, 2, 6];
const sortedNumsAsc = [...nums].sort((a, b) => a - b); // 展开数组避免修改原数组
// 结果: [1, 1, 2, 3, 4, 5, 6, 9]

// 案例2:数字数组降序排序
const sortedNumsDesc = [...nums].sort((a, b) => b - a);
// 结果: [9, 6, 5, 4, 3, 2, 1, 1]

// 案例3:字符串数组排序(按字母顺序)
const strs = ['banana', 'apple', 'cherry', 'date'];
const sortedStrs = [...strs].sort();
// 结果: ['apple', 'banana', 'cherry', 'date']

7. 日期格式化(指定格式)

适用场景:将Date对象转换为指定格式的日期字符串(如YYYY-MM-DD、HH:MM:SS),适用于时间展示、日志记录等。

// 核心单行函数(格式化YYYY-MM-DD HH:MM:SS)
const formatDate = (date = new Date()) => {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0'); // 月份从0开始,补0
  const day = String(date.getDate()).padStart(2, '0');
  const hour = String(date.getHours()).padStart(2, '0');
  const minute = String(date.getMinutes()).padStart(2, '0');
  const second = String(date.getSeconds()).padStart(2, '0');
  return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
// 调用函数
console.log(formatDate()); // 结果: 2026-04-10 14:30:00(当前时间)
console.log(formatDate(new Date(2026, 3, 10))); // 结果: 2026-04-10 00:00:00

8. 字符串数组与数字数组互换

适用场景:快速转换数组元素类型,适用于数据格式转换(如接口返回字符串数组,需转为数字进行计算)。

// 案例1:数字数组转为字符串数组
var arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const strArr = arr.map(String);
// 结果: ['1', '2', '3', '4', '5', '6', '7', '8', '9']

// 案例2:字符串数组转为数字数组
var a = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
const numArr = a.map(Number);
// 结果: [1, 2, 3, 4, 5, 6, 7, 8, 9]

// 补充案例(处理异常值)
var b = ['1', '2', 'abc', '4'];
const numArr2 = b.map(Number);
// 结果: [1, 2, NaN, 4](无法转换的字符串会转为NaN)

9. 对象数组匹配更新指定元素

适用场景:根据条件更新对象数组中的指定元素,适用于待办状态修改、数据编辑等前端高频场景。

const todos = [
  { id: '001', name: '吃饭', done: true },
  { id: '002', name: '睡觉', done: true }
];
const id = '002';
const done = false;
const newTodos = todos.map((todoObj) => {
  // 匹配id,更新done状态,其余元素不变
  if (todoObj.id === id) return { ...todoObj, done };
  else return todoObj;
});
// 结果: newTodos = [
//   { id: '001', name: '吃饭', done: true },
//   { id: '002', name: '睡觉', done: false }
// ]

10. 对象数组删除指定元素

适用场景:过滤删除对象数组中指定条件的元素,适用于待办删除、无效数据清理等场景。

const todos = [
  { id: '001', name: '吃饭', done: true },
  { id: '002', name: '睡觉', done: true }
];
const id = '002';
// 核心代码:过滤掉id为002的元素
const newTodos = todos.filter((todoObj) => todoObj.id !== id);
// 结果: newTodos = [
//   { id: '001', name: '吃饭', done: true }
// ]

11. 对象数组查找指定元素

适用场景:根据key快速查找对象数组中符合条件的元素,适用于详情页数据获取、数据查询等场景。

const items = [
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' },
  { id: 3, name: 'Item 3' }
];
const key = 2; // 要查找的id
// 核心代码:找到id为2的对象
const item = items.find(item => item.id === key);
console.log(item); // 结果: { id: 2, name: 'Item 2' }

12. 对象数组查找指定元素索引

适用场景:快速获取符合条件的元素在数组中的索引,适用于修改、删除指定位置的元素。

const contents = [
  { language: 'zh_CN', alias: "", description: "" },
  { language: 'td_CN', alias: "", description: "" },
  { language: 'en_US', alias: "", description: "" },
  { language: 'ja_JP', alias: "", description: "" }
];
// 核心代码:查找language为zh_CN的元素索引
const index = contents.findIndex(item => item.language === 'zh_CN');
console.log(index); // 结果: 0(数组索引从0开始)

13. 对象数组过滤非空属性元素

适用场景:筛选出对象数组中指定属性不为空的元素,适用于有效数据筛选、表单数据校验等场景。

const contents = [
  { language: 'zh_CN', alias: "1", description: "" },
  { language: 'td_CN', alias: "", description: "" },
  { language: 'en_US', alias: "", description: "" },
  { language: 'ja_JP', alias: "", description: "" }
];
// 核心代码:过滤出alias值不为空的元素
const filteredContents = contents.filter(item => item.alias !== "");
console.log(filteredContents);
// 结果: [{ "language": "zh_CN", "alias": "1", "description": "" }]

14. 对象数组求交集(根据属性匹配)

适用场景:获取两个对象数组中属性匹配的元素,适用于数据对比、重复数据筛选等场景。

let todos1 = [
  { id: "001", name: "吃饭", done: true },
  { id: "002", name: "睡觉", done: true },
];
let todos2 = [
  { id: "001", name: "吃饭", done: true },
  { id: "002", name: "睡觉", done: true },
  { id: "003", name: "学习", done: true },
];
// 核心代码:找出todos2中与todos1 id匹配的元素
todos2 = todos2.filter((item1) =>
  todos1.some((item2) => item2.id === item1.id)
);
console.log(todos2);
// 结果: [
//   { id: "001", name: "吃饭", done: true },
//   { id: "002", name: "睡觉", done: true }
// ]

15. 数组头部添加元素(不修改原数组)

const todos = [
  { id: '001', name: '吃饭', done: true },
  { id: '002', name: '睡觉', done: true }
];
const todoObj = { id: '003', name: '敲码', done: true };
const newTodos = [todoObj, ...todos]; // 核心代码
// 结果: newTodos = [
//   { id: '003', name: '敲码', done: true },
//   { id: '001', name: '吃饭', done: true },
//   { id: '002', name: '睡觉', done: true }
// ]

16. 删除数组指定下标的元素(2种常用方式)

适用场景:明确知道数组中要删除元素的下标,需删除指定位置元素(如删除列表指定索引项、清理数组特定位置无效数据),前端开发高频场景。

// 方式1:splice方法(修改原数组,简洁高效,最常用)
const arr1 = [10, 20, 30, 40, 50];
const index1 = 2; // 要删除的下标(删除元素30)
arr1.splice(index1, 1); // 核心代码:参数1=下标,参数2=删除个数
// 结果: arr1 = [10, 20, 40, 50](原数组被修改)

// 方式2:slice方法(不修改原数组,生成新数组,推荐需保留原数组场景)
const arr2 = [10, 20, 30, 40, 50];
const index2 = 2;
const newArr2 = arr2.slice(0, index2).concat(arr2.slice(index2 + 1)); // 核心代码
// 结果: newArr2 = [10, 20, 40, 50],arr2仍为[10, 20, 30, 40, 50](原数组不变)

// 补充:边界处理(下标越界时,两种方式均不报错,splice无操作,slice返回原数组)
const arr3 = [10, 20];
const index3 = 5; // 下标越界
arr3.splice(index3, 1); // 无操作,arr3仍为[10, 20]
const newArr3 = arr3.slice(0, index3).concat(arr3.slice(index3 + 1)); // newArr3 = [10, 20]

// 补充案例(对象数组删除指定下标元素)
const objArr = [
  { id: 1, name: '苹果' },
  { id: 2, name: '香蕉' },
  { id: 3, name: '橙子' }
];
const objIndex = 1; // 删除下标为1的“香蕉”
// 方式1(修改原数组)
objArr.splice(objIndex, 1);
// 结果: objArr = [{ id: 1, name: '苹果' }, { id: 3, name: '橙子' }]
// 方式2(不修改原数组)
const newObjArr = objArr.slice(0, objIndex).concat(objArr.slice(objIndex + 1));
// 结果: newObjArr = [{ id: 1, name: '苹果' }, { id: 3, name: '橙子' }]

总结:两种方式各有适用场景——splice方法修改原数组,代码简洁,适合无需保留原数组的场景;slice方法不修改原数组,避免污染原始数据,适合需保留原数组(如数据备份、回滚)的场景。注意:下标从0开始,需做好边界判断,避免下标越界导致无效操作。

17. 对象数组多层数据,只保留两层结构

适用场景:处理多层嵌套的对象数组(如接口返回的复杂数据),需简化结构、只保留两层数据(顶层对象 + 一层子对象/子数组),适用于数据展示、表格渲染等无需深层数据的场景,避免冗余数据影响性能。

// 核心函数:递归/遍历处理,只保留两层数据(顶层 + 一层子级)
// 思路:遍历顶层对象数组,仅保留顶层属性和第一层子级,删除子级中的嵌套数据
const keepTwoLayers = (arr) => {
  // 遍历顶层数组,处理每个顶层对象
  return arr.map(item => {
    // 复制顶层对象(避免修改原数据)
    const newItem = {...item};
    // 遍历顶层对象的每个属性,判断是否为对象/数组(即子级)
    for (let key in newItem) {
      const value = newItem[key];
      // 若子级是对象(非null)或数组,仅保留其自身属性,删除嵌套层级
      if (typeof value === 'object' && value !== null) {
        // 数组:保留数组元素,但元素若为对象,仅保留其自身属性(不嵌套)
        if (Array.isArray(value)) {
          newItem[key] = value.map(subItem => {
            return typeof subItem === 'object' && subItem !== null ? {...subItem} : subItem;
          });
        } else {
          // 普通对象:仅保留自身属性,删除嵌套属性
          newItem[key] = {...value};
        }
      }
    }
    return newItem;
  });
};

// 示例:多层嵌套对象数组(模拟接口返回的复杂数据)
const complexArr = [
  {
    id: 1,
    name: '商品分类1',
    info: {
      desc: '电子产品',
      detail: { // 三层嵌套,需删除
        createTime: '2026-01-01',
        updateTime: '2026-04-10'
      },
      tags: [
        { name: '热门', type: { id: 1, name: '推荐' } }, // 三层嵌套,需删除
        { name: '新品', type: { id: 2, name: '新品' } }
      ]
    }
  },
  {
    id: 2,
    name: '商品分类2',
    info: {
      desc: '生活用品',
      detail: { // 三层嵌套,需删除
        createTime: '2026-02-01',
        updateTime: '2026-04-05'
      },
      tags: [
        { name: '热销', type: { id: 3, name: '热销' } }
      ]
    }
  }
];

// 调用函数,只保留两层结构
const twoLayerArr = keepTwoLayers(complexArr);
console.log(twoLayerArr);
// 结果:
// [
//   {
//     id: 1,
//     name: '商品分类1',
//     info: { desc: '电子产品', detail: {}, tags: [ { name: '热门' }, { name: '新品' } ] }
//   },
//   {
//     id: 2,
//     name: '商品分类2',
//     info: { desc: '生活用品', detail: {}, tags: [ { name: '热销' } ] }
//   }
// ]

总结:keepTwoLayers函数通过map遍历顶层对象数组,复制每个顶层对象,再遍历其属性,对对象/数组类型的子级进行处理——数组元素若为对象则仅保留自身属性,普通对象仅保留自身属性,删除所有三层及以上的嵌套数据。该方法可灵活处理各类多层嵌套对象数组,简化数据结构,避免冗余嵌套影响前端渲染性能,适用于表格展示、列表渲染等无需深层数据的场景。

18. 批量修改对象数组的指定属性(批量更新)

适用场景:批量修改对象数组中所有元素的指定属性,或根据条件批量更新属性值,适用于批量操作(如批量修改状态、批量设置默认值),前端开发高频场景。

// 案例1:批量修改所有元素的指定属性(统一设置默认值)
const products = [
  { id: 1, name: '手机', stock: 100, isSale: false },
  { id: 2, name: '电脑', stock: 50, isSale: false },
  { id: 3, name: '平板', stock: 80, isSale: false }
];
// 核心代码:批量将isSale设为true,stock统一减10
const updatedProducts1 = products.map(item => ({
  ...item,
  isSale: true,
  stock: item.stock - 10
}));
// 结果:所有商品isSale为true,stock均减少10

// 案例2:根据条件批量修改属性(满足条件的元素才更新)
// 批量将stock>60的商品isSale设为true,其余不变
const updatedProducts2 = products.map(item => {
  if (item.stock > 60) {
    return {...item, isSale: true};
  }
  return item; // 不满足条件则返回原对象
});
// 结果:id为1、3的商品isSale为true,id为2的商品保持不变

// 补充:批量修改多个不同属性(按需设置)
const updatedProducts3 = products.map(item => ({
  ...item,
  price: item.id * 1000, // 新增属性并赋值
  stock: item.stock > 60 ? item.stock - 10 : item.stock, // 条件赋值
  isSale: item.stock > 60 // 条件赋值(布尔值)
}));
console.log(updatedProducts3);
// 结果:所有商品新增price属性,stock按需减少,isSale按条件设置

总结:利用map方法遍历对象数组,通过对象扩展运算符(...)保留原对象属性,同时修改指定属性值,可实现统一批量修改或条件批量修改。该方法不修改原数组,生成新数组,避免污染原始数据,适用于批量操作场景,代码简洁且可复用,是前端批量处理对象数组的常用方式。

三、十大排序算法(前端实战版)

排序算法是前端数据处理的核心基础,以下整理十大常用排序算法,先通过表格对比各算法关键信息,再详细说明核心原理、JS实现代码(简洁可复制)、适用场景及优缺点,贴合前端开发实际需求,避免复杂冗余,重点适配数组排序场景。

排序算法 核心特点 时间复杂度 空间复杂度 稳定性 前端适用场景
冒泡排序 相邻元素对比,逐步冒泡至末尾 O(n²) O(1) 稳定 少量数据,简单场景
选择排序 每次选最小/大元素,与首位交换 O(n²) O(1) 不稳定 少量数据,对稳定性无要求
插入排序 分已排序/未排序,逐个插入合适位置 O(n²)(接近有序时O(n)) O(1) 稳定 少量数据、数据接近有序(如表单排序)
希尔排序 插入排序优化,按步长分组排序 O(nlogn) O(1) 不稳定 中量数据
快速排序 分治法,选基准值分组递归排序 O(nlogn) O(logn) 不稳定 大量数据,前端最常用
归并排序 分治法,拆分后合并有序子数组 O(nlogn) O(n) 稳定 大量数据,对稳定性有要求
堆排序 利用大顶堆特性,逐步取出堆顶元素 O(nlogn) O(1) 不稳定 大量数据,对空间要求严格
计数排序 非比较排序,统计元素次数后重构数组 O(n + k)(k为元素范围) O(k) 稳定 元素为整数、范围较小(如分数排序)
桶排序 非比较排序,分桶排序后合并 O(n + k) O(n + k) 稳定 元素分布均匀、大量数据(如数据统计)
基数排序 非比较排序,按位数依次排序 O(n * k)(k为最大数位数) O(n + k) 稳定 整数、字符串,大量数据

1. 冒泡排序(Bubble Sort)

核心原理:重复遍历数组,每次比较相邻两个元素,将较大元素“冒泡”到数组末尾,逐步完成排序。

// 优化版:添加标志位,无交换时直接退出(提升效率)
const bubbleSort = (arr) => {
  const newArr = [...arr]; // 不修改原数组
  const len = newArr.length;
  for (let i = 0; i < len - 1; i++) {
    let hasSwap = false; // 标志位:是否发生交换
    for (let j = 0; j < len - 1 - i; j++) {
      if (newArr[j] > newArr[j + 1]) {
        [newArr[j], newArr[j + 1]] = [newArr[j + 1], newArr[j]]; // 交换元素
        hasSwap = true;
      }
    }
    if (!hasSwap) break; // 无交换,说明已排序完成
  }
  return newArr;
};
// 示例
const arr1 = [3, 1, 4, 1, 5, 9];
console.log(bubbleSort(arr1)); // 结果:[1, 1, 3, 4, 5, 9]

优缺点:简单易理解,空间复杂度低(O(1));时间复杂度O(n²),数据量大时效率极低,适用于少量数据排序。

2. 选择排序(Selection Sort)

核心原理:每次遍历未排序部分,找到最小(或最大)元素,与未排序部分的第一个元素交换,逐步缩小未排序范围。

const selectionSort = (arr) => {
  const newArr = [...arr];
  const len = newArr.length;
  for (let i = 0; i < len - 1; i++) {
    let minIndex = i; // 记录最小元素下标
    // 找到未排序部分的最小元素
    for (let j = i + 1; j < len; j++) {
      if (newArr[j] < newArr[minIndex]) {
        minIndex = j;
      }
    }
    // 交换最小元素与未排序部分第一个元素
    [newArr[i], newArr[minIndex]] = [newArr[minIndex], newArr[i]];
  }
  return newArr;
};
// 示例
const arr2 = [7, 2, 5, 0, 3];
console.log(selectionSort(arr2)); // 结果:[0, 2, 3, 5, 7]

优缺点:实现简单,空间复杂度O(1);时间复杂度O(n²),不稳定(相同元素可能改变相对位置),适用于数据量小、对稳定性无要求的场景。

3. 插入排序(Insertion Sort)

核心原理:将数组分为“已排序”和“未排序”两部分,每次从无排序部分取一个元素,插入到已排序部分的合适位置。

const insertionSort = (arr) => {
  const newArr = [...arr];
  const len = newArr.length;
  for (let i = 1; i < len; i++) {
    const current = newArr[i]; // 未排序部分的当前元素
    let j = i - 1; // 已排序部分的最后一个下标
    // 找到插入位置
    while (j >= 0 && newArr[j] > current) {
      newArr[j + 1] = newArr[j]; // 元素后移
      j--;
    }
    newArr[j + 1] = current; // 插入当前元素
  }
  return newArr;
};
// 示例
const arr3 = [6, 3, 8, 2, 9];
console.log(insertionSort(arr3)); // 结果:[2, 3, 6, 8, 9]

优缺点:稳定排序,数据接近有序时效率极高(时间复杂度接近O(n));时间复杂度O(n²),适用于少量数据、数据接近有序的场景(如表单排序)。

4. 希尔排序(Shell Sort)

核心原理:插入排序的优化版,将数组按“步长”分组,对每组进行插入排序,逐步缩小步长,最终步长为1时完成排序。

const shellSort = (arr) => {
  const newArr = [...arr];
  const len = newArr.length;
  // 步长初始为数组长度的一半,逐步缩小为1
  for (let gap = Math.floor(len / 2); gap > 0; gap = Math.floor(gap / 2)) {
    // 对每组进行插入排序
    for (let i = gap; i < len; i++) {
      const current = newArr[i];
      let j = i - gap;
      while (j >= 0 && newArr[j] > current) {
        newArr[j + gap] = newArr[j];
        j -= gap;
      }
      newArr[j + gap] = current;
    }
  }
  return newArr;
};
// 示例
const arr4 = [10, 5, 12, 3, 7, 1];
console.log(shellSort(arr4)); // 结果:[1, 3, 5, 7, 10, 12]

优缺点:效率高于插入/冒泡/选择排序,时间复杂度O(nlogn);不稳定,适用于中量数据排序。

5. 快速排序(Quick Sort)

核心原理:分治法,选择一个“基准值”,将数组分为“小于基准”“等于基准”“大于基准”三部分,递归对左右两部分排序,效率极高。

// 简洁版:递归实现,基准值选数组中间元素
const quickSort = (arr) => {
  if (arr.length <= 1) return arr; // 递归终止条件
  const newArr = [...arr];
  const midIndex = Math.floor(newArr.length / 2);
  const pivot = newArr.splice(midIndex, 1)[0]; // 基准值(删除并获取)
  const left = []; // 小于基准的元素
  const right = []; // 大于基准的元素
  // 分组
  for (let item of newArr) {
    item < pivot ? left.push(item) : right.push(item);
  }
  // 递归排序左右两部分,合并结果
  return [...quickSort(left), pivot, ...quickSort(right)];
};
// 示例
const arr5 = [8, 3, 1, 7, 0, 10, 2];
console.log(quickSort(arr5)); // 结果:[0, 1, 2, 3, 7, 8, 10]

优缺点:效率极高,时间复杂度O(nlogn);不稳定,空间复杂度O(logn),适用于大量数据排序(前端最常用的排序算法)。

6. 归并排序(Merge Sort)

核心原理:分治法,将数组递归拆分为两个子数组,直到每个子数组只有一个元素,再逐步合并两个有序子数组,最终得到有序数组。

// 合并两个有序数组
const merge = (left, right) => {
  const result = [];
  let i = 0, j = 0;
  // 对比两个数组,按顺序合并
  while (i < left.length && j < right.length) {
    left[i] < right[j] ? result.push(left[i++]) : result.push(right[j++]);
  }
  // 合并剩余元素
  return [...result, ...left.slice(i), ...right.slice(j)];
};

// 归并排序主函数
const mergeSort = (arr) => {
  if (arr.length <= 1) return arr; // 递归终止条件
  const mid = Math.floor(arr.length / 2);
  const left = arr.slice(0, mid); // 左子数组
  const right = arr.slice(mid); // 右子数组
  // 递归拆分 + 合并
  return merge(mergeSort(left), mergeSort(right));
};
// 示例
const arr6 = [5, 2, 9, 1, 5, 6];
console.log(mergeSort(arr6)); // 结果:[1, 2, 5, 5, 6, 9]

优缺点:稳定排序,时间复杂度O(nlogn);空间复杂度O(n),适用于对稳定性有要求、大量数据的排序场景。

7. 堆排序(Heap Sort)

核心原理:利用堆(大顶堆/小顶堆)的特性,将数组构建为大顶堆(最大值在堆顶),每次取出堆顶元素,再调整堆结构,重复直至排序完成。

// 调整堆结构(大顶堆)
const adjustHeap = (arr, parentIndex, len) => {
  const temp = arr[parentIndex]; // 父节点
  let childIndex = 2 * parentIndex + 1; // 左子节点下标
  while (childIndex < len) {
    // 找到左右子节点中较大的一个
    if (childIndex + 1 < len && arr[childIndex + 1] > arr[childIndex]) {
      childIndex++;
    }
    // 父节点大于子节点,无需调整
    if (temp >= arr[childIndex]) break;
    // 子节点上移
    arr[parentIndex] = arr[childIndex];
    parentIndex = childIndex;
    childIndex = 2 * parentIndex + 1;
  }
  arr[parentIndex] = temp; // 插入父节点到正确位置
};

// 堆排序主函数
const heapSort = (arr) => {
  const newArr = [...arr];
  const len = newArr.length;
  // 1. 构建大顶堆(从最后一个非叶子节点开始调整)
  for (let i = Math.floor(len / 2) - 1; i >= 0; i--) {
    adjustHeap(newArr, i, len);
  }
  // 2. 逐步取出堆顶元素,调整堆结构
  for (let i = len - 1; i > 0; i--) {
    [newArr[0], newArr[i]] = [newArr[i], newArr[0]]; // 堆顶与末尾元素交换
    adjustHeap(newArr, 0, i); // 调整剩余堆结构
  }
  return newArr;
};
// 示例
const arr7 = [3, 9, 2, 10, 4, 7];
console.log(heapSort(arr7)); // 结果:[2, 3, 4, 7, 9, 10]

优缺点:效率高,时间复杂度O(nlogn);不稳定,空间复杂度O(1),适用于大量数据、对空间要求严格的场景。

8. 计数排序(Counting Sort)

核心原理:非比较排序,统计数组中每个元素出现的次数,根据元素大小顺序,依次输出对应次数的元素,适用于元素范围较小的整数数组。

const countingSort = (arr) => {
  if (arr.length <= 1) return arr;
  const newArr = [...arr];
  const max = Math.max(...newArr); // 找到数组最大值
  const min = Math.min(...newArr); // 找到数组最小值
  const countArr = new Array(max - min + 1).fill(0); // 计数数组

  // 统计每个元素出现的次数
  for (let item of newArr) {
    countArr[item - min]++;
  }

  // 构建排序后的数组
  let index = 0;
  for (let i = 0; i < countArr.length; i++) {
    while (countArr[i] > 0) {
      newArr[index++] = i + min;
      countArr[i]--;
    }
  }
  return newArr;
};
// 示例(元素范围较小的整数数组)
const arr8 = [2, 0, 2, 1, 1, 0];
console.log(countingSort(arr8)); // 结果:[0, 0, 1, 1, 2, 2]

优缺点:稳定排序,时间复杂度O(n + k)(k为元素范围);空间复杂度O(k),仅适用于元素为整数、范围较小的场景(如考试分数排序)。

9. 桶排序(Bucket Sort)

核心原理:非比较排序,将数组元素按范围分到不同的“桶”中,对每个桶内的元素进行排序(可使用其他排序算法),最后合并所有桶的元素。

// 桶排序主函数,默认分5个桶
const bucketSort = (arr, bucketCount = 5) => {
  if (arr.length <= 1) return arr;
  const newArr = [...arr];
  const max = Math.max(...newArr);
  const min = Math.min(...newArr);
  const bucketSize = Math.ceil((max - min + 1) / bucketCount); // 每个桶的范围大小
  const buckets = new Array(bucketCount).fill(0).map(() => []); // 初始化桶

  // 将元素分到对应桶中
  for (let item of newArr) {
    const bucketIndex = Math.floor((item - min) / bucketSize);
    buckets[bucketIndex].push(item);
  }

  // 对每个桶排序,合并结果(这里使用插入排序,也可替换为快速排序)
  return buckets.reduce((result, bucket) => {
    return [...result, ...insertionSort(bucket)]; // 复用前面的插入排序
  }, []);
};
// 示例
const arr9 = [4, 2, 8, 1, 5, 7, 3, 6];
console.log(bucketSort(arr9)); // 结果:[1, 2, 3, 4, 5, 6, 7, 8]

优缺点:稳定排序,时间复杂度O(n + k);空间复杂度O(n + k),适用于元素分布均匀、大量数据的排序场景(如数据统计)。

10. 基数排序(Radix Sort)

核心原理:非比较排序,按元素的“位数”(个位、十位、百位...)依次排序,从低位到高位,每次排序后元素按当前位数有序,最终得到完整有序数组。

// 获取数字的某一位(个位=0,十位=1,百位=2...)
const getDigit = (num, digit) => {
  return Math.floor(Math.abs(num) / Math.pow(10, digit)) % 10;
};

// 基数排序主函数
const radixSort = (arr) => {
  if (arr.length <= 1) return arr;
  const newArr = [...arr];
  // 找到数组中最大数的位数
  const maxDigit = Math.max(...newArr).toString().length;

  // 按每一位排序,从个位到最高位
  for (let digit = 0; digit< maxDigit; digit++) {
    const buckets = new Array(10).fill(0).map(() => []); // 0-9共10个桶
    // 按当前位数将元素分到对应桶中
    for (let item of newArr) {
      const bucketIndex = getDigit(item, digit);
      buckets[bucketIndex].push(item);
    }
    // 合并桶,更新数组
    newArr.splice(0, newArr.length, ...buckets.flat());
  }
  return newArr;
};
// 示例(正整数数组)
const arr10 = [123, 45, 6, 789, 10, 23];
console.log(radixSort(arr10)); // 结果:[6, 10, 23, 45, 123, 789]

优缺点:稳定排序,时间复杂度O(n * k)(k为最大数的位数);空间复杂度O(n + k),适用于整数、字符串等可按“位”排序的大量数据场景。

排序算法总结(前端选型参考)

  1. 少量数据(n < 100):优先用插入排序、冒泡排序(简单易实现);

  2. 中大量数据(n > 100):优先用快速排序(效率最高)、归并排序(稳定);

  3. 元素范围小的整数数组:用计数排序、桶排序(效率高于比较排序);

  4. 对稳定性有要求:用归并排序、插入排序、计数排序、桶排序、基数排序;

  5. 对空间有要求:用堆排序、快速排序、冒泡排序、选择排序、插入排序、希尔排序。

  6. 所有代码均为前端实战高频场景,可直接复制到项目中使用,部分代码需根据实际需求(如DOM选择器、属性名)微调;

  7. 单行代码优先简化实现,兼顾简洁性和实用性,复杂场景补充完整示例,适配不同开发需求;

  8. 注意浏览器兼容性:部分API(如Clipboard API、可选链运算符??、扩展运算符)不支持IE浏览器,若需兼容IE,需额外做兼容处理;

  9. 对象/数组操作均优先采用不修改原数据的方式(如扩展运算符、map、filter),避免意外污染原始数据,提升代码可维护性。

TanStack Query 技术指南:异步状态管理核心实践

在前端开发中,我们长期以来习惯于将后端API返回的数据,一股脑存入Redux、Zustand等全局状态管理库中。但很少意识到,后端数据所具备的异步性,决定了这种服务器状态(Server State)的管理,天生就伴随着诸多复杂难题——缓存失效难以控制、重复请求造成资源浪费、加载/错误状态的繁琐处理,这些问题往往需要编写大量冗余代码,还容易出现边缘漏洞。

TanStack Query的出现,恰好为这些痛点提供了标准化、高效率的解决方案。它的核心价值,在于将异步数据请求、缓存管理等逻辑,从React声明式UI的生命周期中彻底解耦,无需手动编写复杂的缓存、重试、同步逻辑,极大降低了前端维护服务器状态同步的成本。本篇文章将由浅入深,带你完整掌握从环境搭建、基础使用,到高级缓存策略、实战技巧的全链路实践,轻松搞定异步状态管理

一、 环境集成

在现代前端项目中,推荐使用 pnpmbun 进行安装。

# 使用 npm
npm i @tanstack/react-query

# 使用 pnpm (推荐)
pnpm add @tanstack/react-query

# 使用 Yarn
yarn add @tanstack/react-query

# 使用 Bun
bun add @tanstack/react-query

二、 核心机制:查询 (useQuery)

useQuery 主要用于处理 幂等性 (同一个操作,执行一次和执行多次,最终结果完全一样,不会产生副作用)的读取操作。其核心在于通过 queryKey 实现对数据的自动化追踪。

1. 核心配置参数

  • queryKey: 必须为数组类型 unknown[]。它是缓存条目的唯一哈希索引。当数组内的依赖项(如 ID、分页参数)发生变化时,查询会自动重新触发。
queryKey: ['todos', 1, 10, 'active', 'apple'] //todo是一条哈希索引,后面的参数是里面的值
  • queryFn: 必须返回一个 Promise。该函数负责执行实际的数据抓取逻辑。
  • enabled: 条件触发,控制是否执行查询(用于依赖请求)。
  • staleTime: 数据新鲜时间,期间不发请求,直接读缓存。
  • gcTime: 缓存保留时长,超时无引用则自动回收。
  • retry: 请求失败自动重试次数。
  • select: 缓存层数据转换,优化性能与组件渲染。
  • refetchOnWindowFocus: 切屏回退时是否自动刷新。
  • refetchOnMount: 组件挂载时是否自动重请求。

2. 状态监听与处理

useQuery 返回的对象包含状态,用于驱动 UI 交互:

  • status: 包括 pendingerrorsuccess
  • isFetching: 布尔值,表示当前是否正处于网络请求中。
const { status, data, error } = useQuery({
  queryKey: ['todos', { filter: 'active' }],
  queryFn: fetchTodoList,
  staleTime: 5000,
});

if (status === 'pending') return <div>Loading...</div>;
if (status === 'error') return <div>Error: {error.message}</div>;

三、 数据变更:变更 (useMutation)

useMutation 用于处理非幂等(执行一次和多次的结果不同)的 增、删、改 操作。

1. 关键生命周期钩子

useQuery 不同,useMutation 提供了完整的请求生命周期回调,便于处理副作用:

  • onMutate: 请求发起前的同步回调,常用于“乐观更新”。
  • onSuccess: 请求成功后的操作。
  • onError: 异常捕获。
  • onSettled: 请求结束(无论成败)的清理工作。

2. 实践示例:

const { mutate, isPending } = useMutation({
  mutationFn: (newTodo) => axios.post('/api/todos', newTodo),
  onSuccess: (data) => {
    // 逻辑处理
  },
  retry: false // 提交类操作通常建议关闭自动重试
});

四、 核心策略:查询失效与同步 (Invalidation)

在执行 Mutation 后,前端缓存往往与后端数据库不再同步。通过 queryClient.invalidateQueries,我们可以声明式地通知特定的缓存条目失效。

1. 原理说明

当查询被标记为“失效(Stale)”后:

  1. 该查询对应的 staleTime 会立即失效。
  2. 如果该查询当前正在页面上渲染,它会自动在后台触发重新拉取(Refetch)。

2. 代码实现

TypeScript

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: updateApi,
  onSuccess: () => {
    // 自动刷新所有以 'userList' 开头的查询
    queryClient.invalidateQueries({
      queryKey: ['userList']
    });
  }
});

五、 进阶要点

1.全局最佳实践配置:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 30, // 30秒内不重新请求
      gcTime: 1000 * 60 * 5, // 缓存5分钟
      retry: 1, // 失败重试1次
      refetchOnWindowFocus: false, // 关闭切屏刷新
      refetchOnMount: true,
    },
  },
});

queryclient常用的几个方法:

  • getQueryData:同步获取缓存数据
  • setQueryData:同步修改缓存数据
  • invalidateQueries:标记数据过期并重新请求
  • cancelQueries:取消进行中的请求
  • prefetchQuery:预加载数据并存入缓存

2. 全局配置注入

在使用 Hooks 之前,必须在应用顶层注入 QueryClientProvider

const queryClient = new QueryClient();

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RootComponent />
    </QueryClientProvider>
  );
}

3. 依赖查询 (Dependent Queries)

当一个请求依赖于另一个请求的结果时,应使用 enabled 选项。

const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });

const { data: projects } = useQuery({
  queryKey: ['projects', user?.id],
  queryFn: () => fetchProjects(user.id),
  enabled: !!user?.id, // 仅在 user.id 存在时执行
});

4. 数据转换 (Select)

利用 select 配置项可以在缓存层面进行数据转换,避免在组件内部进行昂贵的计算。

useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.map(user => user.name), // 仅返回名称列表
});

5.乐观更新:

tanstack的乐观更新是指在服务器响应前,前端立即更新本地的缓存与uI,假设操作会成功。若服务器报错则回滚。

流程:

  • 保存快照(副本):通过queryclient.getQueryData保存旧数据,作为服务器响应失败的回滚
  • 更新:在useMutation的onMutate钩子中,用queryclient.setQueryData直接修改缓存,ui无需响应就更新
  • 错误回滚:onError 中用快照恢复缓存;onSettled 无论成败都重新拉取,确保与服务端数据一致
import { useMutation, useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();
const updateTodoMutation = useMutation({
  mutationFn: updateTodoApi, // 实际调用的 API
  onMutate: async (updatedTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] }); // 取消冲突请求
    const previousTodos = queryClient.getQueryData(['todos']); // 保存旧数据
    // 乐观更新缓存
    queryClient.setQueryData(['todos'], (old) =>
      old?.map(t => t.id === updatedTodo.id ? updatedTodo : t)
    );
    return { previousTodos }; // 返回回滚上下文
  },
  onError: (err, vars, context) => {
    // 失败时回滚
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] }); // 同步服务端最新
  }
});

6.分页 / 无限加载:

普通分页:

核心部分是:将页码放进query,这样将会自动缓存每一页。

const fetchTodos = async ({ pageParam = 1 }) => {
  const res = await fetch(`/api/todos?page=${pageParam}&limit=10`);
  return res.json();
};

const { data, isLoading } = useQuery({
  queryKey: ['todos', page], // 页码变,就重新请求
  queryFn: () => fetchTodos(page),
});

无限加载:

核心:

  • pages:已经加载的所有页
  • fetchNextPage():手动点的加载更多
  • hasNextPage:有没有下一页(true/false)
  • getNextPageParam你定义的规则, 下一页是几?
import { useInfiniteQuery } from '@tanstack/react-query';

export default function Demo() {
  const fetchPages = async ({ pageParam = 1 }) => {
    const res = await fetch(`/api/list?page=${pageParam}&limit=2`);
    const data = await res.json();
    return data;
   // 后端返回格式:{ list: [数据], total: 总条数 }
  };

  // 2. 核心:无限加载
  const {
    pages,           // 已经加载的所有页(数组)
    fetchNextPage,   // 点击加载下一页
    hasNextPage,     // 有没有下一页
    getNextPageParam // 规则:下一页是第几页?
  } = useInfiniteQuery({
    queryKey: ['demo-list'], 
    queryFn: fetchPages,    
    
    getNextPageParam: (lastPage, allPages) => {
      // lastPage: 最后一页数据
      // allPages: 已经加载的所有页

      // 如果已经加载完所有,返回 undefined(没有下一页)
      if (allPages.length * 2 >= lastPage.total) {
        return undefined;
      }
      // 否则返回下一页页码
      return allPages.length + 1;
    }
  });

  return (
    <div>
      <h2>已加载的页:pages</h2>
      {pages?.map((page, index) => (
        <div key={index} style={{ border: '1px solid', margin: 10, padding: 10 }}>
          <h4>第 {index + 1} 页</h4>
          {page.list.map(item => (
            <div key={item.id}>{item.title}</div>
          ))}
        </div>
      ))}

      {/* 点击加载更多 */}
      <button 
        onClick={() => fetchNextPage()} 
        disabled={!hasNextPage}
      >
        {hasNextPage ? '加载下一页' : '已加载全部'}
      </button>
    </div>
  );
}

7.预加载:

 const prefetchNextPage = async () => {
    await queryClient.prefetchQuery({
      // 要预加载的缓存 key(必须和 useQuery 里一样)
      queryKey: ['todos', 2],

      // 请求函数
      queryFn: () => fetch('/api/todos?page=2').then(res => res.json()),

      // 可选:多久内不再重复预加载(默认 30s)
      staleTime: 1000 * 60,
    });

六、 资源引用

七、 实战结合:

TanStack Query 的设计初衷,从来不是取代 Redux、Zustand 等经典状态管理方案,而是填补前端架构中服务器状态管理的空白。两者各司其职、深度融合,才是现代 React 项目的最优架构 —— 让数据逻辑更纯粹,让状态管理更轻盈,真正实现锦上添花的效果。

AI沉淀出来的一些前端开发rules

Vue 3 + TypeScript 通用开发规范(质量下限 + 安全)

定位与全局观

  • 适用范围:任意 Vue 3 + TypeScript 前端(中后台、数据看板、埋点 SDK/管理端安全监测、官网、低代码宿主等)。具体业务子目录命名(如 views/orderviews/mall)由 README / 项目级规则 规定;本节「目录与模块边界」规定通用分层职责与依赖方向,与业务域名称无关。UI 组件库选型由项目自行约定。
  • 对 AI / 开发者的期望:交付物应稳定可维护(判空、错误态、契约清晰)、安全合规(密钥与敏感数据)、结构清晰(可拆分、可演进)。无论产品是「订单」还是「事件上报」,同一套下限均适用。
  • 与 README 的关系接口成功判定、业务错误码、HTTP 语义README.md 为准(第二节)。环境变量命名前缀、文件上传限制、错误上报方式、a11y 达标要求等若与「行业默认」不同,也应在 README 或 README 链接的文档中写明;本规则只写原则,不写死具体工具与阈值。
  • 原则:不得低于目标仓库已有 根目录工程基座(见第五节:ESLint、Prettier、Stylelint、TypeScript、vue-tsc、Commitlint 等)所定义的基线;只允许同等或更严。上不封顶。

目录与模块边界(通用)

目标:任意接手仓库的人(含 AI)能判断「新文件该放哪、谁能依赖谁」,避免跨域耦合与循环依赖。

1. 推荐 src/ 分层职责

以下目录名为常见约定;若仓库使用 pages/ 代替 views/stores/ 代替 store/ 等,等价映射即可,但职责应一致。

目录 职责
api/ HTTP 调用、请求/响应类型禁止写页面状态、路由、业务 UI 分支。按后端域或业务域分子目录。
views/pages/ 路由级页面;可含本页专用子组件、本页 composable(或迁入 hooks/ / composables/)。
components/ 跨页面复用的展示与交互;宜再分 基础/通用业务共享(子目录或命名前缀区分),避免业务细节污染底层组件。
hooks/composables/ 可复用的组合式逻辑(请求+状态、表单、表格等),无路由耦合为佳。
store/stores/ 全局或跨路由会话态禁止把仅单页使用的列表状态默认升仓,除非 README 约定。
router/ 路由表、守卫、元信息;禁止在路由文件内写大块业务逻辑,应委托 composable/store。
utils/ 纯函数、与组件实例无关的工具;禁止依赖具体页面或 router 单例(特殊封装除外并在 README 说明)。
types/ 全局 TS 类型、.d.ts;领域类型优先靠近使用方或 api/ 同模块。
assets/ 图片、字体等静态资源。
config/plugins/directives/layout/locales/ 按项目既有结构放置;新增类目录须在 README 或项目规则 中记一笔,避免一人一套。
典型 src/ 目录树(任意 Vue 3 + TypeScript 项目均可采用;业务子目录名由 README 自定)

以下为与业务无关的参考骨架;viewsapi 下的下一级文件夹(如 orderadmintelemetry)不在此写死,由 README 列出「域 ↔ 路径」映射即可。

src/
├── api/                 # 按业务域/资源分子目录;仅 HTTP 与类型
├── assets/              # 静态资源
├── components/          # 跨页复用组件(可再分子目录:base、business 等)
├── composables/         # 或 hooks/,任选其一或并存但职责不重复
├── config/              # HTTP 客户端、应用级配置(可选,依项目)
├── constants/ 或 constant/ # 全局常量、枚举映射(可选,与 types 区分)
├── directives/          # 自定义指令(可选)
├── layout/ 或 layouts/  # 壳层布局(可选)
├── locales/             # 或 i18n/(可选)
├── plugins/             # Vue 插件注册(可选)
├── router/              # 路由
├── store/ 或 stores/    # 全局状态(可选)
├── styles/              # 全局样式(可选)
├── types/               # 全局类型
├── utils/               # 纯工具函数
└── views/ 或 pages/     # 路由页面;域级子目录与 api/ 对齐
  • 小型项目可删减空目录;大型项目可增加 services/constants/lib/ 等,但须在 README 说明职责,且不破坏上文依赖方向。
  • Monorepo 子应用:每个应用各自具备完整或精简的 src/,本节约束按应用根生效。

2. 组织策略(二选一为主,全仓库一致)

  • 按技术分层api/*views/*components/* 清晰分离;适合中后台与多域并列。
  • 按功能域(feature)features/order/{api,components,pages} 等;适合域边界强、团队按域分工。

混合时须约定:域内可就近放子文件,域间共享须通过 components/hooks/api/ 等公共层,禁止 views/domainA 深层 import views/domainB 的内部实现文件。

3. 依赖方向(必须)

  • 单向依赖页面 →(api / hooks / components / store)→ utils / types禁止 utilsviewsapiviews、子业务页 → 另一业务页内部文件。
  • 禁止明显 循环依赖(A→B→A);出现时用 类型-only import、拆共享模块或下沉 utils 解决。
  • 路径别名(如 @/)仅缩短路径,不改变上述分层语义。

4. api/ 与页面

  • 同一后端资源:api 模块与 views 模块在目录命名上应对齐(具体命名表见 README 或项目级规则)。
  • 页面内禁止手写裸 axios URL 字符串散落;必须经统一客户端与 api/ 内函数(与第二节 README 契约一致)。

5. barrel 文件(index.ts 聚合导出)

  • 谨慎在大型目录根使用 barrel 再导出全部,易引发 循环引用tree-shaking 变差;优先 按需路径导入有限导出列表。若项目统一使用 barrel,须在 README 说明规范。

6. 与 README / 项目规则的关系

  • 本文件:规定「放什么类代码、谁依赖谁」及通用 src/ 骨架适用于所有 Vue 3 + TypeScript 仓库(拷贝时勿删本节)。
  • README(或各仓库自己的项目级规则):规定路径别名业务域文件夹命名api/<域>views/<域> 对照表)、以及本仓库相对上图的增删目录
  • 冲突时:结构纪律以更严、更贴近本仓库 README 者为准。

7. public/src/assets/(应用根目录下,与 src/ 并列,通用)

  • public/(Vite 等约定名,或构建工具等价目录):构建时原样输出到站点根不经模块打包与按内容哈希重命名。适用于 faviconrobots.txt、需固定 URL** 供外链/第三方回调引用的文件。在模板或 HTML 中通常用 根相对路径(如 /logo.svg),**不要**按 ES 模块 import
  • src/assets/:经 import / SFC 引用 进入依赖图,可享受 文件名哈希、压缩、按需 等构建优化。适用于 组件内图片、与打包相关的资源
  • 禁止将本可受益于打包管线的大资源误放 public/ 导致体积与缓存策略失控;禁止将须稳定不变路径的文件误放 src/assets/ 导致部署后 URL 变化破坏外链。例外须在 README 写明。

一、硬性禁止(违反即视为不合格)

1. 空值与类型安全

  • 禁止在未判空/未收窄类型的情况下访问嵌套属性或调用方法(a.b.carr[0].x;可选链能表达意图时优先使用)。
  • 禁止as 粗暴断言掩盖未校验的数据结构;确需断言时应已有运行时校验或明确的不变式说明。
  • any:默认禁止;仅在第三方类型缺失等窄场景使用,且应局限在最小范围(并优先考虑 unknown + 校验/收窄)。

2. 模板与指令

  • 禁止在同一元素上同时使用 v-ifv-for。应改为:外层 template v-for + 内层 v-if,或先用计算属性/过滤后的列表渲染。
  • v-for必须提供稳定、可预测的 :key(优先业务主键;避免仅用索引,除非静态列表且无副作用)。

3. 结构与可维护性

  • 禁止单函数过长(建议 ≤ 80 行为常态上限;超过应拆分)。严禁单函数数百行。
  • 禁止单文件组件无节制膨胀(建议 .vue ≤ 400 行为警戒;超过应拆子组件、composables、纯模块)。严禁单文件上千行堆叠。
  • 禁止setup 或单一函数中混杂无关职责(I/O、领域规则、展示状态搅在一起);应分层或拆分。

大文件拆分顺序(必须)composables(状态、异步与数据契约、领域规则),展示型子组件禁止只拆模板、核心逻辑仍堆在父组件。

4. 异步、接口与状态

  • 禁止在未确认「本次调用在约定意义上成功且载荷可用」时,直接使用业务字段驱动核心 UI。
  • 禁止忽略 loading / 错误态 / 空数据态,导致静默失败或误导。
  • 禁止无意义的空 catch;须记录、兜底 UI 或按统一策略向上抛出。

5. 反例模式(领域无关)

以下属于不合格:请求返回后不先按项目约定判断成功与否,仅按「像数组或带 list/data」猜测结构就当作有效数据使用。

// 反例:未按统一成功判定,易把失败响应当列表用
const res = await api.getList()
const list = Array.isArray(res) ? res : (res?.list ?? res?.data ?? [])

合格方向:先按 README.md(或 README 指向的契约文档)HTTP 客户端/拦截器实际行为 判定成功,再读取载荷,并对数组/对象做校验与默认值。

6. 路由与 URL 输入

  • route.paramsroute.query 及任何来自 URL 的字符串视为不可信输入;用于 跳转、拼请求、权限判断必须校验格式与取值范围(正则、白名单、与当前会话上下文比对等,细节以 README 为准)。
  • 禁止将 query 中的 redirect、完整 URL、外链未经验证与白名单即用于 window.locationrouter.push,防止开放重定向。
  • 数字/枚举类 ID:转换后须校验有效(如 Number.isFinite、正整数、UUID 格式等按 README),再发起请求。

7. 写操作与重复提交

  • 创建、更新、支付、提交表单等写操作:请求进行中必须通过 loading + 禁用触发控件等价互斥锁禁止因连点、双击、重复触发导致并发重复提交。
  • 若 README/后端约定 幂等键(Idempotency-Key) 等机制,必须按约定携带;禁止在未读契约时自行发明不一致的 header/body 字段名。

8. 高风险载荷的运行时校验

  • README 中列出或团队公认的高风险路径(如资金、权限、安全策略、关键配置、不可逆操作的数据依赖):在 HTTP 成功之后、驱动核心业务之前必须运行时形状校验zod / valibot / 手写守卫等,工具以 README 为准)。校验失败须走错误分支,禁止当成功路径继续渲染或提交。
  • 禁止仅依赖 TypeScript as 或「猜字段」消费上述载荷。

二、接口与响应契约(以 README 为准)

本节不写死具体数值或字段名;唯一事实来源为仓库 README.md 中与后端/API 相关的约定(成功码、失败形态、错误提示字段、不存在资源时的 HTTP 行为等)。若契约写在其它文件,README 须指向该文件,AI 与开发者实现前必须先读

必须遵守的纪律(与具体码值无关)

  • 禁止在未阅读 README 契约的情况下,凭猜测拼接「可能是 list/data/数组」来使用响应。
  • 必须按 README 约定区分:HTTP/网络层失败业务层失败(含各自如何提示用户);二者不得混用一种处理方式导致重复提示或静默失败。
  • 若响应体含业务成功/失败标识(如 codesuccess 等,以 README 为准):须在能访问到该字段时按 README 判断;禁止在失败语义下仍把载荷当成功数据驱动核心 UI。
  • 若 HTTP 客户端 拦截器 在失败时 reject、成功时 unwrap 只返回 data:调用方仍须理解「等价于已在拦截器侧按 README 判定成功」,禁止在契约未澄清时假设 unwrap 结果一定可用。
  • 禁止假设列表载荷一定为数组;须 Array.isArray 或 README 约定的类型守卫后再迭代。
  • README 缺失或契约含糊:应先补写 README 或与维护者对齐后再写接口消费代码,禁止长期依赖未文档化的口头约定。

三、安全与数据边界(必选)

  • 密钥与凭证禁止在源码、规则、注释、提交记录中硬编码 token、密码、私钥;使用环境变量或构建注入;含秘密的 .env* 不得提交版本库。
  • 配置与环境变量禁止在业务代码中写死生产基址、环境差异大的开关等;须从 构建时环境变量README 约定的运行时配置接口读取。仓库必须提供可提交的 .env.example(或 README 中的变量表),列出变量名、含义、是否必填与占位示例不得包含真实秘密;新增配置须先更新 example/文档再写代码(变量前缀如 VITE_* 等以 README 为准)。
  • XSS:用户可控内容须按项目方案处理(默认转义、v-html 与富文本须白名单/消毒);禁止为省事绕过。
  • 权限与敏感数据:无权限能力须隐藏或禁用(实现方式依项目);展示侧对手机、证件等 脱敏
  • 请求与输入:不信任仅前端校验;前端校验只改善体验,不作为安全边界依据。
  • 依赖禁止引入来源不明的脚本或外链处理敏感数据;依赖须可审计。
  • 日志与可观测性禁止向控制台、日志平台、错误上报 payload 输出 token、密钥、完整 Cookie、未脱敏 PII;须使用 README 约定的统一上报/日志封装(若项目尚未接入,至少集中一处可替换封装,禁止业务层散落裸 console 作为唯一手段)。生产构建的 source map 策略(是否上传、是否仅内网)以 README/运维为准。
  • Vue 运行时错误渲染、setup、组合式 API 中发生的未捕获错误,须有统一处理入口(如应用级 app.config.errorHandler、布局/根组件 onErrorCaptured 等,具体写法以 README 与项目入口为准)。禁止console.error 而无 用户可感知提示或与 统一上报/日志封装 的衔接,导致生产白屏或客诉无据可查;禁止在业务侧随意 try/catch 吞掉错误且不上报、不提示。
  • 埋点 / 上报 / 监测类功能禁止向第三方或日志系统上报 token、密钥、完整 Cookie、未脱敏 PII;事件名与 payload 宜 类型化,采集逻辑与页面展示 解耦(独立模块或 composable),避免每个按钮复制粘贴上报代码。

四、文件上传与下载(Blob)

具体大小、类型、个数、是否分片README 为准;本节为通用纪律。

  • 下载(Blob / 文件流)禁止在未按 README/拦截器约定确认「响应为预期文件」时直接触发保存;若错误响应可能被包装为 200 + JSON/HTML,须按项目约定检查 Content-Type、魔术字节或解析探测禁止将错误页当文件落盘且无提示。
  • 上传:选择文件后、发起请求前必须校验 大小、个数、允许的 MIME/扩展名(阈值与清单以 README 为准);超限须明确提示禁止无任何校验直接转发任意文件体。
  • 大文件或长传:必须支持或与 README 一致地实现 取消(如 AbortController 及与 组件卸载 一致的清理,避免泄漏与无效回调。

五、根目录工程基座与质量工具(通用)

以下文件通常位于仓库根目录(或 packages/<app>/ 根目录 in monorepo),与业务域无关,可在 Vue 3 + TypeScript 项目间整套拷贝再按 README 微调具体规则条目以各文件内容为准;本节规定必须具备哪些类工具、职责分工与纪律

1. 为何通用

  • 同一套工具链保证:任意成员与 AI 产出的代码在 风格、可疑模式、类型、样式、提交说明 上与仓库一致,减少「仅本地能过、他人一拉就红」的摩擦。
  • 拷贝到新项目时:优先保留 文件名与脚本约定(如 lint:eslintlint:stylelint),替换 extends/插件版本 即可,不必重发明目录里的哲学。

2. 建议具备的根目录配置(按职责)

类别 常见文件名 职责(通用)
ESLint .eslintrc.cjs / .eslintrc.jseslint.config.*.eslintignore JS/TS/Vue 逻辑:未使用变量、any 滥用、Vue 最佳实践、与 TS 解析器配合等。
Prettier prettier.config.*.prettierrc.prettierignore 格式化(引号、换行、尾逗号);与 ESLint 重复规则时须在配置中 分工(常见:eslint-config-prettier 关掉格式冲突)。
Stylelint stylelint.config.*.stylelintignore Vue SFC 中 <style>、SCSS/Less/CSS 的质量与约定(顺序、禁止未知 @ 规则等)。
TypeScript tsconfig.jsontsconfig.app.json 编译选项与路径别名;与 vue-tsc / Vite 共用。
Vue 类型检查 脚本如 vue-tsc --noEmit SFC 与类型;是否进 CI 由 README 说明。
Commitlint commitlint.config.* 提交信息格式(常与 Conventional Commits 一致:feat:fix: 等),便于 changelog 与 code review。
Git 钩子(可选) huskylint-staged 提交前对暂存区跑 eslint/prettier/stylelint,缩短反馈环。
编辑器(可选) .editorconfig.vscode/extensions.json / settings.json 推荐 缩进、换行符;推荐安装 ESLint、Prettier、Vue 等扩展,不强制提交个人 IDE 全局设置。

3. 与「单事实来源」的关系

  • Lint/Format 的细则:以仓库内 实际配置文件 为准;本文件逐条抄录规则 ID。Commitlinttype-enumsubject-* 等以 commitlint.config.* 为准(第五节 §7 为说明与默认表)。
  • README 建议补充:当前项目启用了哪些命令(如 pnpm lint:eslint)、CI 是否强制执行、Commitlint 是否与 husky 绑定。新成员与 AI 实现前可一眼看到。

4. 对开发与 AI 的纪律

  • 禁止为图省事在业务代码中 大面积 eslint-disable / prettier-ignore;确需关闭须 窄范围 + 注释说明原因
  • 新增代码不得 无故抬高 既有告警级别或引入与 Prettier 冲突的手写格式(在已有 format 脚本的前提下)。
  • 提交信息须符合 Commitlint(若仓库已配置),格式与 type 表见第五节 §7禁止随意使用未出现在 type-enum 中的 type(除非先改配置与团队约定)。
  • 不要求每次 AI 任务末尾必须本地执行 lint(与历史约定一致);但交付的代码在风格与规则上应视为能通过当前仓库配置。

5. 新仓库最小清单(可复制检查表)

  • ESLint(含 Vue + TypeScript 支持)+ .eslintignore
  • Prettier + 与 ESLint 不打架的集成
  • Stylelint(若使用 SFC/SCSS)+ .stylelintignore
  • tsconfig + 构建/IDE 一致
  • (推荐)Commitlint + Conventional Commits
  • (推荐)lint-staged + husky
  • README 中 一行说明:pnpm lint:* 或等价命令

6. 依赖与 lockfile(通用)

  • 禁止为解决同一类问题并列引入多套职能重叠的主流基础库(如多套路由级 HTTP 封装、多套日期库),除非 README 说明迁移中或边界用途。
  • 可复现安装:须将 lockfilepnpm-lock.yamlpackage-lock.jsonyarn.lock 等,以项目为准)纳入版本控制禁止在无团队约定的情况下删除 lockfile 或长期依赖未锁版本的 latest / * 拉取生产依赖。
  • 主版本与破坏性升级:升级前须阅读 CHANGELOG / Release Notes,评估构建与运行时影响;禁止在未同步 README 或迁移说明 的情况下大范围跳主版本。
  • 漏洞审计:按团队节奏执行 pnpm audit / npm audit(或 CI),对已知高危依赖按流程修复或记录经批准的例外。

7. Commitlint 与约定式提交(通用)

  • 定位:与 Conventional Commits 对齐,便于 changelog、发版、Code Review 扫一眼知意图。具体校验规则以仓库根目录 commitlint.config.*(或 package.jsoncommitlint 字段)为唯一事实来源;本节为推荐形态与常见 type 表,若配置与本文不一致以配置为准
  • 推荐脚手架extends: ['@commitlint/config-conventional'],再按团队收紧或扩展 rules
  • 标题格式(最常见)<type>[(optional scope)]: <subject>
    • 空行后可有 body破坏性变更可用 ! 置于 type 后,或在 footer 写 BREAKING CHANGE:(与 Conventional Commits 一致,具体以所用 preset 为准)。
  • type(须小写、非空):以下为跨项目推荐枚举(与常见 config-conventional + 团队扩展一致);若仓库删减或新增 type,须同步改 commitlint.config 并在 README 一句说明。
type 含义(简述)
feat 新功能
fix 缺陷修复
docs 文档
style 纯格式(不影响代码含义)
refactor 重构(非 feat 非 fix)
perf 性能优化
test 测试相关
chore 构建、辅助工具、杂项
revert 回滚提交
build 构建系统或外部依赖
ci CI 配置
  • subject(主题)禁止为空;禁止以英文句号 . 结尾(与常见 subject-full-stop 规则一致);宜 简洁、祈使语气(如「fix 登录态丢失」而非「fixed」)。主题大小写是否约束以 commitlint.configsubject-case 为准。
  • 对 AI / 开发者的纪律:生成 commit message 时使用已配置type禁止编造未出现在 type-enum 中的类型(如随意 updatewip),除非团队已写入配置。禁止整句粘贴进 subject 过长无重点;较大说明放 body
  • 与 Git 钩子:若使用 husky 等在 commit-msg 调用 commitlint,本地失败即修正后再提交,避免 CI 与本地不一致。

六、建议性规范(推荐,提升上限)

在已满足第一~五节前提下,用于可读性、复用性与演进成本。

1. 组合式函数(Composables)

  • 导出命名 useXxx;单 composable 围绕一个清晰用例(如分页列表、表单草稿、上报通道初始化)。
  • 变厚时迁入异步与README 所载数据契约拆展示子组件;对外暴露 ref / computed / 方法,实现细节可隐藏。
  • 返回值用普通对象便于解构;需保护的可 readonly() 或仅暴露方法。
  • 重复 ≥2 处的「请求 + 状态 + 错误处理」再提取,避免过早抽象。

2. 计算属性、方法与模板

  • 模板保持简洁;复杂判断与数据处理进 computed 或纯函数。
  • 可缓存的派生状态用 computed;依赖事件入参的用方法。
  • 避免深层嵌套三元,改用命名 computed 或早返回函数。

3. 常量与魔法值

  • 与后端或领域约定的字面量用 as const / 枚举 / 命名常量 集中管理。
  • 格式化、脱敏等与 UI 框架无关的逻辑进 utils 或独立模块,便于测试。
  • 除广泛约定的数字外,魔法数应有命名(如 DEBOUNCE_MS)。

4. 副作用:watch / 生命周期

  • 优先 watch 显式数据源;慎用无依赖清单的 watchEffect
  • deep: true 仅在有必要时开启,并评估性能与循环触发。
  • 异步请求须处理 竞态(序号、AbortControlleronScopeDispose 等与项目一致);禁止在组件销毁后仍写入状态。
  • 多个并行请求可统一 loading,避免 UI 闪动不一致。

5. 组件契约

  • defineProps / defineEmits 使用 TypeScript 形态,事件名与载荷类型明确。
  • 属性透传遵循 Vue 3 $attrs 惯例,避免重复声明。

6. 注释与命名

  • 注释解释 「为什么」;导出模块可简述 用途与前置条件
  • 命名用完整语义;布尔用 is/has/should;与外部 API 字段映射处可简短标注。

7. 性能(按需)

  • 非首屏路由与大体积依赖:懒加载(异步组件或路由级)。
  • 大列表:虚拟列表或等价方案;搜索输入 防抖/节流
  • 稳定且昂贵的子树可按文档评估 v-memo;大对象可考虑 shallowRef / markRaw(明确场景)。

8. 表单与本地状态

  • 校验规则宜 片段复用(常量或工厂函数),减少复制。
  • 使用 localStorage / sessionStorage 等时:键名集中定义敏感数据不落明文(与第三节一致)。

9. 横切能力(埋点、监控、配置中心)

  • 采集与展示分离:上报、轮询探针、SDK 初始化等放在 独立模块 + composable,页面只调用稳定 API(如 track(event, payload)),payload 有类型定义
  • 失败可感知:上报失败宜有策略(重试队列、降级、采样),避免静默丢数且无据可查(实现深度依产品,本节为方向性要求)。
  • 配置与开关:环境相关配置从 环境变量或运行时配置 注入,禁止写死环境差异大的 URL/开关。

10. 可访问性(a11y)

  • 表单与可编辑控件:须有可感知的 标签关联label/aria-labelledby 等,与所选 UI 库推荐写法一致)。
  • 对话框、抽屉等浮层:打开后键盘焦点可进入浮层内控件,关闭后不将焦点留在已卸载节点(优先使用组件库内置行为;若自研须按 README 约定实现)。
  • 禁止为省事移除组件库自带的 语义角色、焦点管理 等可访问性属性;关键路径应键盘可达(与 README 中 a11y 等级目标一致)。

11. 编码与可读性细节(函数、数据边界、组件)

以下提升 可读性与维护性,与第一节「硬性禁止」及第三节「异常」配合;偏 Vue / TypeScript 成熟工程习惯(含与 Vue 生态相近的写法),不绑定具体 UI 库。

  • 函数参数:形参较多(约 ≥4)或含多个可选/布尔开关时,优先 options 对象 + interface/type 标注,避免一长串 positional 参数难以记忆与传错序。
  • 布尔参数避免 foo(a, true, false, true) 无自解释性;改用 对象字段名(如 { immediate: true })或 字面量联合(如 mode: 'sync' | 'async')。
  • 控制流:对 null/非法输入/无权限 等先 早返回 或独立分支,再写主流程;避免四层以上嵌套 if 才把「正常路径」写在最深处。
  • 纯函数命名:数据整形、解析、缺省填充可用 normalize*resolve*ensure*create* 等前缀,使调用处一眼知「在做什么」。
  • 接口数据边界:在 api 返回之后、进入多个视图之前(或在专用 composable 内)将响应 规范化为内部类型/结构,减少模板与业务里重复的 ?. 链与形状猜测(与第二节 README 契约一致)。
  • 异步 UI 状态:优先 status 判别联合(如 'idle' | 'loading' | 'success' | 'error')或等价 有限状态避免多个独立 booleanloading/error/empty)易不同步。
  • 魔法比较:除 README 已约定的 业务码 等外,业务类型、Tab 索引等禁止裸数字/裸字符串散落;用 命名常量、as const 映射或枚举
  • 组件职责展示props + emit 为主;请求、路由副作用、与后端的契约判断放在 父组件或 composable;易变或重绘频繁的区块 拆子组件 并保证 稳定 key(与第一节体量约束一致)。
  • 复杂初始状态:表单/大对象初始值用 createEmptyXxx() 或小工厂,避免在多个组件复制同一默认对象字面量导致引用共享 bug。
  • 开发态代码:断言、冗长调试日志、仅开发可见的校验放在 import.meta.env.DEV(或 README 约定的等价开关)内,避免无分支地打进生产包体积与噪音(与第三节「日志」衔接)。
  • 分支与异常:业务上「失败 / 空数据 / 取消」分支 显式命名(如 if (!ok) return + 注释或封装 handleXxxError),禁止把成功逻辑与错误处理搅在同一深层块中;catch 空吞、仅 console 仍须遵守 第一节与第三节

七、与项目级补充文档的关系

  • 本文件为 跨项目通用下限(含目录职责与依赖方向)。
  • README.md(及其中链接的契约文档)API、配置前缀、文件限制、上报方式、a11y 目标lint/CI 命令入口等的权威来源,优先于本文件对具体实现的历史记忆。
  • 根目录 *.config.*.eslintrc.*tsconfig*.json具体规则条目的权威来源(见第五节)。
  • 各仓库项目级规则(若有)该仓库路径别名、脚手架特有目录、业务域名的权威来源,与本文「目录与模块边界」配合使用。
  • 各仓库可另有 项目级规则(目录、UI 库、i18n、路由命名等)。冲突时取更严者;业务脚手架以项目文档为准。

八、后续可迭代条目(非必须)

可按需在副本中增补:provide/inject 类型策略、全局状态库边界、路由守卫与鉴权细则、E2E/单测策略;提交与分支规范若未用 Commitlint,可在 README 单独约定。

为什么不要让LLM帮我们写文档

这篇文章是今天看到的,觉得还不错,翻译一下。

当我们在写文档或文章时,实际上是在提出问题并回答它。例如,一份产品需求文档(PRD)回答的是“我们要干嘛?”;一份技术规范(Tech Spec)回答的是“我们要怎么做?”。有时,问题本身更难回答——“我们究竟想要达到什么目标?”而每一次尝试回答,其实都是在反思自己是否问对了问题。

但现在,我们有了大语言模型(LLM),由 LLM 生成的文档、文章越来越多,我们需要注意这其中可能有一定的风险:每一份由 LLM 生成的文档,可能都会让我们错失一次思考或者是建立合作信任的机会。

一、让 LLM 替我们写作,就像付费健身

写作的目标不是“写完”,而是“增进理解”——首先是对自己的理解,其次是对周围环境的理解。当我们被委派去写东西时,我们的任务是深入迷雾,去征服未知,带着清晰的结构和洞察走出来。

写作的另一个目标可能是让自己变得更强。这就像健身:每一次对自我极限的挑战,都会促进力量的增长,过程是痛苦且费力的,但是我们能得到正反馈。相反,让 LLM 替我们写作,就像付费健身,虽然很舒适,却无法提升自己的能力(认知或者是写作)。

二、LLM 生成的文字不仅破坏了表达的真实性,也破坏了背后思考的真实性

LLM 生成的文档,还会对我们的公信力产生一定的影响。当我发给别人一份带着明显“AI 味”的文档时,只是在证明 LLM 产出了一些接近他人想听的内容,而不能证明我深入思考过这些东西,这明显不是我的思考产物。

当与别人就这份文档内容进行交流时,我们可能不知所云,这在一定程度上会降低别人对我们的信任,更有甚者,可能因为这份信任的丧失,我们会失去一次机会。

LLM 生成的文字不仅破坏了表达的真实性,也破坏了背后思考的真实性。如果文字是自动生成的,那么观点是否也是自动生成的呢?

三、LLM 在写作过程中的正确用途

LLM 在调研和检查工作时非常有用,它们很擅长快速记录信息或转录文本。

它们尤其擅长生成创意,在这个场景下,它们表现卓越,因为即使生成了 10 个点子而只有一个有用,也无伤大雅。我们可以取其精华,去其糟粕。

因此,可以说,大模型能提高软件交付的效率,但为了最大限度地利用它们,我们必须同时提升我们的思想深度。

参考文献:

alexhwoods.com/dont-let-ai…

前端项目开发/维护中降低成本的方式之一:降低耦合度

问题背景:

随着项目功能不断迭代,公共方法或通用组件内部的判断逻辑会越来越多,导致代码臃肿、后期维护成本急剧上升。
典型场景:网络请求返回统一结果(如登录失效、参数错误),但不同页面需要执行不同的业务逻辑。如果将所有页面逻辑都耦合在公共方法中,会让公共模块变得庞大且难以维护。

传统方式:

网络请求:

service.interceptors.response.use(
    async (res) => {
        if(res.headers['set-cookie']){
            cookieHeader = res.headers['set-cookie']
        }
        return res.data;
    },
    (err) => {
        return Promise.reject(err);
    }
);

业务功能:

-   A 页面调用接口,把结果存到 **Pinia/Vuex****本地存储**
-   B 页面 **监听状态变化**,才能做处理

现在的解决方法:

采用【事件总线作为中间件】的设计方案,通过发布订阅模式解耦模块。让各业务页面在自身模块内独立监听、处理事件,既不侵入公共逻辑,也不互相影响,从而显著降低系统耦合度,提升代码可维护性。

代码实现:

定义中间件:

/**
 * 事件总线
 *  作用:
 *      1、监听多个事件,并执行相应的操作
 *      2、降低组件之间的依赖关系,避免后续模块的修改导致其他模块的修改
 */

// 定义事件名称,可以添加多个
const EVENT_NAMES = ["API:UN_AUTH", "API:VALIDATE_ERROR"] as const

type EventName = (typeof EVENT_NAMES[number])

class EventEmitter {
    private listeners: Record<EventName, Set<Function>>;

    constructor() {
        this.listeners = EVENT_NAMES.reduce((acc, eventName) => {
            acc[eventName] = new Set();
            return acc;
        }, {} as Record<EventName, Set<Function>>);
    }

    /**
     * 监听事件
     * 加入回调函数,后期触发时执行回调函数将结果返回到实际监听处
     * @param eventName 固定事件名
     * @param listener 监听器函数
     */
    on(eventName: EventName, listener: Function) {
        this.listeners[eventName].add(listener);
    }

    /**
     * 触发事件
     * @param eventName 固定事件名
     * @param args 传递的参数
     */
    emit(eventName: EventName, ...args: any[]) {
        this.listeners[eventName].forEach((listener) => listener(...args));
    }

    /**
     * 移除单个监听器(可选扩展)
     * @param eventName 事件名
     * @param listener 待移除的监听器
     */
    off(eventName: EventName, listener: Function) {
        this.listeners[eventName].delete(listener);
    }

    /**
     * 清空某事件的所有监听器(可选扩展)
     * @param eventName 事件名
     */
    clear(eventName: EventName) {
        this.listeners[eventName].clear();
    }
}

export default new EventEmitter()

页面/组件监听事件处理【回调函数】:

eventEmitter.on("API:UN_AUTH", (res) => {
  console.log("页面的总线",  res)
})

网络请求返回结果处:【以下代码只是模拟使用】

if(response.code === 401){
  eventEmitter.emit('API:UN_AUTH', { message: '未授权'})
}
else if(response.code === 404){ 
  eventEmitter.emit('API:VALIDATE_ERROR', { message: '校验失败'})
}

传统方案和事件总线方案的区别:

1、传统方案

-   耦合度较高,一处修改可能牵连其他模块;
-   网络请求返回的数据,必须在**调用处处理**,如需在其他位置使用,必须先存入状态管理或本地存储;
-   遵循 “谁调用接口,谁处理错误” 的模式,错误只回传到调用方。

2、事件总线方案

-   耦合度更低,修改 A 模块逻辑不会影响其他模块;
-   不需要中转存储,一处触发事件,所有关心该事件的地方都能直接接收数据;
-   遵循 “谁关心这个事件 / 错误,谁就监听处理”,实现真正的解耦与分布式处理。

Electron霸屏功能总结

前言

这一段时间都在弄Electron的霸屏功能,Electron中提供了一个方法setKiosk,我以为他可以帮我解决很多的事情,但是实际上,霸屏的实现还需要我们自己干很多事。总结一下有以下这些事

  1. 触摸板三指和四指手势问题(Windows中)
  2. 多屏幕问题
  3. 系统组合键问题
  4. 鼠标侧键前进后退问题
  5. 共享屏幕问题
  6. 切换用户问题

我们来一一解决

后面有npm包地址,可以直接安装到项目中使用。也有源码仓库地址。

npm包基本都解决了,4要自己解决。

正文

触摸板三指和四指手势问题

检测三指和四指手势值,让用户修改。 但是首先要要确认用户是台式还是笔记本。

可以看一下 juejin.cn/post/761081… 这篇文章

通过当前用户sid,查询触摸板三指和四指手势值为不为0,不为0则通知用户进行修改

如果我们在霸屏的时,触摸板三指和四指手势还能使用,那么就可以利用手势切到第二个桌面。所以我们要把他设置为

image.png

我们修改注册表,在HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\PrecisionTouchPad路径下,有两个值ThreeFingerSlideEnabledFourFingerSlideEnabled对应三指和四指。

image.png

如果你没有这两个值的话,表示你从来没有调过三指和四指的手势,并不影响。

可以通过PowerShell设置值,但是虽然值设置了,但是一直没有生效,试了好几种方法,总结让用户自己设置为

详细代码可以见仓库地址。

多屏幕问题

这个好解决,创建窗口盖住副屏就可以了。

 /**
   * 为所有非主屏创建黑幕覆盖窗口。
   */
  createCovers(): void {
    // 先清理旧副屏窗口,避免重复创建。
    this.disposeCovers();

    // 获取全部显示器列表。
    const displays = screen.getAllDisplays();
    // 获取主显示器。
    const primaryDisplay = screen.getPrimaryDisplay();

    displays.forEach((display) => {
      if (display.id === primaryDisplay.id) {
        return;
      }

      // 为副屏创建覆盖窗口。
      const coverWindow = new BrowserWindow({
        x: display.bounds.x,
        y: display.bounds.y,
        width: display.bounds.width,
        height: display.bounds.height,
        frame: false,
        backgroundColor: "#000000",
        resizable: false,
        focusable: false,
        movable: false,
        skipTaskbar: true,
      });

      // 明确覆盖窗口边界,确保跨屏精准覆盖。
      coverWindow.setBounds({
        x: display.bounds.x,
        y: display.bounds.y,
        width: display.bounds.width,
        height: display.bounds.height,
      });
      coverWindow.setAlwaysOnTop(true, "screen-saver", 1);
      coverWindow.setMenuBarVisibility(false);
      void coverWindow.loadURL("about:blank");

      this.coverWindows.push(coverWindow);
    });
  }
系统组合键问题

不禁用系统组合键的话,用户可以切换窗口。可以看一下这个文章,也是自己写的一个npm包。但是有一些系统组合键是没有办法禁用的,如Ctrl+Alt+Delete(安全选项界面)

juejin.cn/post/753868…

鼠标侧键前进后退问题

修改路由的方式就可以了,我没有页面返回,你返回也没有用。

navigate('/login', { replace: true });
共享屏幕问题

霸屏时不让屏幕被共享,Electron有一个属性

  window.setContentProtection(true);
切换用户问题

上面提到可以进入Ctrl+Alt+Delete(安全选项界面),里面有一个切换用户按钮,这个按钮也可以去掉,这样就没有办法切换用户了。

但是要管理员权限,所以打包时要设置管理员权限。electron-builder设置

"build": {
  "win": {
    "requestedExecutionLevel": "requireAdministrator"
  }
}

详细代码见仓库地址

 /**
   * 设置是否允许显示“切换账户”入口。
   */
  public async setSwitchAccountEnabled(enabled: boolean): Promise<void> {
    try {
      // 非 Windows 平台直接跳过。
      if (process.platform !== "win32") {
        return;
      }

      // 目标注册表路径。
      const regPath =
        "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System";
      // 注册表值:0=允许快速用户切换,1=隐藏快速用户切换。
      const hideFastUserSwitchingValue = enabled ? 0 : 1;
      // PowerShell 脚本(开启 Stop,确保异常可捕获)。
      const psScript = [
        "$ErrorActionPreference = 'Stop'",
        `Set-ItemProperty -Path '${regPath}' -Name 'HideFastUserSwitching' -Value ${hideFastUserSwitchingValue} -Type DWord`,
      ].join("; ");

      this.logger.info(`setSwitchAccountEnabled: ${enabled}`);
      await this.runPowerShell(psScript);
    } catch (error) {
      this.logger.error(
        `Failed to set HideFastUserSwitching: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  }

结尾

npm包地址: www.npmjs.com/package/zt-…

仓库地址 :github.com/lzt-T/zt-el…

AI全栈入门指南:使用 NestJs 创建第一个后端项目

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

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

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

image.png

前两篇把认知铺好了,这一篇进入上手阶段。

如果你是第一次接触 NestJS,最推荐的方式不是手动搭目录,而是直接使用官方 CLI 创建项目。原因很简单,CLI 不只是帮你生成几个文件,它还会顺手把一个标准可运行的项目骨架搭好。这样你第一次接触时,不会一上来就被配置细节打断。

下面的示例统一使用 pnpm。如果你平时用的是 npmyarn,只要把命令替换成自己习惯的包管理器即可。

在终端中执行下面这条命令,就可以创建一个新的 NestJS 项目:

pnpm dlx @nestjs/cli new hello-nest

执行之后,CLI 会提示你选择包管理器。直接选择自己当前项目里最常用的那个就行。如果你平时主要使用 pnpm,这里继续选 pnpm 会更顺手。

这一步完成之后,你会得到一个已经初始化好的项目目录。依赖会自动安装,基础文件也会一并生成,所以不需要你再从零创建入口文件、路由文件和配置文件。

如果你之前没有用过 dlx,可以把它理解成临时拉起来跑一下某个命令行工具,不必先全局安装 @nestjs/cli,用完就走,比较适合第一次体验。

认识默认目录结构

项目创建完成后,先不要急着写代码。更重要的是先看一眼默认目录结构,因为这会直接帮助你理解 NestJS 是怎么组织应用的。

一个刚创建出来的项目,核心目录大致会像下面这样:

20260328094755

第一次看这些文件时,可以先抓最重要的几个:

  • src/main.ts 是应用入口,负责把整个 NestJS 应用启动起来
  • src/app.module.ts 是根模块,用来组织当前应用的基础结构
  • src/app.controller.ts 是控制器,负责接收请求和返回结果
  • src/app.service.ts 是服务层,负责承载具体业务逻辑

你会发现,哪怕只是一个最简单的 Hello World 项目,NestJS 也没有把所有逻辑都塞进一个文件里。它一开始就把入口、模块、控制器、服务拆开了。这正是前面提到的结构约束。

也就是说,NestJS 希望你从第一个项目开始,就用一种更接近真实业务系统的方式来组织代码,而不是先随便写,等项目变大后再重构。

如果你现在只记一句话,可以先记这个:

src/main.ts 负责启动,Module 负责组织,Controller 负责接请求,Service 负责写逻辑。

后面无论项目变得多复杂,这套基础分工都不会变。

启动开发环境

进入项目目录后,就可以把开发环境跑起来了:

cd hello-nest
pnpm run start:dev

这条命令会以开发模式启动项目,并开启监听。也就是说,你修改 src 里的代码后,服务通常会自动重新编译并重启,不需要你每次手动停掉再启动。

项目启动成功后,终端一般会看到类似 Nest application successfully started 的提示。默认情况下,服务会监听 3000 端口。

20260328094827

这时候你可以先用浏览器打开下面这个地址:

http://localhost:3000

如果一切正常,你会看到默认返回的 Hello World!。这说明项目已经跑起来了。

这一步的意义不只是确认环境没问题,更重要的是让你先建立一个非常直接的印象:

一个刚生成出来的 NestJS 项目,本身就是可运行的。

也正因为默认项目能立刻跑起来,后面改代码时,你能很直观地对照改了哪个文件、行为发生了什么变化。

写第一个接口

默认项目已经有一个最基础的接口,只是它的业务非常简单。为了真正理解 NestJS 的组织方式,最好的做法不是新建一堆复杂模块,而是先把这个默认接口改一遍。

先看服务层。把 src/app.service.ts 改成下面这样:

import { Injectable } from "@nestjs/common";

@Injectable()
export class AppService {
  getHello(): { message: string; from: string } {
    return {
      message: "Hello NestJS",
      from: "AppService",
    };
  }
}

这段代码的重点不是返回什么内容,而是让你看到,真正的业务结果是由 AppService 提供的。控制器不直接写死所有内容,而是去调用服务层。

接着修改 src/app.controller.ts

import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get("hello")
  getHello(): { message: string; from: string } {
    return this.appService.getHello();
  }
}

这里有两个点值得第一次接触时特别留意。

第一,@Controller()@Get("hello") 这类装饰器,标的是这个类、这个方法在 HTTP 这一层各自干什么。

第二,控制器通过构造函数拿到 AppService,而不是自己手动 new AppService()。这就是依赖注入最直观的体现。你声明自己需要什么,框架负责把依赖准备好。

改完之后,重新访问下面这个地址:

http://localhost:3000/hello

如果一切正常,你会看到类似下面这样的返回结果:

20260328095005

看到这里,其实你已经完成了自己的第一个 NestJS 接口。虽然它非常简单,但最关键的骨架已经出现了。

从 Hello World 看 NestJS 的基本组织方式

一个最简单的 Hello World 也能看出 NestJS 的分层习惯,请求不会随便落进某个函数,而是按约定往前走。最短路径可以先想成下面五步:

  • 客户端发起请求
  • 路由命中控制器方法
  • 控制器调用服务层
  • 服务层返回业务结果
  • 框架把结果写回客户端

20260401083712

在这五步之上还要叠上两块,根 Module(例如 AppModule)把 ControllerService 登记到同一个模块里,src/main.ts 创建应用实例并监听端口,进程才真正跑起来。接请求、写业务、做装配、拉起监听,是同一条最小链路上的不同环节。

对应到代码里,先记住四个角色就够:

  • Controller 接住请求
  • Service 处理业务
  • Module 把相关角色收进同一个模块
  • src/main.ts 把应用跑起来并监听端口

这和先把逻辑全塞进一个文件再说的写法差别很大,NestJS 从第一个接口就在推你把入口、业务和装配拆开。返回值具体写了哪句文案反而不那么重要,更值得留意的是三件事已经成习惯:入口和业务分开、依赖交给框架装配、结构按角色长而不是按临时想法堆。

把这条轮廓记熟,后面再学模块拆分、参数校验、异常处理、数据库接入,都容易挂回同一套形状里。

小结

第一个 NestJS 项目的重点,不是把服务跑起来本身,而是借这个最小示例看清它的基本骨架。

通过 CLI 创建项目,你拿到的是一套标准初始结构。通过修改默认接口,你能看到 ControllerService、依赖注入和应用入口是如何协同工作的。

如果你现在已经能理解下面这几件事,这一篇的目标就达到了:

  • 如何用 CLI 创建一个 NestJS 项目
  • 默认目录里几个核心文件分别负责什么
  • 怎样启动开发环境并访问默认服务
  • 怎样改出自己的第一个接口
  • 为什么这个最小例子已经体现了 NestJS 的基本组织方式

接下来会从这个最小项目出发,看 Controller 和路由是怎么对应起来的。

深入理解浏览器渲染流程

深入理解浏览器渲染流程

0. 事件循环复习

我们之前总结过:事件循环是主线程的工作方式,每执行完一个宏任务,就清空所有微任务,然后可能渲染页面,再取下一个宏任务。

重点来了:渲染到底是怎么发生的? 这就是本篇文章要讲的内容。


1. 为什么需要了解渲染流程?

你每天都在写 HTML、CSS、JS,但浏览器到底是怎么把它们变成屏幕上像素的?

搞懂渲染流程,你就能明白:

  • 为什么改 left/top 会卡,改 transform 却很丝滑
  • 为什么有些 CSS 属性改了开销大,有些开销小
  • 面试官问“重排重绘”时该怎么答

这是前端性能优化的基础,也是面试必考题。


2. 渲染流程五步走

浏览器拿到 HTML 和 CSS 后,会按顺序做这 5 件事:

步骤 名称 做了什么
1 构建 DOM 树 把 HTML 标签转成树形结构
2 构建 CSSOM 树 把 CSS 规则转成树形结构
3 构建渲染树 合并 DOM 和 CSSOM,过滤掉不可见元素
4 布局(Layout) 计算每个元素的位置和大小
5 绘制(Paint) 把像素画到屏幕上

第 4 步也叫 重排(Reflow),第 5 步也叫 重绘(Repaint)

如下图:

2.1 构建 DOM 树

浏览器从上到下解析 HTML,把标签转成树形结构的 DOM 对象。
例如:

<html>
  <body>
    <div>hello</div>
  </body>
</html>

会变成类似这样的结构(伪代码):

document
  └ html
      └ body
          └ div → text "hello"

注意<script> 标签会阻塞解析,因为 JS 可能修改 DOM。可以加 deferasync 避免阻塞。

2.2 构建 CSSOM 树

浏览器解析 CSS 文件或 <style> 标签内的样式,构建成 CSSOM 树(CSS 对象模型)。
CSSOM 记录了选择器与样式规则的对应关系,以及继承关系(比如 bodyfont-size 会传给子元素)。

CSS 不会阻塞 DOM 树的构建,但会阻塞渲染(因为需要完整的样式才能绘制)。

2.3 构建渲染树(重点)

渲染树 = DOM 树 + CSSOM 树,但会过滤掉不需要显示的东西。

具体操作:

  1. 只保留能看见的元素
    • display: none 的元素不进入渲染树(连占位都没有)
    • <head> 标签里的元素不进入渲染树
    • visibility: hidden 的元素进入渲染树(它占位置,只是看不见)
    • opacity: 0 的元素也会进入渲染树(透明也是可见的一种)
  2. 给每个节点附上计算好的样式
    从 CSSOM 里找到匹配的规则,经过层叠、继承、优先级计算,得到每个节点的最终样式。

示例:

<div style="display: none;">看不见我</div>
<div>看得见我</div>

渲染树里只有第二个 div,第一个直接被丢掉了。

为什么需要渲染树?
因为 DOM 树里有很多不参与页面绘制的节点(headscriptdisplay: none 的元素),直接拿着 DOM 树去布局会浪费性能。渲染树就是“最终要画到屏幕上的东西”的清单。

2.4 布局(Layout / 重排)

遍历渲染树,计算每个元素在屏幕上的精确位置和尺寸(宽、高、x、y)。
比如一个 div 宽度是父容器的 50%,就要算出实际像素值。

触发布局的情况

  • 首次渲染
  • 窗口 resize
  • 修改元素的几何属性**(宽/高/边距/位置)**
  • 添加/删除 DOM
  • 读取某些属性(offsetHeightgetComputedStyle 等)

布局是开销最大的步骤。

2.5 绘制(Paint / 重绘)

把每个元素画成像素:背景、边框、文字、阴影、图片等。
浏览器会把页面分成多个图层,分别绘制,最后合成。

触发绘制的情况

  • 改变背景色、文字颜色、边框颜色等(不影响位置)

3. 重排 vs 重绘(核心重点)

这两个概念必须分清。

对比项 重排(Reflow) 重绘(Repaint)
什么时候发生 改宽高、边距、位置、增删 DOM、改字体等 改颜色、背景、阴影、可见性等
开销 很大(重新计算位置) 中等(只重新涂色)
会触发另一个吗 会,重排一定导致重绘 不会,重绘不一定导致重排
优化建议 尽量避免,或用 transform 替代 可接受,但不要频繁

3.1 代码示例

//  坏:触发重排
box.style.width = '200px'
box.style.height = '200px'
box.style.margin = '10px'

//  好:合并修改,只触发一次重排
box.style.cssText = 'width:200px; height:200px; margin:10px;'

//  更好:用 transform 做动画,完全不触发重排/重绘
box.style.transform = 'translateX(100px)'

4. 哪些操作会触发重排?

  • width / height / margin / padding / border
  • font-size(文字大小影响盒子大小)
  • display(比如 noneblock
  • 添加或删除 DOM 元素
  • 改变窗口大小
  • 读取某些属性:offsetHeightoffsetTopscrollTopgetComputedStyle 等(浏览器被迫立即重排)

最后一条只是读一下,浏览器也得乖乖重排才能给你准确值。所以不要在循环里读这些属性。


5. 如何减少重排?

优化手段 说明
合并样式修改 cssText 或切换 class,不要一条一条改
让元素脱离文档流 position: absolutefixed,它的重排不影响别人
批量插入 DOM documentFragment 先组装好,再一次性插入
动画用 transform transform 走合成线程,不触发重排/重绘
避免读触发布局的属性 不要频繁读 offsetHeight 等,如果必须读,先读好存起来

5.1 批量插入 DOM 示例

//  坏:每次插入都触发重排
for (let i = 0; i < 100; i++) {
  document.body.appendChild(div)
}

//  好:用 fragment 一次性插入
const fragment = document.createDocumentFragment()
for (let i = 0; i < 100; i++) {
  fragment.appendChild(div)
}
document.body.appendChild(fragment)  // 只触发一次重排

6. transform 为什么快?

transform 不走布局和绘制,它直接进入合成阶段,由 GPU 处理。

简单理解:

  • left/top:改位置 → 触发重排 → 重绘 → 合成(主线程干,慢)
  • transform:跳过前两步 → 直接合成(合成线程干,快)

所以做动画时,能用 transform 就别用 left/top

/*  慢 */
.box {
  transition: left 0.3s;
  left: 0;
}
.box.active {
  left: 100px;
}

/*  快 */
.box {
  transition: transform 0.3s;
  transform: translateX(0);
}
.box.active {
  transform: translateX(100px);
}

7. 常见面试题

7.1 重排和重绘的区别?哪个更耗性能?

重排是重新计算位置和大小,开销大;重绘是重新涂色,开销中等。重排一定触发重绘,反之不一定。

7.2 哪些属性会触发重排?

widthheightmarginpaddingborderfont-sizedisplayposition 等。还有添加/删除 DOM、改窗口大小。

7.3 如何避免重排?

  • 合并样式修改
  • 使用 transform 做动画
  • 批量操作 DOM
  • 让元素脱离文档流

7.4 transformleft/top 有什么区别?

left/top 触发布局(重排),慢;transform 只触发合成,由 GPU 处理,快。

7.5 为什么有时候读 offsetHeight 会让页面变慢?

因为浏览器需要立即计算最新的布局才能返回准确值,这会强制重排。如果在循环里读,会反复触发重排,性能极差。


8. 总结一句话

浏览器渲染分五步:DOM 树 → CSSOM 树 → 渲染树 → 布局(重排)→ 绘制(重绘)。
重排慢,重绘快,动画用 transform最流畅。
优化核心:减少重排,合并操作,能用合成就合成。

OpenLayers 地图绘制与交互实战:从零构建一个完整的绘制系统

前言

在 WebGIS 开发中,地图绘制功能是一个常见且重要的需求。本文将基于 OpenLayers 框架,手把手教你构建一个完整的地图绘制系统,包含点、线、面、圆的绘制,要素编辑、选择和删除等功能。通过本文,你将深入理解 OpenLayers 的交互机制和图层管理。

最终效果预览

我们将实现一个具有以下功能的地图应用:

  • 🎯 支持绘制点、线、面、圆
  • ✏️ 支持编辑已绘制的图形
  • 🖱️ 支持点击选择要素
  • 🗑️ 支持删除选中要素
  • 🧲 支持顶点吸附功能
  • 📍 支持 GeoJSON 数据展示

image.png

项目结构

ol-app/
├── main.js              # 入口文件,初始化地图
├── src/
│   ├── drawLayer.js     # 绘制图层核心类
│   └── draw.js          # GeoJSON 图层初始化
├── geojsonObject.js     # GeoJSON 数据
└── style.css            # 样式文件

一、核心类设计:DrawLayer

首先,我们创建一个 DrawLayer 类来封装所有的绘制和交互逻辑。这种封装方式让代码更加模块化,便于维护和复用。

1.1 类结构初始化

import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { Draw, Modify, Snap, Select } from 'ol/interaction';
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
import { click } from 'ol/events/condition';

export class DrawLayer {
  constructor(map) {
    this.map = map;
    
    // 创建矢量数据源
    this.source = new VectorSource();
    
    // 创建矢量图层
    this.layer = new VectorLayer({
      source: this.source,
      style: this.getDefaultStyle()
    });
    
    // 添加到地图
    this.map.addLayer(this.layer);

    // 交互对象
    this.drawInteraction = null;
    this.modifyInteraction = null;
    this.snapInteraction = null;
    this.selectInteraction = null;
    this.selectedFeature = null;
  }
}

关键点解析:

  • VectorSource:存储所有绘制的要素数据
  • VectorLayer:负责将要素渲染到地图上
  • 各种 Interaction:OpenLayers 的交互对象,分别处理绘制、编辑、吸附、选择等功能

1.2 样式定义

/**
 * 获取默认样式
 */
getDefaultStyle() {
  return new Style({
    fill: new Fill({
      color: 'rgba(255, 255, 255, 0.2)'
    }),
    stroke: new Stroke({
      color: '#ffcc33',
      width: 2
    }),
    image: new CircleStyle({
      radius: 7,
      fill: new Fill({
        color: '#ffcc33'
      })
    })
  });
}

/**
 * 获取选中样式
 */
getSelectStyle() {
  return new Style({
    stroke: new Stroke({ color: 'red', width: 3 }),
    fill: new Fill({ color: 'rgba(255, 0, 0, 0.2)' }),
    image: new CircleStyle({
      radius: 7,
      fill: new Fill({ color: 'red' })
    })
  });
}

二、绘制功能实现

2.1 开始绘制

/**
 * 开始绘制
 * @param {string} type - 绘制类型: 'Point', 'LineString', 'Polygon', 'Circle'
 * @param {Function} callback - 绘制完成回调
 */
startDraw(type, callback) {
  // 清除之前的绘制交互
  this.stopDraw();
  // 禁用选择模式,避免冲突
  this.disableSelect();

  this.drawInteraction = new Draw({
    source: this.source,
    type: type
  });

  this.drawInteraction.on('drawend', (event) => {
    const feature = event.feature;
    if (callback) {
      callback(feature);
    }
  });

  this.map.addInteraction(this.drawInteraction);

  // 添加吸附功能
  this.snapInteraction = new Snap({
    source: this.source
  });
  this.map.addInteraction(this.snapInteraction);
}

技术要点:

  • Draw 交互会自动将绘制的要素添加到指定的 source 中
  • Snap 交互让新绘制的点可以吸附到已有要素的顶点上,提高精度
  • 绘制前需要停止其他交互,避免冲突

2.2 停止绘制

/**
 * 停止绘制
 */
stopDraw() {
  if (this.drawInteraction) {
    this.map.removeInteraction(this.drawInteraction);
    this.drawInteraction = null;
  }
  if (this.snapInteraction) {
    this.map.removeInteraction(this.snapInteraction);
    this.snapInteraction = null;
  }
}

三、编辑功能实现

3.1 启用编辑

/**
 * 启用编辑模式
 */
enableModify() {
  this.disableModify();
  this.disableSelect();

  this.modifyInteraction = new Modify({
    source: this.source
  });
  this.map.addInteraction(this.modifyInteraction);
}

/**
 * 禁用编辑模式
 */
disableModify() {
  if (this.modifyInteraction) {
    this.map.removeInteraction(this.modifyInteraction);
    this.modifyInteraction = null;
  }
}

Modify 交互允许用户拖拽要素的顶点来编辑图形形状。

四、选择功能实现

4.1 启用选择模式

/**
 * 启用选择模式
 */
enableSelect() {
  this.disableSelect();
  this.stopDraw();
  this.disableModify();

  this.selectInteraction = new Select({
    layers: [this.layer],
    style: this.getSelectStyle(),
    multi: true,
    toggleCondition: click
  });

  // 监听选择事件
  this.selectInteraction.on('select', (e) => {
    const selected = e.selected;
    if (selected.length > 0) {
      this.selectedFeature = selected[0];
      console.log('选中要素:', this.selectedFeature);
    } else {
      this.selectedFeature = null;
    }
  });

  this.map.addInteraction(this.selectInteraction);
}

重点解析:

  • layers: [this.layer]:指定可选中的图层,使用像素检测实现精确选择
  • multi: true:允许多选
  • toggleCondition: click:点击切换选中状态,无需按住 Shift 键

4.2 禁用选择

/**
 * 禁用选择模式
 */
disableSelect() {
  if (this.selectInteraction) {
    this.map.removeInteraction(this.selectInteraction);
    this.selectInteraction = null;
    this.selectedFeature = null;
  }
}

五、要素管理

/**
 * 移除选中的要素
 */
removeSelectedFeature() {
  if (this.selectedFeature) {
    this.source.removeFeature(this.selectedFeature);
    this.selectedFeature = null;
    return true;
  }
  return false;
}

/**
 * 清除所有绘制
 */
clear() {
  this.source.clear();
  this.selectedFeature = null;
}

/**
 * 获取所有绘制的要素
 */
getFeatures() {
  return this.source.getFeatures();
}

/**
 * 移除指定要素
 */
removeFeature(feature) {
  this.source.removeFeature(feature);
  if (this.selectedFeature === feature) {
    this.selectedFeature = null;
  }
}

六、工具栏创建

export function createDrawToolbar(container, drawLayer) {
  const toolbar = document.createElement('div');
  toolbar.className = 'draw-toolbar';
  toolbar.innerHTML = `
    <button data-type="Point" class="draw-point">点</button>
    <button data-type="LineString" class="draw-line">线</button>
    <button data-type="Polygon" class="draw-polygon">面</button>
    <button data-type="Circle" class="draw-circle">圆</button>
    <button id="modify-btn" class="draw-modify">编辑</button>
    <button id="clear-btn" class="draw-clear">清除</button>
    <button id="remove-btn" class="draw-remove">移除</button>
    <button id="select-btn" class="draw-select">选择</button>
  `;

  let isModifying = false;
  let isSelecting = false;

  toolbar.addEventListener('click', (e) => {
    // 绘制按钮
    if (e.target.classList.contains("draw-point")) {
      drawLayer.startDraw("Point");
    }
    if (e.target.classList.contains("draw-line")) {
      drawLayer.startDraw("LineString");
    }
    if (e.target.classList.contains("draw-polygon")) {
      drawLayer.startDraw("Polygon");
    }
    if (e.target.classList.contains("draw-circle")) {
      drawLayer.startDraw("Circle");
    }
    
    // 编辑按钮
    if (e.target.classList.contains("draw-modify")) {
      isModifying = !isModifying;
      if (isModifying) {
        drawLayer.enableModify();
        e.target.textContent = "完成";
      } else {
        drawLayer.disableModify();
        e.target.textContent = "编辑";
      }
    }
    
    // 清除按钮
    if (e.target.classList.contains("draw-clear")) {
      drawLayer.clear();
    }
    
    // 移除按钮
    if (e.target.classList.contains("draw-remove")) {
      if (drawLayer.removeSelectedFeature()) {
        console.log("移除成功");
      } else {
        console.log("没有选中的要素");
      }
    }
    
    // 选择按钮
    if (e.target.classList.contains("draw-select")) {
      isSelecting = !isSelecting;
      if (isSelecting) {
        drawLayer.enableSelect();
        e.target.textContent = "退出选择";
      } else {
        drawLayer.disableSelect();
        e.target.textContent = "选择";
      }
    }
  });

  container.appendChild(toolbar);
  return toolbar;
}

七、主入口文件

import './style.css';
import {Map, View} from 'ol';
import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';
import Overlay from 'ol/Overlay';
import {fromLonLat} from 'ol/proj';

import {geojsonObject1} from '/geojsonObject.js';
import { initDrawLayer } from './src/draw.js';
import { DrawLayer, createDrawToolbar } from './src/drawLayer.js';

// 1. 创建基础绘制图层(显示 GeoJSON 数据)
const vectorLayer = initDrawLayer(geojsonObject1, {
  point: {
    radius: 8,
    fillColor: 'red',
    strokeColor: 'white',
    strokeWidth: 2
  },
  line: {
    color: 'blue',
    width: 4,
    lineDash: [10, 10]
  },
  polygon: {
    fillColor: 'rgba(0, 255, 0, 0.3)',
    strokeColor: 'green',
    strokeWidth: 2
  }
});

// 2. 创建地图
const map = new Map({
  target: 'map',
  layers: [
    new TileLayer({
      source: new XYZ({
        url: 'https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}'
      })
    }),
    vectorLayer
  ],
  controls: [],
  view: new View({
    center: fromLonLat([116.4074, 39.9042]),
    zoom: 15
  })
});

// 3. 创建用户绘制图层
const drawLayer = new DrawLayer(map);

// 4. 创建绘制工具栏
const toolbarContainer = document.createElement('div');
toolbarContainer.id = 'draw-toolbar-container';
document.body.appendChild(toolbarContainer);
createDrawToolbar(toolbarContainer, drawLayer);

// 5. 创建 popup 容器
const popupContainer = document.createElement('div');
popupContainer.className = 'popup';
const popupOverlay = new Overlay({
  element: popupContainer,
  positioning: 'bottom-center',
  offset: [0, -15]
});
map.addOverlay(popupOverlay);

// 6. 点击事件 - 显示要素信息
map.on('click', (evt) => {
  const feature = map.forEachFeatureAtPixel(evt.pixel, (feat) => feat);
  if (feature) {
    const props = feature.getProperties();
    popupContainer.innerHTML = `<b>${props.name}</b><br>类型: ${props.type}`;
    popupOverlay.setPosition(evt.coordinate);
  } else {
    popupOverlay.setPosition(undefined);
  }
});

八、关键技术点总结

8.1 Source vs Layer

特性 Source Layer
作用 数据存储 可视化渲染
关系 被 Layer 引用 引用 Source
类比 数据库 表格组件

8.2 交互优先级

OpenLayers 的交互是按照添加顺序执行的,后添加的优先级更高。因此需要合理管理交互的启用和禁用:

// 启用新交互前,先禁用冲突的交互
startDraw() {
  this.stopDraw();      // 停止之前的绘制
  this.disableSelect(); // 禁用选择,避免冲突
  // ... 创建新交互
}

8.3 多选实现

多选的关键在于 toggleCondition 配置:

const select = new Select({
  multi: true,
  toggleCondition: click  // 点击切换选中状态
});

如果不设置 toggleCondition,默认需要按住 Shift 键才能多选。

九、扩展思路

  1. 导出 GeoJSON:使用 GeoJSON format 将绘制的要素导出
  2. 撤销重做:维护操作历史栈,实现撤销重做功能
  3. 样式编辑器:提供 UI 让用户自定义绘制样式
  4. 测量工具:计算绘制图形的面积和长度

十、完整代码

本文的完整代码已开源,你可以在 GitHub 上找到: github.com/yourname/ol…

结语

通过本文,我们实现了一个功能完整的 OpenLayers 绘制系统。核心思想是将功能封装成独立的类,通过交互对象管理用户操作,使用 Source-Layer 模式管理数据。希望本文对你有所帮助,如果有任何问题,欢迎在评论区讨论!


参考链接:

前端表单构建神器 - formkit初体验

传统表单开发 vs 低代码方案

传统的表单开发,无论是基于dom还是数据驱动的,都离不开手写html模板。尤其对于复杂的表单:关联字段联动、校验、表单字段的排版等等都有相当大的工作量。为此近些年涌现出不少的低代码方案,旨在通过页面拖拽配置的形式来高效的维护表单功能,来代替繁重的代码开发维护。基于JSON Schema的表单构建方案就是在这个背景下诞生,而具有代表性的就是本文要介绍的FormKit

FormKit项目初始化

准备源码路径:D:\2026学习\study\code\formkit示例\001_formkit项目初始化\parent
右键parent,用idea打开

20260410112337.png

20260410112551.png

自动完成依赖安装更新

20260410113807.png

看下默认安装的依赖:

"dependencies": {  
  "@formkit/core": "^2.0.0",  
  "@formkit/icons": "^2.0.0",  
  "@formkit/themes": "^2.0.0",  
  "@formkit/vue": "^2.0.0",  
  "@tailwindcss/vite": "^4.2.2",  
  "tailwindcss": "^4.2.2",  
  "vue": "^3.5.32"  
}

formkit不光是UI框架,更是开箱即用的json schema渲染表单的解决方案。对于UI,formkit直接用Tailwind来构建和维护组件样式。

修复类型引入问题

20260410114017.png

创建pnpm启动项

20260410114231.png

运行dev,访问:http://localhost:5173/,将看到页面:

20260410114451.png

组件的渲染方式

有两种方式:html中编写组件标签和基于schema的集中维护定义。
前者属于传统的组件使用方式,大部分场景下我们的表单开发都是直接用开源组件库如element plus,来编写和维护表单,FormKit也支持这个方式,它提供了内置的常用表单组件,同时提供了非常好的机制让我们扩展自定义组件,包括集成现有的UI组件。

组件定义方式

直接写组件标签,类似于使用Element Plus中的组件来手动构建表单:

<FormKit
  type="form"
  #default="{ value }"
  @submit="submit"
>
  <FormKit
type="text"
name="name"
label="Name"
help="..."
  />
  <FormKit
type="checkbox"
name="flavors"
label="..."
:options="{ ... }"
validation="required|min:2"
  />
  
  <FormKit
type="checkbox"
name="agree"
label="..."
  />
  ...
</FormKit>

基于Schema的定义方式

这种方式方便集中维护表单字段定义,FormKit可以基于表单定义的Schema动态的渲染表单,是低代码表单设计器的构建产物。有了它,我们只要关注于字段配置的扩展以及如何设计和实现表单设计器来在线生成表单定义数据。

<script setup lang="ts">
import {ref} from "vue"

const formSchema = {
  $formkit: 'form',
  children: [{
    $formkit: 'text',
    name: 'name',
    label: 'Name',
    help: '...',
  },
  {
    $formkit: 'checkbox',
    name: 'flavors',
    label: 'Favorite ice cream flavors',
    options: { ... },
    validation: 'required|min:2',
  },
  {
    $formkit: 'checkbox',
    name: 'agree',
    label: '...',
  },
]}

const data = ref({})

async function submit() {
  await new Promise(r => setTimeout(r, 1000))
  alert('Submitted! 🎉')
}
</script>

<template>
  <div class="...">
    <img ...>
    <FormKitSchema :schema="formSchema" v-model="data" @submit="submit" />
    <pre class="...">{{ data }}</pre>
  </div>
</template>

校验

FormKit提供了非常强大的内置校验和自定义扩展的方式,具体可参考校验官方文档
示例中对一个字段启用非空和长度校验非常简单,比如这里的多选框字段,只需简单配置为:validation: 'required|min:2',页面效果:

20260410152500.png

FormKit支持国际化,只需要在formkit.config.ts中进行如下配置:

...
import { zh } from '@formkit/i18n'
const config: DefaultConfigOptions = {
  ...
  locales: { zh },
  locale: 'zh',
}
export default config

会看到系统的校验信息变成了中文

20260410153511.png

官方文档

以上我们的介绍只是FormKit功能特性的九牛一毛,具体的API用法配置请参考FormKit官方文档。个人觉得看了那么多技术文档,FormKit无论是可读性和用户体验都是非常好的,唯一的遗憾是没有中文版。后续的例子也都会从官方文档来扩展。

20260410155356.png

好了,本次的学习分享就到这里。希望本篇能给前端低代码研发的小伙伴一些启示,我是小卷,一个爱学习分享的搬砖老码农,我们下期再见!

ESTree 规范 (acorn@8.15.0示例)

ESTree 是一套用于描述 ECMAScript(JavaScript)代码抽象语法树(AST)的标准化规范。ESTree 规范并非一成不变,而是跟随 ECMAScript 官方版本迭代,分为多个阶段的规范:

  • ES5 规范:最早的 ESTree 规范,仅支持 ES5 语法(如 var、普通函数、if/for 等)。
  • ES6+ 规范:新增 ES6 及后续版本的语法节点(如 ArrowFunctionExpression 箭头函数、ClassDeclaration 类、ImportDeclaration 模块导入等)。
  • ESNext 规范:支持尚未正式纳入 ECMAScript 标准的实验性语法(如装饰器、管道运算符等),供工具提前适配。

语法节点类型

根节点唯一 (Program)

{
    "type": "Program", // 节点类型,`Program` 表示整个程序。
    "start": 0, // 在源码中的开始索引
    "end": 9, // 在源码中的结束索引,这里原代码长度为 9,即共 9 个字符
    "body": [ ... ], // 程序体,是一个语句数组
    "sourceType": "script" // "script" 表示源码是普通脚本(非模块),如果是 `"module"`,则支持 `import`/`export`
}

声明节点

  • VariableDeclaration 变量声明(统一包裹const/let/var)
  • FunctionDeclaration 函数声明(具名函数,提升)
  • ClassDeclaration 类声明(具名类,提升)
  • ImportDeclaration 模块导入声明(仅模块环境)
  • ExportDeclaration 模块导出声明(仅模块环境,含命名 / 默认)
  • ExportNamedDeclaration命名导出
  • ExportDefaultDeclaration默认导出
  • ExportAllDeclaration全部导出

语句节点

  • BlockStatement 块语句({}包裹的代码块)
  • ExpressionStatement 表达式语句(包裹单个表达式作为语句执行)
  • IfStatement 条件判断语句
  • ForStatement for 循环语句
  • WhileStatement while 循环语句
  • ReturnStatement 返回语句(函数内)
  • TryStatement 异常捕获语句
  • BreakStatement 中断循环语句
  • ContinueStatement 继续循环语句

表达式节点

  • Identifier标识符(变量名、函数名、属性名等
  • Literal字面量(直接写死的值)
  • BinaryExpression 二元表达式(双操作数运算)
  • UnaryExpression 一元表达式(单操作数运算)
  • AssignmentExpression 赋值表达式
  • CallExpression 函数调用表达式
  • MemberExpression 成员访问表达式
  • ArrowFunctionExpression 箭头函数表达式
  • ObjectExpression 对象字面量表达式
  • ArrayExpression 数组字面量表达式

其他节点

  • TryStatementtry...catch 语句
  • TemplateLiteral模板字符串
  • TaggedTemplateExpression带标签的模板字符串
  • SpreadElement扩展运算符
  • RestElement剩余参数

Acorn

Acorn 是一个轻量、快速的 JavaScript 解析器,能将代码转换为 ESTree 标准的抽象语法树(AST)。

它主要提供三大核心 API

  • parse(input, options) :解析一段完整的 JavaScript 程序。成功返回 ESTree AST,失败抛出包含位置信息的 SyntaxError 对象
  • parseExpressionAt(input, pos, options) :解析一个独立的 JavaScript 表达式。适用于解析模板字符串内的内嵌表达式等混合内容
  • tokenizer(input, options) :返回一个迭代器,逐个生成代码的 Token。可用于自定义的语法高亮或极简解析器。

parseExpressionAt

  const code = 'const x = 10; const y = 20; x + y * 2;'
  const result = acorn.parseExpressionAt(code, code.indexOf('x + y'),{
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(result);

image.png

tokenizer

示例

  const result = acorn.tokenizer('let a = "hello";',{
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(result);

image.png

关键字(可用于代码高亮)

^(?:break|case|catch|continue|debugger|default|do|else|finally|for|function|if|return|switch|throw|try|var|while|with|null|true|false|instanceof|typeof|void|delete|new|in|this|const|class|extends|export|import|super)$

示例

  const result = acorn.tokenizer('let a = "hello";',{
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(result);

  for(let token of result){
    console.log('token',token);
  }

image.png

image.png

每个 Token 对象都会包含一个 type 属性,指向这样的类型描述对象。

{
    "label": "string", // Token 类型的人类可读名称
    "beforeExpr": false, // 该 Token 类型是否可以在表达式之前出现
    "startsExpr": true, // 该 Token 类型是否作为表达式的开始
    "isLoop": false, // 是否为循环关键字(如 for, while, do)
    "isAssign": false, // 是否为赋值操作符(如 =, +=, -=)
    "prefix": false, // 是否为前缀操作符(如 ++, --, !, ~)
    "postfix": false,  // 是否为后缀操作符(如 ++, --)
    "binop": null,// 如果是二元操作符,这里会有一个优先级数值;否则为 null
    "updateContext": null // 可选函数,用于在解析时更新上下文(通常为 null)
}

声明变量

例1 声明一个变量(基本类型)

const ast = acorn.parse(`let a = 1`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 9,
  "body": [
    {
      "type": "VariableDeclaration", // 变量声明符
      "start": 0,
      "end": 9,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          // 标识符节点,即变量名。
          "id": {
            "type": "Identifier", // 变量名标识符
            "start": 4,
            "end": 5,
            "name": "a" // 变量名
          },
          // 初始化表达式节点,即等号右边的值
          "init": {
            "type": "Literal", // 字面量
            "start": 8,
            "end": 9,
            "value": 1, // 运行时的值,这里是数字 1
            "raw": "1" // 源码中的原始字符串表示 "1"
          }
        }
      ],
      "kind": "let"  // 表示使用 let 关键字声明
    }
  ],
  "sourceType": "script"
}

例2 声明一个变量(数组)

const ast = acorn.parse(`const arr = [1,2]`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 17,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 17,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 17,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 9,
            "name": "arr"
          },
          "init": {
            "type": "ArrayExpression",
            "start": 12,
            "end": 17,
            "elements": [
              {
                "type": "Literal",
                "start": 13,
                "end": 14,
                "value": 1,
                "raw": "1"
              },
              {
                "type": "Literal",
                "start": 15,
                "end": 16,
                "value": 2,
                "raw": "2"
              }
            ]
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例3 声明一个变量(对象)

const ast = acorn.parse(`const arr = {a: 1, b: 2}`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 24,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 24,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 24,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 9,
            "name": "arr"
          },
          "init": {
            "type": "ObjectExpression",
            "start": 12,
            "end": 24,
            "properties": [
              {
                "type": "Property",
                "start": 13,
                "end": 17,
                "method": false,
                "shorthand": false,
                "computed": false,
                "key": {
                  "type": "Identifier",
                  "start": 13,
                  "end": 14,
                  "name": "a"
                },
                "value": {
                  "type": "Literal",
                  "start": 16,
                  "end": 17,
                  "value": 1,
                  "raw": "1"
                },
                "kind": "init"
              },
              {
                "type": "Property",
                "start": 19,
                "end": 23,
                "method": false,
                "shorthand": false,
                "computed": false,
                "key": {
                  "type": "Identifier",
                  "start": 19,
                  "end": 20,
                  "name": "b"
                },
                "value": {
                  "type": "Literal",
                  "start": 22,
                  "end": 23,
                  "value": 2,
                  "raw": "2"
                },
                "kind": "init"
              }
            ]
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例4 三元表达式

const ast = acorn.parse(`const flag = a > b ? true : false`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 33,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 33,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 33,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 10,
            "name": "flag"
          },
          "init": {
            "type": "ConditionalExpression",
            "start": 13,
            "end": 33,
            "test": {
              "type": "BinaryExpression",
              "start": 13,
              "end": 18,
              "left": {
                "type": "Identifier",
                "start": 13,
                "end": 14,
                "name": "a"
              },
              "operator": ">",
              "right": {
                "type": "Identifier",
                "start": 17,
                "end": 18,
                "name": "b"
              }
            },
            "consequent": {
              "type": "Literal",
              "start": 21,
              "end": 25,
              "value": true,
              "raw": "true"
            },
            "alternate": {
              "type": "Literal",
              "start": 28,
              "end": 33,
              "value": false,
              "raw": "false"
            }
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例5 声明变量(逻辑运算符)

  const code = 'let name = jon || "hello";'
  const result = acorn.parse(code, {
    ecmaVersion: 2020,
  });
  console.log(JSON.stringify(result, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 26,
    "body": [
        {
            "type": "VariableDeclaration",
            "start": 0,
            "end": 26,
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "start": 4,
                    "end": 25,
                    // 声明标识
                    "id": {
                        "type": "Identifier",
                        "start": 4,
                        "end": 8,
                        "name": "name"
                    },
                    // 声明初始化内容
                    "init": {
                        "type": "LogicalExpression",// 逻辑表达式
                        "start": 11,
                        "end": 25,
                        "left": {
                            "type": "Identifier",
                            "start": 11,
                            "end": 14,
                            "name": "jon"
                        },
                        "operator": "||",// 操作符
                        "right": {
                            "type": "Literal",
                            "start": 18,
                            "end": 25,
                            "value": "hello",
                            "raw": "\"hello\""
                        }
                    }
                }
            ],
            "kind": "let"
        }
    ],
    "sourceType": "script"
}

函数

例1 箭头函数

const ast = acorn.parse(`const getFlag = (a, b) => a + b`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 31,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 31,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 31,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 13,
            "name": "getFlag"
          },
          "init": {
            "type": "ArrowFunctionExpression",
            "start": 16,
            "end": 31,
            "id": null,
            "expression": true,
            "generator": false,
            "async": false,
            "params": [
              {
                "type": "Identifier",
                "start": 17,
                "end": 18,
                "name": "a"
              },
              {
                "type": "Identifier",
                "start": 20,
                "end": 21,
                "name": "b"
              }
            ],
            "body": {
              "type": "BinaryExpression",
              "start": 26,
              "end": 31,
              "left": {
                "type": "Identifier",
                "start": 26,
                "end": 27,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 30,
                "end": 31,
                "name": "b"
              }
            }
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

例2 普通函数 含有返回值

const ast = acorn.parse(`function getFlag(a, b) { return a + b }  `, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 41,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 39,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 16,
        "name": "getFlag"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 17,
          "end": 18,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 20,
          "end": 21,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 23,
        "end": 39,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 25,
            "end": 37,
            "argument": {
              "type": "BinaryExpression",
              "start": 32,
              "end": 37,
              "left": {
                "type": "Identifier",
                "start": 32,
                "end": 33,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 36,
                "end": 37,
                "name": "b"
              }
            }
          }
        ]
      }
    }
  ],
  "sourceType": "script"
}

例3 函数调用

const ast = acorn.parse(`function getFlag(a, b) { return a + b } getFlag(1, 2)`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 53,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 39,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 16,
        "name": "getFlag"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 17,
          "end": 18,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 20,
          "end": 21,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 23,
        "end": 39,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 25,
            "end": 37,
            "argument": {
              "type": "BinaryExpression",
              "start": 32,
              "end": 37,
              "left": {
                "type": "Identifier",
                "start": 32,
                "end": 33,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 36,
                "end": 37,
                "name": "b"
              }
            }
          }
        ]
      }
    },
    {
      "type": "ExpressionStatement",
      "start": 40,
      "end": 53,
      "expression": {
        "type": "CallExpression",
        "start": 40,
        "end": 53,
        "callee": {
          "type": "Identifier",
          "start": 40,
          "end": 47,
          "name": "getFlag"
        },
        "arguments": [
          {
            "type": "Literal",
            "start": 48,
            "end": 49,
            "value": 1,
            "raw": "1"
          },
          {
            "type": "Literal",
            "start": 51,
            "end": 52,
            "value": 2,
            "raw": "2"
          }
        ],
        "optional": false
      }
    }
  ],
  "sourceType": "script"
}

例4 条件语句

const ast = acorn.parse(`function getFlag(a, b) { if(a > b) { return true } } getFlag(1, 2)`, {
  ecmaVersion: 2020,
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 66,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 52,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 16,
        "name": "getFlag"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 17,
          "end": 18,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 20,
          "end": 21,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 23,
        "end": 52,
        "body": [
          {
            "type": "IfStatement",
            "start": 25,
            "end": 50,
            "test": {
              "type": "BinaryExpression",
              "start": 28,
              "end": 33,
              "left": {
                "type": "Identifier",
                "start": 28,
                "end": 29,
                "name": "a"
              },
              "operator": ">",
              "right": {
                "type": "Identifier",
                "start": 32,
                "end": 33,
                "name": "b"
              }
            },
            "consequent": {
              "type": "BlockStatement",
              "start": 35,
              "end": 50,
              "body": [
                {
                  "type": "ReturnStatement",
                  "start": 37,
                  "end": 48,
                  "argument": {
                    "type": "Literal",
                    "start": 44,
                    "end": 48,
                    "value": true,
                    "raw": "true"
                  }
                }
              ]
            },
            "alternate": null
          }
        ]
      }
    },
    {
      "type": "ExpressionStatement",
      "start": 53,
      "end": 66,
      "expression": {
        "type": "CallExpression",
        "start": 53,
        "end": 66,
        "callee": {
          "type": "Identifier",
          "start": 53,
          "end": 60,
          "name": "getFlag"
        },
        "arguments": [
          {
            "type": "Literal",
            "start": 61,
            "end": 62,
            "value": 1,
            "raw": "1"
          },
          {
            "type": "Literal",
            "start": 64,
            "end": 65,
            "value": 2,
            "raw": "2"
          }
        ],
        "optional": false
      }
    }
  ],
  "sourceType": "script"
}

声明一个空类

{
    "type": "Program",
    "start": 0,
    "end": 11,
    "body": [
        {
            "type": "ClassDeclaration", // 类声明
            "start": 0,
            "end": 11,
            // 类名,是一个 Identifier 节点
            "id": {
                "type": "Identifier",
                "start": 6,
                "end": 9,
                "name": "Cat"
            },
            // 父类 ,如果有 extends 关键字,这里会是表达式节点
            "superClass": null,
            // 包含类的所有成员(方法、属性等)
            "body": {
                "type": "ClassBody",
                "start": 9,
                "end": 11,
                "body": []
            }
        }
    ],
    "sourceType": "module"
}

带构造函数的类

{
    "type": "Program",
    "start": 0,
    "end": 50,
    "body": [
        {
            "type": "ClassDeclaration",
            "start": 0,
            "end": 50,
            "id": {
                "type": "Identifier",
                "start": 6,
                "end": 9,
                "name": "Cat"
            },
            "superClass": null,
            "body": {
                "type": "ClassBody",
                "start": 9,
                "end": 50,
                "body": [
                    {
                        "type": "MethodDefinition",
                        "start": 11,
                        "end": 49,
                        "static": false,
                        "computed": false,
                        "key": {
                            "type": "Identifier",
                            "start": 11,
                            "end": 22,
                            "name": "constructor"
                        },
                        "kind": "constructor",
                        "value": {
                            "type": "FunctionExpression",
                            "start": 22,
                            "end": 49,
                            "id": null,
                            "expression": false,
                            "generator": false,
                            "async": false,
                            "params": [
                                {
                                    "type": "Identifier",
                                    "start": 23,
                                    "end": 27,
                                    "name": "name"
                                }
                            ],
                            "body": {
                                "type": "BlockStatement",
                                "start": 28,
                                "end": 49,
                                "body": [
                                    {
                                        "type": "ExpressionStatement",
                                        "start": 30,
                                        "end": 47,
                                        "expression": {
                                            "type": "AssignmentExpression",
                                            "start": 30,
                                            "end": 46,
                                            "operator": "=",
                                            "left": {
                                                "type": "MemberExpression",
                                                "start": 30,
                                                "end": 39,
                                                "object": {
                                                    "type": "ThisExpression",
                                                    "start": 30,
                                                    "end": 34
                                                },
                                                "property": {
                                                    "type": "Identifier",
                                                    "start": 35,
                                                    "end": 39,
                                                    "name": "name"
                                                },
                                                "computed": false,
                                                "optional": false
                                            },
                                            "right": {
                                                "type": "Identifier",
                                                "start": 42,
                                                "end": 46,
                                                "name": "name"
                                            }
                                        }
                                    }
                                ]
                            }
                        }
                    }
                ]
            }
        }
    ],
    "sourceType": "module"
}

截取片段this.name = name

{
 "body": [
    {
        "type": "ExpressionStatement", // 表达式语句
        "start": 30,
        "end": 47,
        // 真正的表达式
        "expression": {
            "type": "AssignmentExpression", // 赋值表达式
            "start": 30,
            "end": 46,
            "operator": "=",
            "left": {
                "type": "MemberExpression", // 属性访问表达式
                "start": 30,
                "end": 39,
                // 被访问的对象
                "object": {
                    "type": "ThisExpression", // this
                    "start": 30,
                    "end": 34
                },
                // 属性
                "property": {
                    "type": "Identifier",
                    "start": 35,
                    "end": 39,
                    "name": "name"
                },
                // 表示使用点号 . 访问属性(而非 [计算属性名])
                "computed": false,
                // 可选链操作符 ?.
                "optional": false
            },
            "right": {
                "type": "Identifier",
                "start": 42,
                "end": 46,
                "name": "name"
            }
        }
    }
]
}

继承

  const code = 'class Cat extends Animal { constructor(name){ super(name); }}'
  const result = acorn.parse(code, {
    ecmaVersion: 2020,
    sourceType: 'module',
  });
  console.log(JSON.stringify(result, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 61,
    "body": [
        {
            "type": "ClassDeclaration",
            "start": 0,
            "end": 61,
            "id": {
                "type": "Identifier",
                "start": 6,
                "end": 9,
                "name": "Cat"
            },
            "superClass": {
                "type": "Identifier",
                "start": 18,
                "end": 24,
                "name": "Animal"
            },
            "body": {
                "type": "ClassBody",
                "start": 25,
                "end": 61,
                "body": [
                    {
                        "type": "MethodDefinition",
                        "start": 27,
                        "end": 60,
                        "static": false,
                        "computed": false,
                        "key": {
                            "type": "Identifier",
                            "start": 27,
                            "end": 38,
                            "name": "constructor"
                        },
                        "kind": "constructor",
                        "value": {
                            "type": "FunctionExpression",
                            "start": 38,
                            "end": 60,
                            "id": null,
                            "expression": false,
                            "generator": false,
                            "async": false,
                            "params": [
                                {
                                    "type": "Identifier",
                                    "start": 39,
                                    "end": 43,
                                    "name": "name"
                                }
                            ],
                            "body": {
                                "type": "BlockStatement",
                                "start": 44,
                                "end": 60,
                                "body": [
                                    {
                                        "type": "ExpressionStatement",
                                        "start": 46,
                                        "end": 58,
                                        "expression": {
                                            "type": "CallExpression",
                                            "start": 46,
                                            "end": 57,
                                            "callee": {
                                                "type": "Super",
                                                "start": 46,
                                                "end": 51
                                            },
                                            "arguments": [
                                                {
                                                    "type": "Identifier",
                                                    "start": 52,
                                                    "end": 56,
                                                    "name": "name"
                                                }
                                            ],
                                            "optional": false
                                        }
                                    }
                                ]
                            }
                        }
                    }
                ]
            }
        }
    ],
    "sourceType": "module"
}

截取片段分析 super(name)

{
    "type": "ExpressionStatement",
    "start": 46,
    "end": 58,
    "expression": {
        "type": "CallExpression",//调用表达式
        "start": 46,
        "end": 57,
        // 被调用的函数或方法
        "callee": {
            "type": "Super", // super关键字
            "start": 46,
            "end": 51
        },
        // 参数列表
        "arguments": [
            {
                "type": "Identifier",
                "start": 52,
                "end": 56,
                "name": "name"
            }
        ],
        "optional": false
    }
}

模块

命名导入

const ast = acorn.parse(`import { add } from './utills.js'`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 33,
    "body": [
        {
            "type": "ImportDeclaration", // 导入声明
            "start": 0,
            "end": 33,
            
            "specifiers": [
                {
                    "type": "ImportSpecifier", // 导入语句
                    "start": 9,
                    "end": 12,
                    // 模块导入的名称
                    "imported": {
                        "type": "Identifier", 
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    },
                    // 本地使用的名称
                    "local": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    }
                }
            ],
            // 源
            "source": {
                "type": "Literal",
                "start": 20,
                "end": 33,
                "value": "./utills.js", // 运行中
                "raw": "'./utills.js'" // 代码中保留了引号
            }
        }
    ],
    "sourceType": "module"
}

命名导入

const ast = acorn.parse(`import { add } from './utills.js';const result = add(1, 2);`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 59,
    "body": [
        {
            "type": "ImportDeclaration",
            "start": 0,
            "end": 34,
            "specifiers": [
                {
                    "type": "ImportSpecifier",
                    "start": 9,
                    "end": 12,
                    "imported": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    },
                    "local": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    }
                }
            ],
            "source": {
                "type": "Literal",
                "start": 20,
                "end": 33,
                "value": "./utills.js",
                "raw": "'./utills.js'"
            }
        },
        {
            "type": "VariableDeclaration",
            "start": 34,
            "end": 59,
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "start": 40,
                    "end": 58,
                    "id": {
                        "type": "Identifier",
                        "start": 40,
                        "end": 46,
                        "name": "result"
                    },
                    "init": {
                        "type": "CallExpression",
                        "start": 49,
                        "end": 58,
                        "callee": {
                            "type": "Identifier",
                            "start": 49,
                            "end": 52,
                            "name": "add"
                        },
                        "arguments": [
                            {
                                "type": "Literal",
                                "start": 53,
                                "end": 54,
                                "value": 1,
                                "raw": "1"
                            },
                            {
                                "type": "Literal",
                                "start": 56,
                                "end": 57,
                                "value": 2,
                                "raw": "2"
                            }
                        ],
                        "optional": false
                    }
                }
            ],
            "kind": "const"
        }
    ],
    "sourceType": "module"
}

别名导入

const ast = acorn.parse(`import { add as addFun} from './utills.js'`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 42,
    "body": [
        {
            "type": "ImportDeclaration",
            "start": 0,
            "end": 42,
            "specifiers": [
                {
                    "type": "ImportSpecifier",
                    "start": 9,
                    "end": 22,
                    "imported": {
                        "type": "Identifier",
                        "start": 9,
                        "end": 12,
                        "name": "add"
                    },
                    "local": {
                        "type": "Identifier",
                        "start": 16,
                        "end": 22,
                        "name": "addFun"
                    }
                }
            ],
            "source": {
                "type": "Literal",
                "start": 29,
                "end": 42,
                "value": "./utills.js",
                "raw": "'./utills.js'"
            }
        }
    ],
    "sourceType": "module"
}

命名导出一个 变量声明

const ast = acorn.parse(`export const Max_Size = 100;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
    "type": "Program",
    "start": 0,
    "end": 28,
    "body": [
        {
            "type": "ExportNamedDeclaration", // 表示一个命名导出
            "start": 0,
            "end": 28,
            // 被导出的声明节点
            "declaration": {
                "type": "VariableDeclaration",
                "start": 7,
                "end": 28,
                "declarations": [
                    {
                        "type": "VariableDeclarator",
                        "start": 13,
                        "end": 27,
                        "id": {
                            "type": "Identifier",
                            "start": 13,
                            "end": 21,
                            "name": "Max_Size"
                        },
                        "init": {
                            "type": "Literal",
                            "start": 24,
                            "end": 27,
                            "value": 100,
                            "raw": "100"
                        }
                    }
                ],
                "kind": "const"
            },
            "specifiers": [],
            "source": null // 从其他模块重导出
        }
    ],
    "sourceType": "module"
}

命名导出一个 函数声明

const ast = acorn.parse(`export function add(a, b) {return a + b;}`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 41,
  "body": [
    {
      "type": "ExportNamedDeclaration",
      "start": 0,
      "end": 41,
      "declaration": {
        "type": "FunctionDeclaration",
        "start": 7,
        "end": 41,
        "id": {
          "type": "Identifier",
          "start": 16,
          "end": 19,
          "name": "add"
        },
        "expression": false,
        "generator": false,
        "async": false,
        "params": [
          {
            "type": "Identifier",
            "start": 20,
            "end": 21,
            "name": "a"
          },
          {
            "type": "Identifier",
            "start": 23,
            "end": 24,
            "name": "b"
          }
        ],
        "body": {
          "type": "BlockStatement",
          "start": 26,
          "end": 41,
          "body": [
            {
              "type": "ReturnStatement",
              "start": 27,
              "end": 40,
              "argument": {
                "type": "BinaryExpression",
                "start": 34,
                "end": 39,
                "left": {
                  "type": "Identifier",
                  "start": 34,
                  "end": 35,
                  "name": "a"
                },
                "operator": "+",
                "right": {
                  "type": "Identifier",
                  "start": 38,
                  "end": 39,
                  "name": "b"
                }
              }
            }
          ]
        }
      },
      "specifiers": [],
      "source": null
    }
  ],
  "sourceType": "module"
}

命名导出一个变量

const ast = acorn.parse(`const Max_Size = 100;export { Max_Size };`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));

export { Max_Size };这是一个命名导出语句,但它不包含声明declaration: null),而是通过 specifiers 列表来指定要导出的已有变量

{
  "type": "Program",
  "start": 0,
  "end": 41,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 21,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 20,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 14,
            "name": "Max_Size"
          },
          "init": {
            "type": "Literal",
            "start": 17,
            "end": 20,
            "value": 100,
            "raw": "100"
          }
        }
      ],
      "kind": "const"
    },
    {
      "type": "ExportNamedDeclaration",
      "start": 21,
      "end": 41,
      "declaration": null, // 没有内联声明
      // 导出说明符列表
      "specifiers": [
        {
          "type": "ExportSpecifier",
          "start": 30,
          "end": 38,
          // 当前模块本地名称
          "local": {
            "type": "Identifier",
            "start": 30,
            "end": 38,
            "name": "Max_Size"
          },
          // 导出后名称
          "exported": {
            "type": "Identifier",
            "start": 30,
            "end": 38,
            "name": "Max_Size"
          }
        }
      ],
      "source": null // 不是从其他模块中导出
    }
  ],
  "sourceType": "module"
}

命名导出一个函数

const ast = acorn.parse(`function add(a, b) {return a + b;} export { add };`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 50,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 34,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 12,
        "name": "add"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 13,
          "end": 14,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 16,
          "end": 17,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 19,
        "end": 34,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 20,
            "end": 33,
            "argument": {
              "type": "BinaryExpression",
              "start": 27,
              "end": 32,
              "left": {
                "type": "Identifier",
                "start": 27,
                "end": 28,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 31,
                "end": 32,
                "name": "b"
              }
            }
          }
        ]
      }
    },
    {
      "type": "ExportNamedDeclaration",
      "start": 35,
      "end": 50,
      "declaration": null,
      "specifiers": [
        {
          "type": "ExportSpecifier",
          "start": 44,
          "end": 47,
          "local": {
            "type": "Identifier",
            "start": 44,
            "end": 47,
            "name": "add"
          },
          "exported": {
            "type": "Identifier",
            "start": 44,
            "end": 47,
            "name": "add"
          }
        }
      ],
      "source": null
    }
  ],
  "sourceType": "module"
}

默认导出字面量

const ast = acorn.parse(`export default 12;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 18,
  "body": [
    {
      "type": "ExportDefaultDeclaration",
      "start": 0,
      "end": 18,
      "declaration": {
        "type": "Literal",
        "start": 15,
        "end": 17,
        "value": 12,
        "raw": "12"
      }
    }
  ],
  "sourceType": "module"
}

默认导出 变量(基本类型)

const ast = acorn.parse(`var Max_Size = 100;export default  Max_Size ;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 45,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 19,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 18,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 12,
            "name": "Max_Size"
          },
          "init": {
            "type": "Literal",
            "start": 15,
            "end": 18,
            "value": 100,
            "raw": "100"
          }
        }
      ],
      "kind": "var"
    },
    {
      "type": "ExportDefaultDeclaration",
      "start": 19,
      "end": 45,
      "declaration": {
        "type": "Identifier",
        "start": 35,
        "end": 43,
        "name": "Max_Size"
      }
    }
  ],
  "sourceType": "module"
}

默认导出变量 (函数)

const ast = acorn.parse(`function a(){} export default a;`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));

{
  "type": "Program",
  "start": 0,
  "end": 32,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 14,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 10,
        "name": "a"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [],
      "body": {
        "type": "BlockStatement",
        "start": 12,
        "end": 14,
        "body": []
      }
    },
    {
      "type": "ExportDefaultDeclaration",
      "start": 15,
      "end": 32,
      // 被导出的声明或表达式
      "declaration": {
        "type": "Identifier",
        "start": 30,
        "end": 31,
        "name": "a"
      }
    }
  ],
  "sourceType": "module"
}

默认导出 对象表达式

const ast = acorn.parse(`function add(a, b) {return a + b;} export default { add };`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 58,
  "body": [
    {
      "type": "FunctionDeclaration",
      "start": 0,
      "end": 34,
      "id": {
        "type": "Identifier",
        "start": 9,
        "end": 12,
        "name": "add"
      },
      "expression": false,
      "generator": false,
      "async": false,
      "params": [
        {
          "type": "Identifier",
          "start": 13,
          "end": 14,
          "name": "a"
        },
        {
          "type": "Identifier",
          "start": 16,
          "end": 17,
          "name": "b"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "start": 19,
        "end": 34,
        "body": [
          {
            "type": "ReturnStatement",
            "start": 20,
            "end": 33,
            "argument": {
              "type": "BinaryExpression",
              "start": 27,
              "end": 32,
              "left": {
                "type": "Identifier",
                "start": 27,
                "end": 28,
                "name": "a"
              },
              "operator": "+",
              "right": {
                "type": "Identifier",
                "start": 31,
                "end": 32,
                "name": "b"
              }
            }
          }
        ]
      }
    },
    {
      "type": "ExportDefaultDeclaration", // 默认导出声明
      "start": 35,
      "end": 58,
      "declaration": {
        "type": "ObjectExpression",
        "start": 50,
        "end": 57,
        // 对象属性
        "properties": [
          {
            "type": "Property", // 对象属性节点
            "start": 52,
            "end": 55,
            "method": false,
            "shorthand": true,
            "computed": false,
            "key": {
              "type": "Identifier",
              "start": 52,
              "end": 55,
              "name": "add"
            },
            "value": {
              "type": "Identifier",
              "start": 52,
              "end": 55,
              "name": "add"
            },
            // 表示普通数据属性,非getter、setter
            "kind": "init"
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}

默认导出 函数声明

const ast = acorn.parse(`export default function fn() {}`, {
  ecmaVersion: 2020,
  sourceType: "module",
});
console.log(JSON.stringify(ast, null, 2));
{
  "type": "Program",
  "start": 0,
  "end": 31,
  "body": [
    {
      "type": "ExportDefaultDeclaration",
      "start": 0,
      "end": 31,
      "declaration": {
        "type": "FunctionDeclaration",
        "start": 15,
        "end": 31,
        "id": {
          "type": "Identifier",
          "start": 24,
          "end": 26,
          "name": "fn"
        },
        "expression": false,
        "generator": false,
        "async": false,
        "params": [],
        "body": {
          "type": "BlockStatement",
          "start": 29,
          "end": 31,
          "body": []
        }
      }
    }
  ],
  "sourceType": "module"
}

最后

  1. ESTree 规范
  2. 在线查看代码片段的AST语法结构
❌