普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月7日技术

栗子前端技术周刊第 123 期 - axios 包遭入侵、Babylon.js 9.0、Node.js 25.9.0...

2026年4月7日 08:41

🌰栗子前端技术周刊第 123 期 (2026.03.30 - 2026.04.05):浏览前端一周最新消息,学习国内外优秀文章,让我们保持对前端的好奇心。

📰 技术资讯

  1. axios 包遭入侵:axios 包遭入侵,恶意版本植入木马依赖。Axios 是一款每周下载量超 1 亿次的 HTTP 请求库,其高下载量主要源于长期积累的广泛使用率。攻击者利用这一点发布了带有恶意依赖的版本,该依赖包含远程控制木马(不过 Axios 自身代码库并未受损)。此次事件影响重大,因为即便你不直接使用 Axios,项目中的依赖包也可能间接引入它。恶意版本为:axios@1.14.1、axios@0.30.4。

  2. Babylon.js 9.0:Babylon.js 9.0 发布,包括全新的集群光照系统、基于节点的粒子编辑器、体积光照、高级高斯溅射技术等更多特性。

  3. Node.js 25.9.0:Node.js 25.9.0(Current 版本)已发布,新增用于设置进程最大堆内存大小的 --max-heap-size 选项,James Snell 实验性的 “增强版流 API” 以 stream/iter 模块形式正式落地,同时还带来了测试运行器模块模拟功能的改进。

📒 技术文章

  1. Your Options for Preloading Images with JavaScript:使用 JavaScript 预加载图片的几种方案 —— 实现这一需求有多种方法,正如作者所指出的,这可能“出乎意料地棘手...而最佳选择很大程度上取决于具体场景”。

  2. A Gentle Intro to npm Workspaces (With Visuals):npm Workspaces 入门详解 —— Workspaces 可让你在单个代码仓库中管理多个软件包,并将本地包相互关联,使其能够按包名直接互相导入,npm 会在安装时对兼容依赖进行提升放置与去重处理。

  3. 前端工程师必备的 10 个 AI 万能提示词:文中整理了 10 个前端专属 AI 万能提示词,覆盖前端开发全场景——组件开发、Bug 修复、代码重构、样式优化、工程化配置,全部复制就能用,不用自己琢磨,新手也能轻松上手。

🔧 开发工具

  1. Pretext:一款多行文本测量与布局库。前 React 核心团队成员程路(Cheng Lou)三天前发布的这条 X 推文引发轰动,自发布以来获得了 2200 万次曝光,相关代码仓库也收获了 2.5 万星标。为何反响如此热烈?因为人们对网页实现实时布局的潜力感到无比兴奋。
image-20260406132042861
  1. Knip v6:可用于查找并删除项目中未使用的文件、导出项及依赖包。v6 版本集成了 Oxc,性能提升 2 至 4 倍(处理 Astro 项目仅需两秒),且基本可以无缝升级。
image-20260406132311109
  1. ESLint Markdown Plugin 8.0:使用 ESLint 对 Markdown 文档进行代码检查。
image-20260406134234446

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

如何使用飞书机器人连接本地 AI Agent

作者 Cobyte
2026年4月7日 08:30

1. 前言

我们在上一篇文章中讲解了如何使用微信 ClawBot 连接本地 AI Agent,这一篇我们讲解如何使用飞书机器人连接本地 AI Agent。

首先我们需要创建一个飞书应用。

2. 创建飞书应用

2.1 打开飞书开放平台

访问飞书开发平台 open.feishu.cn/app 并登录。

2.2 创建应用

1.点击 创建企业自建应用

image.png

2.填写应用名称和描述

image.png

3.选择应用图标

2.3 复制凭证

在“凭证与基础信息”中复制:

  • App ID
  • App Secret

2.4 启用机器人能力

image.png

2.5 配置权限

权限管理中点击批量导入/导出权限按钮

image.png

然后复制粘贴以下权限:

{
  "scopes": {
    "tenant": [
      "aily:file:read",
      "aily:file:write",
      "application:application.app_message_stats.overview:readonly",
      "application:application:self_manage",
      "application:bot.menu:write",
      "cardkit:card:read",
      "cardkit:card:write",
      "contact:user.employee_id:readonly",
      "corehr:file:download",
      "event:ip_list",
      "im:chat.access_event.bot_p2p_chat:read",
      "im:chat.members:bot_access",
      "im:message",
      "im:message.group_at_msg:readonly",
      "im:message.p2p_msg:readonly",
      "im:message:readonly",
      "im:message:send_as_bot",
      "im:resource"
    ],
    "user": ["aily:file:read", "aily:file:write", "im:chat.access_event.bot_p2p_chat:read"]
  }
}

最后点击确定并申请开通。

2.6 配置事件订阅

事件与回调中进行事件配置,选择订阅方式为长连接,这样我们就可以在本地电脑也可以连接飞书机器人了(本质是WebSocket)。

image.png

接着添加接收消息事件:im.message.receive_v1

image.png

加密策略设置,这个可选项。

image.png

2.7 发布应用

接着我们在版本管理与发布中创建一个版本并发布。

image.png

一般我们在测试的时候,我们自己的飞书账号就是管理员,上述版本申请发布后会在手机飞书上收到一条审核消息,我们按提示进行操作审核即可。

这时我们就可以在手机飞书上搜索我们刚刚创建的“AI 机器人”,然后点击进去就可以对话了,但目前我们还没通过代码连接它,所以还对不了话。

01.jpg

值得注意的是:上述飞书机器人配置也是OpenClaw的官方飞书机器人配置方案。

3. 实现飞书连接本地 Agent

3.1 构建 API Client

根据飞书开发平台开发文档的 《Python SDK 指南》,我们在通过 SDK 调用飞书开放接口之前,需要先在代码中创建一个 API Client,用来指定当前使用的应用、日志级别、HTTP 请求超时时间等基本信息。

我们的实现如下:

from dataclasses import dataclass
import lark_oapi as lark

@dataclass
class FeishuConfig:
    """飞书渠道配置"""
    app_id: str = ""
    app_secret: str = ""
    encrypt_key: str = "" # 可选字段,空字符串表示不使用加密
    verification_token: str = "" # 可选字段,空字符串表示不验证消息来源

class FeishuChannel:
    def __init__(self, config: FeishuConfig):
        self.config = config
        # 构建 API Client
        self._client = lark.Client.builder() \
            .app_id(config.app_id) \
            .app_secret(config.app_secret) \
            .build()

首先我们实现一个配置类来统一管理上述我们说到的 App IDApp Secret 以及在配置事件订阅中讲到的加密策略设置的 Encrypt KeyVerification Token

后续我们就可以通过依赖注入模式调用 FeishuChannel 类了:

# 使用
config = FeishuConfig(app_id="...", app_secret="...")
channel = FeishuChannel(config=config)  # 配置通过构造函数注入

这样实现了配置与实现分离,将来支持热更新配置。

3.2 长连接飞书客户端

根据飞书开发平台开发文档的 《Python SDK 指南》的处理事件章节,我们的实现如下:

from loguru import logger
class FeishuChannel:
    def __init__(self, config: FeishuConfig):
        # 省略...

    def start(self) -> None:
        # 构建事件处理器
        builder = lark.EventDispatcherHandler.builder(
            self.config.encrypt_key, 
            self.config.verification_token
        )
        # 注册接收消息事件处理函数 im.message.receive_v1
        handler = builder.register_p2_im_message_receive_v1(self._on_message).build()
        # 初始化长连接客户端并传入事件处理器   
        ws_client = lark.ws.Client(
            self.config.app_id, 
            self.config.app_secret, 
            event_handler=handler
        )
        # start() 方法会阻塞主线程,持续运行,直到手动关闭
        ws_client.start()

        logger.info("✅ 飞书极简版机器人已启动 (WebSocket)")

    def _on_message(self, data: P2ImMessageReceiveV1) -> None:
        """接收到消息时的回调"""

我们首先构建事件处理器,同时如果你在开发者后台的应用详情中,配置了 事件与回调 > 加密策略 页面内的加密信息(Encrypt Key 和 Verification Token)。则必须将加密信息的值传递到 EventDispatcherHandler.builder 方法的参数中。

接着我们注册接收消息事件处理函数。也就是我们在前面配置事件订阅小节所讲的添加的接收消息事件:im.message.receive_v1

最后通过 lark.ws.Client() 初始化长连接客户端,必填参数为应用的 APP_ID 和 APP_SECRET。我们在上面讲述创建飞书应用的时候已经阐述过 APP_ID 和 APP_SECRET 了。

值得注意的是:飞书的长连接客户端 (ws.Client) 只能用来"接收"事件,不能用来"发送"消息!如果要主动发消息或回复消息,必须使用普通的 Open API 客户端 (lark.Client)。

3.3 接收消息事件处理函数

class FeishuChannel:
    def __init__(self, config: FeishuConfig):
        # 省略...

    def start(self) -> None:
        # 省略...

    def _on_message(self, data: P2ImMessageReceiveV1) -> None:
        """接收到消息时的回调"""
        msg = data.event.message
        # 只处理用户发送的纯文本消息
        if data.event.sender.sender_type == "bot" or msg.message_type != "text":
            return

        content = json.loads(msg.content).get("text", "")
        if not content:
            return
        logger.info(f"收到{msg.chat_id}消息: {content}")
        # 发送消息
        self._process_and_reply(msg.chat_id, content)

我们这里先只处理用户发送的纯文本消息。

3.4 发送消息到 AI Agent 处理并进行回复

前面说了飞书的长连接客户端 (ws.Client) 只能用来"接收"事件,不能用来"发送"消息!如果要主动发消息或回复消息,必须使用普通的 Open API 客户端 (lark.Client)。

所以根据飞书开发平台开发文档的 《Python SDK 指南》的服务器API章节的《发送消息》,我们的实现如下:

import agent_loop as agent  # 导入本地 AI Agent 逻辑模块

class FeishuChannel:
    def __init__(self, config: FeishuConfig):
        # 省略...
    def start(self) -> None:
        # 省略...
    def _on_message(self, data: P2ImMessageReceiveV1) -> None:
        # 省略...
    def _process_and_reply(self, chat_id: str, content: str) -> None:
        """调用本地 AI Agent 获取结果并调用飞书 API 发送"""
        try:
            history = [
                {"role": "system", "content": getattr(agent, "SYSTEM", "你是一个 AI 助手")},
                {"role": "user", "content": content}
            ]
            
            # 1. 运行 AI 思考逻辑
            reply_text = agent.agent_loop(history)
            if not reply_text:
                return

            # 2. 发送回复
            receive_id_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
            req = CreateMessageRequest.builder().receive_id_type(receive_id_type).request_body(
                CreateMessageRequestBody.builder()
                .receive_id(chat_id)
                .msg_type("text")
                .content(json.dumps({"text": reply_text}))
                .build()
            ).build()
            
            resp = self._client.im.v1.message.create(req)
            if resp.success():
                logger.info(f"➡️ 成功回复消息到: {chat_id}")
            else:
                logger.error(f"❌ 回复失败: {resp.msg}")
                
        except Exception as e:
            logger.error(f"❌ 处理异常: {e}")

_process_and_reply 方法中,我们首先构造与 AI Agent 的对话历史,调用 agent.agent_loop(history) 运行我们前面实现的 AI Agent 进行思考获取回复文本。这部分我们应该比较熟悉了。

根据飞书开发平台开发文档的 《发送消息》指南,发送消息时需要指定 receive_id_type 参数。飞书的 ID 有两种类型:

  • chat_id:以 oc_ 开头的群聊 ID
  • open_id:用户个人 Open ID

我们通过简单的字符串前缀判断 chat_id.startswith("oc_") 来区分这两种类型。

构建消息请求时:

  1. 首先创建 CreateMessageRequest 的建造者
  2. 设置 receive_id_type(接收者ID类型)
  3. 通过 request_body() 设置请求体,其中又使用 CreateMessageRequestBody 的建造者设置具体参数
  4. 调用 .build() 方法最终构建请求对象

这里需要注意的是,飞书消息的 content 字段必须是 JSON 字符串格式,所以我们需要使用 json.dumps({"text": reply_text}) 将回复文本包装成 JSON。

最后调用 self._client.im.v1.message.create(req) 发送消息到飞书服务器,并根据响应结果记录成功或失败日志。

现在我们已经完成了飞书渠道的核心实现,接下来编写启动脚本。

4. 启动飞书机器人

我们创建一个独立的启动文件 test_feishu.py

from loguru import logger
from feishu import FeishuChannel, FeishuConfig

def main():
    # 1. 填入你的飞书机器人凭证
    config = FeishuConfig(
        app_id="xxxx",         # 替换为真实的 App ID
        app_secret="xxxx",    # 替换为真实的 App Secret
        encrypt_key="",                      # 如果飞书后台配置了 Encrypt Key 则填入,否则留空
        verification_token=""                # 如果配置了 Verification Token 则填入,否则留空
    )
    
    # 2. 初始化频道并启动长连接
    channel = FeishuChannel(config=config)
    
    logger.info("正在启动飞书机器人长连接...")
    
    # 3. 启动并保持运行
    try:
        channel.start()
    except KeyboardInterrupt:
        logger.info("收到退出信号,正在关闭...")

if __name__ == "__main__":
    main()

启动脚本的实现非常简单直接:

第一步:配置飞书机器人凭证

我们需要创建 FeishuConfig 实例,填入在飞书开放平台创建应用时获取的凭证:

  • app_id:应用的唯一标识,以 cli_ 开头
  • app_secret:应用的密钥,用于 API 身份验证
  • encrypt_key:可选,如果在开发者后台配置了加密策略则需要填入
  • verification_token:可选,用于验证消息来源

第二步:初始化飞书频道

使用配置对象创建 FeishuChannel 实例。这里体现了依赖注入模式的优势——我们可以轻松更换不同的配置来源。

第三步:启动并保持运行

调用 channel.start() 启动飞书机器人的 WebSocket 长连接。这个方法会阻塞主线程,持续运行直到收到退出信号。

我们使用 try...except KeyboardInterrupt 捕获用户按 Ctrl+C 的中断信号,实现优雅退出。当用户想要停止机器人时,只需在终端中按 Ctrl+C,程序会记录退出日志然后正常结束。

运行机器人

在启动之前,先安装相关依赖。

requirements.txt 依赖如下:

python-dotenv==1.0.1
openai==2.24.0
loguru==0.7.3
# 飞书官方 Python SDK
lark-oapi>=1.2.0

执行 pip install -r requirements.txt 安装依赖。

依赖安装完毕后,在终端中执行:

python test_feishu.py

你会看到以下输出:

2026-04-05 00:59:23.456 | INFO     | 正在启动飞书机器人长连接...

此时机器人已经启动并开始监听飞书消息了。你可以在飞书中找到你的机器人应用,发送消息进行测试。

5. 启用异步事件循环线程

5.1 Node.js 和 Python 的主线程对比

我们知道 Node.js 默认的主线程就是事件循环线程,而 Python 的主线程默认是没有事件循环的。所以我们在上面也提到了调用 channel.start() 启动飞书机器人的 WebSocket 长连接会阻塞主线程。因为飞书 Python SDK 的 lark.ws.Client 采用同步阻塞设计,其 start() 方法会启动自己的事件循环并持续运行。主要体现在 FeishuChannel 类的 start 方法中以下代码:

ws_client.start()
logger.info("✅ 飞书极简版机器人已启动 (WebSocket)")

我们发现执行 ws_client.start() 代码后日志不打印了,因为主线程被阻塞了。

而在 Node.js 中以下的代码则不会被阻塞:

const WebSocket = require('ws');

function run_ws() {
  const ws = new WebSocket('ws://example.com/socket');

  ws.on('open', () => {
    console.log('[Worker] WebSocket connected');
  });

  ws.on('message', (data) => {
    console.log('[Worker] Received:', data.toString());
  });
}

// 同步执行
run_ws();
console.log('主线程没有被阻塞');

我们发现直接在 Node.js 的主线程中启动 WebSocket 连接服务是不会阻塞的,因为 Node.js 中的 WebSocket 本身是基于 Node.js 的事件循环来实现的。

5.2 通过 asyncio 启动异步事件循环

通过上一小节我们知道 Python 默认的主线程是没有事件循环的,需要显式创建运行。目前在 Python 3.7+ 推荐通过 asyncio 来启动异步事件循环。

实现很简单:

async def main():
    # 省略...
    try:
        await channel.start()
    except KeyboardInterrupt:
        logger.info("收到退出信号,正在关闭...")

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

上述代码的转换过程等于:

主线程 (执行Python代码)
    ↓
调用 asyncio.run()
    ↓
主线程内部启动事件循环
    ↓
主线程 = 异步事件循环线程

但现在运行会报错,因为虽然主线程启动了异步事件循环线程,但飞书 SDK 的 lark.ws.Client 内部也使用了异步事件循环,当我们在已有的 asyncio 事件循环中调用它时,会产生冲突。

所以我们需要在独立线程中运行飞书的 WebSocket 客户端,避免与主线程的事件循环冲突。修改如下:

class FeishuChannel:
    def __init__(self, config: FeishuConfig):
        # 省略...

    async def start(self) -> None:
        # 省略...
        def run_ws():
            # 为 WebSocket 客户端所在线程设置专属的事件循环,避免报错
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            import lark_oapi.ws.client
            lark_oapi.ws.client.loop = loop
            # 初始化长连接客户端
            ws_client = lark.ws.Client(
                self.config.app_id, 
                self.config.app_secret, 
                event_handler=handler
            )
            ws_client.start()
        # 在独立线程中运行飞书的 WebSocket 客户端,避免与主线程的事件循环冲突
        threading.Thread(target=run_ws, daemon=True).start()
        logger.info("✅ 飞书极简版机器人已启动 (WebSocket)")

        # 保持主协程存活
        while True:
            await asyncio.sleep(1)

主要修改了 FeishuChannel 类的 start 方法,通过 threading.Thread 在独立线程中运行飞书的 WebSocket 客户端,避免与主线程的事件循环冲突,并为飞书 WebSocket 客户端所在线程设置专属的事件循环,避免报错。

5.3 防止 AI 处理阻塞消息接收

无论是 Python 还是 Node.js,都需要防止耗时的 AI 处理阻塞消息接收。所以我们需要修改 _on_message 方法:

class FeishuChannel:
    def __init__(self, config: FeishuConfig):
        # 省略...

    async def start(self) -> None:
        # 省略...

    def _on_message(self, data: P2ImMessageReceiveV1) -> None:
        # 省略...

        # 启动独立线程处理 AI 逻辑和回复,防止阻塞 WebSocket 接收循环
        threading.Thread(
            target=self._process_and_reply, 
            args=(msg.chat_id, content)
        ).start()

我们看到在上述代码中启动一个独立线程处理 AI 逻辑和回复,防止阻塞 WebSocket 接收消息循环。

这时我们再次启动代码,我们可以看到 start 方法中的日志同步打印了:

2026-04-05 01:20:01.456 | INFO     | 正在启动飞书机器人长连接...
2026-04-05 01:20:01.457 | INFO     |  飞书极简版机器人已启动 (WebSocket)

6. 总结

至此,我们已经完整实现了通过飞书机器人连接本地 AI Agent。通过上述实践,我们不仅掌握了具体的实现步骤,更重要的是理解了背后的技术原理:

  • Node.js vs Python 事件循环:Node.js 主线程本身就是事件循环线程,天生异步;Python 则需要显式创建事件循环,主线程默认同步执行
  • Python 异步架构:深入理解了 asyncio 事件循环与多线程的协作模式,以及如何通过线程隔离解决 SDK 的冲突

记住:技术的最佳学习方式就是动手实践。现在就去飞书开放平台创建你的应用,启动这个机器人,感受 AI 与即时通讯结合的魅力吧!

我是程序员Cobyte,欢迎添加 v: icobyte,学习交流 AI 全栈。

每日一题-模拟行走机器人 II🟡

2026年4月7日 00:00

给你一个在 XY 平面上的 width x height 的网格图,左下角 的格子为 (0, 0) ,右上角 的格子为 (width - 1, height - 1) 。网格图中相邻格子为四个基本方向之一("North""East""South" 和 "West")。一个机器人 初始 在格子 (0, 0) ,方向为 "East" 。

机器人可以根据指令移动指定的 步数 。每一步,它可以执行以下操作。

  1. 沿着当前方向尝试 往前一步 。
  2. 如果机器人下一步将到达的格子 超出了边界 ,机器人会 逆时针 转 90 度,然后再尝试往前一步。

如果机器人完成了指令要求的移动步数,它将停止移动并等待下一个指令。

请你实现 Robot 类:

  • Robot(int width, int height) 初始化一个 width x height 的网格图,机器人初始在 (0, 0) ,方向朝 "East" 。
  • void step(int num) 给机器人下达前进 num 步的指令。
  • int[] getPos() 返回机器人当前所处的格子位置,用一个长度为 2 的数组 [x, y] 表示。
  • String getDir() 返回当前机器人的朝向,为 "North" ,"East" ,"South" 或者 "West" 。

 

示例 1:

example-1

输入:
["Robot", "step", "step", "getPos", "getDir", "step", "step", "step", "getPos", "getDir"]
[[6, 3], [2], [2], [], [], [2], [1], [4], [], []]
输出:
[null, null, null, [4, 0], "East", null, null, null, [1, 2], "West"]

解释:
Robot robot = new Robot(6, 3); // 初始化网格图,机器人在 (0, 0) ,朝东。
robot.step(2);  // 机器人朝东移动 2 步,到达 (2, 0) ,并朝东。
robot.step(2);  // 机器人朝东移动 2 步,到达 (4, 0) ,并朝东。
robot.getPos(); // 返回 [4, 0]
robot.getDir(); // 返回 "East"
robot.step(2);  // 朝东移动 1 步到达 (5, 0) ,并朝东。
                // 下一步继续往东移动将出界,所以逆时针转变方向朝北。
                // 然后,往北移动 1 步到达 (5, 1) ,并朝北。
robot.step(1);  // 朝北移动 1 步到达 (5, 2) ,并朝  (不是朝西)。
robot.step(4);  // 下一步继续往北移动将出界,所以逆时针转变方向朝西。
                // 然后,移动 4 步到 (1, 2) ,并朝西。
robot.getPos(); // 返回 [1, 2]
robot.getDir(); // 返回 "West"

 

提示:

  • 2 <= width, height <= 100
  • 1 <= num <= 105
  • stepgetPos 和 getDir 总共 调用次数不超过 104 次。

【宫水三叶】简单模拟题

作者 AC_OIer
2022年4月14日 11:10

模拟

根据题目给定的移动规则可知,机器人总是在外圈移动(共上下左右四条),而移动方向分为四类:

image.png

当行走步数为 $mod = 2 * (w - 1) + 2 * (h - 1)$ 的整数倍时,会回到起始位置,因此我们可以通过维护一个变量 loc 来记录行走的总步数,并且每次将 locmod 进行取模来得到有效步数。

在回答 getPosgetDir 询问时,根据当前 loc 进行分情况讨论(见注释)。

另外还有一个小细节:根据题意,如果当前处于 $(0, 0)$ 位置,并且没有移动过,方向为 East,若移动过,方向则为 South,这可以通过一个变量 moved 来进行特判处理。

代码:

###Java

class Robot {
    String[] ss = new String[]{"East", "North", "West", "South"};
    int w, h, loc; // loc: 有效(取模后)移动步数
    boolean moved; // 记录是否经过移动,用于特判 (0,0) 的方向
    public Robot(int width, int height) {
        w = width; h = height;
    }
    public void step(int num) {
        moved = true;
        loc += num;
        loc %= 2 * (w - 1) + 2 * (h - 1);
    }
    public int[] getPos() {
        int[] info = move();
        return new int[]{info[0], info[1]};
    }
    public String getDir() {
        int[] info = move();
        int x = info[0], y = info[1], dir = info[2];
        // 特殊处理当前在 (0,0) 的情况,当未移动过方向为 East,移动过方向为 South
        if (x == 0 && y == 0) return moved ? ss[3] : ss[0];
        return ss[dir];
    }
    int[] move() {
        if (loc <= w - 1) {
            // 当移动步数范围在 [0,w-1] 时,所在位置为外圈的下方,方向为 East
            return new int[]{loc, 0, 0};
        } else if (loc <= (w - 1) + (h - 1)) {
            // 当移动步数范围在 [w,(w-1)+(h-1)] 时,所在位置为外圈的右方,方向为 North
            return new int[]{w - 1, loc - (w - 1), 1};
        } else if (loc <= 2 * (w - 1) + (h - 1)) {
            // 当移动步数范围在 [(w-1)+(h-1)+1,2*(w-1)+(h-1)] 时,所在位置为外圈的上方,方向为 West
            return new int[]{(w - 1) - (loc - ((w - 1) + (h - 1))), h - 1, 2};
        } else {
            // 当移动步数范围在 [2*(w-1)+(h-1)+1,2*(w-1)+2*(h-1)] 时,所在位置为外圈的左方,方向为 South
            return new int[]{0, (h - 1) - (loc - (2 * (w - 1) + (h - 1))), 3};
        }
    }
}
  • 时间复杂度:$O(1)$
  • 空间复杂度:$O(1)$

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

分类讨论,O(1) 时间复杂度(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2021年11月14日 08:20

本文把 $\textit{width}$ 简称为 $w$,把 $\textit{height}$ 简称为 $h$。

根据题意,机器人只能在网格图的最外圈中移动,移动一整圈需要 $2(w+h-2)$ 步。

lc2069.png

设当前移动的总步数模 $2(w+h-2)$ 的结果为 $s$。分类讨论:

  • 如果 $s < w$,机器人往右走了 $s$ 步,位于 $(s,0)$,面朝东。
  • 如果 $w\le s < w+h-1$,机器人先往右走 $w-1$ 步,再往北走 $s-(w-1)$ 步,位于 $(w-1,s-w+1)$,面朝北。
  • 如果 $w+h-1\le s < 2w+h-2$,机器人先往右走 $w-1$ 步,再往北走 $h-1$ 步,到达右上角 $(w-1,h-1)$,再往西走 $s-(w-1)-(h-1)$ 步,位于 $(w-1-(s-(w-1)-(h-1)),h-1) = (2w + h - s - 3,h-1)$,面朝西。
  • 否则,机器人先往右走 $w-1$ 步,再往北走 $h-1$ 步,再往西走 $w-1$ 步,到达左上角 $(0,h-1)$,再往南走 $s-2(w-1)-(h-1)$ 步,位于 $(0,h-1-(s-2(w-1)-(h-1))) = (0, 2(w+h)-s-4)$,面朝南。

注意:总步数为 $0$ 时,机器人面朝东,但总步数为 $2(w+h-2)$ 的正整数倍时,机器人面朝南。需要特判总步数为 $0$ 的特殊情况吗?不需要,当总步数大于 $0$ 时,我们可以把取模后的范围从 $[0, 2(w+h-2)-1]$ 调整到 $[1, 2(w+h-2)]$,从而使原先模为 $0$ 的总步数变成 $2(w+h-2)$,落入面朝南的分支中,这样就可以避免特判了。

class Robot:
    def __init__(self, width: int, height: int) -> None:
        self.w = width
        self.h = height
        self.s = 0

    def step(self, num: int) -> None:
        # 由于机器人只能走外圈,那么走 (w+h-2)*2 步后会回到起点
        # 把 s 取模调整到 [1, (w+h-2)*2],这样不需要特判 s == 0 时的方向
        self.s = (self.s + num - 1) % ((self.w + self.h - 2) * 2) + 1

    def _getState(self) -> Tuple[int, int, str]:
        w, h, s = self.w, self.h, self.s
        if s < w:
            return s, 0, "East"
        if s < w + h - 1:
            return w - 1, s - w + 1, "North"
        if s < w * 2 + h - 2:
            return w * 2 + h - s - 3, h - 1, "West"
        return 0, (w + h) * 2 - s - 4, "South"

    def getPos(self) -> List[int]:
        x, y, _ = self._getState()
        return [x, y]

    def getDir(self) -> str:
        return self._getState()[2]
class Robot {
    private int w, h, s;

    public Robot(int width, int height) {
        w = width;
        h = height;
        s = 0;
    }

    public void step(int num) {
        // 由于机器人只能走外圈,那么走 (w+h-2)*2 步后会回到起点
        // 把 s 取模调整到 [1, (w+h-2)*2],这样不需要特判 s == 0 时的方向
        s = (s + num - 1) % ((w + h - 2) * 2) + 1;
    }

    public int[] getPos() {
        Object[] t = getState();
        return new int[]{(int) t[0], (int) t[1]};
    }

    public String getDir() {
        Object[] t = getState();
        return (String) t[2];
    }

    private Object[] getState() {
        if (s < w) {
            return new Object[]{s, 0, "East"};
        } else if (s < w + h - 1) {
            return new Object[]{w - 1, s - w + 1, "North"};
        } else if (s < w * 2 + h - 2) {
            return new Object[]{w * 2 + h - s - 3, h - 1, "West"};
        } else {
            return new Object[]{0, (w + h) * 2 - s - 4, "South"};
        }
    }
}
class Robot {
    int w;
    int h;
    int s = 0;

    tuple<int, int, string> getState() {
        if (s < w) {
            return {s, 0, "East"};
        } else if (s < w + h - 1) {
            return {w - 1, s - w + 1, "North"};
        } else if (s < w * 2 + h - 2) {
            return {w * 2 + h - s - 3, h - 1, "West"};
        } else {
            return {0, (w + h) * 2 - s - 4, "South"};
        }
    }

public:
    Robot(int width, int height) : w(width), h(height) {}

    void step(int num) {
        // 由于机器人只能走外圈,那么走 (w+h-2)*2 步后会回到起点
        // 把 s 取模调整到 [1, (w+h-2)*2],这样不需要特判 s == 0 时的方向
        s = (s + num - 1) % ((w + h - 2) * 2) + 1;
    }

    vector<int> getPos() {
        auto [x, y, _] = getState();
        return {x, y};
    }

    string getDir() {
        return get<2>(getState());
    }
};
typedef struct {
    int w;
    int h;
    int s;
} Robot;

Robot* robotCreate(int width, int height) {
    Robot* r = malloc(sizeof(Robot));
    r->w = width;
    r->h = height;
    r->s = 0;
    return r;
}

void robotStep(Robot* r, int num) {
    // 由于机器人只能走外圈,那么走 (w+h-2)*2 步后会回到起点
    // 把 s 取模调整到 [1, (w+h-2)*2],这样不需要特判 s == 0 时的方向
    r->s = (r->s + num - 1) % ((r->w + r->h - 2) * 2) + 1;
}

int* robotGetPos(Robot* r, int* returnSize) {
    int w = r->w, h = r->h, s = r->s;
    int x, y;
    if (s < w) {
        x = s;
        y = 0;
    } else if (s < w + h - 1) {
        x = w - 1;
        y = s - w + 1;
    } else if (s < w * 2 + h - 2) {
        x = w * 2 + h - s - 3;
        y = h - 1;
    } else {
        x = 0;
        y = (w + h) * 2 - s - 4;
    }

    int* ans = malloc(2 * sizeof(int));
    *returnSize = 2;
    ans[0] = x;
    ans[1] = y;
    return ans;
}

char* robotGetDir(Robot* r) {
    int w = r->w, h = r->h, s = r->s;
    if (s < w) {
        return "East";
    } else if (s < w + h - 1) {
        return "North";
    } else if (s < w * 2 + h - 2) {
        return "West";
    } else {
        return "South";
    }
}

void robotFree(Robot* r) {
    free(r);
}
type Robot struct {
w, h, step int
}

func Constructor(width, height int) Robot {
return Robot{width, height, 0}
}

func (r *Robot) Step(num int) {
// 由于机器人只能走外圈,那么走 (w+h-2)*2 步后会回到起点
// 把 step 取模调整到 [1, (w+h-2)*2],这样不需要特判 step == 0 时的方向
r.step = (r.step+num-1)%((r.w+r.h-2)*2) + 1
}

func (r *Robot) getState() (x, y int, dir string) {
w, h, step := r.w, r.h, r.step
switch {
case step < w:
return step, 0, "East"
case step < w+h-1:
return w - 1, step - w + 1, "North"
case step < w*2+h-2:
return w*2 + h - step - 3, h - 1, "West"
default:
return 0, (w+h)*2 - step - 4, "South"
}
}

func (r *Robot) GetPos() []int {
x, y, _ := r.getState()
return []int{x, y}
}

func (r *Robot) GetDir() string {
_, _, d := r.getState()
return d
}
var Robot = function(width, height) {
    this.w = width;
    this.h = height;
    this.s = 0;
};

Robot.prototype.step = function(num) {
    // 由于机器人只能走外圈,那么走 (w+h-2)*2 步后会回到起点
    // 把 s 取模调整到 [1, (w+h-2)*2],这样不需要特判 s === 0 时的方向
    this.s = (this.s + num - 1) % ((this.w + this.h - 2) * 2) + 1;
};

Robot.prototype.getState = function() {
    const w = this.w, h = this.h, s = this.s;
    if (s < w) {
        return [s, 0, "East"];
    } else if (s < w + h - 1) {
        return [w - 1, s - w + 1, "North"];
    } else if (s < w * 2 + h - 2) {
        return [w * 2 + h - s - 3, h - 1, "West"];
    } else {
        return [0, (w + h) * 2 - s - 4, "South"];
    }
};

Robot.prototype.getPos = function() {
    const [x, y, _] = this.getState();
    return [x, y];
};

Robot.prototype.getDir = function() {
    return this.getState()[2];
};
struct Robot {
    w: i32,
    h: i32,
    s: i32,
}

impl Robot {
    fn new(width: i32, height: i32) -> Self {
        Self { w: width, h: height, s: 0 }
    }

    fn step(&mut self, num: i32) {
        // 由于机器人只能走外圈,那么走 (w+h-2)*2 步后会回到起点
        // 把 s 取模调整到 [1, (w+h-2)*2],这样不需要特判 s == 0 时的方向
        self.s = (self.s + num - 1) % ((self.w + self.h - 2) * 2) + 1;
    }

    fn get_state(&self) -> (i32, i32, String) {
        let w = self.w;
        let h = self.h;
        let s = self.s;
        if s < w {
            (s, 0, "East".to_string())
        } else if s < w + h - 1 {
            (w - 1, s - w + 1, "North".to_string())
        } else if s < w * 2 + h - 2 {
            (w * 2 + h - s - 3, h - 1, "West".to_string())
        } else {
            (0, (w + h) * 2 - s - 4, "South".to_string())
        }
    }

    fn get_pos(&self) -> Vec<i32> {
        let (x, y, _) = self.get_state();
        vec![x, y]
    }

    fn get_dir(&self) -> String {
        let (_, _, d) = self.get_state();
        d
    }
}

复杂度分析

  • 时间复杂度:均为 $\mathcal{O}(1)$。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

5911. 模拟行走机器人 II【模拟详解】

作者 yaojun2026
2021年11月14日 00:27

分析

  • 题目:
  • 思路:
    • 直接按照题意模拟一遍。
    • 我们发现机器人只会绕着网格图的外圈走,因此可以先将多余的圈数去掉,直接取余。
    • 140/142测试点过不去,有两类错误
      • 第一种,没有考虑到余数为0的时候,方向可能没有转过来,还是之前的方向。比如饶了一圈回到(0,0),最开始是向右,但是此时正确答案应该向下。
      • 第二种,考虑了余数为0,但是直接对方向回退。在有的数据上,并不能直接回退方向,这个方向是固定的,可以写死。

QQ截图20211114002552.png

代码

class Robot {
public:
    int w, h;
    int x, y, d;
    string dir[4] = {"East", "North", "West", "South"};
    int dx[4]={1, 0, -1, 0};
    int dy[4]={0, 1, 0, -1};
    Robot(int width, int height) {
        w = width;
        h = height;
        x = 0, y = 0, d = 0;
    }
    
    void move(int num) {
        // 外一圈的步数,这里不等于周长,而是长宽减一后的周长,最好手动模拟走一下。
        // 也可以这样写 int cc = 2*(w-1 + h-1);
        int cc = 2*w + 2*h - 4;
        num %= cc;
        // 140/142没考虑到的测试点 num == 0
        if(!num && !x && !y) d = 3;
        while(num--) {
            int nx = dx[d] + x, ny = dy[d] + y;
            if(nx < 0 || nx >= w || ny < 0 || ny >= h) {
                // 如果越界了,就逆时针转90度,换到下一个方向继续走
                d++, d %= 4;
                nx = dx[d] + x, ny = dy[d] + y;
            }
            x = nx, y = ny;
        }
    }
    
    vector<int> getPos() {
        return  {x, y};
    }
    
    string getDir() {
        return dir[d];
    }
};

/**
 * Your Robot object will be instantiated and called as such:
 * Robot* obj = new Robot(width, height);
 * obj->move(num);
 * vector<int> param_2 = obj->getPos();
 * string param_3 = obj->getDir();
 */

用 AI 搞定用户系统:Superpowers 工程化开发教程

2026年4月6日 21:57

🧠 如果你最近在关注 AI Coding,大概率已经刷到过 Superpowers 和 ui-ux-pro-max。 前者试图把“想到哪写到哪”的 AI 编程,拉回到更像工程交付的节奏里;后者则想解决另一个老问题:AI 能把页面写出来,但不一定写得像一个成熟产品。

这篇文章不准备再用“工具很强、流程很酷、装上就起飞”那种方式来介绍它们。

我更想做的,是把几个真正重要的问题讲清楚:这两个 Skill 分别解决什么问题、官方文档里到底怎么安装和工作的,以及如果把它们放进一个真实项目里,具体应该怎样用。

为了把过程讲具体,后文用一个 RBAC 用户权限系统 作为案例来串起整条链路。本文讨论的是单租户后台管理系统里的 RBAC,不展开 ABAC、行级权限、组织继承、多租户隔离这类更复杂的话题。

这是最终完成初版的多租户 RBAC 系统项目,仓库地址为 github.com/Cookieboty/…。感兴趣的同学可以 Star 支持一下。需要注意的是,这个项目虽然是按下文流程 VB 出来的,但过程中也做了不少 bug 处理;另外,受 AI 幻觉影响,部分分支出现过偏差,因此做了一些调整,但整体流程基本可控。


一、Superpowers 与 ui-ux-pro-max 的定位

1.1 Superpowers:面向工程流程的 AI 开发工作流

很多 AI 编程体验之所以让人又爱又烦,本质上不是模型不会写代码,而是它太容易过早进入实现阶段。你刚抛出一个需求,它就开始生成文件;你话还没说完,它已经默认做了三层扩展。

Superpowers 的核心思路,正是把这种“先写再说”的节奏,改造成一套更接近工程实践的工作流。它并不是单一 Skill,而是一套围绕软件开发流程组织起来的技能系统:从 brainstorm、plan,到执行、TDD、review、收尾,尽量让代理在每个阶段做该做的事。

1.2 ui-ux-pro-max:面向前端产出的设计增强能力

很多人第一次用 AI 生成前端页面时,都会有一种熟悉的感觉:布局也有,按钮也有,表格也有,生了一堆组件;但页面往往停留在“摆上去”的层面,缺少真正的设计判断——比如配色节奏、版式层级、字号体系、交互一致性。

ui-ux-pro-max 官方 README 对它的定位,是一个为多平台 AI coding assistant 提供 design intelligence 的 Skill。它不是一个 UI 组件库,也不是设计工具,而更像一个给模型补设计经验的知识层。

1.3 两者的互补关系

  • Superpowers:解决“开发过程是否可控”
  • ui-ux-pro-max:解决“前端结果是否足够成熟”

1.4 何时使用 Superpowers,何时使用 ui-ux-pro-max

Superpowers 适合的场景:

  • 中大型功能开发,一次对话内完成不了
  • 任务同时涉及后端建模、接口、前端、测试的多模块联动
  • 需要跨 session 持续推进,上下文断了也要获山再起
  • 需要可审计、可复盘的交付结果

不容易起效的场景:

  • 修一个很小的 bug、一次性脚本、快速验证原型的任务
  • 尚在探索方向、需求本身就不收敛的创意型工作

ui-ux-pro-max 适合的场景:

  • 页面目标和组件库已经确定,你需要的是更统一、更成熟的设计产出
  • 技术栈比较主流(React、Next.js、Tailwind 等)
  • 不适合:尚在探索风格方向、仓库本身设计不统一或项目组件混乱的情况

💡 实践中最常见的用法:先用 Superpowers 把任务拆清楚、推到后端骨架就位,再用 ui-ux-pro-max 进入前端阶段。两个工具的切换点是自然的:前者管流程,后者管疯界面。


二、安装与基础验证

2.1 Superpowers 安装

官方 README 先强调:不同平台的安装方式并不一样。Claude Code 和 Cursor 这类带插件市场的环境装法比较直接;Codex、OpenCode 这类环境则需要走手动安装说明。

Claude Code:官方 Marketplace 安装

/plugin install superpowers@claude-plugins-official

Claude Code:通过 Superpowers Marketplace 安装

/plugin marketplace add obra/superpowers-marketplace
/plugin install superpowers@superpowers-marketplace

2.2 ui-ux-pro-max 安装

先决条件:Python 3.x

官方文档强调,Python 3.x 是必需的,因为它的搜索脚本依赖 Python 运行。

# 检查是否已安装
python3 --version

# macOS
brew install python3

这一步最好别省。很多“装了但是不好使”的问题,最后往往不是 Skill 本身的问题,而是本地依赖没有满足。

Claude Code:Marketplace 安装

/plugin marketplace add nextlevelbuilder/ui-ux-pro-max-skill
/plugin install ui-ux-pro-max@ui-ux-pro-max-skill


三、官方工作流概览

按照官方 README,Superpowers 的基本工作流如下:

  1. brainstorming
  2. using-git-worktrees
  3. writing-plans
  4. subagent-driven-developmentexecuting-plans
  5. test-driven-development
  6. requesting-code-review / review 相关环节
  7. finishing-a-development-branch

3.1 需求澄清

对应 brainstorming。它不是“礼貌地追问几句”,而是把模糊需求转成一个能继续往下推的设计起点。最关键的产物不是聊天记录,而是后续会被固化下来的设计判断。

3.2 方案收敛与计划拆分

对应 writing-plans。AI 应把任务拆成一组可以逐步完成、逐步验收的动作。官方文档里一直强调 plan 的粒度要小,就是为了让长任务不会因为上下文膨胀而失控。

3.3 执行阶段的约束机制

对应 executing-planssubagent-driven-developmenttest-driven-development。Superpowers 想做的是,让执行阶段继续受到测试、review 和阶段性检查的约束,而不是有了计划也照样一口气往前冲。

3.4 收尾与交付

对应 finishing-a-development-branch。很多 AI 生成代码的问题,不在生成时,而在收尾时:没确认测试结果,没做回归,也没有明确说明当前分支该如何处置。

3.5 ui-ux-pro-max 的工作方式

你提出任何 UI/UX 相关任务,Skill 在相关平台上自动激活,然后识别任务类型,检索对应的风格、配色、字体、布局建议,再把这些结果提供给模型。它更像“设计顾问”,而不是“页面生成器”。


四、RBAC 案例的选择

RBAC 系统之所以合适作为演示案例,是因为它天然有几层结构:后端要建模用户、角色、权限;接口层要做 Guard 和装饰器;前端要做登录、列表、权限分配、菜单控制;交付阶段还要处理联调、初始化数据和容器化启动。这正好能把 Superpowers 的长任务工作流和 ui-ux-pro-max 的前端辅助能力放进同一个案例里观察。

📌范围边界:本文讨论的是单租户后台管理系统里的 RBAC,不涉及 ABAC、数据行级权限、组织/部门继承、多租户隔离等更复杂的权限模型。


五、在 RBAC 项目中的使用方式

5.1 需求边界澄清

权限系统最容易出问题的,不是 CRUD 本身,而是边界不清时默认做了太多假设。所以第一步不是写代码,而是让 AI 先把问题问完整。

  • 🤖 Prompt:需求边界澄清

    /superpowers:brainstorm
    
    我要做一个后台管理系统的 RBAC 权限模块。
    后端使用 NestJS + Prisma + PostgreSQL,前端使用 Next.js 14 + Tailwind + shadcn/ui。
    
    请不要直接写代码,先帮我把边界条件问清楚,包括:
    - 权限粒度到页面、按钮还是接口
    - 是否需要 super_admin 绕过机制
    - refreshToken 是否落库
    - 是否需要审计日志
    - 是否涉及组织 / 部门 / 租户维度
    

这一步的意义非常大。你以为自己要的是“后台权限控制”,模型理解成了“完整企业权限平台”;你只想做按钮级控制,它顺手给你加上了租户维度和审计模型。超出范围的内容局部不一定错,但大概率不是你当下真正需要的东西。

5.2 Spec 设计说明

一旦问题问得差不多了,让 AI 把核心决策收敛成一份设计说明。有没有把下面几件事说死才是关键:

  • 后端模块怎么拆
  • 权限命名采用什么规范
  • 前端如何接入权限判断
  • 哪些内容这次明确不做

如果这些东西不先落下来,后面生成的 Plan 再细,也只是把一堆摇摆中的决定拆得更碎而已。

5.3 Plan 拆分原则

不要用“实现用户模块”这种粗粒度的任务名。一步里面包含的决策越多,执行时越容易发散。

  • 🤖 Prompt:生成实施计划

    /superpowers:writing-plans
    
    基于刚才对齐的 RBAC 权限系统需求,生成完整实施计划。
    
    要求:
    - 后端和前端分开规划
    - 每个步骤要有明确的交付物和验收标准
    - 数据库 Schema 设计作为独立步骤
    - 鉴权模块和业务模块分开
    - 前端页面按功能模块拆分
    

一份可执行的 Plan 应该是这样的结构:

### Phase 1:工程底座
- [ ] 1.1 初始化 monorepo(apps/api + apps/web)
- [ ] 1.2 配置 Prisma + PostgreSQL
- [ ] 1.3 设计并迁移数据库 Schema

### Phase 2:后端核心
- [ ] 2.1 实现 Auth 模块(登录/刷新 Token)
- [ ] 2.2 实现 Users 模块(CRUD + 状态管理)
- [ ] 2.3 实现 Roles 模块
- [ ] 2.4 实现 Permissions 模块
- [ ] 2.5 实现 JwtAuthGuard + PermissionsGuard
- [ ] 2.6 实现 @Permissions() 装饰器

### Phase 3:前端核心
- [ ] 3.1 初始化 Next.js + Tailwind + shadcn/ui
- [ ] 3.2 实现登录页
- [ ] 3.3 实现布局和侧边栏(权限驱动菜单)
- [ ] 3.4 实现用户、角色、权限管理页

### Phase 4:集成与收尾
- [ ] 4.1 前后端联调
- [ ] 4.2 Docker Compose 一键启动
- [ ] 4.3 初始化管理员账号与默认权限

Plan 不是项目目录,而是执行顺序。进入下一阶段前,务必确认当前阶段验收已通过、git commit 已提交。

5.4 后端实现阶段

  • 🤖 Prompt:执行后端阶段

    /superpowers:executing-plan
    
    执行 PLAN.md 中 Phase 1 和 Phase 2 的所有步骤。
    
    每个步骤执行完后,输出对应的验收 curl 命令。
    

让 AI 牛马🐮🐮🐮🐮给我冲啊!!!

后端验收清单:

# 登录获取 Token
curl -X POST http://localhost:3002/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@test.com","password":"123456"}'

# 获取用户信息 Token 应返回 200
curl -X GET http://localhost:3002/api/auth/profile \
    -H "Authorization: Bearer $ACCESS_TOKEN"

# 无权限的 Token 应返回 403(不是 401)
curl -X DELETE http://localhost:3002/users/1 \
  -H "Authorization: Bearer <limitedToken>"

# super_admin 应能访问所有接口
curl http://localhost:3002/users \
  -H "Authorization: Bearer <superAdminToken>"

上述验收标准因实现而异,具体内容可以交由 AI 生成。

验收标准:

  • ✅ 登录成功,返回中包含权限列表的 Token

  • ✅ 有权限接口正常返回 200

  • ✅ 无权限接口返回 403(不是 401)
  • ✅ super_admin 跳过权限检查
  • ✅ Token 过期后 refreshToken 自动续签

5.5 前端实现阶段

等后端骨架差不多了,这时你已经知道自己要做哪些页面,需要的不再是“帮我想我要做什么”,而是“在页面目标已经明确的前提下,把它们做得更像一个成熟后台”。这就是 ui-ux-pro-max 程开始發揮其真正价值的时候。

  • 🤖 Prompt:执行前端阶段

    /superpowers:execute-plan
    
    执行 PLAN.md 中 Phase 3 的所有步骤,使用 ui-ux-pro-max 技能生成前端页面。
    
    设计要求:
    - 整体风格:SaaS 后台管理系统,Modern + Clean
    - 配色方案:参考 SaaS 行业调色盘,主色深蓝(#1E40AF),辅色浅灰
    - 字体:Inter + 系统字体栈
    - 组件库:shadcn/ui,不自造轮子
    - 侧边栏:固定宽 240px,图标 + 文字菜单,根据权限动态渲染
    - 表格:带分页、搜索、批量操作
    - 表单弹窗:Drawer 风格(从右侧滑入),不用居中 Modal
    - 权限分配:Checkbox 树形结构,按 module 分组
    
    页面清单:
    1. 登录页(/login)
    2. 用户管理(/dashboard/users)
    3. 角色管理(/dashboard/roles)
    4. 权限列表(/dashboard/permissions)
    5. 个人信息(/dashboard/profile)
    

前端权限控制实现示例:

// hooks/usePermissions.ts
export function usePermissions() {
  const { user } = useAuth();
  const can = (permission: string) =>
    user?.permissions?.includes(permission) ?? false;
  return { can };
}

// 按钮级权限控制
{ can('user:delete') && (
  <Button variant="destructive" onClick={handleDelete}>
    删除用户
  </Button>
)}

// 路由守卫
export function PermissionGuard({ permission, children }) {
  const { can } = usePermissions();
  if (!can(permission)) return <NoPermission />;
  return children;
}

前端验收清单:

  • ✅ 菜单根据权限动态显示/隐藏
  • ✅ 没权限的页面不允许进入
  • ✅ 没权限的按钮不展示
  • ✅ 权限分配界面能正常保存
  • ✅ 禁用用户无法登录

5.6 集成与交付阶段

很多教程写到前后端页面都出来了,就差不多结束了。但真实项目往往恰恰是在集成阶段开始变难。

  • 🤖 Prompt:执行集成阶段

    /superpowers:execute-plan
    
    执行 PLAN.md 中 Phase 4 的集成步骤:
    
    1. 前后端联调:
       - 配置 Next.js API 代理(/api/* -> http://localhost:3001)
       - 封装 axios 实例,自动注入 Bearer Token,处理 401 自动刷新
       - 统一错误处理(403 跳转无权限页,401 跳转登录页)
    
    2. Docker Compose:
       - postgres 服务(数据持久化)
       - api 服务(NestJS)
       - web 服务(Next.js)
       - 支持 docker compose up -d 一键启动
    
    3. 初始化脚本:
       - 自动创建 super_admin 角色和初始管理员账号
       - 自动写入所有权限枚举到数据库
    
    完成后输出完整的 README,包含本地启动步骤。
    

集成验收清单:

# 一键启动
docker compose up -d

# 初始化数据(运行一次)
docker compose exec api npx ts-node src/scripts/seed.ts

# 验证协调:前端登录后调用后端接口
curl http://localhost:3001/users \
  -H "Authorization: Bearer <tokenFromFrontend>"

最终验收标准:

  • ✅ docker compose up -d 一键启动,服务全部起来
  • ✅ 初始化脚本正常写入权限和管理员账号
  • ✅ 前端登录后 Token 注入正常,接口可访问
  • ✅ 401 / 403 按预期跳转
  • ✅ 足够翻译:无需手动修改配置文件

六、适用场景与现实限制

6.1 真正适合它的场景

  • 中大型功能开发:不是几个小修小补,而是会牵涉模型、接口、页面、测试的完整模块
  • 多模块联动任务:前后端、交互、鉴权、配置、联调需要协同推进
  • 需要跨 session 续做的任务:一次对话干不完,后面还要继续接着做
  • 需要复盘与审计的交付:你希望知道设计是怎么定的、每一步做到哪了、为什么这样做

6.2 不适合全流程的场景

  • 修一个很小的 bug
  • 一次性脚本
  • 快速验证想法的原型
  • 本来就需要边做边改方向的创意型任务

一句话说就是:小任务轻流程,大任务重流程。

6.3 现实限制

Spec / Plan 确实会带来前置成本

如果任务本来就很小,前面花十几分钟对齐、落文档、拈计划,可能真的不划算。

TDD 会让 AI 显得更慢

它的好处是结果更可验证,但代价就是执行节奏不会像“直接写一版能跑的代码”那么快。

ui-ux-pro-max 的效果依赖代码基线

如果你的仓库本身组件体系混乱、设计 token 失控、页面结构脏乱,那它的上限也会被拉低。

人工决策依旧不可替代

Skill 能加强执行,但不能替你决定:哪些复杂度该引入、哪些边界这次先不做、什么时候追求速度还是追求稳定。


七、稍微总结一下

Superpowers 和 ui-ux-pro-max 的真正价值,不在于它们能把 AI 变成“全自动高级工程师”,而在于它们分别补上了两个最常见的缺口:一个补流程,一个补设计。

前者让任务不那么容易一路失控,后者让前端结果不那么容易停留在“有组件,但没产品感”的层面。

它们不是所有项目都需要的东西,也不是装完就一定立刻见效的东西。你还是得判断任务值不值得走完整流程,还是得亲自做关键决策,还是得面对代码基线、团队习惯和项目复杂度这些很现实的问题。

最后别只停留在“看”的层面,亲自去试一试。这是一个新的世界,尽力去拥抱,别被甩开。

Superpowers 适合把大任务拉回工程轨道,ui-ux-pro-max 适合把前端结果拉回产品语境。 它们真正有用的时候,通常不是在“随便试试”的那一刻,而是在你开始认真交付一个项目的时候。

附:跑通这套流程,你还需要一个顺手的模型接入层

用 Superpowers 推进大任务、用 ui-ux-pro-max 打磨前端,本质上都是在让模型做更多事。做得越多,Token 消耗越真实,模型选型的代价也越具体。

用 Claude Code 跑完一个 RBAC 项目的完整流程,从 brainstorm 到集成交付,实际花费可能远超你的预期——尤其是在 subagent 并行、多轮 review、TDD 来回循环的场景下。

这时候你会开始真正关心几个问题:

  • 这个阶段该用旗舰模型,还是可以切小模型?
  • 缓存命中率到底有多少?前缀是否稳定?
  • 每一步的输入输出 Token 分别是多少,优化前后差多少?

要回答这些问题,你需要的不只是模型能力,还需要一个计费清晰、可以自由切换模型、方便做 A/B 对比的接入层。

我朋友超哥在做的 Amux API 正好适合这个场景:

  • 多模型统一接入:Claude、GPT、Gemini 等主流模型一个 Key 搞定,方便按阶段切换,不用维护多套账号;
  • 成本与用量可视化:输入、输出、缓存命中分开统计,跑完一个 Phase 就能看到真实账单,不是估算;
  • 贴近工程实际:用同一套业务请求验证"上下文裁剪""前缀稳定""输出约束"等策略的实际收益,数字说话。

如果你打算认真跑一遍本文的 RBAC 流程,或者手头有类似的中大型 AI 辅助开发项目,不妨把它作为模型接入层试一试。

💬 选平台时,倍率只是起点。充值口径、缓存表现、计费透明度、稳定性,这几项加在一起才决定它是否真的适合工程场景。

写在最后

🧪 这里是言萧凡的 AI 编程实验室

我会在这里持续记录和分享 AI 工具、编程实践,以及那些値得沉淀下来的高效工作方法。

不只聊概念,也尽量分享能直接上手、能够复用的经验。

希望这间小小的实验室,能陪你一起探索、实践和成长。

2026 年,一起进步。

setTimeout设为0就马上执行?JS异步背后的秘密

作者 牛奶
2026年4月6日 20:52

你有没有遇到过这种情况:代码里写了 setTimeout(fn, 0),心想这下该马上执行了吧?结果发现,还是慢了一拍。还有,为什么 PromisesetTimeout 先执行?async/await 到底在等什么?

今天,用餐厅点餐的故事,来讲讲 JavaScript 事件循环。


原文地址

墨渊书肆/setTimeout设为0就马上执行?JS异步背后的秘密


为什么需要事件循环?

单线程的困境

JavaScript 是单线程的——同一时间只能做一件事。

就像只有一个厨师的小餐厅:如果厨师做完一道菜才接下一单,客人等得头发都白了。

所以 JavaScript 采用了异步回调的方式:点完单先去干别的,菜好了再叫你。

事件循环就是"传唤员"

事件循环就像餐厅里的传唤员

  • 厨房做好了菜,传唤员看看单子,喊"33号,你的菜好了"
  • 如果你正在吃饭(执行其他代码),传唤员就等着
  • 轮到你的时候,你放下筷子(执行完当前代码),去取菜(执行回调)

调用栈 — 厨师的工作台

代码是怎么"跑起来"的?

当你调用一个函数,这个函数就被放进调用栈里执行。

就像厨师在工作台上,一边做菜一边接新单,做完一单马上处理下一单:

function cooking() {
  console.log('开始炒菜');
  fry();
  console.log('炒好了');
}

function fry() {
  console.log('放油');
  console.log('放菜');
  console.log('翻炒');
}

cooking();

执行顺序:

调用栈:
1. cooking() 入栈
2. console.log('开始炒菜') 入栈,执行,出栈
3. fry() 入栈
4. fry() 内的 console.log 依次执行
5. fry() 出栈
6. console.log('炒好了') 入栈,执行,出栈
7. cooking() 出栈

调用栈的特点

  • 后进先出:就像叠盘子,最后放上去的先被用
  • 同步执行:每个函数必须执行完,下一个才能进来
  • 栈溢出:如果递归没终止,栈会无限增长直到崩溃
// 栈溢出示例
function recursive() {
  recursive();
}
recursive();
// RangeError: Maximum call stack size exceeded

任务队列 — 取餐口

异步代码放哪儿?

当遇到 setTimeoutPromise事件回调 这些异步任务时,它们不会马上执行,而是被放到任务队列里。

就像点完单,服务员把单子放到取餐口,等叫号再去取。

事件循环的运行机制

┌─────────────────────┐
       调用栈            正在执行
   (Call Stack)       
└─────────────────────┘
          
┌─────────────────────┐
      任务队列           等待执行
   (Task Queue)       
└─────────────────────┘
          
    事件循环 (Event Loop)
    "栈空了?好,取下一个"

事件循环的规则

  1. 首先执行调用栈里的所有同步代码
  2. 调用栈清空后,去任务队列取一个任务执行
  3. 完成后回到步骤1
console.log('1');

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

console.log('3');

// 输出:1 → 3 → 2
// 因为 setTimeout 的回调在任务队列,要等调用栈空才能执行

微任务 vs 宏任务 — VIP和普通号

两种不同的"队"

任务队列其实分两种:

类型 例子 优先级
宏任务(Macrotask) setTimeoutsetIntervalI/OUI渲染
微任务(Microtask) Promise.then()回调、MutationObserverqueueMicrotask

就像餐厅里:

  • 宏任务 = 普通取餐号,要排队
  • 微任务 = VIP会员卡,来了直接优先处理

注意:不是 Promise 本身是微任务,而是 Promise.then() 的回调函数是微任务。

执行顺序

console.log('1');

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

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

console.log('4');

// 输出:1 → 4 → 3 → 2
// 同步代码 → 微任务 → 宏任务

完整执行流程

setTimeout(() => console.log('setTimeout'), 0);

Promise.resolve()
  .then(() => console.log('Promise1'))
  .then(() => console.log('Promise2'));

Promise.resolve()
  .then(() => console.log('Promise3'));

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

// 输出顺序:
// 同步代码
// Promise1
// Promise3
// Promise2      ← Promise.then 链式调用在同一个微任务队列
// setTimeout     ← 所有微任务完成后,才执行宏任务

嵌套的 Promise

Promise.resolve().then(() => {
  console.log('第一个微任务');

  Promise.resolve().then(() => {
    console.log('嵌套的微任务');
  });
});

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

// 输出:
// 同步代码
// 第一个微任务
// 嵌套的微任务
// 微任务队列清空后,才会执行下一个宏任务

async/await — 语法糖的秘密

async/await 是什么?

async/await 是 Promise 的语法糖,让异步代码看起来像同步代码。

// Promise 写法
function getData() {
  return fetch('/api/user')
    .then(res => res.json())
    .then(data => console.log(data));
}

// async/await 写法
async function getData() {
  const res = await fetch('/api/user');
  const data = await res.json();
  console.log(data);
}

await 到底在等什么?

await暂停当前 async 函数的执行,等待 Promise 完成,然后继续执行后面的代码。

暂停期间,其他代码可以继续执行

async function example() {
  console.log('1');

  await fetch('/api/data');  // 这里"暂停"

  console.log('3');  // ← 这行去哪了?
}

console.log('2');
example();
console.log('4');

// 输出:2 → 1 → 4 → 3

await 后面那行代码去哪了?

await 后面的代码不会马上执行,而是被包成一个微任务。等 await 的 Promise resolve 后,这个微任务才会执行:

async function example() {
  console.log('1');

  await fetch('/api/data');  // Promise pending...
  // 下面的代码被包成微任务,要等 Promise 完成才执行

  console.log('3');  // ← 这行实际上是 await 的 resolve 后的回调
}

// 等价于:
function example() {
  console.log('1');
  return fetch('/api/data').then(() => {
    console.log('3');  // ← 这里
  });
}

async 函数返回值

async 函数总是返回一个 Promise

async function getNumber() {
  return 42;
}

getNumber().then(console.log);  // 42

// 等价于:
async function getNumber() {
  return Promise.resolve(42);
}

错误处理

// try-catch
async function fetchData() {
  try {
    const res = await fetch('/api/data');
    const data = await res.json();
  } catch (error) {
    console.log('出错了:', error);
  }
}

// Promise catch
async function fetchData() {
  const res = await fetch('/api/data').catch(err => console.log(err));
}

requestAnimationFrame — 动画的正确姿势

为什么不用 setInterval?

setInterval 不保证什么时候执行,也不保证每次间隔精确:

setInterval(() => {
  moveBall();  // 可能丢帧、卡顿
}, 16);  // 约60fps,但不一定准

requestAnimationFrame 的特点

  • 浏览器优化:在下一次重绘之前执行,不丢帧
  • 页面不可见时:自动暂停,节省性能
  • 约60fps:和屏幕刷新率同步
function animate() {
  moveBall();
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

// 取消动画
const id = requestAnimationFrame(animate);
cancelAnimationFrame(id);

执行顺序

用户点击
   
事件触发
   
微任务(全部清空)← 先清空所有微任务
   
宏任务
   
requestAnimationFrame   所有微任务清空后,渲染之前
   
浏览器渲染

深入了解事件循环 🔬

Node.js 的事件循环

Node.js 和浏览器的事件循环不一样

┌───────────────────────────────────────────────────────┐
│                    Node.js 事件循环                    │
├───────────────────────────────────────────────────────┤
│  ① Timers          →  setTimeout, setInterval 回调    │
│  ② Pending I/O     →  I/O callbacks(延迟到下一循环)   │
│  ③ Idle/Prepare    →  内部使用                         │
│  ④ Poll            →  获取新 I/O 事件                  │
│  ⑤ Check           →  setImmediate 回调               │
│  ⑥ Close           →  close 事件回调                   │
└────────────────────────────────────────── ────────────┘

浏览器和 Node.js 的区别

// 浏览器
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// 输出:microtask → timeout

// Node.js(可能不同)
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// 可能输出:microtask → timeout
// 但 setImmediate 可能更早

queueMicrotask vs Promise.then

queueMicrotask 显式创建一个微任务:

queueMicrotask(() => {
  console.log('我也是微任务');
});

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

// 两者都是微任务,执行顺序相同

浏览器渲染时机

不是每次事件循环都会渲染,浏览器会批量处理

// 可能只触发一次重排/重绘
div.style.top = '100px';
div.style.left = '100px';
div.style.width = '200px';

// 而不是三次单独的重排

任务分解 — 避免卡顿

长时间任务可以分解,让页面保持响应:

function processItems(items) {
  let i = 0;

  function step() {
    // 处理一项
    process(items[i]);

    i++;
    if (i < items.length) {
      // 用 setTimeout 让出主线程
      setTimeout(step, 0);
    }
  }

  step();
}

// 现代浏览器可以用 scheduler.yield()
async function processItems(items) {
  for (const item of items) {
    process(item);
    await scheduler.yield();  // 让出主线程
  }
}

横向对比

API 类型 优先级 使用场景
setTimeout 宏任务 延迟执行、轮询
setInterval 宏任务 定时任务(慎用)
Promise.then 微任务 异步结果处理
async/await 微任务 异步代码写法
requestAnimationFrame 宏任务 动画、游戏循环
MutationObserver 微任务 DOM 变化监听

怎么选?

场景 推荐
延迟执行 setTimeout
等待 Promise await / Promise.then
动画/游戏 requestAnimationFrame
批量 DOM 操作 MutationObserver
分解长任务 setTimeout / scheduler.yield()

总结

概念 像什么 作用
调用栈 厨师灶台 同步代码执行
任务队列 取餐口 等待执行的异步任务
宏任务 普通取餐号 setTimeout、setInterval
微任务 VIP会员卡 Promise、queueMicrotask
事件循环 传唤员 协调调用栈和任务队列

同步代码 → 微任务 → 宏任务 → 渲染 → 下一轮


写在最后

现在你应该明白了:

  • setTimeout(fn, 0) 不是马上执行,要等调用栈空、微任务清空后才轮到你
  • PromisesetTimeout 先执行,因为微任务优先级更高
  • async/await 只是 Promise 的语法糖,本质还是异步
  • requestAnimationFrame 是做动画的正确方式,别用 setInterval

下次你的代码执行顺序不对,先看看是微任务还是宏任务——可能就是它插队了。

5MB vs 4KB vs 无限大:浏览器存储谁更强?

作者 牛奶
2026年4月6日 20:44

你有没有想过这个问题:为什么在网页上勾选了"记住我",下次打开还是登录状态?你改了个主题设置,关掉浏览器再打开,主题还在?浏览器是怎么记住这些数据的?

今天,用**"收纳房间"**的故事,来讲讲浏览器存储。


原文地址

墨渊书肆/5MB vs 4KB vs 无限大:浏览器存储谁更强?


浏览器是怎么"装东西"的?

想象一下你家要装修,需要各种收纳工具:

  • 贴身口袋:装点小东西,随时能用
  • 床头柜:装常用物品,随取随用
  • 衣柜:装换季衣服,大容量
  • 仓库:存大件物品,最大但找起来麻烦

浏览器存储也是这个道理。不同的数据,要用不同的"收纳工具"。


Cookie — 贴身口袋

像个口袋,随身带

Cookie 是最"古老"的浏览器存储方案。它最大的特点是——会自动跟着请求一起发出去

就像你出门带了个口袋,里面装着身份证、银行卡。进任何一家店,都要掏出身份证证明身份。

浏览器也是:每次请求网页,Cookie 都自动带上,服务器就知道"哦,这是张三的浏览器"。

Cookie 的特点

属性 像什么
容量 ~4KB 口袋里只能装这么多
发送 自动随请求发送 出门就带
生命周期 可设置过期时间 可以设有效期
访问 JS和服务器都能读 谁都能用

Cookie 的使用场景

  • 登录状态:"记住我"功能
  • 购物车:逛淘宝加购物车
  • 追踪分析:埋点上报

Cookie 的代码

// 设置Cookie
document.cookie = "username=张三; expires=Fri, 31 Dec 2026 23:59:59 GMT; path=/";

// 读取Cookie
console.log(document.cookie);  // "username=张三; theme=dark"

Cookie 的安全问题

Cookie 虽然方便,但有几个安全属性要注意:

属性 作用 什么意思
HttpOnly JS无法访问 口袋上锁了,店员碰不到
Secure 只在HTTPS发送 只能用加密通道
SameSite 防止CSRF攻击 别人拿不到你的卡

深入了解 Cookie 🔬

Cookie 是怎么工作的?

Cookie 由 HTTP 协议定义,通过 Set-Cookie 响应头设置:

HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=Strict

HTTP/1.1 200 OK
Cookie: sessionId=abc123

浏览器怎么存 Cookie?

每个浏览器都有自己的存储方式:

浏览器 存储位置
Chrome/Edge SQLite 数据库 (%APPDATA%\Local\Google\Chrome\User Data\Default\Cookies)
Firefox JSON 文件 (cookies.sqlite)
Safari 二进制文件

Cookie 的发送规则?

浏览器根据 Domain + Path + SameSite 三个规则决定是否发送:

// 例如:Cookie 设置为 Domain=example.com, Path=/admin
// 会发送给:
// ✅ example.com/admin
// ✅ example.com/admin/users
// ❌ example.com/ (path不匹配)
// ❌ other.com/admin (domain不匹配)

Session Cookie vs 持久 Cookie?

# 会话Cookie(没有Expires/Max-Age)
Set-Cookie: sessionId=abc123
# 关掉浏览器就失效

# 持久Cookie
Set-Cookie: sessionId=abc123; Expires=Wed, 01 Jan 2027 00:00:00 GMT
# 有效期内都有效

LocalStorage — 床头柜

容量大,但不主动发

LocalStorage 是 HTML5 引入的存储方案。最大的特点:不会随请求发出去

就像床头柜——你把东西放里面,下次进门直接拿,不用每次出门都背着。

LocalStorage 的特点

属性 像什么
容量 ~5MB/域 床头柜大小
发送 不随请求发送 不随身带
生命周期 永久存储 除非搬家(手动删除)
API 同步操作 马上拿到

LocalStorage 的使用场景

  • 主题设置:深色/浅色模式
  • 用户偏好:字体大小、语言设置
  • 数据缓存:接口数据本地缓存

LocalStorage 的代码

// 设置
localStorage.setItem('username', '张三');
localStorage.setItem('theme', 'dark');

// 读取
const theme = localStorage.getItem('theme');  // 'dark'

// 删除
localStorage.removeItem('theme');

// 清空
localStorage.clear();

// 遍历
for (let i = 0; i < localStorage.length; i++) {
  const key = localStorage.key(i);
  console.log(`${key}: ${localStorage.getItem(key)}`);
}

LocalStorage 的缺点

  • 同步操作:大量数据会卡界面
  • 只能存字符串:对象要转成 JSON
  • 容量有限:5MB 对大数据不够

深入了解 LocalStorage 🔬

同源策略限制

LocalStorage 遵循同源策略:

✅ http://example.com 和 https://example.com 共享同一个Storage
✅ http://example.com:8080 和 http://example.com:3000 不共享(端口不同)
✅ http://www.example.com 和 http://example.com 不共享(子域名不同)

存储配额

实际容量取决于浏览器和磁盘空间,Chrome 默认是 5MB(可申请更多):

// 查询当前配额和使用量
navigator.storage.estimate().then(({ usage, quota }) => {
  console.log(`已使用: ${(usage / 1024 / 1024).toFixed(2)} MB`);
  console.log(`总配额: ${(quota / 1024 / 1024).toFixed(2)} MB`);
});

// 请求更大的存储空间(需要用户授权)
navigator.storage.persist().then((granted) => {
  console.log('永久存储权限:', granted);
});

为什么 LocalStorage 是同步的?

因为 LocalStorage 读取是直接读磁盘。如果数据量大,同步读取会阻塞主线程:

// ❌ 错误:大数据量时卡界面
localStorage.setItem('bigData', JSON.stringify(largeArray));

// ✅ 更好:拆分存储或用 IndexedDB

SessionStorage — 抽屉

只在当前标签页有效

SessionStorageLocalStorage 几乎一样,唯一的区别是——关闭标签页就没了

就像抽屉里的东西,只有在这个房间能用。换到另一个房间(另一个标签页),抽屉里的东西就不在了。

SessionStorage 的特点

属性 和LocalStorage的区别
容量 ~5MB/域 一样
作用域 仅当前标签页 ❌ 跨标签页不共享
生命周期 关闭标签页失效 ❌ 不能持久保存

SessionStorage 的使用场景

  • 表单草稿:填写到一半的表单
  • 临时状态:当前页面的操作状态

SessionStorage 的代码

// 用法和LocalStorage完全一样
sessionStorage.setItem('draft', JSON.stringify({ title: '我的文章', content: '...' }));

关键区别

// 标签页A中设置
sessionStorage.setItem('key', 'value');
localStorage.setItem('key', 'value');

// 在标签页B中读取
sessionStorage.getItem('key');  // null ❌
localStorage.getItem('key');    // 'value' ✅

深入了解 SessionStorage 🔬

iframe 共享问题

注意:同一个标签页中的 iframe 会共享 SessionStorage(因为是同一个浏览器标签页):

// 父页面
sessionStorage.setItem('shared', 'value');

// iframe 内可以读取到
console.log(sessionStorage.getItem('shared'));  // 'value'

sessionStorage 在隐私模式下

  • Chrome 无痕模式sessionStorage 仍然存在,但标签页关闭后失效
  • Firefox 隐私窗口:完全隔离,每个新窗口都是新的 sessionStorage

和 LocalStorage 的性能对比

两者都是同步 API,性能特性相同。但 SessionStorage 因为数据不持久,有时候比 LocalStorage 更适合存临时数据。


IndexedDB — 仓库

浏览器里的数据库

IndexedDB 是浏览器内置的数据库。容量巨大,能存文件、音频、视频这些大东西。

就像仓库——你家装修工具、电风扇、行李箱都放这儿。东西多,但找起来要翻半天。

IndexedDB 的特点

属性 像什么
容量 很大(取决于磁盘) 仓库,接近无限
数据类型 什么都能存 不挑东西
API 异步操作 异步,不卡界面
查询 支持索引 能分类查找

IndexedDB 的使用场景

  • 离线数据:PWA离线应用
  • 多媒体存储:图片、音频、视频缓存
  • 复杂数据:需要索引查询的数据

IndexedDB 的代码

// 打开数据库
const request = indexedDB.open('myDatabase', 1);

// 创建表(对象存储)
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  const store = db.createObjectStore('users', { keyPath: 'id' });
  store.createIndex('name', 'name', { unique: false });
  store.createIndex('email', 'email', { unique: true });
};

// 添加数据
request.onsuccess = (event) => {
  const db = event.target.result;
  const tx = db.transaction(['users'], 'readwrite');
  const store = tx.objectStore('users');

  store.add({ id: 1, name: '张三', email: 'zhangsan@example.com' });
  store.add({ id: 2, name: '李四', email: 'lisi@example.com' });
};

// 查询数据
const getRequest = store.get(1);
getRequest.onsuccess = () => {
  console.log('查询结果:', getRequest.result);
};

// 使用索引查询
const index = store.index('name');
const indexRequest = index.get('张三');
indexRequest.onsuccess = () => {
  console.log('索引查询结果:', indexRequest.result);
};

IndexedDB 的缺点

  • API 复杂:需要写一堆回调
  • 学习成本高:概念多(数据库、表、事务、索引)

深入了解 IndexedDB 🔬

数据库版本和升级

const request = indexedDB.open('myDatabase', 2);  // 版本号从1升到2

request.onupgradeneeded = (event) => {
  const db = event.target.result;

  // 创建新存储
  if (!db.objectStoreNames.contains('products')) {
    db.createObjectStore('products', { keyPath: 'id' });
  }

  // 删除旧存储
  if (db.objectStoreNames.contains('oldData')) {
    db.deleteObjectStore('oldData');
  }
};

事务的原子性

const tx = db.transaction(['users', 'orders'], 'readwrite');

// 两个操作在一个事务里,要么全成功,要么全失败
tx.objectStore('users').add({ id: 1, name: '张三' });
tx.objectStore('orders').add({ id: 1, userId: 1, product: '电脑' });

tx.oncomplete = () => console.log('事务成功');
tx.onerror = () => console.log('事务失败,全部回滚');

游标遍历大量数据

const tx = db.transaction(['users'], 'readonly');
const store = tx.objectStore('users');
const cursor = store.openCursor();

cursor.onsuccess = (event) => {
  const cur = event.target.result;
  if (cur) {
    console.log('用户:', cur.value.name);
    cur.continue();  // 继续下一个
  }
};

Promise 封装(更简洁的写法)

function openDB(name, version) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);
    request.onupgradeneeded = (e) => resolve(e.target.result);
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

// 使用
const db = await openDB('myDatabase', 1);
const tx = db.transaction('users', 'readwrite');
await tx.objectStore('users').add({ id: 1, name: '张三' });

Cache API — 集装箱

Service Worker 的专属工具

Cache API 是 Service Worker 的一部分,专门用来缓存网络请求。

就像集装箱——你坐飞机带不了大件行李,但可以用集装箱海运。东西多、个头大,但只能走特定渠道。

Cache API 的特点

属性 像什么
容量 很大 集装箱,装得多
存储内容 Request/Response 对 整套打包
生命周期 手动管理 不用就扔
API 异步操作 不卡界面

Cache API 的使用场景

  • 离线应用:把整个网站缓存下来
  • 性能优化:缓存静态资源
  • Service Worker:配合SW实现缓存策略

Cache API 的代码

// 在Service Worker中使用
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        return cachedResponse || fetch(event.request);
      })
  );
});

// 打开缓存
caches.open('my-cache').then((cache) => {
  cache.addAll([
    '/css/style.css',
    '/js/app.js',
    '/images/logo.png'
  ]);
});

// 缓存特定请求
cache.put(request, response);

// 删除缓存
caches.delete('my-cache');

深入了解 Cache API 🔬

缓存策略

// Cache First(缓存优先)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => response || fetch(event.request))
  );
});

// Network First(网络优先)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .catch(() => caches.match(event.request))
  );
});

// Stale-While-Revalidate(先返回缓存,同时更新缓存)
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('my-cache').then((cache) => {
      return cache.match(event.request).then((response) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    })
  );
});

Cache API 和 cookies

Cache API 存储的是完整的 Request/Response 对,不只是 body:

// 缓存时包含了headers、status等所有信息
cache.match(request).then((response) => {
  console.log(response.status);      // 200
  console.log(response.headers.get('content-type'));  // 'text/html'
});

缓存清理策略

// 删除指定缓存
caches.delete('old-cache');

// 清理所有版本,只保留最新的
caches.keys().then((cacheNames) => {
  Promise.all(
    cacheNames
      .filter((name) => name.startsWith('app-') && name !== 'app-v2')
      .map((name) => caches.delete(name))
  );
});

Storage Event — 跨标签页喊话

标签页之间能"喊话"

当 LocalStorage 发生变化时,其他同源的标签页会收到通知。

就像你在客厅喊了一句"饭好了",厨房的人、卧室的人都能听到。

Storage Event 的代码

// 标签页A中监听
window.addEventListener('storage', (event) => {
  console.log('key:', event.key);      // 变化的键
  console.log('oldValue:', event.oldValue);  // 旧值
  console.log('newValue:', event.newValue);  // 新值
  console.log('url:', event.url);      // 触发变化的页面URL
  console.log('storageArea:', event.storageArea);  // localStorage 或 sessionStorage
});

// 标签页B中修改
localStorage.setItem('theme', 'dark');  // 标签页A会收到通知

使用场景

  • 多标签页同步:一个标签页登录,其他标签页同步登录状态
  • 状态广播:跨标签页的状态通知

深入了解 Storage Event 🔬

Storage Event 的触发条件

// ✅ 会触发 storage 事件
localStorage.setItem('key', 'value');
localStorage.removeItem('key');
localStorage.clear();

// ❌ 不会触发 storage 事件(同一个标签页)
// Storage Event 只在「其他标签页」变化时触发

SessionStorage 也会触发?

注意:SessionStorage 本身不跨标签页共享,但 Storage Event 只监听 localStorage 的变化。

// SessionStorage 变化不会触发 storage 事件
sessionStorage.setItem('key', 'value');  // 不会触发其他标签页

// localStorage 变化会触发
localStorage.setItem('key', 'value');  // 其他标签页会收到通知

隐私模式下不触发

在无痕/隐私模式下,Storage Event 不会触发,这是浏览器的隐私保护机制。


横向对比

特性 Cookie LocalStorage SessionStorage IndexedDB Cache API
容量 ~4KB ~5MB ~5MB 很大 很大
生命周期 可设置 永久 关闭失效 永久 手动
发送 自动发 不发 不发 不发 不发
API 简单 同步简单 同步简单 异步复杂 异步
数据类型 字符串 字符串 字符串 所有可序列化 Request/Response
跨标签页 共享 共享 不共享 共享 不共享

怎么选?

场景 推荐
需要服务器读取 Cookie
存用户偏好、主题 LocalStorage
临时状态、标签页隔离 SessionStorage
大数据、离线存储 IndexedDB
Service Worker缓存 Cache API

注意事项

1. 不要存敏感信息

LocalStorage 可以被 JS 访问,XSS 攻击能偷走数据。敏感信息用 HttpOnly Cookie。

2. 存储配额

浏览器对存储有限制,可以用 API 查询:

navigator.storage.estimate().then(({ usage, quota }) => {
  console.log('已用:', (usage / 1024 / 1024).toFixed(2), 'MB');
  console.log('总配额:', (quota / 1024 / 1024).toFixed(2), 'MB');
});

3. 序列化问题

LocalStorage 和 SessionStorage 只能存字符串,对象要转 JSON:

// 存
localStorage.setItem('data', JSON.stringify({ name: '张三' }));

// 取
const data = JSON.parse(localStorage.getItem('data'));

4. 同步 API 的性能问题

LocalStorage/SessionStorage 是同步操作,大量数据会阻塞主线程:

// ❌ 不好:大量数据卡界面
for (let i = 0; i < 10000; i++) {
  localStorage.setItem(`key${i}`, `value${i}`);
}

// ✅ 更好:用 IndexedDB 存储大量数据

总结

存储方式 像什么 特点
Cookie 口袋 小、随请求发、安全属性多
LocalStorage 床头柜 5MB、不发送、永久
SessionStorage 抽屉 5MB、不发送、仅标签页
IndexedDB 仓库 巨大、异步、复杂
Cache API 集装箱 Service Worker专用

选对"收纳工具",数据管理更轻松。


写在最后

现在你应该明白了:

  • Cookie = 口袋,随身带、自动发送、容量小
  • LocalStorage = 床头柜,大容量、不发送、永久保存
  • SessionStorage = 抽屉,只在当前标签页有效
  • IndexedDB = 仓库,最大但操作复杂
  • Cache API = 集装箱,Service Worker专用

下次你在网页上勾选"记住我",或者调整了主题设置——你就知道浏览器是用哪种"收纳工具"帮你存的了。

【节点】[Posterize节点]原理解析与实际应用

作者 SmalBox
2026年4月6日 20:20

【Unity Shader Graph 使用与特效实现】专栏-直达

Posterize 节点是 Unity URP ShaderGraph 中实现色调分离效果的核心工具,其技术本质是通过离散化算法将连续数值空间转换为阶梯状色阶。该节点的数学实现基于经典量化函数:

Out = floor(In * Steps) / Steps

其中 floor 函数负责向下取整操作。在具体实现中,节点通过输入值(In)和色阶数量(Steps)两个关键参数,将颜色、UV 坐标或多通道数据分割为指定数量的等距区间,每个区间内的连续值被统一映射为离散的阶梯值,最终形成类似海报印刷的视觉效果。

端口功能深度解析

  • 输入端口 In‌:支持从标量(float)到四维向量(float4)的全类型动态输入,可灵活处理颜色 RGBA 通道、位置坐标、法线向量等多维数据。实际应用中,开发者可将任意连续数据流接入此端口,实现从色彩管理到几何变形的多样化效果。
  • Steps 参数‌:控制色阶数量的核心参数,每个分量必须 ≥1。当 Steps=4 时,原本连续的 0-1 色调范围将被精确分割为[0,0.25)、[0.25,0.5)、[0.5,0.75)、[0.75,1.0]四个等距区间,每个区间内的值都会映射到对应的离散值(0、0.25、0.5、0.75)。
  • 输出端口 Out‌:返回经过离散化处理的数据,输出维度与输入端口保持严格一致,确保数据流的完整性。

技术优势与实现细节

  • 多维度统一处理‌:支持同时处理颜色空间、纹理坐标、法线贴图等多通道数据,实现全通道的同步色调分离。在高级应用中,开发者可分别控制各通道的 Steps 参数,实现 RGB 通道独立分离等复杂效果。
  • 参数化艺术控制‌:通过 Steps 参数的动态调整,可实现从照片级真实感(Steps=256)到极致简约风格(Steps=2)的平滑过渡,完美适配 Low Poly、像素艺术、复古海报等多样化艺术风格需求。
  • GPU 并行优化‌:相比传统手写 HLSL 代码,Posterize 节点通过 ShaderGraph 的优化编译流程,充分利用 GPU 的并行计算架构,在移动端和高端设备上均能保持出色的渲染性能。

行业应用场景扩展

游戏美术风格化渲染

在独立游戏开发中,通过将 Steps 参数设置为 3-6 区间,配合 UV 坐标的 Posterize 处理,可快速生成低多边形建模的视觉特征。典型案例包括:将角色模型的 UV 坐标 X/Y 分量分别离散化处理,配合颜色通道的阶梯化调整,实现类似《纪念碑谷》的几何艺术风格。同时,通过法线贴图的 Posterize 处理,可在保持模型细节的同时强化风格化表现。

影视级后期处理效果

在虚拟制片领域,结合 Steps=4-8 的色阶分离与动态饱和度调整,可精准模拟 20 世纪海报印刷的经典网点效果。工业化应用流程包括:

  • 使用 Posterize 节点分层处理基础颜色、高光和阴影通道
  • 通过 Saturation 节点分区域增强色彩对比度
  • 添加 Dither 节点模拟印刷半色调和网点纹理
  • 结合 Camera Stacking 技术实现非破坏性后期处理

专业数据可视化系统

在科学计算可视化领域,Posterize 节点可将连续物理数据(如温度梯度、气压分布、地震波强度)转换为清晰的色阶图谱。典型实现方案:将热力图原始数据离散化为 8-16 个精确色阶,通过 Color Mapping 节点实现数据到颜色的智能映射,大幅提升科研人员的数据解读效率。在气象预报、地质勘探等专业领域具有重要应用价值。

交互艺术装置设计

在新媒体艺术领域,Posterize 节点结合实时输入数据,可创建动态变化的视觉艺术装置。通过连接音频分析器输出的频谱数据,驱动 Steps 参数的动态变化,实现音乐可视化效果。同时配合 Kinect 等体感设备,实现基于人体运动的实时色调分离交互。

实战案例深度优化

自适应马赛克效果实现

  • 创建增强型 URP Lit ShaderGraph‌:在 Project 面板右键 Create → Shader → URP → Lit ShaderGraph,并启用 Advanced 选项
  • 智能化节点连接方案‌:
    • 添加 High Definition Render Pipeline Texture 节点作为基础颜色输入
    • 使用 Dual Posterize 节点架构分别处理 UV 坐标的 U/V 分量
    • 通过 Remap 节点精确控制离散化后的数值范围
    • 将最终结果连接至 BaseColor 和 Emission 通道,增强视觉效果
  • 动态参数控制系统‌:通过材质面板的 Float 参数动态调节 Steps 值,并结合 Vertex Color 通道实现基于模型空间的位置遮罩,打造局部马赛克特效。

影视级动态色调分离

  • 创建可复用子图系统‌:将 Posterize 节点封装为功能完整的子图,支持 In、Steps、Intensity 等多个输入参数
  • 高级动态控制网络‌:
    • 添加 Sine Time 节点驱动 Steps 参数的周期性变化
    • 使用 Smoothstep 节点实现色阶数量的非线性过渡
    • 通过 Animation Curve 节点精确控制离散化效果的演变轨迹
  • 多通道协同应用‌:结合 Normal Map 节点和 Height Map 节点,实现法线贴图和视差效果的同步离散化,增强材质的体积感和细节表现力。

性能优化与多平台兼容性方案

精细化性能影响分析

Posterize 节点的性能消耗主要受三个维度影响:

  • 输入数据的复杂度:标量计算消耗最低,四维向量处理消耗最高
  • Steps 参数的数值大小:测试显示 Steps 从 8 增加到 64,性能消耗提升约 40%
  • 多通道并行使用:同时处理颜色、UV 和法线通道时,需要特别注意移动端性能表现

实际性能测试数据表明,在高端移动设备(骁龙 888)上,对 RGBA 颜色通道使用 Steps=8 的 Posterize 处理,帧率下降约 12-15%;而在低端设备(骁龙 660)上,相同操作可能导致帧率下降 25-30%。

全平台兼容性解决方案

  • URP 版本智能适配‌:确保使用 Unity 2019.4 LTS 及以上版本,并安装 URP 7.0+ 版本包
  • 跨平台差异化处理‌:针对 iOS/Android/PC 等不同平台,通过 Graphics Settings 中的 Quality Levels 自动配置最优 Steps 参数
  • 性能优先替代方案‌:对性能敏感的场景,可使用 Half Precision Posterize 节点(减少 50% 计算量)或预计算离散化贴图

常见问题与系统性解决方案

输出异常深度排查

  • 现象分析‌:Posterize 节点输出全黑或全白
  • 根本原因‌:Steps 参数设置过小(<2)导致过度离散化,或输入数据范围超出预期
  • 系统解决方案‌:
    • 使用 Clamp 节点限制 Steps 参数的有效范围(2-256)
    • 通过 Normalize 节点预处理输入数据,确保数值在 0-1 范围内
    • 添加 Debug 节点实时监控各端口数据流

材质渲染错误综合处理

  • 现象识别‌:应用 Posterize 材质后模型出现闪烁、撕裂或颜色异常
  • 多维原因分析‌:URP 渲染管线配置错误、Shader Graph 版本不兼容、材质参数越界等
  • 完整解决流程‌:
    • 通过 Edit → Project Settings → Graphics 确认 Scriptable Render Pipeline 设置
    • 检查 Package Manager 中 Shader Graph 版本与 URP 版本的匹配性
    • 使用 Frame Debugger 逐帧分析渲染状态

进阶技巧与创新应用扩展

非线性色阶分离技术

通过自定义 Steps 曲线实现艺术化离散效果:

  • 添加 Animation Curve 节点控制 Steps 参数的动态变化
  • 使用 Curve Mapping 实现 RGB 通道的独立离散化控制
  • 结合 Noise 节点创建有机变化的色阶分离效果

多节点协同创新应用

  • 与 Hue/Saturation/Luminance 节点组合‌:构建完整的色彩管理系统,实现先分离色阶再精细调整色调的工业化流程
  • 与 Blend 节点深度结合‌:创建多层混合材质,实现局部色调分离与全局效果的完美融合
  • 与 Mask 节点智能联动‌:通过顶点颜色、纹理坐标或深度信息生成动态遮罩,精确控制 Posterize 效果的应用区域和强度

移动端专项优化体系

  • 自适应 LOD 系统‌:根据设备 GPU 性能自动调整 Steps 值和计算精度
  • 预计算优化策略‌:对静态场景元素使用预计算的 Posterize 贴图阵列
  • 精度控制矩阵‌:通过 Shader 属性中的 Precision 选项分级控制计算精度,平衡效果与性能

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

我用 1 天的时间 vibe coding 了一个多人德州扑克游戏

作者 lihaozecq
2026年4月6日 20:13

这是我第一次全程没有修改 AI 代码的 vibe coding 体验,整体下来除了交互和边界 case 需要反复与 AI 确认,功能层面 AI 完全可以顺利搞定,甚至出乎我的意料。

背景

业余时间会跟异地朋友视频聊天,吐槽吐槽生活,偶尔会通过视频的方式娱乐下比如德州扑克,之前用过其他软件但功能繁琐,又需要做任务获取金币。所以一直想自己做一个在线的德州扑克游戏,由于开发工作量比较大,一直搁置。(之前也尝试通过 AI 去实现,但整体效果一般就放弃了)。这周末心血来潮,想到 Harness Engineering 理念这么火,决定用 Claude Code 尝试一下。

先看成果:

游戏桌效果

在线体验:texas-holdem-production-c30c.up.railway.app | 开源仓库:GitHub

过程中用到的工具

Claude Code CLI

本项目全程使用 Claude Code CLI 完成,模型为 Opus 4.6(1M context)。使用最强模型可以提高整体的代码质量,节省很多给反馈给 AI 的沟通时间。

Superpowers Skills

Superpowers 是一套 Claude Code 的 Skills 插件。它的价值在于让 AI 在动手之前先思考。尤其是 brainstorming,当我说实现一个德州扑克的游戏时,它会进入脑暴模式多次跟我对齐最终方案,过程中会提到很多我未曾考虑到的方面。并且每次 Feature 开发都会提前调研相关技术方案,并且给出相应的 trade-off。 这种"先探索再执行"的范式,避免了 AI 闷头写出一堆需要推翻的代码。

Playwright MCP / Browser Use

传统开发流程是:写代码 → 手动打开浏览器 → 看效果 → 截图反馈给 AI。有了 Playwright MCP,Claude 可以自己操控浏览器:打开页面、 创建房间、进入房间、开始游戏、截图验证。

最典型的例子——项目整个 README 的效果截图全是 Claude 自己截图并保存的。

这种"AI 自主验收"的能力大幅减少了沟通往返。

Context7 MCP

LLM 的训练数据有截止日期。当你用 Tailwind CSS v4(和 v3 配置方式完全不同)、LiveKit 最新 SDK、Hono.js 等较新的库时,AI 很容易写出过时的 API。

Context7 MCP 解决这个问题:它能实时拉取 npm 包的最新文档,让 Claude 基于当前版本生成代码。比如 Tailwind v4 不再需要 tailwind.config.js,直接在 CSS 里用 @theme 指令。又或者是 railway 的最新版本部署方式,通过 railway CLI 就帮我完成了部署。我记着前段时间还需要我手动创建 services,配合它才可以完成。


Vibe coding 过程记录

第一阶段:核心骨架

一句话起手:

帮我创建一个移动端德州扑克项目

Claude 使用 Superpowers 完成了 spec、plan 的编写,我确认后一口气生成了:

  • 完整的 monorepo 结构 + tsconfig
  • 共享类型(Card、PlayerInfo、RoomState、GameState)
  • WebSocket 协议定义(15+ 事件类型,全类型安全)
  • 基础的房间管理 + 游戏引擎

这里有个返工的情况,一开始 Claude 提供我前端渲染方案有 dom + css,pixiJS 等方案,我考虑性能问题没有听从它的建议让它采用 pixiJS。但随后问题出现,由于渲染是 Canvas, 导致 Claude 并不能很有效的进行自我测试,我对该技术栈也不熟悉,导致实现效果一般,后又让它基于 dom + css 帮我重新实现。

反思: 在确定技术方案的时候,最好采用自己熟悉的方式,这样在后续的开发中能更有效的进行沟通,适当时也可以看懂代码细节。尤其是 MVP 版本,需要快速迭代,而不是从一开始就想着炫技。

第二阶段:游戏引擎 + UI

这是工作量最大的阶段。德州扑克的规则看似简单,实际逻辑极其复杂:

  • 发牌顺序、盲注轮转、多轮下注
  • Side pot 计算(多人 All-In 时的边池)
  • 手牌评估(顺子、同花、葫芦……)

我的想法是不要从零实现,降低技术难度,提高 AI 开发效率。 手牌评估直接用 poker-evaluator 库(让 Claude 调研后推荐的)。

UI 交互方面,我根据我的想法,频繁让 AI 改动,但整体下来 AI 实现的还是无法复刻我的想法。索性我放弃了。因为这离 MVP 版本越来越远。后来我使用 stitch 进行了尝试,其中桌面效果和主题色都相当不错,我就将 html 代码复制交给了 AI 来复刻。再融合我的想法,最终看着还不错

第三阶段:用户系统 + AI 对手

在上一阶段,所有数据都在内存,用户数据也通过 localStorage 进行存储。这种方式显然做不到真正的多人联机,和金钱持久化。所以我让 AI 帮我实现了用户系统,并增加了 AI 对手系统,这样方便开发中进行多人游戏测试。

最后采取了 AI 推荐的建议:SQLite + JWT + bcrypt。对 AI 来说是常规操作,几乎一遍过。

第四阶段:语音聊天 + 移动端适配

随后我开始增加必不可少的语音功能,毕竟有语音才能跟朋友侃侃而谈,耍耍小心机。

移动端适配是踩坑最多的地方。微信 WebView 里的 CSS rotation 会导致触摸坐标系错乱——手指往右滑,滑块往左跑。Claude 分析出根本原因后,写了一个 RotationAwareSlider 组件,在检测到 CSS 旋转时把 clientY 映射到视觉横轴。

if (isRotated()) {
  // CSS 旋转 90° 后,视觉水平方向对应原始垂直坐标
  ratio = (clientY - rect.top) / rect.height
} else {
  ratio = (clientX - rect.left) / rect.width
}

这种平台特定的 hack,AI 的调试能力其实很强——它能系统性地分析坐标变换,而不是像人一样瞎猜。

第五阶段:性能优化 + 部署

随后我让 AI 帮忙进行了部署,拿到线上链接交给朋友一起体验,朋友第一次加载以为是访问不了,后来发现只是白屏时间过长,性能存在问题。所以我让 AI 帮忙分析了性能问题,并给出了优化方案。

我:现在访问会白屏很久,有什么优化空间?先不用改代码
Claude:[分析] 1. JS 包 728KB,livekit-client 占大头 2. Railway 冷启动 3. 单入口无分割
建议:代码分割 + loading 骨架屏,改动小效果明显
我:做吧

优化后首屏 JS 从 728KB 降到 200KB(-68%),白屏变成了即时 loading 动画。

部署到 Railway 也是 Claude 一手操办——写 Dockerfile、配 volume、railway up


吸取的一些经验

  1. 在这个过程中不是完全放手不管,而是要适当参与,适当给 AI 提供反馈。要在这个过程中不断思考和学习,有时候 AI 可能会一直错下去,这时可以直接终止,让它先去调研最佳实践,然后再反过来优化本项目。

  2. 过程中一些 AI 可能会提到一些新技术 和 新名词,这时候可以通过 Claude 的 /btw 命令来进行询问和学习,对话不会记录到整个上下文中,避免干扰。

  3. 不要带领 AI 钻进牛角尖,一些边界 case,交互可以等 MVP 版本完成后,再进行单独优化。一上来就追求完美,优化细节很容易使整体流程跑偏。

  4. 控制好上下文大小,上下文过大时模型会变得不稳定,当你完成一个较大的Feature时,可以通过 /compact 命令来压缩上下文后,再去实现下一个Feature

One More Thing

整个项目已开源,你也可以用一条 Claude Code 命令从零复刻:

claude --dangerously-skip-permissions -p "Build a full-stack multiplayer Texas Hold'em poker web game called 'ALL IN'..."

完整 prompt 见 README

如果这篇文章对你有帮助,欢迎 Star 仓库 ⭐️


本文项目地址:github.com/lhz960904/t…

在线体验:texas-holdem-production-c30c.up.railway.app

Go 语言协程

2026年4月6日 18:43

Go 语言协程(Goroutine)及其并发模型的深度指南。


第一部分:协程基础与生命周期控制

如何启动协程、主协程的特性以及最基本的同步工具。

package main

import (
"fmt"
"runtime"
"sync"
"time"
)

/**
 * 知识点 1-10: 基础定义与生命周期
 * 1. 使用 'go' 关键字即可启动一个协程。
 * 2. 协程是用户态轻量级线程,初始栈空间仅 2KB。
 * 3. 主协程 (Main Goroutine) 结束,所有子协程立即强制退出。
 * 4. 协程的执行顺序是随机的,由调度器决定。
 * 5. runtime.Gosched() 用于主动让出 CPU 时间片。
 * 6. runtime.Goexit() 立即终止当前协程,但会执行 defer。
 * 7. runtime.NumGoroutine() 获取当前运行中的协程数量。
 * 8. 闭包捕获问题:协程内访问外部循环变量需传参,否则会引用最终值。
 * 9. sync.WaitGroup 用于等待一组协程完成,是基础同步工具。
 * 10. WaitGroup 的 Add() 数量必须与 Done() 一致,否则会死锁或 Panic。
 */

func basicDemo() {
var wg sync.WaitGroup

// 演示闭包捕获陷阱与正确传参
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) { // 必须通过参数传递 i
defer wg.Done()
fmt.Printf("子协程 %d 正在运行\n", id)
}(i)
}

wg.Wait()
fmt.Println("所有基础协程执行完毕")
}

func runtimeControl() {
go func() {
defer fmt.Println("协程退出前的清理工作")
fmt.Println("准备退出协程...")
runtime.Goexit() // 终止协程
fmt.Println("这行代码永远不会执行")
}()

time.Sleep(time.Millisecond * 100)
fmt.Printf("当前活跃协程数: %d\n", runtime.NumGoroutine())
}

func main() {
basicDemo()
runtimeControl()
}

第二部分:通道 (Channel) 深度解析

通道是协程间通信的桥梁,遵循“不要通过共享内存来通信,而要通过通信来共享内存”的哲学。

package main

import (
"fmt"
"time"
)

/**
 * 知识点 11-30: 通道机制
 * 11. 通道是线程安全的,底层自带锁。
 * 12. 无缓冲通道 (Unbuffered):发送和接收必须同时就绪,否则阻塞。
 * 13. 有缓冲通道 (Buffered):在容量范围内发送不阻塞。
 * 14. 关闭通道:close(ch)。重复关闭或关闭 nil 通道会 Panic。
 * 15. 向已关闭通道发送数据会 Panic。
 * 16. 从已关闭通道读取数据:返回零值和 false。
 * 17. 单向通道:chan<- 仅发送,<-chan 仅接收。常用于函数参数约束。
 * 18. select 语句:随机选择一个就绪的通道操作。
 * 19. select default 分支:实现非阻塞发送或接收。
 * 20. 通道可以用于实现信号量 (Semaphore)。
 * 21. range 遍历通道:直到通道被关闭且数据取完才结束。
 * 22. nil 通道的读写会永久阻塞。
 * 23. 内存泄漏:未关闭且无接收者的协程会永久阻塞在发送处。
 */

func channelPatterns() {
// 演示有缓冲通道
ch := make(chan string, 2)
ch <- "消息 1"
ch <- "消息 2"
fmt.Println("有缓冲通道已满,接下来的发送将阻塞")

// 演示 select 超时机制
timeoutCh := make(chan bool, 1)
go func() {
time.Sleep(time.Second * 2)
timeoutCh <- true
}()

select {
case msg := <-ch:
fmt.Println("收到:", msg)
case <-time.After(time.Second * 1):
fmt.Println("请求超时!")
}
}

func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("工人 %d 开始处理任务 %d\n", id, j)
time.Sleep(time.Millisecond * 500)
results <- j * 2
}
}

func workerPoolDemo() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)

// 启动 3 个工人
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}

// 发送任务
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // 关闭任务通道,告知工人没有新任务了

// 收集结果
for a := 1; a <= numJobs; a++ {
<-results
}
fmt.Println("所有任务处理完成")
}

func main() {
channelPatterns()
workerPoolDemo()
}

第三部分:同步原语与锁 (sync 包)

当必须访问共享资源时,Go 提供了传统的锁机制。

package main

import (
"fmt"
"sync"
"sync/atomic"
)

/**
 * 知识点 31-50: 同步原语
 * 31. sync.Mutex:互斥锁,保护共享资源。
 * 32. 不要拷贝锁:锁被使用后拷贝会导致逻辑失效(Panic)。
 * 33. sync.RWMutex:读写锁。允许多个并发读,但写时互斥。
 * 34. RLock() 与 RUnlock():读锁操作。
 * 35. 性能对比:在读多写少的场景,RWMutex 优于 Mutex。
 * 36. sync.Once:确保函数只执行一次(常用于单例模式)。
 * 37. sync.Cond:条件变量,用于协程间的等待/通知机制。
 * 38. sync.Pool:对象池,减轻 GC 压力。
 * 39. sync.Map:并发安全 Map,优化了读写分离。
 * 40. 原子操作 (sync/atomic):利用 CPU 指令实现无锁并发,性能极高。
 */

type SafeCounter struct {
mu    sync.Mutex
v     map[string]int
}

func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
// Lock 之后务必 defer Unlock,防止 Panic 导致死锁
defer c.mu.Unlock()
c.v[key]++
}

func atomicDemo() {
var count int64 = 0
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 原子递增,无需加锁
atomic.AddInt64(&count, 1)
}()
}
wg.Wait()
fmt.Printf("原子计数结果: %d\n", count)
}

var (
instance *SafeCounter
once     sync.Once
)

func GetInstance() *SafeCounter {
once.Do(func() {
fmt.Println("首次初始化单例对象")
instance = &SafeCounter{v: make(map[string]int)}
})
return instance
}

func main() {
atomicDemo()
c := GetInstance()
c.Inc("hits")
fmt.Printf("计数器: %d\n", c.v["hits"])
}

第四部分:上下文控制 (Context Package)

Context 是管理协程树、超时控制和元数据传递的标准方式。

package main

import (
"context"
"fmt"
"time"
)

/**
 * 知识点 51-70: Context 模式
 * 51. Context 负责在协程树中传递取消信号、截止日期和键值。
 * 52. context.Background():根 Context,通常由 main 开启。
 * 53. context.WithCancel():返回可手动取消的 Context。
 * 54. context.WithTimeout():到达指定时间后自动取消。
 * 55. context.WithDeadline():到达某个时刻后自动取消。
 * 56. Done() 通道:当 Context 被取消时,该通道会被关闭。
 * 57. Err():返回取消的原因(Timeout 或 Canceled)。
 * 58. context.WithValue():传递请求作用域内的元数据(慎用,非类型安全)。
 * 59. 最佳实践:将 Context 作为函数的第一个参数传入,名为 ctx。
 * 60. 级联取消:父 Context 取消,所有子 Context 也会同步取消。
 */

func longRunningTask(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Printf("任务 %s 被取消: %v\n", name, ctx.Err())
return
default:
fmt.Printf("任务 %s 正在处理中...\n", name)
time.Sleep(time.Millisecond * 300)
}
}
}

func main() {
// 演示超时控制
ctx, cancel := context.WithTimeout(context.Background(), time.Second*1)
defer cancel() // 释放资源

go longRunningTask(ctx, "Worker-1")

// 等待足够长的时间观察结果
time.Sleep(time.Second * 2)
fmt.Println("主程序退出")
}

第五部分:GMP 调度模型与高级进阶

这部分涉及 Go 运行时的底层逻辑和更高级的并发模式。

package main

import (
"fmt"
"runtime"
"sync"
)

/**
 * 知识点 71-100: GMP 模型与高级实践
 * 71. G (Goroutine):协程,保存任务状态。
 * 72. M (Machine):内核线程,负责执行代码。
 * 73. P (Processor):处理器,保存本地协程队列,连接 G 和 M。
 * 74. 调度策略:Work Stealing(任务窃取),从其他 P 偷取 G。
 * 75. 调度策略:Hand Off(接管),阻塞时 P 与 M 分离,寻找新 M。
 * 76. runtime.GOMAXPROCS():设置 P 的数量,默认为 CPU 核心数。
 * 77. 协程泄露:开启了协程但无法退出,导致内存持续增长。
 * 78. 并发安全检测:使用 'go run -race' 检查数据竞争。
 * 79. 生产者消费者模式:使用管道解耦数据产生和处理。
 * 80. 扇入 (Fan-in):多个管道汇聚到一个管道。
 * 81. 扇出 (Fan-out):一个任务分发到多个协程并行处理。
 * 82. 错误传播:在协程中使用特定的 Result 结构体传递 Error。
 * 83. 优雅退出:通过信号量或 Context 确保协程在关机前完成清理。
 * 84. 无锁编程:尽量使用原子操作或通道,避免锁竞争。
 * 85. 协程栈监控:使用 pprof 工具分析协程堆栈快照。
 */

// 扇入模式演示
func merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)

output := func(c <-chan int) {
for n := range c {
out <- n
}
wg.Done()
}

wg.Add(len(cs))
for _, c := range cs {
go output(c)
}

go func() {
wg.Wait()
close(out)
}()
return out
}

func main() {
// 设置最大并行核心数
runtime.GOMAXPROCS(runtime.NumCPU())

c1 := make(chan int, 1)
c2 := make(chan int, 1)
c1 <- 10
c2 <- 20
close(c1)
close(c2)

// 合并两个通道的数据
for n := range merge(c1, c2) {
fmt.Printf("从合并通道获取: %d\n", n)
}
}

核心知识点补充总结

  1. 并发安全检测:在开发过程中,务必使用 go test -race ./...。这是发现隐藏并发 Bug 的利器。
  2. 死锁预防:死锁通常发生在多个协程互相等待对方释放资源(通道或锁)。保持加锁顺序一致,或尽量使用 select 配备超时机制。
  3. Panic 处理:子协程内部如果发生 Panic 且未捕获(recover),会导致整个进程崩溃。在所有重要的长生命周期协程中,务必在开头 defer 一个 recover 函数。
  4. 避免全局变量:并发环境下,全局变量是万恶之源。尽可能通过参数传递或使用单例模式配合锁。
  5. 合理设置缓冲区:过大的缓冲区会掩盖下游处理能力不足的问题,导致系统“虚假健康”并在压力突增时崩溃。
昨天 — 2026年4月6日技术

前端看go并发

2026年4月6日 18:02

Go 语言的 协程 (Goroutine) 和 JavaScript 的 Web Workers 都是为了处理并发任务,但它们在底层实现、资源消耗和通信方式上有本质区别。


1. 核心差异对比表

特性 Go 协程 (Goroutine) JS Web Workers
本质 用户态轻量级线程 (M:N 调度) 操作系统级线程 (1:1 映射)
内存消耗 极小 (初始约 2KB) 较大 (通常几 MB)
启动速度 极快 (纳秒级) 较慢 (需要启动独立环境)
通信方式 Channel (管道) 或 共享内存 postMessage (结构化克隆数据)
数据共享 可以共享 (通过指针/引用) 完全隔离 (无法直接操作主线程变量)
数量级 轻松开启 百万级 通常建议 不超过 CPU 核心数

2. Go 协程代码演示

Go 协程的特点是:极其简单、共享内存、通信高效

package main

import (
"fmt"
"time"
)

func task(id int, ch chan string) {
// 协程可以直接访问外部变量,也可以通过 channel 通信
result := fmt.Sprintf("任务 %d 完成", id)
ch <- result
}

func main() {
ch := make(chan string)

// 开启 1000 个协程几乎不占资源
for i := 0; i < 1000; i++ {
go task(i, ch) 
}

// 接收结果
for i := 0; i < 10; i++ {
fmt.Println(<-ch)
}
time.Sleep(time.Second)
}

3. Web Worker 代码演示

Web Worker 的特点是:完全隔离、环境独立、通信开销大

主线程 (main.js):

const worker = new Worker('worker.js');

// 只能通过发送消息通信
worker.postMessage({ id: 1 });

// 监听返回
worker.onmessage = function(e) {
    console.log('收到结果:', e.data.result);
};

工作线程 (worker.js):

onmessage = function(e) {
    // 这里无法访问主线程的 window, document 或任何变量
    const result = `任务 ${e.data.id} 完成`;
    postMessage({ result: result });
};

4. 深度区别详解

A. 内存与上下文切换

  • Go: 协程是协作式调度的。Go 运行时(Runtime)会管理成千上万个协程,并将它们映射到少量的系统线程上。切换协程只涉及少量寄存器的保存,代价极低。
  • JS: 每一个 Web Worker 都是一个真实的操作系统线程,拥有独立的内存空间、独立的 V8 实例。这意味着启动一个 Worker 的代价非常高,且它们之间切换由操作系统控制。

B. 数据通信(关键点)

  • Go: 遵循 “不要通过共享内存来通信,而要通过通信来共享内存” 的哲学。你可以通过 Channel 传递指针(高效),也可以加锁(Mutex)直接修改同一个变量。
  • JS: 遵循 “零共享”postMessage 传递数据时,浏览器会先对数据进行结构化克隆(Structured Clone),即深拷贝。如果你传一个 1GB 的对象,主线程和 Worker 都会占用 1GB 内存,且拷贝过程非常耗时。

C. 适用场景

  • Go 协程: 几乎所有高并发场景。高并发 Web 服务器、微服务、大规模爬虫、实时推送系统。
  • Web Worker: 计算密集型任务。例如在浏览器中处理超大图片、加解密、复杂物理计算、视频转码。它存在的意义是不让耗时计算卡死主线程(UI 渲染)。

总结建议

  • 如果你追求 海量任务、极速响应,Go 的协程是绝对的王者。
  • 如果你在浏览器中为了 不让页面卡顿 而处理耗时逻辑,Web Worker 是唯一的选择。
  • 在 Node.js 服务端,如果需要类似 Go 的并发,通常使用 worker_threads 模块,它的行为更接近 Web Worker,依然是基于线程隔离的。

go 并发 并行 异步

2026年4月6日 17:48

在 Go 语言中,理解 并发 (Concurrency)并行 (Parallelism)异步 (Asynchrony) 的区别是进阶的关键。Go 的设计哲学深受这些概念的影响。

1. 核心概念对比 (Analogy)

我们可以用“咖啡馆”来做类比:

概念 类比场景 关注点
并发 (Concurrency) 一个服务员同时为多桌客人服务。他在 A 桌点完菜,不等菜上桌,就去 B 桌倒水。他是在处理多件事,但某一瞬间只能做一件事。 结构 (Structure):如何组织代码以处理多个任务。
并行 (Parallelism) 多个服务员同时工作。服务员 A 在给 A 桌点菜,服务员 B 同时在给 B 桌倒水。他们在执行多件事。 执行 (Execution):在多核 CPU 上同时运行。
异步 (Asynchrony) 客人点完餐后拿到一个取餐号,然后回座位玩手机。等厨房做好了,会通过广播(回调/信号)通知他。 非阻塞 (Non-blocking):发起请求后立即返回,不原地等待结果。

2. Go 语言中的实现

A. 并发 (Concurrency) —— Go 的强项

Go 通过 Goroutine (协程) 实现并发。Go 的口号是:“不要通过共享内存来通信,而要通过通信来共享内存。” 并发是 Go 程序的设计属性。即使在单核 CPU 上,你也可以开启 100 万个协程。

B. 并行 (Parallelism) —— 硬件支撑

Go 运行时(Runtime)会自动将并发的协程调度到多个系统线程上。如果你的电脑有多个 CPU 核心,Go 就会自动实现并行。 你可以通过 runtime.GOMAXPROCS(n) 来限制并行使用的核心数。

C. 异步 (Asynchrony) —— “伪同步”写法

在 Node.js 中,异步通常通过 callback, Promise, async/await 实现。 在 Go 中,异步逻辑是用同步的方式写的。当你发起一个网络请求时,当前的协程会“阻塞”,但 Go 运行时的底层其实是异步非阻塞的(使用 epoll/kqueue),它会自动把 CPU 让给其他协程。


3. 代码演示与详解

package main

import (
"fmt"
"runtime"
"sync"
"time"
)

func task(name string, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 3; i++ {
fmt.Printf("任务 %s: 正在处理第 %d 步\n", name, i)
// 模拟耗时操作(这里会触发协程切换,体现并发)
time.Sleep(time.Millisecond * 100)
}
}

func main() {
// 1. 并行度设置:查看当前系统的 CPU 核心数
cpuCores := runtime.NumCPU()
fmt.Printf("系统核心数: %d\n", cpuCores)
    
// 2. 这里的 WaitGroup 用于同步等待所有协程完成(类似于 Promise.all)
var wg sync.WaitGroup

fmt.Println("--- 程序开始运行 ---")

// 3. 启动两个并发任务
wg.Add(2)
go task("A", &wg) // 开启协程 A
go task("B", &wg) // 开启协程 B

// 这里的代码继续执行,体现了“异步”发起的特性
fmt.Println("主线程:我已经下达了任务,现在我去忙别的了...")

wg.Wait() // 阻塞等待 A 和 B 完成
fmt.Println("--- 所有任务完成 ---")
}

4. 深度对比:Go vs Node.js

特性 Go 语言 Node.js (JavaScript)
模型 CSP (通信顺序进程) Event Loop (事件循环)
线程 多线程 (M:N 调度) 单线程 (通过异步 I/O 模拟并发)
阻塞感 看起来是同步阻塞的,实则异步。代码顺序执行,逻辑清晰。 必须使用 await 或回调,否则会产生异步副作用。
复杂任务 擅长 CPU 密集型 + I/O 密集型。 擅长 I/O 密集型,CPU 密集型会阻塞事件循环。

总结:如何理解?

  1. 并发是逻辑上的:你在代码里写了 go func(),你的程序就具备了并发处理的能力(结构)。
  2. 并行是物理上的:当你的程序运行在多核机器上,Go 自动让这些 go func() 在不同的核心上同时跑(效率)。
  3. 异步是体验上的:在 Go 里,你不需要写复杂的 thencallback。Go 让你用最简单的同步代码,享受高性能的异步底层。

一句话:Go 的伟大之处在于,它用并发(协程)的简单模型,完美利用了并行的硬件能力,并屏蔽了异步编程的复杂性。

htop Cheatsheet

Basic Usage

Start htop and limit the view when needed.

Command Description
htop Start htop with the default interactive view
htop -u username Show only processes owned by one user
htop -p 1234,5678 Monitor only the specified PIDs
htop -t Start in tree view
sudo htop Run with elevated privileges to manage more processes

Navigation and Search

Move around the process list and find what you need quickly.

Key Description
Arrow keys Move up and down through processes
Page Up / Page Down Scroll one page at a time
Home / End Jump to the top or bottom of the list
F3 or / Search for a process by name
F4 or \\ Filter the process list
Space Tag or untag the selected process
U Clear all tags

Sorting and Views

Change how processes are grouped and sorted.

Key / Command Description
P Sort by CPU usage
M Sort by memory usage
T Sort by running time
F5 Toggle tree view
F6 Choose a sort column
htop -s PERCENT_MEM Start sorted by memory usage
H Toggle display of user threads
K Toggle display of kernel threads

Process Actions

Manage processes directly from inside htop.

Key Description
F7 Decrease nice value (raise priority)
F8 Increase nice value (lower priority)
F9 Send a signal to the selected process
15 SIGTERM Ask a process to exit cleanly
9 SIGKILL Force a process to stop immediately
2 SIGINT Interrupt a process, similar to Ctrl+C
q or F10 Quit htop

Startup Options

Useful options for changing the initial view.

Command Description
htop -d 20 Refresh every 2 seconds
htop -C Use monochrome mode
htop -H Highlight new and old processes
htop --readonly Disable process-kill and renice actions
htop --sort-key PERCENT_CPU Start sorted by CPU usage

Customization

Adjust the display and save the layout.

Key / Path Description
F2 Open the setup menu
Columns Add, remove, or reorder process columns
Meters Change header meters and display style
Display options Toggle tree lines, thread names, and other UI settings
~/.config/htop/htoprc Configuration file where settings are saved

Troubleshooting

Quick checks for common htop issues.

Issue Check
htop: command not found Install htop with apt or dnf first
Cannot renice a process Run htop with sudo to change priorities for other users’ processes
Cannot kill a process Confirm you have permission, then use F9 with SIGTERM first
Process list is too noisy Use F4 to filter or start with -u USER
Fast processes disappear Lower the update interval with -d

Related Guides

Use these guides for the full walkthroughs.

Guide Description
htop Command in Linux Full htop guide with examples
top Command in Linux Monitor processes in real time with top
ps Command in Linux List and inspect processes
kill Command in Linux Send signals to processes by PID
pstree Command in Linux View parent and child process relationships

htop Command in Linux: Monitor Processes Interactively

When a server starts responding slowly or a desktop session feels sluggish, you need to figure out which process is consuming the most CPU or memory. The top command can do this, but its interface is minimal and navigation takes some getting used to. htop is an interactive process viewer that improves on top with color-coded meters, mouse support, and the ability to scroll, search, filter, and kill processes without leaving the viewer.

This guide explains how to install and use htop to monitor system resources and manage processes on Linux.

Installing htop

htop is available in the default repositories of most Linux distributions but is not always pre-installed.

On Ubuntu, Debian, and Derivatives:

Terminal
sudo apt install htop

On Fedora, RHEL, and Derivatives:

Terminal
sudo dnf install htop

Verify the installation by checking the version:

Terminal
htop --version
output
htop 3.4.1

htop Syntax

txt
htop [OPTIONS]

Running htop without arguments opens the interactive process viewer with default settings:

Terminal
htop

Understanding the Interface

The htop screen is divided into three areas: a header with system meters, a process list in the center, and a footer with function key shortcuts.

Header Area

The top section shows system-wide resource usage at a glance:

  • CPU bars: one bar per CPU core, color-coded by usage type. Green is normal user processes, red is kernel (system) activity, blue is low-priority (nice) processes, and cyan is virtualization overhead (steal time)
  • Memory bar: shows used, buffered, and cached memory as a proportion of total RAM. Green is memory in use by applications, blue is buffers, and yellow is file cache
  • Swap bar: shows swap usage. If this bar is consistently full, the system does not have enough physical memory for its workload
  • Tasks: total number of processes and threads, with a count of how many are currently running
  • Load average: the 1-minute, 5-minute, and 15-minute system load averages
  • Uptime: how long the system has been running since the last boot

Process List

Below the header, each row represents one process. The default columns are:

  • PID - process ID
  • USER - the owner of the process
  • PRI - kernel scheduling priority
  • NI - nice value (user-space priority, ranges from -20 to 19)
  • VIRT - total virtual memory the process has allocated
  • RES - resident memory, the portion of physical RAM the process is actually using
  • SHR - shared memory, the portion of RES that is shared with other processes (such as shared libraries)
  • S - process state (R running, S sleeping, D uninterruptible sleep, Z zombie, T stopped)
  • CPU% - percentage of CPU time the process is using
  • MEM% - percentage of physical memory the process is using
  • TIME+ - total CPU time consumed since the process started
  • Command - the full command line that launched the process

Function Keys

The bottom bar shows the available actions:

  • F1 - open the help screen
  • F2 - open the setup menu to customize meters and columns
  • F3 - search for a process by name
  • F4 - filter the process list to show only matching entries
  • F5 - toggle tree view
  • F6 - choose a column to sort by
  • F7 - decrease the nice value (higher priority) of the selected process
  • F8 - increase the nice value (lower priority) of the selected process
  • F9 - send a signal to the selected process
  • F10 - quit htop

Sorting Processes

By default, htop sorts processes by CPU usage, so the heaviest processes float to the top. To change the sort column, press F6 and select a column from the list using the arrow keys.

For quick sorting without opening the menu, use these shortcut keys:

  • P - sort by CPU%
  • M - sort by MEM%
  • T - sort by TIME+

Press the same key again to reverse the sort order. This is useful when you want to find the least active processes or the ones that started most recently.

You can also set the initial sort column from the command line:

Terminal
htop -s PERCENT_MEM

This starts htop with the process list sorted by memory usage, which is handy when you are investigating high memory consumption on a server.

Searching for a Process

Press F3 (or /) to open the search bar at the bottom of the screen. Type part of the process name and htop will highlight the first matching entry in the process list. Press F3 again to jump to the next match.

The search is incremental, so the highlight updates as you type each character. Press Esc to close the search bar and return to normal navigation.

Filtering Processes

Press F4 to activate the filter. Unlike search, which jumps between matches, filtering hides all processes that do not match the text you type. Only matching entries remain visible in the list.

This is especially useful on busy systems with hundreds of running processes. For example, typing postgres after pressing F4 reduces the list to only PostgreSQL worker processes and the postmaster, making it much easier to see their combined resource usage.

Press Esc to clear the filter and restore the full process list.

Tree View

Press F5 to toggle tree view. In this mode, htop groups child processes under their parent and displays the process hierarchy as an indented tree. This makes it straightforward to see which processes were spawned by a service manager, a shell session, or a container runtime.

To start htop in tree view directly from the command line:

Terminal
htop -t

Tree view combines well with filtering. Press F4, type a service name, and the tree shows only that service and its child processes. For a dedicated look at process hierarchies outside of htop, see the pstree command .

Killing a Process

To stop a process from within htop, use the arrow keys to highlight it (or search with F3), then press F9. This opens a signal selection menu on the left side of the screen.

The most commonly used signals are:

  • 15 SIGTERM - asks the process to shut down gracefully. This is the default and the safest first choice
  • 9 SIGKILL - forces the process to terminate immediately. Use this only when SIGTERM does not work, because the process gets no chance to clean up
  • 2 SIGINT - the same signal sent by pressing Ctrl+C in a terminal
  • 1 SIGHUP - often used to tell a daemon to reload its configuration without restarting

Select the signal with the arrow keys and press Enter to send it. For more detail on process signals and how to send them from the command line, see the kill command guide .

Warning
SIGKILL bypasses all cleanup routines. Files and sockets may be left in an inconsistent state. Always try SIGTERM first and wait a few seconds before escalating to SIGKILL.

You can also tag multiple processes with Space and then press F9 to send a signal to all of them at once. Press U to untag all processes when you are done.

Changing Process Priority

You can adjust the scheduling priority (nice value) of a process directly from htop. Select the process and press:

  • F7 - decrease the nice value (give the process higher priority)
  • F8 - increase the nice value (give the process lower priority)

Nice values range from -20 (highest priority) to 19 (lowest priority). Only root can set negative nice values, so you will need to run htop with sudo to raise a process above normal priority.

This is useful when a background task like a large compilation is consuming too much CPU and you want to lower its priority so that interactive services remain responsive.

Showing Only One User’s Processes

To limit the process list to a single user, start htop with the -u option:

Terminal
htop -u www-data

This shows only processes owned by www-data, which is useful when you are debugging a web server or an application service and do not want system processes cluttering the view.

Monitoring Specific Processes

To watch a known set of processes by their PIDs, use the -p option:

Terminal
htop -p 1234,5678

The display is restricted to those two processes, but the header meters still show system-wide resource usage. This gives you a focused view while keeping overall load visible at the top.

Customizing the Display

Press F2 to open the setup screen. From here you can:

  • Rearrange the header meters (move CPU bars, swap positions, switch between bar, text, graph, or LED display styles)
  • Add or remove columns in the process list
  • Change the color scheme
  • Toggle display options like showing kernel threads or custom thread names

Changes are saved to ~/.config/htop/htoprc and persist across sessions.

Command-Line Options

  • -d N - set the update interval to N tenths of a second. For example, -d 20 updates every 2 seconds instead of the default 1.5 seconds
  • -u USER - show only processes belonging to USER
  • -p PID[,PID...] - show only the specified process IDs
  • -t - start in tree view
  • -s COLUMN - sort by the given column name (use names like PERCENT_CPU, PERCENT_MEM, TIME)
  • -C - use monochrome mode (no colors), useful on terminals with limited color support
  • -H - highlight new and old processes

Quick Reference

For a printable quick reference, see the htop cheatsheet .

Key / Command Action
F1 Help
F2 Setup (customize meters and columns)
F3 or / Search for a process
F4 Filter the process list
F5 Toggle tree view
F6 Choose sort column
F7 / F8 Decrease / increase nice value
F9 Send a signal to the selected process
F10 or q Quit
P Sort by CPU%
M Sort by MEM%
T Sort by TIME+
Space Tag a process
U Untag all processes
htop -u USER Show only one user’s processes
htop -p PID,PID Monitor specific PIDs
htop -t Start in tree view
htop -d 20 Update every 2 seconds
htop -s PERCENT_MEM Sort by memory usage

Troubleshooting

htop: command not found
htop is not installed by default on every distribution. Install it with your package manager: sudo apt install htop on Debian-based systems or sudo dnf install htop on Fedora and RHEL.

Cannot change process priority (permission denied)
Setting a nice value below 0 requires root privileges. Run htop with sudo to adjust priority for processes owned by other users or to set negative nice values.

CPU bars show unexpected colors
Each color represents a different type of CPU usage. Green is normal user processes, red is kernel activity, blue is low-priority (nice) processes, and cyan is virtualization steal time. Press F1 inside htop to see the full color legend for your version.

Memory bar looks full but the system is responsive
Linux uses free memory for disk cache and buffers. The memory bar in htop distinguishes between application memory (green), buffers (blue), and cache (yellow). Cached memory is released to applications when they need it, so a full-looking bar does not mean the system is out of memory.

Processes appear and disappear quickly
Short-lived processes can start and exit between screen refreshes. Lower the update interval with -d 5 (half a second) to catch fast processes. Keep in mind that a very short interval increases the CPU usage of htop itself.

FAQ

What is the difference between htop and top?
Both show running processes and system metrics in real time. htop adds color-coded CPU and memory meters, mouse support, horizontal and vertical scrolling, built-in search and filtering, and the ability to kill or renice processes without typing a PID. The top command is pre-installed on virtually every Linux system, while htop may need to be installed separately.

Does htop use more resources than top?
Slightly. htop reads additional process details from /proc, so it uses a bit more CPU and memory than top. On modern hardware the difference is negligible, even on systems with thousands of processes.

What do VIRT, RES, and SHR mean?
VIRT is the total virtual memory the process has mapped, including memory it has allocated but may not be using yet. RES (Resident) is the portion actually held in physical RAM. SHR (Shared) is the subset of RES that is shared with other processes, such as shared libraries loaded by multiple programs.

How do I save my htop configuration?
Press F2 to open the setup screen, make your changes, and press F10 to exit. htop saves the configuration automatically to ~/.config/htop/htoprc.

Can I run htop over SSH?
Yes. htop works in any terminal emulator, including SSH sessions. If you are monitoring a remote server over a long session, consider running htop inside screen or tmux so the session persists if the connection drops.

Conclusion

htop gives you a clear, interactive view of what is running on your system and the resources each process is consuming. For related tools, see the ps command for snapshot process listings, kill for sending signals from the command line, and pstree for visualizing the process hierarchy.

01-想做 Code Agent,但不想只会调 API?我把 Claude Code 源码拆成了一套教程

作者 兔子零1024
2026年4月6日 16:18

关键词:Code Agent / Claude Code / CLI / Bootstrap / QueryEngine / Agent 架构 / 工程设计

别把 Code Agent 当聊天机器人:先看懂 Claude Code 的总架构和启动链路

很多人分析 Code Agent,一上来就盯着模型调用,结果越看越碎。

真正的问题不是“它调了哪个模型”,而是这套系统怎么从一个 CLI 命令,变成一个能长期执行任务的 Agent。Claude Code 的前两章其实就在回答这件事:一个 Code Agent 的骨架到底长什么样,它又是怎么被启动起来的。

一、先把范式分清:Chatbot、Copilot、Agent 不是同一种东西

从工程上看,这三者的差异不在 UI,而在执行边界。

类型 能做什么 不能做什么
Chatbot 一问一答、生成文本 不主动行动
Copilot 读编辑器上下文、给建议 通常不闭环执行
Code Agent 调工具、看结果、继续推进 不能没有状态机

Code Agent 的本质不是“更强的聊天”,而是下面这条循环:

flowchart TD
    U["用户输入"] --> C["组装上下文"]
    C --> M["调用模型"]
    M --> R{"返回类型"}
    R --> |"最终回答"| END["结束"]
    R --> |"tool_use"| T["调用工具"]
    T --> TR["工具结果回写历史"]
    TR --> M

只要系统进入这个闭环,它就不再是 Chatbot,而是执行器。

二、Claude Code 的总架构,其实是七块东西咬在一起

把源码抽掉细节,Claude Code 的主骨架是这样的:

CLI / Bootstrap
    ↓
QueryEngine / queryLoop
    ├─ Context Management
    ├─ Tool System
    ├─ Permissions / Hooks
    ├─ Skills / Plugins / MCP
    └─ UI Layer (Ink/React)

这里真正的主干不是 UI,也不是模型 SDK,而是中间三层:

  • queryLoop:负责让任务一轮轮继续;
  • Context:负责让模型每一轮都知道自己处在什么状态;
  • Tool System:负责把模型意图变成真实操作。

换句话说,Claude Code 不是“终端里包了一个 LLM”,而是“用 LLM 驱动的一套工具执行框架”。

三、CLI 启动的第一原则:快路径不能被慢路径拖累

Claude Code 在 cli.tsx 里先做了快速路径分流。像 --version 这种命令,根本不值得把主系统拉起来:

async function main(): Promise<void> {
  const args = process.argv.slice(2);

  if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
    console.log(`${MACRO.VERSION} (Claude Code)`);
    return;
  }
}

这个做法看起来普通,但它代表一个很成熟的 CLI 判断:

非主路径必须延迟加载,不能污染主路径的冷启动时间。

所以内部 MCP 模式、daemon worker、后台会话管理等路径,全部走动态 import()
不需要的模块,不在普通交互场景里承担启动成本。

四、main.tsx 做得最好的地方,不是功能多,而是“等待重叠”

进入 main.tsx 后,Claude Code 立刻做三件事:

import { profileCheckpoint } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');

import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();

import { startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();

这意味着:

  • 入口开始时先打性能点;
  • 企业配置读取立即启动;
  • Keychain 中的 token / API key 预取立即启动;
  • 后续模块加载继续往下跑。
sequenceDiagram
    participant M as main.tsx
    participant K as Keychain
    participant L as 模块加载

    M->>K: startKeychainPrefetch()
    M->>L: 继续加载 Ink / Commander / React
    par 并行
      K-->>K: 读取凭证
      L-->>L: 模块求值
    end

它不是在做复杂优化,而是在贯彻一个很基本的工程原则:把等待和计算重叠起来
CLI 工具的启动体验,往往就输赢在这种细节上。

五、执行模式必须尽早判断:交互式和无头模式根本不是一回事

Claude Code 很早就判断当前是不是非交互模式:

const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print');
const isNonInteractive =
  hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY;

关键点不是 -p,而是这一句:

!process.stdout.isTTY

这意味着只要输出不是终端,比如:

  • 被管道消费;
  • 被重定向到文件;
  • 跑在 CI 里;

它就自动转成非交互模式。

这才是一个能被脚本和流水线真正利用的 Agent CLI。
如果一个 Agent 只能服务“人在终端前手动敲命令”的场景,它的工程价值会被大幅限制。

六、参数解析不是装饰,它定义了系统有多少种工作姿态

main.tsx 里用 Commander.js 注册了大量参数。重要的不是“参数多”,而是参数直接映射系统姿态:

  • --print:无头执行;
  • --bare:跳过 hooks、LSP、插件同步等附加能力;
  • --permission-mode:切换权限模型;
  • --model / --effort:改变推理策略;
  • --allowed-tools / --disallowed-tools:收紧或放宽工具池;
  • --add-dir:调整文件可访问范围。

这说明 Claude Code 不是只有一种运行方式。
它是同一套核心循环,在不同环境里切换不同外壳。

七、真正的入口不是 main(),而是 query()

CLI 解决的是“怎么启动”,真正决定 Agent 行为的是 src/query.ts 里的 query()

export async function* query(
  params: QueryParams,
): AsyncGenerator<StreamEvent | RequestStartEvent | Message | ...> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

最值得注意的是它为什么是 async function*

因为 Agent 不是“跑完再返回”的程序,而是会在过程中持续产生事件:

  • 模型输出;
  • 工具调用;
  • 工具执行进度;
  • 工具结果;
  • 结束原因。

如果不用 async generator,就很难同时做好实时 UI 和中间状态分发。

八、queryLoop() 是整套系统的心跳

真正执行循环的是 queryLoop()。它维护一份跨轮次状态:

type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  turnCount: number
  transition: Continue | undefined
}

这份状态告诉我们,Claude Code 从来不是“多调几次模型”。
它是一个明确的状态推进器。

每轮循环都做这几件事:

  1. 取当前消息;
  2. 检查是否需要压缩;
  3. 调模型;
  4. 看到 tool_use 就派发工具;
  5. 收集结果回写历史;
  6. 判断是否继续。
flowchart TD
    A["准备消息"] --> B["必要时压缩上下文"]
    B --> C["调用模型"]
    C --> D{"出现 tool_use?"}
    D --> |"是"| E["执行工具"]
    E --> F["工具结果回写 messages"]
    F --> A
    D --> |"否"| G{"是否完成?"}
    G --> |"是"| H["返回 Terminal"]
    G --> |"否"| A

只要你理解了这一步,就会知道为什么很多 Agent Demo 看起来会动,但一进真实任务就塌:
它们没有把“任务推进”做成状态机,只是做成了多轮问答。

九、第一篇该记住的,不是某个函数,而是三条原则

Claude Code 的前两章合起来,核心其实只有三条:

1. Agent 不是聊天产品,而是执行系统

只要它进入“调用工具-观察结果-继续决策”的闭环,它就不是普通聊天。

2. 启动路径本身就是工程能力的一部分

快路径分流、并行预热、模式早判,这些都不是边角优化,而是主能力。

3. 一切最终都收敛到 queryLoop 这个状态推进器

CLI、参数、模式、上下文、工具,最后都只是为了让这条循环稳定工作。

最后

如果把 Claude Code 当成“会写代码的聊天机器人”,后面很多设计你都会看不懂。
只有把它当成一个长期运行的执行框架,你才会理解:

  • 为什么启动要这么抠延迟;
  • 为什么模式要这么早分流;
  • 为什么 query() 要做成 async generator;
  • 为什么整个系统要围绕 queryLoop 组织。

这才是看 Claude Code 前两章最该拿到的东西。

React&Vue知识点汇总

作者 哈撒Ki
2026年4月6日 15:52

Vue

1. 声明式渲染

  • 模板语法{{ }} 文本插值、v-html 输出 HTML

  • 指令

    • 内置指令:v-bindv-onv-modelv-if/v-else-if/v-elsev-showv-forv-prev-cloakv-once
    • 自定义指令:全局 Vue.directive(Vue 2)/ app.directive(Vue 3),局部 directives 选项

2. 响应式系统

  • Vue 2:基于 Object.defineProperty,递归遍历对象属性,无法检测

    • 对象属性的添加/删除(需用 Vue.set / this.$set
    • 数组索引修改(需用变异方法或 Vue.set
    • 数组长度变化(需用 splice 等)
  • Vue 3:基于 Proxy,可监听动态添加属性、数组索引修改,性能更优,支持 MapSet 等原生集合的响应式

  • 响应式原理

    • 数据劫持:Vue 对 data 中的属性进行响应式处理(Vue 2 用 Object.defineProperty,Vue 3 用 Proxy)。
    • 依赖收集:当组件渲染或计算属性等执行时,会访问响应式数据,此时将当前正在执行的 Watcher(观察者)添加到该数据的依赖列表(Dep)中。
    • 派发更新:当数据被修改时,Dep 通知所有依赖它的 Watcher 执行更新。
    • 异步更新队列Watcher 更新时并不立即执行 DOM 操作,而是将自身推入一个队列,在下一个事件循环(microtask)中统一执行,并利用 nextTick 提供更新后的回调。

3. vue3 API

API 说明
ref() 声明任意类型的响应式数据,需通过 .value 访问。
reactive() 声明对象/数组类型的响应式数据,可直接访问属性。
computed() 定义计算属性,基于响应式依赖缓存结果。
watch() 监听特定数据源,在数据变化时执行副作用。
watchEffect() 自动追踪其内部使用的响应式数据,并在数据变化时立即重新运行。
onMounted()onUpdated()onUnmounted() 等 组件生命周期不同阶段执行的钩子函数,用法与选项式 API 对应。
provide()inject() 用于跨层级组件通信,祖先组件提供数据,后代组件注入使用。
toRefs()toRef()isRef()unref() 等 用于处理 ref / reactive 对象的辅助函数,帮助进行响应式转换和判断。
defineProps()defineEmits() 在 <script setup> 中声明组件的 props 和 emits,享受完整类型推导。
defineExpose() 在 <script setup> 中声明当前组件暴露给父组件的属性或方法。
defineOptions() 在 <script setup> 中声明组件名 (name) 或 inheritAttrs 等选项。

内置组件

组件 说明
<component> 用于动态渲染不同组件的“元组件”,通过 is 属性决定
<transition> 为单个元素或组件添加进入/离开过渡动画
<transition-group> 为列表中的多个元素或组件添加过渡动画
<keep-alive> 缓存动态组件,避免重复渲染和状态丢失
<teleport> 将组件模板的一部分渲染到 DOM 树中的指定位置
<suspense> 管理异步组件或依赖异步数据的组件,在等待时显示后备内容
<slot> 作为组件模板中的插槽出口,接收父组件分发的内容

内置指令

指令 说明
v-model 在表单元素或组件上创建双向绑定。
v-ifv-else-ifv-else 条件渲染,为 false 时不渲染元素。
v-show 条件渲染,通过 CSS 的 display 属性切换。
v-for 基于源数据多次渲染元素或模板块。
v-on (@) 绑定事件监听器。
v-bind (:) 动态地绑定一个或多个属性。
v-slot (#) 用于声明具名插槽或作用域插槽。

4. 生命周期钩子

阶段 Vue 2 钩子 Vue 3 钩子(Options API) Vue 3 钩子(Composition API)
初始化 beforeCreate, created setup() 代替 beforeCreate/created
挂载 beforeMount, mounted onBeforeMount, onMounted
更新 beforeUpdate, updated onBeforeUpdate, onUpdated
卸载 beforeDestroy, destroyed beforeUnmount, unmounted onBeforeUnmount, onUnmounted
错误捕获 errorCaptured onErrorCaptured
其他 activated, deactivated(keep-alive) onActivated, onDeactivated
调试 renderTracked, renderTriggered(开发) onRenderTracked, onRenderTriggered

5. 插槽

  • 默认插槽、具名插槽、作用域插槽(slot-scope in Vue 2,v-slot in Vue 2.6+ & Vue 3)
  • Vue 3 中 v-slot 统一为指令语法,slot 和 slot-scope 被废弃

6. 混入(Mixin)

  • 全局混入、局部混入
  • 合并策略:数据递归合并,同名钩子合并为数组,方法/组件/指令等直接覆盖
  • 缺点:命名冲突、隐式依赖、代码不直观 → 推荐组合式 API 替代

7. 自定义指令

  • 钩子函数:

    • Vue 2:bindinsertedupdatecomponentUpdatedunbind
    • Vue 3:beforeMountmountedbeforeUpdateupdatedbeforeUnmountunmounted
  • 参数:elbindingvnodeprevVnode

8. 过滤器(Filters)

  • Vue 2 支持模板内过滤器({{ msg | filter }})及全局/局部定义
  • Vue 3 中移除,推荐用计算属性或方法替代

9. 动画与过渡

  • <transition> 单元素过渡
  • <transition-group> 多元素/列表过渡
  • 类名约定:v-enter-from/v-enter-to 等(Vue 3 命名变化)
  • JavaScript 钩子:@before-enter@enter@after-enter 等

10、组件通信方式(详细对比)

方式 Vue 2 Vue 3
props / $emit 支持 支持,emit 需在 setup 中声明
v-model 单个,value + input 可多个,modelValue + update:modelValue,支持自定义修饰符
refs/refs / parent / $children $children 存在 移除 $children,推荐 ref + $parent 或组合式 API
provide / inject 默认非响应式,可传递响应式对象 支持响应式传递,可提供 ref/reactive
event bus new Vue() 作为总线 推荐用 mitt 等第三方库
Vuex Vuex 3 Vuex 4 / Pinia
slot 作用域 slot-scope v-slot 统一语法
组合式 API 可直接使用 ref 传递,逻辑复用更灵活

11、Vue Router 对比(3.x vs 4.x)

特性 Vue Router 3(Vue 2) Vue Router 4(Vue 3)
创建方式 new VueRouter(...) createRouter({ ... })
模式 mode: 'history' / 'hash' history: createWebHistory() / createWebHashHistory()
路由守卫 beforeEach / beforeResolve / afterEach 同,但支持组合式 API 中的 onBeforeRouteUpdate 等
路由元信息 meta
动态路由 addRoutes addRoute,且支持动态删除
导航故障 NavigationFailureType 更完善的类型
组合式 API 不支持 useRouteruseRoute

12、状态管理:Vuex vs Pinia

特性 Vuex 3/4 Pinia
设计理念 基于 Flux,强调 mutations / actions / getters 更简洁,直接修改 state,支持组合式 API
类型推断 需要额外处理 原生 TypeScript 支持
模块化 通过 modules 通过多个 store 自然分割
异步处理 actions 中 actions 中,可直接使用 async/await
代码量 较多模板代码 更少,更直观
热更新 有限支持 支持 store 热更新
Vue 3 推荐 可用,但官方转向 Pinia 官方推荐,轻量且强大

13、构建工具:Vue CLI vs Vite

特性 Vue CLI(基于 webpack) Vite
启动速度 慢(打包后启动) 极快(按需编译,原生 ES modules)
生产构建 基于 webpack,配置灵活但复杂 基于 Rollup,预配置更简单
插件生态 丰富的 webpack 插件 插件系统兼容 Rollup 插件,且提供 Vite 插件
配置方式 vue.config.js vite.config.js
开发环境 HMR 较慢(大规模项目) HMR 快速,保留状态
环境变量 VUE_APP_* VITE_*

14、响应式原理(Vue 2 vs Vue 3)

Vue 2 响应式

  • 遍历 data 对象,对每个属性递归调用 defineReactive,为每个属性创建 Dep(依赖收集器)。

  • 每个属性对应一个 Watcher(观察者),在渲染时收集依赖。

  • 缺点:

    • 无法检测对象属性的新增/删除(需用 Vue.set / this.$set)。
    • 无法直接通过索引修改数组(arr[0] = xx 不触发更新,需用变异方法如 pushsplice)。
    • 初始化时需要递归遍历,性能略差。

Vue 3 响应式

  • 基于 Proxy 代理整个对象,可拦截 getsetdeleteProperty 等操作。

  • 优点:

    • 动态添加/删除属性自动响应。
    • 数组索引修改和 length 变化自动响应。
    • 支持 MapSet 等原生集合。
    • 惰性响应式:只有访问到属性时才会递归代理,性能更好。

总结

Vue 2 通过 Object.defineProperty 劫持对象属性的 getter/setter 来实现响应式,但存在局限性,比如无法监听动态添加的属性,需要通过 Vue.set 处理。Vue 3 改用 Proxy,可以代理整个对象,支持多种操作拦截,解决了上述问题,同时性能更优。响应式核心是依赖收集和派发更新,在 getter 中收集依赖,在 setter 中触发更新,并通过异步队列实现批量更新

15、虚拟 DOM 与 diff 算法

diff 策略

  • 同层比较:只比较同一层节点,不跨层。
  • 双端比较(Vue 2):新旧 VNode 的 children 数组通过头尾交叉比较,找到可复用的节点。
  • 静态提升(Vue 3):编译时标记静态节点,更新时跳过它们。
  • Patch flag(Vue 3):标记动态节点,只更新变化的部分

总结

虚拟 DOM 是一种用 JS 对象模拟真实 DOM 的结构,通过 diff 算法对比新旧 VNode,找出差异并批量更新真实 DOM,减少了直接操作 DOM 的性能开销。Vue 2 的 diff 采用双端比较,Vue 3 则引入了静态提升和 patch flags,进一步优化了更新效率。key 是 diff 过程中识别节点的重要依据,使用稳定的 key 可以保证节点复用,避免渲染错误。

16、 生命周期钩子(执行顺序、使用场景)

父子组件生命周期顺序

  • 创建:父 beforeCreate → 父 created → 子 beforeCreate → 子 created → 子 beforeMount → 子 mounted → 父 mounted
  • 更新:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated
  • 销毁:父 beforeDestroy → 子 beforeDestroy → 子 destroyed → 父 destroyed

常用钩子作用

  • beforeCreate:实例初始化后,数据观测和事件配置之前。无法访问 data、props。
  • created:可访问数据,但 DOM 未挂载,适合异步请求、初始化数据。
  • mounted:DOM 已挂载,可操作 DOM,适合第三方库初始化。
  • beforeDestroy:销毁前,适合清除定时器、取消订阅。
  • activated / deactivatedkeep-alive 组件激活/停用。

17、$nextTick 原理及使用场景

原理

Vue 的异步更新队列。数据变化后,Vue 将开启一个队列,把同一个事件循环内的所有数据变化缓存起来,然后在下一个事件循环(microtask)统一执行 DOM 更新。$nextTick 的回调会在 DOM 更新完成后执行。

使用场景

  • 在数据变化后,需要获取更新后的 DOM 结构。
  • 需要在 mounted 钩子中确保子组件渲染完成。
  • 异步操作后需要等待 DOM 同步。

面试回答

$nextTick 利用 Promise 或 MutationObserver 等微任务机制,将回调延迟到下次 DOM 更新循环之后执行。我们常用来解决数据变化后立即操作 DOM 的问题,比如滚动到底部、获取元素宽高等。Vue 3 中同样有 nextTick 函数,可在组合式 API 中使用。

18、 keep-alive 实现原理及生命周期

作用

缓存不活动的组件实例,避免反复渲染。

原理

内部维护一个缓存对象(键是组件的 key 或自身),当组件切换时,将被移除的组件实例保留在缓存中,而不是销毁。再次激活时从缓存取出复用,触发 activated 和 deactivated 钩子。

相关属性

  • include / exclude:正则或数组,指定要缓存/不缓存的组件。
  • max:最大缓存数,超出时根据 LRU 策略删除。

生命周期

  • 首次进入:created → mounted → activated
  • 缓存后再次进入:activated(不会重新执行 created / mounted
  • 离开时:deactivated

面试回答

keep-alive 是一个抽象组件,它通过缓存 VNode 来保留组件状态,避免重复渲染。内部使用 LRU 算法管理缓存,可以通过 include 和 max 控制缓存策略。被缓存的组件会多出 activated 和 deactivated 钩子,用于在激活/停用时执行逻辑。

19、 组合式 API 与选项式 API 的优缺点

选项式 API(Vue 2 主流):

  • 优点:结构清晰(data、methods、computed 分块),适合初学者。
  • 缺点:逻辑分散,复杂组件难以维护;复用逻辑需借助 mixin,存在缺陷。

组合式 API(Vue 3 引入):

  • 优点:

    • 逻辑集中,按功能组织代码,可读性和可维护性高。
    • 逻辑复用简单,通过组合函数(hooks)实现,无命名冲突。
    • 更好的 TypeScript 类型推断。
  • 缺点:学习曲线稍陡,对初学者不够直观。

面试回答

“选项式 API 将组件选项按类型划分,代码直观但逻辑分散。组合式 API 将相关逻辑聚合在 setup 中,通过组合函数实现复用,尤其适合大型复杂组件。Vue 3 并未废弃选项式 API,两者可混用,但组合式 API 提供了更好的逻辑复用能力和类型支持,是未来的推荐写法。”

20、SSR 原理及优缺点

原理

  • 服务端运行 Vue 应用,生成 HTML 字符串直接返回给浏览器,客户端再“激活”(hydrate)为可交互应用。
  • 同构:同一份代码在服务端和客户端均可运行。

优点

  • 更好的 SEO:搜索引擎能抓取完整 HTML。
  • 更快的首屏加载:用户无需等待 JS 下载即可看到内容。

缺点

  • 开发复杂度高:需考虑 Node.js 环境兼容性。
  • 服务器负载大:每个请求都重新渲染,需注意缓存策略。
  • 部分 API 在服务端不可用(如 window、document),需条件判断。

面试回答

“SSR 在服务端将 Vue 组件渲染成 HTML,发送给客户端,然后客户端进行激活。它主要解决 SPA 的 SEO 问题和首屏加载速度。但实现成本较高,需要处理服务端和客户端环境的差异,并关注服务器性能。通常我们会借助 Nuxt.js(Vue 2)或 Nuxt 3(Vue 3)这样的框架来简化 SSR 开发。”

21、Vue 3 新特性及与 Vue 2 的区别

核心新特性

  • 组合式 API:更好的逻辑复用和代码组织。
  • Proxy 响应式:解决 Vue 2 的响应式局限,性能更优。
  • Teleport:将组件内容渲染到任意 DOM 位置。
  • Fragment:组件支持多个根节点。
  • Suspense:用于异步组件加载时的占位。
  • 全局 API 改造createApp 替代 new Vue,全局配置隔离。
  • 更好的 TypeScript 支持:源码用 TS 重写,类型更完善。
  • 性能提升:编译优化(静态提升、patch flag),打包体积更小。
  • Vite 官方构建工具:开发体验极大提升。

破坏性变更

  • 移除过滤器、$children$on/$once/$offv-on.native 等。
  • v-model 默认 prop 和事件变化,支持多个绑定。
  • v-if 与 v-for 优先级改变。

面试回答

“Vue 3 相比 Vue 2 在响应式系统、组合式 API、性能、TypeScript 支持等方面有重大改进。它引入了 Teleport、Suspense 等内置组件,并用 createApp 创建应用,避免全局污染。虽然有一些破坏性变更,但官方提供了迁移构建和工具帮助升级。Vue 3 也带来了更现代的构建工具 Vite,提升了开发体验。”

22、常见API使用方式 defineEmits、defineExpose、defineOptions、defineProps、

1. defineProps – 接收父组件传递的数据

作用:声明组件的 props(属性),代替传统的 props 选项。

基本用法

<script setup>
// 运行时声明(自动推断类型)
const props = defineProps(['title', 'count'])

// 带类型的声明(TypeScript)
const props = defineProps<{
  title: string
  count?: number   // 可选
}>()
</script>

父组件使用

<MyComponent title="Hello" :count="10" />

2. defineEmits – 向父组件发送事件

作用:声明组件可以触发的事件。

<script setup>
// 简单声明
const emit = defineEmits(['update', 'delete'])

// 带参数验证(TypeScript)
const emit = defineEmits<{
  (e: 'update', id: number): void
  (e: 'delete', name: string): void
}>()

// 触发事件
emit('update', 123)
</script>

父组件监听

<MyComponent @update="handleUpdate" @delete="handleDelete" />

3. defineExpose – 暴露组件内部属性/方法给父组件(通过 ref)

作用:默认 <script setup> 下的组件是关闭的,父组件无法通过 ref 访问其内部成员。使用 defineExpose 明确暴露。

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

const count = ref(0)
const increment = () => count.value++

// 只暴露 increment 和 count,其他不暴露
defineExpose({
  increment,
  count
})
</script>

父组件访问

<template>
  <MyComponent ref="childRef" />
</template>

<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref()

onMounted(() => {
  childRef.value.increment()   // 调用子组件方法
  console.log(childRef.value.count) // 读取子组件数据
})
</script>

4. defineOptions – 设置组件选项(Vue 3.3+)

作用:在 <script setup> 中声明组件名、继承属性、自定义选项等,无需单独的 <script> 块。

<script setup>
defineOptions({
  name: 'MyCustomName',      // 组件名称
  inheritAttrs: false,       // 是否继承非 prop 属性
  // 其他选项(如 components、directives 一般不在这里,但可自定义)
})
</script>

典型场景

  • 设置组件名(方便 Vue Devtools 识别)
  • 关闭属性继承(手动控制 $attrs

23、wacth & watchEffect 区别

特性 watch watchEffect
依赖收集 显式指定要监听的数据源(ref、reactive 属性、getter 函数) 自动收集回调函数中使用的所有响应式数据
初始执行 默认不执行,数据第一次变化时才执行(可配置 immediate: true 立即执行一次,同时收集依赖
访问新旧值 回调中提供旧值和新值 只能访问新值(无法直接获取旧值)
监听多个源 支持同时监听多个数据源(数组形式) 自动收集多个依赖,无需显式指定
精准控制 可以配置 deepflushimmediate 等选项 只有 flush 选项(以及 onTrack/onTrigger 调试)
停止监听 调用返回的函数 同样返回停止函数
适用场景 需要知道具体哪个数据变化、需要旧值、需要惰性执行 简单副作用,自动跟踪依赖,不需要旧值

1、watch 基础用法

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

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 监听单个 ref
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`)
})

// 监听 getter 函数
watch(
  () => state.age,
  (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

// 监听多个源(数组)
watch([count, () => state.age], ([newCount, newAge], [oldCount, oldAge]) => {
  console.log(`count: ${oldCount}->${newCount}, age: ${oldAge}->${newAge}`)
})

// 立即执行 + 深度监听
watch(
  () => state,
  (newVal, oldVal) => {
    console.log('state 变化了', newVal)
  },
  { immediate: true, deep: true }
)
</script>

2、watchEffect 基础用法

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

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 自动收集依赖:count 和 state.age
watchEffect(() => {
  console.log(`count: ${count.value}, age: ${state.age}`)
})
// 初始立即输出:count: 0, age: 3
// 之后任何依赖变化都会重新执行

// 停止监听
const stop = watchEffect(() => { /* ... */ })
stop() // 手动停止
</script>

24、provide() 和 inject() 跨层级组件通信例子

<!-- Ancestor.vue -->
<script setup>
import { provide, ref } from 'vue'

// 提供普通值
provide('theme', 'dark')

// 提供响应式数据(推荐)
const count = ref(0)
const updateCount = () => count.value++
provide('count', count)
provide('updateCount', updateCount)
</script>
<!-- Descendant.vue -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme', 'light') // 默认值 'light'
const count = inject('count')
const updateCount = inject('updateCount')

// 使用
console.log(theme)   // 'dark'
count.value++        // 响应式更新
updateCount()        // 调用方法
</script>

25、toRefs、toRef、isRef、unref 响应式引用工具

1. toRefs – 将响应式对象转换为普通对象,每个属性都是 ref

作用:解构 reactive 对象时保持响应性。

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })

// ❌ 直接解构会丢失响应性
let { count, name } = state
count++  // 不会触发视图更新

// ✅ 使用 toRefs 包装
const stateRefs = toRefs(state)
const { count, name } = stateRefs
count.value++ // 响应式生效

原理toRefs 为每个属性创建一个 ref 链接到原对象的对应属性。

2. toRef – 为响应式对象的单个属性创建 ref

作用:保持对源对象属性的响应式引用,常用于将 props 的某个属性转为 ref 以便传递。

import { reactive, toRef } from 'vue'

const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')

countRef.value++   // 同时修改 state.count
console.log(state.count) // 1

典型场景:组合函数接收 props 中的某个属性并保持响应性。

// useFeature.js
import { toRef, watchEffect } from 'vue'
export function useFeature(propRef) {
  const propVal = toRef(propRef)  // 确保是 ref
  watchEffect(() => {
    console.log(propVal.value)
  })
}

3. isRef – 判断某个值是否为 ref

import { ref, reactive, isRef } from 'vue'

const count = ref(0)
const state = reactive({})
console.log(isRef(count)) // true
console.log(isRef(state)) // false

4. unref – 如果参数是 ref 则返回其 value,否则返回参数本身

作用:方便地获取值,无需手动判断 .value

import { ref, unref } from 'vue'

const count = ref(0)
const plain = 42

console.log(unref(count)) // 0
console.log(unref(plain)) // 42

// 等价于
function myUnref(val) {
  return isRef(val) ? val.value : val
}

常用场景:在组合函数中,参数可能是 ref 也可能是普通值,使用 unref 统一处理。

React

1. React 是什么?核心特点

React 是 Meta 开源的 JavaScript UI 库,专注于构建用户界面。核心特点

  • 声明式编程:描述 UI 状态,React 自动处理 DOM 更新。
  • 组件化:UI 拆分为独立可复用的组件。
  • 单向数据流:数据从父组件流向子组件,可预测、易调试。
  • 虚拟 DOM + Fiber:高效更新,可中断渲染。
  • JSX:JavaScript 语法扩展,允许在 JS 中写类似 HTML 的标记。

2. JSX

JSX 是 React.createElement 的语法糖

// JSX 写法
const element = <h1 className="title">Hello React</h1>;

// 编译后
const element = React.createElement('h1', { className: 'title' }, 'Hello React');

浏览器无法识别 JSX,需要通过 Babel 编译为普通 JS 代码才能执行

3、组件间通信

React 组件间通信方式取决于组件关系,主要方式如下

方式 适用场景 示例
Props 父→子传递数据 <Child message={msg} />
回调函数 子→父传递数据 父传函数给子,子调用 props.onChildData(data)
Context API 跨层级组件通信(避免 props 逐层传递) createContext + useContext
状态管理库 大型应用全局状态 Redux Toolkit、Zustand、Jotai
Refs 直接访问 DOM 或子组件实例 useRef + ref 属性
自定义 Hooks 复用状态逻辑 useAuthuseFetch 等
高阶组件(HOC) 共享逻辑(较少使用,Hooks 更优) 接收组件返回增强组件
Event Bus(事件总线) 任意组件通信(非 React 原生) mitt 等第三方库

4、API

4.1 内置 Hooks

Hook 说明
useState 在函数组件中添加和管理局部状态,返回当前状态和更新函数
useReducer 用于管理包含多个子值或依赖先前状态的复杂组件逻辑,基于 reducer 模式
useContext 读取并订阅组件中的 Context 值,避免 props 逐层传递
useRef 声明一个可变引用,可以保存任何可变值,最常见的用途是访问 DOM 元素
useImperativeHandle 自定义通过 ref 暴露给父组件的实例值,通常与 forwardRef 配合使用
useEffect 将组件连接到外部系统并处理副作用,如数据获取、订阅、手动 DOM 操作,在渲染后执行
useLayoutEffect 在浏览器重新绘制屏幕之前同步触发,用法与 useEffect 相同,但会阻塞视觉更新
useInsertionEffect 在 DOM 变异之前触发,专为 CSS-in-JS 库注入样式而设计
useMemo 缓存昂贵计算的结果,避免在每次渲染时重复计算,仅在依赖项变化时重新计算
useCallback 缓存函数定义,防止因函数重新创建导致的子组件不必要重新渲染
useTransition 将状态更新标记为"过渡",这种更新可以被中断,以避免阻塞用户界面
useDeferredValue 延迟更新 UI 的某一部分,以优先响应用户输入
useId 生成在客户端和服务器上保持稳定的唯一 ID,主要用于可访问性属性
useDebugValue 在 React DevTools 中为自定义 Hook 添加标签,用于调试
useSyncExternalStore 允许函数组件订阅外部 store(如第三方状态管理库或浏览器 API)
useOptimistic 允许在后台操作完成前乐观地更新 UI,提供即时反馈
useActionState 管理表单 action 的状态,包括 pending 状态和返回数据
use 通用的资源读取 API,用于读取 Promise 或 Context 等资源的值,可以在条件语句中调用

4.2 内置组件

这些是可以在 JSX 中使用的 React 内置组件,以 Symbol 常量形式导出

组件 说明
<Fragment> 让你无需向 DOM 添加额外节点即可对子元素列表进行分组,支持简写 <>...</>
<Profiler> 用于编程式测量 React 应用的渲染性能
<StrictMode> 用于检测应用中潜在问题的工具,不会渲染任何可见 UI
<Suspense> 允许在子组件完成加载前显示一个回退 UI
<Activity> React 19 新增 API,用于隐藏和恢复其子组件的 UI 和内部状态

4.3 工具类 API

API 说明
createContext 创建一个 Context 对象,可供组件向其子组件提供数据,搭配 useContext 使用
forwardRef 允许组件将 DOM 节点作为 ref 暴露给父组件,搭配 useRef 使用
lazy 允许在组件第一次被渲染前延迟加载其代码,实现代码分割
memo 允许组件在 props 未发生变化时跳过重新渲染,搭配 useMemo 和 useCallback 使用
startTransition 允许将状态更新标记为非紧急的,与 useTransition 类似但用于非 Hook 场景
act 在测试中用于包裹渲染和交互,确保在断言前所有更新已处理完毕
createElement 创建 React 元素,通常用 JSX 替代,但可在非 JSX 环境中使用
cloneElement 克隆并返回一个新的 React 元素,可覆盖原元素的 props
isValidElement 检查某个值是否为 React 元素
Children 提供 mapforEachcountonlytoArray 等工具方法,用于处理 props.children 不透明数据结构
Component 定义类组件的基类
PureComponent 与 Component 类似,但自带 shouldComponentUpdate 浅比较实现
createRef 创建 ref 对象,类组件中用于访问 DOM 元素

4.4 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

4.5 资源预加载 API

这些 API 用于预加载脚本、样式表、字体等资源,从而让应用更快。基于 React 的框架通常会自动处理资源加载

API 说明
prefetchDNS 预解析 DNS 域名,提前获取 IP 地址,减少后续请求的 DNS 查询时间
preconnect 提前连接到预计请求资源的服务器,建立 TCP 连接和 TLS 握手,即使尚不确定具体需要哪些资源
preload 预获取并缓存预计要使用的资源(如样式表、字体、图片、外部脚本),但不执行,可节省时间
preloadModule 预获取预计要使用的 ESM 模块,但不执行
preinit 预获取并执行外部脚本,或预获取并插入样式表
preinitModule 预获取并执行一个 ESM 模块

4.6 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

5、React Router(路由)

React Router v6 完全利用 Hooks 重构。

核心组件

组件 作用
BrowserRouter history 模式路由容器
HashRouter hash 模式路由容器
Routes / Route 定义路由规则
Link / NavLink 声明式导航
Outlet 嵌套路由占位符

核心 Hooks

Hook 作用
useParams 获取路由参数
useLocation 获取当前 location 对象
useNavigate 程序化导航
useRoutes 配置式路由(替代 Routes + Route

6、React-Redux

API 说明
<Provider store> 顶层组件,使 store 对下层组件可用。
connect(mapStateToProps, mapDispatchToProps, mergeProps, options) 高阶组件(HOC),将 store 中的 state 和 dispatch 映射到组件的 props。
useSelector(selector) Hook,从 store 中提取数据,当数据变化时强制组件重新渲染。
useDispatch() Hook,返回 store 的 dispatch 函数。
useStore() Hook,返回 store 实例本身(不常用)。

Redux 现代推荐: (Redux Toolkit + React-Redux Hooks )

必须掌握的核心 API

API 作用 一句话说明
configureStore 创建 store 像 createStore 但更智能,自动加 thunk 和 DevTools
createSlice 同时生成 reducer 和 action creators 传入 name、initialState、reducers 对象,自动生成
createAsyncThunk 处理异步 action 自动生成 pending/fulfilled/rejected 三个 action,并在 extraReducers 中处理
// store.js
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// 1. 同步 slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1 },     // 直接“修改”
    decrement: state => { state.value -= 1 },
    incrementByAmount: (state, action) => { state.value += action.payload }
  }
});

// 2. 异步 thunk
export const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
  const res = await fetch(`/api/user/${userId}`);
  return res.json();
});

// 3. 异步 slice (处理 thunk 的三种状态)
const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  }
});

// 导出 action creators 和 reducer
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    user: userSlice.reducer
  }
});

export default store;

React-Redux Hooks —— 在组件里用状态

两个核心 Hook

Hook 作用 类比
useSelector 从 store 中读取数据 类似 mapStateToProps
useDispatch 拿到 dispatch 函数 类似 mapDispatchToProps

使用步骤(配合上面的 store)

1. 顶层用 Provider 包裹

// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
);

2. 组件内读取和派发

// Counter.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './store';

function Counter() {
  // 读取状态
  const count = useSelector(state => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
}

3. 异步 thunk 的派发

import { fetchUser } from './store';

function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const { data, loading, error } = useSelector(state => state.user);

  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{data?.name}</div>;
}

7、React 底层原理

1. 虚拟 DOM

虚拟 DOM 是用 JS 对象描述真实 DOM 结构

好处

  • 减少频繁的真实 DOM 操作
  • 跨平台(React Native)
  • 便于 diff 算法计算差异

2. Diff 算法

核心策略

  • 同层比较,不跨层级
  • 类型不同 → 直接销毁重建
  • 类型相同 → 对比属性
  • 列表靠 key 识别节点复用,复杂度优化到 O(n)

为什么 key 不能用 index?
数组增删前置元素会导致 index 错乱,引发组件状态错位、DOM 复用错误。应使用唯一稳定的业务 id

3. Fiber 架构(React 16+)

解决旧 React 一次性渲染卡死主线程的问题

两个阶段

  • Render 阶段:可中断、分片、优先级调度,遍历构建 Fiber 树
  • Commit 阶段:不可中断,一次性更新 DOM、布局、绘制

4. React 更新流程

触发 setState → 生成更新任务 → Fiber 调和(可中断)→ 收集 DOM 变更 → Commit 一次性渲染 → 浏览器绘制

5. 合成事件

React 自己实现了一套事件系统(事件委托到 root 节点),性能高,与原生事件混用时,原生先执行 → 合成后执行,阻止冒泡互不生效

从单体到协同:用 LangGraph 构建 Web3 投研的多智能体工作流

作者 木西
2026年4月6日 13:19

前言

在上一篇文章中,我们通过一个单体 Agent 实现了 Polymarket 与新闻数据的初步整合。然而,在面对波诡云谲的实战环境时,单体架构暴露出逻辑深度不足工具调用混乱以及无法自我纠错等硬伤。

为了解决这些痛点,本文将对原有的单智能体系统进行“手术级”重构,引入 LangGraph 实现多角色协同,让你的投研工具从一个“全栈练习生”进化为一支“专业特种部队”。

一、 单体 Agent 的“天花板”

在尝试构建 Web3 投研助手时,我们通常会给一个 Agent 塞进所有工具(搜索、行情、链上监测)。但在实战中,单体 Agent 常遇到三大痛点:

  1. 注意力涣散:Prompt 过长导致模型忽略了关键的风险提示。
  2. 逻辑闭环难:模型容易“自嗨”,拿到错误数据后直接开始推演,没有纠错机制。
  3. 工具冲突:当工具超过 5 个时,模型选择工具的准确率大幅下降。

二、 多智能体的降维打击:分工与制衡

多智能体架构的核心在于 “角色拆解” 。通过将任务分给不同的 Agent,我们模拟了一个专业投研机构的运作流水线:

1. 角色纯粹化(Specialization)

  • 研究员 (Researcher) :只负责“找”。它精通各种搜索语法,不带主观偏见地搬运事实。
  • 分析师 (Analyst) :只负责“想”。它不直接查数据,而是对研究员提供的情报进行逻辑建模、胜率计算和风险评估。

2. 动态博弈与纠错(Feedback Loop)

这是多智能体最强的地方:分析师可以“打回重做” 。如果研究员提供的情报不足以支撑结论,分析师会提出具体的补查要求,迫使系统进入循环直到逻辑闭环。

三、 实战:基于 LangGraph 的投研工作流

1.工具

import * as dotenv from "dotenv";
dotenv.config();
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { TavilySearch } from "@langchain/tavily";
import axios from "axios";
import { HttpsProxyAgent } from "https-proxy-agent";

const agent = new HttpsProxyAgent("http://127.0.0.1:3067");
const axiosConfig = { timeout: 15000, httpsAgent: agent, proxy: false };
const searchInstance = new TavilySearch({ maxResults: 5 }); // 增加搜索结果以捕捉更多套利新闻

// 1. 搜索工具:强化了对套利/异动新闻的搜索描述
export const financialSearchTool = tool(
    async (input) => {
        const query = typeof input === 'string' ? input : (input.query || JSON.stringify(input));
        console.log(`\n[🔍 正在执行深度搜索]: ${query}`);
        try {
            const res = await searchInstance.invoke(query);
            return JSON.stringify(res);
        } catch (e: any) { return `搜索暂时不可用`; }
    },
    {
        name: "financial_market_search",
        description: "搜索最新新闻背景、套利机会或市场异动。请输入关键词对象,例如:{\"query\": \"Polymarket arbitrage opportunities\"}",
        schema: z.object({ query: z.string() }), 
    }
);

// 2. 深度优化的行情工具 (Arbitrage & Trend Ready)
export const marketDataTool = tool(
    async (input) => {
        const userInput = typeof input === 'string' ? input : (input.marketName || JSON.stringify(input));
        
        // 1. 行业语义映射表 (包含最新套利关键词)
        const mapping: Record<string, string[]> = {
            "原油": ["oil", "crude", "energy", "brent", "wti", "gasoline"],
            "油价": ["oil", "crude", "energy"],
            "中东": ["israel", "gaza", "iran", "lebanon", "middle east", "hezbollah", "conflict"],
            "战争": ["war", "military", "strike", "attack", "invasion"],
            "选举": ["election", "trump", "vance", "harris", "walz"],
            "停火": ["ceasefire", "truce", "peace"],
            "核": ["nuclear", "facility", "isfahan"],
            "海峡": ["hormuz", "strait", "shipping"],
            "宏观": ["fed", "rate cut", "inflation", "recession", "gdp"],
            "套利": ["arbitrage", "mispricing", "spread", "basis", "hedging"],
            "时间差": ["deadline", "expiry", "until", "before", "sooner"],
            "美联储": ["powell", "fomc", "interest rate", "basis points"],
            "加密货币": ["bitcoin", "etf", "ethereum", "solana", "ath"]
        };

        // 获取基础关注词
        let baseTerms = [userInput.toLowerCase()];
        for (const [zh, ens] of Object.entries(mapping)) {
            if (userInput.includes(zh)) {
                baseTerms = ens;
                break;
            }
        }

        // --- 优化点:自动生成组合搜索词(捕获最新最热) ---
        const hotSuffixes = ["ceasefire", "rate cut", "ath", "trump", "deadline"];
        const focusTerms = [
            ...baseTerms,
            ...baseTerms.flatMap(term => hotSuffixes.map(suffix => `${term} ${suffix}`))
        ];

        console.log(`\n[📊 正在扫描套利机会]: 领域 -> ${userInput} | 衍生词数 -> ${focusTerms.length}`);

        try {
            // 获取全平台最火的 50 个市场
            const url = `https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=50`;
            const res = await axios.get(url, axiosConfig);
            
            if (!res.data || res.data.length === 0) return "Polymarket 暂无活跃市场。";

            // 2. 精准过滤逻辑
            const relevantMarkets = res.data.filter((m: any) => {
                const title = m.question.toLowerCase();
                // 必须包含正向词,剔除体育等干扰噪音
                const hasFocus = focusTerms.some(term => title.includes(term));
                const isNoise = ["nhl", "nba", "cup", "game", "soccer", "football"].some(noise => title.includes(noise));
                return hasFocus && !isNoise;
            });

            if (relevantMarkets.length > 0) {
                // 按相关度或题目排序,方便 Agent 对比相似市场寻找套利空间
                const marketList = relevantMarkets.map((m: any) => ({
                    question: m.question,
                    price: m.lastTradePrice,
                    endDate: m.endDate
                }));

                return JSON.stringify({
                    status: "success",
                    count: marketList.length,
                    data: marketList,
                    hint: "请分析以上市场之间是否存在隐含概率冲突或定价偏差。"
                });
            }

            return `[提示]:热门榜单中暂无直接对标 "${userInput}" 的套利交易对。`;
        } catch (e: any) { return `行情接口异常: ${e.message}`; }
    },
    {
        name: "get_realtime_market_data",
        description: "获取 Polymarket 实时赔率与套利机会。支持组合搜索。",
        schema: z.object({ marketName: z.string() }),
    }
);

export const tools = [financialSearchTool, marketDataTool];

2.主程

以下是使用 LangChain 最新 LangGraph 框架实现的协作代码片段:

import { ChatOpenAI } from "@langchain/openai";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { StateGraph, MessagesAnnotation, END } from "@langchain/langgraph";
import { financialSearchTool } from "./tools"; // 复用你之前的工具

// 1. 定义两个不同的 LLM 实例(可以给分析师更高的 Temperature 来激发灵感)
const llm = new ChatOpenAI({
  // 核心修复:显式指定 API Key 和 Base URL
  apiKey: process.env.DEEPSEEK_API_KEY, 
  modelName: "deepseek-chat",
  configuration: {
    baseURL: process.env.DEEPSEEK_API_BASE_URL,
  },
  temperature: 0,
});
// 2. 定义角色逻辑
// 研究员节点:强迫它必须使用工具
async function researcherNode(state: typeof MessagesAnnotation.State) {
  const systemMessage = {
    role: "system",
    content: "你是一名 Web3 研究员。你的任务是利用搜索工具获取关于特定事件的最新事实。获取事实后,直接将其传递给分析师,不要进行深度评论。",
  };
  const response = await llm.bindTools([financialSearchTool]).invoke([systemMessage, ...state.messages]);
  return { messages: [response] };
}

// 分析师节点:负责逻辑推演
async function analystNode(state: typeof MessagesAnnotation.State) {
  const systemMessage = {
    role: "system",
    content: "你是一名顶级高级分析师。你需要审查研究员提供的事实。如果事实太模糊,请要求研究员重新搜索;如果事实充足,请给出最终的套利分析报告。你的回复必须以 '【最终报告】' 开头。",
  };
  const response = await llm.invoke([systemMessage, ...state.messages]);
  return { messages: [response] };
}

// 3. 定义路由逻辑:判断是该继续搜,还是该结束了
function shouldContinue(state: typeof MessagesAnnotation.State) {
  const lastMessage = state.messages[state.messages.length - 1];
  // 如果分析师说了“最终报告”,就结束
  if (typeof lastMessage.content === "string" && lastMessage.content.includes("【最终报告】")) {
    return END;
  }
  // 如果有工具调用,去执行工具
  if (lastMessage.additional_kwargs.tool_calls) {
    return "tools";
  }
  // 否则,让分析师看研究员的结果
  return "analyst";
}

// 4. 构建工作流图 (Graph)
const workflow = new StateGraph(MessagesAnnotation)
  .addNode("researcher", researcherNode)
  .addNode("analyst", analystNode)
  .addNode("tools", new ToolNode([financialSearchTool])) // 专门执行工具的节点
  
  // 连线逻辑
  .addEdge("__start__", "researcher") // 从研究员开始
  .addEdge("tools", "researcher")      // 工具执行完后回到研究员
  .addConditionalEdges("researcher", shouldContinue) // 研究员做完后判断:调工具还是给分析师
  .addConditionalEdges("analyst", shouldContinue);    // 分析师做完后判断:结束还是打回重搜

// 5. 编译并运行
const app = workflow.compile();

async function runMultiAgent() {
  const inputs = { messages: [{ role: "user", content: "分析以伊冲突对 Polymarket 原油预测价格的影响" }] };
  const result = await app.invoke(inputs);
  
  console.log("\n--- 协作过程结束 ---");
  console.log(result.messages[result.messages.length - 1].content);
}

runMultiAgent();

为什么这套代码能跑赢单体模型?

  • 状态可控:你可以清晰看到请求是在“搜索”阶段还是“审计”阶段。
  • 递归深度:通过 recursionLimit 限制,你可以防止 Agent 陷入死循环,同时保证了深度。
  • 模型异构:你可以让研究员用便宜快速的 GPT-4o-mini,而让分析师用逻辑更强的 DeepSeek-V3 或 Claude 3.5。

结语

单体 AI 是工具,多智能体才是“数字员工”。在信息密度极高的 Web3 领域,学会如何编排一群 Agent 协作,将是开发者真正的竞争壁垒。

❌
❌