阅读视图

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

Nginx 反向代理 WebSocket 和 SSE 的踩坑

Nginx 反向代理 WebSocket 和 SSE 的踩坑

项目上了 Nginx 反向代理之后,HTTP 接口全部正常,WebSocket 却连不上,SSE 推送也收不到消息。控制台没有报错,Network 面板看着像是连上了,数据就是不过来。先给结论:WebSocket 和 SSE 都不是标准的 HTTP 请求-响应模型,Nginx 默认配置会把它们当成普通 HTTP 处理,要么握手失败,要么连接被提前关掉。 两者的解法不同,不能混为一谈。

WebSocket 反向代理:三行配置解决 90% 的问题

最小可用配置只需要三行,把 UpgradeConnection 头透传给后端:

location /ws {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

proxy_http_version 1.1 这行容易被忽略。Nginx 代理后端时默认用 HTTP/1.0,而 HTTP/1.0 压根不支持 Upgrade 机制,所以必须显式声明。$http_upgrade 是 Nginx 内置变量,值就是客户端发来的 Upgrade 头内容。

配好之后可以用 wscat 快速验证:通过 Nginx 代理地址连接 wscat -c ws://your-domain.com/ws,能发消息能收消息就说明配置生效了。

超时问题:连上了但过一会儿自动断

大部分人配好 WebSocket 之后会遇到第二个坑——连接建立了,过 60 秒没有数据传输就自动断开。

原因是 Nginx 的 proxy_read_timeout 默认 60 秒。对普通 HTTP 请求来说,60 秒没响应大概率是后端挂了,断开合理。但 WebSocket 连接可能几分钟才有一次消息,60 秒的超时就太短了。一个直接的做法是把 proxy_read_timeoutproxy_send_timeout 调到 3600 秒,但这不是最优解。更靠谱的做法是让应用层做心跳保活——WebSocket 协议本身支持 Ping/Pong 帧,服务端每 30 秒发一个 ws.ping(),超时计时器就会被重置。这样 proxy_read_timeout 保持默认 60 秒都行,还能及时检测到真正的死连接。无脑调大超时反而会让死连接长时间占用资源。

下面是 Node.js 服务端心跳的核心逻辑,每 30 秒向所有活跃连接发送协议级 Ping 帧,客户端会自动回复 Pong,Nginx 感知到数据传输就不会断连:

const wss = new WebSocket.Server({ port: 3000 });
wss.on('connection', (ws) => {
    const heartbeat = setInterval(() => {
        if (ws.readyState === ws.OPEN) ws.ping();
    }, 30000);
    ws.on('close', () => clearInterval(heartbeat));
});

Connection 头的条件判断

有些教程把 Connection 头写死为 "upgrade",如果这个 location 只处理 WebSocket 请求没问题。但如果普通 HTTP 和 WebSocket 请求共用同一个路径前缀,写死就容易出事——我在一个项目里踩过这个坑,前端 fetch 请求和 WebSocket 用了同一个路径前缀 /api,写死 Connection "upgrade" 导致普通接口偶尔返回 502。

解决方案是在 http 块里用 map 做条件判断:当客户端请求携带 Upgrade 头时,Connection 设为 upgrade;普通请求没有该头,则回退为 close。这样同一个 location 就能同时服务 WebSocket 和普通 HTTP 请求:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /api {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

SSE 反向代理:关缓冲、关压缩、调超时

SSE 看起来比 WebSocket 简单——毕竟就是个长连接的 HTTP 响应,不涉及协议升级。但 Nginx 对 SSE 的干扰点更多,也更隐蔽。

第一坑:proxy_buffering 吃掉实时性

这是 SSE 最常见的坑。Nginx 默认开启 proxy_buffering,会把后端的响应数据攒到缓冲区,攒够一定量(默认 4K 或 8K,取决于系统页大小)才发给客户端。普通接口无所谓,SSE 要的就是"服务端写一条、客户端立刻收到一条",缓冲直接破坏了实时性。

表现很有迷惑性:连接建立成功,后端日志显示事件已发送,但前端 EventSourceonmessage 迟迟不触发,过几秒突然一口气收到一堆消息。排查时抓包看 Nginx 到客户端的响应,会发现数据是批量到达的而非逐条到达。

解法很简单,在 SSE 的 location 里关闭代理缓冲,同时关闭 proxy_cache 防止响应被缓存:

location /sse {
    proxy_pass http://backend:3000;
    proxy_buffering off;
    proxy_cache off;
}

第二坑:gzip 压缩阻塞数据流

如果全局开了 gzip on,SSE 的数据流也会被压缩。gzip 算法需要攒够一定量的数据才能输出一个压缩块,效果和 proxy_buffering 一样——消息被攒着了。

这个坑隐蔽得很。我曾经在一个内部监控系统(Nginx 1.22 + Node.js 18)上排查 SSE 延迟,proxy_buffering 早就关了,后端日志确认消息已发出,但前端就是 3-5 秒才收到一批。翻了大半天配置,最后发现是全局 gzip on 藏在一个 include 的公共配置文件里。SSE 消息通常很短,几十到几百字节,压缩收益几乎为零,延迟代价却很大。在 SSE 的 location 里加一行 gzip off 就解决了。

第三坑:超时断连

SSE 和 WebSocket 一样面临超时问题。服务端长时间没有事件要推,Nginx 的 proxy_read_timeout 到了就会断开连接。配置思路类似——可以调大超时,也可以让服务端定时发心跳注释。

SSE 协议规范里约定以冒号开头的行是注释,客户端的 EventSource 不会触发 onmessage,天然适合做保活。

app.get('/sse', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    const heartbeat = setInterval(() => {
        res.write(': heartbeat\n\n');
    }, 15000);
    req.on('close', () => clearInterval(heartbeat));
});

把上面三个坑点的配置合在一起,就是 SSE 完整的 Nginx 配置。

location /sse {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    chunked_transfer_encoding off;
    proxy_read_timeout 86400s;
}

生产环境的边界情况

连接数限制

每个 WebSocket/SSE 连接都占用一个文件描述符。Nginx 的 worker_connections 默认值是 1024,同时在线 500 个 WebSocket 用户就可能打满(Nginx 自身也需要连接对接后端,一个客户端连接对应一个上游连接,实际容量要折半)。

系统层面需要同步调整,否则 Nginx 配置再大也会被 OS 限制挡住。worker_rlimit_nofile 控制 Nginx worker 进程的文件描述符上限,需要大于等于 worker_connections。系统级的 ulimit 也必须配合调高,否则 Nginx 启动时拿不到足够的文件描述符:

# nginx.conf 主配置
worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 65535;
    multi_accept on;
}

系统级文件描述符限制需要在 /etc/security/limits.conf 中设置,确保 Nginx 进程用户有足够的配额:

# /etc/security/limits.conf
nginx soft nofile 65535
nginx hard nofile 65535

改完后用 ulimit -n 确认生效,再 nginx -s reload。可以通过 cat /proc/<nginx_worker_pid>/limits 验证 worker 进程实际拿到的限制值。

多层代理的头丢失

生产环境经常不止一层代理:客户端 → CDN/SLB → Nginx → 后端。经过多层转发,UpgradeConnection 这些逐跳(hop-by-hop)头会被中间层剥掉,WebSocket 握手到了 Nginx 时已经丢失了关键头信息,后端收到的是一个普通 HTTP 请求。

表现为:开发环境直连 Nginx 一切正常,上了生产经过负载均衡器就连不上 WebSocket,返回 400 或 502。

解法分两步。第一步,确认前置代理(SLB/CDN)支持 WebSocket 透传并开启了相关选项,阿里云 SLB 需要在监听配置里勾选"开启 WebSocket",AWS ALB 原生支持但 CLB 需要用 TCP 监听。第二步,在 Nginx 层用 proxy_set_header 显式补上可能丢失的头,而不是依赖客户端传过来的值:

location /ws {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade websocket;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

注意这里 Upgrade 直接写死 websocket 而不是用 $http_upgrade 变量,因为变量值可能已经被前置代理清空了。

Nginx reload 断连接

执行 nginx -s reload 时,Nginx 会优雅关闭旧的 worker 进程。

如果没配 worker_shutdown_timeout,旧 worker 会一直等,直到所有长连接自然断开,导致 reload 后系统里同时跑着新旧两套 worker,内存持续上涨。配了超时(比如 30 秒),reload 时所有 WebSocket/SSE 用户会在 30 秒后被强制断开。

两种策略都不完美,实际操作中建议:

# nginx.conf 主配置
worker_shutdown_timeout 60s;

把超时设为 60 秒,给正在传输数据的连接留够缓冲时间。同时客户端必须实现自动重连——WebSocket 用库自带的 reconnect 机制,SSE 的 EventSource 本身就有自动重连能力(断开后默认 3 秒重试)。这样 reload 造成的断连对用户来说只是一次短暂的闪断,几秒后自动恢复。在需要频繁改配置的场景下,可以考虑用 upstream 的灰度策略,先切走流量再 reload,彻底避免断连。

各配置项速查

配置项 WebSocket SSE 默认值 建议值
proxy_http_version 1.1(必须) 1.1(推荐) 1.0 1.1
proxy_set_header Upgrade $http_upgrade 不需要
proxy_set_header Connection $connection_upgrade ''
proxy_buffering 默认即可 off(必须) on
gzip 默认即可 off(必须) on
proxy_read_timeout 心跳间隔×2 心跳间隔×2 60s 60-3600s
worker_connections 按最大连接数设 按最大连接数设 1024 65535
worker_shutdown_timeout 建议设置 建议设置 无限制 60s

IndexedDB实战:浏览器端离线存储与同步方案

IndexedDB实战:浏览器端离线存储与同步方案

上个月我们组接了个需求:给一个外勤巡检系统做离线支持。巡检员在信号差的工地拍照、填表单,数据先存本地,等有网了再同步上去。听起来不复杂对吧?localStorage 存一下不就完了?

我一开始也是这么想的。

这时候就不得不请出 IndexedDB 了。这东西 API 丑得让人想哭,但在浏览器端做离线存储,它几乎是唯一的正经选择。

离线数据模型设计:别偷懒,状态机是必须的

数据能存下来只是第一步。真正让我掉头发的是:怎么设计数据模型,才能在离线和在线之间无缝切换?

每条记录都要带同步状态

这个原则我们是踩了坑之后才确立的。一开始我们就存原始业务数据,等网络恢复了遍历一遍全量上传。结果发现:已经同步过的数据又传了一遍,同步失败的数据没有重试机制,用户改了已同步的数据不知道该怎么处理。

后来给每条记录加了 syncStatus 字段,整个世界清净了。

const record = {
  id: crypto.randomUUID(),       // 客户端生成 UUID,避免跟服务端 ID 冲突
  title: '5号楼电梯年检',          // 业务字段
  photos: [],                    // 存的是 Blob 引用,实际图片存在单独的 object store
  result: 'passed',
  syncStatus: 'pending',         // pending → syncing → synced / failed
  syncAttempts: 0,               // 重试次数,用于指数退避
  lastSyncError: null,           // 最近一次失败原因,方便排查
  localUpdatedAt: Date.now(),    // 本地最后修改时间
  serverUpdatedAt: null          // 服务端确认时间
}

这里有个容易忽略的细节:idcrypto.randomUUID() 在客户端生成,而不是等服务端返回自增 ID。原因是离线状态下你拿不到服务端 ID,如果用临时 ID 后续还得做一次 ID 映射,非常麻烦。

syncStatus 这个字段其实是个状态机:

  创建/修改
     ↓
  pending ──触发同步──→ syncing ──成功──→ synced
                          │                  ↑
                          失败               │
                          ↓                  │
                        failed ──重试──→ syncing

为什么要用状态机而不是简单的布尔值 isSynced: true/false?因为布尔值无法表达"正在同步中"这个中间态。如果用户在同步过程中又改了数据,你需要知道当前这条记录是"正在传"还是"还没传"。布尔值做不到。我们早期用布尔值时出过一个 bug:同步请求还在飞,用户又改了表单,改完 isSynced 被设回 false,紧接着之前的请求返回成功又把它设成 true,导致用户的最新修改永远没同步上去。状态机彻底解决了这个问题。

同步策略:三种方案,各有各的坑

离线数据攒够了,网络恢复了,怎么把数据送上去?这里有三种常见思路。

方案二:Background Sync API

这是我个人觉得设计得最优雅的方案。通过 Service Worker 注册一个同步任务,浏览器会在"合适的时机"自动触发,哪怕用户关掉了页面。主线程只需要把数据写入 IndexedDB 然后注册一个 sync tag,剩下的交给 Service Worker 在后台完成:

// 主线程:保存数据并注册同步任务
async function saveAndSync(record) {
  const db = await openDB('InspectionDB', 1)
  await db.put('reports', record)
  const registration = await navigator.serviceWorker.ready
  await registration.sync.register('sync-reports')
}

// service-worker.js:监听 sync 事件,执行实际同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-reports') {
    event.waitUntil(doSync()) // 浏览器会等 doSync() 完成
  }
})

doSync() 的内部逻辑和方案一基本一样:从 IndexedDB 捞 pending 记录,逐条推送。

这个方案的坑在于兼容性。截至 2026 年 3 月,Background Sync 基本只有 Chromium 系浏览器(Chrome、Edge)支持,Firefox 和 Safari 都没实现。如果你的用户群里有大量 iOS 用户,这个方案等于白搭。

我们的做法是把 Background Sync 当增强手段:支持就用,不支持就降级到方案一的 online 事件监听。

方案三:定时轮询 + 手动触发

最不"优雅"但最稳的方案。封装一个 SyncScheduler 类,每 30 秒检查一次有没有待同步记录,同时监听 online 事件做即时触发。核心就三件事:定时器轮询、网络恢复即时触发、明确离线时跳过。

class SyncScheduler {
  constructor(db, interval = 30000) {
    this.db = db
    this.interval = interval
    this.timer = null
  }

  start() {
    this.timer = setInterval(() => this.trySync(), this.interval)
    window.addEventListener('online', () => this.trySync())
  }

  async trySync() {
    if (!navigator.onLine) return
    const pending = await this.db.getAllFromIndex('reports', 'by_status', 'pending')
    if (pending.length === 0) return
    // 逐条同步,逻辑同方案一
  }
}

这里 trySync 先检查 navigator.onLine 再查库,避免离线时做无意义的 IndexedDB 查询。stop() 方法用于页面卸载时清理定时器,防止内存泄漏。这个方案没什么花哨的技巧,但在生产环境里反而最让人放心,用户也喜欢看到一个"同步"按钮——给他们确定感。

三个方案我们最终是混着用的:Background Sync 做第一优先级,online 事件做第二优先级,定时轮询做兜底。手动同步按钮作为用户最后的"救命稻草"。

冲突处理:离线同步绕不过的硬骨头

两个巡检员同时离线编辑了同一条报告,回到有网的时候同步上去,服务端收到两个不同版本,听谁的?

策略一:Last Write Wins(最后写入胜出)

最简单——谁的时间戳新,就用谁的。服务端拿 incoming.localUpdatedAt 和已有记录比较,新的覆盖旧的,旧的直接拒绝。实现成本几乎为零。

function handleSync(incoming) {
  const existing = db.findById(incoming.id)
  if (!existing || incoming.localUpdatedAt > existing.localUpdatedAt) {
    db.save(incoming)
    return { status: 'accepted' }
  }
  return { status: 'rejected', reason: 'stale' }
}

问题也很明显:先提交的人的修改会被静默覆盖掉,他完全不知道自己的数据被人"踩"了。对于巡检系统这种场景,一条报告被覆盖可能意味着安全隐患被忽略。所以这个策略只适合数据覆盖后果不严重的场景,比如草稿自动保存。

策略二:服务端仲裁 + 冲突提示

同步的时候带上一个版本号。如果服务端发现版本号对不上,就拒绝写入,把冲突抛给客户端让用户自己决定。客户端在请求体里带上 expectedVersion(即"我修改时基于的版本号"),服务端比对后如果版本不匹配,就返回冲突状态和最新的服务端数据:

async function syncRecord(record) {
  const res = await fetch('/api/reports/sync', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...record, expectedVersion: record.version })
  })
  const result = await res.json()
  if (result.conflict) {
    await db.put('reports', {
      ...record, syncStatus: 'conflict', serverVersion: result.serverData
    })
    showConflictResolver(record, result.serverData) // 弹窗让用户对比选择
  }
}

拿到冲突后,客户端把 syncStatus 设为 conflict,同时把服务端版本缓存到 serverVersion 字段。然后弹一个对比界面,左右两栏分别展示"我的版本"和"服务端版本",让用户逐字段选择保留哪个。用户选完后生成一个合并版本,带着新版本号重新提交。这套流程对用户有一定打扰,但对于巡检报告这类数据准确性要求高的场景,宁可多问一句也不能静默丢数据。

策略三:CRDT(无冲突数据类型)

CRDT 的思路是从数据结构层面消除冲突:每个客户端的操作都设计成可交换、可结合的,合并时不需要协调。举个最简单的例子——G-Counter(增长计数器)。假设要统计某个巡检点的检查次数,两个巡检员 A 和 B 各自离线期间分别检查了 3 次和 2 次:

A 的本地计数器: { A: 3, B: 0 }
B 的本地计数器: { A: 0, B: 2 }

合并规则:对每个节点取 max → { A: 3, B: 2 } → 总数 = 5

不管 A 和 B 的数据以什么顺序到达服务端,合并结果都是 5,不需要冲突处理。这对计数器、集合添加这类操作确实很优雅。

但问题在于,我们的巡检表单是"一个表单一堆字段"——检查结果、备注、整改意见、签名……这些字段是整体覆盖式更新,不是可累加的操作。你没法对"备注从'正常'改成'有裂缝'"和"备注从'正常'改成'需复检'"做 max 合并,因为文本字段没有偏序关系。要让 CRDT 处理这种任意字段的表单编辑,你得把每个字段拆成独立的 Last-Writer-Wins Register,再组合成一个 Map CRDT,实现复杂度直接起飞,而且最终效果和策略二的逐字段冲突对比差不多,还不如让用户自己选。

生产环境的架构拼图

跑了三个月之后整个离线同步系统的架构大致是这样的:

┌──────────────────────────────────────────────────┐
│                  用户操作层                        │
│   表单提交 / 拍照上传 / 列表查看                    │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│               离线数据管理层                       │
│  CRUD API(idb) + 状态机管理 + 配额监控/清理        │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│                IndexedDB                          │
│  reports 表 + attachments 表                      │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│              同步调度层                            │
│  Background Sync → online 事件 → 定时轮询         │
│  并发控制(3路) + 指数退避重试                      │
└────────────────────┬─────────────────────────────┘
                     │
              ┌──────▼──────┐
              │  服务端 API  │
              │ 版本号校验   │
              │ 冲突检测     │
              └─────────────┘

跑了三个月,稳定服务了 200 多个巡检员的日常使用。期间收集到的数据:| 指标 | 数值 | |------|------| | 单用户日均离线记录 | 1530 条 | | 平均单条记录大小(含图片引用) | 28KB(图片 Blob 另算) | | 单用户 IndexedDB 平均占用 | 45MB | | 同步成功率(首次尝试) | 94.7% | | 重试后最终同步成功率 | 99.6% | | 冲突发生率 | 0.3%(大部分是同一巡检点被两人同时检查) | | 清理后平均释放空间 | 每周约 12MB/用户 |

剩下 0.4% 同步始终失败的,基本都是网络极端不稳导致的超时,最后靠用户手动点同步按钮解决。

如果你也在做类似的离线功能,附一下我们实际的迭代过程供参考:

阶段 时间 方案 触发升级的事件
V1 第1~2周 idb + online 事件 + Last Write Wins 能跑通基本流程
V2 第3周 加入定时轮询兜底 发现工地 WiFi 频繁假在线,online 事件不触发同步
V3 第5周 加入版本号冲突检测 两个巡检员覆盖了同一条报告,甲方投诉数据丢失
V4 第7周 加入 Background Sync + 降级策略 用户反馈关掉页面后数据没同步,第二天才发现
V5 第9周 加入配额监控和自动清理 一个巡检员的手机浏览器 IndexedDB 爆了

总共花了大约两个月从最简单的 V1 演进到当前的混合方案。每次升级都是被线上问题推着走的,没有一次是"提前设计"出来的。

JavaScript 深拷贝与浅拷贝详解

前言

在 JavaScript 开发中,拷贝对象是一个非常常见的操作。
但很多时候你以为“复制成功了”,其实只是复制了引用,这就会引发很多 Bug。

所以必须搞清楚:

  • 什么是浅拷贝
  • 什么是深拷贝
  • 常见实现方式有哪些

一、浅拷贝是什么?

浅拷贝只会复制对象的第一层属性。
如果属性值还是对象,那么复制的仍然是引用地址。

const obj1 = {
  name: 'Tom',
  info: {
    age: 18
  }
};

const obj2 = { ...obj1 };

obj2.info.age = 20;

console.log(obj1.info.age); // 20

说明obj1.infoobj2.info指向同一个对象。


二、常见浅拷贝方式

1)展开运算符

const obj2 = { ...obj1 };

2)Object.assign

const obj2 = Object.assign({}, obj1);

这两种都属于浅拷贝。


三、深拷贝是什么?

深拷贝会递归复制对象的每一层,生成完全独立的新对象。

const obj1 = {
  name: 'Tom',
  info: {
    age: 18
  }
};

const obj2 = JSON.parse(JSON.stringify(obj1));

obj2.info.age = 20;

console.log(obj1.info.age); // 18

四、JSON 深拷贝的问题

虽然:

JSON.parse(JSON.stringify(obj))

很常见,但它有局限:

  • 不能拷贝函数

  • 不能拷贝 undefined

  • 不能拷贝Symbol

  • 不能处理循环引用

  • DateRegExp 会丢失信息


五、手写一个简单深拷贝

function deepClone(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  const result = Array.isArray(obj) ? [] : {};

  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      result[key] = deepClone(obj[key]);
    }
  }

  return result;
}

六、总结

浅拷贝和深拷贝最核心的区别是:

  • 浅拷贝:只拷贝第一层
  • 深拷贝:递归拷贝所有层级

实际开发中要根据场景选择,不要把浅拷贝误当成深拷贝。

JavaScript call、apply、bind 详解

前言

callapplybind是 JavaScript 中用来显式改变this的三个常用方法。 它们既是开发中常见工具,也是面试高频考点。


一、为什么需要 call、apply、bind?

有时候我们希望一个函数在执行时,this指向指定对象。

例如:

function say() {
  console.log(this.name);
}

const obj = {
  name: 'Tom'
};

如果直接调用:

say();

此时this不会指向obj。所以我们需要:

say.call(obj);

二、call

call会立即执行函数,并且第一个参数就是指定的this

function say(age) {
  console.log(this.name, age);
}

const obj = { name: 'Tom' };

say.call(obj, 18); // Tom 18

三、apply

applycall类似,也会立即执行函数。区别在于参数形式不同:

  • call:一个一个传参数

  • apply :参数放在数组里传

function say(age, city) {
  console.log(this.name, age, city);
}

const obj = { name: 'Tom' };

say.apply(obj, [18, 'Beijing']); // Tom 18 Beijing

四、bind

bind不会立即执行函数,而是返回一个新的函数,这个新函数的

this已经被绑定好了。

function say(age) {
  console.log(this.name, age);
}

const obj = { name: 'Tom' };

const fn = say.bind(obj, 18);
fn(); // Tom 18

五、三者区别总结

相同点

  • 都可以改变函数执行时的 this

不同点

call

  • 立即执行
  • 参数逐个传

apply

  • 立即执行
  • 参数数组传

bind

  • 不立即执行
  • 返回新函数

六、常见使用场景

1)借用方法

const arrLike = { 0: 'a', 1: 'b', length: 2 };
const arr = Array.prototype.slice.call(arrLike);

console.log(arr); // ['a', 'b']

2)绑定 this

const obj = {
  name: 'Tom',
  say() {
    console.log(this.name);
  }
};

setTimeout(obj.say.bind(obj), 1000);

七、总结

callapplybind的核心作用就是:

显式指定函数执行时的 this。

记忆口诀:

  • call :立即调用,参数逐个传

  • apply:立即调用,参数数组传

  • bind:不立即调用,返回新函数

JS 异步:Event-Loop+async/await

前言

在前端开发中,是不是经常被JS的异步代码绕晕?明明写的代码顺序一样,运行结果却大相径庭?其实核心原因就在于——JS是单线程语言,而异步操作全靠「Event-Loop(事件循环)」来调度。今天就结合具体代码案例,从进程线程、V8引擎到Event-Loop、async/await,一步步拆解,让你搞懂JS异步的底层逻辑~

一、先搞基础:进程 vs 线程

在聊JS异步之前,我们得先分清两个容易混淆的概念:进程和线程,这是理解后续内容的基石~

  • 进程 :简单来说,进程就是CPU运行指令、加载和保存上下文所需的“容器”,是程序运行的独立单位。比如你打开浏览器,每多开一个Tab页,就相当于多开启了一个进程,每个进程之间相互独立,互不干扰。

  • 线程 :线程是进程内的执行单元,是CPU实际执行指令的“最小单位”。一个进程可以包含多个线程,这些线程共享进程的资源,协同完成任务。

举个浏览器的例子:每个浏览器Tab进程中,会包含多个核心线程,其中和我们JS相关的有3个:

  1. 渲染线程:负责渲染页面(HTML、CSS渲染);

  2. JS引擎线程:负责执行JS代码;

  3. HTTP请求线程:负责发送网络请求。

这里有个关键知识点⚠️:因为JS代码可以修改DOM(比如document.write、appendChild),如果JS引擎线程和渲染线程同时运行,会导致页面渲染混乱,所以JS引擎线程和渲染线程是互斥的——也就是说,JS代码执行时,渲染线程会暂停,等JS执行完,渲染线程才会继续工作。这也是为什么有时候JS代码写得太复杂,页面会出现“卡顿”的原因~

二、V8引擎:JS单线程的“幕后推手”

我们写的JS代码,最终是由V8引擎来执行的。而V8引擎在执行JS代码时,默认只开启一个JS引擎线程——这就意味着,JS代码只能“自上而下、依次执行”,同一时间只能做一件事。

那问题来了:如果JS遇到耗时操作(比如setTimeout、网络请求、读取文件),难道要一直等着操作完成,再继续执行后续代码吗?这样会导致页面卡死,用户体验直接拉胯!

为了解决这个问题,JS引入了「异步机制」:单线程处理代码时,遇到同步任务,就立即执行;遇到异步任务,不等待、不阻塞,而是把它暂时存放到“任务队列”中,等JS引擎线程空闲时,再去执行任务队列中的异步任务。

三、核心重点:Event-Loop 事件循环

Event-Loop(事件循环)就是JS处理异步任务的“调度器”,它的执行流程决定了所有同步、异步代码的运行顺序。我们先明确两个核心概念:微任务宏任务——所有异步任务,都会被分到这两个队列中。

3.1 微任务 vs 宏任务

微任务和宏任务的区别,在于它们的“优先级”:微任务优先级高于宏任务,会先于宏任务执行。

  • 微任务(优先级高)

    • Promise.then()、Promise.catch()、Promise.finally()

    • process.nextTick()(Node.js环境,浏览器不支持)

    • MutationObserver(监听DOM变化的API)

  • 宏任务(优先级低)

    • 整个script脚本(最外层的同步代码,属于宏任务的开端)

    • setTimeout()、setInterval()

    • AJAX请求、I/O操作(比如读取文件)

    • UI渲染(页面渲染操作)

    易错点提醒⚠️:很多人会误以为“setTimeout(fn, 0)”会立即执行,其实不然——setTimeout的延迟时间是“最小延迟”,不是“精确延迟”,即使设为0,也会被放入宏任务队列,等待同步代码、微任务全部执行完毕后,才会执行。

3.2 Event-Loop 执行顺序

记住这个顺序,就能搞定80%的异步代码输出题,结合后面的代码案例理解更透彻👇:

  1. 先执行同步代码(最外层script脚本,属于宏任务),执行过程中遇到异步任务,就分别存入微任务队列、宏任务队列;

  2. 同步代码执行完毕后,清空微任务队列(所有微任务依次执行,执行过程中产生的新微任务,也会在本次微任务队列中执行完毕);

  3. 微任务全部执行结束后,若有需要(如DOM发生变化),浏览器会进行页面渲染

  4. 渲染完成后,从宏任务队列中取出第一个宏任务执行(执行该宏任务的过程中,遇到同步、异步任务,重复步骤1-2);

  5. 重复步骤1-4,形成“循环”,这就是Event-Loop。

3.3 代码实操:搞懂Event-Loop执行顺序

结合你给出的第一段代码,我们一步步拆解执行过程,看看为什么输出结果是「1 2 7 3 5 4 6」:

console.log(1); // 同步代码:输出1
new Promise((resolve) => {
  console.log(2); // Promise构造函数内是同步代码:输出2
  resolve()
})
.then(() => {
  console.log(3); // 微任务:存入微任务队列
  setTimeout(() => {
    console.log(4); // 宏任务:存入宏任务队列(延迟0ms)
  }, 0)
})
setTimeout(() => {
  console.log(5); // 宏任务:存入宏任务队列(延迟0ms)
  setTimeout(() => {
    console.log(6); // 宏任务:存入宏任务队列(延迟0ms)
  }, 0)
}, 0)
console.log(7); // 同步代码:输出7

执行步骤拆解👇:

  1. console.log(1):同步,输出「1」;

  2. new Promise:构造函数内是同步代码,console.log(2),输出「2」;调用resolve(),将then回调存入微任务队列(记为微1);

  3. 遇到setTimeout(延迟0ms):宏任务,存入宏任务队列(记为宏1);

  4. console.log(7):同步,输出「7」;

  5. 同步代码执行完毕,开始清空微任务队列:执行微1(then回调),console.log(3),输出「3」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏2);

  6. 微任务队列清空,渲染页面(本次无明显渲染);

  7. 执行宏任务队列第一个宏任务(宏2):console.log(5),输出「5」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏3);

  8. 宏2执行完毕,再次检查微任务队列(无新微任务),执行下一个宏任务(宏2):console.log(4),输出「4」;

  9. 宏3执行完毕,检查微任务队列(无),执行下一个宏任务(宏3):console.log(6),输出「6」。

所以最终输出顺序就是:1 2 7 3 5 4 6 ✅

3.4 再练一题:巩固Event-Loop

再看这段代码👇:

console.log(1);

setTimeout(() => {
  console.log(2);
  setTimeout(() => {
    console.log(3)
  }, 1000)
}, 0)

setTimeout(() => {
  console.log(4)
}, 2000)
console.log(5);

执行步骤拆解👇:

  1. 执行同步代码:console.log(1) → 输出 1

  2. 遇到第一个 setTimeout(..., 0):延迟 0ms 后,把回调(输出 2 + 嵌套定时器)推入宏任务队列

  3. 遇到第二个 setTimeout(..., 2000):延迟 2000ms 后,把回调(输出 4)推入宏任务队列

  4. 执行同步代码:console.log(5) → 输出 5

  5. 同步代码执行完毕,开始处理宏任务队列

    • 取出第一个宏任务:执行 → 输出 2
    • 执行中遇到嵌套的 setTimeout(..., 1000):延迟 1000ms 后,把回调(输出 3)推入宏任务队列;
  6. 此时宏任务队列里,只有「延迟 2000ms 的输出 4」在等待

  7. 时间流逝:

    • 1000ms 到:输出3 被推入宏任务队列 → 立刻执行 → 输出 3
    • 再等 1000ms(总计 2000ms):输出4 被推入宏任务队列 → 执行 → 输出 4

最终输出顺序:1 → 5 → 2 → 3 → 4

宏任务队列是先进先出,为什么 3 比 4 先输出?

关键点:两个定时器不是同时入队

  • 输出 4 的定时器:一开始就设定了 2000ms 延迟,2000ms 后才入队;
  • 输出 3 的定时器:等第一个宏任务执行完(瞬间完成),才设定 1000ms 延迟,1000ms 后就入队执行。1000ms < 2000ms,所以 3 必然比 4 先执行,和宏任务队列顺序无关。

JavaScript 中setTimeout的延迟时间是回调函数加入宏任务队列的等待时间,而非执行时间;宏任务队列遵循先进先出,但不同定时器的回调不是同时入队,谁的延迟时间先耗尽,谁就先入队先执行。

四、进阶:async/await 异步语法糖

async/await 是ES7引入的异步语法,本质是Promise的“语法糖”,让异步代码写起来更像同步代码,可读性大大提升。

4.1 async/await 核心规则

  • async关键字:函数前面加async,等同于函数内部自动返回一个Promise实例对象。比如: async function fn() { return 1; // 等同于 return Promise.resolve(1); } fn().then(res => console.log(res)); // 输出1

  • await关键字:必须跟async配合使用,不能单独使用;如果await后面接的不是Promise对象,await就无法“约束”它,会直接执行后续代码;如果await后面接Promise对象,会“暂停”当前async函数的执行,等待Promise状态变为resolved(成功)或rejected(失败),再继续执行后续代码。

  • 关键原理:await fn() 之所以能“当成同步看待”,核心是——await会把它后续的代码(当前async函数内,await后面的所有代码),挤到微任务队列中,等await后面的Promise执行完成后,再执行这个微任务。

4.2 代码实操:async/await 执行顺序

先看这段基础代码,理解async/await和Promise的关联:

function a() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('a'); // 宏任务
      resolve()
    }, 1000)
  })
}
function b() {
  console.log('b'); // 同步
}

// 案例1:Promise.then写法
a().then(() => {
  b()
})
console.log('hello'); // 同步

输出顺序:hello → a → b(同步代码先执行,a()是Promise,then回调是微任务,等待a()的宏任务执行完,再执行微任务b())

再看async/await写法,对比差异:

function a() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('a'); // 宏任务
      resolve()
    }, 1000)
  })
}
function b() {
  console.log('b'); // 同步
}

async function foo() {
  setTimeout(() => {
    console.log('c'); // 宏任务(延迟1500ms)
  }, 1500)

  await a()  // 等待a()的Promise resolve,后续代码进入微任务
  b()
  console.log('hello');
}

foo()

执行步骤拆解👇:

  1. 调用foo(),执行async函数内部代码;

  2. 遇到setTimeout(延迟1500ms):宏任务,存入宏任务队列(记为宏A);

  3. 遇到await a():a()返回Promise,里面有setTimeout(延迟1000ms,宏任务,记为宏B);此时foo()暂停执行,等待宏B执行完毕、Promise resolve;

  4. 同步代码执行完毕(此时foo()暂停,无其他同步代码),检查微任务队列(无),执行宏任务队列;

  5. 先执行宏B(延迟1000ms):console.log('a'),输出「a」;调用resolve(),此时await等待结束,将foo()后续的代码(b()、console.log('hello'))存入微任务队列;

  6. 宏B执行完毕,检查微任务队列,执行微任务:b()输出「b」,console.log('hello')输出「hello」;

  7. 微任务执行完毕,执行下一个宏任务(宏A,延迟1500ms):console.log('c'),输出「c」。

最终输出顺序:a → b → hello → c ✅

4.3 综合案例:async/await + Promise + setTimeout

console.log('script start'); // 同步
async function async1() {
  await async2() // 等待async2()执行,后续代码进入微任务
  console.log('async1 end'); // 微任务
}
async function async2() {
  console.log('async2 end'); // 同步(async函数内,await前的代码是同步)
}
async1()
setTimeout(() => {
  console.log('setTimeout'); // 宏任务
}, 0)
new Promise((resolve, reject) => {
  console.log('promise'); // 同步
  resolve()
})
  .then(() => {
    console.log('then1'); // 微任务
  })
  .then(() => {
    console.log('then2'); // 微任务(then1执行完后存入)
  }); 
console.log('script end'); // 同步

输出顺序:script start → async2 end → promise → script end → async1 end → then1 → then2 → setTimeout

💡 关键提醒:async函数内,await前面的代码是同步执行的;await后面的代码会被放入微任务队列,和Promise.then的微任务优先级相同,按顺序执行。

五、总结:异步核心知识点梳理

1. JS是单线程,由V8引擎的JS引擎线程执行,与渲染线程互斥;

2. 异步任务分为微任务(优先级高)和宏任务(优先级低);

3. Event-Loop执行顺序:同步代码 → 微任务队列 → 页面渲染 → 宏任务队列(循环);

4. async/await是Promise语法糖,await会将后续代码放入微任务队列,等待Promise resolve后执行。

JavaScript 作用域与作用域链详解

前言

在 JavaScript 中,作用域是非常基础但又非常重要的知识点。
闭包、变量查找、函数执行、模块化等内容,都和作用域密切相关。

本文主要讲清楚:

  • 什么是作用域
  • JavaScript 有哪些作用域
  • 什么是作用域链
  • 变量查找规则是什么

一、什么是作用域?

作用域可以理解为:

变量和函数可以被访问的范围。

也就是说,一个变量不是在任何地方都能访问,它的可访问范围由作用域决定。

例如:

let a = 10;

function test() {
  let b = 20;
  console.log(a); // 可以访问
}

test();
// console.log(b); // 报错

这里:

  • a 在全局作用域中

  • b 在函数作用域中

  • 函数内部可以访问全局变量

  • 全局不能访问函数内部变量


二、JavaScript 中的几种作用域

1)全局作用域

定义在函数外部的变量,通常属于全局作用域。

let name = 'Tom';

function say() {
  console.log(name);
}

全局作用域中的变量,在当前脚本中通常都可以访问。


2)函数作用域

在函数内部声明的变量,只能在函数内部访问。

function test() {
  let age = 18;
  console.log(age);
}

test();
// console.log(age); // 报错

3)块级作用域

使用letconst声明的变量,会形成块级作用域。

{
  let a = 1;
  const b = 2;
}

// console.log(a); // 报错
// console.log(b); // 报错

ifforwhile{}都可以形成块级作用域。


三、var、let、const 的作用域区别

var

  • 没有块级作用域
  • 只有全局作用域和函数作用域
if (true) {
  var a = 10;
}

console.log(a); // 10

let / const

  • 有块级作用域
if (true) {
  let b = 20;
}

// console.log(b); // 报错

四、什么是作用域链?

当在当前作用域中查找某个变量时,如果找不到,就会去上一级作用域查找,直到全局作用域。
这个逐级向上查找的过程,就叫做 作用域链


五、作用域链示例

let a = 1;

function outer() {
  let b = 2;

  function inner() {
    let c = 3;
    console.log(“结果”,a, b, c);
  }

  inner();
}

outer();

输出:

结果,1 2 3

查找过程:

  • c:先在inner 自己内部找,找到

  • binner找不到,去outer 找,找到

  • ainner找不到,outer 找不到,去全局找,找到

这就是作用域链。


六、作用域链的本质

作用域链的本质是:

函数在定义时,就已经确定了它能访问哪些外部变量。

注意,是定义时,不是调用时。

这也是闭包能成立的基础。


七、总结

作用域决定了变量的可访问范围,作用域链决定了变量的查找路径。

重点记住:

  • 全局作用域
  • 函数作用域
  • 块级作用域
  • 变量查找是逐级向上找
  • 找不到最终会报错

JavaScript this 指向详解

前言

在 JavaScript 中,this是一个非常高频、也非常容易让人混乱的知识点。
很多初学者会发现:同样是一个函数,在不同场景下调用this的值居然不一样。

比如:

  • 普通函数里的this

  • 对象方法里的this

  • 构造函数里的this

  • 箭头函数里的this

  • callapplybind改变this指向

本文就来系统讲清楚:JavaScript 中 this 到底指向谁。


一、this 是什么?

this不是在函数定义时决定的,而是在函数调用时决定的

也就是说:

谁调用这个函数,this 通常就指向谁。

先看一个简单例子:

const obj = {
  name: 'Tom',
  say() {
    console.log(this.name);
  }
};

obj.say(); // Tom

这里say()是被obj调用的,所以this指向obj


二、普通函数中的 this

1)浏览器非严格模式下

function test() {
  console.log(this);
}

test();

在浏览器非严格模式下,普通函数直接调用时,

this指向window


2)严格模式下

'use strict';

function test() {
  console.log(this);
}

test();

严格模式下,普通函数直接调用时,

thisundefined


三、对象方法中的 this

如果函数作为对象的方法调用,那么this指向这个对象。

const obj = {
  name: 'Alice',
  say() {
    console.log(this.name);
  }
};

obj.say(); // Alice

注意,真正决定this的,不是函数写在哪,而是怎么调用

const obj = {
  name: 'Alice',
  say() {
    console.log(this.name);
  }
};

const fn = obj.say;
fn(); // 浏览器非严格模式下通常是 undefined 或 window.name

这里fn()已经不是通过obj调用了,所以this不再指向obj


四、构造函数中的 this

当函数通过new调用时,this指向新创建的实例对象。

function Person(name) {
  this.name = name;
}

const p = new Person('Tom');
console.log(p.name); // Tom

这里this指向p


五、箭头函数中的 this

箭头函数没有自己的this,它的this取决于外层作用域

const obj = {
  name: 'Tom',
  say: () => {
    console.log(this.name);
  }
};

obj.say();

这里箭头函数不会绑定obj,而是继承外层的this


如果在浏览器全局环境下,通常指向window


一个更容易理解的例子

const obj = {
  name: 'Tom',
  say() {
    const fn = () => {
      console.log(this.name);
    };
    fn();
  }
};

obj.say(); // Tom

这里箭头函数fn继承了say()this,也就是obj


六、事件中的 this

在 DOM 事件中,普通函数里的this一般指向触发事件的元素。

button.onclick = function () {
  console.log(this); // button 元素
};

如果写成箭头函数:

button.onclick = () => {
  console.log(this);
};

那么这里的this不再指向按钮,而是继承外层作用域。


七、call、apply、bind 改变 this

JavaScript 提供了三种显式改变this的方法:

  • call
  • apply
  • bind
function say() {
  console.log(this.name);
}

const obj = { name: 'Tom' };

say.call(obj);  // Tom
say.apply(obj); // Tom

bind 不会立即执行,而是返回一个新的函数:

const fn = say.bind(obj);
fn(); // Tom

八、this 指向总结

可以简单记住下面几条:

  1. 普通函数直接调用:浏览器非严格模式下指向 window

  2. 对象方法调用:指向调用它的对象

  3. 构造函数调用:指向新实例

  4. 箭头函数:没有自己的 this ,继承外层

  5. call/apply/bind:可以显式指定 this


九、总结

this的核心不是“函数定义在哪”,而是:

函数是如何被调用的。

只要抓住这句话,再结合几种常见场景,this就不会再那么抽象了。

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

前言

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

本章概述

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

学习目标:

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

前置知识:

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

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


1. 环境准备

1.1 检查 Node.js 版本

Tiptap 需要 Node.js 16+ 版本。

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

# 检查 npm 版本
npm --version

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

1.2 选择包管理器

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

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

# 验证安装
pnpm --version

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

# 使用 npm
npm install

# 使用 yarn
yarn add

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


2. 创建项目

2.1 使用 Vite 创建项目

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

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

# 进入项目目录
cd tiptap-demo

# 安装依赖
pnpm install

为什么选择 Vite?

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

2.2 项目结构

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

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

2.3 启动开发服务器

pnpm dev

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


3. 安装 Tiptap

3.1 安装核心包

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

包的说明:

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

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

3.2 验证安装

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

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

4. 创建第一个编辑器

4.1 清理默认代码

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

修改 src/App.tsx

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

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

export default App

修改 src/App.css

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

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

4.2 创建编辑器组件

创建 src/Tiptap.tsx 文件:

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

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

  return <EditorContent editor={editor} />
}

export default Tiptap

代码解析:

  1. 导入必要的模块

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

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

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

4.3 在 App 中使用

修改 src/App.tsx

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

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

export default App

4.4 添加基础样式

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

/* src/App.css */

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

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

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

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

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

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

4.5 测试编辑器

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

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

测试清单:

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

5. 理解 StarterKit

5.1 StarterKit 包含的扩展

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

Nodes(节点):

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

Marks(标记):

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

Extensions(功能):

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

5.2 测试 StarterKit 功能

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

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

现在你应该能看到:

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

5.3 自定义 StarterKit

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

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

6. 添加工具栏

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

6.1 创建工具栏组件

修改 src/Tiptap.tsx

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

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

  if (!editor) {
    return null
  }

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

export default Tiptap

代码解析:

  1. 空值检查

    if (!editor) return null
    

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

  2. Commands 链式调用

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

    editor.isActive('bold')
    

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

  4. 检查命令可用性

    editor.can().undo()
    

    用于禁用不可用的按钮。

6.2 添加工具栏样式

创建 src/Tiptap.css 文件:

/* src/Tiptap.css */

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

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

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

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

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

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

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

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

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

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

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

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

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

.tiptap li {
  margin: 0.25rem 0;
}

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

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

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

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

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

6.3 测试工具栏

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

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

测试步骤:

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

7. 基本配置选项

7.1 常用配置

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

7.2 配置选项说明

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

8. 完整源码

📄 src/Tiptap.tsx

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

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

  if (!editor) {
    return null
  }

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

export default Tiptap

📄 src/App.tsx

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

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

export default App

9. 本章总结

在本章中,我们学习了:

✅ 环境准备

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

✅ 安装 Tiptap

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

✅ 创建编辑器

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

✅ StarterKit

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

✅ 添加工具栏

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

✅ 基本配置

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

🎯 关键知识点

1. useEditor Hook

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

2. Commands 链式调用

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

3. 检查状态

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

10. 下一步

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

第 3 章:Tiptap 与 React 集成

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

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

  • Vue 集成
  • Angular 集成
  • Vanilla JavaScript

准备好继续学习了吗?🚀


11. 练习题

练习 1:添加更多按钮

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

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

练习 2:添加字符计数

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

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

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

练习 3:实现只读模式

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

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

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

12. 常见问题

Q1: 为什么编辑器是 null?

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

if (!editor) return null

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

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

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

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

A: 使用 setContent() 方法:

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

Q4: 快捷键不工作?

A: 确保编辑器有焦点:

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

13. 扩展阅读


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

一、数据结构设计

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

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

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

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

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

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

javascript

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

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

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

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

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

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

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

使用示例

javascript

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

复杂度说明

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

关于通过react使用hooks进行数据状态处理

你想了解在 React 中如何使用 Hooks 来处理数据状态,这是 React 函数组件开发中最核心也最基础的知识点之一。

一、React Hooks 处理状态的核心用法

React 提供了一系列内置 Hooks,其中最常用的是 useState(基础状态管理)和 useReducer(复杂状态管理),下面我会从基础到进阶,结合示例代码讲解它们的使用方式。

1. 基础状态管理:useState

useState 是最基础的 Hook,用于管理组件中的简单状态(如数字、字符串、布尔值、简单对象/数组)。

核心语法

import { useState } from 'react';

// 初始化状态:[状态变量, 更新状态的方法] = useState(初始值)
const [state, setState] = useState(initialValue);

完整示例(处理表单+简单数据)

import { useState } from 'react';

function UserInfo() {
  // 1. 单个简单状态(布尔值)
  const [isShow, setIsShow] = useState(false);
  
  // 2. 对象类型状态(用户信息)
  const [user, setUser] = useState({
    name: '',
    age: 0
  });

  // 3. 数组类型状态(爱好列表)
  const [hobbies, setHobbies] = useState(['读书']);

  // 更新简单状态
  const toggleShow = () => {
    setIsShow(!isShow);
  };

  // 更新对象状态(注意:需要解构原有对象,避免覆盖)
  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setUser({
      ...user, // 解构原有状态,保留未修改的属性
      [name]: name === 'age' ? Number(value) : value // 类型转换
    });
  };

  // 更新数组状态(不可直接修改原数组,需生成新数组)
  const addHobby = () => {
    setHobbies([...hobbies, '运动']); // 解构原数组 + 新增元素
  };

  return (
    <div>
      <button onClick={toggleShow}>{isShow ? '隐藏' : '显示'}</button>
      
      {isShow && (
        <div>
          <div>
            <input
              name="name"
              value={user.name}
              onChange={handleInputChange}
              placeholder="输入姓名"
            />
            <input
              name="age"
              type="number"
              value={user.age}
              onChange={handleInputChange}
              placeholder="输入年龄"
            />
          </div>

          <div>
            <p>爱好:{hobbies.join(', ')}</p>
            <button onClick={addHobby}>添加爱好</button>
          </div>
        </div>
      )}
    </div>
  );
}

export default UserInfo;

关键说明

  • useState 的初始值只会在组件首次渲染时生效,后续更新不会重新执行;
  • 更新状态的方法(如 setUser)是异步的,若需要基于前一次状态更新,建议使用函数式写法:
// 推荐:函数式更新(确保拿到最新的状态)
setUser(prevUser => ({ ...prevUser, age: prevUser.age + 1 }));
  • 对于对象/数组类型的状态,不能直接修改原数据(React 状态是不可变的),必须生成新的对象/数组。
2. 复杂状态管理:useReducer

当状态逻辑复杂(如多个状态关联、状态更新规则多),或组件内状态操作频繁时,useReduceruseState 更易维护(类似 Redux 的核心思想)。

核心语法

import { useReducer } from 'react';

// 1. 定义 reducer 函数:(当前状态, 动作) => 新状态
function reducer(state, action) {
  switch (action.type) {
    case 'UPDATE_NAME':
      return { ...state, name: action.payload };
    case 'INCREMENT_AGE':
      return { ...state, age: state.age + 1 };
    case 'RESET':
      return { name: '', age: 0 };
    default:
      throw new Error('未知的 action 类型');
  }
}

function ComplexState() {
  // 2. 初始化 useReducer:[状态, 分发动作的方法] = useReducer(reducer, 初始状态)
  const [state, dispatch] = useReducer(reducer, { name: '', age: 0 });

  return (
    <div>
      <p>姓名:{state.name}</p>
      <p>年龄:{state.age}</p>

      <button onClick={() => dispatch({ type: 'UPDATE_NAME', payload: '张三' })}>
        设置姓名
      </button>
      <button onClick={() => dispatch({ type: 'INCREMENT_AGE' })}>
        年龄+1
      </button>
      <button onClick={() => dispatch({ type: 'RESET' })}>
        重置
      </button>
    </div>
  );
}

export default ComplexState;

关键说明

  • reducer 是纯函数(无副作用、相同输入必返回相同输出),只负责状态计算;
  • dispatch 用于触发状态更新,通过 action(包含 typepayload)告诉 reducer 要执行的操作;
  • 适合场景:表单多字段联动、购物车状态(加减商品、计算总价)、步骤条状态等。
3. 衍生 Hooks:useState + useEffect 处理异步数据

实际开发中,状态常来自接口请求,此时需要结合 useEffect(处理副作用)来获取并更新状态:

import { useState, useEffect } from 'react';

function FetchData() {
  const [data, setData] = useState(null); // 接口数据
  const [loading, setLoading] = useState(true); // 加载状态
  const [error, setError] = useState(null); // 错误状态

  useEffect(() => {
    // 异步获取数据
    const fetchData = async () => {
      try {
        const res = await fetch('https://api.example.com/user');
        const result = await res.json();
        setData(result);
      } catch (err) {
        setError('数据请求失败:' + err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []); // 空依赖数组:仅首次渲染执行

  if (loading) return <div>加载中...</div>;
  if (error) return <div>{error}</div>;

  return (
    <div>
      <h3>用户数据:</h3>
      <p>姓名:{data?.name}</p>
      <p>邮箱:{data?.email}</p>
    </div>
  );
}

export default FetchData;

二、状态管理的最佳实践

  1. 状态拆分:将相关的状态聚合,无关的状态拆分(比如把“用户信息”和“页面开关”拆成两个 useState);
  2. 避免过度状态:能通过计算得到的值(如“是否成年 = age >= 18”)不需要定义为状态,直接在渲染时计算即可;
  3. 状态提升:如果多个组件需要共享状态,将状态提升到它们的共同父组件中;
  4. 复杂全局状态:若跨组件/跨页面共享状态,可使用 useContext + useReducer,或第三方库(如 Redux Toolkit、Zustand)。

总结

  1. 基础场景用 useState:处理单个/简单类型的状态(数字、字符串、简单对象/数组),核心是“不可变更新”;
  2. 复杂场景用 useReducer:状态逻辑多、多状态关联时,用 reducer 统一管理状态更新规则,更易维护;
  3. 异步状态结合 useEffect:接口请求等异步操作放在 useEffect 中,配合 useState 管理加载、数据、错误状态。

掌握这几个核心 Hooks 的用法,就能覆盖 React 函数组件中绝大部分的状态处理场景了。

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

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

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

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

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

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

早期异步Web API: XMLHttpRequest(AJAX)

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

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

示例:

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

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

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

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

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

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

XMLHttpRequest

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

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

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

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

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

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

实现AJAX

通常需要以下几个步骤:

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

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

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

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

跨域问题和解决方法

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

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

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

AJAX的优势和注意事项

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

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

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

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

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


使用Fetch API与Promise

如何使用 Promise

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

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

首先,Promise 有三种状态:

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

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

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

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

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


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

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

console.log(fetchPromise);

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

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

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

Promise链

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

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

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

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

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

所以以上代码等价于:

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

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

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

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

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

错误捕获

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

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

catch处理函数的输出错误。

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

合并使用多个promise

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

Promise.all()

Promise.all()返回的 Promise:

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

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

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

Promise.any()

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

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

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

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

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

async 和 await

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

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

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

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

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

小结与更多Promise

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

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

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

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

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

从零开发一个微信记账小程序,零依赖、附完整源码

本文记录了「简记账」微信小程序的完整开发过程。从需求分析、架构设计到各页面实现,9个技术亮点一一拆解。适合有微信小程序基础的开发者阅读,也适合想找轻量级项目练手的同学。


一、为什么做这个小程序?

市面上的记账 App 动辄要注册账号、开通会员、同步云端——对于只想记个午饭钱的人来说,太重了。

于是我给自己定了一个极简原则:打开即用,不登录,不注册,记一笔只需 3 步

最终做出来的「简记账」是这样的:

  • 首页:余额卡片 + 一键记收入/记支出
  • 统计页:本月收支汇总 + 分类排行
  • 设置页:CSV 数据导出 + 一键清空

零 npm 依赖,纯原生微信小程序 API,包体积极小。

绠€璁拌处鎴浘1.png


二、项目结构

简记账/
├── app.js              # 全局数据服务层(核心)
├── app.json            # 路由 + tabBar 配置
├── app.wxss            # 全局通用样式
├── pages/
│   ├── index/          # 首页(记账 + 流水)
│   ├── stats/          # 统计页
│   └── settings/       # 设置页
└── images/             # tabBar 图标

结构很干净。没有 components 目录,没有 utils 工具库,不引入任何第三方包。


三、架构设计:app.js 作为数据服务层

这是整个项目最关键的设计决策。

微信小程序里各页面之间共享数据,常见做法有两种:

  1. 每个页面自己读写 Storage
  2. 把 Storage 操作统一封装在 app.js,页面通过 getApp() 调用

我选了第二种。好处是:页面完全不感知存储细节,未来如果从本地存储升级到云数据库,只改 app.js 就够了,页面代码零改动。

数据结构

每一条记账记录长这样:

{
  id: Date.now(),              // 时间戳作唯一 ID,够用
  type: 'income' | 'expense',
  amount: 58.5,                // 数字,不是字符串
  note: '午餐',                // 用户输入,默认为分类名
  category: 'food',            // 分类 key
  icon: '🍜',                  // emoji 图标
  categoryIcon: 'food',        // CSS 类名(用于背景色)
  date: '2026-03-23T10:30:00Z' // ISO 8601,方便计算
}

五个核心方法

// app.js
App({
  onLaunch() {
    this.checkLocalStorage()
  },

  // 初始化:确保 key 存在
  checkLocalStorage() {
    const transactions = wx.getStorageSync('transactions')
    if (!transactions) {
      wx.setStorageSync('transactions', [])
    }
  },

  // 新记录插到数组头部,保证最新在前
  saveTransaction(transaction) {
    let transactions = wx.getStorageSync('transactions') || []
    transactions.unshift(transaction)
    wx.setStorageSync('transactions', transactions)
    return true
  },

  getTransactions() {
    return wx.getStorageSync('transactions') || []
  },

  // 按 id 过滤,重写全量数组
  deleteTransaction(id) {
    let transactions = wx.getStorageSync('transactions') || []
    transactions = transactions.filter(t => t.id !== id)
    wx.setStorageSync('transactions', transactions)
  },

  // 月度统计:按年月筛选后累加
  getMonthlyStats() {
    const transactions = this.getTransactions()
    const now = new Date()
    let income = 0, expense = 0

    transactions.forEach(t => {
      const date = new Date(t.date)
      if (date.getMonth() === now.getMonth() &&
          date.getFullYear() === now.getFullYear()) {
        if (t.type === 'income') income += t.amount
        else expense += t.amount
      }
    })

    return { income, expense, balance: income - expense }
  },

  // 分类汇总
  getStatsByCategory(type) {
    const transactions = this.getTransactions()
    const now = new Date()
    const stats = {}

    transactions.forEach(t => {
      if (t.type !== type) return
      const date = new Date(t.date)
      if (date.getMonth() !== now.getMonth()) return
      stats[t.category] = (stats[t.category] || 0) + t.amount
    })

    return stats
  }
})

为什么用同步 API(Sync 系列)?

异步 API 需要写回调或 Promise,代码层层嵌套。记账这种轻量场景,数据量小,同步读写完全够用,而且代码清晰很多,不会有回调地狱。


四、首页:记账弹窗的设计细节

绠€璁拌处鎴浘2.png

首页的核心交互是底部弹起的记账面板

弹窗实现

我没用 wx:if 控制显隐,而是用 CSS class 切换:

/* 默认隐藏 */
.modal {
  display: none;
  position: fixed;
  top: 0; left: 0;
  width: 100%; height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
  justify-content: center;
  align-items: flex-end; /* 关键:内容贴底部 */
}

/* 激活时显示 */
.modal.active {
  display: flex;
}

/* 弹窗面板:只有上方是圆角 */
.modal-content {
  background: white;
  width: 100%;
  border-radius: 48rpx 48rpx 0 0;
  padding: 48rpx;
  max-height: 80vh;
  overflow-y: auto;
}

WXML 里通过三元表达式动态切换 class:

<view class="modal {{showModal ? 'active' : ''}}" bindtap="closeAddModal">
  <view class="modal-content" catchtap="stopPropagation">
    <!-- 内容 -->
  </view>
</view>

注意 catchtap="stopPropagation" 这里——点击面板内容时,阻止事件冒泡到背景层,否则一碰面板就会关闭弹窗。

为什么不用 wx:if

wx:if 是条件渲染,每次显示/隐藏都会销毁/重建 DOM。用 CSS 切换只是修改 display 属性,性能更好,也不会丢失输入框里已填的内容。

动态分类过滤

记收入和记支出要显示不同的分类选项,我把所有分类存在一个数组里,根据类型实时过滤:

openAddModal(e) {
  const type = e.currentTarget.dataset.type // 'income' 或 'expense'

  const incomeCategories = ['salary', 'bonus', 'investment', 'other_income']

  const filtered = allCategories.filter(c =>
    type === 'income'
      ? incomeCategories.includes(c.value)
      : !incomeCategories.includes(c.value)
  )

  this.setData({
    showModal: true,
    modalType: type,
    categories: filtered,
    selectedCategory: filtered[0].value
  })
}

一套数据,两种视图,不用维护两个独立数组。

智能时间显示

交易列表里的时间,我做了语义化处理,比"2026-03-23 10:30"更有温度:

formatDate(isoString) {
  const date = new Date(isoString)
  const now = new Date()
  const diff = now - date
  const days = Math.floor(diff / (1000 * 60 * 60 * 24))

  if (days === 0) {
    const minutes = Math.floor(diff / (1000 * 60))
    if (minutes === 0) return '刚刚'
    const hours = Math.floor(diff / (1000 * 60 * 60))
    if (hours === 0) return `${minutes}分钟前`
    return `今天 ${this.formatTime(date)}`
  }
  if (days === 1) return '昨天'
  if (days < 7) return `${days}天前`
  return `${date.getMonth() + 1}${date.getDate()}日`
}

输出效果:刚刚 / 5分钟前 / 今天 09:30 / 昨天 / 3天前 / 3月15日


五、统计页:分类排行的实现

绠€璁拌处鎴浘3.png

统计页的核心是把原始数据转成可展示的排行列表。

formatCategories(rawStats, type) {
  const categoryMap = {
    food:       { name: '餐饮', icon: '🍜' },
    transport:  { name: '交通', icon: '🚇' },
    shopping:   { name: '购物', icon: '🛒' },
    // ...其他分类
  }

  return Object.entries(rawStats)
    .map(([key, value]) => ({
      key,
      name: categoryMap[key]?.name || key,
      icon: categoryMap[key]?.icon || '📦',
      amount: value.toFixed(2)
    }))
    .sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)) // 按金额降序
}

Object.entries(){ food: 120, transport: 30 } 这样的对象转成数组,再 map + sort,链式操作很清晰。

结余颜色动态判断:

<view class="stat-value {{balance >= 0 ? 'income' : 'expense'}}">
  ¥{{balance}}
</view>

收支相抵为正显示绿色,亏损显示红色,简单直观。


六、设置页:用剪贴板实现数据导出

微信小程序的文件系统权限比较复杂,直接生成并保存 Excel 文件需要申请额外权限。

我的解法是:生成 CSV 文本,复制到剪贴板,让用户自己粘贴到 Excel

exportData() {
  const transactions = app.getTransactions()
  if (transactions.length === 0) {
    wx.showToast({ title: '暂无数据可导出', icon: 'none' })
    return
  }

  let csv = '类型,金额,备注,分类,日期\n'
  transactions.forEach(t => {
    const date = new Date(t.date)
    const dateStr = `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`
    const type = t.type === 'income' ? '收入' : '支出'
    csv += `${type},${t.amount},${t.note},${t.category},${dateStr}\n`
  })

  wx.setClipboardData({
    data: csv,
    success: () => {
      wx.showModal({
        title: '导出成功',
        content: '数据已复制到剪贴板,请粘贴到Excel中保存',
        showCancel: false
      })
    }
  })
}

这个方案绕开了文件权限的麻烦,对普通用户来说操作也不复杂:复制 → 打开 Excel → 粘贴。


七、UI 设计:用 emoji 代替图标库

整个项目没有引入任何图标字体或 SVG 图标库,全部用 Unicode emoji。

好处:

  • 零包体积增加
  • 天然跨平台兼容
  • 色彩丰富,视觉效果好

每个分类有独立的背景色标:

.transaction-icon.food        { background: #fef3c7; }  /* 暖黄 */
.transaction-icon.transport   { background: #dbeafe; }  /* 浅蓝 */
.transaction-icon.shopping    { background: #fce7f3; }  /* 粉色 */
.transaction-icon.salary      { background: #dcfce7; }  /* 浅绿 */
.transaction-icon.entertainment { background: #e0e7ff; } /* 淡紫 */
.transaction-icon.medical     { background: #fee2e2; }  /* 浅红 */

emoji + 分类色块,不需要设计稿,纯代码实现就有不错的视觉层次。

主色用紫蓝渐变:

background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

顶部卡片加了毛玻璃效果:

.balance-info {
  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(20rpx);
}

八、数据刷新策略

所有页面都实现了 onLoad + onShow 双钩子刷新:

onLoad() { this.loadData() }
onShow() { this.loadData() }

onLoad 是页面第一次加载时触发,onShow 是每次切换到该页面时触发。

如果只有 onLoad,从统计页切回首页时,余额不会更新。加上 onShow 就解决了多页面数据同步的问题。这是微信小程序开发的标准实践,值得记住。


九、云开发迁移路径

虽然目前用的是本地存储,但项目已经为云开发预留了迁移空间:

app.json 已设置 "cloud": trueapp.js 中有注释掉的初始化代码:

// wx.cloud.init({ env: 'your-env-id', traceUser: true })

迁移时只需修改 app.js 里的五个方法:

当前实现 云开发替换
wx.setStorageSync('transactions', data) db.collection('transactions').add({ data })
wx.getStorageSync('transactions') db.collection('transactions').get()
transactions.filter(t => t.id !== id) + setStorageSync db.collection('transactions').doc(id).remove()

页面代码一行不用改。这就是把数据层抽象到 app.js 的价值所在。


十、总结

这个项目有几个值得借鉴的点:

  1. 全局服务模式app.js 统一管理数据读写,页面解耦
  2. 同步 Storage API:避免异步回调,代码清晰
  3. CSS class 控制弹窗:比 wx:if 性能好,不丢失表单状态
  4. emoji 代替图标库:零依赖,包体积最小
  5. 双钩子刷新onLoad + onShow 保证跨页面数据同步
  6. 剪贴板导出:绕过文件权限限制的轻量方案
  7. 动态分类过滤:一套数据,两种视图
  8. 语义化时间:提升用户体验的小细节
  9. 云开发预留:接口层隔离,未来升级零成本

完整源码已在掘金平台开源,可通过文章开头的链接访问

如果觉得有帮助,点个赞再走~


作者:守(SO) | 2026年3月

TECNO发布龙虾智能体EllaClaw

36氪获悉,TECNO发布龙虾智能体EllaClaw。据介绍,EllaClaw是全球首款基于OpenClaw、专为新兴市场打造的移动AI智能体。同时,TECNO即将开启TECNO EllaClaw Beta版本测试。

涂鸦智能发布TuyaClaw,为全网首批支持微信ClawBot

36氪获悉,近日,涂鸦智能发布首个打通数字世界与物理世界的AI助理——TuyaClaw。它基于OpenClaw架构搭建,既能操作屏幕中的浏览器和桌面应用,又能主动调用智能家居设备、办公设备协同工作。同时,TuyaClaw是全网首批支持微信ClawBot,无需复杂安装程序,启用微信插件扫码即可体验。

成都出台公积金新政,最高贷款额度提升至120万元

36氪获悉,据成都发布,3月24日,成都住房公积金管理中心召开新政发布新闻通气会,为进一步提振住房消费,更好满足缴存人刚性和改善性住房需求,促进房地产市场平稳健康发展,成都市出台《关于进一步优化住房公积金有关政策的通知》《关于进一步支持住房消费有关事项的通知》《关于进一步优化重大疾病提取住房公积金的通知》《关于调整住房公积金贷款有关政策的通知》系列政策举措,最高贷款额度提升至120万元,贷款次数也不再限制,首套房认定也进一步放宽。

OpenAI开更优条件吸引PE,与Anthropic竞争企业AI

据知情人士透露,ChatGPT开发商OpenAI正向私募股权公司(PE)提供比竞争对手Anthropic更优厚的条件。这两家人工智能(AI)公司目前正争相拉拢私募股权公司,组建合资企业以筹集新资金并加速企业级AI产品的推广。(新浪财经)
❌