普通视图

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

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

2026年2月28日 15:15

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

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

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

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

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

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

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

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

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

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

1. 定义版本与安装脚本

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

#!/bin/bash

# 指定 Hugo 版本
HUGO_VERSION="0.79.1"

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

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

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

2. 封装启动命令

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

start.sh (开发模式):

#!/bin/bash

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

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

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

方案优势

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

总结

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

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

Flutter——状态管理 Provider 详解

作者 Haha_bj
2026年2月28日 14:42

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

一、Provider 是什么?

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

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

核心优势(对比原生 InheritedWidget)

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

二、Provider 核心概念

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

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

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

步骤 1:添加依赖

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

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

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

import 'package:flutter/foundation.dart';

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

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

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

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

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

步骤 3:注入 Provider 到 Widget 树

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

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

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

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

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

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

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

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

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

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

    return Scaffold(
      appBar: AppBar(title: const Text('Provider 计数器')),
      body: Center(
        child: Text(
          '计数:${counter.count}',
          style: const TextStyle(fontSize: 24),
        ),
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: counter.decrement, // 调用状态方法
            child: const Icon(Icons.remove),
          ),
          const SizedBox(width: 10),
          FloatingActionButton(
            onPressed: counter.increment,
            child: const Icon(Icons.add),
          ),
        ],
      ),
    );
  }
}
方式 2:Consumer(推荐,精准重建)

Consumer 可以精准控制重建范围,避免整个页面重建(性能更优):

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

  @override
  Widget build(BuildContext context) {
    print('CounterPage 整体重建了吗?'); // 状态变化时,这里不会打印!

    return Scaffold(
      appBar: AppBar(title: const Text('Consumer 示例')),
      body: Center(
        // 仅 Consumer 包裹的部分会重建
        child: Consumer<CounterProvider>(
          builder: (context, counter, child) {
            print('Consumer 内部重建了'); // 状态变化时,这里会打印
            return Text(
              '计数:${counter.count}',
              style: const TextStyle(fontSize: 24),
            );
          },
        ),
      ),
      floatingActionButton: Consumer<CounterProvider>(
        builder: (context, counter, child) {
          return Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: [
              FloatingActionButton(
                onPressed: counter.decrement,
                child: const Icon(Icons.remove),
              ),
              const SizedBox(width: 10),
              FloatingActionButton(
                onPressed: counter.increment,
                child: const Icon(Icons.add),
              ),
            ],
          );
        },
      ),
    );
  }
}
方式 3:Selector(更精准,过滤重建)

Selector 可以指定「监听的状态属性」,只有该属性变化时才重建(性能最优):

// 示例:仅当 count 为偶数时才重建
child: Selector<CounterProvider, bool>(
  // 选择要监听的属性(count 是否为偶数)
  selector: (context, counter) => counter.count % 2 == 0,
  builder: (context, isEven, child) {
    return Text(
      '计数:${Provider.of<CounterProvider>(context).count}\n是否偶数:$isEven',
      style: const TextStyle(fontSize: 24),
      textAlign: TextAlign.center,
    );
  },
),

四、Provider 进阶用法

1. 多状态管理(MultiProvider)

当需要注入多个状态类时,用 MultiProvider 避免嵌套:

// 定义第二个状态类(用户信息)
class UserProvider extends ChangeNotifier {
  String _userName = '张三';
  String get userName => _userName;

  void updateName(String name) {
    _userName = name;
    notifyListeners();
  }
}

// 注入多个 Provider
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CounterProvider()),
        ChangeNotifierProvider(create: (context) => UserProvider()),
      ],
      child: const MyApp(),
    ),
  );
}

// 消费多个状态
class MultiStatePage extends StatelessWidget {
  const MultiStatePage({super.key});

  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<CounterProvider>(context);
    final user = Provider.of<UserProvider>(context);

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('计数:${counter.count}'),
            Text('用户名:${user.userName}'),
            ElevatedButton(
              onPressed: () => user.updateName('李四'),
              child: const Text('修改用户名'),
            ),
          ],
        ),
      ),
    );
  }
}

2. 局部状态管理(页面内共享)

若状态仅在某个页面内共享,只需在该页面的 Widget 树中注入 Provider:

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

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => CounterProvider(), // 仅该页面可用
      child: Scaffold(
        appBar: AppBar(title: const Text('局部状态')),
        body: const LocalCounterWidget(),
      ),
    );
  }
}

// 子 Widget 获取局部状态
class LocalCounterWidget extends StatelessWidget {
  const LocalCounterWidget({super.key});

  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<CounterProvider>(context);
    return Text('局部计数:${counter.count}');
  }
}

五、性能优化技巧

  1. 缩小重建范围:优先使用 Consumer/Selector,避免用 Provider.of 导致整个 Widget 重建;

  2. 避免不必要的 notifyListeners () :仅状态真的变化时调用(如判断 _count 变化后再调用);

  3. 使用 lazy 初始化ChangeNotifierProviderlazy: true(默认),仅当首次消费时才创建状态实例;

  4. dispose 释放资源:若状态类持有网络 / 定时器等资源,需重写 dispose

    class TimerProvider extends ChangeNotifier {
      late Timer _timer;
      int _seconds = 0;
    
      TimerProvider() {
        _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
          _seconds++;
          notifyListeners();
        });
      }
    
      // 释放定时器资源
      @override
      void dispose() {
        _timer.cancel();
        super.dispose();
      }
    }
    
  5. 避免在 build 中创建状态:始终在 create 中创建,而非 build 方法(否则会重复创建)。

六、Provider 常见坑点

  1. context 范围问题:在注入 Provider 的同一层级,无法直接用 Provider.of 获取状态(需用 Builder 包裹);

    dart

    // 错误示例:context 是 MyApp 的 context,无法获取 Provider
    ChangeNotifierProvider(
      create: (context) => CounterProvider(),
      child: Text('${Provider.of<CounterProvider>(context).count}'), // 报错
    );
    
    // 正确示例:用 Builder 切换 context
    ChangeNotifierProvider(
      create: (context) => CounterProvider(),
      child: Builder(
        builder: (context) {
          return Text('${Provider.of<CounterProvider>(context).count}');
        },
      ),
    );
    
  2. listen: false 慎用Provider.of<T>(context, listen: false) 仅获取状态,不建立依赖,状态变化时不会重建;

  3. 多 Provider 命名冲突:若有多个同类型 Provider,需用 ProviderScopeConsumerselector 区分。

总结

  1. 核心定位:Provider 是 InheritedWidget 的优雅封装,主打「轻量、易用、响应式」,适合中小规模 App 的状态管理;
  2. 核心流程:定义 ChangeNotifier 状态类 → 用 ChangeNotifierProvider 注入 → 用 Consumer/Selector 消费;
  3. 性能关键:缩小重建范围(Consumer/Selector)、避免不必要的 notifyListeners()、及时释放资源;
  4. 适用场景:全局状态(用户信息、主题)、页面内局部状态(表单数据、计数器),不适合超复杂的状态逻辑(可换 Bloc/Riverpod)。

Three.js 零基础入门:手把手打造交互式 3D 几何体展示系统

作者 烛阴
2026年2月28日 14:27

一、搭建舞台——“三剑客”的诞生

在 3D 世界里,所有的表演都需要一个舞台。Three.js 遵循“三剑客”原则:场景、相机、渲染器

在你的代码开头,我们看到了这样的设定:

// 1. 场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);

// 2. 相机
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 40;

// 3. 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
const viewContainer = document.getElementById('view');
renderer.setSize(viewContainer.clientWidth, viewContainer.clientHeight);
viewContainer.appendChild(renderer.domElement);

解析:

  1. 场景:这就好比是一个空荡荡的剧院舞台。我们把它涂成了深灰色 (0x222222),方便看清演员。
  2. 相机:观众的眼睛。这里用的是 PerspectiveCamera(透视相机)。
    • 40 是视野角度(FOV),就像你睁大眼睛还是眯着眼睛看。
    • window.innerWidth / window.innerHeight 是长宽比,保证画面不变形。
    • camera.position.z = 40:这一步很关键!默认情况下,物体在坐标原点 (0,0,0),相机也在原点。如果你不移开相机,你就跟物体“贴脸”了,什么也看不见。我们把相机往后拉 40 米,就能看清全貌了。
  3. 渲染器:剧院的灯光和特效组。它负责把脑海中的画面画到屏幕上。
    • { antialias: true }:开启了抗锯齿,让边缘光滑,不再有毛刺。

二、点亮世界——没有光,一切都是黑的

如果你直接把物体放进去,可能会发现一片漆黑。为什么?因为在 3D 世界里,除了特殊的“基础材质”,大多数材质都需要光才能被看见。

代码里添加了两个光源:

{
    const light = new THREE.DirectionalLight(0xFFFFFF, 1);
    light.position.set(-1, 2, 4);
    scene.add(light);
}
// ... 还有一个方向相反的光

这里用的是 平行光,模拟太阳光。

  • 第一盏灯:位置在左上方 (-1, 2, 4),负责照亮物体的正面。
  • 第二盏灯:位置在右下方 (1, -2, -4),负责照亮物体的背面或暗部。

为什么要两盏灯? 这是一种低成本的“补光”技巧,防止物体背光面死黑一片,让立体感更强。


三、万物之源——几何体与材质

在 3D 世界里,一个物体 = 几何体 + 材质

  • 几何体:骨架,决定了形状(是方的、圆的,还是扭曲的)。
  • 材质:皮肤,决定了外观(是金属的、塑料的,还是半透明的)。

代码中定义了一个 primitives 对象,里面藏着 20 多种不同的几何体生成函数。让我们挑几个常用模型进行分析:

1. 基础款:方块与球体

'BoxGeometry': () => {
    const width = 8, height = 8, depth = 8;
    addSolidGeometry(new THREE.BoxGeometry(width, height, depth));
},
'SphereGeometry': () => {
    // ...
    addSolidGeometry(new THREE.SphereGeometry(radius, widthSegments, heightSegments));
},

这是最基础的构建模块。BoxGeometry 就像捏泥人时的方块,SphereGeometry 则是球体。注意 widthSegments 参数,它决定了球体的精细度——段数越多,球越圆,但计算量也越大。

2. 进阶款:挤压与车削

这可是把 2D 变成 3D 的魔法!

  • ExtrudeGeometry(挤压几何体): 代码里画了一个爱心形状的 2D 路径,然后给它一个厚度,它就变成了一个 3D 的爱心。

    'ExtrudeGeometry': () => {
        const shape = new THREE.Shape();
        // ... 画 2D 路径 ...
        const extrudeSettings = { depth: 2, bevelEnabled: true, ... };
        addSolidGeometry(new THREE.ExtrudeGeometry(shape, extrudeSettings));
    },
    

    想象一下,你用饼干模具在面团上按了一下,这就是 ExtrudeGeometry 做的事。

  • LatheGeometry(车削几何体): 这就像陶艺转盘。你定义好一个侧面的轮廓线,它旋转一圈就变成了一个罐子。

    'LatheGeometry': () => {
        const points = []; // 一系列二维点
        // ...
        addSolidGeometry(new THREE.LatheGeometry(points));
    },
    

3. 数学之美:参数化几何体与克莱因瓶

代码里最“不明觉厉”的部分来了:

function klein(v, u, target) {
    // ... 一堆复杂的数学公式 ...
}

'ParametricGeometry': () => {
    addSolidGeometry(new ParametricGeometry(klein, slices, stacks));
},

ParametricGeometry 允许你用数学公式来定义形状。这里的 klein 函数生成了一个著名的数学模型——克莱因瓶。它是一个没有“内”和“外”之分的奇怪瓶子。对于初学者,你只需要知道:只要你能写出 x, y, z 的方程,Three.js 就能帮你画出模型。

4. 文字 3D 化

'TextGeometry': () => {
    const loader = new FontLoader();
    const font = loader.parse(local_font);
    const textGeometry = new TextGeometry('three.js', { font, size: 3, ... });
    // ...
},

想让网页显示立体的 "Hello World"?你需要加载字体文件,然后 TextGeometry 会帮你把文字变成 3D 模型,甚至还能加倒角让文字更有质感。


每种基础元件的详细介绍会在后续文章介绍

核心代码与完整示例:     my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

Claude Code 踩坑实录:我流的泪,你别再流

作者 jerrywus
2026年2月28日 12:49

Claude Code (CC) 是 Anthropic 官方推出的 CLI 工具。用了大半个月,它确实能提升效率,但默认配置比较繁琐。这里总结一些让它更顺手的配置和工作流建议。

基础环境配置

/init

老生常谈了,大家都知道

用来初始化项目架构和规范,适用于新的项目或者新的模块。

1. 跳过繁琐的权限确认

image.png

CC 默认的安全机制很严,每条命令都要问 y/n,非常打断心流。建议直接在 .zshrc.bashrc 中加个别名:

echo 'alias cc="claude --dangerously-skip-permissions"' >> ~/.zshrc
source ~/.zshrc

以后敲 cc 就能直接执行。当然,执行删除或破坏性操作时自己要看着点。

2. 调整回车换行

默认回车是发送,很容易手抖把没写完的 Prompt 发出去。

运行 /terminal-setup (需要关闭所有终端,重新打开才生效)。后续打开cc, 可以在按下 Option + Enter时插入新行,而不是发送 Prompt。

发送还是按 Enter

image.png

3. 多会话区分标记

image.png

如果同时开几个 CC 终端(比如一个修 Bug,一个写文档),很容易切错窗口。 使用 /color 指令给不同用途的会话标记不同颜色,一眼就能区分。

4. 监控 Token

image.png

可以安装 claude-hud 插件或者习惯性输入 /status。主要是关注 Context 占用量,心里有数,避免 Token 用量意外爆炸。

项目地址:claude-hud

安装方式也很简单,打开cc, 会话中依次执行:

/plugin marketplace add jarrodwatts/claude-h

/plugin install claude-hud

/claude-hud:setup

无需重启cc即可生效。

让 Claude 更懂项目

1. CLAUDE.md

这是最重要的一点。在项目根目录创建 CLAUDE.md,把架构、代码规范、常用命令写进去。 Claude 启动时会读取它。这能大幅减少"它写的代码风格和我不一致"的问题,省去很多 Review 和纠正的时间。

小技巧:

  • 不要大篇幅去写它,可以把细分的rules总结到 .claude/rules 文件夹中。然后在CLAUDE.md中引用它们即可。

  • 可以让它在会话过程中必须全程尊称您为"哥们",而不是"用户", 当你发现它不再称呼您为"哥们"时,说明它已经开始降智了,此时记得关闭并重新启动新的会话。

image.png

2. 让cc记住常用的工作区目录

由于/add-dir 只能针对单独的会话使用,所以如果想在多个会话中共享项目目录,需要每个会话都执行一次,很麻烦.

可以直接修改 .claude/settings.local.json 文件,添加常用的工作区目录:

  • settings.local.json 示例
{
  "permissions": {
    "additionalDirectories": [
      "/Users/xxx/apps/work/xxx-project"
    ]
  }
}

然后重新打开新的cc会话,无需再次 /add-dir 添加项目目录了。

注意,.gitignore文件需要添加

.claude/*.local.*
*.local.*

节省 Token 的习惯

0. 能给明确指令,就别含糊其词

  • 使用 @ 指令,指定文件路径,而不是直接输入文件内容。
  • 明确告知cc不能做什么,怎么样做。
  • 使用todol列表的形式指导cc完成任务。

1. 及时清除上下文 (/clear)

不要在同一个会话里处理互不相干的任务。

修完 Bug A 转去做 Feature B 时,务必执行 /clear。清理无关上下文不仅省钱,更能减少模型幻觉,让 Claude 聚焦在当前任务上。

2. 60% 警戒线

/compact 提示出现,或者感觉到 Context 消耗超过 60% 时,Claude 的响应会变慢,智商也会下降。建议直接关闭并开启新的会话。

3. 使用 ! 执行简单命令

ls, pwd, git status 这种不需要 LLM 思考的命令,直接以 ! 开头输入(如 !ls -la)。

这样做,第一:不需要退出会话,第二:完全不消耗 Token,第三:也没必要让 cc帮你解释一遍。

比如执行 !git status ,可以直接查看当前项目的git状态。

image.png

进阶工作流

1. 任务队列

可以在会话中执行时候,继续输入新的任务,会陆续进入任务队列中,等待前一个任务完成后再执行。

image.png

2. 快速回滚 (/rewind)

如果 Claude 把代码改崩了,或者上下文聊乱了,直接 /rewind 回滚到之前的对话状态。这比手动 git checkout 或者告诉它"你改错了"要快得多。

3. IDE 联动

  • /ide的使用

    /ide 可以生成链接到某个IDE,比如vscode / trae。

    这样的好处是,可以自动定位当前active的文件,不用手动@文件路径。

    也可以选中某个文件中若干代码,cc会自动把选中的代码关联到当前会话中。

  • /diff的使用

    通过 /diff 直接在终端快速扫一眼变更,可以快速review。或者直接:ghottsy + Lazygit + yazi(后面会写文章单独介绍)

image.png

4. Vim 模式

嫌命令行输入框太小、编辑长 Prompt 不方便的话,输入 /vim 进入 Vim 模式, 直接使用vim快捷键进行编辑。

5. /sandbox指令

/sandbox 是个啥?

简单说,就是给 Claude 加上的一道安全围栏。

没开沙箱的时候,Claude 每跑个命令都得弹窗问你“允许吗?”,稍微复杂点的任务能把你烦死。开了沙箱,它就能在安全范围内自己干活,不用动不动就打断你。

它主要防两件事:

  1. 乱动文件: Claude 只能读写你当前的项目目录。你电脑里的其他东西(像 ~/.ssh/ 密钥或者系统配置)它是碰不到的。
  2. 乱连网: 它只能访问你批准过的域名。想连新的?得先问你。

怎么选模式? 一般直接选 Auto-Allow 就行,沙箱内的安全命令它自己就跑了,适合想快点干完活的人。如果你控制欲比较强,想每条命令都亲自过目,那就选普通模式。

怎么用? 敲 /sandbox 就能呼出菜单。 macOS 直接能用。Linux 或 WSL2 得装俩包:bubblewrap 和 socat。

会影响开发吗? 基本没感觉。平时跑个 flutter run、git status 啥的都在项目里,沙箱不会拦着。它主要防的是万一你装了个带毒的 npm 包,或者 Claude 脑抽了想执行个 rm -rf /,沙箱能把爆炸范围控制在项目文件夹里,不至于把你整个系统带走。

一句话:开了它,Claude 干活更利索,你也更放心它不会越界瞎搞。

6. 让cc自我总结

  • /insights

结合你的会话,cc会自我总结,并提供相关优化建议,生成的报告在:~/.claude/usage-data/report.html

报告主要包含:概述、你的工作内容、如何使用 Claude Code、你做的那些令人印象深刻的事、哪里出了问题、现有 CC 功能可供尝试、使用 Claude Code 的新方法 等内容。

其中最值得关注的是:哪里出了问题、现有 CC 功能可供尝试,可以帮我改善cc的使用。

  • /export 导出会话

适用于分享(比如做了一个特别有意思的功能/工具/演示demo,需要分享的时候)

  • find-skills

find-skills

可以查找你感兴趣的技能。

  • planning-with-files

planning-with-files

cc内置的plan模式是不会自动导出计划的,当你开启新的会话后,上下文会丢失

planning-with-files会把你的工作流变成使用持久化的 Markdown 文件来进行规划、进度跟踪和知识存储——这正是让 Manus 市值数十亿美元的模式。

高级功能

创建自定义的skill

比如通过 /skill-creator 创建一个auto-image-upload skill, 这样再也不用手动切图传 COS 了

具体请戳: 我写了个 Claude Code Skill,再也不用手动切图传 COS 了

sugagent

俗称智能体,每个智能体都在单独的上下文中运行,互不干扰。

我们可以创建一个sugagent,来帮我们自动完成一些任务, 比如自动读取figma设计稿,还原代码。

具体实践可以看cc官方文档: 创建一个sugagent

custom command

比如我们可以创建一个 /git-push 指令,来自动提交并推送当前改动,生成结构清晰的 commit message。

同时可以设置一些前置条件,比如:

  • 必须有 unstaged 或 staged 改动
  • 提交前先运行lint检查
  • 提交前先执行git pull,如果有冲突则停止,并告知用户

hooks使用

比如在Stop的时候执行代码 lint检查,确保代码质量。

~/settings.local.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "npm run lint"
          }
        ]
      }
    ]
  }
}

mcp的使用

mcp 是 claude code 可以访问外部服务的一个协议,通过它可以调用外部服务,比如:figma-mcp, chrome-devtools-mcp, dart mcp 等。

实战应用请看我的文章:一句话生成整套 API:我用 Claude Code 自定义 Skill + MCP 搞了个接口代码生成器

agent team的使用

这是真正的并行任务,多个会话可以同时运行,互不干扰。使用其实很简单,比方说:

请创建一个2人团队,分别查询nuxtjs和nextjs的官方文档,总结出它们的使用区别。

实战应用请看我的文章:一句话生成整套 API:我用 Claude Code 自定义 Skill + MCP 搞了个接口代码生成器

image.png

claudecode --worktree的使用

和agent team不同的是,claudecode --worktree 是单独的指令,只能开启一个会话,并且会创建一个worktree目录,用来存储会话的代码, 默认保存在.claude/worktrees目录下。

其实git worktree早就有了,只是cc将其集成到了cli中,用户使用时是无感知的。

worktree可以用来并行执行互不干扰的需求功能。

image.png

claudecode --worktree --tmux 可以开启一个会话,并且配合agent team自动拆分会话窗口(效果参考:agent team的使用一节中的图片)。

推荐个好用的终端软件

cmux,用过ghottsy的人就会明白它的优势(并且任务完成后它是有消息通知的)。直接上一张图,自行体会~


image.png

右侧是两个插件分别是:lazygit / yazi

lazygit: github.com/jesseduffie…

yazi: github.com/sxyazi/yazi

需要订阅的功能

  1. /chrome — 可以用 chrome-devtools-mcp 代替
  2. /desktop — 用桌面应用继续当前会话
  3. /login 和 /logout
  4. claude remote-control

总结

Claude Code 本质上是一个可以通过自然语言编程的终端环境。新手建议先配好 alias 和回车设置,习惯用 /clear 控制上下文;进阶的话,重点在于维护好 CLAUDE.md,把它当成一个需要 onboarding 的新员工来对待。

附一些坑(持续更新):

  1. 有时候会遇到对话框无法输入,按ctrl+shift+v即可
  2. 在开始提问前,最好通过/mcp查看所有mcp是否可用,免得因为部分连接失败导致任务失败

Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记

作者 _AaronWong
2026年2月28日 12:10

作为一名前端开发,最近接到了一个「划词取词」的需求 —— 老板希望做一个类似豆包、有道词典的划词识别功能,核心要求是低成本、离线可用、Windows 平台优先。整个开发过程一波三折,从 AI 生成的「截屏 + AI 识别」方案,到离线 OCR,最后落地到「划词 + Ctrl+C + 命名管道通信」,踩了不少坑,也积累了一些实战经验,特此记录。

需求背景

核心诉求:用户在任意窗口(浏览器、文档、办公软件等)用鼠标划选文字后,能快速获取选中的文本内容,用于后续的翻译 / 解释等操作,要求:

  • 离线运行,无网络依赖;
  • 仅支持 Windows 系统(公司主流办公环境);
  • 低成本(避免调用付费 OCR/AI 接口);
  • 尽可能不干扰用户原有操作。

三版方案的迭代之路

第一版:截屏 + AI 识别(被打回)

最初想着「快速搞定」,直接让 AI 生成了一份 Python 代码:监听鼠标按下 / 抬起的坐标,截取对应区域的屏幕截图,然后调用 AI 接口识别图片中的文字。

代码核心逻辑是用PIL.ImageGrab截屏,再通过 base64 传给 AI 接口:

# 第一版核心(简化)
def on_click_up(x, y, button, pressed):
    if not pressed:
        # 计算鼠标划选区域
        left = min(last_x, x)
        top = min(last_y, y)
        right = max(last_x, x)
        bottom = max(last_y, y)
        # 截屏
        img = ImageGrab.grab(bbox=(left, top, right, bottom))
        # 调用AI接口识别
        img_base64 = base64.b64encode(img_bytes).decode()
        res = requests.post(AI_API, json={"image": img_base64})
        text = res.json()["text"]
        print("识别的文字:", text)

问题:老板看到 AI 接口的调用成本后直接打回 —— 按公司的使用量,每月要额外支出数千元,完全不符合「低成本」要求。

第二版:离线 OCR(放弃)

既然 AI 接口不能用,那就换离线 OCR(比如 Tesseract)。但实际测试后发现:

  • 不同字体、字号、背景色下,OCR 准确率极低(尤其是小字体 / 模糊文字);
  • 需要用户额外安装 OCR 引擎,部署成本高;
  • 对截图的分辨率、区域裁剪要求极高,适配成本高。

最终因为「准确率达不到老板预期」,这个方案也被放弃了。

第三版:划词 + Ctrl+C + 跨进程通信(最终落地)

某天突然想到:用户划选文字后,系统本身已经把选中的内容「暂存」了,只要调用Ctrl+C复制,就能直接从剪贴板拿到文本 —— 这才是最直接、零成本、准确率 100% 的方案!

核心思路:

  1. Python 脚本监听鼠标划选动作(按下→拖动→抬起);
  2. 判定为有效划词后,自动触发Ctrl+C复制选中内容;
  3. 从剪贴板读取文本,通过「命名管道」传给 Electron 主进程;
  4. Electron 接收数据后,再分发给渲染进程做后续处理。

技术实现拆解

最终方案分为「Python 端(监听 + 复制 + 通信)」和「Electron 端(管道服务 + 数据处理)」两部分,核心依赖 Windows 的「命名管道(Named Pipe)」实现跨进程通信。

1. Python 端:监听划词并发送数据

Python 负责核心的「人机交互监听」和「剪贴板操作」,使用pynput监听鼠标 / 键盘,pyperclip操作剪贴板,win32file实现命名管道通信。

核心逻辑

from pynput import mouse, keyboard
import pyautogui
import pyperclip
import win32file
import pywintypes
import json
import win32gui
import win32process
import psutil

class ClipboardMonitor:
    def __init__(self):
        self.last_mouse_down_time = 0
        self.last_mouse_down_position = (0, 0)
        self.last_user_clipboard_content = None  # 保存用户原有剪贴板内容
        self.keyboard_activity = False  # 避免键盘操作干扰

    # 监听鼠标按下:记录起始位置+发送坐标给Electron
    def on_click_down(self, x, y, button, pressed):
        if pressed:
            self.last_mouse_down_position = (x, y)
            # 发送鼠标按下坐标给Electron(用于判断是否在目标窗口内)
            message = f"click_down_mouse_position:{x},{y}"
            self.send_to_electron(message)
            # 记录当前聚焦的应用(用于过滤禁用列表)
            self.last_mouse_down_client = self.get_focused_application()

    # 监听鼠标抬起:判定有效划词并复制
    def on_click_up(self, x, y, button, pressed):
        if not pressed:
            # 计算鼠标拖动距离(过滤误点击)
            distance = ((x - self.last_mouse_down_position[0]) **2 + (y - self.last_mouse_down_position[1])** 2) **0.5
            # 有效划词:距离>10px + 无键盘/鼠标干扰
            if distance > 10 and not self.keyboard_activity:
                # 检查配置:是否允许打开悬浮窗、当前应用是否在禁用列表
                if self.check_can_open_float_win() and self.last_mouse_down_client not in self.get_disable_client_list():
                    # 保存用户原有剪贴板内容(避免覆盖)
                    self.last_user_clipboard_content = pyperclip.paste()
                    # 自动触发Ctrl+C复制选中内容
                    pyautogui.hotkey('ctrl', 'c')
                    new_clipboard_content = pyperclip.paste()
                    # 对比剪贴板:确认为新选中的内容
                    if new_clipboard_content != self.last_user_clipboard_content:
                        # 封装数据并发送给Electron
                        self.send_clipboard_data(x, y, new_clipboard_content)
                    # 还原用户剪贴板(核心!避免干扰用户)
                    pyperclip.copy(self.last_user_clipboard_content)

    # 获取当前聚焦的应用名称(用于过滤)
    def get_focused_application(self):
        hwnd = win32gui.GetForegroundWindow()
        _, pid = win32process.GetWindowThreadProcessId(hwnd)
        try:
            process = psutil.Process(pid)
            return process.name()
        except:
            return "Unknown"

    # 命名管道发送数据给Electron
    def send_to_electron(self, message):
        pipe_name = r'\.\pipe\quick_word_electron_python_pipe'
        try:
            handle = win32file.CreateFile(
                pipe_name,
                win32file.GENERIC_WRITE,
                0,
                None,
                win32file.OPEN_EXISTING,
                0,
                None
            )
            win32file.WriteFile(handle, message.encode())
            win32file.CloseHandle(handle)
        except pywintypes.error as e:
            print(f"管道通信失败:{e}")

    # 启动监听
    def start(self):
        mouse_listener_down = mouse.Listener(on_click=self.on_click_down)
        mouse_listener_up = mouse.Listener(on_click=self.on_click_up)
        keyboard_listener = keyboard.Listener(on_press=self.on_key_press, on_release=self.on_key_release)
        mouse_listener_down.start()
        mouse_listener_up.start()
        keyboard_listener.start()
        mouse_listener_down.join()

if __name__ == "__main__":
    monitor = ClipboardMonitor()
    monitor.start()

关键细节

  • 剪贴板还原:必须保存用户原有剪贴板内容,复制后还原,否则会干扰用户操作;
  • 应用过滤:读取配置文件中的「禁用应用列表」,避免在指定应用内触发划词;
  • 误触过滤:通过鼠标拖动距离、键盘活动状态,过滤点击、误拖动等无效操作。

2. Electron 端:命名管道服务 + Python 管理

Electron 作为主进程,负责:

  • 启动 / 管理 Python 脚本;
  • 创建命名管道服务,接收 Python 发送的数据;
  • 处理数据并分发给渲染进程。

第一步:封装命名管道服务(namedPipeServer.js)

基于 Node.js 的net模块实现 Windows 命名管道服务,支持连接队列(避免并发问题):

const net = require('net');

class NamedPipeServer {
  constructor(pipeName, cb) {
    this.pipeName = pipeName;
    this.server = null;
    this.maxConnections = 10; // 最大连接数
    this.currentConnections = 0;
    this.connectionQueue = [];
    cb(this)
  }

  // 启动管道服务
  start(onDataCallback) {
    this.server = net.createServer((socket) => {
      // 连接数控制:超出则加入队列
      if (this.currentConnections >= this.maxConnections) {
        this.connectionQueue.push(socket);
      } else {
        this.currentConnections++;
        this.handleConnection(socket, onDataCallback);
      }
    });

    this.server.on('error', (err) => {
      console.error(`管道服务错误:${err.message}`);
    });

    // 监听命名管道
    this.server.listen(this.pipeName, () => {
      console.log(`命名管道监听中:${this.pipeName}`);
    });
  }

  // 处理连接:接收数据
  handleConnection(socket, onDataCallback) {
    socket.on('data', (data) => {
      const message = data.toString().trim();
      onDataCallback(message); // 回调处理数据
    });

    // 连接断开:复用队列中的连接
    socket.on('end', () => {
      this.currentConnections--;
      if (this.connectionQueue.length > 0) {
        this.handleConnection(this.connectionQueue.shift(), onDataCallback);
      }
    });

    socket.on('error', (err) => {
      console.error(`Socket错误:${err.message}`);
    });
  }

  // 停止管道服务
  stop() {
    if (this.server) {
      this.server.close(() => {
        console.log("命名管道服务已关闭");
      });
    }
  }
}

module.exports = { NamedPipeServer };

第二步:初始化 Python 环境 + 管道通信(quickWordLookup.js)

Electron 启动时,自动解压 Python 环境(避免用户手动安装),启动命名管道,再调用 Python 脚本:

const AdmZip = require("adm-zip");
const { NamedPipeServer } = require('./namedPipeServer');
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs');

class QuickWordLookup {
    constructor() {
        this.platform = process.platform;
        this.env = process.env.NODE_ENV || "production";
    }

    // 初始化Python环境+命名管道
    initPython() {
        if (this.platform !== "win32") return;

        // 1. 解压Python环境(打包在应用内的zip包)
        const pluginsPath = this.env === "development" 
            ? path.join(app.getAppPath(), 'plugins') 
            : process.resourcesPath;
        const pythonZipPath = path.join(pluginsPath, "vendors", "python3.11.zip");
        this.pythonDirPath = path.join(pluginsPath, "vendors", "python3.11");
        
        if (!fs.existsSync(this.pythonDirPath)) {
            const zip = new AdmZip(pythonZipPath);
            zip.extractAllTo(this.pythonDirPath, true); // 解压
        }

        // 2. 创建命名管道服务
        const pipeServer = new NamedPipeServer(
            '\\.\pipe\quick_word_electron_python_pipe', 
            () => {
                console.log("管道服务启动成功,启动Python脚本");
                this.openPythonExe(); // 管道就绪后启动Python
            }
        );

        // 3. 处理Python发送的数据
        pipeServer.start((message) => {
            if (message.startsWith("click_down_mouse_position:")) {
                // 处理鼠标按下坐标(判断是否在目标窗口内)
                const [x, y] = message.slice("click_down_mouse_position:".length).split(",").map(Number);
                const isInside = this.handleMousePosition(x, y);
                if (!isInside) return;
            } else if (message.startsWith("messgae_to_send:")) {
                // 处理划词内容:发给渲染进程
                const data = JSON.parse(message.slice("messgae_to_send:".length));
                this.sendToRenderer(data);
            }
        });
    }

    // 启动Python脚本
    openPythonExe() {
        if (this.platform !== "win32") return;
        const exePath = path.join(this.pythonDirPath, 'python.exe');
        // Python脚本路径(打包在应用内)
        const tempFilePath = this.env === "development" 
            ? path.join(__dirname, "../../public/python/underlineWord.py") 
            : path.join(process.resourcesPath, "vendors", "python/underlineWord.py");
        
        const cmd = `"${exePath}" "${tempFilePath}"`;
        exec(cmd, { encoding: 'utf-8' }, (error, stdout, stderr) => {
            if (error) {
                console.error(`Python启动失败:${error.message}`);
            } else {
                console.log("Python划词监听已启动");
            }
        });
    }

    // 发送数据到渲染进程
    sendToRenderer(data) {
        // 主进程→渲染进程通信(根据Electron版本调整)
        const mainWindow = BrowserWindow.getFocusedWindow();
        if (mainWindow) {
            mainWindow.webContents.send('word-lookup-data', data);
        }
    }
}

踩坑总结

  1. 命名管道的跨进程通信

    • Windows 命名管道路径格式必须是\\.\pipe\xxx,Node.js 的net模块需适配这个格式;
    • 必须保证「管道服务先启动,Python 再连接」,否则会出现连接失败;
    • 处理连接并发:添加连接队列,避免多客户端同时连接导致的异常。
  2. 剪贴板操作的坑

    • 直接调用pyautogui.hotkey('ctrl', 'c')在部分应用(如某些加密文档)中无效,需备用方案(win32api.SendMessage发送 WM_COPY 消息);
    • 必须还原用户原有剪贴板内容,否则会引发用户投诉。
  3. Python 环境打包

    • 将 Python 解释器 + 依赖包打包成 zip,Electron 启动时自动解压,避免用户手动安装;
    • 开发 / 生产环境的路径差异:需区分app.getAppPath()process.resourcesPath
  4. 应用兼容性

    • 不同应用的「划词 + 复制」逻辑不同(如某些游戏 / 加密软件屏蔽 Ctrl+C),需做兼容处理;
    • 通过psutil获取当前聚焦应用,支持「禁用应用列表」配置。

优化方向

  1. 增加 Python 进程守护:监控 Python 脚本是否崩溃,自动重启;
  2. 支持更多快捷键:除了鼠标划词,支持用户自定义快捷键触发;
  3. 剪贴板内容过滤:过滤空内容、特殊字符,提升体验;
  4. 跨平台适配:后续可扩展 macOS(使用 Unix 域套接字替代命名管道)。

总结

这次需求从「AI 生成快速方案」到「落地可用」,核心是回归「用户操作的本质」—— 划词后系统本身已有选中内容,无需复杂的截屏 / OCR,只需「借力」系统剪贴板 + 跨进程通信即可搞定。

技术选型上,Electron 负责界面和进程管理,Python 负责底层的系统事件监听,两者通过命名管道高效通信,既满足了离线、低成本的要求,又保证了准确率和用户体验。

这个案例也让我明白:有时候最有效的方案,往往不是最「高科技」的,而是最贴合用户操作习惯、最利用现有系统能力的。

I/O 多路复用:从浏览器到 Linux 内核

作者 charmson
2026年2月28日 12:07

为什么前端工程师需要理解这个?

你写的每一行 fetch()、每一个 WebSocket 连接、每一次 Node.js 的文件读取,背后都在依赖同一套机制。

从 V8 / Node.js 说起

浏览器和 Node.js 都是单线程 + 事件循环模型:

┌─────────────────────────────────────────────────┐
│                  JavaScript 单线程               │
│                                                 │
│   fetch() → Promise → .then()                   │
│   setTimeout() → callback                       │
│   WebSocket.onmessage → handler                 │
└───────────────┬─────────────────────────────────┘
                │ 所有 I/O 都是异步的
                ▼
┌─────────────────────────────────────────────────┐
│              libuv(Node.js 的异步核心)           │
│                                                 │
│   事件循环 → 调用操作系统 I/O 接口               │
│                                                 │
│   Linux:   epoll_wait()                         │
│   macOS:   kqueue()                             │
│   Windows: IOCP                                 │
└─────────────────────────────────────────────────┘

核心矛盾:JS 是单线程的,但现实世界的 I/O 是并发的。
浏览器同时打开 100 个 WebSocket,不可能为每个连接开一个线程——你需要用一个线程监听 100 个 fd,谁有数据就处理谁。

这就是 I/O 多路复用要解决的问题。


三种机制的演进

历史上出现了三种方案,一代比一代好,解决同一个问题:如何高效地同时监听多个文件描述符(fd)?

1983  select    位图轮询,上限 1024
1986  poll      数组轮询,解除上限
2002  epoll     事件驱动,彻底告别 O(n)

select — 最原始的方案

数据结构

fd_set readfds;   // 本质是 1024 位的 bitmap
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);

int ready = select(max_fd + 1, &readfds, NULL, NULL, NULL);
// 返回后必须手动遍历 0~max_fd,逐个 FD_ISSET() 检查

调用全流程

用户态                           内核态
  │                                │
  ├─ 构造 fd_set bitmap ──────────►│
  │                                ├─ 全量拷贝 bitmap(O(n) 内存拷贝)
  │                                ├─ 逐个遍历 fd,调用驱动 poll()(O(n))
  │  [进程挂起等待]                 ├─ 某 fd 就绪 → 标记 bitmap
  │                                ├─ 全量拷贝 bitmap 回用户态(O(n) 内存拷贝)
  │◄───────────────────────────────┤
  ├─ 再次遍历所有 fd(O(n))        │
  │  FD_ISSET() 逐一检查           │

致命缺陷

问题 原因
fd 上限 1024 fd_set 是定长 bitmap,FD_SETSIZE = 1024
每次两次全量拷贝 bitmap 从用户态 → 内核态 → 用户态
两次 O(n) 遍历 内核遍历 + 用户态遍历,n = max_fd
fd_set 被内核改写 每次调用前必须重置,不能复用

poll — 改良版,解除上限

数据结构

struct pollfd {
    int   fd;       // 文件描述符
    short events;   // 关注的事件(用户填,不会被改写)
    short revents;  // 实际发生的事件(内核回写)
};

struct pollfd fds[1000];
int ready = poll(fds, 1000, -1);
// 返回后遍历 fds[],检查 fds[i].revents != 0

相比 select 改进了什么

select 的问题              poll 的解法
─────────────────────────────────────────────
fd 上限 1024         →    pollfd 数组,理论无上限
fd_set 被内核改写    →    events / revents 分离,events 不变
三组 bitmap 混乱     →    events 位掩码,更清晰

没有解决的问题

                    select        poll
全量内存拷贝          ✗             ✗    ← 每次都要拷贝整个数组
内核 O(n) 遍历        ✗             ✗    ← 逐个检查驱动 poll()
用户态 O(n) 遍历      ✗             ✗    ← 还是要自己循环找就绪的

poll 只是 select 的"形状更好的版本",性能瓶颈的根源没变:连接越多越慢,活跃率越低越浪费


epoll — 真正的突破

核心思想转变

select/poll 是主动轮询:每次调用都要问一遍"谁好了?"
epoll 是被动通知:谁好了,内核主动告诉我。

select/poll 模型:
  你:「fd 0 好了吗?没有。fd 1 好了吗?没有。fd 2 好了吗?...」

epoll 模型:
  内核:「fd 7 好了,fd 23 好了,就这俩。」
  你:直接处理这俩。

三个系统调用

// 1. 创建 epoll 实例(一次性)
int epfd = epoll_create1(0);

// 2. 注册 fd(fd 信息常驻内核,无需每次传)
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sockfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);  // O(log n)

// 3. 等待事件(只返回就绪的 fd)
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);
// n = 就绪数量,直接遍历 n 个,无需全量扫描
for (int i = 0; i < n; i++) {
    handle(events[i].data.fd);
}

内核数据结构

epoll 实例(eventpoll)
├── 红黑树(rbr)
│   ├── fd 3  ← epoll_ctl ADD 时插入,O(log n)
│   ├── fd 7
│   ├── fd 23
│   └── ...   ← 所有注册的 fd,常驻内核
│
└── 就绪链表(rdllist)
    │
    │  ← 网卡中断 → 驱动 → ep_poll_callback() → 插入此处
    ├── fd 7   (有数据了)
    └── fd 23  (有数据了)

epoll_wait 只取 rdllist,不碰红黑树
拷贝量 = 就绪数量,与注册总量无关

关键路径:一次数据到达

1. 网卡 DMA 写数据 → 触发硬件中断
2. 内核中断处理 → 调用 TCP/IP 协议栈
3. 数据到达 socket 缓冲区
4. 驱动调用 ep_poll_callback()
5. 将对应 epitem 插入 rdllist
6. 唤醒 epoll_wait 等待的进程
7. epoll_wait 返回,只拷贝 rdllist 中的事件

整个过程,内核从不遍历所有注册的 fd。

LT vs ET 触发模式

LT(水平触发,默认)              ET(边缘触发,EPOLLET)
─────────────────────────────────────────────────────────
fd 有数据 → 每次 epoll_wait 都返回   状态变化时通知一次
不读完没关系,下次还会通知           必须一次读完(循环到 EAGAIN)
实现简单,适合入门                   性能更高,Nginx/Redis 默认用
// ET 模式必须配合非阻塞 I/O + 循环读
ev.events = EPOLLIN | EPOLLET;
fcntl(fd, F_SETFL, O_NONBLOCK);

while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n == -1 && errno == EAGAIN) break;  // 读完了
    if (n <= 0) { close(fd); break; }
    process(buf, n);
}

三者对比

select poll epoll
fd 上限 1024 无限制 无限制
内存拷贝 每次全量 每次全量 仅就绪事件
内核遍历 O(n) O(n) O(1) 回调
fd 信息 每次重传 每次重传 常驻内核
触发模式 LT LT LT + ET
平台 POSIX POSIX Linux 专属

回到前端:这些机制在哪里工作

你写的代码                    底层机制
──────────────────────────────────────────────────────
fetch('https://...')
  .then(res => res.json())  →  libuv → epoll_wait()
                                       等待 TCP socket 就绪

new WebSocket('wss://...')  →  libuv 维护 socket fd
ws.onmessage = handler         数据到达 → epoll 回调 → 事件队列
                                       → JS 微任务队列 → handler()

fs.readFile('./data.json',  →  libuv 线程池(文件 I/O 特殊)
  callback)                    完成后 epoll 通知主线程

setTimeout(fn, 100)         →  timerfd(Linux)加入 epoll 监听
                               100ms 后 timerfd 就绪 → 回调

Node.js 事件循环与 epoll 的关系

┌──────────────────────────────────────┐
│           Node.js 事件循环            │
│                                      │
│  timers → I/O callbacks → idle →     │
│  poll ──────────────────────────────►│
│    │                                 │
│    └── epoll_wait(epfd, events, ...)  │
│         阻塞直到有事件                │
│         返回就绪事件列表              │
│         → 执行对应 JS 回调           │
└──────────────────────────────────────┘

epoll 就是 Node.js "非阻塞 I/O"的操作系统基石。你每次写 await fetch(),都在隐式地使用它。


什么时候用哪个

连接数 < 100,追求可移植性    →  select(或直接用库)
需要跨平台,连接数适中        →  poll
Linux 高并发服务器            →  epoll(libuv/libevent/Nginx 的选择)
macOS/BSD                    →  kqueue(同 epoll 思想)
Windows                      →  IOCP(完成端口,异步模型不同)

实际开发中你几乎不会直接调用这三个——Node.js 的 libuv、Python 的 asyncio、Go 的 netpoll 都已封装好。但理解它们,你才能真正读懂"非阻塞"、"事件驱动"、"单线程高并发"这些词背后的含义。

Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。

作者 JarvanMo
2026年2月28日 12:00

Screenshot 2026-02-28 at 09.04.02.png

Screenshot 2026-02-28 at 09.03.32.png

这两个包在 pub.dev 页面上都显示“即将推出 (coming soon)”,但我已经把 material_ui 添加到我当前的项目里了:

dart pub add material_ui

image.png

替换了其中一个视图里的 import 引用:

image.png

重启 App 之后,所有的组件似乎都还在。

这听起来有点不可思议,毕竟那些组件依然存在于 Flutter 核心库中,但我已经验证过了 😎,一切运行正常。

但实际上,material_ui中并没有任何实际代码,是一个空包。哈哈,不过这也算是一个解耦路上的一小步

// Copyright 2013 The Flutter Authors  
// Use of this source code is governed by a BSD-style license that can be  
// found in the LICENSE file.  
  
/// The Flutter Material Design library.  
///  
/// To use, import `package:material_ui/material_ui.dart`.  
library material_ui;  
  
export 'package:flutter/material.dart';

这对我们 Flutter 开发者意味着什么?

有些人对此感到非常兴奋,认为这会加速 Flutter 的开发效率,因为今后 Flutter 核心引擎、material_uicupertino_ui 这三者可以独立进行版本更新,互不干扰。

也有人持怀疑态度,觉得除了源代码里多改一行 import 之外,不会有任何实质性变化。毕竟 Flutter 现在一年已经雷打不动发布 4 个大版本了,很难想象以后更新频率还能再高到哪去。

但我认为,这无论如何都是迈向前进的一步。这说明 Flutter 团队一直在做事,谷歌并没有放弃 Flutter, “Flutter 已死”这种话纯属无稽之谈。 😂

而且,说不定 Flutter 团队以后能少花点精力在那些(我觉得)完全没用的 Cupertino 组件上,转而简化 Android 和 iOS 的构建流程

感谢阅读,祝大家 Flutter 开发愉快!

给自己整一个 claude code,解锁编程新姿势

作者 imoo
2026年2月28日 11:32

前言

用 claude code 有段时间了,越来越觉得这玩意好使。cursor 和它我都在用,它跟 cursor 定位不太相同,我觉得它更像一个真正了解你的助手,而非一个纯粹的一次性提示工具,甚至不止局限在编程中。

写一次性的代码,我喜欢用 cursor,但对于长期能沉淀下来的东西,我更欣赏 claude

不过之前都是拿老板给好的 key 来做这件事,现在自己倒是想试试成本如何,最终选中了新出的 qwen 来做。

image.png

claude code 是什么

简单来说,就是可以在终端直接聊天的 ai,而不需要你打开什么平台(比如 skywork 网站、豆包网站、cursor 软件等)作为载体。

相较于传统的平台形式,个人认为其优势如下:

  • 靠终端运行,能在没有图形化桌面的系统上使用,比如 linux

  • 拥有执行层,能更好的实现目标。过去的 ai 可能只是长了嘴,没有行动能力,处处受限,claude code 则长了手脚,可以操作各种文件。(clawbot 则是在这个基础上,拓展了更多权限 + 能用手机遥控)

  • 拥有更好的记忆层,他的全局与局部的 claude.md 能更灵活的设置规则,此外拥有范式输入输出能力,可以利用 skill 与 hook 规范化 agent 生成的内容。这两者的结合,再加上精细的设定,能更加靠近我们的终极目标:让 ai 像你一样进行思考

  • 可接受各类型的 api,主流模型厂家基本都可以无缝使用,并且能看出对于各种消息处理都加了优化,即使是很烂的模型跑起来效果也不逊色。

claude code 使用流程

  1. 安装 node.js 以获得 npm 能力,通过 npm 安装 claude code (也可以参考 code.claude.com/docs/zh-CN/… 官方的其他安装路径)
npm install -g @anthropic-ai/claude-code

2. 安装成功,运行 claude 命令

claude

就可以看到面板了

  1. 进到下一步,会发现三个选项都需要登录,但国内 ip 被屏蔽了,不允许使用。

所以我们需要先 ctrl c 退出 claude,配置好环境变量才能跳过这一步了。

环境变量也就是你所使用的大模型 api,一般都会有相关文档,这里我们用 qwen3.5-plus 来举例。类似这样:

export ANTHROPIC_BASE_URL=https://dashscope.aliyuncs.com/apps/anthropic
export ANTHROPIC_API_KEY=YOUR_DASHSCOPE_API_KEY   # 用百炼 API  KEY 替换 YOUR_DASHSCOPE_API_KEY
export ANTHROPIC_MODEL=qwen3.5-plus # 可按需替换为其他支持的模型。 

官方有详细教程:bailian.console.aliyun.com/cn-beijing/…

注意最后一步的修改 .claude.json文件,mac 是需要通过 Command (⌘) + Shift + . 来显示隐藏的文件才能看到

配置完成后,再输入 claude,就会发现跳过这一步了。此时会弹出提示,是否确认在当前文件夹下进入 claude,需要谨慎授权。

  1. 上述操作均完成后,你就可以在终端使用 claude code 了,来一个最简单的 case 来验证可用性

CC Switch

由于我们是通过环境变量的形式来切换 api,每次输入命令行都不是很优雅,特别是你有多个账号的情况下。

那很自然的会想到,能不能有一个程序,帮我们进行一键切换呢?cc switch 就是负责这个事情的。

  1. 安装 cc switch

github.com/farion1231/… 下载安装包并且安装,首次安装会有安全提醒,请不要删除程序,打开【设置】app,进入【隐私与安全】选择打开

  1. 添加供应商

  1. 接着需要填写表单,一般而言,该表单有两种填写方式

    1. 直接填写 json

    2. 填写表单

      1. 供应商名称:随便
      2. api 三件套:api key、api 请求地址、模型名,这三样在你购买 api 时都会提供
  2. 验证你的配置是否有效:

打开终端后,随便发送比如 hello,如果正确回复则有效。之后当你想切换供应商时,只需要在 cc switch 里进行选择即可。

skills 是什么

众所周知,prompts 也就是提示词,良好的提示词可以让大模型更好的工作。

提示词经过了几次变迁

【v1.0】 只输入简单的问题

【v2.0】 发现更专业的提示词,能让模型更好的工作,于是逐渐复杂起来,比如你是一个资深程序员...

【v3.0】 随着提示词的完善,引出了 agent 的概念,写足够特化的提示词,让模型只做一类事。比如 skywork 的各种 agent,他们的提示词就已然成为了一个独立的项目,被称为 PE (Prompts Engineering 提示工程),同时 agent 被允许调用各种 tool/mcp,不过并没有形成规范,更多还是随意发挥

【v4.0】 仅靠提示词无法可靠的描述可执行能力,这时候 skills 出现了,它更像一份完整的说明书,以规范的格式,描述了各类工具的使用流程等,其中可以夹杂脚本,进行标准化输入输出(有点类似 dify 那样的工作流)。

一个简单的 skills 示例

复杂 skill 目录示例

自定义 skills 详情可参阅 platform.claude.com/docs/zh-CN/…

不过对我们而言,更多的是使用市面上已有的 skills,亦或者是让 ai 根据要求自己封装 skill。

  • 使用已有的:直接将 github 链接发送给它,并让他安装

  • 自己封装:

    • 简单版:直接口述要求让它做 skill

    • 进阶版:先安装 github.com/anthropics/… 这个 skill,告诉模型利用这个 skill 引导你做一个 skill

另外就是比较好用的 skill 推荐:

一些使用经验

  1. /plan 切换到计划模式,可以先规划好内容再行动
  2. /clear 用于清除上下文
  3. esc 可以打断模型,两次 esc 可以切出对话节点并进行切换,这个用于回滚十分顺畅
  4. ctrl c (有时候需要两次)可以退出 claude,并且使用一些 claude 相关的命令,比如输入 claude 是启动 claude,claude -r 是打开最近对话列表并进入目标对话

tanstack query的基本使用

2026年2月28日 11:00

一.为什么要使用tanstack query?

显式的来看:主要是可以简化代码,可以看下面的一个例子,这是一个我们经常用的发送请求的一个过程,包括了请求数据,加载处理,还有错误展示。
隐式的来看:除了简化代码,还有自动缓存,可配置的数据过期时间,请求去重,乐观更新,并行和依赖查询等等好处

import { Button } from "antd";
import { useState, useEffect } from "react";

function Home() {
  const [data, setData] = useState<{ id: number, name: string }[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  useEffect(() => {
    setIsLoading(true);
    fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json()).then(data => {
      setData(data);
    }).catch(err => {
      setError(err as Error);
    }).finally(()=>{
      setIsLoading(false);
    })
  },[])
  return (
    <>
      <div>Home页面</div>
      <Button type="primary">Button</Button>
      <div className="mt-10 ml-10">
        {isLoading ? <div>Loading...</div> : null}
        {
          data?.map((item) => {
            return <div key={item.id}>{item.name}</div>
          })
        }
        {error ? <div>Error: {error.message}</div> : null}
      </div>
    </>
  );
}
export default Home;

使用tanstack query之后,代码量减少了很多很多

import { Button } from "antd";
import { useQuery } from "@tanstack/react-query";

function Home() {
  const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json())
  })

  return (
    <>
      <div>Home页面</div>
      <Button type="primary">Button</Button>
      <div className="mt-10 ml-10">
        {isLoading ? <div>Loading...</div> : null}
        {
          data?.map((item) => {
            return <div key={item.id}>{item.name}</div>
          })
        }
        {error ? <div>Error: {error.message}</div> : null}
      </div>
    </>
  );
}
export default Home;

二.学习网址

官方地址:tanstack.com/query/lates…

三.tanstack query的好处:

a.自动缓存

在第一次调用的时候,都会去后台拿数据。但是在再次调用的时候,tanstack因为有缓存,所以会先从缓存中把之前的数据拿出来渲染,同时去请求新的数据,然后把新的数据替换掉data。

// 不使用 TanStack Query
const [data, setData] = useState(null);
useEffect(() => {
  fetchData().then(setData);
}, []);

// 使用 TanStack Query
const { data } = useQuery(['key'], fetchData);
// 自动缓存!后续调用瞬间返回

b.可配置的数据过期时间和重新获取间隔

a.重新获取间隔staleTime

默认情况下,通过 useQuery 或 useInfiniteQuery 查询实例将缓存的数据视为过时的数据。
但是在配置了staleTime之后,比如将 staleTime 设置为例如 30 * 1000,会确保从缓存中读取数据,而不触发任何类型的重新获取,持续 30秒,或直到 Query 手动失效。意思就是30s内再去请求这个接口,不会从后台拿数据,而是从缓存中拿数据。

import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    // 数据在30秒内被视为"新鲜",不会重新获取
    staleTime: 1000 * 30, // 30秒
    // 数据在5分钟后会被视为"过期",下次访问时会后台重新获取
    gcTime: 1000 * 60 * 5, // 5分钟 (v5版本以前叫 cacheTime)
    // 窗口重新聚焦时,如果数据已过期,重新获取
    refetchOnWindowFocus: true,
    // 组件重新挂载时,如果数据已过期,重新获取
    refetchOnMount: true,
    // 网络重新连接时重新获取
    refetchOnReconnect: true,
  });
}

b.数据过期时间gcTime

这个指的是数据存在缓存中的时间。有一点要注意:只有组件卸载后<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">gcTime</font> 才会开始倒计时,倒计时结束后缓存被清理。下次组件挂载时如果没有缓存,才会重新请求。
比如以下代码:

const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json()),
    staleTime: 1000 * 30,
    gcTime: 1000,
  })

staleTime为30s,gcTime为1s。这个是在home页面上的, 如果离开home页面1s以上,再进入到home页面,则会从新请求。 如果在离开home页面1s以内重新进入,那么还是会从缓存中取数据(因为1s以内缓存还没被清理)。

c.并行和依赖查询

a.并行查询

不需要其他的操作,直接写发起请求的就会同时去请求(主要是告诉你useQuery有这个特性(这样子写就会并行查询))

function App () {
  // The following queries will execute in parallel
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
  ...
}

也可以使用useQueries来批量查询

queries接受的是一个查询的数组,返回的userQueries是一个查询结果的数组

function App({ users }) {
  const userQueries = useQueries({
    queries: users.map((user) => {
      return {
        queryKey: ['user', user.id],
        queryFn: () => fetchUserById(user.id),
      }
    }),
  })
}

b.依赖查询eabled

这里主要用到了enabled这个属性,这个属性接受的是一个boolean的变量。以下的意思为先去请求users的数据,然后data有数据之后再去请求articleList的数据。这个还是挺重要的,比如跳转到详情页,需要先拿到id,有id才能去查询数据

const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json()),
    staleTime: 1000 * 30,
    gcTime: 1000,
  })
  console.log('data',data);

  const { data: articleList } = useQuery<{ id: number, title: string }[]>({
    queryKey: ['articleList'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json()),
    enabled: !!data
  })
  console.log('articleList',articleList);

d.完整的加载状态管理

这里有很多状态,但是一般就是用到data和isLoading最多,但是这里列出来让大家都知道

function DataComponent() {
  const query = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
  });

  // 丰富的状态信息
  const {
    data,           // 成功后的数据
    isLoading,      // 首次加载中
    isFetching,     // 任何请求进行中(包括后台)
    isPending,      // 没有数据且没有加载
    isError,        // 是否错误
    isSuccess,      // 是否成功
    error,          // 错误对象
    status,         // 'pending' | 'error' | 'success'
    fetchStatus,    // 'fetching' | 'paused' | 'idle'
    dataUpdatedAt,  // 最后更新时间
    errorUpdatedAt, // 最后错误时间
    failureCount,   // 失败次数
    failureReason,  // 失败原因
    errorUpdateCount, // 错误更新次数
    isPaused,       // 是否因网络暂停
    isRefetching,   // 是否正在重新获取
  } = query;

  if (isLoading) return <Skeleton />;
  if (isError) return <Error message={error.message} />;
  
  return (
    <div>
      <DataView data={data} />
      {isFetching && <BackgroundRefreshIndicator />}
    </div>
  );
}

e.请求去重

就是多个页面同时都用到了这个请求的话,会主动去重,不会同一时间多次请求

// API 函数
const fetchProducts = async (category) => {
  console.log('📦 发起商品请求', new Date().toLocaleTimeString());
  await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟慢请求
  const res = await fetch(`/api/products/${category}`);
  return res.json();
};

// 商品列表组件
function ProductList({ category }) {
  const { data: products } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
  });

  return <div>{products?.map(p => <ProductCard key={p.id} product={p} />)}</div>;
}

// 商品数量统计组件
function ProductCount({ category }) {
  const { data: products } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
  });
  return <span>总数: {products?.length}</span>;
}

// 商品分类导航组件
function CategoryNav({ category }) {
  const { data: products } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
  });

  return <div>分类: {category} ({products?.length}件商品)</div>;
}

// 页面:三个组件同时渲染
function CategoryPage({ category }) {
  return (
    <div>
      <CategoryNav category={category} />
      <ProductCount category={category} />
      <ProductList category={category} />
      {/* 🔥 尽管三个组件都请求相同数据 */}
      {/* ✅ 但只会发起一次API请求 */}
      {/* ✅ 节省带宽,提高性能 */}
    </div>
  );
}

f.乐观更新

什么是乐观更新?

乐观更新就是:假设操作会成功,先更新UI,再发请求。

简单说就是:先给用户看结果,再去服务器确认。

传统方式:
点击按钮 ──> 显示加载 ──> 等待500ms ──> 服务器响应 ──> 更新UI
             用户感觉:慢,有延迟

乐观更新:
点击按钮 ──> 立即更新UI ──> 后台发送请求 ──> 服务器响应
             用户感觉:瞬间响应,像本地操作
const queryClient = useQueryClient()

useMutation({
  // 1. mutationFn: 实际发送请求的函数
  mutationFn: updateTodo,  // 调用API更新待办
  
  // 2. onMutate: 乐观更新的核心!
  onMutate: async (newTodo, context) => {
    // ⚠️ 注意:这里的参数是 (newTodo, context)
    // newTodo: 要更新的数据
    // context: 包含 client (QueryClient 实例)
    
    // 2.1 取消任何正在进行的查询
    // 为什么要取消?避免旧数据覆盖我们的乐观更新
    await context.client.cancelQueries({ queryKey: ['todos'] })
    
    // 2.2 保存当前数据的快照
    // 为什么要保存?如果失败需要回滚到这个状态
    const previousTodos = context.client.getQueryData(['todos'])
    
    // 2.3 乐观更新:立即更新UI
    // 不等服务器响应,先把新待办加到列表里
    context.client.setQueryData(['todos'], (old) => [...old, newTodo])
    
    // 2.4 返回快照,供onError使用
    return { previousTodos }
  },
  
  // 3. onError: 如果请求失败
  onError: (err, newTodo, onMutateResult, context) => {
    // ⚠️ 参数:(错误, 变量, onMutate返回的结果, context)
    
    // 用之前保存的快照恢复数据
    context.client.setQueryData(['todos'], onMutateResult.previousTodos)
    // 效果:新添加的待办消失,回到之前的状态
  },
  
  // 4. onSettled: 无论成功失败都会执行
  onSettled: (data, error, variables, onMutateResult, context) => {
    // 重新获取最新的待办列表,确保与服务器同步
    context.client.invalidateQueries({ queryKey: ['todos'] })
    // 这样即使乐观更新成功了,也会再次确认服务器数据
  },
})
// 假设初始待办列表
const initialTodos = [
  { id: 1, title: '学习React', completed: false },
  { id: 2, title: '写代码', completed: false },
]

// 用户添加新待办
addTodoMutation.mutate({ title: '新任务', completed: false })

// 时间线:
时间 0ms: 用户点击"添加"按钮
        ↓
时间 0ms: onMutate 执行
        • 取消进行中的查询
        • 保存快照: [{id:1}, {id:2}]
        • 立即更新UI: [{id:1}, {id:2}, {title:'新任务'}]
        • 用户立即看到新任务 ✓
        ↓
时间 0ms: mutationFn 开始发送请求到服务器
        ↓
时间 100ms: 请求成功 ✅
        onSettled 执行: 刷新列表
        ↓
时间 100ms: 列表刷新,确认数据同步

// 如果请求失败 ❌
时间 100ms: 请求失败
        ↓
时间 100ms: onError 执行
        • 用快照恢复: [{id:1}, {id:2}]
        • 新任务消失
        • 用户看到错误提示
        ↓
时间 100ms: onSettled 执行: 刷新列表

四.如何使用?

参考: tanstack.com/query/lates…

1.安装

npm i @tanstack/react-query

2.导入

import { createRoot } from 'react-dom/client'
import '@/assets/css/index.css'
import '@ant-design/v5-patch-for-react-19'
import router from '@/router'
import { RouterProvider } from 'react-router-dom'
import { Provider } from 'react-redux'
import store from '@/store'

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <Provider store={store}>
      <RouterProvider router={router}></RouterProvider>
    </Provider>
  </QueryClientProvider>
)

3.使用

a.使用useQuery查询数据

import { Button } from "antd";
import { useQuery } from "@tanstack/react-query";

function Home() {
  const {data,isLoading,error} = useQuery<{ id: number, name: string }[]>({
    queryKey: ['users'],
    queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json())
  })

  return (
    <>
      <div>Home页面</div>
      <Button type="primary">Button</Button>
      <div className="mt-10 ml-10">
        {isLoading ? <div>Loading...</div> : null}
        {
          data?.map((item) => {
            return <div key={item.id}>{item.name}</div>
          })
        }
        {error ? <div>Error: {error.message}</div> : null}
      </div>
    </>
  );
}
export default Home;

b.使用useMutation来更新数据

其实就是在useMutation定义好mutation的function,成功的回调,失败的回调。 然后真的用户做保存动作时,去调用返回的实例中的mutate方法传入参数,然后就会去调用mutationFn的方法,调用成功后走onSuccess的逻辑。

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

interface Todo {
  title: string;
  completed: boolean;
  userId: number;
}

export default function AddTodo() {
  const [title, setTitle] = useState('');
  const queryClient = useQueryClient();

  // 基础 mutation(解构出常用状态)
  const { mutate, isPending, isError, isSuccess, error } = useMutation({
    // 1. mutationFn: 执行实际操作的函数
    mutationFn: (newTodo: Todo) => 
      fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
        headers: {
          'Content-type': 'application/json; charset=UTF-8',
        },
      }).then(res => res.json()),

    // 2. onSuccess: 成功后的回调
    onSuccess: (data) => {
      console.log('添加成功:', data);
      // 刷新待办列表
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      // 清空输入框
      setTitle('');
    },

    // 3. onError: 失败后的回调
    onError: (error) => {
      console.error('添加失败:', error);
    },

    // 4. onSettled: 无论成功失败都会执行
    onSettled: (data, error) => {
      console.log('操作完成', { data, error });
    },
  });

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!title.trim()) return;
    
    // 执行 mutation
    mutate({
      title: title,
      completed: false,
      userId: 1,
    });
  };

  return (
    <div className="add-todo">
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="输入待办事项"
          disabled={isPending}
        />
        <button 
          type="submit"
          disabled={isPending}
        >
          {isPending ? '添加中...' : '添加待办'}
        </button>
      </form>
      
      {/* 显示状态 */}
      {isPending && (
        <div className="loading-indicator">⏳ 正在添加...</div>
      )}
      
      {isError && (
        <div className="error-message">
          ❌ {error?.message}
        </div>
      )}
      
      {isSuccess && (
        <div className="success-message">✅ 添加成功!</div>
      )}
    </div>
  );
}

4.使用全局自定义配置(可选)

在main.ts中可以全局自定义配置(所有的请求在自己没配置的时候会使用全局配置。)

import { createRoot } from 'react-dom/client'
import '@/assets/css/index.css'
import '@ant-design/v5-patch-for-react-19'
import router from '@/router'
import { RouterProvider } from 'react-router-dom'
import { Provider } from 'react-redux'
import store from '@/store'
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 失败后重试次数(默认 3 次),设为 1 或 false 可减少不必要的请求
      retry: 1,
      // 重试延迟(默认指数退避),可自定义
      // retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),

      // 数据过期时间(默认 0,即立即过期)。5 分钟内相同 queryKey 的请求会直接用缓存
      staleTime: 1000 * 60 * 5,
      // 非活跃数据在缓存中保留的时间(默认 5 分钟),超时后垃圾回收
      gcTime: 1000 * 60 * 10,

      // 窗口重新聚焦时是否自动重新请求(默认 true)
      refetchOnWindowFocus: false,
      // 网络重新连接时是否自动重新请求(默认 true)
      refetchOnReconnect: true,
      // 组件重新挂载时是否自动重新请求(默认 true)
      refetchOnMount: true,
    },
    mutations: {
      // mutation 失败后重试次数(默认 0,即不重试)
      retry: 0,
      // 全局 mutation 错误处理
      // onError: (error) => {
      //   console.error('全局 mutation 错误:', error);
      // },
    },
  },
})

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <Provider store={store}>
      <RouterProvider router={router}></RouterProvider>
    </Provider>
  </QueryClientProvider>
)

五.常用的hook

1.useQuery - 数据获取之王

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

function UserProfile({ userId }) {
  // 最基础的数据查询
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // 5分钟内不重新获取
  });

  if (isLoading) return <Loading />;
  if (error) return <Error />;
  return <div>{data.name}</div>;
}

// 常用属性
const {
  data,           // 返回的数据
  isLoading,      // 首次加载中
  isFetching,     // 任何请求进行中
  error,          // 错误对象
  isError,        // 是否错误
  isSuccess,      // 是否成功
  status,         // 'pending' | 'error' | 'success'
  refetch,        // 手动重新获取
  remove,         // 移除缓存
} = useQuery(...);

2. useMutation - 数据修改之王

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

function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newTodo) => 
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      }).then(res => res.json()),
    
    onSuccess: () => {
      // 成功后刷新列表
      queryClient.invalidateQueries(['todos']);
      toast.success('添加成功!');
    },
    
    onError: (error) => {
      toast.error(`失败:${error.message}`);
    },
  });

  return (
    <button 
      onClick={() => mutation.mutate({ title: '新任务' })}
      disabled={mutation.isLoading}
    >
      {mutation.isLoading ? '添加中...' : '添加待办'}
    </button>
  );
}

// 常用属性
const {
  mutate,         // 执行 mutation
  mutateAsync,    // 异步执行
  isLoading,      // 是否加载中
  isError,        // 是否错误
  isSuccess,      // 是否成功
  data,           // 返回数据
  error,          // 错误对象
  reset,          // 重置状态
} = useMutation(...);

3.QueryClient

new这个QueryClient会生成一个queryClient的实例,这个实例就包含了各种的方法。

可以配置全局自定义配置,可以清除缓存。这里一般在main.tsx中生成这个实例,然后使用QueryClientProvider传递给子孙组件,后代组件可以使用useQueryClient拿到这个实例对象,从而操作它的方法。

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 失败后重试次数(默认 3 次),设为 1 或 false 可减少不必要的请求
      retry: 1,
      // 重试延迟(默认指数退避),可自定义
      // retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      // 数据过期时间(默认 0,即立即过期)。5 分钟内相同 queryKey 的请求会直接用缓存
      staleTime: 1000 * 60 * 5,
      // 非活跃数据在缓存中保留的时间(默认 5 分钟),超时后垃圾回收
      gcTime: 1000 * 60 * 10,
      // 窗口重新聚焦时是否自动重新请求(默认 true)
      refetchOnWindowFocus: false,
      // 网络重新连接时是否自动重新请求(默认 true)
      refetchOnReconnect: true,
      // 组件重新挂载时是否自动重新请求(默认 true)
      refetchOnMount: true,
    },
    mutations: {
      // mutation 失败后重试次数(默认 0,即不重试)
      retry: 0,
      // 全局 mutation 错误处理
      // onError: (error) => {
      //   console.error('全局 mutation 错误:', error);
      // },
    },
  },
})
createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <Provider store={store}>
      <RouterProvider router={router}></RouterProvider>
    </Provider>
  </QueryClientProvider>
)

链接:tanstack.com/query/lates…

当用户退出或者切换公司的时候需要去清除缓存。

queryClient.clear()

4.useQueryClient - 缓存操作神器

使用useQueryClient拿到queryClient实例。

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

function TodoManager() {
  const queryClient = useQueryClient();

  // 常用方法
  const handleRefresh = () => {
    // 1. 刷新特定查询
    queryClient.invalidateQueries(['todos']);
    
    // 2. 直接设置缓存
    queryClient.setQueryData(['todos'], newTodos);
    
    // 3. 获取缓存数据
    const todos = queryClient.getQueryData(['todos']);
    
    // 4. 取消进行中的查询
    queryClient.cancelQueries(['todos']);
    
    // 5. 预加载数据
    queryClient.prefetchQuery({
      queryKey: ['todos', 'next'],
      queryFn: fetchNextTodos,
    });
    
    // 6. 重置查询
    queryClient.resetQueries(['todos']);
    
    // 7. 移除缓存
    queryClient.removeQueries(['todos']);
    
    // 8. 清空所有缓存
    queryClient.clear();
  };

  return <button onClick={handleRefresh}>刷新</button>;
}

六.demo地址

地址:gitee.com/rui-rui-an/…

别再混用了!import.meta.env 与 process.env 的本质差异一次讲透

2026年2月28日 10:59

用过vue3的小伙伴,相比对import.meta.envprocess.env都有过多过少的了解,但是你有去真正的了解过吗,今天,勇宝就带着大家一个来聊聊。

先说结论:import.meta.env 更偏“现代前端构建工具(Vite)语义”,process.env 更偏“Node 语义(Webpack/Node 运行时)”

在纯前端项目里,它们看起来都能“读环境变量”,但本质来源、注入时机、可见范围和迁移成本都不一样。

如果现在正在构建 Vue3/Vite 或 React/Vite 项目的话,优先用 import.meta.env;如果是 Webpack 老项目、Node 脚本或服务端代码,process.env 依然是主角。


1)import.meta.env 是什么?

import.meta.envESM + Vite 提供的环境变量访问方式。它不是 Node 原生对象,而是由构建工具在开发/打包阶段注入。

常见特征

  • 内置变量:MODEDEVPRODBASE_URL
  • 自定义变量默认要有前缀(Vite 默认 VITE_),例如:VITE_API_BASE
  • 能在前端代码中直接访问(最终会被构建替换)
// .env.development
VITE_API_BASE=/api
VITE_APP_TITLE=Demo

// 业务代码
const baseURL = import.meta.env.VITE_API_BASE
const isDev = import.meta.env.DEV

适用场景

  1. Vite 项目的前端业务代码
  2. 按环境切换 API 地址、开关日志、控制埋点
  3. 希望享受更清晰的前端变量约束(前缀暴露机制)

2)process.env 是什么?

process.envNode.js 运行时里的环境变量对象。

在服务端(Node)代码中,它天然存在;在前端项目中能不能用,取决于打包器是否做了注入/替换(如 Webpack 的 DefinePlugin)。

常见特征

  • Node 端“原生可用”
  • 前端中常见于旧工程(Vue CLI/Webpack)
  • 常见变量:process.env.NODE_ENVprocess.env.VUE_APP_XXX
// Vue CLI / Webpack 常见
if (process.env.NODE_ENV === 'production') {
  // 生产逻辑
}
const baseURL = process.env.VUE_APP_BASE_API

适用场景

  1. Node 服务端代码(Express、Nest、脚本工具)
  2. Webpack 系项目前端代码
  3. CI/CD 中通过系统环境变量注入配置

3)核心区别(重点)

下面这张表抓住最关键差异:

维度 import.meta.env process.env
本质来源 Vite/ESM 注入 Node 运行时对象(或被打包器替换)
典型生态 Vite Node / Webpack / Vue CLI
前端可见变量前缀 默认 VITE_ Vue CLI 常见 VUE_APP_
内置标识 DEV/PROD/MODE 常见 NODE_ENV
类型体验 在 TS 中更容易做类型增强 常被视作 string | undefined
迁移风险 旧项目需改写变量名与访问方式 在 Vite 前端中直接用可能报错或行为异常

4)代码对比案例

案例 A:按环境切 API 地址

Vite 写法:

const requestBaseURL = import.meta.env.VITE_API_BASE

Webpack/Vue CLI 写法:

const requestBaseURL = process.env.VUE_APP_BASE_API

案例 B:开发环境打印日志

Vite:

if (import.meta.env.DEV) {
  console.log('dev log')
}

Webpack/Node:

if (process.env.NODE_ENV !== 'production') {
  console.log('dev log')
}

案例 C:从 Vue CLI 迁移到 Vite 的典型坑

很多人会直接把旧代码搬过来:

// 旧代码
const url = process.env.VUE_APP_BASE_API

在 Vite 前端中应改为:

const url = import.meta.env.VITE_API_BASE

并把 .env 变量从 VUE_APP_BASE_API 改成 VITE_API_BASE


5)实践建议(避免踩坑)

  1. 前后端变量分层

    • 前端可见:只放“可公开配置”,用 VITE_ 前缀
    • 服务端敏感项(密钥/私钥):只放 process.env(Node 端),不要暴露给前端
  2. 不要混用语义

    • Vite 前端代码统一 import.meta.env
    • Node 脚本、SSR 服务端逻辑统一 process.env
  3. 迁移时一次性改全

    • 变量名前缀、读取方式、构建脚本、文档一起更新
    • 建议加一条 lint/代码审查规则,禁止在 Vite 前端里继续写 process.env.xxx

结语

import.meta.env 是“面向前端构建时”的环境注入接口,process.env 是“面向 Node 运行时”的环境变量接口。

它们都能“读配置”,但不在同一个语义层。把语义边界划清,项目会更稳定,迁移成本也会更低。

好啦!今天的知识点就分享到这里吧,希望读完对你的职业素养有一个质的提升。

js中,什么是快速排序(Quick Sort)

作者 我是何平
2026年2月28日 10:52

在 JavaScript 中,快速排序(Quick Sort) 是一种高效的、基于分治思想(Divide and Conquer) 的递归排序算法。它通过选择一个“基准值”(pivot),将数组划分为两个子数组:

  • 一个包含所有 小于等于 pivot 的元素,
  • 另一个包含所有 大于 pivot 的元素,

然后递归地对这两个子数组进行排序,最终合并得到有序数组。


✅ 快速排序的核心步骤(JS 版)

  1. 选基准(pivot):通常选中间、首尾或随机元素。
  2. 分区(partition):将数组按 pivot 分成左右两部分。
  3. 递归排序左右子数组
  4. 合并结果(在非原地实现中)。

🧩 简洁版(函数式风格,非原地排序)

这是 JavaScript 中最常见、易读的快排写法(不修改原数组):

function quickSort(arr) {
  if (arr.length <= 1) {
    return arr;
  }

  const pivot = arr[Math.floor(arr.length / 2)]; // 选中间元素为 pivot
  const left = [];
  const right = [];
  const equal = [];

  for (let element of arr) {
    if (element < pivot) {
      left.push(element);
    } else if (element > pivot) {
      right.push(element);
    } else {
      equal.push(element);
    }
  }

  return [...quickSort(left), ...equal, ...quickSort(right)];
}

// 示例
const arr = [38, 27, 43, 3, 9, 82, 10];
console.log(quickSort(arr)); 
// 输出: [3, 9, 10, 27, 38, 43, 82]

✅ 优点:代码简洁、易理解、不会改变原数组。
❌ 缺点:不是原地排序,空间开销较大(每次创建新数组)。


🔧 原地排序版(In-place,更高效)

如果你希望直接修改原数组以节省内存,可以使用索引和分区函数:

function quickSortInPlace(arr, low = 0, high = arr.length - 1) {
  if (low < high) {
    const pi = partition(arr, low, high);       // 获取 pivot 正确位置
    quickSortInPlace(arr, low, pi - 1);         // 排序左半部分
    quickSortInPlace(arr, pi + 1, high);        // 排序右半部分
  }
  return arr;
}

function partition(arr, low, high) {
  const pivot = arr[high]; // 选最后一个为 pivot
  let i = low - 1;         // 小于 pivot 区域的边界

  for (let j = low; j < high; j++) {
    if (arr[j] <= pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]]; // 交换
    }
  }
  [arr[i + 1], arr[high]] = [arr[high], arr[i + 1]]; // pivot 归位
  return i + 1;
}

// 示例
const arr = [38, 27, 43, 3, 9, 82, 10];
quickSortInPlace(arr);
console.log(arr); 
// 输出: [3, 9, 10, 27, 38, 43, 82]

✅ 优点:原地排序,空间复杂度更低(O(log n) 递归栈)。
⚠️ 注意:会修改原始数组。


⏱️ 时间复杂度(JavaScript 中同样适用)

情况 时间复杂度
平均情况 O(n log n)
最好情况 O(n log n)
最坏情况 O(n²)(如已排序 + 固定 pivot)

💡 实际中可通过随机选 pivot三数取中 来避免最坏情况。


🆚 与 Array.prototype.sort() 对比

  • JS 内置的 arr.sort() 在现代引擎(V8)中通常使用 TimsortIntrosort(快排+堆排+插入排序混合),性能更优且稳定。
  • 手写快排主要用于学习算法原理或特定场景(如自定义比较逻辑)。
// 内置排序(推荐日常使用)
arr.sort((a, b) => a - b);

✅ 总结

特性 快速排序(JS)
是否稳定 ❌ 不稳定(它在分区(partition)过程中可能会改变相等元素的相对顺序。)
是否原地 可实现(看具体写法)
平均时间复杂度 O(n log n)
适用场景 学习分治思想、面试、自定义排序逻辑

基于SSE的AI对话流式结构

作者 小王同志i
2026年2月28日 10:50

本文章是基于当前AI业务项目梳理的一份SSE流式结构,简单介绍了一下,当前我们实现的AI流式消息的思路,其中可能有很多不合理的地方,欢迎大佬指正和建议🌹

一、整体架构

image.png

二、流式消息字段

首先提供一段完整的处理的消息格式

[
    {
        "msg_id": "xxx",
        "content": [
            {
                "content": "调用联网搜索工具,查询北京近期天气。",
                "is_finished": false,
                "type": "text",
                "type_end": true
            },
            {
                "content": [
                    {
                        "content": "我来帮您查询北京最近的天气情况。",
                        "is_finished": false,
                        "type": "text",
                        "type_end": true
                    },
                    {
                        "content": "联网搜索",
                        "is_finished": false,
                        "params": {
                            "click": true,
                            "icon": "https://xxxx/xxxx.png",
                            "id": "web_search",
                            "status": "end",
                            "data_detail": {
                                "input": "北京天气 2026年2月13日",
                                "output": [
                                    {
                                        "content": [
                                            {
                                                "desc": "2026年02月13日北京天气预报",
                                                "source": "搜狐",
                                                "title": "2026年02月13日北京天气预报",
                                                "url": "xxxx"
                                            },
                                            {
                                                "desc": "2026年02月13日京山天气预报.",
                                                "source": "百度",
                                                "title": "2026年02月13日京山天气预报",
                                                "url": "xxxx"
                                            },
                                            {
                                                "desc": "北京市气象台13日6时发布天气预报,白天晴间多云。",
                                                "source": "中华网新闻频道",
                                                "title": "北京13日白天晴间多云",
                                                "url": "xxxx"
                                            }
                                        ],
                                        "type": "web_search"
                                    }
                                ]
                            }
                        },
                        "type": "complex_tool",
                        "type_end": true
                    },
                    {
                        "content": "联网搜索",
                        "is_finished": false,
                        "params": {
                            "click": true,
                            "icon": "xxx",
                            "id": "web_search",
                            "status": "end",
                            "data_detail": {
                                "input": "北京未来一周天气预报 具体温度 降水",
                                "output": [
                                    {
                                        "content": [
                                            {
                                                "desc": "xxxx.",
                                                "source": "网易",
                                                "title": "xxxx",
                                                "url": "xxxx"
                                            },
                                            {
                                                "desc": "xxx.",
                                                "source": "xxx",
                                                "title": "2026年02月13日北京天气预报",
                                                "url": "xxx"
                                            },
                                            {
                                                "desc": "xxxx",
                                                "source": "腾讯网",
                                                "title": "今明两天,北京持续回暖!_腾讯新闻",
                                                "url": "xxx"
                                            },
                                            {
                                                "desc": "xxx",
                                                "source": "腾讯网",
                                                "title": "北京明天白天晴间多云,最高气温15°C_腾讯新闻",
                                                "url": "xxx"
                                            },
                                            {
                                                "desc": "xxxx",
                                                "source": "网易新闻客户端",
                                                "title": "今明两天,北京持续回暖!",
                                                "url": "xxx"
                                            }
                                        ],
                                        "type": "web_search"
                                    }
                                ]
                            }
                        },
                        "type": "complex_tool",
                        "type_end": true
                    }
                ],
                "is_finished": false,
                "title": "使用联网搜索工具查询北京2026年2月13日及最近几天的天气信息,获取温度、天气状况、风力、降水概率等具体数据",
                "type": "thought_chain",
                "type_end": true,
                "params": {}
            },
            {
                "content": "xxxx",
                "is_finished": false,
                "type": "text",
                "type_end": true
            },
            {
                "content": "任务已完成",
                "is_finished": false,
                "type": "text",
                "type_end": true
            }
        ],
        "is_finished": false,
        "finish_reason": "",
        "type": "think",
        "type_end": true,
        "params": {}
    },
    {
        "msg_id": "xxxx",
        "content": "根据最新的天气信息",
        "is_finished": false,
        "finish_reason": "",
        "type": "text",
        "type_end": true
    }
]

整体,最外层是一个大数组,数组里面第一层是一个一个的json对象,一个json代表一个类型

[
  {
    "msg_id": "xxx",
    "content": "根据最新的天气信息",
    "is_finished": false,
    "finish_reason": "",
    "type": "text",
    "params": {},
    "type_end": true
  },
  ....
]

字段解释如下:

  • msg_id:消息id
  • content:消息内容
  • is_finished:整段流式消息是否结束
  • finish_reason:流式消息结束原因
  • type:消息类型
  • params: 配置信息字段 (可不传,当组件支持的时候会支持对应的功能)
  • type_end:当前类型消息是否结束  

每个类型的消息基本都遵守这个字段组合,无论是文本还是复杂的嵌套关系,每一个类型都遵循。  

三、流式消息类型规范

截止至今,我们总共支持的消息类型有:

  • image 图片
  • conf 配置字段
  • think 思考
  • step 步骤
  • text 文本
  • tool 工具调用
  • complexTool 复杂工具
  • thought_chain 思维链

八种消息类型,后续迭代可能继续新增。

根据我们处理的逻辑,这一系列类型可以分为内容为Array、Object、String三大类,主要类型是根据其内容的类型和最终处理的结果来定。主要划分如下


    // 内容类型为对象的类型
    const CONTENT_OBJECT_MAP = [
      STREAM_TYPES.IMAGE, 
      STREAM_TYPES.CONFIG
    ]
    // 内容类型为数组的类型
    const CONTENT_ARRAY_MAP = [
      STREAM_TYPES.THINK, 
      STREAM_TYPES.THOUGHTCHAIN
    ]
    // 内容类型为字符串的类型
    const CONTENT_INSTEAD_MAP = [
      STREAM_TYPES.STEP,
      STREAM_TYPES.TEXT,
      STREAM_TYPES.TOOL,
      STREAM_TYPES.COMPLEX_TOOL
    ]
    // 类型定义
    export enum STREAM_TYPES {
      IMAGE = 'image',
      CONFIG = 'conf',
      THINK = 'think',
      STEP = 'step',
      TEXT = 'text',
      TOOL = 'tool',
      COMPLEX_TOOL = 'complex_tool',
      THOUGHTCHAIN = 'thought_chain'
    }

流式消息的处理过程有两个阶段,流式中和流式结束,下面我们按照三种不同的 content 结构来进行梳理

2.1、Array类型

内容为Array的类型是表示我们渲染的时候支持嵌套的组件,例如:在思考中,他有自己的内容区域,而在这个内容区域还支持渲染text类型,tool工具类型等。所以这种消息类型用Array数据结构

2.1.1、Think类型

think类型是主要用于我们的思考部分,分为两部分,外层展开收起容器,内嵌组合式消息内容。流式处理分为两个阶段,流式中,流式完成。

1、流式中

此时是接口SSE返回阶段,通过charles抓包,数据结构如下:

    data: {"msg_id":"xxx","content":{"content":"调用","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"联网","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"搜索,","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"调用搜索工具","is_finished":false,"type":"tool","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":true}

这一系列类型的content在流式过程中,返回的是json对象,此时的content里面包裹的其实是另外一个类型的消息,这个内容需要包含对应类型的消息的字段,所以流式过程中返回的是json,通过这种方式,我们可以在think类型中,嵌套多个其他类型的消息。

同时,当一个类型消息结束的时候,最后都会返回一个内容为空的,type_end为true的标识,表示当前类型消息结束

2、流式完成

流式完成就是将流式过程中返回的数据进行组合,组合成用于渲染的格式

    [
      {
        "msg_id": "xxx",
        "content": [
          {
            "content": "调用联网搜索",
            "is_finished": false,
            "type": "text",
            "type_end": true
          },
          {
            "content": "调用搜索工具",
            "is_finished": false,
            "type": "tool",
            "type_end": true
          }
        ],
        "is_finished": false,
        "finish_reason": "",
        "type": "think",
        "type_end": true
      }
    ]

think类型支持一个params字段 cost_time:用于渲染耗时

具体用法

{

    "msg_id": "xxx",

    "content": [...],

    "is_finished": false,

    "finish_reason": "",

    "type": "think",

    "type_end": true,

    "params": {
         "cost_time": "38s"

    }

}

2.1.2、thought_chain类型

thought_chain代表着一个思维链,是一个带状态的连续性的消息,可以用于渲染agent执行某个plan或者具体的环节,其结构与think类型一致,不过它支持更多的params字段配置,同样分为两个阶段。

1、流式中

    data: {"msg_id":"xxx","content":{"content":{"content":"xxx","is_finished":false,"type":"text","type_end":false},"is_finished":false,"title":"使用联网搜索工具查询...","type":"thought_chain","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":{"content":"联网搜索","is_finished":false,"params":{"click":true,"data_detail":{"input":"xxx","output":[{"content":[{"desc":"xxx","source":"xxx","title":"2026年02月13日京山天气预报","url":"xxx"},{"desc":"xxx","source":"中华网新闻频道","title":"新闻频道_中华网","url":"xxx"}],"type":"web_search"}]},"icon":"xxx","id":"web_search"},"type":"complex_tool","type_end":true},"is_finished":false,"title":"使用联网搜索工具查询","type":"thought_chain","type_end":false},"is_finished":false,"finish_reason":"","type":"think","type_end":false}
    data: {"msg_id":"xxx","content":{"content":{"content":"联网搜索","is_finished":false,"params":{"icon":"xxx","id":"web_search","status":"end"},"type":"complex_tool","type_end":true},"is_finished":false,"title":"使用联网搜索工具查询","type":"thought_chain","type_end":true},"is_finished":false,"finish_reason":"","type":"think","type_end":false}

这里有一堆的字段,看起来很麻烦,实际上这里是在think中嵌套了thought_chain,thought_chain内部又嵌套了其他类型组件,简单一点来看,其实和think是一样的,无非是换了一个类型

    data: {"msg_id":"xxx","content":{"content":"调用","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"联网","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"搜索,","is_finished":false,"type":"text","type_end":false},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"调用搜索工具","is_finished":false,"type":"tool","type_end":true},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":false}
    data: {"msg_id":"xxx","content":{"content":"","is_finished":false,"type":"text","type_end":true},"is_finished":false,"finish_reason":"","type":"thought_chain","type_end":true}

2、流式完成

流式完成也和think一样,就是换了一个类型

    [
      {
        "msg_id": "xxx",
        "content": [
          {
            "content": "调用联网搜索",
            "is_finished": false,
            "type": "text",
            "type_end": true
          },
          {
            "content": "调用搜索工具",
            "is_finished": false,
            "type": "tool",
            "type_end": true
          }
        ],
        "is_finished": false,
        "finish_reason": "",
        "type": "thought_chain",
        "type_end": true
      }
    ]

2.2、Object类型

目前就两种类型conf和image类型,本质上就是类型不同和字段不同的区别

image类型是存在两个字段

字段 类型 作用
url string 图片资源路径
preview 1|0 是否可预览

conf类型可以支持去配其他字段,比如在图文消息中会返回

字段 类型 作用
no_share 1|0 是否支持分享

2.2.1、image类型

1、流式中

    // 图片类型
    data: {"msg_id":"xxx","content":{"preview":1,"url":"xxx"},"is_finished":false,"finish_reason":"","type":"image","type_end":true}
    data: {"msg_id":"xxx","content":"","is_finished":true,"finish_reason":"done","type":"text","type_end":false}

2、流式完成

    [
      {
          "msg_id": "xxx",
          "content": {
              "preview": 1,
              "url": "xxx"
          },
          "is_finished": false,
          "finish_reason": "",
          "type": "image",
          "type_end": true
      }
    ]

2.2.2、conf类型

当前场景为图文消息的时候,一般出现在画图技能,任务队列繁忙的时候

1、流式中

    data: {"msg_id":"xxx","content":"xxx","is_finished":false,"finish_reason":"","type":"text","type_end":true}
    data: {"msg_id":"xxx","content":{"preview":0,"url":"xxx"},"is_finished":false,"finish_reason":"","type":"image","type_end":true}
    data: {"msg_id":"xxx","content":{"no_share":1},"is_finished":false,"finish_reason":"","type":"conf","type_end":true}
    data: {"msg_id":"xxx","content":"","is_finished":true,"finish_reason":"done","type":"text","type_end":false}

2、流式完成

    [
        {
            "msg_id": "xxx",
            "content": {
                "no_share": 1
            },
            "is_finished": false,
            "finish_reason": "",
            "type": "conf",
            "type_end": true
        },
        {
            "msg_id": "xxx",
            "content": "xxx。",
            "is_finished": false,
            "finish_reason": "",
            "type": "text",
            "type_end": true
        },
        {
            "msg_id": "xxx",
            "content": {
                "preview": 0,
                "url": "xxx"
            },
            "is_finished": false,
            "finish_reason": "",
            "type": "image",
            "type_end": true
        }
    ]

2.3、String类型

这一分类指的是content为字符串string的一系列消息类,主要包括:

  1. step
  2. text
  3. tool
  4. complex_tool

四个类型,其中比较特殊的就是complex_tool,支持params进行配置

2.3.1、step类型

step类型主要用于步骤纯文案的渲染,

其采用的不是拼接而是替换,例如第一次内容返回的是“正在搜索中”,第二次返回“搜索完成”,渲染出来的ui会是先显示“正在搜索中”,然后显示“搜索完成”,不会进行组合拼接。并且一旦后续有其他类型消息进入,则不在显示

1、流式中

    data: {"msg_id":"xxx","content":"正在搜索中","is_finished":false,"finish_reason":"","type":"step","type_end":false}
    data: {"msg_id":"xxx","content":"搜索完成","is_finished":false,"finish_reason":"","type":"step","type_end":true}

2、流式完成

    [
      {
        "msg_id":"xxx",
        "content":"搜索完成",
        "is_finished":false,
        "finish_reason":"",
        "type":"step",
        "type_end":true
      }
    ]

2.3.2、text类型

text类型就是我们最常见的文本,也是正文,主要采用markdown进行渲染,采用的是拼接逻辑

1、流式中

    data: {"msg_id":"xxx","content":"这","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"是","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"文","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"本","is_finished":false,"finish_reason":"","type":"text","type_end":false}
    data: {"msg_id":"xxx","content":"","is_finished":false,"finish_reason":"","type":"text","type_end":true}

2、流式完成

    [
      {
        "msg_id":"xxx",
        "content":"这是文本",
        "is_finished":false,
        "finish_reason":"",
        "type":"text",
        "type_end":true
      }
    ]

2.3.3、tool类型

最基础的工具类型,一次返回,没有过程,直接渲染

1、流式中

    data: {"msg_id":"xxx","content":"正在调用xxx工具","is_finished":false,"finish_reason":"","type":"tool","type_end":true}

2、流式完成

    [
      {
        "msg_id":"xxx",
        "content":"正在调用xxx工具",
        "is_finished":false,
        "finish_reason":"",
        "type":"tool",
        "type_end":true
      }
    ]

2.3.4、complex_tool类型

工具类型的进阶版,支持params字段配置额外的能力,目前支持:完成状态,点击侧边,耗时展示。

与工具类型的主要区分就是params字段

其params字段目前支持

字段 类型 作用
id string 工具id,必传,用于更新工具状态
click boolean 是否支持点击侧边
data_detail object 侧边详情数据
icon string 工具图标
status “begin” “end” 工具调用状态
cost_time string 工具耗时

如果需要点击,首次返回需要返回click字段

1、流式中

    // 开始调用工具 支持点击首次返回click: true
    data: {"content":"联网搜索","is_finished":false,"params":{"click":true,"icon":"xxx","id":"web_search","status":"begin"},"type":"complex_tool","type_end":true}
    // 获取到结果
    data: {"content":"联网搜索","is_finished":false,"params":{"click":true,"data_detail":{"input":"xxx","output":[{"content":[{"desc":"xxx","source":"xxx","title":"xxx","url":"xxx"}],"type":"web_search"}]},"icon":"xxxx","id":"web_search"},"type":"complex_tool","type_end":true}
    // 调用完成
    data: {"content":"联网搜索","is_finished":false,"params":{"icon":"xxxx","id":"web_search","status":"end"},"type":"complex_tool","type_end":true}

2、流式完成

    [
      {
          "content": "联网搜索",
          "is_finished": false,
          "params": {
              "click": true,
              "icon": "xxx",
              "id": "web_search",
              "status": "end",
              "data_detail": {
                  "input": "2222",
                  "output": [
                      {
                          "content": [
                              {
                                  "desc": "xxx",
                                  "source": "xxx",
                                  "title": "xxx",
                                  "url": "xxx"
                              },
                              {
                                  "desc": "2222",
                                  "source": "2222",
                                  "title": "2222",
                                  "url": "2222"
                              }, 
                          ],
                          "type": "web_search"
                      }
                  ]
              }
          },
          "type": "complex_tool",
          "type_end": true
      }
    ]

然后data_detail也有一套规范,下面进行补充

2.3.5、data_detail规范

data_detail是params 中的一个可配置字段,主要作用是用于侧边栏的详情数据。

这个字段目前是直接返回,直接用的,所以没有流式状态和完成态,前后是保持一致

其有两个字段

字段
input string
output Array
1、input

是顶部输入的字段,展示的是用户的querry问题,目前只支持string。

2、output

output表示的智能体返回的输出部分,数组中接收的是一个个object,每一个object代表一个类型,对象的结构如下

字段
type string
content Array Object

type字段代表返回的类型,content则代表返回的详细内容

当前有三个类型做了处理:web_search、knowledge_recall、create_schedule

web_search,knowledge_recall :这两个类型会被渲染成卡片

create_schedule:对敏感信息进行了隐藏

类型 对应content
web_search Array(卡片)
knowledge_recall Array(卡片)
create_schedule object
其他 object (直接展示)

卡片消息类型字段一致为:

    // web_search
    {
      "content": [
        {
            "desc": "描述",
            "source": "源头",
            "title": "标题",
            "url": "源链接"
        },
        ...
      ],
      "type": "web_search"
    }
    // knowledge_recall
    {
        "content": [
            {
               "desc": "描述",
              "source": "源头",
              "title": "标题",
              "url": "源链接"
            },
            {
               "desc": "描述",
              "source": "源头",
              "title": "标题",
              "url": "源链接"
            }
        ],
        "type": "knowledge_recall"
    }

create\_schedule 创建日程 的output

    {
        "content": {
            "end_time": "2026-02-25 15:00:00", // 结束时间
            "participants": [ // 参与人
                {
                    "id": 123, // id
                    "name": "xxx", // 名字
                    "pic": "xxx", // 头像
                    "workcode": "xxx" // 工号
                },
                {
                    "id": 123,
                    "name": "xxx",
                    "pic": "xxx",
                    "workcode": "xxx"
                }
            ],
            "start_time": "2026-02-25 14:00:00",// 开始时间
            "title": "xxx" // 会议标题
        },
        "type": "create_schedule"
    }

其他类型都是直接返回json,并且直接渲染json。

前端向架构突围系列 - 跨端技术 [11 - 1]:JSBridge 原理与 Hybrid设计

2026年2月28日 10:29

在移动互联网爆发的黄金年代,几乎所有前端和客户端同学都吵过一个架:运营要改个大促活动的规则、换个 banner 位,前端改完代码刷新页面就上线了,客户端却要走「打包→提审→等苹果 / 安卓商店审核→用户下载更新」的完整流程,顺利的话 3 天,遇到审核被打回,一周都搞不定 —— 等功能上线,活动都快结束了。

原生开发的静态化短板,和业务对「动态化、快迭代」的强需求,形成了不可调和的矛盾。也正是在这个背景下,Hybrid 混合开发架构成了行业的标准答案:用原生做 App 外壳和底层能力底座,用 Web 承载高频迭代的业务 UI 和逻辑,兼顾原生的能力边界和 Web 的动态灵活性。

而能让两个完全隔离、语言不通的运行环境顺畅对话的核心纽带,就是我们这一节要拆解的主角 ——JSBridge。它不是什么复杂的黑科技,却是整个混合开发时代最核心的底层基建,哪怕到了今天的小程序、跨端框架时代,它的核心设计思想依然在被沿用。

image.png

1. 为什么我们必须要有 JSBridge?

搞懂 JSBridge 的前提,是先理解 Hybrid 架构里「两个世界的绝对隔离」。

在 Hybrid 架构中,所有 Web 页面都运行在原生提供的 WebView 容器里,而 Web 的 JavaScript 运行环境,和原生的 Java/Kotlin(安卓)/Objective-C/Swift(iOS)运行环境,是两个完全独立、相互隔离的沙箱:

  • Web 端(JS) :天生自带极致的动态化能力,代码改完实时生效,不用发版不用审核;但被浏览器沙箱牢牢限制,无法直接访问设备底层硬件(摄像头、蓝牙、陀螺仪、本地文件系统),也无法调用系统级的原生 UI 组件和能力,能做的事被死死框在浏览器的能力边界里。
  • Native 端:手握设备的所有权限,能调用所有系统 API,渲染性能拉满;但代码是静态编译的,只要改一行逻辑,就必须走完整的发版审核流程,完全跟不上业务的快节奏迭代。

一边是动态性拉满但能力受限的 Web,一边是能力拉满但动态性为零的 Native,想要让两者结合发挥最大价值,就必须在两个隔离的沙箱之间,架起一座能双向通行、能翻译两端语言的桥梁 —— 这就是 JSBridge 的核心价值:打通 Web 与 Native 的通信壁垒,实现双向的方法调用和数据传递

2. JSBridge 的核心通信原理

JSBridge 的双向通信,本质上就是两个方向的问题拆解:Native 调用 JS,以及 JS 调用 Native。所有 Hybrid 架构的底层逻辑,都绕不开这两个核心方向的实现。

2.1 Native 调用 JavaScript:简单直接的代码执行

这个方向的实现逻辑非常朴素,甚至可以说没有什么技术门槛:Native 作为 WebView 的宿主,本身就拥有直接在 WebView 的 JS 上下文里执行代码的权限,本质就是「原生拼接一段 JS 代码字符串,交给 WebView 去执行」。

只是随着系统版本的迭代,有了更高效、更完善的实现方案:

  • 安卓端:早期安卓 API 19 之前,只能用webView.loadUrl("javascript:methodName(params)")实现。这个方案坑非常多:不仅会触发页面刷新,还无法获取 JS 方法的返回值,多次高频调用还会出现阻塞和丢消息的问题,踩过这个坑的老安卓开发应该深有体会。从 API 19(安卓 4.4)开始,官方推出了webView.evaluateJavascript("methodName(params)", callback),不仅执行效率大幅提升,还能通过回调异步获取 JS 执行后的返回值,成了现在的主流方案。
  • iOS 端:早期的 UIWebView 性能差、内存泄漏问题严重,早已被淘汰;目前主流的 WKWebView,通过evaluateJavaScript:completionHandler:方法执行 JS 代码,同样支持异步获取执行结果,稳定性和性能都有质的提升。

这里要提一个工业级实现里的小细节:Native 调用 JS 时,一定要保证执行时机是在 WebView 的页面加载完成之后(也就是onPageFinished/didFinishNavigation回调之后),否则会出现 JS 上下文还没初始化、方法找不到的问题,这是新手最容易踩的坑之一。

2.2 JavaScript 调用 Native:从兼容到高效的三大方案

和 Native 调 JS 不同,Web 端没有直接执行原生代码的权限,所以这个方向的实现会更复杂。行业里经过多年的迭代,从「兼容优先」到「性能优先」,最终沉淀出了三种主流实现方案,每一种都带着鲜明的时代特征。

方案一:URL Scheme 拦截(早期行业主流,兼容性天花板)

这是 Hybrid 发展早期最经典、兼容性最强的方案,也是当年微信、手淘等超级 App 最早用的方案。

它的核心逻辑非常巧妙:

  1. JS 端通过创建隐藏的 iframe,或者直接修改window.location.href,发起一个自定义协议的请求,比如myapp://camera/open?callbackId=123&params={"quality":1080}
  2. Native 端在 WebView 的导航拦截回调里(安卓的shouldOverrideUrlLoading、iOS 的decidePolicyForNavigationAction),捕获到这个请求;
  3. 原生端解析这个自定义 URL 的协议、方法名、参数,执行对应的原生能力,再通过回调把结果返回给 JS 端。

这个方案的优势是全版本兼容,哪怕是非常老旧的系统版本,也能完美支持,没有安全风险;但缺点也很明显:URL 有长度限制,参数太长会被截断,而且每次发起请求都有一定的性能开销,高频调用场景下会有明显的延迟。

方案二:拦截 JS 全局弹窗方法(小众补位方案)

这是一个偏门的补位方案,核心原理是:Web 端调用alert()confirm()prompt()这三个全局弹窗方法时,Native 端可以通过 WebView 的回调拦截到调用内容和参数,其中prompt()支持字符串返回值,刚好能满足通信的需求。

但这个方案的缺点非常致命:需要侵入浏览器的全局方法,可能会影响页面的正常业务逻辑,而且通信性能一般,所以行业里几乎不会把它作为主力通信方案,只会作为极端场景下的兜底兼容方案。

方案三:API 对象注入(现代主流,性能天花板)

这是目前行业里的绝对主流方案,也是性能最高、开发体验最好的方案。核心逻辑是:Native 端直接向 WebView 的 JS 执行上下文,注入一个挂载在 window 上的全局原生 API 对象,JS 端可以像调用普通 JS 方法一样,直接调用这个对象上的原生能力,几乎没有额外的性能开销。

  • 安卓端:通过addJavascriptInterface方法注入全局对象。很多人听说过这个方案有安全漏洞,其实是在 API 17(安卓 4.2)之前,没有严格的方法注解限制,会导致恶意页面通过反射执行任意原生代码,出现严重的安全问题;但在 API 17 之后,官方引入了@JavascriptInterface注解,只有加了注解的方法才能被 JS 调用,安全问题已经被彻底解决。
  • iOS 端(WKWebView) :通过WKScriptMessageHandler协议注入消息处理对象,JS 端通过window.webkit.messageHandlers.<自定义名称>.postMessage()就能把消息发送给原生端,没有安全风险,性能也拉满。

3. 工业级 Hybrid 架构设计:从能用,到稳定好用

搞懂了底层的通信原理,只是跨进了 Hybrid 开发的门槛。在微信、手淘这类亿级用户的超级 App 里,一套成熟的 Hybrid 架构,绝不是简单的方法调用就能搞定的 —— 我们需要设计一套稳定、安全、易扩展、可排查的完整架构体系。

一个经过工业级验证的 Hybrid 架构,从上到下分为 5 个核心层级,每一层都有明确的职责边界和设计考量:

第一层:业务层(Web App)

这一层就是前端开发者最熟悉的部分:基于 Vue/React 等框架开发的业务页面,比如电商的活动页、资讯的详情页。业务代码不需要关心底层的通信细节,只需要引入封装好的 JSBridge SDK,像调用普通前端 API 一样调用原生能力即可。

第二层:JS SDK 封装层(Hybrid 架构的前端核心)

这一层是整个架构的前端门面,也是保证开发体验和稳定性的关键,绝不是简单的方法透传。一个成熟的 SDK,必须包含这些核心能力:

  1. 异步回调管理:JSBridge 通信本质上是异步的,SDK 需要维护一个全局的请求池,每次 JS 调用原生能力时,生成一个唯一的callbackId,把回调函数和 ID 绑定后存入请求池,再把 ID 和参数传给原生;原生执行完毕后,带着callbackId回调 JS,SDK 再通过 ID 找到对应的 Promise,执行 resolve/reject,完美适配前端的异步开发习惯。
  2. 消息队列与防抖:在页面初始化、高频操作等场景,会出现短时间内大量调用 Bridge 的情况,为了防止消息丢失、Native 线程阻塞,SDK 会维护一个消息队列,把并发的调用打包成批量消息,在空闲时间统一发送给原生。
  3. 超时与错误重试:针对原生调用超时、失败的场景,SDK 需要内置超时机制和重试策略,避免业务 Promise 一直 pending,同时给出明确的错误提示,方便业务做兜底处理。
  4. 参数序列化与版本兼容:处理两端的参数类型兼容问题,同时针对不同 App 版本的 Bridge 能力差异,做优雅的降级兼容,避免低版本 App 出现方法找不到的报错。

第三层:Native Bridge 层(Hybrid 架构的原生核心)

这一层是原生侧的调度中心,负责承接 JS 端的所有请求,核心职责有 3 个:

  1. 协议解析与分发:接收 JS 端传来的 JSON 格式数据,解析出目标模块、方法名和参数,分发到对应的原生能力模块执行,同时把执行结果封装成统一格式返回给 JS 端。
  2. 严格的权限校验:这是整个架构的安全生命线!必须做两层校验:一是校验当前加载的页面域名,是否在 App 的白名单内,防止恶意第三方页面调用 Bridge 能力;二是校验当前页面是否有权限调用对应的 API,比如非核心业务页面,不能调用通讯录、短信等敏感权限,从根源上避免用户隐私泄露。
  3. 生命周期管理:和 WebView 的生命周期绑定,页面销毁时,清空对应的回调池和消息队列,防止内存泄漏和无效回调。

第四层:原生能力插件层

这一层是 Hybrid 架构的能力底座,我们会把所有原生能力拆成独立的插件模块:比如设备信息、网络请求、摄像头、文件系统、原生 UI 组件(导航栏、弹窗、Loading)等等。

插件化设计的核心优势是解耦和易扩展:新增一个原生能力,只需要新增一个插件模块,不用修改 Bridge 核心层的代码;同时可以按需加载,避免核心包体积过大,这对超级 App 来说至关重要。

第五层:监控与埋点层

这一层是线上稳定性的保障,也是很多新手会忽略的一层。工业级的架构里,必须内置完整的监控能力:Bridge 调用的成功率、耗时、错误类型、TOP 报错场景,都要做完整的埋点上报。一旦线上出现问题,我们能快速定位是前端参数问题,还是原生执行出错,而不是两眼一抹黑。

4. 总结

JSBridge 从来都不是什么高深的技术,它的核心价值,是用最巧妙的方式,打破了 Web 和 Native 两个隔离世界的壁垒。它就像一个优秀的翻译官,让动态灵活的 Web,和能力强大的 Native,能顺畅对话、各司其职,也让 Hybrid 架构成了移动互联网时代,解决「动态化」需求的最优解。

但 Hybrid 架构从诞生的那天起,就有一个无法逾越的天花板:渲染性能。它的 UI 渲染始终依赖 WebView 浏览器引擎,在长列表滚动、复杂交互动画等场景下,DOM 操作的开销、JS 线程和渲染线程的互斥,导致它的流畅度始终无法和纯原生相提并论。

为了彻底突破 WebView 渲染的性能瓶颈,跨端技术开始向着两个全新的方向演进:一个是以 React Native 为代表的「JS 驱动原生渲染」架构,另一个是以 Flutter 为代表的「自绘引擎」架构。这也是我们下一节要深入探讨的,跨端技术的第二次革命性演进。

说说闭包的理解和应用场景?

作者 光影少年
2026年2月28日 10:21

这个问题是 JS 面试高频题 👍
而且如果你有 7 年前端经验,面试官会期待你说到:

  • 原理
  • 作用域链
  • 内存机制
  • 实际应用场景
  • 优缺点

我给你一套「中高级面试回答版本」。


一、什么是闭包?

一句话定义:

闭包是函数和其词法作用域的组合。

通俗一点:

函数可以记住并访问它定义时的作用域
即使函数在作用域外执行

二、核心本质(一定要讲)

闭包产生的本质原因:

函数执行后,其内部变量没有被销毁
因为有外部引用在使用它

本质是:

函数 + 作用域链 + 垃圾回收机制

三、最经典例子

function outer() {
  let count = 0;

  return function inner() {
    count++;
    console.log(count);
  }
}

const fn = outer();

fn(); // 1
fn(); // 2

为什么 count 没被销毁?

因为:

inner 函数引用了 outer 的变量
outer 的执行上下文不能被回收

这就是闭包。


四、从作用域链角度解释(进阶说法)

JS 在创建函数时,会保存:

[[Environment]]

也就是函数定义时所在的词法环境。

即使 outer 执行结束:

inner 依然持有 outer 的作用域引用

五、闭包的应用场景(面试重点 ⭐⭐⭐)


1️⃣ 数据私有化(最经典)

function createCounter() {
  let count = 0;

  return {
    add() { count++ },
    get() { return count }
  }
}

const counter = createCounter();

实现:

私有变量

这在早期 JS 中是实现“类私有属性”的方式。


2️⃣ 防抖 / 节流

function debounce(fn, delay) {
  let timer = null;

  return function() {
    clearTimeout(timer);
    timer = setTimeout(fn, delay);
  }
}

timer 被闭包保存。


3️⃣ 循环绑定事件(经典面试题)

for (var i = 0; i < 3; i++) {
  (function(i){
    setTimeout(() => {
      console.log(i);
    }, 1000)
  })(i)
}

利用闭包保存每次的 i。

(当然现在可以用 let)


4️⃣ 模块化(早期 IIFE)

const module = (function(){
  let privateVar = 1;

  return {
    get() {
      return privateVar;
    }
  }
})();

5️⃣ React Hooks 本质

比如:

useState()

内部就是通过闭包保存状态。


六、闭包的优缺点

优点

  • 数据私有
  • 延长变量生命周期
  • 实现函数式编程

缺点

  • 容易造成内存泄漏
  • 滥用会增加内存占用

七、什么时候会导致内存泄漏?

如果闭包引用了:

DOM 节点
大对象
长期不释放

就会导致 GC 无法回收。

比如:

function fn() {
  const dom = document.getElementById('box');

  return function() {
    console.log(dom);
  }
}

只要返回函数没释放,dom 就不会被回收。


八、面试标准回答模板(你可以直接背)

闭包是函数和其词法作用域的组合。
当内部函数引用外部函数变量时,外部函数执行完后变量不会被销毁,从而形成闭包。
本质是作用域链和垃圾回收机制的结果。
常见应用包括数据私有化、防抖节流、模块化封装、事件绑定等。

如何在老项目中使用AI实现智能问答

作者 菜鸟shuai
2026年2月28日 10:07

老树发新芽:在 Vue 2 老项目中优雅落地 AI 流式对话 (SSE)

前言:随着 DeepSeek、ChatGPT 等大模型的爆火,给现有业务系统装上“AI 大脑”已成为刚需。但对于许多仍坚守在 Vue 2 + Webpack 时代的企业级老项目来说,引入 AI 能力往往面临着技术栈陈旧、依赖冲突等挑战。本文将以实战代码为例,复盘如何在不破坏原有架构的前提下,利用原生技术栈实现丝滑的 AI 流式问答体验。

一、 痛点分析:为什么不用 Axios?

在老项目中,我们通常封装了统一的 Axios 拦截器来处理 Token、错误码和全局 Loading。但在对接 AI 流式接口(Server-Sent Events, SSE)时,Axios 显得有些“水土不服”:

  1. 流式支持弱:老版本 Axios 对 onDownloadProgress 的支持更多是为了进度条,而非真正的流式解析,容易出现“等一坨数据回来再一次性渲染”的伪流式现象。
  2. 配置繁琐:为了通过 Axios 获取原始流,往往需要修改 responseType 并绕过原有的响应拦截器,代码侵入性强。

破局思路:返璞归真。利用浏览器原生支持的 Fetch API + ReadableStream,既能实现真正的流式读取,又能与项目原有的 Axios 逻辑完全解耦,做到零侵入集成。

二、 核心方案:Fetch + ReadableStream 组合拳

1. 封装通用的流式请求器

我们在项目中通过 AbortController 实现了超时控制和请求中断,配合 TextDecoder 完美解决了二进制流转文本时的乱码问题(特别是中文被截断时)。

/**
 * 发起流式请求的核心方法
 * @param {string} url - 接口地址
 * @param {object} payload - 请求参数
 * @param {function} onMessage - 接收消息的回调
 * @param {function} onThinking - 接收思考过程的回调
 */
async function streamRequest(url, payload, onMessage, onThinking) {
  // 1. 也是老项目常被忽略的细节:请求中断控制器
  const controller = new AbortController();
  // 设置 5 分钟超长超时,适应 AI "慢思考" 的特性
  const timeoutId = setTimeout(() => controller.abort(), 300000);

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        // 如果需要鉴权,在此处添加 Token
        // 'Authorization': 'Bearer ' + getToken() 
      },
      body: JSON.stringify(payload),
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    // 2. 获取流读取器
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    // 3. 循环读取流数据
    while (true) {
      const { done, value } = await reader.read();
      
      if (done) break;

      // 关键点:流式解码,自动处理 UTF-8 多字节字符被切断的情况
      const chunk = decoder.decode(value, { stream: true });
      buffer += chunk;

      // 4. 处理 SSE 格式数据 (假设以 "data:" 开头)
      // 这一步是为了解决 TCP 粘包问题:一次可能收到多条消息,或者一条消息分两次收到
      if (buffer.includes('data:')) {
        const dataBlocks = buffer.split(/(?=data:)/);
        // 保留最后一个可能未传输完整的块,留到下一次循环处理
        buffer = dataBlocks.pop() || '';

        for (const block of dataBlocks) {
          if (!block.trim()) continue;
          processSSEBlock(block, onMessage, onThinking);
        }
      }
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.warn('请求已取消或超时');
    } else {
      throw error;
    }
  } finally {
    clearTimeout(timeoutId);
  }
}

2. 解析 SSE 协议数据

后端通常会定义自定义的事件协议,例如区分“思考中(Think)”和“回答中(Message)”。我们需要精准解析这些状态,给用户更好的反馈。

function processSSEBlock(block, onMessage, onThinking) {
  try {
    // 提取 JSON 内容
    const match = block.match(/data:(.+?)(?=data:|$)/s);
    if (match) {
      const jsonStr = match[1].trim();
      const jsonData = JSON.parse(jsonStr);

      // 状态机分发
      if (jsonData.event === 'think') {
        // AI 正在深度思考
        onThinking(jsonData.answer); 
      } else if (jsonData.event === 'message') {
        // AI 开始输出正文
        onMessage(jsonData.answer);
      }
    }
  } catch (e) {
    // 容错处理:非 JSON 格式的纯文本流
    const textContent = block.replace(/^data:/, '').trim();
    if (textContent && !textContent.startsWith('{')) {
      onMessage(textContent);
    }
  }
}

三、 体验优化:让 AI 更像“人”

在老旧的 UI 框架(如 Element UI)中,如何让 AI 的交互显得现代且灵动?

1. 视觉上的“心跳”机制

AI 的思考过程往往是静默的。为了缓解用户的等待焦虑,我们设计了一个“思考中”的状态机。

  • 痛点:有时候 AI 思考完了,但生成正文还有延迟,导致中间出现尴尬的空白期。
  • 优化:引入智能防抖机制。
// 监听思考流
if (event === 'think') {
  this.isThinking = true;
  this.thinkingContent += content;
  
  // 如果 1.5 秒内没有新的思考内容,自动判定思考结束,转入生成状态
  // 避免后端漏发 'think_end' 事件导致一直显示"思考中"
  clearTimeout(this.thinkTimer);
  this.thinkTimer = setTimeout(() => {
    this.endThinking();
  }, 1500);
}

2. 自动跟随滚动的视窗

当生成内容很长时,用户希望视窗能自动跟随到底部,但在查看上面内容时又不希望被强制拉到底部。

startAutoScroll() {
  // 记录上一次的内容长度
  this.lastLength = 0;
  
  this.scrollInterval = setInterval(() => {
    const currentLength = this.currentContent.length;
    // 只有当内容真正增加时才滚动,避免无意义的 DOM 操作
    if (currentLength > this.lastLength) {
      this.scrollToBottom();
      this.lastLength = currentLength;
    }
  }, 1000); // 1秒的节流频率,既流畅又不占用过多主线程
}

四、 架构思考:Vue 2 老项目的“渐进式”改造

sbs-upms-ui 这种典型的企业级 Vue 2 项目中,我们没有选择重构整个 HTTP 模块,而是采用了插件式的打法:

  1. 独立文件:将 AI 相关的流式请求逻辑封装在单独的 .js 文件中,不污染原有的 API 封装。
  2. 组件复用:将 Markdown 渲染(vue-markdown-it)、打字机效果封装为独立的 Vue 组件,可以在任何业务页面按需引入。
  3. 数据驱动:利用 Vue 的响应式系统,将流式数据直接绑定到 el-inputdiv 上,无需手动操作 DOM 插入文本。

五、 结语

技术不仅仅是追逐新框架。在现有的 Vue 2 老系统中,通过合理利用原生 Fetch API 和流式处理思想,我们依然可以构建出不输给 Next.js/React 的现代化 AI 交互体验。这不仅是功能的叠加,更是对老项目生命力的延续。


相关技术栈:Vue 2.6, Element UI, Fetch API, Server-Sent Events (SSE)

微前端架构下的平台级公共组件资源体系设计

2026年2月28日 09:54

二、核心分层设计(对标阿里 Fusion / 字节 Semi / 腾讯 TDesign)

L0 — Design Token(设计令牌层)

职责:统一视觉语言,与 UI 框架解耦

@cmc/design-tokens
├── colors.css          # CSS Variables
├── spacing.css
├── typography.css
├── shadows.css
├── tokens.json         # Style Dictionary 源文件(可生成多端产物)
└── index.ts            # JS/TS 常量导出
  • Style Dictionary 管理,一份源数据 → 输出 CSS Variables / SCSS / TS / Figma Plugin
  • Element Plus 主题通过覆盖 --el-* 变量接入,各子应用无需各自配置
  • 由基座注入全局 CSS Variables,子应用继承

L1 — 基础增强组件层

职责:Element Plus 二次封装 + 原子级通用组件

@cmc/ui
├── CmcTable/           # 增强表格(列配置化、分页内聚、虚拟滚动)
├── CmcForm/            # 配置化表单(JSON Schema 驱动)
├── CmcUpload/          # 统一上传(OSS/本地/断点续传)
├── CmcDialog/          # 增强弹窗(拖拽/全屏/promise化)
├── CmcSearch/          # 搜索栏(折叠/展开/记忆)
├── CmcDescription/     # 详情描述列表
├── CmcPermission/      # 权限指令/组件
└── ...

关键设计原则

  • Props 透传v-bind="$attrs" 全量透传 Element Plus 原生属性,不做阉割
  • 插槽穿透:暴露原组件所有 slot,保证可扩展性
  • 类型完备:每个组件导出 Props / Emits / Expose 类型定义
  • 无业务逻辑:纯 UI 层,不含接口调用

L2 — 业务组件层

职责:跨系统复用的业务功能单元

@cmc/biz-components
├── ShipmentSelector/      # 船期选择器
├── PortPicker/            # 港口选择器(含模糊搜索+常用)
├── CustomerSearch/        # 客户搜索组件
├── ApprovalFlow/          # 审批流展示
├── FilePreview/           # 统一文件预览
└── ...
  • 允许内置接口调用,但通过 依赖注入 抽象 API 层(不硬编码域名/路径)
  • 通过 provide/inject 或 props 传入 API adapter

L3 — Pro 区块/模板层

职责:页面级可复用布局模式

@cmc/pro-components
├── ProTable/          # 搜索 + 表格 + 分页 + 工具栏 一体化
├── ProForm/           # 分步表单 / 弹窗表单 / 抽屉表单
├── ProLayout/         # 标准页面布局框架
├── ProDetail/         # 标准详情页
└── CrudTemplate/      # CRUD 页面生成器

三、微前端共享机制(核心难点)

方案:Module Federation + npm 包双轨制

┌──────────────────────────────────────────────────────┐
│                    分发策略矩阵                        │
├──────────────┬──────────────┬────────────────────────┤
│   组件层级    │   分发方式    │        理由            │
├──────────────┼──────────────┼────────────────────────┤
│ L0 Token     │ npm 包       │ 构建时确定,极少变更     │
│ L1 基础组件   │ npm 包       │ 稳定,需要类型推导      │
│ L2 业务组件   │ MF Remote    │ 变更频繁,需要热更新     │
│ L3 Pro 区块   │ npm 包       │ 需要 Tree-shaking      │
├──────────────┼──────────────┼────────────────────────┤
│ 紧急热修复    │ MF Remote    │ 一次发布,全部生效       │
└──────────────┴──────────────┴────────────────────────┘

Module Federation 关键架构

                    ┌───────────────────────┐
                    │   Component Service    │
                    │   (独立部署的组件服务)   │
                    │                       │
                    │  remoteEntry.js        │
                    │  ├── CmcUpload        │
                    │  ├── ApprovalFlow     │
                    │  └── ShipmentSelector │
                    └───────────┬───────────┘
                                │
              ┌─────────────────┼─────────────────┐
              ▼                 ▼                   ▼
     ┌────────────┐    ┌────────────┐      ┌────────────┐
     │  供应商门户  │    │  运营后台   │      │  客户门户   │
     │  (Consumer) │    │  (Consumer) │      │  (Consumer) │
     └────────────┘    └────────────┘      └────────────┘

Vite 集成配置@module-federation/vite):

// Component Service(Remote 端)
// vite.config.ts
import { federation } from '@module-federation/vite'

export default defineConfig({
  plugins: [
    federation({
      name: 'cmc_shared_ui',
      filename: 'remoteEntry.js',
      exposes: {
        './CmcUpload': './src/components/CmcUpload/index.vue',
        './ApprovalFlow': './src/components/ApprovalFlow/index.vue',
      },
      shared: {
        vue: { singleton: true },
        'element-plus': { singleton: true },
        pinia: { singleton: true },
      },
    }),
  ],
})
// 子应用(Consumer 端)
// vite.config.ts
federation({
  name: 'supplier_portal',
  remotes: {
    cmc_shared_ui: {
      type: 'module',
      name: 'cmc_shared_ui',
      // 通过 manifest 实现版本管理 + 灰度
      entry: 'https://static.cmclink.com/shared-ui/remoteEntry.js',
    },
  },
  shared: {
    vue: { singleton: true },
    'element-plus': { singleton: true },
  },
})

降级兜底策略(生产必备)

// useRemoteComponent.ts — 加载远程组件,失败回退本地
import { defineAsyncComponent, h } from 'vue'

export function useRemoteComponent(
  remoteName: string,
  localFallback: () => Promise<any>,
) {
  return defineAsyncComponent({
    loader: async () => {
      try {
        const module = await import(/* @vite-ignore */ `cmc_shared_ui/${remoteName}`)
        return module.default || module
      } catch (e) {
        console.warn(`[MF] Remote ${remoteName} 加载失败,回退本地版本`, e)
        const fallback = await localFallback()
        return fallback.default || fallback
      }
    },
    loadingComponent: () => h('div', { class: 'animate-pulse h-8 bg-gray-100 rounded' }),
    timeout: 5000,
  })
}

// 使用
const CmcUpload = useRemoteComponent(
  'CmcUpload',
  () => import('@cmc/ui/CmcUpload'), // npm 包兜底
)

四、版本治理与自动升级

┌───────────┐     push      ┌───────────┐    publish    ┌──────────┐
│ @cmc/ui   │ ──────────▶  │   CI/CD    │ ──────────▶  │ 私有 npm  │
│ 组件仓库   │              │ changesets │              │ Registry │
└───────────┘              └─────┬─────┘              └────┬─────┘
                                 │                          │
                                 │ trigger                  │ Renovate Bot
                                 ▼                          ▼
                          ┌─────────────┐          ┌──────────────┐
                          │ Storybook   │          │ 各子应用仓库   │
                          │ 自动部署     │          │ 自动提 PR      │
                          └─────────────┘          │ patch 自动合并  │
                                                   │ minor 人工审核  │
                                                   └──────────────┘

关键配置

  • changesets:语义化版本 + 自动 CHANGELOG
  • Renovate Bot:每日扫描依赖更新,自动 PR + 自动跑 CI
  • patch 自动合并:配置 Renovate automerge: true for patch
  • minor/major:需人工审核 PR 后合并

五、质量保障体系

环节 工具 说明
单元测试 Vitest + Vue Test Utils 每个 L1/L2 组件 ≥80% 覆盖率
视觉回归 Chromatic / Percy Storybook 截图对比,防止样式劣化
文档验收 Storybook / Histoire 每个组件必须有在线可交互 Demo
类型检查 vue-tsc --noEmit CI 卡口,类型不通过不允许发布
Bundle 分析 rollup-plugin-visualizer 防止包体积膨胀
API 兼容性 api-extractor 导出 API 变更自动检测 + 审批

六、仓库结构建议(pnpm workspace monorepo)

cmc-platform-ui/
├── pnpm-workspace.yaml
├── turbo.json                    # Turborepo 任务编排
├── .changeset/                   # changesets 配置
│
├── packages/
│   ├── design-tokens/            # @cmc/design-tokens (L0)
│   │   ├── tokens.json
│   │   └── package.json
│   │
│   ├── ui/                       # @cmc/ui (L1)
│   │   ├── src/
│   │   │   ├── CmcTable/
│   │   │   ├── CmcForm/
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsup.config.ts
│   │
│   ├── biz-components/           # @cmc/biz-components (L2)
│   │   ├── src/
│   │   └── package.json
│   │
│   ├── pro-components/           # @cmc/pro-components (L3)
│   │   ├── src/
│   │   └── package.json
│   │
│   └── shared/                   # @cmc/shared (工具函数/类型/常量)
│       ├── src/
│       └── package.json
│
├── apps/
│   ├── storybook/                # 组件文档站
│   └── mf-host/                  # Module Federation 组件服务
│       ├── src/
│       └── vite.config.ts
│
└── configs/                      # 共享配置
    ├── eslint-config/
    ├── tsconfig/
    └── tailwind-config/

七、与大厂方案对标

维度 阿里(Fusion/IceWork) 字节(Semi/Garfish) 本方案
设计令牌 Fusion Token Semi Token @cmc/design-tokens
基础组件 Fusion Next Semi Design @cmc/ui
业务组件 金融云物料 内部 Biz @cmc/biz-components
区块模板 IceWork 物料 Semi Pro @cmc/pro-components
微前端 qiankun Garfish Module Federation
共享策略 npm + CDN npm + MF npm + MF 双轨
自动升级 内部机器人 内部机器人 Renovate Bot
文档 Fusion Site Semi Site Storybook/Histoire

八、落地路径(推荐 3 个阶段)

Phase 1(1~2 周):基座搭建

  • 建 monorepo、配置 pnpm workspace + Turborepo
  • 迁移 5~10 个最高频 L1 组件
  • 接通 changesets + 私有 npm 发布

Phase 2(3~4 周):生态完善

  • Storybook 文档站上线
  • 接入 Renovate Bot 自动升级
  • 各子应用替换本地 copy → npm 依赖

Phase 3(5~8 周):高阶能力

  • L2 业务组件接入 Module Federation
  • 视觉回归测试
  • ProTable/ProForm 等 L3 组件建设

从零开始掌握 Shorebird:Flutter 热更新实战指南

作者 赤心Online
2026年2月28日 09:49

从零开始掌握 Shorebird:Flutter 热更新实战指南

在 Flutter 开发中,热更新一直是个让人又爱又恨的难题。Shorebird 的出现,很好地解决了这个痛点。本文将从环境搭建到生产实践,手把手带你掌握 Shorebird 的核心用法,并特别针对国内开发者关心的网络和平台问题给出实用建议。

一、Shorebird 是什么?

Shorebird 是一个为 Flutter 提供代码推送服务的平台,由 Flutter 创始团队成员创立,被公认为最接近官方的热更新方案。

核心优势:

  • 性能无损:Android 端保持 AOT 运行,iOS 端修改代码通过解释器执行(性能保留 90% 以上)
  • 平台合规:技术绕过应用商店限制,符合 Google Play 和 App Store 政策
  • 低侵入性:日常开发仍用标准 Flutter 命令,仅在发布时使用 Shorebird CLI

二、三步上手 Shorebird

🛠️ 第一步:环境配置与项目初始化

在开始前,请确保你的网络可以稳定访问海外服务(Shorebird CLI 和默认服务托管在海外)。

1. 安装 Shorebird CLI

curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash

安装后运行验证:

shorebird doctor

2. 登录账号

shorebird login

浏览器会自动打开授权页面,使用 Google 或 Microsoft 账号登录即可。

3. 初始化项目 进入 Flutter 项目根目录:

shorebird init

这个命令会自动完成:

  • 创建 shorebird.yaml 配置文件(含唯一 app_id)
  • 添加 shorebird_code_push 依赖
  • 配置 Android 网络权限和 .gitignore
  • 在 Shorebird 控制台创建应用记录

💡 国内网络小贴士:如果登录或安装缓慢,可以尝试为终端配置代理,或复制链接在代理模式下手动访问。

📦 第二步:发布第一个版本

在发布补丁前,需要先发布一个通过 Shorebird 构建的“基础版本”。

Android 版本发布

# 生成 AAB(默认,用于 Google Play)
shorebird release android

# 如需生成 APK
shorebird release android --artifact apk

iOS 版本发布

shorebird release ios

⚠️ 注意:发布 iOS 版本前,请确保已在 Xcode 中完成证书和配置文件设置。

执行成功后,Shorebird 会上传符号文件到服务器,生成的安装包(AAB/IPA)即可上传至应用商店。

🩹 第三步:发布热更新补丁

应用上线后,假设需要修复一个小 Bug:

1. 修改 Dart 代码(如修复逻辑或调整 UI)

2. 发布补丁

# Android 补丁
shorebird patch android

# iOS 补丁
shorebird patch ios

Shorebird 会自动对比新旧代码,生成极小(通常几十到几百 KB)的二进制补丁包并上传。

更新生效机制:

  • 用户首次打开 App → 后台静默下载补丁
  • 用户第二次打开 App → 更新自动生效(不中断当前会话)

三、进阶技巧:精细化控制

通过 shorebird_code_push 包提供的 API,可以实现更灵活的控制:

手动检查更新

import 'package:shorebird_code_push/shorebird_code_push.dart';

final _shorebirdCodePush = ShorebirdCodePush();

void checkForUpdate() async {
  // 1. 检查是否有可下载更新
  final isUpdateAvailable = await _shorebirdCodePush.isNewPatchAvailableForDownload();
  if (isUpdateAvailable) {
    // 2. 下载更新
    await _shorebirdCodePush.downloadUpdateIfAvailable();
  }
}

void applyUpdate() async {
  // 3. 在合适时机(如点击“重启应用”按钮)安装更新并重启
  if (await _shorebirdCodePush.isNewPatchReadyToInstall()) {
    await _shorebirdCodePush.installUpdateAndRestart();
  }
}

结合 CI/CD 自动化

Shorebird 可以无缝集成到 Codemagic 等 CI/CD 工具中,实现版本发布和补丁更新的全自动化流程。

四、重要注意事项

📌 更新范围限制

Shorebird 补丁只能更新 Dart 代码,以下变更必须通过新的应用商店版本发布:

  • ❌ 原生代码修改(android/ios/ 目录)
  • pubspec.yaml 依赖变更
  • ❌ 新增资源文件(图片、字体等)

🌐 国内生产环境优化

Shorebird 默认服务在海外,国内使用时建议:

自定义 CDN 方案

  1. 将补丁包托管到国内云存储(如阿里云 OSS、七牛云)
  2. 修改客户端代码,从你的 CDN 地址下载补丁
  3. 大幅提升用户下载速度和成功率

📱 平台支持现状

平台 支持情况 说明
Android ✅ 完美支持 性能无损,完全合规
iOS ✅ 完美支持 遵守 App Store 规则
鸿蒙 (HarmonyOS NEXT) ❌ 不支持 官方暂无计划,需关注后续动态

五、总结与最佳实践

核心命令速查

# 生命周期命令
shorebird init          # 初始化
shorebird release       # 发布版本(基础包)
shorebird patch         # 发布补丁(热更新)

适用场景建议

  • ✅ 推荐使用:仅覆盖 Android + iOS 的中大型 App,追求高性能和低接入成本
  • ⚠️ 暂不适用:必须覆盖鸿蒙系统的项目(需等待官方支持)
  • 💪 最佳实践:国内生产环境务必配置自定义 CDN,确保补丁下载体验

Shorebird 以其优秀的性能和合规性,已成为 Flutter 热更新的首选方案。通过本文的指引,相信你已经能够顺利上手。如果在实际操作中遇到问题,欢迎随时交流探讨。

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

作者 SmalBox
2026年2月28日 09:41

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

Linear Blend Skinning(线性混合蒙皮)节点是Unity URP Shader Graph中专门用于实现骨骼动画效果的重要工具。该节点通过将网格顶点与骨骼变换矩阵相结合,实现平滑的骨骼变形效果,是现代实时角色动画的核心技术之一。在游戏开发、虚拟角色创建和三维动画制作中,线性混合蒙皮技术被广泛应用,它能够使三维模型随着骨骼的移动而自然变形,创造出逼真的角色动画。

线性混合蒙皮的基本原理是将每个顶点与一个或多个骨骼关联,并根据骨骼的变换矩阵对顶点位置、法线和切线进行加权变换。与传统刚性蒙皮不同,线性混合蒙皮允许多个骨骼同时影响同一个顶点,通过权重值控制各骨骼的影响程度,从而实现更加平滑自然的变形效果。这种技术在处理关节弯曲等复杂变形时表现出色,能够有效避免模型撕裂或折叠等视觉问题。

Unity中的Linear Blend Skinning节点专为Entities Graphics系统设计,这是Unity的面向数据技术栈(DOTS)的一部分。Entities Graphics采用数据导向的设计理念,能够高效处理大量动画实体,特别适合需要同时渲染成千上万个动画角色的场景,如大规模人群模拟、战略游戏中的单位群组等。

节点概述与兼容性

Linear Blend Skinning节点是Shader Graph中较为特殊的节点,它不适用于传统的GameObject渲染流程,而是专门为基于ECS(Entity Component System)的Entities Graphics系统设计。这意味着要使用此节点,项目必须启用Entities Graphics包并采用ECS架构进行开发。

Entities Graphics包是Unity DOTS技术栈的核心组成部分,它将渲染系统重新设计为数据导向的架构,能够充分利用多核处理器的并行计算能力。与传统的GameObject/MonoBehaviour系统相比,Entities Graphics在处理大量相似实体时具有显著的性能优势,因为它避免了缓存未命中和虚函数调用等性能开销。

使用Linear Blend Skinning节点前,开发者需要确保项目满足以下条件:

  • 安装并启用Entities Graphics包(版本1.0.0或更高)
  • 使用Unity 2022.2或更高版本
  • 项目已配置为使用Hybrid Renderer V2
  • 网格数据包含适当的蒙皮信息(骨骼权重和骨骼索引)

值得注意的是,Linear Blend Skinning节点仅适用于顶点着色阶段,因为它需要对每个顶点进行蒙皮变换计算。该节点不处理片段着色阶段的操作,如光照计算或纹理采样,这些仍需通过Shader Graph中的其他节点实现。

端口详解

输入端口

Position输入端口接收对象空间中的顶点位置坐标。这个位置是网格的原始顶点位置,尚未经过任何蒙皮变换。在典型的蒙皮流程中,这个输入通常来自Position节点或经过初步变换的顶点位置。

Normal输入端口接收对象空间中的顶点法线向量。法线在蒙皮过程中同样需要变换,以确保光照计算正确。如果法线不经过正确的蒙皮变换,在骨骼动画播放时,模型的明暗效果会出现异常,导致视觉上的不连贯。

Tangent输入端口接收对象空间中的顶点切线向量。切线主要用于法线映射和某些高级着色效果,如各向异性高光。与法线类似,切线也需要经过蒙皮变换以保持与变形后表面的一致性。

所有输入端口都只在顶点着色阶段有效,因为蒙皮计算是在每个顶点上执行的。输入数据的精度通常为float3,即包含三个32位浮点数的向量,这提供了足够的精度用于高质量的动画渲染。

输出端口

Position输出端口提供经过蒙皮变换后的顶点位置。这个位置已经根据关联的骨骼变换矩阵和权重进行了混合计算,可以直接用于后续的变换流程,如模型-视图-投影变换。

Normal输出端口提供经过蒙皮变换后的顶点法线。变换后的法线保持了与变形后表面的垂直关系,确保光照计算准确。法线的变换需要使用骨骼变换矩阵的逆转置矩阵,以保持正确的方向。

Tangent输出端口提供经过蒙皮变换后的顶点切线。切线变换与法线类似,但不需要使用逆转置矩阵,因为切线是方向向量而非法向量。

所有输出端口的数据类型与对应的输入端口一致,均为Vector 3,并且仅在顶点着色阶段有效。输出的数据可以连接到Shader Graph中的其他节点,如Transform节点或光照节点,以完成完整的着色器计算。

技术实现原理

线性混合蒙皮算法

Linear Blend Skinning节点的核心算法基于标准的线性混合蒙皮公式。对于每个顶点,其变换后的位置通过以下公式计算:

P_skinned = Σ(w_i × M_i × P_original)

其中:

  • P_skinned是蒙皮变换后的顶点位置
  • w_i是第i根骨骼的权重值(满足Σw_i = 1)
  • M_i是第i根骨骼的变换矩阵
  • P_original是原始顶点位置

类似的公式也应用于法线和切线的变换,但对于法线,需要使用变换矩阵的逆转置矩阵以保持正确方向:

N_skinned = Σ(w_i × (M_i^(-1))^T × N_original)

在实际实现中,Unity对标准算法进行了优化,以提升在Entities Graphics环境下的性能。这些优化包括:

  • 使用SOA(Structure of Arrays)数据布局提高缓存利用率
  • 利用SIMD指令进行并行矩阵运算
  • 采用分支预测优化减少GPU线程分歧

骨骼矩阵缓冲区

Linear Blend Skinning节点依赖于_SkinMatrices缓冲区,这是一个存储所有骨骼变换矩阵的GPU缓冲区。缓冲区的组织方式对性能有重要影响,通常采用按照骨骼索引顺序排列的线性布局。

每个网格实例通过_SkinMatrixIndex属性确定其在_SkinMatrices缓冲区中的起始位置。这个索引值与网格的骨骼数量结合,用于定位处理该网格所需的所有骨骼矩阵。

在Entities Graphics系统中,_SkinMatrices缓冲区由动画系统在每帧更新,反映当前帧中所有骨骼的变换状态。缓冲区更新通常发生在作业系统中,利用多核CPU并行计算所有骨骼的最终变换矩阵。

权重与索引处理

蒙皮数据中的骨骼权重和索引通常存储在网格的顶点属性中。标准的蒙皮网格通常为每个顶点提供:

  • 最多4个骨骼权重(和为1)
  • 对应的4个骨骼索引

Linear Blend Skinning节点内部处理这些数据,根据骨骼索引从_SkinMatrices缓冲区中获取相应的变换矩阵,然后根据权重进行混合计算。

对于超过4个骨骼影响的顶点,通常需要在建模阶段进行优化,或者使用高级蒙皮技术如双四元数蒙皮(Dual Quaternion Skinning)来避免变形问题。

配置与使用流程

前置条件设置

在使用Linear Blend Skinning节点前,需要进行一系列项目配置:

  • 安装Entities Graphics包:通过Package Manager安装最新版本的Entities Graphics包,并确保在Project Settings中已启用。
  • 配置Hybrid Renderer:在Entities Graphics配置中启用Hybrid Renderer V2,这是支持蒙皮网格渲染的必要组件。
  • 准备蒙皮网格:确保使用的网格包含蒙皮数据,包括骨骼权重和骨骼索引。这些数据通常在三维建模软件(如Blender、Maya)中创建并导出为FBX格式。
  • 设置动画系统:配置ECS动画系统,确保骨骼变换矩阵能够正确更新到_SkinMatrices缓冲区中。

在Shader Graph中的集成

将Linear Blend Skinning节点集成到Shader Graph中的基本步骤:

  1. 创建新的Shader Graph或打开现有图表
  2. 在节点库中找到Linear Blend Skinning节点(位于Animation类别下)
  3. 将节点拖放到图形编辑器中
  4. 连接输入端口:
    • 将Position节点的输出连接到Linear Blend Skinning的Position输入
    • 将Normal节点的输出连接到Normal输入
    • 将Tangent节点的输出连接到Tangent输入
  5. 连接输出端口:
    • 将Position输出连接到Vertex Position主节点输入
    • 将Normal输出连接到后续光照计算节点
    • 将Tangent输出连接到需要切线空间的节点(如法线贴图节点)

与实体组件的协同工作

在ECS环境中,Linear Blend Skinning节点与以下组件协同工作:

  • RenderMesh组件:存储网格和材质引用
  • LocalToWorld组件:存储实体的世界变换
  • SkinMatrixRenderer组件:标记需要蒙皮渲染的实体
  • BoneEntity组件:链接骨骼实体与渲染实体

动画系统会每帧更新骨骼实体的变换,这些变换通过Hybrid Renderer传递到_SkinMatrices缓冲区,最终被Linear Blend Skinning节点使用。

性能优化与最佳实践

缓冲区管理优化

_SkinMatrices缓冲区的管理对性能至关重要:

  • 骨骼矩阵应按照访问频率排序,将同一动画中经常同时使用的骨骼矩阵放置在相邻内存位置
  • 使用动态缓冲区而不是固定大小数组,以适应不同骨骼数量的网格
  • 对于静态或很少变化的骨骼动画,考虑使用常量缓冲区而非每帧更新

矩阵计算优化

在CPU端准备骨骼矩阵时可以采用多种优化策略:

  • 使用矩阵池减少内存分配
  • 利用Burst编译器加速矩阵计算
  • 采用层次化更新,只更新发生变化的骨骼
  • 使用增量更新策略,避免每帧完全重新计算所有矩阵

权重数据处理

优化权重数据可以提升蒙皮质量和性能:

  • 在建模阶段优化骨骼影响范围,减少每个顶点受影响的骨骼数量
  • 使用权重压缩技术减少内存占用
  • 对于移动平台,考虑使用半精度浮点数存储权重
  • 实现权重阈值裁剪,忽略影响微小的骨骼权重

平台特定优化

不同硬件平台可能需要不同的优化策略:

  • 在PC和主机平台,可以利用更复杂的蒙皮算法和更高的骨骼数量限制
  • 在移动平台,应限制最大骨骼数量并使用简化蒙皮算法
  • 对于WebGL目标,特别注意内存使用和矩阵计算复杂度

常见问题与解决方案

蒙皮变形异常

当蒙皮网格出现不自然的变形时,可能的原因和解决方案:

  • 骨骼权重不正确:检查三维软件中的权重绘制,确保关节处的权重过渡平滑
  • 骨骼矩阵计算错误:验证动画系统中骨骼矩阵的计算逻辑,特别是层级变换的处理
  • 缓冲区索引错误:确认_SkinMatrixIndex是否正确设置,与_SkinMatrices缓冲区中的位置对应

性能问题

蒙皮渲染性能不佳时的排查方向:

  • 分析GPU性能,确认瓶颈是否在顶点处理阶段
  • 检查骨骼数量是否过多,考虑使用LOD系统减少远处角色的骨骼数量
  • 评估权重计算复杂度,尝试减少每个顶点受影响的骨骼数量
  • 使用GPU分析工具(如RenderDoc)检查_SkinMatrices缓冲区的更新频率和大小

与光照和阴影的交互问题

蒙皮网格与光照系统交互时的常见问题:

  • 法线变换不正确导致光照异常:确保法线使用正确的变换矩阵(逆转置矩阵)
  • 阴影映射出现acne现象:调整深度偏移或使用更好的阴影过滤技术
  • 动态光照下的性能问题:对于大量蒙皮角色,考虑使用轻量级光照模型或烘焙光照

高级应用与扩展

自定义蒙皮算法

虽然Linear Blend Skinning节点实现了标准的线性混合蒙皮,但开发者可以通过自定义节点扩展其功能:

  • 实现双四元数蒙皮(Dual Quaternion Skinning)以减少关节弯曲处的体积损失
  • 添加蒙皮变形校正,如基于关节角的权重调整
  • 集成物理基的蒙皮,将物理模拟与骨骼动画结合

与其他动画技术结合

Linear Blend Skinning可以与其他动画技术结合使用:

  • 与顶点动画结合,在骨骼动画基础上添加细节运动
  • 与形状键(Blend Shapes)结合,实现面部表情等精细动画
  • 与 tessellation 结合,在GPU上增加几何细节并应用蒙皮

大规模人群渲染

利用Entities Graphics和Linear Blend Skinning实现大规模人群:

  • 使用GPU实例化渲染大量共享相同网格和材质的角色
  • 实现动画LOD,根据距离简化骨骼数量和蒙皮计算
  • 使用动画纹理(Animation Textures)批量处理骨骼矩阵,减少CPU-GPU数据传输

Linear Blend Skinning节点是Unity现代渲染管线中强大的动画工具,结合Entities Graphics架构,能够高效处理复杂的骨骼动画场景。通过深入理解其工作原理和优化策略,开发者可以创建出视觉震撼、性能优异的动画效果,为游戏和交互应用增添活力。随着Unity DOTS技术的不断发展,Linear Blend Skinning节点将在未来的实时图形应用中扮演更加重要的角色。


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

首屏轮播图使用cdn加载webp图片的代码案例

作者 MQliferecord
2026年2月28日 09:27

目标:基于 CDN 加载 WebP 图片,且能自动降级为 PNG/JPG(兼容不支持 WebP 的浏览器)

  1. 第一步:封装 WebP 检测工具,在用户浏览器不支持webp的情况下加载png/jpg图片
// src/utils/image.ts
let webpSupportCache: boolean | null = null;

/**
 * 检测浏览器是否支持 WebP(带缓存)
 */
export async function checkWebPSupport(): Promise<boolean> {
  if (webpSupportCache !== null) return webpSupportCache;

  return new Promise((resolve) => {
    const img = new Image();
    img.src = 'data:image/webp;base64,UklGRkoAAABXRXJjUgAAABAAAAAQAAABAAAAAQAAABgAAAAEAAAAA==';
    img.onload = () => resolve(img.width === 1);
    img.onerror = () => resolve(false);
  })
}

/**
 * 生成图片 URL(自动切换 WebP/原图)
 * @param cdnBase CDN 基础路径
 * @param imgName 图片名称(不含后缀)
 * @param ext 原图后缀(如 png/jpg)
 */
export function getImageUrl(cdnBase: string, imgName: string, ext: string = 'png'): string {
  if (webpSupportCache) {
    // 支持 WebP 则拼接 webp 后缀
    return `${cdnBase}/${imgName}.webp`;
  }
  // 不支持则用原图后缀
  return `${cdnBase}/${imgName}.${ext}`;
}
  1. 首屏轮播图组件



❌
❌