阅读视图

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

每日一题-统计计算机解锁顺序排列数🟡

给你一个长度为 n 的数组 complexity

在房间里有 n 台 上锁的 计算机,这些计算机的编号为 0 到 n - 1,每台计算机都有一个 唯一 的密码。编号为 i 的计算机的密码复杂度为 complexity[i]

编号为 0 的计算机密码已经 解锁 ,并作为根节点。其他所有计算机必须通过它或其他已经解锁的计算机来解锁,具体规则如下:

  • 可以使用编号为 j 的计算机的密码解锁编号为 i 的计算机,其中 j 是任何小于 i 的整数,且满足 complexity[j] < complexity[i](即 j < i 并且 complexity[j] < complexity[i])。
  • 要解锁编号为 i 的计算机,你需要事先解锁一个编号为 j 的计算机,满足 j < i 并且 complexity[j] < complexity[i]

求共有多少种 [0, 1, 2, ..., (n - 1)] 的排列方式,能够表示从编号为 0 的计算机(唯一初始解锁的计算机)开始解锁所有计算机的有效顺序。

由于答案可能很大,返回结果需要对 109 + 7 取余数。

注意:编号为 0 的计算机的密码已解锁,而 不是 排列中第一个位置的计算机密码已解锁。

排列 是一个数组中所有元素的重新排列。

 

示例 1:

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

输出: 2

解释:

有效的排列有:

  • [0, 1, 2]
    • 首先使用根密码解锁计算机 0。
    • 使用计算机 0 的密码解锁计算机 1,因为 complexity[0] < complexity[1]
    • 使用计算机 1 的密码解锁计算机 2,因为 complexity[1] < complexity[2]
  • [0, 2, 1]
    • 首先使用根密码解锁计算机 0。
    • 使用计算机 0 的密码解锁计算机 2,因为 complexity[0] < complexity[2]
    • 使用计算机 0 的密码解锁计算机 1,因为 complexity[0] < complexity[1]

示例 2:

输入: complexity = [3,3,3,4,4,4]

输出: 0

解释:

没有任何排列能够解锁所有计算机。

 

提示:

  • 2 <= complexity.length <= 105
  • 1 <= complexity[i] <= 109

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

💥告别卡顿!前端性能优化第一课: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 背后的隐藏功臣

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 环境

你首先需要的是 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的一般知识,请阅读我的博客文章

3577. 统计计算机解锁顺序排列数

解法

思路和算法

使用已解锁的计算机密码将未解锁的计算机解锁的条件是:已解锁的计算机的编号和密码复杂度分别小于未解锁的计算机的编号和密码复杂度。由于初始时只有编号为 $0$ 的计算机密码已解锁,编号 $0$ 是最小编号,因此对于其他任意编号 $i$,是否可以使用编号为 $0$ 的计算机密码解锁编号为 $i$ 的计算机的情况如下。

  • 当 $\textit{complexity}[i] > \textit{complexity}[0]$ 时,可以使用编号为 $0$ 的计算机密码解锁编号为 $i$ 的计算机。

  • 当 $\textit{complexity}[i] \le \textit{complexity}[0]$ 时,不能使用编号为 $0$ 的计算机密码解锁编号为 $i$ 的计算机。

如果存在 $1 \le i < n$ 的整数 $i$ 满足 $\textit{complexity}[i] \le \textit{complexity}[0]$,则对于任意可以被编号为 $0$ 的计算机密码解锁的计算机的编号 $j$,必有 $\textit{complexity}[j] > \textit{complexity}[0]$,因此 $\textit{complexity}[j] > \textit{complexity}[i]$,即编号为 $j$ 的计算机密码不能解锁编号为 $i$ 的计算机。因此,编号为 $i$ 的计算机无法被解锁,此时无法解锁所有计算机。

如果 $1 \le i < n$ 的所有整数 $i$ 都满足 $\textit{complexity}[i] > \textit{complexity}[0]$,则所有计算机都能被编号为 $0$ 的计算机密码解锁。由于初始时编号为 $0$ 的计算机密码已解锁,因此其余 $n - 1$ 台计算机可以按任意顺序解锁,排列数是 $(n - 1)!$。

代码

###Java

class Solution {
    static final int MODULO = 1000000007;

    public int countPermutations(int[] complexity) {
        long permutations = 1;
        int n = complexity.length;
        for (int i = 1; i < n; i++) {
            if (complexity[i] <= complexity[0]) {
                return 0;
            }
            permutations = permutations * i % MODULO;
        }
        return (int) permutations;
    }
}

###C#

public class Solution {
    const int MODULO = 1000000007;

    public int CountPermutations(int[] complexity) {
        long permutations = 1;
        int n = complexity.Length;
        for (int i = 1; i < n; i++) {
            if (complexity[i] <= complexity[0]) {
                return 0;
            }
            permutations = permutations * i % MODULO;
        }
        return (int) permutations;
    }
}

###C++

const int MODULO = 1000000007;

class Solution {
public:
    int countPermutations(vector<int>& complexity) {
        long long permutations = 1;
        int n = complexity.size();
        for (int i = 1; i < n; i++) {
            if (complexity[i] <= complexity[0]) {
                return 0;
            }
            permutations = permutations * i % MODULO;
        }
        return permutations;
    }
};

###Python

MODULO = 1000000007

class Solution:
    def countPermutations(self, complexity: List[int]) -> int:
        permutations = 1
        n = len(complexity)
        for i in range(1, n):
            if complexity[i] <= complexity[0]:
                return 0
            permutations = permutations * i % MODULO
        return permutations

###C

const int MODULO = 1000000007;

int countPermutations(int* complexity, int complexitySize) {
    long long permutations = 1;
    for (int i = 1; i < complexitySize; i++) {
        if (complexity[i] <= complexity[0]) {
            return 0;
        }
        permutations = permutations * i % MODULO;
    }
    return permutations;
}

###Go

const MODULO = 1000000007

func countPermutations(complexity []int) int {
    permutations := 1
    n := len(complexity)
    for i := 1; i < n; i++ {
        if complexity[i] <= complexity[0] {
            return 0
        }
        permutations = permutations * i % MODULO
    }
    return permutations
}

###JavaScript

const MODULO = 1000000007;

var countPermutations = function(complexity) {
    let permutations = 1;
    let n = complexity.length;
    for (let i = 1; i < n; i++) {
        if (complexity[i] <= complexity[0]) {
            return 0;
        }
        permutations = permutations * i % MODULO;
    }
    return permutations;
};

###TypeScript

const MODULO = 1000000007;

function countPermutations(complexity: number[]): number {
    let permutations = 1;
    let n = complexity.length;
    for (let i = 1; i < n; i++) {
        if (complexity[i] <= complexity[0]) {
            return 0;
        }
        permutations = permutations * i % MODULO;
    }
    return permutations;
};

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{complexity}$ 的长度。需要遍历数组一次。

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

脑筋急转弯(Python/Java/C++/Go)

题目说:用计算机 $j$ 解锁计算机 $i$ 的前提是 $j<i$ 且 $\textit{complexity}[j] < \textit{complexity}[i]$。

观察

  • 一开始就解锁的只有计算机 $0$。
  • 第一轮,被 $0$ 解锁的计算机(记作集合 $A$),密码复杂度比 $\textit{complexity}[0]$ 大。
  • 第二轮,被集合 $A$ 中的计算机解锁的计算机(记作集合 $B$),密码复杂度更大,所以也比 $\textit{complexity}[0]$ 大。
  • 第三轮,被集合 $B$ 中的计算机解锁的计算机(记作集合 $C$),密码复杂度更大,所以也比 $\textit{complexity}[0]$ 大。
  • 依此类推,所有被解锁的计算机的密码复杂度都要比 $\textit{complexity}[0]$ 大。

定理:当且仅当计算机 $0$ 右边的所有计算机的密码复杂度都比 $\textit{complexity}[0]$ 大,才能解锁所有计算机。

充分性:如果计算机 $0$ 右边的所有计算机的密码复杂度都比 $\textit{complexity}[0]$ 大,根据题意,仅用计算机 $0$ 便可解锁所有计算机。

必要性:如果可以解锁所有的计算机,那么计算机 $0$ 右边的所有计算机的密码复杂度都比 $\textit{complexity}[0]$ 大。考虑其逆否命题,即如果在计算机 $0$ 的右边存在计算机 $A$,满足 $\textit{complexity}[i] \le \textit{complexity}[0]$,那么不可能解锁计算机 $A$,更不可能解锁所有计算机。为了解锁计算机 $A$,我们需要在其左边找比 $\textit{complexity}[i]$ 更小的计算机。不断往左找,计算机的密码复杂度只会更小,直到找到一台被计算机 $0$ 解锁的计算机 $B$。$B$ 的密码复杂度必须比 $\textit{complexity}[0]$ 大,但为了能解锁计算机 $A$,$B$ 的密码复杂度又要 $< \textit{complexity}[i] \le \textit{complexity}[0]$,矛盾,所以不可能解锁计算机 $A$。

根据定理,如果计算机 $0$ 右边的所有计算机的密码复杂度都比 $\textit{complexity}[0]$ 大,那么我们可以按照任意顺序解锁这 $n-1$ 台计算机,方案数为 $n-1$ 个不同物品的全排列个数,即

$$
(n-1)!
$$

注意取模。关于模运算的知识点,见 模运算的世界:当加减乘除遇上取模

###py

class Solution:
    def countPermutations(self, complexity: List[int]) -> int:
        MOD = 1_000_000_007
        ans = 1
        for i in range(1, len(complexity)):
            if complexity[i] <= complexity[0]:
                return 0
            ans = ans * i % MOD
        return ans

###java

class Solution {
    public int countPermutations(int[] complexity) {
        final int MOD = 1_000_000_007;
        long ans = 1;
        for (int i = 1; i < complexity.length; i++) {
            if (complexity[i] <= complexity[0]) {
                return 0;
            }
            ans = ans * i % MOD;
        }
        return (int) ans;
    }
}

###cpp

class Solution {
public:
    int countPermutations(vector<int>& complexity) {
        const int MOD = 1'000'000'007;
        long long ans = 1;
        for (int i = 1; i < complexity.size(); i++) {
            if (complexity[i] <= complexity[0]) {
                return 0;
            }
            ans = ans * i % MOD;
        }
        return ans;
    }
};

###go

func countPermutations(complexity []int) int {
const mod = 1_000_000_007
ans := 1
for i := 1; i < len(complexity); i++ {
if complexity[i] <= complexity[0] {
return 0
}
ans = ans * i % mod
}
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{complexity}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

更多相似题目,见下面思维题单的「§5.2 脑筋急转弯」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

分类讨论

解法:分类讨论

因为只能用小 complexity 解锁大 complexity,所以后续解锁的电脑一定满足 complexity[i] > complexity[0]

换句话说,如果存在 i > 0,使得 complexity[i] <= complexity[0],那就无法解锁该电脑,答案为 $0$。

否则,所有电脑都能通过电脑 $0$ 解锁,因此后续编号可以任意排列,答案为 $(n - 1)!$。

复杂度 $\mathcal{O}(n)$。

参考代码(c++)

class Solution {
public:
    int countPermutations(vector<int>& complexity) {
        int n = complexity.size();

        // 检查是否有无法解锁的电脑
        for (int i = 1; i < n; i++) if (complexity[i] <= complexity[0]) return 0;

        // 计算 (n - 1)!
        const int MOD = 1e9 + 7;
        long long ans = 1;
        for (int i = 1; i < n; i++) ans = ans * i % MOD;
        return ans;
    }
};

开发提效利器 - 用好Snippets

前言

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 事件循环:宏任务执行顺序分析

一、先理清核心概念

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

  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 一直关联失败

比如执行`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?

同步至个人站点:为什么在 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 继承

原型与原型链基础

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

  • 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 如何安全构建原型链,了解其他构建方法有何不妥,为我们写出健壮的继承结构添一把力

百度慧播星数字人技术演进

导读

从2023年成立到如今日均服务2万+直播间,百度慧播星已演进为覆盖脚本生成、实时问答、智能决策、音视频克隆的全链路AI直播平台。本文深入解读其技术架构:如何通过检索增强和强化学习生成高转化脚本;如何利用强化学习智能中控动态优化直播策略;以及如何将语音与形象克隆效率提升至“小时级”;如何构建“先验-后验”数据飞轮,让模型自主进化;。罗永浩数字人直播GMV突破5500万的案例,验证了其“超越真人”的带货能力。未来,慧播星正朝着更智能、更拟真、更高效的方向持续迭代。

01 慧播星介绍

电商数字人直播(慧播星)正式成立于2023年,是一款汇集了百度在视觉,语音和语言方面AI能力的原生AI应用产品,致力于打造代际领先的超越真人的直播体验。25年底日均开播直播间已达2万多个,覆盖电商、教育、健康、金融、泛知识内容等多个行业。经过两年多的产品打磨和技术突破,慧播星数字人直播已具备超越真人的能力。例如,这些能力支撑了罗永浩2025年6月15 日的数字人直播首秀,吸引了超 1300 万人次观看,GMV(商品交易总额)突破 5500 万元,这一成绩超过了其同年 5 月的真人直播首秀(GMV 5000 万)。

1.1 商家业务视角——开播流程

商家在慧播星获得带货权限后,即可自助开启数字人直播,主要包括如下流程。

1. 商品选择,可从百度直营店铺(度小店),三方电商平台(京东淘宝拼多多)和百度本地生活的海量商品中选择带货商品

图片

△ 海量内外部商品一键挂接

  1. 形象选择或者定制,从7800+公共库形象中选择主播形象,或通过自助录制5分钟视频定制私有形象

图片

△ 形象选择或定制

  1. 直播间装修,从3600+套直播间模板中选择装修风格和元素,或通过AI自动生成直播间背景图和营销挂件

图片

△ 直播间装修,丰富的模板&组件

  1. 脚本生成,从多种公共风格中选择脚本带货风格,或自定义目标带货风格,补充少量营销信息,一键生成专业的直播脚本

图片

△ 一键脚本生成

  1. 音色选择,从3200+个公共库音色中选择主播音色,或通过手百自助录制,3天内得到私有定制音色

图片

△ 音色选择或制作

6. 直播间互动配置,一键开启一言问答接管,也支持手动配置预置问答对,补充商家知识

图片

△ 直播间互动配置

02 整体技术架构

慧播星整体架构主要由商家端、视觉语音和文本各模态模型、实时渲染引擎、站内外分发系统组成。

图片

为实现更好的直播体验,数字人采用云端生成方案,云端生成系统主要包括如下几个子系统。

1. 商品理解,为脚本,问答,互动等各种内容生成模型提供商品知识增强

2. 脚本生成,围绕商品自动生成风格化口语化的带货脚本

3. 智能问答,用户提问时实时检索商品知识,生成精准的回复,支持弹幕和口播回复

4. 智能互动,以直播效果(评论率、用户退场率、观看时长等)为目标主动向用户发起互动

5. 直播间装修,智能生成直播间背景,合成带营销内容的挂件

图片

03 内容生成

3.1 风格化脚本生成

直播脚本水平与带货效果息息相关,优秀主播的脚本能够打动用户,循循善进引导用户成交。由于普通商家的带货营销水平有限,商家希望仅表达学习某某主播,系统自动为其生成风格相似的脚本。在此需求背景下,慧播星利用多模态商品理解富集构建商品知识库,借助EB4/turbo在电商直播语料上进行大规模预训练,结合人工专家精标数据SFT,通用和电商知识增强等手段实现一键风格化仿写。

  • 商家仅需选定商品和补充少量营销信息,即可按预设风格或者自定风格(提供最少400字的带货文案)一键生成风格相似的带货脚本。客户采纳率92%,开播渗透率67%,相比客户脚本转化率+14%。
  • 考虑到风格化脚本创作需求的独立性,慧播星已将脚本生成独立为工具,商家可脱离直播业务流使用工具。

图片

△ 风格化脚本生成工具UI

技术架构

整体技术主要包括商品理解、检索增强、强化学习风格化生成和后处理阶段。

图片

  • 商品理解。系统通过多模态商品解析技术对商品详情页、海报图、参数图等视觉素材进行 OCR、版面结构识别与多模态模型融合,自动抽取核心卖点、适用人群、功能亮点、使用场景等结构化商品知识。可在单张图里同时捕获“文本内容 + 图示含义 + 排版语义”等特征,并利用 LLM 对解析结果进行归一化和字段对齐,形成高覆盖、高一致性的商品知识库。
  • 检索增强(RAG)链路。用户输入的风格范文(不少于 400 字)会先经过标签分析模块,由大模型识别出其关键风格维度,如:表达节奏(快/慢)、情绪浓度(热情/克制)、营造气氛策略(故事、对比、疑问句)、用户痛点定位、直播常用带货技巧(强调稀缺、促单压力、利益点递进)等。基于这些风格标签,系统自动生成 Query,用于从通用知识库与电商知识库中检索对应表达方式、句式模板与知识上下文(卖点顺序推荐、商品类别常用话术、场景化句法等)。
  • 风格化生成模型。模型基于电商专精的电商直播语料预训练能力,并结合海量运营专家的精细化标注数据(SFT),能够在保持范文风格一致性的同时,将内容自动替换为目标商品的卖点和营销逻辑。为确保生成内容既符合直播场景使用习惯,又具备高情绪感染力,系统引入轻量级 RLHF/强化学习优化,通过人类偏好数据持续调优,使模型能够稳定输出“自然、顺畅、带货效果强”的脚本。为持续提升模型能力,通过数据飞轮对该生成模型进行对齐。
  • 标签化与后处理。脚本被进一步结构化,包括:分镜逻辑、开场引导、利益点铺垫、情绪高点、促单推进、收尾金句等,方便商家在实际直播中灵活调用或进行定制化编辑。

脚本数据飞轮

数字人直播的内容绝大部分来自大模型生成,前期领域专家知识为生成标准,脚本、问答、互动场景的生成质量已达到普通真人主播的水平。然而人工先验知识存在主观偏差,且缺乏全面性和快速适应新变化的能力,完全依赖人工只能达到次优水平。为持续攀升超越域内外头部真人主播,需建立业务和大模型的数据飞轮,通过飞轮效应持续提升模型在数字人直播场景的后验效果。

图片

先验对齐

在真实直播场景中,数字人模型最终追求的是“后验效果最优”——即用户停留、评论增长、转化提升等真实业务指标。然而后验目标往往天然伴随风险:例如激进促单、夸大效果、模糊描述等内容可能在短期内获得更高的用户反馈,却越过事实边界与平台规范,形成安全问题。因此,在模型全面对齐后验之前,必须构建一套稳健、可解释、与平台规范一致的先验对齐体系作为基础。先验奖励模型作为“守门人”,以推理专家模型为判断核心,通过结构化的偏好评分与规则奖励引导模型学习合规、高质、可控的内容风格,实现“先验对齐 → 强化学习 → 专精模型 → 回流验证”的闭环。

自动偏好合成。传统先验奖励完全依赖人工标注,成本高且存在主观性。为解决这一问题,我们集成了多个先进推理类基模型(如 EB4-4T、Deepseek-R1/V3、GPT-o 系列等),通过多模型投票、结果对比分级等方式自动合成偏好。这一自动化偏好生成机制能够模拟“专家标注”,但具备:

  • 一致性更高,减少人工主观波动
  • 覆盖范围更广,数百万级先验数据
  • 适应变化更快,模型可随平台规范或内容趋势变化即时更新

最终形成先验 RM(Reward Model)的核心训练数据。先验 RM 的核心职责是确保模型在任何情况下都不会突破内容安全边界,为后续后验对齐提供稳固底座。

图片

后验数据飞轮

为了让模型吸收用户的真实后验反馈,慧播星构建了一套以“内容探索 + 奖励建模”为两条主线的数据飞轮,实现模型的自主进化与持续增强。

图片

基于后验统计的内容探索:可控、高解释的偏好数据生成链路。后验统计路径主要面向高精度、强可控、可解释性强的偏好数据生产需求,结合在线实验框架,通过真实用户反馈驱动的方式生成偏好样本。通过高频在线实验,系统不断沉淀千级规模的偏好数据,支撑后续的模型偏好对齐训练(如 DPO/IPO 等策略优化方法)。

可泛化的奖励 uplift 建模:大规模偏好数据的高效补充路径。相比基于后验统计的实验方式,uplift 建模路径旨在解决用户行为稀疏、实验成本高的问题,通过泛化模型直接对用户偏好进行预测,生成百万级的偏好数据,实现更高效的数据扩容。采用 S-Learner / T-Learner 等 uplift 方法,构建用户行为因果效应模型,直接预测“某段内容是否会提升用户的互动/评论/停留等关键指标?”

3.2 智能问答

慧播星建设了一套完备的直播场景RAG系统,包括电商领域知识检索模型,通过千亿模型蒸馏的低时延生成模型(12s->2s),数据飞轮。目前已实现多模素材调度,高拟真明星问答,客户个性化表达,垂类适配,商家/商品知识库等产品能力。客户可一键开启智能问答,问答端到端可用率95%,优质率90%,客户开启率94%,运营和客户反馈较好。

图片

△ 智能问答架构

技术架构

慧播星的直播实时问答系统在工程上形成了知识整合 → 领域检索 → 低延迟生成 → 后处理 → 数据飞轮的完整闭环,为超拟真数字人提供了媲美真人的实时互动能力。

  • 知识整合层,系统将商家侧的商品图文、卖点、FAQ、视频脚本、类目属性以及运营沉淀的数据统一入库,并通过向量化处理构建高可用的电商知识底座。
  • 领域知识检索模块结合了千帧蒸馏后的 EB-lite/行业模型与高维向量语义搜索,通过「意图识别 → 精准匹配 → 语义聚类 → 知识召回」的流水线,确保系统能够从复杂直播语境中准确捕捉用户提问意图。直播场景中存在大量口语化、短句化、甚至噪声语料(如: “这个能用多久啊”。“有别的颜色吗?”),系统通过深度语义 embedding(如 ernie embedding)实现高鲁棒性的实时检索,使检索召回的准确率在实时环境下依然保持稳定。
  • 低延时生成模块。基于千亿模型蒸馏结果构建,针对直播高并发、低时延、强一致性的要求,模型经过结构裁剪、张量并行优化与 Prompt 规约,使单轮响应时延从 12s 压缩到 2s,在保证语义丰富度和口播自然度的同时提升端到端体验。
  • 数据飞轮实现持续自我优化:运营反馈、用户互动日志、误匹配案例以及高质问答样本会自动回流到数据处理模块,驱动知识库更新与模型重训练。

3.3 智能中控


真人主播会根据直播间实时状态决策当前应发起何种动作(action),比如直播间互动氛围差的时候是应该邀评,换卖点讲解还是促单?确定动作后主播知道如何最好的的执行动作,例如怎么把邀评讲出来?说什么话,用什么语气,邀请特定观众还是所有观众。行为决策和行为内容生成两者相结合实现直播间下单,关注,留联等最大化目标。超拟真数字人需要具备上述两种核心能力,即给定一个长期目标(如每场次的订单总数,评论总数,观看时长等),要求数字人1)判断在不同直播间状态下应该做出什么行为,是切换卖点讲解,促单逼单,邀评还是多轮互动?2)确定某种行为后生成适合的的行为内容,如塑品讲解,优惠讲解,促单逼单等的具体口播内容。

技术架构

智能中控架构核心由基于强化学习的决策Agent,和基于一言大模型的多任务融合两个部分组成。

图片

基于强化学习的行为决策Agent

行为决策的目标是在不同直播状态下选择最优动作,最大化长期目标(订单、评论、观看时长等)

图片

上图展示了直播环境与RL决策Agent的交互流程:

  • 状态 St:观看人数、评论频率、当前商品、用户行为序列、是否有提问等
  • 动作 At:邀评 / 多轮互动 / 促单 / 动态讲解 / 切换卖点 / 回答问题……
  • 奖励 Rt:订单数变化、评论数增加、停留时长、转化率提升等
  • Agent 通过不断试错 & 策略迭代,获得最优策略。

这使数字人能够像真人主播一样:氛围低时发起互动,用户观望时进行促单,新观众进入时进行商品介绍。RL 的优势在于目标导向:不是优化单句话,而是优化整场直播的 KPI

基于大模型的行为内容生成与融合

当 RL Agent 选择了一个动作后,例如“促单”,还需要生成对应的动作参数:如促单的口播内容,使用什么语气?内容是偏温和还是强节奏?是否引用当前观众的评论?实践中我们通过强化学习训练了一系列action内容生成专精模型,能够生成特定参数指定的直播内容。

未来我们将以语言模型为基座对决策和内容生成任务进行端到端训练,减少分阶段建模带来的累计误差。

04 语音克隆与合成

普通商家原声演绎状态不佳,缺乏带货感。慧播星利用风格迁移TTS技术自动合成感染力强,拟真度高的直播音频。经过两年多的迭代TTS开播使用率从30.3%提升至92.8% 制作时效性从1月降低到1分钟。

电商TTS发展主要经历两个阶段:

第一阶段(2023.3~2024.Q2) **:语音定制工牌麦收音,依赖大量人工传导,整个周期长达一个月

第二阶段(2024.Q3至今) **:小程序自助收音提高收音效率,自动训练架构升级,抑扬顿挫带货效果持续优化

8a3ff4868bef454dc11f0b3d563dfd55.png

第一阶段:工牌麦收音效率低下

9c59c70847e98a944f17b4d9b65d9842.jpg

第二阶段:小程序自助录制

现状:当前慧播星支持原生和激情带货两种音色克隆,客户仅需在手百小程序上录制15分钟语音,系统在1天内自动为客户生成克隆音(对比如下)。目前慧播星已制作12w多个音色,2.7w多个客户定制音色。

b8fb96d6e796ac2b84b99e0421469492.png

两种音效可选

1. 原声效果:还原本人说话特点,如语速和语调

http://blob:https://unitools.fun/fb87134d-97ec-42a5-a0a0-b74980b1cfc3

2. 激情带货效果:让整体情绪更激昂,抑扬顿挫

http://blob:https://unitools.fun/85e53903-5672-4988-85ae-19a4c867a607

未来计划利用海量直播场景的语料数据,进一步降低克隆门槛(对齐竞品的30s)、提升克隆效率(分钟级可完成克隆进行合成)、优化朗读效果(对标直播/视频/讲述/咨询等不同语境的真人) ,同时从单声音的克隆和合成成本达到业内头部领先水平。

克隆+合成技术架构

整体架构主要包括离线声纹注册和模型训练,在线合成三个部分。

图片

△ 形象克隆及合成架构

05 形象克隆与合成

主播形象是直播的核心要素,高拟真形象能够提升用户观看时长,进而提升成单效果。慧播星与视觉技术部深度合作,基于2D数字人技术针对直播场景定制形象克隆和合成能力,建设了接近7800+个公共库形象,有效地支撑商家在慧播星的前期探索,为自建形象做好准备。

图片

△ 慧播星形象制作

形象克隆技术发展主要经历了四个阶段:

第一阶段(2023.3~2023Q4) :V1版本唇形驱动方案适配电商直播场景,跑通录制约束较多的**闭嘴且无遮挡录制+**形象克隆流程,建立起第一批公共库形象

第二阶段(2024.Q4~2024.Q2) :V3V4版本唇形驱动通过数据建设和模型算法优化实现张嘴录制和更自然的唇动效果

第三阶段(2024.Q3~2025.Q2) :进一步降低录制门槛,支持录制中遮挡、大幅度侧脸和人脸出镜

当前阶段客户仅需上传5分钟左右的自然演绎视频,系统在3小时内即可自动为客户生成克隆形象。时至25年底慧播星已累计制作32万多多个形象,8万多个客户定制形象,线上可用率95%

第三阶段(2025.Q3~至今):突破唇形驱动,建设多人出镜,动作驱动,表情驱动,持物驱动等下一代形象生成能力(多模协同的超级主播)。

视觉技术

实时场景下早期的唇动方案采用单阶段建模(如wav2lip),输入音频直接输出像素空间的唇形图片。实践中单阶段方案无法达到逼真的唇动效果,后来的商用方案几乎都采用两阶段方案:第一阶段将音频转化为2D关键点或3D人脸模型作为中间表达,第二阶段将中间表达利用GAN网络解码到像素空间。

视觉生成模型

核心由三个模型组成,3D人脸重建模型音频到3D人脸生成模型3D空间到像素空间人脸生模型。

  • 3D人脸重建利用3DMM将人脸图片(像素)转换为3D mesh(三维空间点)
  • 基于Faceformer改进的音频到3D mesh预估模型,mesh作为中间表达携带了丰富的面部动态,使得生成模型能够生成逼真的唇形图片。
  • 基于StyleGan2改进的人脸生成模型,训练目标包括像素空间的重建损失,特征空间的感知损失,以及对抗生成损失。实现个性化增量微调方案,复用预训练底座只学习每个主播的个性化唇动风格,新形象仅需微调,3小时内完成制作。

a257f774f7b3f261f4d91b5ac0fac33c.png

模型pipeline

在线合成架构

形象合成以tts音频、底板视频帧和直播间背景为输入,通过生成模型实时合成主播嘴部区域,最后组装成视频流推送给用户。其中任务队列建立缓冲区,保障了视频流的连续性。目前已实现单卡多路流式渲染,支撑2万多直播间同时开播

f2a61d2b808afc9c277bca6b01b11654.jpg

在线流式合成架构

06 总结

历经两年多的持续打磨与技术突破,慧播星已经从一款数字人直播工具,成长为覆盖脚本生成、实时问答、智能中控、语音克隆、形象合成等多模态全链路的原生 AI 直播平台。它不仅复刻了真人主播的内容表达与带货节奏,更通过商品理解增强、强化学习决策、先验—后验数据飞轮、大规模音视频生成模型等关键技术,实现了“超越真人”的直播能力。随着业务规模的快速扩张与技术体系的持续演进,慧播星已在日均2万+直播间、万级定制形象与音色、覆盖电商与泛行业场景的真实生产环境中验证了 AI 直播的成熟度和商业价值。未来慧播星将继续沿着“更智能、更具说服力、更高效”的方向迭代:让脚本更精准、互动更自然、视觉更逼真、声音更生动、决策更智慧,并通过持续运转的数据飞轮不断突破直播体验的天花板。

How Fiber Network Works

I just got back from CKCon in beautiful Chiang Mai 🌴, where I gave a talk on the Fiber Network. To help everyone wrap their heads around how Fiber (CKB’s Lightning Network) actually moves assets, I hacked a visual simulation with AI.

To my surprise, people didn’t just understand it—they loved it! 🎉

Here is the “too long; didn’t read” version. But first, go ahead and play with the dots yourself, 👉 Play the Simulation: fiber-simulation

We all love Layer 1 blockchains like Bitcoin or CKB for their security, but let’s be honest: they aren’t exactly built for speed.

Every transaction has to be shouted out to the entire world and written down by thousands of nodes. On CKB, you’re waiting about 8 seconds for a block; on Bitcoin, it’s 10 minutes! Plus, the fees can get nasty if you’re just trying to buy a coffee. ☕️

So, how do we fix this?

The Lightning Network is a scalable, low-fee, and instant micro-payment solution for P2P payments.

The secret sauce isn’t actually new. Even Satoshi Nakamoto hinted at this “high-frequency” magic in an early email:

Intermediate transactions do not need to be broadcast. Only the final outcome gets recorded by the network.

A Lightning Network consists of Peers and Channels. A peer can send, receive, or forward a payment. A Channel is used for communication between two Peers.

Imagine you and a friend want to trade money back and forth quickly:

  1. Opening the Channel: You both put some money into a pot and sign a Funding Tx. This goes on the blockchain (L1).
  2. The Fun Part (Off-Chain): Now that the channel is open, you can send money back and forth a million times instantly! You just update the balance sheet between you two (using HTLCs and signatures). No one else needs to know, and no blockchain fees are paid yet.
  3. Closing the Channel: When you’re done, you agree on the final balance, sign a Shutdown Tx, and tell the blockchain.

Everything in the middle? That’s off-chain magic. ✨

Now, if Fiber was just about paying your direct neighbor, it would be boring. The real power comes from the Network.

This means Alice can pay Bob even if they don’t have a direct channel between them. The payment can travel through one or more intermediate nodes. As long as there is a path with enough liquidity, the payment will reach its destination instantly.

All data is wrapped in Onion Packets (yes, like layers of an onion). The nodes in the middle serve as couriers, but they are blindfolded:

  • They don’t know who sent the money.
  • They don’t know who is receiving it.
  • They only know “pass this to the next guy.”

They simply follow a basic rule: they forward the Hash Time Lock, and if the payment succeeds, they earn a tiny fee for their trouble. Easy peasy.

The “Not So Easy” Part 😅

While the idea is simple, building it is… well, an engineering adventure. We’re dealing with cryptography, heavy concurrency, routing algorithms, and a whole jungle of edge cases. But hey, that’s what makes it fun!

We’ve poured the last two years into building Fiber, and I’m proud to say it’s finally GA ready.

If you want to geek out on the details, check these out:

Here is the full presentation from my talk:
CKB Fiber Network Engineering Updates

数据字典技术方案实战

前言

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

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

常见字典方案剖析

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

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

方案描述

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

优点

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

缺点

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

方案二:按需加载不缓存

方案描述

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

优点

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

缺点

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

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

方案描述

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

优点

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

缺点

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

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

方案描述

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

优点

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

缺点

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

方案选型建议

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

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

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区别及原理实现

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调度?

特性 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响应式

关于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

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

本文记录了我们团队在 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%+

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


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

❌