普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月10日掘金 前端

别让页面 “鬼畜跳”!Google 钦点的 3 个性能指标,治好了我 80% 的用户投诉

2025年12月9日 23:48

💥告别卡顿!前端性能优化第一课:Google钦点的三大核心指标,你真的懂吗?

欢迎来到前端性能优化专栏的第一课!在这个“用户体验至上”的时代,一个卡顿、缓慢、乱跳的网站,就像一辆抛锚在高速公路上的跑车,再酷炫也只会让人抓狂。别担心,Google已经为你准备好了一份“体检报告”——核心Web指标(Core Web Vitals)

今天,我们就来揭开这份报告的神秘面纱,用最通俗易懂的方式,让你彻底搞懂这三大指标,迈出性能优化的第一步!

✨ LCP(Largest Contentful Paint):最大内容绘制

🚀 衡量:加载性能(你的“第一印象”)

LCP,直译过来是最大内容绘制。它衡量的是用户在访问页面时,视口中最大的那块可见内容(图片或文本块)完成渲染所需的时间

简单来说,它回答了一个核心问题:用户觉得你的页面“加载完成”了吗?

想象一下,你打开一个电商网站,最想看到的是商品大图和价格。LCP就是衡量这个“最重要的东西”多久能出现在你面前。它直接反映了用户对页面加载速度的感知。

指标 衡量维度 优秀标准
LCP 用户感知的加载速度 <= 2.5秒

如果你的 LCP 超过 2.5 秒,用户可能就开始感到不耐烦了。优化 LCP,就是让你的“门面”以最快的速度展示给客人!

LCP 示意图

⚠️ CLS(Cumulative Layout Shift):累积布局偏移

🛡️ 衡量:视觉稳定性(告别“鬼畜”跳动)

CLS,累积布局偏移,听起来有点拗口,但它的作用非常直观:它衡量页面加载过程中元素的非预期移动

你一定遇到过这种情况:正准备点击一个按钮,结果它上面的广告突然加载出来,把按钮挤下去了,你点了个空!这就是布局偏移(Layout Shift)。

CLS 的分数就是用来量化这种“鬼畜”跳动有多严重的。分数越低,代表你的页面布局越稳定,用户体验越丝滑。

指标 衡量维度 理想值
CLS 页面布局稳定性 < 0.1

小贴士: 布局偏移通常是由没有设置尺寸的图片、动态插入的广告或内容导致的。想要 CLS 达标,请给你的元素预留好“坑位”!

⚡️ INP(Interaction to Next Paint):交互到下次绘制

🔄 衡量:整体交互响应性(从“第一印象”到“全程流畅”)

INP,交互到下次绘制,是性能指标家族的新晋“网红”。它衡量的是用户进行点击、触摸或键盘输入后,浏览器需要多长时间才能在屏幕上绘制出视觉反馈。

它取代了老前辈 FID(First Input Delay,首次输入延迟) ,为什么呢?

为什么用 INP 替代 FID? INP 的优势
FID 的局限性 仅测量首次输入延迟,忽略了用户在后续操作中遇到的卡顿。
INP 的全面性 监控用户整个访问周期内的所有交互,更全面。
更真实的用户体验 INP 选取最慢的一次交互作为代表值,反映了“整个使用过程中”的流畅度,而不是仅仅看“第一印象”。

简单来说,FID 就像面试官只看你的简历,而 INP 则是全程跟拍你的工作表现。一个真正流畅的网站,不应该只是第一次点击快,而是从头到尾都快!

INP 与 FID 比较图

🔬 实验室数据 vs. 🌍 现场数据:性能优化的“双重奏”

搞懂了三大指标,接下来我们聊聊如何获取这些数据。性能数据主要分为两大类:实验室数据现场数据

类型 来源(数据渠道) 优点 缺点
实验室数据 (Lab Data) Lighthouse 可控、快速、方便复现问题 非真实用户环境,可能与实际体验有偏差
现场数据 (Field Data) CrUX/Web Vitals 真实用户体验,数据最可靠 不易复现问题,需要时间积累数据

🔧 实验室数据工具

实验室数据就像你在实验室里用精密仪器做的测试,环境是固定的。

  • Lighthouse (Chrome 内置) :Chrome 开发者工具里就能找到,它能快速给你打分,支持 LCP/CLS/INP 评分。
  • PageSpeed Insights:Google 官方工具,它会结合实验室数据(Lighthouse)和现场数据(CrUX),给你一份一站式的性能报告。

🛠️ 现场数据工具

现场数据则是你的网站在真实用户、真实网络、真实设备上跑出来的“实战成绩”。

  • Google Search Console:提供网站整体的核心指标健康报告,是 SEO 优化的重要参考。
  • web-vitals JavaScript 库:这是前端工程师的“秘密武器”。它是一个轻量级的库,可以让你在用户浏览器中实时收集 LCP、CLS、INP 数据,并上报到你的分析后台。

💡 使用 web-vitals 收集性能数据(实战代码)

通过这个库,你可以将真实用户的性能数据发送到你的服务器进行分析,建立自己的性能监测体系(RUM,Real User Monitoring)。

import { onLCP, onCLS, onINP } from 'web-vitals'

function sendToAnalytics(metric) {
  // 将性能指标数据转换为 JSON 字符串
  const body = JSON.stringify(metric)
  
  // 使用 navigator.sendBeacon 或 fetch 发送数据,确保在页面关闭前发送成功
  ;(navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
    fetch('/analytics', { body, method: 'POST', keepalive: true })
}

// 监听并上报三大核心指标
onLCP(sendToAnalytics)
onCLS(sendToAnalytics)
onINP(sendToAnalytics)

✅ 总结:性能优化的“三字真经”

好了,总结一下我们今天学到的前端性能优化的“三字真经”:

  1. LCP:核心内容尽快可见(加载速度要快!)
  2. CLS:页面布局稳定(别再乱跳了!)
  3. INP:交互响应及时(操作要流畅!)

通过 LighthouseSearch Console 以及 Web Vitals 库,我们不仅能建立起一套完善的性能监测体系,还能精准地识别并修复那些让用户抓狂的体验瓶颈。

提升网页质量,不仅能让用户开心,还能让你的网站在 Google 搜索中获得更好的排名。

🤝 了解 CDP (Chrome DevTools Protocol):browser-use 背后的隐藏功臣

作者 卤代烃
2025年12月9日 23:07

Chrome DevTools Protocol (CDP) 是 Chromium 浏览器调试工具的核心通信协议:它基于 JSON 格式,可以通过 WebSocket 实现客户端与浏览器内核之间的双向实时交互。

基于 CDP 的开源产品有许多,其中最有名的应该是 Chrome Devtools FrontendPuppeteerPlaywright 了。

Chrome Devtools Frontend 就是前端开发者天天按 F12 唤起的调试面板,而 Puppeteer 和 Playwright 是非常有名的浏览器自动化操作工具,如今的 agent browser tool(例如 playwright-mcpbrowser-usechrome-devtools-mcp)也是基于它们构建的。可以说每个开发者都在使用 CDP,但因它的定位比较底层,大家常常又意识不到他的存在。

Chrome Devtools Frontend Puppeteer

CDP 有自己的官方文档站和相关的 Github 地址,秉承了 Google 开源项目的一贯风格,简洁,克制,就是没什么可读性。文档和项目都是根据源码变动自动生成的,所以只能用来做 API 的查询,这也导致如果没有相关的领域知识,直接阅读相关文档或 deepwiki 是拿不到什么有建设性内容的。

上面的那些吐槽,也是我写本文的原因,互联网上介绍 CDP 的博文太少了,也没什么系统性的架构分析,所以不如我自己来写丰富一下 AI 的语料库(bushi)。

协议格式

首先 CDP 协议是一个典型的 CS 架构,这里我们拿 Chrome Devtools 为例:

  • Chrome Devtools:就是 Client,用来做调试数据的 UI 展示,方便用户阅读
  • CDP:就是连接 Client-Server 的 Protocol,定义 API 的各种格式和细节
  • Chromium/Chrome:就是 Server,用来产生各种数据

CDP 协议的格式基于 JSON-RPC 2.0 做了一些轻量的定制。首先是去掉了 JSON 结构体中的 "jsonrpc": "2.0" 这种每次都要发送的冗余信息。可以看下面几个 CDP 的实际例子:

首先是常规的 JSON RFC Request/Response,细节不用关注,就看整体的格式:

Target.setDiscoverTargets

// Client -> Chromium
{
  "id":2
  "method": "Target.setDiscoverTargets",
  "params": {"discover":true,"filter":[{}]},
}

// Chromium -> Client
{
  "id": 2,
  "result": {}
}

可以看到这就是一个经典的 JSON RFC 调用,用 id 串起 request 和 response 的关系,然后 request 中通过 methodparams 把请求方法和请求参数带上;response 通过 result 带上响应结果。

关于 JSON RFC Notification(Event)的例子如下,定义也很清晰,就不展开了:

Target.targetCreated

{
  "method": "Target.targetCreated",
  "params": {
    "targetInfo": {
      "targetId": "12345",
      "type": "browser",
      "title": "",
      "url": "",
      "attached": true,
      "canAccessOpener": false
    }
  }
}

众所周知,JSON RFC 只是一套协议标准,它其实可以跑在任意的支持双向通讯的通信协议上。目前 CDP 的主流方案还是跑在 WebSocket 上(也可以用本地 pipe 的方式连接,但用的人少),所以用户可以借助任意的 Websocket 开源库搭建出合适的产品。


Domain 整体分类

如果直接看 CDP 的文档,会发现它的目录侧边栏只有一列,那就是 Domains,然后下面有一堆看起来很熟悉的名词:DOM,CSS,Console,Debugger 等等...

CDP Domains Chrome Devtools Frontend

其实这些 Domain 都可以和 Chrome Devtools 联系起来的。所以我们可以从 Chrome Devtools 的各种功能反推 CDP 中的各种 Domain 作用:

  • Elements:会用到 DOM,CSS 等 domain 的 API
  • Console:会用到 Log,Runtime 等 domain 的 API
  • Network:会用到 Network 等 domain 的 API
  • Performance:会用到 Performance,Emulation 等 domain 的 API
  • ......

那么到这里就有一个比较直观的认识了。我们再返回看 CDP 本身,CDP 其实可以分为两大类,然后下面有不同的 Domain 分类:

  • Browser Protocol:浏览器相关的协议,之下的 Domain 都是平台相关的,比如说 Page,DOM,CSS,Network,都是和浏览器功能相关
  • JavaScript Protocol:JS 引擎相关的协议,主要围绕 JS 引擎功能本身,比如说 Runtime,Debugger,HeapProfiler 等,都是比较纯粹的 JS 语言调试功能

deepwiki 给出的 CDP Domains 分类

了解了 Domain 的整体分类,下一步我们探索一下 Domain 内部的运行流程。

Domain 内部通信

理解某个 Domain 的运行流程,还是老办法,对照着 Chrome Devtools Frontend 的某个调试面板反推,这样理解起来是最快的。

这里我们拿 Console 面板为例,这个基本上是 Web 开发者日常使用频率最高的功能了。

从 UI 面板上看有很多功能,有筛选,分类,分组等各种高级功能,但绝大部分的功能都是前端上的实现,联系到背后和 Console 相关的 CDP 协议,其实主要就 5 条:


举一个真实的例子,我们在 Console 面板先发起一个不合规的网络请求,然后再 log 一句话:

  • 首先每个页面打开 Devtools 的时候,会默认调用 Log.enable 启动 log 监听
  • 手动 fetch 一个不合规的地址时,浏览器会先做安全检查,通过 Log.entryAdded 提示不合规
  • 发起一个真实的网络请求,失败后会通过 Runtime.exceptionThrown 提示 Failed to fetch
  • 最后手动调用 console API,CDP 会发一个 Runtime.consoleAPICalled 的调用 log event

把上面的的例子抽象一下,其实所有的 Domain 的调用流程基本都是一样的:

  • 通过 Domain.enable 开启某个 Domain 的调试功能
  • 开启功能后,就可以在这个阶段发送相关的 methods 调用,也可以监听 Chrome 发来的各种 event
  • 通过 Domain.disable 关闭这个 Domain 的调试功能

部分 Domain 并没有 enable/disable 这两个 methods,具体情况具体分析

Target: 特殊的 Domain

上面介绍了 Domain 的分类和 Domain 内部运转的整体流程,但是有一个 Domain 非常的特殊,那就是 Target

type 分类

Target 是一个较为抽象的概述,它指的是浏览器中的可交互实体

  • 我创建了一个浏览器,那么它本身就是一个 type 为「browser」的 Target
  • 浏览器里有一个标签页,那么这个页面本身就是一个 type 为「page」的 Target
  • 这个页面里要做一些耗时计算创建了一个 Worker,那么它就是一个 type 为「worker」的 Target

目前从 chromium 源码上可以看出,Target 的 type 有以下几种:

  • browser,browser_ui,webview
  • tab,page,iframe
  • worker,shared_worker,service_worker
  • worklet,shared_storage_worklet,auction_worklet
  • assistive_technology,other

从上面的 target type 可以看出,Target 整体是属于一个 scope 比较大的实体,基本上是以进程/线程作为隔离单位分割的,每个 type 下可能包含多个 CDP domain,比如说 page 下就有 Runtime,Network,Storage,Log 等 domain,其他类型同理。

交互流程

Target 的内部分类清晰了,那么还剩重要的一环:如何和 Target 做交互

CDP 这里的逻辑是,先发个请求,向 Target 发起交互申请,然后 Target 就会给你一个 sessionId,之后的交互就在这个 session 信道上进行。CDP 在这里也对 JSON-RPC 2.0 做了一个轻量定制,它们把 sessionId 放在了 JSON 的最外层,和 id 同一个层级:

{
  method: "SystemInfo.getInfo",
  id: 9,
  sessionId: "62584FD718EC0B52B47067AE1F922DF1"
}

我举个实际的例子看 session 的交互流程。

假设我们想从 browser Target 上获取一些系统消息,先假设我们事先已经知道了 browser 的 targetId,那么一个完整的 session 通信如下:

这里为了聚焦 session 的核心交互逻辑,下面的 CDP message 删除了不必要的信息

  1. Client 通过 Target.attachToTarget API 向 browser 发起会话请求,拿到 sessionId
// Client —> Chromium
{
  "method": "Target.attachToTarget",
  "params": {
    "targetId": "31a082d2-ba00-4d8f-b807-9d63522a6112", // browser targetId
    "flatten": true // 使用 flatten 模式,后续将会把 sessionId 和 id 放在同一层级
  },
  "id": 8
}

// Chromium —> Client
{
  "id":8,
  "result": {
    "sessionId": "62584FD718EC0B52B47067AE1F922DF1" // 拿到这次对话的 sessionId
  }
}
  1. Client 带上上一步给的 sessionId,发送一条获取系统信息的 CDP 调用并获取到相关消息
// Client —> Chromium
{
  "method": "SystemInfo.getInfo", // 获取系统信息的方法
  "id": 9,
  "sessionId": "62584FD718EC0B52B47067AE1F922DF1" // sessionId 和 id 同级,在最外层
}

// Chromium —> Client
{
  "id": 9,
  "sessionId": "62584FD718EC0B52B47067AE1F922DF1"
  "result": { /* ... */ },
}
  1. 不想在这个 session 上聊了,调用 Target.detachFromTarget 直接断开连接,自此这个会话就算销毁了
// Client —> Chromium
{
  "method": "Target.detachFromTarget",
  "id": 11,
  "sessionId":"62584FD718EC0B52B47067AE1F922DF1"
}

// Chromium —> Client
{
  "id": 11,
  "result": {}
}

上面的流程可以用下面的图表示:

当然涉及 Target 生命周期的相关 Methods 和 Event 还有很多,一一讲解也不现实,感兴趣的同学可以自己探索。

一对多

除了上述的特性,Target 还有一个特点,那就是一个 Target 允许多个 session 连接。这意味着可以有多个 Client 去控制同一个 Target。这在现实中也是很常见的。比如说对于一个网页实体,它既可以被 Chrome Devtools(Client1)调试,也可以同时被 puppeteer(Client2)连接做自动化控制。当然这也会带来了一些资源访问的并发问题,在实际应用场景上需要万分的小心。

综合案例

综上所述,我们可以看一个实际的例子,把上面的内容都囊括起来。

下面的案例我是用 puppeteer 创建了一个 url 为 about:blank 的新网页时,底层的 CDP 调用流程。调用的源文件可以访问右边的超链接下载:create_about_blank_page.har,har 文件可用 Chrome Devtools Network 导入查看:


首先是最开始的 Target 创建流程。注意下图红框和红线里的内容:

  • 首先调用 Target.createTarget 创建一个 page(在调用 createTarget 时,会同步生成一个 tab Target,我们可以忽略这个行为,不影响后续理解)
  • page Target 创建好后,在响应 Target.createTarget methods 的同时,还会发送一个 Target.targetCreated 的 event,里面有这个 page Target 的详细 meta info,例如 targetId,url,title 等
  • page Target 的 meta info 变动时,会下发 Target.targetInfoChanged event 同步信息变化
  • page Target 下发一个 Target.attachedToTarget 的 event,告知 client 这次连接的 sessionId,这样后续的一些 domain 操作就可以带上 sessionId 保证信道了

Target 创建好后,就要开启这个 page 下的各个 Domain 了:

  • Network.enable:开启 Network Domain 的监听,比如说各种网络请求的 request/response 的细节
  • Page.enable:开启 Page Domain 的监听,比如说 navigation 行为的操纵
  • Runtime.enable:开启 Runtime Domain 的监听,比如说要在 page 里 evaluate 一段注入函数
  • Performance.enable:开启 Performance Domain 的监听,比如说一些 metrics 信息
  • Log.enable:开启 log 相关信息的监听,比如说各种 console.log 信息

开启相关 Domain 后,就可以监听这个 page Target 的相关 event 或者主动触发一些方法,如下图所示:

  • 我们主动执行 Page.getNavigationHistory methods,获取当前页面的 history 导航记录
  • 我们监听到 Runtime.consoleAPICalled event 的触发,拿到了一些 console 信息

相关的细节还有很多就不一一列举了,感兴趣的同学可以看上面的 har 源文件,我相信全部看完后就会对 CDP 有个清晰的认知了。

编码建议

就目前(2025.12)而言,Code Agent 和 DeepResearch 等常见的 AI 辅助编程工具在 CDP 领域上表现并不是很好,主要的原因有 3 点:

  • 预训练语料少:从前文可知,CDP 因为协议过于底层,所以相关的使用案例和代码非常少,模型预训练时语料很少,导致幻觉还是比较严重的
  • 文档质量一般:CDP 文档写的太简洁了,基本就是根据出入参自动生成的类型文档,只能用来查询核实一下,想从中获得完整的概念,对 AI 和人来说还是太难了
  • API 动态迭代:CDP 虽然开源出来了,但其本质还是一个为 Chromium 服务的私有协议,其 latest 版本一直在动态迭代中,所以这种动态变化也影响了 AI 的发挥

综合以上原因,我的一个策略是「小步快跑,随时验证」。方案就是对于自己想实现的功能,先让 AI 出一个大致的方案,但是不要直接在自己的迭代的项目里直接写,而是先生成一个可以快速验证相关功能的最小 DEMO,然后亲自去验证这个方案是否符合预期。

「亲自验证 DEMO 可行性」 这一步非常重要,因为 AI 直出的 CDP 解决方案可靠性并不高,不像 AI -> UI 有较高的容错率和置信度,只有在 DEMO 上验证成功的方案才有迁移到正式项目的价值。

另一个解决方案,就是 puppeteer 等优秀项目上吸取经验。puppeteer 底层也是调用 CDP,而且它迭代了十余年,对一些常见案例已经沉淀了一套成熟的解决方案。通过学习它内部的 CDP 调用流程,可以学习到很多文档未曾描述的运用场景。下一篇 Blog,我们就分析一下 puppeteer 的源码架构,让我们在调用过程中更加得心应手。

如何设置你的 PWN 环境

2025年12月9日 22:36

你首先需要的是 Linux Shell ,因为大多数 pwn 挑战通常是 64 位 ELF 可执行文件 。我会写两个不同的部分,分别针对 Windows 和 MacOS。如果你已经用过任何 Linux 发行版,这部分你就没问题了!对于剩下的 Windows/MacOS 用户,我强烈建议安装 Debian Bookworm 作为首选发行版,因为它目前使用 glibc 2.36,尚未实现完整的 RELRO,  且利用起来稍微容易一些。

Windows

对于正在阅读本文的 Windows 用户,你可以按照链接的说明书轻松下载 WSL。如果你懒得看,这里有一份你应该执行的命令列表。

安装使用 Debian for WSL

wsl.exe --update
wsl --set-default-version 2
wsl install -d Debian

就是这样!从现在起,你可以通过开始菜单中的 Debian 搜索 Debian,或者在命令行中运行 Debian,进入你的 Debian shell。关于上述 WSL 2 的要求,WSL 2 允许你在 WSL 内部的集成桌面模式下使用 GUI 应用。这其实没什么特别的意义,除了你可以让你的调试器在一个新窗口里弹出,不用 tmux,这对我来说简直是个大胜利!!

补充一点,你可能需要更新系统包 ,并下载文本编辑器,以避免在 WSL 终端和其他窗口之间切换。你可以用以下命令完成: sudo apt update && sudo apt dist-upgrade && sudo apt(-get) install <editor of choice> 。

如果你想要完全懒散的体验和最少的 Linux shell 作,你可以挂载你的 Windows 文件系统 ,并在 Downloads 目录中使用以下命令(不区分大小写!): cd /mnt/c/users/your\ name/downloads

macOS

不幸的是,对于 MacOS 用户来说,我觉得没有像 Windows 用户那样的集成桌面。所以,我建议你按照这个指南作,尽可能多地在终端里作 ,你会在其中花费比预期更多的时间。好消息是,有 Lima,我们可以用它帮助简化创建 x64 Linux 壳,即使是在 Apple Silicon 上。

这里有一份快速入门的推荐清单。

brew install lima
limactl start --name=x64-debian template://debian --arch=x86_64
limactl shell x64-debian 
sudo apt install tmux

遇到终端颜色的问题,解决方案完全取决于你的终端模拟器 ,所以你自己找解决方案。

额外加分:可写主机文件系统!

LiMactl 编辑 x64-debian 以编辑配置 添加以下不安全的配置:

mounts:
- location: "~"
  writable: true

现在你拥有了一个运行在 x64 上的 Linux Shell ,可以不受限制地访问你的主机文件系统! 你可以用标签自动补全你的虚拟机名字拼写!

恭喜你通过了 Linux 安装!繁琐的部分现在开始了......我们先着手安装调试器和基础工具。

工具安装


恭喜你完成了Linux安装!乏味的部分现在开始了...让我们先安装调试器和基本工具。

实用工具


pwntools
  1. sudo apt install python3 安装python
  2. sudo apt-get install python3-pwntools 安装pwntools
  3. pwn checksec /lib/x86_64-linux-gnu/libc.so.6 验证它是否工作
one gadget
  1. sudo apt install rubygems 安装gem
  2. gem install one_gadget 安装one gadget
  3. one_gadget /lib/x86_64-linux-gnu/libc.so.6 验证它是否工作
rop gadget
  1. sudo -H python3 -m pip install ROPgadget 安装ROPgadget
  2. ROPgadget /lib/x86_64-linux-gnu/libc.so.6 验证它是否工作(警告:输出量很大!!)

调试器


强烈推荐bata-gef,你也应该使用它!

我不建议盲目运行安装脚本。相反,克隆仓库并在你的.gdbinit中source它的gef.py

cd ~
git clone https://github.com/bata24/gef
vi ~/.gdbinit

#.gdbinit
source /path/to/gef.py

对于没有集成桌面模式的MacOS用户,使用我们之前安装的tmux。只需运行以下命令:

  1. tmux 启动tmux
  2. [Ctrl] + B 进入命令模式
  3. ] 启用滚动
  4. [Ctrl] + C 重置状态
  5. [Ctrl] + B,然后<arrow> 切换焦点标签页

反编译器/反汇编器


太好了!现在我们有了开始编写和调试_pwn_脚本所需的一切,这技术上就是我们需要的全部,但有时,挑战不提供源代码。在这种情况下,需要进行一些逆向工程工作。让我们下载**Ghidra**。

设置相当简单,你只需要一个Java运行时,我推荐Temurin,你可以在

这里 找到它。

一旦你安装了运行时,只需运行ghidraRunghidraRun.bat来启动Ghidra

使用Ghidra

Ghidra实际上相当不错且用户友好,你所要做的就是创建一个空项目,然后拖放你的挑战二进制文件到其中,并给它一点时间来生成反汇编和反编译!

容器化和服务器复制


恭喜你走到这一步!你已经准备好处理几乎所有pwn挑战了。然而,我们可以做得更好。

有时,善意的挑战作者会提供libc、ld和/或Dockerfile。这些文件特别有用,因为它们帮助你确保你的挑战使用与服务器相同的库,确保你永远不会遇到_"在我的机器上可以运行"_的问题!所以,我将介绍如何处理这些文件的各种组合。

给定了Libc、ld和Dockerfile


这是最好的情况,因为你不需要做太多。这里有一个你可以运行的命令来使用给定的libcld

./ld-linux-x86-64.so.2 --library-path . ./chal

这将运行链接器,并告诉它使用你目录中的库来启动挑战。就这样! 你现在正在使用给定的库。但是,我们如何复制服务器呢?

首先,我们必须安装Docker。过程相当长,但请相信

文档 并遵循它。

一旦你安装了Docker,你所要做的就是运行以下命令:

docker run -p YOUR_PORT:CONTAINER_PORT -d --privileged $(docker build -q .)

这将构建容器,然后以特权运行它,这通常是_pwn.red/jail_模板所需要的。请将CONTAINER_PORT占位符替换为容器提供挑战的端口,将YOUR_PORT替换为宿主机上的任何未使用端口。默认情况下,这将绑定到所有接口

设置完成后,你可以像这样连接到容器并运行挑战:

nc 127.0.0.1 YOUR_PORT

就这样!你已经成功复制了服务器使用的库,以及服务器本身!完成后,你应该通过运行docker ps找到容器哈希,然后运行docker stop <hash>来清理正在运行的容器。

只提供了Dockerfile


有时,作者只提供Dockerfile。这很糟糕,但没关系,因为我们可以自己提取libcld。我们首先使用上述步骤启动容器,然后执行以下操作:

docker cp 1a2b:/lib/x86_64-linux-gnu/libc.so.6 ./libc.so.6
docker cp 1a2b:/lib/x86_64-linux-gnu/ld-linux-x86-64 ./ld-linux-x86-64.so.2

记得将1a2b替换为容器哈希的前4个字符,启动时会显示给你。有时,Dockerfile会从另一个Linux发行版复制文件到一个文件夹来提供服务,以便使用该发行版的libc。在这种情况下,只需在路径前加上前缀。使用一个容器执行COPY --from=debian:bookworm / /srv的例子,我们只需在/lib前加上/srv来从服务器的文件中复制。

结论


通过以上所有设置和安装,你应该拥有所有工具,以及开始处理pwn挑战的基本知识。快乐pwning! 如果你现在想了解更多关于pwn的一般知识,请阅读我的博客文章

昨天 — 2025年12月9日掘金 前端

开发提效利器 - 用好Snippets

2025年12月9日 22:23

前言

snippets 意思是片段,在 vscode 中可以通过 Snippets:Configure Snippets 命令,可以定义全局级(global)、项目级(Project)、语言级(Language)代码片段,可以按照自定义的规则来快速生成一段代码,进而大幅提升我们的开发效率,可谓是工作提效利器。

1、如何创建 snippets?

  1. 第一步,在 vscode 按 ctrl/command + shift + p,输入 snippets,选择 Snippets:Configure Snippets,可以看到如下界面。

可根据需要选择 snippets 的创建类型,一般选择 global 全局级别的 snippets 即可。

  1. 第二步,选择 New Global Snippets file,输入文件名,按 enter 键,即进入 snippets 配置文件设置。

snippets 文件以 JSON 编写,里面支持书写注释,并可定义无限数量的 snippets。

"snippet": {
"scope": "javascript,typescript",
"prefix": "snippet",
"body": [
"console.log('$1');",
],
"description": "a simple snippet"
}
  • prefix:必填,可以定义一个或多个触发词,由于子字符串匹配不是严格匹配的,类似正则匹配,比如输入 ts 可以匹配到 test
  • body:必填,表示正文,是一行或多行内容,插入时会合并为多行。换行和嵌入的标签页将根据插入摘要的上下文进行格式化。
  • description:可选,表示片段的描述。
  • scope:可选,表示支持的语言,默认支持所有。

2、snippets 支持语法

2.1 光标控制

  • 在 body 正文,它里面可以使用占位符:$0、$1、$2...,代表光标位置,$0 代表光标最后停留的位置,$1 代表光标第一个停留的位置,$2 代表光标第二个停留的位置,以此类推,在使用 prefix 触发词触发词生成代码片段后,可使用 Tab 键跳转到光标位置。
  • 同名光标可以同时使用多个,比如可以使用多个 $1,就可以实现多光标同时编辑
  • 光标也可以加上默认值,比如 ${1:element},默认值为 element,如果触发词触发后,不输入任何内容,则光标位置默认为 element
  • 光标也可以提供多个值来选择,比如 ${1|element1,element2,element3|},当按 Tab 键移动到对应光标时,会出现下拉框可供选择。

2.2 Variables 变量

在 body 正文可以使用变量,语法是 $name${name:default}。其中 default 表示默认值(占位符),当变量未知(即其名称未定义)时,会采用这个默认值。

vscode 中内置一些变量:

  • ${TM_FILENAME}:当前文件名。
  • ${TM_FILENAME_BASE}:当前文件名(不带扩展名)。
  • ${TM_DIRECTORY}:当前文件所在目录。
  • ${TM_FILEPATH}:当前文件路径。
  • ${RELATIVE_FILEPATH}:当前文档的相对(相对于已打开的工作区或文件夹)文件路径。
  • ${RELATIVE_FILEPATH}:当前文档的相对(相对于已打开的工作区或文件夹)文件路径。
  • ${CLIPBOARD}:剪切板的内容。
  • ${TM_SELECTED_TEXT}:当前选择的文本或空字符串。
  • ${CURRENT_SECONDS_UNIX}:10位 unix 时间戳。
  • ...等等。

更多变量可查看vscode snippets 官方文档

2.3 Transform 变换

还支持对变量做正则替换。

举个例子,变量 ${TM_FILENAME} 是表示当前文件名,比如当前文件名为 test.js,使用 "${TM_FILENAME/[\\.]/_/}",表示将文件名中的 . 替换为 _,会把 test.js 变成 test_js

这个语法怎么理解呢?

拿 js 对比来说,比如当前字符串 s 为 test.js,使用正则替换 s.replace(/(.+)\.js/i, '$1df'),其输出结果为 testdf,而在 vscode 中,如果当前文件名为 test.js,使用 ${TM_FILENAME/(.+)\\.js/$1df/i},也能得到同样的结果。

具体规则差异总结如下

  • 也就是不用逗号分开,而改为 /
  • 在以前 \.js 的正则语法中,需要增加一个 \ 表示再多转义一次,要不然会报错。

3、多人项目协作时如何实现 snippets 共享?

之前说过,snippets 可以定义在全局级、项目级、语言级,所以,如果多人协作时,可以把 snippets 定义在项目级,这样 vscode 在根目录生成配置配置,目录如下: 根目录/.vscode/[snippet名].code-snippets,可以在这个文件提交到 git 仓库中,这样项目成员都可以共享该 snippets 了。

4、如何在 markdown 文件中使用 vscode snippets?

默认在 vscode 中,在markdown 文件编写内容是默认不支持 snippets 的,比如我平时写一些 markdown 表格时,会配置一个 markdown 表格的 snippets,但发现在 markdown 文件中输入触发词 prefix 根本无法使用。

{
"md-table": {
"prefix": "md-table",
"body": [
"| 列1标题 | 列2标题 | 列3标题 |",
"|---------|---------|---------|",
"| 内容A   | 内容B   | 内容C   |",
"| 内容D   | 内容E   | 内容F   |"
],
"description": "markdown 表格"
}
}

后面发现可以手动插入 snippets。在 vscode 按 ctrl/command + shift + p,输入 snippets,选择 Snippets:Insert Snippet,就可以看到所有之前配置好对的 snippets 了,选择对应的 snippet 插入即可。

小结

  • vscode 支持全局级(global)、项目级(Project)、语言级(Language)代码片段,一般个人使用的话为了方便,直接定义全局级即可,需要进行多人协助时,可使用项目级 snippets,将生成的配置文件上传到 git 仓库即可。
  • snippets 支持语法:光标控制、Variables 变量、Transform 变换等语法,可按需使用。
  • markdown 中默认不支持 snippets,但可以通过手动插入 snippets 来使用。

最后为了方便,snippets 配置文件的编写可以使用这个工具网站进行生成:snippet-generator

requestAnimationFrame 与 JS 事件循环:宏任务执行顺序分析

2025年12月9日 22:21

一、先理清核心概念

在讲解执行顺序前,先明确几个关键概念:

  1. 宏任务(Macrotask) :常见的有 setTimeoutsetInterval、I/O 操作、script 整体代码、UI 渲染(注意:渲染是独立阶段,不是宏任务,但和 rAF 强相关)。
  2. 微任务(Microtask)Promise.then/catch/finallyqueueMicrotaskMutationObserver 等,会在宏任务执行完后、渲染 / 下一个宏任务前立即执行。
  3. requestAnimationFrame:不属于宏任务 / 微任务,是浏览器专门为动画设计的 API,会在浏览器重绘(渲染)之前执行,执行时机在微任务之后、宏任务之前(下一轮)。

二、事件循环的执行流程

一个完整的事件循环周期执行顺序:

1. 执行当前宏任务(如 script 主代码)
2. 执行所有微任务(微任务队列清空)
3. 执行 requestAnimationFrame 回调
4. 浏览器进行 UI 渲染(重绘/回流)
5. 取出下一个宏任务执行,重复上述流程

三、代码分析

代码执行优先级:同步代码 > 微任务 > rAF(当前帧) > 普通宏任务(setTimeout) > rAF(下一帧) > 后续普通宏任务。

场景 1:基础顺序(script + 微任务 + rAF + 宏任务)

// 1. 同步代码(属于第一个宏任务:script 整体)
console.log('同步代码执行');

// 微任务
Promise.resolve().then(() => {
  console.log('微任务执行');
});

// requestAnimationFrame
requestAnimationFrame(() => {
  console.log('requestAnimationFrame 执行');
});

// 宏任务(setTimeout 是宏任务)
setTimeout(() => {
  console.log('setTimeout 宏任务执行');
}, 0);

// 执行结果顺序大部分情况下是这样的:
// 同步代码执行
// 微任务执行
// requestAnimationFrame 执行
// setTimeout 宏任务执行

代码解释1

  • 第一步:执行同步代码,打印「同步代码执行」;
  • 第二步:微任务队列有 Promise.then,执行并打印「微任务执行」;
  • 第三步:浏览器准备渲染前,执行 rAF 回调,打印「requestAnimationFrame 执行」;
  • 第四步:浏览器完成渲染后,取出下一个宏任务(setTimeout)执行,打印「setTimeout 宏任务执行」。

代码解释2

  • 正常浏览器环境(60Hz 屏幕,无阻塞) :输出顺序是按上方写的先后顺序执行的:

    同步代码执行
    微任务执行
    requestAnimationFrame 执行
    setTimeout 宏任务执行
    

    原因:浏览器每 16.7ms 刷新一次,requestAnimationFrame 会在下一次重绘前执行,而 setTimeout 即使设为 0,也会有 4ms 左右的最小延迟(浏览器限制),所以 requestAnimationFrame 先执行。

  • 极端情况(主线程阻塞 / 浏览器刷新延迟) :可能出现顺序互换:

    同步代码执行
    微任务执行
    setTimeout 宏任务执行
    requestAnimationFrame 执行
    

    原因:如果主线程处理完微任务后,requestAnimationFrame 的回调还没到执行时机(比如浏览器还没到重绘节点),但 setTimeout 的最小延迟已到,就会先执行 setTimeout

总结

  1. 固定顺序:同步代码 → 微任务,这两步是绝对固定的,不受任何因素影响。

  2. 不固定顺序requestAnimationFrame 和 setTimeout 的执行先后不绝对,前者优先级更高但依赖渲染时机,后者受最小延迟限制,多数场景下前者先执行,但不能当作 “绝对结论”。

  3. 核心原则:requestAnimationFrame 属于 “渲染相关回调”,优先级高于普通宏任务(如 setTimeout),但并非 ECMAScript 标准定义的 “微任务 / 宏任务” 范畴,而是浏览器的扩展机制,因此执行时机存在微小不确定性。

场景 2:嵌套场景(rAF 内嵌套微任务 / 宏任务)

console.log('同步代码');

// 第一个 rAF
requestAnimationFrame(() => {
  console.log('rAF 1 执行');
  
  // rAF 内的微任务
  Promise.resolve().then(() => {
    console.log('rAF 1 内的微任务');
  });
  
  // rAF 内的宏任务
  setTimeout(() => {
    console.log('rAF 1 内的 setTimeout');
  }, 0);
  
  // rAF 内嵌套 rAF
  requestAnimationFrame(() => {
    console.log('rAF 2 执行');
  });
});

// 外层微任务
Promise.resolve().then(() => {
  console.log('外层微任务');
});

// 外层宏任务
setTimeout(() => {
  console.log('外层 setTimeout');
}, 0);

// 执行结果顺序:
// 同步代码
// 外层微任务
// rAF 1 执行
// rAF 1 内的微任务
// 外层 setTimeout
// (浏览器下一次渲染前)
// rAF 2 执行
// rAF 1 内的 setTimeout

代码解释

  1. 先执行同步代码 → 外层微任务;
  2. 执行 rAF 1 → 立即执行 rAF 1 内的微任务(微任务会在当前阶段清空);
  3. 浏览器渲染后,执行下一轮宏任务:外层 setTimeout;
  4. 下一次事件循环的渲染阶段,执行嵌套的 rAF 2;
  5. 最后执行 rAF 1 内的 setTimeout(下下轮宏任务)。

场景 3:rAF 与多个宏任务对比

// 宏任务1:setTimeout 0
setTimeout(() => {
  console.log('setTimeout 1');
}, 0);

// rAF
requestAnimationFrame(() => {
  console.log('rAF 执行');
});

// 宏任务2:setTimeout 0
setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

// 执行结果顺序:
// rAF 执行
// setTimeout 1
// setTimeout 2

结论:即使多个宏任务排在前面,rAF 依然会在「微任务后、渲染前」优先执行,然后才执行所有待处理的宏任务。

四、实际应用

rAF 的这个执行特性,常用来做高性能动画(比如 DOM 动画),因为它能保证在渲染前执行,避免「布局抖动」:

// 用 rAF 实现平滑移动动画
const box = document.getElementById('box');
let left = 0;

function moveBox() {
  left += 1;
  box.style.left = `${left}px`;
  
  // 动画未结束则继续调用 rAF
  if (left < 300) {
    requestAnimationFrame(moveBox);
  }
}

// 启动动画
requestAnimationFrame(moveBox);

这个代码的优势:rAF 会和浏览器的刷新频率(通常 60Hz,每 16.7ms 一次)同步,不会像 setTimeout 那样可能出现丢帧,因为 setTimeout 是宏任务,执行时机不固定,可能错过渲染时机。

总结

  1. 核心执行顺序:同步代码 → 所有微任务 → requestAnimationFrame → 浏览器渲染 → 下一轮宏任务(setTimeout/setInterval 等)。
  2. rAF 本质:不属于宏 / 微任务,是浏览器渲染阶段的「专属回调」,优先级高于下一轮宏任务。
  3. 实战价值:rAF 适合做 UI 动画,能保证动画流畅;宏任务(setTimeout)适合非渲染相关的异步操作,避免阻塞渲染。

相比传统的计时器防抖与节流

实战代码:rAF 实现节流(最常用)

rAF 做节流的核心优势:和浏览器渲染同步,不会出现「执行次数超过渲染帧」的无效执行,尤其适合 resizescrollmousemove 这类和 UI 相关的高频事件。

基础版 rAF 节流

function rafThrottle(callback) {
  let isPending = false; // 标记是否已有待执行的回调
  return function(...args) {
    if (isPending) return; // 已有待执行任务,直接返回
    
    isPending = true;
    // 绑定 this 指向,传递参数
    const context = this;
    requestAnimationFrame(() => {
      callback.apply(context, args); // 执行回调
      isPending = false; // 执行完成后重置标记
    });
  };
}

// 测试:监听滚动事件
window.addEventListener('scroll', rafThrottle(function(e) {
  console.log('滚动节流执行', window.scrollY);
}));

代码解释

  1. isPending 标记是否有 rAF 回调待执行,避免同一帧内多次触发;
  2. 每次触发事件时,若没有待执行任务,就通过 rAF 注册回调;
  3. rAF 会在下一次渲染前执行回调,执行完后重置标记,确保每帧只执行一次。

对比传统 setTimeout 节流

// 传统 setTimeout 节流(对比用)
function timeoutThrottle(callback, delay = 16.7) {
  let timer = null;
  return function(...args) {
    if (timer) return;
    timer = setTimeout(() => {
      callback.apply(this, args);
      timer = null;
    }, delay);
  };
}

rAF 节流的优势:

  • 执行时机和浏览器渲染帧完全同步,不会出现「回调执行了但渲染没跟上」的无效操作
  • 无需手动设置延迟(如 16.7ms),自动适配浏览器刷新率(60Hz/144Hz 都能兼容)

实战代码:rAF 实现防抖

rAF 实现防抖需要结合「延迟 + 取消 rAF」的逻辑,核心是「触发事件后,只保留最后一次 rAF 回调」。

function rafDebounce(callback) {
  let rafId = null; // 保存 rAF 的 ID,用于取消
  return function(...args) {
    const context = this;
    // 若已有待执行的 rAF,先取消
    if (rafId) {
      cancelAnimationFrame(rafId);
    }
    // 重新注册 rAF,延迟到下一帧执行
    rafId = requestAnimationFrame(() => {
      callback.apply(context, args);
      rafId = null; // 执行后清空 ID
    });
  };
}

// 测试:监听输入框输入
const input = document.getElementById('input');
input.addEventListener('input', rafDebounce(function(e) {
  console.log('输入防抖执行', e.target.value);
}));

代码解释

  1. 每次触发事件时,先通过 cancelAnimationFrame 取消上一次未执行的 rAF 回调;
  2. 重新注册新的 rAF 回调,确保只有「最后一次触发」的回调会执行;
  3. 防抖的延迟本质是「一帧的时间(16.7ms)」,若需要更长延迟,可结合 setTimeout

带自定义延迟的 rAF 防抖

function rafDebounceWithDelay(callback, delay = 300) {
  let rafId = null;
  let timer = null;
  return function(...args) {
    const context = this;
    // 取消之前的定时器和 rAF
    if (timer) clearTimeout(timer);
    if (rafId) cancelAnimationFrame(rafId);
    
    // 先延迟,再用 rAF 执行(保证渲染前执行)
    timer = setTimeout(() => {
      rafId = requestAnimationFrame(() => {
        callback.apply(context, args);
        rafId = null;
        timer = null;
      });
    }, delay);
  };
}

四、适用场景 vs 不适用场景

场景 是否适合用 rAF 做防抖 / 节流 原因
scroll/resize 事件 ✅ 非常适合 和 UI 渲染强相关,rAF 保证每帧只执行一次
mousemove/mouseover 事件 ✅ 适合 高频触发,rAF 减少无效执行,提升性能
输入框 input/change 事件 ✅ 适合(防抖) 保证输入完成后,在渲染前执行回调(如搜索联想)
网络请求(如按钮点击提交) ❌ 不适合 网络请求和 UI 渲染无关,用传统 setTimeout 防抖更合适
后端数据处理(无 UI 交互) ❌ 不适合 rAF 是浏览器 API,Node.js 环境不支持,且无渲染需求

总结

  1. rAF 适合做防抖 / 节流,尤其在「和 UI 交互相关的高频事件」(scroll/resize/mousemove)场景下,性能优于传统 setTimeout;
  2. rAF 节流:核心是「每帧只执行一次」,利用 isPending 标记避免重复执行;
  3. rAF 防抖:核心是「取消上一次 rAF,保留最后一次」,可结合 setTimeout 实现自定义延迟;
  4. 非 UI 相关的防抖 / 节流(如网络请求),优先用传统 setTimeout,避免依赖浏览器渲染机制。

Firebase CLI 一直关联失败

作者 J船长
2025年12月9日 22:06

比如执行`firebase login``然后跳转浏览器,进行关联

解决办法:

export https_proxy=http://127.0.0.1:xxxx
export http_proxy=http://127.0.0.1:xxxx
export all_proxy=socks5://127.0.0.1:xxxx
firebase login

原因:

macOS 上开 V 只是让浏览器等应用走代理,但终端默认不走代理。终端里的命令(如 firebase)需要手动设置 http_proxy 和 https_proxy 环境变量才能走代理访问 Google 服务。

为什么在 Agent 时代,我选择了 Bun?

作者 mCell
2025年12月9日 22:04

同步至个人站点:为什么在 Agent 时代,我选择了 Bun? - Bun 指南

076.webp

一切都从那条「收购 Bun」的新闻开始

上周三,看到 Anthropic 的一则新闻:他们宣布收购 Bun,并在文中明确写到——对 Claude Code 的用户来说,这次收购意味着更快的性能、更高的稳定性以及全新的能力。(Anthropic)

简单翻译一下就是:

我们要把整个编码 Agent 的基础设施,换成一个更快、更顺手的 JS/TS 运行时。

这条新闻对我触动很大,至少暴露出两个事实:

  1. Agent 时代的基础设施在变化 以前我们写 CLI、写后端,Node 足够用了;但到了 Agent 这种「到处起小进程、到处跑工具」的场景里,启动速度、冷启动性能、all-in-one 工具链,突然变得非常关键。

  2. Bun 不再只是一个「新玩具」 它已经成为一家头部 AI 公司的底层组件之一:Anthropic 早就在内部用 Bun 跑 Claude Code,现在索性直接收购,把它当作下一代 AI 软件工程的基础设施。(bun.sh)

当我再去翻 Bun 的官网时,就会发现:

这是一个集 JavaScript/TypeScript 运行时、包管理器、打包器、测试运行器 于一身的 all-in-one 工具。(bun.sh)

这和我对「传统 JS 运行时」的印象已经完全不一样了。

078.webp

先说说我的技术背景:为什么要写这个专题?

我平时写代码偏向两类技术栈:

  • TypeScript:前端、工具脚本、Node 小服务
  • Go:服务端、一些偏底层的活

很长一段时间里,我对 Node 的使用场景大概就是这几种:

  • 写个小脚本:ts-node / tsx + 一点配置,跑完就扔
  • 写个中小型服务端:Express / Nest 这一类

这些场景里,一个很明显的感受是:

  • Node 够成熟:生态庞大,资料多,出了问题很容易搜到坑
  • 但 Node 也够「厚重」:一堆配置、各种工具组合、打包测试 lint 各种链路

直到我开始认真看 Bun 的文档,第一次有一种很强烈的对比感:

「哦,原来现在写 JS/TS 后端已经可以这么简单了?」

  • 不用写 tsconfig.json(很多时候默认就很好用)
  • dev / build / test 基本就是一行命令: bun run / bun build / bun test
  • 起一个 HTTP server,用的还是 JS/TS,但 API 明显更简洁,很多地方甚至不需要 Node 的那一套底层 API

再加上我最近在做 Agent 相关的东西,自然就顺势产生了一个问题:

既然我本来就想用 TS 写一个 ReAct Agent,那为什么不干脆用 Bun 来做 runtime 呢?

Agent CLI 的选型:为什么会想到 Bun?

在开搞之前,我特地去看了一圈现在比较热门的 Agent CLI 都是怎么选技术栈的:

如果你把这些信息放在一起,会大致看到一个趋势:

  • Rust:更偏向极致性能、可控性、安全性,适合那种「我要把一套工具做到很底层、很稳」的团队。
  • Node:稳定、生态成熟,但随着项目往 AI 工程、Agent 方向发展,在冷启动、工具链整合上没有那么「顺手」。
  • Bun:尝试在 Node 的生态基础上,往前推进一步,做一个all-in-one、性能极强的 JS/TS 运行时

而我这个人,有个很明显的偏好:

能用 TS 解决的,就先别急着上 Rust。

所以,当我看到:

  • Anthropic 在 Agent 业务上,押注 Bun
  • Bun 自己的定位又是:“把你写 Node 的那套事,全部做到更快、更简单”(bun.sh)

那我心里那个问题就更具体了:

在「写 Agent」这个具体场景里,Bun 真的比 Node 体验更好吗?

我不太喜欢只看 benchmark,于是就决定写点实打实的东西来试试看。

一个不到百行的 ReAct Agent Demo

为了验证这个问题,我给自己定了一个很小的练习目标:

Bun + TypeScript,写一个「不到百行代码」的极简 ReAct Agent Demo。

079.webp

这个 Agent 不追求多复杂的功能,专注这几件事:

  1. 维护一个最小可用的 ReAct loop(思考 → 行动 → 观察 → 再思考)

  2. 内置少量工具,比如:

    • read:读取文件内容
    • write:写入/更新文件
  3. 整个项目尽量清爽,不做多余封装

写的过程非常「AI 化」:

  • 先用 Node 写了一版最朴素的版本
  • 再让 AI 帮我改写为 Bun 的 API
  • 我负责补坑、重构、整理结构

结果出乎意料地顺畅:

  • 很多原来需要 fs 模块、各种工具库的地方,在 Bun 里可以用自己的 API 写得更简洁,比如 Bun.file()Bun.write() 这种。(bun.sh)
  • dev / build / test 自己都不用纠结,直接 bun xxx 的那一套就行,几乎不需要额外配置。
  • Agent loop 那段代码本身是比较核心的逻辑,集中精力在这里就好了,不太需要为工程化配置分心。

更关键的是:

整个 Demo 框架搭好后,我有一种「这个东西是可以往前认真维护」的感觉,而不是写完就丢。

这和我之前写很多 Node 小脚本的心理预期是完全不一样的。

再聊一句「全栈」:TS 之后,运行时是谁?

周末刷 X,看到老许的帖子,如下:

080.webp

这句话我很认同。 前后端统一 TS 技术栈,对个人开发者来说太友好了:

  • 一门语言,从浏览器跑到服务端、再到 CLI、工具链
  • 类型系统本身就成为你的「文档 + 校验器」

那顺着这个思路,下一步的问题就自然来了:

既然语言是 TypeScript,那运行时呢? 未来的 TS 运行时,会不会从 Node,逐渐走向 Bun(一部分场景)?

我不打算在这里给出一个结论——毕竟 Node 的体量、生态、历史沉淀摆在那里,而 Bun 目前也还只是一个「两年多一点」的新 runtime。(bun.sh)

但从我自己的体验看,有两点很值得关注:

  1. Bun 是为「现代 JS/TS 开发」重新设计过的 它自带 bundler、test runner、包管理器,不再是「一个 runtime + 一堆第三方工具拼装」的模式。(bun.sh)

  2. Bun 和 Agent、AI 工程这类新场景的契合度异常高

    • CLI 需要冷启动快 → Bun 做得很好
    • 项目喜欢 all-in-one 工具链 → Bun 直接内置
    • 你本来就写 TS → Bun 对 TS 原生友好

这些特性,叠加起来就会让人产生那种感觉:

「如果我要重写一遍现在手里这些 Node 脚本、工具、Agent,那好像真的可以考虑直接上 Bun。」

这个专题想写些什么?

既然已经有了实践的契机(ReAct Agent Demo),再加上我一贯「学新东西喜欢顺手写点东西」的习惯,那干脆就开一个新专题:《Bun 指南》。

这第一篇就是引言,只回答一个问题:为什么要学 Bun?

后面的几篇,我打算按这样的节奏展开(暂定):

  1. 安装与上手:从 Node 迁移到 Bun 有多难?

    • 安装 Bun
    • 跑起第一个 bun run / bun dev
    • 把一个简单的 Node 脚本迁移到 Bun
  2. Bun 的 all-in-one 工具链

    • 包管理器:bun install vs npm/pnpm
    • bun test:内置测试如何用
    • bun build:打包、构建、单文件可执行
  3. 用 Bun 写 HTTP 服务

    • Bun.serve() 的基本用法(bun.sh)
    • 和 Node / Express 的直观对比
    • 简单的 API 服务实战
  4. 文件、进程与工具脚本:Bun 的标准库体验

    • Bun.file() / Bun.write() 等常用 API
    • 用 Bun 写一个实用 CLI 小工具
  5. 实战篇:用 Bun + TS 写一个 ReAct Agent Demo

    • 核心 loop 逻辑拆解
    • 如何组织「工具」层(read / write / 调用外部接口)
    • 运行、调试与「AI Coding 助攻」的一些经验
  6. 踩坑记录 & 迁移经验

    • 哪些地方真的爽了
    • 哪些地方还不如 Node 成熟
    • 写给准备从 Node 迁到 Bun 的你

如果你已经会写 JavaScript / Node,这个专题不会从「什么是 Promise」讲起,而会更聚焦在:

  • 从 Node 迁移到 Bun 时,大脑需要切换的那一小部分东西
  • 在 Bun 里,如何用尽量少的代码做更多事
  • 结合 Agent / AI 工程场景,感受下一代 JS/TS runtime 的味道

写在最后

我并不打算把 Bun 神化成「Node 杀手」——至少短期内,它更像是:

一个极适合「个人开发者 / 小团队 / 新项目 / Agent 工程」尝鲜的 JS/TS 运行时。

而这个系列,就是我在这个尝试过程中的「实践笔记」:

  • 一部分是 Bun 本身的能力整理
  • 一部分是我做 ReAct Agent 的真实踩坑经验
  • 还有一部分,是在这个过程中我对「全栈 / TS / runtime 演进」的一些小思考

如果你:

  • 已经会 Node / TS
  • 正在关注 Agent / AI 工程
  • 又对「更快、更简洁的 JS/TS 运行时」有点好奇

那欢迎一起把这个系列看下去,也欢迎你在实践中告诉我: 在你的场景里,Bun 到底是不是一个更好的选择?

掌握原型链,写出不翻车的 JS 继承

2025年12月9日 21:16

原型与原型链基础

在学习之前需要回顾一下这些基础知识

  • prototype所有函数都包含的一个属性(对象),而对于内置构造函数通常在上面预定义了部分方法,例如:.push.toString等。
  • __proto__所有 JS 对象都有的一个内部属性,指向该对象的原型对象(即父对象的prototype)。
  • constructor 每个 prototype 对象都有一个默认的 constructor 属性,指回其构造函数。

不妨来看个例子:

// 构造函数
function Person(name) {
  this.name = name;
} 

const alice = new Person('Alice');
console.log(alice.__proto__) // Person.prototype
console.log(Person.prototype.constructor) // Person

而它的原型链就是:

// --> 代表.__proto__属性
alice --> Person.prototype --> Object.prototype --> null(所有原型链的终点都是 null)

四种原型继承方式详解

1. 直接赋值父类原型(不推荐)

先来看一个例子:

// 父类构造函数
function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = "动物";

function Cat(color, name, age) {
    // 继承父类的属性
    Animal.apply(this, [name, age]);
    // 使用 .call 也可以
    // Animal.call(this, name, age);
    this.color = color;
}
        
Cat.prototype = Animal.prototype; // 指向父类原型

补充一下 callapply 的区别:

  • call逐个传参,即:fn.call(this, arg1, arg2, ...)
  • apply数组传参,即:fn.apply(this, [arg1, arg2, ...])

这样做下来感觉并没有什么不合适,继承了父类的属性,同时也指向了父类的原型对象。但是这样并不完整,因为如果调用子类的prototype上的constructor属性,正确的继承应该是指向子类自身。

而当我们在代码中执行console.log(Cat.prototype.constructor)最后得到的结果却是 Animal

image.png

所以在最后还需要手动修复构造函数指向,即添加:

Cat.prototype.constructor = Cat;

但是这样做并非万无一失,在这里我们需要了解 JS 的一个特性,那就是 引用式赋值 。在 JS 中,基本数据类型(8种)是按值赋值的,而对象类型是按引用赋值

引用式赋值:指当我将一个对象赋值给另一个变量时,并不是复制了这个对象本身,而是复制了对象在内存中的地址引用,这样就导致两个变量都指向同一个内存位置,不论修改哪个都会对另一个造成影响。

举个最简单的例子:

let obj1 = { name: 'Alice' };
let obj2 = obj1;      // 引用式赋值
obj2.name = 'Bob';

console.log(obj1.name); // "Bob"
console.log(obj1 === obj2); // true(指向同一对象)

回到我们的继承函数,里面就有一个是引用式赋值

Cat.prototype = Animal.prototype;

这就导致了当我们在Cat.prototype上添加方法还是什么的,会污染Animal.prototype,所以尽量别使用直接赋值父类原型

2. 原型链继承(有点缺点)

我们将上面的例子拿下来

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = "动物";

function Cat(color, name, age) {
    Animal.apply(this, [name, age]);
    this.color = color;
}

但是我们这里使用原型链式继承

Cat.prototype = new Animal(); 
Cat.prototype.constructor = Cat; // 修复构造函数指向     

而这里需要了解一下 new 的伪代码了

// 伪代码 new Animal()
let obj = {};
Animal.call(obj); // 也可以用 apply
obj.__proto__ = Animal.prototype;
return obj

首先创建一个空对象,再将父类的this指向空对象,并将空对象的__proto__指向父类的prototype,也就是连上原型链,最后再返还这个空对象。

但是需要注意的是,这里后续创建的所有实例都是共享父类的属性的,在任意一个实例中对父类属性进行修改都会对其他实例造成影响,例如:

function Animal(name) {
  this.name = name;
  this.colors = ['red', 'blue']; // 引用类型属性
}

function Cat() {}
Cat.prototype = new Animal(); // 所有 Cat 实例共享 colors
Cat.prototype.constructor = Cat;

const cat1 = new Cat();
const cat2 = new Cat();
cat1.colors.push('green');
console.log(cat2.colors); // ['red', 'blue', 'green'] 共享引用

3. 空对象中介模式(经典解决方案)

在直接赋值中,不论怎样都会对父类造成影响,那么如果我们在 父类和子类 中间找一个中介来隔断,是不是就能解决这个问题,而这也是我们最经典的解决方法----空对象中介模式

依旧将前面的例子拿来:

function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.species = "动物";

function Cat(color, name, age) {
    Animal.apply(this, [name, age]);
    this.color = color;
}

不妨来看看中介模式是怎么使用的

var F = function() {}; // 空对象中介
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;

其中我们将F.prototype直接继承Animal.prototype,虽然会导致 引用式赋值,但是只要我对Cat.prototype修改不对F造成影响,那么间接对Animal就没有影响。

而最精妙一点就是Cat.prototype = new F();这步,我们根据之前的伪代码可以知道,这步是将Cat.prototype.__proto__ = F.prototype,也就在变相变成Cat.prototype.__proto__ = Animal.prototype

那么即使我们对Cat.prototype本身进行重新赋值,或者添加任何其他属性也不会影响Cat.prototype.__proto__,除非我们显示修改它(或者对修改F.prototype

拓展:

当然我们也可以将其写成继承函数(extend),这也算手写题吧 QwQ

function extend(Parent, Child) {
    // 中介函数
    var F = function() {}; // 函数表达式(有内存开销,但是因为是空函数问题不大)
    // 指向父类原型
    F.prototype = Parent.prototype;
    // 指向空对象实例
    Child.prototype = new F(); // 实例的修改不会影响原型对象
    // 修复构造函数指向
    Child.prototype.constructor = Child; 
}

extend(Animal, Cat)

4. Object.create()(ES5 推荐方式)

Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;

Animal.prototype 为原型创建新对象,在不污染父类构造函数的前提下,更安全地建立子类到父类的原型链连接,并且更加适配现代继承写法。

总结

继承方式 是否推荐 说明
直接赋值原型 污染父类 constructor
原型链继承 引用属性共享问题
中介函数 ✅(兼容旧环境) 安全隔离
Object.create() ✅✅ 现代标准,语义清晰

最佳实践:

  1. 属性继承 → 用 Parent.call(this, ...args)
  2. 方法继承 → 用 Child.prototype = Object.create(Parent.prototype)
  3. 修复 constructor → 显式设置 Child.prototype.constructor = Child

原型继承是 JS 的灵魂。理解 call/apply ,掌握 Object.create 如何安全构建原型链,了解其他构建方法有何不妥,为我们写出健壮的继承结构添一把力

数据字典技术方案实战

作者 树深遇鹿
2025年12月9日 18:01

前言

在后台与中台系统开发领域,数据字典是极为常见且至关重要的概念,相信大多数从事相关开发工作的朋友都对其耳熟能详。几乎每一个成熟的项目,都会专门设置一个字典模块,用于精心维护各类字典数据。这些字典数据在系统中扮演着举足轻重的角色,为下拉框、转义值、单选按钮等组件提供了不可或缺的基础数据支撑。

我自工作以来参与过很多个项目,既有从零开始搭建的,也有接手他人项目的。在实践过程中,我发现不同项目对字典的实现方式各不相同,且各有侧重。例如,对于项目中的字典基本不会发生变化的,项目通常会采用首次全部加载到本地缓存的方式。这种方式能显著节省网络请求次数,提升系统响应速度。然而,对于项目中的字典经常变动的,项目则会采用按需加载的方式,即哪里需要使用字典值,就在哪里进行加载。但这种方式也存在弊端,当某个页面需要使用十多个字典值时,首次进入页面会一次性发出十多个请求来获取这些字典值,影响用户体验。

常见字典方案剖析

在当下,数据字典的实现方案丰富多样,各有优劣。下面将详细介绍几种常见的方案,并分析其特点。我将详细介绍几种常见的方案,并深入剖析其特点。这几种方案皆是我通过实践精心总结而来,其中方案四的思路是由我不爱吃鱼啦提供。

方案一:首次全部加载到本地进行缓存

方案描述

系统启动或用户首次访问时,将所有字典数据一次性加载到本地缓存中。后续使用过程中,直接从缓存中获取所需字典数据,无需再次向服务器发起请求。

优点

  • 访问速度快:后续访问时直接从本地缓存读取数据,无需等待网络请求,响应速度极快。
  • 减少网络请求:一次性加载后,后续使用无需频繁发起网络请求,降低了网络开销。
  • 网络依赖小:即使在网络不稳定的情况下,也能正常使用已缓存的字典数据,保证了系统的稳定性。

缺点

  • 首次加载时间长:若字典数据量较大,首次加载时可能需要较长时间,影响用户体验。
  • 占用存储空间:将所有字典数据存储在本地,会占用较多的本地存储空间,尤其是当字典数据量庞大时。
  • 缓存更新复杂:若字典数据频繁更新,需要设计复杂的缓存同步和更新机制,否则容易出现数据不一致的问题。

方案二:按需加载不缓存

方案描述

当用户触发特定操作,需要使用字典数据时,才从后端实时加载所需数据,且不进行本地缓存。每次使用字典数据时,都重新从服务器获取最新数据。

优点

  • 节省存储空间:不进行本地缓存,节省了本地存储空间,尤其适用于存储资源有限的设备。
  • 数据实时性高:每次获取的数据都是最新的,不存在缓存数据与后端不一致的问题,保证了数据的准确性。

缺点

  • 网络请求频繁:每次使用都需要发起网络请求,在网络状况不佳时,会导致加载时间变长,影响用户体验。
  • 增加服务器负担:频繁的网络请求会增加服务器的负担,尤其是在高并发场景下,可能影响服务器的性能。

方案三:首次按需加载并缓存

方案描述

用户首次访问某个字典数据时,从后端加载该数据并缓存到本地。后续再次访问该字典数据时,直接从缓存中读取,无需再次向服务器发起请求。

优点

  • 减少网络请求:结合了前两种方案的部分优点,既在一定程度上减少了网络请求次数,又不会一次性加载过多数据。
  • 节省存储空间:相较于首次全部加载到本地缓存的方式,不会一次性占用大量本地存储空间,节省了部分存储资源。

缺点

  • 缓存管理复杂:需要记录哪些数据已缓存,以便后续判断是否需要从缓存中读取或重新加载,增加了缓存管理的复杂度。
  • 缓存占用问题:对于不常使用的字典数据,缓存可能会占用不必要的存储空间,造成资源浪费。
  • 缓存更新难题:同样面临缓存更新的问题,需要设计合理的缓存更新策略,以保证数据的准确性和一致性。

方案四:按需加载 + 版本校验更新缓存

方案描述

用户按需发起字典数据请求,首次访问某个字典数据时,从后端加载并缓存到本地。在后端响应头中携带该字典数据的版本信息,后续每次请求该字典数据时,前端对比本地缓存的版本信息和响应头中的版本信息。若版本信息不一致,则清除本地缓存中对应的字典数据,并重新从后端加载最新数据;若版本信息一致,则直接使用本地缓存的数据。

优点

  • 数据实时性有保障:通过版本校验机制,能够及时获取到字典数据的更新,确保前端使用的数据与后端保持一致,避免了因缓存数据未及时更新而导致的业务问题。
  • 减少不必要的网络请求:在字典数据未更新时,直接使用本地缓存,无需发起网络请求,节省了网络带宽和服务器资源。
  • 平衡存储与性能:既不会像首次全部加载那样占用大量本地存储空间,又能在一定程度上减少网络请求,在存储和性能之间取得了较好的平衡。

缺点

  • 版本管理复杂:后端需要维护字典数据的版本信息,并且要确保版本号的准确性和唯一性,这增加了后端开发的复杂度和维护成本。
  • 额外开销:每次请求都需要进行版本信息对比操作,虽然开销较小,但在高并发场景下,可能会对系统性能产生一定影响。
  • 首次加载体验:首次加载字典数据时,依然需要从后端获取数据,若数据量较大或网络状况不佳,可能会影响用户体验。

方案选型建议

建议根据项目特性选择方案,没有最好的技术方案,只有最适合项目的技术方案:

  • 字典稳定且量小:方案一全量缓存
  • 字典频繁更新:方案四版本校验缓存
  • 存储敏感场景:方案三按需缓存
  • 实时性要求极高:方案二无缓存方案

ps:如果大家有更好的方案,也可以在评论区提出,让我们大家一起学习成长

代码实现(方案四)

下述代码的实现基于vue3+pinia,该代码实现了统一管理全局字典数据,支持按需加载、缓存复用、版本控制、动态更新、批量处理字典数据等功能。

pinia store的实现

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDictDetails, type Details } from '@/api/system/dict'

export const useDictStore = defineStore('dict', () => {
    // 存储字典数据,键为字典名称,值为字典详情数组
    const dictData = ref<Record<string, Details[]>>({})
    // 存储字典版本信息,键为字典名称,值为版本号
    const dictVersions = ref<string>('')

    /**
     * 更新字典版本信息
     * @param version 新的字典版本号
     */
    const updateDictVersion = (version: string) => {
        dictVersions.value = version
    }

    /**
     * 获取字典版本
     * @returns 字典版本号
     */
    const getDictVersion = () => {
        return dictVersions.value || ''
    }

    /**
     * 加载字典数据
     * @param dictNames 字典名称数组
     * @returns 加载的字典数据对象
     */
    const getDicts = async (dictNames: string[]) => {
        try {
            if (!Array.isArray(dictNames)) {
                return {};
            }
            // 过滤并去重有效字典名称
            const uniqueNames = [...new Set(dictNames.filter(name => 
                typeof name === 'string' && name.trim()
            ))];
            
            if (uniqueNames.length === 0) {
                return {};
            }

            const result: Record<string, Details[]> = {};
            const unloadedDicts: string[] = [];

            // 分离已加载和未加载的字典
            dictNames.forEach(name => {
                if (dictData.value[name]) {
                    result[name] = dictData.value[name];
                } else {
                    unloadedDicts.push(name);
                }
            });

            // 如果有未加载的字典,从接口请求获取
            if (unloadedDicts.length > 0) {
                const { data } = await getDictDetails(unloadedDicts);

                // 合并新加载的数据到结果
                Object.assign(result, data);

                // 更新全局字典缓存
                Object.assign(dictData.value, data);
            }

            return result;
        } catch (error) {
            console.error('加载字典数据失败:', error);
            return {};
        }
    };

    /**
     * 根据字典名称获取字典数据
     * @param name 字典名称
     * @returns 字典详情数组
     */
    const getDict = (name: string) => {
        return dictData.value[name] || []
    }

    /**
     * 根据字典名称和值获取字典标签
     * @param name 字典名称
     * @param value 字典值
     * @returns 字典标签
     */
    const getDictLabel = (name: string, value: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.value === value)
        return item?.label || ''
    }

    /**
     * 根据字典名称和标签获取字典值
     * @param name 字典名称
     * @param label 字典标签
     * @returns 字典值
     */
    const getDictValue = (name: string, label: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.label === label)
        return item?.value || ''
    }

    /**
     * 清除指定字典数据
     * @param names 字典名称
     */
    const clearDicts = (names: string[]) => {
        names.forEach(name => {
            clearDict(name)
        })
    }


    /**
     * 清除指定字典数据
     * @param name 字典名称
     */
    const clearDict = (name: string) => {
        delete dictData.value[name]
    }

    /**
     * 清除所有字典数据
     */
    const clearAllDict = () => {
        dictData.value = {}
    }

    return {
        dictData,
        updateDictVersion,
        getDictVersion,
        getDict,
        getDicts,
        getDictLabel,
        getDictValue,
        clearDict,
        clearDicts,
        clearAllDict
    }
})

useDict 实现

为组件提供字典数据的统一访问入口,封装了字典数据的初始化加载、详情查询、标签/值转换等高频操作,简化组件层对字典数据的调用逻辑。

import { type Details } from '@/api/system/dict'
import { useDictStore } from '@/store/dict'

// 根据字典值的name获取字典详情
export const useDict = (params: string[] = []) => {

  const dict = ref<Record<string, Details[]>>()
  const dictStore = useDictStore()

  const getDicts = async () => {
    dict.value = await dictStore.getDicts(params)
  }

  // 初始化字典数据
  getDicts()

  // 根据字典名称获取字典数据
  const getDict = (name: string) => {
    return dictStore.getDict(name)
  }

  // 根据字典值获取字典label
  const getDictLabel = (name: string, value: string) => {
    return dictStore.getDictLabel(name, value)
  }

  return {
    dict,
    getDict,
    getDictLabel
  }
}

响应拦截

主要用于获取字典的版本信息,通过对比版本信息,从而确定是否清除本地的字典缓存数据,并更新本地缓存的版本信息

// 响应拦截器
service.interceptors.response.use(
  // AxiosResponse
  (response: AxiosResponse) => {
    const dictVersion = response.headers['x-dictionary-version']
    if (dictVersion) {
      const dictStore = useDictStore()
      // 对比版本是否有更新
      if (dictStore.getDictVersion() !== dictVersion) {
        dictStore.clearAllDict()
        dictStore.updateDictVersion(dictVersion || '')
      }
    }
    // ...项目中的业务逻辑
  }
)

项目中的具体使用

下述的怎么使用封装的字典管理的简单demo

<script setup lang="ts">
import { useDict } from '@/hooks/useDict'
// 获取dict
const { dict, getDictLabel } =  useDict(['status', 'sex'])
console.log(dict.status, dict.sex)
</script>

结语

本文介绍了四种主流的数据字典实现方案,从全量加载到按需加载,从无缓存到版本校验缓存,每种方案都展现了其独特的优势与缺点。通过对比分析,我们不难发现,没有一种方案能够适用于所有场景,而是需要根据项目的具体特性进行灵活选择。对于字典稳定且量小的项目,全量缓存方案能够带来极致的响应速度;对于字典频繁更新的场景,版本校验缓存方案则能在保障数据实时性的同时,实现存储空间与网络请求的平衡优化。未来,随着技术的不断进步与应用场景的不断拓展,数据字典的实现方案也将持续演进。

博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。

webpack和vite区别及原理实现

作者 光影少年
2025年12月9日 17:52

WebpackVite 都是用于构建现代前端应用的构建工具,它们在原理和实现上有显著的区别。下面我将详细比较它们的异同,帮助你了解两者的工作原理以及各自的优势。


一、Webpack 和 Vite 的核心区别

特性 Webpack Vite
构建速度 较慢,特别是大型项目 快,几乎是即时的
构建原理 通过打包所有资源,生成最终的 bundle 采用按需编译,利用浏览器原生支持 ES 模块
开发模式 一开始就进行全部的打包,编译速度较慢 通过浏览器原生支持 ES Modules,只有请求的模块才会被处理
构建产物 生成一个或多个 bundle 文件 基于 ES Module 按需加载,不同于 Webpack 完整的打包
支持类型 支持所有 JavaScript,CSS,图片,字体等 主要支持 ES 模块,针对现代浏览器优化
使用体验 配置复杂,适用于各种需求和优化 配置简单,适合快速开发,但功能不如 Webpack 灵活

二、Webpack 原理和实现

1. 传统的打包工具

Webpack 是一个 模块打包器,它将所有的静态资源(JavaScript、CSS、图片等)当作模块处理,并生成一个或多个 bundle 文件,最终这些文件将被浏览器加载。

2. 打包过程:

Webpack 的打包过程主要包含以下几个阶段:

  1. 解析阶段(Parsing)

    • Webpack 从入口文件(entry)开始,递归地解析每一个依赖,生成依赖图。
    • 在解析时,Webpack 会调用 loader 对不同类型的文件进行预处理(如 Babel 转译、Sass 编译等)。
  2. 构建阶段(Building)

    • Webpack 会通过 loaderplugin 处理所有模块,生成最终的 AST(抽象语法树)
    • 使用 module bundling 将所有模块合并成一个或多个文件(bundle)。
  3. 优化阶段(Optimization)

    • Webpack 会对生成的 bundle 进行优化,如:分割代码(Code Splitting)、压缩(Terser)等。
  4. 输出阶段(Output)

    • 最终将 bundle 输出到指定的目录,并生成相应的文件供浏览器使用。

3. Webpack 需要时间打包所有资源

由于 Webpack 会将所有资源都打包成一个或多个文件,所以当你做 webpack --mode development 命令时,它必须编译所有文件,这就导致开发过程中启动时间较长。


三、Vite 原理和实现

1. 基于浏览器原生支持的 ES Modules

Vite 的核心原理是利用浏览器原生支持 ES Modules,它并不像 Webpack 那样进行完整的打包,而是通过 按需加载 来提高构建速度。

2. Vite 开发流程:

Vite 的开发过程分为两个阶段:

开发阶段:
  1. 按需编译

    • 当你启动 Vite 时,它不会一次性打包整个项目,而是仅对 首次请求的模块 进行编译和服务。比如,只有用户第一次访问某个页面时,Vite 才会编译该页面依赖的 JavaScript 和 CSS。
  2. 热模块替换(HMR)

    • Vite 提供了 即时的热模块替换,当你在开发过程中修改了某个模块,Vite 会只编译并替换该模块,而不是重新打包整个项目。这大大提高了开发体验。
构建阶段:
  1. 生产构建(build)

    • 在生产环境下,Vite 使用 Rollup(一个现代的 JavaScript 打包工具)进行最终的打包,将所有模块合并成一个优化过的 bundle,进行代码拆分,压缩等优化,生成最终的静态文件。

3. 不需要一直打包全部资源

Vite 的按需编译和快速响应机制,使得开发过程非常迅速。只有在页面访问时,才会处理该页面的依赖,避免了 Webpack 那种完全打包的性能消耗。


四、Webpack 与 Vite 优缺点对比

特性 Webpack Vite
构建速度 较慢(尤其是大型项目时) 极快,尤其是冷启动和热更新
配置复杂性 配置较为复杂,需要处理许多细节 配置简单,开箱即用,少配置即可
开发体验 开发中每次更改都会触发完整编译 热更新速度快,修改后的内容即时反应
支持的功能 功能强大,支持的插件丰富,几乎无所不包 适合现代前端开发,特性较为简洁和聚焦
构建产物 生成一个或多个较大的 bundle 生成多个按需加载的小文件
适用场景 适合中大型复杂项目,支持更多自定义需求 适合中小型项目、现代前端框架(如 React/Vue)

五、总结

  1. Webpack:

    • 适用于复杂的前端项目,支持插件和加载器的灵活扩展。
    • 在开发时,启动和热更新较慢,尤其是大型项目。
    • 配置复杂,需要更多的手动配置来实现项目定制。
  2. Vite:

    • 更适合现代前端开发,特别是对开发速度和用户体验有高要求的场景。
    • 使用浏览器原生的 ES Modules 来实现按需编译和即时热更新,开发体验极佳。
    • 适用于现代前端框架(如 Vue、React),并在生产环境中使用 Rollup 进行高效构建。

📌 推荐场景:

  • Webpack 适合 大型、复杂的前端项目,尤其是有多种技术栈、框架,或者需要更多自定义构建的项目。
  • Vite 更适合 快速开发、现代化前端应用,尤其是小型或中型项目,或者想要享受极速开发体验的团队。

React Scheduler为何采用MessageChannel调度?

2025年12月9日 17:43
特性 setTimeout requestAnimationFrame requestIdleCallback MessageChannel
本质 定时器宏任务 动画回调钩子 空闲期回调钩子 消息通信宏任务
执行时机 延迟指定毫秒后(不精确) 下一帧渲染之前(与刷新率同步) 浏览器空闲时(一帧的末尾) 下一轮事件循环(作为宏任务)
主要设计目的 延迟执行代码 实现流畅动画 执行低优先级后台任务 不同上下文间通信
关键优势 通用、灵活、兼容性好 动画流畅、节能(后台暂停) 不阻塞渲染与交互,利用空闲时间 延迟极短且稳定,可精准控制任务切片
关键缺陷 时序不精确,嵌套有最小延迟(如4ms) 依赖渲染周期,执行频率固定(~16.7ms) 执行时机不可控,可能长期得不到调用 非用于调度,是“创造性”用法
React调度的适用性 ❌ 延迟不可控,不适合精细调度 ❌ 依赖渲染节奏,无法在帧中多次调度 ❌ 时机不可靠,无法满足及时响应需求 ✅ 在事件循环中及时插入任务,实现可中断调度的理想选择

📝 各API功能与特性详解

下面我们来具体看看每个API的核心工作机制和适用场景。

  1. setTimeout

    • 功能:最基础的异步定时器,用于在指定的延迟(毫秒)后,将回调函数推入任务队列等待执行
    • 执行机制:它设置的是一个“最小延迟”,而非精确时间。回调的实际执行时间会受到主线程上其他任务(如同步代码、微任务、UI渲染)的阻塞,延迟可能远大于设定值
    • 使用场景:适用于对时间精度要求不高的延迟操作,如防抖/节流、轮询检查等。
  2. requestAnimationFrame

    • 功能:专为动画设计的API,其回调函数会在浏览器下一次重绘(即绘制下一帧)之前执行
    • 执行机制:与显示器的刷新率(通常是60Hz,约16.7ms/帧)同步。浏览器会自动优化调用,在页面不可见时(如标签页被隐藏)会自动暂停,以节省资源
    • 使用场景实现任何需要平滑过渡的动画效果,是替代setTimeout做动画的最佳实践
  3. requestIdleCallback

    • 功能:允许你在浏览器空闲时期调度低优先级任务
    • 执行机制:在一帧处理完用户输入、requestAnimationFrame回调、布局和绘制等关键任务后,如果还有剩余时间,才会执行它的回调回调函数会接收一个IdleDeadline参数,告诉你当前帧还剩余多少空闲时间
    • 使用场景:适合执行一些非紧急的后台任务,如数据上报、非关键的数据预取等。
  4. MessageChannel

    • 功能:用于在不同浏览器上下文(如两个iframe、主线程与Web Worker)间建立双向通信的通道
    • 执行机制:调用port.postMessage()方法,会向消息队列添加一个宏任务。这个任务会在当前事件循环的微任务执行完毕后、下一次事件循环中执行。
    • 使用场景:主要应用于跨上下文通信。它在React调度中的用法,是利用其能产生一个在下一轮事件循环中尽早执行的宏任务的特性。

⚙️ 为什么React最终选择了MessageChannel?

React调度器的核心目标是:实现可中断的并发渲染,将长任务切成小片,在每一帧中插入执行,同时能快速响应高优先级更新。这就要求调度器能主动、及时地“让出”主线程。

结合上表和分析,其他API不适用于此的原因如下:

  • setTimeout延迟不稳定且不可控。其最小延迟(如4ms)在密集调度时会造成浪费,更严重的是,延迟时间可能被拉长,导致调度器无法在预期时间内“苏醒”并交还主线程,影响页面响应
  • requestAnimationFrame调用频率被锁死在屏幕刷新率(约16.7ms一次) 。这意味着即便一帧中有大量空闲时间,调度器也无法插入更多任务切片,无法充分利用帧内的空闲资源
  • requestIdleCallback执行时机“太被动”且不稳定。它依赖于浏览器的“空闲通知”,但空闲期可能很短或很久都不出现。对于需要主动、可预测地进行任务切片的调度器来说,这不可靠,同时,其兼容性也不够理想。

MessageChannel的优势正在于解决了上述问题:

  1. 主动且及时的调度:通过port.postMessage(),React可以主动在下一个事件循环中创建一个宏任务来继续工作。这让调度器能精确地在每个5ms左右的时间片(这是React设定的切片时间目标-7)结束后“中断”自己,及时归还主线程给浏览器进行渲染或处理交互。
  2. 更高的执行频率:它不依赖屏幕刷新率,可以在同一帧内的多次事件循环中连续调度,从而更密集、更充分地利用一帧之内的计算资源。
  3. 避免微任务的弊端:为什么不直接用Promise(微任务)?因为微任务会在当前事件循环中连续执行直到队列清空,这同样会长时间阻塞主线程,达不到“可中断”的目的

💎 总结

React选择MessageChannel,是基于其能产生一个在事件循环中及时、稳定执行的宏任务这一特性。这为React实现主动、可中断、基于时间片的任务调度提供了最合适的底层机制,从而在实现并发渲染的同时,保障了浏览器的渲染和用户交互能获得最高优先级的响应。

用一篇文章带你手写Vue中的reactive响应式

作者 momo06117
2025年12月9日 17:42

关于reactive

最近小编在刷vue面经时,有看到ref与reacive的区别,关于ref大家都耳熟能详,但是对于ractive,小编一开始只了解到与ref的区别是不需要.value访问值并且只能绑定引用数据类型,而ref既能绑定基本类型又能绑定引用数据类型,那么reactive的存在的意义在哪里?

这样的困惑驱使小编不能仅满足于表面的理解。在 Vue 庞大而精密的体系里,reactive 必然承载着特殊的使命。通过查阅资料和源码,小编这了解到reactive背后的奥秘,下面就一一道来。

github.com/vuejs/core/… 附上github上reactive的源码

reactive的设计理念

首先,为什么reactive只能接受引用数据类型?这是因为reactive是基于ES6的Proxy实现的响应式,而Proxy只能代理引用数据类型。

而ref在绑定基本数据类型时是基于Object.defineProperty通过数据劫持变化来实现数据响应式的。对于引用数据类型,defineProperty的方法存在一些弊端:比如无法监听到对象属性的新增和删除,也无法监听数组索引的直接设置和length变化。这里简单对比一下vue响应式方式。

实现方式 适用类型 核心缺陷
Object.defineProperty(ref 底层) 基本类型(包装为对象) 1. 无法监听对象新增 / 删除属性;2. 无法监听数组索引 / 长度变化;3. 只能劫持单个属性
Proxy(reactive 底层) 引用类型(对象 / 数组 / Map 等) 无上述缺陷,可代理整个对象,支持动态增删属性、数组操作

因此,ref在代理对象时也是借助到reactive

reactive基于Proxy的响应式系统能完美解决这些问题,下面我们来写一个简单的reactive响应式

reactive代理对象

要实现reactive实现数据响应式,我们需要先创建一个reactive方法,通过Proxy进行代理。 其中,proxy代理的target需要是对象并且没有被代理过。

//创建一个Map来保存代理过的reactive
const reactiveMap = new Map()

function isReactive(target){
  if(reactiveMap.has(target)){
      return true
  }
  return false
}

export function reactive(target){
  //检查是否已经被代理
  if(isReactive(target)){
    return target
  }


  return createReactiveObject(
      target,
      mutableHandlers
  )
}

export function createReactiveObject(target,mutableHandlers){
   //检查是否为对象
    if(typeof target !== 'object' || target == null){
      return target
    }
    //Proxy 接受俩个参数 代理对象和代理方法
    const newReactive =  new Proxy(target,mutableHandlers)
    reactiveMap.set(target,newReactive)
    return newReactive
}

Get & Set

我们新建一个文件导出mutableHandlers方法供proxy使用,mutableHandlers需要有一个get与set,分别在访问和修改target时触发。get需要实现依赖收集,当访问对象属性时将对应的副作用函数收集到依赖集合,set需要实现当对象属性更改时,更新依赖,通知副函数执行。

import {track,trigger} from './effect.js'

const get = (target, key) => {
  // target代理对象 key键名
  track(target, key) // 副作用收集
  // 相当于target[key]
  const value = Reflect.get(target, key)
  if (typeof value === 'object' && value !== null) {
    return reactive(value)
  }
  return value
}

const set = (target, key, value) => {
  // console.log('target被设置值', key, value)
  const oldValue = Reflect.get(target, key)
  // 比较是否更新
  if (oldValue !== value) {
    const result = Reflect.set(target, key, value)
    trigger(target, key, value, oldValue)
    return result
  }
  return true // 如果值没有变化,返回true表示设置成功
}

export const mutableHandlers = {
  get,
  set
}

副作用的收集与触发

接下来,我们要完成依赖收集函数track和副作用触发函数trigger。做之前我们要思考一下他们要做的事情: track在get时触发,主要负责将副作用函数effect载入targetMap中,tigger在set时触发 主要负责执行副作用函数。

提一嘴 *WeakMap是es6的新特性 特殊的是键必须是引用类型 *

// 存储依赖关系
const targetMap = new WeakMap() // 可以看set({}:map())
let activeEffect //当前执行的副作用函数

// 副作用的执行函数
export const effect = (callback) => {
  activeEffect = callback
  callback()
  activeEffect = null
}

//依赖收集
export const track = (target, key) => {
  // 如果该依赖没有副作用直接返回
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map())) // 第一次收集该依赖
  }

  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set())) // 依赖的第一个副作用
  }
  dep.add(activeEffect)
}

//触发
export const trigger = (target, key) => {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => effect())
  }
}

测试

到此为止,我们已经简单完成reactive的demo。接下来新建一个vue文件来测试一下这个简易的reactiveDemo

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

<script setup>
import {reactive} from './utils/reactivity/reactive.js'
import {effect} from './utils/reactivity/effect.js'
const count = reactive({
  value: 0
})

effect(()=>{
  console.log('count的值是:', count.value) 
})

effect(()=>{
  console.log(count.value, '正在执行计算')
})

effect(()=>{
  console.log(count.value, '正在渲染页面')
})


const handleClick = ()=>{
  count.value++
}
</script>

当我们点击按钮触发count.value++时,到触发代理proxy的set,执行targget,从而触发此前访问过count.value相关的副作用函数,完成更新。

image.png

总结

reactive是Vue中实现响应式的Api,它通过proxy实现代理,为ref代理对象提供支持。reactive不是“多余选项”了,而是vue响应式的核心支柱。

而要实现reactive的核心在于:

  • 使用proxy代理
  • 收集与触发副作用函数

*以上是小编在学习过程中的一点小见解 如果有写得不对的 欢迎在评论区指出 *

image.png

前端基础数据中心:从混乱到统一的架构演进

2025年12月9日 17:41

本文记录了我们团队在 Vue 3 + TypeScript 项目中,如何将散乱的基础数据管理逻辑重构为统一的「基础数据中心」。如果你的项目也有类似的痛点,希望这篇文章能给你一些参考。

一、问题是怎么来的

做过 B 端系统的同学应该都有体会——基础数据无处不在。港口、船舶、航线、货币、字典……这些数据在几乎每个页面都会用到,要么是下拉选择,要么是代码翻译,要么是表格筛选。

我们项目一开始的做法很「朴素」:哪里用到就哪里请求。后来发现这样不行,同一个港口列表接口一个页面能请求三四次。于是开始加缓存,问题是加着加着,代码变成了这样:

store/basicData/cache.ts      <- Pinia 实现的缓存
composables/basicData/cache.ts  <- VueUse + localStorage 实现的缓存
store/port.ts                   <- 独立的港口缓存(历史遗留)

三套缓存系统,各自为政。更要命的是 CACHE_KEYS 这个常量在两个地方都有定义,改一处忘一处是常态。

某天排查一个 bug:用户反馈页面显示的港口名称和实际不一致。查了半天发现是两套缓存系统的数据版本不同步——A 组件用的 Pinia 缓存已经过期刷新了,B 组件用的 localStorage 缓存还是旧数据。

是时候重构了。

二、想清楚再动手

重构之前,我们先梳理了需求优先级:

需求 优先级 说明
跨组件数据共享 P0 同一份数据,全局只请求一次
缓存 + 过期机制 P0 减少请求,但数据要能自动刷新
请求去重 P1 并发请求同一接口时,只发一次
持久化 P1 关键数据存 localStorage,提升首屏速度
DevTools 调试 P2 能在 Vue DevTools 里看到缓存状态

基于这些需求,我们确定了架构原则:

Store 管状态,Composable 封业务,Component 只消费。

三、分层架构设计

最终的架构分三层:

┌─────────────────────────────────────────────────┐
│               Component Layer                    │
│              (Vue 组件/页面)                     │
│  只使用 Composables,不直接访问 Store            │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│              Composable Layer                    │
│           (composables/basicData/)              │
│  usePorts / useVessels / useDict / ...          │
│  封装 Store,提供业务友好的 API                  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│                Store Layer                       │
│             (store/basicData/)                  │
│  useBasicDataStore                              │
│  统一缓存、加载状态、请求去重、持久化            │
└─────────────────────────────────────────────────┘

为什么要分这么多层?

  • Store 层:单一数据源,解决「数据从哪来」的问题
  • Composable 层:业务封装,解决「数据怎么用」的问题
  • Component 层:纯消费,只关心「界面怎么展示」

这样分层之后,职责边界就清晰了。组件开发者不用关心缓存策略,只管调 usePorts() 拿数据就行。

四、核心实现

4.1 Store 层:请求去重是关键

Store 层最核心的逻辑是 loadData 方法。这里要处理三种情况:

  1. 缓存命中 → 直接返回
  2. 有相同请求正在进行 → 复用已有 Promise
  3. 发起新请求 → 请求完成后写入缓存
// store/basicData/useBasicData.ts
export const useBasicDataStore = defineStore('basic-data', () => {
  const cacheMap = ref<Map<BasicDataType, CacheEntry>>(new Map())
  const pendingRequests = new Map<BasicDataType, Promise<unknown>>()

  async function loadData<T>(
    type: BasicDataType,
    fetcher: () => Promise<T>,
    config?: CacheConfig
  ): Promise<T | null> {
    // 1. 缓存命中
    const cached = getCache<T>(type)
    if (cached !== null) return cached

    // 2. 请求去重——这是关键
    const pending = pendingRequests.get(type)
    if (pending) return pending as Promise<T | null>

    // 3. 发起新请求
    const request = (async () => {
      try {
        const data = await fetcher()
        setCache(type, data, config)
        return data
      } finally {
        pendingRequests.delete(type)
      }
    })()

    pendingRequests.set(type, request)
    return request
  }

  return { loadData, getCache, setCache, clearCache }
})

请求去重的实现很简单:用一个 Map 存储正在进行的 Promise。当第二个请求进来时,直接返回已有的 Promise,不发新请求。

这样即使页面上 10 个组件同时调用 usePorts(),实际 API 请求也只有 1 次。

4.2 Composable 层:工厂函数批量生成

港口、船舶、航线……这些 Composable 的逻辑高度相似,用工厂函数批量生成:

// composables/basicData/hooks.ts
function createBasicDataComposable<T extends BaseDataItem>(
  type: BasicDataType,
  fetcher: () => Promise<T[]>,
  config?: CacheConfig
) {
  return () => {
    const store = useBasicDataStore()

    // 响应式数据
    const data = computed(() => store.getCache<T[]>(type) || [])
    const loading = computed(() => store.getLoadingState(type).loading)
    const isReady = computed(() => data.value.length > 0)

    // 自动加载
    store.loadData(type, fetcher, config)

    // 业务方法
    const getByCode = (code: string) => 
      data.value.find(item => item.code === code)

    const options = computed(() => 
      data.value.map(item => ({
        label: item.nameCn,
        value: item.code
      }))
    )

    return { data, loading, isReady, getByCode, options, refresh }
  }
}

// 一行代码定义一个 Composable
export const usePorts = createBasicDataComposable('ports', fetchPorts, { ttl: 15 * 60 * 1000 })
export const useVessels = createBasicDataComposable('vessels', fetchVessels, { ttl: 15 * 60 * 1000 })
export const useLanes = createBasicDataComposable('lanes', fetchLanes, { ttl: 30 * 60 * 1000 })

这样做的好处是:

  • 新增一种基础数据,只需加一行代码
  • 所有 Composable 的 API 完全一致,学习成本低
  • 类型安全,TypeScript 能正确推断返回类型

4.3 字典数据:特殊处理

字典数据稍微复杂一些,因为它是按类型分组的。我们单独封装了 useDict

export function useDict() {
  const store = useBasicDataStore()

  // 加载全量字典数据
  store.loadData('dict', fetchAllDict, { ttl: 30 * 60 * 1000 })

  const getDictItems = (dictType: string) => {
    const all = store.getCache<DictData>('dict') || {}
    return all[dictType] || []
  }

  const getDictLabel = (dictType: string, value: string) => {
    const items = getDictItems(dictType)
    return items.find(item => item.value === value)?.label || value
  }

  const getDictOptions = (dictType: string) => {
    return getDictItems(dictType).map(item => ({
      label: item.label,
      value: item.value
    }))
  }

  return { getDictItems, getDictLabel, getDictOptions }
}

使用起来非常直观:

<script setup>
const dict = useDict()
const cargoTypeLabel = dict.getDictLabel('CARGO_TYPE', 'FCL') // "整箱"
</script>

<template>
  <el-select>
    <el-option 
      v-for="opt in dict.getDictOptions('CARGO_TYPE')" 
      :key="opt.value" 
      v-bind="opt" 
    />
  </el-select>
</template>

五、实际使用场景

场景一:下拉选择器

最常见的场景。以前要自己请求数据、处理格式,现在一行搞定:

<script setup>
import { usePorts } from '@/composables/basicData'

const { options: portOptions, loading } = usePorts()
const selectedPort = ref('')
</script>

<template>
  <el-select v-model="selectedPort" :loading="loading" filterable>
    <el-option v-for="opt in portOptions" :key="opt.value" v-bind="opt" />
  </el-select>
</template>

场景二:表格中的代码翻译

订单列表里显示港口代码,用户看不懂,要翻译成中文:

<script setup>
import { usePorts } from '@/composables/basicData'

const { getByCode } = usePorts()

// 翻译函数
const translatePort = (code: string) => getByCode(code)?.nameCn || code
</script>

<template>
  <el-table :data="orderList">
    <el-table-column prop="polCode" label="起运港">
      <template #default="{ row }">
        {{ translatePort(row.polCode) }}
      </template>
    </el-table-column>
  </el-table>
</template>

场景三:字典标签渲染

状态、类型这类字段,通常要显示成带颜色的标签:

<script setup>
import { useDict } from '@/composables/basicData'

const dict = useDict()
</script>

<template>
  <el-tag :type="dict.getDictColorType('ORDER_STATUS', row.status)">
    {{ dict.getDictLabel('ORDER_STATUS', row.status) }}
  </el-tag>
</template>

场景四:数据刷新

用户修改了基础数据,需要刷新缓存:

import { usePorts, clearAllCache } from '@/composables/basicData'

const { refresh: refreshPorts } = usePorts()

// 刷新单个
await refreshPorts()

// 刷新全部
clearAllCache()

六、缓存策略

不同数据的变化频率不同,缓存策略也不一样:

数据类型 TTL 持久化 原因
国家/货币 1 小时 几乎不变
港口/码头 15-30 分钟 偶尔变化
船舶 15 分钟 数据量大(10万+),不适合 localStorage
航线/堆场 30 分钟 相对稳定
字典 30 分钟 偶尔变化

持久化用的是 localStorage,配合 TTL 一起使用。数据写入时记录时间戳,读取时检查是否过期。

船舶数据量太大,存 localStorage 会导致写入超时,所以不做持久化,每次刷新页面重新请求。

七、调试支持

用 Pinia 还有一个好处:Vue DevTools 原生支持。

打开 DevTools,切到 Pinia 面板,能看到:

  • 当前缓存了哪些数据
  • 每种数据的加载状态
  • 数据的具体内容

排查问题时非常方便。

另外我们还提供了 getCacheInfo() 方法,可以在控制台查看缓存统计:

import { getCacheInfo } from '@/composables/basicData'

console.log(getCacheInfo())
// {
//   ports: { cached: true, size: 102400, remainingTime: 600000 },
//   vessels: { cached: false, size: 0, remainingTime: 0 },
//   ...
// }

八、踩过的坑

坑 1:响应式丢失

一开始我们这样写:

// ❌ 错误写法
const { data } = usePorts()
const portList = data.value // 丢失响应式!

datacomputed,取 .value 之后就变成普通值了,后续数据更新不会触发视图刷新。

正确做法是保持响应式引用:

// ✅ 正确写法
const { data: portList } = usePorts()
// 或者
const portList = computed(() => usePorts().data.value)

坑 2:循环依赖

Store 和 Composable 互相引用导致循环依赖。解决办法是严格遵守分层原则:Composable 可以引用 Store,Store 不能引用 Composable。

坑 3:SSR 兼容

localStorage 在服务端不存在。如果你的项目需要 SSR,持久化逻辑要加判断:

const storage = typeof window !== 'undefined' ? localStorage : null

九、总结

重构前后的对比:

维度 重构前 重构后
缓存系统 3 套并存 1 套统一
代码复用 到处复制粘贴 工厂函数批量生成
请求优化 无去重,重复请求 自动去重
调试 只能打 log DevTools 原生支持
类型安全 部分 any 完整类型推断

核心收益:

  1. 开发效率提升:新增基础数据类型从半天缩短到 10 分钟
  2. Bug 减少:数据不一致问题基本消失
  3. 性能优化:重复请求减少 60%+

如果你的项目也有类似的基础数据管理问题,可以参考这个思路。关键是想清楚分层,把「状态管理」和「业务封装」分开,剩下的就是体力活了。


本文基于实际项目经验整理,代码已做脱敏处理。欢迎讨论交流。

2025 复盘 | 穿越AI焦虑周期,进化为 "AI全栈"

作者 coder_pig
2025年12月9日 17:36

1. 今年一定

好几年没写「年终总结」,翻了下「掘金」,上一篇还停留在「2021年」,倒不是这几年没活过,也不是不想写,只是每次都止步于「拖延」。每到年底,脑子里就会蹦出的各种想法,被我零散地记录在 "大纲笔记" 中:

写这玩意挺废「时间」和「心力」,所以总想着 "找个周末,花一大块时间,好好梳理一下",结果都是一拖再拖:拖到元旦,拖到春节,拖到元宵。

然后,转念一想:"这都明年了,还写个🐣毛啊?算了,算了,明年一定!"。2022、2023、2024 就在这样的 "明年一定" 中溜走了...


人到中年,主观感觉「时间」过得越来越快,「记性」 也大不如前,很多当时觉得 "刻骨铭心" 的瞬间 (如:结婚、当爹),如今回忆起,就剩下一个 "模糊的轮廓"。不写的话,又是「 🐟」一年,趁着 2025 年还没过完,很多感觉还是热乎的,赶紧动笔,今年一定!!!

2. 如此生活30年,直到大厦崩塌

2025 年,对我冲击最大的当属 "AI",大到让我不得不重新审视「自己的工作和价值」。

我接触 AI 并不算晚,2024 年那会跟风玩了下 ChatGPT,后面在 GitHub Copilot (便宜盗版,30¥/月) 的协助下,快速交付了一个爬虫单子。尝到 "提效甜头" 的我,咬咬牙上了 年付正版 (100刀/年) 的车。

当时 AI 在我的眼里,只是个 "比较聪明的代码补全工具",可以帮我少些几行代码,仅此而已,因为 逻辑还得我来把控

但到了 2025 年,事情却变得有点不一样了:

AI能读懂/分析整个项目、重构屎山代码、把一个模糊的业务需求实现得有模有样。

当然,最让我 "破防" 的还是它的 "解BUG" 能力:

按照以往的习惯,我得去 Google、翻 Stack Overflow、看源码,少说得折腾半天。现在,把 报错日志相关代码 丢给它,通常几秒就能:指出问题所在给出解决方案,甚至可以 帮我改好并测试通过

积累多年,一度 "引以为傲" 的「编程经验」(对API的熟练程度、快速定位BUG的直觉、配置环境的熟练度等) 在 AI 的面前,变得 "不堪一击"。渐渐地,我的「工作流程」也发生了改变,"亲手" 敲下的代码越来越少,取而代之的是一套 "机械化" 的 "肌肉记忆":

  • 写注释等待AI补全按Tab:哪怕脑子里知道下一行该写什么,手指也会下意识停顿,等待那行灰色的建议浮现,然后无脑按下 Tab。
  • 提需求生成代码Accept:把业务逻辑描述一遍,丢给 AI,都不太细看生成的具体实现,直接点击 Accept All,主打一个 "能跑就行"。
  • 运行报错复制日志丢给 AI:遇到 Bug,第一反应不再是去分析堆栈信息 (Stack Trace),而是CV日志到对话框,问它:"解决报错:xxx"。
  • 效果不对截图丢给 AI:连描述问题的精力都省了,直接截图往对话框里一扔,附上一句 "改成这样"。

代码跑通了,效率提高了,却带来了精神上的「空虚」,我似乎再也感受不到当初那种「编程的快乐」:

  • 为了解决某个问题,苦思冥想,抽丝剥茧,最后成功 "破案" 时 "多巴胺疯狂分泌" 的 "快感"。
  • 查各种资料、反复推敲、验证,最终设计出一个自己觉得 "牛逼哄哄" 代码架构时的 "成就感"。

同时,也陷入了一种深深的「自我怀疑与迷茫」:

  • 越来越搞不清楚自己的「定位」(存在价值),上面那套 "连招" 找个实习生培训两天也能干。我曾赖以为生的那些 "技能",正变得廉价、可替代、甚至有点多余 ...
  • 找不到方向,以前「程序员成长路径」很清晰:学语言 → 学框架 → 学架构 → 学系统设计 → 刷算法 → 搞源码 ... 只要你一步步往上爬,爬到 "山顶" 就能成为 "大牛"。而如今却好像 "失效" 了...
  • 可控感被剥夺,程序员是典型的「内控型人格」—— 相信通过逻辑和细节掌控能预测一切。而但 AI 的「黑箱特性」带来了「工具不可控」,无法完全准确预测AI输出,调试从 "追踪逻辑" 变为 "试探模型"。

3. 调整对待AI的心态

3.1. 从 "焦虑" 到 "接纳"

我深知「焦虑」无用,于是开始探寻「破局之道」,反复阅读大量资料后发现,几乎所有人都在让你「拥抱 AI」,但具体怎么拥抱法?没人说,或者说得含糊不清,有些甚至还想割我 "韭菜" 🤡 ?屏蔽这些噪音,冷静下来复盘,拨开情绪迷雾,透过现象看本质。

首先,坦诚地「接纳」肯定是没错的,历史的车轮从不因个人的意志而停止转动,当 第一次工业革命的蒸汽机 轰鸣作响时,那些坚守手工工场的匠人们,也经历着相同的困境。精细手艺 在不知疲倦、效率千倍的 机械化工厂 面前显得苍白而无力。大机器生产取代手工劳动,不是一种选择,而是一种必然的 "降维打击"。

现在,我们同样站在了 "生产力变革" 的周期节点上, "效率至上" 的底层逻辑从未改变。是选择成为被时代甩下车的 "旧时代纺织工"?还是进化为驾驭机器的 "新时代工程师"?回归「第一性原理」,剥开 "智能" 的外衣,想想 "AI 的本质是什么?" —— 「干活的工具

所以,面对 AI,我们要做的事情就是琢磨 "如何用好这个工具? ",即:详细阅读使用说明后,在合适的场景,用合适的方式,解决合适的问题。

3.2. AI 有什么用?—— 能力放大 + 自学利器

3.2.1. 能力放大器

🐶 经常在 自媒体平台 刷到 "普通人学AI后致富/逆袭" 的 叙事,看到这些 "逆天标题" 没把我笑死:

多的不说,记住这段话就对了:

变现的核心能力从来不是使用工具,而是商业认知、市场洞察、营销推广、客户服务。AI 只是一个环节,不要高估了工具的作用,而低估了 商业常识的重要性,也不要低估了背后的 隐性成本和巨大的工作量

这些 "AI变现教程" 的 最大问题

让你把AI当成一个 独立的、全新的、需要从零开始的 "行业" 去卷。

对于 99% 的普通人而言,把AI看作 "能力放大器" 会更靠谱一点,即:

思考如何利用AI,帮我把我已有的技能/兴趣做得更好?

比如:

  • 用 AI 减少重复劳动,提高工作效率和质量,把时间花在更有创造力的事情。
  • AI 负责广度,你负责深度,在你热爱的小众领域里用AI武装自己,做到 "人无我有,人有我精"。

今年「Vibe Coding (氛围编程) 」很火:

用自然语言描述想要的效果,AI帮你写代码,你只负责验收结果和提修改意见,不用管具体代码怎么实现的。

编程门槛大大降低,普通人 只要能把 创意和感觉 翻译成需求,就能借助 AI 将其快速具象化为 可运行的产品

但你会发现,绝大多数生成的作品都是 "一次性原型或玩具":灵光一现即可实现,却缺乏持续迭代、架构设计与用户验证,因此难以具备商业价值、也难形成可持续的产品形态。

真正能够利用 Vibe Coding 实现变现的,往往是具备一定 编程经验产品思维 的 "专业人士"。他们不仅能用 AI 快速实现灵感,还能对作品进行持续优化、迭代和工程化打磨,从而将 "灵感原型" 进化为 "可用产品"。

再说一个自己观察到的例子,前阵子 OpenAI 发布了用于生成短视频的「Sora2」,B站 很快涌现了一堆 AI 生成的 "赛博科比" 恶搞视频。

看到一个播放量破百万的作品有点意思,点进 UP 主的主页想看下其它作品,结果发现他并不是突然爆火的 "新人",人家已经默默做了好几年视频,只是过去的播放量惨淡 (几十几百)。但他却一直坚持创作,尝试不同的方向,能清晰地看到他的剪辑、叙事和整体制作水平在一点点提高。

AI 不会让没有积累的人"平地起飞",但有可能让有准备的人"一飞冲天"。—《抠腚男孩》

3.2.2. 自学利器

看到这里,可能有人会问:

"那普通人怎么办?我没啥专业技能,也没有长期积累啊? "

简单,那就 "" 啊!!!以前学习的最大限制是什么?

没人教、教不好、学不动、坚持难

而现在,你有了一个「全知全能、知无不言、24小时为你服务的免费老师

  • 不会写代码?手把手教你,从逻辑到示例一步步拆开。
  • 想转行?给你路径、资源、练习清单、复盘建议。
  • 想跨领域?帮你建立知识框架,把陌生领域最难啃的部分变简单。
  • 遇到瓶颈?像一对一导师一样不断提问、引导、纠偏。

当然,想要这台 "自学利器" 高效运转起来,实现 快速学习/试错/跨域 还需要掌握一些 方法论

详细解读可以参加我之前写的《如何借助AI,高效学习,实现快速"跨域"》

3.2.3. 不要神化 AI

🐶 2333,经常刷到 "xx公司发布新的 xx 模型/AI产品颠覆行业,xx师要失业了" 的标题,但事实真的如此吗?最近 Google 家的 Nano Banana Pro 🍌很火,号称当前 "最强AI生图" 模型,亲身体验下确实强 (本文大部分配图就是它出的),天天在群里吹爆。

某天晚上,有 "多年专业设计经验" 的老婆收到一个改图需求 (抠素材,按要求调整海报):

😄 看着简单,感觉 🍌 就能做,于是我提出和老婆 PK 下,她用 PS 改,我用 🍌 嘴遁修图,看谁出的图又快又好。结果:她10分钟不到就改完,而我跟 🍌 Battle了半个小时没搞好,最终的效果图 (左边她的,右边我的):

🤡 "甲方" 的评价 (破大防了😭):

观察仔细的读者可能会问:"你是不是漏了一个车🚗啊?",憋说了,这破车把我调麻了...

那一刻,我深刻体会到了什么叫 "不要拿你的兴趣爱好,去挑战别人的饭碗",真的是 "降维打击" 啊!

AI 确实拉低了创作的门槛,但目前还处于生成 80分 内容的阶段 (效率),最后的 10-20 分 (细节、审美、情感) 才是价值的核心。——《抠腚男孩》

后面复盘,老婆看了下我的 Prompt,说我的流程有点问题,应该让 AI 先把素材全抠出来先,再慢慢组合。后面试了下,效果确实有好一些。不过,不得不说,AI自动抠图 这块确实可以:

🤣 老婆在日常设计时也会用 AI 来偷懒,比如:生成配图、提高清晰度、扩图等。

3.3. AI 是什么? —— 概率预测机器

现阶段谈论 AI,其实都是在谈论 大模型 (LLM) —— 一个极其复杂的 "概率预测机器"。

通过学习海量数据的 "统计规律",逐步逼近这些数据背后的 "概率分布",从而能够在给定 "上下文" 时预测最合理的下一步输出。

不同类型产物的生成原理图解 (看不懂没关系,简单了解下即可):

文本

② 图片 (扩散模型 & 自回归模型)

③ 音频 (自回归模型 & Codec + Token 预测 )

④ 视频 (扩散式 & 自回归/时空Token)

3.4. AI 的能力边界 —— 优/劣势

LLM 擅长发现 "相关性",但难以进行真正的 "因果推理",它只是在 "模仿智能",而非 "真正地理解意图,拥有意识"。 —— 《抠腚男孩

弄清楚 AI 的本质是 "概率预测机器" 后,接着从 "代码生成" 的角度梳理下它的 "优势 & 劣势":

了解完 AI优/劣势 后,接着就可以推演「人 & AI」 的 高效协作方式

一句话概括

AI 负责 "生产力" (重复、繁琐、高上下文、高整合的工作),人负责 "方向与边界" (判断、创造、决策、理解组织与业务)。

4. 必备技能 —— Prompt

一般译作 "提示词" 或 "描述词",个人认为后者更加贴切,即:描述问题/需求的 "词句组合" 。「Prompt Engineering-提示词工程」是所有人都必须掌握的 "使用AI的核心技能"。

4.1. 把话说清楚

🐶 别被几个英文单词吓倒,现在的 AI 比几年前聪明多了,普通人 只要能:

把诉求讲得清晰、完整、有逻辑,就能解决绝大多数问题

示例:

  • ❌ 混乱说法:帮我计划个周末玩的地方。
  • ✅ 有条理说法:周末想带5岁孩子一日游,2大1小,预算500以内,北京,不想跑太远,能放电、有吃饭的地方、避开暴晒,地铁可达最好。

AI输出结果 (前者输出不同城市的游玩方案,后者输出了具体的行程方案):

4.2. 套框架

再往上走,就是了解一些经典的 "Prompt框架",然后再提问时套用,以提高 AI 输出的稳定性、准确性和质量。所谓的 "框架",其实就是 "结构化模板",规定问题中包含哪些 "要素",比如最经典的「CTRF」框架:

套框架示例 (填空题~):

常见的框架还有 RTFCOSTARSPARCOTAPE 等等,适用于不同的场景。杰哥整合了自己知道的所有框架精华和高级技巧,弄了通用的「Prompt 最佳实践清单

无脑套就是了,助记口诀

也可以用故事流程来串联助记,读者可自行发挥,顺序无需固定:

让一位说书人 (角色) ,用生动的语气 (风格语气) ,给孩子们 (受众) 讲个故事 (指令) 。故事的开头 (上下文) 是...,结局 (目标) 要感人。故事的结构 (格式) 要像这样 (示例) ,但不要 (约束) 出现暴力情节。请先构思情节 (逐步思考) ,写完后再想想怎么能更精彩 (反思) 。

😄 懒得记的话,可以用我之前搭的小工具 →「CP AI Prompt助手」

配下 DeepSeekKey,复制粘贴你写的 简单Prompt,它会基于上面的十个维度对提示词进行优化:

4.3. 写出牛逼的Prompt

明白了怎么 "套框架" 写 "结构化的Prompt",但你可能还是会感到疑惑:

用的同样的AI,为什么别人的生成效果就是比我好?

尤其在 AI 生图 领域,看大佬分享的 Prompt,里面一堆看不懂的专业参数:

环境、构图、光影、景深、镜头、光圈、色调、氛围、胶片颗粒、对比度、主体增强、氛围灯...

能写出这么 专业的Prompt,是因为他们有 "相关领域的行业经验" 吗?

答:有加成,但不全是。高手的核心技能不是 "记这些专业知识",而是:知道如何指使 AI 给自己提供专业知识、框架、术语,然后再反向用这些框架让 AI 编写和优化 Prompt。

😄 其实思路很简单,拆解下这套方法论:

维度词术语/词库通用模板填空得第一版PromptAI专家视角优化迭代优化沉淀

详细玩法可以看下图:

4.4. Prompt 逆向

Prompt 逆向工程RPE,Reverse Prompt Engineering),就是:从 "输出" 反推 "是什么Prompt" 生成了它。一般用于:学习优秀案例调试和诊断问题构建Prompt库和模板企业质量控制安全审计 (防御Prompt注入攻击)。

4.4.1. 简单版

普通人 用这个套路就够了,选个聪明点的模型 (如:GPT5Gemini 3 Pro),粘贴图片,写 Prompt 让它反推:

差得有点远,描述「不满意的点」,让AI继续优化Prompt:

接着用优化后的 Prompt 来生成,可以看到效果差不多了,接着让 AI 提取一个「通用的Prompt

拿 AI 生成的 Prompt 生图,看效果,描述问题,循环反复,直到稳定生成自己想要的效果~

4.4.2. 专业版

🐶 其实也差不多,只是流程比较 "标准化",经常搞还能自己搭个 "工作流",适合专业选手,思路:

快速拆解推断 Prompt提取要素重建 Prompt优化迭代模板化沉淀

详细图解:

上面是通用的,还有几个 额外功能 的玩法也罗列下:

5. 锦上添花——懂点AI常识

🐶 懂点AI常识,能让你更 有的放矢用好AI (装逼),比如:连 Token 都不知道的话,就有点贻笑大方了。这里只是简单罗列下相关名词,不用死记,有个大概印象即可,不影响你使用AI,跳过也没关系。😄 详细讲解,建议直接复制名词问题AI,也可移步至《AI-概念名词 & LLM-模型微调》自行查阅~

5.1. AI (人工智能) 基础概念

5.2. NLP (自然语言处理)

5.3. Transformer 架构 (大型模型基础)

5.4. 语言模型基础 (Language Models)

5.5. LLM 核心概念 (Large Language Models)

5.6. 数据与训练流程 (Training & Fine-tuning)

5.7. 推理阶段 (Inference)

5.8. RAG (检索增强生成)

5.9. 多模态 AI

5.10. AIGC (生成式内容)

5.11. 模型压缩、部署与加速 (LLMOps)

5.12. Agent (自主智能体)

5.13. AGI (通往通用智能)

6. AI 编程领域专精

😄 最后,聊聊 AI 编程 领域的一些心得~

6.1. 前置知识

6.1.1. 编程模型

AI 代码写得好不好,主要看 "模型" 的 "编程能力",评估 "模型优劣" 的几个 "常见维度":

LLM 的能力很难用一句话概括,所以厂商们每次发新模型都会用一堆 Benchmark 来证明 (🐶不服,跑个分?)

推理与数学能力 (Reasoning)

"智能的核心指标",高分意味着能够做更复杂任务 (如:工程规划、Agent等),常见基准:GSM8K-小学奥数式数学题、MATH-高难度数学、AIME/AMC-奥林匹克数学、GPQA-博士级科学问答、BigBench Hard (BBH)-推理难题集合 等。

语言理解与知识能力 (Language / Knowledge)

"通用模型 IQ 测试",常见基准:MMLU-大学生多学科理解测试、MMLU-Pro - 更难版本、ARC / HellaSwag - 常识推理、OpenBookQA/TriviaQA - 事实/知识问答 等。

③ ✨编程能力 (Coding)

"商业价值极高的应用点",常见基准:HumanEval - 函数级别代码生成、MBPP / MBPP+ 简单编程题、SWE-Bench / SWE-Bench Verified✨:真实 GitHub issue + 多文件工程 (最接近真实开发场景,近两年厂商都在比这个)、Codeforces-算法比赛、CRUXEval / RepoBench-项目级分析 等。

多模态能力 (Multimodal)

"下一代 AI 产品的必争之地" (做 AI 助手、看图、自动化办公等),常见基准:MathVista:带图的数学推理、ChartQA / DocVQA:文档理解、TextCaps / ImageNet:视觉场景理解、VideoMME:视频理解、V-Bench / VQAv2:视觉问答 等。

安全性 (Safety / Robustness)

企业用户很看重 "安全合规",常见基准:Harmlessness / TruthfulnessAdvBench:对抗攻击、Red Team 红队测试Over-Refusal 测试(不会乱拒绝)、Speculative Safety(推测生成的风险)等。

速度/延迟/吞吐 (Performance Metrics)

"决定实际用户体验",常见指标:Tokens per second (推理速度)、First Token Latency (首字延迟)、Throughput QPS (每秒处理请求数)、Context processing speed (长文档处理速度)。


有时还会发布一些 "技术参数":

  • 模型规模:模型的参数量大小,影响推理与表达上限,规模越大,能力越强,但成本、延迟和部署难度也越高。如:70B = 70 billion = 700亿参数。
  • 训练数据规模:模型预训练时学习的 token 总量,代表其知识 "阅历"。数据越多通常知识覆盖越广,但质量、去重和清洗策略比单纯堆量更关键,高质量数据才能让模型表现更稳。如:15T = 15 trillion = 1.5万亿个token。
  • 上下文窗口:模型单次可接收并 "记住" 的 "输入长度上限",决定你能塞多少代码、文档和对话历史;窗口越大越适合做整仓分析、长文档问答、复杂任务,但会牺牲成本和延迟,且需要额外机制确保在超长上下文中仍能抓住重点。
  • 推理深度:模型答题时的 "思考力度",深推理模式更准确、适合复杂问题,但会更慢、更贵,适合关键任务而非高频交互。
  • 价格:按 token 收费,区分输入价、输出价与最小计费单位;部分模型提供 "缓存命中 (cache hit) ",对重复提示只按更低费率计费,大幅降低长上下文与多轮调用成本。价格决定模型可否大规模、频繁和低成本使用。
  • 延迟标准:包含首 token 延迟 (FTL) 与 生成速度 (token/s),分别决定 "多久开始回应" 和 "内容生成有多快";低延迟让补全、对话、Agent 流程更流畅,而高延迟会严重影响开发体验与实时性,是工程中比 "更聪明一点" 更重要的性能指标。
  • 模型行为控制能力:通过 Temperature、Top-p、System Prompt、工具权限等机制控制模型的随机性、稳定性与执行边界;行为越可控,越能确保输出一致、不跑偏,并安全地接入工具链或生产系统,是把模型从 demo 提升到可上线能力的关键参数。

🤡 个人 "主观" 认为的 "编程模型" 能力排名:

😊 一句话概括我的 "选模型策略":

选好的模型事半功倍!工程大活Claude精细小活 (如改BUG) 用 GPT,写前端页面用 Gemini

🐶 问:这些都是国外的模型啊,怎么才能用上?A社还锁区,经常封号?而且价格好贵啊?

答:😏 这个问题充钱可以解决.jpg,多逛下海 (xian) 鲜 (yu) 市场,国人的 "薅羊毛" 能力不是盖的,各种 "镜像站、第三方中转" 。氪金的时候注意找有 "售后" 的,随用随充,买 "短期 (如月付) ",不要买 "长期 (如年付)",这种看 LLM官方 政策的,一封就直接G了,说不定就 "卷款跑路"~

6.1.2. AI 编程工具的四种形态

😄 一句话归纳:

普通开发者 & Vibe Coding用户AI IDE/插件 居多,DevOps/后端工程师CLI团队/企业系统云端AgentAI 应用开发者AI SDK 构建构建 AI 产品与 Agent 系统。

接着说下 "AI编程" 的三种演进层次~

6.2. 第一层:AI 辅助开发

最早期的AI开发方式,以「人主导 + AI辅助」为核心逻辑,由两种交互模式组成:

  • 补全式:基于输入光标前的上下文,预测下一个单词、下一行代码、甚至整个函数。
  • 对话式:在 IDE 侧边栏或网页中,通过自然语言问答来生成代码、解释代码或查找 Bug。如:"帮我写一个 Python 的正则来验证邮箱" 或 "这段代码为什么报错?"

这一层的局限:

  • 上下文有限:AI 通常只能看到当前文件或少量相关片段,缺乏对整个项目架构的理解。
  • 被动性:AI 不会主动修改你的代码文件,它生成代码,你负责复制粘贴和校验。
  • 人是瓶颈:所有的决策、文件切换、环境配置都必须由人来操作。

6.3. 第二层:AI 驱动开发流程 (规范+Agent)

目前最前沿、最热门的阶段,AI 不再只是吐出代码片段,而是进化为 Agent (智能体),拥有了 "大脑" (规划能力) 和 "手脚" (工具使用能力),可以 "自主完成一个多步骤的开发任务"。

变成了「人定目标 + Agent 自主执行」,如:"实现一个简单的待办事项 Web 服务,要求:REST API,内存存储即可,有单元测试",Agent 可能会进行这样的任务拆解并执行:

设计目录结构 → 创建代码文件 → 写业务逻辑 → 写测试 → 运行测试并自我修复。

为了系统地应用 Agent,业界逐渐采用「 "规范"驱动的开发流程」(Spec-Driven Development):

需求规范文档任务分解Agent执行验证反馈

这个流程确保了 清晰的目标定义可追踪的执行过程,而不是让Agent盲目操作。开发者需要维护的 "三类规范" (规范必须比写代码更清晰):

  • 功能规范:目标、用户故事、输入输出、性能要求、鉴权、边界情况等。
  • 技术规范:模块结构、API、模型字段、状态机、异常流程等,Agent会根据这些自动创建项目。
  • 验收规范:测试通过、接口返回正确、性能满足要求、行为与设计一致,即每个功能的评价方式。

人不再写代码 (或者少写),负责「定义 + 审核 + 授权」,人-Agent 协作 的三阶段循环:

  • 人主导-目标设定:范围、约束、边界、不允许做的事情。
  • Agent主导-执行:分解、规划、写代码、自动Debug、修复、生成报告。
  • 人主导-验收:代码质量、安全性、单元测试覆盖率、偏差是否满足业务需求等。

💡 层2 关注的是「开发流程自动化」,任务的起点通常是 "已经确定好的需求/feature"。

6.4. 第三层:AI 全栈

所谓的 "AI 全栈",本质上就是 "让 AI 同时扮演多个软件开发角色",而 "一人分饰多角" 的自然实现方式就是 "多 Agents"。——《抠腚男孩

6.4.1. 为什么聊到 "AI全栈" 就会扯到"多 Agents"?

🤔 想象一下让 "AI全栈开发一个应用" 需要经历哪些步骤?

产品需求理解技术选型架构设计API 设计前后端代码生成数据库 schema错误处理文档生成单测编写测试执行部署脚本CI/CD 配置

让一个 Agent 承包上面所有的工作,会有什么问题?

记忆量爆炸、目标切换频繁、推理链拉得太长,错误积累变大、一旦一步出错,后续全崩、风格、结构、代码质量难统一、难以并行。

软件工程是 "多角色协作" 的结果:产品经理、架构师、后端、前端、测试、文档、DevOps... 如果想 "AI 模拟完整的软件开发流程",自然也需要 "让 AI 也模拟这些角色",于是就变成了这些 Agent

  • Planner / Architect (产品/架构):理解需求、拆任务、出计划 (Plan)。
  • Coder / Implementer (实现):按计划改代码、增删文件。
  • Searcher / Context Agent (检索):在代码库里找相关文件、API、调用链。
  • Tester / QA (写测 / 跑测):写测试、跑测试、分析报错。
  • Fixer / Debug Agent (修BUG):根据测试/运行结果修复代码。
  • Reviewer / Critic Agent (代码审阅):检查风格、一致性、潜在 bug / 安全问题
  • Ops / Deploy Agent (部署):写 Docker、CI/CD、部署脚本(有些系统只做到生成,不自动执行)

即「AI 全栈 = AI 软件开发流水线 = 模拟整个软件部门 = 多 Agent 系统」,这是开发任务决定的。

"AI全栈" 需要的三大核心能力:

  • 长任务规划 (Planning):开发一个系统不是线性的,是树状决策结构,要拆分任务,就需要 Planner Agent。
  • 并行执行 (Parallel Execution):前端、后端、文档、测试不可能一个个线性做。多 Agent 可以:前端 Agent 改 UI、后端 Agent 写 API、Docs Agent 补文档、Test Agent 补测试。
  • 验证 & 修复 (Validation Loop):真正让 "AI 全栈" 可行的关键是 "循环",写代码、跑测试、找错误、修复、再跑,需要 "多 Agent + 状态机" 才能撑起这个能力。

"AI 全栈系统" 的实现,本质就这四步:

  • 「定义一堆上面这样的角色」
  • 排布拓扑」决定这些角色之间的连接结构和调用关系。谁先谁后 (拓扑/顺序)、有没有循环 (写→测→修→再测)、有并行吗 (前后端Agent同时干活?)、是由一个 "主管Agent" 指挥大家?还是大家按照状态机自己转?
  • 给每个角色接上能用的"工具",让它真的能动手干活」常见工具:文件 (读/写代码、生成 diff)、终端 (执行命令)、搜索 (在 repo 里搜符号 / 用法)、HTTP/Browser (查文档、查API)、Git (开分支、commit、生成PR)、结构化分析 (AST分析、调用图、依赖图)。比如:Coder Agent 配置 "文件读写、diff 生成、代码搜索" 的工具,用来 "在受控范围内改代码"。
  • 套一层安全边界权限 (读写、只能改指定目录、终端命令必须在沙箱里执行)、人在回环 (关键操作必须人工确认,如:Plan-任务规划、大范围diff、部署相关改动/高危脚本)、防注入/误操作 (不轻信代码库里的"指令"-如:恶意README 写 "rm -rf /"、对外部输入做过滤-日志/错误信息/用户Prompt、限制重试次数,避免死循环修改)。

一句话概括就是:

AI 全栈 = 一群小模型/小角色 + 一个调度关系图 + 一堆工具函数 + 一圈安全护栏

😄 弄清楚本质,以后看任何 "AI 全栈多 Agents" 方案,都可以基于这三个问题进行快速拆解:

  • 它有哪些角色?(Planner / Coder / Tester / Fixer / Ops…)
  • 这些角色是按什么拓扑 / 流程连起来的?
  • 每个 Agent 有哪些工具?安全边界是什么?

6.4.2. 业界主流多 Agent 架构模式

前面AI常识部分有提到过,这里直接让🍌画个图~

6.4.3. 个人级 "AI全栈" 演进历程

🤡 上面的理论看起来简单,但对于个人来说,想要亲手实现这样 一整套多 Agents AI 全栈系统,工作量爆炸:

得自己写调度、管状态、接工具、控安全、做可视化,还要维护一堆 prompt 和配置,算完整平台工程了...

🤔 笔者认为 "个人级AI全栈" 更倾向于:

在个人可以承受的复杂度和时间成本内,让AI参与尽可能多的开发环节,而不是一次性造一个企业级AI工厂。

😄 其实,你可能已经在无形中体验 "AI 全栈" 的雏形了,现代 AI 编程工具 本身就内置了 多 Agent 编排能力~

Claude Code Sub Agents

CC 中允许创建多个带 独立角色与上下文Sub Agent (小型专属AI工作者),用法简单:

  1. 创建 Sub Agent
  • Claude Code CLI 输入 /agents,选择「Create new agent
  • 选择作用域:项目级 (推荐,只给当前项目使用)、用户级 (所有项目可用)
  • 填写:name (调用的时候用到)、description (决定CC何时自动调它)
  • 选择可用工具 (file_edit / bash / file_search / git …)
  • 完善系统Prompt:可以先让 Claude 生成,再自己改

保存后,会在 .claude/agents/ 生成一个类似这样的文件:

---
name: backend-dev
description: "专门负责后端接口、服务逻辑和数据库相关代码的实现与修改"
model: sonnet
tools: [file_search, file_edit]
color: blue
---

你是一个资深后端工程师,精通 Node.js + TypeScript 和这个项目的后端架构。
你的职责:
- 只改后端相关的代码(controllers, services, repositories)
- 遵循项目现有的代码风格和结构
- 所有改动都要尽量小步、安全、可读
在给出修改时:
- 标明文件路径
- 用 patch 的风格展示修改
- 如果需要新增文件,要说明用途和引用关系

不想自动生成,可以在 .claude/agents/ 手动按照上面的格式自己写md,保存后 CC 会自动识别。还可以在命令行启动CC时添加 --agents 参数 (适用于临时挂载场景):

claude --agents '{
  "log-analyzer": {
    "description": "分析测试日志和错误堆栈的专用Agent",
    "system_prompt": "你只负责阅读测试输出、日志,帮助定位问题和怀疑文件,不写代码",
    "tools": ["file_search"]
  }
}'
  1. 调用 Sub Agent (串起来) 的三种方式
  • 自然语言编排,用普通指令描述任务,由 Claude 自动判断并调用合适的 Sub Agent,最灵活、最贴近自然对话的方式。如:请用 backend-dev subagent 修改 search controller 的分页逻辑。
  • 结构化点名调用,明确指定要调用哪个 Sub Agent,适合需要精确控制执行顺序或避免模型误判的情况。如:Use the test-runner subagent to run the unit tests.
  • ③ 在 Agentrooms 中使用 @agent-name 直接点名,通过@用户的方式派任务,可同时管理多个 Agent,方便多人视图和多 Agent 协作。如:@backend-dev 帮我调整这个接口的返回格式
  1. 多个 Sub Agents 协同工作简单示例 (开发 → 测试 → 分析 → 再开发):
  • 让 developer 生成补丁
  • 让 test-runner 运行测试
  • 让 log-analyst 分析失败原因
  • 再让 developer 根据分析修复
  • Claude 会自动接力,也可以由你手动编排~

Cursor 2.0 多 Agent 编排

2.0 后,Cursor 界面从 "以文件为中心" 变成 "以Agent为中心",多了个 Agent Layout,切换后,侧边栏会显示当前 Agent、计划(plan)和改动,你把需求丢进去,Agent 负责读文件、计划、改代码、跑测试。

支持 同一指令 下,最多可 并行 (Parallel) 跑 8 个 Agent,每个 Agent 会在自己独立的 Git worktree / 沙盒工作区 内工作:各自改代码、build、跑测试,不会互相冲突 (🤡 就是费 Token...)。还多了一个 Plan Mode (先规划再执行),在 Agent 输入框 中按 Shift + Tab 可以切换到这个模式 (也可以手动选):

Cursor 不会直接假设你的需求,而是询问一系列澄清问题:

通过这些澄清,使 AI获得了完整的上下文,可以生成更精确的计划,避免后续的返工。接着会生成一个 plan.md 的计划文档:

你可以对文件进行编辑:增删任务、调整任务顺序、更新技术细节、调整实现方法等。确定无误后,点击 Build,Agent 会读取最新版本的 plan.md,并完成对应的任务。

🤔 与 CC Sub Agents 可编排不同,CursorAgent 更像是一个组合能力的 "大Agent",由它自动编排多个内嵌的、对用户不可见 的 Agent 来完成 用户提出的任务,收敛复杂性,只展示改动/测试结果。它的 Parallel Agents 探索不同方案,最后再汇总/合并的玩法,不算严格意义上的 "主流多 Agent 架构模式" 中的 "并行Agents模式"-支持显式地定义 / 分配 不同角色的 Agent,并让它们并行协作。

类似的支持 "多Agents" 玩法的 AI 编程工具还有:

  • GitHub Copilot Workspace多步骤 Pipeline Agents,从任务描述 → 生成完整 plan → 自动执行 → 修正,多步骤 cascaded agents,自动提 PR。
  • Google Gemini Code Assistmulti-expert prompt routing,任务自动分配给最擅长的模型/agent,复杂 monorepo 搜索 → 专家 agent 提供答案,针对 cloud infra 的执行-验证循环。
  • Replit 的 AI Dev 环境多工具执行 Agent,轻量一站式多Agent开发流水线。
  • ...等,限于篇幅,就不展开讲了~

觉得 AI编程工具 满足不了,接着就是围绕自己的开发流程,开发基于 LLMAPI 封装一些 小脚本/小工具

// 推进开发闭环的简单伪代码 (需求 → 修改 → 测试 → 修复)
plan = llm("你是架构师,帮我拆解这个改动需求…")
files = find_related_files(plan)

patches = llm("你是后端开发,只能改这些文件…", files + plan)
apply_patches_to_workdir(patches)

test_result = run_tests()

if test_result.failed:
    fix_patches = llm("你是调试工程师,根据报错修复…",
                      test_result + current_code)
    apply_patches_to_workdir(fix_patches)

大多数个人开发者达到这一层,基本够用了,再往上就是加:日志、可配置、一点UI、简单任务管理等,弄成一个仅为自己服务的 "AI 全栈开发小平台" (😄 此时更像是一个 Agent 工程师,搭建 "企业级AI全栈" 的基石)。

6.4.4. 落地方法论

根本原则

在一个完整开发周期里 (从想法到上线),有意识地让 AI 参与尽可能多的环节,并用 "多角色思维" 来组织这些调用,但工程复杂度要控制在个人能持续维护的范围内。


① 项目级自检


② 项目阶段拆解


③ 搭建可复用工作流


7. 结语

行文至此,再回看这篇拖了许久的 "年终总结",心情早已从最初面对 AI 秒解 Bug 时的 "破防" 与 "迷茫",变得平静且笃定,我们:

  • 剥开 AI "智能" 的外衣,看到了它作为 "概率预测机器" 的本质。
  • 学会用 "结构化的Prompt" 去驾驭它,而不是被幻觉带偏。
  • 也见证了开发模式从简单的 Chat 进化成 Copilot,再到如今初具雏形的 Agentic Workflow

但归根结底,AI 带来的最大变量,不在于它替我们写了多少行代码,而在于它重塑了 "专业" 的定义。

  • 懂得"底层原理"依然重要——否则你不知道为什么 AI 会把人修成 "汽车",也无法在它 "一本正经胡说八道" 时进行纠偏。
  • 懂得提问比解答更重要—— Prompt 是新时代的编程语言,清晰的逻辑表达 + 对业务的深度理解,才是最高效的 "编译器"。
  • 懂得架构比实现更重要——当 "AI 全栈" 成为可能,当一个个 Agent 可以各司其职,我们不再是死磕语法的 "搬砖工",而更像指挥数字化施工队的 "包工头 & 总设计师"。

"技术焦虑" 的解药,从来不是拒绝变化,而是成为变化的一部分。以前,我们的壁垒是 "熟练度+记忆力",以后则是 "想象力+判断力+系统工程能力",拥抱AI,在这个属于创造者的时代,进化为无所不能的 "超级个体🦸‍♀️"!

前端页面崩溃监控全攻略:心跳判定 + Service Worker 接管

作者 Sthenia
2025年12月9日 17:34

背景

在浏览器环境中,“页面崩溃(Page Crash)”并不是一个浏览器主动抛出的可监听事件。 页面可能因为以下原因被动终止:

  1. 内存 OOM 导致 tab 崩溃

  2. 浏览器内部的 renderer 进程挂掉

  3. 业务代码死循环、长任务阻塞导致页面卡死

  4. 页面在后台被系统杀进程(尤其移动端)

  5. 浏览器关闭 / 标签页关闭但执行不到 beforeunload(常见)

由于浏览器没有提供“页面是否异常退出”的 API,因此前端监控体系通常只能间接推断崩溃

本次调研希望解决以下两个问题:

🎯 目标

  1. 如何在单标签页场景中准确推断页面是否异常退出?

  2. 多标签页环境中,一个页面崩溃后,如何被其他页面检测并上报?

  3. 是否能做到不依赖页面再次打开 —— 即实时上报?(如 Service Worker)

  4. 最终方案应尽量稳定、低侵入、可扩展并减少误报率

调研方案

1. 基于「退出打标 + 心跳检测」的崩溃推断方案

这是目前业内最常见的思路,例如不少监控 SDK 都采用类似机制。

1.1 核心思路

  1. 页面正常退出时(beforeunload / pagehide / visibilitychange)写入 normalExit = true

  2. 如果是崩溃,则正常退出钩子不会触发 → normalExit 保持 false

  3. 下次启动页面时读取存储(localStorage),若发现上次 normalExit=false,则认为存在异常退出

单页面流程示意图

正常退出 → normalExit = true → 下次打开不告警
崩溃 → normalExit = false 且心跳断更 → 下次打开上报崩溃

// 单页面检测崩溃代码

// 伪代码,还需要处理beforeunload / pagehide / visibilitychange
window.addEventListener('beforeunload', () => {
  localStorage.setItem('normalExit', 'true');
});
function checkCrash() {
  const normalExit = localStorage.getItem('normalExit');
  if (normalExit !== 'true') {
    reportCrash();
  }
  localStorage.setItem('normalExit', 'false');
}

上面只是讨论到当页面单开的情况,那么如果是多标签页的场景下该如何设计呢?要知道localStorage是在同一个域名下各个标签页共享的。

根据上面的检测原理,会想到,给每个页面都设置一个独立的 tabId,并在其中一个页面获取所有的页面normalExit,判断normalExit 是不是 false,false则认为是发生了崩溃。

但是不行,当我们打开多页面时,因为页面并还没有退出,获取到的normalExit其实都是false的,那我们就需要多一个字段去判断,心跳时间;

1.2 多标签页场景的完整设计

针对多页面场景,每个页面需要心跳 + tabId,判断页面是否还存活

为什么需要心跳?

因为多页面时,每个页面运行中时 normalExit 本来就是 false。 所以不能只看 normalExit,需要结合“最后心跳时间”:

normalExit = false + 心跳超过阈值未更新 → 判定崩溃

👇 关键逻辑代码(精简示例)

** 为每个 tab 创建唯一 ID **

// 
function getTabId() {
  let id = sessionStorage.getItem('**tab_id**');
  if (!id) {
    id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
    sessionStorage.setItem('**tab_id**', id);
  }
  return id;
}

心跳写入(运行中 normalExit = false)

function saveAll(map: Record<string, HeartbeatRecord>) {
  try {
    localStorage.setItem(KEY, JSON.stringify(map));
  } catch {}
}

export function writeHeartbeat(rec: Omit<HeartbeatRecord, 'ts' | 'tabId'>) {
  const tabId = getTabId();
  const map = loadAll();
  map[tabId] = { ...map[tabId], ...rec, ts: Date.now(), tabId };
  saveAll(map);
}

export function startSessionHeartbeat(intervalMs = 3000, recBase?: Omit<HeartbeatRecord, 'ts' | 'tabId'>) {
  const page = `${window.location.pathname}${window.location.hash || ''}`;
  let timer: number | undefined;

  function beat() {
    writeHeartbeat({ page, version: recBase?.version, env: recBase?.env, meta: recBase?.meta, normalExit: false });
  }

  beat();
  timer = setInterval(beat, intervalMs);

  window.addEventListener('beforeunload', () => {
    markNormalExit();
    if (timer) clearInterval(timer);
    clearTabHeartbeat();
  });

  return () => {
    if (timer) clearInterval(timer);
  };
}

正常退出钩子(beforeunload)


export function markNormalExit() {
  const tabId = getTabId();
  const map = loadAll();
  if (map[tabId]) {
    map[tabId].normalExit = true;
    saveAll(map);
  }
}

window.addEventListener('beforeunload', () => {
  markNormalExit(); // 标记正常退出
  clearTabHeartbeat(); // 清除心跳
});

// 重新打开页面时判断历史页面是否崩溃
if (!rec.normalExit && diff > timeoutMs) {
  reportCrash(rec);
}

下次打开时检测崩溃(核心)

判断崩溃逻辑:

  1. 不是正常退出时,normalExit 为 false

  2. 当崩溃时,normalExit 为false 或 undefined

  3. diff > timeoutMs 主要是为了当多开页面时,正常的页面心跳时间一直在滚动更新,不会少于timeoutMs,防止误报

export function checkPreviousAbnormalExit(
  timeoutMs: number,
  report: (payload) => void
) {
  const now = Date.now();
  const currentTab = getTabId();
  const map = loadAll();

  let changed = false;
  Object.values(map).forEach((rec) => {
    if (!rec || rec.tabId === currentTab) return;
    const diff = now - (rec.ts || 0);

    if (!rec.normalExit && diff > timeoutMs) {
      //// diff > timeoutMs 有个弊端,当用户崩溃后,刚好设了一个时间戳,并且马上打开一个新标签页,这时diff可能还没超过timeoutMs,这种情况会漏报
      report({ ...rec, diff });
      delete map[rec.tabId];
      changed = true;
    } else if (rec.normalExit) {
      delete map[rec.tabId];
      changed = true;
    }
  });

  if (changed) saveAll(map);
}

1.3 多页面心跳的隐藏坑点(必须处理)

🔥 1. 页面隐藏时定时器会被延迟

浏览器切后台后,setInterval 会被降频,甚至几秒才执行一次。 这会误伤心跳逻辑。

处理方式:

页面隐藏时直接标记 normalExit=true,避免误报。

但为什么可行?因为后台页面本身不应计入崩溃统计(用户没在看)。

🔥 2. 心跳的时间不准

如果是使用定时器更新心跳时间,心跳更新时间并不会特别准可以了解下setInterval原理,比如设了3000毫秒更新一次,有可能是3000+-N000毫秒才执行更新,也可能是主线程有大计算导致更新时间更慢,所以阈值不能设置和心跳时间一样的时间,得有一定的宽容度。 建议阈值为:

阈值 = 心跳间隔 \* 2

1.4 方案一的优劣总结

优点

  • 实现简单,不依赖 Service Worker

  • 能检测页面是否在上一次会话中异常退出

  • 多标签页可准确判断单页崩溃

缺点(重点)

  • 无法实时上报,必须等待下次打开页面

  • 页面进程在后台被系统杀死,无法触发页面生命周期事件导致无法打标记,下次启动页面时会存在误报。但是误报笔者是觉得允许的,采集到样本大的页面崩溃路径才是最有可能导致崩溃的页面;

2. 基于 Service Worker 的实时心跳监控

既然方案一无法实时上报,那么是否能借助Worker 实现实时上报呢?worker 独立于页面运行,可以在页面崩溃后继续存活,从而实现实时上报。

笔者一开始是想到用 Web Worker 来实现的,但是后来发现 Web Worker 生命周期和页面是绑定的,页面崩溃后,Web Worker 也不可用,所以无法实现实时上报。只能是用service Worker 来实现。

Service Worker(SW)可以在页面崩溃后继续存活,只要浏览器进程未关闭。 利用 SW 作为“监控总控”,页面与 SW 双向通信,从而实现:

✔ 页面实时心跳发送

✔ SW 主动判断某 tab 心跳超时

✔ 立即上报崩溃事件(无需等待下次进入)

2.1 方案架构图

页面 A/B/C
  ↓ (heartbeat)
Service Worker(独立线程)
  ↓ (report)
监控服务(如 Sentry)

2.2 SW 的核心逻辑

  1. 收到心跳,更新 tab 的 时间搓
if (data.type === 'heartbeat') {
  tabLastBeat.set(tabId, { ts: now() });
  ensureCrashChecker();
}
  1. 定时检查哪些 tab 心跳超时
if (nowTs - ts > CRASH_TIMEOUT_MS) {
sendReport();
tabLastBeat.delete(tabId);
}
  1. 页面向 SW 发送 exit 消息,避免误报
postToSW({ type: 'exit', tabId: this.tabId });

2.3 页面侧的心跳通知

this.timer = setInterval(() => {
  this.postToSW({
    type: 'heartbeat',
    tabId: this.tabId,
    ts: Date.now()
  });
}, this.heartbeatIntervalMs);

同时绑定生命周期:

  • beforeunload

  • visibilitychange(hidden → exit)

  • pagehide

2.4 方案二的优劣

优点

  • 支持实时上报

  • 监控逻辑不依赖浏览器是否回到页面

  • 多标签页信息共享更自然(SW 本来就是共享运行时)

缺点(非常关键)

  • 浏览器窗口关闭时,SW 也会消失 → 无法上报

  • 仅适用于开启 SW 的站点(需 HTTPS + 同源)

  • SW 更新策略复杂(需处理 skipWaiting、claim 等)

最终结论与推荐方案

场景 最佳方案 原因
简单不复杂 方案一(localStorage + 心跳) 下次打开可判断所有异常退出,覆盖范围最大
想实时上报崩溃(非浏览器关闭情况) 方案二(Service Worker) 页面崩溃后 SW 仍可运行并上报
希望误报率最低 结合两者 SW 实时 & 下次打开兜底

附录

  1. 完整方案一代码示例见:页面崩溃上报实现代码
  2. 方案二 Service Worker 示例代码见:Service Worker 页面崩溃监控代码Service Worker 代码
  3. 完整demo 见:页面崩溃监控 Demo

如果觉得代码有用麻烦点个小星星。

前端应该知道的浏览器知识

2025年12月9日 17:27

 浏览器整体架构:多进程多线程

现代浏览器(以Chrome为代表)采用分层、多进程的架构,主要目的是安全、稳定和性能。一个典型的浏览器可以被抽象为下图所示的三层结构:

  • 第一层:用户界面:我们看到的地址栏、书签栏、按钮等。它运行在独立的 浏览器进程 中,响应用户的全局操作。
  • 第二层:浏览器内核:浏览器的“大脑”,负责调度和管理。网络线程处理请求,UI后端线程绘制基础控件,而最核心的渲染引擎(如Blink)和JS引擎(如V8)则运行在独立的渲染进程中。
  • 第三层:数据持久层:负责Cookie、本地存储、缓存等数据的读写。

deepseek_mermaid_20251209_899f2a.png

主要进程及其职责包括:

进程 职责
浏览器进程 负责界面显示(地址栏、书签)、用户交互、子进程管理等。
GPU进程 负责独立的图形绘制(3D CSS、WebGL)。
网络进程 负责所有网络资源加载。
渲染进程(核心) 每个标签页通常对应一个独立的渲染进程,负责解析HTML/CSS、执行JavaScript、进行布局和绘制(排版、渲染)。我们说的“主线程”就在这里。
插件进程 每个插件独立进程,防止崩溃影响浏览器。

所以,浏览器整体是多进程的。而在一个渲染进程内部,又包含多个线程协同工作

🧵 渲染进程内的线程分工

一个典型的渲染进程包含以下关键线程:

  1. GUI渲染线程:负责解析HTML/CSS、构建DOM树、CSSOM树、布局和绘制等。注意:GUI渲染线程与JS引擎线程是互斥的
  2. JavaScript引擎线程(这就是我们常说的“主线程”或“UI线程”) :负责执行JavaScript代码(如V8引擎)。我们常说的“JavaScript是单线程的”,指的就是这个线程。
  3. 定时器触发线程:管理setTimeoutsetInterval的计时,计时完毕将回调加入任务队列。
  4. 异步HTTP请求线程:处理XMLHttpRequestfetch等网络请求,完成后将回调加入任务队列。
  5. 事件触发线程:管理事件循环,当事件(如点击)触发时,将对应的回调函数加入任务队列。
  6. 合成线程:将页面分层信息发送给GPU进程。

核心:“主线程”是什么?

我们通常所说的  “主线程” ,狭义上指  “JavaScript引擎线程” ,广义上指的是承担了JavaScript执行、页面渲染(GUI)、事件处理等核心工作的这个单一线程执行模型

因为GUI渲染线程JS引擎线程是互斥的,它们不能同时执行,所以可以理解为一个“工作主线程”在不同时段切换着做这两件事。其工作流程可以总结为下图:

deepseek_mermaid_20251209_b32264.png

为什么这样设计?  主要是为了保证DOM操作结果的一致性。如果JS线程和渲染线程同时工作,JS可能在渲染中途修改DOM,导致渲染出错。互斥执行简化了并发控制,但带来了性能挑战。

🧵 微观机制:渲染进程与事件循环

作为前端开发者,我们必须深入理解渲染进程的内部,因为我们的代码就在这里执行。下图描绘了渲染进程中,从接收网络数据到最终屏幕像素的关键工作流:

deepseek_mermaid_20251209_8bd5ee.png

结合上图,我们来理解几个最关键的概念:

  1. 渲染流水线
    这是浏览器将代码变成像素的过程。你需要理解几个关键步骤:

    • 解析与构建树:HTML解析为DOM树,CSS解析为CSSOM树
    • 布局:计算每个DOM元素在视口中的精确位置和大小(又称“回流”)。
    • 绘制:将元素的文本、颜色、边框等视觉信息填充到多个图层上(又称“重绘”)。
    • 合成:这是现代浏览器保持流畅的关键。合成线程将各个图层分块,交由GPU进程进行光栅化(变成位图),最后像叠盘子一样合成为最终画面。这个过程完全在独立的线程进行,不阻塞主线程
  2. 事件循环:JavaScript的并发模型
    是 JavaScript 实现异步编程的核心机制,其作用是协调同步任务异步任务的执行顺序,让单线程的 JS 能够高效处理非阻塞操作(如网络请求、定时器、DOM 事件)。

    • 宏任务队列:包含 setTimeoutsetIntervalI/O、UI渲染、MessageChannel 等回调。

    • 微任务队列:包含 Promise.thenMutationObserverqueueMicrotask 等回调。
      运行规则:每执行一个宏任务后,会清空整个微任务队列,然后检查是否需要渲染,接着再取下一个宏任务。

    核心规则一个宏任务 → 所有微任务 → (可能渲染)→ 下一个宏任务

💡 对前端开发的深刻启示

  1. 性能瓶颈在主线程:所有同步JS、DOM操作、样式计算、布局都发生在同一个主线程上。这就是为什么长时间的同步JS会“卡死”页面——它阻塞了渲染和事件处理。

  2. 理解渲染时机:浏览器会智能地合并多次DOM操作,但直接读取某些布局属性(如 offsetTopgetComputedStyle)会强制触发同步布局,导致性能骤降。

  3. 善用异步与分层

    • 用 requestAnimationFrame 执行动画,让它与渲染周期对齐。
    • 将耗时计算移入 Web Worker(运行在独立线程,无法访问DOM)。
    • 利用CSS transform 和 opacity 属性进行动画,它们可以由合成线程单独处理,完全避开主线程和重绘,效率最高。
  4. React调度器的用武之地:React正是深刻理解了上述机制,才用 MessageChannel 将渲染工作拆分为5ms左右的可中断任务单元。每个单元执行后,通过事件循环将控制权交还浏览器,从而避免长任务阻塞,实现流畅的并发更新。

Mac上Git不识别文件名大小写修改?一招搞定!

作者 Ric970
2025年12月9日 17:25

Mac 上 Git 不识别文件名大小写修改?一招搞定!

作为一名经常在 Mac 上开发的程序员,你是否遇到过这样的窘境:明明手动把文件名从大写改成了小写(比如把 README.TXT 改成 readme.txt),但提交 Git 时却发现这个改名操作完全没生效?别慌,这不是 Git 的 bug,而是 Mac 文件系统和 Git 默认配置的 “小摩擦”,下面来看看如何解决这个问题。

一、问题复现:改了大小写,Git 却 “看不见”

场景很典型:

  • 本地有一个大写文件名的文件,比如 HELLO.md,已经被 Git 追踪;

  • 手动右键重命名,或用 mv HELLO.md hello.md 改成小写;

  • 执行 git status 查看状态,结果 Git 显示 “working tree clean / 工作区干净”,完全没检测到文件名的变化;

  • 试图直接 git add 也没用,改名仿佛 “石沉大海”。

二、根因分析:Mac 文件系统的 “大小写不敏感” 特性

问题的核心在于:

  • Mac 默认的文件系统(APFS/HFS+)通常是大小写不敏感的;

  • 在系统层面,HELLO.mdhello.md 会被认为是同一个文件;

  • Git 默认会继承操作系统的文件大小写敏感性配置;

  • 当 macOS 告诉 Git “这两个文件名是同一个” 时,Git 自然就不会把大小写修改识别为 “文件变更”,也就不会记录这个操作了。

三、解决方案:两种思路,按需选择

针对这个问题,有 “临时解决单次问题” 和 “永久配置一劳永逸” 两种方案,可以根据场景选择。

方案 1:临时解决(单次文件名修改)

如果你只是偶尔修改一个文件的大小写,用 git rm --cached 配合 mv 指令就能快速解决,步骤如下:

# 1. 先删除 Git 对原大写文件的追踪(本地文件不会被删除)
git rm --cached 大写文件名  # 示例:git rm --cached HELLO.md

# 2. 用 mv 指令重命名本地文件(如果还没改的话)
mv 大写文件名 小写文件名    # 示例:mv HELLO.md hello.md

# 3. 将小写文件重新加入 Git 追踪
git add 小写文件名         # 示例:git add hello.md

# 4. 提交修改
git commit -m "rename: 文件名大小写调整(HELLO.md -> hello.md)"

执行完这四步后,再用 git status 就能看到 Git 已经正确识别到文件名的修改了,后续正常 push 即可。

方案 2:永久配置(全局 / 仓库级识别大小写)

如果你经常需要修改文件名大小写,每次都手动执行临时方案会很繁琐,这时候可以修改 Git 的配置,让它强制识别文件名大小写。

方式 A:仅当前仓库生效(推荐)

进入你的 Git 仓库根目录,执行以下命令,仅对当前项目生效:

cd /path/to/your/repo  # 进入你的项目目录
git config core.ignorecase false

方式 B:全局生效(所有 Git 仓库)

如果想让所有本地 Git 仓库都识别文件名大小写,执行全局配置命令:

git config --global core.ignorecase false

四、关键注意事项

  • 先提交其他修改:操作前一定要确保工作区其他未提交的修改已经提交或暂存,避免执行 git rm --cached 时误删其他文件的追踪。

  • 跨平台兼容要注意:如果你的仓库需要在 Windows/macOS(大小写不敏感)和 Linux(大小写敏感)之间切换,建议尽量保持文件名全小写,避免仅靠大小写区分文件(比如同时存在 File.txtfile.txt),否则在不同系统上可能出现文件冲突。

  • 验证配置是否生效:修改配置后,可以用以下命令确认是否成功: `# 查看当前仓库的大小写敏感配置 git config core.ignorecase

查看全局的大小写敏感配置

git config --global core.ignorecase 输出false` 就说明配置生效了。

五、总结

  • Mac 上 Git 不识别文件名大小写修改,本质是 文件系统特性Git 默认配置 的冲突;

  • 通过 git rm --cached + mv 的临时方案可以快速解决单次问题;

  • 修改 core.ignorecase 配置则能在项目或全局层面 “一劳永逸”。

掌握这两种方法后,就不用再担心文件名大小写的 “Git 盲区” 了。如果你也遇到过类似的问题,或者有其他解决技巧,也可以在团队内部文档或代码规范里补充说明,方便大家排坑。

JavaScript 数组原生方法手写实现

作者 1024肥宅
2025年12月9日 17:21

引言

在JavaScript开发中,数组方法是日常编码的核心工具。理解这些方法的内部实现原理不仅能帮助我们写出更高效的代码,还能在面试中展现扎实的基础。本文将完整实现JavaScript中最重要、最常用的数组方法,涵盖高阶函数、搜索方法、扁平化方法和排序算法。

一、高阶函数实现

1.1 map方法实现

map是最常用的高阶函数之一,它创建一个新数组,其结果是该数组中的每个元素调用一次提供的函数后的返回值。

Array.prototype.myMap = function (callback, thisArg) {
  // 输入验证
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + "is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;
  const result = new Array(len);

  // 遍历并执行回调
  for (let i = 0; i < len; i++) {
    // 处理稀疏数组
    if (i in obj) {
      result[i] = callback.call(thisArg, obj[i], i, obj);
    }
  }

  return result;
};

// 使用示例
const numbers = [1, 2, 3];
const squares = numbers.myMap((num) => num * num);
console.log(squares); // [1, 4, 9]
1.2 filter方法实现

filter方法创建一个新数组,包含通过测试的所有元素。

Array.prototype.myFilter = function (callback, thisArg) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + " is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;
  const result = [];

  for (let i = 0; i < len; i++) {
    if (i in obj) {
      // 如果回调返回true,则保留该元素
      if (callback.call(thisArg, obj[i], i, obj)) {
        result.push(obj[i]);
      }
    }
  }
  return result;
};

// 使用示例:筛选出大于2的数字
const nums = [1, 2, 3, 4, 5];
const filtered = nums.myFilter((num) => num > 2);
console.log(filtered); // [3, 4, 5]
1.3 reduce方法实现

reduce是最强大的高阶函数,可以将数组元素通过reducer函数累积为单个值。

Array.prototype.myReduce = function (callback, initialValue) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + " is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;

  // 处理空数组且无初始值的情况
  if (len === 0 && initialValue === undefined) {
    throw new TypeError("Reduce of empty array with no initial value");
  }

  let accumulator = initialValue;
  let startIndex = 0;

  // 如果没有提供初始值,使用第一个有效元素作为初始值
  if (initialValue === undefined) {
    // 找到第一个存在的元素(处理稀疏数组)
    while (startIndex < len && !(startIndex in obj)) {
      startIndex++;
    }

    if (startIndex === len) {
      throw new TypeError("Reduce of empty array with no initial value");
    }

    accumulator = obj[startIndex];
    startIndex++;
  }

  // 执行reduce操作
  for (let i = startIndex; i < len; i++) {
    if (i in obj) {
      accumulator = callback(accumulator, obj[i], i, obj);
    }
  }

  return accumulator;
};

// 使用示例
const sum = [1, 2, 3, 4, 5].myReduce((acc, curr) => acc + curr, 0);
console.log(sum); // 15

// 复杂示例:数组转对象
const items = [
  { id: 1, name: "Apple" },
  { id: 2, name: "Banana" },
  { id: 3, name: "Orange" },
];

const itemMap = items.myReduce((acc, item) => {
  acc[item.id] = item;
  return acc;
}, {});

console.log(itemMap);
// {
//   '1': { id: 1, name: 'Apple' },
//   '2': { id: 2, name: 'Banana' },
//   '3': { id: 3, name: 'Orange' }
// }

二、搜索与断言方法

2.1 find方法实现

find方法返回数组中满足测试函数的第一个元素的值。

Array.prototype.myFind = function (callback, thisArg) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + " is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;

  for (let i = 0; i < len; i++) {
    if (i in obj) {
      if (callback.call(thisArg, obj[i], i, obj)) {
        return obj[i];
      }
    }
  }

  return undefined;
};

// 使用示例
const users = [
  { id: 1, name: "Alice", age: 25 },
  { id: 2, name: "Bob", age: 30 },
  { id: 3, name: "Charlie", age: 35 },
];

const user = users.myFind((user) => user.age > 28);
console.log(user); // { id: 2, name: 'Bob', age: 30 }
2.2 findIndex方法实现

findIndex方法返回数组中满足测试函数的第一个元素的索引。

Array.prototype.myFindIndex = function (callback, thisArg) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + " is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;

  for (let i = 0; i < len; i++) {
    if (i in obj) {
      if (callback.call(thisArg, obj[i], i, obj)) {
        return i;
      }
    }
  }

  return -1;
};

// 使用示例
const numbers = [5, 12, 8, 130, 44];
const firstLargeNumberIndex = numbers.myFindIndex(num => num > 10);
console.log(firstLargeNumberIndex); // 1
2.3 some方法实现

some方法返回数组中是否至少有一个元素通过了测试。

Array.prototype.mySome = function (callback, thisArg) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + " is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;

  for (let i = 0; i < len; i++) {
    if (i in obj) {
      if (callback.call(thisArg, obj[i], i, obj)) {
        return true;
      }
    }
  }

  return false;
};

// 使用示例
const hasEven = [1, 3, 5, 7, 8].mySome((num) => num % 2 === 0);
console.log(hasEven); // true
2.4 every方法实现

every方法测试数组中的所有元素是否都通过了测试。

Array.prototype.myEvery = function (callback, thisArg) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + " is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;

  for (let i = 0; i < len; i++) {
    if (i in obj) {
      if (!callback.call(thisArg, obj[i], i, obj)) {
        return false;
      }
    }
  }

  return true;
};

// 使用示例
const allPositive = [1, 2, 3, 4, 5].myEvery((num) => num > 0);
console.log(allPositive); // true

三、数组扁平化方法

3.1 flat方法实现

flat方法创建一个新数组, 其中所有子数组元素递归连接到指定深度。

Array.prototype.myFlat = function (depth = 1) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  // 深度参数验证
  if (depth < 0) {
    throw new RangeError("depth must be a non-negative integer");
  }

  const result = [];

  const flatten = (arr, currentDepth) => {
    for (let i = 0; i < arr.length; i++) {
      const element = arr[i];
      // 如果当前深度小于指定深度且元素是数组, 则递归扁平化
      if (Array.isArray(element) && currentDepth < depth) {
        flatten(element, currentDepth + 1);
      } else {
        // 否则直接添加到结果数组
        // 注意: 如果depth为0,则不会扁平化任何数组
        result.push(element);
      }
    }
  };

  flatten(this, 0);
  return result;
};

// 使用示例
const nestedArray = [1, [2, [3, [4]], 5]];
console.log(nestedArray.myFlat()); // [1, 2, [3, [4]], 5]
console.log(nestedArray.myFlat(2)); // [1, 2, 3, [4], 5]
console.log(nestedArray.myFlat(Infinity)); // [1, 2, 3, 4, 5]
3.2 flatMap方法实现

flatMap方法首先使用映射函数映射每个元素, 然后将结果压缩成一个新数组。

Array.prototype.myFlatMap = function (callback, thisArg) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  if (typeof callback !== "function") {
    throw new TypeError(callback + " is not a function");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;
  const result = [];

  for (let i = 0; i < len; i++) {
    if (i in obj) {
      const mapped = callback.call(thisArg, obj[i], i, obj);

      // 如果回调函数返回的是数组, 则展开它
      if (Array.isArray(mapped)) {
        for (let j = 0; j < mapped.length; j++) {
          result.push(mapped[j]);
        }
      } else {
        // 如果不是数组,直接添加
        result.push(mapped);
      }
    }
  }

  return result;
};

// 使用示例
const phrases = ["Hello world", "JavaScript is awesome"];
const words = phrases.myFlatMap((phrase) => phrase.split(" "));
console.log(words); // ["Hello", "world", "JavaScript", "is", "awesome"]

// 另一个示例:展开并过滤
const numbers2 = [1, 2, 3, 4];
const result = numbers2.myFlatMap((x) => (x % 2 === 0 ? [x, x * 2] : []));
console.log(result); // [2, 4, 4, 8]

四、排序算法实现

4.1 sort方法实现

JavaScript原生的sort方法使用TimSort算法(一种混合排序算法, 结合了归并排序和插入排序)。这里我们实现一个简单但功能完整的排序方法, 支持自定义比较函数。

Array.prototype.mySort = function (compartFn) {
  if (this === null) {
    throw new TypeError("this is null or not defined");
  }

  const obj = Object(this);
  const len = obj.length >>> 0;

  // 如果没有提供比较函数, 使用默认的字符串比较
  if (compartFn === undefined) {
    // 默认比较函数: 将元素转为字符串, 然后比较UTF-16代码单元值序列
    compartFn = function (a, b) {
      const aString = String(a);
      const bString = String(b);

      if (aString < bString) return -1;
      if (aString > bString) return 1;
      return 0;
    };
  } else if (typeof compartFn !== "function") {
    throw new TypeError("compareFn must be a function or undefined");
  }

  // 实现快速排序算法(高效且常用)
  function quickSort(arr, left, right, compare) {
    if (left >= right) return;

    const pivotIndex = partition(arr, left, right, compare);
    quickSort(arr, left, pivotIndex - 1, compare);
    quickSort(arr, pivotIndex + 1, right, compare);
  }

  function partition(arr, left, right, compare) {
    // 选择中间元素作为基准值
    const pivotIndex = Math.floor((left + right) / 2);
    const pivotValue = arr[pivotIndex];

    // 将基准值移到最右边
    [arr[pivotIndex], arr[right]] = [arr[right], arr[pivotIndex]];

    let storeIndex = left;

    for (let i = left; i < right; i++) {
      // 使用比较函数比较当前元素和基准值
      if (compare(arr[i], pivotValue) < 0) {
        [arr[storeIndex], arr[i]] = [arr[i], arr[storeIndex]];
        storeIndex++;
      }
    }

    // 将基准值放到正确的位置
    [arr[storeIndex], arr[right]] = [arr[right], arr[storeIndex]];
    return storeIndex;
  }

  // 将稀疏数组转换为紧凑数组(跳过不存在的元素)
  const compactArray = [];
  for (let i = 0; i < len; i++) {
    if (i in obj) {
      compactArray.push(obj[i]);
    }
  }

  // 执行快速排序
  if (compactArray.length > 0) {
    quickSort(compactArray, 0, compactArray.length - 1, compartFn);
  }

  // 将排序后的数组复制回原数组,保持稀疏性
  let compactIndex = 0;
  for (let i = 0; i < len; i++) {
    if (i in obj) {
      obj[i] = compactArray[compactIndex++];
    }
  }

  return obj;
};

// 使用示例
const unsorted = [3, 1, 4, 1, 5, 9, 2, 6, 5];
unsorted.mySort();
console.log(unsorted); // [1, 1, 2, 3, 4, 5, 5, 6, 9]

// 使用自定义比较函数
const students = [
  { name: "Alice", score: 85 },
  { name: "Bob", score: 92 },
  { name: "Charlie", score: 78 },
];

students.mySort((a, b) => b.score - a.score);
console.log(students);
// 按分数降序排列

五、总结

5.1 实现要点总结
  1. 输入验证: 始终检查this是否为nullundefined, 以及回调函数是否为函数类型
  2. 稀疏数组处理: 使用in操作符检查索引是否存在
  3. 类型安全: 使用>>>0确保长度为非负整数
  4. 性能考虑:
  • 避免不必要的数组拷贝
  • 使用适当的算法(如快速排序对于sort方法)
  • 注意递归深度(特别是对于flat方法)
  1. 与原生方法差异:
  • 我们的实现在某些边缘情况下可能与原生方法略有不同
  • 原生方法通常有更好的性能和内存管理
5.2 实际应用场景
  1. 数据处理: mapfilterreduce是数据处理的三件套
  2. 搜索功能: findfindIndex用于数据检索
  3. 表单验证: someevery用于验证多个输入
  4. 状态管理: flatflatMap在处理嵌套状态时特别有用
  5. 数据展示: sort用于数据排序

通过手动实现这些核心数组方法,我们不仅加深了对JavaScript数组操作的理解,还掌握了函数式编程的核心概念。

记住:在实际生产环境中,仍然建议使用原生数组方法,因为它们经过了充分优化和测试。但理解这些方法的实现原理,将使你成为一个更出色的JavaScript开发者。

❌
❌