阅读视图

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

告别漫长的HbuilderX云打包排队!uni-app x 安卓本地打包保姆级教程(附白屏、包体积过大排坑指南)

接触过 uni-app 的同学,在进行 App 打包时习惯使用 HBuilderX 的“云打包”。但随着项目变大,你一定会遇到这些痛苦:

  • 漫长的排队与等待:打个包动辄半小时起步,遇到高峰期更是无限制延长打包时间

image.png

  • 体积过大的“斩杀线” :包体积稍微大点,HBuilderX 就会提示你需要额外付费才能打包。

image.png

  • 恼人的次数限制:每天的免费打包次数有限,稍微改个 bug 想测一下都得精打细算。
  • 各种受限的配置:例如使用谷歌登录时,应用名称会被云打包强制固定为 uniappX,无法修改。

今天,我将手把手教你如何跑通 Android 本地打包流程!一次配置,终身受益!

本地打包的绝对优势:

  1. 极速出包:打包时间从原本半小时以上(周五较多人排队打包更是一次一小时以上),直接缩短到 20 秒左右!(视情况而定)
  2. 绝对自由:你可以随心所欲地修改 Android 原生配置(如包名、各种第三方登录的名称等)。
  3. 自主瘦身:可自行精简 SDK,剔除不需要的模块,完美避开云打包的大体积收费限制。
  4. 无限续杯:没有次数限制,没有排队,随心所欲,想打就打!

image.png

(注:本文基于 uni-app x 5.01 Alpha 版本演示,其他版本流程基本一致。)

废话少讲,准备动手,准备动手

image.png

准备阶段:环境与资源

1. 下载官方离线 SDK

前往 DCloud 官网,下载与你的 HBuilderX 版本完全一致的 Android 离线 SDK。

官方文档链接:doc.dcloud.net.cn/uni-app-x/n…

image.png

2. 在 HBuilderX 中生成本地资源

在你的 uni-app x 项目中,点击顶部菜单栏 发行 ➡️ 原生App-本地打包 ➡️ 生成本地打包App资源
编译完成后,会生成一个以你的 AppID 命名的文件夹(如 __UNI__1940137),复制这个文件夹备用。

image.png

勾选你要打包的应用类型:Android or iOS

image.png

image.png


核心阶段:配置 Android Studio 工程

第一步:打开正确的纯安卓工程

  1. 解压刚才下载的官方离线 SDK 压缩包。(如下图所示)

image.png

  1. 打开 Android Studio,点击 Open(或 File -> Open)。

  2. ⚠️重点防坑:  不要直接打开最外层文件夹,一定要展开目录,选中里面的 uniappxnativepackage 这个文件夹,点击打开。(如图所示)

image.png

  1. 【注意:耐心等待】  打开之后,注意看软件的右下角,会有一个进度条在转,或者显示 Gradle Build Running... / Syncing...。
    👉 只要右下角还在转,就什么都不要点,把手离开鼠标,去喝口水。等它彻底转完,左边出现一个带绿色安卓小机器人图标的 app 文件夹,才算准备就绪!

image.png

  1. 加载成功,准备就绪:

image.png

  1. 这时候注意你的文件类型识别为了Android,为了方便操作对应文件路径,把Android切换为Project,如图所示:

image.png

第二步:导入你的前端代码资源

  1. 在 Android Studio 左侧目录树,依次展开:app -> src -> main -> assets -> apps。(如果找不到assets文件夹,可直接在main下自行创建)
  2. 将刚才在 HBuilderX 里生成的 __UNI__XXXXXXX 文件夹,直接粘贴到 apps 目录中。(如下图所示)

image.png

image.png

image.png

image.png

第三步:修改 App 桌面名称

  1. 展开目录:app -> src -> main -> res -> values。
  2. 双击打开 strings.xml。
  3. 将 uni-app x 中的 uni-app x 修改为你真实的 App 名称。

image.png

第四步:修改应用包名 (Package Name)

  1. 找到 app 目录直属的 build.gradle(图标带个大象🐘)并打开。
  2. 找到 defaultConfig 节点下的 applicationId "com.xxxx.xxxx"
  3. 将其修改为你自己的包名(如 com.yourcompany.app)。
  4. 务必点击右上角弹出的 Sync Now 进行代码同步。

image.png

  1. 注意:修改build.gradle后要点击右上角的 Sync Now应用一下,否则无效 image.png

关键阶段:配置证书与离线 AppKey

要让 App 正常运行并成功打包,必须配置签名证书和离线打包 Key,否则打开会直接红屏报错

第五步:获取云端证书与生成 AppKey

  1. 登录 DCloud 开发者后台(dev.dcloud.net.cn),进入你的项目。
  2. 在  “Android云端证书”  页面,下载你的 .keystore 证书文件到电脑桌面,并记录下证书密码证书别名 (Alias)  和 SHA1指纹
  3. 在左侧菜单找到  “各平台信息” -> “离线打包Key管理”
  4. 填入你刚才配置的包名和 SHA1 指纹,点击生成,复制生成好的那一长串 AppKey

第六步:将证书放入工程并配置

  1. 将下载好的 .keystore 证书文件,直接复制粘贴到 Android Studio 左侧的 app 文件夹根目录下。(如下图所示放到app目录下)

image.png

  1. 再次打开 app/build.gradle 文件,在 buildTypes { 这一行的正上方,手动添加如下签名配置:
signingConfigs {
        config {
            keyAlias '你的证书别名'
            keyPassword '你的证书密码'
            storeFile file('你的证书文件名.keystore')
            storePassword '你的证书密码'
            v1SigningEnabled true
            v2SigningEnabled true
        }
    }
  1. 然后在下方的 buildTypes -> release 里面,加上一行引用:signingConfig signingConfigs.config
  2. 修改完毕后,再次点击右上角的 Sync Now

image.png

第七步:配置离线 AppKey

  1. 打开 app -> src -> main -> AndroidManifest.xml。
  2. 滑动到文件最下方,在  标签的正上方,添加如下代码:
<meta-data
            android:name="dcloud_appkey"
            android:value="在这里粘贴你刚才生成的极长AppKey字符串" />

image.png


避坑指南:解决白屏与包体积过大问题(必看!)

如果现在直接打包,你会面临两个新手必踩的坑:打开只有标题栏一片空白,以及包体积高达 150MB+ 。我们需要做最后两步优化。

第八步:解决首页白屏问题

原因:  官方模版默认的 MainActivity.kt 是一个带安卓原生按钮的测试壳子,我们需要把它换成直接启动 uni-app 的代码。
解决:

  1. 打开 app -> src -> main -> java -> ... -> MainActivity.kt。
  2. 清空里面的所有代码,替换为以下纯净版启动代码(注意包名要保留你自己的):
package com.example.uniappx_native_package // 这里的 package 保持你文件原有的不要动

import android.os.Bundle
import io.dcloud.uniapp.UniAppActivity

class MainActivity : UniAppActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}

image.png

第九步:App 瘦身(减小包体积)

原因:  离线 SDK 默认是“全家桶”,把微信、支付宝、个推、地图等所有模块全塞进去了。
解决:

  1. 打开 app/build.gradle,滑到最底部的 dependencies { ... } 区域。
  2. 把你项目中没有用到的功能依赖,在前面加上 // 注释掉。
    (例如:没用到华为广告,就注释掉 implementation "com.huawei.hms:ads-lite...";没用到高德地图,就注释掉 implementation 'com.amap.api:...' 等等)
  3. 注释完成后,点击 Sync Now。这能让你的 APK 体积瞬间缩小几十兆!

image.png


最终阶段:一键出包!

激动人心的时刻到了!

  1. 点击 Android Studio 顶部菜单栏:Build -> Generate Signed Bundle / APK...
  2. 选择 APK,点击 Next。
  3. 选择你的证书路径,填入密码和别名,勾选记住密码,点击 Next。
  4. 在最后一个窗口,选中 release(正式版) ,或者 debug(调试版)
  5. 点击 Create

image.png

image.png

image.png

image.png

等待右下角进度条跑完,点击弹窗中的 locate 定位文件夹。
恭喜你!完美打包的 app-debug.apk 就出现了

image.png


总结:
第一次本地打包由于要对齐包名、证书、AppKey,稍显繁琐。但这套流程跑通之后,以后你每次修改了前端代码,只需在 HBuilderX 里生成一下本地资源,去 Android Studio 替换掉 apps 目录下的文件夹,然后直接点 Build,20 秒左右即可一键出包

再也不用忍受云端漫长的排队等待,再也不用担心大体积应用的额外收费,真正的“打包自由”,你值得拥有!

image.png

下次有空再更新下 iOS 的本地打包

下次再见!🌈

Snipaste_2025-04-27_15-18-02.png


[前端特效] 左滑显示按钮的实现介绍

最近在开发「Todo-List」应用,今天想介绍下一个前端特效 - 左滑显示按钮组的实现。

左滑功能演示压缩.gif

精简后的代码已提交至Github-Gist:slide-item-demo.html,有需要自取~

下述是具体实现的讲解。

页面代码

<div class="slide-container">
    <!-- 滑动项 -->
    <div class="slide-item" id="item1">
        <div class="item-content">项目1 - 向左滑动删除</div>
        <div class="delete-btn">删除</div>
    </div>
    <div class="slide-item" id="item2">
        <div class="item-content">项目2 - 向左滑动删除</div>
        <div class="delete-btn">删除</div>
    </div>
</div>

这里页面代码就一个容器,内部是列表项,而每个列表项内部是一个主体内容外加一个删除按钮。

样式代码

.slide-item {
    position: relative;
    overflow: hidden; /* 隐藏超出部分 */
    user-select: none; /* 防止拖拽时选中文字 */
    ...
}

.item-content {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 2;
    ...
}

.delete-btn {
    ...
}

上述是精简后的核心的样式,主要有:

  • slide-item 的溢出隐藏,来保证左滑后主体部分溢出列表项边界后被隐藏
  • item-content 的绝对位置是结合父组件的相对位置来使用的,确保位置是在父组件内;topleft固定左上角位置;z-index必须大于delete-btn的,来确保未滑动时,可以遮挡隐藏删除组件
  • delete-btn基本和item-content类似的样式

滑动效果实现

滑动效果的实现主要依赖 JavaScript,这块代码确实有点实现难度,十分考验程序员对 JavaScript 各种监听事件以及对变量状态的熟练使用程度。

这里具体源码就不展示了,主要是太长了,就介绍下大体实现思路吧。有源码需要的同学,请自取:slide-item-demo.html

监听事件的运用

主要涉及两类事件:一类是实现左滑交互效果的拖拽事件的监听;一类是防止干扰的点击事件或原生拖拽事件的监听。

1. 基础拖拽事件:实现左滑交互效果

  • mousedown - 开始拖拽:当鼠标在元素上按下时触发,通常在这里记录初始位置、准备拖拽、可以设置拖拽标志为true;具体到这里的左滑效果中是 dragStartHandler 事件。
  • mousemove - 拖拽过程:鼠标在元素上移动时持续触发,负责更新元素位置,配合mousedown开启的标志位来执行;具体到这里的左滑效果中是 dragMoveHandler 事件。
  • mouseup - 结束拖拽:鼠标松开时触发,清理拖拽状态、重置相关标志位;具体到这里的左滑效果中是 dragEndHandler 事件。

2. 防止其他事件干扰

  • click - 点击处理:点击时不触发拖拽效果甚至回复拖拽效果;具体到这里的左滑效果中是 clickHandler 事件。
  • dragstart - 阻止原生拖拽:阻止浏览器默认的拖拽行为(如图片拖拽、链接拖拽),避免与自定义拖拽实现冲突
item.addEventListener('dragstart', (e) => e.preventDefault());

状态变量的运用

整个处理过程中,通过状态变量来控制组件的最终位置等数据,最终配合拖拽事件等来实现左滑效果。

具体来说就是两个变量了:stateinstances

  • state 用于控制具体左滑项的各种位置信息和状态信息。
  • instances 用于存储整个列表项数据,来确保点击其他位置时,原已经滑动的列表项可以恢复,从而实现「滑动A后,滑动B,此时,A自动恢复」。

总结

日常看到的含拖拽效果,我理解应该都是类似上述代码实现的。掌握了上述操作,其他拖拽效果也就会了。


好啦,以上就是今天的讲解内容啦,感谢阅读,欢迎三连!

前端JS: 虚拟dom是什么? 原理? 优缺点?

虚拟DOM (Virtual DOM)

什么是虚拟DOM?

虚拟DOM是一个JavaScript对象,它是真实DOM的轻量级内存表示。它是一个抽象层,用于描述UI应该是什么样子。

核心原理

1. 创建虚拟DOM树

// 虚拟DOM对象示例
const vNode = {
  type: 'div',
  props: {
    className: 'container',
    onClick: () => console.log('clicked')
  },
  children: [
    { type: 'h1', props: {}, children: 'Hello' },
    { type: 'p', props: {}, children: 'Virtual DOM' }
  ]
};

2. Diff算法(差异化比较)

  • 比较策略

    • 同层级比较(时间复杂度O(n))
    • 类型不同 → 直接替换
    • 类型相同 → 比较属性
    • 列表比较(key优化)

3. 渲染流程

真实DOM操作昂贵          虚拟DOM操作快速
     ↓                         ↓
状态变化 → 生成虚拟DOM → Diff比较 → 最小化更新 → 更新真实DOM

核心实现步骤

1. 初始化

// 创建虚拟DOM
function createElement(type, props, ...children) {
  return {
    type,
    props: props || {},
    children: children.flat()
  };
}

2. Diff算法实现思路

function diff(oldVNode, newVNode) {
  // 1. 类型不同,直接替换
  if (oldVNode.type !== newVNode.type) {
    return { type: 'REPLACE', newNode: newVNode };
  }
  
  // 2. 属性比较
  const propPatches = diffProps(oldVNode.props, newVNode.props);
  
  // 3. 子节点比较
  const childrenPatches = diffChildren(oldVNode.children, newVNode.children);
  
  return { propPatches, childrenPatches };
}

优点

1. 性能优化

  • 批量更新:合并多次DOM操作
  • 最小化更新:只更新变化的部分
  • 减少重排重绘:优化渲染性能

2. 开发效率

  • 声明式编程:关注"应该是什么样子"
  • 跨平台能力:一套代码多端渲染
  • 组件化:更好的代码组织和复用

3. 其他优势

  • 抽象真实DOM差异
  • 更好的可测试性
  • 框架级优化支持

缺点

1. 性能开销

  • 内存占用:额外存储虚拟DOM树
  • CPU计算:Diff算法有计算成本
  • 初始渲染慢:需要构建虚拟DOM树

2. 适用场景限制

  • 简单页面不适用:小项目可能得不偿失
  • 实时性要求高:游戏、动画等场景
  • SSR首屏:可能产生双重计算

3. 学习成本

  • 需要理解虚拟DOM概念
  • 框架特定的API学习
  • 调试相对复杂

实际应用对比

原生DOM操作

// 传统方式
const list = document.getElementById('list');
list.innerHTML = '';  // 清空(重绘)
items.forEach(item => {
  const li = document.createElement('li');
  li.textContent = item;
  list.appendChild(li);  // 多次重排
});

虚拟DOM方式

// React示例
function List({ items }) {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.text}</li>)}
    </ul>
  );
}
// 只更新变化的li,批量DOM操作

现代框架实现差异

React

  • Fiber架构:可中断的Diff过程
  • 协调器(Reconciler):调度更新优先级
  • 并发模式:更好的用户体验

Vue

  • 响应式依赖追踪
  • 编译时优化:静态节点提升
  • 更细粒度的更新

性能优化建议

1. 合理使用key

// 好的:稳定唯一的key
{items.map(item => (
  <ListItem key={item.id} item={item} />
))}

// 避免:index作为key
{items.map((item, index) => (
  <ListItem key={index} item={item} />  // 不推荐
))}

2. 减少不必要的渲染

  • 使用React.memo、PureComponent
  • 合理使用useMemo、useCallback
  • 避免在render中创建新对象/函数

3. 代码分割

  • 按需加载组件
  • 路由懒加载
  • 减少初始包大小

总结

虚拟DOM是现代前端框架的核心技术,它在大多数应用场景下提供了更好的开发体验和可接受的性能。但对于性能极其敏感或简单的应用,直接操作DOM或使用更轻量的方案可能更合适。

使用建议

  • 大型复杂应用 ✅ 推荐使用
  • 简单静态页面 ❌ 可能过度设计
  • 性能敏感场景 🔍 需要详细评估

别再谈提效了:AI 时代的开发范式本质变了

回应标题,为什么说别再谈提效。只要说的是提效,那思维主体就依旧是人为主导,AI 是辅助。但新时代的开发范式主导权已经发生了迁移:从“人主导、AI 辅助”,变成“AI 主导、人辅助”。

真正该讨论的是——当 AI 成为主要生产者,人该负责什么?

我认为真正的转折点是 Opus 4.6、GPT 5.2/5.3 模型的发布,模型能力大幅增强,使 AI 编码领域上了一个大的阶梯,比如 Remotion。

传统开发范式

不是把原流程每一步都加个 AI 就是新的开发方式,而是需要真正思考AI时代下的新范式,把传统流程里“靠人搬运信息、补齐细节、重复劳动、低效回路”的部分,改成更短的闭环 + 更强的自动验证。

AI 时代,代码是副产品,推理意图才是真资产

在先理解的开发范式之前,先达成一个共识:在 AI 时代下,代码不重要,而为什么这么写代码才是最重要的,推理过程才是真的资产。

代码越来越像编译产物,而推理过程(意图、边界、风险判断)才是可复用的工程资产。

在旧范式里,人写代码 -> 人 review 代码 -> Git 保存 Diff。Git 只记住改了什么,但是没有记住为什么改。

这在 AI 时代下已经不可用,因为 Agent 生成的是海量代码,Agent 代码生成量已经超过人能 review 的上限,如果还靠人去 review,那你的上限就是人,但如果 review 的是意图,那你的上限就约等于 AI 的上限,这两者可能是数十倍的差距。

那开发者不看代码了看什么?看意图。

前一段时间我还认为现在的工程师是 code review 工程师,但是现在我认为可以将前面的 code 去掉了。

新范式里更接近两种形态:

  • Agent 写代码,人 review 意图:人不再逐行读实现,而是确定风险是否可控,验证是否正确
  • 人给意图和标准,Agent 生成代码并自证:人提供目标、边界情况、验收标准,Agent 负责实现、写测试用例、跑验证、给出变更理由和影响范围

我个人目前更倾向于第二种,因为它把人的注意力从实现细节转移到了验收上。

新范式里需要保存推理链路,如果推理过程只存在于上下文窗口中,一旦 Session 结束,上下文就丢失了,这最终只会导致:代码仓库越来越大,项目越来越不可控,因为关键的为什么没有被持久化。

所以,仅仅把代码存进 Git 里已经不够了,还需要一个意图资产库,把推理过程沉淀为可复用、可审计、可持续迭代更新的持久化资产。

理解代码为什么被写出来,比看到代码本身更重要。

面向人工智能编码助手的规范驱动开发 SDD:github.com/Fission-AI/…

开发新范式

各步骤流程具体解释

  • 产品提出需求:需求文档需要尽可能详细,包括背景、用户场景、功能描述、可衡量的目标

  • 可执行需求标准:将大需求变为让 AI 具体可执行、可最终验收的标准

  • AI 制定技术方案、协议约定:让 AI 做技术方案选型,选出最不可能错的那一个

  • AI 制定测试用例:其实也就是先定标准再干活,而不是先干活再去定标准

  • AI 编码、互相 Code Review,然后更新补充、执行测试用例,但是这还不够,要不然迟早会被海量的测试用例拖垮。这里最好能基于 Diff 给出影响范围,智能选择回归,同时给出覆盖率目标,新范式需要像人一样更聪明的跑测试用例

  • 测试验收,将 Bug 提炼给 AI,AI 修复:应当根据需求沉淀用例资产库、缺陷资产库,描述完整的上下文信息,让 AI 能够持续进化

  • 线上观测:这里核心数据应该要脱敏、聚合、建立反馈给 AI 的机制,AI 进行修复后进行归因分析,反哺用例库、知识库,形成数字化资产

这些事情都让 AI 做了,那人做什么?

人负责理解需求 => 拆分需求 => 拆分可独立闭环的目标 => 定执行标准 => 确定技术方案 => 确定用例范围 => 验收,所有做的事情都是为 AI 服务

核心是人设置标准和约束,AI 做事情。

具体执行与挑战

似乎上面每一个步骤想要做好都是挑战?

  • 第一个挑战:如何将产品给出的需求提炼为AI可执行的需求标准? 这需要开发者有较好的需求理解能力、边界设计能力、叠加一定的经验以及一定的产品思维。这一步是我个人认为最难也是最有价值的一步,如果这一环节无法做好,后续的AI开发流程就会受到严重影响。这一步需要产品经理和开发者共同完成,或者开发者独自完成。
  • 第二个挑战:审美。 AI 制定技术方案、编写测试用例作为编码的前置条件,他们确定之后,编码的方向就确定了。而这与个人的技术品味强相关,技术审美比以往更加重要,而拥有较好的技术品味同样是一件不简单的事,这是长期积累的判断力。
  • 第三个挑战:测试自动化。 随着开发规模扩大,尤其是在 AI 生成大量代码的情况下,将面临恐怖的测试规模,需要有一套基于 Git diff 给出影响范围,智能进行回归的方案。
  • 第四个挑战:全自动化运维。 在 AI 时代不仅仅是部署与监控,而是能够出现异常时自动响应并进行调整,从而形成自我进化机制。当然这里面会有一些安全问题,比如核心数据不应该暴露等,过去的CICD流程都需要被重构。
  • 第五个挑战:警惕某些开发者的 Vibe Coding。 你以为是在提速,实际上是在给未来挖坑,不能仅仅为了速度而牺牲技术的长远可维护性。在 AI 时代下,开发者必须时刻保持对代码质量、架构合理性的敏感,这对开发者的要求同样不低。

未来工程师的核心竞争力

过去很多年,会写代码几乎等于有竞争力。谁能写出更复杂、更优雅、更稳定的代码,谁就越值钱。 但 AI 把这条曲线快速拉平了,当实现成本趋近于零,原来旧的招人标准在我现在看来毫无价值。

当写代码不再稀缺,真正稀缺的是什么?我认为接下来最值钱的能力是以下三点:

  • 问题拆解能力:把模糊目标变成 AI 可执行、可细分闭环的任务链
  • 判断能力:决定要做什么、不做什么,确定好每个模块明确的边界
  • 审美能力:知道什么叫能用,什么叫好用,知道什么叫不行。让代码达到可运行的标准,评估推理质量,为生成的代码负责

这些能力并非全新,优秀的工程师和产品经理一直具备这些能力,只是过去被大量实现层的工作掩盖。

以后开发者的角色定义我认为会集中在两个方向:产品工程师Agent 工程师

(1)产品工程师: 也就是接下来的开发者,不仅仅是既懂产品又会写代码,而是能用工程能力把产品落地成可交付的人,他们的核心是利用 AI 写出稳定运行、正确的代码,他们的目标不再是生成代码,而是如何证明代码是对的

(2)Agent 工程师: 他们是新时代的架构师,他们不主要负责写代码,而是负责为 Agent 搭建工作环境与生产流水线——产品工程师执行具体任务,Agent 工程师对整个链路的质量、效率与稳定性负责。有时候 AI 写不出好代码,不是它笨,而是工程结构没搭好,上下文不足。Agent 工程师的核心职责是设计好整个 AI 链路以及 AI 能运行的环境,以及构建最重要的反馈闭环,例如:互相审查 => 测试验证 => 自我修复更新。

思考:如果模型也拥有了这些能力,那工程师还剩下什么?

结语

如果你还在纠结Cursor怎么配置,哪个代码编辑器好用,我直接给你一个判断,你还在考虑怎么用AI写代码,就说明你还没搞懂这场变革,真正的转变是你根本就不需要写代码了。

如果你也看好 AI 行业的长期趋势,愿意把技术落到真实业务、真实用户与真实增长中,做浪潮里的建设者而不是旁观者——我们正在招募同路人。

欢迎联系我:buyaotutoua@163.com(邮件标题建议:AI + 姓名 + 方向)

【学习笔记】ECMAScript 词法环境全解析

词法环境规范

ECMAScript 定义词法环境为:

A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code.

翻译为:词法环境是一种规范类型,用于根据 ECMAScript 代码的词法嵌套结构,定义标识符与特定变量和函数之间的关联关系。

词法环境由两个组件构成:

  1. Environment Record:记录标识符绑定
  2. [[OuterEnv]]:指向外部词法环境的引用(全局环境为 null

Environment Record 类型

规范定义了五种环境记录类型:

类型 用途 说明
Declarative Environment Record let/const/class、函数内的函数声明 将标识符直接绑定到值
Object Environment Record with 语句、全局 var、全局函数声明 将标识符绑定到对象属性(全局函数声明通过 CreateGlobalFunctionBinding 挂载到 window
Function Environment Record 函数调用 继承自 Declarative,额外包含 [[ThisValue]][[FunctionObject]][[HomeObject]](super 引用)、[[NewTarget]](检测 new 调用)
Global Environment Record 全局环境 包含一个 Object Record(对应 var、全局函数声明)和一个 Declarative Record(对应 let/const/class
Module Environment Record ES 模块 继承自 Declarative,支持 import 绑定

注意:函数声明的绑定位置取决于所在作用域。在全局作用域中,函数声明通过 CreateGlobalFunctionBinding(ES2024 §16.1.7)进入 Object ER,行为与 var 一致(挂载到 window)。在函数作用域中,函数声明通过 FunctionDeclarationInstantiation(ES2024 §10.2.11)进入 Function ER(继承自 Declarative ER),不挂载到全局对象。

Environment Record 的继承关系

五种 ER 不是互相转化的关系,而是一个继承体系

Environment Record(抽象基类 — 定义公共接口)
│
├── Declarative Environment Record(声明式 — 直接绑定标识符到值)
│   │
│   ├── Function Environment Record(函数式 — 增加 this/new.target/super)
│   │
│   └── Module Environment Record(模块式 — 增加 import 间接绑定)
│
├── Object Environment Record(对象式 — 标识符绑定到对象属性)
│
└── Global Environment Record(全局式 — 组合了 Object ER + Declarative ER)

Environment Record 的生命周期

每个 Environment Record 经历 4 个阶段

创建(Create) → 绑定注册(Binding) → 使用(Access) → 销毁(Destroy)
阶段 规范操作 说明
创建 进入新作用域时自动创建 函数调用、进入块、加载模块、脚本启动
绑定注册 CreateMutableBinding / CreateImmutableBinding 在环境中注册标识符(此时 let/const 处于 uninitialized 状态 → TDZ)
初始化 InitializeBinding(name, value) var/函数声明在创建阶段就初始化;let/const 在执行到声明语句时才初始化
使用 GetBindingValue / SetMutableBinding 读取和修改变量值
销毁 无显式操作,由 GC 负责 当没有任何引用指向该环境时被回收(闭包会延长生命周期)

五种 ER 的协作通信机制

五种 ER 通过继承(共享接口)、组合(Global 包含两种 ER)、链接[[OuterEnv]] 链)、间接引用(Module 的 import binding)这四种方式协作。

Global Environment Record — 组合模式

records_index.html.pngHasBinding 查找时两边都查:

GlobalER.HasBinding(name):
  1. 先查 Declarative ER → 有则返回 true
  2. 再查 Object ER (即 window 对象) → 有则返回 true
  3. 都没有 → 返回 false
// —— 全局作用域:var 和函数声明 → Object ER ——
var a = 1;
function foo() {}
window.a;   // 1     ← Object ER,映射到 window
window.foo; // ƒ     ← Object ER,映射到 window

// —— 全局作用域:let/const/class → Declarative ER ——
let b = 2;
window.b;   // undefined ← Declarative ER,不映射到 window

// —— 函数作用域:函数内的函数声明 → Function ER(继承自 Declarative ER)——
function outer() {
  function inner() {}
  window.inner; // undefined ← 不挂载到 window,绑定在 outer 的 Function ER 中
}

Function Environment Record — 继承 + 扩展

函数 ER 继承自 Declarative ER,额外增加了字段:

index.html.png

需要注意两点:

  1. 箭头函数的 this 查找:箭头函数没有自己的 [[ThisValue]],通过 [[OuterEnv]] 链向外查找包含 [[ThisValue]] 的 Function ER:

    const obj = {
      method() {
        // Function ER: { [[ThisValue]]: obj, [[ThisBindingStatus]]: initialized }
    
        const arrow = () => {
          // Declarative ER(箭头函数不创建 Function ER)
          // 访问 this → 沿 [[OuterEnv]] → 找到 method 的 Function ER → obj
          console.log(this); // obj
        };
      },
    };
    
  2. 函数内的函数声明绑定到 Function ER:与全局函数声明进入 Object ER 不同,函数内部的函数声明通过 FunctionDeclarationInstantiation 绑定到当前 Function ER(继承自 Declarative ER),不挂载到全局对象:

    function outer() {
      // Function ER(继承自 Declarative ER)
      function inner() {} // → 绑定到 outer 的 Function ER
      var localVar = 1; // → 同样绑定到 outer 的 Function ER
    
      console.log(typeof inner); // "function"
      console.log(window.inner); // undefined ← 不挂载到 window
    }
    
    // 对比全局行为
    function globalFn() {} // → Object ER → window.globalFn = ƒ
    console.log(window.globalFn); // ƒ globalFn()
    

Module Environment Record — 间接绑定

模块 ER 继承自 Declarative ER,新增 CreateImportBinding 方法,import 绑定是指向另一个模块 ER 中绑定的间接引用(活绑定)

// moduleA.js 的 Module ER
ModuleER_A {
  count: 0,                    // 本地绑定
  increment: <function>        // 本地绑定
}

// moduleB.js 的 Module ER
ModuleER_B {
  count: IndirectBindingModuleER_A.count   // 间接绑定!
  // GetBindingValue("count") 实际上是:
  // → 跳转到 ModuleER_A → GetBindingValue("count") → 返回当前值
}
// moduleA.js
export let count = 0;
export function increment() {
  count++;
}

// moduleB.js
import { count, increment } from './moduleA.js';
console.log(count); // 0 — 间接读取 ModuleER_A 的 count
increment(); // ModuleER_A 的 count 变为 1
console.log(count); // 1 — 再次间接读取,拿到最新值(活绑定)

Object Environment Record — with、全局 var 和全局函数声明

Object ER 将标识符绑定映射到一个对象的属性,在两种场景中使用:

  1. 全局作用域:作为 Global ER 的 [[ObjectRecord]],承载 var 和函数声明,[[BindingObject]]window
  2. with 语句:临时将对象包装为 Object ER 插入作用域链,[[BindingObject]]with 的参数对象

127.0.0.1_5500_index.html (1).png

所有操作本质上都是对 [[BindingObject]] 的属性读写,这也是 var / function 声明会挂载到 window 的根本原因

完整协作流程

当执行一段代码时,各种 ER 如何协作:

127.0.0.1_5500_index.html (2).png

[[Environment]] 内部槽

每个函数对象都有一个 [[Environment]] 内部槽

When a function is created, a reference to the Lexical Environment in which it was created is saved in its [[Environment]] internal slot.

翻译:当一个函数被创建时,它创建时所处的词法环境的引用会被保存在该函数的 [[Environment]] 内部槽中。简单来说:函数在定义的那一刻,就把当时的作用域"拍了张快照"存起来了。这就是闭包能访问外部变量的根本原因。

闭包的规范定义——函数对象持有对创建时词法环境的引用

// 伪代码:函数创建过程
FunctionCreate(kind, ParameterList, Body, Scope, ...) {
  let F = new FunctionObject();
  F.[[Environment]] = Scope;   // ← 闭包的本质:保存创建时的词法环境
  F.[[FormalParameters]] = ParameterList;
  F.[[ECMAScriptCode]] = Body;
  return F;
}

[[OuterEnv]] 外部环境引用

[[OuterEnv]] 是词法环境的外部引用,它构成了作用域链:

// 嵌套函数的词法环境链
innerEnv.[[OuterEnv]] → outerEnv.[[OuterEnv]] → globalEnv.[[OuterEnv]] → null

GetIdentifierReference 抽象操作

当引擎需要解析一个标识符时,调用 ResolveBinding,其核心是 GetIdentifierReference

GetIdentifierReference(env, name, strict):
1. If env is null, return a Reference Record { [[Base]]: unresolvable, ... }
2. Let exists = env.HasBinding(name)
3. If exists is true:
     return Reference Record { [[Base]]: env, [[ReferencedName]]: name, ... }
4. Else:
     let outer = env.[[OuterEnv]]
     return GetIdentifierReference(outer, name, strict)   // 递归向外查找

具体触发场景:

// 1. 读取变量 → 解析 x
console.log(x);

// 2. 赋值 → 解析 x(左侧也需要解析,得到 Reference Record 才能写入)
x = 5;

// 3. 函数调用 → 解析 foo
foo();

// 4. 运算表达式 → 解析 a 和 b
a + b;

// 5. typeof → 解析 y(特殊:unresolvable 不抛错,返回 "undefined")
typeof y;

简单来说:只要代码里出现了一个名字(不是属性访问的 . 后面那个),就触发一次 GetIdentifierReference。

obj.prop 中 obj 会触发,但 .prop 不会——属性访问走的是 [[Get]],不走环境链查找。

TDZ 的规范定义

let/const 声明的变量在环境记录中的初始状态为 uninitialized

// CreateMutableBinding(name, canDelete)
// 创建绑定但不初始化 → 状态为 uninitialized

// InitializeBinding(name, value)
// 将绑定的状态从 uninitialized 变为 initialized

// GetBindingValue(name, strict)
// 如果绑定状态是 uninitialized → 抛出 ReferenceError

这就是 TDZ(暂时性死区) 的本质:变量已存在于环境记录中(因此不会沿作用域链向外查找),但尚未初始化(访问时抛错)。

常见陷阱

1. TDZ 陷阱 — 在声明前访问 let/const

// ❌ 错误:在声明前访问,触发 TDZ
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 1;

// ✅ 正确:var 不存在 TDZ,只是 undefined
console.log(b); // undefined
var b = 1;

// ❌ 容易忽略的场景:函数参数默认值中的 TDZ
function foo(x = y, y = 2) { // ReferenceError: y 在 x 初始化时还处于 TDZ
  return x + y;
}
foo();

2. 闭包陷阱 — 循环中共享同一个变量绑定

// ❌ 错误:var 只有一个绑定,所有回调共享同一个 i
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:3 3 3
// 原因:var i 在 Function ER 中只有一份绑定,
//       循环结束时 i 已经是 3,三个箭头函数读到的都是同一个 i

// ✅ 正确:let 每次迭代创建新的 Declarative ER,各自持有独立的 i 绑定
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2

3. this 丢失陷阱 — 普通函数与箭头函数混用

const obj = {
  name: 'obj',

  // ❌ 错误:箭头函数没有自己的 [[ThisValue]]
  // 沿 [[OuterEnv]] 向外找到全局 Function ER,this 为 window/undefined(strict)
  arrowMethod: () => {
    console.log(this.name); // undefined(严格模式下报错)
  },

  // ✅ 正确:普通函数创建 Function ER,[[ThisValue]] 绑定为调用时的 obj
  normalMethod() {
    console.log(this.name); // 'obj'

    // ✅ 内部箭头函数沿 [[OuterEnv]] 找到 normalMethod 的 Function ER → this 为 obj
    const inner = () => console.log(this.name);
    inner(); // 'obj'
  },
};

obj.arrowMethod();
obj.normalMethod();

// ❌ 方法赋值后调用,Function ER 的 [[ThisValue]] 重新绑定为 undefined(严格模式)
const fn = obj.normalMethod;
fn(); // TypeError 或 window.name(非严格模式)

4. with 陷阱 — 动态插入作用域链,导致查找不可预测

const obj = { a: 1 };
const a = 2;

with (obj) {
  // Object ER 被插入作用域链最顶层
  // GetIdentifierReference 先查 obj 的属性,再查外部
  console.log(a); // 1 ← 读取的是 obj.a,而非外部的 a = 2
}

// ❌ 动态属性导致歧义:无法在编译期确定标识符归属
const obj2 = {};
with (obj2) {
  console.log(a); // 2 ← obj2 没有 a,向外找到外部的 a = 2
}
// 同一个标识符 a,with 不同对象结果不同,引擎无法优化,严格模式直接禁止 with

5. 模块活绑定陷阱 — import 是引用而非拷贝

// moduleA.js
export let count = 0;
export function increment() { count++; }

// moduleB.js
import { count, increment } from './moduleA.js';

console.log(count); // 0

increment();
console.log(count); // 1 ← 活绑定,读取的是 ModuleER_A 中 count 的当前值

// ❌ 常见误区:以为 import 的是值的拷贝,实则是间接引用
// ❌ import 绑定是只读的,不能直接赋值
count = 10; // TypeError: Assignment to constant variable

React 架构进阶:自定义 Hooks 的高级设计模式与最佳实践

在 React 16.8 引入 Hooks 之后,我们告别了 Class 组件中复杂的生命周期和高阶组件(HOC)的嵌套地狱。然而,随着业务复杂度的提升,简单的 useState 和 useEffect 组合往往导致组件内部逻辑臃肿,难以维护。

很多开发者停留在“把逻辑抽离成函数”的初级阶段,却忽略了自定义 Hooks(Custom Hooks)本质上是逻辑复用的设计模式。本文将深入探讨自定义 Hooks 的高级设计模式,如何通过合理的抽象提升代码的可读性、可测试性和复用性。

一、为什么我们需要高级设计模式?

在初级实践中,我们常看到这样的代码:

// ❌ 反模式:逻辑泄露与耦合
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/user/${userId}`).then(setUser).finally(() => setLoading(false));
  }, [userId]);

  // ... 还有获取用户帖子、获取用户关注列表的逻辑混在一起
  return loading ? <Spinner /> : <div>{user.name}</div>;
}

这种写法的问题在于:

  1. UI 与逻辑耦合:组件既负责渲染,又负责数据获取。
  2. 难以测试:很难在不渲染 UI 的情况下测试数据获取逻辑。
  3. 无法复用:如果在另一个页面也需要获取用户信息,代码只能复制粘贴。

通过自定义 Hooks,我们可以将“关注点分离(Separation of Concerns)”。

二、核心设计模式详解

2.1 容器模式(Container Pattern)的 Hooks 化

这是最经典的模式,将数据获取和状态管理逻辑剥离,组件只负责展示。

// ✅ useUser.ts - 专注数据逻辑
export function useUser(userId) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;
    
    async function fetchUser() {
      try {
        const response = await fetch(`/api/user/${userId}`);
        if (!cancelled) {
          setState({ data: await response.json(), loading: false, error: null });
        }
      } catch (err) {
        if (!cancelled) setState({ data: null, loading: false, error: err });
      }
    }

    fetchUser();
    return () => { cancelled = true; }; // 清理副作用
  }, [userId]);

  return state;
}

// ✅ UserProfile.tsx - 专注 UI 展示
function UserProfile({ userId }) {
  const { data: user, loading, error } = useUser(userId);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{user.name}</div>;
}

优势:UI 组件变得极其纯净,逻辑 Hook 可以独立进行单元测试。

2.2 状态机模式(State Machine Pattern)

对于复杂的交互流程(如表单提交、多步骤向导、播放器控制),简单的布尔值状态(isLoadingisSuccessisError)容易导致状态冲突。此时应引入有限状态机思想。

// ✅ useAsyncAction.ts - 管理复杂状态流转
function useAsyncAction(asyncFunction) {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'START': return { status: 'loading', data: null, error: null };
      case 'SUCCESS': return { status: 'success', data: action.payload, error: null };
      case 'FAILURE': return { status: 'failure', data: null, error: action.payload };
      case 'RESET': return { status: 'idle', data: null, error: null };
      default: return state;
    }
  }, { status: 'idle', data: null, error: null });

  const execute = useCallback(async (...args) => {
    dispatch({ type: 'START' });
    try {
      const result = await asyncFunction(...args);
      dispatch({ type: 'SUCCESS', payload: result });
    } catch (err) {
      dispatch({ type: 'FAILURE', payload: err });
    }
  }, [asyncFunction]);

  return { ...state, execute };
}

应用场景:登录注册流程、文件上传、复杂的表单验证。它保证了状态流转的确定性,避免了“既 loading 又 error”的非法状态。

2.3 组合模式(Composition Pattern)

Hooks 最大的威力在于组合。我们可以像搭积木一样,将多个小 Hooks 组合成一个功能强大的大 Hook。

// 基础 Hook:处理本地存储
function useLocalStorage(key, initialValue) {
  // ... 实现略
  return [value, setValue];
}

// 基础 Hook:处理窗口大小
function useWindowSize() {
  // ... 实现略
  return { width, height };
}

// ✅ 组合 Hook:响应式主题管理器
function useResponsiveTheme() {
  const [theme, setTheme] = useLocalStorage('app-theme', 'light');
  const { width } = useWindowSize();

  // 自动逻辑:屏幕小于 768px 强制使用移动端样式,但保留用户主题偏好
  const isMobile = width < 768;
  const effectiveTheme = isMobile ? 'mobile-optimized' : theme;

  useEffect(() => {
    document.body.className = effectiveTheme;
  }, [effectiveTheme]);

  return { theme, setTheme, isMobile };
}

核心价值:降低了单个 Hook 的认知负荷,每个 Hook 只做一件事,并通过组合产生新的行为。

2.4 观察者模式与订阅机制

在处理全局事件或非 React 源的数据(如 WebSocket、第三方 SDK)时,可以使用观察者模式。

// ✅ useWebSocket.ts
function useWebSocket(url) {
  const [message, setMessage] = useState(null);

  useEffect(() => {
    const ws = new WebSocket(url);
    
    ws.onmessage = (event) => {
      setMessage(JSON.parse(event.data));
    };

    ws.onerror = (error) => {
      console.error('WS Error', error);
    };

    // 清理连接
    return () => {
      ws.close();
    };
  }, [url]);

  const sendMessage = useCallback((data) => {
    // 发送逻辑
  }, []);

  return { message, sendMessage };
}

三、避坑指南:自定义 Hooks 的常见陷阱

3.1 条件调用 Hooks

错误示范

function useConditionalHook(condition) {
  if (condition) {
    useEffect(() => { ... }); // ❌ 违反 Rules of Hooks
  }
}

修正:Hooks 必须在顶层调用。如果需要根据条件执行逻辑,请将条件判断写在 Hook 内部,而不是包裹 Hook 本身。

3.2 过度抽象

不要为了复用而复用。如果一个逻辑只在当前组件使用,或者不同组件的使用差异极大,强行提取 Hook 反而会增加认知负担。 “三次法则” 是一个不错的经验:当同一段逻辑出现第三次时,再考虑提取。

3.3 依赖项数组的陷阱

在自定义 Hook 中返回回调函数时,务必注意闭包陷阱。

// ❌ 容易捕获旧状态的回调
function useCounter() {
  const [count, setCount] = useState(0);
  const logCount = () => {
    console.log(count); // 可能永远是初始值或旧值
  };
  return { count, logCount };
}

// ✅ 使用 ref 或将其放入 useEffect/useCallback 依赖中
function useCounter() {
  const [count, setCount] = useState(0);
  
  const logCount = useCallback(() => {
    console.log(count); 
  }, [count]); // 确保依赖最新 count
  
  return { count, logCount };
}

四、实战案例:构建一个通用的 useFetch

结合上述模式,我们来构建一个生产级别的 useFetch

import { useEffect, useReducer, useCallback } from 'react';

// 定义状态类型
const initialState = {
  data: null,
  loading: false,
  error: null,
};

function reducer(state, action) {
  switch (action.type) {
    case 'REQUEST': return { ...state, loading: true, error: null };
    case 'SUCCESS': return { loading: false, data: action.payload, error: null };
    case 'FAILURE': return { loading: false, data: null, error: action.payload };
    case 'RESET': return initialState;
    default: return state;
  }
}

export function useFetch(url, options = {}) {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { manual = false } = options; // 是否手动触发

  const execute = useCallback(async (overrideUrl) => {
    const targetUrl = overrideUrl || url;
    if (!targetUrl) return;

    dispatch({ type: 'REQUEST' });
    try {
      const response = await fetch(targetUrl);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      const data = await response.json();
      dispatch({ type: 'SUCCESS', payload: data });
    } catch (err) {
      dispatch({ type: 'FAILURE', payload: err.message });
    }
  }, [url]);

  useEffect(() => {
    if (!manual) {
      execute();
    }
  }, [execute, manual]);

  return { ...state, refetch: execute, reset: () => dispatch({ type: 'RESET' }) };
}

使用示例

function UserList() {
  const { data, loading, error, refetch } = useFetch('/api/users');

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了: {error} <button onClick={refetch}>重试</button></div>;

  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

五、总结

自定义 Hooks 不仅仅是代码复用的工具,更是 React 组件架构的核心支柱。

  1. 逻辑解耦:让 UI 组件回归纯粹的表现层。
  2. 状态治理:利用 Reducer 和状态机管理复杂交互。
  3. 能力组合:通过小 Hook 的堆叠构建复杂功能。
  4. 测试友好:逻辑与视图分离使得单元测试变得简单高效。

掌握这些高级模式,你将能够编写出更健壮、更易维护的 React 应用,真正发挥 Hooks 体系的威力。


后续思考题:

  • 如何在自定义 Hook 中处理服务端渲染(SSR)时的 Hydration 问题?
  • 自定义 Hooks 能否完全替代 Redux/MobX 等全局状态管理库?边界在哪里?

欢迎在评论区分享你在项目中封装过的最得意的自定义 Hook!

【LangChain.js学习】 向量数据库(内存/持久化)

核心说明

向量数据库是 LangChain 构建知识库问答的核心组件,用于存储文档文本的向量嵌入(Embedding),并支持相似性检索(根据查询语句的向量匹配最相关的文本块)。分为「内存向量数据库」(MemoryVectorStore,临时存储)和「持久化向量数据库」(Chroma,永久存储),前者适合测试/临时场景,后者适合生产环境。

一、核心概念

1. 向量嵌入(Embedding)

将文本转换为数值向量(如1536维数组),使计算机能通过「向量距离」衡量文本语义相似度,本文使用阿里通义千问的 text-embedding-v2 模型生成嵌入。

2. 相似性检索

输入查询语句→生成查询向量→计算与库中所有文本向量的距离(如余弦相似度)→返回最相似的N个文本块,是知识库问答的核心逻辑。

二、内存向量数据库(MemoryVectorStore)

核心特点

  • 数据存储在内存中,程序重启后丢失;
  • 无需额外部署服务,开箱即用;
  • 适合快速测试、临时知识库场景。

完整实现代码

import { TextLoader } from "@langchain/classic/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/classic/text_splitter";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";

// 1. 初始化嵌入模型(阿里通义千问)
const embeddingsModel = new OpenAIEmbeddings({
    model: "text-embedding-v2", // 通义千问嵌入模型
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", // 阿里百炼兼容接口
        apiKey: "[你的阿里百炼API Key]", // 替换为有效Key
    },
});

// 2. 初始化内存向量数据库
const vectorStore = new MemoryVectorStore(embeddingsModel);

// 3. 加载并分割文档(复用文本加载逻辑)
const loader = new TextLoader("./data/data.txt");
const documents = await loader.load();

// 文本分割器(适配中文语义)
const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 25, // 每个文本块最大字符数
    chunkOverlap: 5, // 块间重叠字符数(保证上下文连贯)
    separators: [",", "。"], // 中文优先分割符
});
const splitDocs = await splitter.splitDocuments(documents);

// 4. 将分割后的文本块存入向量库(自动生成嵌入向量)
await vectorStore.addDocuments(splitDocs);

// 5. 相似性检索(查询+返回Top2最相关文本)
const results = await vectorStore.similaritySearch("李娟的出生于哪里?", 2);

// 输出检索结果
console.log("内存向量库检索结果:");
results.forEach((doc, index) => {
    console.log(`第${index+1}条:`, doc.pageContent);
});

/** 输出示例:
内存向量库检索结果:
第1条: 李娟,1979年7月出生于新疆生产建设兵团
第2条: 兵团,籍贯四川乐至,当代女作家
*/

三、持久化向量数据库(Chroma)

核心特点

  • 数据持久化存储(磁盘/数据库),程序重启后不丢失;
  • 支持独立部署服务,多进程/多实例共享数据;
  • 适合生产环境、长期维护的知识库场景。

1. 环境准备

安装Chroma(Python)

# 安装Chroma依赖
pip install chromadb

# 启动Chroma服务(后台运行,端口8000)
chroma run --host 0.0.0.0 --port 8000

安装LangChain-Chroma依赖(Node.js)

pnpm add @langchain/community

2. 完整实现代码

import { TextLoader } from "@langchain/classic/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "@langchain/classic/text_splitter";
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { OpenAIEmbeddings } from "@langchain/openai";

// 1. 初始化嵌入模型(与内存库一致)
const embeddingsModel = new OpenAIEmbeddings({
    model: "text-embedding-v2",
    configuration: {
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        apiKey: "[你的阿里百炼API Key]",
    },
});

// 2. 初始化Chroma向量数据库(连接远程服务)
const vectorStore = new Chroma(embeddingsModel, {
    url: "http://localhost:8000", // Chroma服务地址
    collectionName: "langchain_nodejs_demo", // 集合名称(类似数据库表)
});

// 3. 加载并分割文档(与内存库一致)
const loader = new TextLoader("./data/data.txt");
const documents = await loader.load();

const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 25,
    chunkOverlap: 5,
    separators: [",", "。"],
});
const splitDocs = await splitter.splitDocuments(documents);

// 4. 将文本块存入Chroma(自动创建集合+生成嵌入)
await vectorStore.addDocuments(splitDocs);

// 5. 相似性检索
const results = await vectorStore.similaritySearch("李娟的出生于哪里?", 2);

// 输出检索结果
console.log("Chroma向量库检索结果:");
results.forEach((doc, index) => {
    console.log(`第${index+1}条:`, doc.pageContent);
});

/** 输出示例:
Chroma向量库检索结果:
第1条: 李娟,1979年7月出生于新疆生产建设兵团
第2条: 兵团,籍贯四川乐至,当代女作家
*/

3. 关键扩展操作

// 1. 清空集合(删除所有数据)
await vectorStore.delete({ collectionName: "langchain_nodejs_demo" });

// 2. 带分数的相似性检索(返回相似度得分,0-1,越高越相似)
const resultsWithScore = await vectorStore.similaritySearchWithScore("李娟的作品有哪些?", 2);
console.log("带分数的检索结果:");
resultsWithScore.forEach(([doc, score], index) => {
    console.log(`第${index+1}条:`, doc.pageContent, `相似度:${score.toFixed(4)}`);
});

// 3. 自定义检索参数(如过滤元数据)
const filteredResults = await vectorStore.similaritySearch(
    "李娟的职务",
    1,
    { source: "./data/data.txt" } // 仅检索指定来源的文本
);

四、内存/持久化向量库对比

维度 内存向量库(MemoryVectorStore) 持久化向量库(Chroma)
数据存储 内存 磁盘/数据库
持久化 程序重启丢失 永久保留
部署成本 无(无需额外服务) 需部署Chroma服务
性能 读写速度快(无网络IO) 有网络IO,速度略慢
多实例共享 不支持 支持(多进程连接同一服务)
适用场景 测试、临时知识库 生产环境、长期知识库

五、核心原理与关键注意事项

1. 核心流程

flowchart TD
    A[加载文档] --> B[文本分割为小文本块]
    B --> C[嵌入模型生成文本向量]
    C --> D[存入向量数据库]
    E[用户查询] --> F[生成查询向量]
    F --> G[向量库相似性检索]
    G --> H[返回最相关文本块]

2. 关键注意事项

  1. Chroma服务启动:确保Chroma服务正常运行(chroma run),否则会报连接错误;
  2. 文本分割参数chunkSize 不宜过大(超过嵌入模型上下文)或过小(语义不完整),中文建议20-50字符;
  3. 集合名称管理:Chroma的 collectionName 建议按业务分类(如 product_docuser_manual),避免数据混乱。

深入理解事件循环:异步编程的基石

在现代软件开发中,异步编程已成为构建高性能、响应式应用的核心技术。无论是前端 JavaScript 开发,还是后端 Node.js 服务,亦或是其他语言中的异步框架,事件循环(Event Loop) 都是实现非阻塞 I/O 和并发处理的关键机制。

本文将深入探讨事件循环的工作原理、在不同运行环境中的实现差异,以及如何利用这一机制编写高效的异步代码。

一、为什么需要事件循环?

1.1 单线程的局限性

传统同步编程模型中,程序按顺序执行,每个操作必须等待前一个操作完成。这种模式在处理 I/O 操作(如文件读写、网络请求、数据库查询)时会导致严重的性能瓶颈:

// 同步代码示例 - 阻塞式
const data = readFile('large-file.txt'); // 阻塞直到文件读取完成
console.log(data);
processUserRequest(); // 必须等待上面完成才能执行

在上述代码中,如果文件很大,整个程序会"冻结",无法响应用户的其他操作。

1.2 异步非阻塞的优势

事件循环通过异步非阻塞的方式解决了这个问题:

// 异步代码示例 - 非阻塞式
readFile('large-file.txt', (err, data) => {
    console.log(data);
});
processUserRequest(); // 立即执行,不等待文件读取

这样,程序可以在等待 I/O 操作完成的同时,继续处理其他任务,大大提高了资源利用率。

二、事件循环的核心组成

事件循环机制主要由以下几个部分组成:

2.1 调用栈(Call Stack)

调用栈是一个后进先出(LIFO)的数据结构,用于跟踪函数执行。当函数被调用时,它被压入栈顶;当函数返回时,它从栈顶弹出。

|-----------------|
| functionC()     | <- 栈顶
|-----------------|
| functionB()     |
|-----------------|
| functionA()     |
|-----------------|
| main()          | <- 栈底
|-----------------|

2.2 任务队列(Task Queue)

任务队列存储待执行的回调函数。根据任务类型的不同,通常分为:

  • 宏任务(Macrotask) :setTimeout、setInterval、I/O 操作、UI 渲染等
  • 微任务(Microtask) :Promise.then/catch/finally、MutationObserver、queueMicrotask 等

2.3 事件循环本身

事件循环是一个持续运行的循环,其基本工作流程如下:

  1. 检查调用栈是否为空
  2. 如果为空,从微任务队列中取出所有微任务并执行
  3. 如果微任务队列为空,从宏任务队列中取出一个宏任务执行
  4. 重复上述过程

三、浏览器环境中的事件循环

3.1 执行流程详解

在浏览器环境中,事件循环的执行顺序遵循以下规则:

console.log('1. 同步代码开始');

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

Promise.resolve().then(() => {
    console.log('3. Promise.then 回调(微任务)');
});

console.log('4. 同步代码结束');

// 输出顺序:
// 1. 同步代码开始
// 4. 同步代码结束
// 3. Promise.then 回调(微任务)
// 2. setTimeout 回调(宏任务)

3.2 渲染时机

浏览器的渲染时机对于理解事件循环至关重要:

  • 微任务执行完毕后,如果宏任务队列中有任务,且该任务执行过程中触发了 DOM 变化,浏览器可能会在下一个宏任务执行前进行渲染
  • requestAnimationFrame 会在浏览器下一次重绘之前执行,通常用于动画优化
// 渲染时机示例
div.style.width = '100px'; // 触发重排

Promise.resolve().then(() => {
    div.style.width = '200px'; // 微任务中修改
    // 此时浏览器可能还未渲染第一次修改
});

setTimeout(() => {
    div.style.width = '300px'; // 宏任务中修改
    // 浏览器可能在执行此任务前已经渲染了前面的修改
}, 0);

四、Node.js 环境中的事件循环

Node.js 的事件循环与浏览器有所不同,它分为六个阶段:

4.1 六个阶段

  1. timers:执行 setTimeout 和 setInterval 的回调
  2. pending callbacks:执行某些系统操作的回调(如 TCP 错误)
  3. idle, prepare:内部使用
  4. poll:获取新的 I/O 事件,执行 I/O 回调
  5. check:执行 setImmediate 的回调
  6. close callbacks:执行关闭事件的回调(如 socket.on('close'))

4.2 Node.js 特有行为

// Node.js 中的特殊行为
setTimeout(() => {
    console.log('timeout');
}, 0);

setImmediate(() => {
    console.log('immediate');
});

// 在 I/O 回调中,setImmediate 总是先于 setTimeout 执行
fs.readFile('file.txt', () => {
    setTimeout(() => {
        console.log('timeout in I/O');
    }, 0);
    
    setImmediate(() => {
        console.log('immediate in I/O');
    });
});

五、微任务与宏任务的深度对比

5.1 执行优先级

微任务的优先级高于宏任务,这是理解异步代码执行顺序的关键:

// 复杂嵌套示例
console.log('start');

setTimeout(() => {
    console.log('timeout1');
    Promise.resolve().then(() => {
        console.log('promise in timeout1');
    });
}, 0);

Promise.resolve().then(() => {
    console.log('promise1');
    setTimeout(() => {
        console.log('timeout in promise');
    }, 0);
});

console.log('end');

// 输出顺序:
// start
// end
// promise1
// timeout in promise
// timeout1
// promise in timeout1

5.2 实际应用场景

微任务适用场景:

  • 需要立即执行的异步操作
  • 保证在下一个宏任务之前完成的操作
  • 状态同步、数据更新等

宏任务适用场景:

  • 延迟执行的操作
  • I/O 操作
  • UI 渲染相关的操作

六、常见陷阱与最佳实践

6.1 常见陷阱

陷阱 1:误以为 setTimeout(fn, 0) 会立即执行

// 错误理解
setTimeout(() => {
    console.log('立即执行?');
}, 0);

// 实际上,它会被放入宏任务队列,至少要在当前同步代码和所有微任务执行完后才会执行

陷阱 2:微任务过多导致宏任务饥饿

// 危险代码 - 可能导致宏任务永远无法执行
function starveMacrotasks() {
    Promise.resolve().then(() => {
        console.log('微任务');
        starveMacrotasks(); // 递归创建微任务
    });
}
starveMacrotasks();
// setTimeout 等宏任务可能永远得不到执行机会

6.2 最佳实践

  1. 合理使用微任务和宏任务:根据业务需求选择合适的异步机制
  2. 避免微任务无限递归:防止阻塞宏任务队列
  3. 注意执行顺序:在涉及多个异步操作时,明确预期的执行顺序
  4. 利用 async/await 提高可读性:现代 JavaScript 推荐使用 async/await 语法
// 推荐的 async/await 写法
async function fetchData() {
    try {
        const response = await fetch('/api/data');
        const data = await response.json();
        return data;
    } catch (error) {
        console.error('获取数据失败:', error);
        throw error;
    }
}

七、性能优化建议

7.1 减少不必要的异步操作

// 不推荐 - 创建了大量不必要的 Promise
array.map(item => {
    return Promise.resolve(item).then(processItem);
});

// 推荐 - 直接同步处理
array.map(item => {
    return processItem(item);
});

7.2 批量处理微任务

// 不推荐 - 逐个创建微任务
items.forEach(item => {
    queueMicrotask(() => processItem(item));
});

// 推荐 - 批量处理
queueMicrotask(() => {
    items.forEach(item => processItem(item));
});

7.3 合理使用 requestIdleCallback

对于非关键的后台任务,可以使用 requestIdleCallback 在浏览器空闲时执行:

requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
        performTask(tasks.pop());
    }
}, { timeout: 2000 }); // 最多等待 2 秒

八、未来展望

随着 Web 技术和运行时环境的不断发展,事件循环机制也在持续演进:

  • Web Workers 和 SharedArrayBuffer:提供了真正的多线程能力
  • Async Local Storage:改进了异步上下文管理
  • 更好的错误追踪:改进异步错误的堆栈追踪
  • 性能监控工具:更精确的事件循环性能分析工具

结语

事件循环是异步编程的基石,深入理解其工作原理对于编写高效、可靠的异步代码至关重要。无论是在浏览器还是 Node.js 环境中,掌握事件循环的执行顺序、微任务与宏任务的区别,以及各种最佳实践,都能帮助开发者避免常见的陷阱,提升代码质量和性能。

随着技术的不断发展,虽然新的抽象层和工具不断涌现,但对事件循环本质的理解始终是优秀开发者的核心竞争力之一。希望本文能够帮助你更深入地理解这一重要概念,并在实际开发中灵活运用。

Vite 性能瓶颈排查标准流程

第一步:诊断(必须做)

按照分析工具

pnpm add -D rollup-plugin-visualizer
or
yarn add -D  rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

plugins: [
  visualizer({
    open: true,
    gzipSize: true,
    brotliSize: true
  })
]

运行

pnpm build
or
yarn build

重点观察

在分析页里重点看

关注点 看什么
那个chunk最大 首屏是否>500kb
Inported by 谁引用了它
重依赖 是否多个chunk重复打包
大JSON图/图标 是否被主入口引用

常见“元凶”

  • 第三方UI库全量引入(如 ant-design-vue/element plus)
  • lodash 全量
  • moment
  • echarts
  • icon大数据文件
  • 地图geojson
  • 误import整个utils目录

第二步:合理分包(manualChunks)

这是最核心的优化点

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {

        // 1️⃣ 第三方库分离
        if (id.includes('node_modules')) {

          if (id.includes('ant-design-vue'))
            return 'antd-vendor'

          if (id.includes('vue'))
            return 'vue-core'

          return 'vendor'
        }

        // 2️⃣ 业务页面自动分包
        if (id.includes('src/views/')) {
          const parts = id.split('/')
          const index = parts.indexOf('views')
          const folderName = parts[index + 1]
          return `view-${folderName}`
        }

      }
    }
  }
}

优化目标

包类型 目标
主入口index <200kb
vendor 可缓存
每个view 按需加载

第三步:巨型静态数据处理

错误示范

// main.ts
import icons from './all-icons.js'  // ❌ 500kB 直接进主包

正确做法(动态加载)

const loadIcons = () => import('./all-icons.js')

规则

文件大小 处理方式
>100kB 必须动态import
>300kb 拆分 or cdn
>1MB 禁止进主包

第四步:资源CDN化

1. vite base 指向 CDN

export default defineConfig({
  base: process.env.NODE_ENV === 'production'
    ? 'https://cdn.xxx.com/'
    : '/'
})

2. 图片/字体策略

  • .woff2>100kB 上传CDN
  • 大背景图禁止进JS
  • 不要base64大图

3. 巨型JS文件CDN化 例如:

  • icon库
  • geojson
  • 富文本编辑器扩展 直接
<script src="https://cdn.xxx.com/icons.js"></script>

第五步:PWA检查

如果使用

vite-plugin-pwa

必须检查:

workbox: {
  globPatterns: ['**/*.{js,css,html}']
}

风险点

  • index.html被precache
  • index.js 过大
  • 每次发布强制用户下载大包

建议

  • 只precache必要资源
  • 避免缓存大业务chunk
  • 首屏尽量轻量

进阶补充

1. 开启esbuild压缩

build: {
  minify: 'esbuild'
}

2. 开启tree shaking 优化

optimizeDeps: {
  include: []
}

不要乱include

3. 路由懒加载必须写对

{
  path: '/home',
  component: () => import('@/views/home/index.vue')
}

禁止

import Home from '@/views/home/index.vue'

Node.js 中间层退潮:从“前端救星”到“成本噩梦”

如果你和我一样,是2016年前后入行的前端,一定记得那个热血沸腾的年代。

那时候,前端圈最响亮的口号是:“Node.js是前端的后端”。我们兴奋地讨论BFF、大前端,仿佛看到了前端工程师的未来——不再被后端牵着鼻子走,自己掌控整个数据链路

我也是在那时候,第一次用Node.js搭起了BFF层。那种“前端也能写后端”的掌控感,至今难忘。

然而最近两年,风向变了。

很多中大厂都在悄悄“回退”Node.js中间层。有的把逻辑收归后端,有的切到Serverless,有的干脆砍掉整个BFF。曾经自豪的技术栈,怎么就成了“成本中心”?

今天,想站在咱们前端的视角,聊聊这场“退潮”背后的真实故事。

一、当年我们为什么对BFF如此着迷?

因为,我们真的受够了

回想一下没有BFF的日子有多痛苦:

产品经理说:“详情页需要展示用户昵称、订单金额、商品列表。”

你打开接口文档,发现要调三个接口:/user/info/order/detail/product/list。三个接口调完,还要自己拼数据。

// 没有BFF时,前端要自己聚合数据
async function getOrderPage(orderId) {
  // 串行调用三个接口
  const user = await fetch(`/api/user/info?userId=123`);
  const order = await fetch(`/api/order/detail?orderId=${orderId}`);
  const products = await fetch(`/api/product/list?orderId=${orderId}`);
  
  // 手动拼数据
  return {
    userName: user.name,
    orderAmount: order.amount,
    productList: products
  };
}

更崩溃的是,App端要的字段和Web端不一样。后端说:“你们前端能不能统一一下?”

你心里一万只羊驼跑过:“明明是你接口设计不合理,怪我咯?”

BFF给了我们“全栈”的尊严

Node.js BFF的出现,像是给前端打开了一扇窗。

// BFF层:数据聚合、裁剪、适配
router.get('/web/order/detail', async (ctx) => {
  // 并行调用,性能更好
  const [user, order, products] = await Promise.all([
    fetchUser(ctx.query.userId),
    fetchOrder(ctx.query.orderId),
    fetchProducts(ctx.query.orderId)
  ]);
  
  // 为Web端定制返回格式
  ctx.body = {
    userInfo: { name: user.name, avatar: user.avatar },
    orderInfo: { amount: order.amount, status: order.status },
    productList: products.map(p => ({ id: p.id, name: p.name, price: p.price }))
  };
});

// 为App端返回精简数据
router.get('/app/order/detail', async (ctx) => {
  // 同样的数据来源,不同的返回结构
});
  • 后端继续提供原子接口,保持他们所谓的“纯洁”
  • 我们在Node层做聚合、裁剪、适配
  • 前端只调Node层,拿到的就是“刚刚好”的数据

更重要的是,不用再求后端改接口了

字段名不对?Node层改一下。缺少数据?Node层调个新接口。响应太慢?Node层加个缓存。

// 后端接口字段名不合理?BFF层一键改写
const user = await fetchUser(userId);
// 后端返回的是 user_name,前端要的是 userName
return { userName: user.user_name };

那种“自己说了算”的感觉,太爽了。

二、蜜月期过后,我们开始尝到苦果

但架构是有代价的,只是这个代价,当时我们没算清楚。

运维噩梦:第一个周末被叫起来修服务器的滋味

我记得特别清楚,那是2019年的一个周六早上。

手机突然狂震,群里炸了:线上订单页打不开了。我迷迷糊糊爬起来,登录服务器,发现Node进程挂了。重启,又挂。再看,内存泄露。

# 前端不熟悉的运维命令
top # 看CPU
free -m # 看内存
tail -f /var/log/nginx/error.log # 看nginx日志
journalctl -u node-app # 看系统日志

那天我在电脑前蹲了四个小时,查日志、看监控、dump内存快照...最后发现是一个第三方SDK有bug。

作为一个前端,我擅长的是CSS布局、组件通信、状态管理。服务器的负载均衡、内存监控、日志采集,这些我根本不熟。

但因为是“前端负责的BFF”,出了问题,只能自己扛。

重复劳动:每个项目都在写一样的代码

后来公司扩张,业务线越来越多。每条线都要BFF,于是我们建了一套又一套。

打开代码库,惊人的相似:

// 业务线A的BFF
router.get('/a/order/detail', async (ctx) => {
  const data = await fetchData();
  return { code: 0, data };
});

// 业务线B的BFF
router.get('/b/order/detail', async (ctx) => {
  const data = await fetchData(); // 几乎一样的逻辑
  return { code: 0, data };
});

// 业务线C的BFF
router.get('/c/order/detail', async (ctx) => {
  const data = await fetchData(); // 又一遍
  return { code: 0, data };
});

这种重复劳动,本质上是在浪费我们前端的价值。我们本该花时间研究组件复用、性能优化、用户体验,结果天天在写重复的数据聚合代码。

“数据对不上”的锅,永远是我们背

最憋屈的是扯皮的时候。

前端调BFF接口,返回的数据缺字段。产品问:谁的问题?

后端说:“我接口返回了,你自己去看。” BFF说:“我透传了,没动过。” 最后查出来,是后端某个服务升级,字段名改了。

// 后端悄悄改了字段名,BFF层还在用旧的
// 后端返回:{ nickname: '张三' }
// BFF层还在用:user.name
// 前端收到:undefined

但沟通成本已经花了,时间已经耽误了,项目已经延期了。

三、杀死BFF的,不是后端,是新技术

如果说内部问题是“慢性病”,那新技术的出现,就是对BFF的“降维打击”。

Serverless:终于不用半夜修服务器了

我第一次接触Serverless,是帮朋友搞一个小程序。

// 云函数版本的BFF
exports.main = async (event, context) => {
  const { userId, orderId } = event.query;
  
  // 一样的聚合逻辑
  const [user, order] = await Promise.all([
    fetchUser(userId),
    fetchOrder(orderId)
  ]);
  
  return { user, order };
};

不用买机器、不用配nginx、不用考虑扩缩容。写完代码,serverless deploy,完事。出问题了?看日志,改代码,再部署。全程不用碰服务器

而且成本低得惊人。以前BFF服务器7x24小时运行,半夜没人访问也在烧钱。Serverless按调用次数计费,低流量时期几乎不花钱。

// 传统BFF:一直运行
app.listen(3000, () => {
  console.log('server running'); // 半夜也在运行
});

// Serverless:按需启动
exports.handler = async (event) => {
  // 有请求才执行,执行完就销毁
  return { statusCode: 200, body: 'hello' };
};

GraphQL:让前后端“吵架”变少了

GraphQL刚出来时,我们觉得它不就是BFF的另一种形式吗?但用了一段时间才发现,最大的改变是:前后端终于有了一份清晰的“契约”

# 前端声明要什么
query {
  order(id: "123") {
    amount
    status
    user {
      name
      avatar
    }
    products {
      name
      price
    }
  }
}
// GraphQL resolver:聚合逻辑还在,但契约更清晰了
const resolvers = {
  Order: {
    user: (order) => fetchUser(order.userId),
    products: (order) => fetchProducts(order.id)
  }
};

以前调BFF接口,返回什么全靠看代码、靠猜。用GraphQL,前端明确声明要哪些字段,返回的数据结构是强类型的,IDE里还有智能提示。

后端终于“开窍”了

这几年,后端也在变化。

// 以前:后端坚持原子接口
GET /user/123
GET /orders?userId=123
GET /products?orderId=456

// 现在:后端提供聚合接口
GET /web/profile?userId=123
// 返回:{ user: {...}, recentOrders: [...], favoriteProducts: [...] }

后端团队也开始重视文档、规范字段命名、保证数据契约的稳定性。前端对BFF的依赖,自然就降低了。

四、但我们真的做错了吗?

写到这里,可能会觉得BFF是一个“错误的选择”。

但我想说:在那个时间点,BFF就是最好的解。

BFF解决了当时最痛的三个问题:

  1. 不用调N个接口了:一次请求,拿到所有数据
  2. 不同端可以定制数据了:Web、App、小程序各取所需
  3. 不用求后端改接口了:我们自己能改

对于我们前端来说,BFF给了我们更大的话语权和自主权。它让我们从“切图仔”变成了“能掌控数据链路的人”。

这段经历,也让我们学会了后端思维:缓存、并发、熔断、限流...这些知识,现在依然在用。

五、今天,我们前端该怎么玩?

如果你问我,现在要不要学Node.js中间层,我的答案是:要学,但不是以前那种玩法。

优先拥抱Serverless

// 传统BFF
const app = require('express')();
app.get('/api/order', async (req, res) => {
  // 业务逻辑
});
app.listen(3000);

// Serverless版本
exports.handler = async (event) => {
  // 同样的业务逻辑
  return { statusCode: 200, body: JSON.stringify(data) };
};

除非有特殊需求,否则优先用云函数。运维成本几乎为零,咱们前端可以真正专注于业务逻辑。

学GraphQL,但别只会写resolver

// 理解GraphQL的设计思想
type User {
  id: ID!
  name: String!
  orders: [Order!]!
}

type Order {
  id: ID!
  amount: Float!
  status: String!
}

Schema优先、强类型契约、按需查询——这些思想,会让你对“前后端协作”有更深的理解。

把BFF当“学习后端思维”的跳板

即使以后不用BFF了,那段经历也是宝贵的。你学会了如何处理并发、如何设计缓存、如何做服务熔断、如何排查线上问题。

// 这些能力依然有用
Promise.all([fetchA(), fetchB(), fetchC()]); // 并发控制
node --inspect-brk app.js // 调试技巧

这些能力,会让你成为“更懂后端”的前端,在协作中更有话语权。

六、写在最后

技术的世界,没有永恒的真理,只有不断变化的语境。

BFF从崛起到回落,不是一个失败的故事,而是一个成长的印记。它见证了前端从“切图”到“全栈”的探索,也见证了架构演进的必然规律。

对于我们每个亲身经历过的人来说,重要的是:不要停留在过去的荣光里,也不要否定曾经的探索

保持学习,保持思考,保持对新技术的好奇。

这才是我们前端最宝贵的品质。


如果你也经历过BFF的起起落落,欢迎在评论区聊聊你的故事。

HTML&CSS&JS:基于定位的实时天气卡片

这个 HTML 页面实现了一个基于用户地理位置的实时天气信息卡片,界面美观、交互流畅。页面加载自动定位→请求天气数据→渲染卡片,支持手动刷新,全流程有加载 / 错误状态提示,动效过渡自然。赶快收藏学习吧。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

image

HTML&CSS

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>实时天气卡片</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            padding: 20px;
        }

        .weather-card {
            background: rgba(255, 255, 255, 0.95);
            border-radius: 20px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
            padding: 24px;
            max-width: 280px;
            width: 100%;
            backdrop-filter: blur(10px);
            transition: transform 0.3s ease, box-shadow 0.3s ease;
        }

        .weather-card:hover {
            transform: translateY(-3px);
            box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
        }

        .location {
            text-align: center;
            margin-bottom: 16px;
        }

        .location-icon {
            font-size: 24px;
            margin-bottom: 6px;
        }

        .city-name {
            font-size: 20px;
            font-weight: 700;
            color: #333;
            margin-bottom: 4px;
        }

        .current-time {
            font-size: 12px;
            color: #888;
            font-weight: 500;
        }

        .weather-main {
            text-align: center;
            margin: 20px 0;
        }

        .weather-icon {
            font-size: 56px;
            margin-bottom: 8px;
        }

        .temperature {
            font-size: 42px;
            font-weight: 300;
            color: #333;
            line-height: 1;
        }

        .temperature span {
            font-size: 22px;
            vertical-align: top;
        }

        .weather-description {
            font-size: 16px;
            color: #666;
            margin-top: 6px;
            text-transform: capitalize;
            margin-right: 20px;
        }

        .weather-details {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 12px;
            margin-top: 20px;
            padding-top: 16px;
            border-top: 1px solid #f0f0f0;
        }

        .detail-item {
            text-align: center;
            padding: 8px 6px;
            background: #f8f9fa;
            border-radius: 10px;
            transition: background 0.3s ease;
        }

        .detail-item:hover {
            background: #e9ecef;
        }

        .detail-icon {
            font-size: 18px;
            margin-bottom: 4px;
        }

        .detail-value {
            font-size: 14px;
            font-weight: 600;
            color: #333;
            margin-bottom: 2px;
        }

        .detail-label {
            font-size: 10px;
            color: #999;
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }

        .loading {
            text-align: center;
            color: #666;
            font-size: 14px;
        }

        .error {
            background: #fee;
            color: #c33;
            padding: 12px;
            border-radius: 10px;
            text-align: center;
            margin-top: 16px;
            font-size: 14px;
        }

        .refresh-btn {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            padding: 10px 24px;
            border-radius: 20px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            margin-top: 16px;
            width: 100%;
            transition: transform 0.2s ease, box-shadow 0.2s ease;
        }

        .refresh-btn:hover {
            transform: scale(1.02);
            box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
        }

        .refresh-btn:active {
            transform: scale(0.98);
        }
    </style>
</head>
<body>
    <div class="weather-card" id="weatherCard">
        <div class="location">
            <div class="location-icon">📍</div>
            <div class="city-name" id="cityName">获取位置中...</div>
            <div class="current-time" id="currentTime">--:--:--</div>
        </div>

        <div id="weatherContent">
            <div class="loading">正在获取天气信息...</div>
        </div>
    </div>

    <script>
        // 天气图标映射
        const weatherIcons = {
            '01d': '☀️', '01n': '🌙',
            '02d': '⛅', '02n': '☁️',
            '03d': '☁️', '03n': '☁️',
            '04d': '☁️', '04n': '☁️',
            '09d': '🌧️', '09n': '🌧️',
            '10d': '🌦️', '10n': '🌧️',
            '11d': '⛈️', '11n': '⛈️',
            '13d': '❄️', '13n': '❄️',
            '50d': '🌫️', '50n': '🌫️'
        };

        // 更新时间
        function updateTime() {
            const now = new Date();
            const options = {
                year: 'numeric',
                month: 'long',
                day: 'numeric',
                weekday: 'long',
                hour: '2-digit',
                minute: '2-digit',
                second: '2-digit',
                hour12: false
            };
            document.getElementById('currentTime').textContent = now.toLocaleDateString('zh-CN', options);
        }

        // 通过经纬度获取中文城市名称
        async function getChineseCityName(latitude, longitude) {
            try {
                // 使用免费的反向地理编码 API
                const response = await fetch(
                    `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${latitude}&longitude=${longitude}&localityLanguage=zh`
                );
                const data = await response.json();

                // 优先使用市级名称,如果没有则使用区级名称
                if (data.city) {
                    return data.city;
                } else if (data.locality) {
                    return data.locality;
                } else if (data.principalSubdivision) {
                    return data.principalSubdivision;
                }
                return null;
            } catch (error) {
                console.error('获取中文城市名称失败:', error);
                return null;
            }
        }

        // 获取位置和天气
        async function getWeather() {
            const weatherContent = document.getElementById('weatherContent');
            const cityName = document.getElementById('cityName');

            if (!navigator.geolocation) {
                weatherContent.innerHTML = '<div class="error">您的浏览器不支持地理位置定位</div>';
                return;
            }

            try {
                // 获取地理位置
                const position = await new Promise((resolve, reject) => {
                    navigator.geolocation.getCurrentPosition(resolve, reject, {
                        enableHighAccuracy: true,
                        timeout: 10000,
                        maximumAge: 0
                    });
                });

                const { latitude, longitude } = position.coords;

                // 获取中文城市名称
                const chineseCity = await getChineseCityName(latitude, longitude);
                if (chineseCity) {
                    cityName.textContent = chineseCity;
                } else {
                    cityName.textContent = '获取位置中...';
                }

                // 调用 OpenWeatherMap API(使用免费的 API key,实际使用时需要替换)
                const apiKey = '4d8fb5b93d4af21d66a2948710284366'; // 这是一个公开的 demo key
                const response = await fetch(
                    `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&appid=${apiKey}&units=metric&lang=zh_cn`
                );

                if (!response.ok) {
                    throw new Error('获取天气信息失败');
                }

                const data = await response.json();

                // 显示天气信息(传入中文城市名称)
                displayWeather(data, chineseCity);

            } catch (error) {
                console.error('Error:', error);
                weatherContent.innerHTML = `
                    <div class="error">
                        ${error.message || '无法获取天气信息,请检查网络连接'}
                    </div>
                    <button class="refresh-btn" onclick="getWeather()">重试</button>
                `;
            }
        }

        // 显示天气信息
        function displayWeather(data, chineseCityName) {
            const weatherContent = document.getElementById('weatherContent');
            const cityName = document.getElementById('cityName');

            // 更新城市名称(优先使用中文城市名称)
            if (chineseCityName) {
                cityName.textContent = chineseCityName;
            } else if (data.name) {
                cityName.textContent = data.name;
            } else {
                cityName.textContent = '当前位置';
            }

            const iconCode = data.weather[0].icon;
            const icon = weatherIcons[iconCode] || '🌤️';

            weatherContent.innerHTML = `
                <div class="weather-main">
                    <div class="weather-icon">${icon}</div>
                    <div class="temperature">
                        ${Math.round(data.main.temp)}<span>°C</span>
                    </div>
                    <div class="weather-description">
                        ${data.weather[0].description || '未知天气'}
                    </div>
                </div>

                <div class="weather-details">
                    <div class="detail-item">
                        <div class="detail-icon">💧</div>
                        <div class="detail-value">${data.main.humidity}%</div>
                        <div class="detail-label">湿度</div>
                    </div>
                    <div class="detail-item">
                        <div class="detail-icon">💨</div>
                        <div class="detail-value">${Math.round(data.wind.speed)} m/s</div>
                        <div class="detail-label">风速</div>
                    </div>
                    <div class="detail-item">
                        <div class="detail-icon">🌡️</div>
                        <div class="detail-value">${Math.round(data.main.feels_like)}°C</div>
                        <div class="detail-label">体感</div>
                    </div>
                </div>

                <button class="refresh-btn" onclick="getWeather()">🔄 刷新天气</button>
            `;
        }

        // 初始化
        document.addEventListener('DOMContentLoaded', () => {
            updateTime();
            setInterval(updateTime, 1000); // 每秒更新时间
            getWeather(); // 获取天气
        });
    </script>
</body>
</html>

HTML

  • div weather-card weatherCard:容器标签。天气卡片核心容器。毛玻璃效果 + 圆角 + 阴影,hover 时有上浮动效
  • div location:容器标签。位置 / 时间展示区。包含定位图标、城市名称、当前时间
  • div weatherContent:容器标签。天气内容动态展示区 初始显示「加载中」,后续通过 JS 替换为天气数据
  • div loading:容器标签。加载状态提示 初始显示「正在获取天气信息...」
  • div error:容器标签。错误提示容器 定位 / 接口失败时显示错误信息
  • button refresh-btn:按钮标签。刷新天气按钮。点击触发重新获取天气数据,有 hover/active 动效
  • script:脚本标签。内嵌 JavaScript。实现定位、接口请求、数据渲染、时间更新等动态逻辑

CSS

1. 全局重置与基础布局

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box; /* 统一盒模型,避免 padding 撑大元素 */
}
body {
  min-height: 100vh; /* 占满屏幕高度 */
  display: flex;
  justify-content: center;
  align-items: center; /* 卡片垂直水平居中 */
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 渐变背景 */
  font-family: 'Segoe UI', sans-serif; /* 现代无衬线字体 */
  padding: 20px; /* 移动端留白,避免卡片贴边 */
}

核心:全局重置默认边距,弹性布局实现卡片居中,渐变背景提升视觉质感。

2. 天气卡片核心样式

.weather-card {
  background: rgba(255, 255, 255, 0.95); /* 半透明白色 */
  border-radius: 20px; /* 大圆角提升现代感 */
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); /* 柔和阴影 */
  padding: 24px;
  max-width: 280px; /* 固定最大宽度,适配移动端 */
  width: 100%;
  backdrop-filter: blur(10px); /* 毛玻璃核心效果 */
  transition: transform 0.3s ease, box-shadow 0.3s ease; /* 过渡动效 */
}
.weather-card:hover {
  transform: translateY(-3px); /* 鼠标悬浮上浮 */
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); /* 阴影放大 */
}

核心:backdrop-filter: blur(10px) 实现毛玻璃效果,hover 上浮 + 阴影增强交互反馈,固定最大宽度适配移动端。

3. 天气数据布局样式

/* 天气主信息区(温度/图标/描述) */
.weather-main {
  text-align: center;
  margin: 20px 0;
}
.temperature {
  font-size: 42px;
  font-weight: 300; /* 轻量级字体,更现代 */
  line-height: 1; /* 消除行高冗余 */
}
.temperature span {
  font-size: 22px;
  vertical-align: top; /* 摄氏度符号上对齐 */
}

/* 天气详情网格(湿度/风速/体感) */
.weather-details {
  display: grid;
  grid-template-columns: repeat(3, 1fr); /* 三等分网格 */
  gap: 12px;
  border-top: 1px solid #f0f0f0; /* 分隔线 */
  padding-top: 16px;
}
.detail-item {
  text-align: center;
  background: #f8f9fa; /* 浅灰背景 */
  border-radius: 10px;
  padding: 8px 6px;
  transition: background 0.3s ease;
}
.detail-item:hover {
  background: #e9ecef; /* hover 加深背景 */
}

核心:网格布局实现「湿度 / 风速 / 体感」三等分展示,轻量级字体 + 对齐优化提升温度显示的视觉层次,细节项 hover 背景变化增强交互。

4. 按钮与状态样式

/* 刷新按钮 */
.refresh-btn {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 与页面背景呼应 */
  color: white;
  border: none;
  padding: 10px 24px;
  border-radius: 20px; /* 胶囊按钮 */
  width: 100%; /* 全屏宽按钮,适配移动端点击 */
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.refresh-btn:hover {
  transform: scale(1.02); /* 轻微放大 */
  box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); /* 发光阴影 */
}
.refresh-btn:active {
  transform: scale(0.98); /* 点击下压 */
}

/* 加载/错误状态 */
.loading {
  text-align: center;
  color: #666;
}
.error {
  background: #fee; /* 浅红背景 */
  color: #c33; /* 深红文字 */
  padding: 12px;
  border-radius: 10px;
  text-align: center;
}

核心:按钮渐变背景与页面呼应,hover 放大 + 发光阴影,active 下压模拟物理按钮反馈;错误状态用红系配色提示,加载状态居中显示,视觉层级清晰。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

从 Next.js 完全迁移到 vinext 的实战踩坑指南

一次真实项目从 Next.js (Cloudflare Pages) 迁移到 vinext (Cloudflare Workers) 的全过程记录,涵盖构建、部署、运行时、国际化、认证等 16+ 个坑位及其解决方案。

项目简介

本文基于开源项目 edge-next-starter 的真实迁移经验撰写。

edge-next-starter 是一个面向 Cloudflare 全栈开发的 Next.js 生产级启动模板,集成了 D1 数据库、R2 对象存储、KV 缓存、better-auth 认证、next-intl 国际化、Stripe 支付等企业级能力,开箱即用。项目采用 Repository 模式 + Edge Runtime 架构设计,所有代码均运行在 Cloudflare Workers 全球边缘网络上。

GitHub: github.com/TangSY/edge… ⭐ 欢迎 Star

背景与动机

2026 年 2 月 24 日,Cloudflare 发布了一篇震动 Web 开发社区的博客:How we rebuilt Next.js with AI in one week。一名 Cloudflare 工程师 Steve Faulkner 使用 Anthropic 的 Claude AI 模型,仅花费约 1100 美元的 token 费用,在一周内从零重建了 Next.js 94% 的 API。产物就是 vinext(发音 "vee-next")—— 一个基于 Vite 构建的 Next.js 替代实现,专门针对 Cloudflare Workers 优化。

Cloudflare CTO Dane Knecht 称之为「Next.js 的解放日」。项目绝大部分代码由 AI 编写,人类负责架构方向、优先级和设计决策。

vinext 相比传统的 @cloudflare/next-on-pages 或 OpenNext 方案,有以下显著优势:

  • 原生 Workers 部署:不再需要逆向工程 Next.js 的构建输出,一条命令直接部署
  • Vite 构建:取代 Turbopack/webpack,生产构建速度最高提升 4 倍
  • 更小的 bundle:客户端包体积最高缩小 57%
  • 开发环境一致性vite dev 直接在 Workers 运行时中运行,可以测试 D1、KV、Durable Objects 等平台 API
  • RSC 原生支持:完整的 React Server Components、流式渲染、Server Actions 支持

但 vinext 仍处于实验阶段(🚧 Experimental),迁移过程远非一帆风顺 — 本文记录了我们在真实生产项目中遇到的 16 个坑位

项目技术栈

组件 技术
框架 Next.js App Router (RSC)
运行时 Cloudflare Workers (Edge Runtime)
数据库 Cloudflare D1 (SQLite)
ORM Prisma + @prisma/adapter-d1
认证 better-auth (从 NextAuth v5 迁移)
国际化 next-intl (en/zh)
存储 Cloudflare R2
缓存 Cloudflare KV
包管理 pnpm

迁移概览

整个迁移涉及 25 个 commits,修改了 50+ 个文件。问题主要集中在以下几个维度:

构建阶段 ─── Prisma Client 模块解析 (3 次迭代)
          └── Wrangler 配置格式转换

运行时 ───── ESM 导入方式变更
          ├── Proxy 导出签名
          ├── 环境变量访问方式
          └── NextURL 只读属性

框架兼容 ─── Middleware matcher 语法
          ├── notFound() 错误处理
          ├── RSC 条件导出
          ├── .rsc 请求处理
          └── Link 组件 vs 原生 <a>

认证系统 ─── DateInt 类型转换
          ├── OAuth State 清理查询
          ├── VerificationToken 主键
          ├── String ↔ Int ID 转换
          └── emailVerified BooleanInt

第一关:Vite 构建 — Prisma Client 解析

症状pnpm build 失败,报 No such module ".prisma/client/default"

原因:Prisma 生成的客户端使用 .prisma/client/default 作为裸模块标识符 (bare specifier)。虽然它以 . 开头,但并不是相对路径 — Node.js 会从 node_modules 中解析它。Vite 把它当作相对路径处理,找不到模块后将其标记为 external,导致 Workers 运行时报错。

踩坑过程(3 次迭代):

第一次:resolve.alias(失败)

// vite.config.ts — 第一次尝试
export default defineConfig({
  resolve: {
    alias: {
      '.prisma/client/default': resolve(import.meta.dirname, 'node_modules/.prisma/client/wasm.js'),
    },
  },
});

问题import.meta.dirname 在 Vite 编译 TypeScript 配置时,可能解析到临时目录而非项目根目录,导致 CI 环境路径错误。

第二次:process.cwd()(部分成功)

// 改用 process.cwd()
'.prisma/client/default': resolve(
  process.cwd(),
  'node_modules/.prisma/client/wasm.js'
),

问题:在 pnpm 的 store 布局下,.prisma/client/wasm.js 不在 node_modules 直接目录中,ENOENT 错误。

第三次:Vite resolveId 插件(最终方案)

function prismaClientResolve(): Plugin {
  const _require = createRequire(import.meta.url);
  let prismaDir: string | null = null;

  // 策略 1: 项目根目录直接查找
  const directPath = resolve(process.cwd(), 'node_modules', '.prisma', 'client');

  // 策略 2: 从 @prisma/client 包位置反向查找(兼容 pnpm store)
  let pnpmPath: string | null = null;
  try {
    const prismaClientPkg = _require.resolve('@prisma/client/package.json');
    pnpmPath = resolve(dirname(prismaClientPkg), '..', '..', '.prisma', 'client');
  } catch {}

  // 选择第一个存在的路径
  for (const candidate of [directPath, pnpmPath].filter(Boolean) as string[]) {
    if (existsSync(candidate)) {
      prismaDir = candidate;
      break;
    }
  }

  return {
    name: 'prisma-client-resolve',
    enforce: 'pre',
    resolveId(source) {
      if (!source.startsWith('.prisma/client')) return null;
      if (!prismaDir) return null;

      const subpath = source === '.prisma/client' ? '' : source.slice('.prisma/client/'.length);

      if (subpath === 'default' || subpath === '') {
        // 优先使用 wasm.js(Workers WASM 引擎)
        const wasmPath = resolve(prismaDir, 'wasm.js');
        if (existsSync(wasmPath)) return wasmPath;

        // 回退到 default.js
        const defaultPath = resolve(prismaDir, 'default.js');
        if (existsSync(defaultPath)) return defaultPath;
      }
      return null;
    },
  };
}

关键点

  • 使用 createRequire@prisma/client 的实际位置反向定位 .prisma/client
  • existsSync 检查避免路径猜测
  • 优先 wasm.js(Workers 兼容)而非 default.js(Node.js 专用)

第二关:Wrangler 配置 — Pages → Workers 格式转换

症状:部署到 Cloudflare 后,环境变量显示为 "preview" 而非 "production"

原因:vinext 使用 Workers 部署(wrangler deploy),但项目的 wrangler 配置还是 Cloudflare Pages 格式。

修改前(Pages 格式):

pages_build_output_dir = ".vercel/output/static"

修改后(Workers 格式):

main = "dist/server/index.js"
no_bundle = true

# vinext 的构建产物是预打包的,告诉 Wrangler 不要重新用 esbuild 打包
[[rules]]
type = "ESModule"
globs = ["**/*.js", "**/*.mjs"]

[assets]
not_found_handling = "none"
directory = "dist/client"

关键点

  • no_bundle = true 是必须的,否则 Wrangler 会用 esbuild 重新打包 vinext 的构建产物,遇到 .prisma/client/default 外部导入时直接报错
  • [[rules]] ESModule 类型声明让 Wrangler 正确处理 vinext 输出的 ES Module 文件
  • [assets] 替代 pages_build_output_dir,指向 vinext 的客户端构建产物

第三关:Workers 运行时 — ESM 与 Proxy 导出

症状:部署后访问任何页面返回 500,Workers 日志无明显错误

问题 1cloudflare:workers 模块必须用静态 ESM 导入

// ❌ 错误 — CJS 动态导入在 Workers 中不工作
const { env } = require('cloudflare:workers');

// ✅ 正确 — 静态 ESM 导入
import { env as cloudflareEnv } from 'cloudflare:workers';

问题 2:vinext 要求 proxy 使用命名导出而非默认导出

// ❌ 错误 — Next.js middleware 的默认导出
export default function middleware(req) { ... }

// ✅ 正确 — vinext 要求命名导出 { proxy }
export async function proxy(req: NextRequest) { ... }

Vitest 兼容cloudflare:workers 在测试环境中不存在,需要 mock:

// vitest.cloudflare-stub.ts
export const env = {};
export default { env };

// vitest.config.ts
resolve: {
  alias: {
    'cloudflare:workers': resolve(__dirname, 'vitest.cloudflare-stub.ts'),
  },
}

第四关:Middleware → Proxy — matcher 语法不兼容

症状:所有页面返回 404,/en 路径抛出 Error 1101(next-intl locale context 缺失)

原因:vinext 的 matchMiddlewarePath 实现中对 matcher pattern 做了 .replace(/\./g, "\\.") 处理。这会破坏 Next.js 标准的正则表达式 matcher:

// Next.js 标准 matcher(包含正则语法)
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)'],
};
// 经过 vinext 的 .replace 后变成:
// /((?!api|_next/static|_next/image|.*\\.\\..*)\\..*)
// 完全无法匹配任何路径 → proxy 永远不执行

解决方案:使用 vinext 支持的 :param 语法:

export const config = {
  matcher: ['/:path*'],
};
// vinext 将 :path* 转换为 (?:.*) → 匹配所有路径

附带说明:在 Workers 中不需要排除 _next/static 等静态资源路径,因为 Cloudflare CDN 在请求到达 Worker 之前就会直接提供静态文件。但为了安全,我们在 proxy 中增加了静态文件扩展名检测:

const STATIC_EXTENSIONS =
  /\.(ico|png|jpg|jpeg|gif|svg|webp|css|js|map|woff2?|ttf|eot|webmanifest|txt|xml|json)$/i;

export async function proxy(req: NextRequest) {
  if (STATIC_EXTENSIONS.test(req.nextUrl.pathname)) {
    return NextResponse.next();
  }
  // ...
}

第五关:next-intl — NextURL.port 只读属性

症状:访问任何页面抛出 TypeError: Cannot set property port which has only a getter

原因next-intlcreateIntlMiddleware 内部会修改 NextURL 对象的 port 属性。在标准 Next.js 中 NextURL.port 是可读写的,但 vinext 的 Workers 运行时将其实现为只读 getter。

解决方案:用轻量级的自定义 i18n 路由处理器替代 createIntlMiddleware

function handleI18nRouting(req: NextRequest): NextResponse {
  const { locales, defaultLocale } = routing;
  const pathname = req.nextUrl.pathname;

  // 路径已包含 locale 前缀,直接通过
  const hasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );
  if (hasLocale) return NextResponse.next();

  // 从 Accept-Language 检测首选 locale
  const acceptLanguage = req.headers.get('accept-language') || '';
  let detectedLocale = defaultLocale;
  for (const locale of locales) {
    if (acceptLanguage.toLowerCase().includes(locale)) {
      detectedLocale = locale;
      break;
    }
  }

  // 使用原生 URL 重定向(避免 NextURL setter 问题)
  const url = new URL(req.url);
  url.pathname = `/${detectedLocale}${pathname}`;
  return NextResponse.redirect(url);
}

关键点:使用 new URL(req.url) 而非 req.nextUrl.clone(),因为后者会触发 NextURL 的 setter 逻辑。


第六关:环境变量 — cloudflare:workers vs process.env

症状:认证功能在本地开发正常,部署后报 CLIENT_ID_AND_SECRET_REQUIRED

原因:通过 wrangler secret put 设置的密钥(如 GOOGLE_CLIENT_IDNEXTAUTH_SECRET),只能通过 cloudflare:workersenv 绑定访问,不会出现在 process.env 中。

// ❌ 在 Workers 中永远是 undefined
const secret = process.env.NEXTAUTH_SECRET;

// ✅ 正确方式
import { env as cloudflareEnv } from 'cloudflare:workers';
const secret = (cloudflareEnv as Record<string, unknown>).NEXTAUTH_SECRET;

最终方案:创建统一的环境变量解析函数:

function getEnvVar(key: string): string | undefined {
  // 优先从 Cloudflare Workers 绑定读取
  const cfEnv = cloudflareEnv as Record<string, unknown>;
  if (cfEnv?.[key]) return String(cfEnv[key]);
  // 回退到 process.env(本地开发、测试环境)
  return process.env[key];
}

第七关:notFound() 异常 — NEXT_NOT_FOUND 未捕获

症状:某些页面间歇性返回 500,日志显示 NEXT_NOT_FOUND 异常

原因:vinext 在错误恢复时会用 undefined params 重新渲染 locale layout。代码中的 notFound()(来自 next/navigation)抛出 NEXT_NOT_FOUND 错误,但 vinext 不会像标准 Next.js 那样捕获它,导致整个 RSC 渲染链崩溃。

// ❌ vinext 中不工作
export default async function LocaleLayout({ params }: Props) {
  const { locale } = await params;
  if (!routing.locales.includes(locale)) {
    notFound(); // 抛出 NEXT_NOT_FOUND → 500
  }
}

// ✅ 回退到默认 locale
export default async function LocaleLayout({ params }: Props) {
  const resolvedParams = await params;
  const locale = routing.locales.includes(resolvedParams?.locale)
    ? resolvedParams.locale
    : routing.defaultLocale;
  // 继续渲染...
}

第八关:NextIntlClientProvider — RSC 条件导出冲突

症状:所有页面路由报 Error 1101,日志显示 headers() is not a function

原因next-intlNextIntlClientProvider 使用了 package.json 的 exports 条件导出。在 vinext 的 RSC 环境中,它解析到了一个异步服务端版本,该版本会调用 headers()(来自 next/headers)。但 vinext 不支持在该上下文中调用 headers()

解决方案:创建本地的 'use client' 包装组件:

// app/[locale]/intl-provider.tsx
'use client';

import { IntlProvider } from 'use-intl';

export function ClientIntlProvider({
  locale,
  messages,
  children,
}: {
  locale: string;
  messages: Record<string, unknown>;
  children: React.ReactNode;
}) {
  return (
    <IntlProvider locale={locale} messages={messages as IntlMessages}>
      {children}
    </IntlProvider>
  );
}

关键点

  • 直接从 use-intl(next-intl 的底层库)导入 IntlProvider
  • 'use client' 指令确保 vinext 将其序列化为客户端引用
  • 不使用 NextIntlClientProvider,避免触发服务端条件导出

第九关:RSC 导航 — .rsc 请求被 Auth 拦截

症状:浏览器前进/后退按钮导致页面空白

原因:vinext 使用 .rsc 后缀进行客户端 RSC payload 请求(例如 /en/dashboard.rsc)。proxy 将这些请求当作普通页面请求处理,检查认证状态后重定向到 /login。但 .rsc 请求期望的是 RSC 流数据,收到重定向后 React 无法解析,导致页面空白。

解决方案:在 proxy 中对 .rsc 请求只做 i18n 路由,跳过 auth 检查:

if (pathname.endsWith('.rsc')) {
  // 去掉 .rsc 后缀检查 i18n 路由
  const cleanPath = pathname.slice(0, -4);
  const hasLocale = locales.some(
    locale => cleanPath.startsWith(`/${locale}/`) || cleanPath === `/${locale}`
  );

  if (hasLocale) return NextResponse.next();

  // 添加 locale 前缀并重定向
  const url = new URL(req.url);
  url.pathname = `/${detectedLocale}${pathname}`;
  return NextResponse.redirect(url);
}

安全性:认证在服务端组件层(getSessionSafe())强制执行,不依赖 proxy。


第十关:Link 组件与 API 路由 — RSC 流损坏

症状:用 <Link> 跳转到 API 端点后,浏览器后退显示原始 JSON 而非页面

原因:Next.js 的 <Link> 组件触发客户端 RSC 导航。API 端点返回 JSON 而非 RSC payload,React 尝试解析 JSON 作为 RSC 流失败,破坏了浏览器历史记录中的 React 根节点。

解决方案:对 API 链接使用原生 <a> 标签:

// ❌ 会触发 RSC 导航
<Link href="/api/health">Health Check</Link>

// ✅ 强制全页面导航
<a href="/api/health" target="_blank" rel="noopener noreferrer">
  Health Check
</a>

第十一关:better-auth — Date ↔ Int 类型不匹配

症状:better-auth 的所有数据库写入操作失败

原因:better-auth 内部所有日期字段使用 JavaScript Date 对象,但我们的 Prisma schema 使用 Int(Unix 时间戳,秒)来兼容 D1/SQLite。Prisma 的 SQLite provider 不会自动做这个转换。

解决方案:创建 Prisma Client Proxy,拦截所有 auth 相关模型的操作:

// lib/db/auth-prisma-proxy.ts
const AUTH_DATE_FIELDS: Record<string, string[]> = {
  user: ['emailVerified', 'createdAt', 'updatedAt'],
  account: ['expiresAt', 'createdAt', 'updatedAt'],
  session: ['expires', 'createdAt', 'updatedAt'],
  verificationToken: ['expires', 'createdAt', 'updatedAt'],
};

function deepConvertInputs(obj: unknown, parentKey?: string): unknown {
  if (obj instanceof Date) return Math.floor(obj.getTime() / 1000);
  if (Array.isArray(obj)) return obj.map(item => deepConvertInputs(item));
  if (obj !== null && typeof obj === 'object') {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
      result[key] = deepConvertInputs(value, key);
    }
    return result;
  }
  return obj;
}

export function createAuthPrismaProxy<T>(prisma: T): T {
  return new Proxy(prisma as object, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      if (typeof prop !== 'string') return value;
      const modelKey = resolveModelKey(prop);
      if (!modelKey) return value;

      // 为 auth 模型的每个方法创建代理
      return new Proxy(value as object, {
        get(modelTarget, methodName, modelReceiver) {
          const method = Reflect.get(modelTarget, methodName, modelReceiver);
          if (typeof method !== 'function') return method;
          if (!ALL_OPS.has(methodName as string)) return method.bind(modelTarget);

          return async function (...args: any[]) {
            // 输入:递归转换 Date → Unix timestamp
            if (args[0] && typeof args[0] === 'object') {
              args[0] = deepConvertInputs(args[0]);
            }
            const result = await method.apply(modelTarget, args);
            // 输出:Unix timestamp → Date(仅已知日期字段)
            return convertOutputDates(result, modelKey);
          };
        },
      });
    },
  }) as T;
}

使用方式:

// lib/auth/index.ts
export const auth = betterAuth({
  database: prismaAdapter(createAuthPrismaProxy(prisma), { provider: 'sqlite' }),
  // ...
});

第十二关:OAuth 状态管理 — deleteMany 中的 Date 未转换

症状:Google OAuth 回调后重定向到 ?error=please_restart_the_process

原因:better-auth 的 OAuth 流程中,findVerificationValue 函数在查找状态后会执行清理操作:

// better-auth 内部代码 (state.mjs)
await adapter.deleteMany({
  model: 'verification',
  where: [{ field: 'expiresAt', operator: 'lt', value: new Date() }],
});

我们的 proxy 第一版只拦截了 createupdatefind 操作,且只转换 datacreate/update 字段中的 Date。deleteManywhere 子句中的 new Date() 没有被转换,导致 SQLite 比较失败。

修复:将拦截范围扩展到所有 Prisma 操作(ALL_OPS),并使用递归的 deepConvertInputs 处理整个 args 树:

const ALL_OPS = new Set([
  'create',
  'createMany',
  'update',
  'updateMany',
  'upsert',
  'delete',
  'deleteMany',
  'findFirst',
  'findUnique',
  'findMany',
  'count',
  'aggregate',
]);

第十三关:VerificationToken — 主键缺失与 ID 类型错误

症状:Google OAuth 回调返回 internal_server_error,Workers 日志显示两个错误

错误 1verificationToken.delete({ where: { id: null } })

原因:VerificationToken 模型的 id 字段定义为 String?(可选),没有设置为主键。配合 generateId: false,better-auth 不会为 VerificationToken 生成 ID,导致删除操作传入 id: null

错误 2user.findFirst({ where: { id: "1" } })

原因:better-auth 内部使用 z.coerce.string() 将所有 ID 转换为字符串。但 Prisma schema 中 User 的 idInt,字符串 "1" 无法匹配整数 1

修复

  1. 数据库迁移 — 重建 verification_tokens 表:
-- 0007_fix_verification_token_pk.sql
CREATE TABLE IF NOT EXISTS verification_tokens_new (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  identifier TEXT NOT NULL,
  token TEXT NOT NULL UNIQUE,
  expires INT NOT NULL,
  created_at INT DEFAULT (strftime('%s', 'now')),
  updated_at INT DEFAULT (strftime('%s', 'now')),
  UNIQUE(identifier, token)
);
INSERT INTO verification_tokens_new (identifier, token, expires, created_at, updated_at)
  SELECT identifier, token, expires, created_at, updated_at FROM verification_tokens;
DROP TABLE verification_tokens;
ALTER TABLE verification_tokens_new RENAME TO verification_tokens;
CREATE INDEX idx_verification_tokens_expires ON verification_tokens(expires);
  1. Prisma schema 更新
model VerificationToken {
  id    Int    @id @default(autoincrement())  // 原来是 String?
  // ...
}
  1. 启用 useNumberId — better-auth 内置的 String ↔ Int ID 转换:
// lib/auth/index.ts
export const auth = betterAuth({
  advanced: {
    database: {
      useNumberId: true, // 替代 generateId: false
    },
  },
});

useNumberId: true 让 better-auth 的适配器层自动做 String → Number(输入)和 Number → String(输出)的 ID 转换。


第十四关:emailVerified — Boolean vs Int

症状:Google OAuth 登录时,创建用户失败

原因:当用户通过 Google OAuth 登录时,better-auth 认为邮箱已被 Google 验证,设置 emailVerified: true(布尔值)。但 schema 中 emailVerifiedInt?(Unix 时间戳)。

修复:在 proxy 的 deepConvertInputs 中添加布尔值 → 时间戳转换:

const BOOLEAN_TO_TIMESTAMP_FIELDS = new Set(['emailVerified']);

function deepConvertInputs(obj: unknown, parentKey?: string): unknown {
  if (obj instanceof Date) return Math.floor(obj.getTime() / 1000);

  // 特定字段的 boolean → timestamp 转换
  if (typeof obj === 'boolean' && parentKey && BOOLEAN_TO_TIMESTAMP_FIELDS.has(parentKey)) {
    return obj ? Math.floor(Date.now() / 1000) : null;
  }

  // ... 递归处理
}

第十五关:D1 迁移 — ALTER TABLE 不支持动态默认值

症状wrangler d1 migrations apply 报错 non-constant default

原因:SQLite 的 ALTER TABLE ADD COLUMN 语句不允许使用非常量默认值(如 strftime('%s', 'now'))。

-- ❌ 错误
ALTER TABLE accounts ADD COLUMN created_at INT DEFAULT (strftime('%s', 'now'));

-- ✅ 正确 — 先用常量默认值,再用 UPDATE 回填
ALTER TABLE accounts ADD COLUMN created_at INT DEFAULT 0;
UPDATE accounts SET created_at = strftime('%s', 'now') WHERE created_at = 0;

第十六关:CI/CD — Workers 域名包含账户子域

症状:GitHub Actions 部署成功但健康检查失败(HTTP 000)

原因:Cloudflare Workers 的默认域名格式是 <worker-name>.<account-subdomain>.workers.dev,而不是 <worker-name>.workers.dev。CI 配置中的回退 URL 少了账户子域。

# ❌ 错误
DEPLOYMENT_URL="https://my-worker.workers.dev"

# ✅ 正确
DEPLOYMENT_URL="https://my-worker.t-ac5.workers.dev"

最佳实践:在 GitHub Repository Variables 中配置 TEST_DEPLOYMENT_URLPRODUCTION_DEPLOYMENT_URL,不要依赖 hardcoded 回退值。


核心代码清单

迁移后的核心文件结构:

├── vite.config.ts              # Vite 配置 + Prisma resolveId 插件
├── proxy.ts                    # 替代 middleware.ts(i18n + auth + CORS/CSRF)
├── wrangler.toml               # 本地开发配置(Workers 格式)
├── wrangler.test.toml          # 测试环境配置
├── wrangler.prod.toml          # 生产环境配置
├── vitest.cloudflare-stub.ts   # cloudflare:workers 测试 mock
├── lib/
│   ├── auth/
│   │   ├── index.ts            # better-auth 配置 + getEnvVar
│   │   ├── session.ts          # getSessionSafe (RSC 安全包装)
│   │   └── password.ts         # PBKDF2 密码哈希(Edge 兼容)
│   └── db/
│       ├── client.ts           # Prisma 单例 + cloudflare:workers ESM 导入
│       └── auth-prisma-proxy.ts # Date↔Int + Boolean→Int 类型转换代理
├── app/[locale]/
│   └── intl-provider.tsx       # 本地 'use client' IntlProvider 包装
└── migrations/
    └── 0007_fix_verification_token_pk.sql  # VerificationToken 主键修复

总结

从 Next.js 迁移到 vinext 不是简单的"换个构建工具"那么简单。主要挑战来自三个层面:

  1. Vite vs webpack 的模块解析差异:Prisma 的裸模块标识符、条件导出的解析优先级等,在两个构建系统中行为完全不同。

  2. Workers vs Node.js 运行时差异process.env 不可用、NextURL 属性只读、notFound() 未被捕获 — 这些都是 Workers 运行时的限制。

  3. 第三方库假设:next-intl 假设 NextURL.port 可写,better-auth 假设日期字段是 Date 对象 — 这些假设在标准 Next.js 中成立,但在 vinext 的 Workers 环境中全部失效。

核心教训:vinext 不是 Next.js 的替代品,而是 Next.js API 在 Workers 运行时上的重新实现。任何依赖 Next.js 实现细节(而非公开 API)的代码,都可能需要适配。

本文基于 2026 年 2 月的实际迁移经验,vinext 仍在快速迭代中,部分问题可能在后续版本中得到解决。

为什么我推荐前端项目都应该使用 TanStack Query 管理接口请求

前言

本文主要介绍为什么我推荐在 React 项目中使用 TanStack Query 来管理接口请求,以及它在真实业务场景中的优势(没有太多配图,可能有点干吧,但应该对没了解过的同学有帮助)。同时也会梳理前端项目数据获取方式的演进过程,说明为什么逐步发展到需要一个专门的请求库来统一管理接口请求状态。

基础实现示例

import { useEffect, useState } from 'react';
import axios from 'axios';

function Demo() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const res = await axios.get('/api/user/info');
        setData(res.data);
      } catch (err) {
        console.error('请求失败:', err);
      }
    };

    fetchData();
  }, []);

  return <div>{data && data.name}</div>;
}

export default Demo;

上面是一段最基础的接口请求代码。在 React 组件中发起接口请求本质上属于副作用操作,因此通常通过 useEffect 来完成。useEffect 是一个 React Hook,用于将组件与外部系统进行同步。

接口请求通常是异步操作,需要使用 await 等待响应结果,因此函数必须声明为 async。然而,useEffect 的回调函数本身不能直接声明为 async,否则会产生如下类型报错:

类型“() => Promise”的参数不能赋给类型“EffectCallback”的参数。

不能将类型“Promise”分配给类型“void | Destructor”。ts(2345)

因此,实际开发中通常会在 useEffect 内部额外声明一个异步函数,再进行调用,从而规避这一限制。

异步Hook请求接口

如果每次都按照这种模式编写代码,整体体验并不友好。可以自行封装一个支持 asyncuseEffect,或者直接使用第三方库如 react-useahooks 提供的 useAsyncEffect,从而减少模板代码。

import { useState } from 'react';
import { useAsyncEffect } from 'ahooks';
import axios from 'axios';

function Demo() {
  const [data, setData] = useState(null);

  useAsyncEffect(async () => {
    try {
      const res = await axios.get('/api/user/info');
      setData(res.data);
    } catch (err) {
      console.error('请求失败:', err);
    }
  }, []);

  return <div>{data && data.name}</div>;
}

export default Demo;

虽然这种方式在代码结构上更简洁,但仍然存在明显问题。当接口参数较多时,一旦参数发生变化,就必须将这些参数加入 deps 依赖数组中,确保状态变化后能够重新触发请求。随着依赖项不断增多,deps 会逐渐膨胀,影响范围也随之扩大。

如果同一个 Effect 中还包含其他逻辑,整体上下文会变得冗长,维护难度明显上升,往往不得不拆分多个 Effect,导致数据请求逻辑分散,不再纯粹聚焦于接口管理本身。

自封装 Hook 管理更多状态

当基础数据获取完成后,产品经理追求更高质量的成品,往往会提出更多要求,例如在请求过程中展示 loading 状态、接口失败时显示错误提示、失败自动重试等。这些需求都与接口状态密切相关。

import { useState } from 'react';
import { useAsyncEffect } from 'ahooks';
import axios from 'axios';

function useRequest(api) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useAsyncEffect(async () => {
    setLoading(true);
    setError(null);

    try {
      const res = await api();
      setData(res.data);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, []);

  return { data, loading, error };
}

export default function Demo() {
  const { data, loading, error } = useRequest(() =>
    axios.get('/api/user/info')
  );

  if (loading) return <div>加载中...</div>;
  if (error) return <div>请求失败</div>;

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

通过自定义 Hook,可以将 dataloadingerror 等状态进行统一管理,从而降低组件内部的复杂度。实际上,这种方式已经逐渐演化为一个轻量版的 useRequest,其核心目标在于减少样板代码,并集中管理接口生命周期相关的所有状态。

当业务规模持续增长时,自行维护这套逻辑的成本会不断提高,最终会推动我们采用更加成熟、完善的请求管理方案。

推荐使用请求库 TanStack Query

到这里,便引出了本文的核心主角 TanStack Query。它是目前 React 生态中最成熟、最流行的异步数据管理库之一。 官方文档:tanstack.com/query/lates…

先看一个基础示例:。

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

function fetchUser() {
  return axios.get('/api/user/info');
}

export default function Demo() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['userInfo'],
    queryFn: fetchUser,
  });

  if (isLoading) return <div>加载中...</div>;
  if (isError) return <div>请求失败</div>;

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

useQuery 接收两个核心参数:queryKeyqueryFn。其中 queryKey 类似于依赖数组 deps,用于标识当前请求的唯一性。当接口依赖多个参数时,只需将参数一并加入 queryKey,即可在状态变化时自动触发重新请求。

相较于传统 useEffect + 自封装 Hook 的方式,TanStack Query 主要具备以下优势:

  • 彻底解耦请求逻辑与状态管理 传统方案需要手动维护 loadingerrordata 三种状态,组件与请求逻辑高度耦合。TanStack Query 将完整的异步生命周期统一托管,组件只需关注业务渲染本身。
  • 内置请求缓存与去重机制 当多个组件请求同一接口时,TanStack Query 会自动命中缓存并合并请求,避免重复发起网络请求,显著降低接口压力,同时减少无意义的 loading 闪烁。
  • 错误处理与重试机制标准化 内置 retryonErrorerrorBoundary 等能力,使异常处理逻辑高度统一,避免每个接口重复实现兜底逻辑。

此外,它还提供了诸如无限滚动、分页加载、Tab 切换自动刷新等大量派生 Hook,覆盖绝大多数复杂业务场景。

TanStank Query 实际业务场景

注: 在项目中使用 TanStack Query v5 时,需要先在 main.tsx 中创建一个全局唯一的 QueryClient 实例,并通过 QueryClientProvider 注入到应用根节点,使整个应用具备统一的数据缓存管理能力。

 <QueryClientProvider client={queryClient}>
     <RouterProvider
        future={{
          v7_startTransition: true,
        }}
        router={router}
      />
      <ReactQueryDevtools initialIsOpen={false} />
 </QueryClientProvider>

这样配置后,整个应用所有组件都可以共享同一份请求缓存与状态管理能力。

典型业务场景说明:

案例一: 数据获取超远程刷新

在实际项目中经常遇到这种情况:

A 组件负责请求数据并展示,B 组件有个刷新按钮,但两个组件之间层级相隔很远,甚至分布在完全不同的路由页面中。点击 B 的刷新按钮后,需要触发 A 组件重新拉取接口数据。

传统方案通常依赖:

  • 全局状态管理工具
  • EventEmitter 事件通信
  • 层层 props 传递刷新信号

这些方式都会带来额外的复杂度,并增加维护成本。

在使用 TanStack Query 后,这类问题可以被高度简化。只需要通过 queryClient.invalidateQueries 精准失效指定的 queryKey,即可触发所有使用该 queryKey 的组件自动重新请求数据,实现跨组件、跨页面的数据同步刷新。

A 组件:数据获取

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

function fetchUser() {
  return axios.get('/api/user/info');
}

export default function A() {
  const { data, isLoading } = useQuery({
    queryKey: ['userInfo'],
    queryFn: fetchUser,
  });

  if (isLoading) return <div>加载中...</div>;

  return <div>用户名:{data?.data?.name}</div>;
}

B 组件:触发刷新

import { useQueryClient } from '@tanstack/react-query';

export default function B() {
  const queryClient = useQueryClient();

  const handleRefresh = () => {
    queryClient.invalidateQueries({
      queryKey: ['userInfo'],
    });
  };

  return <button onClick={handleRefresh}>刷新用户信息</button>;
}

机制说明:

invalidateQueries 会将对应 queryKey 的缓存标记为失效状态,所有依赖该 queryKey 的组件在下次渲染时都会自动触发重新请求,从而实现数据同步刷新。这一过程无需组件之间建立任何直接通信关系。

案例二: 增删改接口操作绑定按钮状态

useQuery 负责获取数据与缓存管理,而 useMutation 负责对服务端数据产生副作用的操作,包括新增、修改、删除等写操作。

在业务语义上,请求列表、详情这类只读操作使用 useQuery,表单提交、状态变更、删除记录等操作统一交由 useMutation 管理,这种职责划分非常清晰。

import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

function createUser(data) {
  return axios.post('/api/user/create', data);
}

export default function CreateUser() {
  const queryClient = useQueryClient();

  const { mutate, isPending } = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['userList'],
      });
    },
  });

  const handleSubmit = () => {
    mutate({
      name: 'Tom',
      age: 18,
    });
  };

  return (
    <button onClick={handleSubmit} disabled={isPending}>
      {isPending ? '提交中...' : '新增用户'}
    </button>
  );
}

案例三: 更激进的请求策略

有的同学还想更进步一些

image.png

如果还想进一步提升性能与用户体验,可以将所有 GET 请求统一接入缓存体系。这样当再次请求相同 queryKey 的接口时,将直接命中内存缓存,不会重新发起网络请求,从而显著降低接口压力与页面加载时间。

在 TanStack Query 中,这一能力通过 staleTimecacheTime 进行精细控制。

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      gcTime: 30 * 60 * 1000,
      refetchOnWindowFocus: false,
      retry: 1,
    },
  },
});

这套配置表示:

  • 五分钟内数据保持新鲜状态,相同 queryKey 直接命中缓存
  • 三十分钟内缓存仍然存在于内存中,避免频繁回收
  • 页面切换、窗口聚焦不会触发自动刷新,防止无意义请求
  • 失败后仅重试一次,避免接口异常导致雪崩

当然这个实在太激进了,我的项目中未使用,因为数据变更的实时性要求比较高,可能适合一些长期不会变的字典类接口。

还有更多的乐观更新之类的高级用法,TanStack Query也封装好了好用的方法,推荐大家自行探索吧。

结语

通过本文可以看到,请求库在现代前端开发中的价值早已超出“发请求”本身,而是承担起了完整的数据生命周期管理职责。TanStack Query 在工程化、可维护性以及 DX 体验上的优势,使其成为复杂业务场景中的优选方案,希望能对你的实际开发有所启发。

告别版本焦虑:如何为 Hugo 项目定制专属构建环境

在维护公司官网的过程中,我遇到过一个典型的静态网站开发痛点:“在我的电脑上是好的,为什么在你那里就报错了?”

经过排查,罪魁祸首往往是 Hugo 版本不一致

为什么 Hugo 版本管理很重要?

Hugo 是一个更新非常频繁的开源项目,且不同版本之间(尤其是大版本更新时)经常会出现破坏性变更(Breaking Changes)。

  • 某个特定的 SCSS 函数在旧版本可用,新版本被废弃。
  • Markdown 渲染引擎的默认配置发生了改变。
  • 主题(Theme)可能只兼容特定范围的 Hugo 版本。

对于一个长期维护的项目,如果依赖开发者本地系统全局安装的 Hugo(例如通过 brew install hugo),很难保证每个人都使用完全相同的版本。更糟糕的是,当我们需要维护多个 Hugo 项目时,有的项目需要 v0.79,有的项目需要 v0.120,频繁切换系统版本简直是噩梦。

为了解决这个问题,我在现在这套项目中引入了一套项目级 Hugo 版本管理方案

解决方案:将 Hugo “关进”项目里

我的核心思路是:不要依赖系统全局的 Hugo,而是让项目自带 Hugo。

具体来说,我在项目根目录下提供了一套脚本,用于自动下载并运行指定版本的 Hugo 二进制文件。这个文件只存在于项目的 bin/ 目录下,与操作系统隔离。

1. 定义版本与安装脚本

我在 scripts/install_hugo.sh 中硬编码了项目所需的 Hugo 版本(目前是 v0.79.1 Extended)。

#!/bin/bash

# 指定 Hugo 版本
HUGO_VERSION="0.79.1"

# 根据操作系统判断下载链接
OS="$(uname -s)"
case "$OS" in
    Darwin) FILE_NAME="hugo_extended_${HUGO_VERSION}_macOS-64bit" ;;
    Linux)  FILE_NAME="hugo_extended_${HUGO_VERSION}_Linux-64bit" ;;
    *)      echo "Unsupported OS"; exit 1 ;;
esac

# 下载并解压到 bin 目录
mkdir -p bin
curl -L "https://github.com/gohugoio/hugo/releases/.../${FILE_NAME}.tar.gz" -o hugo.tar.gz
tar -xvf hugo.tar.gz -C bin

新加入的开发者只需运行一次 sh scripts/install_hugo.sh,即可在几秒钟内获得一个完全可用的构建环境,无需关心如何去 GitHub Release 页面翻找历史版本。

2. 封装启动命令

为了方便使用,我封装了 start.shbuild.sh 脚本。这些脚本会优先查找项目内的 bin/hugo,如果找不到才尝试使用系统的 hugo

start.sh (开发模式):

#!/bin/bash

HUGO="hugo"
# 优先使用项目内的 Hugo
if [ -f "./bin/hugo" ]; then
    HUGO="./bin/hugo"
fi

echo "Using Hugo: $($HUGO version)"
# 启动开发服务器
$HUGO server --cleanDestinationDir --forceSyncStatic --minify --theme book

这样,开发者只需执行 ./start.sh,就能确保使用的是经过验证的 v0.79.1 版本,完全避免了版本差异带来的渲染问题。

方案优势

  1. 环境一致性:无论是 macOS 还是 Linux,无论是本地开发还是 CI/CD 流水线,构建结果完全一致。
  2. 零干扰:项目内的 Hugo 不会污染系统环境。你可以同时开发依赖 Hugo v0.120 的新项目,互不冲突。
  3. 极速上手:新成员入职配置环境的时间从“半小时”缩短为“一条命令”。
  4. 可移植性:甚至可以将 bin/ 目录(排除在 git 外)打包拷贝到离线环境使用。

总结

技术不仅是代码的堆砌,更是工程效率的提升。通过这套简单的 Shell 脚本,我成功解决了 Hugo 版本碎片化的问题,让公司这套官网项目的维护变得更加轻松、可靠。

如果你也在维护 Hugo 站点,强烈建议尝试这种“自带电池”的管理方式!

Flutter——状态管理 Provider 详解

Flutter 中的 Provider 状态管理库,它是基于 InheritedWidget 封装的轻量级、易上手的状态管理方案,也是 Flutter 官方推荐的主流方案之一。我会从「核心概念、基本用法、进阶场景、性能优化、和原生 InheritedWidget 的对比」几个维度,由浅入深讲解,让你既能快速上手,也能理解底层逻辑。

一、Provider 是什么?

Provider 是 Flutter 生态中最流行的状态管理库之一,核心定位是:

  • 封装 InheritedWidget:解决原生 InheritedWidget 代码冗余、手动管理依赖的痛点;
  • 响应式状态管理:状态变化时,仅依赖该状态的 Widget 自动重建;
  • 轻量易用:无需复杂的设计模式(如 Bloc),新手也能快速掌握;
  • 单向数据流:状态变更逻辑集中管理,便于调试和维护。

核心优势(对比原生 InheritedWidget)

特性 原生 InheritedWidget Provider
代码量 多(需自定义子类、管理依赖) 少(一行代码封装状态)
状态更新 需手动重建 InheritedWidget 自动通知依赖 Widget
多状态管理 需嵌套多个 InheritedWidget 支持多 Provider 组合
状态复用 好(可跨页面共享)

二、Provider 核心概念

在使用 Provider 前,先理解 3 个核心类:

类名 作用
ChangeNotifier 状态载体:存储可变化的状态,提供 notifyListeners() 方法通知状态变更
ChangeNotifierProvider 状态提供者:将 ChangeNotifier 注入 Widget 树,供子 Widget 获取
Consumer/Provider.of() 状态消费者:子 Widget 中获取状态,建立依赖绑定

三、Provider 基础使用步骤(以「计数器」为例)

步骤 1:添加依赖

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.5+1  # 查看 pub.dev 获取最新版本

步骤 2:定义状态类(继承 ChangeNotifier)

这是存储状态的核心,所有可变化的状态都放在这里,状态变更时调用 notifyListeners() 通知消费者:

import 'package:flutter/foundation.dart';

// 计数器状态类
class CounterProvider extends ChangeNotifier {
  // 可变化的状态
  int _count = 0;

  // 对外暴露的只读属性(避免外部直接修改状态)
  int get count => _count;

  // 状态变更方法(集中管理逻辑)
  void increment() {
    _count++;
    // 通知所有依赖的 Widget 状态变更,触发重建
    notifyListeners();
  }

  void decrement() {
    _count--;
    notifyListeners();
  }

  void reset() {
    _count = 0;
    notifyListeners();
  }
}

步骤 3:注入 Provider 到 Widget 树

通过 ChangeNotifierProvider 将状态类注入 Widget 树,子树中所有 Widget 都能获取该状态:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    // 根节点注入 Provider(整个 App 可共享该状态)
    ChangeNotifierProvider(
      // 创建状态实例
      create: (context) => CounterProvider(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Provider 示例',
      home: const CounterPage(),
    );
  }
}

步骤 4:消费状态(3 种方式)

方式 1:Provider.of(context)(基础)

最直接的方式,获取状态并建立依赖绑定:

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    // 获取状态实例(listen: true 表示监听状态变化,默认 true)
    final counter = Provider.of<CounterProvider>(context);

    return Scaffold(
      appBar: AppBar(title: const Text('Provider 计数器')),
      body: Center(
        child: Text(
          '计数:${counter.count}',
          style: const TextStyle(fontSize: 24),
        ),
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: counter.decrement, // 调用状态方法
            child: const Icon(Icons.remove),
          ),
          const SizedBox(width: 10),
          FloatingActionButton(
            onPressed: counter.increment,
            child: const Icon(Icons.add),
          ),
        ],
      ),
    );
  }
}
方式 2:Consumer(推荐,精准重建)

Consumer 可以精准控制重建范围,避免整个页面重建(性能更优):

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    print('CounterPage 整体重建了吗?'); // 状态变化时,这里不会打印!

    return Scaffold(
      appBar: AppBar(title: const Text('Consumer 示例')),
      body: Center(
        // 仅 Consumer 包裹的部分会重建
        child: Consumer<CounterProvider>(
          builder: (context, counter, child) {
            print('Consumer 内部重建了'); // 状态变化时,这里会打印
            return Text(
              '计数:${counter.count}',
              style: const TextStyle(fontSize: 24),
            );
          },
        ),
      ),
      floatingActionButton: Consumer<CounterProvider>(
        builder: (context, counter, child) {
          return Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: [
              FloatingActionButton(
                onPressed: counter.decrement,
                child: const Icon(Icons.remove),
              ),
              const SizedBox(width: 10),
              FloatingActionButton(
                onPressed: counter.increment,
                child: const Icon(Icons.add),
              ),
            ],
          );
        },
      ),
    );
  }
}
方式 3:Selector(更精准,过滤重建)

Selector 可以指定「监听的状态属性」,只有该属性变化时才重建(性能最优):

// 示例:仅当 count 为偶数时才重建
child: Selector<CounterProvider, bool>(
  // 选择要监听的属性(count 是否为偶数)
  selector: (context, counter) => counter.count % 2 == 0,
  builder: (context, isEven, child) {
    return Text(
      '计数:${Provider.of<CounterProvider>(context).count}\n是否偶数:$isEven',
      style: const TextStyle(fontSize: 24),
      textAlign: TextAlign.center,
    );
  },
),

四、Provider 进阶用法

1. 多状态管理(MultiProvider)

当需要注入多个状态类时,用 MultiProvider 避免嵌套:

// 定义第二个状态类(用户信息)
class UserProvider extends ChangeNotifier {
  String _userName = '张三';
  String get userName => _userName;

  void updateName(String name) {
    _userName = name;
    notifyListeners();
  }
}

// 注入多个 Provider
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CounterProvider()),
        ChangeNotifierProvider(create: (context) => UserProvider()),
      ],
      child: const MyApp(),
    ),
  );
}

// 消费多个状态
class MultiStatePage extends StatelessWidget {
  const MultiStatePage({super.key});

  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<CounterProvider>(context);
    final user = Provider.of<UserProvider>(context);

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('计数:${counter.count}'),
            Text('用户名:${user.userName}'),
            ElevatedButton(
              onPressed: () => user.updateName('李四'),
              child: const Text('修改用户名'),
            ),
          ],
        ),
      ),
    );
  }
}

2. 局部状态管理(页面内共享)

若状态仅在某个页面内共享,只需在该页面的 Widget 树中注入 Provider:

class LocalStatePage extends StatelessWidget {
  const LocalStatePage({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => CounterProvider(), // 仅该页面可用
      child: Scaffold(
        appBar: AppBar(title: const Text('局部状态')),
        body: const LocalCounterWidget(),
      ),
    );
  }
}

// 子 Widget 获取局部状态
class LocalCounterWidget extends StatelessWidget {
  const LocalCounterWidget({super.key});

  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<CounterProvider>(context);
    return Text('局部计数:${counter.count}');
  }
}

五、性能优化技巧

  1. 缩小重建范围:优先使用 Consumer/Selector,避免用 Provider.of 导致整个 Widget 重建;

  2. 避免不必要的 notifyListeners () :仅状态真的变化时调用(如判断 _count 变化后再调用);

  3. 使用 lazy 初始化ChangeNotifierProviderlazy: true(默认),仅当首次消费时才创建状态实例;

  4. dispose 释放资源:若状态类持有网络 / 定时器等资源,需重写 dispose

    class TimerProvider extends ChangeNotifier {
      late Timer _timer;
      int _seconds = 0;
    
      TimerProvider() {
        _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
          _seconds++;
          notifyListeners();
        });
      }
    
      // 释放定时器资源
      @override
      void dispose() {
        _timer.cancel();
        super.dispose();
      }
    }
    
  5. 避免在 build 中创建状态:始终在 create 中创建,而非 build 方法(否则会重复创建)。

六、Provider 常见坑点

  1. context 范围问题:在注入 Provider 的同一层级,无法直接用 Provider.of 获取状态(需用 Builder 包裹);

    dart

    // 错误示例:context 是 MyApp 的 context,无法获取 Provider
    ChangeNotifierProvider(
      create: (context) => CounterProvider(),
      child: Text('${Provider.of<CounterProvider>(context).count}'), // 报错
    );
    
    // 正确示例:用 Builder 切换 context
    ChangeNotifierProvider(
      create: (context) => CounterProvider(),
      child: Builder(
        builder: (context) {
          return Text('${Provider.of<CounterProvider>(context).count}');
        },
      ),
    );
    
  2. listen: false 慎用Provider.of<T>(context, listen: false) 仅获取状态,不建立依赖,状态变化时不会重建;

  3. 多 Provider 命名冲突:若有多个同类型 Provider,需用 ProviderScopeConsumerselector 区分。

总结

  1. 核心定位:Provider 是 InheritedWidget 的优雅封装,主打「轻量、易用、响应式」,适合中小规模 App 的状态管理;
  2. 核心流程:定义 ChangeNotifier 状态类 → 用 ChangeNotifierProvider 注入 → 用 Consumer/Selector 消费;
  3. 性能关键:缩小重建范围(Consumer/Selector)、避免不必要的 notifyListeners()、及时释放资源;
  4. 适用场景:全局状态(用户信息、主题)、页面内局部状态(表单数据、计数器),不适合超复杂的状态逻辑(可换 Bloc/Riverpod)。

Three.js 零基础入门:手把手打造交互式 3D 几何体展示系统

一、搭建舞台——“三剑客”的诞生

在 3D 世界里,所有的表演都需要一个舞台。Three.js 遵循“三剑客”原则:场景、相机、渲染器

在你的代码开头,我们看到了这样的设定:

// 1. 场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);

// 2. 相机
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 40;

// 3. 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
const viewContainer = document.getElementById('view');
renderer.setSize(viewContainer.clientWidth, viewContainer.clientHeight);
viewContainer.appendChild(renderer.domElement);

解析:

  1. 场景:这就好比是一个空荡荡的剧院舞台。我们把它涂成了深灰色 (0x222222),方便看清演员。
  2. 相机:观众的眼睛。这里用的是 PerspectiveCamera(透视相机)。
    • 40 是视野角度(FOV),就像你睁大眼睛还是眯着眼睛看。
    • window.innerWidth / window.innerHeight 是长宽比,保证画面不变形。
    • camera.position.z = 40:这一步很关键!默认情况下,物体在坐标原点 (0,0,0),相机也在原点。如果你不移开相机,你就跟物体“贴脸”了,什么也看不见。我们把相机往后拉 40 米,就能看清全貌了。
  3. 渲染器:剧院的灯光和特效组。它负责把脑海中的画面画到屏幕上。
    • { antialias: true }:开启了抗锯齿,让边缘光滑,不再有毛刺。

二、点亮世界——没有光,一切都是黑的

如果你直接把物体放进去,可能会发现一片漆黑。为什么?因为在 3D 世界里,除了特殊的“基础材质”,大多数材质都需要光才能被看见。

代码里添加了两个光源:

{
    const light = new THREE.DirectionalLight(0xFFFFFF, 1);
    light.position.set(-1, 2, 4);
    scene.add(light);
}
// ... 还有一个方向相反的光

这里用的是 平行光,模拟太阳光。

  • 第一盏灯:位置在左上方 (-1, 2, 4),负责照亮物体的正面。
  • 第二盏灯:位置在右下方 (1, -2, -4),负责照亮物体的背面或暗部。

为什么要两盏灯? 这是一种低成本的“补光”技巧,防止物体背光面死黑一片,让立体感更强。


三、万物之源——几何体与材质

在 3D 世界里,一个物体 = 几何体 + 材质

  • 几何体:骨架,决定了形状(是方的、圆的,还是扭曲的)。
  • 材质:皮肤,决定了外观(是金属的、塑料的,还是半透明的)。

代码中定义了一个 primitives 对象,里面藏着 20 多种不同的几何体生成函数。让我们挑几个常用模型进行分析:

1. 基础款:方块与球体

'BoxGeometry': () => {
    const width = 8, height = 8, depth = 8;
    addSolidGeometry(new THREE.BoxGeometry(width, height, depth));
},
'SphereGeometry': () => {
    // ...
    addSolidGeometry(new THREE.SphereGeometry(radius, widthSegments, heightSegments));
},

这是最基础的构建模块。BoxGeometry 就像捏泥人时的方块,SphereGeometry 则是球体。注意 widthSegments 参数,它决定了球体的精细度——段数越多,球越圆,但计算量也越大。

2. 进阶款:挤压与车削

这可是把 2D 变成 3D 的魔法!

  • ExtrudeGeometry(挤压几何体): 代码里画了一个爱心形状的 2D 路径,然后给它一个厚度,它就变成了一个 3D 的爱心。

    'ExtrudeGeometry': () => {
        const shape = new THREE.Shape();
        // ... 画 2D 路径 ...
        const extrudeSettings = { depth: 2, bevelEnabled: true, ... };
        addSolidGeometry(new THREE.ExtrudeGeometry(shape, extrudeSettings));
    },
    

    想象一下,你用饼干模具在面团上按了一下,这就是 ExtrudeGeometry 做的事。

  • LatheGeometry(车削几何体): 这就像陶艺转盘。你定义好一个侧面的轮廓线,它旋转一圈就变成了一个罐子。

    'LatheGeometry': () => {
        const points = []; // 一系列二维点
        // ...
        addSolidGeometry(new THREE.LatheGeometry(points));
    },
    

3. 数学之美:参数化几何体与克莱因瓶

代码里最“不明觉厉”的部分来了:

function klein(v, u, target) {
    // ... 一堆复杂的数学公式 ...
}

'ParametricGeometry': () => {
    addSolidGeometry(new ParametricGeometry(klein, slices, stacks));
},

ParametricGeometry 允许你用数学公式来定义形状。这里的 klein 函数生成了一个著名的数学模型——克莱因瓶。它是一个没有“内”和“外”之分的奇怪瓶子。对于初学者,你只需要知道:只要你能写出 x, y, z 的方程,Three.js 就能帮你画出模型。

4. 文字 3D 化

'TextGeometry': () => {
    const loader = new FontLoader();
    const font = loader.parse(local_font);
    const textGeometry = new TextGeometry('three.js', { font, size: 3, ... });
    // ...
},

想让网页显示立体的 "Hello World"?你需要加载字体文件,然后 TextGeometry 会帮你把文字变成 3D 模型,甚至还能加倒角让文字更有质感。


每种基础元件的详细介绍会在后续文章介绍

核心代码与完整示例:     my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

Claude Code 踩坑实录:我流的泪,你别再流

Claude Code (CC) 是 Anthropic 官方推出的 CLI 工具。用了大半个月,它确实能提升效率,但默认配置比较繁琐。这里总结一些让它更顺手的配置和工作流建议。

基础环境配置

/init

老生常谈了,大家都知道

用来初始化项目架构和规范,适用于新的项目或者新的模块。

1. 跳过繁琐的权限确认

image.png

CC 默认的安全机制很严,每条命令都要问 y/n,非常打断心流。建议直接在 .zshrc.bashrc 中加个别名:

echo 'alias cc="claude --dangerously-skip-permissions"' >> ~/.zshrc
source ~/.zshrc

以后敲 cc 就能直接执行。当然,执行删除或破坏性操作时自己要看着点。

2. 调整回车换行

默认回车是发送,很容易手抖把没写完的 Prompt 发出去。

运行 /terminal-setup (需要关闭所有终端,重新打开才生效)。后续打开cc, 可以在按下 Option + Enter时插入新行,而不是发送 Prompt。

发送还是按 Enter

image.png

3. 多会话区分标记

image.png

如果同时开几个 CC 终端(比如一个修 Bug,一个写文档),很容易切错窗口。 使用 /color 指令给不同用途的会话标记不同颜色,一眼就能区分。

4. 监控 Token

image.png

可以安装 claude-hud 插件或者习惯性输入 /status。主要是关注 Context 占用量,心里有数,避免 Token 用量意外爆炸。

项目地址:claude-hud

安装方式也很简单,打开cc, 会话中依次执行:

/plugin marketplace add jarrodwatts/claude-h

/plugin install claude-hud

/claude-hud:setup

无需重启cc即可生效。

让 Claude 更懂项目

1. CLAUDE.md

这是最重要的一点。在项目根目录创建 CLAUDE.md,把架构、代码规范、常用命令写进去。 Claude 启动时会读取它。这能大幅减少"它写的代码风格和我不一致"的问题,省去很多 Review 和纠正的时间。

小技巧:

  • 不要大篇幅去写它,可以把细分的rules总结到 .claude/rules 文件夹中。然后在CLAUDE.md中引用它们即可。

  • 可以让它在会话过程中必须全程尊称您为"哥们",而不是"用户", 当你发现它不再称呼您为"哥们"时,说明它已经开始降智了,此时记得关闭并重新启动新的会话。

image.png

2. 让cc记住常用的工作区目录

由于/add-dir 只能针对单独的会话使用,所以如果想在多个会话中共享项目目录,需要每个会话都执行一次,很麻烦.

可以直接修改 .claude/settings.local.json 文件,添加常用的工作区目录:

  • settings.local.json 示例
{
  "permissions": {
    "additionalDirectories": [
      "/Users/xxx/apps/work/xxx-project"
    ]
  }
}

然后重新打开新的cc会话,无需再次 /add-dir 添加项目目录了。

注意,.gitignore文件需要添加

.claude/*.local.*
*.local.*

节省 Token 的习惯

0. 能给明确指令,就别含糊其词

  • 使用 @ 指令,指定文件路径,而不是直接输入文件内容。
  • 明确告知cc不能做什么,怎么样做。
  • 使用todol列表的形式指导cc完成任务。

1. 及时清除上下文 (/clear)

不要在同一个会话里处理互不相干的任务。

修完 Bug A 转去做 Feature B 时,务必执行 /clear。清理无关上下文不仅省钱,更能减少模型幻觉,让 Claude 聚焦在当前任务上。

2. 60% 警戒线

/compact 提示出现,或者感觉到 Context 消耗超过 60% 时,Claude 的响应会变慢,智商也会下降。建议直接关闭并开启新的会话。

3. 使用 ! 执行简单命令

ls, pwd, git status 这种不需要 LLM 思考的命令,直接以 ! 开头输入(如 !ls -la)。

这样做,第一:不需要退出会话,第二:完全不消耗 Token,第三:也没必要让 cc帮你解释一遍。

比如执行 !git status ,可以直接查看当前项目的git状态。

image.png

进阶工作流

1. 任务队列

可以在会话中执行时候,继续输入新的任务,会陆续进入任务队列中,等待前一个任务完成后再执行。

image.png

2. 快速回滚 (/rewind)

如果 Claude 把代码改崩了,或者上下文聊乱了,直接 /rewind 回滚到之前的对话状态。这比手动 git checkout 或者告诉它"你改错了"要快得多。

3. IDE 联动

  • /ide的使用

    /ide 可以生成链接到某个IDE,比如vscode / trae。

    这样的好处是,可以自动定位当前active的文件,不用手动@文件路径。

    也可以选中某个文件中若干代码,cc会自动把选中的代码关联到当前会话中。

  • /diff的使用

    通过 /diff 直接在终端快速扫一眼变更,可以快速review。或者直接:ghottsy + Lazygit + yazi(后面会写文章单独介绍)

image.png

4. Vim 模式

嫌命令行输入框太小、编辑长 Prompt 不方便的话,输入 /vim 进入 Vim 模式, 直接使用vim快捷键进行编辑。

5. /sandbox指令

/sandbox 是个啥?

简单说,就是给 Claude 加上的一道安全围栏。

没开沙箱的时候,Claude 每跑个命令都得弹窗问你“允许吗?”,稍微复杂点的任务能把你烦死。开了沙箱,它就能在安全范围内自己干活,不用动不动就打断你。

它主要防两件事:

  1. 乱动文件: Claude 只能读写你当前的项目目录。你电脑里的其他东西(像 ~/.ssh/ 密钥或者系统配置)它是碰不到的。
  2. 乱连网: 它只能访问你批准过的域名。想连新的?得先问你。

怎么选模式? 一般直接选 Auto-Allow 就行,沙箱内的安全命令它自己就跑了,适合想快点干完活的人。如果你控制欲比较强,想每条命令都亲自过目,那就选普通模式。

怎么用? 敲 /sandbox 就能呼出菜单。 macOS 直接能用。Linux 或 WSL2 得装俩包:bubblewrap 和 socat。

会影响开发吗? 基本没感觉。平时跑个 flutter run、git status 啥的都在项目里,沙箱不会拦着。它主要防的是万一你装了个带毒的 npm 包,或者 Claude 脑抽了想执行个 rm -rf /,沙箱能把爆炸范围控制在项目文件夹里,不至于把你整个系统带走。

一句话:开了它,Claude 干活更利索,你也更放心它不会越界瞎搞。

6. 让cc自我总结

  • /insights

结合你的会话,cc会自我总结,并提供相关优化建议,生成的报告在:~/.claude/usage-data/report.html

报告主要包含:概述、你的工作内容、如何使用 Claude Code、你做的那些令人印象深刻的事、哪里出了问题、现有 CC 功能可供尝试、使用 Claude Code 的新方法 等内容。

其中最值得关注的是:哪里出了问题、现有 CC 功能可供尝试,可以帮我改善cc的使用。

  • /export 导出会话

适用于分享(比如做了一个特别有意思的功能/工具/演示demo,需要分享的时候)

  • find-skills

find-skills

可以查找你感兴趣的技能。

  • planning-with-files

planning-with-files

cc内置的plan模式是不会自动导出计划的,当你开启新的会话后,上下文会丢失

planning-with-files会把你的工作流变成使用持久化的 Markdown 文件来进行规划、进度跟踪和知识存储——这正是让 Manus 市值数十亿美元的模式。

高级功能

创建自定义的skill

比如通过 /skill-creator 创建一个auto-image-upload skill, 这样再也不用手动切图传 COS 了

具体请戳: 我写了个 Claude Code Skill,再也不用手动切图传 COS 了

sugagent

俗称智能体,每个智能体都在单独的上下文中运行,互不干扰。

我们可以创建一个sugagent,来帮我们自动完成一些任务, 比如自动读取figma设计稿,还原代码。

具体实践可以看cc官方文档: 创建一个sugagent

custom command

比如我们可以创建一个 /git-push 指令,来自动提交并推送当前改动,生成结构清晰的 commit message。

同时可以设置一些前置条件,比如:

  • 必须有 unstaged 或 staged 改动
  • 提交前先运行lint检查
  • 提交前先执行git pull,如果有冲突则停止,并告知用户

hooks使用

比如在Stop的时候执行代码 lint检查,确保代码质量。

~/settings.local.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "npm run lint"
          }
        ]
      }
    ]
  }
}

mcp的使用

mcp 是 claude code 可以访问外部服务的一个协议,通过它可以调用外部服务,比如:figma-mcp, chrome-devtools-mcp, dart mcp 等。

实战应用请看我的文章:一句话生成整套 API:我用 Claude Code 自定义 Skill + MCP 搞了个接口代码生成器

agent team的使用

这是真正的并行任务,多个会话可以同时运行,互不干扰。使用其实很简单,比方说:

请创建一个2人团队,分别查询nuxtjs和nextjs的官方文档,总结出它们的使用区别。

实战应用请看我的文章:一句话生成整套 API:我用 Claude Code 自定义 Skill + MCP 搞了个接口代码生成器

image.png

claudecode --worktree的使用

和agent team不同的是,claudecode --worktree 是单独的指令,只能开启一个会话,并且会创建一个worktree目录,用来存储会话的代码, 默认保存在.claude/worktrees目录下。

其实git worktree早就有了,只是cc将其集成到了cli中,用户使用时是无感知的。

worktree可以用来并行执行互不干扰的需求功能。

image.png

claudecode --worktree --tmux 可以开启一个会话,并且配合agent team自动拆分会话窗口(效果参考:agent team的使用一节中的图片)。

推荐个好用的终端软件

cmux,用过ghottsy的人就会明白它的优势(并且任务完成后它是有消息通知的)。直接上一张图,自行体会~


image.png

右侧是两个插件分别是:lazygit / yazi

lazygit: github.com/jesseduffie…

yazi: github.com/sxyazi/yazi

需要订阅的功能

  1. /chrome — 可以用 chrome-devtools-mcp 代替
  2. /desktop — 用桌面应用继续当前会话
  3. /login 和 /logout
  4. claude remote-control

总结

Claude Code 本质上是一个可以通过自然语言编程的终端环境。新手建议先配好 alias 和回车设置,习惯用 /clear 控制上下文;进阶的话,重点在于维护好 CLAUDE.md,把它当成一个需要 onboarding 的新员工来对待。

附一些坑(持续更新):

  1. 有时候会遇到对话框无法输入,按ctrl+shift+v即可
  2. 在开始提问前,最好通过/mcp查看所有mcp是否可用,免得因为部分连接失败导致任务失败

Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记

作为一名前端开发,最近接到了一个「划词取词」的需求 —— 老板希望做一个类似豆包、有道词典的划词识别功能,核心要求是低成本、离线可用、Windows 平台优先。整个开发过程一波三折,从 AI 生成的「截屏 + AI 识别」方案,到离线 OCR,最后落地到「划词 + Ctrl+C + 命名管道通信」,踩了不少坑,也积累了一些实战经验,特此记录。

需求背景

核心诉求:用户在任意窗口(浏览器、文档、办公软件等)用鼠标划选文字后,能快速获取选中的文本内容,用于后续的翻译 / 解释等操作,要求:

  • 离线运行,无网络依赖;
  • 仅支持 Windows 系统(公司主流办公环境);
  • 低成本(避免调用付费 OCR/AI 接口);
  • 尽可能不干扰用户原有操作。

三版方案的迭代之路

第一版:截屏 + AI 识别(被打回)

最初想着「快速搞定」,直接让 AI 生成了一份 Python 代码:监听鼠标按下 / 抬起的坐标,截取对应区域的屏幕截图,然后调用 AI 接口识别图片中的文字。

代码核心逻辑是用PIL.ImageGrab截屏,再通过 base64 传给 AI 接口:

# 第一版核心(简化)
def on_click_up(x, y, button, pressed):
    if not pressed:
        # 计算鼠标划选区域
        left = min(last_x, x)
        top = min(last_y, y)
        right = max(last_x, x)
        bottom = max(last_y, y)
        # 截屏
        img = ImageGrab.grab(bbox=(left, top, right, bottom))
        # 调用AI接口识别
        img_base64 = base64.b64encode(img_bytes).decode()
        res = requests.post(AI_API, json={"image": img_base64})
        text = res.json()["text"]
        print("识别的文字:", text)

问题:老板看到 AI 接口的调用成本后直接打回 —— 按公司的使用量,每月要额外支出数千元,完全不符合「低成本」要求。

第二版:离线 OCR(放弃)

既然 AI 接口不能用,那就换离线 OCR(比如 Tesseract)。但实际测试后发现:

  • 不同字体、字号、背景色下,OCR 准确率极低(尤其是小字体 / 模糊文字);
  • 需要用户额外安装 OCR 引擎,部署成本高;
  • 对截图的分辨率、区域裁剪要求极高,适配成本高。

最终因为「准确率达不到老板预期」,这个方案也被放弃了。

第三版:划词 + Ctrl+C + 跨进程通信(最终落地)

某天突然想到:用户划选文字后,系统本身已经把选中的内容「暂存」了,只要调用Ctrl+C复制,就能直接从剪贴板拿到文本 —— 这才是最直接、零成本、准确率 100% 的方案!

核心思路:

  1. Python 脚本监听鼠标划选动作(按下→拖动→抬起);
  2. 判定为有效划词后,自动触发Ctrl+C复制选中内容;
  3. 从剪贴板读取文本,通过「命名管道」传给 Electron 主进程;
  4. Electron 接收数据后,再分发给渲染进程做后续处理。

技术实现拆解

最终方案分为「Python 端(监听 + 复制 + 通信)」和「Electron 端(管道服务 + 数据处理)」两部分,核心依赖 Windows 的「命名管道(Named Pipe)」实现跨进程通信。

1. Python 端:监听划词并发送数据

Python 负责核心的「人机交互监听」和「剪贴板操作」,使用pynput监听鼠标 / 键盘,pyperclip操作剪贴板,win32file实现命名管道通信。

核心逻辑

from pynput import mouse, keyboard
import pyautogui
import pyperclip
import win32file
import pywintypes
import json
import win32gui
import win32process
import psutil

class ClipboardMonitor:
    def __init__(self):
        self.last_mouse_down_time = 0
        self.last_mouse_down_position = (0, 0)
        self.last_user_clipboard_content = None  # 保存用户原有剪贴板内容
        self.keyboard_activity = False  # 避免键盘操作干扰

    # 监听鼠标按下:记录起始位置+发送坐标给Electron
    def on_click_down(self, x, y, button, pressed):
        if pressed:
            self.last_mouse_down_position = (x, y)
            # 发送鼠标按下坐标给Electron(用于判断是否在目标窗口内)
            message = f"click_down_mouse_position:{x},{y}"
            self.send_to_electron(message)
            # 记录当前聚焦的应用(用于过滤禁用列表)
            self.last_mouse_down_client = self.get_focused_application()

    # 监听鼠标抬起:判定有效划词并复制
    def on_click_up(self, x, y, button, pressed):
        if not pressed:
            # 计算鼠标拖动距离(过滤误点击)
            distance = ((x - self.last_mouse_down_position[0]) **2 + (y - self.last_mouse_down_position[1])** 2) **0.5
            # 有效划词:距离>10px + 无键盘/鼠标干扰
            if distance > 10 and not self.keyboard_activity:
                # 检查配置:是否允许打开悬浮窗、当前应用是否在禁用列表
                if self.check_can_open_float_win() and self.last_mouse_down_client not in self.get_disable_client_list():
                    # 保存用户原有剪贴板内容(避免覆盖)
                    self.last_user_clipboard_content = pyperclip.paste()
                    # 自动触发Ctrl+C复制选中内容
                    pyautogui.hotkey('ctrl', 'c')
                    new_clipboard_content = pyperclip.paste()
                    # 对比剪贴板:确认为新选中的内容
                    if new_clipboard_content != self.last_user_clipboard_content:
                        # 封装数据并发送给Electron
                        self.send_clipboard_data(x, y, new_clipboard_content)
                    # 还原用户剪贴板(核心!避免干扰用户)
                    pyperclip.copy(self.last_user_clipboard_content)

    # 获取当前聚焦的应用名称(用于过滤)
    def get_focused_application(self):
        hwnd = win32gui.GetForegroundWindow()
        _, pid = win32process.GetWindowThreadProcessId(hwnd)
        try:
            process = psutil.Process(pid)
            return process.name()
        except:
            return "Unknown"

    # 命名管道发送数据给Electron
    def send_to_electron(self, message):
        pipe_name = r'\.\pipe\quick_word_electron_python_pipe'
        try:
            handle = win32file.CreateFile(
                pipe_name,
                win32file.GENERIC_WRITE,
                0,
                None,
                win32file.OPEN_EXISTING,
                0,
                None
            )
            win32file.WriteFile(handle, message.encode())
            win32file.CloseHandle(handle)
        except pywintypes.error as e:
            print(f"管道通信失败:{e}")

    # 启动监听
    def start(self):
        mouse_listener_down = mouse.Listener(on_click=self.on_click_down)
        mouse_listener_up = mouse.Listener(on_click=self.on_click_up)
        keyboard_listener = keyboard.Listener(on_press=self.on_key_press, on_release=self.on_key_release)
        mouse_listener_down.start()
        mouse_listener_up.start()
        keyboard_listener.start()
        mouse_listener_down.join()

if __name__ == "__main__":
    monitor = ClipboardMonitor()
    monitor.start()

关键细节

  • 剪贴板还原:必须保存用户原有剪贴板内容,复制后还原,否则会干扰用户操作;
  • 应用过滤:读取配置文件中的「禁用应用列表」,避免在指定应用内触发划词;
  • 误触过滤:通过鼠标拖动距离、键盘活动状态,过滤点击、误拖动等无效操作。

2. Electron 端:命名管道服务 + Python 管理

Electron 作为主进程,负责:

  • 启动 / 管理 Python 脚本;
  • 创建命名管道服务,接收 Python 发送的数据;
  • 处理数据并分发给渲染进程。

第一步:封装命名管道服务(namedPipeServer.js)

基于 Node.js 的net模块实现 Windows 命名管道服务,支持连接队列(避免并发问题):

const net = require('net');

class NamedPipeServer {
  constructor(pipeName, cb) {
    this.pipeName = pipeName;
    this.server = null;
    this.maxConnections = 10; // 最大连接数
    this.currentConnections = 0;
    this.connectionQueue = [];
    cb(this)
  }

  // 启动管道服务
  start(onDataCallback) {
    this.server = net.createServer((socket) => {
      // 连接数控制:超出则加入队列
      if (this.currentConnections >= this.maxConnections) {
        this.connectionQueue.push(socket);
      } else {
        this.currentConnections++;
        this.handleConnection(socket, onDataCallback);
      }
    });

    this.server.on('error', (err) => {
      console.error(`管道服务错误:${err.message}`);
    });

    // 监听命名管道
    this.server.listen(this.pipeName, () => {
      console.log(`命名管道监听中:${this.pipeName}`);
    });
  }

  // 处理连接:接收数据
  handleConnection(socket, onDataCallback) {
    socket.on('data', (data) => {
      const message = data.toString().trim();
      onDataCallback(message); // 回调处理数据
    });

    // 连接断开:复用队列中的连接
    socket.on('end', () => {
      this.currentConnections--;
      if (this.connectionQueue.length > 0) {
        this.handleConnection(this.connectionQueue.shift(), onDataCallback);
      }
    });

    socket.on('error', (err) => {
      console.error(`Socket错误:${err.message}`);
    });
  }

  // 停止管道服务
  stop() {
    if (this.server) {
      this.server.close(() => {
        console.log("命名管道服务已关闭");
      });
    }
  }
}

module.exports = { NamedPipeServer };

第二步:初始化 Python 环境 + 管道通信(quickWordLookup.js)

Electron 启动时,自动解压 Python 环境(避免用户手动安装),启动命名管道,再调用 Python 脚本:

const AdmZip = require("adm-zip");
const { NamedPipeServer } = require('./namedPipeServer');
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs');

class QuickWordLookup {
    constructor() {
        this.platform = process.platform;
        this.env = process.env.NODE_ENV || "production";
    }

    // 初始化Python环境+命名管道
    initPython() {
        if (this.platform !== "win32") return;

        // 1. 解压Python环境(打包在应用内的zip包)
        const pluginsPath = this.env === "development" 
            ? path.join(app.getAppPath(), 'plugins') 
            : process.resourcesPath;
        const pythonZipPath = path.join(pluginsPath, "vendors", "python3.11.zip");
        this.pythonDirPath = path.join(pluginsPath, "vendors", "python3.11");
        
        if (!fs.existsSync(this.pythonDirPath)) {
            const zip = new AdmZip(pythonZipPath);
            zip.extractAllTo(this.pythonDirPath, true); // 解压
        }

        // 2. 创建命名管道服务
        const pipeServer = new NamedPipeServer(
            '\\.\pipe\quick_word_electron_python_pipe', 
            () => {
                console.log("管道服务启动成功,启动Python脚本");
                this.openPythonExe(); // 管道就绪后启动Python
            }
        );

        // 3. 处理Python发送的数据
        pipeServer.start((message) => {
            if (message.startsWith("click_down_mouse_position:")) {
                // 处理鼠标按下坐标(判断是否在目标窗口内)
                const [x, y] = message.slice("click_down_mouse_position:".length).split(",").map(Number);
                const isInside = this.handleMousePosition(x, y);
                if (!isInside) return;
            } else if (message.startsWith("messgae_to_send:")) {
                // 处理划词内容:发给渲染进程
                const data = JSON.parse(message.slice("messgae_to_send:".length));
                this.sendToRenderer(data);
            }
        });
    }

    // 启动Python脚本
    openPythonExe() {
        if (this.platform !== "win32") return;
        const exePath = path.join(this.pythonDirPath, 'python.exe');
        // Python脚本路径(打包在应用内)
        const tempFilePath = this.env === "development" 
            ? path.join(__dirname, "../../public/python/underlineWord.py") 
            : path.join(process.resourcesPath, "vendors", "python/underlineWord.py");
        
        const cmd = `"${exePath}" "${tempFilePath}"`;
        exec(cmd, { encoding: 'utf-8' }, (error, stdout, stderr) => {
            if (error) {
                console.error(`Python启动失败:${error.message}`);
            } else {
                console.log("Python划词监听已启动");
            }
        });
    }

    // 发送数据到渲染进程
    sendToRenderer(data) {
        // 主进程→渲染进程通信(根据Electron版本调整)
        const mainWindow = BrowserWindow.getFocusedWindow();
        if (mainWindow) {
            mainWindow.webContents.send('word-lookup-data', data);
        }
    }
}

踩坑总结

  1. 命名管道的跨进程通信

    • Windows 命名管道路径格式必须是\\.\pipe\xxx,Node.js 的net模块需适配这个格式;
    • 必须保证「管道服务先启动,Python 再连接」,否则会出现连接失败;
    • 处理连接并发:添加连接队列,避免多客户端同时连接导致的异常。
  2. 剪贴板操作的坑

    • 直接调用pyautogui.hotkey('ctrl', 'c')在部分应用(如某些加密文档)中无效,需备用方案(win32api.SendMessage发送 WM_COPY 消息);
    • 必须还原用户原有剪贴板内容,否则会引发用户投诉。
  3. Python 环境打包

    • 将 Python 解释器 + 依赖包打包成 zip,Electron 启动时自动解压,避免用户手动安装;
    • 开发 / 生产环境的路径差异:需区分app.getAppPath()process.resourcesPath
  4. 应用兼容性

    • 不同应用的「划词 + 复制」逻辑不同(如某些游戏 / 加密软件屏蔽 Ctrl+C),需做兼容处理;
    • 通过psutil获取当前聚焦应用,支持「禁用应用列表」配置。

优化方向

  1. 增加 Python 进程守护:监控 Python 脚本是否崩溃,自动重启;
  2. 支持更多快捷键:除了鼠标划词,支持用户自定义快捷键触发;
  3. 剪贴板内容过滤:过滤空内容、特殊字符,提升体验;
  4. 跨平台适配:后续可扩展 macOS(使用 Unix 域套接字替代命名管道)。

总结

这次需求从「AI 生成快速方案」到「落地可用」,核心是回归「用户操作的本质」—— 划词后系统本身已有选中内容,无需复杂的截屏 / OCR,只需「借力」系统剪贴板 + 跨进程通信即可搞定。

技术选型上,Electron 负责界面和进程管理,Python 负责底层的系统事件监听,两者通过命名管道高效通信,既满足了离线、低成本的要求,又保证了准确率和用户体验。

这个案例也让我明白:有时候最有效的方案,往往不是最「高科技」的,而是最贴合用户操作习惯、最利用现有系统能力的。

I/O 多路复用:从浏览器到 Linux 内核

为什么前端工程师需要理解这个?

你写的每一行 fetch()、每一个 WebSocket 连接、每一次 Node.js 的文件读取,背后都在依赖同一套机制。

从 V8 / Node.js 说起

浏览器和 Node.js 都是单线程 + 事件循环模型:

┌─────────────────────────────────────────────────┐
│                  JavaScript 单线程               │
│                                                 │
│   fetch() → Promise → .then()                   │
│   setTimeout() → callback                       │
│   WebSocket.onmessage → handler                 │
└───────────────┬─────────────────────────────────┘
                │ 所有 I/O 都是异步的
                ▼
┌─────────────────────────────────────────────────┐
│              libuv(Node.js 的异步核心)           │
│                                                 │
│   事件循环 → 调用操作系统 I/O 接口               │
│                                                 │
│   Linux:   epoll_wait()                         │
│   macOS:   kqueue()                             │
│   Windows: IOCP                                 │
└─────────────────────────────────────────────────┘

核心矛盾:JS 是单线程的,但现实世界的 I/O 是并发的。
浏览器同时打开 100 个 WebSocket,不可能为每个连接开一个线程——你需要用一个线程监听 100 个 fd,谁有数据就处理谁。

这就是 I/O 多路复用要解决的问题。


三种机制的演进

历史上出现了三种方案,一代比一代好,解决同一个问题:如何高效地同时监听多个文件描述符(fd)?

1983  select    位图轮询,上限 1024
1986  poll      数组轮询,解除上限
2002  epoll     事件驱动,彻底告别 O(n)

select — 最原始的方案

数据结构

fd_set readfds;   // 本质是 1024 位的 bitmap
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);

int ready = select(max_fd + 1, &readfds, NULL, NULL, NULL);
// 返回后必须手动遍历 0~max_fd,逐个 FD_ISSET() 检查

调用全流程

用户态                           内核态
  │                                │
  ├─ 构造 fd_set bitmap ──────────►│
  │                                ├─ 全量拷贝 bitmap(O(n) 内存拷贝)
  │                                ├─ 逐个遍历 fd,调用驱动 poll()(O(n))
  │  [进程挂起等待]                 ├─ 某 fd 就绪 → 标记 bitmap
  │                                ├─ 全量拷贝 bitmap 回用户态(O(n) 内存拷贝)
  │◄───────────────────────────────┤
  ├─ 再次遍历所有 fd(O(n))        │
  │  FD_ISSET() 逐一检查           │

致命缺陷

问题 原因
fd 上限 1024 fd_set 是定长 bitmap,FD_SETSIZE = 1024
每次两次全量拷贝 bitmap 从用户态 → 内核态 → 用户态
两次 O(n) 遍历 内核遍历 + 用户态遍历,n = max_fd
fd_set 被内核改写 每次调用前必须重置,不能复用

poll — 改良版,解除上限

数据结构

struct pollfd {
    int   fd;       // 文件描述符
    short events;   // 关注的事件(用户填,不会被改写)
    short revents;  // 实际发生的事件(内核回写)
};

struct pollfd fds[1000];
int ready = poll(fds, 1000, -1);
// 返回后遍历 fds[],检查 fds[i].revents != 0

相比 select 改进了什么

select 的问题              poll 的解法
─────────────────────────────────────────────
fd 上限 1024         →    pollfd 数组,理论无上限
fd_set 被内核改写    →    events / revents 分离,events 不变
三组 bitmap 混乱     →    events 位掩码,更清晰

没有解决的问题

                    select        poll
全量内存拷贝          ✗             ✗    ← 每次都要拷贝整个数组
内核 O(n) 遍历        ✗             ✗    ← 逐个检查驱动 poll()
用户态 O(n) 遍历      ✗             ✗    ← 还是要自己循环找就绪的

poll 只是 select 的"形状更好的版本",性能瓶颈的根源没变:连接越多越慢,活跃率越低越浪费


epoll — 真正的突破

核心思想转变

select/poll 是主动轮询:每次调用都要问一遍"谁好了?"
epoll 是被动通知:谁好了,内核主动告诉我。

select/poll 模型:
  你:「fd 0 好了吗?没有。fd 1 好了吗?没有。fd 2 好了吗?...」

epoll 模型:
  内核:「fd 7 好了,fd 23 好了,就这俩。」
  你:直接处理这俩。

三个系统调用

// 1. 创建 epoll 实例(一次性)
int epfd = epoll_create1(0);

// 2. 注册 fd(fd 信息常驻内核,无需每次传)
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sockfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);  // O(log n)

// 3. 等待事件(只返回就绪的 fd)
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);
// n = 就绪数量,直接遍历 n 个,无需全量扫描
for (int i = 0; i < n; i++) {
    handle(events[i].data.fd);
}

内核数据结构

epoll 实例(eventpoll)
├── 红黑树(rbr)
│   ├── fd 3  ← epoll_ctl ADD 时插入,O(log n)
│   ├── fd 7
│   ├── fd 23
│   └── ...   ← 所有注册的 fd,常驻内核
│
└── 就绪链表(rdllist)
    │
    │  ← 网卡中断 → 驱动 → ep_poll_callback() → 插入此处
    ├── fd 7   (有数据了)
    └── fd 23  (有数据了)

epoll_wait 只取 rdllist,不碰红黑树
拷贝量 = 就绪数量,与注册总量无关

关键路径:一次数据到达

1. 网卡 DMA 写数据 → 触发硬件中断
2. 内核中断处理 → 调用 TCP/IP 协议栈
3. 数据到达 socket 缓冲区
4. 驱动调用 ep_poll_callback()
5. 将对应 epitem 插入 rdllist
6. 唤醒 epoll_wait 等待的进程
7. epoll_wait 返回,只拷贝 rdllist 中的事件

整个过程,内核从不遍历所有注册的 fd。

LT vs ET 触发模式

LT(水平触发,默认)              ET(边缘触发,EPOLLET)
─────────────────────────────────────────────────────────
fd 有数据 → 每次 epoll_wait 都返回   状态变化时通知一次
不读完没关系,下次还会通知           必须一次读完(循环到 EAGAIN)
实现简单,适合入门                   性能更高,Nginx/Redis 默认用
// ET 模式必须配合非阻塞 I/O + 循环读
ev.events = EPOLLIN | EPOLLET;
fcntl(fd, F_SETFL, O_NONBLOCK);

while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n == -1 && errno == EAGAIN) break;  // 读完了
    if (n <= 0) { close(fd); break; }
    process(buf, n);
}

三者对比

select poll epoll
fd 上限 1024 无限制 无限制
内存拷贝 每次全量 每次全量 仅就绪事件
内核遍历 O(n) O(n) O(1) 回调
fd 信息 每次重传 每次重传 常驻内核
触发模式 LT LT LT + ET
平台 POSIX POSIX Linux 专属

回到前端:这些机制在哪里工作

你写的代码                    底层机制
──────────────────────────────────────────────────────
fetch('https://...')
  .then(res => res.json())  →  libuv → epoll_wait()
                                       等待 TCP socket 就绪

new WebSocket('wss://...')  →  libuv 维护 socket fd
ws.onmessage = handler         数据到达 → epoll 回调 → 事件队列
                                       → JS 微任务队列 → handler()

fs.readFile('./data.json',  →  libuv 线程池(文件 I/O 特殊)
  callback)                    完成后 epoll 通知主线程

setTimeout(fn, 100)         →  timerfd(Linux)加入 epoll 监听
                               100ms 后 timerfd 就绪 → 回调

Node.js 事件循环与 epoll 的关系

┌──────────────────────────────────────┐
│           Node.js 事件循环            │
│                                      │
│  timers → I/O callbacks → idle →     │
│  poll ──────────────────────────────►│
│    │                                 │
│    └── epoll_wait(epfd, events, ...)  │
│         阻塞直到有事件                │
│         返回就绪事件列表              │
│         → 执行对应 JS 回调           │
└──────────────────────────────────────┘

epoll 就是 Node.js "非阻塞 I/O"的操作系统基石。你每次写 await fetch(),都在隐式地使用它。


什么时候用哪个

连接数 < 100,追求可移植性    →  select(或直接用库)
需要跨平台,连接数适中        →  poll
Linux 高并发服务器            →  epoll(libuv/libevent/Nginx 的选择)
macOS/BSD                    →  kqueue(同 epoll 思想)
Windows                      →  IOCP(完成端口,异步模型不同)

实际开发中你几乎不会直接调用这三个——Node.js 的 libuv、Python 的 asyncio、Go 的 netpoll 都已封装好。但理解它们,你才能真正读懂"非阻塞"、"事件驱动"、"单线程高并发"这些词背后的含义。

❌