普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月21日首页

胖东来正与酒鬼酒洽谈600元新品

2026年3月21日 16:05
胖东来与酒鬼酒拟推出次高端白酒新品。知情人士透露:“新品定价600-800元,双方已就该产品洽谈了一段时间。”去年7月,酒鬼酒与胖东来推出首款联名产品“酒鬼·自由爱”。该产品面向大众市场,定价200元,综合成本168.26元,毛利率15.87%。新品上市后曾一度热销,线上和线下均出现短暂断货。(酒业内参)

工具指南7-Unix时间戳转换工具

作者 GeraldChen
2026年3月21日 13:32

几乎每个开发者都遇到过这种场景:后端返回一个 1710921600,你盯着它看了三秒,不知道这是哪天哪个时间。或者反过来,需要给接口传一个时间参数,要把"2026年3月20日下午2点"转换成 Unix 时间戳,打开浏览器搜 "timestamp converter"。

这个操作的频率比你想象的高。日志排查、接口调试、数据库查询、定时任务配置——时间戳无处不在。这篇文章从原理聊起,讲清楚 Unix 时间戳的设计逻辑和常见坑点,顺便分享一些实用技巧。

Unix 时间戳的本质

Unix 时间戳(Unix Timestamp)的定义很简单:从 UTC 1970年1月1日 00:00:00 到某个时刻经过的秒数

比如 0 就是 1970-01-01T00:00:00Z,86400 是 1970-01-02T00:00:00Z(一天有 86400 秒),而你读到这篇文章时的当前时间大约是 17 开头的十位数字。

这个设计来自 Unix 操作系统。1970年之前的时间用负数表示,比如 -86400 是 1969-12-31T00:00:00Z。

为什么用时间戳而不是日期字符串

直觉上,"2026-03-20 14:00:00"1773986400 更好懂。但在系统设计中,时间戳有几个明显优势:

无歧义"2026-03-20 14:00:00" 是哪个时区的?不知道。但 1773986400 指向的是一个确定的时刻,全球一致。

易计算:两个时间戳相减就是秒数差。判断 "A 是否在 B 之后" 只需要比较大小。用日期字符串做这些操作,先得解析再计算。

存储紧凑:一个 32 位整数占 4 字节,一个 ISO 8601 日期字符串至少 19 字节。在大量数据场景下差距明显。

排序高效:整数排序远快于字符串排序。数据库对整数字段的索引效率也更高。

所以后端系统和数据库普遍使用时间戳存储时间,展示时再转换成可读格式。

秒级 vs 毫秒级:别搞混了

这是最常见的踩坑点之一。不同系统和语言使用的时间戳精度不同:

精度 位数 示例 常见场景
秒级 10位 1773986400 Unix/Linux、PHP、Python time.time()
毫秒级 13位 1773986400000 JavaScript Date.now()、Java System.currentTimeMillis()
微秒级 16位 1773986400000000 Python time.time_ns() // 1000、数据库精确记录
纳秒级 19位 1773986400000000000 Go time.Now().UnixNano()、高精度计时

实际开发中最常遇到的是秒级和毫秒级的混淆。快速判断方法:数一下位数。10位是秒,13位是毫秒。

一个典型的 bug 场景:前端用 Date.now() 拿到毫秒级时间戳传给后端,后端按秒级解析,结果日期跑到了公元 58000 年。反过来也一样,后端返回秒级时间戳,前端直接传给 new Date() 不乘 1000,显示出来是 1970 年。

AnyFreeTools 的时间戳工具会自动识别输入的是秒级还是毫秒级,省去手动判断的麻烦。

时区:时间戳最容易出错的地方

时间戳本身是 UTC 时间,没有时区概念。但是当你把时间戳转换成可读日期时,时区就来了。

1773986400 这个时间戳:

  • 在 UTC 是 2026-03-20 06:00:00
  • 在北京时间 (UTC+8) 是 2026-03-20 14:00:00
  • 在纽约时间 (UTC-4, 夏令时) 是 2026-03-20 02:00:00

同一个时间戳,三个不同的"日期时间"。这不是 bug,这就是时区的本质。

常见时区问题

服务器时区不一致:前端服务器在东八区,后端服务器在 UTC,数据库在美西。不同服务拿到同一个时间戳转成本地时间后对不上,排查日志时容易困惑。

夏令时:美国每年 3 月和 11 月调整时钟。一个 cron 任务设定在"每天凌晨 2 点执行",在夏令时切换那天可能不执行(2 点被跳过了)或执行两次(2 点重复了)。

Date 对象的隐式时区转换

// 这两行代码的结果可能不同
const d1 = new Date("2026-03-20");           // 解析为 UTC 00:00
const d2 = new Date("2026-03-20T00:00:00");  // 解析为本地时区 00:00

console.log(d1.getTime() === d2.getTime());  // false(如果你不在 UTC 时区)

JavaScript 的 Date 构造函数在处理不同格式的日期字符串时,时区行为不一致。这个设计被广泛认为是 JS 时间处理中最反直觉的点之一。

最佳实践

  1. 存储和传输一律用 UTC 时间戳,展示时再转为用户所在时区
  2. API 文档明确标注时间戳精度(秒还是毫秒)
  3. 日志统一使用 UTC 时间,方便跨时区排查
  4. 避免依赖服务器本地时间,用 NTP 同步

各语言的时间戳操作

JavaScript

// 获取当前时间戳(毫秒)
const nowMs = Date.now();
const nowSec = Math.floor(Date.now() / 1000);

// 时间戳转日期
const date = new Date(1773986400 * 1000);  // 注意乘 1000
console.log(date.toISOString());           // "2026-03-20T06:00:00.000Z"
console.log(date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }));
// "2026/3/20 14:00:00"

// 日期转时间戳
const ts = new Date("2026-03-20T14:00:00+08:00").getTime() / 1000;

Python

import time
from datetime import datetime, timezone, timedelta

# 获取当前时间戳
now = int(time.time())

# 时间戳转日期
dt = datetime.fromtimestamp(1773986400, tz=timezone.utc)
print(dt.isoformat())  # "2026-03-20T06:00:00+00:00"

# 日期转时间戳
dt = datetime(2026, 3, 20, 14, 0, 0, tzinfo=timezone(timedelta(hours=8)))
ts = int(dt.timestamp())

Go

package main

import (
    "fmt"
    "time"
)

func main() {
    // 获取当前时间戳
    now := time.Now().Unix()

    // 时间戳转日期
    t := time.Unix(1773986400, 0)
    fmt.Println(t.UTC().Format(time.RFC3339))

    // 日期转时间戳
    loc, _ := time.LoadLocation("Asia/Shanghai")
    t2 := time.Date(2026, 3, 20, 14, 0, 0, 0, loc)
    fmt.Println(t2.Unix())
}

Bash

# 获取当前时间戳
date +%s

# 时间戳转日期(macOS)
date -r 1773986400

# 时间戳转日期(Linux)
date -d @1773986400

# 日期转时间戳(Linux,注意带时区)
date -d "2026-03-20T14:00:00+08:00" +%s

当然,如果只是临时查一下,没必要开终端写代码。直接打开 AnyFreeTools 时间戳转换工具,粘贴时间戳就能看到结果,支持秒级和毫秒级自动识别。

2038 问题:32 位时间的尽头

如果你用过 32 位系统,可能听说过 "Y2K38" 问题。32 位有符号整数的最大值是 2147483647,对应的时间是 2038年1月19日 03:14:07 UTC。过了这个时刻,32 位时间戳溢出,会回绕到 1901 年。

这不是假想的问题。2023 年已经有报告指出部分嵌入式设备和旧版数据库因为提前计算未来日期触发了 2038 相关的 bug。

现代系统基本已经迁移到 64 位时间戳。64 位有符号整数能表示到公元 2920 亿年后,足够用了。但如果你维护的系统中有以下情况,需要注意:

  • 数据库字段用 INT(32) 存时间戳
  • C 语言代码中用 time_t 且编译目标是 32 位
  • 嵌入式系统或 IoT 设备运行 32 位固件

检查方法很简单:试着存入 2147483648(2038 年之后的时间戳),看系统是否正常处理。

实用场景

日志排查

线上出了故障,需要定位到 "15:23:45 到 15:24:10 之间的日志"。先把这两个时间转成时间戳,然后在日志系统中按时间戳范围过滤。比手动翻日志快得多。

缓存过期

Redis 的 EXPIREAT 命令接受 Unix 时间戳:

# 设置 key 在 2026-03-21 00:00:00 UTC(北京时间 08:00)过期
redis-cli EXPIREAT mykey 1774051200

定时任务

系统的 cron 任务可能需要根据时间戳计算下次执行时间。比如"每 7 天执行一次",可以用上次执行的时间戳加上 604800(7 天的秒数)。

JWT 过期时间

JWT 的 exp(过期时间)和 iat(签发时间)字段都是 Unix 时间戳(秒级)。调试 JWT 时经常需要把这些字段转成可读时间来确认 token 是否过期。可以配合 JWT 解码工具 一起使用。

数据库时间查询

-- 查询最近 24 小时的订单(假设 created_at 存的是秒级时间戳)
SELECT * FROM orders
WHERE created_at > UNIX_TIMESTAMP() - 86400;

-- MySQL: 时间戳转日期
SELECT FROM_UNIXTIME(1773986400);

-- PostgreSQL: 时间戳转日期
SELECT to_timestamp(1773986400);

在线工具 vs 命令行

命令行的 date 命令可以做时间戳转换,但不同操作系统的语法不一样(macOS 用 -r,Linux 用 -d @),而且不支持毫秒级时间戳的直接转换。

在线工具的优势在于:

  • 零记忆成本:不用记命令语法
  • 可视化:同时显示多个时区的对应时间
  • 自动识别精度:粘贴 10 位或 13 位数字,自动判断是秒还是毫秒
  • 双向转换:时间戳 → 日期、日期 → 时间戳,一个页面搞定

AnyFreeTools 的时间戳工具还会显示当前时间的实时时间戳(每秒更新),在需要 "获取当前时间戳" 的场景下直接复制就行。

小结

时间戳看起来简单,但时区、精度、溢出这些细节处处是坑。核心原则就三条:

  1. 存储传输用 UTC 时间戳,展示再转时区
  2. 明确精度(秒 vs 毫秒),接口文档写清楚
  3. 注意 32 位限制,老系统该升级就升级

日常开发中,时间戳转换是高频低门槛的操作,没必要每次都写代码。遇到需要快速查看或转换的场景,用 在线工具更高效。

本系列其他文章


原文链接chenguangliang.com/posts/blog0…

零代码上线一个图片处理网站,我是如何使唤AI干活的?

2026年3月21日 12:24

零代码上线一个图片处理网站,我是怎么做到的?

一个产品经理的「AI 开发」实验报告

最近我再次尝试了下,完全用AI来做一个 图片处理工具(也就是下文的轻图),看看到底是不是靠谱的。

为什么做图片处理工具呢?一直想做一些自己日常工作能用到的工具网站,而图片可以算是日常工作里非常高频的处理对象,不管是 产品经理、 前端开发 还是 UI设计师,甚至包括 后端开发 ,都会或多或少的需要处理图片——图片压缩、图片格式转换、二维码生成、二维码解码、图片Base64编解码等等。


一、先问你一个问题

你有没有想过:不写一行代码,能不能做出一个功能完整的网站?

半年前,我也不信。

直到我亲手做出来了——轻图 (image.mid-life.vip/)  ,一个涵盖图片裁剪、压缩、格式转换、九宫格切图、拼图、二维码、Base64 等十几种功能的在线工具站。

全程零代码。  我只负责提需求、验收效果,剩下的,全部交给 AI。

今天,我想把这段经历写下来,分享给每一个好奇「AI 到底能干什么」的人。


二、从「想法」到「上线」:我做了什么?

我的角色:需求输出 + 效果验收

在整个开发过程中,我的工作只有两件事:

  1. 说清楚我要什么
    比如:「用户上传图片后,要在浏览器里完成压缩,不能上传到服务器」「九宫格切图要支持圆角」「证件照压缩要能调到 200KB 以内」……
  2. 验收效果
    打开页面,点点看,功能对不对、体验好不好。不对就继续提需求,对了就进入下一项。

没有写代码。  没有配环境。没有查文档。没有 debug。

所有实现,都由 AI 根据我的需求描述,一步步完成。

AI 做了什么?

从项目架构、技术选型、到每个功能模块的实现,AI 负责:

  • 设计 monorepo 结构,拆分 image-coreimage-uishared 等包
  • 实现图片压缩、格式转换、裁剪、马赛克、文字、水印等核心逻辑
  • 接入 WebAssembly(MozJPEG、OxiPNG 等)做高质量压缩
  • 用 FFmpeg.wasm 在浏览器里完成视频转 Live Photo
  • 搭建 Next.js 前端、SEO 优化、帮助中心、教程页……

一个正常需要 2–3 人、1–2 个月才能做完的项目,在 AI 的协助下,以「需求驱动」的方式,被拆解成一个个可验收的小任务,高效推进。


三、为什么我敢说「你的图片绝对安全」?

这是轻图最让我骄傲的一点:所有图片处理,100% 在浏览器本地完成。

技术原理(通俗版)

当你把图片拖进轻图:

  • 图片只存在于你的电脑/手机内存
  • 压缩、裁剪、格式转换……全部在你的浏览器里用 Canvas、WebAssembly 完成
  • 没有任何一张图片会被上传到我的服务器

换句话说:你的照片,从打开网站到下载完成,从未离开过你的设备。

为什么这很重要?

我们每天都会遇到需要处理图片的场景:

  • 报名考试,证件照要压缩到 200KB
  • 发朋友圈,想做九宫格切图
  • iPhone 拍的 HEIC 在电脑上打不开,要转 JPG
  • 电商主图、简历照片、社交头像……

很多在线工具会要求你「上传」图片。上传意味着:你的照片会经过别人的服务器。

而轻图不需要上传。  打开网页,选图,处理,下载——全程在本地完成。隐私和安全,从设计上就被保证了。

这也是我在需求里反复强调的一点:「全部在浏览器端实现,图片不上传服务端。」 AI 在实现时,严格遵循了这一点。


四、AI 开发,效率到底有多夸张?

传统开发 vs AI 协作

环节 传统方式 我的方式
需求沟通 写 PRD、开会、反复对齐 直接跟 AI 说「我要什么」
技术选型 调研、对比、写方案 AI 给出建议,我拍板
写代码 程序员一行行写 AI 按需求生成
调试修复 查日志、断点、改代码 描述问题,AI 改
文档/教程 单独安排人写 AI 按结构批量生成

最大的变化:  我不再需要「等排期」「等开发」「等联调」。
有想法 → 提需求 → 验收 → 迭代。节奏完全掌握在自己手里。

一个具体例子

有一次,我需要加一个「图片 Base64 编解码」功能。
我对 AI 说:
「加一个工具页,用户粘贴 Base64 能预览图片,上传图片能转成 Base64,支持多种输出格式,全部本地处理。」

几分钟后,功能上线。
我打开页面,试了几种格式,确认没问题,就发布了。

没有拉会、没有排期、没有「下周才能做」。  这就是 AI 带来的效率飞跃。


五、轻图能帮你做什么?

轻图目前支持这些功能(全部免费、无需注册):

基础编辑:裁剪、旋转、镜像、马赛克、添加文字、水印、背景

模板切图:九宫格、四宫格、六宫格、圆角裁切

模板拼图:长图拼接、多图模板拼图

格式转换:JPG、PNG、WebP、GIF、SVG 互转,视频转 Live Photo

调整尺寸:按像素、百分比或社交平台预设(微信、小红书、抖音等)

图片压缩:智能压缩,可调质量,支持证件照等场景

二维码:链接/文本转二维码、美化、解码

Base64:图片与 Base64 互转

所有功能,打开浏览器就能用,图片不会上传,隐私有保障。


六、如果你也想试试「AI 开发」

我的几点体会:

  1. 需求要具体
    「做一个图片网站」太模糊。「做一个在浏览器里压缩图片的工具,支持 JPG/PNG,可调质量,不上传服务器」——这样 AI 才知道要做什么。
  2. 验收要严格
    AI 生成的代码不一定一次就对。你要会「挑毛病」:这里不对、那里体验不好。迭代几次,效果会越来越好。
  3. 选对工具
    我用的是 Cursor 等 AI 编程助手,配合结构化的需求文档(spec)和项目规范。好的工具 + 清晰的需求 = 事半功倍。
  4. 从一个小项目开始
    不必一上来就做「大系统」。先做一个单页工具、一个小功能,验证整个流程,再慢慢扩展。

七、技术延伸:如何让 AI 更好地理解需求?(Spec Kit 工作流)

如果你对技术实现感兴趣,可以继续往下看;否则可以直接跳到结尾。

轻图从 0 到 1 的开发过程中,我采用了 GitHub Spec Kit 倡导的 Spec-Driven Development(规格驱动开发)  工作流。简单说:先写好「要做什么」,再让 AI 按规格实现「怎么做」,而不是直接让 AI 自由发挥。

Spec Kit 是什么?

Spec Kit 是 GitHub 开源的 AI 编码工具包,核心理念是:规格(spec)是可执行的——它不只是文档,而是能直接驱动 AI 生成符合预期的代码。

我的工作流:五步走

步骤 做什么 我的体会
1. Constitution 建立项目原则(代码质量、测试标准、性能要求等) 相当于给 AI 定「宪法」,后续所有决策都参考它
2. Specify 用自然语言描述功能需求(要什么、为什么) 只讲业务,不讲技术栈;越具体,AI 输出越准
3. Clarify 对模糊点提问、澄清,把答案写回规格 在写计划前做,能大幅减少返工
4. Plan 确定技术栈和实现方案 这时才谈框架、架构、依赖
5. Tasks → Implement 拆成可执行任务,让 AI 按顺序实现 任务有依赖关系,AI 会按正确顺序执行

Spec Kit 最佳实践(我的总结)

  1. 先定原则,再写需求
    用 /speckit.constitution 建立项目「宪法」,让 AI 在写代码时自动遵守(如:所有文档用中文、图片处理必须浏览器端完成)。
  2. 需求阶段不聊技术
    在 Specify 阶段,只描述「用户要什么」「业务规则是什么」,不要提前指定「用 React 还是 Vue」。技术选型留给 Plan 阶段。
  3. 澄清优先于计划
    用 /speckit.clarify 在写实现计划前,把规格里的模糊点、边界情况问清楚。否则 AI 会按自己的理解实现,容易偏离预期。
  4. 多步细化,不要一次到位
    Spec-Driven 强调「分步求精」:先有规格 → 再澄清 → 再计划 → 再拆任务 → 再实现。每一步都有产出,可验收。
  5. 任务要有依赖和顺序
    /speckit.tasks 会生成带依赖关系的任务列表,AI 按顺序执行,避免「还没建好数据模型就先写 API」这类问题。
  6. 实现前做一致性检查
    用 /speckit.analyze 在实现前检查 spec、plan、tasks 三者是否一致,减少实现阶段的返工。

这套流程让「需求 → 代码」的路径变得可预测、可追溯。如果你也在用 Cursor、Claude Code 等 AI 编程助手做从 0 到 1 的项目,不妨试试 Spec Kit。


八、最后,欢迎你来用轻图

轻图已经上线,所有功能免费。

网址:image.mid-life.vip/

无论你是要压缩证件照、做九宫格、转格式,还是生成二维码——打开浏览器,选图,处理,下载。
你的图片,全程只在你自己的设备里。


如果你觉得这篇文章有启发,欢迎转发给身边对「AI 开发」或「隐私安全」感兴趣的朋友。

也欢迎在评论区聊聊:
你用过 AI 做过什么?效率有没有飞起来?


轻松处理每一张图,在线完成裁剪、格式转换、拼图等常用操作。
轻图 · 免费在线图片处理工具

闭包:那个“赖着不走”的家伙,到底有什么用?

作者 kyriewen
2026年3月21日 12:03

昨天我们认识了闭包——那个“虽然离开了家,但还记得家里密码”的神奇函数。今天咱们来深挖一下:闭包这玩意儿到底能干啥?有没有什么副作用?怎么防止它把内存吃光?看完这篇,你不仅知道闭包怎么用,还能在面试官面前侃侃而谈。

前言

闭包就像一个“赖着不走”的租客。你以为人走了,结果他还留着你的钥匙,时不时回来拿点东西。这在JavaScript里有时候特别好用,有时候又特别坑。

今天我们就来盘点闭包的几个经典应用场景,顺便聊聊怎么让它“体面退场”,别把你的内存吃光。

一、闭包的应用场景:这个“赖着不走”的家伙还挺有用

1. 模块化:私有变量与公共方法

没有ES6模块之前,闭包是JS实现模块化的主要手段。它能把内部细节藏起来,只暴露需要公开的接口。

const counter = (function() {
  let count = 0; // 私有变量,外面访问不到
  
  function increment() {
    count++;
    console.log(count);
  }
  
  function decrement() {
    count--;
    console.log(count);
  }
  
  function getCount() {
    return count;
  }
  
  return {
    increment,
    decrement,
    getCount
  };
})();

counter.increment(); // 1
counter.increment(); // 2
console.log(counter.count); // undefined,拿不到
console.log(counter.getCount()); // 2

这个模式叫IIFE(立即执行函数),它创建了一个闭包,里面的count变量被返回的方法“记住”了,外部无法直接修改,只能通过提供的接口操作。像不像一个“保险箱”?钥匙只给了你几个特定的人。

2. 函数工厂:批量生产定制函数

闭包可以用来创建带有特定“预设”的函数,比如一个能记录调用次数的函数。

function createCounter(initial = 0) {
  let count = initial;
  return function() {
    count++;
    return count;
  };
}

const counterA = createCounter(10);
console.log(counterA()); // 11
console.log(counterA()); // 12

const counterB = createCounter(0);
console.log(counterB()); // 1

每个计数器都独立拥有自己的count变量,互不干扰。这个工厂就像是做定制蛋糕,每个客户拿到的是自己专属的那一份。

3. 防抖与节流:控制函数执行频率

防抖和节流是前端性能优化的常见手法,它们的核心都依赖闭包来保存计时器和状态。

防抖:用户连续触发事件时,只有最后一次等待结束后才执行(比如搜索框输入)。

function debounce(fn, delay) {
  let timer = null; // 闭包保存timer
  return function(...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

// 使用
const search = debounce(() => console.log('搜索中...'), 500);

节流:限制函数在单位时间内最多执行一次(比如滚动事件)。

function throttle(fn, delay) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= delay) {
      last = now;
      fn.apply(this, args);
    }
  };
}

这两个函数返回的都是闭包,里面的timerlast被“记住”了,所以每次调用都能访问到上一次的状态。

4. 柯里化:提前固定参数

柯里化是把多参数函数变成一系列单参数函数的技术,本质也是闭包。

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...more) {
        return curried.apply(this, args.concat(more));
      };
    }
  };
}

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6

每次返回新函数时,原来的args被闭包保存,直到参数凑齐才执行。就像是你给一家餐厅留了订单,每次打电话加菜,最后一起结算。

5. 事件监听中的回调

在事件回调里访问外部变量,其实也是闭包。比如一个简单的计数器按钮:

let count = 0;
document.getElementById('btn').addEventListener('click', function() {
  count++;
  console.log(count);
});

这里的匿名函数“记住”了外部的count变量,每次点击都能访问到最新的值。

二、闭包的“阴暗面”:内存泄漏与性能

闭包这么香,为什么还有人说它不好?因为它会“赖着不走”——那些被记住的变量,即使外部函数已经执行完了,也不会被垃圾回收,只要闭包函数还活着,它们就一直存在。

1. 什么是内存泄漏?

内存泄漏就是程序用完了内存,但系统没有及时回收,导致内存占用越来越大,最后浏览器变卡、甚至崩溃。

闭包导致泄漏的典型场景:

function leak() {
  let bigData = new Array(1000000).fill('leak');
  return function() {
    console.log('I am a closure');
    // 虽然没有直接使用bigData,但闭包还是引用了它
  };
}

const closureFn = leak(); // 泄漏了100万个元素的数组

上面这个例子中,返回的函数虽然没有用到bigData,但因为bigData和它在同一个作用域,闭包会保留整个作用域链上的所有变量。所以如果闭包一直存在,那些无用的变量也一直占用内存。

2. 如何避免闭包导致的内存泄漏?

  • 用完后解除引用:把闭包函数的变量置为null
closureFn = null; // 这样bigData就可以被回收了
  • 只保留需要的变量:如果闭包中只用到部分变量,可以用let声明在闭包外部提前“过滤”。
function good() {
  let bigData = new Array(1000000).fill('data');
  let needed = 'only me';
  return function() {
    console.log(needed); // 只引用needed,bigData会被回收
  };
}

因为闭包只引用了needed,引擎可以优化,把bigData标记为不可达。

  • 避免在循环中创建闭包(除非必要),因为循环中的闭包可能会意外持有大量变量。

3. 弱引用:救星Map和Set

ES6引入了WeakMapWeakSet,它们的键是弱引用的——如果键对象不再被其他地方引用,那么即使还在WeakMap里,也会被垃圾回收。

这在闭包中可以用来缓存数据,而不阻止回收。

const cache = new WeakMap();

function process(obj) {
  if (!cache.has(obj)) {
    const result = heavyComputation(obj);
    cache.set(obj, result);
  }
  return cache.get(obj);
}

如果obj在其他地方被销毁了,cache里的键值对也会自动消失,不会造成泄漏。

三、实战:闭包的最佳实践

  1. 用闭包封装私有数据:在不需要完全隔离的情况下,闭包是模块化的好帮手。但现代开发可以用ES6模块(import/export)替代IIFE,更清晰。

  2. 防抖节流用闭包保存状态:这是闭包的经典应用,没啥好纠结的。

  3. 谨慎使用返回闭包的高阶函数:如果闭包持有大量数据,确保及时清理。

  4. 善用let替代varlet有块级作用域,能避免一些意外的闭包问题。

  5. 在DevTools里监控内存:用Chrome的Memory面板,可以拍快照,看看哪些闭包对象一直存在,帮助定位泄漏。

四、总结:闭包是个好员工,但别让它996

闭包是JavaScript的强大特性,它让函数拥有了“记忆”,能实现模块化、柯里化、防抖节流等高级功能。但也要注意它的副作用:被记住的变量不会自动消失,如果不注意,容易造成内存泄漏。

记住几个原则:

  • 用完闭包,及时解除引用。
  • 在闭包里只引用需要的变量,减少内存占用。
  • 现代开发中,能用ES6模块就用模块,减少手动闭包模式。
  • 遇到缓存场景,优先考虑WeakMap

掌握了闭包,你就掌握了JS高级编程的核心钥匙。明天我们将走进JS的另一个灵魂领域——原型和原型链,看看那个让新手望而生畏的概念,到底是怎么一回事。

如果你觉得今天的闭包应用和内存管理讲得透彻,点个赞让更多人看到。有疑问评论区见,我们明天见!

Web安全:从“来源校验”到“CSRF Token”的演进

2026年3月21日 11:44

在现代Web应用中,服务端需要对请求进行合法性校验,以防止恶意攻击(如跨站请求伪造CSRF)。

早期一种常见的做法是依据HTTP请求头中的OriginReferer信息来判断请求来源,只放行来自可信站点的请求。然而,这种机制存在明显缺陷,因此逐渐被更安全的方案,如CSRF Token所取代。

一、基于Origin/Referer的校验机制及其局限性

1. 工作原理

服务端检查每个请求的OriginReferer头:

  • 若该头的值在允许的域名列表内,则放行;
  • 若头信息缺失或域名不符,则拒绝请求。

这种方法的逻辑是:正常浏览器请求会自动带上当前页面的来源,因此可以借此区分“本站内发起的请求”和“外部站点发起的跨站请求”。

2. 主要缺点

(1)请求头可以被伪造

OriginReferer完全由客户端(浏览器、脚本、命令行工具)控制。攻击者可以轻易构造带有任意Referer值的请求,从而绕过服务端的来源校验。例如,使用curl直接添加Referer: 即可骗过服务器。

这使得该机制只能防御最基础的、不修改请求头的攻击,防护强度较低。

(2)搜索引擎与攻击者之间的两难困境

为了SEO,网站必须放行搜索引擎爬虫(如Googlebot)的请求。但服务器通常只能通过User-AgentReferer来识别这些爬虫,这就带来了两种风险:

  • 伪造爬虫身份:攻击者将自己的User-Agent改为Googlebot,同时清空Referer,就可能绕过限制;
  • 利用搜索引擎来源:如果服务器信任来自google.comReferer,攻击者可搭建恶意站点,伪造该Referer诱导用户点击,从而发起跨站请求。

核心问题在于:所有关键校验字段都由客户端提供,服务端无法真正验证其真实性。

二、更安全的方案:CSRF Token

为了解决上述问题,现代Web应用普遍采用CSRF Token机制,它将校验依据从“请求来源”转移到“服务端与前端预先协商的、不可预测的密钥”。

1. 工作原理

(1)建立会话时生成Token

当用户首次访问页面或登录成功后,服务端生成一个随机、不可预测的字符串(CSRF Token),并将该Token与当前用户的会话(Session)绑定。同时,Token被安全地写入前端页面,例如放在表单的隐藏字段中,或存储在<meta>标签内供JavaScript读取。

(2)发起请求时携带Token

当用户执行需要保护的敏感操作(如修改密码、转账)时,前端自动从页面中提取Token,并将其放入请求的特定位置(通常是请求头X-CSRF-Token,或POST表单字段)。这一过程对正常用户无感。

(3)服务端校验Token

服务端收到请求后,取出请求中的Token,并与会话中保存的Token进行比对:

  • 一致 → 请求确实来自网站自身页面且由用户主动触发,放行;
  • 不一致或缺失 → 拒绝请求。

2. CSRF Token为何更安全

  • 不可预测性:Token为服务端生成的随机值,攻击者无法提前构造包含正确Token的恶意请求。
  • 绑定会话:每个用户的Token与会话唯一绑定,无法在其他会话下复用。
  • 不依赖客户端上报的来源:即便攻击者伪造OriginReferer,缺少有效Token的请求依然会被拦截。
  • 对搜索引擎友好:爬虫不执行页面脚本或提交表单,不会携带Token,因此不会被误拦;正常用户请求则因正确携带Token而正常放行。

三、现代Web中的组合防护

虽然CSRF Token能够有效防御CSRF攻击,但在实际工程中,为了兼顾安全性与用户体验,通常会组合使用多种防护手段:

  • SameSite Cookie属性:将Cookie设置为SameSite=LaxStrict,可阻止大多数跨站请求自动携带Cookie,从浏览器层面减轻CSRF风险。
  • 双重Cookie验证:将Token同时放在Cookie和请求头中,服务端校验两者是否一致,适用于前后端分离架构。
  • API网关/签名机制:对于非浏览器的API调用(如移动端、第三方服务),使用更严格的签名算法(如OAuth、HMAC)确保请求的完整性和来源可信。

四、总结

  • 基于Origin/Referer的来源校验虽然实现简单,但其安全性严重依赖客户端上报的信息,容易被伪造,且在允许搜索引擎时存在明显绕过风险。
  • CSRF Token机制通过服务端生成并校验随机密钥,从根本上解决了跨站请求伪造问题,成为现代Web安全防护的重要基石。在实际应用中,结合SameSite、双重Cookie等多种策略,可以构建更纵深、更健壮的防护体系。

Promise.try () 完全指南

作者 haorooms
2026年3月21日 11:39

在 JavaScript 异步编程中,开发者常面临一个痛点:同步代码的错误无法被 Promise 的 .catch () 捕获,而 setTimeout/setInterval 等宏任务的错误更是 “逃逸” 到全局,难以统一处理。Promise.try () 作为解决这类问题的关键 API,本文将从作用、用法、兼容性、与其他 API 的对比等维度,全面解析其价值和使用场景。

一、核心问题:setTimeout/setInterval 的错误能捕获吗?

1. 直接捕获:几乎不可能

setTimeout/setInterval 的回调函数运行在新的宏任务执行栈中,脱离了原有的 Promise 链 /try-catch 作用域,因此:

js

// ❌ 无法捕获 setTimeout 内的错误
try {
  setTimeout(() => {
    throw new Error('定时器错误');
  }, 100);
} catch (err) {
  console.log('捕获到错误:', err); // 永远不会执行
}

// ❌ Promise.catch 也抓不到
Promise.resolve()
  .then(() => {
    setTimeout(() => {
      throw new Error('定时器错误');
    }, 100);
  })
  .catch(err => console.log('捕获到错误:', err)); // 同样无效

2. 间接处理:手动封装为 Promise

唯一能 “捕获” 定时器错误的方式,是在回调内主动处理,并封装为 Promise:

js

function delayTask(fn, ms) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try {
        const result = fn(); // 执行任务
        resolve(result);
      } catch (err) {
        reject(err); // 手动捕获错误并 reject
      }
    }, ms);
  });
}

// 使用示例
delayTask(() => {
  throw new Error('定时器内的错误');
}, 100)
.then(res => console.log(res))
.catch(err => console.log('捕获到错误:', err)); // 生效!

3. 关键结论

  • setTimeout/setInterval 的错误无法被外层 try-catch/Promise.catch 直接捕获
  • 必须在回调内部用 try-catch 包裹逻辑,并手动 reject 才能纳入 Promise 链;
  • 而 Promise.try () 的核心价值,正是无需手动 try-catch,自动统一同步 / 异步错误(但对定时器这类宏任务仍需额外封装)。

二、Promise.try () 核心功能:统一同步 / 异步错误处理

1. 为什么需要 Promise.try ()?

日常开发中,一个函数可能混合同步逻辑和异步逻辑,同步错误会直接抛出(而非进入 Promise.catch):

js

// 问题代码:同步错误无法被 catch 捕获
function getUser(id) {
  if (!id) throw new Error('id 不能为空'); // 同步错误
  return fetch(`/api/user/${id}`); // 异步 Promise
}

// 使用时
getUser() // 直接抛出错误,不会进入 catch
  .then(res => res.json())
  .catch(err => console.log('错误:', err));

而 Promise.try () 能把同步代码 “包装” 成 Promise 链,让同步错误也能被 .catch () 捕获:

js

// 修复:用 Promise.try 包裹
function getUser(id) {
  return Promise.try(() => {
    if (!id) throw new Error('id 不能为空'); // 同步错误
    return fetch(`/api/user/${id}`); // 异步 Promise
  });
}

// 使用时
getUser()
  .then(res => res.json())
  .catch(err => console.log('错误:', err)); // 同步/异步错误都能捕获!

2. Promise.try () 的核心作用

表格

核心作用 具体说明
统一错误捕获 同步代码抛出的错误 → 自动转为 Promise.reject,可被 .catch () 捕获
简化代码 无需手动写 try-catch 包裹同步逻辑,代码更简洁
语义化启动 Promise 链 new Promise((resolve) => resolve(fn())) 更直观
兼容返回值类型 无论回调返回同步值、Promise、还是抛出错误,都统一为 Promise 实例

3. 核心特性:“穿透” 异步层级

即使回调内是多层异步逻辑,Promise.try () 也能保持错误捕获的一致性:

js

Promise.try(async () => {
  const userId = await getUserId(); // 异步获取 ID
  if (!userId) throw new Error('无用户 ID'); // 同步判断
  const user = await fetchUser(userId); // 异步请求
  return user;
})
.catch(err => console.log('所有错误都能捕获:', err));

三、Promise.try () 用法全解析

1. 基本语法

js

// 语法 1:基础用法
Promise.try(executor)
  .then(result => { /* 处理成功结果 */ })
  .catch(error => { /* 处理所有错误(同步+异步) */ });

// 语法 2:结合 async/await
Promise.try(async () => {
  // 混合同步/异步逻辑
  const data = await fetchData();
  if (data.length === 0) throw new Error('无数据');
  return data;
})
.catch(err => console.error(err));

2. 常见使用场景

场景 1:封装混合同步 / 异步的函数

js

// 封装工具函数:统一错误处理
function getCache(key) {
  return Promise.try(() => {
    // 同步:先查内存缓存
    const cacheData = localStorage.getItem(key);
    if (cacheData) return JSON.parse(cacheData); // 同步返回
    
    // 异步:缓存不存在则请求接口
    return fetch(`/api/cache/${key}`).then(res => res.json());
  });
}

// 使用:同步/异步错误都能 catch
getCache('user_123')
  .then(data => console.log('数据:', data))
  .catch(err => console.log('错误:', err));

场景 2:替代 try-catch + Promise 手动封装

js

// 传统写法(繁琐)
function doTask() {
  return new Promise((resolve, reject) => {
    try {
      const result = syncOperation(); // 同步操作
      resolve(result);
    } catch (err) {
      reject(err);
    }
  });
}

// Promise.try 写法(简洁)
function doTask() {
  return Promise.try(() => {
    return syncOperation(); // 自动处理同步错误
  });
}

场景 3:处理可能抛出错误的同步函数

js

// 同步函数可能抛错
function parseJSON(str) {
  return JSON.parse(str); // 无效 JSON 会同步抛错
}

// 用 Promise.try 包装,转为 Promise 错误
Promise.try(() => parseJSON('{invalid json}'))
  .catch(err => console.log('JSON 解析错误:', err)); // 生效

3. 与其他类似写法的对比

表格

写法 能否捕获同步错误 代码简洁度 语义化
Promise.try(fn) ✅ 能 ✅ 极简 ✅ 高(明确启动 Promise 链)
new Promise(resolve => resolve(fn())) ❌ 不能(同步错误直接抛出) ❌ 繁琐 ❌ 低
(async () => fn())() ✅ 能 ✅ 简洁 ❌ 语义不明确
Promise.resolve().then(fn) ❌ 不能(同步错误直接抛出) ✅ 简洁 ❌ 低

结论:Promise.try () 是唯一兼顾 “简洁 + 语义化 + 同步错误捕获” 的方案。

四、Promise.try () 兼容性与替代方案

1. 原生兼容性

  • 原生支持:Promise.try() 并非 ES 标准 API,是 Bluebird.js(第三方 Promise 库)率先实现的特性,Node.js/ 浏览器原生 Promise 未内置;

  • 环境支持

    • 直接使用:需引入 Bluebird.js、Q 等第三方 Promise 库;
    • 原生替代:可手动实现 polyfill。

2. 手动实现 Promise.try ()(兼容所有环境)

如果不想引入第三方库,可自己封装一个极简版:

js

// 兼容所有环境的 Promise.try 实现
if (!Promise.try) {
  Promise.try = function (executor) {
    return new Promise((resolve, reject) => {
      try {
        // 执行回调,获取返回值
        const result = executor();
        // 如果返回的是 Promise,直接 resolve;否则包装为 Promise
        resolve(result);
      } catch (err) {
        // 同步错误直接 reject
        reject(err);
      }
    });
  };
}

// 测试:完全兼容原生用法
Promise.try(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err)); // 生效

3. 用 async/await 替代(ES2017+)

ES2017 后的 async/await 也能实现类似效果,本质是语法糖:

js

// 等价于 Promise.try 的 async/await 写法
async function wrapFn(fn) {
  try {
    return await fn(); // await 会处理同步值/Promise
  } catch (err) {
    return Promise.reject(err);
  }
}

// 使用
wrapFn(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err));

注意:async 函数本身返回 Promise,因此 await fn() 会自动将同步值转为 resolved Promise,同步错误会被 try-catch 捕获。

五、避坑指南:Promise.try () 的常见误区

误区 1:认为能捕获 setTimeout 等宏任务错误

js

// ❌ 错误认知:Promise.try 无法直接捕获定时器错误
Promise.try(() => {
  setTimeout(() => {
    throw new Error('定时器错误');
  }, 100);
})
.catch(err => console.log('捕获到:', err)); // 无效

原因:setTimeout 回调是新的宏任务,脱离了当前 Promise 链的执行栈,必须在回调内手动 try-catch + reject。

误区 2:忽略回调返回非 Promise 的情况

js

// ✅ 正确:Promise.try 会自动包装同步返回值为 Promise
const res = Promise.try(() => 123);
console.log(res instanceof Promise); // true
res.then(num => console.log(num)); // 123

误区 3:与 Promise.resolve () 混淆

js

// ❌ Promise.resolve 无法捕获同步错误
Promise.resolve(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err)); // 无效

// ✅ Promise.try 能捕获
Promise.try(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err)); // 生效

核心区别:Promise.resolve () 只是包装 “值” 为 Promise,不会执行回调;而 Promise.try () 会立即执行回调,并捕获执行过程中的错误。

六、总结

核心要点

  1. setTimeout/setInterval 错误:无法被外层 try-catch/Promise.catch 直接捕获,需在回调内手动 try-catch + 封装为 Promise;
  2. Promise.try () 核心价值:统一同步 / 异步错误捕获,让同步代码的错误也能进入 Promise.catch,无需手动写 try-catch;
  3. 兼容性:非原生 ES 标准,需引入 Bluebird 或手动实现 polyfill,也可通过 async/await 实现等价效果;
  4. 关键误区:Promise.try () 无法捕获宏任务(如定时器)的错误,仅能处理当前执行栈内的同步 / 微任务错误。

最佳实践

  • 封装混合同步 / 异步逻辑的函数时,优先使用 Promise.try () 统一错误处理;
  • 处理定时器 / 事件回调等宏任务时,需在回调内手动 try-catch,并封装为 Promise;
  • 无第三方库时,用 async/await + try-catch 作为 Promise.try () 的替代方案。

Promise.try () 虽非原生标准,但它解决了异步编程中 “同步错误逃逸” 的核心痛点,是编写健壮、统一的异步代码的重要工具。

OpenClaw Gateway RPC 运行时:一个 WebSocket 协议引擎的深度解剖

作者 毛骗导演
2026年3月21日 10:08

如果你用过 OpenClaw,你一定注意到它的跨平台能力——iOS、Android、macOS 客户端、命令行工具、Web 控制台……这些客户端分布在不同设备、不同网络,却都能实时与 Gateway 对话,收发消息、触发 AI 会话、管理 Cron 任务。

这一切的底层,是一套基于 WebSocket 的 RPC 协议运行时。今天我们把它从头到尾拆开来看。


一、为什么选 WebSocket,而不是 REST / gRPC

在正式看代码之前,先聊聊选型理由。

Gateway 需要同时满足两件事:双向通信(服务端主动推送事件)和请求-响应语义(客户端发方法调用,服务端回结果)。

REST 解决了第二点,却没有第一点。gRPC 两点都有,但它需要 HTTP/2,而桌面 menubar 和移动端 SDK 与 Gateway 之间的网络环境可能经过 Tailscale / 反向代理,HTTP/2 的兼容性反而是麻烦。

WebSocket 在这里是个刚好够用的选择:

  • 单连接全双工,服务端随时可推;
  • 基于 HTTP 升级,穿透代理友好;
  • 纯文本 JSON 帧,调试可见,无额外序列化依赖;
  • 浏览器原生支持,Control UI 和 Webchat 可以直连。

二、协议层:三种帧和一个版本号

所有帧定义在 src/gateway/protocol/schema/frames.ts,用 @sinclair/typebox 描述 schema。完整定义:

// RequestFrame:客户端调用某个 RPC 方法
export const RequestFrameSchema = Type.Object(
  {
    type: Type.Literal("req"),
    id: NonEmptyString,          // 调用 ID,对应响应时 echo 回来
    method: NonEmptyString,      // 如 "chat.send" / "sessions.list"
    params: Type.Optional(Type.Unknown()),
  },
  { additionalProperties: false },
);

// ResponseFrame:服务端对某次 req 的回应
export const ResponseFrameSchema = Type.Object(
  {
    type: Type.Literal("res"),
    id: NonEmptyString,          // 与 req.id 对应
    ok: Type.Boolean(),
    payload: Type.Optional(Type.Unknown()),
    error: Type.Optional(ErrorShapeSchema),
  },
  { additionalProperties: false },
);

// EventFrame:服务端主动推送的事件,无需 req 触发
export const EventFrameSchema = Type.Object(
  {
    type: Type.Literal("event"),
    event: NonEmptyString,       // 如 "agent" / "tick" / "chat"
    payload: Type.Optional(Type.Unknown()),
    seq: Type.Optional(Type.Integer({ minimum: 0 })),
    stateVersion: Type.Optional(StateVersionSchema),
  },
  { additionalProperties: false },
);

// 顶层判别联合
export const GatewayFrameSchema = Type.Union(
  [RequestFrameSchema, ResponseFrameSchema, EventFrameSchema],
  { discriminator: "type" },
);

seq 字段是广播事件的全局单调递增序号。客户端可以用 seq 检测是否有事件被跳过(网络抖动时丢帧),按需请求重放。定向推送(broadcastToConnIds)不带 seq,因为它不是全局序列。

stateVersion 则是双整数的版本向量 { presence: number, health: number },让客户端知道它现在看到的状态快照是否是最新的。


三、服务端常量:帧大小与心跳节奏

src/gateway/server-constants.ts

// 与客户端保持同步,canvas 快照可以很大
export const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024;
// 单连接发送缓冲区上限,超过则视为慢消费者
export const MAX_BUFFERED_BYTES = 50 * 1024 * 1024;
// 握手阶段(未认证)的帧大小限制,远小于认证后
export const MAX_PREAUTH_PAYLOAD_BYTES = 64 * 1024;

// 心跳 tick 事件间隔:30 秒
export const TICK_INTERVAL_MS = 30_000;
// 健康快照刷新间隔:60 秒
export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
// 握手超时:3 秒(连接后必须完成认证)
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 3_000;

// 去重 TTL 和最大条目数
export const DEDUPE_TTL_MS = 5 * 60_000;
export const DEDUPE_MAX = 1000;

注意 MAX_PREAUTH_PAYLOAD_BYTES = 64KB——这是握手阶段(尚未认证)允许的最大帧大小。任何未认证连接发来超过 64KB 的帧,立刻被关闭,原因记录为 preauth-payload-too-large。这杜绝了攻击者在握手阶段发送巨型帧耗尽内存。

MAX_BUFFERED_BYTES 是另一个关键数字。广播函数发帧之前,会检查 socket.bufferedAmount,如果超过这个阈值,该客户端会被踢下线(slow consumer),防止一个慢消费者拖垮整个广播队列。


四、连接生命周期:从 TCP 到 hello-ok

整个 WebSocket 连接的生命周期由两个文件分工:

4.1 连接打开:发 Challenge

一个新的 WebSocket 连接进来,attachGatewayWsConnectionHandler 立刻做这几件事:

wss.on("connection", (socket, upgradeReq) => {
  const connId = randomUUID();          // 连接唯一 ID
  let handshakeState: "pending" | "connected" | "failed" = "pending";
  const openedAt = Date.now();

  // 立刻发 challenge 事件,里面有 nonce
  const connectNonce = randomUUID();
  send({
    type: "event",
    event: "connect.challenge",
    payload: { nonce: connectNonce, ts: Date.now() },
  });

  // 握手超时计时器,3 秒内未完成则强制关闭
  const handshakeTimer = setTimeout(() => {
    if (!client) {
      handshakeState = "failed";
      setCloseCause("handshake-timeout", { handshakeMs: Date.now() - openedAt });
      close();
    }
  }, handshakeTimeoutMs);
  // ...

为什么要先发 nonce?

这是防重放攻击的关键机制。客户端在发送 connect 请求时,需要用自己的设备私钥对 {nonce, role, scopes, signedAt} 进行签名。nonce 是服务端生成的一次性随机数,且有 2 分钟时效窗口(DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000)。攻击者即使截获了一次合法的签名,也无法在另一个连接里复用——因为 nonce 不同。

4.2 连接关闭:追踪诊断信息

关闭时,代码把所有诊断信息一起打进日志:

socket.once("close", (code, reason) => {
  const durationMs = Date.now() - openedAt;
  // 如果是 node 角色断开,注销 nodeRegistry
  if (client?.connect?.role === "node") {
    const context = buildRequestContext();
    const nodeId = context.nodeRegistry.unregister(connId);
    if (nodeId) {
      removeRemoteNodeInfo(nodeId);
      context.nodeUnsubscribeAll(nodeId);
    }
  }
  // 更新 presence 快照
  if (client?.presenceKey) {
    upsertPresence(client.presenceKey, { reason: "disconnect" });
    broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion });
  }
  // 记录 closeCause、握手状态、最后一帧的 type/method/id
  logWs("out", "close", {
    connId, code, reason: logReason,
    durationMs, cause: closeCause,
    handshake: handshakeState,
    lastFrameType, lastFrameMethod, lastFrameId,
  });
});

lastFrameType / lastFrameMethod / lastFrameId 是每次处理消息时都会更新的三个字段,专门用于断连后的事后诊断——看连接最后在干什么、卡在哪个方法上。


五、握手认证:一条六关卡流水线

握手认证是 message-handler.ts 最复杂的部分,大约占整个文件的 80%。它是一条线性流水线,任何一关卡失败都直接关闭连接。

第一关:格式验证

第一条消息必须是合法的 RequestFrame,且 method === "connect",params 满足 ConnectParamsSchema。否则立刻关闭,原因 invalid-handshake

const isRequestFrame = validateRequestFrame(parsed);
if (!isRequestFrame || parsed.method !== "connect" || !validateConnectParams(parsed.params)) {
  const handshakeError = isRequestFrame
    ? parsed.method === "connect"
      ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
      : "invalid handshake: first request must be connect"
    : "invalid request frame";
  // 关闭连接...
}

第二关:协议版本协商

ConnectParams 里有 minProtocol 和 maxProtocol 两个字段,形成客户端声明的协议版本区间。服务端的 PROTOCOL_VERSION 必须落在这个区间内,否则报 protocol-mismatch

const { minProtocol, maxProtocol } = connectParams;
if (maxProtocol < PROTOCOL_VERSION || minProtocol > PROTOCOL_VERSION) {
  markHandshakeFailure("protocol-mismatch", {
    minProtocol, maxProtocol, expectedProtocol: PROTOCOL_VERSION,
  });
  sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, "protocol mismatch", {
    details: { expectedProtocol: PROTOCOL_VERSION },
  });
  close(1002, "protocol mismatch");
  return;
}

这个双边区间设计让协议升级平滑——客户端可以声明 minProtocol: 3, maxProtocol: 5,服务端可以接受任何版本在此范围内的连接,而不需要精确匹配。

第三关:Origin 检查

对于 Control UI 和 Webchat 类型的客户端,以及带 Origin 头的浏览器请求,必须通过 Origin 合法性检查。这里有一个特殊的告警路径:

if (originCheck.matchedBy === "host-header-fallback") {
  originCheckMetrics.hostHeaderFallbackAccepted += 1;
  logWsControl.warn(
    `security warning: websocket origin accepted via Host-header fallback...`
  );
}

当 Origin 头缺失时,服务端可以配置 dangerouslyAllowHostHeaderOriginFallback 回退到用 Host 头做匹配,但这是危险选项,每次接受都会写警告日志并计数。

第四关:角色和共享认证

ConnectParams 里的 role 只有两个值——"operator" 或 "node"

export const GATEWAY_ROLES = ["operator", "node"] as const;
export type GatewayRole = (typeof GATEWAY_ROLES)[number];
  • operator:人类或自动化工具发来的命令,绝大多数 RPC 方法只有 operator 能调;
  • node:移动端设备(iOS/Android)注册为远程执行节点,只能使用 node.* 方法。

共享认证(auth.token / auth.password)在这一关校验,同时也对 loopback 直连请求做 Tailscale/trusted-proxy 判断。

第五关:设备身份验证

如果客户端提供了 device 字段({ id, publicKey, signature, signedAt, nonce }),就进入设备签名验证流程:

const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
if (!derivedId || derivedId !== device.id) {
  rejectDeviceAuthInvalid("device-id-mismatch", "device identity mismatch");
  return;
}
const signedAt = device.signedAt;
if (typeof signedAt !== "number" || Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS) {
  rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
  return;
}
// nonce 必须和服务端发出的 connectNonce 完全一致
if (providedNonce !== connectNonce) {
  rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
  return;
}

三重验证:设备 ID 必须能从公钥推导出来(防止伪造 ID);签名时间戳必须在 ±2 分钟窗口内(防止重放);nonce 必须和本次连接发出的挑战值一致(防止跨连接重用)。

认证决策由 resolveConnectAuthState + resolveConnectAuthDecision 两步完成,支持多种凭据类型:

export type ConnectAuthState = {
  authResult: GatewayAuthResult;
  authOk: boolean;
  authMethod: GatewayAuthResult["method"];
  sharedAuthOk: boolean;          // token/password 认证结果
  sharedAuthProvided: boolean;
  bootstrapTokenCandidate?: string;   // 初次配对 bootstrap token
  deviceTokenCandidate?: string;      // 已配对设备的持久 token
  deviceTokenCandidateSource?: DeviceTokenCandidateSource;
};

第六关:设备配对验证

即使认证通过,设备还需要验证是否已配对(getPairedDevice),且配对记录中的公钥与当前连接提供的公钥完全一致。

如果未配对,触发配对流程 requestDevicePairing

const requirePairing = async (
  reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade",
) => {
  const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
    isLocalClient, hasBrowserOriginHeader, isControlUi, isWebchat, reason,
  });
  const pairing = await requestDevicePairing({
    deviceId: device.id,
    publicKey: devicePublicKey,
    ...clientPairingMetadata,
    silent: allowSilentLocalPairing,
  });
  if (pairing.request.silent === true) {
    // 本地客户端静默自动批准
    const approved = await approveDevicePairing(pairing.request.requestId);
    context.broadcast("device.pair.resolved", { requestId, deviceId, decision: "approved", ts }, { dropIfSlow: true });
  } else if (pairing.created) {
    // 远程客户端:广播配对请求,等待人工审批
    context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
    // 关闭连接,让客户端等待审批后重连
    close(1008, "pairing required");
    return false;
  }
};

Scope 升级(客户端请求比配对记录更高的权限)、Role 升级(客户端请求不同角色)、设备元数据变更(平台/设备家族不符合已记录的 pinned 值),都会触发重新配对流程,每次都记安全审计日志:

logGateway.warn(
  `security audit: device access upgrade requested reason=role-upgrade device=${device.id} ip=... auth=${authMethod} roleFrom=... roleTo=${role} scopesFrom=... scopesTo=...`,
);

六、hello-ok:握手成功的状态快照

六关全部通过后,服务端发送 hello-ok 响应,里面包含一个完整的状态快照:

const helloOk = {
  type: "hello-ok",
  protocol: PROTOCOL_VERSION,
  server: {
    version: resolveRuntimeServiceVersion(process.env),
    connId,          // 本次连接的唯一 ID,客户端日志定位用
  },
  features: {
    methods: gatewayMethods,   // 所有可用方法名列表
    events,                    // 所有可能推送的事件名列表
  },
  snapshot,          // 当前 presence、health、配置路径等完整状态
  canvasHostUrl,     // Canvas 宿主 URL(node 角色专有)
  auth: deviceToken ? {
    deviceToken: deviceToken.token,
    role: deviceToken.role,
    scopes: deviceToken.scopes,
    issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
  } : undefined,
  policy: {
    maxPayload: MAX_PAYLOAD_BYTES,       // 25MB
    maxBufferedBytes: MAX_BUFFERED_BYTES, // 50MB
    tickIntervalMs: TICK_INTERVAL_MS,    // 30000ms
  },
};

features.methods 是方法白名单——客户端收到之后才知道这个服务端版本支持哪些方法,可以根据此做功能降级。Snapshot 的完整 schema:

export const SnapshotSchema = Type.Object({
  presence: Type.Array(PresenceEntrySchema),  // 所有在线客户端列表
  health: HealthSnapshotSchema,               // 渠道健康状态
  stateVersion: StateVersionSchema,           // { presence: number, health: number }
  uptimeMs: Type.Integer({ minimum: 0 }),
  configPath: Type.Optional(NonEmptyString),
  stateDir: Type.Optional(NonEmptyString),
  sessionDefaults: Type.Optional(SessionDefaultsSchema),
  authMode: Type.Optional(Type.Union([
    Type.Literal("none"), Type.Literal("token"),
    Type.Literal("password"), Type.Literal("trusted-proxy"),
  ])),
  updateAvailable: Type.Optional(Type.Object({
    currentVersion: NonEmptyString,
    latestVersion: NonEmptyString,
    channel: NonEmptyString,
  })),
}, { additionalProperties: false });

这个 snapshot 让客户端在连接成功的瞬间就拿到足够的上下文,不需要再单独发 health 或 status 请求——减少了一个 RTT。


七、认证完成后:请求路由和方法调度

握手成功后,后续帧必须全是 RequestFrametype: "req")。非 req 帧会收到错误响应,但连接不会立刻关闭。

每个请求经过 handleGatewayRequest 完成三层过滤:

export async function handleGatewayRequest(
  opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
): Promise<void> {
  const { req, respond, client, isWebchatConnect, context } = opts;

  // 第一层:角色授权
  const authError = authorizeGatewayMethod(req.method, client);
  if (authError) { respond(false, undefined, authError); return; }

  // 第二层:控制平面写操作限流(3次/60秒)
  if (CONTROL_PLANE_WRITE_METHODS.has(req.method)) {
    const budget = consumeControlPlaneWriteBudget({ client });
    if (!budget.allowed) {
      respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `rate limit exceeded...`, {
        retryable: true,
        retryAfterMs: budget.retryAfterMs,
        details: { method: req.method, limit: "3 per 60s" },
      }));
      return;
    }
  }

  // 第三层:查找 handler 并执行
  const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
  if (!handler) {
    respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`));
    return;
  }

  // 包裹在插件 request scope 中执行
  await withPluginRuntimeGatewayRequestScope({ context, client, isWebchatConnect }, invokeHandler);
}

CONTROL_PLANE_WRITE_METHODS 目前是 config.applyconfig.patchupdate.run 三个——修改配置和触发更新,每 60 秒只能调 3 次,防止自动化脚本频繁 hammer。

withPluginRuntimeGatewayRequestScope 把 handler 包裹在一个插件运行时请求 scope 里,允许子 Agent 在工具执行过程中回调到 Gateway 方法——这是 Pi 嵌入式运行时和上下文引擎工具调用时的关键路径。


八、方法注册:核心处理器表

所有方法处理器通过展开合并聚合到一张哈希表里:

export const coreGatewayHandlers: GatewayRequestHandlers = {
  ...connectHandlers,
  ...logsHandlers,
  ...voicewakeHandlers,
  ...healthHandlers,
  ...channelsHandlers,
  ...chatHandlers,
  ...cronHandlers,
  ...deviceHandlers,
  ...doctorHandlers,
  ...execApprovalsHandlers,
  ...webHandlers,
  ...modelsHandlers,
  ...configHandlers,
  ...wizardHandlers,
  ...talkHandlers,
  ...toolsCatalogHandlers,
  ...ttsHandlers,
  ...skillsHandlers,
  ...sessionsHandlers,
  ...systemHandlers,
  ...updateHandlers,
  ...nodeHandlers,
  ...nodePendingHandlers,
  ...pushHandlers,
  ...sendHandlers,
  ...usageHandlers,
  ...agentHandlers,
  ...agentsHandlers,
  ...browserHandlers,
};

每个 handler 的类型签名简洁统一:

export type GatewayRequestHandler = (opts: GatewayRequestHandlerOptions) => Promise<void> | void;

export type GatewayRequestHandlerOptions = {
  req: RequestFrame;
  params: Record<string, unknown>;
  client: GatewayClient | null;
  isWebchatConnect: (params: ConnectParams | null | undefined) => boolean;
  respond: RespondFn;
  context: GatewayRequestContext;
};

respond 是 handler 专属的响应回调,已经绑定了 req.id,handler 调用 respond(true, payload) 时,框架自动拼出 { type: "res", id: req.id, ok: true, payload }。handler 不需要关心 WebSocket 帧格式。


九、Scope 权限体系:最小特权原则落地

OpenClaw 实现了细粒度的 Scope 系统,比 RBAC 更精细:

// src/gateway/method-scopes.ts
export const ADMIN_SCOPE    = "operator.admin";
export const READ_SCOPE     = "operator.read";
export const WRITE_SCOPE    = "operator.write";
export const APPROVALS_SCOPE = "operator.approvals";
export const PAIRING_SCOPE  = "operator.pairing";

每个方法都有明确的 scope 归属:

  • operator.readhealthchannels.statussessions.listlogs.tail……只读操作;
  • operator.writesendagentchat.sendnode.invoke……发消息和触发 AI;
  • operator.approvalsexec.approval.*……仅管理执行许可;
  • operator.pairingnode.pair.*device.pair.*……仅管理设备配对;
  • operator.adminconfig.*wizard.*agents.create……管理员操作,覆盖所有;

读操作有一个特殊规则——operator.write 隐含了 operator.read 的权限:

if (requiredScope === READ_SCOPE) {
  if (scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE)) {
    return { allowed: true };
  }
  return { allowed: false, missingScope: READ_SCOPE };
}

这让"能写的客户端也能读"这个直觉得以实现,而无需给每个有写权限的客户端都显式加上 read scope。

Node 角色的方法隔离

node 角色只能使用 NODE_ROLE_METHODS 里列出的方法:

const NODE_ROLE_METHODS = new Set([
  "node.invoke.result",   // 返回工具调用结果
  "node.event",           // 推送节点事件
  "node.pending.drain",   // 清空待执行队列
  "node.canvas.capability.refresh",
  "node.pending.pull",
  "node.pending.ack",
  "skills.bins",          // 汇报已安装的 skill 二进制
]);

移动端设备作为 node 接入后,只能报告执行结果和拉取任务,完全无法调用 chat.sendconfig.set 这类方法——即使它的 token 泄露,攻击面也被大幅压缩。


十、广播引擎:事件推送的作用域守卫

服务端主动推事件通过 src/gateway/server-broadcast.ts 实现。核心是 createGatewayBroadcaster

export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient> }) {
  let seq = 0;

  const broadcastInternal = (
    event: string,
    payload: unknown,
    opts?: GatewayBroadcastOpts,
    targetConnIds?: ReadonlySet<string>,
  ) => {
    if (params.clients.size === 0) { return; }
    const isTargeted = Boolean(targetConnIds);
    const eventSeq = isTargeted ? undefined : ++seq;  // 定向推送不带全局 seq
    const frame = JSON.stringify({
      type: "event", event, payload,
      seq: eventSeq,
      stateVersion: opts?.stateVersion,
    });

    for (const c of params.clients) {
      if (targetConnIds && !targetConnIds.has(c.connId)) { continue; }

      // Scope 守卫:特权事件只推给有对应 scope 的连接
      if (!hasEventScope(c, event)) { continue; }

      // 慢消费者处理
      const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES;
      if (slow && opts?.dropIfSlow) { continue; }   // 允许丢弃:跳过
      if (slow) {
        c.socket.close(1008, "slow consumer");       // 不允许丢弃:踢出
        continue;
      }
      c.socket.send(frame);
    }
  };
  // ...
}

事件的 Scope 守卫是独立的:

const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
  "exec.approval.requested": [APPROVALS_SCOPE],
  "exec.approval.resolved":  [APPROVALS_SCOPE],
  "device.pair.requested":   [PAIRING_SCOPE],
  "device.pair.resolved":    [PAIRING_SCOPE],
  "node.pair.requested":     [PAIRING_SCOPE],
  "node.pair.resolved":      [PAIRING_SCOPE],
};

function hasEventScope(client: GatewayWsClient, event: string): boolean {
  const required = EVENT_SCOPE_GUARDS[event];
  if (!required) { return true; }   // 无守卫事件:全部客户端可接收
  const role = client.connect.role ?? "operator";
  if (role !== "operator") { return false; }
  const scopes = Array.isArray(client.connect.scopes) ? client.connect.scopes : [];
  if (scopes.includes(ADMIN_SCOPE)) { return true; }
  return required.some((scope) => scopes.includes(scope));
}

执行审批请求和设备配对请求这类事件,只会推送给有对应 scope 的连接。一个只有 operator.read scope 的只读监控客户端,不会收到安全敏感事件。

dropIfSlow 选项让广播变成"尽力投递"语义——状态快照和 tick 心跳等无关紧要的事件,慢消费者直接跳过,不会导致其被踢下线。而 agent 流式回复这种必须保序的事件,则让慢消费者付出被踢下线的代价。


十一、UnauthorizedFloodGuard:防角色探测攻击

已认证的连接,如果反复调用权限不足的方法,会触发 UnauthorizedFloodGuard

const DEFAULT_CLOSE_AFTER = 10;   // 超过 10 次未授权调用,关闭连接
const DEFAULT_LOG_EVERY = 100;    // 每 100 次记一条日志(防日志爆炸)

export class UnauthorizedFloodGuard {
  private count = 0;
  private suppressedSinceLastLog = 0;

  registerUnauthorized(): UnauthorizedFloodDecision {
    this.count += 1;
    const shouldClose = this.count > this.closeAfter;          // > 10 次则关闭
    const shouldLog = this.count === 1                         // 第一次必记
                   || this.count % this.logEvery === 0         // 每 100 次记一次
                   || shouldClose;                             // 触发关闭时必记
    // ...
    return { shouldClose, shouldLog, count, suppressedSinceLastLog };
  }

  reset(): void {   // 只要有一次成功的授权调用,计数归零
    this.count = 0;
    this.suppressedSinceLastLog = 0;
  }
}

这个设计非常合理:每个连接独立一个 guard 实例;只要穿插了合法调用,计数就会重置(不会因为偶尔调用一个没权限的方法就被踢);但如果连续探测超过 10 个无权方法,就会被关闭,且只有在第 1 次、第 100、200……次或关闭时才写日志,防止日志被 flood。


十二、GatewayRequestContext:请求上下文的依赖注入

每个 handler 收到的 context 对象是整个 Gateway 的服务注入点,类型定义超过 70 行:

export type GatewayRequestContext = {
  deps: ReturnType<typeof createDefaultDeps>;
  cron: CronService;
  cronStorePath: string;
  execApprovalManager?: ExecApprovalManager;
  loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
  getHealthCache: () => HealthSummary | null;
  refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
  logGateway: SubsystemLogger;
  broadcast: GatewayBroadcastFn;
  broadcastToConnIds: GatewayBroadcastToConnIdsFn;
  nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
  nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
  nodeSubscribe: (nodeId: string, sessionKey: string) => void;
  nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
  nodeRegistry: NodeRegistry;
  agentRunSeq: Map<string, number>;
  chatAbortControllers: Map<string, ChatAbortControllerEntry>;
  chatAbortedRuns: Map<string, number>;
  chatRunBuffers: Map<string, string>;
  addChatRun: (sessionId: string, entry: { sessionKey: string; clientRunId: string }) => void;
  removeChatRun: (...) => { ... } | undefined;
  registerToolEventRecipient: (runId: string, connId: string) => void;
  dedupe: Map<string, DedupeEntry>;
  wizardSessions: Map<string, WizardSession>;
  getRuntimeSnapshot: () => ChannelRuntimeSnapshot;
  startChannel: (channel: ChannelId, accountId?: string) => Promise<void>;
  stopChannel: (channel: ChannelId, accountId?: string) => Promise<void>;
  // ...
};

这个对象由 buildRequestContext() 在每次消息处理时构建。它是闭包驱动的——broadcastnodeRegistrychatAbortControllers 这些状态都来自 server 启动时初始化的共享引用,buildRequestContext 只是把它们打包成统一接口传进 handler。

dedupe 是去重 map,防止网络抖动导致同一请求被客户端重试多次(TTL = 5 分钟,最多 1000 条)。agentRunSeq 确保 agent 流式事件的序号单调递增,不会因为并发推送乱序。


十三、健康快照:版本化的惰性刷新

health-state.ts 实现了一个简洁的健康快照管理模式:

let presenceVersion = 1;
let healthVersion = 1;
let healthCache: HealthSummary | null = null;
let healthRefresh: Promise<HealthSummary> | null = null;  // 防并发重入

export async function refreshGatewayHealthSnapshot(opts?: { probe?: boolean }) {
  if (!healthRefresh) {
    healthRefresh = (async () => {
      const snap = await getHealthSnapshot({ probe: opts?.probe });
      healthCache = snap;
      healthVersion += 1;     // 健康版本 +1,触发客户端更新
      if (broadcastHealthUpdate) {
        broadcastHealthUpdate(snap);  // 推送健康事件给所有客户端
      }
      return snap;
    })().finally(() => {
      healthRefresh = null;   // 无论成功失败,清除 in-flight 标记
    });
  }
  return healthRefresh;  // 并发调用共享同一个 Promise
}

healthRefresh 是一个 Promise 去重锁:如果已经有一个 health 刷新在进行中,新的调用直接返回同一个 Promise,而不会发起新的健康检查。这对于 hello-ok 之后立刻触发 refreshGatewayHealthSnapshot({ probe: true }) 的场景很重要——多个客户端几乎同时连接时,只会有一次真实的健康探测。

presenceVersion 和 healthVersion 是两个独立的单调整数。stateVersion: { presence, health } 随每个广播事件一起发出,客户端可以判断自己是否持有最新快照,按需主动刷新(而不是每次连接都重新拉一遍)。


十四、启动侧车:Gateway 开机时的并行任务

src/gateway/server-startup.ts 的 startGatewaySidecars 负责在 Gateway HTTP 服务器就绪后,启动一系列后台服务:

export async function startGatewaySidecars(params) {
  // 1. 清理过期 session 锁文件(防止崩溃后残留锁)
  await cleanStaleLockFiles({ sessionsDir, staleMs: 30 * 60 * 1000, removeStale: true });

  // 2. 启动浏览器控制服务器(如果配置启用)
  browserControl = await startBrowserControlServerIfEnabled();

  // 3. 启动 Gmail 监听器(如果配置了 hooks.gmail.account)
  await startGmailWatcherWithLogs({ cfg, log: logHooks });

  // 4. 加载内部 Hook 处理器
  clearInternalHooks();
  const loadedCount = await loadInternalHooks(cfg, defaultWorkspaceDir);

  // 5. 启动所有渠道(Telegram、Discord、Signal……)
  await params.startChannels();

  // 6. 触发 gateway:startup 内部 hook(延迟 250ms,等渠道就绪)
  setTimeout(() => {
    void triggerInternalHook(createInternalHookEvent("gateway", "startup", "gateway:startup", ...));
  }, 250);

  // 7. 启动插件服务(memory-lancedb 等 plugin services)
  pluginServices = await startPluginServices({ registry, config, workspaceDir });

  // 8. 启动内存后端(QMD memory)
  void startGatewayMemoryBackend({ cfg, log });

  // 9. 处理 restart sentinel(服务重启后自动唤醒之前的 agent)
  if (shouldWakeFromRestartSentinel()) {
    setTimeout(() => void scheduleRestartSentinelWake({ deps }), 750);
  }

  return { browserControl, pluginServices };
}

这里有一个细节:gateway:startup hook 是 250ms 后触发的,而 restart sentinel wake 是 750ms 后触发的。这个时序保证 hook 处理器和渠道连接在 agent 唤醒前已经就绪。


十五、Schema 验证:AJV + TypeBox 的双层体系

所有 RPC 参数的 schema 用 @sinclair/typebox 声明,运行时验证用 AJV 编译。protocol/index.ts 在模块加载时把所有 schema 预编译为验证函数:

const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
  allErrors: true,          // 收集所有错误而非第一个就停
  strict: false,
  removeAdditional: false,  // 不移除额外字段(由 TypeBox additionalProperties: false 处理)
});

export const validateConnectParams = ajv.compile<ConnectParams>(ConnectParamsSchema);
export const validateRequestFrame  = ajv.compile<RequestFrame>(RequestFrameSchema);
export const validateResponseFrame = ajv.compile<ResponseFrame>(ResponseFrameSchema);
// ... 共 50+ 个预编译验证器

所有 schema 都带 additionalProperties: false,确保客户端不能传入任何未声明字段。这既防止了字段注入,也让接口保持严格的向前兼容性。

当验证失败时,formatValidationErrors 把 AJV 的错误对象转换为可读的错误消息:

export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
  // 特殊处理 additionalProperties 错误,给出具体是哪个多余字段
  for (const err of errors) {
    if (keyword === "additionalProperties") {
      const additionalProperty = params?.additionalProperty;
      parts.push(`${where}: unexpected property '${additionalProperty}'`);
      continue;
    }
    // 通用错误格式化
    const where = instancePath ? `at ${instancePath}: ` : "";
    parts.push(`${where}${message}`);
  }
  // 去重后合并
  const unique = Array.from(new Set(parts.filter(...)));
  return unique.join("; ");
}

十六、可观测性:结构化的 WS 日志体系

Gateway 的 WebSocket 日志不是简单的字符串拼接,而是有独立的结构化系统(ws-log.ts,439 行)。

每条日志都有固定格式:

[gateway/ws] ← in  req  chat.send  id=a1b2…c3d4  session=xxx… delta=12ms
[gateway/ws] → out res  chat.send  ✓  durationMs=142  errorCode=n/a
[gateway/ws] → out event  agent  seq=1024  clients=3  presenceVersion=7

UUID 被缩写为 前8位…后4位 的形式(shortId),既可读又节省空间。敏感字段(API key、token)通过 redactSensitiveText 脱敏后才进日志。

这套日志体系让运维人员在不需要断点调试的情况下,就能从日志里还原出一次完整的请求往来过程。


总结

回顾整个 Gateway RPC 运行时,它的设计体现了几个核心原则:

协议的严格性:每个帧都经过 Schema 验证,每个握手步骤都是独立的关卡。任何格式错误或安全异常,都会立刻终止连接并记录原因。协议版本双边区间让客户端可以声明自己能接受的范围,而不是要求精确匹配。

认证的纵深防御:六关卡握手流水线——格式验证、版本协商、Origin 检查、共享认证、设备签名、配对验证——每关都是独立的防线。nonce-based 挑战-响应防重放,设备公钥推导设备 ID 防伪造,平台/家族 pinning 防元数据冒用。

权限的最小原则:Role + Scope 双维度,每个 RPC 方法和每个广播事件都有精确的权限要求。node 角色只能使用 node.* 方法,读操作和写操作分开授权,安全敏感事件只推给有对应 scope 的连接。

可靠性的精细运营:慢消费者踢出、去重 TTL、健康快照惰性刷新与 Promise 去重锁、unauthorized flood guard——每一个都是针对具体运维场景的精确补丁。


涉及源文件

Module Federation 2.0 共享策略翻车实录:版本协商、热更新与依赖冲突的排查工具链

2026年3月21日 10:05

三个月前,我们把一个 B 端 SaaS 平台从 Webpack 5 的 Module Federation 1.0 迁到了 2.0。主应用加 6 个远程团队的子应用,涉及 React 18、antd 5.x、三套不同版本的 lodash,外加一个用了 moment 死活不肯迁 dayjs 的老团队。

迁完当天,线上白屏了。

控制台报了一个极其隐晦的错:Shared module is not available for eager consumption。查了两个小时才定位到根因——两个子应用对 react-dom 的版本协商结果不一致,一个拿到了 18.2.0,另一个拿到了 18.3.1。而 18.3.1 那个由于加载顺序的问题,在协商窗口关闭后才注册上来,直接被跳过了。

这篇文章是那次翻车之后,团队花三周搞出来的调优方案和诊断工具链的复盘。

MF 2.0 的共享运行时到底在干什么

协商窗口——最容易翻车的地方

关键问题来了:什么时候协商?

MF 2.0 有一个隐式的"协商窗口"概念。主应用调用 init() 初始化共享作用域后,会等待所有已知的远程容器注册完它们的共享模块,然后进入消费阶段。一旦某个模块开始消费某个共享依赖,协商窗口就关闭了——后来者注册的版本不会被纳入考量。

用时间线表示会更直观:

  init()  → 注册窗口打开
  Remote A 注册 react@18.2.0  
  Remote B 注册 react@18.3.1  
  Remote A 消费 react → 协商:选 18.3.1
  ════════ 协商窗口关闭 ════════
  Remote C 注册 react@18.2.0   来晚了
  Remote C 消费 react → 拿到 18.3.1
  (如果 C 配了 ~18.2.0 + strictVersion: true → 直接报错)

我们线上白屏就是这个时序问题。Remote C 是一个懒加载的子应用,用户点击菜单才加载,注册得晚,但它的 react-dom 配了 strictVersion: true,协商结果不满足它的版本要求,页面直接崩了。

版本协商算法:不只是 semver 匹配

默认协商策略的三层逻辑

MF 2.0 的版本协商不是简单的"找最新版",而是一个三层决策逻辑。

第一层是 singleton 模式判断。如果某个包被标记为 singleton,全局只保留一个版本,取已注册版本中最高的那个。这时如果同时配了 strictVersion,而最高版本不满足消费者的 requiredVersion,就会直接抛错;不配 strictVersion 的话,即使版本不匹配也硬上。

第二层是 semver 范围匹配。在所有已注册版本中,筛选出满足消费者 requiredVersion 范围的,取其中最高的。

第三层是兜底。没有满足条件的版本时,先尝试消费者自带的 fallback 版本;fallback 也没有的话,看 strictVersion ——配了就报错,没配就拿最高版本硬上,赌一把兼容性。

这三层逻辑在文档里分散在好几个地方,拼起来才能看到全貌。实际运行时还要考虑 eager 标记的影响——eager: true 的模块会在 init() 阶段就被加载,直接跳过协商窗口。

singleton 的隐式降级——我们踩过最阴的坑

reactreact-dom 几乎所有人都会配 singleton: true,这没问题。

我们有一个子应用依赖了 React 18.3.1 新增的 useFormStatus hook,但全局协商结果是 18.2.0(主应用锁了 18.2.0 且最先注册)。子应用拿到了 18.2.0 的 React,调用 useFormStatus 时直接 undefined is not a function

排查这个问题花了大半天,因为没有任何 warning。看配置,一切正常;看网络请求,React 确实加载了;看版本号——这一步才意识到拿到的不是预期版本。教训很明确:

//  只写 singleton → 版本不匹配时静默降级,运行时才爆炸
react: { singleton: true, requiredVersion: '^18.3.0' }

//  加上 strictVersion → 至少报一个明确的错误
react: { singleton: true, strictVersion: true, requiredVersion: '^18.3.0' }

两行配置的差别,决定了你是花 10 分钟看报错信息定位问题,还是花半天在毫无线索的情况下大海捞针。

远程模块热更新:比想象中复杂得多

静态远程 vs 动态远程

MF 2.0 支持两种远程模块加载方式。静态远程在构建时确定入口 URL,动态远程在运行时决定从哪里加载。热更新的难度完全不同。静态远程的"热更新"其实是个伪命题——URL 不变,浏览器缓存不失效,用户刷新页面才能拿到新版本。

动态远程才有真正的热更新能力。核心流程是:从配置中心拿到最新的远程入口 URL(带版本 hash),动态初始化远程容器,让新容器和当前的共享作用域完成握手,再获取模块工厂。

热更新的真正难点:共享依赖的状态一致性

假设 Team B 发布了子应用新版本,主应用通过动态远程加载了新的 remoteEntry.js,新版本把 antd 从 5.12 升到了 5.15。

问题在于:旧版本的子应用已经通过共享作用域拿到了 antd@5.12,全局样式和 ConfigProvider 的上下文状态都已经注入到 DOM 里了。新版本注册了 antd@5.15,但共享作用域里 antd 早就被消费过了,协商窗口关了。结果就是新子应用用的还是 5.12 的 antd,但代码是按 5.15 的 API 写的——该有的方法不存在,该变的行为没变。

我们的方案:分代共享作用域

最终我们搞了一个"分代"机制。每次有远程模块热更新时,不在原来的共享作用域上修修补补,而是创建一个新的作用域"代",让新版本的模块在新代里协商。新代会继承上一代已锁定的共享模块(除了需要升级的部分),新版本的远程模块在新代中注册和解析,旧版本继续用旧代,互不干扰。代价是内存占用增加——同一个依赖可能在不同代里各加载一份。

分代方案不是万能的。有几种情况我们选择直接强制整页刷新:

  • react / react-dom 版本变了——这俩 singleton 没法分代,React 的内部状态是全局的
  • 共享的状态管理库(zustand、redux)大版本变了——store 结构不兼容
  • CSS-in-JS 运行时(styled-components、emotion)版本变了——样式上下文会出问题

分代方案的其他代价

调试变得更复杂了。多代并存意味着同一个 antd Button 组件可能在页面上有两个版本同时渲染,样式不一致。我们的解法是在分代切换时对旧代组件做一次强制 unmount + remount,但这会导致短暂的 UI 闪烁。

GC 也是个问题。

诊断工具链:从"猜"到"看见"

共享作用域可视化面板

排查共享依赖问题最痛苦的地方是看不见。

我们做了一个 Chrome DevTools 面板插件。核心逻辑不复杂:遍历 __webpack_share_scopes__ 的全部条目,提取包名、版本号、注册来源、是否 eager、是否已被消费等信息,外加我们通过 monkey-patch 注入的注册时间戳和消费时间戳,结构化成扁平的数据数组。

面板 UI 分两个视图。表格视图列出所有共享包及其版本,标记出哪些是协商胜出的、哪些是 fallback、哪些被跳过了。时间线视图展示每个远程容器的注册和消费顺序——时序问题在这个视图里一眼就能看出来。

版本协商模拟器

线上出了问题再排查太晚了,我们需要在 CI 阶段就发现潜在冲突。

思路是:构建阶段收集所有子应用的 shared 配置,模拟运行时的协商过程,提前暴露不兼容。模拟器遍历每个应用注册的包版本,对每个消费者的 requiredVersion 做匹配检查。两种情况会标红:一是 strictVersion 配了但没有满足条件的版本,二是 singleton 模式下最高版本不满足某个消费者的 requiredVersion——也就是说该消费者运行时会拿到一个不兼容的版本。

我们把这个 checker 集成到了 GitLab CI 的 merge request 流程里。每次有子应用提交 MR,CI 会从 federation registry 服务拉取所有其他子应用当前的 shared 配置,跑一遍模拟协商。有冲突的话 MR 直接标红,强制人工 review。上线两个月,这个 checker 在 CI 阶段拦截了 14 次潜在的版本冲突,其中 3 次如果上了线上就是白屏级别的故障。

运行时依赖图谱追踪

最后一个工具解决的是:页面上真出了共享依赖相关的 bug,怎么快速定位到是哪条依赖链路出了问题。

我们对 MF 的 __webpack_init_sharing__ 和远程容器的 init / get 方法做了 monkey-patch,记录每一次共享依赖的注册、协商和消费事件,包括时间戳、来源容器名、注册了哪些共享模块等。trace 数据导出为 JSON 后,扔到可视化面板里就能画出完整的依赖图谱和时间线。排查问题时,不用再在控制台里一层层展开对象了。

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

2026年3月21日 09:59

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

手撸开始:一个 200 行以内的迷你 reactivity

第二步:effect——注册一个"关心数据变化"的函数

function effect(fn: () => void) {
  const run = () => {
    activeEffect = run
    effectStack.push(run)
    fn()                      // 执行 fn 的过程中会触发 get → 收集依赖
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] || null
  }
  run() // 立即执行一次,触发首次依赖收集
}

为什么要用栈?看这个场景:

effect(() => {          // effectA
  console.log(state.a)
  effect(() => {        // effectB
    console.log(state.b)
  })
  console.log(state.c)  // 这里 activeEffect 应该是 effectA,不是 effectB
})

如果不用栈,内层 effect 执行完后 activeEffect 就丢了,外层的 state.c 会收集不到依赖。这不是什么边界 case,嵌套 computed 就会触发这个场景。

第三步:依赖存储结构

// 数据结构:target → key → Set<effect>
// 翻译成人话:哪个对象的哪个属性,被哪些 effect 函数关心
const targetMap = new WeakMap<object, Map<string | symbol, Set<() => void>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return  // 没人在执行 effect,不用收集
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  deps.add(activeEffect) // 把当前 effect 函数记下来
}

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const deps = depsMap.get(key)
  if (!deps) return
  deps.forEach(fn => fn()) // 数据变了,挨个通知
}

为什么用 WeakMap

第四步:reactive——把普通对象变成响应式

function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)                     // 读的时候:收集依赖
      const result = Reflect.get(obj, key, receiver)
      // 如果值还是对象,递归代理(懒代理,用到才代理)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key as keyof T]
      const result = Reflect.set(obj, key, value, receiver)
      if (oldValue !== value) {
        trigger(obj, key)                 // 写的时候:触发更新
      }
      return result
    }
  })
}

注意这里的懒代理——不是一上来就递归把所有嵌套对象全代理了,而是访问到某个属性发现它是对象时才代理。这是 Vue3 相比 Vue2 的一个性能优化。Vue2 的 observe 是初始化时递归全量遍历,数据量大的时候初始化会卡。


设计权衡:为什么 Vue3 这么设计

Proxy vs defineProperty

维度 defineProperty Proxy
新增属性 拦不到,需要 $set 自动拦截
数组变异 需要 hack 7 个方法 原生支持
初始化成本 全量递归 懒代理,按需
兼容性 IE9+ IE 完全不支持
性能 属性多时慢 整体更优

Vue3 放弃 IE 不是任性,是 Proxy 没法 polyfill。这是个技术选型的取舍——用兼容性换来了更好的 API 和性能。

为什么不用脏检查(Angular 1 的方式)

脏检查是每次变化都全量对比。数据少的时候没啥,数据一多就是灾难。就像你为了看快递到没到,每五分钟打开门看一次,不如让快递员到了给你打电话。

为什么依赖收集在 get 里而不是手动声明

手动声明依赖意味着你需要自己维护"谁依赖谁"的关系。React 的 useEffect 就是这个思路——你得手写依赖数组。忘写一个?恭喜你喜提一个 stale closure bug。

Vue 的自动依赖收集虽然有运行时成本,但开发体验好太多。你用到了哪些数据,框架自动知道。


边界与踩坑

解构丢失响应式

回到开头的问题:

const state = reactive({ count: 0 })

//  解构出来是个普通值,和 state 断开了
const { count } = state // count = 0,就是个数字

//  用 toRefs 保持连接
const { count } = toRefs(state) // count 是个 ref,.value 和 state.count 同步

reactive 只能用于对象

//  基本类型不能用 reactive
const count = reactive(0) // 报错,Proxy 只能代理对象

//  基本类型用 ref
const count = ref(0) // 内部其实是 reactive({ value: 0 })

ref 本质上就是把基本类型包了一层对象,这就是为什么你要写 .value——不是 Vue 团队故意折磨你。

大数组 / 大对象的性能

响应式不是免费的。每次 get 都要执行 track,每次 set 都要 trigger。几千个属性的对象做响应式,初始化和更新都有开销。

// 大列表只读展示?别用 reactive
import { markRaw, shallowRef } from 'vue'

//  shallowRef:只有 .value 本身的替换是响应式的,内部属性不追踪
const bigList = shallowRef(fetchHugeList())

//  markRaw:标记对象永远不被代理
const rawData = markRaw(someHugeObject)

循环引用

const a = reactive({} as any)
const b = reactive({} as any)
a.b = b
b.a = a // ← 不会爆栈,因为是懒代理,访问到才代理

Vue3 的懒代理在这里救了你一命。如果是 Vue2 的全量递归,这就直接栈溢出了。


总结:响应式的通用思维模型

Vue3 的响应式不是什么独创发明,它是一个经典模式的精致实现:

拦截 → 收集 → 通知

  • 拦截:Proxy 拦截对象的读和写
  • 收集:读的时候记下"谁在读"
  • 通知:写的时候告诉所有读过的人"值变了"

这个模型不止用于 UI 框架。数据库的触发器、Excel 的公式联动、消息队列的发布订阅,底层都是这个模式。以后遇到类似的问题——"A 变了,B 要自动跟着变"——你就知道该用什么结构了:建一个依赖图,读时收集,写时触发。

最后送一句:理解响应式最好的方式不是读文档,是自己撸一遍。 200 行代码,一杯咖啡的时间,你能获得的理解比读十篇文章都多。

不会 Rust 也能玩 WebAssembly:3 个 npm install 就能用的 WASM 神器

作者 ofox
2026年3月21日 09:47

刷掘金热榜发现 WebAssembly 又上去了,评论区一堆人说「学 WASM 得先学 Rust」,劝退了不少人。

说实话我之前也是这么想的——直到上个月做一个内部工具的时候,发现有些 npm 包底层就是 WASM,安装完直接用,完全不需要碰 Rust。今天分享 3 个我实际用过的,都是 npm install 一把梭,零 Rust 基础也能直接上手。

先说结论

干什么的 性能提升 上手难度
sql.js 浏览器里跑 SQLite 比 IndexedDB 查询快 5-10x ⭐ 极低
@ffmpeg/ffmpeg 浏览器里处理视频 JS 根本做不到的事 ⭐⭐ 低
photon-wasm 图片滤镜/裁剪/压缩 比 Canvas API 快 2-5x ⭐ 极低

场景一:浏览器里跑 SQLite(sql.js)

做后台管理系统的时候遇到一个需求:前端要对一个几万行的 CSV 做复杂筛选和聚合。一开始用 JS 数组硬撸 filter + reduce,代码写得我自己都看不懂,而且 5 万行数据一个聚合查询要卡 3 秒。

后来想到——为什么不在浏览器里直接用 SQL?

npm install sql.js
import initSqlJs from 'sql.js';

// 初始化,需要指定 wasm 文件位置
const SQL = await initSqlJs({
  locateFile: file => `https://sql.js.org/dist/${file}`
});

// 创建内存数据库
const db = new SQL.Database();

// 建表 + 导入 CSV 数据
db.run(`CREATE TABLE sales (
  date TEXT,
  region TEXT,
  product TEXT,
  amount REAL,
  quantity INTEGER
)`);

// 批量插入(用事务,不然会巨慢)
db.run('BEGIN TRANSACTION');
csvData.forEach(row => {
  db.run(
    'INSERT INTO sales VALUES (?, ?, ?, ?, ?)',
    [row.date, row.region, row.product, row.amount, row.quantity]
  );
});
db.run('COMMIT');

// 现在可以用 SQL 了!
const result = db.exec(`
  SELECT region,
         SUM(amount) as total_sales,
         COUNT(*) as order_count,
         AVG(amount) as avg_order
  FROM sales
  WHERE date >= '2026-01-01'
  GROUP BY region
  ORDER BY total_sales DESC
`);

console.log(result[0].values);
// [['华东', 2847563.5, 12847, 221.6], ['华南', ...]]

5 万行数据,这个聚合查询 60ms 搞定。之前纯 JS 要 3 秒多。

踩坑点

  1. wasm 文件要单独加载。如果用 Vite,需要把 sql-wasm.wasm 放到 public 目录,locateFile 指向 /sql-wasm.wasm
  2. 数据库在内存里,刷新就没了。想持久化可以用 db.export() 导出 Uint8Array,存到 IndexedDB 或者 localStorage
  3. 不支持并发写入。如果有 Web Worker 也在操作同一个数据库实例,会出问题。建议把 sql.js 整个跑在一个 Worker 里
// 持久化方案
const data = db.export();
const buffer = new Uint8Array(data);
localStorage.setItem('mydb', JSON.stringify(Array.from(buffer)));

// 恢复
const saved = JSON.parse(localStorage.getItem('mydb'));
const db = new SQL.Database(new Uint8Array(saved));

场景二:浏览器里剪视频(@ffmpeg/ffmpeg)

这个是真没想到的——FFmpeg 编译成了 WASM,能在浏览器里跑。

我的场景是做一个视频剪辑小工具,用户上传视频后自动截取前 30 秒作为预览。之前都是传到后端处理,现在直接前端搞定,省了一台服务器。

npm install @ffmpeg/ffmpeg @ffmpeg/util
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

const ffmpeg = new FFmpeg();

// 加载 WASM(首次会比较慢,约 25MB)
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
await ffmpeg.load({
  coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
  wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});

// 监听进度
ffmpeg.on('progress', ({ progress }) => {
  console.log(`处理进度: ${(progress * 100).toFixed(1)}%`);
});

// 写入文件到虚拟文件系统
const videoFile = document.querySelector('input[type="file"]').files[0];
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));

// 截取前 30 秒 + 压缩
await ffmpeg.exec([
  '-i', 'input.mp4',
  '-t', '30',           // 只要前 30 秒
  '-vf', 'scale=720:-2', // 压缩到 720p
  '-c:v', 'libx264',
  '-preset', 'fast',
  '-crf', '28',
  'output.mp4'
]);

// 读取结果
const data = await ffmpeg.readFile('output.mp4');
const blob = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);

// 直接在页面上播放
document.querySelector('video').src = url;

踩坑点

  1. WASM 文件巨大。ffmpeg-core.wasm 大概 25MB,首次加载会很慢。建议做 loading 动画 + 缓存到 Service Worker
  2. SharedArrayBuffer 限制。多线程版本需要页面设置 COOP/COEP 响应头,很多部署环境不支持。单线程版也能用,就是慢一些:
    // 单线程版本,兼容性更好
    const baseURL = 'https://unpkg.com/@ffmpeg/core-st@0.12.6/dist/esm';
    
  3. 2GB 文件上限。WASM 内存限制,超过 2GB 的视频处理不了。不过前端场景一般也碰不到这个上限
  4. iOS Safari 有坑。部分老版本 Safari 对 WASM 内存分配有 bug,大文件处理可能崩溃。2026 年的 Safari 17+ 基本没问题了

场景三:图片处理快到飞起(photon-wasm)

Canvas API 做图片处理不是不能用,但一旦图片大一点(比如 4K),肉眼可见地卡。photon 是 Rust 写的图片处理库,编译成 WASM 后性能碾压 Canvas。

npm install @aspect-build/photon-wasm
# 或者直接用 CDN
import * as photon from '@aspect-build/photon-wasm';

// 从 Canvas 获取图片数据
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

// 加载图片到 canvas
const img = new Image();
img.src = 'photo.jpg';
await new Promise(resolve => img.onload = resolve);
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);

// 创建 PhotonImage
const image = photon.open_image(canvas, ctx);

// 应用滤镜 —— 一行代码搞定
photon.filter(image, 'oceanic');    // 海洋风滤镜
// photon.grayscale(image);         // 灰度
// photon.gaussian_blur(image, 3);  // 高斯模糊
// photon.sharpen(image);           // 锐化

// 调整亮度对比度
photon.alter_channel(image, 0, 20);  // R通道+20

// 写回 canvas
photon.putImageData(canvas, ctx, image);

// 导出为 Blob 下载
canvas.toBlob(blob => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'processed.jpg';
  a.click();
}, 'image/jpeg', 0.9);

实测一张 4000x3000 的照片:

操作 Canvas API photon-wasm 提速
灰度转换 180ms 35ms 5.1x
高斯模糊 2400ms 480ms 5.0x
批量滤镜(3个叠加) 850ms 190ms 4.5x

踩坑点

  1. npm 包名很混乱。搜 photon wasm 会找到好几个包,认准 GitHub(silvia-odwyer/photon)上的官方版本
  2. 内存要手动管理PhotonImage 对象用完记得调用 .free() 释放 WASM 内存,不然会内存泄漏
  3. 不支持 HEIC 格式。苹果的 HEIC 图片需要先用其他库转成 JPEG/PNG 再处理

什么时候该用 WASM,什么时候别折腾

说实话大部分前端场景不需要 WASM。如果你只是做个 CRUD 后台,加什么 WASM 纯属给自己找事。

但这几种情况值得考虑:

  • 计算密集型:大量数据处理、加解密、图片/音视频处理
  • 现有 C/C++/Rust 轮子:比如 SQLite、FFmpeg,直接编译过来比用 JS 重写强一万倍
  • 需要离线能力:数据库、文档解析这些在端上跑,不依赖后端

而且 WASI 0.3 刚在今年 2 月发布了,以后 WebAssembly 不只是浏览器的东西——边缘计算、Serverless 都能用。路线图里甚至有 wasi:nn 专门用来跑 AI 推理,以后在浏览器里直接跑模型可能也不是梦。

小结

说白了 WebAssembly 对前端来说就是一个性能工具箱。不需要学 Rust,不需要懂编译原理,npm install 完就能享受 native 级别的性能。

这三个库我在实际项目里都用过,sql.js 用得最多(报表系统的前端聚合),ffmpeg.wasm 偶尔用(用户端视频预处理),photon 适合需要批量图片处理的场景。

热榜在问「前端要不要学 WASM」——我觉得不用「学」它,就行了。

从“输入网址”到“帧级控制”:我对事件循环与主线程管理的终极认知

作者 荒野码农
2026年3月21日 06:12

摘要:你是否真正清楚从输入 URL 到页面渲染的每一毫秒里,浏览器到底做了什么?渲染究竟发生在事件循环的哪一步?为什么 Promise 总是比 setTimeout 快?为什么时间切片必须依赖宏任务?本文将以全链路视角,从用户输入地址开始,还原“同步代码 -> 微任务清空 -> 多队列宏任务调度 -> 渲染”的完整闭环。我们将深入浏览器内核的多队列优先级机制,并重新定义性能优化的核心:利用宏任务的“单次执行”机制,主动切割主线程的连续占用时间。


🚀 第一部分:起点——从输入 URL 到主线程的“第一行代码”

一切始于用户在地址栏输入 URL 并按下回车。这一刻,一场精密的交响乐正式奏响。

1. 前置状态:空标签页的主线程在做什么?

在请求发出前,如果我们打开的是一个空白标签页:

  • 渲染进程(Renderer Process) :已分配,进程存在。
  • 主线程(Main Thread) :已创建,处于**“空闲等待(Idle/Waiting)”状态。它运行着底层的消息循环(Message Loop)**,但没有任何业务代码。
  • 结论:主线程就像一个亮着灯的空舞台,演员(JS 引擎)待命,只等剧本(HTML/JS)通过网络送达。

2. 网络阶段:被忽视的“宏观异步”

  • 网络请求:浏览器发起 HTTP 请求。此时主线程继续空闲或处理其他已有任务。

  • 资源下载:HTML 文件通过网络传输(这可能耗时几十毫秒到几秒)。

  • 颠覆性视角

    我们常认为首屏脚本是“同步代码”,但从系统视角看,它本质上是一段“基于网络 I/O 的宏观异步代码”
    主线程一直在“异步等待”资源到位。一旦 HTML 下载完成,解析器开始工作,生成的 <script> 执行任务才被推入主线程。

3. 第一阶段:执行同步代码(Synchronous Code)

当 HTML 解析遇到 <script> 标签(非 async/defer):

  • 动作:解析暂停,JS 引擎立即执行脚本中的全局同步代码

  • 现象:这是主线程第一次被连续独占

  • 细节

    • 变量声明、函数定义立即执行。
    • 如果遇到 setTimeoutPromise,它们的注册代码(如设置定时器、创建 Promise 对象)会同步执行,但回调函数会被分别扔进宏任务队列微任务队列此时绝不执行
    • DOM 操作同步更新内存中的 DOM 树,但屏幕尚未渲染

⚙️ 第二部分:事件循环的微观全流程(含多队列优先级与渲染时机)

当全局同步代码执行完毕,调用栈清空。此时,主线程并没有立刻去拿宏任务,而是进入了著名的事件循环(Event Loop) 。这是一个严格的多队列调度闭环。

1. 多队列的真实架构

浏览器并非只有一个“宏任务队列”,而是维护着多个不同优先级的宏任务队列(Task Queues) ,它们对应不同的任务源(Task Sources):

  • 🔴 用户交互队列(User Interaction) :点击、滚动、键盘输入。优先级最高,为了保障极致的流畅度,这类任务往往会被优先调度。
  • 🟠 网络回调队列(Network)fetchXHR、WebSocket 消息到达。
  • 🟡 定时器队列(Timer)setTimeoutsetInterval 到期任务。
  • 🟢 解析任务队列(Parsing) :HTML 解析过程中产生的后续脚本执行任务。
  • 🔵 微任务队列(Microtask Queue)只有一个。存放 Promise 回调、MutationObserverqueueMicrotask

2. 事件循环的精确执行步骤(The Loop)

一个完整的事件循环迭代(Iteration)遵循以下铁律顺序

Step 0: 同步代码执行完毕

  • 全局脚本或上一个宏任务中的同步代码运行结束,调用栈清空。

Step 1: 🔴 强制清空微任务队列(Microtask Checkpoint)

  • 这是铁律! 在去取任何宏任务之前,事件循环必须先检查微任务队列。
  • 动作:只要微任务队列不为空,就依次取出并执行所有微任务
  • 循环机制:如果在执行微任务 A 时产生了微任务 B,B 会被立即加入队列并在当前轮次紧接着执行。这个过程会一直持续,直到微任务队列彻底为空
  • 注意:在此阶段,渲染尚未发生。如果微任务无限循环,宏任务和渲染将永远被阻塞。

Step 2: 🟢 尝试更新渲染(Rendering Update)

  • 时机:只有当微任务队列彻底清空后。

  • 动作:浏览器检查是否有需要更新的视觉变化(DOM 变更、样式计算、布局、绘制)。

  • 条件

    1. 如果有 DOM/CSS 变动。
    2. 且距离上一帧渲染已超过一定时间(通常目标是 16.6ms/60fps)。
  • 结果:浏览器进行Render(渲染) ,将画面呈现给用户。

    • 🌟 这就是用户感知到“页面动了”或“点击有反应”的时刻。

Step 3: 🔵 智能选择并取出一个宏任务(Macrotask Selection)

  • 现在,微任务空了,渲染也做完了(或不需要做)。事件循环开始处理宏任务。

  • 关键机制:多队列优先级调度

    • 事件循环不会简单地按“先进先出”从一个大池子里取任务。

    • 它会扫描所有宏任务队列(用户交互、网络、定时器等)。

    • 策略:根据队列优先级任务饥饿防止算法进行选择。

      • 例如:如果“用户交互队列”里有点击事件,即使“定时器队列”里的任务更早进入,浏览器也极可能优先取出用户交互任务,以保证响应性。
      • 例如:网络回调通常比普通定时器优先级高。
  • 动作:从选中的那个队列中,取出第一个任务(Task)

    • 🔴 核心限制每次循环只取一个任务! 即使该队列后面还有 99 个任务,本次也只处理这 1 个。剩下的留在队列里,等下一轮循环再竞争。

Step 4: 执行宏任务

  • 主线程开始执行这个被取出的任务。
  • 在此期间,如果产生了新的微任务,它们会被加入微任务队列,但不会立即执行,必须等到下一个循环的 Step 1

Step 5: 回到 Step 1(循环继续)

  • 宏任务执行完毕 -> 回到 Step 1 清空新产生的微任务 -> Step 2 渲染 -> Step 3 选下一个宏任务...

💡 第三部分:从原理到实践——为何我们需要“时间切片”?

理解了上述从同步代码到多队列调度的完整流程,我们终于来到了前端性能优化的核心战场。

1. 现实的痛点:长任务的“霸权”

回顾一下 Step 3 和 Step 2 的关系:

  • 浏览器只有在当前宏任务执行完毕后,才有机会去检查渲染(Step 2)。

  • 如果一个宏任务(比如处理 10 万条数据、复杂的 DOM 计算)执行了 500ms,那么在这 500ms 内:

    • 微任务队列无法被清空(因为宏任务没结束)。
    • 渲染更新无法进行(因为宏任务没结束)。
    • 用户交互(点击、滚动)即使进入了高优先级队列,也必须等待当前这个“霸道”的宏任务跑完才能被取出(Step 3)。

后果:页面白屏、点击无反应、滚动卡顿。这就是典型的主线程阻塞

2. 破局之道:主动“切割”主线程

既然浏览器的事件循环机制是 “每次只取一个宏任务,然后强制检查渲染” ,那么聪明的工程师就会想:

“如果我无法改变浏览器的调度规则,那我能不能主动迎合它?如果我故意把一个需要 500ms 的大任务,拆分成 10 个 50ms 的小任务,分别作为 10 个独立的宏任务放入队列,会发生什么?”

答案

  • 每执行完一个 50ms 的小任务,事件循环就会进入 Step 1(清微任务)  和 Step 2(渲染)
  • 浏览器获得了 10 次刷新屏幕的机会。
  • 用户交互队列获得了 10 次插队执行的机会。
  • 结果:原本卡死的 500ms,变成了丝滑的 10 帧动画。

这就是时间切片(Time Slicing) 的本质:它不是一种新的 API,而是一种利用事件循环“单任务执行 + 渲染间隙”机制的工程化策略。

3. 为什么 Promise 做不到,而 setTimeout 可以?

现在,让我们用刚才学到的铁律顺序来验证两种实现方式。

❌ 错误示范:试图用 Promise (微任务) 切片

javascript

编辑

1function badSlice() {
2  processChunk(); // 处理一小块
3  Promise.resolve().then(badSlice); // 尝试递归
4}
  • 原理分析

    • processChunk() 执行完。
    • 进入 Step 1 (清微任务) :发现队列里有 badSlice 的回调。
    • 立即执行 badSlice
    • 在 badSlice 里又产生新的微任务...
    • 死循环:根据铁律,微任务必须彻底清空才能进入 Step 2 (渲染)。只要你的递归不停止,微任务队列永远不为空,渲染永远被阻塞
  • 结局:页面依然卡死,甚至可能因栈溢出或微任务过多导致崩溃。

✅ 正确示范:利用 setTimeout (宏任务) 切片

1function goodSlice() {
2  processChunk(); // 处理一小块(同步执行,耗时短,如 10ms)
3  setTimeout(goodSlice, 0); // 将下一块推入【宏任务队列】
4}
  • 原理深度解析

    1. processChunk() 执行完毕(当前宏任务结束)。

    2. Step 1:检查微任务队列(为空)。

    3. Step 2 (关键!) :微任务已空,浏览器立即触发渲染。用户看到画面更新,感觉到页面是“活”的。

    4. Step 3:事件循环根据优先级,从宏任务队列中取出下一个任务(即刚才 setTimeout 放入的 goodSlice)。

      • 注意:这里完美利用了“每次只取一个宏任务”的机制。
    5. Step 4:执行下一块数据。

    6. 循环:回到 Step 1,再次为渲染腾出空间。

核心结论:

时间切片的成功,完全依赖于我们将大任务拆解为独立的“宏任务”。
只有宏任务的边界,才是事件循环中 “执行 -> 渲染 -> 再执行” 的天然分割线。微任务由于必须在渲染前全部清空,无法充当这个分割线。

🎯 第四部分:重新定义性能优化——管理“连续占用时长”

基于以上全链路理解,我们需要修正对性能优化的认知:

❌ 误区:优化是为了减少总计算时间

  • 事实:如果你的业务逻辑需要计算 100 万个数据,无论是否切片,CPU 的总指令数是固定的。
  • 代价:切片甚至会因为多次进出事件循环、上下文切换(Context Switch)和定时器开销,导致总耗时略微增加

✅ 真相:优化是为了管理“连续占用时长”

  • 目标:控制主线程连续执行同步代码的时间片(Time Slice)

  • 标准:确保每个宏任务的执行时间 < 50ms(理想情况 < 16.6ms),以便在 Step 2 能够及时触发渲染。

  • 收益

    • 虽然总耗时可能从 2.0s 变成 2.1s。
    • 但用户感受到的是:每隔 16ms 页面就能响应一次操作,全程丝滑,而不是前 2s 完全卡死,第 2.1s 突然恢复。

核心结论

“前端性能优化的核心,不在于改变业务的总计算量,而在于管理主线程的‘连续占用时长’。通过时间切片,我们将长任务拆解为符合帧率要求的短宏任务,利用事件循环的‘单任务执行’机制,在任务间隙强制插入渲染和用户响应窗口。这是以微小的总效率损耗,换取极致的用户体验响应性(Responsiveness) 。”


🌟 总结:全链路异步世界观

通过这次从“输入 URL”开始的深度剖析,我们建立了一套完整的认知体系:

  1. 宏观视角(网络层) :从输入网址开始,所有代码本质上都是网络异步到达主线程的。首屏脚本只是第一个“大宏任务”。

  2. 执行顺序(时间轴铁律)

    • 同步代码:立即执行,阻塞解析。
    • 微任务:同步代码结束后,立即全部清空(优先级高于宏任务)。
    • 渲染:发生在微任务清空后、下一个宏任务前
    • 宏任务:每次循环只取一个,且需经过多队列优先级调度(用户交互 > 网络 > 定时器等)。
  3. 优化策略(工程层) :利用 setTimeout 等宏任务机制实现时间切片,主动切割主线程的连续占用时间,确保渲染时机能按时到来。

🌟 终极结语:从“码农”到“系统架构师”的觉醒

如果把这篇关于事件循环的深度解析作为终点,那么它留给我们的不应该仅仅是几个面试题的答案,而应该是一种思维模式的转变

不要只做需求的“翻译官”,要做系统的“操盘手”。

当你写下每一行代码时,请试着透过语法糖,看到背后正在发生的:

  • 主线程是否在连续空转?
  • 微任务队列是否正在无限膨胀?
  • 渲染管线是否被阻塞在下一个宏任务之前?
  • 用户的点击是否能在 100ms 内得到响应?

真正的工程能力,不在于你掌握了多少 API,而在于你能否利用这些 API,去精细地管理浏览器的每一毫秒,去驾驭那个看不见的、复杂的异步世界,最终交付给用户一个如丝般顺滑的系统。

这就是我们从“输入 URL”一直推导到“时间切片”的终极奥义。

愿你在未来的架构设计中,都能游刃有余地驾驭主线程,打造出极致流畅的用户体验!🚀


如果你觉得这篇深度解析对你有启发,欢迎点赞、收藏、转发,让我们一起在前端底层原理的道路上不断精进!

【源码】【react】useCallback、useMemo、memo 原理

作者 yzin
2026年3月21日 03:12

最近跟进部门的 PC 性能优化项目,给的 SOP 是尽量使用 useCallback、useMemo、memo,我印象中 react 文档是不推荐全部使用这些 hook 的,但又不太清楚原因,因此想通过源码看下作用的原理。

另外想吐槽一下,刷到很多文章都说不要过早优化,遇到性能问题时再去优化。我想说这种话的是没遇到 ld 心血来潮搞个性能专项,随便拍了个目标,然后要你倒推哪里可以抠出性能的场景😀。你们倒是说下怎么极致地抠出性能啊😄。

首先看导出 API

// packages/react/index.js
export {
...
  memo,
  useCallback,
  useMemo,
  ...
} from './src/ReactClient';

memo

// packages/react/src/ReactMemo.js
export function memo<Props>(
  type: React$ElementType,
  compare?: (oldProps: Props, newProps: Props) => boolean,
) {
  if (__DEV__) {
    if (type == null) {
      console.error(
        'memo: The first argument must be a component. Instead ' +
          'received: %s',
        type === null ? 'null' : typeof type,
      );
    }
  }
  const elementType = {
    $$typeof: REACT_MEMO_TYPE,
    type,
    compare: compare === undefined ? null : compare,
  };
  if (__DEV__) {
    let ownName;
    Object.defineProperty(elementType, 'displayName', {
      enumerable: false,
      configurable: true,
      get: function () {
        return ownName;
      },
      set: function (name) {
        ownName = name;

        // The inner component shouldn't inherit this display name in most cases,
        // because the component may be used elsewhere.
        // But it's nice for anonymous functions to inherit the name,
        // so that our component-stack generation logic will display their frames.
        // An anonymous function generally suggests a pattern like:
        //   React.memo((props) => {...});
        // This kind of inner function is not used elsewhere so the side effect is okay.
        if (!type.name && !type.displayName) {
          Object.defineProperty(type, 'name', {
            value: name,
          });
          type.displayName = name;
        }
      },
    });
  }
  return elementType;
}

这段代码其实没干什么,主要就标记了$$typeof: REACT_MEMO_TYPE 。还需要看下 memo 具体对组件的影响。

// packages/react-reconciler/src/ReactFiber.js
export function createFiberFromTypeAndProps(
  type: any, // React$ElementType
  key: ReactKey,
  pendingProps: any,
  owner: null | ReactComponentInfo | Fiber,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  let fiberTag: WorkTag = FunctionComponent;
  // The resolved type is set if we know what the final type will be. I.e. it's not lazy.
  let resolvedType = type;
  if (typeof type === 'function') {
    if (shouldConstruct(type)) {
      fiberTag = ClassComponent;
      if (__DEV__) {
        resolvedType = resolveClassForHotReloading(resolvedType);
      }
    } else {
      if (__DEV__) {
        resolvedType = resolveFunctionForHotReloading(resolvedType);
      }
    }
  } else if (typeof type === 'string') {
    if (supportsResources && supportsSingletons) {
      const hostContext = getHostContext();
      fiberTag = isHostHoistableType(type, pendingProps, hostContext)
        ? HostHoistable
        : isHostSingletonType(type)
          ? HostSingleton
          : HostComponent;
    } else if (supportsResources) {
      const hostContext = getHostContext();
      fiberTag = isHostHoistableType(type, pendingProps, hostContext)
        ? HostHoistable
        : HostComponent;
    } else if (supportsSingletons) {
      fiberTag = isHostSingletonType(type) ? HostSingleton : HostComponent;
    } else {
      fiberTag = HostComponent;
    }
  } else {
    getTag: switch (type) {
      case REACT_ACTIVITY_TYPE:
        return createFiberFromActivity(pendingProps, mode, lanes, key);
      case REACT_FRAGMENT_TYPE:
        return createFiberFromFragment(pendingProps.children, mode, lanes, key);
      case REACT_STRICT_MODE_TYPE:
        fiberTag = Mode;
        mode |= StrictLegacyMode;
        if (disableLegacyMode || (mode & ConcurrentMode) !== NoMode) {
          // Strict effects should never run on legacy roots
          mode |= StrictEffectsMode;
        }
        break;
      case REACT_PROFILER_TYPE:
        return createFiberFromProfiler(pendingProps, mode, lanes, key);
      case REACT_SUSPENSE_TYPE:
        return createFiberFromSuspense(pendingProps, mode, lanes, key);
      case REACT_SUSPENSE_LIST_TYPE:
        return createFiberFromSuspenseList(pendingProps, mode, lanes, key);
      case REACT_LEGACY_HIDDEN_TYPE:
        if (enableLegacyHidden) {
          return createFiberFromLegacyHidden(pendingProps, mode, lanes, key);
        }
      // Fall through
      case REACT_VIEW_TRANSITION_TYPE:
        if (enableViewTransition) {
          return createFiberFromViewTransition(pendingProps, mode, lanes, key);
        }
      // Fall through
      case REACT_SCOPE_TYPE:
        if (enableScopeAPI) {
          return createFiberFromScope(type, pendingProps, mode, lanes, key);
        }
      // Fall through
      case REACT_TRACING_MARKER_TYPE:
        if (enableTransitionTracing) {
          return createFiberFromTracingMarker(pendingProps, mode, lanes, key);
        }
      // Fall through
      default: {
        if (typeof type === 'object' && type !== null) {
          switch (type.$$typeof) {
            case REACT_CONTEXT_TYPE:
              fiberTag = ContextProvider;
              break getTag;
            case REACT_CONSUMER_TYPE:
              fiberTag = ContextConsumer;
              break getTag;
            // Fall through
            case REACT_FORWARD_REF_TYPE:
              fiberTag = ForwardRef;
              if (__DEV__) {
                resolvedType = resolveForwardRefForHotReloading(resolvedType);
              }
              break getTag;
            case REACT_MEMO_TYPE:
              fiberTag = MemoComponent;
              break getTag;
            case REACT_LAZY_TYPE:
              fiberTag = LazyComponent;
              resolvedType = null;
              break getTag;
          }
        }
        let info = '';
        let typeString;
        if (__DEV__) {
          if (
            type === undefined ||
            (typeof type === 'object' &&
              type !== null &&
              Object.keys(type).length === 0)
          ) {
            info +=
              ' You likely forgot to export your component from the file ' +
              "it's defined in, or you might have mixed up default and named imports.";
          }

          if (type === null) {
            typeString = 'null';
          } else if (isArray(type)) {
            typeString = 'array';
          } else if (
            type !== undefined &&
            type.$$typeof === REACT_ELEMENT_TYPE
          ) {
            typeString = `<${
              getComponentNameFromType(type.type) || 'Unknown'
            } />`;
            info =
              ' Did you accidentally export a JSX literal instead of a component?';
          } else {
            typeString = typeof type;
          }

          const ownerName = owner ? getComponentNameFromOwner(owner) : null;
          if (ownerName) {
            info += '\n\nCheck the render method of `' + ownerName + '`.';
          }
        } else {
          typeString = type === null ? 'null' : typeof type;
        }

        // The type is invalid but it's conceptually a child that errored and not the
        // current component itself so we create a virtual child that throws in its
        // begin phase. This is the same thing we do in ReactChildFiber if we throw
        // but we do it here so that we can assign the debug owner and stack from the
        // element itself. That way the error stack will point to the JSX callsite.
        fiberTag = Throw;
        pendingProps = new Error(
          'Element type is invalid: expected a string (for built-in ' +
            'components) or a class/function (for composite components) ' +
            `but got: ${typeString}.${info}`,
        );
        resolvedType = null;
      }
    }
  }

  const fiber = createFiber(fiberTag, pendingProps, key, mode);
  fiber.elementType = type;
  fiber.type = resolvedType;
  fiber.lanes = lanes;

  if (__DEV__) {
    fiber._debugOwner = owner;
  }

  return fiber;
}

可以看到当 $$typeof 为 REACT_MEMO_TYPE 时,使用 MemoComponent 作为 fiberTag 创建 fiber。

// packages/react-reconciler/src/ReactFiberBeginWork.js
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  if (__DEV__) {
    if (workInProgress._debugNeedsRemount && current !== null) {
      // This will restart the begin phase with a new fiber.
      const copiedFiber = createFiberFromTypeAndProps(
        workInProgress.type,
        workInProgress.key,
        workInProgress.pendingProps,
        workInProgress._debugOwner || null,
        workInProgress.mode,
        workInProgress.lanes,
      );
      copiedFiber._debugStack = workInProgress._debugStack;
      copiedFiber._debugTask = workInProgress._debugTask;
      return remountFiber(current, workInProgress, copiedFiber);
    }
  }

  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (
      oldProps !== newProps ||
      hasLegacyContextChanged() ||
      // Force a re-render if the implementation changed due to hot reload:
      (__DEV__ ? workInProgress.type !== current.type : false)
    ) {
      // If props or context changed, mark the fiber as having performed work.
      // This may be unset if the props are determined to be equal later (memo).
      didReceiveUpdate = true;
    } else {
      // Neither props nor legacy context changes. Check if there's a pending
      // update or context change.
      const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
        current,
        renderLanes,
      );
      if (
        !hasScheduledUpdateOrContext &&
        // If this is the second pass of an error or suspense boundary, there
        // may not be work scheduled on `current`, so we check for this flag.
        (workInProgress.flags & DidCapture) === NoFlags
      ) {
        // No pending updates or context. Bail out now.
        didReceiveUpdate = false;
        return attemptEarlyBailoutIfNoScheduledUpdate(
          current,
          workInProgress,
          renderLanes,
        );
      }
      if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
        // This is a special case that only exists for legacy mode.
        // See <https://github.com/facebook/react/pull/19216>.
        didReceiveUpdate = true;
      } else {
        // An update was scheduled on this fiber, but there are no new props
        // nor legacy context. Set this to false. If an update queue or context
        // consumer produces a changed value, it will set this to true. Otherwise,
        // the component will assume the children have not changed and bail out.
        didReceiveUpdate = false;
      }
    }
  } else {
    didReceiveUpdate = false;

    if (getIsHydrating() && isForkedChild(workInProgress)) {
      // Check if this child belongs to a list of muliple children in
      // its parent.
      //
      // In a true multi-threaded implementation, we would render children on
      // parallel threads. This would represent the beginning of a new render
      // thread for this subtree.
      //
      // We only use this for id generation during hydration, which is why the
      // logic is located in this special branch.
      const slotIndex = workInProgress.index;
      const numberOfForks = getForksAtLevel(workInProgress);
      pushTreeId(workInProgress, numberOfForks, slotIndex);
    }
  }

  // Before entering the begin phase, clear pending update priority.
  // TODO: This assumes that we're about to evaluate the component and process
  // the update queue. However, there's an exception: SimpleMemoComponent
  // sometimes bails out later in the begin phase. This indicates that we should
  // move this assignment out of the common path and into each branch.
  workInProgress.lanes = NoLanes;

  switch (workInProgress.tag) {
    case LazyComponent: {
      const elementType = workInProgress.elementType;
      return mountLazyComponent(
        current,
        workInProgress,
        elementType,
        renderLanes,
      );
    }
    case FunctionComponent: {
      const Component = workInProgress.type;
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        workInProgress.pendingProps,
        renderLanes,
      );
    }
    case ClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps = resolveClassComponentProps(
        Component,
        unresolvedProps,
      );
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case HostHoistable:
      if (supportsResources) {
        return updateHostHoistable(current, workInProgress, renderLanes);
      }
    // Fall through
    case HostSingleton:
      if (supportsSingletons) {
        return updateHostSingleton(current, workInProgress, renderLanes);
      }
    // Fall through
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    case HostText:
      return updateHostText(current, workInProgress);
    case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);
    case HostPortal:
      return updatePortalComponent(current, workInProgress, renderLanes);
    case ForwardRef: {
      return updateForwardRef(
        current,
        workInProgress,
        workInProgress.type,
        workInProgress.pendingProps,
        renderLanes,
      );
    }
    case Fragment:
      return updateFragment(current, workInProgress, renderLanes);
    case Mode:
      return updateMode(current, workInProgress, renderLanes);
    case Profiler:
      return updateProfiler(current, workInProgress, renderLanes);
    case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);
    case ContextConsumer:
      return updateContextConsumer(current, workInProgress, renderLanes);
    case MemoComponent: {
      return updateMemoComponent(
        current,
        workInProgress,
        workInProgress.type,
        workInProgress.pendingProps,
        renderLanes,
      );
    }
    case SimpleMemoComponent: {
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        workInProgress.type,
        workInProgress.pendingProps,
        renderLanes,
      );
    }
    case IncompleteClassComponent: {
      if (disableLegacyMode) {
        break;
      }
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps = resolveClassComponentProps(
        Component,
        unresolvedProps,
      );
      return mountIncompleteClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case IncompleteFunctionComponent: {
      if (disableLegacyMode) {
        break;
      }
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps = resolveClassComponentProps(
        Component,
        unresolvedProps,
      );
      return mountIncompleteFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case SuspenseListComponent: {
      return updateSuspenseListComponent(current, workInProgress, renderLanes);
    }
    case ScopeComponent: {
      if (enableScopeAPI) {
        return updateScopeComponent(current, workInProgress, renderLanes);
      }
      break;
    }
    case ActivityComponent: {
      return updateActivityComponent(current, workInProgress, renderLanes);
    }
    case OffscreenComponent: {
      return updateOffscreenComponent(
        current,
        workInProgress,
        renderLanes,
        workInProgress.pendingProps,
      );
    }
    case LegacyHiddenComponent: {
      if (enableLegacyHidden) {
        return updateLegacyHiddenComponent(
          current,
          workInProgress,
          renderLanes,
        );
      }
      break;
    }
    case CacheComponent: {
      return updateCacheComponent(current, workInProgress, renderLanes);
    }
    case TracingMarkerComponent: {
      if (enableTransitionTracing) {
        return updateTracingMarkerComponent(
          current,
          workInProgress,
          renderLanes,
        );
      }
      break;
    }
    case ViewTransitionComponent: {
      if (enableViewTransition) {
        return updateViewTransition(current, workInProgress, renderLanes);
      }
      break;
    }
    case Throw: {
      // This represents a Component that threw in the reconciliation phase.
      // So we'll rethrow here. This might be a Thenable.
      throw workInProgress.pendingProps;
    }
  }

  throw new Error(
    `Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
      'React. Please file an issue.',
  );
}

这里看到有 MemoComponent 和 SimpleMemoComponent 两种类型。但是在前面只有 MemoComponent 一种 fiberTag,这两种类型有什么区别?

updateMemoComponent

// packages/react-reconciler/src/ReactFiberBeginWork.js
function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
): null | Fiber {
  if (current === null) {
    const type = Component.type;
    if (isSimpleFunctionComponent(type) && Component.compare === null) {
      let resolvedType = type;
      if (__DEV__) {
        resolvedType = resolveFunctionForHotReloading(type);
      }
      // If this is a plain function component without default props,
      // and with only the default shallow comparison, we upgrade it
      // to a SimpleMemoComponent to allow fast path updates.
      workInProgress.tag = SimpleMemoComponent;
      workInProgress.type = resolvedType;
      if (__DEV__) {
        validateFunctionComponentInDev(workInProgress, type);
      }
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        resolvedType,
        nextProps,
        renderLanes,
      );
    }
    const child = createFiberFromTypeAndProps(
      Component.type,
      null,
      nextProps,
      workInProgress,
      workInProgress.mode,
      renderLanes,
    );
    child.ref = workInProgress.ref;
    child.return = workInProgress;
    workInProgress.child = child;
    return child;
  }
  const currentChild = ((current.child: any): Fiber); // This is always exactly one child
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes,
  );
  if (!hasScheduledUpdateOrContext) {
    // This will be the props with resolved defaultProps,
    // unlike current.memoizedProps which will be the unresolved ones.
    const prevProps = currentChild.memoizedProps;
    // Default to shallow comparison
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  const newChild = createWorkInProgress(currentChild, nextProps);
  newChild.ref = workInProgress.ref;
  newChild.return = workInProgress;
  workInProgress.child = newChild;
  return newChild;
}

updateMemoComponent 在组件初始化化时,如果组件为 SimpleFunctionComponent 且无 compare 时,将 tag 转换为 SimpleMemoComponent。

// packages/react-reconciler/src/ReactFiber.js
function shouldConstruct(Component: Function) {
  const prototype = Component.prototype;
  return !!(prototype && prototype.isReactComponent);
}

export function isSimpleFunctionComponent(type: any): boolean {
  return (
    typeof type === 'function' &&
    !shouldConstruct(type) &&
    type.defaultProps === undefined
  );
}

updateMemoComponent 在组件更新时,会检查是否可复用。

// packages/react-reconciler/src/ReactFiberBeginWork.js
function checkScheduledUpdateOrContext(
  current: Fiber,
  renderLanes: Lanes,
): boolean {
  // Before performing an early bailout, we must check if there are pending
  // updates or context.
  const updateLanes = current.lanes;
  if (includesSomeLane(updateLanes, renderLanes)) {
    return true;
  }
  // No pending update, but because context is propagated lazily, we need
  // to check for a context change before we bail out.
  const dependencies = current.dependencies;
  if (dependencies !== null && checkIfContextChanged(dependencies)) {
    return true;
  }
  return false;
}

const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
  current,
  renderLanes,
);
if (!hasScheduledUpdateOrContext) {
  // This will be the props with resolved defaultProps,
  // unlike current.memoizedProps which will be the unresolved ones.
  const prevProps = currentChild.memoizedProps;
  // Default to shallow comparison
  let compare = Component.compare;
  compare = compare !== null ? compare : shallowEqual;
  if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
}

checkScheduledUpdateOrContext 主要检查组件自身 state 和依赖 context 是否更新(没深入去看)。如果更新了,不可复用,否则调用 compare 进行比较;如果 props 不变,返回 bailoutOnAlreadyFinishedWork,否则创建新组件,然后继续检查子节点树。

// packages/react-reconciler/src/ReactFiberBeginWork.js
function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  if (current !== null) {
    // Reuse previous dependencies
    workInProgress.dependencies = current.dependencies; 
    // dependencies存储了该组件依赖的上下文(Context)等信息,直接复用可以避免重新收集
  }

  if (enableProfilerTimer) {
    // Don't update "base" render times for bailouts.
    stopProfilerTimerIfRunning(workInProgress);
  }

  markSkippedUpdateLanes(workInProgress.lanes);

  // Check if the children have any pending work.
  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
    // The children don't have any work either. We can skip them.
    // TODO: Once we add back resuming, we should check if the children are
    // a work-in-progress set. If so, we need to transfer their effects.

    if (current !== null) {
      // Before bailing out, check if there are any context changes in
      // the children.
      lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
      if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
        return null;
      }
    } else {
      return null;
    }
  }

  // This fiber doesn't have work, but its subtree does. Clone the child
  // fibers and continue.
  cloneChildFibers(current, workInProgress);
  return workInProgress.child;
}

bailoutOnAlreadyFinishedWork 的主要作用:

  • 复用 dependencies(存储了该组件依赖的上下文(Context)等信息)
  • 检查子组件自身 state 和依赖 context 是否更新,如果没有更新则跳过子节点树,否则克隆一份子节点树,继续检查子节点树是否可复用

updateSimpleMemoComponent

// packages/react-reconciler/src/ReactFiberBeginWork.js
function updateSimpleMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
): null | Fiber {
  // TODO: current can be non-null here even if the component
  // hasn't yet mounted. This happens when the inner render suspends.
  // We'll need to figure out if this is fine or can cause issues.
  if (current !== null) {
    const prevProps = current.memoizedProps;
    if (
      shallowEqual(prevProps, nextProps) &&
      current.ref === workInProgress.ref &&
      // Prevent bailout if the implementation changed due to hot reload.
      (__DEV__ ? workInProgress.type === current.type : true)
    ) {
      didReceiveUpdate = false;

      // The props are shallowly equal. Reuse the previous props object, like we
      // would during a normal fiber bailout.
      //
      // We don't have strong guarantees that the props object is referentially
      // equal during updates where we can't bail out anyway — like if the props
      // are shallowly equal, but there's a local state or context update in the
      // same batch.
      //
      // However, as a principle, we should aim to make the behavior consistent
      // across different ways of memoizing a component. For example, React.memo
      // has a different internal Fiber layout if you pass a normal function
      // component (SimpleMemoComponent) versus if you pass a different type
      // like forwardRef (MemoComponent). But this is an implementation detail.
      // Wrapping a component in forwardRef (or React.lazy, etc) shouldn't
      // affect whether the props object is reused during a bailout.
      workInProgress.pendingProps = nextProps = prevProps;

      if (!checkScheduledUpdateOrContext(current, renderLanes)) {
        // The pending lanes were cleared at the beginning of beginWork. We're
        // about to bail out, but there might be other lanes that weren't
        // included in the current render. Usually, the priority level of the
        // remaining updates is accumulated during the evaluation of the
        // component (i.e. when processing the update queue). But since since
        // we're bailing out early *without* evaluating the component, we need
        // to account for it here, too. Reset to the value of the current fiber.
        // NOTE: This only applies to SimpleMemoComponent, not MemoComponent,
        // because a MemoComponent fiber does not have hooks or an update queue;
        // rather, it wraps around an inner component, which may or may not
        // contains hooks.
        // TODO: Move the reset at in beginWork out of the common path so that
        // this is no longer necessary.
        workInProgress.lanes = current.lanes;
        return bailoutOnAlreadyFinishedWork(
          current,
          workInProgress,
          renderLanes,
        );
      } else if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
        // This is a special case that only exists for legacy mode.
        // See <https://github.com/facebook/react/pull/19216>.
        didReceiveUpdate = true;
      }
    }
  }
  return updateFunctionComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes,
  );
}

updateSimpleMemoComponent 的主要区别在于:

  • 先 compare 比较依赖,再检查组件自身 state 和依赖 context 是否更新
  • 赋值 props
  • 赋值 lanes

为什么 updateSimpleMemoComponent 需要这两个赋值,而 updateMemoComponent 不需要?暂时没太搞懂,先跳过。

定量分析 memo 优化效果

既然 memo 可以跳过组件创建,为什么 react 不给所有组件加上 memo?(官方文档也提到使用React Compiler 的话,可以不用 memo)。如果定量的话,怎样的组件才需要使用 memo?

When you enable React Compiler, you typically don’t need React.memo anymore.

首先是初始化。使用 memo 封装组件。去掉开发模式的代码,其实函数不多。

export function memo<Props>(
  type: React$ElementType,
  compare?: (oldProps: Props, newProps: Props) => boolean,
) {
// ...
  const elementType = {
    $$typeof: REACT_MEMO_TYPE,
    type,
    compare: compare === undefined ? null : compare,
  };
  // ...
  return elementType;
}

再以 SimpleMemoComponent 为例。

function updateSimpleMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes,
): null | Fiber {
  if (current !== null) {
    const prevProps = current.memoizedProps;
    if (
      shallowEqual(prevProps, nextProps) &&
      current.ref === workInProgress.ref &&
      // Prevent bailout if the implementation changed due to hot reload.
      (__DEV__ ? workInProgress.type === current.type : true)
    ) {
      didReceiveUpdate = false;

      workInProgress.pendingProps = nextProps = prevProps;

      if (!checkScheduledUpdateOrContext(current, renderLanes)) {
        workInProgress.lanes = current.lanes;
        return bailoutOnAlreadyFinishedWork(
          current,
          workInProgress,
          renderLanes,
        );
      } else if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
        didReceiveUpdate = true;
      }
    }
  }
  return updateFunctionComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes,
  );
}
  • 命中缓存时:节省时间是 updateFunctionComponent - (shallowEqual + checkScheduledUpdateOrContext + bailoutOnAlreadyFinishedWork + k)。 其中,shallowEqual 时间复杂度是 O(props 数量),checkScheduledUpdateOrContext 时间复杂度是 O(依赖 context 数量)。
  • 未命中缓存时:节省时间是 -(shallowEqual + checkScheduledUpdateOrContext)

updateFunctionComponent 和 bailoutOnAlreadyFinishedWork 的差异,实在没有那么多精力分析源码。问了下 AI,updateFunctionComponent: O(组件复杂度 × 子组件数量 × diff算法复杂度),bailoutOnAlreadyFinishedWork: O(子组件数量),代码行数大概多了几百到一千多行。

假设命中缓存概率为 x,节省时间的数学期望为:x(updateFunctionComponent - bailoutOnAlreadyFinishedWork - k) - (shallowEqual + checkScheduledUpdateOrContext),k 的量级可以忽略,约等于 x(updateFunctionComponent - bailoutOnAlreadyFinishedWork) - (shallowEqual + checkScheduledUpdateOrContext)。

这样看来,只要不是命中缓存概率过低(10% 以内)或者 compare 复杂度过高的话,没理由不上 memo 啊。哪位大佬能解释下?

小结

  1. 只要不是命中缓存概率过低(10% 以内),无脑 memo(暂时还没用过 compare 复杂度较高或依赖 context 数量较高的场景,通常加起来顶天了就 30 个)

  2. 当满足以下两个条件时,无需重新创建组件:

    • compare 比较 props 不变(没传 compare 函数默认为 shallowEqual)
    • 组件自身 state 和依赖 context 未更新

useCallback、useMemo

// packages/react/src/ReactHooks.js
export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

export function useMemo<T>(
  create: () => T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useMemo(create, deps);
}
// packages/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount: Dispatcher = {
...
  useCallback: mountCallback,
  useMemo: mountMemo,
...
};

const HooksDispatcherOnUpdate: Dispatcher = {
...
  useCallback: updateCallback,
  useMemo: updateMemo,
...
};

const HooksDispatcherOnRerender: Dispatcher = {
...
  useCallback: updateCallback,
  useMemo: updateMemo,
...
};

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  if (shouldDoubleInvokeUserFnsInHooksDEV) {
    setIsStrictModeForDevtools(true);
    try {
      nextCreate();
    } finally {
      setIsStrictModeForDevtools(false);
    }
  }
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  // Assume these are defined. If they're not, areHookInputsEqual will warn.
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  const nextValue = nextCreate();
  if (shouldDoubleInvokeUserFnsInHooksDEV) {
    setIsStrictModeForDevtools(true);
    try {
      nextCreate();
    } finally {
      setIsStrictModeForDevtools(false);
    }
  }
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

可以看到 useCallback 和 useMemo 的区别仅在于:

  • useCallback 将第一个参数直接返回,而 useMemo 返回第一个参数的执行结果
  • useMemo 在严格模式下会执行两次 nextCreate

useCallback

在官方文档中,说 useCallback 不会阻止创建函数。

Note that useCallback does not prevent creating the function. You’re always creating a function (and that’s fine!), but React ignores it and gives you back a cached function if nothing changed.

要彻底搞清楚这句话,需要理解 react 的渲染流程。如果暂时以性能优化为目标,可以先跳过这部分。我们只需要知道 react 组件渲染时,组件函数会重新调用。

function ProductPage({ productId, referrer, theme }) {
  // 在多次渲染中缓存函数
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]); // 只要这些依赖没有改变

  return (
    <div className={theme}>
      {/* ShippingForm 就会收到同样的 props 并且跳过重新渲染 */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

假设有上面这样一个组件。每次渲染时,都会调用 ProductPage(…),callback 自然也重新创建了一次。不过当 deps 没变时,会返回 hook.memoizedState 中存储的上一次创建的 callback。所以如果 callback 不是其他 hooks 的依赖或作为 props 传给 memo 组件时,使用 useCallback 是负优化的。

useMemo

如何衡量计算过程的开销是否昂贵?

In general, unless you’re creating or looping over thousands of objects, it’s probably not expensive.

文档用到的说法叫循环 thousands of objects,这类说法就很恶心,特别遇到 ld 要定量分析的时候。

只看 updateMemo 的话,命中缓存和不使用 useMemo 的代码区别主要是:

  • 命中缓存时:运行代码多出 updateWorkInProgressHook + areHookInputsEqual - nextCreate
  • 未命中缓存时:运行代码多出 updateWorkInProgressHook + areHookInputsEqual
function updateWorkInProgressHook(): Hook {
  // This function is used both for updates and for re-renders triggered by a
  // render phase update. It assumes there is either a current hook we can
  // clone, or a work-in-progress hook from a previous render pass that we can
  // use as a base.
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.

    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      if (currentFiber === null) {
        // This is the initial render. This branch is reached when the component
        // suspends, resumes, then renders an additional hook.
        // Should never be reached because we should switch to the mount dispatcher first.
        throw new Error(
          'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
        );
      } else {
        // This is an update. We should always have a current hook.
        throw new Error('Rendered more hooks than during the previous render.');
      }
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

areHookInputsEqual 的时间复杂度是 O(n) ,updateWorkInProgressHook 的运行行数为 50 以内。假设命中缓存概率为 x,nextCreate 运行行数为 y,则使用 useMemo 的运行行数的数学期望是 x(50+n-y) + (1-x)(50+n) = 50+n−xy,即:命中缓存概率 * nextCreate 运行行数 > 50 + 依赖数,即可考虑使用 useMemo,就算考虑 mountMemo 初始化的损耗,命中缓存概率 * nextCreate 运行行数在50~100行往上就可以了,根本不需要像文档说的一样要到 thousands of。(可能有别的地方没有考虑到,欢迎斧正)

小结

  1. 如果 callback 不是其他 hooks 的依赖或作为 props 传给 memo 组件时,使用 useCallback 是负优化的
  2. 命中缓存概率 * nextCreate 运行行数在50~100行往上就可以考虑使用 useMemo

React vs Vue:两种前端架构哲学的深度解析

作者 小凡同志
2026年3月20日 23:12

React vs Vue:两种前端架构哲学的深度解析

2026年了,React Compiler 已经稳定可用,Vue Vapor Mode 也在 Vue 3.6 中正式亮相。这两个框架的底层逻辑到底有什么不同?

前言:从手动操作到声明式编程

十年前我们还在用 jQuery 手动操作 DOM。

$('#btn').click() 写多了,代码就像意大利面条。后来 Angular 带来了 MVC,React 带来了 Virtual DOM,Vue 把响应式做到了极致。

现在回头看,React 和 Vue 其实代表了两种完全不同的架构思路。理解它们的分歧点,比纠结"哪个更好"更有价值。

一、核心理念:显式控制 vs 自动追踪

React 的哲学:给你控制权

React 的设计理念很简单:开发者知道什么时候该更新

function Counter() {
  const [count, setCount] = useState(0);

  // 你必须显式调用 setCount
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

React 的渲染是"全量"的。每次状态变化,组件函数重新执行,返回新的 JSX。React 再对比新旧 Virtual DOM,算出最小变更。

这种方式的好处是可预测。你写的代码就是执行的逻辑,没有黑魔法。

坏处也明显:优化负担在开发者身上。useMemouseCallbackReact.memo 缺一不可,稍不注意就重复渲染。

Vue 的哲学:我帮你追踪

Vue 的想法相反:框架比开发者更清楚依赖关系

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

Vue 在编译阶段就分析好了模板中的依赖。count 变化时,框架自动定位到具体 DOM 节点,直接更新。

不需要你记一堆优化规则。响应式系统帮你搞定。

关键分歧

维度 React Vue
更新粒度 组件级 细粒度(变量级)
优化责任 开发者 框架
心智模型 显式控制 自动追踪
代码风格 函数式 声明式

二、响应式机制:Pull vs Push

这两个词听起来很抽象,但本质是怎么知道"数据变了"。

React:Pull 模型

React 是 Pull。它不会监听数据变化,而是在渲染时拉取最新值

function User({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]); // 依赖数组靠人工维护

  return <div>{user?.name}</div>;
}

这里有个坑:如果忘了写 [userId],就拿到旧数据。如果写了 [user],就无限循环。

React 的依赖数组是信任开发者。你说没依赖,它就信。

React Compiler 在 2025 年底已经稳定发布。Compiler 能自动推导依赖,不用再手写 useMemo/useCallback

Vue:Push 模型

Vue 是 Push。数据变化主动推送给订阅者。

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

const userId = ref(1)
const user = ref(null)

// 自动追踪 userId 的依赖
watchEffect(async () => {
  user.value = await fetchUser(userId.value)
})
</script>

watchEffect 会自动收集用到的响应式变量。userId 一变,回调重新执行。

不需要依赖数组。框架帮你追踪。

源码层面的差异

React 的依赖检测在运行时。每次渲染对比上一次的状态。

Vue 的依赖检测在编译时 + 运行时。编译阶段标记响应式变量,运行时通过 Proxy 拦截访问和修改。

// Vue 响应式简化实现
function ref(value) {
  const dep = new Set();

  return new Proxy({ value }, {
    get(target, key) {
      // 收集当前活跃的 effect
      if (activeEffect) dep.add(activeEffect);
      return target[key];
    },
    set(target, key, newVal) {
      target[key] = newVal;
      // 通知所有订阅者
      dep.forEach(effect => effect());
      return true;
    }
  });
}

这套机制让 Vue 能做到精准更新。只有真正用到的数据变了,才会触发更新。

三、2026年的新变量:编译时优化

过去一年,两个框架的编译时优化都有了实质性进展。

React Compiler:自动 memoization

2025年10月,React Compiler 1.0 正式发布。现在是 2026 年,它已经经过了生产环境的验证。

它是个 Babel 插件,编译阶段分析你的代码,自动插入 memoization。不用再手写 useMemo/useCallback

// 以前
function List({ items }) {
  const sorted = useMemo(() =>
    items.sort((a, b) => b.score - a.score),
    [items]
  );
  return <div>{sorted.map(...)}</div>;
}

// 有了 Compiler,直接写
function List({ items }) {
  const sorted = items.sort((a, b) => b.score - a.score);
  return <div>{sorted.map(...)}</div>;
}

Compiler 会把 sorted 编译成条件性 memoized 值。只有 items 真的变了,才重新计算。

实测效果

  • Meta Quest Store:某些交互快 2.5 倍
  • Sanity Studio:渲染时间减少 20-30%
  • Wakelet:LCP 提升 10%,INP 提升 15%

这解决了 React 最大的痛点:优化负担太重。

Vue Vapor Mode:干掉 Virtual DOM

Vue 的回应是 Vapor Mode。2025 年底它在 Vue 3.6 中作为实验性功能发布,现在(2026年3月)已经可以尝试使用。

Vapor Mode 的思路很激进:编译时直接生成 DOM 操作代码,跳过 Virtual DOM

<template>
  <div>{{ count }}</div>
  <button @click="count++">+</button>
</template>

传统 Vue:编译成 Virtual DOM → 运行时 diff → patch DOM

Vapor Mode:编译成直接的 DOM 操作代码

// Vapor Mode 编译结果示意
let _div, _btn;
export function render(_ctx) {
  if (!_div) {
    _div = document.createElement('div');
    _btn = document.createElement('button');
    _btn.onclick = () => _ctx.count++;
  }
  _div.textContent = _ctx.count;
  return [_div, _btn];
}

没有 diff,没有 patch,直接操作 DOM。

性能数据

  • 能在 100ms 内挂载 10 万个组件
  • 目标是匹配 Solid.js 的渲染效率

Vapor Mode 支持混合模式:可以和现有 Virtual DOM 组件共存。你可以只对性能敏感的部分启用 Vapor Mode。

对比总结

特性 React Compiler Vue Vapor Mode
发布状态 2025.10 稳定版 2025 实验性,2026 可用
优化阶段 编译时 编译时
优化方式 自动 memoization 消除 Virtual DOM
向后兼容 React 17+ Vue 3 混合模式
限制 需遵循 React 规则 仅支持 Composition API

四、性能数据:到底谁快?

2024-2025 年的 benchmark 数据:

DOM 操作

Vue 在 DOM 操作任务上比 React 快 36%(几何平均 1.02 vs React)。

初始渲染

  • Vue:中小型 SPA 首屏略快
  • React:大型数据密集型应用扩展性更好

包体积

  • Vue:31KB(gzip)/ 84KB(未压缩)
  • React:32.5KB(gzip)/ 101KB(未压缩)

差距不大,都能接受。

Core Web Vitals

  • Vue:FCP(首次内容绘制)更好
  • React:复杂交互场景表现更优

一个关键结论

性能不是主要差异点。两个框架都足够快。

真正的区别是优化方式

  • React:手动优化 → React Compiler 自动优化
  • Vue:自动优化 → Vapor Mode 极致优化

五、怎么选?

没有标准答案,但有几个参考维度。

选 React,如果你:

  • 团队偏好函数式编程
  • 需要丰富的第三方生态(React 的 npm 包更多)
  • 做复杂交互应用(仪表盘、可视化)
  • 已经投入 Next.js 生态

选 Vue,如果你:

  • 想要开箱即用的体验
  • 团队有后端转前端的成员(模板语法更友好)
  • 需要渐进式迁移(可以先在一个页面用 Vue)
  • 重视性能且不想手动优化

2026年的现状

React Compiler 已经在生产环境证明了价值,优化负担不再是 React 的短板。Vue Vapor Mode 让 Vue 性能更进一步,两者差距在缩小。

两个框架都在进化,差距在缩小。

写在最后

架构选择没有银弹。

React 给你控制权,代价是多写代码。Vue 帮你省代码,代价是接受框架的约束。

2026年,这两条路线已经收敛:React 变得更智能,Vue 变得更高效。

你现在的选择,不会让你后悔。重要的是深入理解你选的框架,而不是来回横跳。

毕竟,用户不关心你用什么框架。他们只关心产品好不好用。


参考来源

文中性能数据来自 2024-2025 年公开 benchmark 报告。

Electron 太胖了?试试 Electrobun,12MB 打包一个 AI 桌面助手

作者 ofox
2026年3月20日 21:05

前两天刷掘金热榜看到 Electrobun 这个名字,第一反应是——又一个 Electron 替代品?Tauri 不是已经卷过一轮了吗?

但是当我看到打包体积 12MB 的时候,还是没忍住试了一下。结果一个下午就撸出了一个能用的 AI 聊天桌面助手,打包完一看体积,确实有点离谱。

先说结论

对比项 Electron Tauri Electrobun
运行时 Node.js + Chromium Rust + 系统 WebView Bun + 系统 WebView
开发语言 JS/TS Rust + JS/TS 纯 TypeScript
Hello World 包体积 ~270MB ~8MB ~12MB
冷启动速度 很快
学习成本 高(要会 Rust)
生态成熟度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐(刚 v1)

Tauri 体积更小,但你得写 Rust。Electrobun 的卖点就是:纯 TypeScript 全栈,不用学新语言,体积还能压到 12MB 级别。

为什么不用 Electron?

不是黑 Electron,我之前的几个小工具都是 Electron 写的。但问题真的很实际:

  1. 一个 Hello World 就 270MB,用户下载要等半天
  2. 每个 Electron 应用都自带一个 Chromium,开 3 个 Electron 应用等于开了 3 个 Chrome
  3. 内存占用,随便一个小工具就吃 200MB+ 内存

Tauri 解决了体积和性能问题,但代价是你得写 Rust。对于纯前端来说,这个门槛确实不低。

Electrobun 的思路是:用 Bun 替代 Node.js 做主进程(Bun 本身就比 Node 快),渲染层用操作系统自带的 WebView(macOS 用 WebKit,Windows 用 Edge WebView2),不捆绑浏览器引擎。

上手:5 分钟跑起来

前置条件:装好 Bun(没装的话 curl -fsSL https://bun.sh/install | bash)。

# 创建项目
bunx electrobun init my-ai-chat
cd my-ai-chat

# 装依赖
bun install

# 跑起来
bun run dev

跑完你会看到一个原生窗口弹出来,里面是一个简单的欢迎页面。整个过程不到 1 分钟。

项目结构长这样:

my-ai-chat/
├── src/
│   ├── main.ts              # 主进程(Bun 环境)
│   └── renderer/
│       ├── index.html        # 页面
│       ├── style.css         # 样式
│       └── script.ts         # 前端逻辑
├── electrobun.config.ts      # 构建配置
└── package.json

和 Electron 的结构很像,main.ts 对应 Electron 的 main.jsrenderer/ 对应渲染进程。

改造成 AI 聊天助手

我的目标:做一个桌面版的 AI 聊天工具,支持多模型切换(GPT-4o、Claude、DeepSeek 等),流式输出。

主进程:创建窗口 + IPC

// src/main.ts
import { BrowserWindow } from "electrobun/bun";

const win = new BrowserWindow({
  title: "AI Chat Desktop",
  width: 800,
  height: 600,
  url: "electrobun://renderer/index.html",
});

// 监听渲染进程发来的消息
win.webview.onMessage("chat-request", async (data) => {
  const { model, messages } = data;

  try {
    // 调用 AI API,流式返回
    const response = await fetch("https://api.ofox.ai/v1/chat/completions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.OFOX_API_KEY}`,
      },
      body: JSON.stringify({
        model: model,
        messages: messages,
        stream: true,
      }),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    while (reader) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value);
      const lines = chunk.split("\n").filter((line) => line.startsWith("data:"));

      for (const line of lines) {
        const json = line.slice(5).trim();
        if (json === "[DONE]") continue;

        try {
          const parsed = JSON.parse(json);
          const content = parsed.choices?.[0]?.delta?.content;
          if (content) {
            // 实时推送到渲染进程
            win.webview.sendMessage("chat-stream", { content });
          }
        } catch {}
      }
    }

    win.webview.sendMessage("chat-done", {});
  } catch (err) {
    win.webview.sendMessage("chat-error", { error: String(err) });
  }
});

这里有个细节:Electrobun 的 IPC 通信用的是 onMessage / sendMessage,比 Electron 的 ipcMain / ipcRenderer 简洁不少。不需要单独写 preload 脚本。

渲染进程:聊天界面

<!-- src/renderer/index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>AI Chat</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <div id="app">
    <div class="header">
      <select id="model-select">
        <option value="gpt-4o">GPT-4o</option>
        <option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
        <option value="deepseek-chat">DeepSeek V3</option>
        <option value="qwen-plus">Qwen Plus</option>
      </select>
    </div>
    <div id="messages" class="messages"></div>
    <div class="input-area">
      <textarea id="input" placeholder="输入消息..." rows="3"></textarea>
      <button id="send-btn">发送</button>
    </div>
  </div>
  <script src="./script.ts"></script>
</body>
</html>
// src/renderer/script.ts
import { webview } from "electrobun/webview";

const messagesDiv = document.getElementById("messages")!;
const input = document.getElementById("input") as HTMLTextAreaElement;
const sendBtn = document.getElementById("send-btn")!;
const modelSelect = document.getElementById("model-select") as HTMLSelectElement;

let chatHistory: Array<{ role: string; content: string }> = [];
let currentAssistantMsg: HTMLDivElement | null = null;

function addMessage(role: string, content: string) {
  const div = document.createElement("div");
  div.className = `message ${role}`;
  div.textContent = content;
  messagesDiv.appendChild(div);
  messagesDiv.scrollTop = messagesDiv.scrollHeight;
  return div;
}

sendBtn.addEventListener("click", () => {
  const text = input.value.trim();
  if (!text) return;

  // 显示用户消息
  addMessage("user", text);
  chatHistory.push({ role: "user", content: text });

  // 创建助手消息占位
  currentAssistantMsg = addMessage("assistant", "") as HTMLDivElement;

  // 发送到主进程
  webview.sendMessage("chat-request", {
    model: modelSelect.value,
    messages: chatHistory,
  });

  input.value = "";
  sendBtn.disabled = true;
});

// 接收流式响应
webview.onMessage("chat-stream", (data) => {
  if (currentAssistantMsg) {
    currentAssistantMsg.textContent += data.content;
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
  }
});

webview.onMessage("chat-done", () => {
  if (currentAssistantMsg) {
    chatHistory.push({
      role: "assistant",
      content: currentAssistantMsg.textContent || "",
    });
  }
  currentAssistantMsg = null;
  sendBtn.disabled = false;
});

// Ctrl+Enter 发送
input.addEventListener("keydown", (e) => {
  if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
    sendBtn.click();
  }
});

样式(简洁暗色主题)

/* src/renderer/style.css */
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: #1a1a2e;
  color: #eee;
  height: 100vh;
}

#app {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.header {
  padding: 12px 16px;
  background: #16213e;
  border-bottom: 1px solid #333;
}

.header select {
  background: #0f3460;
  color: #eee;
  border: 1px solid #444;
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 14px;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}

.message {
  margin-bottom: 12px;
  padding: 10px 14px;
  border-radius: 10px;
  max-width: 80%;
  line-height: 1.6;
  white-space: pre-wrap;
}

.message.user {
  background: #0f3460;
  margin-left: auto;
}

.message.assistant {
  background: #1a1a3e;
  border: 1px solid #333;
}

.input-area {
  display: flex;
  gap: 8px;
  padding: 12px 16px;
  background: #16213e;
  border-top: 1px solid #333;
}

.input-area textarea {
  flex: 1;
  background: #0f3460;
  color: #eee;
  border: 1px solid #444;
  border-radius: 8px;
  padding: 10px;
  font-size: 14px;
  resize: none;
}

.input-area button {
  background: #e94560;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
}

.input-area button:hover { background: #c81e45; }
.input-area button:disabled { opacity: 0.5; cursor: not-allowed; }

打包体验

bun run build

打包完成后:

dist/
└── AI Chat Desktop.app   # macOS
    └── (总大小: ~64MB)

等等,说好的 12MB 呢?

这里要解释一下:12MB 是 Electrobun 框架本身的开销,加上你的业务代码和依赖。我的项目因为没有额外的 npm 包(AI 调用用的是原生 fetch),实际打包大约 64MB,主要是 Bun runtime 占了大头。

作为对比,同样功能的 Electron 版本打包后 310MB。差了将近 5 倍。

而且 Electrobun 有个杀手锏:差分更新。它内置了增量更新机制,版本迭代时只推送差异补丁,补丁大小可以小到 14KB。Electron 每次更新基本要重新下载整个 Chromium。

踩坑记录

1. Bun 版本兼容

Electrobun v1 要求 Bun >= 1.2。我一开始用的 1.1.x,bunx electrobun init 直接报了一堆类型错误。升级 Bun 后就好了:

bun upgrade

2. 环境变量加载

Bun 主进程不会自动读 .env 文件。需要手动加载:

// main.ts 顶部
import { $ } from "bun";
// 或者直接在 electrobun.config.ts 里配 env

我最后的方案是在 electrobun.config.tsenv 字段里写死(开发时),打包时从系统环境变量读取。

3. WebView 兼容性

macOS 上用的是 WebKit,不是 Chromium。这意味着一些 Chrome 特有的 API 不能用。我一开始用了 structuredClone 在 IPC 里传数据,结果在某些 macOS 版本上挂了。改成 JSON.parse(JSON.stringify(...)) 就没问题了。

4. 流式响应的坑

Bun 的 fetch 对 SSE 流式响应的支持和 Node.js 有点不一样。response.body 返回的是 ReadableStream,需要用 getReader() 来读,不能直接 for await...of。这个搞了我半小时。

值不值得用?

说实话,Electrobun 目前还是 v1 早期阶段,生态和 Electron 没法比。但如果你的场景是:

  • 轻量级工具类应用(不需要复杂原生功能)
  • 对包体积敏感(给客户分发不想让人等半天)
  • 团队全是前端,不想碰 Rust
  • 想尝鲜 Bun 生态

那完全值得试试。

我这个 AI 聊天桌面助手的完整流程:从 bunx electrobun init 到打包出可用的 .app,大概 3 小时(包括踩坑时间)。体验下来比第一次用 Electron 顺畅不少,至少不用折腾 webpack 配置和 preload 脚本。

API 层我用的是兼容 OpenAI 协议的聚合接口,改个 base_url 就能切不同模型,省得每个模型单独对接 SDK。如果你也想做类似的多模型桌面工具,这个思路可以参考。

完整代码我后续整理后会放 GitHub,有兴趣的可以先 mark 一下。

用 three.js 实现 3D 地图

作者 codingWhat
2026年3月20日 20:56

之前写过一篇文章介绍了使用ECharts GL实现立体地图 => 用 ECharts GL 把地图「立」起来,那如果ECharts GL满足不了需求,就可以考虑使用Three.js啦。

Three.js

three.js 是一个在浏览器里把 3D 画出来的图形库。你可以把它想成搭舞台,那咋把舞台搭起来呢?

  1. Scene:搭舞台的“背景板”(灯光、模型、网格)。
  2. Camera:决定你从哪个方向看(透视相机更像“人眼”视角,正交相机更像“测绘”视角)。
  3. Renderer:负责“出画面”(每一帧把 scene + camera 渲染到画布)。

看一眼最小骨架,后面不迷路:

import * as THREE from "three"

const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
camera.position.set(0, 0, 10)

const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(width, height)
container.appendChild(renderer.domElement)

function animate() {
  requestAnimationFrame(animate)
  renderer.render(scene, camera)
}
animate()

理解了这三件事,你再把“地区轮廓”转换成 3D 网格,就等于把数据变成了可以被光照和相机看到的模型。

实现流程

  1. 读取 GeoJSON:先把 Polygon 统一成和 MultiPolygon 一样的数据形态;
  2. 遍历每个 polygon ring:把点序列写进 THREE.ShapemoveTo/lineTo
  3. ExtrudeGeometry 把二维轮廓“拉起来”成三维实体(depth 决定高度,bevel 决定边缘质感)
  4. 给几何体分配材质组合:上表面更亮、侧边更有层次,立体感才会成立
  5. Box3.expandByObject() 算中心与尺寸:一键对齐相机,让视角永远落在地图上
  6. 初始化 CSS2DRenderer:在动画循环里把 WebGL 和 2D 标签叠加起来,再驱动光柱/粒子等小动效

1) 统一 GeoJSON:让 PolygonMultiPolygon 数据结构一致

很多 GeoJSON 在实际项目里会“有时是 Polygon,有时是 MultiPolygon”。如果你写死一套遍历逻辑,就会出现:某些地区根本没被绘制出来,或者逻辑分支越写越乱。

我是这样实现的:如果几何类型是 Polygon,就把 coordinates 包一层,让它变成 MultiPolygon 风格的二维数组。这样后续只写一套循环就够了。

// 统一 GeoJSON(关键点:Polygon -> MultiPolygon-like)
export default function useConversionStandardData() {
  const transfromGeoJSON = (worldData) => {
    const features = worldData.features
    for (let i = 0; i < features.length; i++) {
      const element = features[i]
      if (element.geometry.type === "Polygon") {
        // Polygon: [ [ [x,y], ... ] ]
        // 包一层 -> MultiPolygon-like: [ [ [x,y], ... ] ]
        element.geometry.coordinates = [element.geometry.coordinates]
      }
    }
    return worldData
  }

  return { transfromGeoJSON }
}

数据与坐标:先想清楚你画的是“平面”还是“球面”

THREE.Shape 本质上只认“二维平面坐标”。所以第一件事不是写代码,而是先确认:你的 GeoJSON 点 (x, y) 在你的 Three.js 世界里,究竟应该落到哪里。

  • 如果你的数据已经是“平面化坐标”(例如直接用 GeoJSON 的 (x, y) 去描轮廓),那就可以直接 Shape -> ExtrudeGeometry,不用经纬度转换。
  • 如果你的数据是经纬度 (lon, lat),你就必须先做坐标转换。路线是:用球面映射把点投到球面上,再用四元数让面朝向球面法线。

2) 核心:从轮廓到 3D 面(Shape + ExtrudeGeometry)

这一段就是“把平面地图变成立体模型”的关键啦:只要你理解了它,后面的居中、标签、动效就都只是加配件。

在 Three.js 里:

  • THREE.Shape 负责把轮廓“描一遍”(moveTo/lineTo
  • THREE.ExtrudeGeometry 负责把描好的轮廓“拉高变厚”(depth/bevel 等参数决定你要多立体)

我们可以把GeoJSON 的 ring 逐点喂给 Shape,再一口气拉伸成网格。注意:Mesh 的材质传数组是为了“上表面更亮、侧边更有阴影感”。

import * as THREE from "three"

const extrudeSettings = { depth: 0.2, bevelEnabled: true, bevelSegments: 1, bevelThickness: 0.1 }

function buildRegion3D({ geoJson, topFaceMaterial, sideMaterial }) {
  const mapGroup = new THREE.Group()

  geoJson.features.forEach((feature) => {
    const province = new THREE.Object3D()
    const coordinates = feature.geometry.coordinates

    coordinates.forEach((multiPolygon) => {
      multiPolygon.forEach((polygon) => {
        const shape = new THREE.Shape()
        polygon.forEach(([x, y], i) => (i === 0 ? shape.moveTo(x, y) : shape.lineTo(x, y)))

        const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings)
        province.add(new THREE.Mesh(geometry, [topFaceMaterial, sideMaterial]))
      })
    })

    mapGroup.add(province)
  })

  return mapGroup
}

3) 自动居中

你也不想每换一份 GeoJSON 就手动调相机坐标,对吧?那就用包围盒做“自动聚焦”。

Box3().expandByObject() 会把整个地图包起来,你拿到中心点以后就可以:让相机 lookAt 它,控制器 target 也跟着指向它。

import * as THREE from "three"

function initCameraTargetByBox({ group, camera, controls }) {
  const box3 = new THREE.Box3().expandByObject(group)
  const center = new THREE.Vector3()
  box3.getCenter(center)

  camera.lookAt(center.x, center.y, 0)
  if (controls?.target) controls.target = new THREE.Vector3(center.x, center.y, 0)
}

效果会立刻变“稳定”:换地区数据也能落在视野正中。


4) 标签(CSS2D)与光柱

做到“有形”还不够,得让人看得懂。标签负责告诉你“这是什么”,光柱负责把注意力“引到那里”。

标签(CSS2DRenderer)

这里我没有做3D字体几何体(太重也太麻烦),而是用 HTML div 作为“贴纸”。CSS2DObject 让它能跟随相机正确投影。

import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer"

const create2DTag = (html, className = "") => {
  const div = document.createElement("div")
  div.innerHTML = html
  div.className = className
  div.style.pointerEvents = "none"
  div.style.visibility = "hidden"

  const label = new CSS2DObject(div)
  label.show = (text, point) => {
    label.element.innerHTML = text
    label.element.style.visibility = "visible"
    label.position.copy(point)
  }
  label.hide = () => { label.element.style.visibility = "hidden" }
  return label
}

渲染时只要每帧调用一次 css2dRender.render(scene, camera),标签就会“自动跟镜头走”。

光柱

光柱的漂亮之处在于“看起来更立体”,实现方案:两张切图交叉,再配合透明渲染参数避免穿帮。

import * as THREE from "three"

// textureLoader / 纹理 url 在外层准备
const createLightPillar = (lon, lat, height, textureUrl, color = 0x00ffff) => {
  const group = new THREE.Group()
  const geometry = new THREE.PlaneBufferGeometry(height / 6.219, height)
  geometry.rotateX(Math.PI / 2)
  geometry.translate(0, 0, height / 2)

  const material = new THREE.MeshBasicMaterial({
    map: textureLoader.load(textureUrl),
    color,
    transparent: true,
    depthWrite: false,
    side: THREE.DoubleSide,
  })

  const a = new THREE.Mesh(geometry, material)
  const b = a.clone()
  b.rotateZ(Math.PI / 2)

  group.add(a, b)
  group.position.set(lon, lat, 0)
  return group
}

地图面生成后把光柱加进 mapGroup 就行

const light = createLightPillar(...lightCenter, heightScaleFactor, lightPillarTextureUrl)
light.position.z = 0.31
mapGroup.add(light)

5) 让动画活起来

你不需要把每一帧都烧到极致,但需要让“画面在动”,让它不显得生硬:

  • 背景光圈缓慢旋转
  • 粒子沿 z 轴上升再重置
  • 2D 标签每帧由 CSS2DRenderer 重新渲染

核心循环就四件小事:WebGL 渲染、2D 标签叠加、粒子/旋转等状态更新、以及 TWEEN.update() 推进动画:

loop() {
  requestAnimationFrame(() => this.loop())
  this.renderer.render(this.scene, this.camera)
  if (this.rotatingApertureMesh) this.rotatingApertureMesh.rotation.z += 0.0005
  if (this.css2dRender) this.css2dRender.render(this.scene, this.camera)
  for (const p of this.particleArr || []) {
    p.updateSequenceFrame()
    p.position.z += 0.01
    if (p.position.z >= 6) p.position.z = -6
  }
  TWEEN.update()
}

踩过的坑分享给大家,少走些弯路

  1. 坐标系不匹配THREE.Shape 只认平面坐标。你的 GeoJSON 如果和 Three.js 的绘制坐标不一致,就会出现“地图飞走了”的尴尬,需要先做投影/坐标转换。
  2. 空洞(holes)处理:把 ring 直接塞进 Shape,没有显式处理 shape.holes。一旦你的 GeoJSON 带内环(岛/湖泊/凹洞),不处理 holes 就会“该挖的地方没挖开”。
  3. bevel 参数太大:倒角太厚会让面数暴涨,性能变差。一般从小 bevel 开始试,满足质感再加料。
  4. 数据点顺序:点序自交或乱序时,Shape 可能生成失败,或者“看起来像被折弯”。这类问题通常要先检查几何数据本身。

CSS子选择器与伪类:精准控制元素样式的利器

作者 bluceli
2026年3月20日 20:21

在日常的前端开发中,我们经常需要精准地选择和样式化特定的元素。CSS子选择器和伪类为我们提供了强大的工具,让我们能够以更精细的方式控制页面样式。本文将深入探讨这些选择器的使用技巧和最佳实践。

子选择器:精准定位子元素

子选择器(>)只选择直接子元素,而不包括后代元素。这种选择器在构建复杂的组件结构时特别有用。

/* 只选择.nav的直接子元素li */
.nav > li {
  padding: 10px 15px;
}

/* 选择.card的直接子元素.title */
.card > .title {
  font-size: 1.5rem;
  font-weight: bold;
}

子选择器的优势在于它能够避免样式意外应用到嵌套更深的元素上。例如,在导航菜单中,我们可能只想样式化顶级菜单项,而不影响下拉菜单中的项目。

伪类:动态选择元素

CSS伪类让我们能够根据元素的状态或位置来选择它们,这为交互式设计提供了无限可能。

:nth-child() 系列伪类

/* 选择每3个元素中的第2个 */
.item:nth-child(3n+2) {
  background-color: #f0f0f0;
}

/* 选择偶数个子元素 */
.list-item:nth-child(even) {
  margin-bottom: 10px;
}

/* 选择最后一个子元素 */
.container > :last-child {
  border-bottom: none;
}

:not() 伪类

:not()伪类让我们能够排除特定的元素,这在处理特殊情况时非常有用。

/* 选择所有不是.disabled的按钮 */
.button:not(.disabled) {
  cursor: pointer;
  background-color: #007bff;
}

/* 选择所有不是第一个子元素的项 */
.item:not(:first-child) {
  margin-top: 10px;
}

:empty 伪类

:empty伪类选择没有子元素的元素,这对于处理动态内容很有帮助。

/* 当容器为空时显示提示信息 */
.container:empty::before {
  content: "暂无数据";
  color: #999;
  padding: 20px;
}

实战应用案例

1. 响应式网格布局

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

/* 为每行的第一个元素添加特殊样式 */
.grid > :nth-child(4n+1) {
  border-left: 3px solid #007bff;
}

2. 表单验证样式

/* 必填字段 */
input:required {
  border-right: 3px solid #dc3545;
}

/* 有效字段 */
input:valid {
  border-color: #28a745;
}

/* 无效字段 */
input:invalid:not(:placeholder-shown) {
  border-color: #dc3545;
  background-color: #fff8f8;
}

3. 交互式列表

.menu-item {
  padding: 12px 16px;
  cursor: pointer;
  transition: all 0.3s ease;
}

/* 悬停状态 */
.menu-item:hover {
  background-color: #f8f9fa;
  transform: translateX(5px);
}

/* 激活状态 */
.menu-item:active {
  transform: scale(0.98);
}

/* 焦点状态 */
.menu-item:focus {
  outline: 2px solid #007bff;
  outline-offset: -2px;
}

性能优化建议

虽然CSS选择器很强大,但过度使用复杂的选择器会影响性能。以下是一些优化建议:

  1. 优先使用类选择器:类选择器的性能通常优于属性选择器和伪类选择器
  2. 避免过深的嵌套:保持选择器的简洁性
  3. 合理使用子选择器:只在确实需要区分直接子元素和后代元素时使用
  4. 利用CSS变量:减少重复的选择器规则
/* 好的做法 */
.card {
  --card-padding: 20px;
  --card-radius: 8px;
}

.card-header {
  padding: var(--card-padding);
  border-radius: var(--card-radius) var(--card-radius) 0 0;
}

/* 避免过度嵌套 */
.container .content .section .article .title {
  /* 这种选择器性能较差 */
}

浏览器兼容性

现代浏览器对大多数CSS选择器和伪类都有良好的支持。但在使用较新的特性时,仍需考虑兼容性问题:

/* 为不支持:has()的浏览器提供回退 */
.card:has(.featured) {
  border: 2px solid gold;
}

/* 回退方案 */
.card.featured {
  border: 2px solid gold;
}

总结

CSS子选择器和伪类是前端开发中不可或缺的工具。通过合理使用这些选择器,我们能够:

  • 精准控制元素的样式
  • 创建更丰富的交互效果
  • 提高代码的可维护性
  • 优化页面性能

在实际项目中,建议根据具体需求选择合适的选择器,并注意性能和兼容性的平衡。掌握这些技巧将让你的CSS代码更加优雅和高效。

开发环境优化完全指南:告别等待,让开发如丝般顺滑

作者 wuhen_n
2026年3月20日 09:33

前言

想象一下这个场景:

我们正在写一个复杂的组件,思路如泉涌。保存文件,想看看效果:5 秒... 10 秒... 30 秒...

等页面刷新出来的时候,我们已经忘了刚才在想什么。心流被打断,灵感消失,只能重新理清思路。

这不是技术问题,这是对开发者时间的浪费。

根据 Stack Overflow 2023 年的调查,前端开发者平均每天要等待 30 - 60 分钟用于构建和热更新。

好消息是:这些等待时间,大部分都可以被优化掉。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮你一步步把开发环境的等待时间从“喝杯咖啡”缩短到“眨个眼”。

为什么会慢?先找到问题在哪

# 早上9点,开始工作
$ npm run dev

# 等待... 30 秒后项目终于启动了
# 打开浏览器,还要等 10 秒才能看到页面

# 修改一个文件,保存
# 等待... 10 秒后热更新完成

# 一天下来:
# 启动次数:10次 × 30 秒 = 300秒
# 修改次数:100次 × 10 秒 = 1500秒
# 总等待时间:1500秒 = 25分钟

这还只是保守估计。在大项目中,等待时间可能是这个数字的 3-5 倍。

开发环境的性能瓶颈

开发环境的速度主要受四个因素影响:

  1. 依赖处理:扫描、预构建 node_modules
  2. 文件编译:转换 .vue.ts.scss 等文件
  3. 模块图维护:跟踪文件之间的依赖关系
  4. 网络传输:浏览器加载文件的速度

如何判断瓶颈在哪?

我们可以使用 Vite 的调试模式:

vite --debug

我们会看到类似这样的输出:

vite:deps 扫描依赖中... 245.3ms
vite:deps 找到 156 个依赖 245.3ms
vite:deps 预构建中... 3240.5ms  ← 这里最慢!
vite:server 服务器启动完成 3512.8ms

根据输出结果,我们就可以做出正确的决断:

  • 如果 预构建 时间最长 → 优化依赖预构建
  • 如果 转换文件 时间最长 → 优化文件编译
  • 如果 服务器启动 时间最长 → 优化配置

依赖预构建优化 - 80%的性能提升从这里开始

什么是依赖预构建?

想象我们要整理一个巨大的图书馆(node_modules):

  • 不预构建:每次有人要看书,都要现场整理那一本书
  • 预构建:提前把所有书整理好,有人要就直接拿

Vite 的预构建就是提前把第三方库整理成浏览器可以直接使用的格式。

为什么需要手动配置预构建?

Vite 默认会自动预构建,但它其实没有那么智能,以下场景,Vite 并不会预构件:

场景1:动态导入

if (user.isAdmin) {
  const Chart = await import('echarts')  // 不会被预构建!
}

场景2:Monorepo 本地包

import { Button } from '@company/ui'  // 不会被预构建!

场景3:深层依赖

import 'a'  // a 依赖 b,b 依赖 c  // c 可能不会被预构建! 

include 优化:告诉 Vite 需要预构建什么

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  
  optimizeDeps: {
    // ✅ 需要预构建的依赖
    include: [
      // 1. 体积大的库(减少请求数)
      'echarts',           // 原来可能有几百个文件,合并成一个
      'lodash-es',         // lodash-es 有 600+ 个文件!
      'ant-design-vue',    // UI 库通常都很大
      
      // 2. Monorepo 中的本地包
      '@company/ui',
      '@company/utils',
      '@company/hooks',
      
      // 3. 动态导入的库
      'monaco-editor',     // 只在需要时加载,但预构建后加载更快
      'xlsx',              // 导出功能可能不常用,但需要时希望快
      
      // 4. 有深层依赖的库
      'date-fns',          // 有很多子模块
      'lodash'             // 虽然不推荐,但如果用了就预构建
    ]
  }
})

exclude 优化:告诉 Vite 不需要预构建什么

// vite.config.js
export default defineConfig({
  optimizeDeps: {
    exclude: [
      // 1. 已经提供 ESM 格式的现代库
      'vue',           // Vue 本身已经优化好
      'vue-router',    // 不需要再打包
      'pinia',
      
      // 2. 很少用到的大库(按需加载更好)
      'pdfjs-dist',    // 只在查看 PDF 时用到
      'three',         // 只在 3D 页面用到
      
      // 3. 有特殊构建要求的库
      '@sentry/browser',  // 有自己的构建工具
      'firebase'          // 复杂的构建配置
    ]
  }
})

include 还是 exclude?一个流程看懂

遇到一个依赖 →
    ↓
是本地包(@company/xxx)? → 是 → include
    ↓否
是动态导入的? → 是 → include
    ↓否
体积 > 1MB? → 是 → include(除非很少用)
    ↓否
依赖深度 > 3层? → 是 → include
    ↓否
已提供 ESM 格式? → 是 → 可以 exclude
    ↓否
用默认行为

实战:如何找出需要 include 的依赖

// scripts/analyze-deps.js
import fs from 'fs'
import path from 'path'

// 分析 node_modules 中哪些包体积大
function findHeavyDeps() {
  const nodeModules = path.resolve('node_modules')
  const deps = fs.readdirSync(nodeModules)
    .filter(d => !d.startsWith('.'))
    .map(dep => {
      const pkgPath = path.join(nodeModules, dep)
      try {
        const stats = fs.statSync(pkgPath)
        return { name: dep, size: stats.size }
      } catch {
        return { name: dep, size: 0 }
      }
    })
    .sort((a, b) => b.size - a.size)
    .slice(0, 20)  // 前20个最大的
  
  console.log('体积最大的依赖:')
  deps.forEach(d => {
    console.log(`${d.name}: ${(d.size / 1024 / 1024).toFixed(2)}MB`)
  })
}

findHeavyDeps()

文件监听优化 - 让电脑知道该看哪

为什么需要优化文件监听?

Vite 默认会监听项目中的所有文件。在大型项目中,这可能会导致很多问题:

  • CPU 占用高:要监控几万个文件的变化
  • 内存占用大:要维护所有文件的状态
  • 更新慢:变化时要检查的文件太多

配置监听范围

// vite.config.js
export default defineConfig({
  server: {
    watch: {
      // ❌ 不要监听这些文件夹
      ignored: [
        '**/node_modules/**',  // 依赖包,不需要监听
        '**/dist/**',          // 构建输出,不需要监听
        '**/.git/**',          // git 目录
        '**/.idea/**',         // IDE 配置
        '**/.vscode/**',       // VSCode 配置
        '**/*.log',            // 日志文件
        '**/coverage/**',      // 测试覆盖率报告
        '**/tests/**',         // 测试文件(通常不需要热更新)
        '**/__tests__/**',     // 同上
        '**/__mocks__/**'      // Mock 文件
      ],
      
      // 只在需要的地方监听
      // 默认会监听整个项目,但我们可以更精确
      paths: [
        'src/**',              // 源代码
        'index.html',          // 入口文件
        'vite.config.js'       // 配置文件
      ]
    }
  }
})

热更新优化 - 从“等 5 秒”到“眨眼就好”

热更新为什么慢?

修改文件
    ↓
Vite 发现变化
    ↓
重新编译这个文件
    ↓
找出所有依赖这个文件的模块(可能很多!)
    ↓
重新编译所有受影响的模块
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求新模块
    ↓
执行更新

优化一:减少模块依赖范围

// 不好的做法:一个文件导入太多东西
// UserManagement.vue
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
import { useSettingsStore } from '@/stores/settings'
import UserList from './UserList.vue'
import UserForm from './UserForm.vue'
import UserFilters from './UserFilters.vue'
import UserStats from './UserStats.vue'
// ... 20 个 import

// ✅ 好的做法:按需加载,拆分组件
// UserManagement.vue
import { useUserStore } from '@/stores/user'  // 只导入需要的

// 其他组件通过异步加载
const UserList = defineAsyncComponent(() => import('./UserList.vue'))
const UserForm = defineAsyncComponent(() => import('./UserForm.vue'))
const UserFilters = defineAsyncComponent(() => import('./UserFilters.vue'))

优化二:定义热更新边界

// 在组件中明确告诉 Vite 如何处理更新
if (import.meta.hot) {
  // 1. 接受自身更新(默认行为)
  import.meta.hot.accept()
  
  // 2. 只接受某些依赖的更新
  import.meta.hot.accept(['./api.js', './utils.js'], (modules) => {
    console.log('API 或工具函数更新了')
    // 重新执行某些逻辑
  })
  
  // 3. 拒绝更新(某些模块不适合热更新)
  import.meta.hot.decline('./heavy-chart.js')
  
  // 4. 清理资源(更新前执行)
  import.meta.hot.dispose(() => {
    // 清理定时器、事件监听器等
    clearInterval(timer)
    window.removeEventListener('resize', handler)
  })
}

优化三:CSS 热更新优化

// vite.config.js
export default defineConfig({
  css: {
    // 开发时的 CSS 选项
    devSourcemap: false,  // 关闭 sourcemap,加快速度
    
    preprocessorOptions: {
      scss: {
        // 缓存编译结果
        implementation: 'sass',
        // 避免使用 fiber(会导致热更新慢)
        fiber: false,
        // 全局注入变量(只注入需要的)
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

优化四:使用更快的编译器

// vite.config.js
export default defineConfig({
  // 使用 esbuild 替代 tsc 进行 TypeScript 转译
  esbuild: {
    target: 'es2020',
    // 启用 esbuild 的 JSX 编译
    jsxFactory: 'h',
    jsxFragment: 'Fragment',
    // 排除不需要转译的文件
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules/
  },
  
  // 生产构建时才使用 TypeScript 检查
  plugins: [
    vue(),
    // 开发环境不检查类型,加快速度
    process.env.NODE_ENV === 'production' && tsChecker()
  ]
})

内存优化 - 让浏览器喘口气

为什么内存占用高?

内存占用主要来自:

  • 模块图:记录所有文件的依赖关系
  • 转换缓存:每个文件转换后的结果
  • sourcemap:调试用的映射信息
  • 浏览器缓存:编译后的代码

配置内存限制

// vite.config.js
export default defineConfig({
  server: {
    // 模块缓存限制
    moduleCache: {
      maxSize: 500  // 最多缓存 500 个模块
    },
    
    // 模块图清理间隔
    moduleGraph: {
      pruneInterval: 60000  // 每 60 秒清理一次未使用的模块
    }
  },
  
  // 开发环境关闭 sourcemap
  build: {
    sourcemap: false
  },
  
  // 限制处理的文件大小
  esbuild: {
    exclude: [/\.(png|jpe?g|gif|webp|mp4|webm|ogg|mp3|wav|flac|aac)$/]
  }
})

内存监控和自动清理

// 在 vite.config.js 中添加内存监控
export default defineConfig({
  plugins: [
    {
      name: 'memory-monitor',
      configureServer(server) {
        let timer = setInterval(() => {
          const used = process.memoryUsage().heapUsed / 1024 / 1024 / 1024
          
          if (used > 1.5) {  // 超过 1.5GB
            console.log(`🧹 内存使用 ${used.toFixed(2)}GB,正在清理...`)
            
            // 清理模块缓存
            server.moduleGraph.clear()
            
            // 强制垃圾回收(如果可用)
            if (global.gc) {
              global.gc()
            }
          }
        }, 60000)  // 每分钟检查一次
        
        // 服务器关闭时清理定时器
        server.httpServer?.on('close', () => {
          clearInterval(timer)
        })
      }
    }
  ]
})

一键优化配置模板

完整的优化配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { dependencies } from './package.json'

// 需要预构建的重型依赖
const heavyDeps = [
  'echarts',
  'ant-design-vue',
  'lodash-es',
  'xlsx',
  'monaco-editor',
  'd3',
  'three',
  '@company/ui',
  '@company/utils',
  '@company/charts'
]

// 不需要预构建的现代库
const esmDeps = ['vue', 'vue-router', 'pinia', 'vueuse']

export default defineConfig({
  plugins: [vue()],
  
  // 依赖优化
  optimizeDeps: {
    include: heavyDeps,
    exclude: esmDeps,
    // 使用 esbuild 加速
    esbuildOptions: {
      target: 'es2020',
      define: {
        'process.env.NODE_ENV': '"development"'
      }
    }
  },
  
  // 开发服务器配置
  server: {
    // 启用 HTTP/2 加速请求
    https: true,
    http2: true,
    
    // 文件监听优化
    watch: {
      ignored: [
        '**/node_modules/**',
        '**/dist/**',
        '**/.git/**',
        '**/.idea/**',
        '**/.vscode/**',
        '**/*.log',
        '**/coverage/**',
        '**/tests/**',
        '**/__tests__/**',
        '**/__mocks__/**'
      ]
    },
    
    // 内存优化
    moduleCache: {
      maxSize: 500
    },
    
    // 热更新优化
    hmr: {
      timeout: 5000,
      overlay: false  // 关闭错误覆盖,加快速度
    }
  },
  
  // 编译优化
  esbuild: {
    target: 'es2020',
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules|\.(png|jpe?g|gif|webp|mp4)$/,
    jsxFactory: 'h',
    jsxFragment: 'Fragment'
  },
  
  // CSS 优化
  css: {
    devSourcemap: false,
    preprocessorOptions: {
      scss: {
        implementation: 'sass',
        fiber: false,
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

NPM 脚本优化

{
  "scripts": {
    "dev": "vite",
    "dev:debug": "vite --debug",
    "dev:fresh": "rm -rf node_modules/.vite && vite",
    "dev:profile": "vite --profile",
    "build": "vite build",
    "preview": "vite preview",
    "analyze": "node scripts/analyze-deps.js"
  }
}

常见问题速查表

启动很慢

可能原因 解决方案
预构建太多 优化 include 配置
文件监听范围太大 配置 watch.ignored
依赖版本冲突 删除 node_modules 重装
磁盘 I/O 瓶颈 迁移到 SSD

热更新慢

可能原因 解决方案
模块图过大 拆分大组件
没有定义热更新边界 使用 import.meta.hot.accept()
CSS 编译慢 优化预处理器配置
浏览器卡顿 关闭不必要的扩展

内存占用高

可能原因 解决方案
缓存太多 限制 moduleCache.maxSize
没有垃圾回收 添加内存监控和清理
sourcemap 太大 关闭 devSourcemap
内存泄漏 检查插件和代码

优化检查清单

  • 使用 vite --debug 分析启动时间
  • 确认 include 包含所有重型依赖
  • 确认 exclude 排除了已优化的依赖
  • 优化文件监听范围
  • 拆分大文件为小组件
  • 使用虚拟列表处理长列表
  • 启用 HTTP/2
  • 监控内存使用
  • 配置合理的缓存策略

结语

记住:开发者的时间比机器的时间更宝贵。花一个小时优化开发环境,可能每天能为团队节省数小时的等待时间。这是性价比最高的投资之一。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

❌
❌