普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月9日掘金 前端

一句话生成整套 API:我用 Claude Code 自定义 Skill + MCP 搞了个接口代码生成器

作者 jerrywus
2026年2月9日 10:24

一句话生成整套 API:我用 Claude Code 自定义 Skill + MCP 搞了个接口代码生成器

从 Swagger 文档到 TypeScript 类型、API 函数、Mock 数据,一句指令全自动。

前言

做前端的应该都经历过这种事:

后端丢来一个 Swagger 链接,然后你得:

  1. 打开文档,一个个看接口定义
  2. 手写 TypeScript 类型(请求参数、响应结构)
  3. 写 API 调用函数
  4. 造 Mock 数据给本地开发用
  5. 注册 Mock 路由

一个模块少说 2-3 个接口,这些重复劳动能耗掉半天。

后来我想,这活儿能不能自动化?于是折腾了一套方案,现在只要一句话:

实现接口:https://gateway.xxx.cn/doc.html#/组织架构服务/供应商管理/page_1

Claude Code 会自己打开 Swagger 文档、提取接口信息、让你勾选要实现哪些接口,然后并行生成所有代码。

这篇文章记录一下整个搭建过程和实际跑起来的效果。

整体架构

先看全貌,方案分三块:

┌─────────────────────────────────────────────────┐
│                  api-add Skill                   │
│              (工作流编排 / 入口)                    │
├─────────────────────────────────────────────────┤
│                                                  │
│  ┌──────────────────┐                            │
│  │ chrome-devtools   │  ← 读取 Swagger 文档       │
│  │      MCP          │  ← 提取接口信息             │
│  └──────────────────┘                            │
│           │                                      │
│           ▼                                      │
│  ┌──────────────────────────────────┐            │
│  │        Agent Team (并行)          │            │
│  │  ┌────────────┐ ┌─────────────┐  │            │
│  │  │ api-define │ │ mock-create │  │            │
│  │  │  (Haiku)   │ │  (Haiku)    │  │            │
│  │  │            │ │             │  │            │
│  │  │ TS 类型    │ │ Mock 数据    │  │            │
│  │  │ API 函数   │ │ Mock 路由    │  │            │
│  │  └────────────┘ └─────────────┘  │            │
│  └──────────────────────────────────┘            │
│                                                  │
└─────────────────────────────────────────────────┘
  • Skill:自定义技能,定义工作流怎么跑
  • MCP (Model Context Protocol):让 AI 能操控浏览器,直接读文档
  • Agent Team:两个 Agent 同时干活,一个写类型和 API,一个写 Mock

下面一个个说。

一、Chrome DevTools MCP -- 让 AI "看见"浏览器

MCP 是什么?

MCP(Model Context Protocol)是 Anthropic 出的一个开放协议,让 AI 能跟外部工具交互。简单说就是 AI 的插件系统,接上不同的 MCP Server,AI 就多了一种能力。

为什么要用 Chrome DevTools MCP?

Swagger/Knife4j 文档是动态渲染的 SPA 页面。你用 fetchcurl 去请求,拿到的只是一个空壳 HTML,接口信息全靠 JS 渲染出来,根本抓不到。

Chrome DevTools MCP 能让 AI 操控一个真实的浏览器:

  • 打开页面,等 JS 渲染完
  • 读取页面的可访问性树(Accessibility Tree)
  • 点击元素、做页面交互

说白了就是让 AI 能像人一样看网页。

怎么配置

在 Claude Code 里添加 chrome-devtools MCP server:

chrome devtools mcp github 地址

  • 打开 github 项目页面,找到 Claude Code 的配置指令。进入项目根目录,终端执行:
claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
  • 然后在项目 .claude 目录下创建 mcp.json
{
  "mcpServers": {
    "chrome-devtools": {
      "command": "npx",
      "args": [
        "-y",
        "chrome-devtools-mcp@latest"
      ]
    }
  }
}

配好之后 Claude Code 就能操作浏览器了,主要用到这几个工具:

工具 干什么的
navigate_page 打开指定 URL
take_snapshot 获取页面快照(可访问性树)
click 点击页面元素
fill 填写表单
take_screenshot 截图

我们这个场景主要用前三个:导航、快照、点击。

二、api-add Skill -- 工作流编排

Skill 是什么?

Claude Code 的 Skill 就是一个 Markdown 文件,告诉 AI 碰到什么情况该怎么做。里面写清楚:

  • 什么时候触发
  • 按什么步骤执行
  • 有什么限制

文件放在 .claude/skills/<skill-name>/SKILL.md,Claude Code 启动时会自动加载。

api-add Skill 怎么设计的

我想要的效果是:给一个 Swagger URL,自动把接口文档变成可用的代码。

.claude/skills/api-add/SKILL.md
---
name: api-add
description: 从 Swagger 文档或 md 文档快速创建 API function、
  TypeScript 类型定义和 Mock 实现。
  触发关键词:实现接口、创建接口、添加API、接口定义。
---

# API from Swagger Doc

## skill 触发场景

### 场景1
用户提供接口 url,并说实现接口定义

### 场景2
用户指定一个 md 文档,则直接从文档中读取接口定义

## 工作流程

### 第一步:获取接口信息

使用 chrome-devtools-mcp 读取 Swagger 文档:

1. 使用 navigate_page 打开 Swagger URL
2. 使用 take_snapshot 读取页面内容
3. 展开左侧菜单,获取当前分类下的所有接口列表
4. 使用 AskUserQuestion,列出所有接口供用户选择
5. 用户确认后,逐一点击并提取完整信息

### 第二步:创建 Agent Team 并行生成代码

创建 2 个 teammate 分别负责:
- api-define:TypeScript 类型 + API 函数
- mock-create:Mock 数据 + Mock 路由

### 第三步:清除 teams 并结束

这里说几个我做的选择:

1. 为什么用 MCP 而不是直接请求 API?

Swagger 文档是前端渲染的 SPA,HTTP 请求拿不到内容。必须在真实浏览器里跑一遍 JS 才能看到接口信息。

2. 为什么要让用户选接口?

一个模块可能有十几个接口,但这次迭代可能只用到其中两三个(或者部分接口已经实现过了)。让用户自己勾选,省得生成一堆用不上(或者重复)的代码。

3. 为什么用 Agent Team?

写 TypeScript 类型/API 函数和写 Mock 数据/路由,这两件事互不依赖。让两个 Agent 同时跑,时间省一半。而且 Agent 用的是 Haiku 模型,比主模型便宜很多。

✏️ 我测试了一下,单独写⬆是6分钟多一点;使用agent teams 是4分钟多一点(因为是小功能, 时间节省不太明显, 但贵在省时间。 你可以尝试大功能,比如实现一个复杂的模块,时间节省会更明显)

三、Agent 定义 -- 分工干活

除了 Skill,还得定义两个 Agent,它们才是真正写代码的。

api-define Agent

.claude/agents/api-define.md
---
name: api-define
description: 实现指定模块的 api function & typescript 类型的创建
model: haiku
color: green
---

实现指定模块的 api function & typescript 类型的创建,
严格按以下要求实现:

1. 严格参照 .claude/rules/ 中的编码规范
2. 完整实现:TypeScript 类型、API 函数

mock-create Agent

.claude/agents/mock-define.md
---
name: mock-create
description: 实现指定 api 接口的 mock 实现
model: haiku
color: orange
---

实现指定 api 接口的 mock 实现,严格按以下要求实现:

1. 严格参照 .claude/rules/ 中的编码规范
2. 完整实现:Mock 服务器(mocks 目录),
   实现 Express 接口(routes、controllers、data)

几个值得说的点:

  • model: haiku -- 用轻量模型就够了,写这种模式化的代码不需要大模型,跑得快还省钱
  • "严格参照编码规范" -- 靠 .claude/rules/ 里的规则文件约束代码风格,后面会讲
  • color -- 终端里用不同颜色区分两个 Agent 的输出,看着方便

四、实战演示

来看实际跑一遍是什么样。我要给"供应商管理"模块实现接口。

Step 1:触发 Skill

只需要输入一句话:

实现接口:https://gateway.xxx.cn/doc.html#/组织架构服务/供应商管理/page_1

Claude Code 会自动识别到 api-add Skill,加载后通过 MCP 打开 Swagger 文档:

image.png

Step 2:读取文档,选择接口

AI 通过浏览器快照读到页面内容,找到左侧菜单里"供应商管理"下的所有接口,弹出选择框让我勾:

image.png

它做了这几件事:

  • 识别了左侧菜单的接口列表(POST 分页列表、GET 配置商户)
  • 点进每个接口 Tab,提取了完整的请求参数和响应结构
  • URL 指向的"分页列表"被标成了推荐选项

我两个都选了。

Step 3:Agent Team 并行干活

确认后,Claude Code 起了一个 Agent Team,两个 Agent 同时开工:

image.png

截图里能看到:

  • api-definer(绿色)在写 TypeScript 类型定义和 API 函数
  • mock-creator(橙色)在写 Mock 数据和路由
  • 两个同时跑,互不影响
  • 底部状态栏显示着两个 Agent 的运行状态

Step 4:完成,收工

两个 Agent 干完活,自动关闭并清理资源:

image.png

最终生成了这些文件:

# API & 类型定义
src/types/supply-company.ts          ← TypeScript 类型
src/api/supply-company/index.ts      ← API 函数

# Mock 实现
mocks/routes/data/supply-company-page.json    ← Mock 数据
mocks/routes/supply-company.controller.cjs    ← Mock 控制器
mocks/routes/org.cjs                          ← 路由挂载(已更新)

五、看看生成的代码

代码质量怎么样?直接贴。

TypeScript 类型定义

部分展示:

// src/types/supply-company.ts

/** 供应商分页查询参数 */
export interface ISupplyCompanyPageParam extends IPageParam {
  /** 供应商名称 */
  name?: string;
}

/** 供应商分页列表项 */
export interface ISupplyCompanyPageVO {
  /** 供应商组织id */
  orgId: number;
  /** 公司编码 */
  code: string;
  /** 供应商名称 */
  orgName: string;
  /** 负责人id */
  staffId: number;
  /** 负责人姓名 */
  userName: string;
  /** 状态 */
  status: string;
  /** 所属商户 */
  merchantName: string;
  /** 创建人 */
  creator: string;
  /** 创建时间 */
  createTime: string;
}

I 前缀、JSDoc 注释、继承 IPageParam,跟项目里手写的一模一样。

API 函数

部分展示:

// src/api/supply-company/index.ts

export async function querySupplyCompanyPage(
  params: ISupplyCompanyPageParam
) {
  let total = 0;
  let data = [] as ISupplyCompanyPageVO[];
  params = toConditional(params);

  try {
    const { code, context, message } = await Http.post<{
      total: number;
      data: ISupplyCompanyPageVO[];
    }>(`${baseUrl}/page`, { ...params });

    if (code !== EResponseCode.Succeed) {
      throw new Error(message || '服务器异常,请稍后再试~');
    }
    total = context?.total || 0;
    data = context?.data || [];
  } catch (error) {
    throw new Error(getHttpErrorMessage(error));
  }

  return { total, data };
}

项目里标准的 API 写法:async/await + try/catch + toConditional + 错误处理,一个不差。

Mock 数据

部分展示:

// mocks/routes/data/supply-company-page.json
[
  {
    "orgId": 1001,
    "code": "SC-2025-001",
    "orgName": "上海奢品供应链有限公司",
    "staffId": 2001,
    "userName": "张经理",
    "status": "ENABLED",
    "merchantName": "LuxMall旗舰店",
    "creator": "系统管理员",
    "createTime": "2025-01-15 10:30:00"
  }
  // ... 更多数据
]

Mock 数据的字段值是有意义的中文内容,不是那种 "string" 占位符。

Mock 控制器

部分展示:

// mocks/controllers/supply-company.controller.cjs

const express = require('express');
const router = express.Router();
const supplyCompanyList = require('./data/supply-company-list.json');

/**
 * 供应商分页列表
 * POST /page
 */
router.post('/page', (req, res) => {
  let all = JSON.parse(JSON.stringify(supplyCompanyList));
  const { page = 1, size = 50, name } = req.body || {};

  // 按供应商名称模糊搜索
  if (name) {
    all = all.filter((item) =>
      String(item.orgName).includes(String(name))
    );
  }

  const total = all.length;
  const start = (Number(page) - 1) * Number(size);
  const end = start + Number(size);
  const data = all.slice(start, end);

  res.json({
    code: 0,
    message: null,
    context: { total, data },
    traceId: '',
    spanId: '',
  });
});

module.exports = router;

分页、模糊搜索、标准响应格式,都按项目的 Mock 规范来的。

六、代码质量靠什么保证?Rules

你可能会想:AI 怎么知道我们项目的编码规范?

.claude/rules/ 目录。这是 Claude Code 的规则系统,你可以理解为给 AI 写了一份项目编码手册:

.claude/rules/
├── api.md           ← API 实现标准(函数命名、错误处理模式)
├── ts-define.md     ← TypeScript 规范(I前缀、E前缀、JSDoc)
├── mock.md          ← Mock 服务器架构(路由、控制器、数据文件)
├── components.md    ← 组件库参考
├── vue-single-file.md ← Vue SFC 标准
└── ...

每个 Agent 工作时都会读这些规则文件。所以生成出来的代码风格跟项目里手写的一致,不会出现那种一看就是 AI 写的通用代码。

七、想复刻?文件结构在这

如果你想在自己项目里搞一套,需要这些文件:

.claude/
├── agents/
│   ├── api-define.md          ← API 定义 Agent
│   └── mock-define.md         ← Mock 创建 Agent
├── skills/
│   └── api-add/
│       └── SKILL.md           ← 工作流编排 Skill
├── rules/
│   ├── api.md                 ← API 编码规范
│   ├── ts-define.md           ← TypeScript 规范
│   └── mock.md                ← Mock 规范
└── ...

# MCP 配置(项目级或全局)
.mcp.json                      ← Chrome DevTools MCP 配置

八、效果对比

维度 手动开发 api-add Skill
耗时 6.5m 4.3m
类型定义 手动从文档抄 自动提取,不会漏字段
API 函数 复制模板手动改 自动生成,符合规范
Mock 数据 手动编假数据 自动生成,内容像真的
代码规范 看个人习惯 Rules 强制约束
人为错误 字段名拼错、类型写错 从文档直接提取,基本不会错

总结

回头看,这套方案做了四件事:

  1. 用 MCP 让 AI 能读浏览器里的 Swagger 文档
  2. 用 Skill 把多步骤任务编排成一句话就能触发的流程
  3. 用 Agent Team 让两个轻量 Agent 并行干活,省时间也省钱
  4. 用 Rules 约束代码风格,保证生成的代码跟手写的一样

说到底就是把"从文档到代码"这个重复劳动自动化了。

这套方案也不只能用在 Swagger 上。改一下 Skill 的工作流,Apifox、Postman、自定义 Markdown 文档、GraphQL Schema,只要浏览器能打开的接口文档都能接。

如果你也在用 Claude Code,可以试试 Skill + MCP 这个组合。


觉得有用的话点个赞,也欢迎在评论区聊聊你的 Claude Code 玩法。

【前端缓存】localStorage 是同步还是异步的?为什么?

作者 大知闲闲i
2026年2月9日 10:17

localStorage 是同步的,其设计初衷是为了简化 API 并适应早期的 Web 应用场景。尽管底层硬盘 IO 本质上是异步的,但浏览器通过阻塞 JavaScript 线程实现了同步行为。对于需要存储大量数据或避免阻塞主线程的场景,建议使用异步的 IndexedDB。

一、为什么会有这样的问题?

localStorage 是 Web Storage API 的一部分,它提供了一种存储键值对的机制。它的数据是持久存储在用户的硬盘上的,而不是内存中。这意味着即使用户关闭浏览器或电脑,数据也不会丢失,除非主动清除浏览器缓存或使用代码删除。

当你通过 JavaScript 访问 localStorage 时,浏览器会从硬盘中读取数据或向硬盘写入数据。虽然读写操作期间,数据可能会被暂时存放在内存中以提高处理速度,但其主要特性是持久性,并且不依赖于会话。

二、硬盘是 IO 设备,IO 读取不都是异步的吗?

是的,硬盘确实是 IO 设备,大部分与硬盘相关的操作系统级 IO 操作是异步进行的,以避免阻塞进程。但在 Web 浏览器环境中,localStorage 的 API 被设计为同步的,即使底层的硬盘读写操作具有 IO 特性。

JavaScript 代码在访问 localStorage 时,浏览器提供的 API 通常会在 js 执行线程上下文中直接调用。这意味着尽管硬盘需要等待数据读取或写入完成,localStorage 的读写操作是同步的,会阻塞 JavaScript 线程直到操作完成。

三、完整操作流程

localStorage 实现同步存储的方式是阻塞 JavaScript 的执行,直到数据的读取或写入操作完成。这种同步操作的实现可以简单概述如下:

  1. js线程调用:当 JavaScript 代码执行一个 localStorage 的操作,比如 localStorage.getItem('key')localStorage.setItem('key', 'value'),这个调用发生在 js 的单个线程上。

  2. 浏览器引擎处理:浏览器的 js 引擎接收到调用请求后,会向浏览器的存储器子系统发出同步 IO 请求。此时 js 引擎等待 IO 操作的完成。

  3. 文件系统的同步 IO:浏览器存储器子系统对硬盘执行实际的存储或检索操作。尽管操作系统层面可能对文件访问进行缓存或优化,但从浏览器的角度看,它会进行一个同步的文件系统操作,直到这个操作返回结果。

  4. 操作完成返回:一旦 IO 操作完成,数据要么被写入硬盘,要么被从硬盘读取出来,浏览器存储器子系统会将结果返回给 js 引擎。

  5. JavaScript 线程继续执行:js 引擎在接收到操作完成的信号后,才会继续执行下一条 js 代码。

四、为什么 localStorage 被设计为同步的?

  1. 历史原因:localStorage 是在早期 Web 标准中引入的,当时的 Web 应用相对简单,对异步操作的需求并不强烈。

  2. API 简洁性:同步 API 更易于理解和使用,开发者无需处理回调或 Promise,代码更直观。

  3. 数据量小:localStorage 设计用于存储少量数据(通常为 5MB 左右),同步操作在数据量较小时对性能影响不大。

  4. 兼容性考虑:保持同步行为有助于兼容旧代码和旧浏览器。

  5. 浏览器政策:浏览器厂商可能出于提供一致用户体验或方便管理用户数据的角度,选择保持其同步特性。

五、那 IndexedDB 会造成滥用吗?

虽然 IndexedDB 提供了更大的存储空间和更丰富的功能,但潜在地也可能被滥用。不过,相比 localStorage,它增加了一些特性来降低被滥用的风险:

  1. 异步操作:IndexedDB 是异步 API,即使处理更大的数据也不会阻塞主线程,避免对页面响应性的直接影响。

  2. 用户提示和权限:某些浏览器在网站尝试存储大量数据时,可能会弹出提示要求用户授权,使用户有机会拒绝超出合理范围的存储请求。

  3. 存储配额和限制:尽管 IndexedDB 提供的存储容量比 localStorage 大得多,但它也不是无限的。浏览器会设定一定的存储配额,超出时拒绝更多的存储请求。

  4. 更清晰的存储管理:IndexedDB 的数据库形式允许有组织的存储和更容易的数据管理,用户或开发者可以更容易地查看和清理占用的数据。

  5. 逐渐增加的存储:某些浏览器在数据库大小增长到一定阈值时,可能会提示用户是否允许继续存储,而不是一开始就分配很大的空间。

六、一个简单测试例子

平时编写代码时,我们并没有以异步的方式使用 localStorage。以下是一个简单的测试示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
<script>
const testLocalStorage = () => {
    console.log("==========> 设置 localStorage 之前");
    localStorage.setItem('testLocalStorage', '我是同步的');
    console.log("==========> 获取 localStorage 之前");
    console.log('==========> 获取 localStorage', localStorage.getItem('testLocalStorage'));
    console.log("==========> 获取 localStorage 之后");
}
testLocalStorage();
</script>
</body>
</html>

运行上述代码,你会发现日志输出是顺序执行的,验证了 localStorage 的同步特性。

Chrome 插件实战:如何实现“杀不死”的可靠数据上报?

2026年2月9日 10:06

最近有一个需求:“监控用户怎么用标签组(Tab Groups),打开了啥,关闭了啥,统统都要记下来上报给服务器!

如下就是一个标签组:

image.png

初看这个需求,似乎很简单:监听一下事件,调接口上报一下完事儿。

但仔细一想,为了保证数据的可靠性,还有几个“隐形坑”必须填上:

  1. 用户网断了怎么办? 数据不能丢,等网好了得自动补发
  2. 用户直接 Alt+F4 关浏览器怎么办? 必须在浏览器被杀死的瞬间,或者下次启动时把关闭日志数据发出去。
  3. 高频操作怎么办? 如果用户一秒钟关了 20 个组,不能卡顿,数据写入也不能错乱、丢失。
  4. 服务器挂了怎么办? 本地不能无限存,否则会把用户浏览器撑爆。

核心策略:

解决方案一句话总结:

监听标签组的开启和关闭,开启或关闭的时候,产生的日志第一时间先写到本地硬盘(Storage)中,然后再尝试上报到服务端,只有当上报成功了才从本地存储删。只要没删,就依靠定时任务死磕到底。

流程设计

  1. 拦截事件:监听 chrome.tabGroupsonCreatedonRemoved

  2. 持久化 (Persist)第一时间将数据写入 chrome.storage.local。哪怕下一毫秒浏览器崩溃,数据也在硬盘里。

  3. 上报 (Report):使用fetch尝试发送 HTTP 请求(开启 keepalive)。

  4. 提交 (Commit)

    • 如果成功:从本地存储中删除该条记录。
    • 如果失败:保留记录,等待重试。

为什么不用 navigator.sendBeacon?

你可能会想到用 navigator.sendBeacon 来解决关闭浏览器时的数据丢失问题。 确实,sendBeacon 是为了“页面卸载”场景设计的,但它有两个致命缺点:

  1. 无法获取服务器响应:它只返回 true/false 表示“是否放入队列”,不代表服务器处理成功。
  2. 无法做“成功即删”:我们的 WAL 策略要求 只有服务器返回 200 OK,才从本地删除数据。如果用 sendBeacon,我们不知道是否发送成功,就无法安全地删除本地数据(删了可能丢,不删可能重)。

因此,我们选择 fetch 配合 keepalive: true

一句话总结:fetch + keepalive 能覆盖 sendBeacon 的“卸载场景尽量发出去”的能力,同时我们还能拿到响应状态码,从而做到“确认服务端收到了才删除本地”。

参考链接:developer.mozilla.org/en-US/docs/…

关键实现细节

Chrome 插件里的 chrome.storage 读写是异步的,所以会有竞态问题。

前提: 我们为了管理方便,通常会把所有日志放在同一个 Key(例如 logs)下的一个数组里。正是因为大家抢着改这同一个数组,才出了事。

为什么单线程也有竞态?

JS 是单线程的,但 await 会挂起当前任务并释放主线程的控制权。在 await get()await set() 之间,其他事件处理函数可能插入执行并修改数据。

const task = (group) => {
    // ...
    const data = await chrome.storage.local.get(...); // 暂停,释放控制权
    // ... (此时其他事件可能插入执行,修改了 storage) ...
    await chrome.storage.local.set(...); // 写入,可能会覆盖别人的修改
    // ...
}

// 标签组关闭的时候触发
chrome.tabGroups.onRemoved.addListener(task);

举个例子:假设你创建了两个标签页分组,这两个标签组同时关闭(A 和 B),就触发标签组关闭事件,就会触发两次task函数的执行。

  1. Task A:执行 get(),读取到 [1]。准备写入 3,遇 await 挂起。
  2. Task B:因为 A 暂停了,JS 引擎转而处理 taskB。执行 get()。因 A 尚未写入3,B 读取到的仍是 [1]。准备写入4,遇 await 挂起。
  3. Task A:恢复。内存数据变为 [1,3]。执行 set() 写入硬盘。
  4. Task B:恢复。内存数据变为 [1, 4]。执行 set() 写入硬盘。

结果:最终设置进存储的是 [1, 4],数据 3 被 B 的写入覆盖丢失了!这就是经典的“读-改-写”竞争。

解决方案

为了解决这个问题,我们可以利用 Promise 链实现一个简单的“任务队列”,强制所有存储操作排队执行:

// 全局任务队列
let globalTaskQueue = Promise.resolve();

/**
 * 串行执行器:无论外界如何并发调用,内部永远排队执行
 */
function runSequentially(task) {
  // 1. 把新任务拼到队列尾部
  // 无论之前的任务有没有做完,新任务都得排在 globalTaskQueue 后面执行
  const next = globalTaskQueue.then(() => task());
  
  // 2. 更新队列指针
   // 关键点:如果 next 失败(Rejected),catch错误,防止一个任务失败阻塞整个队列,
   // catch会返回一个新的 Resolved Promise
   // 所以 globalTaskQueue 总是指向一个“健康”的 Promise,确保后续任务能接上
  globalTaskQueue = next.catch(() => {});
  return next;
}

// 使用示例
async function saveReport(report) {
  const task = async () => {
    const data = await chrome.storage.local.get(['reports']);
    // ... 读写逻辑 ...
    await chrome.storage.local.set({ reports: newData });
  };

  return runSequentially(task);
}

原理解析:

这就好比 排队做核酸globalTaskQueue 就是队伍的最后一个人。

  1. 初始状态:队伍里没人(Promise.resolve())。
  2. A 来了:调用 runSequentially(TaskA)
    • globalTaskQueue.then(() => TaskA()):A 站在了队伍最后。
    • globalTaskQueue 更新指向 A。
  3. B 来了:调用 runSequentially(TaskB)
    • 此时 globalTaskQueue 指向 A。
    • A.then(() => TaskB()):B 站在了 A 后面。哪怕 A 还在做(pending),B 也得等着。
    • globalTaskQueue 更新指向 B。

为什么要 .catch(() => {})

如果不加 catch,万一 A 做核酸时晕倒了(抛出 Error),整个 Promise 链就会中断(Rejected),导致排在后面的 B、C、D 全都无法执行。 加上 catch 后,相当于把晕倒的 A 抬走,队伍继续往下走,B 依然能正常执行。

思考:能不能通过拆分 Key 来避免竞态问题?

你可能会提出:“能不能把每个标签组日志存成独立的 Key(如 report-分组id),读取时遍历所有 report- 开头的 Key?这样不就完全避免了数组并发读写冲突了吗?

这方案可行,且非常巧妙!

优点:

  1. 天然无锁(各写各的):A 写入 report-A,B 写入 report-B。这就好比大家各自在自己的本子上写字,而不是去抢同一块黑板。既然资源不共享,自然就不需要“排队”或“加锁”,彻底根治了并发冲突。

  2. 性能极高:写入是 O(1) 的纯追加操作。

缺点:

  1. Key 污染:chrome.storage` 就像一个抽屉。如果你往里塞了 1000 张“小纸条”(独立的 Key),当你想要找别的东西(比如配置项)时,会被这些碎纸条淹没,调试的时候简直要疯。
  2. 找起来慢(全量扫描):虽然写的时候快,但读的时候慢死了。每次启动补发数据,你必须把抽屉彻底翻个底朝天(get(null)),把所有东西倒在桌上,再一张张挑出是日志的纸条。数据一多,这操作卡得要命。

2. 双重重试机制 (保证最终一致性)

当浏览器被直接关闭时,插件进程不会瞬间消失。浏览器会先关闭所有标签和分组,这会触发插件的 onRemoved 事件。我们利用这最后几百毫秒的“回光返照”时间,接收关闭消息并将数据抢先存入本地硬盘,然后再尝试进行数据上报。

不过还是会有数据积压到本地的情况,“不是说日志上报成功了就删吗?为什么本地还会有积压数据?”

没错,理想情况下本地存储应该是空的。但在现实世界中,意外无处不在:

  1. 用户断网了(比如连着 Wi-Fi 但没外网,或者在飞机上)。
  2. 服务器挂了(接口返回 500 或超时)。
  3. 浏览器崩溃:虽然崩溃瞬间插件无法监听新事件,但之前已经存入硬盘的任务可能还没来得及发出去(或者发到一半进程没了),这些数据依然安全地躺在硬盘里。

在这些情况下,数据发不出去,就必须滞留在本地等待下一次机会。我们需要建立一套机制,把这些“漏网之鱼”捞出来重发。

  • 时机一:浏览器启动时 (onStartup) 用户再次打开浏览器时,说明环境可能恢复了(比如连上了网),这是补发积压数据的绝佳时机。

  • 时机二:定时器轮询 (alarms) 如果用户一直不关闭浏览器,我们也不能干等。利用 chrome.alarms 设置一个每 5 分钟的定时任务。

    灵魂拷问:为什么不用 setInterval

    说白了就一句话:MV3版本插件的 Service Worker 不会一直在线。

    它是事件驱动的:浏览器有事件推送到Service Worker的话就起来干活;活干完、并且一会儿没新事件,浏览器就把它挂起/回收(内存清空)省资源。

    • setInterval / setTimeout:本质是“内存里自己数秒”。Service Worker 一被挂起/回收,计时器直接断电,你就别指望它“每 5 分钟准点打卡”了。
    • chrome.alarms:浏览器帮你托管的闹钟。时间到了就发 alarms.onAlarm 事件,必要时还能把 Service Worker 叫醒来处理。

    结论很简单:想要靠谱的定时重试,用 chrome.alarmssetInterval 适合页面这种常驻环境里的小轮询。

// 监听定时器触发:浏览器到点会派发事件,必要时唤醒 SW
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === ALARM_NAME) processPendingReports();
});

// JS 版伪代码:读本地 -> 丢弃过期 -> 逐条上报 -> 上报成功才删除(失败继续留着等下次)
async function processPendingReports() {
  const reports = (await storageGet('pending_reports')) ?? [];

  const pending = removeExpired(reports);
  if (pending.length !== reports.length) {
    await storageSet('pending_reports', pending);
  }

  for (const report of pending) {
    const ok = await sendReport(report);
    if (ok) await storageRemove('pending_reports', report.id);
  }
}

// 说明:这里的 storageGet/storageSet/storageRemove 是为了讲清流程的伪函数,
// 这几个是对chrome.storage.local.get/set的封装。

3. 自我保护机制 (防堆积)

背景知识:

数据存储在 storage.local 中,并在移除扩展程序时自动清除。存储空间限制为 10 MB(在 Chrome 113 及更早版本中为 5 MB),但可以通过请求 "unlimitedStorage" 权限来增加此限制。默认情况下,它会向内容脚本公开,但可以通过调用 chrome.storage.local.setAccessLevel() 来更改此行为。 参考:Chrome Storage API

尽管有 10MB 甚至无限的空间,但如果服务器彻底挂了,或者用户处于断网环境、秒关浏览器,本地数据依然会无限膨胀,最终影响性能。

所以我们需要设置熔断机制

  • 容量限制:最多保留 N 条(例如 1000 条),新数据挤占旧数据。
  • 有效期限制:数据产生超过 7 天未上报成功,视为过期数据直接丢弃。
  • 数据压缩:如果单条日志比较大(URL 很长/字段很多),可以考虑把数据压缩后再存。

代码示例:Step 1 - 存储与压缩

const MAX_REPORTS = 1000;
const REPORT_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7天
const STORAGE_KEY = 'pending_reports';

async function saveReport(newReport) {
  // 使用之前定义的串行锁,防止并发冲突
  return runSequentially(async () => {
    // 1. 读取现有数据
    const result = await chrome.storage.local.get([STORAGE_KEY]);
    let reports = result[STORAGE_KEY] || [];

    // 2. 追加新报告 (可选:先进行压缩)
    // 使用原生 CompressionStream (Gzip) 进行压缩,能大幅节省空间
    const reportToSave = await compressReport(newReport); 
    reports.push(reportToSave);

    // 3. 执行熔断策略(自我保护)
    const now = Date.now();
    
    // 3.1 有效期限制:过滤掉过期的
    reports = reports.filter(r => (now - r.timestamp) <= REPORT_EXPIRATION_MS);

    // 3.2 容量限制:如果还超标,剔除最旧的
    if (reports.length > MAX_REPORTS) {
      reports.shift();
    }

    // 4. 写回硬盘
    await chrome.storage.local.set({ [STORAGE_KEY]: reports });
  });
}

/**
 * 使用原生 CompressionStream API 进行 Gzip 压缩
 * 流程:JSON -> String -> Gzip Stream -> ArrayBuffer -> Base64
 *
 * 为什么要这么转?
 * 1. CompressionStream 只接受流(Stream)作为输入。
 * 2. chrome.storage 只能存储 JSON 安全的数据(字符串/数字/对象),不能直接存二进制(ArrayBuffer/Blob)。
 * 3. 所以必须把压缩后的二进制数据转成 Base64 字符串才能存进去。
 */
async function compressReport(report) {
  // 1. 转字符串
  const jsonStr = JSON.stringify(report);
  
  // 2. 创建压缩流
  const stream = new Blob([jsonStr]).stream().pipeThrough(new CompressionStream('gzip'));
  
  // 3. 读取流为 ArrayBuffer
  const compressedResponse = await new Response(stream);
  const blob = await compressedResponse.blob();
  const buffer = await blob.arrayBuffer();

  // 4. 转 Base64 存储 (storage 不支持直接存二进制 Blob)
  return {
    id: report.id || Date.now(),
    timestamp: report.timestamp,
    // 标记这是压缩数据
    isCompressed: true,
    data: arrayBufferToBase64(buffer)
  };
}

// 辅助函数:ArrayBuffer 转 Base64
function arrayBufferToBase64(buffer) {
  let binary = '';
  const bytes = new Uint8Array(buffer);
  const len = bytes.byteLength;
  for (let i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

代码解释:

  1. 关于 saveReport 的熔断逻辑

    • runSequentially:这就是我们前面提到的“排队做核酸”,防止同时写文件导致数据错乱。
    • filter 过期数据:每次写入前,顺手把 7 天前的“老古董”清理掉,保持队列新鲜。
    • shift 剔除旧数据:如果队列满了(超过 1000 条),就狠心把最老的那条删掉,给新数据腾位置。 (注意:虽然可以申请 unlimitedStorage 获得无限空间,但CPU/内存和序列化/反序列化开销仍然存,。如果队列太长,每次读取都会卡顿,所以必须限制数量。)
  2. 关于 compressReport 的二进制转换

    • new Response(stream):这其实是个偷懒的小技巧。CompressionStream 吐出来的是个流(Stream),要把它变成我们能处理的二进制块(ArrayBuffer),按理说得写个循环一点点读。但浏览器的 Response 对象自带了“把流一口气吸干并转成 Blob”的功能,所以我们借用它来省去写循环读取的麻烦。
    • Base64 转码chrome.storage 比较娇气,它只能存字符串或 JSON 对象,存不了二进制数据(ArrayBuffer)。如果你直接把压缩后的二进制扔进去,它会变成一个空对象 {}。所以我们需要把二进制数据“编码”成一串长长的字符串(Base64),存的时候存字符串,取的时候再还原回去。

代码示例:Step 2 - 读取与上报

既然存进去了,怎么发出来呢?可以直接发 Base64 吗?

可以,但没必要。 即使算上 Base64 的 33% 膨胀,压缩后(100KB -> 26.6KB)依然血赚。但转回 Binary 有两个核心优势:

  1. 极致省流:把那 33% 的膨胀再压回去(26.6KB -> 20KB)。
  2. 不给后端找麻烦:只要加上 Content-Encoding: gzip,服务器网关(Nginx)会自动解压,后端业务代码拿到的直接就是 JSON。如果你发 Base64,后端还得专门写代码先解码再解压,容易被同事吐槽

这里就轮到 base64ToUint8Array 登场了:

// 辅助函数:Base64 转 Uint8Array
function base64ToUint8Array(base64) {
  const binaryString = atob(base64);
  const bytes = new Uint8Array(binaryString.length);
  for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }
  return bytes;
}

async function sendReport(report) {
  let body = report;
  const headers = { 'Content-Type': 'application/json' };

  if (report.isCompressed) {
    // 1. Base64 -> 二进制 (还原体积)
    // 这一步至关重要!如果不转回二进制直接发 Base64,流量会白白增加 33%
    const binaryData = base64ToUint8Array(report.data);
    
    // 2. 直接发送二进制,并告诉服务器:“我发的是 Gzip 哦”
    body = binaryData;
    headers['Content-Encoding'] = 'gzip';
  } else {
    // 兼容旧数据
    body = JSON.stringify(report);
  }

  await fetch('https://api.example.com/log', {
    method: 'POST',
    headers: headers,
    body: body,
    keepalive: true
  });
}

总结:数据流转全景

  • 存(Storage)JSON -> Gzip -> Base64 (为了存 Storage)
  • 发(Network)Base64 -> Binary -> Network (利用 Content-Encoding: gzip)

完整流程图

image.png

总结

在前端(尤其是离线优先或插件环境)做数据上报,“即时发送”是不可靠的。通过引入本地存储作为缓冲区,配合串行锁定时重试容量控制,我们构建了一个健壮的日志上报系统。

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

作者 SmalBox
2026年2月9日 09:58

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

曝光节点是Unity Shader Graph中一个功能强大的工具节点,专门用于在着色器中访问摄像机的曝光信息。在基于物理的渲染(PBR)流程中,曝光控制是实现高动态范围(HDR)渲染的关键组成部分,而曝光节点则为着色器艺术家提供了直接访问这些曝光参数的途径。

曝光节点的核心功能是从当前渲染管线中获取摄像机的曝光值,使着色器能够根据场景的曝光设置做出相应的反应。这在创建对光照条件敏感的着色器效果时尤为重要,比如自动调整材质亮度、实现曝光自适应效果或者创建与摄像机曝光设置同步的后期处理效果。

在现代化的游戏开发中,HDR渲染已经成为标准配置,它允许场景中的亮度值超出传统的0-1范围,从而能够更真实地模拟现实世界中的光照条件。曝光节点正是在这样的背景下发挥着重要作用,它架起了着色器与渲染管线曝光系统之间的桥梁。

渲染管线兼容性

曝光节点在不同渲染管线中的支持情况是开发者需要特别注意的重要信息。了解节点的兼容性有助于避免在项目开发过程中遇到意外的兼容性问题。

节点 通用渲染管线 (URP) 高清渲染管线 (HDRP)
Exposure

从兼容性表格中可以清楚地看到,曝光节点目前仅在高清渲染管线(HDRP)中得到支持,而在通用渲染管线(URP)中不可用。这一差异主要源于两种渲染管线在曝光处理机制上的根本区别。

HDRP作为Unity的高端渲染解决方案,内置了完整的物理相机和曝光系统,支持自动曝光(自动曝光适应)和手动曝光控制。HDRP的曝光系统基于真实的物理相机参数,如光圈、快门速度和ISO感光度,这使得它能够提供更加真实和灵活的曝光控制。

相比之下,URP虽然也支持HDR渲染,但其曝光系统相对简化,主要提供基本的曝光补偿功能,而没有HDRP那样完整的物理相机模拟。因此,URP中没有提供直接访问曝光值的Shader Graph节点。

对于URP用户,如果需要实现类似的功能,可以考虑以下替代方案:

  • 使用自定义渲染器特性传递曝光参数
  • 通过脚本将曝光值作为着色器全局属性传递
  • 使用URP提供的其他光照相关节点间接实现类似效果

端口详解

曝光节点的端口配置相对简单,但理解每个端口的特性和用途对于正确使用该节点至关重要。

名称 方向 类型 描述
Output 输出 Float 曝光值。

曝光节点只有一个输出端口,这意味着它只能作为数据源在Shader Graph中使用,而不能接收外部输入。这种设计反映了曝光值的本质——它是从渲染管线的相机系统获取的只读参数。

输出端口的Float类型表明曝光值是一个标量数值,这个数值代表了当前帧或上一帧的曝光乘数。在HDRP的曝光系统中,这个值通常用于将场景中的光照值从HDR范围映射到显示设备的LDR范围。

理解曝光值的数值范围对于正确使用该节点非常重要:

  • 当曝光值为1.0时,表示没有应用任何曝光调整
  • 曝光值大于1.0表示增加曝光(使图像更亮)
  • 曝光值小于1.0表示减少曝光(使图像更暗)
  • 在自动曝光系统中,这个值会根据场景亮度动态变化

在实际使用中,曝光节点的输出可以直接用于乘法运算来调整材质的亮度,或者用于更复杂的曝光相关计算。例如,在创建自发光材质时,可以使用曝光值来确保材质在不同曝光设置下保持视觉一致性。

曝光类型深度解析

曝光节点的核心功能通过其曝光类型(Exposure Type)设置来实现,这个设置决定了节点从渲染管线获取哪种类型的曝光值。理解每种曝光类型的特性和适用场景是掌握该节点的关键。

名称 描述
CurrentMultiplier 从当前帧获取摄像机的曝光值。
InverseCurrentMultiplier 从当前帧获取摄像机的曝光值的倒数。
PreviousMultiplier 从上一帧获取摄像机的曝光值。
InversePreviousMultiplier 从上一帧获取摄像机的曝光值的倒数。

CurrentMultiplier(当前帧曝光乘数)

CurrentMultiplier是最常用的曝光类型,它提供当前帧相机的实时曝光值。这个值反映了相机系统根据场景亮度和曝光设置计算出的当前曝光乘数。

使用场景示例:

  • 实时调整材质亮度以匹配场景曝光
  • 创建对曝光敏感的特殊效果
  • 确保自定义着色器与HDRP曝光系统同步

技术特点:

  • 值随每帧更新,响应实时变化
  • 直接反映当前相机的曝光状态
  • 适用于大多数需要与曝光同步的效果

InverseCurrentMultiplier(当前帧曝光乘数倒数)

InverseCurrentMultiplier提供当前帧曝光值的倒数,即1除以曝光乘数。这种类型的曝光值在某些特定计算中非常有用,特别是当需要抵消曝光影响时。

使用场景示例:

  • 在后期处理效果中抵消曝光影响
  • 创建在任意曝光设置下保持恒定亮度的元素
  • 进行曝光相关的颜色校正计算

技术特点:

  • 值与CurrentMultiplier互为倒数
  • 可用于"反向"曝光计算
  • 在需要保持恒定视觉亮度的效果中特别有用

PreviousMultiplier(上一帧曝光乘数)

PreviousMultiplier提供上一帧的曝光值,这在某些需要平滑过渡或避免闪烁的效果中非常有用。由于自动曝光系统可能会导致曝光值在帧之间变化,使用上一帧的值可以提供更加稳定的参考。

使用场景示例:

  • 实现曝光平滑过渡效果
  • 避免因曝光突变导致的视觉闪烁
  • 时间相关的曝光计算

技术特点:

  • 提供前一帧的曝光状态
  • 有助于减少曝光突变带来的视觉问题
  • 在时间性效果中提供一致性

InversePreviousMultiplier(上一帧曝光乘数倒数)

InversePreviousMultiplier结合了上一帧数据和倒数计算,为特定的高级应用场景提供支持。这种曝光类型在需要基于历史曝光数据进行复杂计算的效果中发挥作用。

使用场景示例:

  • 基于历史曝光的数据分析
  • 复杂的时序曝光效果
  • 高级曝光补偿算法

技术特点:

  • 结合了时间延迟和倒数计算
  • 适用于专业的曝光处理需求
  • 在高级渲染技术中使用

实际应用案例

HDR自发光材质

在HDRP中创建自发光材质时,使用曝光节点可以确保材质在不同曝光设置下保持正确的视觉表现。以下是一个基本的实现示例:

  1. 创建Shader Graph并添加Exposure节点
  2. 设置曝光类型为CurrentMultiplier
  3. 将自发光颜色与曝光节点输出相乘
  4. 连接到主节点的Emission输入

这种方法确保了自发光材质的亮度会随着相机曝光设置自动调整,在低曝光情况下不会过亮,在高曝光情况下不会过暗。

曝光自适应效果

利用PreviousMultiplier和CurrentMultiplier可以创建平滑的曝光过渡效果,避免自动曝光调整时的突兀变化:

  1. 添加两个Exposure节点,分别设置为PreviousMultiplier和CurrentMultiplier
  2. 使用Lerp节点在两者之间进行插值
  3. 通过Time节点控制插值速度
  4. 将结果用于需要平滑过渡的效果

这种技术特别适用于全屏效果或UI元素,可以确保视觉元素在曝光变化时平稳过渡。

曝光不变元素

某些场景元素可能需要在不同曝光设置下保持恒定的视觉亮度,这时可以使用InverseCurrentMultiplier:

  1. 使用Exposure节点设置为InverseCurrentMultiplier
  2. 将需要保持恒定亮度的颜色值与曝光倒数相乘
  3. 这样可以抵消相机曝光对特定元素的影响

这种方法常用于UI渲染、调试信息显示或其他需要独立于场景曝光的视觉元素。

性能考虑与最佳实践

虽然曝光节点本身性能开销很小,但在实际使用中仍需注意一些性能优化策略:

  • 避免在片段着色器中过度复杂的曝光计算
  • 考虑使用顶点着色器进行曝光相关计算(如果适用)
  • 对于静态物体,可以评估是否真的需要每帧更新曝光值
  • 在移动平台使用时注意测试性能影响

最佳实践建议:

  • 在HDRP项目中充分利用曝光节点确保视觉一致性
  • 理解不同曝光类型的适用场景,选择合适的类型
  • 结合HDRP的Volume系统测试着色器在不同曝光设置下的表现
  • 在自动曝光和手动曝光模式下都进行测试

故障排除与常见问题

在使用曝光节点时可能会遇到一些常见问题,以下是相应的解决方案:

  • 节点在URP中不可用:这是预期行为,曝光节点仅支持HDRP
  • 曝光值不更新:检查相机是否启用了自动曝光,在手动曝光模式下值可能不变
  • 效果不符合预期:确认使用了正确的曝光类型,不同场景需要不同的类型
  • 移动端表现异常:某些移动设备可能对HDR支持有限,需进行针对性测试

调试技巧:

  • 使用Debug节点输出曝光值检查实际数值
  • 在不同光照环境下测试着色器表现
  • 对比手动曝光和自动曝光模式下的效果差异

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

⏰前端周刊第 452 期(2026年2月2日-2月8日)

2026年2月9日 09:49

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

前端周刊封面


💬 推荐语

本期聚焦“交互组件选择 + 浏览器行为细节 + 生态工具更新”。Web 开发部分从组合框/多选/列表框的选型指南、浏览器对“意外”变更的敏感反应,到“不要把单词拆成字母”的可访问性提醒;工具与性能板块涵盖 Deno 生态新进展、ESLint 10 发布、ViteLand 月报、以及 SVG/视频与 Node.js 版本演进的性能分析。CSS 方面关注 @scope、@container scroll-state()、bar chart 与 clamp() 等现代特性;JavaScript 则有 Temporal 提案、显式资源管理、框架选型与 React/Angular 的新范式探讨。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

⚡️ 性能

🎨 CSS

💡 JavaScript

前端向架构突围系列 - 状态数据设计 [8 - 3]:服务端状态与客户端状态的架构分离

2026年2月9日 09:48

写在前面

架构师的核心能力之一是分类。 如果你觉得状态管理很痛苦,通常是因为你试图用同一种工具处理两种截然不同的东西:

  1. 客户端状态 (Client State): 比如“侧边栏是否展开”、“当前的夜间模式”。它们是同步的、瞬间完成的、由前端完全控制。
  2. 服务端状态 (Server State): 比如“用户订单列表”。它们是异步的、可能失效的、由后端控制。

Redux 并不擅长管理 Server State。 真正专业的做法是:让 Redux 回归 UI,让 TanStack Query (React Query) 接管 API。

image.png


一、 为什么要把 API 赶出 Redux?

1.1 消失的“样板代码”

在传统的 Redux 处理 API 流程中,你需要写:

  • 一个 Constant 定义 FETCH_USER_REQUEST
  • 一个 Action Creator
  • 一个处理 Pending/Success/ErrorReducer
  • 一个 useEffect 来触发请求

而在 TanStack Query 中,这只需要一行代码:

const { data, isLoading } = useQuery({ queryKey: ['user'], queryFn: fetchUser });

1.2 缓存与失效:Redux 的盲区

Server State 最难的不是“获取”,而是**“维护”**。

  • 用户离开页面 5 分钟后回来,数据还是新的吗?
  • 两个组件同时请求同一个接口,会发两次请求吗?
  • 弱网环境下,请求失败了会自动重试吗? 如果要用 Redux 实现这些,你需要写几百行复杂的 Middleware。而这些,是 Server State 管理工具的标配

二、 架构模型:双层数据流

现代前端架构推荐采用 “双层分离” 模型:

2.1 外部层:服务端状态 (Server State)

  • 工具: TanStack Query (React Query) 或 SWR。
  • 职责: 缓存管理、自动预取、失效检查 (Stale-While-Revalidate)、请求去重。
  • 特点: 它是异步的。

2.2 内部层:客户端状态 (Client State)

  • 工具: Zustand, Pinia, Jotai 或简单的 React Context。
  • 职责: 管理纯粹的 UI 逻辑(开关、多语言、主题、临时草稿)。
  • 特点: 它是同步的。

三、 实战战术:从“手动挡”切换到“自动挡”

3.1 自动化的依赖追踪

想象一个场景:你修改了用户的头像,你需要更新所有显示头像的地方。

  • 旧模式 (Redux): 修改成功后,手动发起一个 updateUserAction 去修改 Redux 里的那个大对象。
  • 新模式 (Query): 只需要执行一次“失效(Invalidate)”。
// 当用户修改个人资料成功时
const mutation = useMutation({
  mutationFn: updateProfile,
  onSuccess: () => {
    // 告诉系统:['user'] 这个 key 下的数据脏了,请自动重新拉取
    queryClient.invalidateQueries({ queryKey: ['user'] })
  },
})

架构意义: 你的代码不再需要关心“数据怎么同步”,只需要关心“数据何时失效”。

3.2 乐观更新 (Optimistic Updates)

这是架构高级感的核心。当用户点赞时,我们不等后端返回,直接改 UI。

TanStack Query 允许你在 onMutate 中手动修改缓存副本,如果请求失败,它会自动回滚。这种复杂的逻辑如果写在 Redux 里,会让 Reducer 逻辑变得极度臃肿。


四、 选型决策:什么时候该用谁?

作为架构师,你需要给团队划清界限:

状态类型 典型例子 推荐工具 存储位置
API 数据 商品列表、用户信息 TanStack Query 专用 Cache 池
全局 UI 状态 登录 Token、全局主题 Zustand / Pinia 全局 Store
局部 UI 状态 某个弹窗的开关 useState 组件内部
复杂表单 多步骤注册表单 React Hook Form 专用 Form State

导出到 Google 表格


五、 总结:让 Redux 变“瘦”

通过把 API 逻辑剥离出去,你会发现你的 Redux(或者 Zustand)Store 瞬间缩水了 80% 。 剩下的代码变得极其纯粹:只有纯同步的 UI 逻辑。

这种**“分治”**带来的好处是巨大的:

  1. 心智负担降低: 你不再需要管理复杂的 loading 状态机。
  2. 性能提升: TanStack Query 的细粒度缓存比 Redux 的全量对比快得多。
  3. 开发效率: 团队成员可以更专注地编写业务逻辑,而不是在样板代码中挣扎。

结语:控制的艺术

我们已经成功地将 API 数据和 UI 状态分开了。 但还有一种状态最让架构师头疼:流程状态。 当你的业务逻辑包含“待支付 -> 支付中 -> 支付成功/失败 -> 申请退款 -> 已关闭”这种复杂的链路时,无论你用什么工具,代码里都会充满 if/else

这种逻辑该如何优雅地管理?

Next Step: 下一节,我们将引入一个在航天和游戏领域应用了几十年的数学模型。 我们将学习如何用“图”的思想,终结代码里的逻辑乱麻。

业务方上压力了,前端仔速通RGB转CMYK

2026年2月9日 09:40

一、开端

"又双叒叕大事不好了,咱们导出的图片有问题,印刷出来有色差!业务方都被逼着要去外采软件了!"

下班前,产品突然在群里丢了一颗重磅炸弹。

外采软件?什么情况?要是真把业务方逼去外采了,咱们 IT 往后的日子可就不好过了。

事不宜迟,咱们赶紧看看是怎么个事。

二、问题背景

我们团队负责的是加盟商报货相关业务,其中有一个定制宣传物料的模块,业务流程是这样的:

  1. 设计师在后台创建可定制的模板(使用 Fabric 实现的一个可视化编辑器)
  2. 加盟商通过小程序填写定制信息(门店名称、图片、文案等)下单
  3. 设计师在后台审核并合成最终设计稿(使用离屏 Canvas 渲染并直接上传 OSS)
  4. 导出高清原图发往印刷厂印刷,最终交付给加盟商

这个系统的前端部分使用了 Fabric 来实现图片编辑功能,基于浏览器 Canvas API 导出图片,而 Canvas 只支持 RGB 色彩模式。但印刷厂需要的是 CMYK 模式,这导致印刷出来有非常明显的色差。

三、颜色的本质:色彩学

要搞明白为什么有色差,我们首先要知道,什么是颜色。

这部分比较冗长,如果你已经具备了相关前置知识,可以直接跳转至「为什么有色差」一节

1. 色彩模式

1672年,牛顿通过一块棱镜,发现了光的色散,从而揭示了白光由不同颜色光谱组成的本质。

而后,物理学家大卫·布儒斯特进一步发现染料的原色只需要红、黄、蓝三种颜色,基于这三种颜色,就可以调配出任何其他颜色。

随着科技的进步,生理学家托马斯杨根据人类眼球的视觉生理特征又提出了新的三原色,即红、绿、蓝三种颜色。

此后,人类开始意识到,色光和颜料的原色及其混合规律是有不同的,这实际上引出的是 加色模式减色模式 两种色彩模式。

减色模式

我们知道,人类并不能直接看到物体本身的颜色,我们看到的物体的颜色,实际上是物体反射的光的颜色。红色的物体,实际上是吸收了除红光以外的所有光,才让唯一的红光可以进入我们的眼球。

因此,在现实世界我们看到的所有不自发光物体的颜色,都应当按照减色模式进行调配和描述,如美术中使用到的颜料、印染工艺中使用的染料等。

减法三原色为青色(C)、品红色(M)、黄色(Y),合称 CMY。而现如今的印刷行业普遍采用的 CMYK 模式,则是因为使用三种颜色的颜料无法正确混合出纯正的黑色(通常是深灰色),因此需要额外单独的黑色(K)染料来印染黑色。

减色法的颜色效果完全依赖于环境光的照射和白纸的反射能力——油墨本身会吸收一部分光,白纸也无法 100% 反射所有光线,并且油墨染料的化学特性限制了其反射光谱的纯度。

加色模式

而针对可以直接发出光线的物体,人类所看到的颜色就直接是其发出光线颜色本身了。

和减色的三原色不同,加法三原色为红(R)、绿(G)、蓝(B),也就是大家熟知的 RGB 模式。

加色法是主动光源。主动光源通常可以发出非常纯净、高饱和度的单色光,并能将亮度提升到很高。这使它能够呈现非常鲜艳、明亮的颜色。

就比如你看到这篇文章时使用的显示器,每个像素都是由红绿蓝三种颜色的发光二极管组成的。

曾经红极一时、如雷贯耳的“周冬雨排列”

由于物理世界的限制,印刷品很难达到显示器那种发光体的亮度和饱和度。

2. 色彩空间

色彩模式告诉你,使用青、品红、黄三种颜色的调料可以调配出任何你想要的颜色,但是却没有告诉你,如果我想要调配出正红色,要用多少青色、多少品红、和多少黄色?

甚至你想要的正红色,其自身都没法用一个统一的标准来表述——这正红得多红才叫正红呀?

想要定量地描述颜色,我们需要引入色彩空间(Color Space)的概念。

CIE XYZ

1931 年,国际照明委员会(CIE)创建了 CIE XYZ 色彩空间,这是第一个基于人眼视觉特性的标准色彩空间。

基于 XYZ 三个坐标,我们可以用唯一确定的数值形式表示出人类肉眼可见的所有颜色。如此一来,我们便能给每一种颜色精准定位了。

sRGB

虽然有了 CIE XYZ 这个“统一语言”,但在 90 年代末,电脑普及和互联网爆发带来了一个极其现实的挑战:显示器的显示能力是有限的,而当时的网络带宽更是寸土寸金。

如果说 CIE XYZ 是一本包含了几十万词条、大而全的《牛津英语大词典》,那么我们在日常交流中,其实只需要一本几千词的《日常口语手册》就足够了。 强行传输 XYZ 这种海量数据,既超出了显示器的承载能力,也拖慢了网速。

为了在显示效果、传输效率和跨设备一致性之间找到那个平衡点,1996 年,微软和惠普选取了当时主流 CRT 显示器(大头电视)荧光粉能发出的红绿蓝,作为基准三原色,由此创造了流行至今的 sRGB 色彩空间,其中 s 意为标准(Standard)

CMYK

与显示器不同,印刷时选取不同的印刷介质和油墨,都会导致最终的印刷效果不同,因此针对特定的纸张和油墨组合,诞生了一系列不同的 CMYK 色彩空间,例如:

  • FOGRA / ISO Coated (欧洲标准): 针对欧洲常用的铜版纸印刷。
  • GRACoL / SWOP (美国标准): 常见于美国的出版物。
  • Japan Color (日本标准): 针对亚洲人视觉偏好的冷调印刷。

3. 色域

为了方便比较,我们通常会将不同的色彩空间统一映射到 CIE XYZ 色彩空间内进行比对。

如果我们将 Z 坐标进行归一化和压缩,再将所有该色彩空间内所有颜色的 X、Y 坐标连起来,就会得到一个封闭的二维图形,这个封闭的图形就是色域(Color Gamut),就是该色彩模式所能表示的颜色范围。

其中马蹄形区域是可见光的色域,通常被称作“全色域”

通过图像我们不难看出,sRGB 的色域并不能完全覆盖 CMYK,这意味着,一个在 sRGB 下能表示出的颜色,在 CMYK 模式下可能根本没有对应的颜色,这会导致风光摄影中一些常见的绿色无法在印刷时体现。因此,传统印刷行业对微软和 Adobe 等公司制定的 sRGB 标准提出了强烈的反对和质疑。

面对印刷业巨头的联合抵制和抗议,微软并没有认怂,他们之间的纠纷战争维持了三年之久,最后在 Adobe 公司的调解下,制定了 Adobe RGB 色域,这一更广阔的色域完美地包含了印刷所需的所有颜色。

但是摄影及印刷行业的从业者毕竟是少数,绝大多数的互联网用户并不需要关心 CMYK 这种印刷时才会遇到的色彩模式,传统的 sRGB 依旧可以满足网上冲浪的全部需求。

此外,更广色域的图片也需要更专业更贵的显示器、搭配专业软件才能正常显示,这也是为什么即便到了今天,sRGB 在互联网领域依旧占据绝对统治地位。

Tips: 显示器的色域

当你挑选显示器的时候,可能常常会听到诸如“120% sRGB”、“97% sRGB”等关键词,这里的百分比,实际上就是显示器色域占 sRGB 色域的范围。如果不考虑专业设计场景,理论上只要显示器能达到 100% 的 sRGB 色域,便可以满足你日常上网的全部需求

而类似“Adobe RGB 100% 色域”、“P3 广色域” 、“杜比视界”等更广阔的色域,随着时代的发展也逐渐有了更多的日常使用场景,如 B 站现在就支持 HDR 杜比视界的视频播放;在大型单机游戏领域,也越来越多地支持的 P3 色域。

四、为什么有色差?

了解了色彩学的基础知识后,我们重新审视一下最开始的那个问题:

我们知道,印刷厂的印刷机,最终印刷一定是使用 CMYK 四种颜色的墨水进行印刷的,因此当我们给出 RGB 原图时,必然经过了印刷厂的一次转换,这可能发生在机器内部,也可能发生在印刷厂的内部系统流程中;

而设计师手动转换色彩空间后,印刷没有色差,这就说明,色差的根源就在于印刷厂的这一次转换!

现阶段,想要将 RGB 转为 CMYK,通常有两种转换方式:

1. 基于基础数学公式

这是最简单、最基础的算法,通常用于不要求颜色精确度的场景。

转换步骤:

  1. 归一化: 将 R, G, B 的值(0-255)除以 255,使其范围变为 0~1
  2. 计算黑色(K):K = 1 - Max(R, G, B)
  3. 计算 C, M, Y:
    • C = (1 - R - K) / (1 - K)
    • M = (1 - G - K) / (1 - K)
    • Y = (1 - B - K) / (1 - K)

注意: 如果 K = 1(纯黑),则 C, M, Y 均为 0

这种算法的思路很朴素:既然 RGB 是加色,CMYK 是减色,那就通过数学关系做个映射。理论上确实可以完成转换,但问题在于——这种纯数学转换完全不考虑现实世界的设备差异。

同样是显示一个红色 RGB(255, 0, 0),不同品牌、不同型号的显示器,实际发出的光的波长和强度都不一样。你的显示器可能偏冷色调,我的显示器可能偏暖色调,但在算法眼里,它们都是 RGB(255, 0, 0)

同样是印刷 C0 M100 Y100 K0,不同的打印机、不同的纸张、不同的油墨,印出来的颜色也千差万别。这家印刷厂的红色油墨偏橙,那家印刷厂的红色油墨偏紫,但算法根本不知道这些差异。

而最令人头疼的是色域映射问题——RGB 能显示的某些鲜艳颜色,比如荧光绿 RGB(0, 255, 0),在 CMYK 的色域里根本没有对应的颜色。算法会强行把它映射成 C100 M0 Y100 K0,但印出来的绿色会明显发灰、发暗,完全不是你在屏幕上看到的那种鲜艳的绿。

纯算法转换假设所有设备都是"标准"的,假设色域可以完美映射,但现实世界里这两个假设都不成立。

2. 基于 ICC 特性文件

这是目前设计软件(如 Adobe Illustrator、Photoshop 等)和专业印刷流程采用的标准方式。

正如上一节中提到,RGB 和 CMYK 都有各自的色彩空间,显示器和打印机之间各说各话,你在显示器上看到的颜色,打印出来可能是另一个颜色。

为了解决这个问题,1993 年,包括 Adobe、Apple、Microsoft、Sun 等八家科技公司联合成立了国际色彩联盟 ICC(International Color Consortium,国际色彩联盟),目标就是建立一个开放、跨平台的色彩管理标准。ICC 配置文件规范也由此诞生。

ICC 来色彩管理界只办三件事:公平!公平!还是他**的公平!

不好意思串台了,但是其实某种意义上来说也没错。ICC 的出现是为了确保"所见即所得",它的最终目标是让你在屏幕上看到的红色,在打印纸上也是同样的红色。

PCS:色彩转换的中间人

为了做到“所见即所得”,ICC 系统引入了一个中间色彩空间 PCS(Profile Connection Space,特性连接空间)。这是一个设备无关的、中介的、与人眼感知相关的色彩空间(通常使用前面提到的 CIE XYZ 或者基于其演化出的 CIE Lab 色彩空间)。

有了 ICC 规范之后,每个设备的 ICC 文件都是通过专业仪器实际测量出来的:

  • 显示器的 ICC 文件:厂商用校色仪测量这台显示器,记录下 RGB(255, 0, 0) 在这台显示器上实际发出的光对应的 Lab 值(比如 Lab(53.23, 80.11, 67.22)

在你系统的显示器设置中,你可以看到当前显示器的颜色描述文件,它通常以你的显示器型号命名

这个文件就是显示器厂商针对这一型号制作的 ICC 文件,其内部包含了整台显示器所能展示的全部颜色,通常会随着驱动文件自动下载到你的电脑中。

大多数厂商所提供的只是一个通用 ICC 文件,实际上,哪怕是相同厂商、相同型号的显示器,受品控、原料批次及使用老化等因素影响,其显示效果也会有细微的差别。在某些对色彩准确性要求比较高的场景下(如影视、平面设计等)通常还需要针对单台设备进行颜色校准,并且制作一份矫正后的 ICC 或 LUT,才能够保证最终产出的图像和肉眼看到的一致。

  • 印刷机的 ICC 文件:印刷厂用分光光度计测量,记录下 C0 M100 Y100 K0 在这台印刷机、这种纸张、这种油墨上实际印出来的颜色对应的 Lab 值(比如 Lab(47.82, 68.30, 48.05)

每个设备的 ICC 文件都描述了该设备色彩空间与 PCS 之间的转换关系,就像不同国家的语言都可以通过英语作为中介进行翻译,如此一来,当你在显示器上看到一张照片并想打印出来时,只要经过如下转换:

  1. 显示器的 ICC 配置文件把 RGB 信号转换到 Lab 色彩空间
  2. 印刷机的 ICC 配置文件再把这个 Lab 值翻译成印刷机需要的 CMYK 信号

因为 Lab 是基于人眼感知的绝对色彩空间,所以这样转换后,你在屏幕上看到的红色,和印刷出来的红色,在人眼看来就是同一个颜色了。反之亦是同理。

渲染意图:当色域溢出时怎么办?

虽然 ICC 文件可以实现从 RGB 到 CMYK 的双向映射,但是还记得我们前文提到的 RGB 的色域要比 CMYK 更广吗?这必然会导致有部分 RGB 颜色,无法和 CMYK 颜色进行映射。这时就轮到 渲染意图(Rendering Intent) 登场了。

在 ICC 规范中,一共有四种法定意图,它们决定了如何处理色域外的颜色。

可感知意图(Perceptual)

可感知意图的核心原理是等比例压缩,以 RGB 转 CMYK 为例,它将 RGB 的色域等比例缩放到 CMYK 的色域,颜色之间的相对关系(层次、过渡)保留得比较好。虽然整体饱和度可能会稍微下降,但图片看起来非常自然,不会有色块断层。

可以看出,图片虽然整体饱和度下降,但是颜色渐变过渡被保留得很好,不存在明显的断层,文本颜色也依旧可以辨识。

相对比色意图(Relative Colorimetric)

相对比色意图的核心逻辑是精准对齐 + 硬性裁剪,同样以 RGB 转 CMYK 为例,如果颜色在 CMYK 的色域内,就不会做任何改动;如果颜色超出了 CMYK 的色域就会直接截取为 CMYK 的边缘色彩。

这种方式转换的颜色最"准",因为它尽可能保持了大部分原始数值。但在极鲜艳、极暗的区域,可能会出现"并色"(Clipping)现象,即原本有层次的颜色变成了相同的颜色,丢失了层次感。

可以看出,图片在中部颜色没有溢出的部分保持了相同的色彩,但在两侧出现了较为明显的色域断层和边界。边界外颜色的渐变效果已被截断,且和同样超出色域范围的文本颜色被压缩成了相同的颜色,导致文本无法辨识。

此外还有饱和度意图(Saturation)和绝对比色意图(Absolute Colorimetric),由于篇幅限制这里就不多做赘述了。

黑场补偿:保留暗部细节

可感知意图为了让所有颜色都能塞进目标色域,会移动所有颜色(甚至是那些本来就在色域内的颜色)。这意味着你看到的颜色虽然“和谐”,但已经不再是原始定义的那个准确的数值了,色差会比较明显。

而相对比色虽然尽可能多地保证了色准,但是面对色域外的颜色(尤其是深色)时又极易丢失细节

左图为 RGB 原图,右图为 CMYK 使用相对比色意图,不开启黑场补偿

可以看出白框中蓝莓的暗区细节已经完全丢失

那么有没有办法,能够让我们在保证色准的同时,尽可能多地保留暗部细节呢?

有的兄弟,有的,这门技术就是黑场补偿(Black Point Compensation)

黑场补偿的原理,本质上就是将原图的暗区进行缩放:它会先找到源文件(RGB)中最黑的点,再找到目标输出(CMYK)能达到的最黑的点,并将整个画面的亮度范围进行等比例的"缩放",让 RGB 的黑点刚好对应上 CMYK 的黑点。

如此一来,原本深灰和全黑之间的相对比例就被保留了下来,虽然整体看起来可能没那么深邃了,但暗部的细节纹理被成功"挤"进了 CMYK 能表达的范围内。

开启了黑场补偿后,可以看出暗区细节被完好地保留了下来

并非所有 ICC 文件的可感知意图都完美

除了解决相对比色意图的暗部细节丢失以外,BFC 也同样可以给可感知意图兜底。

我们知道,ICC 文件是由厂商自行制作的,那必然会出现:有些厂商的“可感知”算法做得很好,暗部过渡自然;而有些厂商的算法却过于保守,或者在处理某些特定颜色时产生了意料之外的偏色。

而 BPC 是一种标准化的算法(由 Adobe 提出并贡献给 ICC)。它不依赖于 ICC 内部复杂的查表映射,而是在转换阶段进行一次数学上的端点对齐。因此,BPC 提供了一层额外的保险,确保无论你使用哪种意图,最黑的点始终能对应到输出设备的最黑点。

在 Photoshop、Illustrator 等软件中,通常建议默认开启黑场补偿;而部分图像处理工具则可能不提供这一功能。

五、如何解决?

到这里,我们几乎可以确定了色差的根源,原因无非以下几个:

  • 印刷厂根本直接用的算法公式转换
  • 印刷厂的转换工具不支持渲染意图和黑场补偿
  • 印刷厂的渲染意图和黑场补偿选错了
  • 印刷厂用的 ICC 文件不对

但是不管到底是哪个问题,我们都有一个万能的解法——将 RGB 原图按照设计师的要求一比一转好后,再发给印刷厂。毕竟设计师转出来的发过去,印出来就是对的嘛。

依葫芦画瓢,和设计师一番沟通之后,我们确定了转换的过程与目标:

  • RGB 原图:ICC 文件使用浏览器内置的 sRGB IEC61966-2.1,这是 Canvas 导出图片的默认配置
  • CMYK 转换:使用 Adobe Illustrator 软件中的默认预设——日本常规用途2
    • ICC 文件:Japan Color 2001 Coated
    • 渲染意图:可感知
    • 黑场补偿:开

方案确定了,接下来进行技术调研吧。

六、技术选型

我们最初的调研方向是使用服务端转换,因为相对成熟的 npm 包大多都只支持 Node 环境,而非浏览器环境。

1. Sharp

首先,我们找到的是 Sharp 这个 Node 库,其底层基于 C/C++libvips,宣称_比使用最快的 ImageMagick 和 GraphicsMagick 设置快 4 到 5 倍_,在 Node 中可以开箱即用,也是大多数 Node 应用的首选。

使用 Sharp 完成 RGB 到 CMYK 的转换非常简单,核心代码仅四行:

import { Injectable } from '@nestjs/common';
import * as sharp from 'sharp';

@Injectable()
export class ImageService {
  async transformToCMYK(file: Express.Multer.File): Promise<Buffer> {
    return sharp(file.buffer)
      .withIccProfile('./profiles/JapanColor2001Coated.icc')
      .jpeg({ quality: 100, chromaSubsampling: '4:4:4' })
      .toBuffer();
  }
}

美中不足的是,Sharp 毕竟是一个精简的图像处理框架,它仅支持纯算法和纯 ICC 文件的 CMYK 转换,前文提到的渲染意图和黑场补偿等均未支持。

2. ImageMagick

ImageMagick 是一个非常老牌的图像处理框架,堪比音视频领域的 ffmpeg。而最重要的是它支持指定渲染意图和开启黑场补偿。

本地安装后,你可以使用如下命令行命令来实现 RGB 到 CMYK 的转换:

magick convert input.jpg \
  -profile "sRGB_v4_ICC_preference.icc" \
  -intent Relative \
  -black-point-compensation \
  -profile "Your_Target_CMYK.icc" \
  output.jpg

除了直接使用命令行调用二进制文件,我们还可以使用 magickwand.js,这是一个基于 swigemnapi 的库,同时实现了 Node.js 原生和浏览器 WASM 版本。

magickwand.js 的 Node.js 原生版本专为与 Express.js 等框架配合使用而设计,非常适合服务器端应用。官方文档宣称它_经过内存泄漏调试,并且在仅使用异步方法时,绝不会阻塞事件循环_。

在 Node 中使用 magickwand.js 也非常简单,代码示例如下:

import { Injectable, Logger } from "@nestjs/common";
import { Intent } from "./dto/cmyk.dto";
import { Magick } from "magickwand.js/native";
import * as fs from "fs";
import * as path from "path";

@Injectable()
export class ImageService {
  private logger = new Logger(ImageService.name);
  private readonly profiles: Record<string, Magick.Blob> = {};

  constructor() {
    // Japan Color 2001 Coated
    this.loadIccProfile(
      "JapanColor2001Coated",
      "./profiles/JapanColor2001Coated.icc"
    );
    // 普通CMYK描述文件
    this.loadIccProfile(
      "Generic CMYK Profile",
      "./profiles/Generic CMYK Profile.icc"
    );
  }

  private loadIccProfile(profileName: string, profilePath: string) {
    if (this.profiles[profileName]) {
      this.logger.warn(`${profileName} 配置文件已存在,跳过加载`);
      return;
    }

    const fullPath = path.join(__dirname, profilePath);
    const buffer = fs.readFileSync(fullPath).buffer;
    const blob = new Magick.Blob(buffer);
    this.profiles[profileName] = blob;
  }

  async transformToCMYK(
    file: Express.Multer.File,
    intent: Intent,
    blackPointCompensation: boolean
  ): Promise<Buffer> {
    const inputBlob = new Magick.Blob(file.buffer.buffer as ArrayBuffer);
    const inputImage = new Magick.Image(inputBlob);
    // 指定渲染意图
    await inputImage.renderingIntentAsync(intent);
    // 设置黑场补偿
    await inputImage.blackPointCompensationAsync(blackPointCompensation);
    // 转换 ICC 配置文件
    await inputImage.iccColorProfileAsync(
      this.profiles["JapanColor2001Coated"]
    );
    // 指定输出格式
    await inputImage.magickAsync("JPEG");

    const outputBlob = new Magick.Blob();
    await inputImage.writeAsync(outputBlob);
    const outputBuffer = await outputBlob.dataAsync();

    return Buffer.from(outputBuffer);
  }
}

这个库的主要问题是它没有 JS/TS 的文档,只有 C/C++ 的文档,使用时往往需要你根据 TS 的参数类型连蒙带猜去传参。

3. PIL/Pillow

除了使用 Node,在 Python 中我们也有很多的选择,例如 PIL/Pillow,它同样非常强大易用,代码示例如下:

from PIL import Image, ImageCms

img = Image.open("input.jpg")
rgb_profile = ImageCms.getOpenProfile("sRGB Color Space Profile.icm")
cmyk_profile = ImageCms.getOpenProfile("JapanColor2001Coated.icc")

transform = ImageCms.buildTransform(
    rgb_profile,
    cmyk_profile,
    "RGB",
    "CMYK",
    renderingIntent=ImageCms.Intent.RELATIVE_COLORIMETRIC,  # 相对比色
    flags=ImageCms.Flags.BLACKPOINTCOMPENSATION,  # 黑场补偿
)

cmyk_img = ImageCms.applyTransform(img, transform)
cmyk_img.save("output.jpg", quality=95, icc_profile=cmyk_profile.tobytes())

七、困难重重

既然有这么多现成的库,而且代码看着也没多少,一定很好实现吧。

很可惜,理想很美好,现实很悲催。在实际落地过程中,我们遇到了很多问题。

问题一:CI/CD 构建失败

最开始,我们选择了功能最完善的 magickwand.js。它天然支持渲染意图和黑场补偿,正好满足我们的需求。本地编码调试一切正常,但提交到 CI/CD 平台后,构建直接失败了:

排查后发现,magickwand.js 依赖 xpm 这个 C/C++ 包管理器。在执行 npm install 时,xpm 会去 npm 源查找 package.json 中声明的 xpack 字段,然后从 GitHub 下载对应平台的二进制文件:

{
  "xpack": {
    "binaries": {
      "baseUrl": "https://github.com/xpack-dev-tools/ninja-build-xpack/releases/download/v1.13.1-1",
      "platforms": {
        "darwin-arm64": {
          "fileName": "xpack-ninja-build-1.13.1-1-darwin-arm64.tar.gz",
          ...
        },
        ...
      }
    }
  }
}

构建容器内无法访问 Github,这个问题我们无法解决,只能放弃 magickwand.js,转而考虑其他方案。

实际上我们还有一个方案,就是绕过 xpm,直接将预编译好的 ImageMagick 二进制文件都下载到本地,然后在 Node 中写一个平台适配层,封装下命令调用,也可以满足需求。

但是使用 child_process 来调用会有很多问题:

  1. 性能开销大:涉及进程创建、销毁和上下文切换成本;
  2. 通信效率低:需通过标准输入输出进行数据序列化与反序列化,增加了额外的处理延迟;
  3. 并发控制复杂:需手动管理进程池和资源竞争,避免系统资源耗尽;
  4. 异步编程繁琐:必须处理流控制、背压和错误恢复机制,代码复杂度显著增加;
  5. 稳定性风险高:子进程崩溃可能影响主进程稳定性,且进程间状态难以共享。

综合考虑下来,这个也只能作为实在没有办法的备选,不应当作为首选方案。

问题二:图像传输的性能瓶颈

除了构建上的难题,最致命的实际是后端处理所带来的用户体验问题。

在我们的业务场景中,设计稿需要以 300 DPI 导出,一张海报的分辨率通常是 7087×9449,RGB 原图约 30MB;而门店横幅、围挡等大尺寸设计稿,原图甚至会达到 100MB+。

虽然前段时间运维升级了公司的网络带宽,由原先的 25Mb 调整到 100Mb,但是即便是跑满带宽,下载速度也只能达到约 12MB/s,而这还是建立在不考虑服务器带宽的前提下,完整的转换流程仍然需要:

  1. 前端上传原图到后端(30-100MB 上行)
  2. 后端处理转换(4 核 8G 的处理器需要 10s 以上的处理时间)
  3. 后端返回 CMYK 图片(30-100MB 下行)
  4. 前端手动上传到 OSS(30-100MB 再次上行)

整个 RTT 实测下来超过了 100 秒,还要承受网络波动导致传输失败的风险。这种体验完全无法接受。

我们也想过优化方案——把 Fabric 的渲染逻辑移到服务端:

  1. 前端只传 JSON 配置文件(体积小)
  2. 后端用 fabric + node-canvas 渲染图片
  3. 就地转换为 CMYK 并直接上传 OSS
  4. 返回图片 URL

理论上可以减少一次上行和一次下行,将 RTT 缩短至 30 秒以内。但这个方案评估下来,问题更多:

1. 渲染场景复杂,迁移成本极高

我们有两个场景需要适配:

  • 设计师编辑模板:直接导出 Canvas 内容
  • 加盟商生成终稿:先替换占位内容,再导出;还需前置生成低分辨率预览稿,以及展示处理进度

如果将 Fabric 渲染逻辑迁移到 Node:

  • 一套代码适配:需要从头梳理两套逻辑的异同点,工作量巨大
  • 两套代码分离:后续维护成本会直线上升

而且前端现有的历史渲染代码本就错综复杂,要保证 Node 生成的图片和浏览器完全一致,需要投入更多的开发和测试资源。

2. 字体合规风险

设计团队使用的字体都是免费或商业授权的,但大多数字体的授权范围仅限于桌面使用。如果把字体文件上传到服务器,属于"网络传播"或"网络嵌入"用途,需要单独授权。

要合法使用服务端渲染,我们需要:

  1. 对所有免费、商业字体进行全面审计
  2. 申请新的适用范围授权(费时费力,成本高昂)

这期间,一旦出现纰漏,可能收到律师函、侵权通知或高额赔偿。

作为一家上市公司,古茗在全国有上万家加盟门店。如此大的体量,任何合规风险都可能给公司造成无法估量的损失。

3. 服务端性能问题

使用服务端渲染还有一个绕不过的问题就是性能问题,在服务端执行图像处理,同样需要耗费 CPU 和内存性能,我们需要对使用场景进行梳理,根据埋点信息统计出调用频次,以评估接口性能,并对接口进行压测。如果性能不能满足,我们还需要申请更高配置的服务器。

这同样需要我们花费更多的时间,测试资源本就紧张,难以协调,线上稳定性也难以保障。

客户端方案的探索

服务端方案成本太高,必须另寻出路,而客户端方案,JS 处理肯定是不行了,性能太差。而除了 JS 我们还有一条路可以走——WebAssembly。

ImageMagick 是用 C/C++ 编写的,理论上我们可以用 Emscripten 编译为 WASM。但想要打通整条链路,我们需要:

  1. 搭建 emscripten 环境
  2. 使用 cmake/autotools 编译依赖库
  3. 链接和编译 ImageMagick 主代码库
  4. 编写 JS/WASM 胶水层代码

参考:WebAssembly实战-在浏览器中使用ImageMagick-腾讯云开发者社区-腾讯云

这套流程虽然很明确,但学习和上手成本确实不低。受限于工期,我们先尝试寻找现成的方案:

1. magickwand.js WASM 版本

magickwand.js 本身就提供了 WASM 版本,但使用后发现它依赖 SharedArrayBuffer,这要求启用跨域隔离(Cross-Origin Isolation)。这不仅需要改造现有的构建脚手架,发布时还需要改造网关配置。加之这个库之前在 CI/CD 环节就有问题,我们只能放弃。

2. 其他 WASM 库

ImageMagick 官网推荐的 WASM-ImageMagick 已经 6 年没更新了。我们在 npm 上找到了 @imagemagick/magick-wasm,其作者是 ImageMagick 的核心开发者之一,下载量排名靠前,更新活跃,非常可靠。

最重要的是,它不存在我们前面提到的任何一个问题!

八、工程接入

问题解决,接下来只需要将 magick-wasm 接入到工程中即可。

1. 前置准备

magick-wasm 这个库内部使用 BigInt,如果你的 browserslist 指定版本过低,Babel 编译时可能会报错,添加一个 supports bigint 即可:

{
  "browserslist": [
    "supports bigint",
    "not dead"
  ]
}

2. WASM 初始化

我们需要在页面组件中加载 WASM 模块,这里我们要求必须初始化成功,因为如果 WASM 模块无法加载,设计师转换色彩模式失败,仍会影响后续印刷。

const WASM_LOCATION = new URL('@imagemagick/magick-wasm/magick.wasm', import.meta.url);

const App: React.FC = () => {
  useMount(() => {
    setLoading(true);
    initializeImageMagick(WASM_LOCATION)
      .then(() => console.log('ImageMagick 初始化成功'))
      .catch(() => {
        const message = 'ImageMagick 初始化失败';
        CustomReport.sendWarning(ArmsLogs.initializeImageMagickFailed, { message });
        Modal.error({
          title: message,
          content: '请使用最新版本的 Chrome 浏览器!',
          onOk: () => window.close(),
        });
      })
      .finally(() => setLoading(false));
  });
}

初始化逻辑中需要注意添加 Loading 提示,因为初始化 WASM 是需要通过网络请求获取 .wasm 文件的,如果网速过慢就有可能导致触发转换时 WASM 模块还没有初始化完成。

此外,在初始化失败时还要接入埋点告警,以便我们感知线上的使用情况。

3. 色彩模式转换

这部分的核心转换逻辑也并不多,大致流程如下:

const RGB_PROFILE_LOCATION = new URL('@/assets/icc/sRGB Color Space Profile.icm', import.meta.url);
const CMYK_PROFILE_LOCATION = new URL('@/assets/icc/JapanColor2001Coated.icc', import.meta.url);

const readFile = async (url: URL): Promise<Uint8Array> => {
  const response = await fetch(url);
  const arrayBuffer = await response.arrayBuffer();
  return new Uint8Array(arrayBuffer);
};

export const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  const [rgbProfileUint8Array, cmykProfileUint8Array] = await Promise.all([
    readFile(RGB_PROFILE_LOCATION),
    readFile(CMYK_PROFILE_LOCATION),
  ]);
  const rgbProfile = new ColorProfile(rgbProfileUint8Array);
  const cmykProfile = new ColorProfile(cmykProfileUint8Array);

  return new Promise((resolve, reject) => {
    ImageMagick.read(uint8Array, MagickFormat.Jpeg, (image) => {
      image.blackPointCompensation = true;
      image.renderingIntent = RenderingIntent.Perceptual;
      /**
       * 必须同时指定 source 和 target,否则在 safari 下会有 bug
       * https://github.com/dlemstra/magick-wasm/blob/main/src/magick-image.ts#L3976
       * safari canvas 导出的图片无法检测出 icc,会导致转换失败
       */
      const success = image.transformColorSpace(
        rgbProfile,
        cmykProfile,
        ColorTransformMode.HighRes
      );
      if (!success) {
        message.error('色彩空间转换失败!');
        CustomReport.sendWarning(ArmsLogs.colorSpaceTransformFailed, {
          message: '色彩空间转换失败!',
        });
        reject(new Error('色彩空间转换失败!'));
      } else {
        image.write(MagickFormat.Jpeg, (result) => {
          // 需要拷贝一份,否则 result 会被 GC 回收
          resolve(new Uint8Array(result));
        });
      }
    });
  });
};

但是这里有两个坑点需要注意:

  1. Safari 下 ICC 检测失败

transformColorSpace 在源码中判断了图像是否内嵌了 profile,如果没有嵌入,会直接返回失败。

源码位置:github.com/dlemstra/ma…

在 Chrome 中通过 Canvas 导出的图片,调用 ImageMagick 查询 ICC 文件时可以正常找到,但是通过 Safari 导出的图片则无法检出。

奇怪的是,使用 macOS 自带预览查看颜色描述文件信息时却恰好得到了相反的结果——使用 Safari 导出的图片正确嵌入了 sRGB IEC61966-2.1 文件,而 Chrome 导出的图片却没有显示颜色描述文件。

这个问题笔者没有深入研究,如果有了解原因的朋友也欢迎在评论区回复解答下疑惑

因此在 Safari 下 transformColorSpace 方法不会执行任何操作,直接返回了 true。

阅读源码后发现要规避这个问题,只需要同时传入 source 和 target 即可:

const RGB_PROFILE_LOCATION = new URL('@/assets/icc/sRGB Color Space Profile.icm', import.meta.url);
const CMYK_PROFILE_LOCATION = new URL('@/assets/icc/JapanColor2001Coated.icc', import.meta.url);

export const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  const [rgbProfileUint8Array, cmykProfileUint8Array] = await Promise.all([
    readFile(RGB_PROFILE_LOCATION),
    readFile(CMYK_PROFILE_LOCATION),
  ]);
  const rgbProfile = new ColorProfile(rgbProfileUint8Array);
  const cmykProfile = new ColorProfile(cmykProfileUint8Array);

  return new Promise((resolve, reject) => {
    ImageMagick.read(uint8Array, MagickFormat.Jpeg, (image) => {
      image.blackPointCompensation = true;
      image.renderingIntent = RenderingIntent.Perceptual;
      /**
       * 必须同时指定 source 和 target,否则在 safari 下会有 bug
       * https://github.com/dlemstra/magick-wasm/blob/main/src/magick-image.ts#L3976
       * safari canvas 导出的图片无法检测出 icc,会导致转换失败
       */
      const success = image.transformColorSpace(
        rgbProfile,
        cmykProfile,
        ColorTransformMode.HighRes
      );
      if (!success) {
        reject(new Error('色彩空间转换失败!'));
      } else {
        image.write(MagickFormat.Jpeg, resolve);
      }
    });
  });
};

当然别忘记在代码中留下对应的注释说明,防止后人维护重复踩坑。

  1. WASM GC 导致数据丢失

image.write 回调中的 data 对象来自 magick-wasm 的内存,它的生命周期不受 JS 控制,回调结束或后续写入时那段内存可能已经被复用/释放。

要解决这个问题也很简单,原地复制一份即可:

image.write(MagickFormat.Jpeg, (data) => {
  // 需要拷贝一份,否则 result 会被 GC 回收
  resolve(new Uint8Array(data));
});

同样留下一个贴心的注释,后续只需适配对应的业务代码即可

4. 性能优化

功能是实现了,但业务实际用下来还是发现不少问题,主要集中在性能方面。

业务使用的是统一采购的 16G 的 M1 芯片 iMac,按理来讲不会卡,但是深入了解了业务的操作习惯后,发现了几个很有意思的点:

  • 业务习惯同时多开 4、5 个标签页,同时操作
  • 业务在页面操作的同时,本地会开着 AI/PS 以方便作图

虽然 WebAssembly 运行速度非常快,但它与 JavaScript 共享同一个事件循环(Event Loop)。如果你在主线程直接调用一个耗时较长的 WASM 函数,它依然会阻塞 UI 响应,导致页面卡顿。

在现代浏览器中,同一个域名的不同标签页,通常也是共用的同一个进程,这还会导致,我们在一个标签页下处理图像,同域的其他标签页也无法操作(主线程被阻塞),浏览器还会弹出页面无响应的提示

因此,我们还需要做针对性的性能优化。

Worker 多线程

性能优化的第一步,就是将 WebAssembly 从主线程中移出去。我们可以使用 Web Worker 将 WASM 的逻辑单独放在 worker 线程中执行,从而避免阻塞主线程。

想要使用 worker 很简单,你只需要创建一个 worker.js 文件,随后在主线程中使用:

const myWorker = new Worker("worker.js");

即可将 worker.js 中的代码放在独立的 worker 线程中执行。

注意这里不能用 SharedWorker,一方面 Safari 长期以来对 SharedWorker 支持不佳,另一方面 SharedWorker 更多使用在是跨标签通信,或者某些需要共享资源的场景,对于上面提到的多标签并发图像处理反而起到负作用(多个标签共享一个 Worker,处理是串行的),无法最大程度利用现代多核 CPU 的性能。

此外,由于单个标签页可能会触发多次图像处理,我们还可以使用单例模式减少重复的 WASM 初始化,从而进一步优化性能,代码示例如下:

// Worker 实例
let workerInstance: Worker | null = null;

/**
 * 获取 Worker 实例(单例模式)
 */
const getWorker = (): Worker => {
  if (!workerInstance) {
    workerInstance = new Worker(new URL('./magick.worker.ts', import.meta.url), { 
      type: 'module' 
    });
    // 监听 Worker 返回的消息
    workerInstance.onmessage = (event) => {};
    // 监听 Worker 错误
    workerInstance.onerror = (error) => {};
  }
  return workerInstance;
};

Worker 同源限制

在上线前我们还遇到一个问题,我们的前端构建产物是托管在 OSS 上的,这里使用 new URL 获取到的 worker 资源不同源,导致无法加载。

为了解决这个问题,我们将 worker 内部的逻辑单独抽离到一个 npm 包中,连同依赖项一起打包成 UMD 格式,在业务工程中通过 fetch 方式获取脚本内容。

const WORKER_URL = new URL('@guming/magick-worker/build/umd/index.js', import.meta.url);
// Fetch worker 文件内容
const response = await fetch(WORKER_URL);
const workerCode = await response.text();
// 创建 Blob 和 Blob URL
const blob = new Blob([workerCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
// 创建 Worker
const worker = new Worker(blobUrl);

如果你使用 Vite,也可以使用 Vite 的 import MyWorker from'./worker.js?worker'语法。

或者也可以使用 remote-web-worker 这样的库来少写点代码。

Comlink 零拷贝传输

Worker 通过 postMessage 与主线程通信,数据传输有两种模式:

  1. 结构化克隆(Structured Clone)

这也是最常用的一种写法,代码示例如下:

const worker = new Worker('worker.js');
const imageBuffer = new ArrayBuffer(100 * 1024 * 1024);

worker.postMessage({ type: 'process', data: imageBuffer });

这种方式会为接收方创建一个数据的完整副本。对于 100MB 的图片,传输瞬间会导致内存占用翻倍(变为 200MB)。如果是 5 个标签页同时操作,内存峰值将迅速堆叠,引发浏览器 OOM(内存溢出)崩溃。

  1. 可转移对象(Transferable Objects)

除了结构化克隆之外,worker 还提供了一种允许你直接转交对象内存的方式,代码示例如下:

const worker = new Worker('worker.js');
const imageBuffer = new ArrayBuffer(100 * 1024 * 1024);

worker.postMessage(
  { type: 'process', data: imageBuffer },
  [imageBuffer]  // 第二个参数:要转移的对象列表
);

// 转移后,imageBuffer 在主线程不可用
console.log(imageBuffer.byteLength); // 0 —— 所有权已转移

通过这种方式,我们可以避免对大对象进行拷贝,从而减少通信时上下文结构化的性能开销。

在实际开发工作中,我们通常还需要写一套复杂的事件通信逻辑,来保障和 worker 之间的通信,代码可能长这样:

// 主线程
let workerInstance: Worker | null = null;
let messageId = 0;
const pendingRequests = new Map<number, { resolve: Function; reject: Function }>();

/**
 * 获取 Worker 实例(单例模式)
 */
const getWorker = (): Worker => {
  if (!workerInstance) {
    workerInstance = new Worker(new URL('./magick.worker.ts', import.meta.url), { 
      type: 'module' 
    });

    // 监听 Worker 返回的消息
    workerInstance.onmessage = (event) => {
      const { id, type, data, error } = event.data;
      const request = pendingRequests.get(id);

      if (request) {
        if (type === 'success') {
          request.resolve(data);
        } else if (type === 'error') {
          request.reject(new Error(error));
        }
        pendingRequests.delete(id);
      }
    };

    // 监听 Worker 错误
    workerInstance.onerror = (error) => {
      console.error('Worker error:', error);
      // 拒绝所有等待中的请求
      pendingRequests.forEach(({ reject }) => reject(error));
      pendingRequests.clear();
    };
  }
  return workerInstance;
};

/**
 * 向 Worker 发送消息并等待响应
 */
const sendMessageToWorker = <T>(
  method: string, 
  data?: any,
): Promise<T> => {
  return new Promise((resolve, reject) => {
    const id = messageId++;
    const worker = getWorker();
    // 保存 promise 的 resolve 和 reject
    pendingRequests.set(id, { resolve, reject });
    // 发送消息到 Worker
    worker.postMessage({ id, method, data });
  });
};

const initializeWorker = (): Promise<void> => {
  return sendMessageToWorker('initializeWorker');
};

export const transformColorSpace = (uint8Array: Uint8Array): Promise<Uint8Array> => {
  return sendMessageToWorker<Uint8Array>('transformColorSpace', uint8Array);
};
// worker
import { initMagick, ImageMagick, MagickImage } from '@imagemagick/magick-wasm';

let initialized = false;

const initializeWorker = async (): Promise<void> => {};
const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {};

// 监听主线程的消息
self.onmessage = async (event) => {
  const { id, method, data } = event.data;
  
  try {
    let result;
    // 根据方法名调用对应的函数
    switch (method) {
      case 'initializeWorker':
        await initializeWorker();
        result = undefined;
        break;
      case 'transformColorSpace':
        result = await transformColorSpace(data);
        break;
      default:
        throw new Error(`Unknown method: ${method}`);
    }
    // 返回成功结果
    self.postMessage({ id, type: 'success', data: result });
  } catch (error) {
    // 返回错误
    self.postMessage({ id, type: 'error', error });
  }
};

比较复杂,有一定的学习和理解成本。我们可以使用 Comlink 库来封装 worker 的通信逻辑,从而避免手动维护一套事件通信逻辑,代码可以精简如下:

// 主线程
import * as Comlink from 'comlink';
import type { WorkerApi } from './magick.worker';

let workerInstance: Worker | null = null;
let workerApi: Comlink.Remote<WorkerApi> | null = null;

const getWorkerApi = (): Comlink.Remote<WorkerApi> => {
  if (!workerApi) {
    workerInstance = new Worker(new URL('./magick.worker.ts', import.meta.url), { type: 'module' });
    workerApi = Comlink.wrap<WorkerApi>(workerInstance);
  }
  return workerApi;
};

export const initializeWorker = async (): Promise<void> => {
  const api = getWorkerApi();
  await api.initializeWorker();
};

export const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  const api = getWorkerApi();
  return api.transformColorSpace(Comlink.transfer(uint8Array, [uint8Array.buffer]));
};
// worker
import * as Comlink from 'comlink';

const initializeWorker = async (): Promise<void> => {};

const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  return new Promise((resolve, reject) => {
    ImageMagick.read(uint8Array, MagickFormat.Jpeg, (image) => {
      ...
      image.write(MagickFormat.Jpeg, (result) => {
        const output = new Uint8Array(result);
        // 使用 Transferable,避免大数据复制
        resolve(Comlink.transfer(output, [output.buffer]));
      });
    });
  });
};

const workerApi = {
  initializeWorker,
  transformColorSpace,
};

export type WorkerApi = typeof workerApi;

Comlink.expose(workerApi);

写法非常简单,仿佛根本没有 worker 的存在,Comlink 帮你封装了所有通信的细节。

静态资源缓存

原先的 transformColorSpace 写法中,每次执行都会重复请求一次 ICC 文件,我们完全可以将请求做前置缓存,统一放到 initializeWorker 内部,实测下来可以减少每次 2s 以上的重复请求耗时:

/**
 * 初始化 ImageMagick WASM
 */
const initializeWasm = async (wasmUrl: string): Promise<void> => {
  const wasmBytes = await readFile(wasmUrl);
  await initializeImageMagick(wasmBytes);
};

/**
 * 初始化 ICC profiles
 */
const initializeProfiles = async (rgbProfileUrl: string, cmykProfileUrl: string): Promise<void> => {
  const [rgbProfileUint8Array, cmykProfileUint8Array] = await Promise.all([
    readFile(rgbProfileUrl),
    readFile(cmykProfileUrl),
  ]);
  const rgbProfile = new ColorProfile(rgbProfileUint8Array);
  const cmykProfile = new ColorProfile(cmykProfileUint8Array);
  profiles = { rgb: rgbProfile, cmyk: cmykProfile };
};

/**
 * 初始化 Worker
 */
const initializeWorker = async (config: {
  wasmUrl: string;
  rgbProfileUrl: string;
  cmykProfileUrl: string;
}): Promise<void> => {
  if (initialized) return;
  return Promise.all([
    initializeWasm(config.wasmUrl),
    initializeProfiles(config.rgbProfileUrl, config.cmykProfileUrl),
  ]).then(() => {
    initialized = true;
  });
};

5. 性能测试

我们将优化前后各操作的性能进行对比,测试基准条件如下:

  • 图片大小:127.3MB
  • 芯片:Apple M4
  • 核心数:10(4 性能和 6 能效)
  • 内存:32G
  • 浏览器:Chrome 144.0.7559.110(正式版本) (arm64)

单标签处理性能

阶段 主线程方案 Worker 方案(结构化克隆) Worker 方案(零拷贝传输)
初始化 - 619.20ms 730.10ms
图像处理 42710.70ms(42.71s) 48494.60ms(48.5s) 48281.70ms(48.27s)
通信耗时 - 61.40ms 53.00ms
组装 Blob 74.15ms 140.80ms 154.00ms
总耗时 42784.85ms(42.79s) 48696.8ms(48.7s) 48494.60ms(48.5s)

大图的处理时间稍长,实际上处理 20M 左右的图片,处理速度均控制在 10-20s 内。

多标签并发处理性能

指标 主线程方案 Worker 方案
标签 1 完成时间 43.25s 45.17s
标签 2 完成时间 40.39s 42.28s
标签 3 完成时间 无法处理 41.84s
全部完成时间 页面等待超 5 分钟才可以交互 45.17s
其他标签是否卡顿 所有同域标签全部卡死

内存使用对比

在 Chrome 中可以使用 performance.memory 获取当前的内存使用情况,其中返回对象的 jsHeapSizeLimit 字段表示当前 JavaScript 页面可以使用的最大堆内存限制。

在 64 位系统中,物理内存大于 16G 的,堆内存最大限制为 4G;小于等于 16G 的,最大堆内存限制为 2G。

在 32位系统中,最大堆内存限制为 1G。

参考:Performance.memory - Web API | MDN

场景 主线程方案 Worker 方案(结构化克隆) Worker 方案(零拷贝传输)
初始化前 536.96 MB 134.92 MB 153.93 MB
初始化后 653.57 MB 171.09 MB 156.86 MB
Blob组装前 653.57 MB 238.52 MB 230.86 MB
发送前 3105.70 MB (对应图像处理中) 355.71 MB 348.05 MB
接收后 415.65 MB 364.07 MB
Blob 组装后 3105.70 MB 415.65 MB 364.07 MB

在主线程方案的测试过程中,第二个标签页在处理图像过程中,堆内存来到了 5492.76 MB,已经超出了 4G 的堆内存限制,这直接导致了第三个标签页的白屏崩溃。而 Worker 方案,页面全部正常展示 Loading,未出现白屏等情况,所有页面几乎同时输出了转换后的图片。

设计师使用的设备为公司统一采购的 M1 芯片 iMac,16G 内存。

在设计师的机子上 Chrome 最大堆内存限制为 2G,主线程方案仅支持同时开启一个标签页处理

优化效果总结

  1. 稳定性:突破 4GB 堆内存瓶颈

这是本次优化最显著的成果。在 64 位 Chrome 中,即便物理内存高达 32GB,单个标签页的 JS 堆内存限制(jsHeapSizeLimit)通常仍被锁定在 4GB

主线程方案在处理 120MB+ 大图时,瞬时内存飙升至 3.1GB。当开启 3 个标签页并发处理时,内存占用迅速叠加至 5.5GB 左右,触发 OOM,导致浏览器标签页直接白屏崩溃

通过将计算密集型任务移出主线程,主线程内存始终维持在 300MB-400MB 的较低水平。Worker 方案成功绕过了单线程堆内存限制,实现了 5 个以上标签页的稳定并发。

  1. 用户体验:从“全域卡死”到“流畅加载”

主线程方案在处理期间,由于执行栈被 ImageMagick 完全阻塞,导致同域下的所有标签页失去响应,用户无法进行任何交互。

Worker 方案虽然在单线程处理耗时上略慢于主线程(约增加 13% 的上下文开销),但它保证了 UI 的绝对响应速度。用户在处理百兆大图的同时,依然可以平滑地切换标签页、点击按钮或观看 Loading 动画。

  1. 数据传输优化:零拷贝的价值

使用结构化克隆时,数据发送前后有 60MB 的内存差值,而零拷贝将内存波动降至 16MB,在大数据量下,这个差距会随着并发量的增加而变得极度明显。

通过使用零拷贝传输,我们避免了 CPU 密集的序列化过程,同时减少了内存峰值和 GC 压力,保证了并发情况下页面的正常使用。

  1. 综合对比看板
维度 主线程方案 Worker 方案 (优化后) 结论
单图总耗时 42.79s 48.5s 主线程略快,但牺牲了交互性
并发可靠性 极差 (仅支持2次并发) 极优秀 (并发无压力) Worker 解决了生存问题
主线程内存峰值 3105.70 MB 364.07 MB 降低了 88% 的主线程内存压力
交互体验 页面完全冻结 始终流畅 核心体验提升

九、总结

本次需求从一个看似简单的"颜色不对"问题出发,最终演变成了一次涉及色彩科学、图像处理、Web 技术栈选型以及前端性能优化的综合技术攻坚。

回顾整个过程,我们遇到的困难主要集中在三个方面:

技术选型的权衡:从 Sharp 到 ImageMagick,从 Node.js 到 Python,再到 WebAssembly,每一种方案都有其适用场景和局限性。我们需要在功能完整性、性能表现、开发成本以及基建适配性之间反复权衡。

基础设施的限制:CI/CD 环境的网络策略、服务器性能、字体授权合规等"非技术"因素,往往会成为技术方案落地的最大障碍。这提醒我们,技术方案的设计不能脱离实际的业务环境。

用户体验的坚守:最初的服务端方案虽然功能简单完善,但超 100s 的等待时间完全无法接受。正是对用户体验的坚持,驱使我们最终找到了客户端 WASM 方案,并通过性能优化将处理时间大大缩短到 20 秒内。

最终,通过在浏览器端集成 @imagemagick/magick-wasm,我们实现了:

  • 完整的 ICC Profile 支持,精确控制色彩转换
  • 统一的渲染意图和黑场补偿配置,转换效果相较专业设计软件(AI/PS)色差低于 1%。
  • 无需服务端参与,避免了网络传输问题和字体合规风险。
  • 本地多线程处理,支持并发图像处理,最大程度利用设备性能。
  • 解决印刷色差问题,节约 80% 设计师重复劳动

这次经历让我们深刻认识到:解决问题的过程往往比问题本身更有价值。在探索过程中积累的色彩管理知识、WASM 技术和性能优化经验、以及对业务场景的深入理解,都将成为团队宝贵的技术资产。

更重要的是,这次技术改造不仅解决了燃眉之急,更为后续的图像处理需求奠定了坚实基础。当下次遇到类似的图像处理问题时,我们已经有了一套成熟的解决思路和技术储备。

技术服务业务,业务驱动技术。希望这次实践能为遇到类似问题的朋友们提供一些参考和启发。

参考文章

High performance Node.js image processing

ImageMagick | Mastering Digital Image Alchemy

Photoshop功能|使用颜色配置文件

Troubleshooting Common Problems

Relative Colorimetric or Perceptual? Which Rendering Intent Should I Use? - YouTube

What is LAB Color Space? [HD] - YouTube

浅谈显示器色域:从sRGB到广色域 - 知乎

可转移对象 - Web API | MDN

150万开发者“被偷家”!这两款浓眉大眼的 VS Code 插件竟然是间谍

作者 JarvanMo
2026年2月9日 09:14

两款看起来完全合法的常用 VS Code 插件刚刚上演了我调查过最复杂的供应链攻击。如果你是一名开发者,很有可能你已经中招了。

数据触目惊心:总计 150 万次安装。两款插件运行起来都毫无瑕疵,提供的 AI 编程辅助功能与宣传的一模一样。而这恰恰是它们最危险的地方。

当你正忙着敲代码、开发新功能时,这些插件却在后台悄无声息地搜刮你打开的每一个文件、记录你的每一次按键,并将所有内容传输到位于中国的服务器。没有警告,没有授权弹窗。只有一场沉默而系统性的窃取。

让我带你看看事情的真相,因为这不仅仅是又一个“安全警示”,它更是现代供应链攻击的教科书式案例,揭示了为什么你的开发环境可能是整个安全防护体系中最薄弱的一环。


完美伪装:功能强大的“良性”插件

这就是被 Koi Security 的安全研究员命名为“恶搞柯基(MaliciousCorgi)”行动真正令人感到恐怖的地方:这两款插件确实提供了价值。

安装 ChatGPT — 中文版ChatMoss,你确实能得到正儿八经的 AI 代码建议。询问关于代码的问题,你会收到准确且有帮助的回答。自动补全很丝滑,错误解释也合情合理。一切功能都表现得像一个现代 AI 编程助手该有的样子。

这不是那种会导致 IDE 崩溃或满屏弹窗的拙劣恶意软件。攻击者明白一个核心道理:功能性等于信任。当一个工具兑现了它的承诺,开发者就会放下戒备,不再追问。

根据 Koi Security 安全研究员的调查,这两款恶意 VS Code 插件在被发现之前,总计安装量已达到惊人的 150 万次。其中,名为 “ChatGPT — 中文版” 的插件占据了绝大部分的市场份额。

这两款插件都顺利通过了微软的市场审核流程。它们累积了超过一百万次的下载量,在 VS Code 官方市场上公开存在了数月之久,甚至还收获了大量不知情用户的五星好评,而这些用户完全没意识到自己运行的是间谍软件。

三大隐秘通道,一场毁灭性打击

在提供宣传中的 AI 功能之余,这些插件在后台静默运行着三个并行的辅助数据采集机制。你可以把它们想象成从不同角度钻入你开发环境的抽油井。

通道 1:实时文件监视

  • 触发机制:只要你在 VS Code 中打开任何文件(无需编辑或保存),插件就会读取其全部内容。
  • 操作方式:文件内容会被 Base64 编码,并传输到一个隐藏的追踪 iframe 中。
  • 监控力度:正常的 AI 自动补全仅需光标周围约 20 行上下文,但这些插件会抓取每一份文件,且在每次通过 onDidChangeTextDocument 事件进行编辑时都会重复此操作。
  • 泄露内容:你的核心算法、构建数周的 API 集成、甚至是写给自己的临时处理方案注释,都被发送到了未经授权的服务器。

通道 2:服务器受控的精准收割

  • 机制:插件维持着一个命令控制通道(C&C),随时等待指令。
  • 指令执行:当服务器发送类似 {"type": "getFilesList"} 的 JSON 指令时,插件会从你的工作区批量抓取多达 50 个文件。
  • 目标选择:这种抓取并非随机,而是极具针对性地锁定除图片以外的所有内容,包括数据库凭证、包含 API 密钥的 .env 文件、AWS 访问令牌和 SSH 私钥。
  • 隐蔽性:整个过程完全静默,没有进度条,没有通知,你根本察觉不到整个项目正在被有计划地窃取。

通道 3:画像引擎

  • 技术手段:插件的 webview 中嵌入了一个完全透明且不可见的零像素 iframe。
  • 专业平台:该 iframe 加载了诸葛 IO(Zhuge.io)、GrowingIO、TalkingData 和百度统计四个商业分析平台。
  • 深度分析:这些企业级系统旨在构建全面的用户画像,追踪你的身份、地理位置、所属公司、核心项目以及日常活动模式。
  • 攻击意图:画像引擎会告诉攻击者哪些人的文件最值得偷。如果你身处头部科技公司或独角兽企业,或者是负责核心基础设施的高级工程师,你的工作区将成为高价值目标。

这是一种在开发者层面实施的典型供应链攻击方法:先画像,再窃取,最后将其武器化。

为什么这次攻击能大获全胜(以及这对你意味着什么)

AI 编程助手从根本上改变了开发者工具的信任模型。这些插件为了实现其功能,必须获得广泛的访问权限。它们需要读取你的代码来提供建议,也需要理解你的项目结构来给出相关的回答。

这种合法的需求为恶意行为提供了完美的伪装。你该如何区分一个 AI 助手是在读取 20 行上下文,还是在窃取整个文件?你又该如何分辨是有益的数据分析还是侵入性的用户画像? 除非使用专业的安全工具,否则你根本无法区分,而大多数开发者并不会运行这类工具。

VS Code 市场采取了多重安全措施:包括多种杀毒引擎的恶意软件扫描、异常使用模式监测、名称抢注预防以及插件签名验证。然而,这些插件通过了所有审核。

究其原因,是因为功能完善的恶意软件看起来与合法软件无异。其代码运行正常,行为逻辑合理,发布者看起来也完全可信,没有任何明显的危险信号。这就是当下的新现实:恶意插件不再需要看起来鬼鬼祟祟,它们只需要“好用”就行了。


更广泛的趋势:你的 IDE 已沦为攻击目标

“恶搞柯基(MaliciousCorgi)”行动并非孤立事件。它是针对开发环境日益升级的连环攻击中的一部分。

  • 官方清理:仅在 2025 年,微软就从 VS Code 市场中移除了 110 个恶意插件。
  • GlassWorm 行动:该攻击劫持了多个 OpenVSX 插件,将其转化为自传播蠕虫,在 36,000 次安装中窃取了来自 GitHub、npm 和加密货币钱包的凭证。
  • Shai-Hulud 蠕虫:它攻击了 npm 生态系统,向 100 多个包注入了自复制恶意软件,用于抓取令牌并自动发布到更多包中。其第二版更加激进:如果窃取凭证失败,它会尝试摧毁受害者的整个家目录。
  • PackageGate 漏洞:在 npm、pnpm、vlt 和 Bun 中发现了 6 个零日漏洞,这些漏洞能绕过 lockfile 完整性检查并自动执行恶意代码。pnpm 修复了两个严重漏洞(CVE-2025–69263 和 CVE-2025–69264),而微软旗下的 npm 却以“行为符合预期”为由关闭了漏洞报告。

你看清其中的规律了吗?攻击者正在系统性地瞄准开发者供应链的每一个环节。无论是包管理器、IDE 插件还是构建工具,只要是代码流经的开发环节,现在都被视为可利用的攻击面。

这些攻击之所以屡屡得手,是因为我们的开发工具在设计初衷上优先考虑的是便捷性,而非安全性

你现在应该立刻采取的操作

如果你正在阅读本文且电脑上安装了 VS Code,请按照以下紧急行动计划执行:

第一步:检查是否存在恶意插件

打开 VS Code 并进入“扩展(Extensions)”面板,搜索以下插件:

  • ChatGPT — 中文版(发布者:WhenSunset)
  • ChatMossCodeMoss(发布者:zhukunpeng)

如果你发现了其中任何一个,请立即将其卸载。

第二步:假设环境已失守

如果你曾安装过上述任一插件,请务必视工作区内的所有凭证为已泄露状态。这意味着你必须采取以下补救措施:

  • 更替所有的 API 密钥和访问令牌
  • 更改数据库密码
  • 重新生成 SSH 密钥
  • 撤销并重新签发云服务凭证(如 AWS、GCP、Azure 等)
  • 审查过去几个月的云服务商日志,排查是否存在未经授权的访问记录

没错,这确实很痛苦。但比起几个月后才猛然发现有人一直在盗用你的 AWS 凭证挖掘加密货币,或者发现你的核心专利算法出现在了竞争对手的产品中,现在的这点麻烦显然要轻得多。

第三步:审计你的其他插件

你现在安装了多少个插件?你还记得当初为什么要安装它们吗?你确切知道每一个插件都在后台做些什么吗?

请逐一过遍你的插件列表,并针对每一个插件自问以下问题:

  • 它最后一次更新是什么时候?
  • 它的安装量有多少?
  • 发布者是否经过验证,或者至少是你认识的可靠来源?
  • 它请求的权限是否超出了其宣称功能所需的合理范围?

请移除所有你并非活跃使用的插件。请记住,每一个插件都是一个潜在的攻击面。

构建更稳固的防御体系

个人的警惕虽然有帮助,但还远远不够,这个问题需要系统性的解决方案。

  • 对于开发团队:建议实施插件白名单制度。

    • 创建一份经过安全团队审核的核准插件清单。
    • 在将新插件加入白名单前,利用 ExtensionTotal 或 VScan 等工具进行风险评估。
  • 对于企业组织:可以使用集中化的插件管理工具(如 JFrog 的 IDE Extensions Control)。

    • 这类工具提供经过审核的本地缓存仓库。
    • 能够防止开发者安装未经授权的附加组件。
  • 对于个人开发者:对插件采取“零信任”心态。

    • 高下载量并不等同于安全,功能好用也不代表值得信任。
    • 功能性与安全性是两种完全独立的属性。
    • 监控更新:长期停更的项目突然发布新版本是一个危险信号,这可能意味着账号已被劫持。
    • 审查改动:开启插件更新通知,以便在自动更新前查看具体改动内容。
    • 环境隔离:针对不同的工作场景使用独立的 VS Code Profile。
    • 保持纯净:在处理敏感项目时使用仅含最少插件的纯净配置;而那些插件成堆的配置仅用于实验和学习,而非生产工作。

展望未来

截至 2026 年 1 月 26 日,“恶搞柯基(MaliciousCorgi)”的两款插件仍可在 VS Code 市场上获取。虽然微软安全团队已收到通知,这些插件最终会被移除并加入黑名单以触发自动卸载,但这终究是亡羊补牢,而非未雨绸缪。

新的攻击行动可能已经在某处悄然展开,它们使用不同的发布者账号、不同的插件名称,甚至更先进的技术。攻击者在每一次迭代中都在学习。

现在的问题不再是是否还会发生此类攻击,而是开发者和企业能否足够快地转变安全习惯来应对威胁。你的开发环境现在就是基础设施,请务必审计它、监控它,并应用与生产系统同等严苛的安全纪律。

因为下一次,受害者可能不再是 150 万开发者,而仅仅是你自己。等到你察觉时,你的代码、凭证以及公司的知识产权,或许早已易手。

你的团队在安装插件前是如何进行审核的?欢迎在评论区分享你的做法,我很想知道不同组织是如何应对这一挑战的。

jQuery 4.0 发布,IE 终于被放弃了

2026年2月9日 09:10

那个曾经风靡一时的 jQuery,它 20 岁了。

说实话,第一次看到 jQuery 4.0 发布 这个消息的时候,我是愣了一下的。

因为我以为它早就不会再有什么大版本了。

一个诞生于 2006 年的 JavaScript 库,在 Vue、React、Svelte、各种框架层出不穷的今天,居然还能在 2026 年,发布一个 Major 版本。

而且不是简单的修修补补,是一次真正意义上的大更新。


这次升级,把该砍掉的砍掉了,向现代浏览器靠拢。

1、不再支持 IE10 及以下

这个其实一点都不意外

  • IE10 及以下:直接放弃
  • IE11:暂时还活着,但已经开始拆支撑代码了
  • 官方已经明说:jQuery 5.0 移除专门支持 IE 11 及更早版本的代码

在这里插入图片描述

如果你现在的业务对 IE 的依赖很强,那么还是老老实实的用 jQuery3.x 吧。


2、大批 API 被移除了

下面这些 API,其实很多人都没有在用了。

比如:

  • jQuery.isArray
  • jQuery.trim
  • jQuery.parseJSON
  • jQuery.now
  • jQuery.isFunction
  • jQuery.isNumeric

官方态度也很直接:

浏览器早就有原生实现了,不会再重复造轮子

对应的替代方案也很清晰:

  • Array.isArray()
  • String.prototype.trim()
  • JSON.parse()
  • Date.now()

在这里插入图片描述

这一步,对老项目可能有点费劲,但对整个生态来说,反而是好事。


3、jQuery 终于现代化了

以前的 jQuery:AMD、RequireJS、构建方式很可以说是很老了。

现在源码直接是 ES Module,用 Rollup 打包,可以更好地和现代构建工具配合。

这意味着 jQuery 不再只能靠 script 标签活着了,终于可以被当成现代模块来使用

4、focus / blur 事件顺序变了

以前 jQuery 自己统一了一套事件顺序,现在它选择:

完全遵循 W3C 标准

也就是说,如果你项目里有比较复杂的事件联动:

  • focus
  • blur
  • focusin
  • focusout

那么升级前一定要多测一下。


5、Deferred 和 Callbacks 被彻底移除

jQuery 4.0 的 slim 版

  • 没有 Deferred
  • 没有 Callbacks
  • gzip 后只有 19.5KB

官方态度也很明确:

Promise 都是原生的了,还留这些干嘛

如果你还在用:

$.Deferred()

那升级前,最好先想好迁移方案。


我已经很多年没在新项目里用 jQuery 了,但看到 4.0 这个版本,还是觉得挺震撼的。

它可能不是最标准的技术选型,但在合适的地方,依然是个让人放心的工具,这其实已经很难得了。

本文首发于公众号:程序员大华,专注前端、Java开发,AI应用和工具的分享。关注我,少走弯路,一起进步!

记录overflow:hidden和scrollIntoView导致的页面问题

作者 EchoEcho
2026年2月9日 08:54

问题描述:

在一个编辑器中开发页面组件,组件内部对子元素设置了position:absolute定位,并且元素内容区域设置了overflow:hidden属性。

启动项目后,可以在编辑器中可以对该组件进行相关设置和修改。当切换选中内容时,页面会自动滚动,将选中组件显示到浏览器视口中,修改对应属性也会重新渲染对应组件。

第一次渲染时UI展示正常。但是当对该组件切换选中元素或者对设置了定位的子元素设置新属性时都会导致下图中子元素的定位异常。但是在调试面板中查询该元素属性值,也没有任何改变。尝试重新在控制面板中赋值对应的top值,模型又会显示到指定位置。

图片

解决过程

尝试使用内容监听器在组件被选中后,重新赋值对应的topleft值无法解决此问题。

后来通过浏览器断点调试,发现在触发监听器之前,该组件执行了scrollIntoView方法,见下图

图片

相关分析:

在执行ScrollIntoView期间会多次重绘【reflow/repaint】页面布局。而子元素中定位相关属性值会在重绘时基于父元素的当前视口上下文重新计算,导致位置偏移,比如上图中的子元素底部与父元素对齐现象。

  1. 平滑滚动动画(behavior: 'smooth'):
  • 动画过程会逐步改变滚动位置,触发多次布局计算。
  • 如果父元素有 overflow: hidden,子元素超出部分在动画中可能被“拉回”或重定位。
  1. block: 'center' 配置:
  • 这会尝试将父元素置于视口中心,如果父元素高度不是固定值(例如依赖内容或响应式),百分比 top 会基于新滚动位置重新计算,导致子元素“滑动”到底部对齐。
  1. 绝对定位的参考点变化:
  • absolute 元素依赖最近的 position: relative 祖先。在滚动动画中,如果祖先的可见区域变化,子元素的计算位置会偏移。
  1. 浏览器特定行为:
  • Chrome/Safari 在 smooth scroll 时有时会错误处理百分比定位,尤其是结合 overflow: hidden 时。

而这里遇到的问题就是在组件相关容器中设置了overflow:hidden

解决:

overflow:hidden改成overflow:clip就解决此问题了。

解析overflow:hiddenoverflow:clip

  • overflow: hidden
    • 隐藏超出元素边界的内容,但内容在内部仍然“存在”。
    • 不显示滚动条,但可以通过JavaScript(如 element.scrollLeft)或嵌套滚动访问隐藏内容。
    • 这是较早的标准值,广泛支持所有现代浏览器。
  • overflow: clip
    • 完全“剪切”超出边界的内容,就好像超出部分不存在一样。
    • 不允许任何形式的滚动访问(即使通过JS),内容被彻底丢弃。
    • 这是CSS Overflow Module Level 3 中的新值(引入于 2020 年左右),浏览器支持较新(Chrome 90+、Firefox 75+、Safari 15+)。在旧浏览器中可能回退到 hidden

2. 关键区别

方面 overflow: hidden overflow: clip
内容可见性 隐藏超出部分,但内容仍可通过JS 滚动访问。 完全剪切超出部分,无法通过任何方式访问。
滚动行为 创建一个隐形的滚动容器;滚动事件可冒泡到父元素。 不创建滚动容器;滚动事件直接传递给父元素,不被捕获。
性能影响 可能导致浏览器计算隐藏内容的布局和渲染(较低性能)。 优化性能:浏览器忽略超出内容的渲染和布局(更快,尤其在复杂页面)。
定位/粘性影响 支持 position: sticky 等行为;创建新的块格式化上下文 (BFC)。 不支持 position: sticky(元素不会粘性);不创建 BFC
JS 交互 可以用JS 修改滚动位置(如 scrollTo())。 无法用 JS 滚动;超出内容被视为不存在。
浏览器支持 所有现代浏览器(IE6+)。 较新浏览器;需检查兼容性(polyfill 有限)。
用例 适合需要隐藏但可能内部滚动的场景(如裁剪图片但允许JS动画)。 适合纯静态剪切场景(如性能敏感的游戏/UI),或防止意外滚动。
  • 核心差异总结:hidden 是“隐藏但可访问”的(像盖了个盖子),而 clip 是“彻底删除超出部分”的(像用剪刀剪掉)。clip 更严格,旨在提高性能,但牺牲了一些灵活性。

createAsyncThunk

作者 liyang_ii
2026年2月9日 00:20

一、 创建异步函数

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { getStuListApi } from '../api/stuApi';

// 1. 创建异步 thunk
const fetchStudents = createAsyncThunk(
  'students/fetchStudents',
  async () => {
    const response = await getStuListApi();
    return response;
  }
);

// 2. 创建 slice
const studentsSlice = createSlice({
  name: 'students',
  initialState: {
    list: [],
    loading: false,
    error: null,
  },
  reducers: {
    // 同步 reducers...
  },
  // 3. 使用 extraReducers 处理异步 action
  extraReducers: (builder) => {
    builder
      // pending:请求开始
      .addCase(fetchStudents.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      // fulfilled:请求成功
      .addCase(fetchStudents.fulfilled, (state, action) => {
        state.loading = false;
        state.list = action.payload;
      })
      // rejected:请求失败
      .addCase(fetchStudents.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

export default studentsSlice.reducer;

二、在组件中使用

import { fetchStudents } from '../store/modules/students';

function StudentsList() {
  const dispatch = useDispatch();
  const { list, loading, error } = useSelector((state) => state.students);

  useEffect(() => {
    dispatch(fetchStudents());
  }, [dispatch]);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error}</div>;

  return (
    <div>
      {list.map((student) => (
        <div key={student.id}>{student.name}</div>
      ))}
    </div>
  );
}

DNS详解——域名是如何解析的

作者 深度涌现
2026年2月8日 22:39

上周,我把域名指向了一个新的服务器。修改了A记录,等了一段时间……结果什么都没发生。旧网站仍然显示。我清除了浏览器缓存,还是不行。重启电脑,依然无济于事。

三个小时后,我了解了DNS传播和TTL(生存时间)。这个看似无关的探索让我真正理解了DNS的工作原理。不仅仅是“它将域名转换为IP地址”,而是整个系统。

以下是我学到的东西。

DNS 的本质很简单:它将域名解析为 IP 地址。你输入 example.com,DNS 返回 93.184.216.34,然后你的浏览器连接到该 IP 地址。如果没有 DNS,你就需要记住每个网站的 IP 地址。谁也不想这样。

但有趣的地方在于它是如何实现的。

DNS层次结构

让我最终理解 DNS 的思维模型是:它像一条层层递进的链子。每一层都了解下一层的信息,但没有哪一层知道全部信息。

DNS Hierarchy diagram showing Root, TLDs, Domains, and Subdomains

所有 DNS 查询都从这里开始。直到我需要调试传播问题时,我才知道根服务器的存在。全球共有 13 个根服务器集群,由 Verisign、ICANN 和 NASA 等组织运营。根服务器不知道 example.com 在哪里,但它们知道如何找到 .com 顶级域名服务器。它们是引用链的起点。

顶级域名 (TLD)

您知道这部分——域名的最后几部分:.com.org.in.io。顶级域名服务器知道所有注册在其名下的域名。.com顶级域名知道在哪里可以找到example.comgoogle.com以及其他所有.com域名。

常用顶级域名:

  • .com - 商业用途(任何人都可以注册)
  • .org - 组织
  • .net - 网络服务
  • .io - 英属印度洋领地(科技界人士青睐)
  • .dev - 开发者(需要 HTTPS)
  • .gov - 仅限美国政府
  • .edu - 教育机构

领域

你实际购买的域名包括:examplegooglegithub。加上顶级域名 (TLD) 后,就得到 example.com。这些域名可以从 Namecheap、GoDaddy 或 Cloudflare 等注册商处购买。我的域名使用Cloudflare ,DNS 管理简洁且免费。(了解 DNS 后,你或许可以看看我关于将 Django REST Framework 部署到生产环境的指南——在那里你会真正用到这些知识。)

子域名

域名之前的任何内容:api.example.commail.example.comstaging.example.com。最棒的是什么?您可以通过 DNS 记录自行创建这些内容,无需额外购买。如果您拥有 example.com,则可以免费创建无限数量的子域名。

DNS记录

我第一次打开域名DNS管理面板时,看到了一堆我不认识的记录类型:A、AAAA、CNAME、MX、TXT……看起来很吓人。但一旦你了解了每种记录的作用,其实就很简单了。

记录

这是你最常用到的配置项。它将域名映射到 IPv4 地址。当我把网站迁移到新服务器时,我修改的就是这个配置项。

example.com    A    93.184.216.34

当有人请求 example.com 时,DNS 返回 93.184.216.34

AAAA 记录

同样的方法,只不过是针对 IPv6 地址。为什么叫这个奇怪的名字?因为 IPv6 地址的长度是 IPv4 地址的四倍,所以用了四个 A。我自己还没设置过这种地址。

example.com    AAAA    2606:2800:220:1:248:1893:25c8:1946

CNAME(规范名称)

这个很巧妙。它没有指向某个IP地址,而是指向另一个域名。它的意思是“去问问这个域名吧”。

www.example.com     CNAME    example.com
blog.example.com    CNAME    example.ghost.io

使用第三方服务时超级实用。我用它来将子域名指向托管服务——你的博客可以指向 Ghost,你的文档可以指向 GitBook,而无需自己管理它们的 IP 地址。

MX(邮件交换)

有没有想过hello@yourdomain.com是如何知道该把邮件发送到哪里的?答案是MX记录。MX记录指定了你的域名邮件应该发送到哪里。优先级编号很重要——编号越小的邮件会优先发送。

example.com    MX    10    mail1.example.com
example.com    MX    20    mail2.example.com

当有人向hello@example.com发送电子邮件时,邮件服务器会检查MX记录以确定邮件投递地址。如果mail1(优先级10)出现故障,则会尝试使用mail2(优先级20)。

TXT 记录

这个乍一看似乎没什么特别的——它只是用来存储文本。但它实际上用于一些重要的用途。

example.com    TXT    "v=spf1 include:_spf.google.com ~all"
example.com    TXT    "google-site-verification=abc123xyz"

我曾使用 TXT 记录来:

  • 域名验证- Google、Stripe 和 AWS 会要求您添加 TXT 记录以证明您拥有该域名。
  • SPF - 指定哪些服务器可以代表您发送电子邮件(防止欺骗)
  • DKIM - 使用加密签名进行电子邮件身份验证
  • DMARC - 处理 SPF/DKIM 检查失败的策略

TTL(生存时间)

就是这一点让我彻底懵了。还记得我那三个小时的困惑吗?我旧 A 记录的 TTL 值是 3600 秒,也就是一个小时。所有缓存了我旧 IP 地址的解析器都会保留这个值长达一个小时,然后才会再次检查。

TTL(生存时间)告诉解析器要缓存 DNS 解析结果多长时间。

example.com    A    93.184.216.34    TTL=3600

这意味着:“此答案将缓存 3600 秒(1 小时)。在此之前请勿再次提问。”

常用 TTL 值:

  • 300(5分钟) - 频繁变更,需要快速故障转移
  • 3600(1 小时) - 普通网站
  • 86400(1天) - 很少变化

权衡取舍

TTL 值低= 更新速度快,但 DNS 查询次数多(首次加载速度稍慢)。

高 TTL = 更少的 DNS 查询(更好的性能),但更改记录时传播速度较慢。

我从惨痛的教训中学到的一个实用技巧:如果你计划迁移服务器,请提前几天将 TTL(生存时间)降低到 300。迁移后,旧的缓存记录会在 5 分钟内过期,而不是几个小时。真希望我在迁移前就知道这一点。

DNS解析过程

当我仔细梳理了整个过程后,一切都豁然开朗了。当你在浏览器中输入 api.example.com 时,实际发生的情况如下:

Step 1: Browser cache
"Do I have api.example.com cached?" → No

Step 2: OS DNS cache
"Do I have it?" → No

Step 3: Router cache
"Do I have it?" → No

Step 4: ISP's DNS Resolver
"Do I have it?" → No, let me find out...

Step 5: Ask Root Server
Resolver: "Where is api.example.com?"
Root: "I don't know, but .com TLD is at 192.5.6.30"

Step 6: Ask .com TLD Server
Resolver: "Where is api.example.com?"
TLD: "I don't know, but example.com's nameserver is at ns1.example.com"

Step 7: Ask example.com's Nameserver
Resolver: "Where is api.example.com?"
NS: "It's at 93.184.216.34"

Step 8: Response flows back
Resolver caches it (based on TTL)
Returns 93.184.216.34 to your browser

Step 9: Browser connects to 93.184.216.34

视觉呈现:

DNS Resolution Process diagram showing the flow from browser through caches to nameserver

每个中间解析器都会根据生存时间 (TTL) 缓存结果。来自同一网络的后续请求会跳过大部分请求过程,因为解析器已经知道答案。这就是为什么第二次访问网站比第一次访问快的原因。

什么是解析器?

我以前总是把域名解析器和域名服务器搞混。它们听起来很像,但功能却不一样。

解析器是一种 DNS 服务器,它负责为你查找 IP 地址。它通过根域名服务器 → 顶级域名服务器 → 权威域名服务器进行递归查找。

你的设备向解析器询问:“example.com 的 IP 地址是什么?” 解析器完成所有工作并返回:“是 93.184.216.34”。

常用公共解析器:

  • 8.8.8.8 - Google 公共 DNS
  • 1.1.1.1 - Cloudflare(速度最快,注重隐私)
  • 208.67.222.222 - OpenDNS(思科)
  • 9.9.9.9 - Quad9(专注于安全,可阻止恶意软件)

您可以在网络设置中更改使用的解析器。我已在我的机器上切换到 1.1.1.1——它明显比我的 ISP 的默认解析器快。

什么是域名服务器?

域名服务器是权威(Truth)的信息来源。它实际保存着域名的 DNS 记录。注册域名时,您需要将其指向域名服务器,这些服务器存储着您的 A 记录、CNAME 记录、MX 记录等。

解析器和域名服务器的区别:

  • 解析器- 通过向其他服务器(消息传递者)询问来查找答案
  • 域名服务器- 保存实际的 DNS 记录(权威(Truth)的来源)

当你从Namecheap或GoDaddy购买域名时,他们会提供默认的域名服务器,例如:

ns1.namecheap.com
ns2.namecheap.com

您可以更改这些设置以使用 Cloudflare、AWS Route 53 或您自己的域名服务器:

# Cloudflare nameservers
ns1.cloudflare.com
ns2.cloudflare.com

# AWS Route 53 (example)
ns-1234.awsdns-56.org
ns-789.awsdns-12.co.uk

为什么要费劲更改域名服务器?不同的服务商提供的功能各不相同。我把域名迁移到了 Cloudflare,因为他们提供免费的 DDoS 防护、CDN 服务,而且控制面板也更好用。如果你已经在使用 AWS,那么 Route 53 是个不错的选择。

权威型与递归型

权威(Authoritative )域名服务器——拥有域名的最终答案。当被问及“example.com 在哪里?”时,它会返回实际的 IP 地址,因为它保存着相关记录。

递归解析器——不保存记录,而是通过向权威服务器查询来查找答案。你的 ISP 的 DNS 服务器和 8.8.8.8 都是递归解析器。

查看 DNS 缓存

这些命令在我调试过程中帮了我大忙。当你需要查看实际缓存的内容时:

在浏览器中

# Chrome
chrome://net-internals/#dns

# Firefox
about:networking#dns

# Edge
edge://net-internals/#dns

此处显示所有已缓存的域名及其 IP 地址和剩余 TTL。

在您的电脑上

# Windows (Command Prompt)
ipconfig /displaydns

# Mac
sudo dscacheutil -cachedump

# Linux
# Most distros use systemd-resolved
resolvectl statistics

查询域名

# Using nslookup (Windows/Mac/Linux)
nslookup example.com

# Using dig (Mac/Linux - more detailed)
dig example.com

# Query specific record type
dig example.com MX
dig example.com TXT

# Query using a specific resolver
dig @8.8.8.8 example.com
dig @1.1.1.1 example.com

清除 DNS 缓存

浏览器

转到chrome://net-internals/#dns并单击“清除主机缓存”。

操作系统

# Windows
ipconfig /flushdns

# Mac
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder

# Linux (systemd)
sudo systemd-resolve --flush-caches

我应该先这么做的。清除缓存会强制系统获取最新的DNS记录,而不是使用过期的缓存记录。

为什么 DNS 对系统设计至关重要

DNS 的有趣之处远不止于“托管网站”。如果您正在设计大规模系统,DNS 就不仅仅是一个查询服务,而是一种工具。

负载均衡

DNS 可以为同一个域名返回不同的 IP 地址。每个请求都可能被分配到不同的服务器。这被称为 DNS 轮询负载均衡。

example.com    A    93.184.216.34
example.com    A    93.184.216.35
example.com    A    93.184.216.36

故障转移

如果一台服务器发生故障,DNS 服务将停止返回其 IP 地址。健康检查会检测到此故障,DNS 记录将被更新,流量将转移到正常运行的服务器。

地理路由

返回距离用户最近的服务器的 IP 地址。欧洲用户将获得欧洲服务器的 IP 地址,亚洲用户将获得亚洲服务器的 IP 地址。这称为地理域名系统 (GeoDNS) 或基于延迟的路由。

缓存的权衡取舍

DNS 缓存可以减少查询时间和 DNS 服务器的负载,但会导致无法即时更新。例如,如果更改 IP 地址且 TTL 设置为 24 小时,则部分用户在长达 24 小时内仍会访问旧 IP 地址。

快速参考

┌─────────────┬────────────────────────────────────────┐
│ Term        │ What it does                           │
├─────────────┼────────────────────────────────────────┤
│ Root        │ Top of DNS tree, knows TLD locations   │
│ TLD         │ .com, .org, .io - knows domains under  │
│ Domain      │ What you buy (example, google)         │
│ Subdomain   │ What you create free (api, www, mail)  │
├─────────────┼────────────────────────────────────────┤
│ A Record    │ Domain → IPv4 address                  │
│ AAAA Record │ Domain → IPv6 address                  │
│ CNAME       │ Domain → Another domain (alias)        │
│ MX          │ Where to send email                    │
│ TXT         │ Verification, SPF, DKIM, DMARC         │
├─────────────┼────────────────────────────────────────┤
│ TTL         │ Cache duration in seconds              │
│ Resolver    │ Server that finds IPs for you          │
└─────────────┴────────────────────────────────────────┘

DNS 解析导致我的网站瘫痪了三个小时。不过现在我终于明白了——不仅是它的作用,还有它的工作原理。下次再遇到解析失败的问题,我就知道该从哪里入手了。

www.bhusalmanish.com.np/blog/posts/…

mp.weixin.qq.com/s/G8oAelYq-…

JS 对象

作者 偶像佳沛
2026年2月8日 22:33

“为什么我给对象加的属性不见了?”“new 构造函数为什么不用 return 也能拿到对象?”“对象遍历为什么会拿到原型上的属性?”

重点:JS 万物皆对象

一、JS 对象是什么

简而言之,对象是用花括号 { } 包裹的键值对,


const a = 1
const person = {
  name: '小明',
  age: 18
}

二、JS 对象的创建方式

1.通过字面量创建

例如:

const person = {

}

这里的花括号相当于告诉了 V8 引擎,我现在写的东西是个对象,接下来的逻辑请按对象处理

2.通过 new 创建(调用官方提供的构造函数)

例如:

const a = new Object()
// a = {}

官方为 JS 提供了引用类型以及原始类型的构造函数,调用即可创建该类型的实例对象

3.通过自己写的构造函数通过 new 关键字调用

例如:

function Car(color) {
  this.name = 'su7'
  this.height = '1400'
  this.lang = '4800'
  this.weight = '1500'
  this.color = color
}
const car1 = new Car('purple')
const car2 = Car('purple')
console.log(car1, car2); // Car { name: 'su7', height: '1400', lang: '4800', weight: '1500', color: 'purple' } undefined

可以看到,car1 是通过 new 生成的,car2 则是直接通过直接调用我们写的 Car 函数生成的,为什么 car1 有值,car2 却是 undefined 呢?

先看 car2 ,因为我们在函数内并没有返回值,是 undefined 情有可原 再看 car1 ,同样没有返回值,可是有值,这唯一的区别就是 car1 是通过 new 生成的, 而在 new 一个函数(此处为Car) ,将 Car 用成了一个构造函数的样子,返回了一个实例对象,new 完源码其实是添加了返回值的,再来粗浅表达一下 new 源码大概干了些什么(不涉及原型链的部分的写法)


function Car(color) {
  this.name = 'su7'
  this.height = '1400'
  this.lang = '4800'
  this.weight = '1500'
  this.color = color
}
const car1 = new Car('purple')


// function Car(color) {
//   const a = {}
//   a.name = 'su7'
//   a.height = '1400'
//   a.lang = '4800'
//   a.weight = '1500'
//   a.color = color
//   return a
// }

像是官方提供的数据类型构造函数,其实是涉及原型链和原型的继承的,这个点后面文章再介绍,此文仅谈论对象的表层内容

4.Object.create()

这是官方提供的 Object 里的方法,侧重于原型的继承,后续文章详细介绍,此文提一嘴

三、对象的一些基本操作

1.对象身上的键值对是可以添加函数的,我们一般称之为方法

const a = {
  skill: function() {
    console.log('会飞');
  }
}

a.skill() // 会飞

2.对象身上的增加和函数属性值

const a = {
  skill: function() {
    console.log('会飞');
  }
}

a.skill() // 会飞

a.options = {
  color: 'red',
  size: '1000'
}

console.log(a.options); // { color: 'red', size: '1000' }

delete a.options

console.log(a.options); // undefined

此处介绍了 JS 中对象怎么删除和添加属性

四、包装类

这个名词也就是看着专业

在你定义一个字面量,不管是谁的字面量,官方都是通过 new 字段调用对应数据类型的构造函数生成的

这也是 JS 万物皆对象的依据


var num = 123  // new Number(123)
num.a = 'aaa'
console.log(num.a); // undefined

// // 原始类型一定无法添加属性和方法,属性和方法是对象独有的
// var num = 123  // new Number(123)
// num.a = 'aaa'
// delete num.a
// console.log(num.a); // undefined

按理来说,在给身为String类型的 num 添加 a 属性的时候就应该报错,但是 JS 是弱类型语言,一个变量只有在被使用的时候才知道是什么类型

在打印 num.a 的时候, V8 才想起来了, numString 类型,而不是一个对象,所以源码在编译运行的时候, 偷偷把 num 身上的属性值给删掉了,当值被判定 为原始类型时就会自动将包装对象上添加的属性移除

但是在给 num 加属性的时候,JS 是真让把 num 当对象使

这里再举一个例子,涉及一点原型,后端文章会具体讲


var str = 'hello' 
typeof('hello')  // string
var str = new String('hello')
str.length = 2

console.log(str.length);  // 5

delete str.length

console.log(str.length);  // 5

其实 JS 中,String 类型的数据身上本就有 length 属性,我这里给他再挂一个 length ,可以看到,打印出来的 str.length 还是 5 ,就算把这个 length 删了,他还是有

这个是什么原因,最主要的,就是因为 length 不是挂在 str 的表层上,他是挂在 String 的原型上,这里 strlength 其实是继承过来的,关于原型的介绍,后续文章会提及

UniApp 路由导航守卫

作者 如果你好
2026年2月8日 22:09

UniApp 路由导航守卫

大家平时做 Vue 项目,路由守卫基本都是标配:beforeEach 一写,白名单、token 校验、跳转拦截一气呵成。

但换到 UniApp 就会发现一个问题:没有 Vue-Router,也没有 beforeEach

很多人刚上手都会懵:路由守卫到底怎么写?

其实 UniApp 有自己的方案,就是官方提供的 拦截器(interceptor),今天就把完整可落地、直接复制粘贴的路由守卫给你们。


Vue 里是这样写的

router.beforeEach((to, from, next) => {
  if (to.path === '/login') return next()
  const token = sessionStorage.getItem('token')
  if (!token) return next('/login')
  next()
})

逻辑很简单:

  • 白名单直接过
  • 没 token 跳登录
  • 有 token 正常跳转

但 UniApp 不这套,它的页面跳转全靠:

  • uni.navigateTo
  • uni.redirectTo
  • uni.reLaunch
  • uni.switchTab

所以思路也很直接:把这几个 API 全部拦截,自己做校验


UniApp 路由守卫核心:拦截器

官方文档里其实写得很清楚: uni.addInterceptor 可以拦截几乎所有 uni 开头的 API,包括路由、请求、扫码、支付等等。

路由守卫,本质就是: 拦截路由跳转 → 判断权限 → 放行 / 拦截跳登录

拦截器最重要的就是一个方法: invoke(args)

  • return true = 放行
  • return false = 拦截,不执行原来的跳转

懂这个,整个路由守卫就通了。


直接上代码:utils/interceptor.js

// 全局路由拦截器(路由守卫)
// 在 App.vue onLaunch 中调用一次即可

// 白名单:不需要登录就能访问的页面
const whiteList = new Set([
  '/pages/login/login',
  // '/pages/register/register', 想加就加
])

// 核心校验逻辑
function checkAuth(url) {
  // 截取纯路径,忽略 ? 参数
  const path = url.split('?')[0]

  // 白名单直接放行
  if (whiteList.has(path)) {
    return true
  }

  // 校验 token
  const token = uni.getStorageSync('token')
  return !!token
}

// 拦截器配置
const routeInterceptor = {
  invoke(args) {
    console.log('即将跳转:', args.url)

    // 校验通过,正常跳转
    if (checkAuth(args.url)) {
      return true
    }

    // 未登录 → 跳登录,并把原来要跳的地址带上
    uni.redirectTo({
      url: `/pages/login/login?redirect=${encodeURIComponent(args.url)}`
    })

    // 拦截本次路由跳转
    return false
  }
}

// 注册所有路由拦截
export function initRouteGuard() {
  uni.addInterceptor('navigateTo', routeInterceptor)
  uni.addInterceptor('redirectTo', routeInterceptor)
  uni.addInterceptor('reLaunch', routeInterceptor)
  uni.addInterceptor('switchTab', routeInterceptor)

}

在 App.vue 中启用(非常关键)

为什么必须写在 onLaunch

因为: 拦截器必须在任何页面跳转之前就注册好 onLaunch 是应用启动最早的生命周期,只执行一次,最适合干这事。

<script>
import { initRouteGuard } from '@/utils/interceptor.js'

export default {
  onLaunch() {
    // 启动路由守卫
    initRouteGuard()
  },
  onShow() {},
  onHide() {}
}
</script>

登录成功后如何“回跳原来页面”

拦截时我们拼了一个 redirect 参数:

/login?redirect=xxx

登录成功后这样跳回去就行:

async function login() {
  // 登录请求...
  const token = res.data.token
  uni.setStorageSync('token', token)

  // 获取当前页面实例
  const pages = getCurrentPages()
  const current = pages[pages.length - 1]
  const redirect = current.options.redirect

  if (redirect) {
    // 跳回原来想访问的页面
    uni.redirectTo({
      url: decodeURIComponent(redirect)
    })
  } else {
    // 默认跳首页或 tabBar
    uni.switchTab({
      url: '/pages/home/home'
    })
  }
}

一些实用小细节

  1. tabBar 页面也能拦截 switchTab 已经包含在拦截里,没 token 照样跳登录。

  2. 路径一定要写完整 /pages/login/login 别写错,不然白名单不生效。

  3. navigateBack 一般不用拦截 返回上一页通常不需要权限,除非你有特殊场景。

  4. 可扩展权限控制 想加角色、验过期、验状态,直接在 checkAuth 里加逻辑就行,非常灵活。


总结

  • UniApp 没有 beforeEach,但能用 uni.addInterceptor 实现一模一样的效果
  • 拦截 navigateTo / redirectTo / reLaunch / switchTab 四个 API 就够覆盖所有路由
  • 白名单 + token 校验 = 最常用路由守卫
  • 必须在 App.vue onLaunch 里初始化,否则不生效
  • 代码直接复制,改改页面路径就能上线

这套写法我在好几个 UniApp 项目里都在用,H5、小程序、App 全端稳定,没坑。

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

2026年2月8日 21:18

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

前言:当我第一次学习前端时,导师让我实现一个Todo应用。我花了2小时写了50行代码,导师看了一眼说:“试试Vue3吧。” 我用30分钟重写了同样的功能,代码减少到20行。那一刻,我明白了什么是真正的数据驱动开发。今天,我想通过这个Todo应用,带你体验这场思维革命。

第一章:传统开发方式的困境

让我们先回顾一下用原生JavaScript实现的Todo应用:

<!-- demo.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>传统Todo应用</title>
</head>
<body>
    <h2 id="app"></h2>
    <input type="text" id="todo-input">
    <script>
        // 传统做法:命令式编程
        const app = document.getElementById('app')
        const todoInput = document.getElementById('todo-input')
        
        // 手动监听事件
        todoInput.addEventListener('change', function(event){
            const todo = event.target.value.trim()
            if(!todo){
                console.log('请输入任务')
                return
            }
            // 手动更新DOM
            app.innerHTML = todo
        })
    </script>
</body>
</html>

🔍 传统方式的三大痛点:

  1. 命令式编程:你需要像指挥官一样告诉浏览器每一步该做什么
  2. DOM操作繁琐:每次数据变化都要手动查找和更新DOM
  3. 关注点错位:80%的代码在处理界面操作,只有20%在处理业务逻辑

这就像每次想改变房间布局,都要亲自搬砖砌墙

第二章:Vue3的数据驱动革命

现在,让我们看看用Vue3实现的完整Todo应用:

<!-- App.vue -->
<template>
  <div>
    <!-- 1. 数据绑定 -->
    <h2>{{title}}</h2>
    
    <!-- 2. 双向数据绑定 -->
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="输入任务后按回车"
    >
    
    <!-- 3. 条件渲染 -->
    <ul v-if="todos.length">
      <!-- 4. 列表渲染 -->
      <li v-for="todo in todos" :key="todo.id">
        <!-- 5. 双向绑定到对象属性 -->
        <input type="checkbox" v-model="todo.done">
        
        <!-- 6. 动态class绑定 -->
        <span :class="{done: todo.done}">{{todo.title}}</span>
      </li>
    </ul>
    
    <!-- 7. v-else指令 -->
    <div v-else>
      暂无任务
    </div>
    
    <!-- 8. 计算属性使用 -->
    <div>
      进度:{{activeTodos}} / {{todos.length}}
    </div>
    
    <!-- 9. 计算属性的getter/setter -->
    全选<input type="checkbox" v-model="allDone">
  </div>
</template>

<script setup>
// 10. Composition API导入
import { ref, computed, watch } from 'vue'

// 11. 响应式数据
const title = ref("Todos任务清单")
const todos = ref([
  {
    id: 1,
    title: '学习vue',
    done: false
  },
  {
    id: 2,
    title: '打王者',
    done: false
  },
    {
    id: 3,
    title: '吃饭',
    done: true
  }
])

// 12. 计算属性
const activeTodos = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 13. 方法定义
const addTodo = () => {
  if(!title.value) return
  
  todos.value.push({
    id: Date.now(),  // 更好的ID生成方式
    title: title.value,
    done: false
  })
  
  title.value = ""
}

// 14. 计算属性的getter/setter
const allDone = computed({
  get() {
    return todos.value.length > 0 && 
           todos.value.every(todo => todo.done)
  },
  set(val) {
    todos.value.forEach(todo => todo.done = val)
  }
})

// 15. 监听器 - 补充知识点
watch(todos, (newTodos) => {
  console.log('任务列表发生变化:', newTodos)
  // 可以在这里实现本地存储
}, { deep: true })

// 16. 生命周期钩子 - 补充知识点
import { onMounted } from 'vue'
onMounted(() => {
  console.log('组件挂载完成')
  // 可以在这里从本地存储读取数据
})
</script>

<style>
.done {
  color: gray;
  text-decoration: line-through;
}

/* 17. 组件样式作用域 - 补充知识点 */
/* 这里的样式只作用于当前组件 */
</style>

第三章:Vue3核心API深度解析

🎯 1. ref - 响应式数据的基石

代码:

const title = ref("Todos任务清单")

补充:

  • ref用于创建响应式引用
  • 访问值需要使用.value
  • 为什么需要.value?因为Vue需要知道哪些数据需要被追踪变化
// ref的内部原理简化版
function ref(initialValue) {
  let value = initialValue
  return {
    get value() {
      // 这里可以收集依赖
      return value
    },
    set value(newValue) {
      value = newValue
      // 这里可以通知更新
    }
  }
}

🎯 2. v-model - 双向绑定的魔法

代码:

<input type="text" v-model="title">

补充: v-model实际上是语法糖,它等于:

<input 
  :value="title"
  @input="title = $event.target.value"
>

对于复选框,v-model的处理有所不同:

<input type="checkbox" v-model="todo.done">
<!-- 等价于 -->
<input 
  type="checkbox" 
  :checked="todo.done"
  @change="todo.done = $event.target.checked"
>

🎯 3. 指令系统详解

v-show vs v-if

<!-- v-if是真正的条件渲染 -->
<div v-if="show">条件渲染</div> <!-- 会从DOM中移除/添加 -->

<!-- v-show只是控制display -->
<div v-show="show">显示控制</div> <!-- 始终在DOM中,只是display切换 -->

动态参数

<!-- 动态指令参数 -->
<a :[attributeName]="url">链接</a>
<button @[eventName]="doSomething">按钮</button>

🎯 4. computed - 智能计算属性

细节

// 计算属性的缓存特性
const expensiveCalculation = computed(() => {
  console.log('重新计算') // 只有依赖变化时才会执行
  return todos.value
    .filter(todo => !todo.done)
    .map(todo => todo.title.toUpperCase())
    .join(', ')
})

// 依赖没有变化时,直接返回缓存值
console.log(expensiveCalculation.value) // 输出并打印"重新计算"
console.log(expensiveCalculation.value) // 直接返回缓存值,不打印

🎯 5. watch - 数据监听器

重要知识点:

// 1. 监听单个ref
watch(title, (newTitle, oldTitle) => {
  console.log(`标题从"${oldTitle}"变为"${newTitle}"`)
})

// 2. 监听多个数据源
watch([title, todos], ([newTitle, newTodos], [oldTitle, oldTodos]) => {
  // 处理变化
})

// 3. 立即执行的watch
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { immediate: true }) // 组件创建时立即执行一次

// 4. 深度监听
watch(todos, (newTodos) => {
  // 可以检测到对象内部属性的变化
}, { deep: true })

🎯 6. 生命周期钩子

完整生命周期:

import { 
  onBeforeMount, 
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onErrorCaptured
} from 'vue'

onBeforeMount(() => {
  console.log('组件挂载前')
})

onMounted(() => {
  console.log('组件已挂载,可以访问DOM')
})

onBeforeUpdate(() => {
  console.log('组件更新前')
})

onUpdated(() => {
  console.log('组件已更新')
})

onBeforeUnmount(() => {
  console.log('组件卸载前')
})

onUnmounted(() => {
  console.log('组件已卸载')
})

onErrorCaptured((error) => {
  console.error('捕获到子组件错误:', error)
})

第四章:Vue3开发模式的优势

🚀 1. 开发效率对比

功能 传统JS代码量 Vue3代码量 效率提升
数据绑定 10-15行 1行 90%
列表渲染 15-20行 3行 85%
事件处理 5-10行 1行 80%
样式绑定 5-10行 1行 80%

🎯 2. 思维模式转变

传统开发思维(怎么做):

1. 找到DOM元素
2. 监听事件
3. 获取数据
4. 操作DOM更新界面

Vue3开发思维(要什么):

1. 定义数据状态
2. 描述UI与数据的关系
3. 修改数据
4. 界面自动更新

💡 3. 性能优化自动化

Vue3自动为你做了这些优化:

// 1. 虚拟DOM减少真实DOM操作
// 2. Diff算法最小化更新
// 3. 响应式系统精确追踪依赖
// 4. 计算属性缓存避免重复计算
// 5. 组件复用减少渲染开销

第五章:实战技巧与最佳实践

📝 1. 代码组织建议

<script setup>
// 1. 导入部分
import { ref, computed, watch, onMounted } from 'vue'

// 2. 响应式数据
const title = ref('')
const todos = ref([])

// 3. 计算属性
const activeCount = computed(() => { /* ... */ })

// 4. 方法定义
const addTodo = () => { /* ... */ }

// 5. 生命周期
onMounted(() => { /* ... */ })

// 6. 监听器
watch(todos, () => { /* ... */ })
</script>

🎨 2. 样式管理技巧

<style scoped>
/* scoped属性让样式只作用于当前组件 */
.todo-item {
  padding: 10px;
}

/* 深度选择器 */
:deep(.child-component) {
  color: red;
}

/* 全局样式 */
:global(.global-class) {
  font-size: 16px;
}
</style>

🔧 3. 调试技巧

// 1. 在模板中调试
<div>{{ debugInfo }}</div>

// 2. 使用Vue Devtools浏览器插件
// 3. 使用console.log增强
watch(todos, (newTodos) => {
  console.log('todos变化:', JSON.stringify(newTodos, null, 2))
}, { deep: true })

结语:从学习者到实践者

通过这个Todo应用,我们看到了Vue3如何将我们从繁琐的DOM操作中解放出来,让我们能更专注于业务逻辑。这种声明式编程的思维方式,不仅让代码更简洁,也让开发更高效。

记住

  1. Vue3不是魔法,但它让开发变得像魔法一样简单
  2. 学习Vue3不仅是学习一个框架,更是学习一种更好的编程思维
  3. 从今天开始,尝试用数据驱动的方式思考问题

下一步建议

  1. 在Vue Playground中多练习
  2. 阅读Vue3官方文档
  3. 尝试实现更复杂的功能(过滤、搜索、排序)
  4. 学习Vue Router和Pinia

📚 资源推荐

希望这篇文章能帮助你更好地理解Vue3的强大之处!如果你有任何问题或想法,欢迎在评论区讨论交流。🌟

一起进步,从今天开始!

构建全栈AI应用:集成Ollama开源大模型

2026年2月8日 20:59

在AI技术迅猛发展的今天,开源大模型如 DeepSeek 系列为开发者提供了强大工具,而无需依赖云服务。构建一个全栈AI应用,不仅能深化对前后端分离架构的理解,还能探索AI集成的最佳实践。本文将基于一个实际项目,分享如何使用React前端、Node.js后端,并通过 LangChain 库调用 Ollama 部署的 DeepSeek-R1:8b 模型,实现一个简单的聊天功能。这个项目适用于初学者上手全栈开发,或资深开发者扩展AI能力。

项目需求源于日常场景:开发者常常需要快速测试AI响应,或构建原型应用来验证想法。传统方式可能涉及复杂API调用,而开源Ollama简化了本地部署。技术栈选择上,前端采用React结合Tailwind CSS和Axios,实现响应式UI和网络交互;后端使用Express框架提供API服务;AI部分则集成Ollama,确保模型运行高效且本地化。这不仅仅是代码堆砌,更是关于模块化、容错和跨域处理的综合实践。

接下来,我们将剖析项目架构、代码实现和关键知识点,包括自定义Hook、API管理、提示工程等。通过提供的代码示例,读者可以轻松复现,并根据需要扩展为更复杂的应用,如代码审查或内容生成工具。

项目架构概述

项目采用前后端分离设计,确保各部分独立开发和部署。

  • 前端:浏览器端运行(默认端口5173),处理用户输入和显示AI响应。使用React构建,借助自定义Hook封装逻辑,避免组件复杂化。Axios用于发送请求到后端。
  • 后端:Node.js与Express框架,监听3000端口,提供RESTful API。核心接口/chat接收消息,调用AI模型后返回结果。集成CORS中间件支持跨域。
  • AI集成:Ollama部署DeepSeek-R1:8b模型(端口11434),通过LangChain构建提示链。模型温度设为0.1,确保输出稳定。

这种架构优势在于可扩展性:前端专注交互,后端管理业务,AI作为服务可独立优化。启动时,前端用Vite工具,后端Node运行,Ollama后台启动模型。

前端实现详解

前端核心是创建一个简洁界面,发送消息并展示AI回复。假设初始消息为模拟输入,实际可扩展为用户表单。

App.jsx:主组件

主组件使用Hook获取数据,渲染加载状态或内容:

jsx

import { useEffect } from 'react';
import { useGitDiff } from './hooks/useGitDiff.js'

export default function App() {
  const { loading, content } = useGitDiff('hello');

  return (
    <div className="flex">
      {loading ? 'loading...' : content}
    </div>
  )
}

这里,Hook接收参数(模拟消息),返回loading和content。组件逻辑简单:加载中显示提示,否则渲染回复。结合Tailwind CSS的flex类,实现响应式布局。

useGitDiff.js:自定义Hook

Hook封装状态和副作用:

jsx

import { useState, useEffect } from 'react'
import { chat } from '../api/index.js'

export const useGitDiff = () => {
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    (async () => {
      setLoading(true);
      const { data } = await chat('你好');
      setContent(data.reply);
      setLoading(false);
    })()
  }, [])
  return {
    loading,
    content,
  }
}

使用useState管理状态,useEffect异步调用API。模拟消息“你好”,实际可动态传入。返回对象供组件使用,实现数据驱动渲染。

api/index.js:API管理

统一管理请求:

jsx

import axios from 'axios';

const service = axios.create({
  baseURL: 'http://localhost:3000',
  headers: {
    'Content-Type': 'application/json',
  },
  timeout: 120000,
});

export const chat = (message) => service.post('/chat', {message})

Axios实例设置baseURL、headers和timeout。chat函数封装POST请求,便于复用。

前端设计强调简洁,易于添加输入框扩展为完整聊天UI。

后端实现详解

后端使用Express搭建服务器,支持AI调用。

index.js:服务器文件

代码如下:

JavaScript

import express from 'express';
import cors from 'cors';
import { ChatOllama } from '@langchain/ollama';
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'

const model = new ChatOllama({
  baseURL: "http://localhost:11434",
  model: "deepseek-r1:8b",
  temperature: 0.1
})

const app = express();
app.use(cors());
app.use(express.json());

app.get('/hello', (req, res) => {
  res.send('hello world');
})

app.post('/chat', async (req, res) => {
  console.log(req.body, "//////");
  const { message } = req.body;
  if (!message || typeof message !== 'string') {
    return res.status(400).json({
      error: "message 必填,必须是字符串"
    })
  }
  try {
    const prompt = ChatPromptTemplate.fromMessages([
      ["system", "You are a helpful assistent."],
      ["human", '{input}']
    ])
    const chain = prompt.pipe(model).pipe(new StringOutputParser());
    console.log("正在调用大模型");
    const result = await chain.invoke({
      input: message,
    })
    res.json({
      reply: result
    })
  } catch (err) {
    console.log(err);
    res.status(500).json({
      err: "调用大模型失败"
    })
  }
})

app.listen(3000, () => {
  console.log('server is running on port 3000');
})

初始化Ollama模型。app实例使用CORS和JSON中间件。GET /hello测试路由。POST /chat校验message,构建提示链调用模型,返回reply。容错处理确保稳定性。

关键知识点:跨域与中间件

跨域问题是前后端分离的痛点。浏览器同源策略阻塞不同端口请求。使用cors中间件解决,后端允许前端访问。

中间件链:请求经CORS、JSON解析后到达路由。Express的灵活性便于添加日志或认证。

关键知识点:HTTP与路由

HTTP基于请求响应。GET无body,POST适合传输消息。响应码如400、500指示状态。

路由定义资源访问:app.post处理异步AI调用。

AI集成详解

Ollama提供本地API,LangChain简化提示:系统角色定义,用户输入占位。链式pipe确保输出解析。

温度0.1减少随机性。扩展可自定义提示,如添加上下文。

项目优化

  • 用户输入:前端添加表单动态消息。
  • 安全:添加校验或限流。
  • 部署:容器化Ollama,后端云托管。
  • 扩展:多轮对话或工具调用。

结语

通过集成 Ollama 的全栈应用,我们看到AI如何赋能开发。欢迎讨论优化思路,一起推动开源生态。

告别“玄学”UI:从“删代码碰运气”到“控制 BFC 结界”

作者 im_AMBER
2026年2月8日 20:56

 序:告别“玄学”UI 

之前我认为UI是不需要费心写的,因此也忽略了许多有关CSS等样式的细节,现在看来实在是基础不牢地动山摇,这是错误的——只会将 UI 需求直接丢给 AI,像开盲盒一样等待结果,全都交给了AI“外包”。

当布局错位时,我往往通过视觉描述增加提示词来“碰运气”,把AI写的代码又丢给AI改,结果却常导致 AI 为了调整一个局部参数而重写整个文件,逻辑越改越乱,自相矛盾,到处都是冲突的补丁......

CSS 布局不是样式的堆砌,而是物理逻辑的构建。

今天,我通过对已有的博客、网页等页面的“破坏性实验”和 F12 深度调试,意识到 UI 开发的核心不在于具体的样式代码,而在于对容器约束与布局环境的掌控。


一、 溢出与约束:滚动的物理本质

1. BUG : 预览组件不能滚动查看全部内容

在开发文档预览组件时,我遇到了一个典型问题:

文本内容被截断,只能显示一半,且没有滚动条。不是理想的可以滚动的预览查看的状态。 中间的内容区域溢出(Overflow)但没有触发滚动机制。

2. 从“无限增长”到“边界意识”

在默认状态下,块级容器遵循内容流向。

如果父容器没有设定明确的高度(Height),它会随着内容无限延伸。

浏览器认为容器高度等于内容高度,因此不存在“溢出”,自然不会触发滚动条。

我的修复路径:为父容器显式声明高度边界(如 max-h-[90vh]h-[500px])。

overflow - CSS:层叠样式表 | MDN

溢出处理 (Overflow)

官方定义overflow 属性指定如果内容溢出一个元素的框(其内容区、内边距区或边框区)时,会发生什么。

深度理解:滚动不是内容的属性,而是内容与容器边界的冲突。必须先有“墙”(约束),溢出才有意义。


二、 Flex 布局:环境决定属性

1. 权力等级:为什么 flex: 1 会失效?

在博客页面实战中,我曾尝试强行在子元素写入 flex: 1 以期望它填满剩余空间,但在浏览器 F12 中,该属性显示为灰色。

  • 发现:子元素对空间的分配权(Flex item properties)必须建立在父级开启了 FFC(Flex Formatting Context) 的前提下。
  • CSS 页面是由一个个“上下文(Context)”组成的。 display: flex 开启了 FFC,overflow: auto 开启了 BFC。 之所以在 F12 里 flex: 1 是灰色的,就是因为我试图在“普通文档流”里运行“弹性分配协议”,这属于协议不匹配
  • 如果父级不是 display: flex,子元素的弹性属性将由于缺乏上下文环境而无法被引擎解析。

2. 动态分配:对话区的“铺满”逻辑

在设计 Sidebar + 对话区的布局( 例如这种左右分栏的页面 )中,AI 写 UI 常给出固定高度,导致右侧对话区无法随窗口自适应。

  • 逻辑重构:删掉死高度,将对话区设为 flex-1
  • 在 Flex 纵向布局中,这意味着它会“吸收”掉父容器中除去 Header 和 Footer 之外的所有剩余空间。

3.守地盘: min-h-0

在 Flex 容器中,子元素的 min-height 默认值是 auto

这意味着内容会倾向于撑开容器。

如果不手动设置 min-h-0,当内容非常多时,即使你设置了 flex-1,容器也可能被内容“撑爆”而不会出现内部滚动。


三、 空间隔离:BFC 结界与绝对定位

1. BFC:独立的渲染自治区

通过 F12 观察网页,我深刻理解了 BFC 的重要性。

BFC (Block Formatting Context)

官方定义:块级格式化上下文。它是 Web 页面的可视化 CSS 渲染的一部分,是布局过程中生成块级盒子的区域。

工程含义

  • 内部自治:BFC 内部的元素布局不会影响到外部,反之亦然。
  • 包含浮动:BFC 容器可以自动计算内部浮动元素的高度(解决高度塌陷)。
  • 消除重叠:BFC 区域不会与浮动(float)盒子重叠。

在工程实践中,我们最常用的触发 BFC 的方式是设置 overflow: hiddendisplay: flexdisplay: grid

这也解释了为什么当我为了解决溢出(Overflow)而给父容器加了高度限制和滚动属性时,其实也顺便开启了 BFC,从而解决了内部布局的一些奇怪偏移。

2. Position Absolute:脱离文档流的参照系

absolute 的定位并不是绝对的坐标,它是一种 “寻找祖先” 的行为。

  • 它脱离了常规文档流(不占位),像幽灵般漂浮。 编辑
  • 它的坐标参照系是最近的一个 positionstatic 的祖先元素。如果找不到,它会一直回溯到根节点。通常我们把父级设为 relative,就是为了给 absolute 的子元素立一个“参照桩”,防止它一路回溯到 body 导致“幽灵”乱飘。

四、 小结

会用AI开发的前提是能看懂AI的代码,而不是把所有都只是交给AI代劳。

用AI代替思考的开发者只会被AI取代,这是我对自己的警醒。

AI 只能提供样式的结果,但无法感知容器间的压力。

  • 当内容溢出时,AI 会盲目增加 h-full,但它不知道这会导致容器突破父级的物理边界。
  • 当布局错位时,AI 会堆砌 !important,但它不知道这破坏了 CSS 的权重优先级。

只有理解了 “封闭性原理”“环境决定属性” ,你才能在 AI 给出错误补丁时,一眼看出那个导致崩盘的“虚假约束”。

我想作为一个开发者,开发的思考是最珍贵的,写代码只是把思考落地的方式。AI其实也许只是一个把脑子里的想法帮忙写出来,或者帮忙提供思路,而不是代替开发者的脑子。

这次解决滚动问题的核心逻辑:

  • 封闭性原理:一个容器如果不“封顶”(没写 h-fullmax-h),它就是无限高的。无限高的东西永远不会滚动。
  • 分配法则(Flexbox)flex-1 是“抢地盘”,而 min-h-0 是“守地盘”。如果没有 min-h-0,内容会撑爆布局。
  • BFC(块级格式化上下文) :本质上就是给容器划了一块“自治区”,里面的东西怎么排,不影响外面。

实战排查流,学会用浏览器调试:

  • F12 调试:手动设置背景色等,在dev tools里面大胆调试,追溯父子元素。
  • Computed 计算值:不要只看写的 CSS,要看浏览器最终“算出来”的高宽。

限于个人经验,文中若有疏漏,还请不吝赐教。

参考文献

区块格式化上下文 - CSS:层叠样式表 | MDN

盒模型 - 学习 Web 开发 | MDN

Scroll Area – Radix Primitives

查看和更改 CSS  |  Chrome DevTools  |  Chrome for Developers

height - Sizing - Tailwind CSS

Adding custom styles - Core concepts - Tailwind CSS

弹性盒子 - 学习 Web 开发 | MDN

flex-grow - Flexbox & Grid - Tailwind CSS

将 Props 传递给组件 – React 中文文档

解构 - JavaScript | MDN

箭头函数表达式 - JavaScript | MDN

闭包 - JavaScript | MDN

display - CSS:层叠样式表 | MDN

格式化上下文简介 - CSS:层叠样式表 | MDN

flex-basis - Flexbox & Grid - Tailwind CSS

应用或脱离流式布局 - CSS:层叠样式表 | MDN

Visual formatting model

vue3响应式解构注意

2026年2月8日 20:34

reactive 的响应式是深度绑定的(默认递归代理所有嵌套对象),直接解构外层对象得到的嵌套对象,本质还是 reactive 生成的代理对象,因此它本身的响应式不会丢失;但如果对这个嵌套对象再做解构,就会回到之前的问题 —— 解构其属性会丢失响应式。

代码示例(核心验证)

vue

<script setup lang="ts">
import { reactive } from 'vue'
// 创建响应式对象 const user = reactive({ name: '张三', age: 20 }) // 直接解构:丢失响应式 const { name, age } = user  对属性是基本类型时会丢失响应式这时需要用toRefs包裹
// 外层响应式对象,包含嵌套对象
const user = reactive({
  info: { // 嵌套对象,被 reactive 深度代理
    name: '张三',
    age: 20
  },
  hobby: ['篮球', '游戏'] // 嵌套数组,同样被深度代理
})
const user = reactive({ name: '张三', age: 20 }) // 用 toRefs 解构:保留响应式(转为 ref 类型) const { name, age } = toRefs(user) const changeName = () => { name.value = '李四' // 需通过 .value 修改,原对象会同步更新 console.log(user.name) // 输出 "李四" }



直接解构外层对象得到的嵌套对象
// 直接解构外层对象:拿到嵌套对象 info 和 hobby
const { info, hobby } = user

// 场景1:修改解构出的嵌套对象的属性(仍有响应式)
const changeInfo = () => {
  info.name = '李四' // ✅ 有响应式,视图会更新
  hobby.push('看书') // ✅ 有响应式,视图会更新
  console.log(user.info.name) // 输出 "李四"(和原对象同步)
}

// 场景2:对嵌套对象再解构(属性丢失响应式)
const { name, age } = info
const changeName = () => {
  name = '王五' // ❌ 非响应式,TS 提示无法赋值,视图无变化
  console.log(info.name) // 仍然是 "李四"
}

// 场景3:直接替换整个嵌套对象(仍有响应式)
const replaceInfo = () => {
  info.age = 25 // ✅ 改属性:响应式
  // 注意:如果直接替换整个嵌套对象,也需要通过原对象或解构的嵌套对象操作
  user.info = { name: '赵六', age: 30 } // ✅ 响应式
  // 或 info = { name: '赵六', age: 30 } ❌ 错误!解构的 info 是常量,不能直接赋值
}
</script>

<template>
  <div>原对象:{{ user.info.name }} - {{ user.info.age }} | {{ user.hobby }}</div>
  <div>解构嵌套对象:{{ info.name }} - {{ info.age }} | {{ hobby }}</div>
  <div>解构嵌套对象的属性:{{ name }} - {{ age }}</div>
  
  <button @click="changeInfo">修改嵌套对象属性</button>
  <button @click="changeName">修改解构的嵌套属性</button>
  <button @click="replaceInfo">替换嵌套对象</button>
</template>

运行结果:

  • 点击「修改嵌套对象属性」:所有关联视图(原对象、解构的嵌套对象)都会更新;
  • 点击「修改解构的嵌套属性」:视图无变化,嵌套对象的属性也没改;
  • 点击「替换嵌套对象」:原对象和解构的嵌套对象视图都会更新。

二、原理拆解:为什么嵌套对象仍有响应式?

  1. reactive 对对象做深度代理:当创建 reactive({ info: { name: '张三' } }) 时,不仅外层对象被 Proxy 代理,内部的 info 对象也会被递归转为 Proxy 代理对象;
  2. 直接解构 const { info } = user:拿到的 inforeactive 生成的代理对象本身(而非原始值),因此访问 / 修改 info.name 仍会触发响应式的依赖收集和更新;
  3. 解构嵌套对象的属性 const { name } = info:拿到的是 info.name原始值(如字符串 "张三"),而非代理属性,因此丢失响应式。

【React-6/Lesson89(2025-12-27)】React Context 详解:跨层级组件通信的最佳实践📚

作者 Jing_Rainbow
2026年2月8日 20:18

🎯 React 组件通信方式概览

在 React 开发中,组件之间的数据传递是核心问题。目前主要有四种通信方式:

1️⃣ 父子组件通信

这是最基础的通信方式,父组件通过 props 将数据传递给子组件。这种方式简单直接,适用于层级较浅的组件关系。

function Child({ message }) {
  return <div>{message}</div>
}

function Parent() {
  return <Child message="Hello from Parent" />
}

2️⃣ 子父组件通信

子组件通过回调函数将数据传递给父组件。父组件定义一个函数,通过 props 传递给子组件,子组件调用这个函数来传递数据。

function Child({ onMessage }) {
  return <button onClick={() => onMessage('Hello from Child')}>发送消息</button>
}

function Parent() {
  const handleMessage = (msg) => {
    console.log(msg)
  }
  return <Child onMessage={handleMessage} />
}

3️⃣ 兄弟组件通信

兄弟组件之间的通信通常需要通过共同的父组件作为中转。父组件维护共享状态,兄弟组件通过 props 接收和修改这个状态。

function SiblingA({ value, onChange }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />
}

function SiblingB({ value }) {
  return <div>输入的值:{value}</div>
}

function Parent() {
  const [value, setValue] = useState('')
  return (
    <>
      <SiblingA value={value} onChange={setValue} />
      <SiblingB value={value} />
    </>
  )
}

4️⃣ 跨层级通信

当组件层级较深时,使用 props 一层层传递数据会变得非常繁琐。这就是 Context 要解决的问题。

🚀 Context 的诞生背景

痛点分析

在传统的父子组件通信中,如果需要将数据从顶层组件传递到深层嵌套的子组件,就必须经过每一层中间组件。这种方式存在以下问题:

传递路径过长:数据需要经过多个中间组件,每个组件都需要接收并传递 props,即使它们并不使用这些数据。

维护成本高:每次修改数据结构或添加新的 props,都需要修改整条传递链路上的所有组件。

代码冗余:中间组件充满了不相关的 props 传递,代码可读性降低。

现实类比

这就像古代的驿站传递制度:皇帝要给边疆的将军送一封密信,必须经过沿途的每个驿站,每个驿站都要接收并转发这封信,即使驿站官员并不关心信的内容。这种传递方式效率低下,而且容易在传递过程中出现问题。

就像《长安的荔枝》中描述的那样,为了将新鲜荔枝从岭南送到长安,需要经过无数驿站,每个驿站都要接力传递,成本极高,风险很大。

💡 Context 的核心思想

Context 提供了一种在组件树中共享数据的方式,无需通过 props 一层层传递。它的核心思想是:

数据在查找的上下文里:在最外层组件提供数据,任何层级的组件都可以直接访问这些数据。

主动查找能力:需要消费数据的组件拥有主动查找数据的能力,而不是被动接收。

规矩不变:父组件(外层组件)仍然负责持有和改变数据,只是传递方式从"一路传"变成了"全局提供"。

📖 Context 的三步使用法

第一步:创建 Context 容器

使用 createContext 创建一个 Context 对象,这个对象就是数据容器。可以传入一个默认值作为参数。

import { createContext } from 'react'

export const UserContext = createContext(null)

createContext 接受一个默认值参数,当组件在匹配的 Provider 之外使用 Context 时,会使用这个默认值。

第二步:提供数据

使用 Context.Provider 组件包裹需要共享数据的组件树,通过 value 属性提供数据。

export default function App() {
  const user = {
    name: "Andrew"
  }
  
  return (
    <UserContext.Provider value={user}>
      <Page />
    </UserContext.Provider>
  )
}

Provider 组件接受一个 value 属性,这个值会被所有消费该 Context 的组件访问到。Provider 可以嵌套使用,内层的 Provider 会覆盖外层的值。

第三步:消费数据

使用 useContext Hook 在组件中消费 Context 数据。

import { useContext } from 'react'
import { UserContext } from '../App'

export default function UserInfo() {
  const user = useContext(UserContext)
  return (
    <div>{user.name}</div>
  )
}

useContext 接受一个 Context 对象作为参数,返回该 Context 的当前值。当 Context 的值发生变化时,使用该 Context 的组件会重新渲染。

🎨 实战案例:主题切换应用

需求分析

创建一个支持白天/夜间主题切换的应用,主题状态需要在整个应用中共享。这是一个典型的跨层级通信场景。

项目结构

theme-demo/
├── src/
│   ├── App.jsx
│   ├── contexts/
│   │   └── ThemeContext.jsx
│   ├── components/
│   │   ├── Header.jsx
│   │   └── Content.jsx
│   ├── pages/
│   │   └── Page.jsx
│   └── theme.css

创建 ThemeContext

首先创建主题 Context,包含主题状态和切换主题的方法。

import { createContext, useState, useEffect } from 'react'

export const ThemeContext = createContext(null)

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  
  const toggleTheme = () => {
    setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'))
  }
  
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme)
    document.body.setAttribute('data-theme', theme)
  }, [theme])
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

关键点解析

  • useState 管理主题状态,初始值为 'light'
  • toggleTheme 函数使用函数式更新,确保基于前一个状态进行切换
  • useEffect 监听主题变化,同步更新 htmlbody 元素的 data-theme 属性
  • ThemeProvider 作为高阶组件,包裹子组件并提供主题上下文

在应用中使用 ThemeProvider

在应用的根组件中使用 ThemeProvider 包裹整个组件树。

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

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

在组件中消费主题数据

在 Header 组件中消费主题数据,显示当前主题并提供切换按钮。

import { useContext } from 'react'
import { ThemeContext } from '../contexts/ThemeContext'

export default function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext)
  
  return (
    <div style={{ padding: 24 }}>
      <h2>当前主题:{theme}</h2>
      <button className="button" onClick={toggleTheme}>
        切换主题
      </button>
    </div>
{

在 Page 组件中也可以消费主题数据,实现不同组件共享同一主题状态。

import { useContext } from 'react'
import { ThemeContext } from '../contexts/ThemeContext'
import Header from '../components/Header'
import Content from '../components/Content'

export default function Page() {
  const theme = useContext(ThemeContext)
  
  return (
    <div style={{ padding: 24 }}>
      <Header />
      <Content />
    </div>
  )
}

🎯 CSS 变量实现主题切换

使用 CSS 变量(Custom Properties)是实现主题切换的优雅方式。CSS 变量允许我们在不同的主题下动态改变样式。

定义 CSS 变量

theme.css 中定义主题相关的 CSS 变量。

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

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

语法解析

  • :root 选择器匹配文档的根元素,在这里定义全局 CSS 变量
  • --bg-color--text-color 等是自定义属性名,必须以 -- 开头
  • [data-theme='dark'] 是属性选择器,匹配所有 data-theme 属性值为 'dark' 的元素
  • 在不同的选择器中重新定义变量值,实现主题切换

使用 CSS 变量

在样式中使用 var() 函数引用 CSS 变量。

body {
  margin: 0
  background-color: var(--bg-color)
  color: var(--text-color)
  transition: all 0.3s
}

.button {
  padding: 8px 16px
  background: var(--primary-color)
  color: #fff
  border: none
  cursor: pointer
}

关键特性

  • var(--bg-color) 引用之前定义的 CSS 变量
  • data-theme 属性改变时,CSS 变量的值会自动更新
  • transition: all 0.3s 实现主题切换的平滑过渡效果

JavaScript 控制 CSS 变量

通过 JavaScript 动态修改元素的 data-theme 属性,触发 CSS 变量的变化。

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

当 theme 状态变化时,useEffect 会执行,更新 DOM 元素的属性,从而触发 CSS 变量的重新计算。

🔍 Context 的高级特性

默认值的作用

Context 的默认值在以下情况下使用:

  1. 组件在匹配的 Provider 之外使用 Context
  2. Provider 的 value 属性为 undefined
const MyContext = createContext('默认值')

function Component() {
  const value = useContext(MyContext)
  return <div>{value}</div>
}

export default function App() {
  return <Component />
}

在这个例子中,Component 会显示"默认值",因为它没有被任何 Provider 包裹。

Provider 嵌套

多个 Provider 可以嵌套使用,内层的 Provider 会覆盖外层的值。

const ThemeContext = createContext('light')
const ColorContext = createContext('blue')

function Child() {
  const theme = useContext(ThemeContext)
  const color = useContext(ColorContext)
  return <div>主题:{theme},颜色:{color}</div>
}

export default function App() {
  return (
    <ThemeContext.Provider value="dark">
      <ColorContext.Provider value="red">
        <Child />
      </ColorContext.Provider>
    </ThemeContext.Provider>
  )
}

Child 组件会显示"主题:dark,颜色:red"。

Context 性能优化

当 Context 的值变化时,所有消费该 Context 的组件都会重新渲染。为了优化性能,可以:

  1. 拆分 Context:将频繁变化和不常变化的数据拆分到不同的 Context 中
const UserContext = createContext(null)
const ThemeContext = createContext(null)

function App() {
  const [user, setUser] = useState(null)
  const [theme, setTheme] = useState('light')
  
  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <Child />
      </ThemeContext.Provider>
    </UserContext.Provider>
  )
}
  1. 使用 useMemo:避免不必要的对象重新创建
function App() {
  const [theme, setTheme] = useState('light')
  
  const contextValue = useMemo(() => ({
    theme,
    toggleTheme: () => setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }), [theme])
  
  return (
    <ThemeContext.Provider value={contextValue}>
      <Child />
    </ThemeContext.Provider>
  )
}
  1. 使用 React.memo:避免不必要的组件重新渲染
const ExpensiveComponent = React.memo(function ExpensiveComponent() {
  const { theme } = useContext(ThemeContext)
  return <div>主题:{theme}</div>
})

📝 Context 最佳实践

1. 合理拆分 Context

不要将所有数据都放在一个 Context 中,应该根据功能模块合理拆分。

const UserContext = createContext(null)
const ThemeContext = createContext(null)
const LocaleContext = createContext(null)

2. 使用自定义 Hook

封装 Context 的消费逻辑,提供更友好的 API。

export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme 必须在 ThemeProvider 内部使用')
  }
  return context
}

function Component() {
  const { theme, toggleTheme } = useTheme()
  return <button onClick={toggleTheme}>{theme}</button>
}

3. 提供默认值或错误处理

确保 Context 的使用是安全的,提供合理的默认值或错误处理。

export function useUser() {
  const user = useContext(UserContext)
  if (!user) {
    throw new Error('useUser 必须在 UserProvider 内部使用')
  }
  return user
}

4. 避免过度使用

Context 适合用于全局状态,如用户信息、主题、语言设置等。对于局部状态,仍然应该使用 props 或状态管理库。

5. 文档化 Context

为 Context 添加清晰的文档说明,包括提供的数据类型和使用方法。

/**
 * 用户上下文
 * @type {React.Context<{
 *   name: string
 *   email: string
 *   role: string
 * }>}
 */
export const UserContext = createContext(null)

🌟 总结

React Context 是解决跨层级组件通信的强大工具,它提供了优雅的数据共享方式,避免了 props 传递的繁琐。通过合理使用 Context,可以:

  • 简化组件间的数据传递
  • 提高代码的可维护性
  • 实现全局状态管理
  • 构建更灵活的应用架构

结合 CSS 变量,Context 可以轻松实现主题切换、国际化等全局功能。在实际开发中,应该根据具体需求选择合适的通信方式,Context 是工具箱中的重要一员,但不是唯一的解决方案。

❌
❌