普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月28日首页

# 老司机 iOS 周报 #361 | 2025-12-29

作者 ChengzhiHuang
2025年12月28日 20:40

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

文章

🐕 Exploring interactive snippet intents

@BluesJiang: 这篇文章主要探索了一下 App Intent 框架。苹果在 WWDC25 上引入了 App Intent 的可交能力,在 Widget、App Shortcut、Intent 中都可以使用。作者探索了这个 App Intent 的交互框架和编码逻辑,旨在了解这个交互框架可以做什么,不可以做什么,交互分范式是什么样的。
这个框架使用 SwiftUI 编码,但是交互逻辑与方式则有很大的不同,在 App Intent 框架下,不存在传统生命式框架下的状态和交互变化,甚至按钮的触发事件也不是直接的,而是间接通过注册的 Intent 来完成响应。
如果有需要在 App 外做即时响应的功能,可以考虑研究一下。

🐎 使用 "git mv" 命令记录 Git 中文件名的大小写更改

@含笑饮砒霜:这篇文章主要介绍了在 macOS 和 Windows 默认的大小写不敏感但保留大小写的文件系统中,直接修改文件名大小写时 Git 不会记录该名称变更,可能导致文件系统与 Git 存储的文件名不一致,进而引发后续使用(如跨大小写敏感文件系统、CI 打包)的问题,同时给出解决方案:使用 git mv 命令记录文件名大小写变更,若不便使用该命令,可通过 “先重命名为临时名称、再改为目标名称” 的两阶段提交方式实现同样效果。

🐎 Swift Configuration 1.0 released

@AidenRao:Swift Configuration 1.0 的正式发布。该项目旨在为 Swift 应用提供一套统一的配置管理方案,帮助开发者优雅地处理来自环境变量、配置文件乃至远程服务的各类配置项。通过它,我们可以告别过去分散繁琐的配置逻辑,以更清晰、安全和可维护的方式构建应用。

🐎 Using associated domains alternate mode during development

@DylanYang:作者向我们介绍了如何在调试 AASA(apple-app-site-association) 相关能力时,通过开发者模式使域名相关的改动可以即时的被同步到。开发者模式需要我们在对应域名上加上特定后缀,并且只对开发模式的签名文件生效。有调试相关能力需求的开发者可以参考一下。

🐢 Command Line Interface Guidelines

@zhangferry:这篇文章是一份开源的《命令行界面(CLI)设计指南》,核心目标是结合传统 UNIX 原则与现代需求,帮助开发者打造更易用、更友好的 CLI 程序。虽然现在 GUI 非常普及,但 CLI 以其灵活、稳定、跨平台的优势在很多场景(例如 DevOps)都在放光发热。所以了解如何更好的设计 CLI 仍有必要,以下是从文章内挑选的几条重要设计指南:

  • 基础规范:使用对应语言的命令行参数解析库,Swift 下是 swift-argument-parser;成功时返回 0,失败返回非 0;核心输出到 stdout(支持管道传递),日志,错误信息输出到 stderr(避免干扰管道)
  • 帮助和文档:默认运行无参数时显示简洁的帮助,-h/--help 对应完整的帮助说明。
  • 输出设计:人类可读最重要,如果为了人类可读破坏了机器可读,可以增加 --plain 参数输出机器可读内容,这有利于 grep、awk 工具的集成
  • 错误处理:避免冗余输出,核心错误应该放在末尾
  • 参数和标志:优先使用 flags,而不是依赖位置读参数;所有 flags 都提供短格式和长格式两种(-h/--help);危险操作增加一个保护措施:输入名称、--force 标志等
  • 健壮性与兼容性:及时响应用户的输入(100ms 以内),如果流程耗时增加进度反馈(进度条)
  • 环境变量:避免占用 POSIX 标准变量;本地用 .env 管理但不应把 .env 当做配置文件;不要使用环境变量存储密钥等重要信息,这样很容易泄漏,推荐通过文件或密钥管理服务

🐕 SwiftUI Group Still(?) Considered Harmful

@Damien:本文指出 SwiftUI 的 Group 会把修饰符“分发”给每个子视图,曾让 onAppear 被多次触发。onAppear/task 虽被苹果特殊处理,但文档未改,且自定义修饰符与在 List 内仍照分发。解决方案为:除非必须一次性给兄弟视图统一加修饰符,否则别用 Group,直接重复代码或拆视图更稳妥。

代码

🐢 SwiftAgents

@阿权:SwiftAgents 为 Swift 开发者提供了一套现代化、类型安全、并发友好的 AI Agent 开发框架,兼具强大的功能与优雅的 API 设计,适合在苹果全平台及 Linux 上构建下一代智能应用。

实现能力:

  • Agent 框架:支持 ReAct、PlanAndExecute、ToolCalling 等多种推理模式
  • 灵活内存系统:包含对话内存、滑动窗口、摘要记忆及可插拔持久化后端
  • 类型安全工具:通过 @Tool@Parameter 宏大幅减少样板代码
  • 多代理编排:支持监督者-工作者模式、并行执行与智能路由
  • 全平台支持:兼容 iOS 17+、macOS 14+、Linux(Ubuntu 22.04+)
  • 强并发安全:基于 Swift 6.2 的 Actor 隔离与 Sendable 类型
  • 可观测性与弹性:内置日志追踪、指标收集、重试策略与熔断器

适用场景:

  • 对话式 AI 助手
  • 自动化任务执行与决策流程
  • 多 Agent 协同分析系统
  • 需要持久化记忆与工具调用的复杂应用

🐕 XcodeBuildMCP 1.15.0 released

@Cooper Chen:XcodeBuildMCP 是一个基于 Model Context Protocol(MCP)的开源工具,将 Xcode 的构建、运行与模拟器能力以标准化接口暴露给 AI Agent,使其能够真正参与 iOS / macOS 的开发流程。开发者只需在首次调用时设置好 project、simulator 和 scheme,之后的每一次调用都可以直接复用配置,“一次设定,次次生效”。

这一设计显著降低了上下文和参数负担:

  • 上下文占用减少 24.5%(smaller context footprint)
  • 每次调用所需参数更少(fewer params per call)

对于依赖 AI 自动编译、跑测试、定位问题的场景而言,这意味着更低的 Token 消耗、更稳定的 Agent 行为,以及更高效的工具调用体验。XcodeBuildMCP 是连接 Xcode 与 AI 工作流的关键基础设施,尤其适合构建长期、可持续的智能开发系统。

音视频

🐕 CS193 Stanford 2025

@极速男孩:这是是斯坦福大学计算机科学系著名的公开课程 CS193p: Developing Applications for iOS(iOS 应用程序开发)。主要涵盖最新的 iOS SDK 特性。根据网站最新信息(Spring 2025 版本),内容包括 Xcode 的使用、SwiftUI 的视图与修饰符、Swift 类型系统、动画、数据持久化(SwiftData)以及多线程等。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

网易集团丁迎峰退休;《王者万象棋》开启大规模测试;《燕云十六声》周年时装引争议 | 氪游周报

2025年12月28日 20:34

‍文 | 贝果树‍ 

编辑 | 刘士武

新游速递

腾讯《王者荣耀》IP自走棋新作《王者万象棋》开启大规模测试

12月25日,《王者万象棋》开启首轮大规模测试。《王者万象棋》是腾讯基于《王者荣耀》IP开发的一款策略对战游戏。在游戏中,玩家需要通过构建英雄阵容、升级英雄等级、布局合理站位来取得胜利。本次测试为不计费删档性质,不开放付费功能,允许内容直播与传播。

《王者万象棋》

国产抗日谍战FPS《抵抗者》发布实机演示

12月26日,由国内团队浩汤科技开发的抗日谍战题材第一人称射击游戏《抵抗者》发布实机演示视频。《抵抗者》是一款以1940年代的中国为背景的第一人称射击游戏,融合了谍战解迷和动作射击元素,章节将会跨越中国的不同战场,玩家将以不同身份投入到抵抗侵略者的行动中。目前游戏已上线Steam页面。

《抵抗者》

老游情报

《光与影:33号远征队》独立游戏奖项因使用AI违反评选规则被撤回

独立游戏大奖组委会发布公告正式撤销游戏《光与影:33号远征队》所获得的“年度游戏”与“最佳首作”两项大奖。撤销原因是开发商在提交参评时曾书面承诺未使用生成式AI,但后续被证实游戏在开发过程中使用了该技术。目前,年度游戏奖授予《蓝途王子(Blue Prince)》,最佳首作奖授予至《抱歉已打烊(Sorry We're Closed)》。

 《燕云十六声》周年时装“飞白成诗”引发争议

开放世界游戏《燕云十六声》近期因周年庆时装“飞白成诗”的设计问题,在玩家社区中引发争议。部分玩家认为服装中的露背绑带、臀部蝴蝶结、超短裙及透明薄纱等元素的女款时装,视觉效果过于暴露。12月25日,官方在更新公告中表示,该设计实为染色异常,并对时装进行了调整。12月27日,官方发公告称男女角色存在部分外观体型显示效果异常,现已紧急排查定位原因。

 《燕云十六声》官方公告

《使命召唤》系列联合创始人Vince Zampella因车祸去世

知名游戏开发商Infinity Ward联合创始人、《使命召唤》系列的核心奠基人之一Vince Zampella于2025年12月21日因车祸不幸去世。Vince Zampella曾与团队创立Infinity Ward,推出《使命召唤》系列,并领导开发了《使命召唤:现代战争》。

Vince Zampella

行业要闻

广州出台游戏电竞产业专项扶持政策

12月22日,广州市政府办公厅正式印发《广州市扶持游戏电竞产业发展的十八条措施》。内容覆盖游戏研发、运营、出海、电竞、人才等方面。《措施》指出,对具有中华优秀传统文化内涵的重点游戏选题,给予最高200万元的事前补助;对海外影响力强的游戏,给予最高30万元补助;对上线后的优秀游戏产品,按营收分成给予最高500万元补助;对在广州举办国际国内高水平电竞赛事的主承办方,给予最高500万元补助等。

广州市政府办公厅发布《广州市扶持游戏电竞产业发展的十八条措施》

网易集团执行副总裁丁迎峰退休

12月27日,网易公司发布公告,宣布公司执行副总裁、互动娱乐事业群负责人丁迎峰(丁丁)将于2025年12月31日正式退休。退休后,他将继续担任公司顾问职务。

网易集团执行副总裁丁迎峰

12月游戏版号发放

12月25日,国家新闻出版署发布了2025年12月份国产及进口网络游戏审批信息,共有147款游戏过审。本次共发放国产游戏版号144个,进口游戏版号3个。本批次过审的产品包括腾讯《QQ经典农场》、三七互娱《荒漠沙舟》、乐元素《白银之城》等。

本文首发自“36氪游戏”

减肥神药,如何点燃一场世纪之争?

2025年12月28日 20:00

马斯克一句话,把减肥神药司美格鲁肽推上热搜,富人疯抢,患者断药,全球陷入断货混乱。诺和诺德低估了减肥市场的疯狂,供应链被挤爆,山寨药遍地开花,利润被空手套白狼,本期视频就来聊聊中美药企逐鹿万亿赛道。

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

IndexedDB 使用指南

2025年12月28日 19:40

前言

在上一篇文章介绍了前端跨页面通讯终极指南⑧:Cookie 用法全解析,下一篇是介绍前端跨页面通讯终极指南⑨:# IndexedDB 用法全解析,考虑到这种方式并不是常用,先介绍下IndexedDB的使用方法,再对跨页面通信进行总结。

下面介绍下IndexedDB概念以及常见用法。

1. 基本概念

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。虽然 Web Storage 在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心。而 IndexedDB 提供了这种场景的解决方案。

IndexedDB是浏览器提供的 NoSQL 数据库,用于在客户端存储大量结构化数据。它支持事务、索引查询和异步操作,适合离线应用、数据缓存等场景。

数据库层级结构

Database (数据库)
├── Object Store (对象存储空间)
│   ├── Index (索引)
│   └── Data Records (数据记录)
└── Object Store (对象存储空间)

关键特性

  • 异步操作:所有操作都是异步的,使用事件或Promise
  • 事务支持:原子性操作,要么全部成功要么全部回滚
  • 索引查询:支持基于索引的高效查询
  • 存储限制:通常限制为几百MB(取决于浏览器)
  • 持久化存储:数据在浏览器关闭后仍然保留

2. 核心API

2.1 打开数据库

const request = indexedDB.open('dbName', version);

2.2 创建/升级数据库

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  // 创建或升级存储空间
};

2.3 事务操作

const transaction = db.transaction(storeNames, mode);
const store = transaction.objectStore(storeName);

这里的事务需要特别说明下。为什么要引入事务?

我们用银行转账的案例进行说明:

假设你要从 A账户 转账 100 元给 B账户。这个过程包含两个必不可少的步骤:

  1. 从 A 账户扣除 100 元。
  2. 给 B 账户增加 100 元。

如果没有事务(不安全的情况): 如果在步骤 1 完成后,突然停电了或系统出错了,步骤 2 没执行。结果就是:A 的钱少了,B 没收到钱,这 100 元凭空消失了。

有了事务(安全的情况): 当你点击“确认转账”时,数据库开启了一个“事务”:

  • 原子性:数据库将步骤 1 和步骤 2 打包成一个整体。只有当两个步骤成功时,修改才会生效;只要中间任意一步出错,数据库会自动“回滚”,让一切回到转账前的样子(A 的钱没少,B 的钱没多)。

对应到 IndexedDB

  • 银行 -> 整个 IndexedDB 数据库 (db)
  • A账户B账户 -> 相同的对象存储 (accounts)
  • 转账这个行为 -> 一个事务 (transaction)

现在我们来看代码如何实现这个逻辑:

// 1. 开启事务
// 参数 ['accounts'] 指定了事务的【作用域】:只允许操作 'accounts' 这个表
// 参数 'readwrite' 指定了【模式】:允许修改数据(读写)
const transaction = db.transaction(['accounts'], 'readwrite');
// 2. 获取对象仓库(表)
const store = transaction.objectStore('accounts');
// --- 以下是事务内的具体操作 ---
// 操作 A:更新账户 A 的余额(假设原为 1000,现改为 900)
store.put({ id: 'A', balance: 900 });
// 操作 B:更新账户 B 的余额(假设原为 1000,现改为 1100)
store.put({ id: 'B', balance: 1100 });
// --- 监听事务结果 ---
// 如果上面所有操作都成功
transaction.oncomplete = function(event) {
    console.log("转账成功!数据已永久保存。");
};
// 如果中间任何一步报错(比如账户 B 不存在,或者磁盘满了)
transaction.onerror = function(event) {
    console.log("转账失败!刚才的修改全部撤销,A 的余额变回 1000。");
};

通过这个例子我们可以清楚的看到IndexedDB事务的核心作用:

  • 原子性:将单个或多个相关的数据库操作(扣款、存款)打包成一个不可分割的整体。
  • 一致性:确保数据库从一个正确的状态(转账前)转换到另一个正确的状态(转账后)。如果中途失败,则会回到初始状态,不会出现数据不一致(钱少了)的中间状态。

2.4. 常用操作方法

// 添加数据
store.add(data);

// 更新数据
store.put(data);

// 删除数据
store.delete(key);

// 获取数据
store.get(key);

// 获取所有数据
store.getAll();

// 使用索引查询
store.index('indexName').get(value);

3. 数据库操作

3.1 打开数据库

const openDB = (dbName, version) => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(dbName, version);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      // 数据库升级逻辑
    };
  });
};

3.2 关闭数据库

db.close();

3.3 删除数据库

const deleteDB = (dbName) => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.deleteDatabase(dbName);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve('数据库删除成功');
  });
};

4. 事务处理

4.1 创建事务

const transaction = db.transaction(['store1', 'store2'], 'readwrite');

4.2 事务模式

  • 'readonly':只读事务
  • 'readwrite':读写事务
  • 'versionchange':版本变更事务

4.3 事务错误处理

transaction.onerror = (event) => {
  console.error('事务错误:', event.target.error);
};

transaction.onabort = () => {
  console.log('事务已回滚');
};

transaction.oncomplete = () => {
  console.log('事务完成');
};

4.4 手动回滚事务

transaction.abort();

5. 数据存储

5.1 创建对象存储空间

const store = db.createObjectStore('users', {
  keyPath: 'id', // 使用数据中的id字段作为主键
  autoIncrement: true // 自动生成主键
});

5.2 添加数据

const addData = (store, data) => {
  return new Promise((resolve, reject) => {
    const request = store.add(data);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

5.3 更新数据

const updateData = (store, data) => {
  return new Promise((resolve, reject) => {
    const request = store.put(data);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

5.4 删除数据

const deleteData = (store, key) => {
  return new Promise((resolve, reject) => {
    const request = store.delete(key);

    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
};

6. 查询操作

6.1 获取单个数据

const getData = (store, key) => {
  return new Promise((resolve, reject) => {
    const request = store.get(key);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

6.2 获取所有数据

const getAllData = (store) => {
  return new Promise((resolve, reject) => {
    const request = store.getAll();

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

6.3 范围查询

const rangeQuery = (store, range) => {
  return new Promise((resolve, reject) => {
    const request = store.openCursor(range);

    const results = [];
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results);
      }
    };

    request.onerror = () => reject(request.error);
  });
};

7. 索引使用

7.1 创建索引

store.createIndex('nameIndex', 'name', { unique: false });
store.createIndex('emailIndex', 'email', { unique: true });

7.2 使用索引查询

const queryByIndex = (store, indexName, value) => {
  return new Promise((resolve, reject) => {
    const index = store.index(indexName);
    const request = index.get(value);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

7.3 范围索引查询

const rangeQueryByIndex = (store, indexName, range) => {
  return new Promise((resolve, reject) => {
    const index = store.index(indexName);
    const request = index.openCursor(range);

    const results = [];
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results);
      }
    };

    request.onerror = () => reject(request.error);
  });
};

8. 完整示例

用户管理系统

// 用户管理系统
class UserManager {
  constructor(dbName = 'UserDB', version = 1) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }

  async init() {
    this.db = await this.openDB();
    return this;
  }

  async openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve(request.result);

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

        if (!db.objectStoreNames.contains('users')) {
          const store = db.createObjectStore('users', { keyPath: 'id' });
          store.createIndex('name', 'name', { unique: false });
          store.createIndex('email', 'email', { unique: true });
          store.createIndex('createdAt', 'createdAt', { unique: false });
        }
      };
    });
  }

  async addUser(user) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['users'], 'readwrite');
      const store = transaction.objectStore('users');

      user.createdAt = new Date().toISOString();

      const request = store.add(user);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async getUserById(id) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['users'], 'readonly');
      const store = transaction.objectStore('users');
      const request = store.get(id);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async getUserByEmail(email) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['users'], 'readonly');
      const store = transaction.objectStore('users');
      const index = store.index('email');
      const request = index.get(email);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async updateUser(id, updates) {
    const user = await this.getUserById(id);
    if (!user) throw new Error('用户不存在');

    const updatedUser = { ...user, ...updates };
    return this.putUser(updatedUser);
  }

  async putUser(user) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['users'], 'readwrite');
      const store = transaction.objectStore('users');
      const request = store.put(user);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async deleteUser(id) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['users'], 'readwrite');
      const store = transaction.objectStore('users');
      const request = store.delete(id);

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async getAllUsers() {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['users'], 'readonly');
      const store = transaction.objectStore('users');
      const request = store.getAll();

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async searchUsers(query) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['users'], 'readonly');
      const store = transaction.objectStore('users');
      const index = store.index('name');

      const range = IDBKeyRange.bound(query, query + '\uffff');
      const request = index.openCursor(range);

      const results = [];
      request.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor) {
          results.push(cursor.value);
          cursor.continue();
        } else {
          resolve(results);
        }
      };

      request.onerror = () => reject(request.error);
    });
  }
}

// 使用示例
async function demo() {
  const userManager = new UserManager();
  await userManager.init();

  // 添加用户
  await userManager.addUser({
    id: 'user1',
    name: '张三',
    email: 'zhangsan@example.com',
    age: 25
  });

  // 获取用户
  const user = await userManager.getUserById('user1');
  console.log('获取用户:', user);

  // 更新用户
  await userManager.updateUser('user1', { age: 26 });

  // 搜索用户
  const users = await userManager.searchUsers('张');
  console.log('搜索结果:', users);

  // 删除用户
  await userManager.deleteUser('user1');
}

image.png

总结

最后总结一下:IndexedDB是前端的数据库,通常在 Web Storage 无法满足容量要求的场景下才使用,它能够存储大量数据,一般不轻易用。

2025,AI 编程元年,我用 TRAE 做了这些!

2025年12月28日 19:20

大家好,我是不如摸鱼去,欢迎来到我的分享专栏。

2025 已经到了尾声,由于最近在家养病,早早写好了 不如摸鱼去的 2025 年终总结,今年的关键词是直面天命,记录并总结一下 2025 的经历与收获。

今年是 AI 编程元年,也是AI 编程工具大爆发的一年,我觉得还是有必要单开一篇总结下一整年的 AI 编程的实践与收获,聊一聊 2025 AI/Vibe Coding 对我的影响。

这一年光我用过的就有 GitHub Copilot、Cursor、Trae、Qoder、Codebuddy、Kiro、Claude Code、Catpaw、Antigravity 等等。最早是在用 GitHub Copilot,然后是 Cursor(支付方式比较友好),再后来国产 AI 编程工具 TRAE、Codebuddy、Qoder 陆续发布,每个新的工具基本都有在用。

自从 Cursor 锁区,TRAE就成为了我的主力 AI 编程工具,所以今天就主要聊一聊我使用 TRAE 的心路历程。

我在用 AI 做什么?

AI 已经完美融入到了很多人的生活中,以前是百度一下、谷歌一下,现在变成了 AI 一下。今年我主要是用来做以下事情:

  • 我的主业:编程搬砖,大多数程序员现在都已经是 Vibe Coding 或者 SDD 编程了,工作已经离不开 AI 了。
  • 写文章:平时写写文章,有的事情会交给 AI。不过全文 AI 编写的话,AI 味儿非常大,所以我目前主要是拿来写摘要、生成标题建议、生成封面提示词等。
  • 生成图片:制作文章封面、制作手绘图漫画之类的、生成游戏分镜等等。
  • 查询知识:这个应该是大多数人都会用的,例如豆包、DeepSeek、Kimi之类的应用,制作旅行攻略、查询就医看病知识、解读病理、检查报告等等。
  • 开源:我在开源项目中使用 AI 进行 review,使用 AI 对代码进行重构,也会使用 AI 进行功能编码和 bug 修复,还会使用 AI 做一些感兴趣的小东西。

大家都在用 AI 做什么呢?

TRAE 初代的使用

实话说,初代 TRAE 是相当的蹩脚,那时候的 TRAE 还是红色的图标,对比 Cursor 和 GitHub Copilot 确实不能打。不过没关系,先忍一忍,毕竟当时是免费的claude-3.5,还要啥自行车呢🐶?然后就有了这篇文章《从零到一:用Trae快速搭建uni-app AI对话小程序页面》

当时准备做一个流式响应 AI对话小程序,而刚发布的 TRAE 正好提供了免费的 claude-3.5 模型使用,于是用 TRAE 基于 uni-app 和 wot-starter 制作了这么一个简单的对话 demo。

体验下来能用,但是当时实力对比 cursor 确实有所差距,而且还需要排队,不过毕竟是免费的claude-3.5,还要啥自行车呢,如果它收费我可以说是它有问题,而现在只能说是我不会问🐶。

TRAE SOLO

7 月 21 日,TRAE SOLO 发布,它带来了由AI主导开发,从输入到交付的全链路协同的开发模式,而我也成为了第一批 TRAE SOLO 用户,并且使用 TRAE SOLO 复刻了童年坦克大战,实践了全 AI 辅助开发小程序的提效实践。

复刻童年小游戏

《当年偷偷玩小霸王,现在偷偷用 Trae Solo 复刻坦克大战》一文中,我使用 TRAE SOLO 复刻了童年坦克大战。

凭借此篇文章,我获得了 TRAE 头号玩家活动的二等奖。

同时,文章也被收录到 TRAE 公众号的最佳实践文章。

欲买桂花同载酒,终不似少年游,就算复刻得再像,也无法再回到从前,但 AI 用新的工具链把旧日的快乐从记忆中唤醒,让我们与童年跨越时空的交流,也是一次非常酷的体验!

AI 辅助开发小程序

《TRAE 辅助下的 uni-app 跨端小程序工程化开发实践分享》一文中,我分享了使用 TRAE 作为主要AI编程工具,使用 uni-app 快速上手摸板 wot-starter ,通过 AI 辅助实现了开发效率的显著提升。整体开发时间从传统的 40 人日缩短至 22 人日,效率提升约 45%,根据团队的实际体验,相比传统开发方式,开发体验有了明显改善。

在文中我们也介绍了《如何用 AI 驱动 wot-ui 开发小程序》《wot-ui是如何使用lms.txt 让 AI 更好地理解文档》,这对于我们本次实践的提效是非常重要的,完整明确的外部依赖文档,能让 AI 快速使用我们常用的技术栈。

本篇文章同样也被收录到 TRAE 公众号的最佳实践文章。

开发像老乡鸡那样做饭小程序

去年,「老乡鸡不装了,直接开源」的消息引发了广泛的关注。我也纳闷,老乡鸡不是做菜的吗,开的哪门子源?原来是把他们的菜品、溯源报告这些开源了。然后 GitHub 上这个叫「像老乡鸡那样做饭」的项目火了,如今 star 数量已经达到了 22k,这是它的地址:github.com/Gar-b-age/C…

作为一名爱做饭的程序员,面对如此诱人的开源资源,怎能袖手旁观?于是,在《老乡鸡也开源?我用 TRAE SOLO 做了个像老乡鸡那样做饭小程序!》一文中,我分享了使用 TRAE SOLO 实现小程序前后端及数据整理的全过程。

本篇文章同样也被收录到 TRAE 公众号的最佳实践文章。

过程中保持数据干净、纯净上下文、增量式沟通等三个原则是使我们事半功倍的利器

小结

此阶段TRAE 与 TRAE SOLO 的实力相比初版已经大大增强了,缺点就是内存占据较大、Figma设计图还原度仍然赶不上 cursor,不过已经可以作为主力开发工具来用了。

TRAE SOLO 正式版

11 月 12 日 TRAE SOLO 正式版上线了,新增三栏布局、DiffView 工具、SOLO Coder + Plan,支持多任务并行等功能。随后又上线了 Gemini 3 Pro 模型,可以作为 Claude 模型的平替了。

在这期间,我使用正式版的 TRAE SOLO 完成了「像老乡鸡那样做饭」小程序的开源、手势粒子交互特效的制作、「一只饺子的使命」小游戏的制作和 @wot-ui/router的重构。

「像老乡鸡那样做饭」小程序开源

《TRAE SOLO 正式发布了?我用它将像老乡鸡那样做饭小程序开源了!》一文中,介绍了我们几乎零代码 用 TRAE SOLO 完成了「像老乡鸡那样做饭」小程序的开源与 CI/CD 流程。

这篇文章则获得了 TRAE SOLO 实战赛的第三名。

开发过程中过程中保持数据干净、纯净上下文、增量式沟通等三个原则仍然是使我们事半功倍的利器,这其实和当下很流行的 SDD 规范相符,在这种小型项目中,基于一个优秀的项目模板和良好的需求输入和开发计划,由规范驱动开发,能让项目做的更好、走得更远。

手势粒子交互特效的制作

《Gemini 3做粒子交互特效很出圈?拿 TRAE SOLO 来实现一波!》一文中,轻松实现了《早安午安晚安粒子交互特效》,可以看出 TRAE SOLO 正式版发布之后,功能上的增强还是不错的,还有很多我们本次没用到的功能例如 Plan 模式等也很有用,接入了 Gemini-3-Pro 后大模型的短板基本补上了。

「一只饺子的使命」小游戏

起因是 TRAE 群里有个冬至主题活动,我就准备写个温馨小游戏参与,灵感来自于《一条狗的使命》。游戏讲述了一个拟人化的饺子在冬至这一天,帮助一位因事业受挫而沮丧的年轻人重新振作,最终与家人团聚的温暖故事。我让 TRAE SOLO 整理分镜和AI绘图描述,然后用小香蕉画图,把图片放到项目里,让 TRAE SOLO开始开发。这个其实挺有意思的,可以自己去做这种视觉小说式互动游戏,自己写主线就行了。

游玩地址:jiaozi.wot-ui.cn/

可惜没有获得冬至主题活动的奖品🐶

重构 @wot-ui/router

使用 TRAE SOLO 将 uni-mini-router 重构,并迁移到 @wot-ui/router 了,完善了导航守卫的功能并从rollup迁移到了tsdown。后面计划将 uni-mini-ci 也同样重构并迁移到 @wot-ui/ci。

文档地址:my-uni.wot-ui.cn/

这个过程用 TRAE SOLO,用量有点顶不住了啊,大流程使用 TRAE 国际版开发,模型用 Gemini-3-Pro,小修小补用的国内版的GLM-4.7。最后的重构的结果也还可以,还添加了测试用例,填补了之前的很多缺漏😂。

小结

TRAE SOLO 正式版发布后,我在各种场景都有尝试,编程能力相较之前要强了不少,而且也有 Gemini-3-Pro 这种强力模型,figma 还原的能力也强了不少。

TRAE 年度创作之星

12 月 27 号,获得了 TRAE 2025 年度五大用户奖——年度创作之星,感谢 TRAE。

总结

虽然 AI Coding 取代前端开发的论调层出不穷,出一个新的大模型,前端就会死一次,但是目前 AI Coding 仍然没有替代任何一个职业,“AI 落地的最后一公里”仍未解决,人在 AI Coding 中仍扮演着决策者和指导者的角色。尽管当前 AI Coding 的脉络仍未清晰,但是 AI Coding 未来确实无比值得期待。所以我们要拥抱变化,拥抱未来,拥抱 AI Coding。

2026 AI 在我的工作、生活和学习中已经慢慢变得不可缺少,TRAE在这一年中也逐渐成为我的主力 AI 编程工具。

2026 希望你、我和 AI 编程的未来都同样充满希望。

欢迎评论区沟通、交流👇👇

苹果前全球供应链高管创业,将“十五五”高端碳材料价格打到10万元以内丨36氪首发

2025年12月28日 18:55

作者丨欧雪

编辑丨袁斯来

硬氪获悉,专注于高端碳材料领域的大元硬碳(惠州)新材料科技有限公司(下称“大元硬碳”)已于近期完成近千万元天使轮融资。我们总结了本轮融资信息和该公司几大亮点:

 

融资金额与资金用途

融资轮次:天使轮

融资金额:人民币800万元

投资方:惠州仲恺新兴产业投资有限公司(广东国资背景)

资金用途:主要用于建立一条吨级小试生产线

 

公司基本信息

成立时间:2024年

总部地点:广东省惠州市仲恺高新区

核心定位:一家基于树脂基路线的高性能碳材料供应商,初期聚焦于满足市场对下一代电池的负极材料需求。

核心产品与技术优势:钠离子电池用硬炭负极:实验室样品已实现比容量≥390mAh/g,首效≥93%的技术指标。公司称,该数据远高于当前市场成熟产品(约290-300mAh/g)及目前行业头部企业下一代电池所用材料水平(约320-350mAh/g)。

硅碳负极用多孔炭骨架:掌握用于化学气相沉积(CVD)法硅碳负极的多孔炭骨架制备技术。该材料是高端硅碳负极的核心,目前高端市场被日本企业(如Kuraray等)垄断,价格高达30万元/吨。大元硬碳的目标是在2027年将高性能树脂基多孔炭的售价降至10万元/吨以内,推动高端多孔炭国产化替代与硅碳负极成本下降。

大元硬碳的高端多孔炭(图源/企业)

 

市场体量

目前,传统石墨负极已接近理论极限,硅基负极被视为下一代突破方向,也是“十五五”规划明确的下一代负极材料核心,而高性能、低成本的多孔炭材料正是其规模化应用的关键制约与核心机会。

据行业报相关告预测,国内钠离子电池硬炭负极市场规模有望从2025年的20亿元增至2028年的80亿元;硅碳负极用多孔炭市场规模有望从2025年的2亿元增至77亿元;固态金属电池负极成长空间更大,预计2030年将达250亿元。

 

公司业绩

大元硬碳目前处于小试验证阶段,尚未形成规模化营收。公司当前的重点是通过小试产线向下游头部电池客户送样测试,并借助惠州当地产业资源推进合作。 

 

团队背景

大元硬碳团队拥有“学术研究+产业经验+连续创业”的复合背景优势。公司的技术根基由湖南大学材料学院何月德副教授领衔的碳材料团队奠定。此外,公司创始人之一梁铭曾担任苹果全球供应链负责人,还曾拥有资本市场运作经验及高科技硬件创业经验。大股东楼文渊则拥有华为、万科等500强企业背景及新材料创业经验。联合创始人谭杨梅为惠州本地成功实业家,提供生产基地与产业资源网络。

 

创始人思考

硬氪:您从金融和苹果供应链背景转向新材料创业,是基于怎样的行业判断?

梁铭:我在参与电动垂直起降飞机项目时发现,电动飞机对电池的能量密度、安全性要求远高于汽车,未来商业化落地大批量生产之后,现有锂电池会有很大的瓶颈。调研后发现,下一代电池的突破关键之一在材料端。“十五五”规划也明确将多孔碳列为下一代负极材料核心。

目前高端多孔碳被日本公司垄断,价格达30万元/吨,导致硅碳负极成本过高。我们认为,在碳材料技术和成本上实现突破,才能真正推动硅碳负极的国产化与规模化应用,这也是我们选择从树脂基高端碳材料切入的原因。

硬氪:与日本公司相比,大元硬碳如何实现成本的大幅降低?

梁铭:日本公司虽有技术先发优势,但其国内下游需求市场相对于中国市场太小,而我们可以依托中国成熟的电池产业链,快速规模化降本动力,;第二,国内近二十年国内石墨负极产业已培育出大批优质设备供应商,我们基于国产设备进行定制化开发,大幅减小资本开支;第三,依托高校产研结合,不断提升工艺水平,形成技术优势,可以以更低成本,实现不输于甚至超越海外头部企业技术指标产品的规模化制造能力。

硬氪:除新能源电池外,公司的材料技术未来还有哪些应用场景?

梁铭:树脂基碳材料因其高一致性、可设计性强,应用前景广阔。我们已规划三个方向:航空领域——用于热塑性树脂基碳纤维,可提升飞机结构件制造效率并降低重量;生物医疗——碳材料生物相容性好,可用于人工骨骼、心脏瓣膜等;军工核能——硬炭/石墨复合材料可作为第四代小型化核反应堆的载体及减速剂材料。

当前我们聚焦于锂/钠电池负极材料领域,因为这是最确定、最紧迫的市场,其他领域将随产业发展逐步推进。

 

投资人思考

惠州仲恺创新投资集团有限公司董事长张晓表示:投资大元硬碳是仲恺面向未来加快孕育新质生产力的一步,我们对大元硬碳的技术和商业化前景充满信心,看好大元硬碳迅速发展,成为固态电池硅碳负极用多孔炭及钠离子电池负极领先企业。我们要当好大元硬碳的产业合伙人、资本合伙人,与大元硬碳共同打造高端材料基地,助推仲恺打造新材料产业集群和新型工业化建设。

Node.js 原生实现JSON-RPC及长进程双向通信实践

作者 前端流一
2025年12月28日 18:22

Node.js 原生实现JSON-RPC及长进程双向通信实践

问题

由于负责的业务项目中原来的架构每个操作都 spawn 一个 Python大模型的 进程,执行完就退出。当 Agent 需要人机交互(help_needed)时,进程已经结束了,submitUserResponse 没法把响应传回去。

解决思路

改成长进程,Python 启动后不退出,通过 stdin/stdout 持续通信。

协议设计

用 JSON-RPC 风格,每行一个 JSON。

请求(JS → Python stdin):

{"jsonrpc":"2.0","id":"req_1","method":"get_llm_providers","params":{}}

响应(Python stdout → JS):

[RESPONSE]{"jsonrpc":"2.0","id":"req_1","result":{...}}

事件通知(Python → JS):

[EVENT]{"jsonrpc":"2.0","method":"help_needed","params":{"query":"需要什么帮助?"}}

加前缀 [RESPONSE][EVENT] 是为了和其他日志区分开。

Python 端实现

核心是一个死循环读 stdin:

class DaemonWrapper:
    def __init__(self):
        self.wrapper = BRTWrapper()  # 复用同一个实例,状态保持
        self.running = True
        
    async def read_stdin_line(self):
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, sys.stdin.readline)
        
    async def run(self):
        self.send_event('ready', {'message': '守护进程已就绪'})
        
        while self.running:
            line = await self.read_stdin_line()
            if not line:  # EOF
                break
            request = json.loads(line.strip())
            asyncio.create_task(self.handle_request(request))

关键点:

  1. run_in_executor 把同步的 readline 变成异步,不阻塞事件循环
  2. create_task 处理请求,不阻塞主循环继续读 stdin
  3. BRTWrapper 实例复用,asyncio.Event 等状态都在

JS 端实现

spawn 进程后,监听 stdout 解析响应:

async startDaemon() {
    this.daemonProcess = spawn('/bin/bash', [this.runScript, '--daemon'], {
        stdio: ['pipe', 'pipe', 'pipe']
    })
    
    this.daemonProcess.stdout.on('data', (data) => {
        this._handleDaemonOutput(data.toString())
    })
}

_handleDaemonOutput(data) {
    // 处理跨行数据
    this.inputBuffer += data
    const lines = this.inputBuffer.split('\n')
    this.inputBuffer = lines.pop() || ''
    
    for (const line of lines) {
        if (line.startsWith('[RESPONSE]')) {
            const response = JSON.parse(line.substring(10))
            this._handleDaemonResponse(response)
        } else if (line.startsWith('[EVENT]')) {
            const event = JSON.parse(line.substring(7))
            this._handleDaemonEvent(event)
        }
    }
}

发请求就是往 stdin 写:

async _sendRequest(method, params = {}) {
    const requestId = `req_${++this.requestIdCounter}`
    
    return new Promise((resolve, reject) => {
        this.pendingRequests.set(requestId, { resolve, reject })
        
        const request = { jsonrpc: '2.0', id: requestId, method, params }
        this.daemonProcess.stdin.write(JSON.stringify(request) + '\n')
    })
}

踩坑

1. stdout 数据分片

stdout 的 data 事件不保证按行来,可能一次收到半行,也可能一次收到好几行。必须用 buffer 拼接,按 \n 切分。

2. 长任务阻塞

python的大模型任务跑起来可能几分钟,不能阻塞 stdin 读取,否则 submitUserResponse 发过来收不到。用 create_task 把长任务丢后台。

3. stdin 是同步的

Python 的 sys.stdin.readline() 是同步阻塞的,直接 await 会卡住事件循环。必须用 run_in_executor 扔到线程池。

4. 进程清理

关闭时先尝试发 shutdown 命令优雅退出,超时后 SIGTERM,再不行 SIGKILL。Windows 用 taskkill。

效果

const client = new BundleBRTClient(bundlePath)

// 监听需要帮助事件
client.on('help_needed', async (data) => {
    const answer = await promptUser(data.query)
    await client.submitUserResponse(answer)  // 传回同一个进程
})

await client.startBRTask('...')  // 任务跑在后台
await client.getTaskStatus()          // 复用同一进程
await client.close()                  // 清理

进程启动一次,后续所有调用都是 stdin/stdout 通信,状态保持,多轮对话可以正常工作。

Flutter超大图像导出插件:chunked_widget_to_image插件介绍

作者 BG
2025年12月28日 17:47

前言

哈喽,各位 Flutter 开发者们!今天我要给大家介绍一个非常实用的 Flutter 插件 - chunked_widget_to_image

作为一个经常需要处理图像导出的开发者,你是否也曾为 Flutter 无法处理超大图像导出而苦恼?是否也希望有一个能够突破平台限制的强大图像导出工具?那么今天介绍的这个插件,绝对能让你眼前一亮!

什么?你说你没遇到过超大图像导出需求?那你也应该看看,说不定哪天就用上了呢~

为什么选择 chunked_widget_to_image?

在 Flutter 生态中,虽然有一些截图工具,但对于超大尺寸的图像导出往往力不从心,容易出现内存溢出或平台纹理限制等问题。而 chunked_widget_to_image 则是一个专门为解决这些问题而设计的插件,它采用了创新的分块处理技术,可以轻松应对各种超大图像导出需求。

让我来给你展示一下它的核心优势:

  • 超大图像支持:突破大多数平台的纹理限制(最大支持 16384 像素宽高),支持导出超大尺寸图像
  • 分块处理机制:采用智能分块技术,有效避免内存问题
  • 多格式支持:支持 PNG 和 JPEG 格式导出
  • 灵活渲染模式:支持普通渲染和离屏渲染
  • 预编译静态库:使用预编译静态库替代构建时编译,提供更快的构建时间和一致的行为
  • 高性能原生支持:集成了 libpng 和 libjpeg-turbo 原生库,保证高质量和性能

是不是感觉功能很强大?别急,还有更详细的介绍等着你呢!

工作原理揭秘

分块处理技术

插件的核心在于其独特的分块处理机制。通过将大图像分割成多个较小的块逐一处理,避免了一次性加载整个图像造成的内存压力。在源码中可以看到具体的实现逻辑:

final List<Rect> chunksRect = [];
final double totalHeight = convSize.height;
double dy = 0;
while (dy < totalHeight) {
  final double currentChunkHeight =
  (dy + chunkHeight <= totalHeight)
      ? chunkHeight
      : totalHeight - dy;
  chunksRect.add(Rect.fromLTWH(0, dy,
      convSize.width,
      currentChunkHeight));
  dy += chunkHeight;
}

这种分块策略确保了即使是非常大的图像也能被顺利处理。插件内部会自动计算合适的像素比,以确保图像在各种平台上都能正确导出:

if(size.width*pixelRatio > _kMaxChunkSize||
    size.height*pixelRatio > _kMaxChunkSize){
  pixelRatio = pixelRatio*(_kMaxChunkSize/math.max(size.width,size.height));
}

原生库集成

为了保证图像质量和处理性能,插件深度集成了两个著名的图像处理库:

  • libpng:用于 PNG 格式的编码处理
  • libjpeg-turbo:用于 JPEG 格式的编码处理

这些原生库通过 C/C++ 实现,提供了比纯 Dart 实现更高的性能和更好的图像质量。

C 层实现细节

插件的高性能实现不仅依赖于 Dart 层的分块策略,C 层的实现也至关重要。在 C 层,插件为 PNG 和 JPEG 两种格式分别提供了高效的处理函数。

PNG 格式处理

PNG 格式的处理主要通过 widget_png.c 实现,其核心逻辑包括:

  1. 上下文创建 (create_png_context):初始化 PNG 写入结构,设置图像参数,包括宽度、高度和颜色类型等
  2. 数据写入 (write_png_data):将 RGBA 数据分块写入 PNG 文件,其中使用了 MAX_BLOCK_WIDTH_PX(5120*4 像素)作为最大分块宽度,以确保大图像的稳定处理
  3. 资源清理 (save_png_image):完成 PNG 文件写入并释放相关资源
// PNG 数据写入的核心逻辑
for (int row_idx = 0; row_idx < row_count; row_idx++) {
    memset(row_buf, 0, src_stride);
    // 当前行的原始 RGBA 数据起始地址
    png_bytep curr_row_data = rgba_data + (row_idx * src_stride);
    // 按 5120*4 像素宽度纵向切割当前行,逐块填充
    for (int block_x = 0; block_x < src_stride; block_x += MAX_BLOCK_WIDTH_PX) {
        int curr_block_width_px = (block_x + MAX_BLOCK_WIDTH_PX) > src_stride
                                  ? (src_stride - block_x)
                                  : MAX_BLOCK_WIDTH_PX;
        memcpy(
                row_buf + block_x,    // 目标:行缓冲区的块位置
                curr_row_data + block_x, // 源:原始数据的块位置
                curr_block_width_px            // 拷贝字节数
        );
    }
    png_write_rows(png_ptr, &row_buf, 1);
}

JPEG 格式处理

JPEG 格式的处理通过 widget_jpeg.c 实现,其关键特点包括:

  1. 上下文创建 (create_jpeg_context):初始化 JPEG 压缩结构,设置图像参数和质量(默认 100%,使用最快 DCT 方法)
  2. 数据写入 (write_jpeg_data):将 RGBA 数据转换为 I420 格式后再写入 JPEG,这利用了 JPEG 原生的 YUV 色彩空间优势
  3. 资源清理 (save_jpeg_image):完成 JPEG 文件写入并释放相关资源
// RGBA → I420 转换并写入 JPEG
rgba_to_i420(
        rgba_data,
        src_stride,
        y_plane,
        width,
        u_plane,
        width / 2,
        v_plane,
        width / 2,
        width,
        row_count
);

for (int y = 0; y < row_count; y++) {
    uint8_t* y_row = y_plane + y * width;
    uint8_t* u_row = u_plane + (y / 2) * (width / 2);
    uint8_t* v_row = v_plane + (y / 2) * (width / 2);

    for (int x = 0; x < width; x++) {
        jpeg_row[x * 3 + 0] = y_row[x];
        jpeg_row[x * 3 + 1] = u_row[x >> 1];
        jpeg_row[x * 3 + 2] = v_row[x >> 1];
    }
    jpeg_write_scanlines(cinfo, row_ptr, 1);
    ctx->current_row++;
}

C 层实现还包含错误处理机制,使用 setjmp/longjmp 来捕获和处理 JPEG 和 PNG 编码过程中可能出现的错误,确保插件的稳定性和健壮性。

Isolate 中的图像编码

插件在设计时特别关注了 UI 线程的性能,所有图像编码和保存操作都在独立的 isolate 中执行,避免了主线程 UI 卡顿的问题。在 Dart 层,插件通过 compute 函数将图像写入任务分配到后台 isolate:

static Future<int> executeWriteImage(WriteImageComputeParams params) async {
  return await compute(
    executeWriteImageIsolate,
    params,
  );
}

int executeWriteImageIsolate(WriteImageComputeParams params) {
  try {
    int result = _writeWidgetToImage(params);
    return result;
  } finally {
  }
}

这种设计确保了即使在处理大型图像时,Flutter 应用的 UI 也能保持流畅响应,为用户提供了更好的体验。

安装与配置指南

1. 添加依赖

在你的 pubspec.yaml 文件中添加以下依赖:

dependencies:
  chunked_widget_to_image: ^1.0.0

2. 安装依赖

flutter pub get

3. 配置说明(重要变更)

与早期版本不同,插件现在使用预编译静态库进行图像处理,而不是构建时配置选项。这一重大变更意味着:

  • 更快的构建时间
  • 跨环境的一致行为
  • 简化的构建复杂度
  • 移除了构建时配置选项 (CHUNKED_WIDGET_TO_PNG 和 CHUNKED_WIDGET_TO_JPEG)

这种方法消除了构建时环境变量的需求,并提供更快的构建时间。所有支持的平台都使用预编译的静态库。

快速上手教程

基础用法

// 创建控制器
final controller = WidgetToImageController();

// 在 Widget 树中使用
WidgetToImage(
  controller: controller,
  child: YourWidget(), // 你想要转换为图片的 widget
),

// 导出为图片文件
controller.toImageFile(
  outPath: '/path/to/output.png',
  format: ImageFormat.png,
  callback: (result, message) {
    if (result) {
      print('图片导出成功: $message');
    } else {
      print('图片导出失败: $message');
    }
  },
);

离屏渲染

对于不需要添加到 widget 树中的内容,可以使用离屏渲染功能:

controller.toImageFileFromWidget(
  YourWidget(),
  outPath: '/path/to/output.jpg',
  format: ImageFormat.jpg,
  callback: (result, message) {
    // 处理结果
  },
);

长内容处理

针对长列表或长内容,插件提供了专门的方法:

controller.toImageFileFromLongWidget(
  YourLongWidget(),
  outPath: '/path/to/output.png',
  format: ImageFormat.png,
  callback: (result, message) {
    // 处理结果
  },
);

平台支持情况

插件根据平台使用不同的实现方式:

  • 支持平台 (Android/iOS/macOS/Windows): 使用原生库(libpng, libjpeg-turbo)保证高性能和高质量
  • Linux 平台: 目前暂不支持(在 pubspec.yaml 中已被注释)

重要变更:macOS 平台现在仅支持 ARM64 架构 (Apple Silicon)。此变更简化了分发并确保在现代 macOS 设备上的最佳性能。

实际应用场景

  1. 长图文分享:社交媒体中的长图分享功能
  2. 报表导出:将复杂的数据可视化图表导出为高清图像
  3. 证书生成:动态生成并保存个性化证书
  4. 地图快照:截取大尺寸地图视图为本地文件
  5. 文档预览:将多页文档内容导出为图像序列

性能优化建议

  1. 合理设置分块大小:插件内部会自动计算合适的分块大小,以在内存占用和处理效率间找到平衡点
  2. 选择合适的图像格式:JPEG 适合照片类内容,PNG 适合图形和界面截图
  3. 异步处理:图像导出是耗时操作,插件内部已实现异步处理,务必在后台线程执行
  4. 内存管理:插件内部会自动处理图像内存释放,避免内存泄漏

错误处理

当某个功能在编译时被禁用,而用户试图使用它时:

  • 函数将返回错误码 -1 表示该功能不可用
  • 不会发生崩溃或未定义的行为

总结

chunked_widget_to_image 插件为 Flutter 开发者提供了一个强大且灵活的图像导出解决方案。无论是简单的 widget 截图还是复杂的超大图像处理,它都能胜任。通过巧妙的分块技术和原生库集成,解决了传统截图方式面临的诸多限制。

插件在 1.0.0 版本中进行了重大重构,采用预编译静态库的方式,显著提升了构建效率和跨平台一致性。虽然移除了按需编译的功能,但换来了更快的构建速度和更稳定的运行时表现。

如果你在项目中遇到图像导出相关的需求,不妨试试这款插件。它的设计理念和实现方式也为我们在处理其他大文件或大数据场景时提供了很好的参考思路。

插件目前仍在积极开发中,欢迎大家在 GitHub 上提出 issue 和 PR,共同完善这个实用工具。希望这篇文章能帮助你更好地理解和使用 chunked_widget_to_image 插件!

项目信息

安装方式

flutter pub add chunked_widget_to_image

P.S. 以上文章内容由AI总结.

React难上手原因找到了,原来是因为坑太多了。。。

2025年12月28日 17:36

都说React难上手,那它到底难在哪里?

人们总吐槽React相比Vue上手难度难不少,学Vue的可以立即上手,而React则要个十天半个月才能上手写点东西,而且还容易出错。那么React到底难在哪里呢?

React难就难在开发过程中,太容易遇到坑了,需要小心翼翼避开他们,才可以走的顺。

今天我们就来盘点下React开发过程中常见的一些坑吧。

React第一坑

setState异步化这个是很多文章都会提的一个坑,我们来看看它坑在哪里呢?

import { useState } from "react";
export default function Counter() {
  const [count, setCount] = useState(0);
  const onClick = () => {
    setCount(count + 1);
    // 这里打印count
    console.log(count)
  };
  return (
    <div>
      <button onClick={onClick}>点我</button>
    </div>
  );
}

上面的代码点击一次“点我”按钮的时候,控制台会输出啥?

答案是:0。

sateCount并不是立即执行的,因此设置后立即获取count,获取到的还是原来的值。要获取到第二次的状态,需要等待Counter构造函数的下一次执行。

import { useState } from "react";
export default function Counter() {
  const [count, setCount] = useState(0);
  // 这里打印count
  console.log(count)
  const onClick = () => {
    setCount(count + 1);
  };
  return (
    <div>
      <button onClick={onClick}>点我</button>
    </div>
  );
}

也就是想上面的代码一样,点击“点我”按钮后,控制台将输出1。

React第二坑

多次执行setCount,就像执行了一次。我们先看一个代码:

import { useState } from "react";
export default function Counter() {
  const [count, setCount] = useState(0);
  const onClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };
  return (
    <div>
      <button onClick={onClick}>点我</button>
      当前计数:<span>{count}</span>
    </div>
  );
}

大家看看,当点击“点我”按钮时,当前计数显示的是多少?有人可能会说是:3。

那就错了,答案其实是1。这跟我们的预期有点不一致,这就是它的第二个坑。那如果要达到预期,我们应该怎么改呢?

import { useState } from "react";
export default function Counter() {
  const [count, setCount] = useState(0);
  console.log(count);
  const onClick = () => {
    // 将count改成一个函数(count)=> count + 1
    setCount((count) => count + 1);
    setCount((count) => count + 1);
    setCount((count) => count + 1);
  };
  return (
    <div>
      <button onClick={onClick}>点我</button>
      当前计数:<span>{count}</span>
    </div>
  );
}

有一个奇怪的地方是,我们点击按钮时虽然调用了三次setCount,但是控制台只打印了最后一次的count结果,这是为啥呢?其实是因为setCount是设置后,组件的渲染通过任务队列比如Promise.then或者setTimeout的方式去异步更新的,在队列里面只是判断是否更新过state属性,更新过了才会重新渲染组件,因此多次修改属性后,组件仅仅只会有一次更新,而不是每次修改状态都更新。因此就算像下面的代码,也会导致组件重新渲染:

import { useState } from "react";
export default function Counter() {
  const [count, setCount] = useState(0);
  console.log(count);
  const onClick = () => {
    // 先+1后-1,相当于没改
    setCount((count) => count + 1);
    setCount((count) => count - 1);
  };
  return (
    <div>
      <button onClick={onClick}>点我</button>
      当前计数:<span>{count}</span>
    </div>
  );
}

React第三坑

先看代码

import { useEffect, useState } from "react";
export default function HoverSwitch() {
  const [isOn, setIsOne] = useState(false);
  const label = isOn ? "关闭" : "开启";
  const onClick = () => {
    setIsOne(!isOn);
  };

  useEffect(() => {
    const onMouseMove = () => {
      if (isOn) {
        console.log("move");
      }
    };
    document.addEventListener("mousemove", onMouseMove);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
    };
  }, []);
  return (
    <div>
      <button onClick={onClick}>{label}</button>
    </div>
  );
}

点击“开启”按钮后,在页面上面移动鼠标,控制台是否会打印“move”,答案明显是不会。那究竟是为什么呢?

原因是useEffect函数没有依赖任何state,其回调函数仅仅只会调用一次,虽然该回调函数保存了isOn这个闭包,但是因为isOn是一个基本数据类型,当组件重新渲染时,此isOn已经不是原来的isOn,而useEffect函数保留的还是原来的isOn,所以其值一直都是false,故不会打印“move”。

那要如何做呢?有两个修改方案,一是useEffect的依赖列表上加上isOn;二是isOn改成useRef,即引用类型。我更推荐方案二,因为不用重新执行回调函数,资源消耗更少。

import { useEffect, useState, useRef } from "react";
export default function HoverSwitch() {
  const isOnRef = useRef(false);
  const [label, setLabel] = useState("开启");
  const onClick = () => {
    isOnRef.current = !isOnRef.current;
    setLabel(isOnRef.current ? "关闭" : "开启");
  };

  useEffect(() => {
    const onMouseMove = () => {
      if (isOnRef.current) {
        console.log("move");
      }
    };
    document.addEventListener("mousemove", onMouseMove);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
    };
  }, []);
  return (
    <div>
      <button onClick={onClick}>{label}</button>
    </div>
  );
}

但是useRef也使用的限制,一是修改ref的值不会触发重新渲染;二是ref的值在渲染期间不可读取。

import { useRef } from "react";
export default function HoverSwitch() {
  const labelRef = useRef('开启');
  // 不能组件的构造函数内容调用labelRef.current,包括下面的div里面
  return (
    <div>
      <button onClick={onClick}>{labelRef.current}</button>
    </div>
  );
}

以上就是我在使用React的过程中总结的一些坑,大家尽量避一避。

SSE+broadcastChannel

作者 Focus_
2025年12月28日 17:32

多标签页实时消息同步方案:SSE + BroadcastChannel 完美解决!

你是否遇到过这样的问题: 用户同时打开多个标签页,每个标签页都建立了独立的 WebSocket 或 SSE 连接,导致服务器压力大、消息重复推送、资源浪费?本文将分享一个优雅的解决方案,通过 SSE + BroadcastChannel 的组合,实现单连接、多标签页实时消息同步,既节省资源又提升用户体验。

适用场景

推荐使用

  • 实时消息推送:系统通知、用户消息、业务提醒等
  • 数据同步:多标签页状态同步、购物车同步、表单数据同步
  • 任务状态更新:后台任务进度、数据处理状态、导出任务完成通知
  • 系统公告:全局消息广播、系统维护通知、版本更新提示

实际案例

在我们的 BI 系统中,该方案成功应用于:

  • 消息中心:实时推送系统消息和业务通知
  • 任务管理:后台数据处理任务的状态更新和完成通知(如素材批量上传任务)
  • 国际化同步:多语言配置的实时更新

不推荐使用

  • 高频双向通信:如实时聊天、游戏等,建议使用 WebSocket
  • 大量数据传输:如文件传输、大数据同步,建议使用 HTTP 轮询或分页
  • 跨域通信:需要使用 postMessage 或其他跨域方案

前言

如果想快速参考实现可直接跳转到:目录-实现方案👇

初衷

在现代 Web 应用中,实时消息推送、任务状态更新等是常见的需求。然而,当用户同时打开多个标签页时,如何确保消息能够正确同步到所有标签页,同时避免重复连接和资源浪费,是一个值得深入探讨的技术问题。

本文基于实际项目经验,分享如何通过 SSE(Server-Sent Events)BroadcastChannel API 的组合方案,实现高效的多标签页实时消息同步。该方案不仅解决了单标签页消息推送的问题,还优雅地处理了多标签页场景下的连接管理和消息分发。

通过本文,我们将探讨:

  • 如何设计多标签页消息同步架构
  • SSE 和 BroadcastChannel 的实战应用
  • 连接管理和错误恢复的最佳实践
  • 性能优化和用户体验提升技巧

适合人群

  • 需要实现实时消息推送功能的前端开发者
  • 希望优化多标签页应用性能的工程师
  • 对 SSE 和 BroadcastChannel API 感兴趣的技术爱好者
  • 正在寻找 WebSocket 替代方案的开发者

问题背景

多标签页消息同步的挑战

在实际业务场景中,我们经常遇到以下问题:

场景一:用户打开多个标签页

当用户同时打开多个标签页访问同一个应用时,如果每个标签页都建立独立的 SSE 连接,会导致:

  • 服务器资源浪费(多个长连接)
  • 消息重复推送(每个标签页都收到相同消息)
  • 用户体验不一致(不同标签页消息状态不同步)

若系统采用 HTTP 1.0/1.1 协议,用户每打开一个页面就会建立一个长连接;当打开的标签页数量超过 6 个时,受浏览器并发连接数限制,第七个及之后的标签页将无法正常加载,出现卡顿。

场景二:标签页关闭与重连

当某个标签页关闭时,如果该标签页持有唯一的 SSE 连接,其他标签页将无法继续接收消息。需要:

  • 检测连接断开
  • 自动在其他标签页重新建立连接
  • 保证消息不丢失

场景三:消息去重与状态同步

多个标签页需要:

  • 避免重复显示相同的消息通知
  • 保持消息已读/未读状态同步
  • 统一更新 UI 状态(如未读消息数)

传统方案的局限性

方案 优点 缺点
纯 SSE 实现简单,浏览器原生支持 多标签页会建立多个连接,资源浪费
纯 WebSocket 双向通信,功能强大 实现复杂,需要心跳检测,多标签页问题同样存在
LocalStorage 事件 跨标签页通信简单 只能传递字符串,性能较差,不适合频繁通信
SharedWorker 真正的单例连接 兼容性一般,调试困难

技术选型

为什么选择 SSE

SSE(Server-Sent Events) 是 HTML5 标准中的一种服务器推送技术,具有以下优势:

  1. 简单易用:基于 HTTP 协议,无需额外协议升级
  2. 自动重连:浏览器原生支持断线重连机制
  3. 单向推送:适合服务器主动推送消息的场景
  4. 文本友好:天然支持文本数据,JSON 解析方便
// SSE 基本使用
const eventSource = new EventSource('/api/sse');
eventSource.onmessage = (event) => {
  console.log('收到消息:', event.data);
};

为什么选择 BroadcastChannel

BroadcastChannel API 是 HTML5 提供的跨标签页通信方案:

  1. 同源通信:同一域名下的所有标签页可以通信
  2. 简单高效:API 简洁,性能优秀
  3. 类型支持:支持传输对象、数组等复杂数据类型
  4. 事件驱动:基于事件机制,易于集成
// BroadcastChannel 基本使用
const channel = new BroadcastChannel('my-channel');
channel.postMessage({ type: 'MESSAGE', data: 'Hello' });
channel.onmessage = (event) => {
  console.log('收到广播:', event.data);
};

组合方案的优势

将 SSE 和 BroadcastChannel 结合,可以实现:

  • 单连接管理:只有一个标签页建立 SSE 连接
  • 消息广播:SSE 接收的消息通过 BroadcastChannel 同步到所有标签页
  • 连接恢复:标签页关闭时,其他标签页自动接管连接
  • 状态同步:所有标签页的消息状态保持一致

实现方案

整体架构设计

sequenceDiagram
    participant Server as 服务器端
    participant TabA as 标签页 A<br/>(主连接)
    participant BC as BroadcastChannel
    participant TabB as 标签页 B<br/>(从连接)

    Note over TabA: 初始化阶段
    TabA->>TabA: 检查是否有 SSE 连接
    alt 无连接
        TabA->>Server: 建立 SSE 连接
        Server-->>TabA: 连接成功
    end

    Note over Server,TabB: 消息接收阶段
    Server->>TabA: 推送消息 (SSE)
    TabA->>TabA: 处理消息<br/>(更新状态、显示通知)
    TabA->>BC: 广播消息
    BC->>TabB: 同步消息
    TabB->>TabB: 处理消息<br/>(更新状态、显示通知)

    Note over TabA,TabB: 连接管理阶段
    TabA->>TabA: 标签页关闭
    TabA->>BC: 发送关闭信号
    BC->>TabB: 通知连接关闭
    TabB->>TabB: 关闭旧连接
    TabB->>Server: 重新建立 SSE 连接
    Server-->>TabB: 连接成功

核心流程

  1. 初始化阶段

    • 应用启动时,检查是否已有 SSE 连接
    • 如果没有,当前标签页建立 SSE 连接
    • 如果有,直接使用现有连接
  2. 消息接收阶段

    • SSE 连接接收到服务器推送的消息
    • 当前标签页处理消息(显示通知、更新状态)
    • 通过 BroadcastChannel 广播消息到其他标签页
    • 其他标签页接收广播,同步处理消息
  3. 连接管理阶段

    • 标签页关闭时,发送关闭信号到 BroadcastChannel
    • 其他标签页监听到关闭信号,关闭旧连接
    • 重新建立 SSE 连接,确保消息不中断

核心实现

1. SSE 连接封装

首先,我们需要封装一个支持重连和错误处理的 SSE 连接工具:

import { EventSourcePolyfill } from 'event-source-polyfill';
import util from '@/libs/util';
import Setting from "@/setting";

const MAX_RETRY_COUNT = 3;
const RETRY_DELAY = 3000;

const create = (url, payload) => {
  let retryCount = 0;

  const connect = () => {
    const token = util.cookies.get("token")
    if(!token){
      return
    }

    const eventSource = new EventSourcePolyfill(
      `${Setting.request.apiBaseURL}${url}`,
      {
        headers: {
          token: util.cookies.get("token"),
          pageUrl: window.location.pathname,
          userId: util.cookies.get("userId"),
        },
        heartbeatTimeout: 28800000, // 8小时心跳超时
      }
    );

    eventSource.addEventListener("open", function (e) {
      console.log('SSE连接成功');
      retryCount = 0; // 重置重试次数
    });

    eventSource.addEventListener("error", function (err) {
      console.error('SSE连接错误:', err);

      if (retryCount < MAX_RETRY_COUNT) {
        retryCount++;
        console.log(`尝试重新连接 (${retryCount}/${MAX_RETRY_COUNT})...`);
        setTimeout(() => {
          eventSource.close();
          connect();
        }, RETRY_DELAY);
      } else {
        console.error('SSE连接失败,已达到最大重试次数');
        eventSource.close();
      }
    });

    return eventSource;
  };

  return connect();
}

export default {
  create
}

关键点解析

  • 使用 EventSourcePolyfill 支持自定义 headers(原生 EventSource 不支持)
  • 实现自动重连机制,最多重试 3 次
  • 设置心跳超时时间,防止长时间无响应导致连接假死
  • 在 headers 中传递 token 和页面信息,便于服务端识别和路由

2. BroadcastChannel 封装

创建一个简洁的 BroadcastChannel 工具类:

export const createBroadcastChannel = (channelName: string) => {
  const channel = new BroadcastChannel(channelName);
  return {
    channel,
    sendMessage(data: any) {
      channel.postMessage(data);
    },
    receiveMessage(callback: (data: any) => void) {
      channel.onmessage = (event) => {
        callback(event.data);
      };
    },
    closeChannel() {
      channel.close();
    },
  };
};

设计说明

  • 封装成工厂函数,便于创建多个通道(消息通道、连接管理通道)
  • 提供简洁的 API:发送消息、接收消息、关闭通道
  • 支持传递任意类型数据(对象、数组等)

3. SSE 连接管理

实现单例模式的 SSE 连接管理:

import sseRequest from "@/plugins/request/sse";
import store from "@/store";

export const fetchSSE = (payload?: { [key: string]: string }) => {
  const eventSource = sseRequest.create("/sse/connect", {
    ...payload
  });
  return eventSource;
};

export const initSSEEvent = async () => {
  console.log('sse-init');
  // 检查是否已经有实例在其他标签页中创建
  let eventSource = (store.state as any).admin.request.sseEvent;

  if (!eventSource) {
    // 如果没有实例,则创建一个新的
    eventSource = fetchSSE();
    // 存储到 Vuex 中
    store.commit('admin/request/SET_SSE_EVENT', eventSource);
  }

  return eventSource;
};

核心逻辑

  • 通过 Vuex 全局状态管理 SSE 连接实例
  • 实现单例模式:如果已有连接,直接复用
  • 避免多个标签页同时建立连接

4. 消息处理与广播

实现消息接收、处理和跨标签页同步:

import { createBroadcastChannel } from "@/libs/broadcastChannel";

// 创建消息广播通道
const { sendMessage, receiveMessage } =
  createBroadcastChannel("message-channel");

export const pushWatchAndShowNotifications = async (): Promise<any> => {
  // 获取 SSE 连接实例
  const eventSource = (store.state as any).admin.request.sseEvent;
  if (!eventSource) {
    return;
  }

  // 监听服务器推送的消息
  eventSource.addEventListener("MESSAGE", function (e) {
    const fmtData = JSON.parse(e.data);

    // 1. 广播消息到其他标签页
    sendMessage(fmtData);

    // 2. 当前标签页处理消息
    handleIncomingMessage(fmtData);
  });

  // 监听用户任务推送
  eventSource.addEventListener("USER_TASK", function (e) {
    const fmtData = JSON.parse(e.data);

    // 广播任务消息到其他标签页
    sendMessage({ type: "USER_TASK", data: fmtData });

    // 当前标签页处理任务消息
    handleIncomingUserTask(fmtData);
  });

  // 监听其他标签页广播的消息
  receiveMessage((data) => {
    if (data.type === "USER_TASK") {
      handleIncomingUserTask(data.data);
    } else {
      handleIncomingMessage(data);
    }
  });

  return eventSource;
};

function handleIncomingMessage(fmtData: any) {
  const productId = (store.state as any).admin.user.info?.curProduct;
  const productData = fmtData[productId];
  if (!productData) {
    return;
  }

  const { noReadCount, popupList } = productData;
  // 更新未读消息数
  store.commit("admin/layout/setUnreadMessage", noReadCount);

  // 显示消息通知
  if (popupList.length > 0) {
    popupList.forEach((message, index) => {
      showNotification(message, index);
    });
  }
}

处理流程

  1. SSE 接收到消息后,立即通过 BroadcastChannel 广播
  2. 当前标签页处理消息(更新状态、显示通知)
  3. 其他标签页通过 BroadcastChannel 接收消息,同步处理
  4. 确保所有标签页状态一致

5. 连接恢复机制

实现标签页关闭时的连接恢复:

import { createBroadcastChannel } from '@/libs/broadcastChannel';

// 创建连接管理通道
const { sendMessage, receiveMessage } =
  createBroadcastChannel('sse-close-channel');

export default defineComponent({
  methods: {
    handleCloseMessage() {
      const sseEvent = (store.state as any).admin.request.sseEvent
      if (sseEvent) {
        sseEvent.close()
        store.commit('admin/request/CLEAR_SSE_EVENT');
      }
    },
    handleSSEClosed() {
      // 监听其他标签页关闭 SSE 连接的消息
      receiveMessage((data) => {
        if (data === 'sse-closed') {
          console.log('SSE connection closed in another tab. Re-establishing connection.');
          // 关闭旧连接
          this.handleCloseMessage()
          // 重新建立连接
          initSSEEvent();
          this.handleGetMessage()
          this.handleGetUserTasks()
        }
      });
    }
  },
  mounted() {
    // 页面卸载时,关闭 SSE 连接并通知其他标签页
    on(window, 'beforeunload', () => {
      const eventSource = (store.state as any).admin.request.sseEvent;
      if (eventSource) {
        eventSource.close();
        store.commit('admin/request/CLEAR_SSE_EVENT');
      }
      // 广播关闭消息
      sendMessage('sse-closed');
    });

    // 初始化 SSE 连接
    const token = (store.state as any).admin.user.info?.curProduct
      || util.cookies.get("token");
    if (token && !(store.state as any).admin.request.sseEvent) {
      initSSEEvent();
      pushWatchAndShowNotifications();
    }

    // 监听其他标签页的连接关闭事件
    this.handleSSEClosed();
  },
  beforeUnmount() {
    this.handleCloseMessage()
  }
})

恢复机制

  1. 标签页关闭时,发送 sse-closed 消息到 BroadcastChannel
  2. 其他标签页监听到消息,关闭旧连接并清理状态
  3. 重新初始化 SSE 连接和相关监听
  4. 确保至少有一个标签页保持连接

6. 状态管理

在 Vuex 中管理 SSE 连接状态:

export default {
  namespaced: true,
  state: {
    sseEvent: null  // SSE 连接实例
  },
  mutations: {
    // 设置 SSE 事件
    SET_SSE_EVENT(state, payload) {
      state.sseEvent = payload
    },
    // 清除 SSE 事件
    CLEAR_SSE_EVENT(state) {
      state.sseEvent = null
    }
  }
}

方案总结

方案优势

  1. 资源优化

    • 多个标签页共享一个 SSE 连接,减少服务器压力
    • 降低网络带宽消耗
    • 减少客户端内存占用
  2. 用户体验提升

    • 所有标签页消息状态实时同步
    • 避免重复通知,减少干扰
    • 连接自动恢复,消息不丢失
  3. 实现简洁

    • 基于浏览器原生 API,无需额外依赖
    • 代码结构清晰,易于维护
    • 兼容性好,现代浏览器全面支持
  4. 扩展性强

    • 可以轻松添加新的消息类型
    • 支持多个 BroadcastChannel 通道
    • 便于集成到现有项目

局限性及注意事项

  1. 浏览器兼容性

    • BroadcastChannel 不支持 IE 和部分旧版浏览器
    • 需要提供降级方案(如 LocalStorage 事件)
  2. 同源限制

    • BroadcastChannel 只能在同源页面间通信
    • 跨域场景需要使用其他方案(如 postMessage)
  3. 连接管理

    • 需要妥善处理标签页关闭和刷新场景
    • 避免内存泄漏(及时清理事件监听)
  4. 错误处理

    • SSE 连接断开时需要重连机制
    • 网络异常时的降级策略

最佳实践建议

  1. 连接管理

    // 建议:使用单例模式管理连接
    // 建议:在应用入口统一初始化
    // 建议:页面卸载时清理资源
    
  2. 消息去重

    // 建议:为消息添加唯一 ID
    // 建议:使用 Set 或 Map 记录已处理消息
    // 建议:设置消息过期时间
    
  3. 性能优化

    // 建议:限制 BroadcastChannel 消息大小
    // 建议:使用防抖处理频繁消息
    // 建议:批量处理消息更新
    
  4. 错误恢复

    // 建议:实现指数退避重连策略
    // 建议:添加连接状态监控
    // 建议:提供手动重连功能
    

技术对比总结

特性 SSE + BroadcastChannel WebSocket 轮询
实现复杂度 ⭐⭐ 简单 ⭐⭐⭐⭐ 复杂 ⭐ 很简单
服务器压力 ⭐⭐ 低(单连接) ⭐⭐⭐ 中等 ⭐⭐⭐⭐ 高
实时性 ⭐⭐⭐⭐ 优秀 ⭐⭐⭐⭐⭐ 极佳 ⭐⭐ 一般
多标签页支持 ⭐⭐⭐⭐⭐ 完美 ⭐⭐ 需额外处理 ⭐⭐⭐ 一般
浏览器兼容 ⭐⭐⭐⭐ 良好 ⭐⭐⭐⭐ 良好 ⭐⭐⭐⭐⭐ 完美

未来优化方向

  1. 连接池管理:支持多个 SSE 连接,按业务类型分离
  2. 消息队列:离线消息缓存和重放机制
  3. 性能监控:连接质量监控和自动优化
  4. 降级方案:兼容旧浏览器的替代实现

参考文档


结语

SSE + BroadcastChannel 的组合方案为多标签页实时消息同步提供了一个优雅的解决方案。该方案在保证功能完整性的同时,兼顾了性能和用户体验。希望本文能够帮助你在实际项目中更好地应用这些技术。

写在最后

如果你在实际项目中应用了这个方案,欢迎分享你的经验和遇到的问题。如果你有更好的想法或优化建议,也欢迎在评论区交流讨论。

如果这篇文章对你有帮助,请点个赞支持一下,让更多开发者看到这个方案!

Elips-Core:轻量级 Node.js Web 框架核心实现

2025年12月28日 17:23

概述

Elips-Core 是一个基于 Koa.js 构建的轻量级 Web 应用框架核心模块。它通过约定优于配置的设计理念,提供了一套完整的自动化加载机制,帮助开发者快速构建结构清晰、易于维护的 Node.js 应用程序。

核心设计理念

Elips-Core 采用了现代 Web 框架的典型设计模式:

  1. 约定优于配置:通过预定义的目录结构和命名规范,自动加载应用组件
  2. 模块化架构:将应用功能划分为独立的模块(Controller、Service、Middleware 等)
  3. 分层设计:清晰的业务逻辑分层,提升代码可维护性
  4. 自动化加载:通过 Loader 机制自动扫描并注册应用组件

架构组成

1. 核心入口 (index.js)

框架的启动入口,负责整个应用的初始化流程:

// 创建 Koa 实例
const app = new Koa()

// 初始化应用配置
app.baseDir = process.cwd()          // 项目根目录
app.bussinessPath = './app'           // 业务代码目录
app.env = env()                       // 环境配置

启动流程

  1. 创建 Koa 应用实例
  2. 设置基础路径和环境配置
  3. 依次执行各个 Loader 加载应用组件
  4. 注册全局中间件
  5. 启动 HTTP 服务

2. 环境配置管理 (env.js)

提供统一的环境判断接口,支持多环境部署:

  • isLocal() - 判断是否为本地开发环境
  • isBeta() - 判断是否为测试环境
  • isProd() - 判断是否为生产环境
  • get() - 获取当前环境名称

环境配置通过 process.env._ENV 环境变量控制,默认为 local

3. 配置加载器 (loader/config.js)

实现多环境配置管理策略:

配置文件结构

config/
  ├── config.default.js  # 默认配置(所有环境共享)
  ├── config.local.js    # 本地开发配置
  ├── config.beta.js     # 测试环境配置
  └── config.prod.js     # 生产环境配置

加载策略

  1. 首先加载 config.default.js 作为基础配置
  2. 根据当前环境加载对应的环境配置文件
  3. 环境配置覆盖默认配置,合并后挂载到 app.config

这种设计允许开发者将通用配置集中管理,同时为不同环境提供定制化配置。

4. 中间件加载器 (loader/middleware.js)

自动扫描并加载自定义中间件:

目录结构示例

app/middleware/
  ├── auth/
  │   └── jwt-auth.js
  └── logger/
      └── access-logger.js

访问方式

app.middlewares.auth.jwtAuth      // jwt-auth.js
app.middlewares.logger.accessLogger  // access-logger.js

特性

  • 支持多级目录组织
  • 自动将连字符命名转换为驼峰式(kebab-case → camelCase)
  • 中间件函数接收 app 实例作为参数

5. Controller 加载器 (loader/controller.js)

负责加载所有业务控制器:

工作流程

  1. 扫描 app/controller 目录下的所有 .js 文件
  2. 根据文件路径创建层级命名空间
  3. 实例化 Controller 类并挂载到 app.controller

示例

app/controller/
  └── user/
      └── user-info.js  → app.controller.user.userInfo

Controller 通常实现具体的业务逻辑处理,接收请求并返回响应。

6. Service 加载器 (loader/service.js)

加载业务逻辑服务层:

设计目的

  • 将复杂的业务逻辑从 Controller 中分离
  • 提供可复用的业务功能模块
  • 便于单元测试和维护

加载机制: 与 Controller 加载器类似,自动扫描 app/service 目录,支持多级目录结构,挂载到 app.service

7. 路由加载器 (loader/router.js)

统一管理应用路由:

核心功能

  1. 创建 KoaRouter 实例
  2. 扫描 app/router 目录下的所有路由文件
  3. 执行路由文件,传入 approuter 实例
  4. 添加兜底路由(404 处理)
  5. 将路由注册到 Koa 应用

兜底机制

router.get('*', async (ctx, next) => {
  ctx.status = 302
  ctx.redirect(app?.options?.homePage ?? '/')
})

对于未匹配的路由,自动重定向到首页,提升用户体验。

8. 路由验证加载器 (loader/router-schema.js)

集成 JSON Schema 和 AJV 进行 API 参数校验:

功能说明

  • 加载 app/router-schema 目录下的所有 schema 定义
  • 将所有 schema 合并到 app.routerSchema 对象
  • 配合中间件实现自动化参数验证

应用场景

// router-schema/user.js
module.exports = {
  '/api/user/create': {
    type: 'object',
    properties: {
      username: { type: 'string' },
      email: { type: 'string', format: 'email' }
    },
    required: ['username', 'email']
  }
}

通过声明式的方式定义 API 接口约束,提高接口的健壮性。

9. 扩展功能加载器 (loader/extend.js)

提供框架扩展能力:

特点

  • 扫描 app/extend 目录下的扩展模块
  • 将扩展直接挂载到 app 对象上(而非挂载到 app.extend
  • 支持防冲突检查,避免覆盖已有属性

使用示例

// app/extend/helper.js
module.exports = (app) => {
  return {
    formatDate(date) {
      // 日期格式化逻辑
    }
  }
}

// 使用
app.helper.formatDate(new Date())

这种设计允许开发者根据业务需求扩展框架功能,而无需修改核心代码。

加载顺序设计

框架的加载顺序经过精心设计,确保依赖关系正确:

1. middlewareLoader     # 加载中间件(供后续使用)
2. routerSchemaLoader   # 加载路由验证规则
3. controllerLoader     # 加载控制器
4. serviceLoader        # 加载服务层
5. configLoader         # 加载配置文件
6. extendLoader         # 加载扩展功能
7. 全局中间件注册       # 注册全局中间件
8. routerLoader         # 最后加载路由(依赖上述所有组件)

这种顺序确保了:

  • Controller 可以使用 Service
  • Router 可以使用 Controller 和 Middleware
  • 所有组件都可以访问 Config

命名约定

框架统一采用以下命名转换规则:

  • 文件命名kebab-case(短横线分隔)
    • 例如:user-info.jsauth-middleware.js
  • 代码访问camelCase(驼峰命名)
    • 例如:app.controller.userInfoapp.middlewares.authMiddleware

转换逻辑

// 正则替换:将 -x 或 _x 转换为大写 X
name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase())

这种约定使得文件命名符合 Unix 风格,而代码访问符合 JavaScript 规范。

技术亮点

1. 自动化加载

通过 glob 模块实现文件系统扫描,自动发现和加载应用组件,减少手动配置工作。

2. 跨平台兼容

使用 path.sep 处理路径分隔符,确保代码在 Windows、Linux、macOS 等不同操作系统上正常运行。

const { sep } = path  // Windows: '\' , Unix: '/'
const middlewarePath = path.resolve(app.bussinessPath, `.${sep}middleware`)

3. 容错处理

在关键加载环节添加了异常捕获,提供友好的错误提示:

try {
  defaultConfig = require(path.resolve(configPath, './config.default.js'))
} catch (e) {
  console.log('[exception] failed to load default.config file:', e.message)
  console.log('Error details:', e.stack)
}

即使某些配置文件不存在,应用也能继续启动。

4. 灵活可扩展

通过 Loader 机制和 Extend 功能,框架提供了良好的扩展性,开发者可以:

  • 自定义中间件
  • 扩展框架功能
  • 定制业务组件

应用场景

Elips-Core 适用于以下场景:

  1. 快速原型开发:约定式的目录结构加快开发速度
  2. 中小型 Web 应用:轻量级设计,性能开销小
  3. API 服务:内置路由验证和分层架构,适合构建 RESTful API
  4. 学习框架设计:代码简洁清晰,是学习 Node.js 框架设计的良好范例

与主流框架对比

vs Egg.js

Elips-Core 的设计理念与阿里的 Egg.js 相似,都采用:

  • 约定优于配置
  • Loader 加载机制
  • 多环境配置管理

但 Elips-Core 更加轻量,适合小型项目或学习使用。

vs Koa

相比原生 Koa,Elips-Core 提供了:

  • 完整的项目结构规范
  • 自动化组件加载
  • 开箱即用的分层架构

降低了项目初始化和规范制定的成本。

最佳实践建议

  1. 遵循目录约定:按照框架规定的目录结构组织代码
  2. 合理分层
    • Controller 处理请求响应
    • Service 封装业务逻辑
    • Middleware 处理通用逻辑
  3. 善用配置管理:将环境相关的配置抽离到配置文件
  4. 使用路由验证:通过 JSON Schema 确保 API 输入合法性
  5. 扩展而非修改:通过 Extend 机制扩展功能,避免修改核心代码

总结

Elips-Core 是一个设计精巧的轻量级 Web 框架核心,它通过自动化的 Loader 机制和约定式的目录结构,大幅简化了 Koa 应用的开发流程。其核心优势在于:

  • 零配置启动:遵循约定即可自动加载组件
  • 清晰的分层架构:Controller-Service-Middleware 模式
  • 多环境支持:灵活的配置管理机制
  • 良好的扩展性:Loader 和 Extend 机制
  • 跨平台兼容:代码可在不同操作系统运行

对于 Node.js 开发者而言,Elips-Core 既可以作为生产工具快速搭建应用,也可以作为学习资料深入理解框架设计模式。它展示了如何通过简洁的代码实现强大的功能,体现了"简约而不简单"的工程哲学。

参考资源


本文基于 Elips-Core 框架源码分析撰写,适用于了解 Node.js Web 框架设计原理的开发者。

零基础也能懂!React Hooks实战手册:useState/useEffect上手就会,告别类组件

2025年12月28日 17:03

React16.8 之前,函数组件只能作为「无状态组件」存在,所有需要状态管理、生命周期处理的业务需求,都只能依赖类组件实现。而 React16.8 推出 Hooks 特性后,彻底颠覆了函数组件的能力边界,让函数组件可以优雅的实现状态、生命周期、副作用管理等所有功能,也让函数组件成为 React 官方主推、企业开发的主流选型。

本文将极简回顾类组件核心用法,重点精讲 React Hooks 的使用、原理和实战技巧,吃透这篇,彻底掌握 React16.8+ 的主流开发方式。

一、React16.8 之前:类组件一统天下

在 Hooks 出现之前,想要开发带「状态」的 React 组件,类组件是唯一选择,核心能力只有两个:状态管理 + 生命周期钩子。

✅ 类组件 核心2大能力

1. 组件状态管理

  • 初始化状态:通过 this.state = { } 定义组件自身的响应式状态(状态:修改后会触发组件重新渲染的变量)

  • 更新状态:this.setState() 必须通过 方法修改状态,禁止直接赋值修改 this.state,调用后会触发组件重新渲染

import React, { Component } from 'react'
// 类组件核心示例(含状态管理+生命周期)
class App extends Component {
  constructor() {
    super()
    // 初始化响应式状态
    this.state = { count: 0 }
  }

  // 组件首次加载完成后执行(初始化请求/定时器常用)
  componentDidMount() {
    console.log('组件加载完毕');
  }

  // 状态更新后执行
  componentDidUpdate() {
    console.log('组件更新完毕');
  }

  // 状态更新方法:必须通过setState修改状态
  add() {
    this.setState({ count: this.state.count + 1 })
  }

  render() {
    return (
      <div>
        <h2>{this.state.count}</h2>
        {/* 绑定事件需通过bind确保this指向 */}
        <button onClick={this.add.bind(this)}>add</button>
      </div>
    )
  }
}
export default App

2. 类组件核心生命周期(3个必用)

类组件通过固定的生命周期钩子函数,处理组件「挂载、更新、卸载」三个核心阶段的业务逻辑,也是类组件处理副作用(请求数据、定时器、事件监听)的唯一方式:

  1. componentDidMount:组件初次挂载完成后执行一次 → 常用:发起异步请求、绑定事件监听、开启定时器

  2. componentDidUpdate:组件每次状态更新渲染完成后执行 → 常用:根据状态变化更新DOM、发起关联请求

  3. componentWillUnmount:组件即将卸载销毁前执行一次 → 常用:清除定时器、解绑事件监听、取消请求,做收尾清理工作


二、React16.8+ 新时代:函数组件 + Hooks 封神(全文重点)

React 团队推出 Hooks 的核心目的:为函数组件赋能,让原本「无状态、无生命周期」的函数组件,拥有和类组件同等的能力,同时解决类组件 this 指向混乱、生命周期逻辑分散、复用状态逻辑繁琐的痛点。

✨ 核心结论

  1. React17+ 项目开发,优先使用【函数组件 + Hooks】 是绝对主流

  2. Hooks 翻译为「钩子」,本质是:为函数组件提供的一系列内置函数,让函数组件拥有状态、生命周期、副作用管理能力

  3. 核心核心2个基础钩子:useState(状态管理) + useEffect(生命周期+副作用管理),掌握这2个,就能完成90%的业务开发!

三、核心Hook ①:useState — 函数组件的「状态管理神器」

✅ 作用

函数组件定义响应式状态,完美替代类组件的 this.state + this.setState,是函数组件能拥有「自身状态」的核心。

✅ 语法格式

import { useState } from 'react' // 必须手动导入

// 语法:const [状态变量, 状态更新函数] = useState(初始值)
const [count, setCount] = useState(0)
const [list, setList] = useState([])
const [userInfo, setUserInfo] = useState({name: '张三', age: 20})
  • 数组解构赋值,变量名可自定义,语义化命名即可

  • 第一个参数:状态变量,直接使用即可,无需 this

  • 第二个参数:状态更新函数,调用该函数修改状态,会触发组件重新渲染

  • 括号内:状态初始值,可以是任意类型(数字、数组、对象、null/undefined)

✅ 基础使用(替代类组件状态)

import { useState } from 'react'

export function Counter() {
  // 定义状态:计数初始值为0
  const [count, setCount] = useState(0)

  // 修改状态:调用setCount,直接更新,无this、无绑定
  const add = () => {
    setCount(count + 1)
  }

  return (
    <div>
      <h2>计数:{count}</h2>
      <button onClick={add}>add</button>//点击加一
    </div>
  )
}

ScreenShot_2025-12-28_165205_759.png

✅ 进阶用法

1. 复杂状态初始化(函数式初始化)

如果状态的初始值需要复杂计算、异步获取、耗时操作,直接写值会导致组件每次渲染都执行该计算,性能浪费。此时传入一个函数,该函数只会在组件初次挂载时执行一次,返回初始值。

// 推荐:复杂初始化用函数,只执行一次
const [name, setName] = useState(() => {
  return '张三' // 可写任意复杂逻辑
})

2. 依赖原状态更新(函数式更新)

当新状态的取值依赖于上一次的旧状态时,推荐给更新函数传入一个回调函数,回调函数的参数就是「最新的旧状态」,确保拿到的状态值永远是最新的,避免异步更新导致的取值错误。

const add = () => {
  // 函数式更新:prevCount 是最新的旧状态
  setCount(prevCount => prevCount + 1)
}
// 数组/对象同理
const [list, setList] = useState([])
const pushItem = () => {
  setList(prevList => [...prevList, '新数据'])
}

✅ useState 核心优势

  1. 一个组件中可以多次调用,定义多个独立状态,互不影响,状态管理更灵活

  2. 无需处理 this 指向问题,类组件的 this.setState 经常需要绑定 this,Hooks 完全规避

  3. 写法极简,无冗余模板代码,代码量比类组件减少50%以上


四、核心Hook ②:useEffect — 函数组件的「生命周期+副作用万能钩子」

✅ 核心定义

useEffect 是函数组件中处理 副作用 的唯一入口,同时完美替代类组件的3个核心生命周期,是函数组件的重中之重。

副作用:指和组件渲染无关的操作,比如:异步请求、开启/清除定时器、绑定/解绑事件监听、操作DOM、本地存储等。

✅ 核心语法

import { useEffect, useState } from 'react' // 配套导入

useEffect(() => {
  // 【核心执行体】:需要执行的副作用逻辑
  console.log('执行副作用')
  
  // 【清理函数】:可选,return 一个函数,组件卸载时执行
  return () => {
    console.log('组件卸载,执行清理工作')
  }
}, [依赖项数组]) // 核心:通过依赖项控制执行时机

✅ 关键规则(重中之重,必背)

useEffect 的执行时机,完全由第二个参数【依赖项数组】决定,这也是和类组件生命周期一一对应的核心,4种核心用法覆盖所有业务场景,对应类组件的生命周期精准无差:

1. 无依赖项数组 → 等价于 类组件 componentDidMount + componentDidUpdate


useEffect(() => {
  console.log('组件初次加载执行 + 每次渲染更新后都执行')
})

组件初次挂载完成后执行一次,之后每次组件重新渲染(任意状态变化)都会再次执行,适合需要「每次更新都同步执行」的逻辑。

2. 空依赖项数组 [] → 等价于 类组件 componentDidMount

useEffect(() => {
  console.log('只在组件【初次挂载】时执行一次,永久不重复执行')
  // 【最常用场景】:发起初始化异步请求、开启定时器、绑定全局事件
}, [])

✅ 核心高频用法:组件加载完成后只请求一次接口,React开发中80%的接口请求都用这种写法!

useEffect(() => {
  // 初始化请求数据
  fetch('接口地址')
    .then(res => res.json())
    .then(data => {
      console.log('请求成功', data)
    })
}, [])

3. 依赖项数组带指定变量 [x,y] → 等价于 类组件 componentDidUpdate


const [count, setCount] = useState(0)
// 依赖 count 变量
useEffect(() => {
  console.log('组件初次加载执行一次 + 每次 count 变化时执行一次')
  // 场景:根据某个状态的变化,做关联逻辑(比如:搜索关键词变化重新请求接口)
}, [count])

精准监听指定状态,只有依赖的变量发生改变时,才会执行副作用逻辑,是性能优化的核心写法,也是类组件 componentDidUpdate 的精准平替。

4. useEffect 返回清理函数 → 等价于 类组件 componentWillUnmount

useEffect(() => {
  // 挂载时:开启定时器
  const timer = setInterval(() => {
    setCount(prev => prev + 1)
  }, 1000)
  
  // 卸载时:执行return的清理函数 → 清除定时器
  return () => {
    clearInterval(timer)
  }
}, [])

✅ 核心规则:return 的回调函数,会在组件即将卸载销毁前 执行一次,专门用来做「清理工作」,比如清除定时器、解绑事件监听、取消未完成的请求,和类组件 componentWillUnmount 功能完全一致,且写法更优雅,副作用和清理逻辑写在同一个地方,不会遗漏。


五、类组件 VS 函数组件+Hooks 实战对比(核心精华)

✅ 核心结论:写法对比,差距一目了然

同样实现「计数器+定时器」功能,类组件,函数组件+Hooks,代码量、可读性、简洁度 完胜!

类组件实现(繁琐、冗余、有this问题)

import React, { Component } from 'react'
class App extends Component {
  state = { count: 0 }
  timer = null

  componentDidMount() {
    // 挂载开启定时器
    this.timer = setInterval(() => {
      this.setState({ count: this.state.count + 1 })
    }, 1000)
  }

  componentWillUnmount() {
    // 卸载清除定时器
    clearInterval(this.timer)
  }

  render() {
    return <h2>{this.state.count}</h2>
  }
}

函数组件+Hooks实现(极简、优雅、无冗余)

import { useState, useEffect } from 'react'
export function App() {
  const [count, setCount] = useState(0)
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1)
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  return <h2>{count}</h2>
}

✅ Hooks 核心优势(为什么成为主流?)

  1. this 彻底抛弃 关键字:类组件的最大痛点就是this 指向混乱,需要 bind、箭头函数等方式处理,Hooks 完全规避,代码更干净。

  2. 逻辑更聚合:类组件中,一个业务的「初始化+清理」逻辑会分散在 componentDidMountcomponentWillUnmount 两个生命周期中,Hooks 中写在同一个 useEffect 里,逻辑闭环,可读性拉满。

  3. 无冗余模板代码:无需写 classconstructorrender,一个函数搞定所有逻辑,代码量直接减少一半以上。

  4. 状态复用更简单:Hooks 可以轻松抽离公共逻辑(比如请求封装、表单处理),实现跨组件复用,类组件的复用需要高阶组件/HOC,写法繁琐。

  5. 官方主推方向:React 团队明确表示,未来的新特性都会优先支持 Hooks,类组件不再新增特性,仅做兼容维护。


六、必避的2个 Hooks 常见坑(新手必看)

坑1:直接修改 useState 的状态值,不调用更新函数

// ❌ 错误:直接修改数组/对象,不会触发组件重新渲染
const [list, setList] = useState([])
const add = () => {
  list.push('test')
}

// ✅ 正确:必须调用更新函数,返回新的状态值
const add = () => {
  setList([...list, 'test'])
}

核心原理:React 的状态是「不可变的」,只有通过更新函数返回新值,React 才能检测到状态变化,触发渲染。

坑2:useEffect 依赖项数组「漏写/错写」

// ❌ 错误:使用了count变量,但依赖项数组中没写,导致拿到的count永远是初始值
useEffect(() => {
  setInterval(() => {
    setCount(count + 1)
  }, 1000)
}, [])

// ✅ 正确:要么补全依赖项,要么用函数式更新
useEffect(() => {
  setInterval(() => {
    setCount(prev => prev + 1)
  }, 1000)
}, [])

七、总结(精炼核心,直击重点)

1. 版本分界清晰

  • React16.8 前:类组件是唯一能写「完整功能」的组件,靠this.state/setState 管理状态,靠3个生命周期处理副作用。

  • React16.8+:函数组件 + Hooks 成为绝对主流,类组件能做的,函数组件都能做,且做得更好。

2. 核心2个Hook,搞定所有开发需求

  1. useState:给函数组件加「状态」,替代类组件的 this.state + setState,无 this 烦恼,写法极简。

  2. useEffect:给函数组件加「生命周期+副作用」,一个钩子顶类组件3个生命周期,逻辑聚合,是开发核心。

3. 终极选型建议

  • 新项目开发:无脑用 函数组件 + Hooks,这是React的未来,也是企业招聘的主流要求。

  • 维护老项目:类组件的知识足够看懂即可,无需新增类组件代码。

  • 核心心法:Hooks的本质是「为函数组件赋能」,让函数组件拥有类组件的所有能力,同时解决类组件的痛点。 ✨

从样式到结构:TailwindCss + Fragment 如何让 React 代码更干净、更高效

作者 xhxxx
2025年12月28日 16:34

🚀 重塑 React 开发体验:用 Tailwind CSS 拒绝 Bad Styles,用 Fragment 拥抱纯净结构

在 React 项目开发中,有两个看似微小却影响深远的细节,能显著提升代码质量与开发体验:一是用 Tailwind CSS 替代传统样式写法,彻底告别“Bad Styles”;二是用 Fragment 替代无意义的包裹 div,让组件结构更干净。本文将从实践出发,分享这两项技术的核心价值与使用方式。


一、样式的演进:从 Bad Styles 到原子化

传统 CSS 的困境

早期开发中,我们习惯为每个 UI 元素编写专属类名:

<h1 class="home-page-title">欢迎</h1>
<p class="home-page-desc">这是首页描述</p>
.home-page-title { font-size: 2rem; font-weight: bold; }
.home-page-desc { font-size: 1rem; color: #666; margin-top: 0.5rem; }

这种写法的问题在于:

  • 无法复用:换个页面就得重新命名、重写样式;
  • 命名成本高:类名越来越长,如 user-profile-card-header-title
  • 维护困难:设计调整需全局搜索替换。

这就是典型的 “Bad Styles” ——样式与业务强耦合,牺牲了可维护性。

面向对象 CSS(OOCSS)的改进

OOCSS 提出:将样式拆解为基础单元,通过组合构建 UI。例如:

<h1 class="text-2xl font-bold">欢迎</h1>
<p class="text-base text-gray-600 mt-2">这是首页描述</p>

这种方式提升了复用性,但开发者仍需手动定义 .text-2xl.mt-2 等原子类,且难以保证团队一致性。

Tailwind CSS:开箱即用的原子化方案

Tailwind CSS 将 OOCSS 理念产品化——它预置了数千个精心设计的原子类,覆盖布局、颜色、间距、响应式、交互状态等所有场景。你无需写任何自定义 CSS,直接在 JSX 中组合即可。于是JSX + TailWindCss就构成了UI界面

在 Vite 项目中快速集成

配置过程极为简单:

  1. 安装依赖:

    npm install tailwindcss @tailwindcss/vite
    
  2. 配置 Vite 插件(vite.config.js):

import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
 plugins: [
   tailwindcss(),
 ],
})
  1. 导入Tailwind CSS:
  @import "tailwindcss";

完成以上三步,即可在组件中直接使用:

const ArticleCard =()=>{
  // JSX + tailwindcss(UI的一部分) =UI
  return(
    <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition">
      <h2 className="text-lg font-bold">TailWindCss</h2>
      <p className="text-gray-500 mt-2">
        用utlity class 快速构建UI
      </p>
    </div>
  )
}
export default function App() {
  return (
  <>
    <h1>111</h1>
    <h2>222</h2>
    <button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">提交</button>
    <button className="px-4 py-2 bg-gray-300 text-black rounded-md hover:bg-gray-400">默认</button>
    <ArticleCard/>
  </>
  )
}

image.png


TailWindCss的设计理解

细心的你可能注意到:<h1><h2> 标签失去了浏览器默认的大字体和粗体样式。
这是因为 Tailwind 采用 “无样式重置”(preflight)策略——从零开始构建 UI,确保所有样式显式可控。

这不是缺陷,而是优势:它强制你主动思考每一个视觉表现,避免隐式依赖浏览器默认样式。

为什么 Tailwind CSS 如此高效?

在理解其用法后,我们来总结它“好用”的本质原因:

  1. 根治 Bad Styles
    原子类天然解耦样式与组件,杜绝重复和命名焦虑。
  2. 开发效率飞跃
    所有样式在 JSX 中完成,无需切换文件;配合编辑器插件,智能提示+可视化预览让编码如丝般顺滑。
  3. 强制设计一致性
    所有值(颜色、间距等)来自统一配置,确保全站 UI 风格一致,新人也能快速产出规范代码。
  4. 生产体积极小
    构建时自动移除未使用的类,最终 CSS 通常仅几十 KB。
  5. 交互与响应式开箱即用
    hover:focus:md: 等前缀让复杂状态和响应式布局变得直观易写。

三、Fragment:消除无意义的 DOM 包裹

最初的 Fragment 是为了性能优化而来。

在前端开发中,频繁操作真实 DOM 是性能的大敌。早在 React 出现之前,浏览器就提供了 DocumentFragment 来解决这个问题。看这段代码:

const container =document.querySelector('.container');
const p1 =document.createElement('p');
p1.textContent ='1111';
const p2 =document.createElement('p');
p2.textContent ='2222';
// 为了性能优化而来
const fragment =document.createDocumentFragment();
//在内存中操作
fragment.appendChild(p1);
fragment.appendChild(p2);
container.appendChild(fragment);

如果没有 fragment,我们只能一个一个地挂载 DOM 元素:

container.appendChild(p1); // 触发一次重排
container.appendChild(p2); // 再触发一次重排

每一次 appendChild 都可能迫使浏览器重新计算布局(reflow)和重绘(repaint)。当插入多个元素时,这种开销会迅速累积,导致页面卡顿。

而通过 DocumentFragment,我们可以先把所有元素在内存中组装好——这个过程不触碰真实 DOM,速度极快、开销极小。最后一次性将整个片段挂载到页面,只触发一次渲染流程

fragment 就像一辆出租车:它把 p1p2 等 DOM 元素接上车,在内存中完成调度,然后送到 container 门口。任务完成后,它自己悄然离开,不会出现在 DOM 树中

React 的 Fragment 正是继承了这一理念。

过去,由于 React 要求组件必须返回单一根元素,我们常被迫写:

return (
  <div> {/* 这个 div 没有语义 */}
    <h1>标题</h1>
    <p>内容</p>
    <button>操作</button>
  </div>
);

这个额外的 <div> 会:

  • 增加无用 DOM 节点;
  • 破坏 HTML 语义(如在 <ul> 内部插入 div 会破坏列表结构);
  • 可能干扰 CSS 布局(如 flex 或 grid 容器的直接子项)。

Fragment 正是为解决此问题而生。它允许你分组多个元素,不渲染任何额外节点

return (
  <>
    <h1>标题</h1>
    <p>内容</p>
    <button>操作</button>
  </>
);

这不仅是结构上的简洁,更是对性能初心的回归——就像原生 DocumentFragment 一样,React Fragment 让我们在组合元素的同时,避免不必要的 DOM 开销

Fragment 的价值在于

  • 保持 DOM 纯净:输出结构与意图完全一致;
  • 提升性能:减少无意义的节点创建与 diff;
  • 保障语义正确:尤其在表格、列表等对子元素有严格要求的场景中至关重要。

结语:简洁即力量,显式即可靠

在现代前端工程中,代码的优雅不仅在于功能实现,更在于表达方式。Tailwind CSS 与 React Fragment 正是两个看似微小、实则深刻的“杠杆点”——它们分别从 样式系统组件结构 两个维度,推动我们走向更高品质的开发实践。

  • Tailwind CSS 不是“写更多 class”,而是“写更少、更确定的样式”
    它以原子化设计终结命名焦虑,以显式声明取代隐式依赖,让 UI 开发变得可预测、可复用、可规模化。
  • Fragment 不是“省一个 div”,而是对 DOM 语义与性能的尊重
    它让我们摆脱为框架妥协的冗余包裹,输出真正符合 HTML 规范、布局意图清晰的结构。

这两项实践,共同指向一个核心理念:好的代码,应当忠于意图,而非迁就工具

将它们融入你的日常开发,不仅是技术选型的优化,更是工程思维的升级——从此,告别 Bad Styles,告别无意义嵌套,写出既高效又优雅的 React 应用。

「✍️JS原子笔记 」深入理解JS数据类型检测的4种核心方式

作者 Maxkim
2025年12月28日 16:32

在JavaScript开发中,数据类型检测是基础且高频的需求,准确判断数据类型能帮助我们规避诸多潜在bug。Maxkim在这里为大家整理了4种核心的数据类型检测方式,结合其原理、特点及适用场景展开解析。

一、typeof:最基础的检测方式

typeof是JS中最常用的基础数据类型检测运算符,使用语法简单:typeof 要检测的值,其返回值是一个表示数据类型的字符串(如"string"、"number"、"boolean"等)。

核心特点:

1. 优势

对基本数据类型(string、number、boolean、undefined、symbol、bigint)的检测结果准确可靠;对function类型也能正确识别,返回"function"。

2. 局限性

存在明显的“盲点”——数组(Array)、普通对象(Object)、null都会被判断为"object",无法区分这三者。

示例

image.png

二、instanceof:基于原型链的引用类型检测

instanceof 用于检测某个对象是否为指定构造函数的实例,其核心原理是:判断该构造函数的prototype属性是否存在于被检测对象的原型链上。使用语法:对象 instanceof 构造函数,返回值为布尔值(true/false)。

核心特点

1. 优势

能准确区分引用数据类型,比如可以明确判断数组(Array)、对象(Object)、函数(Function)等引用类型的具体类型。

2. 局限性

  • 无法检测基本数据类型:因为instanceof检测的是原型链关系,而基本数据类型是值类型,并非对象,不存在原型链。例如 "hello" instanceof String 会返回false(除非是通过new String("hello")创建的包装对象)。
  • 受原型链修改影响:如果手动修改了对象的原型,可能导致instanceof的检测结果不准确。

示例

image.png

三、constructor:通过构造函数判断类型

每个对象实例都有一个constructor属性(继承自原型),该属性有两个核心作用:一是指向创建该实例的构造函数;二是借助这一特性实现数据类型的判断。使用语法:要检测的值.constructor === 构造函数,返回布尔值。

核心特点

1. 优势

相比typeof,能准确区分数组、对象等引用类型;相比instanceof,不仅能检测引用类型,部分基本数据类型(除undefined、null外)也能通过其包装对象的constructor检测。

示例

image.png

2. 局限性

当手动修改对象的原型时,constructor属性可能会被覆盖或改变,此时就无法准确判断数据类型了。

示例

image.png

四、Object.prototype.toString.call():最精准的“万能检测法”

这是JS中最精准、最通用的数据类型检测方式,核心原理是调用Object原型上未被重写的toString方法,该方法会返回一个格式为"[object 数据类型]"的字符串,从而准确判断数据类型。使用语法:Object.prototype.toString.call(要检测的值)

关键疑问:为什么不直接用obj.toString()?先搞懂2个基础概念

先拆2个关键概念,懂了就好理解了:

1. 什么是“重写toString方法”? toString是Object自带的原型方法(可以理解为Object给所有后代对象留的一个“基础功能”),但数组(Array)、函数(Function)这些Object的“后代”,都自己改了这个功能——比如数组的toString改成了“返回数组里的元素拼成的字符串”(比如[1,2].toString()会得到"1,2"),函数的toString改成了“返回函数的代码”,这就是“重写”。

2. 什么是this?这里不用复杂理解! this在这里就指“当前调用方法的那个对象”。比如obj.toString(),这里的this就是obj;如果是Object.prototype.toString(),默认this是Object.prototype本身,不是我们要检测的obj。

现在解释核心问题: 如果直接写obj.toString(),因为obj(比如数组、函数)已经重写了toString,所以执行的是“改过后的功能”,没法得到“数据类型”; 而Object.prototype.toString.call(要检测的值),作用是“强行让要检测的值(比如obj)去执行Object原型上那个没被改的toString方法”——call的作用就是“换this”,把toString方法里的this换成我们要检测的那个值,这样这个原始的toString方法就能识别出this(也就是要检测的值)的真实类型,返回“[object 数据类型]”了。

举个通俗例子: 把Object原型的toString比作“官方身份识别器”,原本只能识别自己人;数组、函数这些“后代”把自己的“身份识别器”改成了“展示自己内容的工具”。 直接用obj.toString(),就是让obj用自己改后的工具,看不到身份; 用call就是“把官方识别器借来,对着obj扫一下”,就能得到真实身份了。

核心特点

1. 优势

检测结果最精准,能区分所有JS数据类型,包括null、undefined、数组、对象、函数、日期等。

示例

image.png


Object.prototype.toString.call(null) → "[object Null]"(正确)
Object.prototype.toString.call(undefined) → "[object Undefined]"(正确)
Object.prototype.toString.call([]) → "[object Array]"(正确)
Object.prototype.toString.call({}) → "[object Object]"(正确)
Object.prototype.toString.call(new Date()) → "[object Date]"(正确)

2. 局限性

语法相对繁琐,需要完整书写调用链,但这一点可以通过封装函数解决(如封装一个getType函数)。

总结:4种检测方式的适用场景

  • 快速检测基本数据类型(排除null):使用typeof,简洁高效。
  • 检测引用类型是否为某个构造函数的实例:使用instanceof,适合原型链相关的类型判断。
  • 简单场景下的引用类型区分:可使用constructor,但需注意原型是否被修改。
  • 需要精准区分所有数据类型(包括null、数组、特殊对象等):优先使用Object.prototype.toString.call(),这是最可靠的方案。

以上就是JS数据类型检测的4种核心方式,掌握它们的原理与差异,能让我们在开发中根据实际需求选择最合适的检测方案,提升代码的健壮性。

前端已死?我用 Trae + Gemini 零代码手搓 3D 塔罗牌,找到了新出路

作者 芋圆ai
2025年12月28日 15:11

🔮 全流程 AI 手搓:我只动了动嘴,AI 就帮我复活了古老的塔罗牌

前言

还记得我 3 个月前分享的那篇《用 AI 写八字算命 UI》吗?

那时候,AI 虽然能写出像样的界面,但更多时候还是疯狂的调试,很多复杂的动画逻辑和样式细节,还得我自己上手微调。

短短 90 天过去,我被 AI 进化速度震撼了。

这一次,我决定挑战一个地狱级难度:完全不写一行核心代码,指挥 AI 开发一款高颜值的 3D 塔罗牌应用。

结果?Lumina Tarot 诞生了。它不仅有丝滑的 3D 交互,还能像真人占卜师一样“读心”。今天就来复盘一下,在这 3 个月里 AI 编程到底发生了什么质变。

😲 效果展示:这真的是 AI 写的?

先看成品。当我把这些需求扔给 AI 时,我其实没抱太大希望,但它交出的答卷让我这个 AI 博主都惊了。

image.png

图注:图片感觉不到这种交互感,真的很美,大家可以去自己试试

image.png

图注:这个排版和审美我只能说我真的绝了

image.png

图注:每个地方都有很好的交互,在卡牌上有倾斜的动画和金粉,然后后面的背景也是有交互的

image.png


🤖 过程揭秘:我负责想象,AI 负责实现

在这个项目中,我的角色从“程序员”变成了“指挥官”。我不再纠结 div 怎么居中,而是专注于审美逻辑

1️⃣ 挑战 AI 的审美上限:纯代码绘制 SVG 牌背

我跟 AI 说:

"我不要网上的素材图,我要你用 SVG 代码画一个‘暗黑天体’风格的牌背,要有金色线条和神秘符号。"

AI 思考了 5 秒钟,然后吐出了几百行 SVG 代码。结果就是你们看到的这个——细节拉满,放大 10 倍也不失真。 以前设计师要画半天的图,AI 几秒钟就“算”出来了。

image.png

图注:这复杂的几何纹理,竟然全是 AI 一行行代码敲出来的。

2️⃣ 挑战 AI 的逻辑能力:3D 翻牌动画

我也不懂什么 CSS 3D 变换矩阵。我只是告诉 AI:

"我希望卡牌能像真实物体一样,鼠标滑过要有倾斜感,翻开时要有重力感。"

AI 自动帮我写好了 perspectiverotate3d 甚至还贴心地加上了动态光影遮罩。 我只负责提需求,难点全交给它。 不出一会就完美的完成,bug居然都没什么

image.png

3️⃣ 全栈闭环:Supabase + Vercel 一键上云

不仅是前端,AI 把后端的活儿也包圆了。

  • 数据库:它帮我设计了 Supabase 的表结构和 Row Level Security 策略。
  • 部署:代码推送到 GitHub,Vercel 自动构建。

image.png以前需要一个团队干的事儿,现在我 + AI + Serverless 就搞定了。


🌌 AI 的美学:微交互中的“呼吸感”

很多人觉得 AI 只能写逻辑,不懂审美。大错特错。 在 Lumina Tarot 中,我特意测试了 AI 对“氛围感”的理解。

我没有给出具体的参数,只是用自然语言描述感觉:

  • "让光效像呼吸一样自然"
  • "让翻牌的瞬间有一种沉甸甸的仪式感"

结果,AI 不仅帮我写了 transition,还自己加上了 cubic-bezier 贝塞尔曲线来模拟物理惯性。甚至在鼠标移动时,它自动补全了视差滚动 (Parallax Effect) 的逻辑。 这种对“美”的通感,才是 AI 最让我细思极恐的地方。


😈 魔鬼细节:那些 AI 替我填的坑

除了大框架,真正让我觉得“AI 能处”的,是它对细节的补全。这些点我甚至都没提,是它自己想到的:

  1. 移动端适配:我只测了 PC 端,AI 却自动给手机端加上了触摸滑动事件,防止在手机上无法翻牌。
  2. 图片预加载:为了防止翻开牌时图片还在加载(白屏),AI 自动插入了 new Image() 预加载逻辑。
  3. 防误触:在洗牌动画结束前,它贴心地禁用了点击事件,防止用户乱点导致动画穿模。
  4. 无障碍支持:所有的 SVG 和按钮,它都顺手加上了 aria-label

它比我更像一个有经验的高级前端工程师。


🛠️ 工具复盘:为什么我选择了 Trae?

这次开发我全程使用的是 Trae 编辑器。说实话,作为阅“软”无数的 AI 博主,Trae 的进步真的让我惊喜。

以前我可能会用 Claude Code,但最近它越来越贵,而且在某些立场问题上(你懂的)让人无法接受。作为一个中国开发者,我更愿意选择尊重我们的工具。

Trae 在接入了最新的 Gemini 3 模型后,能力直接起飞。特别是它对 MCP (Model Context Protocol) 的深度集成,让 AI 对项目上下文的理解达到了一个新的高度。 在写这个塔罗牌项目时,Trae 的开发效率完全不比 Claude Code 差,甚至在处理复杂的中文逻辑时更胜一筹。

🔥 便宜、强大、还尊重用户,还要什么自行车?

image.png

图注:这就是我的“开发现场”,全是自然语言对话。


💭 最后的思考:在算法的浪潮里,寻找人类的坐标

三个月前,当我还在为那个八字算命 UI 逐行调试 CSS 时,我内心其实充满焦虑:“如果 AI 编程进化得这么快,我们这些程序员存在的意义是什么?”

今天,看着 Lumina Tarot 在屏幕上流转的光影,我找到了答案。

AI 确实拿走了“搬砖”的瓦刀,但它交还给我们的,是“设计”的权杖。 它让我们不再受困于 Syntax Error 和繁琐的配置,从而能腾出手来,去思考那些更本质的问题: “什么是好的体验?” “如何用交互传递情感?” “如何在这个焦虑的时代,给用户一点点慰藉?”

技术门槛的消失,并不意味着创造力的贬值,反而是创造力的解放。 当代码不再是壁垒,审美、共情能力、对人性的洞察,将成为我们在这个 AI 时代唯一的、也是最坚固的护城河。

Lumina Tarot 的代码也许 99% 都是 AI 生成的,但那个决定“要用星空背景来安抚人心”的念头,属于我,属于人类。

别让代码限制了你的想象力,更别让对 AI 的恐惧禁锢了你的可能性。 去创造吧,就像从未受过伤一样。 🌟


亿晶光电:全椒县光伏项目无法按期推进,收到听证通知

2025年12月28日 17:42
36氪获悉,亿晶光电公告,公司此前拟在安徽省滁州市全椒县投资建设年产10GW光伏电池、10GW光伏切片及10GW光伏组件项目。由于光伏行业近几年出现阶段性结构型产能错配,行情疲软,全行业产能开工率持续下滑,公司滁州项目只完成一期光伏电池项目中7.5GW产能的落地,电池剩余产能及二、三期光伏切片和光伏组件项目未有建设。同时,受行业和市场影响,公司于2024年10月开始,将滁州基地陆续停产。全椒县经济开发区管委会认为公司未能全面履行前期相关协议的约定,导致项目无法按期推进,前期协议无法履行,因而向公司发出听证通知,拟解除投资协议及补充协议、追回1.4亿元出资款、不再履行后期出资义务,并追究公司偿还代建费用、租金及资金占用成本等违约责任。目前,听证程序尚未开展,听证及最终行政决定结果尚不确定,本次听证对公司本期利润或期后利润产生的影响尚不确定。

贵州茅台:尽最大努力防止价格炒作

2025年12月28日 17:31
12月28日,在贵州茅台酒全国经销商联谊会上,茅台集团党委书记、董事长陈华谈到了社会普遍关心的茅台价格问题。他提到,要价格合理稳预期,价格市场化改革目的是要尊重市场经济规律和消费者的选择,让产品价格随行就市。随行就市的根本目的,是要根据市场供需实际,努力促进量价平衡。价格过高或者过低,都容易引起市场波动。当产品存销比适当的时候,价格就是比较合理的,价格合适了,专卖店就能成为消费者的“第一选择”。必须想尽一切办法,尽最大努力防止价格炒作,这既是对广大消费者负责,也是对茅台自己负责。(每日经济新闻)

2连板泰尔股份:内外部经营环境未发生重大变化,不存在应披露而未披露的重大事项

2025年12月28日 17:21
36氪获悉,泰尔股份公告,公司股票于2025年12月25日、12月26日连续两个交易日内收盘价格涨幅偏离值累计超过20%,属于股票交易异常波动情况。经核实,公司前期所披露的信息不存在需要更改、补充之处,未发现近期公共媒体报道了可能或已经对公司股票交易价格产生较大影响的未公开重大信息,公司已披露的经营情况、内外部经营环境未发生重大变化,公司、控股股东和实际控制人不存在关于公司的应披露而未披露的重大事项,或处于筹划阶段的重大事项。控股股东、实际控制人在股票异常波动期间不存在买卖公司股票的情形。

9天6板浙江世宝:公司经营正常,无应披露而未披露的重大事项

2025年12月28日 17:02
36氪获悉,浙江世宝公告,公司A股股票连续三个交易日收盘价格涨幅偏离值累计达到+20%以上。经核实,公司前期披露的信息无需要更正补充之处,公司经营正常,内外部经营环境未发生重大变化。公司、控股股东和实控人不存在应披露而未披露的重大事项或筹划阶段的重大事项。控股股东、实控人在股票交易异常波动期间未买卖公司股票。公司不存在违反信息公平披露的情形。
❌
❌