普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月21日首页

A股三大指数午间休盘集体上涨,半导体板块走强

2026年1月21日 11:32
36氪获悉,A股三大指数午间休盘集体上涨,沪指涨0.16%,深成指涨0.76%,创业板指涨0.85%;贵金属、半导体、电脑硬件板块领涨,招金黄金、湖南白银、龙芯中科、中国长城涨停;林木、煤炭、零售板块走弱,平潭发展跌超7%,安泰集团跌超4%,新世界跌超2%。

OpenAI:将自行承担“星际之门”项目能源费用

2026年1月21日 11:28
OpenAI公告称,在宣布“星际之门”项目一年后,计划到2029年将美国的人工智能基础设施扩展到10吉瓦。OpenAI称,已经在规划容量方面远超目标的一半,得克萨斯州第一批站点已经开始训练和提供服务,同时得克萨斯州、新墨西哥州、威斯康星州和密歇根州还有多个“星际之门”站点正在开发中。OpenAI表示,在所有“星际之门”社区计划中,承诺自行承担能源费用,确保项目运营不会推高电价。每个社区和地区都有独特的能源需求及电网状况,OpenAI承诺将根据具体区域量身定制。

好消息:过审了。坏消息:苹果后台又挂了~

作者 iOS研究院
2026年1月21日 11:21

背景

对于每一位iOS开发者而言,App Store审核的“通过”通知,大概是能瞬间驱散多日疲惫的强心剂。

Congratulations!

Review of your submission has been completed. It is now eligible for distribution.

尤其是在经历过反复修改、条款博弈、漫长等待后,看到审核状态从“正在审核”跳转为“已通过”的那一刻,那种如释重负的喜悦,足以让人暂时忘却熬夜改bug、对着拒信抓耳挠腮的窘迫。

苹果后台又挂了

昨天上午11点半提交的审核,大概下午4点半进入审核。在正在审核中,持续到今天凌晨2点多。总计耗时9个小时。

对于正规产品,不玩蹭流量,不玩隐藏功能,不搞割韭菜。基本上这种现象属于正常。

毕竟大陆区的下午4点,美国加利福尼亚的凌晨1点。谁家审核员,凌晨加班又加点?

当然,有一种情况是会秒过。前提是在二进制改动很小,并且AppStore元数据层面未做更改。【基本上常见于APP迭代产品】

言归正传,因为版本是手动发布。结果刚刚打开苹果后台的APP分类,哦豁503了~

不用慌,大家都一样

苹果后台偶尔摆烂太正常了,可能又在偷偷调整自身审核的算法。这里可能是ASO排名规则,也有可能是审核算法。

总之,听话纯粹的产品不用慌,红海分类&蹭量项目慌也没卵用。(没错,就比如社交APP)

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

我国电动汽车充电基础设施(枪)总数突破2000万大关

2026年1月21日 11:12
国家能源局发布2025年12月全国电动汽车充电设施数据。根据国家充电设施监测服务平台数据,截至2025年12月底,我国电动汽车充电基础设施(枪)总数达到2009.2万个,同比增长49.7%,突破2000万大关。其中,公共充电设施(枪)471.7万个,同比增长31.9%,公共充电桩额定总功率达到2.20亿千瓦,平均功率约为46.53千瓦;私人充电设施(枪)1537.5万个,同比增长56.2%,私人充电设施报装用电容量达到1.34亿千伏安。(人民日报)

React性能优化相关hook记录:React.memo、useCallback、useMemo

作者 web_bee
2026年1月21日 11:12

React.memo

它是什么、做什么的,概念理解

React.memo 是 React 提供的一个高阶组件(Higher-Order Component, HOC) ,用于对函数组件进行浅层记忆化(shallow memoization) ,从而避免在 props 没有变化时进行不必要的重新渲染,提升性能。

怎么用:

import React from 'react';

const MyComponent = React.memo((props) => {
  return <div>{props.value}</div>;
});
  • MyComponent 是一个函数组件。
  • 使用 React.memo 包裹后,React 会在每次父组件重新渲染时,先比较当前 props 和上一次的 props。
  • 如果 props 浅比较相等(shallowly equal) ,则跳过本次渲染,直接复用上次的渲染结果。

⚠️ 注意:React.memo 只对 props 进行比较,不处理 state、context 或 hooks 的变化

浅比较(Shallow Comparison)规则

React.memo 默认使用 浅比较 来判断 props 是否变化:

  • 对于 原始类型(string、number、boolean、null、undefined、symbol) :值相等即视为相同。
  • 对于 对象、数组、函数仅比较引用是否相同(即 ===),即使内容完全一样,只要引用不同,就认为 props 发生了变化。
1. 示例:浅比较失效的情况
function Parent() {
  const [count, setCount] = useState(0);

  // 每次渲染都创建新对象 → 引用不同
  const data = { value: 'hello' };

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <Child data={data} /> {/* Child 会每次都重新渲染! */}
    </>
  );
}

const Child = React.memo(({ data }) => {
  console.log('Child rendered');
  return <div>{data.value}</div>;
});

虽然 data 内容没变,但每次都是新对象,引用不同 → React.memo 无效。

解决方法

  • 使用 useMemo 缓存对象:

    const data = useMemo(() => ({ value: 'hello' }), []);
    
  • 或确保传递的 prop 引用稳定(如使用 useCallback 处理函数)。


自定义比较函数(可选)

你可以传入第二个参数给 React.memo,提供自定义的比较逻辑:

const Child = React.memo(
  ({ a, b, onUpdate }) => {
    return <div>{a} - {b}</div>;
  },
  (prevProps, nextProps) => {
    // 返回 true:props 相等,不重新渲染
    // 返回 false:props 不同,需要重新渲染
    return prevProps.a === nextProps.a && prevProps.b === nextProps.b;
    // 注意:通常不比较函数(如 onUpdate),除非你确定它稳定
  }
);

📌 自定义比较函数的返回值含义与 shouldComponentUpdate 相反:

  • true 表示“不需要更新
  • false 表示“需要更新

使用场景:

推荐使用 React.memo 的情况:

  • 组件是 纯展示型(presentational) ,只依赖 props。
  • 组件 渲染开销较大(如包含复杂计算、大量 DOM 节点)。
  • 父组件频繁更新,但该子组件的 props 实际很少变化
  • 配合 useCallback / useMemo 确保传入的函数/对象引用稳定。

不推荐滥用:

  • 组件很小、渲染成本低 → 加 React.memo 反而增加比较开销。
  • props 中包含经常变化的对象/函数,且未做缓存 → React.memo 无效。
  • 组件依赖 Context 或内部有状态(state)→ React.memo 无法阻止因 context/state 变化导致的重渲染。

🔍 注意:React.memo 不能阻止以下情况的重渲染:

  • 组件自身调用 useStateuseReducer 触发更新。
  • 组件消费了 Context,而 Context 的值发生变化。
  • 父组件强制更新(如使用 key 变更)。

注意事项:

  • React.memo 是函数组件的性能优化工具,通过浅比较 props 避免重复渲染。
  • 只对 props 有效,且依赖引用稳定性。
  • 必须配合 useCallback(函数)和 useMemo(对象/数组)才能发挥最大效果。
  • 不要默认给所有组件加 React.memo,应基于性能分析(如 React DevTools Profiler)按需使用。
  • 自定义比较函数可用于复杂场景,但要小心性能开销。

💡 最佳实践:先写出清晰的代码,在发现性能瓶颈后再优化,避免过早优化带来的复杂性。

useCallback

它主要是用来缓存函数本身的; 当组件内的state改变,如果函数依赖没有改变就不重新创建函数;

前置知识:

react 如何触发页面的渲染:

import { useState } from 'react';

const [count, setCount] = useState(0);

setState 时会出触发当前页面的重新更新;故当前页面内的所有组件也会 重新渲染;

问题来了,有一些组件可能并不需要重新渲染,可能它传递的props没有改变,但是组件还是会从新渲染;

如何来规避这些组件的无效渲染:

  • useCallback 缓存函数
  • useMemo 缓存函数返回结果(类似vue中的 computed)
  • React.memo 用于对传入的props进行浅比较,true则不刷新页面,false就重新加载组件

什么是 useCallback

useCallback 是 React 提供的一个 Hook,用于优化性能,它能够缓存函数,避免在组件重新渲染时不必要的函数重新创建。

基本语法

const memoizedCallback = useCallback(
  () => {
    // 回调函数体
  },
  [dependencies] // 依赖数组
);
  • 第一个参数:要缓存的函数。
  • 第二个参数:依赖数组(与 useEffect 类似),只有当依赖项发生变化时,才会返回一个新的函数;否则返回之前缓存的函数引用。

为什么需要 useCallback

在 React 中,当组件重新渲染时,其内部的所有函数都会被重新创建。对于传递给子组件的回调函数来说,这意味着:

  1. 每次父组件渲染都会创建一个新的函数实例
  2. 子组件会因为接收到的 props 不同而重新渲染,即使实际内容没有变化
  3. 在依赖数组中使用的函数如果不被缓存,可能导致 effect 无限执行

useCallback 通过缓存函数实例来解决这些问题。

useMemo

useMemo 主要用于缓存计算结果,避免在每次组件渲染时都重复执行开销较大的计算逻辑。

类似于vue中的computed

怎么用:

const memoizedValue = useMemo(() => {
  // 执行昂贵的计算
  return computeExpensiveValue(a, b);
}, [a, b]); // 依赖数组
  • 第一个参数:一个函数,返回需要缓存的值。
  • 第二个参数:依赖数组(deps),只有当数组中的值发生变化时,才会重新执行计算函数;否则返回之前缓存的结果。

核心作用跳过不必要的计算,提升性能。

使用场景:

在函数组件中,每次渲染都会重新执行整个函数体。如果其中有复杂计算(如遍历大数组、深度递归、格式化大量数据等),就会造成性能浪费。

✅ 场景 1:缓存复杂计算结果

const sortedList = useMemo(() => 
  list.sort((a, b) => a.name.localeCompare(b.name)), 
  [list]
);

✅ 场景 2:创建稳定对象/数组引用(配合 React.memo)

const config = useMemo(() => ({
  theme: 'dark',
  lang: 'zh'
}), []); // 确保引用不变,避免子组件不必要重渲染

✅ 场景 3:避免在渲染中创建新实例

// ❌ 每次渲染都新建 Date 对象(虽小但可能影响子组件)
const today = new Date();

// ✅ 如果不需要响应时间变化,可缓存
const today = useMemo(() => new Date(), []);

✅ 场景 4:结合 Context 避免 Provider 不必要更新

const value = useMemo(() => ({ user, updateUser }), [user]);
return <UserContext.Provider value={value}>...</UserContext.Provider>;

防止因 value 引用变化导致所有消费者重渲染。

注意事项与陷阱

⚠️ 1. 不要滥用 useMemo

  • 对于简单计算(如 a + b),使用 useMemo 反而增加内存和比较开销。
  • 先写清晰代码,再根据性能分析(Profiler)决定是否优化

⚠️ 2. 依赖项必须完整且正确

// ❌ 错误:缺少依赖
const result = useMemo(() => expensiveFn(x), []); // x 变化时不会更新!

// ✅ 正确
const result = useMemo(() => expensiveFn(x), [x]);

否则会导致 stale closure(闭包过期) —— 使用的是旧值。

⚠️ 3. 不要用 useMemo 执行副作用

// ❌ 错误:useMemo 不是 useEffect!
useMemo(() => {
  localStorage.setItem('data', JSON.stringify(data));
  return data;
}, [data]);

→ 副作用应放在 useEffect 中。

⚠️ 4. 数组/对象依赖项需稳定

// ❌ 每次渲染都创建新数组 → 依赖永远“变化”
const items = useMemo(() => filter(items, condition), [items, [condition]]);

// ✅ 应确保 condition 是稳定值(如 state 或 useMemo 缓存)

总结

  • useMemo 用于缓存计算结果,避免重复昂贵操作。

  • 它通过依赖数组控制何时重新计算。

  • 主要用于:

    • 优化性能(大计算、大数据处理)
    • 创建稳定对象/数组引用(配合 React.memo
    • 减少 Context Provider 的不必要更新
  • 不要为了优化而优化,优先保证代码可读性。

  • 务必正确填写依赖项,避免 stale closure。

💡 经验法则:当你发现某个计算在组件每次渲染时都执行,且该计算较重或结果用于 props 传递时,考虑 useMemo

工信部:将发布人形机器人与具身智能综合标准化体系建设指南

2026年1月21日 11:04
国新办举行新闻发布会介绍2025年工业和信息化发展成效。工业和信息化部副部长张云明表示,下一步工信部将持续推动人形机器人技术创新和迭代升级,以人形机器人为小切口带动计算智能大产业发展,将持续加强人形机器人产品质量、网络和数据安全方面的检验检测,开展相关科技伦理研究与管理服务,以高水平安全保障高质量发展。同时,工信部将加速做好人形机器人生态,强化国家人工智能产业投资基金对人形机器人的支持力度,建设人形机器人开源社区,发布人形机器人与具身智能综合标准化体系建设指南,促进创新成果全球共享。(中国网)

三家商业航天企业IPO辅导更新

2026年1月21日 11:00
36氪获悉,近日,天兵科技、星河动力、星际荣耀三家商业航天企业更新了IPO辅导进展。据证监会官网,北京星河动力航天科技股份有限公司(简称星河动力)于近日更新IPO辅导进展,辅导机构为华泰联合证券有限责任公司。星际荣耀航天科技集团股份有限公司(简称星际荣耀)也于近日更新IPO辅导进展,其辅导机构为天风证券股份有限公司、中信证券股份有限公司。此外,江苏天兵航天科技股份有限公司公示IPO上市辅导进展报告(第一期),中信建投为辅导机构。

淘宝天猫:2025年高分商家成交增速达普通商家2.2倍

2026年1月21日 10:54
36氪获悉,1月20日,2026淘宝天猫商家服务大会宣布,平台将在2026年帮助数百万优质商家做好服务、并通过优质服务获得确定性增长。数据显示,2025年淘宝天猫平台好服务商家用户复购率、净GMV均为较低服务水平商家的两倍。此外,2025年真实体验分4.8分以上高分商家成交额同比增速为普通服务商家(4.5-4.8分)的2.2倍,服饰、快消、家居等行业高分商家成交额不同程度提升。

Agent 开发必学 —— LangChain 生态、MCP 协议与 SOP 落地实战

作者 Wan9Q1ye
2026年1月21日 10:53

引言

在 AI Agent 开发从“写着玩”进入“工业化落地”的阶段后,开发者面临的挑战已不再是如何调用 API,而是如何构建一个可控、可扩展、且具备标准接口的系统。

本文将结合 LangChain 最新生态、MCP (Model Context Protocol) 协议以及 SOP (标准作业程序) 思维,为你拆解一套现代 Agent 开发的“黄金组合”。


一、 Agent 开发的“四根支柱”

在构建一个复杂的 AI 应用时,我们需要清晰地定义四个层面:

  1. 大脑 (LLM) :核心算力,负责推理。
  2. 骨架 (@langchain/core) :定义标准化的接口(Runnable、BaseMessage),让不同模型可切换。
  3. 手脚 (MCP / Tools) :连接外部数据与 API 的标准通道。
  4. 灵魂 (SOP / LangGraph) :定义 Agent 的思考路径,确保其行为符合业务规范。

二、 剖析 LangChain 生态系统

正如你所看到的,LangChain 已演变为一个模块化的帝国。理解这些包的依赖关系是开发的第一步:

1. @langchain/core:一切的基石

它是生态系统的“宪法”,定义了所有组件必须遵守的协议。

  • 统一接口:无论你用 OpenAI 还是 Gemini,它们在代码里都是 BaseChatModel
  • Runnable 协议:所有的组件(Prompt, LLM, Parser)都通过 .pipe() 串联,实现了流式处理(Streaming)和异步调用的原生支持。

2. @langchain/langgraph:从“链”到“图”

如果说传统的 Chain 是线性的,那么 LangGraph 就是循环且有状态的。它是目前落地 SOP 的最佳工具,支持:

  • 持久化状态:Agent 聊到一半可以“断点续传”。
  • 人机协作 (Human-in-the-loop) :在 SOP 的关键节点(如打款、删库)强制介入人工审批。

三、 引入 MCP:AI 界的 USB-C 接口

在你的 Agent 想要调用外部工具(比如查询 SQL 或发送 Slack)时,传统的做法是手动编写 BaseTool。但现在,我们有了 MCP (Model Context Protocol)

为什么 MCP + LangChain 是绝配?

  • 解耦:你不需要在 LangChain 代码里写复杂的数据库连接逻辑。
  • 标准化:一个符合 MCP 协议的工具服务器,可以同时被 LangChain、Claude Desktop 和 Cursor 识别。
  • 多语言:你可以用 Go 写一个高性能的 MCP 工具服务器,然后在 TypeScript 编写的 LangChain Agent 中调用它。

四、 核心实战:用 TS 构建 SOP 驱动的 MCP Agent

下面我们通过 TypeScript 演示如何将这些组件缝合在一起,构建一个具备 “查询 -> 判定 -> 执行” SOP 流程的智能体。

1. 环境准备

Bash

npm install @langchain/core @langchain/openai @langchain/langgraph @langchain/mcp

2. 完整代码实现

TypeScript

import { ChatOpenAI } from "@langchain/openai";
import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import { McpClient } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { convertMcpToLangChainTool } from "@langchain/mcp";
import { ToolNode } from "@langchain/langgraph/prebuilt";

// --- 第一部分:初始化 MCP 工具 (手脚) ---
async function setupMcpTools() {
  const transport = new StdioClientTransport({
    command: "npx",
    args: ["-y", "@modelcontextprotocol/server-everything", "run"],
  });
  const client = new McpClient({ name: "MyToolBox", version: "1.0.0" }, { capabilities: {} });
  await client.connect(transport);
  
  // 将 MCP 能力转化为 LangChain 可用的 Tool 数组
  return await convertMcpToLangChainTool(client);
}

// --- 第二部分:定义 SOP 状态与节点 (灵魂) ---
const AgentState = Annotation.Root({
  messages: Annotation<any[]>({
    reducer: (x, y) => x.concat(y),
    default: () => [],
  }),
  isApproved: Annotation<boolean>({
    reducer: (x, y) => y,
    default: () => false,
  })
});

// --- 第三部分:编排 SOP 流程 (骨架) ---
async function run() {
  const mcpTools = await setupMcpTools();
  const model = new ChatOpenAI({ modelName: "gpt-4o" }).bindTools(mcpTools);

  const workflow = new StateGraph(AgentState)
    // 节点 1: 思考/决策
    .addNode("agent", async (state) => {
      const response = await model.invoke(state.messages);
      return { messages: [response] };
    })
    // 节点 2: 执行工具 (MCP)
    .addNode("action", new ToolNode(mcpTools))
    
    // 设置 SOP 逻辑线
    .addEdge(START, "agent")
    .addConditionalEdges("agent", (state) => {
      const lastMsg = state.messages[state.messages.length - 1];
      return lastMsg.tool_calls?.length > 0 ? "action" : END;
    })
    .addEdge("action", "agent");

  const app = workflow.compile();
  
  // 运行 Agent
  const finalState = await app.invoke({
    messages: [{ role: "user", content: "请使用 MCP 工具列举当前目录文件并分析" }]
  });
  console.log(finalState.messages.map(m => m.content));
}

run();

五、 深度总结对比

为了方便记忆,我们通过下表总结这三个核心概念的协作关系:

组件 对应包 解决的问题 核心心法
LangChain Core @langchain/core 接口不统一、代码耦合。 标准化:一切皆为 Runnable。
MCP @langchain/mcp 扩展能力难复用、环境隔离。 解耦:工具是独立的服务。
LangGraph @langchain/langgraph Agent 行为不可控、无状态。 可预测:思考路径就是流程图。

六、 为什么我们要采用这种模块化架构?

  1. 按需安装:正如你所提到的,不需要 OpenAI 就不装 @langchain/openai,保持项目精简。
  2. 工程化可观测:在 LangGraph 中,你可以清晰地看到 Agent 停在哪个 SOP 节点。
  3. 未来兼容性:即使明年出现了比 LangChain 更火的框架,你的 MCP 工具服务器 依然可以直接迁移使用。

结语

AI 开发正从“魔法”走向“工程”。理解 LangChain 的包结构只是第一步,真正的进阶在于如何利用 MCP 协议 扩展 Agent 的边界,并用 SOP (LangGraph) 驯服 LLM 的不确定性。

ETF总规模回到6万亿元以下

2026年1月21日 10:50
Wind数据显示,截至1月21日全市场上市ETF规模为5.93万亿元,较此前的6.24万亿元有所下降。从各大指数对应的ETF来看,大宽基品种规模下降明显。其中,中证A500ETF整体规模跌破3000亿元,为2859.81亿元;40只中证A500ETF中,规模在百亿元的有8只。(第一财经)

阿里千问刷新全球开源模型新纪录

2026年1月21日 10:48
36氪获悉,1月21日,AI开源社区HuggingFace最新数据显示,阿里千问衍生模型数突破20万个,成为全球首个达成此目标的开源大模型;同时,千问系列模型下载量突破10亿次,平均每天被下载110万次,已完全超越美国Llama,稳居开源大模型全球第一。

你的网站慢到让用户跑路?这5个被忽视的性能杀手,改完速度飙升300%

2026年1月21日 10:45

摘要:当你的老板指着后台数据咆哮"为什么转化率这么低",当用户在评论区疯狂吐槽"卡成PPT",你还在纠结要不要压缩那张2MB的图片?醒醒吧!真正拖垮你网站的,是那些藏在代码里的"隐形炸弹"。本文不讲那些烂大街的优化技巧,只聊5个被99%开发者忽视的性能杀手,以及如何用几行代码让你的网站起飞。文末附送《Chrome DevTools实战指南》。


01. 那个因为"慢2秒"损失50万的电商网站

老王,某电商平台的前端负责人。 2025年双十一,他们的网站流量暴涨,销售额却暴跌。

数据惨不忍睹:

  • 页面加载时间:从1.5秒飙升到3.5秒
  • 跳出率:从15%飙升到45%
  • 转化率:从8%暴跌到3%
  • 预估损失:50万

老板暴怒:"这个月的奖金全没了!给我查!"

老王一脸懵逼:"代码没报错啊,功能都正常啊,我们还做了图片压缩和懒加载啊!"

他打开Chrome DevTools,Performance面板上的火焰图密密麻麻,像心电图一样疯狂跳动。

然后他发现了真相:

不是图片的问题,不是网络的问题,而是代码的问题

准确地说,是5个被他忽视的"性能杀手",正在疯狂消耗用户的耐心。


02. 性能杀手1:React的"隐形炸弹" —— Re-render地狱

问题场景

老王的商品列表页,有1000个商品。 用户点击"加入购物车",页面卡顿2秒。

他的代码长这样:

function ProductList({ products }) {
  const [cart, setCart] = useState([])

  // 看起来没问题对吧?
  return (
    <div className='product-grid'>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={() => setCart([...cart, product])}
        />
      ))}
    </div>
  )
}

function ProductCard({ product, onAddToCart }) {
  console.log("渲染:", product.name) // 加这行debug

  return (
    <div className='product-card' onClick={onAddToCart}>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button>加入购物车</button>
    </div>
  )
}

看起来很正常对吧?

但当老王点击"加入购物车"时,控制台炸了:

渲染: iPhone 15
渲染: MacBook Pro
渲染: AirPods Pro
渲染: iPad Air
... (重复1000次)

卧槽!每次添加购物车,1000个商品全部重新渲染!

性能数据

老王用React DevTools Profiler测了一下:

优化前:
- 每次添加购物车:1000次组件渲染
- 耗时:~500ms
- 用户感受:明显卡顿,点击无响应
- FPS:从60掉到15

为什么会这样?

因为每次setCartProductList重新渲染,所有的ProductCard也跟着重新渲染。 虽然它们的props没变,但React默认会重新渲染所有子组件。

优化方案

// 方案1:使用React.memo避免不必要的re-render
const ProductCard = React.memo(({ product, onAddToCart }) => {
  console.log("渲染:", product.name) // 现在只打印1次!

  return (
    <div className='product-card' onClick={onAddToCart}>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button>加入购物车</button>
    </div>
  )
})

function ProductList({ products }) {
  const [cart, setCart] = useState([])

  // 方案2:使用useCallback避免每次创建新函数
  const handleAddToCart = useCallback((product) => {
    setCart((prev) => [...prev, product])
  }, [])

  return (
    <div className='product-grid'>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={() => handleAddToCart(product)}
        />
      ))}
    </div>
  )
}

优化效果

优化后:
- 每次添加购物车:1次组件渲染
- 耗时:~5ms
- 性能提升:100倍!
- FPS:稳定60
- 用户感受:丝滑流畅

老王的感悟:

"我以为React会自动优化,结果它只是'诚实'地重新渲染所有东西。React.memouseCallback不是过度优化,而是必需品。"


03. 性能杀手2:内存泄漏的"慢性毒药" —— 忘记清理的副作用

问题场景

老王的网站有个实时聊天功能。 用户在不同聊天室之间切换,页面越来越卡,最后直接崩溃。

他的代码:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])

  useEffect(() => {
    // 订阅WebSocket
    const ws = new WebSocket(`wss://chat.example.com/${roomId}`)

    ws.onmessage = (event) => {
      setMessages((prev) => [...prev, JSON.parse(event.data)])
    }

    ws.onerror = (error) => {
      console.error("WebSocket错误:", error)
    }

    // 问题:忘记清理!
    // 每次切换房间都会创建新连接
    // 旧连接没关闭,内存泄漏!
  }, [roomId])

  return (
    <div className='chat-room'>
      {messages.map((msg) => (
        <div key={msg.id} className='message'>
          <strong>{msg.user}:</strong> {msg.text}
        </div>
      ))}
    </div>
  )
}

性能数据

老王用Chrome DevTools的Memory面板录制了一段:

切换前(1个房间):
- 内存占用:50MB
- WebSocket连接:1个

切换5次后:
- 内存占用:250MB
- WebSocket连接:6个(1个当前 + 5个僵尸)

切换10次后:
- 内存占用:500MB
- WebSocket连接:11个
- 页面开始卡顿
- 浏览器警告:内存不足

更可怕的是:

这些僵尸连接还在接收消息,触发setMessages,导致已经卸载的组件还在更新状态。

控制台疯狂报错:

Warning: Can't perform a React state update on an unmounted component.

优化方案

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])

  useEffect(() => {
    console.log(`连接到房间: ${roomId}`)
    const ws = new WebSocket(`wss://chat.example.com/${roomId}`)

    ws.onmessage = (event) => {
      setMessages((prev) => [...prev, JSON.parse(event.data)])
    }

    ws.onerror = (error) => {
      console.error("WebSocket错误:", error)
    }

    // 关键:清理函数!
    return () => {
      console.log(`断开房间: ${roomId}`)
      ws.close()
      // 清理消息
      setMessages([])
    }
  }, [roomId])

  return (
    <div className='chat-room'>
      {messages.map((msg) => (
        <div key={msg.id} className='message'>
          <strong>{msg.user}:</strong> {msg.text}
        </div>
      ))}
    </div>
  )
}

优化效果

优化后:
- 切换10次后内存占用:50MB(稳定)
- WebSocket连接:始终只有1个
- 无内存泄漏警告
- 页面流畅运行

常见的内存泄漏场景:

// ❌ 忘记清理定时器
useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick")
  }, 1000)
  // 忘记清理!
}, [])

// ✅ 正确做法
useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick")
  }, 1000)

  return () => clearInterval(timer)
}, [])

// ❌ 忘记移除事件监听
useEffect(() => {
  window.addEventListener("resize", handleResize)
  // 忘记清理!
}, [])

// ✅ 正确做法
useEffect(() => {
  window.addEventListener("resize", handleResize)

  return () => window.removeEventListener("resize", handleResize)
}, [])

// ❌ 忘记取消网络请求
useEffect(() => {
  fetch("/api/data").then(setData)
  // 组件卸载了,请求还在继续!
}, [])

// ✅ 正确做法
useEffect(() => {
  const controller = new AbortController()

  fetch("/api/data", { signal: controller.signal })
    .then(setData)
    .catch((err) => {
      if (err.name !== "AbortError") {
        console.error(err)
      }
    })

  return () => controller.abort()
}, [])

04. 性能杀手3:列表渲染的"性能陷阱" —— key的错误使用

问题场景

老王的待办事项列表,用户删除第一项时,整个列表都卡了一下。

他的代码:

function TodoList({ todos }) {
  const [items, setItems] = useState(todos)

  const handleDelete = (index) => {
    setItems(items.filter((_, i) => i !== index))
  }

  return (
    <ul>
      {items.map((item, index) => (
        // 问题:使用index作为key!
        <TodoItem
          key={index}
          item={item}
          onDelete={() => handleDelete(index)}
        />
      ))}
    </ul>
  )
}

function TodoItem({ item, onDelete }) {
  console.log("渲染TodoItem:", item.text)

  return (
    <li>
      <input type='checkbox' defaultChecked={item.done} />
      <span>{item.text}</span>
      <button onClick={onDelete}>删除</button>
    </li>
  )
}

为什么用index作为key是错的?

假设有3个待办事项:

初始状态:
[
  { id: 1, text: '买菜', done: false },  // key=0
  { id: 2, text: '做饭', done: true },   // key=1
  { id: 3, text: '洗碗', done: false }   // key=2
]

删除第一项后:
[
  { id: 2, text: '做饭', done: true },   // key=0 (变了!)
  { id: 3, text: '洗碗', done: false }   // key=1 (变了!)
]

React看到的是:

  • key=0的内容从"买菜"变成了"做饭" → 需要更新
  • key=1的内容从"做饭"变成了"洗碗" → 需要更新
  • key=2消失了 → 需要删除

结果:React重新渲染了所有剩余的项!

性能数据

使用index作为key:
- 删除第1项:重新渲染2个组件
- 删除第1项(1000项列表):重新渲染999个组件
- 耗时:~300ms
- 用户感受:明显卡顿

使用稳定的id作为key:
- 删除第1项:只删除1个组件
- 删除第1项(1000项列表):只删除1个组件
- 耗时:~3ms
- 性能提升:100倍!

优化方案

function TodoList({ todos }) {
  const [items, setItems] = useState(todos)

  const handleDelete = (id) => {
    setItems(items.filter((item) => item.id !== id))
  }

  return (
    <ul>
      {items.map((item) => (
        // 使用稳定的唯一ID作为key
        <TodoItem
          key={item.id}
          item={item}
          onDelete={() => handleDelete(item.id)}
        />
      ))}
    </ul>
  )
}

什么时候可以用index作为key?

只有在以下所有条件都满足时:

  1. 列表是静态的,不会增删改
  2. 列表项没有id
  3. 列表不会重新排序

否则,永远使用稳定的唯一ID


05. 性能杀手4:主线程的"阻塞地狱" —— 同步计算

问题场景

老王的搜索功能,用户每输入一个字符,页面就卡一下。

他的代码:

function SearchPage() {
  const [query, setQuery] = useState("")
  const [allData] = useState(generateLargeDataset()) // 10000条数据

  // 问题:每次输入都要同步过滤10000条数据!
  const filteredResults = allData.filter((item) => {
    const searchText = query.toLowerCase()
    return (
      item.title.toLowerCase().includes(searchText) ||
      item.description.toLowerCase().includes(searchText) ||
      item.tags.some((tag) => tag.toLowerCase().includes(searchText)) ||
      item.author.toLowerCase().includes(searchText)
    )
  })

  return (
    <div>
      <input
        type='text'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='搜索...'
      />
      <div className='results'>
        {filteredResults.map((item) => (
          <SearchResult key={item.id} item={item} />
        ))}
      </div>
    </div>
  )
}

性能数据

输入"react":
- 过滤10000条数据
- 耗时:~200ms
- FPS:从60掉到5
- 用户感受:输入框卡顿,打字延迟

问题在哪?

每次输入一个字符,都要:

  1. 触发setQuery
  2. 组件重新渲染
  3. 同步执行filter,阻塞主线程200ms
  4. 用户看到卡顿

优化方案1:使用useDeferredValue

import { useDeferredValue, useMemo } from "react"

function SearchPage() {
  const [query, setQuery] = useState("")
  const [allData] = useState(generateLargeDataset())

  // 延迟更新query,让输入框保持流畅
  const deferredQuery = useDeferredValue(query)

  // 使用useMemo缓存计算结果
  const filteredResults = useMemo(() => {
    if (!deferredQuery) return allData

    const searchText = deferredQuery.toLowerCase()
    return allData.filter(
      (item) =>
        item.title.toLowerCase().includes(searchText) ||
        item.description.toLowerCase().includes(searchText) ||
        item.tags.some((tag) => tag.toLowerCase().includes(searchText)),
    )
  }, [deferredQuery, allData])

  return (
    <div>
      <input
        type='text'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='搜索...'
      />
      <div className='results'>
        {filteredResults.map((item) => (
          <SearchResult key={item.id} item={item} />
        ))}
      </div>
    </div>
  )
}

优化方案2:使用Web Worker

// search-worker.js
self.onmessage = function (e) {
  const { data, query } = e.data
  const searchText = query.toLowerCase()

  const results = data.filter(
    (item) =>
      item.title.toLowerCase().includes(searchText) ||
      item.description.toLowerCase().includes(searchText) ||
      item.tags.some((tag) => tag.toLowerCase().includes(searchText)),
  )

  self.postMessage(results)
}

// SearchPage.jsx
function SearchPage() {
  const [query, setQuery] = useState("")
  const [results, setResults] = useState([])
  const [allData] = useState(generateLargeDataset())
  const workerRef = useRef(null)

  useEffect(() => {
    // 创建Worker
    workerRef.current = new Worker(
      new URL("./search-worker.js", import.meta.url),
    )

    workerRef.current.onmessage = (e) => {
      setResults(e.data)
    }

    return () => workerRef.current?.terminate()
  }, [])

  useEffect(() => {
    if (workerRef.current) {
      workerRef.current.postMessage({ data: allData, query })
    }
  }, [query, allData])

  return (
    <div>
      <input
        type='text'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='搜索...'
      />
      <div className='results'>
        {results.map((item) => (
          <SearchResult key={item.id} item={item} />
        ))}
      </div>
    </div>
  )
}

优化效果

使用useDeferredValue:
- 输入框:始终流畅,无延迟
- 搜索结果:延迟更新,但不阻塞输入
- FPS:稳定60

使用Web Worker:
- 主线程:完全不阻塞
- 搜索计算:在后台线程进行
- 用户体验:完美流畅

06. 性能杀手5:网络请求的"瀑布流" —— 串行请求

问题场景

老王的用户详情页,加载超级慢。

他的代码:

async function loadUserProfile(userId) {
  // 串行请求,慢死了!
  const user = await fetch(`/api/users/${userId}`).then((r) => r.json())
  const posts = await fetch(`/api/users/${userId}/posts`).then((r) => r.json())
  const comments = await fetch(`/api/users/${userId}/comments`).then((r) =>
    r.json(),
  )
  const followers = await fetch(`/api/users/${userId}/followers`).then((r) =>
    r.json(),
  )

  return { user, posts, comments, followers }
}

性能数据

串行请求:
- 请求1(用户信息):200ms
- 请求2(文章列表):300ms
- 请求3(评论列表):250ms
- 请求4(粉丝列表):200ms
- 总耗时:950ms

而且每次切换用户都要重新请求!

优化方案1:并行请求

async function loadUserProfile(userId) {
  // 并行请求,快多了!
  const [user, posts, comments, followers] = await Promise.all([
    fetch(`/api/users/${userId}`).then((r) => r.json()),
    fetch(`/api/users/${userId}/posts`).then((r) => r.json()),
    fetch(`/api/users/${userId}/comments`).then((r) => r.json()),
    fetch(`/api/users/${userId}/followers`).then((r) => r.json()),
  ])

  return { user, posts, comments, followers }
}

优化方案2:使用SWR缓存

import useSWR from "swr"

const fetcher = (url) => fetch(url).then((r) => r.json())

function UserProfile({ userId }) {
  // SWR自动处理缓存、重新验证、错误重试
  const { data: user } = useSWR(`/api/users/${userId}`, fetcher)
  const { data: posts } = useSWR(`/api/users/${userId}/posts`, fetcher)
  const { data: comments } = useSWR(`/api/users/${userId}/comments`, fetcher)
  const { data: followers } = useSWR(`/api/users/${userId}/followers`, fetcher)

  if (!user) return <Loading />

  return (
    <div>
      <UserInfo user={user} />
      <PostList posts={posts || []} />
      <CommentList comments={comments || []} />
      <FollowerList followers={followers || []} />
    </div>
  )
}

优化效果

并行请求:
- 所有请求同时发出
- 总耗时:300ms(最慢的那个)
- 性能提升:3倍!

使用SWR缓存:
- 首次加载:300ms
- 再次访问:0ms(从缓存读取)
- 后台自动更新
- 性能提升:无限倍!

07. 如何发现这些性能杀手?Chrome DevTools实战

Performance面板

1. 打开Chrome DevTools(F12)
2. 切换到Performance标签
3. 点击录制按钮(圆圈)
4. 操作你的页面(点击、滚动等)
5. 停止录制

看什么?
- 火焰图:找到耗时最长的函数
- FPS图:找到掉帧的时刻
- Main线程:找到阻塞主线程的操作

关键指标:

  • FPS < 60:用户会感到卡顿
  • Long Task(>50ms):阻塞主线程
  • Layout Shift:页面抖动

Memory面板

1. 打开Memory标签
2. 选择"Heap snapshot"
3. 点击"Take snapshot"
4. 操作页面(切换路由、打开弹窗等)
5. 再次"Take snapshot"
6. 对比两次快照

看什么?
- 内存增长:是否有泄漏
- Detached DOM:是否有僵尸节点
- Event listeners:是否有未清理的监听器

React DevTools Profiler

1. 安装React DevTools扩展
2. 打开Profiler标签
3. 点击录制
4. 操作页面
5. 停止录制

看什么?
- 组件渲染次数
- 渲染耗时
- 为什么重新渲染(props/state变化)

08. 那个电商网站的转变

3个月后,老王再次打开后台数据。

优化后的数据:

  • 页面加载时间:从3.5秒降到1.2秒
  • 跳出率:从45%降到18%
  • 转化率:从3%提升到9%
  • 新增收入:150万

老板拍着老王的肩膀:"这个月奖金翻倍!"

老王笑了:"其实就改了几行代码。"

他改了什么?

  1. 给所有列表组件加上React.memo
  2. 清理了所有useEffect的副作用
  3. index改成了稳定的id作为key
  4. useDeferredValue优化了搜索
  5. 把串行请求改成了并行 + SWR缓存

总共改动:不到100行代码 性能提升:300% 收入增长:150万


09. 写在最后:性能优化不是锦上添花,是救命稻草

很多开发者觉得性能优化是"高级话题",是"有时间再说"的事情。

错了。

性能优化不是锦上添花,而是生死攸关

  • Google研究:页面加载时间每增加1秒,转化率下降7%
  • Amazon研究:每100ms延迟,销售额下降1%
  • 这不是理论,这是真金白银

更重要的是:

这5个性能杀手,不需要你学什么高深的技术。 它们就藏在你每天写的代码里。

  • 加个React.memo
  • 写个return清理函数
  • index改成id
  • 用个useDeferredValue
  • 改成Promise.all

就这么简单。

但这些简单的改动,能让你的网站从"卡成PPT"变成"丝滑流畅"。

能让你的用户从"关闭页面"变成"下单购买"。

能让你的老板从"暴怒咆哮"变成"奖金翻倍"。

所以,别再忽视性能了。

打开Chrome DevTools,看看你的网站有没有这些性能杀手。

改掉它们,让你的网站飞起来。


你的网站有这些性能问题吗?

优化后性能提升了多少?

在评论区分享你的优化经验吧!

说不定,你的经验能帮助另一个正在被老板骂的开发者。

CesiumLite-一行搞定Cesium三维模型管理

2026年1月21日 10:44

🌍 Cesium 模型加载太复杂😭CesiumLite让你一行代码搞定!

本文深入介绍 CesiumLite 的三维模型管理模块,从 Cesium 原生 Model API 的开发痛点到 ModelManager 的封装原理,再到实战应用,教你如何优雅地在三维地图中加载和管理 glTF/GLB 模型。

前言

在 WebGIS 应用开发中,三维模型展示是构建真实场景的核心功能之一。无论是城市建筑、BIM 构件、产品展示,还是动态对象(车辆、人物、无人机),都需要将 glTF/GLB 格式的三维模型加载到 Cesium 场景中。

然而,使用 Cesium 原生 Model API 进行模型管理时,开发者往往会遇到以下问题:

  • 需要手动计算模型矩阵(涉及坐标转换、弧度转换)
  • 缺乏统一的模型 ID 管理机制
  • 动画控制复杂,需要深入理解 ModelAnimationCollection
  • 样式调整需要熟悉 ColorBlendMode 和 Material 系统
  • 资源清理容易遗漏,导致内存泄漏

CesiumLite 的三维模型管理模块(ModelManager) 应运而生,它提供了简化的 API,让模型加载、位置姿态调整、样式控制、动画播放等操作变得简单直观,大幅提升开发效率。

image.png

在线演示

项目提供了完整的三维模型管理演示页面,你可以访问以下链接体验实际效果:

在线预览

项目地址

演示页面包含以下功能:

  • 模型加载: 支持 URL 加载和本地文件上传
  • 位置姿态控制: 经纬度、航向角、俯仰角、翻滚角、缩放比例调整
  • 样式控制: 颜色、透明度、轮廓高亮设置
  • 动画控制: 播放、暂停、停止、速度调整、循环模式选择
  • 模型管理: 显示/隐藏、移除、批量清空、定位到模型

开发痛点分析

痛点 1: 坐标转换和矩阵计算复杂

使用 Cesium 原生 API 加载一个模型,需要这样写:

// Cesium 原生方式
const position = Cesium.Cartesian3.fromDegrees(116.391, 39.907, 100);

const hpr = new Cesium.HeadingPitchRoll(
  Cesium.Math.toRadians(45),   // 航向角转弧度
  Cesium.Math.toRadians(0),    // 俯仰角转弧度
  Cesium.Math.toRadians(0)     // 翻滚角转弧度
);

// 计算模型矩阵
const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame(position, hpr);

// 应用缩放
Cesium.Matrix4.multiplyByUniformScale(modelMatrix, 2.0, modelMatrix);

// 加载模型
const model = await Cesium.Model.fromGltfAsync({
  url: './models/building.glb',
  modelMatrix: modelMatrix,
  scale: 2.0
});

viewer.scene.primitives.add(model);

问题在于:

  • 需要记住多个 Cesium API 的调用顺序(Cartesian3、HeadingPitchRoll、Transforms)
  • 角度需要手动转换为弧度(容易遗漏或出错)
  • 矩阵计算代码冗长,降低可读性
  • 新手开发者学习成本高

痛点 2: 缺乏统一的模型 ID 管理

// 需要自己管理模型实例
const modelId = 'model_' + Date.now();
const modelMap = new Map();

const model = await Cesium.Model.fromGltfAsync({...});
viewer.scene.primitives.add(model);

// 手动存储
modelMap.set(modelId, model);

// 后续操作需要手动查询
const model = modelMap.get(modelId);
if (model) {
  model.show = false;
}

// 移除时需要同步操作两处
viewer.scene.primitives.remove(model);
modelMap.delete(modelId);

问题在于:

  • 需要自己实现 ID 生成策略
  • 需要手动维护 Map 数据结构
  • 增删改查操作容易出现不一致
  • 资源清理逻辑分散,容易遗漏

痛点 3: 动画控制繁琐且容易出错

// 播放模型内置动画
const model = await Cesium.Model.fromGltfAsync({...});

// 需要等待模型 ready
if (model.ready) {
  // 确保 viewer.clock.shouldAnimate = true
  viewer.clock.shouldAnimate = true;

  // 检查动画是否存在
  const animations = model.sceneGraph?.components?.animations;
  if (animations && animations.length > 0) {
    // 播放第一个动画
    const animation = model.activeAnimations.add({
      index: 0,
      loop: Cesium.ModelAnimationLoop.REPEAT,
      multiplier: 1.5
    });
  }
}

// 暂停/恢复动画需要记录状态
// 改变速度需要移除并重新添加动画

问题在于:

  • 需要手动检查模型是否 ready
  • 需要记得开启 clock.shouldAnimate
  • 动画存在性检查代码冗长
  • 暂停/恢复、改变速度操作复杂
  • Cesium 1.127+ 版本 ModelAnimation 属性只读,无法直接修改速度

痛点 4: 样式修改需要深入理解材质系统

// 修改模型颜色
const model = await Cesium.Model.fromGltfAsync({...});

// 需要理解 ColorBlendMode 枚举
model.color = Cesium.Color.RED.withAlpha(0.8);
model.colorBlendMode = Cesium.ColorBlendMode.MIX;
model.colorBlendAmount = 0.5;  // 混合强度

// 轮廓高亮
model.silhouetteColor = Cesium.Color.YELLOW;
model.silhouetteSize = 3.0;

// 阴影设置
model.shadows = Cesium.ShadowMode.ENABLED;

问题在于:

  • 需要理解 ColorBlendMode 的三种模式(MIX、REPLACE、HIGHLIGHT)
  • colorBlendAmount 的值对效果影响不直观
  • 样式属性分散,不便于统一管理
  • 带纹理的模型颜色覆盖效果可能与预期不符

CesiumLite 的解决方案

核心设计思路

CesiumLite 的 ModelManager 采用了以下设计理念:

  1. 简化的配置驱动 API:使用直观的配置对象,隐藏复杂的坐标转换和矩阵计算
  2. 统一的 ID 管理系统:自动生成唯一 ID,内部使用 Map 管理模型实例
  3. 封装的动画控制接口:提供 play/pause/stop 等语义化方法,自动处理状态管理
  4. 简洁的样式配置:统一的颜色、透明度、轮廓高亮 API,无需关心底层实现
  5. 完善的资源管理机制:提供 destroy 方法,确保资源正确释放

架构设计

ModelManager (管理器)
├── viewer: Cesium.Viewer           # Cesium 实例引用
├── defaultOptions: Object          # 默认配置
└── _models: Map<String, Wrapper>  # 模型存储

Wrapper (内部包装对象)
├── id: String                      # 唯一标识
├── model: Cesium.Model            # Cesium Model 实例
├── config: Object                 # 配置信息
├── isLoaded: Boolean              # 加载状态
└── currentAnimation: ModelAnimation  # 当前播放的动画

核心代码实现

1. ModelManager 类: 核心管理器

ModelManager 负责模型的增删改查和资源管理,是模块的核心:

class ModelManager {
  constructor(viewer, options = {}) {
    if (!viewer) throw new Error('Viewer instance is required');
    this.viewer = viewer;

    this.defaultOptions = {
      maximumMemoryUsage: 512,
      defaultScale: 1.0,
      defaultShow: true,
      shadows: Cesium.ShadowMode.ENABLED,
      defaultAnimationLoop: Cesium.ModelAnimationLoop.REPEAT,
      defaultAnimationSpeed: 1.0,
      ...options
    };

    // 使用 Map 存储所有模型
    this._models = new Map();
  }

  // 添加模型
  addModel(config) { /* ... */ }

  // 移除模型
  removeModel(modelId) { /* ... */ }

  // 更新模型属性
  updateModel(modelId, options) { /* ... */ }

  // 获取模型实例
  getModel(modelId) { /* ... */ }

  // 清空所有模型
  clearModels() { /* ... */ }
}

设计亮点:

  • 构造函数验证 viewer 必需参数,避免运行时错误
  • 使用 Map 数据结构存储模型,支持快速查询(O(1) 复杂度)
  • 提供默认配置合并机制,用户只需覆盖需要的配置项
  • 所有公共方法都有明确的返回值(Boolean 或具体对象)

2. 坐标转换封装: 简化矩阵计算

核心方法 _buildModelMatrix 封装了复杂的坐标转换逻辑:

_buildModelMatrix(position, orientation, scale) {
  // 1. 经纬度转笛卡尔坐标
  const pos = Cesium.Cartesian3.fromDegrees(
    position.longitude,
    position.latitude,
    position.height || 0
  );

  // 2. 角度转弧度(自动处理)
  const hpr = new Cesium.HeadingPitchRoll(
    Cesium.Math.toRadians(orientation.heading || 0),
    Cesium.Math.toRadians(orientation.pitch || 0),
    Cesium.Math.toRadians(orientation.roll || 0)
  );

  // 3. 生成模型矩阵
  const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame(pos, hpr);

  // 4. 应用缩放
  Cesium.Matrix4.multiplyByUniformScale(modelMatrix, scale || 1.0, modelMatrix);

  return modelMatrix;
}

设计亮点:

  • 用户只需提供经纬度和角度(度),自动完成弧度转换
  • 封装所有 Cesium API 调用,降低使用门槛
  • 支持高度默认值(0),简化配置
  • 返回完整的 modelMatrix,可直接传递给 Model.fromGltfAsync

3. 动画控制封装: 一行代码播放动画

playAnimation(modelId, options = {}) {
  const wrapper = this._models.get(modelId);
  if (!wrapper?.model || !wrapper.isLoaded) return false;

  // 自动开启 clock 动画
  this._ensureClockAnimating();

  // 提前检查动画是否存在
  const animations = wrapper.model.sceneGraph?.components?.animations;
  if (Array.isArray(animations) && animations.length === 0) {
    console.warn(`Model (${modelId}) has no animations`);
    return false;
  }

  const index = options.index ?? 0;
  const loop = options.loop ?? this.defaultOptions.defaultAnimationLoop;
  const speed = options.speed ?? this.defaultOptions.defaultAnimationSpeed;

  // 移除旧动画,添加新动画
  if (wrapper.currentAnimation) {
    wrapper.model.activeAnimations.remove(wrapper.currentAnimation);
  }

  wrapper.currentAnimation = wrapper.model.activeAnimations.add({
    index,
    loop,
    multiplier: speed,
    reverse: !!options.reverse
  });

  return true;
}

设计亮点:

  • 自动检查模型是否加载完成(isLoaded 标志)
  • 自动开启 viewer.clock.shouldAnimate(常见的坑)
  • 提供友好的错误提示(模型无动画时)
  • 封装动画切换逻辑,避免内存泄漏
  • 支持速度、循环模式、反向播放等配置

4. 资源管理: 避免内存泄漏

removeModel(modelId) {
  const wrapper = this._models.get(modelId);
  if (!wrapper) return false;

  const { model } = wrapper;
  try {
    if (model) {
      // 从场景中移除
      this.viewer.scene.primitives.remove(model);

      // 销毁模型实例
      if (!model.isDestroyed?.()) model.destroy?.();
    }
  } finally {
    // 释放 object URL(本地文件加载时)
    this._revokeObjectUrl(wrapper);

    // 从 Map 中删除
    this._models.delete(modelId);
  }

  return true;
}

destroy() {
  // 清空所有模型
  this.clearModels();
  this.viewer = null;
}

设计亮点:

  • 使用 try-finally 确保资源清理逻辑一定执行
  • 自动释放 object URL(本地文件加载场景)
  • 提供 destroy 方法用于销毁管理器本身
  • 清理逻辑统一封装,避免遗漏

使用教程

基础用法

1. 初始化 CesiumLite
import CesiumLite from 'cesium-lite';

const cesiumLite = new CesiumLite('cesiumContainer', {
  map: {
    camera: {
      longitude: 116.391,
      latitude: 39.907,
      height: 500
    }
  }
});

// 获取模型管理器实例
const modelManager = cesiumLite.modelManager;
2. 加载模型
基础加载(URL)
const modelId = modelManager.addModel({
  url: './models/building.glb',
  position: {
    longitude: 116.391,
    latitude: 39.907,
    height: 0
  }
});
完整配置
const modelId = modelManager.addModel({
  url: './models/building.glb',
  position: {
    longitude: 116.391,
    latitude: 39.907,
    height: 0
  },
  orientation: {
    heading: 45,   // 航向角(度)
    pitch: 0,      // 俯仰角(度)
    roll: 0        // 翻滚角(度)
  },
  scale: 2.0,      // 缩放比例
  show: true,      // 是否显示
  onLoad: (id, model) => {
    console.log('模型加载成功:', id);
    // 自动定位到模型
    viewer.camera.flyToBoundingSphere(model.boundingSphere);
  },
  onError: (id, error) => {
    console.error('模型加载失败:', error);
  }
});
加载本地文件
// 从 input[type=file] 获取文件
const file = fileInput.files[0];

const modelId = modelManager.addModel({
  url: file,  // 支持 Blob/File 对象
  position: { longitude: 116.391, latitude: 39.907, height: 0 }
});
3. 模型显示控制
// 隐藏模型
modelManager.hide(modelId);

// 显示模型
modelManager.show(modelId);

// 移除模型
modelManager.removeModel(modelId);

// 清空所有模型
modelManager.clearModels();
4. 位置姿态调整
// 更新模型位置
modelManager.updateModel(modelId, {
  position: {
    longitude: 116.4,
    latitude: 39.9,
    height: 150
  }
});

// 更新姿态角度
modelManager.updateModel(modelId, {
  orientation: {
    heading: 90,
    pitch: 10,
    roll: 5
  }
});

// 更新缩放比例
modelManager.updateModel(modelId, {
  scale: 3.0
});
5. 样式控制
// 设置颜色和透明度
modelManager.setColor(modelId, Cesium.Color.RED, 0.8);

// 或使用 CSS 颜色字符串
modelManager.setColor(modelId, '#ff0000', 0.8);

// 设置轮廓高亮
modelManager.setSilhouette(modelId, Cesium.Color.YELLOW, 3.0);

// 设置阴影模式
modelManager.setShadows(modelId, Cesium.ShadowMode.ENABLED);
6. 动画控制
// 播放第一个动画
modelManager.playAnimation(modelId, {
  index: 0,                                    // 动画索引
  loop: Cesium.ModelAnimationLoop.REPEAT,    // 循环模式
  speed: 1.5                                   // 播放速度
});

// 暂停/恢复动画(支持进度保持)
modelManager.pauseAnimation(modelId);

// 停止动画
modelManager.stopAnimation(modelId);

// 改变播放速度
modelManager.setAnimationSpeed(modelId, 2.0);

高级用法

自定义配置
// 创建带自定义配置的管理器
const modelManager = new ModelManager(viewer, {
  maximumMemoryUsage: 1024,                      // 内存限制(MB)
  defaultScale: 2.0,                             // 默认缩放
  shadows: Cesium.ShadowMode.CAST_ONLY,         // 默认阴影模式
  defaultAnimationSpeed: 1.5                     // 默认动画速度
});
批量管理模型
// 获取所有模型
const allModels = modelManager.getAllModels();

allModels.forEach(({ id, model, config, isLoaded }) => {
  console.log('模型ID:', id);
  console.log('是否加载完成:', isLoaded);
  console.log('配置信息:', config);
});

// 批量设置样式
allModels.forEach(({ id }) => {
  modelManager.setColor(id, Cesium.Color.BLUE, 0.9);
});

// 批量清空
modelManager.clearModels();
结合业务场景
// 场景1: 加载城市建筑群
const buildings = [
  { url: './models/building1.glb', lng: 116.391, lat: 39.907, height: 0 },
  { url: './models/building2.glb', lng: 116.392, lat: 39.908, height: 0 },
  { url: './models/building3.glb', lng: 116.393, lat: 39.909, height: 0 }
];

const buildingIds = buildings.map(b => {
  return modelManager.addModel({
    url: b.url,
    position: { longitude: b.lng, latitude: b.lat, height: b.height },
    scale: 2.0
  });
});

// 场景2: 动态对象展示(车辆轨迹)
const carId = modelManager.addModel({
  url: './models/car.glb',
  position: { longitude: 116.391, latitude: 39.907, height: 0 },
  orientation: { heading: 0, pitch: 0, roll: 0 }
});

// 模拟移动
setInterval(() => {
  const model = modelManager.getModel(carId);
  const config = modelManager.getAllModels().find(m => m.id === carId)?.config;
  if (config) {
    config.position.longitude += 0.0001;  // 向东移动
    modelManager.updateModel(carId, { position: config.position });
  }
}, 100);

对比传统开发方式

代码量对比

功能 传统方式 CesiumLite 减少代码量
加载模型 15 行 5 行 66%
位置调整 12 行 3 行 75%
动画播放 20 行 3 行 85%
样式设置 8 行 1 行 87%

功能对比

功能 传统方式 CesiumLite
坐标转换 需手动计算矩阵 自动处理
ID 管理 需自己实现 自动生成和管理
动画控制 需手动检查和管理状态 封装完整接口
资源清理 容易遗漏 统一清理机制
错误处理 需自己实现 内置错误回调
本地文件支持 需手动创建 object URL 自动处理

快速开始

1. 安装

# NPM 安装(推荐)
npm install cesium-lite

# 或者通过 GitHub 克隆
git clone https://github.com/lukeSuperCoder/cesium-lite.git
cd cesium-lite
npm install

2. 引入使用

import CesiumLite from 'cesium-lite';

// 初始化地图
const cesiumLite = new CesiumLite('cesiumContainer', {
  map: {
    camera: {
      longitude: 116.391,
      latitude: 39.907,
      height: 500
    }
  }
});

// 使用模型管理器
const modelId = cesiumLite.modelManager.addModel({
  url: './models/building.glb',
  position: { longitude: 116.391, latitude: 39.907, height: 0 },
  scale: 2.0
});

3. 运行示例

# 启动开发服务器
npm run dev

# 访问示例页面
http://localhost:8020/examples/model.html

最佳实践建议

1. 模型优化建议

// 建议: 使用 Draco 压缩的 glTF 模型(减少 50%-70% 文件大小)
const modelId = modelManager.addModel({
  url: './models/building_compressed.glb',
  position: { longitude: 116.391, latitude: 39.907, height: 0 }
});

// 建议: 限制同时加载的模型数量
if (modelManager.getAllModels().length > 20) {
  console.warn('模型数量过多,可能影响性能');
}

2. 动画性能优化

// 建议: 限制同时播放的动画数量
let playingCount = 0;
const MAX_PLAYING = 5;

if (playingCount < MAX_PLAYING) {
  modelManager.playAnimation(modelId, {
    index: 0,
    speed: 1.0,
    loop: Cesium.ModelAnimationLoop.REPEAT
  });
  playingCount++;
}

3. 资源管理建议

// 建议: 在组件销毁时清理资源
componentWillUnmount() {
  // 清空所有模型
  cesiumLite.modelManager.clearModels();

  // 销毁管理器
  cesiumLite.modelManager.destroy();
}

4. 错误处理建议

// 建议: 使用 onError 回调处理加载失败
const modelId = modelManager.addModel({
  url: './models/building.glb',
  position: { longitude: 116.391, latitude: 39.907, height: 0 },
  onError: (id, error) => {
    // 记录错误日志
    console.error(`模型加载失败 [${id}]:`, error);

    // 显示友好提示
    alert('模型加载失败,请检查网络连接或文件路径');

    // 清理失败的模型记录
    // modelManager 内部已自动清理,无需手动操作
  }
});

未来规划

ModelManager 后续将会支持:

  • 模型点击交互和属性查询
  • 可视化编辑器(拖拽、旋转控制点)
  • 模型包围盒计算和缓存
  • KTX2 纹理压缩支持
  • 模型 LOD(细节层次)管理
  • 模型聚合显示

相关资源

总结

CesiumLite 的三维模型管理模块通过简化的 API 设计和完善的功能封装,有效解决了 Cesium 原生开发中的诸多痛点:

  • 简化坐标转换: 无需手动计算矩阵,支持直观的经纬度+角度配置
  • 统一 ID 管理: 自动生成唯一标识,内置 Map 存储机制
  • 封装动画控制: 一行代码播放动画,自动处理状态管理
  • 简洁样式 API: 颜色、透明度、轮廓高亮一步到位
  • 完善资源管理: 统一的清理机制,避免内存泄漏
  • 本地文件支持: 自动处理 Blob/File,无需手动创建 object URL

如果你正在使用 Cesium 开发三维模型展示功能,CesiumLite 将是你的最佳选择,让开发效率提升 3 倍!


⭐ 如果这个项目对你有帮助,欢迎给个 Star 支持一下!

💬 有任何问题或建议,欢迎在评论区交流!

相关标签: #Cesium #三维地图 #WebGIS #三维模型 #glTF #前端开发 #JavaScript #开源项目 #地图可视化

ThreeJS 详解光线投射与物体交互

2026年1月21日 10:42

本文档涵盖了Three.js中光线投射(Raycaster)与物体交互的关键概念和实现方法,基于实际代码示例进行讲解。

1. 光线投射基础概念

光线投射是一种在三维空间中追踪光线路径的技术,主要用于检测鼠标与3D物体的交互。在Three.js中,Raycaster类提供了光线投射功能,可以用来检测鼠标点击、悬停等事件与场景中物体的交点。

alt text

2. Raycaster对象创建

首先需要创建一个Raycaster对象和鼠标位置对象:

// 创建投射光线对象
const raycaster = new THREE.Raycaster();

// 鼠标的位置对象
const mouse = new THREE.Vector2();

3. 场景设置

在进行光线投射之前,需要先设置好场景、相机和待检测的物体:

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,                                    // 视野角度
  window.innerWidth / window.innerHeight, // 宽高比
  0.1,                                  // 近平面
  300                                   // 远平面
);

// 设置相机位置
camera.position.set(0, 0, 20);
scene.add(camera);

// 创建几何体和材质
const cubeGeometry = new THREE.BoxBufferGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
  wireframe: true,                       // 线框模式显示
});

const redMaterial = new THREE.MeshBasicMaterial({
  color: "#ff0000",                      // 红色材质
});

// 创建多个立方体用于交互测试
let cubeArr = [];
for (let i = -5; i < 5; i++) {
  for (let j = -5; j < 5; j++) {
    for (let z = -5; z < 5; z++) {
      const cube = new THREE.Mesh(cubeGeometry, material);
      cube.position.set(i, j, z);        // 设置立方体位置
      scene.add(cube);
      cubeArr.push(cube);                // 将立方体添加到数组中便于检测
    }
  }
}

4. 鼠标事件监听

监听鼠标事件并将屏幕坐标转换为标准化设备坐标(NDC):

// 监听鼠标点击事件
window.addEventListener("click", (event) => {
  // 将鼠标位置归一化为设备坐标 [-1, 1]
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -((event.clientY / window.innerHeight) * 2 - 1);
  
  // 从相机设置光线投射器
  raycaster.setFromCamera(mouse, camera);
  
  // 检测与物体的交点
  let result = raycaster.intersectObjects(cubeArr);
  
  // 对相交的物体进行处理
  result.forEach((item) => {
    item.object.material = redMaterial;  // 改变相交物体的材质
  });
});

5. 鼠标移动事件监听(可选)

除了点击事件,也可以监听鼠标移动事件实现实时交互:

// 监听鼠标移动事件(注释掉的部分)
/*
window.addEventListener("mousemove", (event) => {
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -((event.clientY / window.innerHeight) * 2 - 1);
  raycaster.setFromCamera(mouse, camera);
  let result = raycaster.intersectObjects(cubeArr);
  result.forEach((item) => {
    item.object.material = redMaterial;
  });
});
*/

6. 渲染器配置

配置渲染器以支持场景渲染:

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 开启场景中的阴影贴图
renderer.shadowMap.enabled = true;
renderer.physicallyCorrectLights = true;

// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

7. 轨道控制器设置

添加轨道控制器以支持相机交互:

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 设置控制器阻尼,让控制器更有真实效果,必须在动画循环里调用.update()。
controls.enableDamping = true;

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

8. 动画循环

在动画循环中更新控制器并渲染场景:

// 设置时钟
const clock = new THREE.Clock();

function render() {
  let time = clock.getElapsedTime();

  controls.update();                       // 更新控制器
  renderer.render(scene, camera);          // 渲染场景
  
  // 渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

9. 响应式设计

处理窗口大小变化:

// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
  // 更新摄像头
  camera.aspect = window.innerWidth / window.innerHeight;
  // 更新摄像机的投影矩阵
  camera.updateProjectionMatrix();

  // 更新渲染器
  renderer.setSize(window.innerWidth, window.innerHeight);
  // 设置渲染器的像素比
  renderer.setPixelRatio(window.devicePixelRatio);
});

10. Raycaster方法详解

10.1 setFromCamera方法

该方法根据相机和鼠标位置设置光线:

raycaster.setFromCamera(mouse, camera);

10.2 intersectObjects方法

该方法检测光线与指定物体数组的交点:

let result = raycaster.intersectObjects(cubeArr);

返回的结果是一个数组,每个元素包含交点信息,如交点位置、相交的物体等。

11. 交点结果处理

交点结果包含丰富的信息:

result.forEach((item) => {
  // item.distance: 交点与射线起点之间的距离
  // item.point: 交点的三维坐标
  // item.face: 相交的面
  // item.object: 相交的物体
  item.object.material = redMaterial;      // 更改相交物体的材质
});

12. 性能优化建议

  1. 只对需要交互的物体进行检测,避免检测整个场景
  2. 合理设置检测频率,避免每帧都进行检测造成性能问题
  3. 使用分组管理需要检测的物体,便于批量处理

总结

光线投射是Three.js中实现用户交互的重要技术,通过Raycaster类可以轻松实现鼠标与3D物体的交互。主要步骤包括:

  1. 创建Raycaster和鼠标位置对象
  2. 设置场景、相机和待检测物体
  3. 监听鼠标事件并转换坐标
  4. 使用setFromCamera方法设置光线
  5. 使用intersectObjects方法检测交点
  6. 处理交点结果实现交互效果

通过这种技术,可以实现点击选择物体、悬停高亮、拖拽等功能,大大增强用户的交互体验。

从痛点到架构:用 Chrome DevTools Panel 做埋点校验,我是怎么落地的

2026年1月21日 10:39

01 背景

被忽视的“隐形时间杀手”

在现代互联网企业的软件交付链路中,我们往往过于关注架构的复杂度、算法的优劣、页面的渲染性能(FCP/LCP),却极容易忽视那些夹杂在开发流程缝隙中的“微小损耗”。

这就好比一辆 F1 赛车,引擎再强劲,如果进站换胎的时间由于螺丝刀不顺手而慢了 2 秒,最终的比赛结果可能就是天壤之别。对于前端开发者而言,“埋点校验”就是那个不顺手的螺丝刀。

让我们还原一个极其真实的场景,这个场景可能每天都在成千上万个工位上发生: 你需要开发一个电商大促的落地页。需求文档里不仅有复杂的 UI 交互,还密密麻麻地列出了 50 个埋点需求:

  • “Banner 曝光上报”
  • “商品卡片点击上报,需携带 SKU、SPU、RankId”
  • “商品列表曝光、弹窗点击曝光、展示曝光上报等”
  • “用户滚动深度上报”
  • ……

日常埋点需求

当你熬夜写完业务代码,准备提测前,你必须确保这 50 个埋点一个不错。因为在数据驱动的逻辑下,代码跑通只是及格,数据对齐才是满分。*如果埋点错了,运营拿到的数据就是不实的,后续的所有转化分析、漏斗模型都将建立在虚假的基石之上。

开发者的一百种“崩溃”

于是,你开始了痛苦的校验流程。 你熟练地按下 F12,打开 Chrome DevTools,切换到 Network 面板。 你刷新页面,看着 Waterfall 瀑布流瞬间涌出几百个请求。 图片、JS、CSS、字体文件、XHR 接口、WebSocket 心跳……它们混杂在一起。你眯着眼睛,试图在其中找到那几个 gif 请求或者 sendBeacon 调用。

崩溃瞬间 A:大海捞针 你输入了过滤关键词 lego 或者 mark-p。列表变少了,但依然有几十个。你必须一个一个点开,查看 Headers,查看 Payload。Payload 可能是压缩过的 JSON 字符串,你得复制出来,打开一个新的 Tab,访问 JSON.cn,粘贴,格式化,然后肉眼比对 section_id 是不是 10086

Network 面板

崩溃瞬间 B:稍纵即逝 你需要验证“点击跳转”的埋点。你清空了 Network,点击了按钮。页面跳转了。 就在跳转的一瞬间,你看到了埋点请求闪了一下。但是,随着新页面的加载,Network 面板被瞬间清空(除非你记得勾选 Preserve log,但即使勾选了,新页面的请求也会迅速把旧请求淹没)。 你不得不重新来过,把手速练得像电竞选手一样,试图在跳转前的那几百毫秒内截获数据。

跳转页面后数据无法持久化

崩溃瞬间 C:参数黑盒 产品经理跑过来问:“为什么这个字段是空的?”你看着那堆乱码一样的编码参数,心里只有一句话:我怎么知道它是原本就空,还是传输过程丢了?

背景痛点

问题的本质:认知负荷过载

根据我们的内部效能统计,一个资深前端开发在处理“埋点自测”这一环节时,平均每个埋点需要消耗 3 到 5 分钟。这不仅是时间的浪费,更是认知资源(Cognitive Resources)的剧烈消耗。 每一次切换窗口、每一次复制粘贴、每一次在混乱的列表中聚焦眼神,都在打断开发者的“心流”(Flow)。当你从 JSON 格式化网站切回 IDE 时,你可能已经忘了刚才想改的那行代码在哪里。

这就是我们启动 zzChromeTools 项目的初衷。我们不是为了造轮子而造轮子,而是为了把开发者从低效的、重复的、高认知负荷的劳动中解放出来。

02 现状

在决定开发自研工具之前,我们必须回答一个问题:市面上已有的工具,真的不够用吗?

我们对业内主流的网络调试方案进行了深度调研,包括浏览器原生工具、代理抓包工具以及第三方插件。结论是:它们都很强大,但在“埋点校验”这个垂直细分领域,它们都存在着严重的“信噪比”(Signal-to-Noise Ratio)过低的问题。

Chrome DevTools Network 面板:全能选手的软肋

Chrome 的 Network 面板是所有前端开发者的“母语”。它的优势在于原生、零成本、信息全。 但“信息全”恰恰是它的软肋。

  • 无差别对待: 它平等地展示每一个 HTTP 请求。对于浏览器来说,加载一张图片和上报一条埋点数据,本质上没有区别。但在业务逻辑上,埋点数据的重要性远高于静态资源。在 Network 面板中,核心信号(埋点)被海量的噪音(静态资源)淹没了。
  • 缺乏语义化: Network 面板只展示 HTTP 协议层面的信息(Status, Type, Size)。它不懂你的业务。它不知道 section_id 是什么,它无法帮你高亮显示“错误的参数”。
  • 上下文易失: 虽然有 Preserve log,但在多页面跳转、SPA 路由切换的复杂场景下,日志的管理依然非常混乱。

Charles / Fiddler / Whistle:重型武器的杀鸡用牛刀

Charles、Fiddler 以及 Whistle 是抓包界的神器。它们支持强大的断点、重写、HTTPS 解密。 但是,用它们来查埋点,太重了。

  • 配置成本高: 你需要安装证书、配置系统代理、设置手机 WiFi 代理。对于仅仅想在 PC 浏览器上快速看一眼埋点的场景,这个启动成本太高。
  • 数据隔离差: 开启系统代理后,你电脑上所有软件的网络请求(QQ、微信、系统更新)都会涌入 Charles。你依然需要花费大量精力去写 Include/Exclude 规则来过滤。
  • 视觉交互差: 它们的 JSON 解析能力虽然有,但往往也是通用的树状结构,无法针对特定的埋点字段进行定制化展示。

现有的埋点插件(OmniBug 等)

市面上也有一些优秀的埋点插件,如 OmniBug。它们确实解决了部分问题。 但它们的局限性在于:

  • 适配性问题: 往往只适配 Adobe Analytics、Google Analytics 等国际通用标准。对于国内大厂自研的埋点 SDK(往往有自定义的加密协议、特殊的字段结构),它们无能为力。
  • 功能单一: 仅仅展示数据,缺乏与本地开发环境的联动(如 Whistle 代理控制)。

总结: 现有的工具链存在一个巨大的真空地带。 我们需要一款轻量级(无需配置代理)、定制化(懂内部业务逻辑)、高信噪比(自动降噪)且持久化(不怕页面刷新)的浏览器扩展。 这就是 zzChromeTools 的定位。

多渠道优点对比

03 难点

当我们立项并准备动手开发时,恰逢 Chrome 扩展生态迎来了一次史无前例的“大地震”——Manifest V3 (MV3) 的强制推行。

如果您关注过浏览器技术,就会知道,Google 宣布在 2024 年逐步停止对 Manifest V2 (MV2) 的支持。这意味着,我们开发的新工具必须,也只能基于 MV3 架构。

从 MV2 到 MV3,不是简单的版本号 +1,而是底层安全模型和进程模型的范式转移(Paradigm Shift)。对于插件开发者来说,这简直是一场“灾难”般的挑战。

难点一:“隔离世界”的铁壁铜墙

在浏览器扩展的架构中,存在一个核心概念叫“隔离世界”(Isolated World)。

  • 页面脚本(Page Script): 也就是你的业务代码,运行在主世界(Main World)。
  • 内容脚本(Content Script): 插件注入到页面的代码,运行在隔离世界。

在 MV2 时代,虽然两者 JS 环境隔离,但 Content Script 对 DOM 的访问权限非常大,且通过简单的 <script> 标签注入就能轻松打破隔离,直接访问页面全局变量。

但在 MV3 中,Google 为了安全(防止插件窃取用户数据),极大地收紧了权限。 我们的需求是:拦截页面发出的 navigator.sendBeacon 请求。 业务代码调用的是 window.navigator.sendBeacon。如果我们只是在 Content Script 里重写这个函数,是完全没用的。因为业务代码运行在主世界,它看不到隔离世界的修改。 如何合法地、安全地穿透这层“次元壁”,去监听主世界的 API 调用? 这是第一个技术拦路虎。

难点二:Service Worker 的“嗜睡症”

MV3 做出的最大改变,就是移除了 MV2 中常驻后台的 Background Page,取而代之的是 Service Worker

  • Background Page (MV2): 就像一个 7x24 小时运行的后台服务器。你可以在里面存变量,它永远都在。
  • Service Worker (MV3): 它是瞬态(Ephemeral)的。它是事件驱动的。当没有事件发生时(比如几分钟没操作),浏览器会强制杀掉这个 Service Worker 进程以节省内存。

这意味着什么? 这意味着如果你在 Background 里用一个全局变量 let logs = [] 来存埋点数据,只要你去上个厕所回来,Service Worker 可能就重启了,你的数据全丢了! 对于一个需要长时间记录日志的工具来说,这种“健忘”的特性是致命的。如何在一个无状态的、随时可能死亡的进程中保持数据的连续性?这是第二个难点。

难点三:通信链路的迷宫

数据产生在页面(Page),拦截在脚本(Script),处理在后台(Service Worker),展示在面板(DevTools Panel)。 这就涉及到了 4 个完全独立的上下文 之间的通信。 MV3 废除了很多阻塞式的 API,强制使用异步通信。 特别是 Service WorkerDevTools Panel 的通信。由于 Service Worker 是被动的,而 DevTools 是用户主动查看的,如何建立一个高效的、低延迟的管道? 传统的 chrome.runtime.connect 长连接在 MV3 的 Service Worker 中变得非常脆弱(容易断连)。

面对这些由 MV3 带来的“降维打击”,我们没有退路,只能在架构设计上进行深度突围。

04 业内方案

在详细介绍我们的方案之前,有必要看看针对类似问题,业内同行们通常是如何解决的,以及为什么我们没有采用这些方案。

方案 A:declarativeNetRequest (DNR)

MV3 引入了 declarativeNetRequest API,旨在取代 MV2 强大的 webRequest API(这也是广告拦截插件最受伤的地方)。

  • 原理: 通过配置 JSON 规则,告诉浏览器“阻断”或“修改”某些请求。
  • 优点: 性能好,隐私安全。
  • 缺点: 能力太弱。 DNR 主要用于拦截和修改 Headers,它无法读取请求体(Request Body)。 对于埋点校验来说,最重要的就是 Payload(埋点参数)。如果读不到 Body,这个方案就毫无意义。所以,DNR 方案 PASS。

方案 B:重写 XHR / Fetch 原型

这是传统的“Hook”方案。

  • 原理: 劫持 XMLHttpRequest.prototype.openwindow.fetch
  • 缺点:
    1. 覆盖不全: 现代埋点 SDK 大多使用 navigator.sendBeacon 进行上报,因为它在页面卸载时更可靠。劫持 XHR/Fetch 无法捕获 Beacon 请求。
    2. 侵入性风险: 如果处理不好,容易破坏原有的业务逻辑,甚至导致死循环。

方案 C:Debugger Protocol

  • 原理: 使用 chrome.debugger API,像 DevTools 一样 attach 到页面上。
  • 优点: 权限极大,可以看到一切网络请求。
  • 缺点: 用户体验极差。 当插件 attach debugger 时,浏览器顶部会出现一个黄色的警告条:“xxx 插件正在调试此浏览器”,这会给用户带来极大的不安全感。而且,一个页面只能被一个 debugger attach,这会与真正的 DevTools 冲突。

综上所述: 现有的标准 API 要么拿不到数据(DNR),要么体验太差(Debugger)。我们必须寻找一条“少有人走的路”——基于主世界注入的 AOP 旁路捕获模式

05 我的方案

本章节将深入代码细节,为您展示 zzChromeTools 的核心架构。我们将整个系统拆解为三个核心模块:主世界注入模块旁路通信模块数据持久化模块

架构总览

我们的核心设计思想是:“特洛伊木马”。 既然外部拦截困难,那我们就进入内部。通过 MV3 的新特性,将一段经过精心设计的“探针代码”直接投放到页面的 JS 引擎中,在数据发出的源头进行截获,然后通过安全的隧道传输出去。

整体架构如下图所示:

┌─────────────────────────────────────────────────────────────────┐
│  页面主世界 (Main World)                                          │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ navigator.sendBeacon (被 Hook)                               ││
│  │         ↓                                                    ││
│  │ window.postMessage({ source: "my-ext-beacon", url, data })  ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
                              ↓ postMessage
┌─────────────────────────────────────────────────────────────────┐
│  Content Script (隔离世界) - mark-p.ts                           │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ window.addEventListener("message", ...)                      ││
│  │ 过滤 source === "my-ext-beacon"                              ││
│  │ 解析数据 → 组装 PingRecord                                    ││
│  │         ↓                                                    ││
│  │ sendToBackground({ name: "store-record", body: record })    ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
                              ↓ Plasmo Messaging
┌─────────────────────────────────────────────────────────────────┐
│  Background Service Worker                                       │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ messages/store-record.ts  → pingRecords.unshift(record)     ││
│  │ messages/get-records.ts   → res.send(pingRecords)           ││
│  │                                                              ││
│  │ pingRecords: PingRecord[] (内存数组)                         ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
                              ↑ 每 800ms 轮询
┌─────────────────────────────────────────────────────────────────┐
│  DevTools Panel - SpmTools/index.tsx                             │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ setInterval(fetchRecords, 800)                               ││
│  │ sendToBackground({ name: "get-records" })                    ││
│  │         ↓                                                    ││
│  │ Ant Design Table 渲染数据                                    ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

整体架构

时序图

核心突破:world: 'MAIN' 的妙用

在 MV3 中,chrome.scripting.executeScript API 增加了一个不起眼但至关重要的属性:world。 通过设置 world: 'MAIN',我们可以合法地打破 Content Script 与 Page Script 之间的隔离。

代码实战:background/index.ts 中,我们监听页面的加载,并注入脚本:

// src/background/index.ts

/**
 * 这段函数将被注入到"主世界"执行。
 * 只能写成纯函数形式,或外联文件:此处内联更简单。
 */
function overrideSendBeaconInMain() {
  const originalSendBeacon = navigator.sendBeacon
  navigator.sendBeacon = function (url, data) {
    if (
      typeof url === "string" &&
      url.includes("lego.example.com/page/mark-p")  // 埋点上报域名
    ) {
      // 把埋点请求的 url、data 通过 window.postMessage 抛给页面
      window.postMessage({ source: "my-ext-beacon", url, data }, "*")
    }
    return originalSendBeacon.apply(this, arguments)
  }
  // 标记监控状态,供 DevTools 检测
  window.__is_spm_monitor_open__ = true
}

/**
 * 注入脚本到指定 tab 的主世界
 */
async function injectSendBeaconOverride(tabId: number) {
  console.log("[BG] Injecting overrideSendBeaconInMain into tab =>", tabId)
  try {
    await chrome.scripting.executeScript({
      target: { tabId },
      world: "MAIN",  // 核心魔法:指定代码在主世界执行
      func: overrideSendBeaconInMain
    })
  } catch (err) {
    console.error("[BG] Failed to inject script =>", err)
  }
}

这段代码的价值在于,它利用了 MV3 的官方能力,无需像 MV2 那样往 DOM 里插入丑陋的 <script> 标签,既干净又隐蔽。

注入时机控制:在页面 JS 执行前完成拦截

注入时机至关重要。如果注入太晚,页面的埋点 SDK 可能已经缓存了原生 sendBeacon 的引用,我们的 Hook 就无法生效。

// src/background/index.ts

// 监听 tab 更新,在 loading 状态时注入
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  // 如果没有 URL,直接跳过
  if (!tab.url) return

  // 只针对特定站点
  const isTargetSite = tab.url.includes("example.com")

  // 如果是进入 loading 状态 且是我们目标站点
  if (changeInfo.status === "loading" && isTargetSite) {
    // 根据配置决定是否自动清空历史数据
    if (baseConfig.baseConfig.automaticallyEmpty) {
      pingRecords.splice(0, pingRecords.length)
    }

    // 根据配置决定注入策略
    if (baseConfig.baseConfig.alwaysInjectedOnRefresh) {
      // 始终注入模式
      await injectSendBeaconOverride(tabId)
    } else if (baseConfig.baseConfig.injectSpmScriptOnNextRefresh) {
      // 仅下一次刷新时注入
      await injectSendBeaconOverride(tabId)
      baseConfig.baseConfig.injectSpmScriptOnNextRefresh = false
    }
  }
})

技术要点解析:

  1. loading 状态注入changeInfo.status === "loading" 确保我们在页面 JS 执行前完成注入
  2. 灵活的注入策略:支持"始终注入"和"下次刷新注入"两种模式
  3. 自动清空选项:可配置每次刷新时自动清空历史埋点数据

核心逻辑:AOP 旁路捕获(Bypass Capture)

注入成功后,spyOnSendBeacon 函数会在页面上下文中执行。这里我们使用了 AOP(面向切面编程)的思想。

我们不修改业务逻辑,只是在业务逻辑执行的“切面”上插了一根管子。

关键安全原则:

  1. 保存原生引用:防止死循环
  2. 透传返回值sendBeacon 返回 boolean 表示是否入队成功,必须正确返回
  3. 使用 apply 保持 this 指向:确保原生方法正常工作**
function overrideSendBeaconInMain() {
  const originalSendBeacon = navigator.sendBeacon
  navigator.sendBeacon = function (url, data) {
    if (typeof url === "string" && url.includes("lego.example.com/page/mark-p")) {
      window.postMessage({ source: "my-ext-beacon", url, data }, "*")
    }
    // 必须使用 apply 保持 this 指向,并返回原函数的执行结果
    return originalSendBeacon.apply(this, arguments)
  }
}

通信隧道:跨越四层维度的接力

数据被捕获后,需要经历一场“长征”才能到达开发者眼前的面板。

Step 1: Main World -> Content Script (postMessage)

Content Script 运行在隔离世界,但可以监听主世界发出的 postMessage

// src/contents/mark-p.ts
import type { PlasmoCSConfig } from "plasmo"
import { v4 } from "uuid"
import { sendToBackground } from "@plasmohq/messaging"

// Plasmo 配置:在所有页面上运行,尽早注入
export const config: PlasmoCSConfig = {
  matches: ["<all_urls>"],
  run_at: "document_start"  // 避免 "runtime not available" 错误
  // 缺省 world => "ISOLATED"(隔离世界)
}

// 定义埋点数据结构
interface PingRecord {
  id: string
  time: string
  pagetype: string
  actiontype: string
  sectionId: string
  sortId: string
  sortName: string
  fullData: any
}

// 监听 window.postMessage
window.addEventListener("message", (ev) => {
  // 严格校验 source,防止恶意网页伪造数据
  if (!ev.data || ev.data.source !== "my-ext-beacon") {
    return
  }

  const { url, data } = ev.data

  // 解析 data(可能是 JSON 字符串)
  let parsedBody: any
  try {
    parsedBody = JSON.parse(typeof data === "string" ? data : "")
  } catch {
    parsedBody = data
  }

  // 组装一个 PingRecord,提取关键业务字段
  const newRecord: PingRecord = {
    id: v4(),  // 使用 UUID 保证唯一性
    time: new Date().toLocaleTimeString(),
    pagetype: parsedBody?.pagetype || "",
    actiontype: parsedBody?.actiontype || "",
    sectionId: parsedBody?.backup?.sectionId || "",
    sortId: parsedBody?.backup?.sortId || "",
    sortName: parsedBody?.backup?.sortName || "",
    fullData: parsedBody  // 保留完整数据供调试
  }

  // 把记录发给 background
  sendToBackground({
    name: "store-record",
    body: newRecord
  })
})

Step 2: Content Script -> Service Worker (Plasmo Messaging)

我们使用 Plasmo 框架提供的消息系统,它封装了 chrome.runtime.sendMessage 并提供了更好的类型支持:

// src/background/messages/store-record.ts
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { pingRecords, type PingRecord } from "../pingRecord"

/** 接收 content-script 发送过来的新埋点,把它存进 pingRecords */
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
  const newRecord = req.body as PingRecord

  // 支持清空操作
  if (req.body === "clear") {
    pingRecords.splice(0, pingRecords.length)
  } else {
    // 在顶部插入(新数据在前)
    pingRecords.unshift(newRecord)
  }

  res.send("ok")
}

export default handler

Step 3: Service Worker 的内存管理

// src/background/pingRecord.ts
export interface PingRecord {
  id: string
  time: string
  pagetype: string
  actiontype: string
  sectionId: string
  sortId: string
  sortName: string
  fullData: any
}

/** 全局只在内存中保存,刷新/重启后会丢失 */
export const pingRecords: PingRecord[] = []

Step 4: Service Worker -> DevTools Panel (轮询策略)

这是最关键的设计决策。我们没有选择长连接(connect),而是选择了短轮询(Polling)

// src/components/panels/SpmTools/index.tsx
import { sendToBackground } from "@plasmohq/messaging"

useEffect(() => {
  let intervalId: number

  function fetchRecords() {
    // 获取埋点记录
    sendToBackground<PingRecord[]>({
      name: "get-records"
    }).then((res) => {
      if (Array.isArray(res)) {
        setRecords(res)
        // 更新过滤器选项...
      }
    })

    // 检查监控状态
    chrome.devtools.inspectedWindow.eval(
      "window.__is_spm_monitor_open__",
      (result: boolean, isException) => {
        if (!isException) {
          setIsSpmMonitorOpen(result)
        }
      }
    )
  }

  fetchRecords()  // 先拉一次

  // 每 800ms 轮询一次
  intervalId = window.setInterval(() => {
    fetchRecords()
  }, 800)

  return () => clearInterval(intervalId)
}, [])

消息处理器极其简洁:

// src/background/messages/get-records.ts
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { pingRecords } from "../pingRecord"

const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
  // 直接把内存中保存的埋点列表返回
  res.send(pingRecords)
}

export default handler

DevTools Panel 打开时,每隔 800ms 调用一次 chrome.runtime.sendMessage({ action: "get_records" })。 Service Worker 收到请求,返回 pingRecords,并清空已发送的记录(或保留根据需求)。

为什么是轮询?

  1. 对抗 Service Worker 休眠: 轮询是无状态的。即使 SW 休眠了,下一次轮询请求会自动唤醒它。
  2. 简单可靠: 避免了复杂的连接断开重连逻辑。
  3. 性能无损: 800ms 的频率对于现代 CPU 来说,负载几乎为 0。且数据只是内存读取,延迟在纳秒级。

辅助:基于 Plasmo 的工程化实践

我们引入了 Plasmo 框架来构建整个插件。Plasmo 被称为"浏览器插件领域的 Next.js"。它提供了:

  • 热重载(HMR):开发时修改代码无需重新加载扩展
  • React 支持:使用 Ant Design 构建 DevTools 面板 UI
  • TypeScript 开箱即用:完整的类型支持
  • 消息系统封装@plasmohq/messaging 简化了跨上下文通信

为什么使用 Plasmo

实际使用体验

使用流程

快速开始流程

开启控制台面板 -> 选择 zzChromeTools

流程1

根据需求勾选能力

流程2

在页面上触发埋点

流程3

筛选数据 / 清空数据

流程4

时效对比

时效对比

06 价值

zzChromeTools 不仅仅是一个代码的堆砌,它代表了我们对前端工程化的深度思考。我们将它的价值概括为三个维度:

时间维度的价值

  • 旧流程: 查找(1min)+ 解析(1min)+ 验证(1min)= 3 分钟/个
  • 新流程: 打开面板,自动高亮 = 5 秒/个。 如果一个项目有 50 个埋点,我们直接节省了 2.5 小时 的纯垃圾时间。对于一个 10 人的前端团队,一年节省的工时成本是非常可观的。

心理维度的价值

这无法用 KPI 衡量,但最为重要。 工具的“顺手程度”直接影响开发者的幸福感。当工具能够像呼吸一样自然时,开发者可以将宝贵的注意力(Attention)集中在业务逻辑和架构设计上,而不是被琐事打断。 我们消灭了“噪音”,留下了“信号”。 这种清爽的调试体验,能让开发者在面对繁琐的埋点需求时,少一分焦虑,多一分从容。

资产维度的价值

这个插件的架构本身就是一份宝贵的技术资产。

  • 它验证了 MV3 架构下复杂通信的可行性。
  • 它提供了一套标准化的“主世界注入”模板,未来可以扩展用于其他场景(如性能监控 SDK 的调试、AB Test 标记的查看等)。

What's more

zzChromeTools除了埋点校验之外,还有如下小工具用于提效前端开发:

  1. 常用工程跳转/二维码

未命中工程链接时,展示记录工程

命中工程链接时,展示工程子页面

Whistle 代理一键切换

*Whistle 代理一键切换

一键分析当前页面字体

一键分析当前页面字体

JSON 层级查找工具

JSON 层级查找工具

07 结论与未来展望

开发 zzChromeTools 的过程,是我们不满于低效的现状,但不通过抱怨来发泄,而是通过技术手段去改变它的体现。

未来 Roadmap

虽然目前的版本已经解决了 80% 的痛点,但我们仍有更宏大的计划:

  1. 持久化存储升级: 引入 IndexedDB,彻底解决 Service Worker 重启可能导致极端情况下数据丢失的问题,支持保存几天的埋点历史,方便回溯。
  2. 全协议覆盖: 除了 sendBeacon,还将 Hook XMLHttpRequestfetch,实现对所有类型上报的无死角覆盖。
  3. 自动化测试集成: 探索暴露 API 给 Puppeteer/Playwright,让自动化测试脚本也能读取插件捕获的埋点数据,实现埋点回归的自动化。

结语

Chrome MV3 是一堵墙,但技术不仅能砌墙,也能架桥。 通过对底层原理的深入挖掘,我们证明了即使在最严格的安全限制下,依然可以打造出极致的开发者工具。 希望本文能给你带来两方面的收获:一是关于 Chrome 插件开发的硬核知识,二是一种“不凑合、不妥协”的极客精神。

拒绝无效加班,从打磨手中的武器开始。

转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

2025年累计培育专精特新中小企业超14万家

2026年1月21日 10:30
国务院新闻办公室举行新闻发布会,介绍2025年工业和信息化发展成效有关情况。发布会上介绍,加强优质企业梯度培育,累计培育专精特新中小企业超14万家,有效期内的高新技术企业达50.4万家。2025年规模以上工业专精特新“小巨人”企业增加值同比增长9%,前11个月规模以上工业高新技术企业利润总额同比增长5.3%。(中国网)

工信部:AI已渗透领航级智能工厂70%以上业务场景

2026年1月21日 10:24
国新办举行新闻发布会介绍2025年工业和信息化发展成效。工业和信息化部信息通信发展司司长谢存表示,目前人工智能(AI)已渗透领航级智能工厂70%以上的业务场景,沉淀了超6000个垂直领域模型,带动1700多项关键智能制造装备和工业软件规模化应用,形成一批具备感知、决策和执行能力的工业智能体,推动智能制造从自动化向自主化改进。(中国网)
❌
❌