阅读视图
OpenAI:将自行承担“星际之门”项目能源费用
好消息:过审了。坏消息:苹果后台又挂了~
背景
对于每一位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隐藏功能,轻松过审。
沪深两市成交额突破1.5万亿元
我国电动汽车充电基础设施(枪)总数突破2000万大关
React性能优化相关hook记录:React.memo、useCallback、useMemo
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不能阻止以下情况的重渲染:
- 组件自身调用
useState、useReducer触发更新。- 组件消费了
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 中,当组件重新渲染时,其内部的所有函数都会被重新创建。对于传递给子组件的回调函数来说,这意味着:
- 每次父组件渲染都会创建一个新的函数实例
- 子组件会因为接收到的 props 不同而重新渲染,即使实际内容没有变化
- 在依赖数组中使用的函数如果不被缓存,可能导致 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。
工信部:将发布人形机器人与具身智能综合标准化体系建设指南
三家商业航天企业IPO辅导更新
淘宝天猫:2025年高分商家成交增速达普通商家2.2倍
Agent 开发必学 —— LangChain 生态、MCP 协议与 SOP 落地实战
引言
在 AI Agent 开发从“写着玩”进入“工业化落地”的阶段后,开发者面临的挑战已不再是如何调用 API,而是如何构建一个可控、可扩展、且具备标准接口的系统。
本文将结合 LangChain 最新生态、MCP (Model Context Protocol) 协议以及 SOP (标准作业程序) 思维,为你拆解一套现代 Agent 开发的“黄金组合”。
一、 Agent 开发的“四根支柱”
在构建一个复杂的 AI 应用时,我们需要清晰地定义四个层面:
- 大脑 (LLM) :核心算力,负责推理。
- 骨架 (@langchain/core) :定义标准化的接口(Runnable、BaseMessage),让不同模型可切换。
- 手脚 (MCP / Tools) :连接外部数据与 API 的标准通道。
- 灵魂 (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 行为不可控、无状态。 | 可预测:思考路径就是流程图。 |
六、 为什么我们要采用这种模块化架构?
-
按需安装:正如你所提到的,不需要 OpenAI 就不装
@langchain/openai,保持项目精简。 - 工程化可观测:在 LangGraph 中,你可以清晰地看到 Agent 停在哪个 SOP 节点。
- 未来兼容性:即使明年出现了比 LangChain 更火的框架,你的 MCP 工具服务器 依然可以直接迁移使用。
结语
AI 开发正从“魔法”走向“工程”。理解 LangChain 的包结构只是第一步,真正的进阶在于如何利用 MCP 协议 扩展 Agent 的边界,并用 SOP (LangGraph) 驯服 LLM 的不确定性。
ETF总规模回到6万亿元以下
阿里千问刷新全球开源模型新纪录
你的网站慢到让用户跑路?这5个被忽视的性能杀手,改完速度飙升300%
摘要:当你的老板指着后台数据咆哮"为什么转化率这么低",当用户在评论区疯狂吐槽"卡成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
为什么会这样?
因为每次setCart,ProductList重新渲染,所有的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.memo和useCallback不是过度优化,而是必需品。"
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?
只有在以下所有条件都满足时:
- 列表是静态的,不会增删改
- 列表项没有id
- 列表不会重新排序
否则,永远使用稳定的唯一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
- 用户感受:输入框卡顿,打字延迟
问题在哪?
每次输入一个字符,都要:
- 触发
setQuery - 组件重新渲染
- 同步执行
filter,阻塞主线程200ms - 用户看到卡顿
优化方案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万
老板拍着老王的肩膀:"这个月奖金翻倍!"
老王笑了:"其实就改了几行代码。"
他改了什么?
- 给所有列表组件加上
React.memo - 清理了所有
useEffect的副作用 - 把
index改成了稳定的id作为key - 用
useDeferredValue优化了搜索 - 把串行请求改成了并行 + 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三维模型管理
🌍 Cesium 模型加载太复杂😭CesiumLite让你一行代码搞定!
本文深入介绍 CesiumLite 的三维模型管理模块,从 Cesium 原生 Model API 的开发痛点到 ModelManager 的封装原理,再到实战应用,教你如何优雅地在三维地图中加载和管理 glTF/GLB 模型。
前言
在 WebGIS 应用开发中,三维模型展示是构建真实场景的核心功能之一。无论是城市建筑、BIM 构件、产品展示,还是动态对象(车辆、人物、无人机),都需要将 glTF/GLB 格式的三维模型加载到 Cesium 场景中。
然而,使用 Cesium 原生 Model API 进行模型管理时,开发者往往会遇到以下问题:
- 需要手动计算模型矩阵(涉及坐标转换、弧度转换)
- 缺乏统一的模型 ID 管理机制
- 动画控制复杂,需要深入理解 ModelAnimationCollection
- 样式调整需要熟悉 ColorBlendMode 和 Material 系统
- 资源清理容易遗漏,导致内存泄漏
CesiumLite 的三维模型管理模块(ModelManager) 应运而生,它提供了简化的 API,让模型加载、位置姿态调整、样式控制、动画播放等操作变得简单直观,大幅提升开发效率。
在线演示
项目提供了完整的三维模型管理演示页面,你可以访问以下链接体验实际效果:
演示页面包含以下功能:
- 模型加载: 支持 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 采用了以下设计理念:
- 简化的配置驱动 API:使用直观的配置对象,隐藏复杂的坐标转换和矩阵计算
- 统一的 ID 管理系统:自动生成唯一 ID,内部使用 Map 管理模型实例
- 封装的动画控制接口:提供 play/pause/stop 等语义化方法,自动处理状态管理
- 简洁的样式配置:统一的颜色、透明度、轮廓高亮 API,无需关心底层实现
- 完善的资源管理机制:提供 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(细节层次)管理
- 模型聚合显示
相关资源
- GitHub 仓库: github.com/lukeSuperCo…
- 在线演示: lukesupercoder.github.io/cesium-lite…
- 问题反馈: GitHub Issues
- 需求分析文档: 三维模型管理-需求分析.md
总结
CesiumLite 的三维模型管理模块通过简化的 API 设计和完善的功能封装,有效解决了 Cesium 原生开发中的诸多痛点:
- ✅ 简化坐标转换: 无需手动计算矩阵,支持直观的经纬度+角度配置
- ✅ 统一 ID 管理: 自动生成唯一标识,内置 Map 存储机制
- ✅ 封装动画控制: 一行代码播放动画,自动处理状态管理
- ✅ 简洁样式 API: 颜色、透明度、轮廓高亮一步到位
- ✅ 完善资源管理: 统一的清理机制,避免内存泄漏
- ✅ 本地文件支持: 自动处理 Blob/File,无需手动创建 object URL
如果你正在使用 Cesium 开发三维模型展示功能,CesiumLite 将是你的最佳选择,让开发效率提升 3 倍!
⭐ 如果这个项目对你有帮助,欢迎给个 Star 支持一下!
💬 有任何问题或建议,欢迎在评论区交流!
相关标签: #Cesium #三维地图 #WebGIS #三维模型 #glTF #前端开发 #JavaScript #开源项目 #地图可视化
ThreeJS 详解光线投射与物体交互
本文档涵盖了Three.js中光线投射(Raycaster)与物体交互的关键概念和实现方法,基于实际代码示例进行讲解。
1. 光线投射基础概念
光线投射是一种在三维空间中追踪光线路径的技术,主要用于检测鼠标与3D物体的交互。在Three.js中,Raycaster类提供了光线投射功能,可以用来检测鼠标点击、悬停等事件与场景中物体的交点。
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. 性能优化建议
- 只对需要交互的物体进行检测,避免检测整个场景
- 合理设置检测频率,避免每帧都进行检测造成性能问题
- 使用分组管理需要检测的物体,便于批量处理
总结
光线投射是Three.js中实现用户交互的重要技术,通过Raycaster类可以轻松实现鼠标与3D物体的交互。主要步骤包括:
- 创建Raycaster和鼠标位置对象
- 设置场景、相机和待检测物体
- 监听鼠标事件并转换坐标
- 使用setFromCamera方法设置光线
- 使用intersectObjects方法检测交点
- 处理交点结果实现交互效果
通过这种技术,可以实现点击选择物体、悬停高亮、拖拽等功能,大大增强用户的交互体验。
从痛点到架构:用 Chrome DevTools Panel 做埋点校验,我是怎么落地的
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。
崩溃瞬间 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 Worker 到 DevTools 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.open和window.fetch。 -
缺点:
-
覆盖不全: 现代埋点 SDK 大多使用
navigator.sendBeacon进行上报,因为它在页面卸载时更可靠。劫持 XHR/Fetch 无法捕获 Beacon 请求。 - 侵入性风险: 如果处理不好,容易破坏原有的业务逻辑,甚至导致死循环。
-
覆盖不全: 现代埋点 SDK 大多使用
方案 C:Debugger Protocol
-
原理: 使用
chrome.debuggerAPI,像 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
}
}
})
技术要点解析:
-
loading 状态注入:
changeInfo.status === "loading"确保我们在页面 JS 执行前完成注入 - 灵活的注入策略:支持"始终注入"和"下次刷新注入"两种模式
- 自动清空选项:可配置每次刷新时自动清空历史埋点数据
核心逻辑:AOP 旁路捕获(Bypass Capture)
注入成功后,spyOnSendBeacon 函数会在页面上下文中执行。这里我们使用了 AOP(面向切面编程)的思想。
我们不修改业务逻辑,只是在业务逻辑执行的“切面”上插了一根管子。
关键安全原则:
- 保存原生引用:防止死循环
-
透传返回值:
sendBeacon返回boolean表示是否入队成功,必须正确返回 -
使用
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,并清空已发送的记录(或保留根据需求)。
为什么是轮询?
- 对抗 Service Worker 休眠: 轮询是无状态的。即使 SW 休眠了,下一次轮询请求会自动唤醒它。
- 简单可靠: 避免了复杂的连接断开重连逻辑。
- 性能无损: 800ms 的频率对于现代 CPU 来说,负载几乎为 0。且数据只是内存读取,延迟在纳秒级。
辅助:基于 Plasmo 的工程化实践
我们引入了 Plasmo 框架来构建整个插件。Plasmo 被称为"浏览器插件领域的 Next.js"。它提供了:
- 热重载(HMR):开发时修改代码无需重新加载扩展
- React 支持:使用 Ant Design 构建 DevTools 面板 UI
- TypeScript 开箱即用:完整的类型支持
-
消息系统封装:
@plasmohq/messaging简化了跨上下文通信
实际使用体验
使用流程
开启控制台面板 -> 选择 zzChromeTools
根据需求勾选能力
在页面上触发埋点
筛选数据 / 清空数据
时效对比
06 价值
zzChromeTools 不仅仅是一个代码的堆砌,它代表了我们对前端工程化的深度思考。我们将它的价值概括为三个维度:
时间维度的价值
- 旧流程: 查找(1min)+ 解析(1min)+ 验证(1min)= 3 分钟/个。
- 新流程: 打开面板,自动高亮 = 5 秒/个。 如果一个项目有 50 个埋点,我们直接节省了 2.5 小时 的纯垃圾时间。对于一个 10 人的前端团队,一年节省的工时成本是非常可观的。
心理维度的价值
这无法用 KPI 衡量,但最为重要。 工具的“顺手程度”直接影响开发者的幸福感。当工具能够像呼吸一样自然时,开发者可以将宝贵的注意力(Attention)集中在业务逻辑和架构设计上,而不是被琐事打断。 我们消灭了“噪音”,留下了“信号”。 这种清爽的调试体验,能让开发者在面对繁琐的埋点需求时,少一分焦虑,多一分从容。
资产维度的价值
这个插件的架构本身就是一份宝贵的技术资产。
- 它验证了 MV3 架构下复杂通信的可行性。
- 它提供了一套标准化的“主世界注入”模板,未来可以扩展用于其他场景(如性能监控 SDK 的调试、AB Test 标记的查看等)。
What's more
zzChromeTools除了埋点校验之外,还有如下小工具用于提效前端开发:
- 常用工程跳转/二维码
Whistle 代理一键切换
一键分析当前页面字体
JSON 层级查找工具
07 结论与未来展望
开发 zzChromeTools 的过程,是我们不满于低效的现状,但不通过抱怨来发泄,而是通过技术手段去改变它的体现。
未来 Roadmap
虽然目前的版本已经解决了 80% 的痛点,但我们仍有更宏大的计划:
-
持久化存储升级: 引入
IndexedDB,彻底解决 Service Worker 重启可能导致极端情况下数据丢失的问题,支持保存几天的埋点历史,方便回溯。 -
全协议覆盖: 除了
sendBeacon,还将 HookXMLHttpRequest和fetch,实现对所有类型上报的无死角覆盖。 - 自动化测试集成: 探索暴露 API 给 Puppeteer/Playwright,让自动化测试脚本也能读取插件捕获的埋点数据,实现埋点回归的自动化。
结语
Chrome MV3 是一堵墙,但技术不仅能砌墙,也能架桥。 通过对底层原理的深入挖掘,我们证明了即使在最严格的安全限制下,依然可以打造出极致的开发者工具。 希望本文能给你带来两方面的收获:一是关于 Chrome 插件开发的硬核知识,二是一种“不凑合、不妥协”的极客精神。
拒绝无效加班,从打磨手中的武器开始。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~