普通视图

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

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 编程的未来都同样充满希望。

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

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 的恐惧禁锢了你的可能性。 去创造吧,就像从未受过伤一样。 🌟


基于 Nuxt 4 + Strapi 5 构建高性能 AI 导航站

作者 知航驿站
2025年12月28日 16:07

摘要:在 AI 工具爆发的今天,如何快速构建一个既具备内容深度(CMS 管理),又有极致交互体验(SSR + 现代化 UI)的导航平台?本文将复盘 Creator AI Hub 的从零开发历程,深度解析 Monorepo 架构设计、Strapi v5 无头 CMS 的灵活应用以及 Nuxt 4 的全栈实践。


🚀 引言:为什么选择这套技术栈?

在构建 Creator AI Hub 时,我们面临几个核心需求:

  1. 内容为王:需要频繁更新 AI 工具、解决方案和 SOP,且内容结构复杂(包含会员专属字段)。
  2. SEO 友好:作为导航站,搜索引擎优化至关重要,服务端渲染 (SSR) 是必选项。
  3. 开发效率:前后端需紧密配合,但又要保持独立部署和维护的灵活性。

基于此,我们采用了 TurboRepo + Nuxt 4 + Strapi 5 的黄金组合。这不仅是一次技术选型的胜利,更是对现代 Web 开发最佳实践的一次完整探索。


image.png

image.png

1. 系统架构概览 (System Architecture)

本项目采用 Monorepo 策略管理全栈代码,基于 TurboRepo 构建高效的工作流。前端采用 Nuxt 4 进行服务端渲染 (SSR),后端使用 Strapi v5 作为 Headless CMS 提供 RESTful API,数据存储在 MySQL 中。

1.1 技术栈 (Tech Stack)

领域 技术选型 版本 说明
Monorepo TurboRepo ^2.7.2 高性能构建系统,统一管理 apps
Package Manager pnpm 9.0.0 高效的磁盘空间利用与依赖管理
Frontend Nuxt ^4.2.2 基于 Vue 3 的全栈框架,负责 SSR 与交互
Styling Tailwind CSS ^3.4 原子化 CSS 框架,通过 @nuxtjs/tailwindcss 集成
Backend Strapi 5.33.0 灵活的 Headless CMS,提供 API 服务
Database MySQL 8.0 关系型数据库,通过 mysql2 驱动连接
Docs VitePress Latest 静态文档生成器

1.2 目录结构 (Directory Structure)

.
├── apps/
│   ├── api/          # Strapi 后端应用 (Port: 1337)
│   │   ├── src/api/  # 业务逻辑 (Content Types, Controllers, Services)
│   │   └── config/   # 数据库与插件配置
│   ├── web/          # Nuxt 前端应用 (Port: 3000)
│   │   ├── components/ # Vue 组件
│   │   ├── composables/# 组合式函数 (Data Fetching, State)
│   │   └── pages/      # 路由页面
│   └── docs/         # 项目文档 (VitePress)
├── package.json      # Workspace 根配置
├── turbo.json        # Turbo 构建管线配置
└── pnpm-workspace.yaml

2.3 会员权限设计 (Membership System)

image.png 为了区分普通用户与付费会员,我们在 Strapi 后端扩展了 MembershipOrder 模型。

核心逻辑

  1. 用户注册: 默认分配为 Authenticated 角色,无会员权益。
  2. 订阅支付: 用户购买会员(月/年/终身)后,后端更新 Membership 表,记录过期时间。
  3. 权限校验: 在获取 Tool 详情时,Controller 会校验当前用户的 Membership 状态。如果过期或未订阅,则剔除 tool.member 字段(包含 SOP 和敏感数据)。
// apps/api/src/api/tool/controllers/tool.ts (伪代码示例)
async findOne(ctx) {
  const { id } = ctx.params;
  const user = ctx.state.user;
  
  // 1. 获取工具完整数据
  const entity = await strapi.service('api::tool.tool').findOne(id, { populate: ['member'] });
  
  // 2. 检查用户会员状态
  const isMember = await strapi.service('api::membership.membership').checkStatus(user?.id);
  
  // 3. 数据清洗:非会员移除 member 字段
  if (!isMember && entity.member) {
    delete entity.member;
  }
  
  return this.transformResponse(entity);
}

3. 后端设计 (Backend Design) - Strapi v5

后端核心职责是提供结构化的内容管理与 API 服务。

2.1 数据模型 (Content Modeling)

我们定义了多维度的内容类型 (Content Types) 来支撑业务:

  • Collection Types:
    • Tool: AI 工具核心数据(名称、描述、URL、Logo)。
    • Category: 工具分类。
    • Solution: 解决方案文章。
  • Single Types:
    • Global: 全局配置(站点标题、SEO 信息、社群二维码)。
  • Components:
    • tool.member: 付费会员专属字段(SOP、避坑指南),利用 Strapi 组件功能实现灵活的数据结构。

Schema 示例 (Global Config)

// apps/api/src/api/global/content-types/global/schema.json
{
  "kind": "singleType",
  "collectionName": "globals",
  "info": {
    "singularName": "global",
    "pluralName": "globals",
    "displayName": "Global"
  },
  "attributes": {
    "communityGroupTitle": {
      "type": "string",
      "default": "加入官方交流群"
    },
    "communityGroupQrCode": {
      "type": "media",
      "multiple": false,
      "allowedTypes": ["images"]
    }
  }
}

2.2 API 扩展与权限

  • 权限控制: 使用 @strapi/plugin-users-permissions 管理 Public 与 Authenticated 角色权限。
  • 自定义逻辑: 通过覆写 Core Controllers 或增加 Middleware 实现更细粒度的权限控制(如:非会员请求 Tool 详情时过滤掉 tool.member 字段)。

3. 前端设计 (Frontend Design) - Nuxt 4

前端应用专注于高性能渲染与极致的用户体验。

3.1 核心特性实现

  • 服务端渲染 (SSR): 提升 SEO 表现,首屏加载速度快。
  • 组合式开发: 利用 Nuxt Composables 封装业务逻辑。

3.2 关键代码解析

🔐 身份认证与状态管理 (useAuth.ts)

为了保证用户体验,我们封装了统一的认证逻辑,支持注册、登录及 JWT Token 持久化。

// apps/web/composables/useAuth.ts
export const useAuth = () => {
  const user = useState<User | null>('user', () => null)
  const token = useCookie('auth_token')
  const { $strapi } = useNuxtApp()

  // 登录逻辑
  const login = async (credentials: LoginInput) => {
    try {
      const { user: userData, jwt } = await $strapi.login(credentials)
      token.value = jwt
      user.value = userData
      return true
    } catch (e) {
      return false
    }
  }
  
  // 注册逻辑 (自动关联默认角色)
  const register = async (input: RegisterInput) => {
    const { user: newUser, jwt } = await $strapi.register(input)
    token.value = jwt
    user.value = newUser
  }

  return { user, login, register }
}

全局配置获取 (useGlobal.ts)

为了实现配置动态化,我们封装了 useGlobalConfig,它在应用初始化时从 Strapi 获取配置,并适配 Strapi v5 的响应结构。

// apps/web/composables/useGlobal.ts
export const useGlobalConfig = () => {
  return useState('global-config', () => null)
}

export const fetchGlobalConfig = async () => {
  const config = useGlobalConfig()
  const { find } = useStrapi() // 封装的 Strapi Fetcher
  
  try {
    // 适配 Strapi v5 API 响应结构 (dataunwrap)
    const response = await find('global', {
      populate: '*' // 连表查询所有关联字段 (如图片)
    })
    config.value = response.data || {}
  } catch (error) {
    console.error('Failed to fetch global config:', error)
  }
}

动态组件渲染 (profile.vue)

image.png

在个人中心页,我们直接绑定从后端获取的配置数据,实现运营内容的实时更新。

<!-- apps/web/pages/profile.vue -->
<script setup lang="ts">
const globalConfig = useGlobalConfig()

// 处理图片 URL 的辅助函数
const getQrCodeUrl = (qrCodeObj: any) => {
  // 兼容 Strapi 上传插件的 URL 格式
  return qrCodeObj?.url ? `${useStrapiUrl()}${qrCodeObj.url}` : '/default-qr.png'
}
</script>

<template>
  <div class="community-card">
    <h3>{{ globalConfig?.communityGroupTitle }}</h3>
    <img 
      :src="getQrCodeUrl(globalConfig?.communityGroupQrCode)" 
      alt="Community QR" 
    />
  </div>
</template>

5. 部署与运维 (Deployment & DevOps)

4.1 环境变量管理

项目使用 .env 文件管理敏感信息,区分开发与生产环境:

# .env (Root)
STRAPI_URL=http://localhost:1337
NUXT_PUBLIC_API_URL=http://localhost:1337/api
DATABASE_HOST=localhost
DATABASE_PORT=3306

4.2 构建流程

利用 TurboRepo 的缓存机制加速构建:

# 并行构建所有应用
pnpm build

# Turbo 智能缓存示例
# apps/web: cache miss, executing 345ms
# apps/api: cache hit, replaying output 50ms

4.3 生产环境建议

  • Process Manager: 使用 PM2 管理 Node.js 进程。
  • Reverse Proxy: Nginx 反向代理,配置 SSL 证书与 Gzip 压缩。
  • Storage: Strapi 上传文件建议对接 AWS S3 或阿里云 OSS 对象存储。

6. 开发效能与 AI 赋能 (AI-Driven Development)

Creator AI Hub 的诞生本身就是 AI 赋能开发的最佳实践。

⏱️ 惊人的速度:从 0 到 1,仅用 48 小时

如果不借助 AI 工具,这样一个包含全栈架构、CMS 后台、会员系统和 SSR 前端的项目,通常需要 2-3 周的开发周期。但在 AI 辅助下,我们实现了 10 倍提效

6.1 AI 辅助全流程

  1. 架构设计: AI 协助选型 TurboRepo + Nuxt 4,并生成了初始的 Monorepo 目录结构。
  2. Schema 生成: Strapi 的复杂 Content Types(如嵌套组件、关联关系)JSON 配置,由 AI 一键生成,节省了大量手动配置时间。
  3. 代码编写:
    • 前端 useAuthuseGlobal 等核心 Composable 逻辑由 AI 快速实现。
    • Tailwind CSS 的响应式布局和微交互效果,通过 AI 提示词快速调整。
  4. 文案与文档: 项目文档(包括本文)、SEO 描述、初始测试数据,均由 AI 辅助撰写。

7. 架构扩展性:从导航站到无限可能 (Scalability)

Creator AI Hub 的这套 "Monorepo + Headless CMS + SSR Frontend" 架构,本质上是一个通用的内容变现与知识付费基座。它不仅限于导航站,只需微调数据模型,即可快速裂变出多种应用:

7.1 💡 变体一:付费课程平台

  • 改动点: 将 Tool 模型改为 Course(课程),tool.member 改为 CourseChapter(课程章节)。
  • 功能复用: 会员订阅系统、订单支付、CMS 章节管理、前端视频播放页。

7.2 🛒 变体二:虚拟资源商城

  • 改动点: 将 Category 细化为资源类型(PPT模板/设计素材/Prompt),增加 DownloadLink 字段。
  • 功能复用: 搜索过滤、资源详情页、下载权限控制(仅会员或单次购买可下载)。

7.3 🏢 变体三:企业内部知识库

  • 改动点: 开启 Strapi 的 SSO 单点登录,关闭公开注册。
  • 功能复用: 文档层级管理、SOP 标准化流程展示、全站全文检索。

这套架构的最大价值在于**“一次构建,处处复用”**。Monorepo 使得我们可以在 apps/ 目录下轻松添加新的前端应用(如 apps/mobileapps/admin-dashboard),共享同一套后端 API 和 TypeScript 类型定义,极大地降低了多业务线的维护成本。


8. 结语

通过 Nuxt 4 + Strapi 5 的黄金组合,配合 AI 结对编程模式,我们快速构建了一个既有内容深度,又有良好交互体验的 AI 导航平台。这不仅验证了技术栈的先进性,更证明了在 AI 时代,个人的开发潜能可以被无限放大

AI 驱动前端开发覆盖的能力全景拆解

2025年12月28日 15:33

AI 正在改变前端开发,但不是“替你写页面”那么简单。

在过去一年里,关于 AI 与前端的讨论,几乎都集中在一个问题上:

AI 能不能直接把前端页面写出来?

实际工程中的答案往往是:
能写,但不一定该写;能跑,但不一定能维护。

本文尝试跳出“某个工具/框架”的视角,从工程全流程出发,系统性拆解:

  • AI 在前端开发中到底能做哪些事
  • 哪些能力适合“直接用 AI”
  • 哪些能力必须进入工程链路
  • 如何在真实团队中组合使用,避免“AI 工程反模式”

一、先给结论:AI 不该只盯着“写页面”

在真实项目中,前端开发并不只是“写组件”:

  • 有需求拆解
  • 有规格设计
  • 有工程约束
  • 有测试与治理
  • 有长期维护成本

AI 的价值,分布在整个链路中,而不是集中在“代码生成”这一点上。

如果只讨论“AI 写页面”,会严重低估它的工程潜力。

二、前端工程全流程拆解(AI 能介入的 9 个环节)

从工程角度,一个典型的前端中后台开发流程可以拆成 9 个阶段:

  1. 需求理解与拆解
  2. 规格化(Spec / Schema)
  3. 模板/脚手架生成(Codegen)
  4. 页面与组件实现
  5. 重构与迁移
  6. 调试与缺陷修复
  7. 测试与质量保障
  8. 代码评审与工程治理
  9. 文档与知识沉淀

下面逐一分析:AI 在每一层的真实价值与风险

三、需求理解与拆解:AI 的“第一生产力”

AI 非常擅长的事

  • 将自然语言需求拆解为页面/功能点清单
  • 列出字段、操作、权限、异常场景
  • 辅助补齐遗漏的边界条件

工程价值

  • 减少沟通成本
  • 降低遗漏风险
  • 提高需求进入实现阶段的质量

👉 这是“低风险、高收益”的 AI 使用场景,几乎没有副作用。

四、规格化(Spec / Schema):AI 最理想的输出形态

相比直接写代码,AI 更适合输出结构化规格,例如:

  • 页面配置(字段、表单、校验)
  • 接口契约(OpenAPI / DTO)
  • 路由与权限声明
  • 表格列、搜索条件、状态枚举
{
  "title": "供应商管理",
  "columns": [
    { "prop": "name", "label": "供应商名称" },
    { "prop": "status", "label": "状态" }
  ]
}

为什么这是 AI 的“甜蜜点”?

  • 结构化输出稳定
  • 可校验、可 diff
  • 可进入工程链路
  • 可反复复用

👉 Spec 是 AI 与工程之间最重要的“接口层”。

五、模板 / 脚手架生成:AI 的放大器,而不是主角

在有 Spec 的前提下,前端可以通过:

  • 页面模板
  • 代码生成器
  • AST 工具

确定性地生成代码骨架

此时 AI 的角色是:

  • 生成 Spec
  • 而不是直接生成页面文件

工程收益

  • 代码风格统一
  • 可重复生成
  • 易于回滚
  • 新人友好

👉 模板 + 生成器负责“稳定性”,AI 负责“变化”。

六、页面与组件实现:AI 的高风险区

适合 AI 直接参与的场景

  • Demo / 原型
  • 内部工具
  • 探索性 UI
  • 一次性页面

高风险场景

  • 权限逻辑
  • 核心业务流程
  • 复杂状态管理
  • 跨模块交互

在这些场景中,让 AI 直接写代码,往往是一个工程反模式

  • 不稳定
  • 难 review
  • 难以长期维护

👉 这里更适合“小范围、受限”的 AI 辅助,而不是放权。

七、重构与迁移:AI 的“耐心型劳动力”

在这些场景中,AI 非常有价值:

  • Vue2 → Vue3
  • Router / 状态库升级
  • API 调整引发的批量修改

最佳实践

  • 按模块拆分
  • 小步 PR
  • lint / test 兜底
  • AI 输出 patch(diff),而不是整文件

👉 AI 很适合做“重复但细致”的迁移工作。

八、调试与修复:报错驱动的高性价比场景

这是目前 AI 在工程中性价比最高的用途之一:

  • lint 报错
  • 编译失败
  • 测试失败

将错误日志 + 相关文件交给 AI,限制改动范围:

  • 只改指定文件
  • 只做最小 diff

👉 这是“可控、可回滚、立竿见影”的 AI 用法。

九、测试、评审与工程治理:AI 的隐藏价值

测试

  • 生成单测用例
  • 补齐边界测试
  • 生成 mock 数据

Review / 治理

  • 总结 PR 风险点
  • 检查风格一致性
  • 提示潜在技术债

👉 AI 在“辅助判断”层面的价值,远大于“替你写代码”。

十、两种 AI 使用范式的对比

Vibe Coding(即兴编码)

  • AI 直接写/改代码
  • 快、爽、探索性强
  • 难审计、难规模化

Spec-Driven Coding(规格驱动)

  • AI 产 Spec / Patch
  • 模板/生成器产代码
  • lint / test / CI 兜底
  • 稳定、可控、团队友好

真实工程中,探索用 vibe,交付用 spec-driven。

十一、从工程视角的能力优先级建议

优先落地(低风险)

  1. 需求拆解
  2. Spec 生成
  3. 文档/说明
  4. lint/test 修复

需要治理后落地

  1. 页面生成
  2. 迁移重构
  3. 测试体系补齐

谨慎使用

  1. 核心业务逻辑
  2. 架构级重构

十二、总结:AI 在前端的“正确位置”

AI 并不是来“替代前端工程师写页面”的。
它真正擅长的是:

把变化结构化,把重复自动化,把质量交给工程链路。

如果只能记住一句话:

AI 的能力不该绕开工程,而应该被工程约束。

如果这篇文章对你有启发,欢迎点赞或收藏 👍

React 中的跨层级通信:使用 Context 实现主题切换功能

作者 ohyeah
2025年12月28日 13:58

在现代前端开发中,组件之间的数据通信是构建复杂应用时无法回避的核心问题。尤其当应用规模逐渐扩大、组件层级不断加深时,传统的“props 逐层传递”方式会迅速暴露出其局限性——不仅代码冗长,而且维护成本高。本文将围绕一个典型的主题切换功能,深入剖析如何利用 React 的 useContextcreateContext 实现任意深度组件间的数据共享,并探讨这种模式背后的设计思想与实践价值。


一、问题背景:为什么需要跨层级通信?

在 React 应用中,父子组件之间可以通过 props 轻松传递数据。然而,一旦组件树变得复杂(例如存在多层嵌套),而某个深层子组件又需要访问顶层状态(如用户偏好、语言设置、主题模式等),开发者就不得不将状态从根组件一层层向下透传。这种“长安的荔枝”式的传递路径,不仅繁琐,还容易造成中间组件的“污染”——它们被迫接收并转发自己并不关心的数据。

超过父子层级,传递的路径太长,这正是传统 props 通信方式在大型项目中的痛点所在。

为了解决这一问题,React 提供了 Context API,它允许我们在组件树中创建一个“数据通道”,使得任意后代组件都能直接访问该通道中的状态,而无需依赖中间组件的介入。


二、项目结构概览

我们以一个简单的主题切换功能为例,展示 Context 的实际应用。项目主要包含以下几个文件:

  • src/App.jsx:应用入口,包裹主题提供者和页面组件。
  • src/theme.css:定义全局 CSS 变量,支持亮色与暗色主题。
  • src/contexts/ThemeContext.jsx:创建并导出 ThemeContext,实现主题状态管理。
  • src/pages/Page.jsx:页面容器,渲染头部组件。
  • src/components/Header.jsx:头部组件,消费主题状态并提供切换按钮。

整个架构清晰体现了“顶层持有状态、任意组件消费”的设计哲学。


三、CSS 主题变量:样式层面的主题支持

theme.css 中,我们利用 CSS 自定义属性(即 CSS 变量)来定义两套主题:

:root {
  --bg-color: #ffffff;
  --text-color: #222;
  --primary-color: #1677ff;
}

[data-theme='dark'] {
  --bg-color: #141414;
  --text-color: #f5f5f5;
  --primary-color: #4e8cff;
}

这里的关键在于 [data-theme='dark'] 这个属性选择器。当 HTML 根元素(<html>)上设置了 data-theme="dark" 时,浏览器会自动覆盖 :root 中定义的变量值,从而实现样式的动态切换。

同时,body 元素通过 var(--bg-color)var(--text-color) 引用这些变量,并添加了 transition: all 0.3s 实现平滑过渡效果。这种纯 CSS 的方案轻量高效,与 JavaScript 状态解耦,是实现主题切换的理想基础。

值得注意的是,“CSS 也是一门编程语言”——这并非夸张。借助变量、计算函数(如 calc())、媒体查询等特性,CSS 已具备相当的逻辑表达能力。


四、Context 的创建与提供:ThemeContext 的实现

ThemeContext.jsx 中,我们使用 createContext 创建了一个上下文对象:

export const ThemeContext = createContext(null);

初始值设为 null,表示在未被 Provider 包裹时,消费组件将获取到空值(实际开发中可根据需要设置默认状态)。

接着,ThemeProvider 组件封装了主题状态的逻辑:

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme((t) => t === 'light' ? 'dark' : 'light');
  };

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

这里有几个关键点:

  1. 状态管理:使用 useState 维护当前主题(lightdark)。
  2. 副作用同步:通过 useEffect 监听 theme 变化,并将值同步到 <html> 元素的 data-theme 属性上,从而触发 CSS 主题切换。
  3. 数据提供:通过 ThemeContext.Provider{theme, toggleTheme} 作为 value 传递给所有子组件。

这种设计使得状态的“持有”与“变更”集中在顶层组件,符合“规矩不变,父组件(顶层组件)复杂持有和改变数据”的原则。


五、任意组件消费状态:Header 中的 useContext

Header.jsx 中,我们不再依赖 props 获取主题信息,而是主动“寻找”数据:

import { useContext } from "react";
import { ThemeContext } from "../contexts/ThemeContext";

export default function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <div style={{ marginBottom: 24 }}>
      <h2>当前主题: {theme}</h2>
      <button className="button" onClick={toggleTheme}>切换主题</button>
    </div>
  );
}

通过 useContext(ThemeContext),组件直接从上下文中提取所需的状态和方法。这种方式打破了层级限制,无论 Header 嵌套多深,只要其祖先中有 ThemeProvider,就能正常工作。

这正体现了笔记中的核心观点:“要消费数据状态的组件拥有找数据的能力(主动获取数据),而不是被动接受”。这种“拉取”(pull)而非“推送”(push)的模式,极大提升了组件的独立性和复用性。


六、App 组件:搭建上下文环境

最后,在 App.jsx 中,我们将整个应用包裹在 ThemeProvider 内:

import ThemeProvider from "./contexts/ThemeContext";
import Page from './pages/Page';

export default function App() {
  return (
    <>
      <ThemeProvider>
        <Page />
      </ThemeProvider>
    </>
  );
}

这样,从 PageHeader 的所有后代组件都处于同一个主题上下文中,可以自由访问主题状态。这种结构简洁明了,职责分明:App 负责搭建环境,ThemeProvider 负责状态管理,Header 负责 UI 交互。


七、总结:Context 的价值与适用场景

通过这个主题切换的例子,我们可以清晰看到 Context API 在解决跨层级通信问题上的优势:

  • 解耦中间组件:无需让无关组件承担数据传递职责。
  • 提升可维护性:状态集中管理,逻辑清晰。
  • 增强组件复用性:任何组件只要引入 Context,即可获得所需数据。
  • 符合“主动获取”理念:消费端掌握主动权,系统更灵活。

当然,Context 并非万能。对于高频更新的状态(如输入框内容),过度使用 Context 可能导致不必要的重渲染。但在管理全局配置(如主题、语言、用户信息)等低频、高共享性的数据时,它无疑是最佳选择之一。

回到最初的问题:如何优雅地实现跨组件通信?答案已经显而易见——借助 Context,让数据在组件树中自由流动,而开发者只需关注“在哪里提供”和“在哪里使用”,无需再为“怎么传过去”而烦恼。

这不仅是技术方案的优化,更是开发思维的升级:从“被动传递”走向“主动获取”,从“路径依赖”走向“上下文感知”。而这,正是现代 React 应用架构演进的重要方向。

不止是代码堆放:带你全面掌握 Monorepo 核心技术与选型

作者 明月_清风
2025年12月28日 11:43

在前端工程化日益复杂的今天,很多开发者对 Monorepo 的第一印象往往是:“不就是把好几个项目的代码塞进一个 Git 仓库吗?”

如果仅仅是“物理堆放”,那不仅不能提效,反而会带来权限混乱和构建缓慢的灾难。真正的 Monorepo 是一套工程化管理方案,它通过精妙的工具链,解决了多项目协作中代码复用、依赖同步和版本管理的痛点。


一、 为什么我们需要 Monorepo?

在传统的 Multirepo(多仓库)模式下,如果你维护着一个组件库 UI-Lib 和三个业务项目,流程通常是这样的:

  1. 修改 UI-Lib 的代码。
  2. 更新版本号,发布到 npm。
  3. 到三个业务项目中分别执行 npm update
  4. 如果发现 Bug,重复上述步骤……

这种“发布-拉取”的循环极大地浪费了开发时间。而 Monorepo 将它们置于同一工作区:

  • 实时反馈:修改组件库,业务项目立即生效,无需发布 npm。
  • 原子重构:当你需要改动一个核心接口时,可以在同一个 Commit 中修改所有受影响的子项目,确保系统始终处于可用状态。

二、 核心技术:支撑 Monorepo 的三根支柱

要玩转 Monorepo,你必须掌握以下三个层面的技术选型:

1. 依赖管理层(Workspaces)

这是 Monorepo 的心脏。它负责将仓库内的各个 Package 互相“软链接”,并统一管理第三方依赖。

  • pnpm Workspaces (首选) :通过内容寻址存储,极大节省磁盘空间,并能严格禁止未声明的依赖访问(解决幻影依赖问题)。
  • Yarn Workspaces:老牌方案,生态完善。

2. 任务编排层(Build Pipeline)

当仓库里有几十个项目时,运行 npm run build 如果要等半小时,那是不可接受的。我们需要“聪明”的工具:

  • Turborepo:由 Vercel 出品。核心能力是 指纹缓存(Hashing) ——如果代码没变,直接从缓存读取结果;以及 并行执行——自动分析依赖图谱,让互不影响的项目同时构建。
  • Nx:功能更全面的“重型武器”,支持可视化依赖图谱分析,适合对构建流程有极致定制化需求的大厂。

3. 发布与版本层(Versioning)

如何决定哪个包需要发版?如何自动生成 Changelog?

  • Changesets:非常推荐。它通过在 PR 中添加描述文件,自动化处理版本更新和发布流程,尤其适合开源项目或多人协作。

三、 选型指南:你真的需要它吗?

Monorepo 不是银弹,它也有自己的“适用边界”:

维度 建议使用 Monorepo 建议保持 Multirepo
项目关联度 存在大量共享组件、工具类、类型定义。 各项目业务独立,几乎没有代码交集。
技术栈 统一使用 React 或 Vue,规范一致。 混合了多种框架或不同年代的老旧项目。
团队规模 中小型团队,追求快速迭代与低沟通成本。 跨部门超大型团队,对代码权限有极其严格限制。
构建压力 具备配置 Turborepo 等工具的能力。 没有专人维护配置,不愿增加构建工具复杂度。

四、 落地建议:从 0 到 1 的最优路径

如果你准备在团队落地 Monorepo,建议采用这套**“黄金组合”**:

  1. 包管理:使用 pnpm。它的速度和依赖处理能力是目前社区的共识。
  2. 脚手架:使用 Turborepo。它的学习曲线最平缓,只需一个 turbo.json 就能让构建速度起飞。
  3. 语言:全量开启 TypeScript。Monorepo 的最大优势之一就是跨项目的类型安全——你修改了 A 包的接口定义,B 包在编译阶段就会直接报错。

结语

Monorepo 的本质是将管理复杂度转化为了工具链复杂度。虽然初期配置需要一定成本,但它带来的协同效率和代码复用率是无可比拟的。

你想了解如何用 pnpm + Turborepo 搭建一个最小可运行的 Monorepo 模板吗?我可以为你提供具体的配置步骤。

Single-SPA 学习总结

作者 鹏北海
2025年12月28日 10:22

一、什么是 Single-SPA

Single-SPA 是微前端领域的"鼻祖"框架,它是一个用于前端微服务化的 JavaScript 框架,允许你在同一个页面中使用多个框架(React、Vue、Angular 等),而不需要刷新页面。

核心定位

  • JS Entry 方案:通过加载子应用的 JS 入口文件来集成
  • 路由驱动:基于 URL 变化自动激活/卸载子应用
  • 框架无关:支持任意前端框架,只要导出生命周期函数

二、核心概念

1. 三种模块类型

类型 说明 生命周期 使用场景
Application 有 UI 的微前端应用 bootstrap → mount → unmount 独立页面/功能模块
Parcel 可复用的 UI 组件 手动挂载/卸载 跨应用共享组件
Utility 无 UI 的共享模块 工具函数、API 封装、状态管理

2. 生命周期函数

每个 Application 必须导出三个生命周期函数:

// 应用首次加载时调用(只执行一次)
export async function bootstrap(props) {
  console.log("应用初始化");
}

// 路由匹配时调用(每次激活都执行)
export async function mount(props) {
  // 渲染 DOM
  ReactDOM.createRoot(props.container).render(<App />);
}

// 路由离开时调用
export async function unmount(props) {
  // 清理 DOM 和副作用
  root.unmount();
}

3. 生命周期流程

首次加载:  load → bootstrap → mount
路由切换:  unmount → mount (另一个应用)
完全卸载:  unmount → unload (可选)

三、核心原理

1. 路由劫持

Single-SPA 通过劫持浏览器的路由事件来实现应用切换:

// 劫持 history API
const originalPushState = window.history.pushState;
window.history.pushState = function (...args) {
  originalPushState.apply(this, args);
  // 触发应用切换逻辑
  reroute();
};

// 监听 popstate 事件
window.addEventListener("popstate", reroute);

2. 应用状态机

每个应用都有状态流转:

NOT_LOADED → LOADING_SOURCE_CODE → NOT_BOOTSTRAPPED
    → BOOTSTRAPPING → NOT_MOUNTED → MOUNTING → MOUNTED
    → UNMOUNTING → NOT_MOUNTED

3. 应用加载与执行

// 注册应用
registerApplication({
  name: "app1",
  app: () => import("./app1/main.js"), // 动态导入
  activeWhen: "/app1", // 激活条件
  customProps: { authToken: "xxx" }, // 传递给子应用的 props
});

// 启动
start();

4. Import Map 模块解析

Single-SPA 推荐使用 Import Map 来管理模块 URL:

<script type="importmap">
  {
    "imports": {
      "react": "https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js",
      "@myorg/app1": "https://mycdn.com/app1/main.js"
    }
  }
</script>

浏览器会根据 Import Map 解析 import '@myorg/app1' 到实际 URL。


四、Single-SPA 生态

1. 核心包

包名 作用
single-spa 核心库,提供注册、启动、生命周期管理
single-spa-layout 声明式布局引擎,用 HTML 定义路由和布局

2. 框架适配器

适配器 框架
single-spa-react React
single-spa-vue Vue 2/3
single-spa-angular Angular
single-spa-svelte Svelte

3. 开发工具

工具 作用
import-map-overrides 运行时覆盖 Import Map,方便本地调试
import-map-injector 支持从远程 URL 加载 Import Map
create-single-spa 官方脚手架 CLI

五、项目结构

推荐的目录结构

micro-frontends/
├── root-config/              # 基座应用
│   ├── src/
│   │   ├── index.ejs         # HTML 模板 + 布局配置
│   │   └── root-config.js    # 应用注册和启动
│   └── webpack.config.js
│
├── navbar/                   # 导航栏应用(始终显示)
├── app1/                     # 业务应用 1
├── app2/                     # 业务应用 2
│
├── shared/                   # 共享模块
│   ├── api/                  # API 封装
│   └── styleguide/           # 公共样式/组件
│
└── shared-dependencies/      # Import Map 配置
    └── importmap.json

六、创建 Single-SPA 项目

方式一:使用官方脚手架 create-single-spa(推荐)

# 全局安装
npm install -g create-single-spa

# 或使用 npx
npx create-single-spa

脚手架会引导你选择:

  1. 项目类型

    • single-spa root config - 基座应用
    • single-spa application / parcel - 子应用
    • in-browser utility module - 工具模块
  2. 框架:React / Vue / Angular / Svelte / None

  3. 包管理器:npm / yarn / pnpm

创建基座应用

npx create-single-spa --moduleType root-config
# 选择组织名称,如 @myorg

创建 React 子应用

npx create-single-spa --moduleType app-parcel --framework react

创建 Vue 子应用

npx create-single-spa --moduleType app-parcel --framework vue

方式二:手动配置

如果需要更多控制,可以手动配置:

1. 基座应用 (root-config)

mkdir root-config && cd root-config
npm init -y
npm install single-spa single-spa-layout
npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin
// src/root-config.js
import { registerApplication, start } from "single-spa";
import {
  constructApplications,
  constructRoutes,
  constructLayoutEngine,
} from "single-spa-layout";

const routes = constructRoutes(document.querySelector("#single-spa-layout"));
const applications = constructApplications({
  routes,
  loadApp: ({ name }) => import(/* webpackIgnore: true */ name),
});

applications.forEach(registerApplication);
constructLayoutEngine({ routes, applications }).activate();
start();

2. React 子应用

npm install single-spa-react react react-dom
// src/main.js
import React from "react";
import ReactDOMClient from "react-dom/client";
import singleSpaReact from "single-spa-react";
import App from "./App";

const lifecycles = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: App,
  errorBoundary(err, info, props) {
    return <div>Error</div>;
  },
});

export const { bootstrap, mount, unmount } = lifecycles;

3. Vue 子应用

npm install single-spa-vue vue
// src/main.js
import { createApp, h } from "vue";
import singleSpaVue from "single-spa-vue";
import App from "./App.vue";

const vueLifecycles = singleSpaVue({
  createApp,
  appOptions: {
    render() {
      return h(App);
    },
  },
});

export const { bootstrap, mount, unmount } = vueLifecycles;

方式三:参考官方示例仓库

示例 地址 说明
React 示例 github.com/react-micro… 完整的 React 微前端示例
Vue 示例 github.com/vue-microfr… 完整的 Vue 微前端示例
混合框架示例 github.com/polyglot-mi… React + Vue + Angular 混合

在线演示:


七、开发调试技巧

1. 使用 import-map-overrides

// 在浏览器控制台启用开发工具
localStorage.setItem("devtools", true);

// 刷新页面后,右下角会出现开发工具面板
// 可以将任意模块指向本地开发服务器

2. 本地开发流程

# 1. 启动本地子应用
cd my-app && npm start --port 9001

# 2. 访问线上/本地基座
# 3. 使用 import-map-overrides 将 @myorg/my-app 指向 localhost:9001

3. 独立运行子应用

子应用应该能够独立运行,方便开发调试:

// 判断是否在 single-spa 环境中
if (!window.singleSpaNavigate) {
  // 独立运行
  ReactDOM.createRoot(document.getElementById("root")).render(<App />);
}

八、Single-SPA vs 其他方案

特性 single-spa qiankun micro-app
Entry 类型 JS Entry HTML Entry HTML Entry
沙箱隔离 ❌ 无内置 ✅ Proxy 沙箱 ✅ 沙箱隔离
样式隔离 ❌ 无内置 ✅ Shadow DOM / Scoped ✅ 样式隔离
老项目改造 较高 极低 极低
灵活性 ★★★★★ ★★★★ ★★★
学习曲线 较陡 中等 较平缓

选择建议

  • 选 single-spa:需要最大灵活性、深入理解微前端原理、技术能力强的团队
  • 选 qiankun:企业级项目、多历史系统接入、需要开箱即用的沙箱和隔离
  • 选 micro-app:快速上手、组件化思维、Vue 技术栈为主

九、最佳实践

1. 共享依赖

通过 Import Map 共享公共依赖,避免重复加载:

{
  "imports": {
    "react": "https://cdn.jsdelivr.net/npm/react@18/...",
    "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18/..."
  }
}

2. 样式隔离

Single-SPA 不提供内置样式隔离,需要自行处理:

  • CSS Modules
  • CSS-in-JS (styled-components, emotion)
  • BEM 命名规范
  • 添加应用前缀

3. 全局状态管理

  • 使用 Utility 模块共享状态
  • 发布订阅模式 (EventBus)
  • 通过 customProps 传递

4. 错误边界

singleSpaReact({
  // ...
  errorBoundary(err, info, props) {
    return <ErrorPage error={err} />;
  },
});

十、学习资源

官方资源

视频教程

相关规范

Vuex 详解:现代 Vue.js 应用的状态管理方案

2025年12月28日 00:09

引言

在现代前端应用开发中,随着应用复杂度的不断提升,组件间的数据共享和状态管理变得越来越重要。Vuex 作为 Vue.js 官方推荐的状态管理库,为 Vue 应用提供了一种集中式、可预测的状态管理模式。本文将结合提供的代码实例,深入探讨 Vuex 的核心概念、工作原理以及实际应用。

一、Vuex 的基本概念

Vuex 是一个专门为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

1.1 为什么需要 Vuex?

从提供的代码中可以看到,我们有两个组件 MyCount 和 MyPersons,它们都需要访问和修改共享的状态数据:

  • MyCount 组件需要访问和修改 sum(求和值)
  • MyPersons 组件需要访问和修改 persons(人员列表)
  • 两个组件都需要相互访问对方的状态数据

如果没有 Vuex,这种跨组件的数据共享需要通过复杂的父子组件传值或事件总线来实现,随着应用规模扩大,代码将变得难以维护。Vuex 通过提供一个全局的单例状态树,优雅地解决了这个问题。

1.2 Vuex 的核心概念

从 index.js 文件中可以看到,Vuex 包含以下几个核心部分:

javascript

复制下载

export default new Vuex.Store({
  actions,
  mutations,
  state,
  getters
})

State(状态) :应用的单一状态树,包含所有需要共享的数据。

Mutations(变更) :唯一更改状态的方法,必须是同步函数。

Actions(动作) :提交 mutations,可以包含任意异步操作。

Getters(获取器) :从 state 中派生出一些状态,类似于计算属性。

二、Vuex 的基本使用

2.1 初始化与配置

首先需要在项目中安装和配置 Vuex:

javascript

复制下载

import Vuex from 'vuex'
import Vue from 'vue'

// 使用插件
Vue.use(Vuex)

const store = new Vuex.Store({
  // 配置项
})

2.2 组件中访问状态

在组件中,可以通过 $store 访问 Vuex 的状态:

javascript

复制下载

// 在计算属性中直接访问
computed: {
  persons(){
    return this.$store.state.persons
  },
  sum(){
    return this.$store.state.sum
  }
}

2.3 组件中修改状态

修改状态有两种方式:

方式一:通过 actions(可包含异步操作)

javascript

复制下载

methods: {
  incrementOdd(){
    this.$store.dispatch('jiaOdd', this.n)
  }
}

方式二:直接提交 mutations(同步操作)

javascript

复制下载

methods: {
  add(){
    const personObj = {id:nanoid(), name:this.name};
    this.$store.commit('ADD_PERSON', personObj)
  }
}

三、四个 Map 辅助函数的使用

为了简化代码,Vuex 提供了四个辅助函数:

3.1 mapState

将 state 映射为组件的计算属性:

javascript

复制下载

// 数组写法(简写)
...mapState(['sum', 'persons'])

// 对象写法(可重命名)
...mapState({currentSum: 'sum', personList: 'persons'})

3.2 mapGetters

将 getters 映射为组件的计算属性:

javascript

复制下载

...mapGetters(['bigSum'])

3.3 mapMutations

将 mutations 映射为组件的方法:

javascript

复制下载

// 对象写法
...mapMutations({increment: 'JIA', decrement: 'JIAN'}),

// 数组写法
...mapMutations(['JIA', 'JIAN'])

3.4 mapActions

将 actions 映射为组件的方法:

javascript

复制下载

...mapActions({incrementOdd: 'jiaOdd', incrementWait: 'jiaWait'})

四、Vuex 模块化与命名空间

随着应用规模扩大,将所有状态集中在一个文件中会变得难以维护。Vuex 允许我们将 store 分割成模块,每个模块拥有自己的 state、mutations、actions、getters。

4.1 模块化配置

从重构后的代码可以看到,我们将 store 拆分成了两个模块:

count.js - 处理计数相关的状态
person.js - 处理人员相关的状态

javascript

复制下载

// index.js
import countAbout from './count'
import personAbout from './person'

export default new Vuex.Store({
  modules: {
    countAbout,
    personAbout
  }
})

4.2 命名空间

通过设置 namespaced: true 开启命名空间,可以避免不同模块之间的命名冲突:

javascript

复制下载

// count.js
export default {
  namespaced: true,
  // ... 其他配置
}

4.3 模块化后的访问方式

访问 state:

javascript

复制下载

// 直接访问
persons(){
  return this.$store.state.personAbout.persons
}

// 使用 mapState(需要指定命名空间)
...mapState('personAbout', ['persons'])

访问 getters:

javascript

复制下载

// 直接访问
firstPersonName(){
  return this.$store.getters['personAbout/firstPersonName'];
}

// 使用 mapGetters
...mapGetters('personAbout', ['firstPersonName'])

提交 mutations:

javascript

复制下载

// 直接提交
this.$store.commit('personAbout/ADD_PERSON', personObj);

// 使用 mapMutations
...mapMutations('countAbout', {increment: 'JIA', decrement: 'JIAN'})

分发 actions:

javascript

复制下载

// 直接分发
this.$store.dispatch('personAbout/addWangPerson', personObj);

// 使用 mapActions
...mapActions('countAbout', {incrementOdd: 'jiaOdd', incrementWait: 'jiaWait'})

五、实际应用案例分析

5.1 计数器模块(count.js)

这个模块展示了如何处理同步和异步的状态更新:

javascript

复制下载

// 同步操作
JIA(state, value) {
  state.sum += value;
},

// 异步操作(通过 action)
jiaWait(context, value) {
  setTimeout(() => {
    context.commit('JIAWAIT', value);
  }, 500);
}

5.2 人员管理模块(person.js)

这个模块展示了更复杂的业务逻辑:

javascript

复制下载

// 条件性提交 mutation
addWangPerson(context, value) {
  if (value.name.indexOf('王') === 0) {
    context.commit('ADD_PERSON', value);
  } else {
    alert('添加的人必须姓王!');
  }
},

// 异步 API 调用
addServer(context) {
  axios.get('https://api.uixsj.cn/hitokoto/get?type=social').then(
    response => {
      const word = {id: nanoid(), name: response.data};
      context.commit('ADD_PERSON', word);
    },
    error => {
      alert(error.message);
    }
  )
}

六、Vuex 的最佳实践

6.1 严格遵循数据流

Vuex 强制实施一种单向数据流:

  1. 组件派发 Action
  2. Action 提交 Mutation
  3. Mutation 修改 State
  4. State 变化触发组件更新

6.2 合理使用模块化

  • 按功能或业务逻辑划分模块
  • 为所有模块启用命名空间
  • 保持模块的独立性

6.3 异步操作的处理

  • 所有异步逻辑放在 Actions 中
  • 保持 Mutations 的纯粹性(只做状态变更)
  • 合理处理异步错误

6.4 表单处理策略

在 MyPersons.vue 中,我们看到了典型的表单处理模式:

javascript

复制下载

add(){
  const personObj = {id: nanoid(), name: this.name};
  this.$store.commit('personAbout/ADD_PERSON', personObj);
  this.name = ''; // 清空表单
}

七、Vuex 的优缺点分析

7.1 优点

  1. 集中式状态管理:所有状态变化都可以追踪和调试
  2. 组件通信简化:跨组件数据共享变得简单
  3. 可预测的状态变化:通过严格的规则保证状态变化的可预测性
  4. 插件生态丰富:支持时间旅行、状态快照等高级功能
  5. TypeScript 支持:提供完整的类型定义

7.2 缺点

  1. 学习曲线:需要理解 Flux 架构思想
  2. 代码冗余:简单的应用可能不需要 Vuex
  3. 样板代码:需要编写一定量的模板代码
  4. 性能考虑:大型状态树可能影响性能

八、替代方案与未来趋势

8.1 Vuex 的替代方案

  1. Pinia:Vue.js 的下一代状态管理库,更加轻量且对 TypeScript 友好
  2. Composition API:使用 reactive 和 provide/inject 实现简单的状态共享
  3. 事件总线:适合小型应用的简单通信

8.2 Vuex 4 和 Vuex 5

  • Vuex 4 支持 Vue 3,API 基本保持不变
  • Vuex 5(开发中)将提供更好的 TypeScript 支持和更简洁的 API

结论

Vuex 作为 Vue.js 生态中成熟的状态管理方案,为构建中大型 Vue 应用提供了可靠的架构基础。通过本文的分析,我们可以看到 Vuex 如何:

  1. 提供集中式的状态管理
  2. 通过严格的规则保证状态变化的可预测性
  3. 通过模块化支持大型应用的状态管理
  4. 提供丰富的辅助函数简化开发

在实际项目中,是否使用 Vuex 应该根据应用规模和复杂度来决定。对于小型应用,简单的组件通信可能就足够了;但对于中大型应用,Vuex 提供的结构化状态管理方案将大大提升代码的可维护性和可扩展性。

随着 Vue 3 的普及,开发者也可以考虑使用 Composition API 或 Pinia 等更现代的解决方案,但 Vuex 的核心思想和设计模式仍然是值得学习和借鉴的宝贵经验。

告别重复传参!用柯里化提升代码优雅度

作者 鱼鱼块
2025年12月27日 23:54

柯里化:让函数“慢慢来”,一次只吃一口

在编程世界里,我们常常会遇到这样一种场景:一个函数需要多个参数才能完成任务。但有时候,这些参数并不会一下子全部准备好——可能今天知道第一个,明天拿到第二个,后天才凑齐全部。

这时候,柯里化(Currying) 就派上用场了。它就像一位耐心的厨师,不急着把整道菜做完,而是先记住你已经给的食材,等你把剩下的材料陆续送来,再一锅炒好。


从最简单的加法说起

假设我们有一个普通的加法函数:

function add(a, b) {
  return a + b;
}
console.log(add(1, 2)); // 输出 3

这很直接:两个数一起传进去,立刻出结果。但如果我只能先给你 a,过一会儿再告诉你 b 呢?普通函数就无能为力了。

于是我们可以手动“柯里化”一下:

function add(a) {
  return function(b) {
    return a + b;
  };
}
console.log(add(1)(2)); // 输出 3

你看,现在 add(1) 返回的是一个新函数,这个函数“记得”了 a = 1,等你再调用它传入 b,就能算出结果。这背后靠的是 闭包——内部函数可以“记住”外部函数的变量,即使外部函数已经执行完了。

这种写法虽然可行,但只适用于固定两个参数的情况。如果函数有三个、四个甚至更多参数,手动嵌套写起来会非常繁琐,而且难以复用


自动柯里化:通用解决方案

手动为每个函数写柯里化版本太麻烦了。有没有办法写一个“万能工具”,自动把任意多参函数变成可逐步传参的形式?

当然有!来看这个通用的 curry 函数:

function add(a, b, c, d) {
  return a + b + c + d;
}

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args); // 参数够了,直接执行
    }
    return (...rest) => curried(...args, ...rest); // 不够?继续收
  };
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)(4)); // 10
console.log(curriedAdd(1, 2)(3, 4)); // 10,也可以一次传多个

它是怎么工作的?

  • fn.length 是 JavaScript 中函数的一个属性,表示该函数声明时定义的参数个数(不包括剩余参数)。
  • 每次调用 curried,都会收集当前传入的参数(通过 ...args)。
  • 如果当前参数数量 ≥ 原函数所需参数数量,就立即执行 fn(...args)
  • 否则,返回一个新函数,这个新函数会把已有的 args 和后续传入的 rest 合并,再次调用 curried —— 这就是递归的思想。

只要收集到足够数量的参数,就立刻执行;否则,返回一个新函数继续等待。

🔍 注意:这个实现利用了 闭包 + 递归 的思想。每次调用都把已有的参数“存起来”,直到攒够为止。而闭包保证了这些中间参数不会丢失。


实战:日志函数的柯里化妙用

柯里化不只是炫技,它在实际开发中非常有用。比如处理日志:

const log = type => message => {
  console.log(`${type}: ${message}`);
};

const errorLog = log('ERROR');
const infoLog = log('info');

errorLog('接口异常');        // 输出: ERROR: 接口异常
infoLog('页面加载完成');     // 输出: info: 页面加载完成

这里,log 是一个柯里化函数。我们先固定日志类型(如 'ERROR'),得到一个专门打错误日志的函数 errorLog。以后只要传消息内容就行,不用每次都写类型。

优势在哪里?

  1. 减少重复代码:不需要每次写 log('error', 'xxx')
  2. 提高可读性errorLog('xxx')log('error', 'xxx') 更直观。
  3. 便于组合与复用:可以轻松创建不同级别的日志器,并在多个模块中共享。

总结:柯里化的三大核心

  1. 闭包:保存已传入的参数,不会被垃圾回收。
  2. 递归/链式调用:每次返回新函数,继续接收剩余参数。
  3. 退出条件:当参数数量达到原函数要求(fn.length),就执行并返回结果。

柯里化不是必须用的技术,但它能让你的函数更灵活、更具组合性。就像乐高积木——你可以先拼好一部分,等需要时再接上其他模块,最终搭出完整作品。

下次当你发现某个函数总是在不同地方传相同的前几个参数时,不妨试试柯里化——让函数学会“等一等”,说不定代码会变得更优雅!

static 和 lib/assets资源区别

作者 Robet
2025年12月27日 22:50

这是一个非常关键的问题!在 SvelteKit 项目中,static/ 目录和 src/lib/assets/(或任何 src/ 下的资源)处理方式完全不同,直接影响到:

  • 资源是否被 构建工具处理
  • 是否支持 哈希文件名(缓存优化)
  • 如何 引用资源路径
  • 是否能使用 ESM 导入语法

✅ 核心区别总结

特性 static/ 目录 src/lib/assets/(或其他 src/ 下)
是否经过 Vite 构建 ❌ 不处理,原样复制 ✅ 会经过 Vite 处理(压缩、哈希、优化)
输出位置 直接复制到 build/ 根目录 打包进 build/_app/assets/...
引用方式 绝对路径 /image.png 必须通过 ESM 导入import img from '$lib/assets/image.png'
文件名哈希 ❌ 无(不利于长期缓存) ✅ 有(如 image.abc123.png,利于 CDN 缓存)
适用场景 robots.txt、favicon.ico、sitemap.xml 等静态 Web 资源 应用内使用的图片、图标、字体等模块化资源

🔍 详细解释

1. static/ 目录 —— “纯静态 Web 根目录”

  • SvelteKit 在构建时,会原封不动地将 static/ 内容复制到输出目录(build/)的根部

  • 这些文件不会被 Vite 处理(不压缩、不重命名、不哈希)。

  • 浏览器通过 绝对路径 访问:

    <!-- 在 +layout.svelte 或 app.html 中 -->
    <link rel="icon" href="/favicon.ico" />
    <img src="/images/logo.png" />
    
  • 典型用途

    • favicon.ico
    • robots.txt
    • sitemap.xml
    • 第三方验证文件(如 google-site-verification=xxx.html
    • 需要固定 URL 的公开资源

⚠️ 如果你把应用内的图片放这里(如 /static/product.jpg),会导致:

  • 无法利用内容哈希 → 用户可能看到旧图(缓存问题)
  • 无法 tree-shaking 未使用的图片
  • 无法使用现代图像格式自动转换(如 WebP)

2. src/lib/assets/ —— “模块化资源”

  • 这些文件被视为 JavaScript 模块依赖

  • 必须通过 ESM import 语句 引用:

    <!-- 在 .svelte 或 .js 文件中 -->
    <script>
      import logo from '$lib/assets/logo.png';
    </script>
    
    <img src={logo} alt="Logo" />
    
  • Vite 会:

    • 为文件生成 唯一哈希名(如 logo.a1b2c3.png
    • 自动优化(压缩、转 WebP 等,需配置)
    • 只打包实际被引用的资源
    • 支持动态 import()(按需加载)
  • 输出路径示例:

    build/
    └── _app/
        └── assets/
            └── logo.a1b2c3.png
    

这是 SvelteKit 推荐的应用内资源管理方式!


🧪 对比示例

场景:在页面中显示一张 logo

❌ 错误做法(把应用图片放 static)

<!-- src/routes/+page.svelte -->
<img src="/logo.png" />  <!-- 来自 static/logo.png -->
  • 问题:URL 固定为 /logo.png,浏览器缓存后更新困难。

✅ 正确做法(用 $lib/assets/

<!-- src/routes/+page.svelte -->
<script>
  import logo from '$lib/assets/logo.png';
</script>
<img src={logo} />
  • 渲染结果:<img src="/_app/assets/logo.a1b2c3.png" />
  • 优势:文件名含哈希,更新即失效缓存,CDN 友好。

📁 推荐目录结构

project/
├── static/
│   ├── favicon.ico        ← 必须放这里(浏览器固定请求 /favicon.ico)
│   ├── robots.txt         ← 必须放这里
│   └── vercel.svg         ← 第三方验证文件
└── src/
    └── lib/
        └── assets/
            ├── images/
            │   ├── logo.png
            │   └── hero.jpg
            └── icons/
                └── star.svg

💡 特殊情况:什么时候用 static/ 存图片?

仅当你需要固定 URL 且不希望被构建工具处理时,例如:

  • 一个公开的分享图,URL 必须是 https://yoursite.com/share-banner.jpg
  • 第三方平台要求特定路径的图片(如微信公众号素材)
  • 非常大的背景图,不想被打包(但通常仍建议用 ESM + 动态导入)

否则,一律使用 $lib/assets/ + import


✅ 总结一句话

static/ 是给“Web 服务器”看的静态文件;
src/lib/assets/ 是给“你的应用代码”用的模块化资源。

❌
❌