阅读视图

发现新文章,点击刷新页面。

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 不能阻止以下情况的重渲染:

  • 组件自身调用 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

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

引言

在 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 的不确定性。

你的网站慢到让用户跑路?这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

为什么会这样?

因为每次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三维模型管理

🌍 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 详解光线投射与物体交互

本文档涵盖了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 做埋点校验,我是怎么落地的

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),更多干货实践,欢迎交流分享~

Lodash 源码解读与原理分析 - 语言那些事儿

一、整体性能优化策略分析

1. 类型检查优化

核心优化点

  • 直接类型判断:优先使用 typeofinstanceof 等原生操作符,原生方法执行层级浅,避免额外函数调用开销,是性能最优的基础判断方式。

  • 标签检查:对于数组、日期、正则等复杂类型,使用 Object.prototype.toString.call() 获取标准类型标签,规避原生判断的兼容性问题,同时保证判断准确性。

  • 缓存优化:缓存常用的类型标签(如 arrayTagobjectTag)和正则表达式(如二进制、八进制匹配正则),减少重复创建带来的内存和性能消耗。

  • 短路逻辑:通过 &&|| 短路特性,优先判断高频场景和边界值,减少不必要的后续检查,缩短函数执行路径。

性能收益

减少类型检查的开销,提高函数执行速度;提升类型判断的准确性和跨环境一致性,适配不同 JavaScript 引擎的类型系统差异,降低误判概率。

2. 转换函数优化

核心优化点

  • 渐进式转换:采用“从简单到复杂”的转换路径,优先处理基础类型,再适配复杂类型和特殊场景,避免一开始就进入高开销逻辑。

  • 边界情况处理:专门针对 nullundefinedNaNInfinity 等边界值做特殊处理,避免转换过程中抛出异常,同时减少冗余计算。

  • 类型感知:根据输入类型动态选择最优转换逻辑,如类数组对象直接复用数组拷贝逻辑,迭代器对象通过迭代协议转换,避免“一刀切”的低效处理。

  • 性能平衡:在转换准确性和执行性能间取舍,如字符串转数字时,优先使用一元运算符 +,仅在特殊进制场景下使用 parseInt

性能收益

提升转换函数的执行效率,减少异常处理开销;支持更广泛的输入类型,增强函数灵活性,同时降低边界值转换的错误率。

3. 克隆函数优化

核心优化点

  • 深度控制:通过标记位(CLONE_DEEP_FLAG)区分浅克隆与深克隆,浅克隆仅复制表层引用,深克隆递归处理嵌套结构,按需分配性能开销。

  • 类型特定处理:针对数组、对象、Map、Set、TypedArray 等不同类型,采用专属克隆策略,如数组直接拷贝元素,对象遍历自有属性,避免通用逻辑的性能损耗。

  • 循环引用检测:在深克隆中通过栈或哈希表追踪已克隆对象,处理循环引用场景,避免无限递归导致的栈溢出和性能崩溃。

  • 自定义克隆:支持传入自定义处理函数,允许开发者针对特殊类型(如 DOM 元素)定制克隆逻辑,兼顾灵活性与性能。

性能收益

提高克隆操作的执行速度,避免不必要的深层递归;减少内存冗余复制,降低垃圾回收压力;支持复杂对象结构克隆,同时规避循环引用风险。

4. 内存优化

核心优化点

  • 对象池复用:重用临时对象(如类型标签缓存、正则对象),减少频繁创建和销毁带来的内存碎片,降低 GC 触发频率。

  • 引用释放:在函数执行完毕后,及时清空不再使用的变量引用(如克隆后的临时栈、比较函数中的中间结果),便于垃圾回收器回收内存。

  • 浅拷贝优先:默认提供浅克隆能力,仅在明确需要时才启用深克隆,避免过度克隆导致的内存浪费和性能开销。

  • 内存预分配:对于数组转换和克隆场景,根据源数据长度预分配合适的内存空间,避免数组动态扩容带来的性能损耗。

性能收益

减少整体内存占用,降低垃圾回收压力和执行阻塞时间;提升函数执行连贯性,避免内存泄漏,保障应用长期运行稳定性。

5. 代码复用与抽象

核心优化点

  • 内部工具函数提炼:将通用逻辑封装为底层工具函数(如 baseClonebaseGetTagcopyObject),上层函数通过调用底层函数实现功能,实现“一次优化、全域受益”。

  • 函数组合设计:通过标记位和参数透传实现函数组合,如克隆函数复用 baseClone,仅通过不同标记位区分深浅克隆和符号支持,减少代码冗余。

  • 参数规范化:统一处理函数参数(如 toFinite 预处理、guard 参数适配),避免在每个函数中重复编写参数校验逻辑。

  • 模块化组织:将类型判断、转换、克隆等相关功能按模块划分,核心逻辑集中维护,降低迭代和优化成本。

性能收益

减少代码冗余量,降低维护成本;集中优化核心底层函数,提升整体性能上限;代码结构更清晰,可读性和可扩展性更强。

6. 环境适配

核心优化点

  • 环境特性检测:提前检测运行环境对原生方法(如 Array.isArraySymbol.iterator)的支持情况,优先选用原生方法。

  • 优雅降级策略:在低版本环境中,为不支持的特性提供自定义实现,如不支持 Symbol 的环境中,克隆函数自动跳过符号属性处理。

  • 原生方法优先:对于 isArray 等基础判断,直接复用原生 Array.isArray,仅在原生方法不可用时 fallback 到自定义实现,最大化利用原生性能优势。

  • 跨引擎兼容:处理不同 JavaScript 引擎对类型标签、原型链的差异,确保函数在浏览器、Node.js 等环境中表现一致。

性能收益

充分发挥原生方法的底层优化优势,提升函数在现代环境中的执行效率;保证跨环境兼容性,减少环境差异导致的错误,降低适配成本。

二、函数级详细分析

1. 类型转换函数

1.1 _.castArray

功能:将值转换为数组,若值已为数组则直接返回原引用,避免冗余创建。

function castArray() {
  // 短路逻辑:无参数时直接返回空数组,避免后续判断开销
  if (!arguments.length) {
    return [];
  }
  var value = arguments[0];
  // 类型判断:已为数组则返回原引用,否则包裹为数组
  return isArray(value) ? value : [value];
}
    

性能优化点

  • 短路逻辑优先:优先检查参数长度,无参数时直接返回空数组,缩短执行路径,避免不必要的类型判断。

  • 原引用复用:若输入已是数组,直接返回原引用,不创建新数组,减少内存分配和 GC 压力。

  • 极简逻辑设计:仅包含核心判断逻辑,无冗余代码,函数调用栈浅,执行效率接近原生操作。

输入输出示例

// 输入
_.castArray(1);           // 单个值转换为数组
_.castArray([1, 2, 3]);   // 已为数组,返回原引用
_.castArray();            // 无参数,返回空数组

// 输出
// [1]
// [1, 2, 3] (与原数组引用一致)
// []
    

适用场景:需要统一参数为数组格式的场景,如函数入参标准化、批量处理前的格式适配,尤其适合高频调用场景。

1.2 _.toArray

功能:将各类值(对象、字符串、迭代器、Map/Set 等)转换为标准数组,适配多类型输入。

function toArray(value) {
  // 短路逻辑:空值直接返回空数组,避免后续复杂判断
  if (!value) {
    return [];
  }
  // 类数组优化:优先处理类数组对象,区分字符串与其他类型
  if (isArrayLike(value)) {
    // 字符串特殊处理:转为字符数组,其他类数组直接拷贝
    return isString(value) ? stringToArray(value) : copyArray(value);
  }
  // 迭代器支持:适配 ES6 迭代器协议,兼容 Map、Set 等可迭代对象
  if (symIterator && value[symIterator]) {
    return iteratorToArray(value[symIterator]());
  }
  // 复杂类型适配:根据类型标签选择对应转换逻辑
  var tag = getTag(value),
      // 映射类型专属转换函数,避免条件判断冗余
      func = tag == mapTag ? mapToArray : (tag == setTag ? setToArray : values);

  return func(value);
}
    

性能优化点

  • 分层类型适配:按“空值→类数组→迭代器→复杂对象”的顺序判断,优先处理高频简单场景,缩短执行路径。

  • 类数组专项优化:对类数组对象直接复用 copyArray 底层逻辑,避免手动遍历 arguments 等低效操作。

  • 迭代器原生适配:利用 ES6 迭代器协议,直接遍历可迭代对象,无需额外类型转换,兼顾兼容性与性能。

  • 函数映射复用:通过类型标签映射专属转换函数,避免多层 if-else 判断,逻辑更清晰且执行高效。

// 输入
_.toArray({ 'a': 1, 'b': 2 });  // 对象转换为值数组
_.toArray('abc');               // 字符串转换为字符数组
_.toArray(new Set([1, 2, 3]));  // Set 转换为数组

// 输出
// [1, 2]
// ['a', 'b', 'c']
// [1, 2, 3]
    

适用场景:需要统一多类型输入为数组的场景,如数据批量处理、迭代器结果固化、对象属性值提取等。

1.3 _.toFinite

功能:将值转换为有限数字,处理无限值、NaN、边界值等特殊情况,返回标准化有限数。

function toFinite(value) {
  // 短路逻辑:空值或假值(除 0 外)统一返回 0,0 保持原样避免误转换
  if (!value) {
    return value === 0 ? value : 0;
  }
  // 渐进式转换:先转为数字,再处理无限值
  value = toNumber(value);
  // 无限值处理:将正负无限值转为对应方向的最大整数,保证有限性
  if (value === INFINITY || value === -INFINITY) {
    var sign = (value < 0 ? -1 : 1);
    return sign * MAX_INTEGER;
  }
  // NaN 处理:非数字或 NaN 时返回 0,否则返回转换后的值
  return value === value ? value : 0;
}
    

性能优化点

  • 边界值短路处理:优先处理空值、0 等高频边界场景,避免后续复杂转换逻辑。

  • 转换逻辑复用:复用 toNumber 函数完成基础转换,减少代码冗余,同时受益于 toNumber 的性能优化。

  • 无限值高效修正:通过符号判断和最大整数相乘,快速将无限值转为有限值,逻辑简洁且执行高效。

  • NaN 精准过滤:利用 value === value 判断 NaN(NaN 与自身不相等),比 isNaN 更精准且性能更优。

// 输入
_.toFinite(3.2);          // 有限数字,直接返回
_.toFinite(Infinity);      // 无限值,转换为最大整数
_.toFinite('3.2');         // 字符串,转换为数字

// 输出
// 3.2
// 1.7976931348623157e+308
// 3.2
    

适用场景:需要确保数值为有限值的场景,如数值计算、长度限制、范围校验等,避免无限值或 NaN 导致的逻辑异常。

1.4 _.toInteger

功能:将值转换为整数,处理小数、无限值、字符串等输入,返回标准化整数。

function toInteger(value) {
  // 复用逻辑:先转为有限值,规避无限值和 NaN 影响
  var result = toFinite(value),
      // 取模运算:快速获取小数部分,比 Math.floor 更高效
      remainder = result % 1;

  // 取整处理:有小数部分则减去余数,整数直接返回,NaN 时返回 0
  return result === result ? (remainder ? result - remainder : result) : 0;
}
    

性能优化点

  • 底层逻辑复用:依赖 toFinite 完成边界值和无限值处理,避免重复编码,同时保证逻辑一致性。

  • 高效取整方式:使用取模运算 % 和减法实现取整,避免 Math.floor 的函数调用开销,执行速度更快。

  • NaN 精准过滤:通过 result === result 判断 NaN,确保异常值转为 0,逻辑严谨且性能优异。

// 输入
_.toInteger(3.2);          // 小数转换为整数
_.toInteger('3.2');         // 字符串转换为整数
_.toInteger(Infinity);      // 无限值转换为最大整数

// 输出
// 3
// 3
// 1.7976931348623157e+308
    

适用场景:需要整数输入的场景,如数组索引、循环次数、长度计算等,确保输入数值的整数标准化。

1.5 _.toLength

功能:将值转换为有效数组长度(0 至 MAX_ARRAY_LENGTH 之间的整数),适配数组长度限制场景。

function toLength(value) {
  // 短路逻辑:空值返回 0;非空值先转为整数,再限制在有效范围
  return value ? baseClamp(toInteger(value), 0, MAX_ARRAY_LENGTH) : 0;
}
    

性能优化点

  • 多层逻辑复用:复用 toInteger 完成数值整数化,复用 baseClamp 完成范围限制,代码极简且复用率高。

  • 短路空值处理:空值直接返回 0,避免后续转换和范围限制开销,缩短执行路径。

  • 有效范围固化:通过 MAX_ARRAY_LENGTH 限制上限,适配 JavaScript 数组长度最大值,避免无效长度赋值。

输入输出示例

// 输入
_.toLength(3.2);          // 转换为有效长度
_.toLength(Infinity);      // 转换为最大数组长度
_.toLength(-1);            // 负数转换为 0

// 输出
// 3
// 4294967295
// 0
    

适用场景:数组长度设置、切片范围限制、类数组对象长度标准化等场景,确保长度值合法有效。

1.6 _.toNumber

功能:将值转换为数字,支持基础类型、对象、特殊进制字符串等多种输入,适配复杂转换场景。

function toNumber(value) {
  // 短路逻辑:已为数字直接返回,避免后续转换
  if (typeof value == 'number') {
    return value;
  }
  // 符号特殊处理:Symbol 类型无法转为有效数字,直接返回 NaN
  if (isSymbol(value)) {
    return NAN;
  }
  // 对象转换逻辑:优先调用 valueOf 获取原始值,无则转为字符串
  if (isObject(value)) {
    var other = typeof value.valueOf == 'function' ? value.valueOf() : value;
    value = isObject(other) ? (other + '') : other;
  }
  // 非字符串处理:通过一元运算符快速转为数字,比 Number() 更高效
  if (typeof value != 'string') {
    return value === 0 ? value : +value;
  }
  // 字符串处理:去除首尾空格,适配特殊进制
  value = baseTrim(value);
  var isBinary = reIsBinary.test(value);
  // 进制判断:二进制/八进制单独解析,十六进制校验后解析
  return (isBinary || reIsOctal.test(value))
    ? freeParseInt(value.slice(2), isBinary ? 2 : 8)
    : (reIsBadHex.test(value) ? NAN : +value);
}
    

性能优化点

  • 分层转换策略:按“数字→符号→对象→非字符串→字符串”的顺序处理,优先快速转换高频场景。

  • 原生运算符复用:使用一元运算符 + 完成基础转换,比 Number() 构造函数调用开销更低。

  • 正则缓存优化:复用预定义正则(reIsBinaryreIsOctal),避免每次转换重复创建正则对象。

  • 进制精准解析:仅对特殊进制字符串使用 parseInt,普通字符串仍用一元运算符,平衡准确性与性能。

  • 对象值优先策略:优先调用 valueOf 获取原始值,减少字符串转换的开销,符合 JavaScript 类型转换规范。

// 输入
_.toNumber(3.2);          // 数字,直接返回
_.toNumber('3.2');         // 字符串转换为数字
_.toNumber('0b1010');      // 二进制字符串转换为数字

// 输出
// 3.2
// 3.2
// 10
    

适用场景:数值计算前的格式标准化、用户输入值转换、特殊进制解析等场景,支持复杂输入类型的精准转换。

1.7 _.toPlainObject

功能:将值转换为普通对象,包含自身及继承的属性,剥离原型链特性,返回纯粹对象。

function toPlainObject(value) {
  // 复用底层函数:copyObject 负责属性拷贝,keysIn 遍历所有属性(含继承)
  return copyObject(value, keysIn(value));
}
    

性能优化点

  • 极致逻辑复用:完全依赖 copyObjectkeysIn 底层函数,无额外代码,复用已有优化逻辑。

  • 高效属性遍历keysIn 批量获取所有属性(含继承),避免手动遍历原型链的低效操作。

  • 浅拷贝优先:仅拷贝属性引用,不进行深克隆,平衡转换效率与内存开销。

// 输入
function Foo() { this.b = 2; }
Foo.prototype.c = 3;
_.toPlainObject(new Foo());  // 转换为普通对象,包含继承属性

// 输出
// { 'b': 2, 'c': 3 }
    

适用场景:原型链属性提取、类实例转为普通对象、序列化前的格式处理等场景,避免原型链特性干扰。

1.8 _.toSafeInteger

功能:将值转换为安全整数(-2^53 + 1 至 2^53 - 1 之间),规避不安全整数的精度问题。

function toSafeInteger(value) {
  // 短路逻辑:空值处理,0 保持原样;非空值转为整数后限制在安全范围
  return value
    ? baseClamp(toInteger(value), -MAX_SAFE_INTEGER, MAX_SAFE_INTEGER)
    : (value === 0 ? value : 0);
}
    

性能优化点

  • 多层逻辑复用:复用 toInteger 完成整数化,复用 baseClamp 完成安全范围限制,代码简洁且高效。

  • 精准边界控制:通过 MAX_SAFE_INTEGER 固化安全范围,避免不安全整数导致的精度丢失。

  • 空值短路处理:区分 0 与其他空值,避免误转换,同时减少后续逻辑开销。

// 输入
_.toSafeInteger(3.2);          // 转换为安全整数
_.toSafeInteger(Infinity);      // 转换为最大安全整数
_.toSafeInteger('3.2');         // 字符串转换为安全整数

// 输出
// 3
// 9007199254740991
// 3
    

适用场景:需要确保整数精度的场景,如 ID 存储、数值计算、跨环境数据传输等,避免不安全整数的精度问题。

1.9 _.toString

功能:将值转换为字符串,特殊处理 nullundefined 等边界值,返回标准化字符串。

function toString(value) {
  // 边界值处理:null/undefined 转为空字符串,其他值复用 baseToString 底层逻辑
  return value == null ? '' : baseToString(value);
}
    

性能优化点

  • 边界值短路:优先处理 nullundefined,直接返回空字符串,避免后续复杂转换逻辑。

  • 底层逻辑复用:复用 baseToString 处理所有非边界值,统一转换规则,减少代码冗余。

  • 极简封装:仅做边界值拦截,无额外逻辑,函数执行栈浅,性能接近原生转换。

// 输入
_.toString(null);           // null 转换为空字符串
_.toString(-0);             // -0 转换为 '-0'
_.toString([1, 2, 3]);      // 数组转换为字符串

// 输出
// ''
// '-0'
// '1,2,3'
    

适用场景:字符串拼接前的格式标准化、日志输出、数据序列化等场景,确保边界值转换的一致性。

2. 类型判断函数

2.1 _.isArray

功能:检查值是否为数组,是最基础的类型判断函数之一。

// 原生方法复用:直接指向 Array.isArray 原生方法,性能最优且兼容性好
var isArray = Array.isArray;

性能优化点

  • 原生方法直引:完全复用浏览器/引擎原生 Array.isArray 方法,原生方法由底层编译实现,执行效率远超自定义判断逻辑。

  • 零冗余封装:无任何额外代码,直接暴露原生方法,函数调用开销最低。

// 输入
_.isArray([1, 2, 3]);       // 数组返回 true
_.isArray({ 'a': 1 });      // 对象返回 false

// 输出
// true
// false

适用场景:所有需要判断数组类型的场景,如参数校验、数据格式判断、批量处理前的类型筛选等。

2.2 _.isBoolean

功能:检查值是否为布尔值(含布尔对象),兼顾基础类型和包装对象。

function isBoolean(value) {
  // 短路优先:直接比较基础布尔值,高频场景快速返回
  return value === true || value === false ||
    // 包装对象处理:类对象且类型标签为 boolTag,适配 new Boolean() 场景
    (isObjectLike(value) && baseGetTag(value) == boolTag);
}

性能优化点

  • 基础值短路判断:优先与 truefalse 直接全等比较,覆盖 99% 高频场景,快速返回结果。

  • 包装对象精准适配:仅对类对象执行标签检查,避免对基础类型做冗余标签判断,平衡准确性与性能。

  • 标签检查复用:复用 baseGetTag 底层函数,统一类型标签获取逻辑,减少代码冗余。

// 输入
_.isBoolean(false);         // 布尔值返回 true
_.isBoolean(new Boolean(false));  // 布尔对象返回 true
_.isBoolean('false');       // 字符串返回 false

// 输出
// true
// true
// false
    

适用场景:布尔值校验场景,如条件判断参数、配置项值检查、表单输入值类型判断等,兼顾基础类型与包装对象。

2.3 _.isFunction

功能:检查值是否为函数,支持普通函数、生成器函数、异步函数、代理函数等多种函数类型。

function isFunction(value) {
  // 短路排除:非对象直接返回 false,减少后续标签检查开销
  if (!isObject(value)) {
    return false;
  }
  // 类型标签判断:适配多种函数类型,覆盖特殊函数场景
  var tag = baseGetTag(value);
  return tag == funcTag || tag == genTag || tag == asyncTag || tag == proxyTag;
}
    

性能优化点

  • 非对象快速排除:优先判断非对象类型,直接返回 false,覆盖大部分非函数场景,缩短执行路径。

  • 多标签精准匹配:通过类型标签区分多种函数类型,避免 typeof 无法识别特殊函数的局限性,同时保证判断准确性。

  • 底层标签复用:复用 baseGetTag 函数,统一类型标签获取逻辑,受益于底层缓存优化。

// 输入
_.isFunction(_);            // 函数返回 true
_.isFunction(/abc/);        // 正则表达式返回 false

// 输出
// true
// false

适用场景:函数参数校验、回调函数判断、动态执行前的类型检查等场景,支持所有函数类型的精准判断。

2.4 _.isNumber

功能:检查值是否为数字(含数字对象),兼顾基础类型、包装对象和特殊数字(NaN、Infinity)。

function isNumber(value) {
  // 基础类型优先:typeof 快速判断基础数字类型,高频场景高效返回
  return typeof value == 'number' ||
    // 包装对象处理:类对象且类型标签为 numberTag,适配 new Number() 场景
    (isObjectLike(value) && baseGetTag(value) == numberTag);
}

性能优化点

  • typeof 快速判断typeof value == 'number' 是基础数字类型判断的最快方式,覆盖大部分高频场景。

  • 包装对象延迟判断:仅对类对象执行标签检查,避免对基础类型做冗余操作,平衡性能与准确性。

  • 兼容特殊数字typeof NaNtypeof Infinity 均为 'number',自然兼容这类特殊数字,无需额外判断。

// 输入
_.isNumber(3);              // 数字返回 true
_.isNumber(new Number(3));  // 数字对象返回 true
_.isNumber('3');            // 字符串返回 false

// 输出
// true
// true
// false
    

适用场景:数字类型校验场景,如数值计算前的类型检查、表单输入值筛选、配置项数值验证等。

2.5 _.isString

功能:检查值是否为字符串(含字符串对象),排除数组等易混淆类型。

function isString(value) {
  // 基础类型优先:typeof 快速判断基础字符串类型
  return typeof value == 'string' ||
    // 包装对象处理:排除数组(避免误判),类对象且标签为 stringTag
    (!isArray(value) && isObjectLike(value) && baseGetTag(value) == stringTag);
}
    

性能优化点

  • 基础类型短路typeof value == 'string' 快速判断基础字符串,覆盖高频场景,执行效率高。

  • 数组精准排除:优先排除数组(数组 typeof 为 'object',易与字符串对象混淆),避免后续标签检查的冗余开销。

  • 包装对象精准匹配:仅对非数组类对象执行标签检查,逻辑严谨且性能可控。

// 输入
_.isString('abc');           // 字符串返回 true
_.isString(new String('abc'));  // 字符串对象返回 true
_.isString([1, 2, 3]);       // 数组返回 false

// 输出
// true
// true
// false
    

适用场景:字符串类型校验场景,如字符串处理前的格式判断、用户输入值类型筛选、文本序列化前检查等。

2.6 _.isObject

功能:检查值是否为对象(含函数,符合 JavaScript 语言规范),排除 null 等伪对象。

function isObject(value) {
  var type = typeof value;
  // 核心判断:非 null 且类型为 'object' 或 'function',符合 JS 对象定义
  return value != null && (type == 'object' || type == 'function');
}
    

性能优化点

  • 极简逻辑设计:仅通过 typeofnull 排除实现核心判断,无冗余代码,执行路径最短。

  • 符合语言规范:将函数归为对象类型,遵循 JavaScript 语言设计,避免额外类型转换开销。

  • 快速 null 排除value != null 同时排除 nullundefined,一步到位,效率高于分开判断。

// 输入
_.isObject({});             // 对象返回 true
_.isObject([]);             // 数组返回 true
_.isObject(null);           // null 返回 false

// 输出
// true
// true
// false
    

适用场景:对象类型的基础校验场景,如参数是否为引用类型、数据是否可遍历、属性操作前的类型判断等。

2.7 _.isObjectLike

功能:检查值是否为类对象(有属性且可遍历,排除函数),精准区分类对象与函数。

function isObjectLike(value) {
  // 核心判断:非 null 且 typeof 为 'object',排除函数和基础类型
  return value != null && typeof value == 'object';
}
    

性能优化点

  • 极简高效判断:仅通过两个条件实现核心逻辑,无额外函数调用,执行效率极高。

  • 精准边界区分:排除函数(typeof 为 'function'),与 isObject 形成互补,满足细分场景需求。

  • 复用性强:作为底层工具函数,被多个类型判断函数依赖,一次优化多处受益。

// 输入
_.isObjectLike({});          // 对象返回 true
_.isObjectLike([]);          // 数组返回 true
_.isObjectLike(function() {});  // 函数返回 false

// 输出
// true
// true
// false
    

适用场景:类对象的精准判断场景,如属性遍历前检查、对象拷贝前类型筛选、非函数引用类型校验等。

2.8 _.isPlainObject

功能:检查值是否为普通对象(由 Object 构造函数创建或原型为 null),排除数组、正则、类实例等特殊对象。

function isPlainObject(value) {
  // 第一层过滤:非类对象或标签非 objectTag,直接返回 false
  if (!isObjectLike(value) || baseGetTag(value) != objectTag) {
    return false;
  }
  // 原型链检查:原型为 null 是普通对象(如 Object.create(null))
  var proto = getPrototype(value);
  if (proto === null) {
    return true;
  }
  // 构造函数检查:确保构造函数是 Object,排除自定义类实例
  var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor;
  return typeof Ctor == 'function' && Ctor instanceof Ctor &&
    funcToString.call(Ctor) == objectCtorString;
}
    

性能优化点

  • 分层过滤策略:按“类对象→类型标签→原型链→构造函数”的顺序过滤,优先排除非目标类型,缩短执行路径。例如非类对象或类型标签非objectTag的场景,直接返回false,无需进入后续复杂的原型链检查。

  • 原型链高效获取:复用getPrototype底层函数,统一原型获取逻辑,避免手动操作__proto__的兼容性问题,同时受益于底层函数的性能优化。

  • 构造函数精准校验:通过funcToString.call(Ctor)获取构造函数字符串,与objectCtorStringObject构造函数字符串)比对,避免自定义类实例误判,兼顾准确性与效率,比直接比较构造函数引用更严谨。

  • 原生方法复用:借助hasOwnProperty原生方法检查原型上的constructor属性,避免原型链遍历开销,执行效率优于手动遍历原型。

// 输入
_.isPlainObject({});                // 普通对象返回 true
_.isPlainObject(Object.create(null)); // 原型为 null 的对象返回 true
_.isPlainObject(new Foo());         // 自定义类实例返回 false
_.isPlainObject([]);                // 数组返回 false

// 输出
// true
// true
// false
// false
    

适用场景:普通对象的精准校验场景,如配置项解析、对象序列化前筛选、避免原型链污染的类型判断等,确保操作仅针对纯粹的普通对象,排除特殊对象干扰。

2.9 _.isNull

功能:检查值是否严格为null,是极简且高频的边界值判断函数,排除undefined等相似值。

function isNull(value) {
  // 严格全等判断:仅当值与 null 完全一致时返回 true,逻辑极简
  return value === null;
}
    

性能优化点

  • 极致精简逻辑:仅依赖===严格全等运算符,无任何额外函数调用、条件分支,执行路径最短,是性能最优的判断逻辑之一。

  • 无冗余开销:无需类型转换、标签检查等操作,直接进行值比对,函数调用栈极浅,执行效率接近原生运算。

// 输入
_.isNull(null);            // 严格为 null 返回 true
_.isNull(undefined);       // undefined 返回 false
_.isNull(0);               // 数字 0 返回 false

// 输出
// true
// false
// false
    

适用场景null值的精准校验场景,如参数默认值处理、边界值拦截、数据初始化状态判断等,是基础且高频的类型判断工具。

2.10 _.isUndefined

功能:检查值是否为undefined,包括变量未定义、属性不存在等场景,精准区分undefinednull

function isUndefined(value) {
  // 严格判断:利用 typeof 特性,undefined 类型唯一标识为 'undefined'
  return typeof value === 'undefined';
}
    

性能优化点

  • 原生运算符高效判断typeof value === 'undefined'是判断undefined的标准方式,可覆盖变量未声明、值为undefined两种场景,且执行效率极高。

  • 无额外逻辑开销:无需依赖其他底层函数,无条件分支冗余,函数执行速度接近原生运算,适配高频调用场景。

// 输入
_.isUndefined(undefined);  // 值为 undefined 返回 true
_.isUndefined(null);       // null 返回 false
_.isUndefined(window.foo); // 未声明变量返回 true

// 输出
// true
// false
// true
    

适用场景undefined值的校验场景,如函数参数是否传递、对象属性是否存在、变量初始化状态判断等,是基础边界值处理的核心函数。

2.11 _.isNaN

功能:检查值是否为NaN,精准区分NaN与其他数字、非数字类型,规避原生isNaN的误判问题。

function isNaN(value) {
  // 核心逻辑:NaN 唯一特性是与自身不相等,结合数字类型判断避免误判
  return isNumber(value) && value !== value;
}
    

性能优化点

  • 精准判断逻辑:利用NaN !== NaN的特性,结合isNumber过滤非数字类型,避免原生isNaN将非数字(如字符串)误判为NaN的问题,兼顾准确性与性能。

  • 底层逻辑复用:复用isNumber函数完成数字类型校验,无需重复编写类型判断逻辑,减少代码冗余,同时受益于isNumber的优化。

  • 短路逻辑优化isNumber(value)优先执行,非数字类型直接返回false,避免后续value !== value的判断,缩短执行路径。

// 输入
_.isNaN(NaN);              // NaN 返回 true
_.isNaN(3);                // 数字 3 返回 false
_.isNaN('3');              // 字符串 '3' 返回 false
_.isNaN(Infinity);         // 无限值返回 false

// 输出
// true
// false
// false
// false
    

适用场景NaN值的精准校验场景,如数值计算结果检查、数据格式验证、异常值拦截等,避免NaN导致的逻辑异常。

2.12 _.isFinite

功能:检查值是否为有限数字,排除NaNInfinity、非数字类型,精准判断有效有限数值。

function isFinite(value) {
  // 分层判断:先校验数字类型,再排除 NaN 和无限值,逻辑严谨
  return isNumber(value) && value !== Infinity && value !== -Infinity && value === value;
}

性能优化点

  • 短路分层判断:优先通过isNumber过滤非数字类型,再依次排除无限值、NaN,高频非目标场景快速返回,缩短执行路径。

  • 原生值直接比对:与Infinity-Infinity直接全等比对,无额外函数调用,执行效率高,同时精准规避边界值。

  • 逻辑复用优化:依赖isNumber完成基础类型校验,保持与其他数字相关判断函数的逻辑一致性,减少维护成本。

// 输入
_.isFinite(3.2);           // 有限数字返回 true
_.isFinite(Infinity);      // 无限值返回 false
_.isFinite(NaN);           // NaN 返回 false
_.isFinite('3.2');         // 字符串返回 false

// 输出
// true
// false
// false
// false
    

适用场景:有限数字的校验场景,如数值计算、范围限制、数据格式化前检查等,确保操作仅针对有效有限数值。

三、核心总结与实践启示

1. 核心设计理念

Lodash 类型判断与转换函数的优化核心,是**“精准性与性能的平衡”“逻辑复用与分层优化”**。通过原生方法优先、短路逻辑、缓存优化等手段降低执行开销,同时借助类型标签、分层判断等策略保证跨环境一致性与精准性;底层工具函数的提炼的复用,实现了“一次优化、全域受益”,既减少代码冗余,又降低维护成本。

2. 实践优化启示

  • 优先复用原生能力:原生方法(如Array.isArraytypeof)由底层编译实现,执行效率远超自定义逻辑,应优先复用,仅在原生方法有局限时补充自定义逻辑。

  • 分层处理高频场景:设计函数时按“高频简单场景→低频复杂场景”分层判断,通过短路逻辑快速返回结果,缩短执行路径,适配高频调用需求。

  • 重视边界值处理nullundefinedNaNInfinity等边界值是异常高发点,提前拦截处理既能避免报错,又能减少冗余计算,提升函数稳定性。

  • 合理复用底层逻辑:将通用逻辑封装为底层工具函数,上层函数通过参数透传、标记位控制实现功能扩展,减少重复编码,提升代码可维护性。

3. 适用场景延伸

这些优化思路不仅适用于工具库开发,也可迁移至业务代码优化中。例如:表单校验场景可复用“分层判断+边界值拦截”思路提升校验效率;大数据处理场景可借鉴缓存优化、内存预分配策略减少开销;跨环境应用开发可参考环境适配、类型标签判断策略保证一致性。

总之,Lodash 的设计思路为 JavaScript 类型操作提供了高效范式,核心在于通过精细化的逻辑设计,在保证功能完整性的前提下,将性能损耗降至最低。

了解你的 AI 编码伙伴:Coding Agent核心机制解析

导读

AI 编码工具正在从"智能补全"演进为能自主完成复杂任务的 Coding Agent。本文基于开源项目源码研究与实践经验,系统性地拆解 Coding Agent 的工作原理。旨在帮助开发者在了解Coding Agent后,与AI伙伴更好的协作配合,更高效的提问和拿到有效结果。

01 背景

AI 编码工具的发展速度快得有点"离谱"。从开始使用 GitHub Copilot 的代码补全,到使用Claude Code、Cursor、Comate IDE等完成复杂编程任务,AI 不再只是个「智能补全工具」,它能读懂你的代码库、执行终端命令、甚至帮你调试问题,成为你的“编码伙伴”。

我自己在团队里推 AI 编码工具的时候,发现一个很有意思的现象:大家都在用,但很少有人真正理解它是怎么工作的。有人觉得它"很神奇",有人吐槽它"经常乱来",还有人担心"会不会把代码搞乱"。这些困惑的背后,其实都指向同一个问题:我们对这个"伙伴"还不够了解。

就像你不会无脑信任一个新来的同事一样,要和 AI 编码伙伴配合好,你得知道它的工作方式、能力边界、以及怎么"沟通"才更有效。

在经过多次的实践尝试后,我尝试探索它的底层原理,并写下了这篇文章记录,主要围绕了这些内容展开:

  • Coding Agent 的核心工作机制,包括身份定义、工具调用、环境感知等基础组成。

  • 从零实现一个最小化 Coding Agent 的完整过程,以建立对 Agent 工作流程的直观理解。

  • 上下文管理、成本控制、冲突管控等生产环境中的关键技术问题及其解决方案。

  • Rule、MCP、Skill 等能力扩展机制的原理与应用场景。

在了解原理后,我和伙伴的协作更佳顺畅,让伙伴更清晰的了解我的意图,我拿到有效的回答。

02 概念

2.1 从Workflow到Agent

取一个实际的例子:休假申请。

如果我们的需求非常简单:

一键申请明天的休假。

在这里插入图片描述

这个需求可以被简化为一个固定的工作流

  1. 打开网页。

  2. 填写起始时间。

  3. 填写结束时间。

  4. 填写休假原因。

  5. 提交表单。

全过程没有任何模糊的输入,使用程序化即可完成,是最原始的工作流形态。

如果需求再模糊一些:

申请后天开始3天休假。

这个需求的特点是没有明确的起始和截止时间,需要从语义上分析出来

  1. 起始时间:后天。

  2. 休假时长:3天。

  3. 转换日期:10.14 - 10.16。

  4. 执行申请:提交表单。

这是一个工作流中使用大模型提取部分参数的典型案例,是模型与工作流的结合。

如果需求更加模糊:

国庆后休假连上下个周末。

这样的需求几乎没有任何直接确定日期的信息,同时由于年份、休假安排等动态因素,大模型不具备直接提取参数的能力。将它进一步分解,需要一个动态决策、逐步分析的过程:

  1. 知道当前年份。

  2. 知道对应年份的国庆休假和调休安排。

  3. 知道国庆后第一天是星期几。

  4. 国庆后第一天到下个周末设为休假日期。

  5. 额外补充调休的日期。

  6. 填写并提交表单。

可以看出来,其中1-5步都是用来最终确定休假日期的,且需要外部信息输入,单独的大模型无法直接完成工作。这是一个典型的Agent流程,通过大模型的智能工具访问外部信息结合实现用户需求。

2.2 什么是Agent

Agent是以大模型为核心,为满足用户的需求,使用一个或多个工具,自动进行多轮模型推理,最终得到结果的工作机制。

2.3 什么是Coding Agent

在Agent的基本定义的基础上,通过提示词、上下文、工具等元素强化“编码”这一目的,所制作的特化的Agent即为Coding Agent。

Coding Agent的最大特征是在工具的选取上,模拟工程师进行代码编写的环境,提供一套完整的编码能力,包括:

  • 阅读和查询代码:

    • 读取文件,对应 cat 命令。

    • 查看目录结构,对应 tree 命令。

    • 通配符查找,对应 ls命令(如 **/*.test.tssrc/components/**/use*.ts)。

    • 正则查找,对应grep 命令(如function print\(.+\) 可以找函数定义)。

    • LSP(Language Server Protocol),用于提供查找定义、查找引用、检查代码错误等能力。

  • 编写或修改代码:

    • 写入文件。

    • 局部编辑文件。

    • 删除文件。

  • 执行或交互命令:

    • 执行终端命令。

    • 查看终端命令stdout输出。

    • 向终端命令stdin 输入内容。

除此之外,通常Coding Agent还具备一些强化效果而设定的工具,通常表现为与Agent自身或外部环境进行交互,例如经常能见到的TODO、MCP、Subagent等等。

03 内部组成

3.1 上下文结构

3.2 身份定义

一个Agent首先会将模型定义成一个具体的身份(红色与橙色部分),例如在社区里常见的这样的说法:

You are a Senior Front-End Developer and an Expert in React, Nexts, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks.

在身份的基础上,再附加工作的目标和步骤拆解,比如Cline有类似这样的内容:

github.com/cline/cline…

  1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.

  2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go.

  3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis within <thinking></thinking> tags. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Then, think about which of the provided tools is the most relevant tool to accomplish the user's task. Next, go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, close the thinking tag and proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params). DO NOT ask for more information on optional parameters if it is not provided.

  4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. You may also provide a CLI command to showcase the result of your task; this can be particularly useful for web development tasks, where you can run e.g. open index.html to show the website you've built.

  5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.

不用特别仔细地看每一句话,多数Coding Agent会提供一些详实的行动准则、目标要求,这部分称为“Guideline”。

有一些Coding Agent可以在多种模式(或者说智能体)之间进行切换,例如Cursor有Edit、Ask、Plan等,RooCode有Architect、Orchestrator等,有些产品还支持自定义模式。

Cursor

RooCode

选择不同的模式时,实际上会产生不同的目标要求、行为准则,即不同的Guideline环节。因此系统提示词中的身份部分,通常会分成不变的Base Prompt(红色)和可变的Agent Prompt(橙色)两个部分来管理,实际开始任务时再拼装起来。

3.3 工具调用

Agent的另一个最重要的组成部分是工具,没有工具就无法称之为一个Agent。让Agent能够使用工具,就必须要有2部分信息:

  1. 有哪些工具可以用,分别是什么作用。

  2. 如何指定使用一个工具。

对于第一点(哪些工具),在Agent开发过程中,一般视一个工具为一个函数,即由以下几部分组成一个工具的定义:

  1. 名称。

  2. 参数结构。

  3. 输出结构。

实际在调用模型时,“输了结构”往往是不需要提供给模型的,但在Agent的实现上,它依然会被预先定义好。而“名称”和“参数结构”会统一组合成一个结构化的定义,通常所有工具都只接收1个参数(对象类型),用JSON Schema表示参数结构。

一个典型的工具定义:

{
  "name": "read",
  "description": "Read the contents of a file. Optionally specify line range to read only a portion of the file.",
  "parameters": {
    "type": "object",
    "properties": {
      "path": {
        "type": "string",
        "description": "The file path to read from"
      },
      "lineStart": {
        "type": "integer",
        "description": "The starting line number (1-indexed). If not specified, reads from the beginning of the file."
      },
      "lineEnd": {
        "type": "integer",
        "description": "The ending line number (1-indexed). If not specified, reads to the end of the file."
      }
    },
    "required": ["path"]
  }
}

可以简单地把这个工具理解成对应的TypeScript代码:

interface ReadToolParameter {
path: string;
lineStart?: number;
lineEnd?: number;
}

async function read(parameters: ReadToolParameter) {
// 工具实现
}

对于第2点(指定使用工具),则是要让大模型知道工具调用的具体格式。这在业界通常有2种做法。

第1种以Claud Code、Codex等为典型,使用大模型提供的Function Calling格式调用,分为以下几步:

  1. 在调用大模型时,通过一个tools 字段传递所有的工具定义。

  2. 模型会返回一个消息中包含tool_calls 字段,里面每一个对象是一个工具的调用,使用id 作为唯一标识。

  3. 工具产生的结果,以一条role: 'tool' 的消息返回,其中tool_call_id 与调用的id对应,content 是工具的结果(这里各家模型厂商的实现略有不同,其中Anthropic要求role: user,但content字段中传递toolResult,其结构是[{type: 'tool_result',tool_use_id: toolBlock.id, content: toolResultContent}],tool_use_id与调用的id对应)。

第2种方式是以Cline、RooCode为典型,使用一种自定义的文本格式来表示工具调用,通常选择XML的结构,例如对于Cline,读取一个文件的结构如下:

<read_file>
<path>src/index.ts</path>
</read_file>

只要在模型返回的消息中出现这样的结构,就会被解析为一个工具调用,得到的结果以普通的role: 'user' 的消息返回,包括实际内容和一些提示相关的信息。

Content of src/index.ts:

Note:

- this file is truncated to line 1000, file has a total 2333 lines.
- use read_file with line_start and line_end parameters to read more content.
- use seach_in_files tool searching for specific patterns in this file.

...

3.4 环境感知

Coding Agent之所以可以在一个代码库上执行任务,除了通过工具来遍历、检索代码外,另一个因素是Agent实现会在调用模型时主动地提供一部分与项目有关的信息。

其中对Coding Agent工作最有用的信息之一是代码库的结构,即一个表达出目录、文件结构的树型区块。这部分信息通常会符合以下特征:

  1. 尽可能地保留目录的层级结构,使用换行、缩进的形式表达。

  2. 遵循 .gitignore 等项目配置,被忽略的文件不会表现在树结构中。

  3. 当内容过多时,有一定的裁剪的策略,但同时尽可能多地保留信息。

以Cursor为例,这部分的内容大致如下:

<project_layout>
Below is a snapshot of the current workspace's file structure at the start of the conversation. This snapshot will NOT update during the conversation. It skips over .gitignore patterns.

codex-cursor/
  - AGENTS.md
  - CHANGELOG.md
  - cliff.toml
  - codex-cli/
    - bin/
      - codex.js
      - rg
    - Dockerfile
    - package-lock.json
    - package.json
    - scripts/
      - build_container.sh
      - build_npm_package.py
      - init_firewall.sh
      - [+4 files (1 *.js, 1 *.md, 1 *.py, ...) & 0 dirs]
  - codex-rs/
    - ansi-escape/
      - Cargo.toml
      - README.md
      - src/
        - lib.rs
</project_layout>

当内容数量超过阈值时,会采用广度优先的保留策略(即尽可能地保留上层目录结构),同时对于被隐藏的文件或子目录,会形如 [+4 files (1 *.js, 1 *.md, 1 *.py, ...) & 0 dirs]这样保留一个不同文件后缀的数量信息。

除了目录结构外,还有一系列默认需要模型感知的信息,在一个Coding Agent的工作环境中,它通常分为2大类,各自又有一系列的细项:

  1. 系统信息:

    1. 操作系统(Windows、macOS、Linux,具体版本)。

    2. 命令行语言(Shell、Powershell、ZSH)。

    3. 常见的终端命令是否已经安装( python3nodejqawk等,包含具体版本)。

    4. 代码库目录全路径。

  2. 为Agent扩展能力的信息:

    1. Rule(自动激活的部分)。

    2. Skill(摘要描述部分)。

    3. MCP(需要的Server和Tool列表)。

    4. Memory(通常是全量)。

需要注意的是,环境信息这部分,一般不出现在系统提示词中,而是和用户提问的消息放置在一起。

3.5 简单实现

在身份定义、工具调用、环境感知这3部分最基础的Agent组成都达成后,简单地使用大模型的API,进行自动化的工具调用解析、执行、发送新一轮模型调用,可以非常简单地实现一个最小化的Coding Agent。

可以尝试用以下的提示词,使用任意现有的Coding Agent产品,为你编写一个实现,并自己调试一下,感受Coding Agent的最基础的逻辑:

我希望基于大模型实现一个Coding Agent,以下是我的具体要求:

1. 使用Claude作为模型服务商,使用环境变量管理我的API Key。
2. 默认使用Claude Sonnet 4.5模型。
3. 使用Anthropic's Client SDK调用模型。
4. 不需要支持流式输出。
5. 使用TypeScript编写。

以下是Agent提供的工具:

1. read({path: string}):读取一个文件的内容
2. list({directory: string}):列出一个目录下的一层内容,其中目录以`/`结尾
3. write({path: string, content: string}):向文件写入内容
4. edit({path: string, search: string, replace: string}):提供文件中的一块内容

以下是交互要求:

1. 通过NodeJS CLI调用,支持`query``model`两个参数,可以使用`yargs`解析参数。
2. 在System消息中,简短地说明Coding Agent的角色定义、目标和行为准则等。
3. 在第一条User消息中,向模型提供当前的操作系统、Shell语言、当前目录绝对路径信息,同时包含跟随`query`参数的内容,组织成一条模型易于理解的消息。
4. 对每一次模型的工具调用,在控制台打印工具名称和标识性参数,其中标识性参数为`path``directory`,根据工具不同来决定。
5. 如果模型未调用工具,则将文本打印到控制台。

请在当前目录下建立一个`package.json`,并开始实现全部的功能。

04 优质上下文工程

4.1 成本控制

大模型是一个非常昂贵的工具,以Claude为例,它的官方API价格如下:

我们可以观察到一些特征:

  1. 输出的价格是输入的5倍(但实际考虑到输出与输出的数量比例,输出的价格根本不值一提)。

  2. 缓存输入(Cache Writes)比正常输入(Base Input)更贵一些,约1.25倍。

  3. 缓存命中(Cache Hits)的价格比正常输入(Base Input)要便宜很多,为1/10的价格。

这就意味着,一个良好使用缓存的Agent实现,其成本会比不用缓存降低8-10倍。因此所有的Coding Agent一定会细致地梳理内容结构,最大化利用缓存

在大模型的API中,缓存通常以“块”为单位控制,例如:

  1. 系统提示词中不变的部分。

  2. 系统提示词中可变部分。

  3. 工具定义。

  4. 每一条消息,单条消息也可以拆成多个块。

继续观察Claude对于缓存控制的文档:

可以看到,在大模型API中各种参数一但有所变动,缓存都会大量失效(至少消息缓存全部失效,大概率系统缓成失效),这就会造成成本的极大提升。因此,在Coding Agent实现中,都会从一开始就确定所有参数,整个任务不做任何变更。一些很经典的实例:

  1. 一次任务不会一部分消息开思考模式,一部分不开,因为思考参数会让全部的消息缓存失效。

  2. 切换不同模式(如Edit、Ask、Plan)时,虽然能使用的工具不同,但只是在消息中增加说明,而不会真的将 tools 字段改变。

另外,Coding Agent会尽可能保持历史消息内容完全不变,以最大化地缓存消息。例如对于一个进行了10轮模型调用的任务,理论上第10次调用中,前9轮的消息内容都会命中缓存。但如果此时擅自去修改了第1轮的工具调用结果(例如试图删除读取的文件内容),看似可能消息的长度减少了,但实际因为缓存被破坏,造成的是10倍的成本提升。

总而言之,缓存是一个至关重要的因素,Coding Agent的策略优化通常以确保缓存有效为前提,仅在非常必要的情况下破坏缓存

4.2 空间管理

Coding Agent因为会自动地与大模型进行多轮的交互,随着不断地读入文件、终端命令输出等信息,上下文的长度会变得非常的大,而大模型通常只具备128K左右的总长度,因此如何将大量内容“适配”到有限的长度中,是一个巨大的挑战。

控制上下文长度的第一种方式是“裁剪”,即在整个上下文中,将没用的信息删除掉。试想如下的场景:

  1. 模型读取了一个文件的内容。

  2. 模型将文件中 foo 这一行改成了 bar

  3. 模型又将文件中 eat 这一行改成了 drink

假设我们对模型每一次修改文件,都返回最新的文件内容,如果这个文件有1000行,那么1次读取、2次修改,就会产生3000行的空间占用

一种优化方式就是,在这种连续的读-改的场景下,只保留最后一条消息中有全文内容,即上述3次模型调用后,出现在上下文中的内容实际是这样的:

<!-- Assistant -->
read(file)

<!-- User -->
[This file has been updated later, outdated contents are purged from here]

<!-- Assistant -->
edit(file, foo -> bar)

<!-- User -->
The edit has been applied successfully.

--- a/file
+++ b/file
@@ -23,1 +23,1 @@
-foo
+bar

[This file has been updated later, outdated contents are purged from here]

<!-- Assistant -->
edit(file, eat -> drink)

<!-- User -->
The edit has been applied successfully, the new file content is as below:

{content of file}

可以看到,通过将连续对同一文件的修改进行裁剪,可以只保留最新的内容,同时又使用unidiff 之类的形式保留中间编辑的差异信息,最大限度地降低空间占用,又能保留模型的推理逻辑。

但裁剪不能使用在非连续的消息中,随意地使用剪裁逻辑,很有可能破坏消息缓存结构,进而使模型调用的输入无法通过缓存处理,几倍地增加模型的调用成本。

即便裁剪有一定效果,但随着更多的内容进入到上下文中,始终会有将上下文占满的时候,此时模型将完全无法进行推理。为了避免这种情况出现,Coding Agent通常会使用“压缩”这一技术,即将前文通过模型摘要成少量的文字,同时又保留比较关键的推理链路。

通常,压缩在上下文即将用完的时候触发,如已经使用了90%的上下文则启动压缩,压缩的目标是将90%的内容变为10%的长度,即省出80%的空间供后续推理。

压缩本身是一个模型的任务,即将所有的上下文(可以选择性地保留最新的1-2对消息)交给模型,同时附带一个压缩的要求,让模型完成工作。这个压缩的要求的质量将决定压缩的最终结果,一个比较典型的实现是Claude Code的“八段式摘要”法:

const COMPRESSION_SECTIONS = [
  "1. Primary Request and Intent",    // 主要请求和意图
  "2. Key Technical Concepts",        // 关键技术概念
  "3. Files and Code Sections",       // 文件和代码段
  "4. Errors and fixes",              // 错误和修复
  "5. Problem Solving",               // 问题解决
  "6. All user messages",             // 所有用户消息
  "7. Pending Tasks",                 // 待处理任务
  "8. Current Work"                   // 当前工作
];

通过将信息压缩成8部分内容,能够最大限度地保留工作目标、进度、待办的内容。

4.3 独立上下文

在实际的应用中,其实大概率是不需要128K上下文用满的,但真实表现又往往是上下文不够用。这中间存在的差异,在于2类情况:

  1. 为了满足一个任务,需要收集大量的信息,但收集到正常信息的过程中,会引入无效的、错误的内容,占用上下文。

  2. 一个任务足够复杂,分解为多个小任务后各自占用部分上下文,但加起来以后会超出限制。

试想一下,对于一个这样的任务:

修改我的Webpack配置,调整文件拆分逻辑,让最终产出的各个JS文件大小尽可能平均。

但是很“不幸”地,这个项目中存在6个 webpack.config.ts文件,且最终splitChunks 配置在一个名为 optimization.ts 的文件中管理,那么对于Coding Agent来说,这个任务中就可能存在大量无意义的上下文占用:

  1. 读取了6个 webpack.config.ts ,一共2000行的配置内容,但没有任何splitChunks 的配置,包含了大量 import 其它模块。

  2. 又读取了10个被 import 的模块,最终找到了 optimization.ts 文件。

  3. 经过修改后,执行了一次 npm run build 来分析产出,发现JS的体积不够平均。

  4. 又修改 optimization.ts ,再次编译,再看产出。

  5. 循环往复了8次,终于在最后一次实现了合理的splitChunks 配置。

这里面的“6个 webpack.config.ts ”、“10个其它模块”、“8次优化和编译”都是对任务最终目标并不有效的内容,如果它们占用150K的上下文,这个任务就不得不在中途进行1-2次的压缩,才能够最终完成。

为了解决这个问题,当前多数的Coding Agent都会有一个称为“Subagent”的概念。就好比一个进程如果只能使用4GB的内存,而要做完一件事需要16GB,最好的办法就是开5个进程。Subagent是一种类似子进程的,在独立的上下文空间中运行,与主任务仅进行必要信息交换的工作机制

再回到上面的案例,在Subagent的加持下,我们可以将它变成以下的过程:

  1. 启动一个Subagent,给定目标“找到Webpack文件拆分的代码”。

    1. 读取6个 webpack.config.ts

    2. 读取10个被 import 的模块。

    3. 确定目标文件 optimization.ts

    4. 返回总结:在 optimization.ts 中有文件拆分的配置,当前配置为……。

  2. 启动一个Subagent,给定目标“修改 optimization.ts ,使产出的JS体积平均,执行 npm run build 并返回不平均的文件“。

    1. 修改 optimization.ts

    2. 执行 npm run build,得到命令输出。

    3. 分析输出,找到特别大的JS文件,返回总结:配置已经修改,当前 xxx.js 体积为平均值的3倍(723KB),其它文件体积正常。

  3. 启动一个Subagent,给宝目标“分析 dist/stats.json,检查 xxx.js 中的模块,修改 optimization.ts 使其分为3个250KB左右的文件,执行 npm run build并返回不平均的文件”。

    1. ……

    2. ……

  4. 继续启动6次Subagent,直到结果满意。

不难看出来,这种模式下主体的Coding Agent实际是在"指挥"Subagent做事,自身的上下文占用是非常有限的。而Subagent仅****“专注”于一个小目标****,也不需要太多的上下文,最终通过这类不断开辟新上下文空间的方式,将一个复杂的任务完成。

4.4 注意力优化

如果你经常使用Coding Agent,或在业界早期有过比较多的使用经验,你可能会发现这种情况:Coding Agent在完成一个任务到一半时,忘了自己要做什么,草草地结束了任务,或偏离了既定目标产生很多随机的行为。

会发生这样的情况,有一定可能是裁剪、压缩等策略使有效的上下文信息丢失了,但更多是因为简单的一个用户需求被大量的代码内容、命令输出等推理过程所掩盖,权重弱化到已经不被大模型“注意到”,因此最初的目标也就完全丢失了。

Coding Agent一个很重要的任务,就是在长时间运作的同时随时调整大模型的注意力,使其始终聚焦在最终目标、关注当前最需要做的工作,不要偏离预先设定的路线。为了实现这一效果,Coding Agent产品提出了2个常见的概念。

第一称为TODO,在很多的产品中,你会看到Agent先将任务分解成几个步骤,转为一个待办列表。这个列表在界面上始终处于固定的位置,随着任务的推进会逐步标记为完成。这个TODO实际上并不是给用户看的,而是给模型看的

在实际的实现中,每一次调用模型时,在最后一条消息(一般就是工具调用的结果)上,除了原始消息内容外,会增加一个称为“Reminder”的区域。这个区域因为始终出现在所有消息的最后,通常来说在模型的注意力中优先级更高,而且绝对不会受其它因素影响而消失

Reminder中可以放置任意内容,比较经典的有:

  1. TODO及进度。用于模型时刻理解目标、进展、待办。
<reminders>
- Planned todos:
  - [x] Explore for code related to "print" function
  - [x] Add "flush" parameter to function
  - [ ] Refactor all "print" function calls to relect the new parameter
</reminders>
  1. 工具子集。如前面《缓存》相关的描述,因为修改工具定义会使缓存失效,因此当切换模式使得可用的工具减少时,一般仅在Reminder中说明部分工具不可用,由模型来遵循这一约束,而不是直接删除部分工具。
<!-- 切换至Ask模式 -->
<reminders>
- You can ONLY use these tools from now on:
  - read
  - list
  - grep
  - bash
</reminders>
  1. 行为指示。例如当模型连续多次给出名称、参数都一模一样的工具调用时,说明模型处在一种不合理的行为表现上,此时在Reminder中增加提示,让模型感知到当前状态的错误,就有可能调整并脱离错误的路线。
<!-- Assistant -->
read(file)

<!-- User -->
The file content: ...

<!-- Assistant -->
read(file)

<!-- User -->
The file content: ...

<reminders>
- Your are using read tool the second time with exactly the same parameters, this usually means an unexpected situation, you should not use this tool again in your response.
</reminders>
  1. 状态提示。例如激活某一个Skill时,Reminder中可以提示“当前正在使用名为X的Skill“,这种提示可以让模型更加专注于完成一个局部的工作。
<reminders>
- You are currently working with the skill "ppt" active, be focused on this task until you quit with exit_skill tool.
</reminders>

需要额外注意的是,Reminder仅在最后一条消息中出现,当有新的消息时,旧消息上的Reminder会被移除。基于这一特征,我们知道Reminder是永远无法命中缓存的,因此Reminder部分的内容长度要有控制,避免造成过多的成本消耗。

4.5 冲突管控

随着Coding Agent能力的发展,当下执行的任务时间越来越长、编辑的文件越来越多,同时更多的用户也习惯于在Agent工作的同时自己也进行编码工作,甚至让多个Agent任务并发执行。这种“协同”形态下,不少用户曾经遇到过这样的问题:

自己将Agent生成的代码做了一些修正,但之后Agent又把代码改了回去。

这个现象的基本原因也很清楚,就是Agent并不知道你改动过代码。例如以下的过程使Agent读取并编辑了一个文件:

<!-- Assistant -->
read(file)

<!-- User -->
The file content:
...
console.log('hello');
...
<!-- Assistant -->
edit(file, hello -> Hello)

<!-- User -->
Edit has been applied successfully.

这个时候,在模型见到的上下文中,这个文件中的代码显然是console.log('Hello'); 。假设乃又将它改成了console.trace('Hello'); ,后面模型依然会基于.log 来修改代码,用户看起来就是代码“改了回去”。

解决这种共同编辑文件的冲突,实际上有多种方法:

  • 加锁法。当Agent读取、编辑一个文件时,更新模型认知的文件内容的快照。当这个Agent再一次编辑这个文件时,读取文件当前的实际内容,和快照做比对,如果内容不一样,拒绝这一次编辑,随后要求Agent重新读取文件(更新快照与实际内容一致)再进行编辑。这是一种主流的做法,不过Agent实现上的细节比较重
<!-- Assistant -->
edit(file, console.log...)

<!-- User -->
This edit is rejected, the file has been modified since your last read or edit, you should read this file again before executing any write or edit actions.

<!-- Assistant -->
read(file)

<!-- User -->
The file content: ...

<!-- Assistant -->
edit(file, console.trace...);
  • 推送法。监听所有模型读取、编辑过的文件的变更,当文件发生变更时,在下一次模型调用时,不断通过Reminder区域追加这些变更,让模型“实时”地知道文件有所变化,直到文件被下一次读取。这种方式能让模型更早地感知变化,但推送信息可能过多影响成本和推理速度。
<!-- Assistant -->
run_command(ls)

<!-- User -->
The command output: ...

<reminders>
- These files have been modified since your last read or edit, you should read before write or edit to them:
  - file
  - file
  - ...
</reminders>
  • 隔离法。使用Git Worktree方案,直接让不同的Agent任务在文件系统上隔离,在一个独立的Git分支上并行工作,相互不受干扰。在任务完成后,用户检查一个任务的全部变更,在采纳时再合并回实际的当前Git分支,有冲突的由用户解决冲突。这种方法让Agent根本不需要考虑冲突问题,但缺点是系统资源占用高,且有合并冲突风险

文件编辑冲突只是一个比较常见的现象,实际上用户和Agent、多个Agent并行工作,可能造成的冲突还有很多种,例如:

用户敲了半行命令 ls -,Agent直接在终端里敲新的命令 grep "print" -r src执行,导致最后的命令是 ls -grep "print" -r src ,是一个不合法的命令。

终端的抢占也是一种冲突,但相对更容易解决,只要让每一个Agent任务独占自己的终端,永远不与用户、其它Agent任务相交叉即可。

4.6 持久记忆

我们都知道,模型是没有状态的,所以每一次Agent执行任务,对整个项目、对用户的倾向,都是从零开始的过程。这相当于历史经验无法积累,很多曾经调整过的细节、优化过的方向都会被重置。虽然可以通过比如Rule这样的方式去持久化这些“经验”,但需要用户主动的介入,使用成本是相对比较高的。

因此当前很多Coding Agent产品都在探索“记忆”这一能力,争取让Agent变得用的越多越好用。记忆这个话题真正的难点在于:

  1. 如何触发记忆。

  2. 如何消费记忆。

  3. 什么东西算是记忆。

首先对于“如何触发”这一问题,常见于2种做法:

  1. 工具型。定义一个 update_memory 工具,将记忆作为一个字符串数组看待,工具能够对其进行增、删改,模型在任务过程中实时地决定调用。往往模型并不怎么喜欢使用这类工具,经常见于用户有强烈情感的描述时才出现,比如“记住这一点”、“不要再……”。

  2. 总结型。在每一次对话结束后,将对话全部内容发送给模型,并配上提示词进行记忆的提取,提取后的内容补充到原本记忆中。总结型的方案往往又会过度地提取记忆,将没必要的信息进行持久化,干扰未来的推理。

  3. 存储型。不进行任何的记忆整理和提取,而是将所有任务的原始过程当作记忆,只在后续“消费”的环节做精细的处理。

然后在“如何消费”的问题下,也常见有几种做法:

  1. 始终附带。记忆内容记录在文件中,Agent实现中将文件内容附带在每一次的模型请求中。即模型始终能看到所有的记忆,这无疑会加重模型的认知负担,也占用相当多的上下文空间,因为很多记忆可能是与当前任务无关的。

  2. 渐进检索。本身不带记忆内容到模型,但将记忆以文件系统的形式存放,Agent可以通过readlistgrep 等工具来检索记忆。配合“存储型”的触发方式,能让全量的历史任务都成为可被检索的记忆。但这种方式要求模型有比较强的对记忆的认知,在正确的时刻去找相关的记忆。但往往因为根本不知道记忆里有什么,进而无法知道什么时候应该检索,最终几乎不触发检索。

而最终的问题,“什么东西是记忆”,是当下Coding Agent最难以解决的问题之一。错误的、不必要的记忆甚至可能造成实际任务效果的下降,因此精确地定义记忆是Agent实现的首要任务。

通常来说,记忆会分为2种大的方向:

  1. 事实型。如“使用4个空格作为缩进”、“不要使用any 类型“,这些都是事实。事实是无关任何情感、不带主观情绪的。

  2. 画像型。如”用户更喜欢简短的任务总结“就是一种对用户的画像。画像是单个用户的特征,并不一定与项目、代码、架构相关。

在Coding Agent上,往往更倾向于对”事实型“的内容进行记忆,而不考虑用户画像型的记忆。

同时,从业界的发展,可以看到越来越多的模型厂商在从底层进行记忆能力的开发,如最近Google的Titan架构就是一种记忆相关的技术。可能未来某一天,Agent实现上已经不需要再关注记忆的逻辑与实现,模型自身将带有持久化的记忆能力。

05 能力扩展

在实际应用中,还需要一些机制来让Agent更好地适应特定的项目、团队和个人习惯。当前主流的Coding Agent产品都提供了Rule、MCP、Skill这三种扩展能力,它们各有侧重,共同构成了Agent的能力增强体系。

5.1 Rule

当面对业务的repo往往存在一些领域相关的知识而非模型的知识库中已有的内容,这些往往需要凭借老员工的经验或者读取大量代码库的信息进行总结后才能明白,这些内容便适合放到Rule中,作为静态的不会频繁改动的内容放入Environment Context中长期Cache。

好的Rule应当足够精简、可操作且范围明确,人看不懂的规则或者描述不清的规则模型是一定搞不定无法遵守的。

  • 将Rule控制在 500 行以内。

  • 将较大的规则拆分为多个可组合的规则,采取按需的方式,按照 文件路径/关键场景 激活Rule;对于特定场景激活的Rule,采取编写索引的方式创建Rule,让模型渐进式激活,比如项目针对网络请求和错误处理相关做了项目维度的封装处理,但这种情况并不是每个文件ts/tsx文件都会遇到的诉求,比如在项目的rules目录下创建index.mdr(curso是.mdc文件),编写下面的激活的条件:

    • 需要进行API调用获取数据

    • 处理异步操作的错误和加载状态

    • 当编码涉及以下任一情况时,必须立刻阅读 [08-api-error-handling.mdc](mdr:.cursor/rules/08-api-error-handling.mdc)

  • 提供具体示例或参考文件,针对xx情况正确的方式是`code`。

  • 避免模糊的指导,比如交互式的东西模型交互不了,不需要写进去。

  • 为了模型能够积极验证每次改动是否符合预期,告知模型改动后可以执行的正确的构建命令,以及某些自定义命令(比如自动化测试)引导模型在后台启动命令,在xx秒后读取日志文件的内容进行结果的判断。

5.2 MCP

MCP(Model Context Protocol)是Anthropic提出的一种标准化的工具扩展协议,它允许开发者以统一的方式为Coding Agent添加新的能力。

与Rule的"声明式约束"不同,MCP是一种实时工具调用协议,即通过MCP server的方式进行连接,来扩展Agent可以做的事情。

一个典型的场景是集成外部服务。比如你的项目托管在GitHub上,可以让Agent直接访问GitHub实现创建Issue、查询PR状态、添加评论等功能:

{
    "mcpServers": {
        "github": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-github"],
            "env": {
                "GITHUB_PERSONAL_ACCESS_TOKEN": "<your-github-token>"
            }
        }
    }
}

配置好后,Agent就能在代码审查过程中自动创建Issue记录问题、查询相关PR的讨论、甚至根据代码变更自动生成commit message。

MCP的另一个优势是实现门槛低。一个MCP Server本质上就是一个标准输入输出的程序,它通过JSON-RPC协议与Agent通信,当模型需要外部能力的时候,调用MCP Server,而模型无需关心其内部代码实现,Agent只需要按照固定的协议去连接获取内容。

5.3 Skill

5.3.1 什么是Skill

随着模型能力的提升,使用Agent完成的任务复杂度逐渐增加,使用Coding Agent可以进行本地代码执行和文件系统完成跨领域的复杂任务。但随着这些Agent的功能越来越强大,我们需要更具可组合性、可扩展性和可移植性的方法,为它们配备特定领域的专业知识,因此Agent Skill作为一种为Agent扩展能力的标准诞生。Skill 将指令、脚本和资源的文件夹打包,形成专业领域的知识,Agent在初始化的时候会获取可用的Skills列表,并在需要的时候动态加载这些内容来执行特定任务。

随着 Skill 复杂性的增加,它们可能包含过多的上下文信息,无法放入单个配置文件中 SKILL.md,或者某些上下文信息仅在特定场景下才相关。在这种情况下,Skill可以在当前目录中bundle额外的文件,并通过文件名引用这些文件,这些额外的文件提供了更多详细信息,Coding Agent 可以根据需要选择浏览和查找这些信息。Skill 是渐进式触发的, 因此 SKILL.mdnamedescription很关键,这会始终存在于Agent的环境上下文中提供给模型,模型会根据这些描述信息来决定是否在当前任务中触发该Skill,当你明确希望使用某个Skill完成任务,可以在prompt中指定“使用xxxx Skill完成xx任务”。

5.3.2 Skill和代码执行

LLM在很多任务上表现出色,但许多操作需要使用编写代码 -> 代码执行的方式,带来更高效的操作、确定性的以及可靠性的结果。生成式的模型常常通过生成可执行代码的方式去验证/计算结果。

代码既可以作为可执行工具,也可以作为文档。Skill中应该明确让模型是应该直接运行脚本,还是应该将其作为参考信息读取到上下文中。

5.3.3 如何创建Skill

每个Skill由一个必需的 SKILL.md 文件和可选的bundle资源组成,Skill 应该只包含完成任务所需的信息。

skill-name/
├── SKILL.md (必需)
   ├── YAML frontmatter 元数据 (必需)
      ├── name: (必需)
      ├── description: (必需,这是 skill 的主要触发机制,帮助模型理解何时使用该 skil)
      └── compatibility: (可选)
   └── Markdown 说明 (必需)
└── bundle的资源 (可选)
    ├── scripts/          - 可执行代码 (Python/Bash/等)
    ├── references/       - 需要时加载到上下文的文档
    └── assets/           - 用于输出的文件 (模板、图标、字体等)

举一个具体的例子,比如当我们需要进行批量项目的技术栈migrate,比如将less迁移postcss,中间涉及一系列的复杂步骤,比如:

  • 安装postcss以及postcss plugin的依赖

  • 配置postcss的config

  • 分析项目用到了哪些less varibale替换成css vars

  • 删除mixin并替换

  • 一系列的其他兼容less的语法转换...

  • 替换文件后缀

上面的工作可以通过清晰的流程描述,并配合脚本实现,因此可以作为一个Skill将经验变成可复制的,一个less-to-postcss的skill的结构:

5.3.4 Skill的使用

人人都可以创建Skill,也可以让Agent来编写Skill,这是Skill非常便捷的地方。Skill通过instructions和code赋予Coding Agent新的能力。虽然这使其功能强大并有很高的自由度,但也意味着恶意SKill可能会在其使用环境中引入漏洞,诱使模型窃取数据并执行非预期操作。仅从可信来源安装Skill,如果无法确信来源可信,在使用前请务必进行彻底审核。

Skill的出现并不是替代MCP的出现,而是相互配合,在合适的场景下选取Skill或是MCP。某些任务Skill和MCP Server均可完成,但Skill通过执行代码的方式可以一次性加载完整流程,但MCP Server要经历多次查询和多轮对话往返,这种情况下Skill更为合适,但这不意味着绝对的优势,比如标准化文档创建这个典型的场景,创建PPT/Word/Excel在本地使用Skill即可完成,但数据的提供则需要借助MCP Server进行查询。因此Skill擅长的是在本地通过执行 code的方式完成复杂任务,在用户私有数据、动态数据查询这些情况下Skill就无法搞定了,这和用户的数据库以及隐私强关联,需要让模型无法感知在执行过程中的隐私信息,Skill能够与MCP Server互补完成更为复杂的流程。

Vue3中如何优雅实现支持多绑定变量和修饰符的双向绑定组件?

一、自定义input/select等基础表单组件(v-model配合props/emit)

1.1 双向绑定的核心原理

Vue3中组件的双向绑定本质是propsemit的语法糖。在Vue3.4+版本,官方推荐使用defineModel()宏简化实现,而低版本则需要手动处理属性与事件的传递。

1.2 自定义Input组件

方式一:使用defineModel宏(Vue3.4+推荐)

<!-- CustomInput.vue -->
<script setup>
// defineModel自动处理props和emit的双向绑定
const model = defineModel()
</script>

<template>
  <input 
    v-model="model" 
    placeholder="请输入内容" 
    class="custom-input"
  />
</template>

<style scoped>
.custom-input {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}
</style>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const inputValue = ref('')
</script>

<template>
  <div>
    <CustomInput v-model="inputValue" />
    <p class="mt-2">输入结果:{{ inputValue }}</p>
  </div>
</template>

方式二:手动处理props与emit(兼容低版本)

<!-- CustomInputLegacy.vue -->
<script setup>
// 接收父组件传递的value
const props = defineProps(['modelValue'])
// 定义更新事件
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input 
    :value="props.modelValue" 
    @input="emit('update:modelValue', $event.target.value)"
    placeholder="请输入内容"
    class="custom-input"
  />
</template>

父组件使用方式与defineModel版本完全一致。

1.3 自定义Select组件

<!-- CustomSelect.vue -->
<script setup>
const model = defineModel()
// 接收选项配置
const props = defineProps({
  options: {
    type: Array,
    required: true,
    default: () => []
  },
  placeholder: {
    type: String,
    default: '请选择'
  }
})
</script>

<template>
  <select v-model="model" class="custom-select">
    <option value="" disabled>{{ props.placeholder }}</option>
    <option 
      v-for="option in props.options" 
      :key="option.value" 
      :value="option.value"
    >
      {{ option.label }}
    </option>
  </select>
</template>

<style scoped>
.custom-select {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  background-color: white;
}
</style>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import CustomSelect from './CustomSelect.vue'

const selectedValue = ref('')
const selectOptions = [
  { value: 'vue', label: 'Vue.js' },
  { value: 'react', label: 'React' },
  { value: 'angular', label: 'Angular' }
]
</script>

<template>
  <div>
    <CustomSelect 
      v-model="selectedValue" 
      :options="selectOptions" 
      placeholder="选择前端框架"
    />
    <p class="mt-2">选中值:{{ selectedValue }}</p>
  </div>
</template>

1.4 多v-model绑定

Vue3支持在单个组件上绑定多个v-model,通过指定参数区分:

<!-- UserForm.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <div class="flex gap-2">
    <input v-model="firstName" placeholder="姓" class="custom-input" />
    <input v-model="lastName" placeholder="名" class="custom-input" />
  </div>
</template>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const userFirstName = ref('')
const userLastName = ref('')
</script>

<template>
  <div>
    <UserForm 
      v-model:first-name="userFirstName" 
      v-model:last-name="userLastName" 
    />
    <p class="mt-2">姓名:{{ userFirstName }} {{ userLastName }}</p>
  </div>
</template>

1.5 处理v-model修饰符

自定义组件也可以支持v-model修饰符,比如实现首字母大写:

<!-- CustomInputWithModifier.vue -->
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    // 处理capitalize修饰符
    if (modifiers.capitalize && value) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

<template>
  <input v-model="model" placeholder="请输入内容" class="custom-input" />
</template>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import CustomInputWithModifier from './CustomInputWithModifier.vue'

const inputValue = ref('')
</script>

<template>
  <div>
    <CustomInputWithModifier v-model.capitalize="inputValue" />
    <p class="mt-2">处理后的值:{{ inputValue }}</p>
  </div>
</template>

二、复合表单组件的封装(如带验证的输入框、日期选择器)

2.1 带验证的输入框

往期文章归档
免费好用的热门在线工具

封装一个集成验证逻辑的输入框组件,支持多种验证规则:

<!-- ValidatedInput.vue -->
<script setup>
import { ref, computed } from 'vue'
const model = defineModel()
const props = defineProps({
  rules: {
    type: Object,
    default: () => ({})
  },
  label: {
    type: String,
    default: ''
  }
})

const showError = ref(false)
const errorMessage = ref('')

// 验证输入值
const validate = (value) => {
  showError.value = false
  errorMessage.value = ''

  // 必填验证
  if (props.rules.required && !value) {
    showError.value = true
    errorMessage.value = props.rules.requiredMessage || '此字段为必填项'
    return false
  }

  // 最小长度验证
  if (props.rules.minLength && value.length < props.rules.minLength) {
    showError.value = true
    errorMessage.value = props.rules.minLengthMessage || 
      `最少需要输入${props.rules.minLength}个字符`
    return false
  }

  // 邮箱格式验证
  if (props.rules.email && value) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(value)) {
      showError.value = true
      errorMessage.value = props.rules.emailMessage || '请输入有效的邮箱地址'
      return false
    }
  }

  return true
}

// 失去焦点时触发验证
const handleBlur = () => {
  validate(model.value)
}

// 输入时清除错误提示
const handleInput = () => {
  showError.value = false
  errorMessage.value = ''
}
</script>

<template>
  <div class="validated-input">
    <label v-if="props.label" class="input-label">{{ props.label }}</label>
    <input 
      v-model="model" 
      @blur="handleBlur" 
      @input="handleInput"
      :class="{ 'input-error': showError }"
      class="custom-input"
      :placeholder="props.label || '请输入内容'"
    />
    <div v-if="showError" class="error-message">{{ errorMessage }}</div>
  </div>
</template>

<style scoped>
.validated-input {
  margin-bottom: 16px;
}
.input-label {
  display: block;
  margin-bottom: 4px;
  font-size: 14px;
  font-weight: 500;
}
.input-error {
  border-color: #ff4d4f;
}
.error-message {
  margin-top: 4px;
  font-size: 12px;
  color: #ff4d4f;
}
</style>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import ValidatedInput from './ValidatedInput.vue'

const email = ref('')
const emailRules = {
  required: true,
  requiredMessage: '邮箱不能为空',
  email: true,
  emailMessage: '请输入有效的邮箱地址'
}
</script>

<template>
  <ValidatedInput 
    v-model="email" 
    label="邮箱地址" 
    :rules="emailRules" 
  />
</template>

2.2 日期选择器组件

封装一个支持格式化和范围选择的日期选择器:

<!-- DatePicker.vue -->
<script setup>
import { ref, computed } from 'vue'
const model = defineModel()
const props = defineProps({
  format: {
    type: String,
    default: 'YYYY-MM-DD'
  },
  placeholder: {
    type: String,
    default: '选择日期'
  }
})

// 格式化显示的日期
const formattedDate = computed(() => {
  if (!model.value) return ''
  const date = new Date(model.value)
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${year}-${month}-${day}`
})

// 处理日期变化
const handleDateChange = (e) => {
  model.value = e.target.value
}
</script>

<template>
  <div class="date-picker">
    <input 
      type="date" 
      :value="formattedDate" 
      @change="handleDateChange"
      :placeholder="props.placeholder"
      class="custom-input"
    />
    <p v-if="model.value" class="mt-2">选中日期:{{ formattedDate }}</p>
  </div>
</template>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import DatePicker from './DatePicker.vue'

const selectedDate = ref('')
</script>

<template>
  <DatePicker v-model="selectedDate" />
</template>

三、表单组件库的设计思路(扩展性与通用性)

3.1 可配置化设计原则

  1. 原子化props设计:将组件的每个可配置项拆分为独立props,如placeholderdisabledsize
  2. 默认值与覆盖机制:为props提供合理默认值,同时允许用户通过props覆盖
  3. 类型安全:使用TypeScript定义props类型,提供更好的开发体验

3.2 插槽的灵活运用

通过插槽增强组件的扩展性:

<!-- CustomInputWithSlot.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <div class="input-group">
    <slot name="prefix"></slot>
    <input v-model="model" class="custom-input" />
    <slot name="suffix"></slot>
  </div>
</template>

父组件使用插槽:

<CustomInputWithSlot v-model="value">
  <template #prefix>
    <span class="prefix-icon">📧</span>
  </template>
  <template #suffix>
    <button @click="clearInput">清除</button>
  </template>
</CustomInputWithSlot>

3.3 样式定制方案

  1. CSS变量主题:使用CSS变量定义主题色、间距等
:root {
  --input-border-color: #ddd;
  --input-focus-color: #409eff;
  --input-error-color: #ff4d4f;
}
  1. 类名穿透:允许用户通过class props传递自定义样式类
  2. Scoped样式与全局样式结合:组件内部使用scoped样式,同时提供全局样式类供用户覆盖

3.4 事件系统设计

  1. 原生事件透传:使用v-bind="$attrs"透传原生事件
  2. 自定义事件:定义组件特有的事件,如validate-successvalidate-fail
  3. 事件命名规范:采用kebab-case命名,如update:model-value

3.5 组件组合策略

  1. 基础组件与复合组件分离:将基础的Input、Button等与复合的Form、FormItem分离
  2. 依赖注入:使用provideinject实现跨组件通信,如表单验证状态的共享
  3. 高阶组件:通过高阶组件增强基础组件的功能,如添加防抖、节流等

课后Quiz

问题1:如何在Vue3中实现组件的双向绑定?请分别写出Vue3.4+和低版本的实现方式。

答案解析

  • Vue3.4+推荐使用defineModel()宏:
<script setup>
const model = defineModel()
</script>
<template>
  <input v-model="model" />
</template>
  • 低版本手动处理props与emit:
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
  <input 
    :value="props.modelValue" 
    @input="emit('update:modelValue', $event.target.value)" 
  />
</template>

父组件统一使用v-model="value"绑定。

问题2:如何让自定义组件支持多个v-model绑定?请给出示例代码。

答案解析: 通过为defineModel()指定参数实现多v-model绑定:

<!-- 子组件 -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
  <input v-model="firstName" placeholder="姓" />
  <input v-model="lastName" placeholder="名" />
</template>

父组件使用:

<CustomComponent 
  v-model:first-name="userFirstName" 
  v-model:last-name="userLastName" 
/>

问题3:在设计表单组件库时,如何保证组件的扩展性和通用性?

答案解析

  1. 可配置props:将组件的每个可配置项拆分为独立props,提供合理默认值
  2. 插槽机制:使用插槽允许用户插入自定义内容
  3. 样式定制:使用CSS变量、类名穿透等方式支持样式定制
  4. 事件透传:透传原生事件,同时定义自定义事件
  5. 组合设计:基础组件与复合组件分离,使用依赖注入和高阶组件增强功能

常见报错解决方案

报错1:[Vue warn]: Missing required prop: "modelValue"

产生原因:自定义组件使用了v-model,但父组件未绑定值,或子组件未正确定义props。 解决办法

  • 确保父组件使用v-model="value"绑定响应式变量
  • 子组件正确使用defineModel()或声明modelValue prop

报错2:[Vue warn]: Invalid prop: type check failed for prop "modelValue". Expected String, got Number

产生原因:v-model绑定的变量类型与子组件期望的prop类型不匹配。 解决办法

  • 检查父组件绑定变量的类型,确保与子组件prop类型一致
  • 子组件中使用.number修饰符或在defineModel()中指定类型

报错3:[Vue warn]: Extraneous non-emits event listeners (update:modelValue) were passed to component

产生原因:子组件未声明update:modelValue事件,或使用了片段根节点导致事件无法自动继承。 解决办法

  • 使用defineModel()宏自动处理事件声明
  • 或手动使用defineEmits(['update:modelValue'])声明事件

参考链接

我开源了一个 GrapesJS 插件

一、背景:为什么要做这个插件?

在做可视化编辑器(证书编辑器、模板编辑器、低代码页面编辑器)时,我大量使用了 GrapesJS

但在真实业务中,很快遇到了一个非常实际的问题:

Canvas 内的文本输入监听并不可靠

具体表现包括:

  • 中文输入法(IME)下:

    • input 事件触发不稳定
    • composition 阶段无法准确感知文本变化
  • GrapesJS 的 Canvas 是 iframe:

    • iframe reload 后监听全部失效
    • DOM 动态变化时无法自动重新绑定
  • 高频输入会导致:

    • 性能抖动
    • 无法做实时联动(预览、校验、联想)

而业务侧的诉求非常明确:

✅ 希望能稳定捕获:

  • 用户正在输入的文本(实时)
  • 用户完成输入的文本(提交)
  • 支持中文 / 日文 / 韩文输入法
  • 可配置节流

这就是 grapesjs-text-change 诞生的背景。


二、插件目标与能力设计

插件目标非常清晰:

🎯 为 GrapesJS 提供稳定、工程化的文本输入监听能力

核心能力:

能力 说明
✅ IME 兼容 支持中文 / 日文输入
✅ iframe 自动重绑定 Canvas reload 后自动恢复
✅ 输入节流 避免高频触发
✅ 标准事件输出 对外统一事件接口
✅ TypeScript 支持 类型安全
✅ 即插即用 零侵入集成

三、使用效果展示

安装:

npm install grapesjs-text-change

集成:

import TextChangePlugin from 'grapesjs-text-change';

editor.use(TextChangePlugin, {
  throttle200,
});

editor.on('text:input'e => {
  console.log('实时输入:', e.text);
});

editor.on('text:commit'e => {
  console.log('输入完成:', e.text);
});

你可以:

  • 实时联动右侧预览
  • 做文本校验
  • 同步外部状态
  • 做多语言编辑联动

四、核心实现思路

1️⃣ iframe 自动绑定机制

GrapesJS 的 Canvas 是 iframe,不能只绑定一次。

插件内部监听:

editor.on('canvas:ready', bindFrame);
editor.on('canvas:frame:load', bindFrame);

每次 iframe 重建时:

  • 重新获取 contentDocument
  • 扫描 [contenteditable]
  • 自动绑定监听器

确保不会因为刷新导致监听失效。


2️⃣ IME 输入兼容处理

中文输入并不是简单的 input 事件。

需要监听:

  • compositionstart
  • compositionupdate
  • compositionend
  • input

插件内部维护一个状态机:

let composing = false;

el.addEventListener('compositionstart', () => composing = true);
el.addEventListener('compositionend', () => {
  composing = false;
  emitCommit();
});

el.addEventListener('input', () => {
  if (!composing) emitInput();
});

这样可以:

  • 避免拼音阶段误触发
  • 只在真正提交时触发 commit

3️⃣ 输入节流(Throttle)

高频输入如果不做节流:

  • 会触发大量业务逻辑
  • 性能下降明显

插件内使用可配置 throttle:

emitInput = throttle(fn, options.throttle);

用户可根据业务自由配置:

{ throttle: 100 }

4️⃣ 标准事件设计

插件对外只暴露两个稳定事件:

text:input   // 实时输入
text:commit  // 输入完成

统一事件格式:

{
  text: string;
  target: HTMLElement;
}

避免业务侧直接耦合 DOM。


五、工程化构建

📦 技术栈

  • TypeScript
  • tsup(打包)
  • ESM + CJS 双产物
  • 自动生成 d.ts

⚙️ tsup 配置示例

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm''cjs'],
  dtstrue,
  sourcemaptrue,
  cleantrue,
});

打包:

pnpm build

📁 项目结构

grapesjs-text-change/
├── .github/
│   └── workflows/
│       ├── release.yml      # npm 发布 workflow
│       └── pages.yml        # GitHub Pages 部署 workflow
├── demo/                    # 在线演示
│   ├── index.html
│   ├── main.ts
│   ├── vite.config.ts
│   └── package.json
├── src/
│   ├── TextChangePlugin.ts  # 核心插件逻辑
│   └── index.ts             # 入口文件
├── package.json
├── tsup.config.ts
├── tsconfig.json
├── .gitignore
└── README.md

保持:

  • 职责拆分清晰
  • 可测试
  • 可扩展

六、自动化发布

通过 GitHub Actions + Changeset:

  • 自动版本管理
  • 自动发布到 npm
  • 自动生成 changelog

这保证了:

✅ 插件可持续维护 ✅ 发布过程稳定可靠 ✅ 降低人工成本


七、适合哪些场景?

这个插件特别适合:

✅ 可视化编辑器 ✅ 富文本编辑 ✅ 模板编辑器 ✅ 多语言编辑 ✅ 低代码平台 ✅ 在线证书 / 海报设计器

如果你在 GrapesJS 中:

  • 需要稳定监听文本变化
  • 需要支持中文输入
  • 不想重复踩坑

可以直接使用这个插件。


八、开源地址

欢迎 Star ⭐ / Issue / PR:

👉 GitHub github.com/xiayuguo/gr…

👉 NPM www.npmjs.com/package/gra…


九、结语

这个插件本质上是:

一次真实业务驱动的工程化抽象实践。

如果你也在做:

  • GrapesJS 二次开发
  • 编辑器工程
  • 低代码平台
  • 前端工程化

欢迎交流经验,一起打磨更好的工具生态 🚀

vue3 打包dist后 nginx 代理部署 浏览器单独访问其中一个路由 报404

Vue 3 打包部署 Nginx 404 问题解决方案

这是Vue 3 SPA应用部署到Nginx时最常见的问题。当用户直接访问非根路径(如 /about/user/123)时,Nginx会在服务器上查找对应的物理文件,但Vue应用是单页面应用,这些路由实际上是由前端路由控制的。

1. 问题根源

Vue 3应用打包后:

  • 只有 index.html是真实存在的物理文件
  • 其他路由如 /about/contact是前端路由
  • 当直接访问这些路由时,Nginx在服务器上找不到对应的 about.html文件
  • 因此返回 404 错误

2. 完整解决方案

2.1 方案一:Nginx 配置(推荐)

核心思路:让Nginx对所有找不到的路径都返回 index.html,由Vue Router处理路由

server {
    listen 80;
    server_name your-domain.com;  # 你的域名
    root /usr/share/nginx/html;   # Vue打包文件所在目录
    index index.html;

    # 处理Vue路由 - 核心配置
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源缓存优化
    location ~* .(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # Gzip压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types
        application/atom+xml
        application/geo+json
        application/javascript
        application/x-javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rdf+xml
        application/rss+xml
        application/xhtml+xml
        application/xml
        font/eot
        font/otf
        font/ttf
        image/svg+xml
        text/css
        text/javascript
        text/plain
        text/xml;
}

关键配置说明

  • try_files $uri $uri/ /index.html;:按顺序尝试查找文件

    1. 先找 $uri(请求的路径)
    2. 再找 $uri/(目录)
    3. 都找不到就返回 /index.html(由Vue处理路由)

2.2 方案二:Docker部署配置

如果你使用Docker部署,Nginx配置:

# Dockerfile
FROM nginx:alpine

# 复制打包文件
COPY dist/ /usr/share/nginx/html/

# 复制自定义Nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # 核心配置:处理Vue路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源优化
    location ~* .(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

2.3 方案三:Vue Router 配置检查

确保你的Vue Router配置正确:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  // 关键:使用createWebHistory,而不是createWebHashHistory
  history: createWebHistory(process.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import('@/views/Home.vue')
    },
    {
      path: '/about',
      name: 'About',
      component: () => import('@/views/About.vue')
    },
    {
      path: '/:pathMatch(.*)*',  // 404页面
      name: 'NotFound',
      component: () => import('@/views/NotFound.vue')
    }
  ]
})

export default router

重要:生产环境必须使用 createWebHistory,而不是 createWebHashHistory(URL带#号的那种)。

2.4 方案四:Vue 项目配置检查

检查 vite.config.jsvue.config.js

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  base: '/',  // 确保base路径正确
  build: {
    outDir: 'dist',
    assetsDir: 'assets'
  }
})
// vue.config.js (Vue CLI)
module.exports = {
  publicPath: '/',  // 确保publicPath正确
  outputDir: 'dist',
  assetsDir: 'assets'
}

3. 部署验证步骤

3.1 本地验证打包结果

打包前先验证:

# 打包
npm run build

# 查看dist目录结构
ls -la dist/
# 应该看到类似:
# index.html
# assets/
#   index-xxx.js
#   index-xxx.css

3.2 本地测试部署

可以使用 serve测试打包结果:

# 安装serve
npm install -g serve

# 在dist目录启动服务
serve -s dist

# 访问 http://localhost:3000
# 测试直接访问路由:http://localhost:3000/about

3.3 部署到Nginx后的验证

部署后测试:

  1. 访问根路径http://your-domain.com(应该正常)
  2. 直接访问路由http://your-domain.com/about(应该正常)
  3. 刷新页面http://your-domain.com/about(应该正常)

4. 常见问题排查

4.1 问题:配置了try_files但还是404

排查步骤

  1. 检查Nginx配置是否生效

    nginx -t  # 检查配置语法
    nginx -s reload  # 重新加载配置
    
  2. 检查Nginx错误日志

    tail -f /var/log/nginx/error.log
    
  3. 检查文件权限

    chmod -R 755 /usr/share/nginx/html/
    

4.2 问题:静态资源404

解决方案:确保静态资源路径正确

location /assets/ {
    alias /usr/share/nginx/html/assets/;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

4.3 问题:开发环境正常,生产环境404

原因

  • 开发环境使用webpack-dev-server,自带路由处理
  • 生产环境需要Nginx配置支持

解决:按照上面的Nginx配置进行设置

5. 进阶配置

5.1 子路径部署

如果你的应用部署在子路径下(如 http://domain.com/myapp/):

// vite.config.js
export default defineConfig({
  base: '/myapp/',
  // ...
})
server {
    listen 80;
    server_name domain.com;
    root /usr/share/nginx/html;
    
    location /myapp/ {
        alias /usr/share/nginx/html/;
        try_files $uri $uri/ /myapp/index.html;
        index index.html;
    }
}

5.2 多环境配置

# 开发环境
server {
    listen 8080;
    server_name dev.domain.com;
    # 开发环境配置
}

# 生产环境
server {
    listen 80;
    server_name domain.com;
    # 生产环境配置
}

6. 一键部署脚本

#!/bin/bash
# deploy.sh

echo "开始部署Vue应用..."

# 1. 打包
echo "打包Vue应用..."
npm run build

# 2. 备份旧版本
if [ -d "/usr/share/nginx/html" ]; then
    echo "备份旧版本..."
    tar -czf /tmp/vue-app-backup-$(date +%Y%m%d%H%M%S).tar.gz /usr/share/nginx/html
fi

# 3. 复制新版本
echo "复制新版本到Nginx目录..."
sudo cp -r dist/* /usr/share/nginx/html/

# 4. 设置权限
echo "设置文件权限..."
sudo chown -R nginx:nginx /usr/share/nginx/html/
sudo chmod -R 755 /usr/share/nginx/html/

# 5. 重启Nginx
echo "重启Nginx服务..."
sudo nginx -t && sudo nginx -s reload

echo "部署完成!"

7. 总结

Vue 3 SPA部署404问题的核心解决方案

  1. Nginx配置try_files $uri $uri/ /index.html;
  2. Vue Router配置:使用 createWebHistory
  3. 打包配置:确保 base路径正确
  4. 文件权限:确保Nginx有读取权限

按照上述配置部署后,直接访问任何路由都能正常工作。

**

学习Three.js--缓冲类型几何体(BufferGeometry)

学习Three.js--缓冲类型几何体(BufferGeometry)

前置核心说明

BufferGeometry 是 Three.js 中所有几何体的底层核心(BoxGeometry/SphereGeometry 等预设几何体均基于它构建),也是官方唯一推荐使用的几何体类型(旧版 Geometry 已被废弃)。

核心区别与优势(为什么用 BufferGeometry)

类型 核心特点 性能 官方态度
BufferGeometry(缓冲几何体) 顶点数据存储在「类型化数组」(Float32Array 等)中,直接对接 GPU 内存 极高(GPU 直接读取,无数据转换) 主推,唯一维护
Geometry(旧版几何体) 顶点数据存储在普通数组中,需转换后才能给 GPU 使用 较低(多一层数据转换) 废弃,不再维护

核心逻辑

BufferGeometry 本身是「空容器」,没有任何预设形状,你需要通过定义顶点数据(坐标、颜色、纹理坐标等)来「自定义任意几何形状」,核心是:
类型化数组(顶点数据)→ BufferAttribute(属性封装)→ BufferGeometry(绑定属性)→ 渲染对象(Mesh/Line/Points)


一、BufferGeometry 核心概念与基础用法

1. 核心术语解释

  • 顶点(Vertex):3D 空间中的一个点,由 X/Y/Z 三个坐标值组成,是构成几何体的最基本单元;
  • 类型化数组:如 Float32Array(32位浮点数组),专门用于存储顶点数据,比普通数组更节省内存、GPU 读取更快;
  • BufferAttribute:Three.js 对「类型化数组」的封装,告诉 Three.js 「数组中的数据如何分组解析」(比如每3个值为一组表示一个顶点坐标);
  • 属性(Attribute):几何体的「数据维度」,如 position(顶点坐标)、color(顶点颜色)、uv(纹理坐标)、normal(法线)等,一个几何体可绑定多个属性。

2. 基础使用流程

以下是从「创建空几何体」到「渲染自定义形状」的完整流程

步骤1:创建空的 BufferGeometry 容器
// 语法:无参数,创建空的缓冲几何体
const geometry = new THREE.BufferGeometry();
步骤2:定义顶点数据(类型化数组)

顶点数据必须用 类型化数组(不能用普通数组),常用:

  • Float32Array:存储浮点型数据(坐标、颜色、UV 等,最常用);
  • Uint16Array:存储无符号16位整数(索引数据)。
// 顶点坐标数据:每3个值为一组(X,Y,Z),表示一个顶点的3D坐标
// 示例:6个顶点,对应2个三角形(Mesh 默认按三角面渲染)
const vertices = new Float32Array([
  0, 0, 0,   // 顶点1:(0,0,0)
  50, 0, 0,  // 顶点2:(50,0,0)
  0, 100, 0, // 顶点3:(0,100,0) → 第一个三角形(顶点1-2-3)
  0, 0, 10,  // 顶点4:(0,0,10)
  0, 0, 100, // 顶点5:(0,0,100)
  50, 0, 10  // 顶点6:(50,0,10) → 第二个三角形(顶点4-5-6)
]);
步骤3:创建 BufferAttribute
// 语法:new THREE.BufferAttribute(类型化数组, 组内元素数量, 是否归一化)
// 关键:itemSize=3 → 每3个值为一组(对应X/Y/Z坐标)
const positionAttribute = new THREE.BufferAttribute(vertices, 3);
BufferAttribute 参数 类型 默认值 核心说明
array TypedArray 必传,存储顶点数据的类型化数组
itemSize Number 必传,每组的元素数量(坐标=3,颜色=3/4,UV=2)
normalized Boolean false 是否归一化数据(颜色数据常用,将0-255转为0-1)
usage Number THREE.StaticDrawUsage 数据使用方式(静态/动态,默认静态即可)
步骤4:将属性绑定到几何体
// 语法:geometry.setAttribute(属性名, BufferAttribute对象)
// 核心:属性名必须是固定值,如 "position"(坐标)、"color"(颜色)、"uv"(纹理)
geometry.setAttribute('position', positionAttribute);
步骤5:创建材质和渲染对象
// 材质:MeshBasicMaterial 不受光照影响,适合调试
const material = new THREE.MeshBasicMaterial({
  color: 0x00ff00,    // 基础颜色(无顶点颜色时生效)
  wireframe: false,   // 是否显示线框(true=线框,false=实体)
  side: THREE.DoubleSide // 双面渲染(避免背面不可见)
});

// 创建网格对象(将几何体+材质绑定)
const mesh = new THREE.Mesh(geometry, material);

// 添加到场景
scene.add(mesh);

二、BufferGeometry 核心参数与常用属性

1. 构造函数(无参数)

// 始终无参数,创建空几何体,后续通过 setAttribute 绑定数据
const geometry = new THREE.BufferGeometry();

2. 核心属性

属性名 类型 说明 示例
attributes Object 存储所有绑定的属性(position/color/uv 等) geometry.attributes.position → 获取坐标属性
index BufferAttribute 索引缓冲区(优化顶点重复,下文详解) geometry.setIndex(索引数组)
boundingBox Box3 几何体的包围盒(自动计算,用于碰撞检测/裁剪) geometry.computeBoundingBox() → 计算包围盒
boundingSphere Sphere 几何体的包围球 geometry.computeBoundingSphere()
drawRange Object 渲染范围(只渲染部分顶点) geometry.drawRange = { start: 0, count: 3 } → 只渲染前3个顶点

3. 核心方法

方法名 说明 示例
setAttribute(name, attribute) 绑定属性到几何体 geometry.setAttribute('position', attr)
getAttribute(name) 获取已绑定的属性 geometry.getAttribute('position')
removeAttribute(name) 移除属性 geometry.removeAttribute('color')
setIndex(array) 设置索引缓冲区 geometry.setIndex(new Uint16Array([0,1,2]))
computeBoundingBox() 计算几何体包围盒 geometry.computeBoundingBox()
computeBoundingSphere() 计算几何体包围球 geometry.computeBoundingSphere()
computeVertexNormals() 计算顶点法线(让光照生效) geometry.computeVertexNormals()
dispose() 销毁几何体(释放内存) geometry.dispose()

三、BufferGeometry 进阶用法

1. 索引缓冲区(Index):减少顶点重复

问题场景

绘制一个矩形(由两个三角面组成),直接定义顶点需要6个(重复2个):
(0,0,0)、(1,0,0)、(0,1,0)、(1,0,0)、(1,1,0)、(0,1,0)

索引解决方案
  • 定义4个唯一顶点 + 6个索引(指定三角面的顶点顺序),节省内存:
// 步骤1:创建空几何体
const geometry = new THREE.BufferGeometry();

// 步骤2:定义4个唯一顶点(无重复)
const vertices = new Float32Array([
  0, 0, 0,  // 顶点0
  1, 0, 0,  // 顶点1
  0, 1, 0,  // 顶点2
  1, 1, 0   // 顶点3
]);
const posAttr = new THREE.BufferAttribute(vertices, 3);
geometry.setAttribute('position', posAttr);

// 步骤3:定义索引(每3个值为一组,指定三角面的顶点索引)
// 第一个三角面:顶点0→1→2;第二个三角面:顶点1→3→2
const indices = new Uint16Array([
  0, 1, 2, 
  1, 3, 2
]);
// 设置索引缓冲区
geometry.setIndex(new THREE.BufferAttribute(indices, 1));

// 步骤4:创建材质和网格
const material = new THREE.MeshBasicMaterial({ color: 0xff0000, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
核心优势
  • 顶点数量从6个减少到4个,数据量降低33%;
  • 复杂几何体(如球体)可减少大量重复顶点,性能提升显著。

2. 顶点颜色(Color Attribute):每个顶点自定义颜色

核心逻辑

给几何体绑定 color 属性,材质开启 vertexColors: true,即可让每个顶点显示自定义颜色,三角面内自动渐变。

// 步骤1:创建空几何体 + 顶点坐标
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
  0, 0, 0,   // 顶点0
  1, 0, 0,   // 顶点1
  0, 1, 0    // 顶点2
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

// 步骤2:定义顶点颜色(每3个值为一组,RGB,0-1范围)
const colors = new Float32Array([
  1, 0, 0,   // 顶点0:红色
  0, 1, 0,   // 顶点1:绿色
  0, 0, 1    // 顶点2:蓝色
]);
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

// 步骤3:材质开启顶点颜色(关键)
const material = new THREE.MeshBasicMaterial({
  vertexColors: true, // 启用顶点颜色(覆盖基础color)
  side: THREE.DoubleSide
});

// 步骤4:创建网格
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
效果

三角面会从红色顶点渐变到绿色,再渐变到蓝色,实现多彩渐变效果。

3. 法线属性(Normal):让光照生效

MeshStandardMaterial 等受光照影响的材质,需要「法线数据」才能计算光影,可手动定义或自动计算:

// 方式1:自动计算法线(推荐,适合简单几何体)
geometry.computeVertexNormals();

// 方式2:手动定义法线(精准控制,复杂几何体)
const normals = new Float32Array([
  0, 0, 1,  // 顶点0:法线朝向Z轴正方向
  0, 0, 1,  // 顶点1:法线朝向Z轴正方向
  0, 0, 1   // 顶点2:法线朝向Z轴正方向
]);
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));

4. UV 纹理坐标:绑定纹理贴图

UV 坐标(0-1范围)用于将2D图片贴到3D几何体上,每2个值为一组(U=横向,V=纵向):

// 定义UV坐标(每2个值为一组)
const uvs = new Float32Array([
  0, 0,  // 顶点0:贴图左下角
  1, 0,  // 顶点1:贴图右下角
  0, 1   // 顶点2:贴图左上角
]);
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));

// 加载纹理并绑定到材质
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load('texture.jpg');
const material = new THREE.MeshBasicMaterial({ map: texture });

四、完整实战示例(自定义三角面+索引+顶点颜色)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>BufferGeometry 完整示例</title>
  <style>body { margin: 0; overflow: hidden; }</style>
</head>
<body>
  <script type="module">
    import * as THREE from 'https://threejsfundamentals.org/threejs/resources/threejs/r132/build/three.module.js'
    import { OrbitControls }  from "https://threejsfundamentals.org/threejs/resources/threejs/r132/examples/jsm/controls/OrbitControls.js"
    // 1. 创建三大核心
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    camera.position.z = 2;

    // 2. 自定义 BufferGeometry(带索引+顶点颜色)
    const geometry = new THREE.BufferGeometry();

    // 2.1 顶点坐标(4个唯一顶点,绘制矩形)
    const vertices = new Float32Array([
      -0.5, -0.5, 0,  // 顶点0
       0.5, -0.5, 0,  // 顶点1
      -0.5,  0.5, 0,  // 顶点2
       0.5,  0.5, 0   // 顶点3
    ]);
    geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

    // 2.2 顶点颜色(4个顶点对应4组颜色)
    const colors = new Float32Array([
      1, 0, 0,  // 顶点0:红
      0, 1, 0,  // 顶点1:绿
      0, 0, 1,  // 顶点2:蓝
      1, 1, 0   // 顶点3:黄
    ]);
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

    // 2.3 索引缓冲区(指定三角面的顶点顺序)
    const indices = new Uint16Array([
      0, 1, 2,  // 第一个三角面:0→1→2
      1, 3, 2   // 第二个三角面:1→3→2
    ]);
    geometry.setIndex(new THREE.BufferAttribute(indices, 1));

    // 2.4 计算法线(可选,若用受光照材质则需要)
    geometry.computeVertexNormals();

    // 3. 创建材质(启用顶点颜色)
    const material = new THREE.MeshBasicMaterial({
      vertexColors: true, // 启用顶点颜色
      side: THREE.DoubleSide,
      wireframe: false
    });

    // 4. 创建网格并添加到场景
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    // 5. 轨道控制器(交互)
    const controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.05;

    // 6. 动画循环
    function animate() {
      requestAnimationFrame(animate);
      mesh.rotation.x += 0.01;
      mesh.rotation.y += 0.01;
      controls.update();
      renderer.render(scene, camera);
    }
    animate();

    // 7. 窗口适配
    window.addEventListener('resize', () => {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
    });
  </script>
</body>
</html>

示例效果

56382a99-8b86-4af3-9b51-172939c77ff5.png

  • 场景中显示一个彩色矩形(由两个三角面组成);
  • 矩形顶点分别为红、绿、蓝、黄,面内自动渐变;
  • 支持鼠标旋转/缩放视角,矩形缓慢旋转。

五、注意事项与性能优化

1. 关键注意点

  • 类型化数组必须正确:顶点坐标用 Float32Array,索引用 Uint16Array/Uint32Array,不能混用;
  • itemSize 必须匹配:坐标=3,颜色=3/4,UV=2,索引=1,错误会导致几何体显示异常;
  • 双面渲染:自定义几何体默认只渲染正面,需设置 side: THREE.DoubleSide 避免背面不可见;
  • 内存释放:不再使用的几何体,必须调用 geometry.dispose() 释放内存,避免内存泄漏。

2. 性能优化技巧

  • 使用索引缓冲区:减少重复顶点,降低数据量;
  • 控制顶点数量:复杂几何体按需分段,避免顶点过多;
  • 静态数据复用:相同形状的几何体复用,无需重复创建;
  • drawRange 局部渲染:只渲染需要显示的顶点范围,减少计算。

核心总结

  1. 核心地位:BufferGeometry 是 Three.js 所有几何体的底层核心,官方唯一推荐使用;
  2. 核心流程:类型化数组→BufferAttribute→setAttribute→绑定到渲染对象;
  3. 核心优化:索引缓冲区可减少顶点重复,是高性能自定义几何体的关键;
  4. 核心属性position(坐标)、color(颜色)、uv(纹理)、normal(法线)是最常用的属性;
  5. 性能原则:用类型化数组、复用几何体、释放无用数据,最大化 GPU 渲染效率。

ThreeJS 精通粒子特效

本文档涵盖了Three.js中粒子特效的关键概念和实现方法,基于实际代码示例进行讲解。

1. 粒子系统基础

1.1 点材质 (PointsMaterial) 设置

点材质是创建粒子特效的基础,可以通过多种参数配置粒子外观:

// 设置点材质
const pointsMaterial = new THREE.PointsMaterial();
pointsMaterial.size = 0.1;                           // 粒子大小
pointsMaterial.color.set(0xfff000);                 // 粒子颜色
pointsMaterial.sizeAttenuation = true;              // 是否根据相机深度衰减粒子大小
pointsMaterial.sizeAttenuation = true;              // 相机深度衰减

1.2 粒子纹理配置

为了增强粒子的视觉效果,通常会使用纹理:

// 载入纹理
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load("./textures/particles/2.png");

// 设置点材质纹理
pointsMaterial.map = texture;                       // 纹理贴图
pointsMaterial.alphaMap = texture;                  // 透明度贴图
pointsMaterial.transparent = true;                  // 启用透明度
pointsMaterial.depthWrite = false;                  // 禁用深度写入
pointsMaterial.blending = THREE.AdditiveBlending;   // 混合模式

2. 粒子几何体创建

2.1 使用缓冲几何体 (BufferGeometry)

高效的粒子系统通常使用缓冲几何体来存储大量顶点数据:

// 创建粒子几何体
const particlesGeometry = new THREE.BufferGeometry();
const count = 5000;                                // 粒子数量

// 设置缓冲区数组
const positions = new Float32Array(count * 3);     // 位置数组
const colors = new Float32Array(count * 3);        // 颜色数组

// 设置顶点位置
for (let i = 0; i < count * 3; i++) {
  positions[i] = (Math.random() - 0.5) * 100;      // 随机位置
  colors[i] = Math.random();                        // 随机颜色
}

// 将属性添加到几何体
particlesGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
particlesGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));

2.2 从现有几何体创建粒子

可以从现有的几何体(如球体)转换为粒子:

// 创建球几何体
const sphereGeometry = new THREE.SphereBufferGeometry(3, 30, 30);

// 删除UV属性(如果不需要纹理映射)
delete sphereGeometry.attributes.uv;

// 创建粒子系统
const points = new THREE.Points(sphereGeometry, pointsMaterial);
scene.add(points);

3. 星河粒子系统

3.1 基础星河效果

创建具有随机分布的星河效果:

// 生成星河粒子
const generateGalaxy = () => {
  geometry = new THREE.BufferGeometry();
  const positions = new Float32Array(params.count * 3);
  const colors = new Float32Array(params.count * 3);

  // 循环生成点
  for (let i = 0; i < params.count; i++) {
    // 当前的点应该在哪一条分支的角度上
    const branchAngel = (i % params.branch) * ((2 * Math.PI) / params.branch);

    // 当前点距离圆心的距离
    const distance = Math.random() * params.radius * Math.pow(Math.random(), 3);
    const current = i * 3;

    // 随机偏移
    const randomX = (Math.pow(Math.random() * 2 - 1, 3) * (params.radius - distance)) / 5;
    const randomY = (Math.pow(Math.random() * 2 - 1, 3) * (params.radius - distance)) / 5;
    const randomZ = (Math.pow(Math.random() * 2 - 1, 3) * (params.radius - distance)) / 5;

    // 计算最终位置
    positions[current] = Math.cos(branchAngel + distance * params.rotateScale) * distance + randomX;
    positions[current + 1] = 0 + randomY;
    positions[current + 2] = Math.sin(branchAngel + distance * params.rotateScale) * distance + randomZ;

    // 混合颜色,形成渐变色
    const mixColor = centerColor.clone();
    mixColor.lerp(endColor, distance / params.radius);

    colors[current] = mixColor.r;
    colors[current + 1] = mixColor.g;
    colors[current + 2] = mixColor.b;
  }

  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));

  // 设置点材质
  material = new THREE.PointsMaterial({
    size: params.size,
    sizeAttenuation: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    map: particlesTexture,
    alphaMap: particlesTexture,
    transparent: true,
    vertexColors: true,                             // 使用顶点颜色
  });

  points = new THREE.Points(geometry, material);
  scene.add(points);
};

4. 雪花粒子系统

4.1 多层雪花效果

创建多个层次的雪花粒子系统:

function createPoints(url, size = 0.5) {
  const particlesGeometry = new THREE.BufferGeometry();
  const count = 10000;

  // 设置缓冲区数组
  const positions = new Float32Array(count * 3);
  const colors = new Float32Array(count * 3);

  // 设置顶点
  for (let i = 0; i < count * 3; i++) {
    positions[i] = (Math.random() - 0.5) * 100;    // 在空间内随机分布
    colors[i] = Math.random();                      // 随机颜色
  }

  particlesGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  particlesGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));

  // 设置点材质
  const pointsMaterial = new THREE.PointsMaterial();
  pointsMaterial.size = size;                       // 不同大小的粒子
  pointsMaterial.color.set(0xfff000);
  pointsMaterial.sizeAttenuation = true;

  // 载入纹理
  const textureLoader = new THREE.TextureLoader();
  const texture = textureLoader.load(`./textures/particles/${url}.png`);

  pointsMaterial.map = texture;
  pointsMaterial.alphaMap = texture;
  pointsMaterial.transparent = true;
  pointsMaterial.depthWrite = false;
  pointsMaterial.blending = THREE.AdditiveBlending;
  pointsMaterial.vertexColors = true;               // 启用顶点颜色

  const points = new THREE.Points(particlesGeometry, pointsMaterial);
  scene.add(points);
  return points;
}

// 创建多层雪花效果
const points = createPoints("1", 1.5);
const points2 = createPoints("xh", 1);
const points3 = createPoints("xh", 2);

4.2 粒子动画

为粒子系统添加动画效果:

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

  // 旋转动画
  points.rotation.x = time * 0.3;
  points2.rotation.x = time * 0.5;
  points2.rotation.y = time * 0.4;
  points3.rotation.x = time * 0.2;
  points3.rotation.y = time * 0.2;

  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

5. 渲染器配置

5.1 基础渲染器设置

针对粒子系统优化渲染器:

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);

// 开启场景中的阴影贴图
renderer.shadowMap.enabled = true;
renderer.physicallyCorrectLights = true;

// 将渲染器添加到DOM
document.body.appendChild(renderer.domElement);

6. 粒子系统优化技巧

6.1 混合模式

使用适当的混合模式提升视觉效果:

// 加法混合,常用于发光效果
pointsMaterial.blending = THREE.AdditiveBlending;

6.2 深度写入控制

控制粒子的深度写入行为:

// 禁用深度写入,避免粒子间的遮挡问题
pointsMaterial.depthWrite = false;

6.3 粒子大小衰减

根据距离调整粒子大小:

// 启用大小衰减,远处的粒子看起来更小
pointsMaterial.sizeAttenuation = true;

7. 控制器和辅助工具

7.1 轨道控制器

添加交互式视角控制:

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);

// 设置控制器阻尼,让控制器更有真实效果
controls.enableDamping = true;

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

8. 响应式设计

处理窗口大小变化:

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

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

总结

本章详细介绍了Three.js中粒子特效的各个方面,包括:

  1. 粒子系统基础概念
  2. 点材质的配置和优化
  3. 缓冲几何体的使用
  4. 星河和雪花粒子系统的创建
  5. 粒子动画和交互
  6. 渲染优化技巧

通过合理运用这些技术,可以创建出丰富多彩的粒子特效,如星系、雪花、烟雾、火焰等视觉效果。关键在于理解粒子的几何体构建、材质配置以及性能优化方法,从而在保证视觉效果的同时维持良好的运行性能。

# 🚀 前端性能核心:全链路缓存架构指南

"最好的网络请求是不发送请求。" —— 前端性能优化第一性原理

本文档旨在为您提供一套高级、系统化且通俗易懂的前端缓存解决方案。通过分层架构图、决策流程表和实战对比,帮助您从零构建高性能的 Web 应用。


📚 目录

  1. 核心架构:洋葱模型
  2. 机制对比:武器库一览
  3. 决策指南:该用哪种缓存?
  4. 实战场景:代码与收益
  5. 最佳实践清单

1. 核心架构:洋葱模型

前端缓存并非单一技术,而是一个分层的防御体系。请求像穿过洋葱一样,层层检查缓存,直到不得不向服务器发起请求。

graph TD
    A[用户发起请求] --> B{1. 内存缓存 Memory Cache}
    B -- 命中 (0ms) --> R[返回数据]
    B -- 未命中 --> C{2. Service Worker Cache}
    C -- 命中 (<5ms) --> R
    C -- 未命中 --> D{3. HTTP 磁盘缓存 Disk Cache}
    D -- 命中 (10-50ms) --> R
    D -- 未命中 --> E{4. Push Cache / HTTP/2}
    E -- 命中 --> R
    E -- 未命中 --> F[📡 发起网络请求]
    F --> G{CDN 边缘节点}
    G -- 命中 --> R
    G -- 未命中 --> H[源服务器 Origin Server]
    H --> R

🔍 层级解析

层级 关键词 存活时间 特点 适用场景
L1 内存缓存 变量/State 页面关闭即逝 速度极快 (RAM),无网络开销 页面内临时数据、图片
L2 Service Worker Programmable 持久化 (需手动管理) 离线可用,完全可编程控制 App Shell、离线应用、精细化资源控制
L3 HTTP 磁盘缓存 Cache-Control 由 Header 决定 被动管理,浏览器自动处理 CSS/JS 文件、通用静态资源
L4 网络请求 CDN/Server N/A 最慢,受网络环境影响大 实时数据、首次加载

2. 机制对比:武器库一览

不同的存储方案对应不同的作战场景。不要手里拿着锤子(localStorage),看什么都是钉子。

📊 浏览器存储方案横向评测

特性 Memory (变量/Map) Local Storage Session Storage IndexedDB Cookies
容量限制 仅受内存限制 ~5MB ~5MB >250MB (大容量) 4KB
读写速度 🚀 极快 (纳秒级) 🐢 慢 (同步阻塞) 🐢 慢 (同步阻塞) ⚡️ 快 (异步) 🐢 慢 (随请求发送)
生命周期 刷新即失 永久有效 会话级 (Tab关闭即失) 永久有效 可配置过期时间
数据结构 任意 JS 对象 字符串 (String) 字符串 (String) 结构化数据/二进制 字符串
最佳用途 高频交互数据 用户偏好设置 表单草稿 大量数据/文件/离线数据 身份认证 Token

3. 决策指南:该用哪种缓存?

当您面对一个资源时,请参考此流程图进行决策:

flowchart TD
    Start((资源请求)) --> Q1{数据是否私有?}
    
    Q1 -- 是 (用户数据) --> Q2{数据实时性要求?}
    Q1 -- 否 (公共资源) --> Q3{内容是否固定不变?}
    
    Q2 -- 高 (股票/状态) --> A[❌ 不缓存 / 仅内存短时缓存]
    Q2 -- 中 (个人资料) --> B[✅ 协商缓存 (Etag) + 内存缓存]
    Q2 -- 低 (历史订单) --> C[✅ IndexedDB / LocalStorage 持久化]
    
    Q3 -- 是 (Libs/Logo) --> D[✅ 强缓存 1年 (Cache-Control: immutable)]
    Q3 -- 否 (HTML/配置) --> E[✅ 协商缓存 (no-cache)]
    
    style A fill:#ffcccc,stroke:#333
    style D fill:#ccffcc,stroke:#333

💡 缓存策略速查表

资源类型 推荐策略 (Header) 解释
HTML 主入口 no-cache 每次都向服务器验证,确保用户总是获得最新版本引用。
JS / CSS / 图片 max-age=31536000, immutable 强缓存 1 年。配合文件名 Hash (e.g., main.a1b2c3.js) 实现更新。
API 接口数据 private, max-age=60 私有缓存。允许客户端缓存 60 秒,过期后重新请求。
敏感数据 no-store 禁止任何缓存,不在任何地方留下痕迹。

4. 实战场景:代码与收益

场景一:静态资源(强缓存)

问题:每次刷新页面,浏览器都要重新下载巨大的 JS bundle 和高清图片,浪费带宽且加载慢。

✅ 解决方案:Nginx 配置强缓存 + 文件名 Hash。

# Nginx 配置示例
location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
    # 缓存 1 年,告诉浏览器:只要文件名没变,就别来烦我
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

📈 效果对比

指标 优化前 (无缓存) 优化后 (强缓存) 提升幅度
首屏加载 (2nd View) 2.5s 0.3s 🔥 88%
带宽消耗 100% < 1% 💰 99%
HTTP 请求数 50+ 1-2 (仅HTML) 📉 95%

场景二:高频 API(内存缓存)

问题:用户在"商品列表"和"详情页"之间反复切换,每次都要重新请求相同的商品数据,导致页面闪烁。

✅ 解决方案:实现一个简单的内存缓存层。

const memoryCache = new Map();

async function fetchWithCache(url, ttl = 5000) {
    const now = Date.now();
    
    // 1. 检查缓存是否有效
    if (memoryCache.has(url)) {
        const { data, timestamp } = memoryCache.get(url);
        if (now - timestamp < ttl) {
            console.log('⚡️ 命中内存缓存:', url);
            return data;
        }
    }

    // 2. 缓存失效或不存在,发起请求
    console.log('📡 发起网络请求:', url);
    const response = await fetch(url);
    const data = await response.json();

    // 3. 写入缓存
    memoryCache.set(url, { data, timestamp: now });
    return data;
}

📈 效果对比

行为 优化前 优化后 用户感知
点击返回列表 Loading 转圈 1s 瞬间展示 "这也太快了吧!" 😲
服务器 QPS 1000 200 压力降低 80% 🛡️

场景三:离线优先(Service Worker)

问题:弱网环境下(如地铁、电梯),应用直接白屏或断网,无法提供基础服务。

✅ 解决方案:使用 Service Worker 拦截请求,优先返回缓存。

// sw.js (简化版)
self.addEventListener('fetch', event => {
  // 拦截请求
  event.respondWith(
    caches.match(event.request).then(cachedResponse => {
      // 策略:Cache First (缓存优先)
      // 1. 如果缓存里有,直接返回缓存 (极速)
      if (cachedResponse) {
        return cachedResponse;
      }
      // 2. 如果没有,再去网络请求
      return fetch(event.request);
    })
  );
});

📈 效果对比

环境 优化前 优化后
4G 网络 加载需 1.5s < 100ms
断网/飞行模式 🦖 恐龙小游戏 (404) 正常浏览 (显示旧数据)

5. 最佳实践清单

在实施缓存策略时,请遵循以下 "黄金法则"

  1. HTML 永远不要强缓存Cache-Control: no-cache。这是你的更新入口,一旦缓存了 HTML,你发新版用户都看不见。
  2. 静态资源加 Hashscript.js ❌ -> script.8a7b9c.js ✅。配合强缓存使用,更新时只需改文件名。
  3. 区分用户数据:API 响应头如果是用户私有的,务必加上 private,防止 CDN 缓存了 A 用户的个人资料给 B 用户看。
  4. 善用 Stale-While-Revalidate:这是一种"即时响应,后台更新"的策略。先给用户看旧数据(秒开),后台悄悄去取新数据更新 UI。
  5. 监控命中率:没有监控的优化是盲人摸象。在 Response Header 中添加 X-Cache: HIT/MISS 来观察效果。

总结:缓存是空间换时间的艺术。合理的缓存策略能让你的应用从 "可用" 变为 "极致流畅"。从今天开始,检查你的每一个请求,问自己:"这个请求,真的需要发出去吗?"

面试官:手写一个Promise.all

Promise.all是javascript中处理并发请求的核心方法。面试中经常会被要求手写实现,以考察对Promise状态机、异步处理以及数组遍历的掌握。

1. Promise.all的核心逻辑

在动手写之前,先明确它的四个特性:

  1. 输入:接收一个可迭代对象(通常是数组)

  2. 返回:返回一个新的Promise

  3. 成功条件:只有当数组中所有的Promise都成功时,新Promise才成功,并返回一个按顺序排列的结果数组

  4. 失败条件: 只要有一个Promise失败,新Promise立即失败(Fail-fase),并返回第一个失败的错误

2. 手写代码实现

    /**
 * 实现Promise.all
 * 
 * @param promises 一个包含多个Promise实例的数组
 * @returns 一个新的Promise实例,只有当所有传入的Promise实例都成功时才会成功,否则会在第一个失败的Promise实例时失败
 */ 


function promissAll<T>(promises: Promise<T>[]): Promise<T[]> {
    // 1. 返回一个新的Promise实例
    return new Promise<T[]>((resolve, reject) => {
        // 判断参数是否为可迭代对象
        if (!promises || typeof promises[Symbol.iterator] !== 'function') {
            return reject(new TypeError('Argument is not iterable'));
        }
        const results: T[] = []; // 用于存储每个Promise的结果
        let completedCount = 0; // 用于跟踪已完成的Promise数量
        const promiseArray = Array.from(promises); // 将传入的参数转换为数组
        const total = promiseArray.length; // 获取Promise的总数量

        // 2. 如果传入的数组为空,立即resolve一个空数组
        if (total === 0) {
            resolve(results);
            return;
        }
        // 3. 遍历每个Promise实例
        promiseArray.forEach((promise, index) => {
            // 使用Promise.resolve确保即使传入的不是Promise实例也能正确处理
            Promise.resolve(promise).then((value) => {
                results[index] = value; // 存储当前Promise的结果
                completedCount++;
                // 如果所有Promise都已完成,resolve最终结果数组
                if (completedCount === total) {
                    resolve(results);
                }
            })
        }, (error) => {
            // 4. 如果有任何一个Promise实例失败,立即reject
            reject(error);
        })
    })

}

3. 实现细节解析

Q1: 为什么不直接result.push(value)?

  • 原因: Promise是异步的,执行完成的时间不确定。如果使用push,返回的结果顺序会乱,必须通过result[index]确保结果和原数组的索引一一对应。 Q2: 为什么用count计数而不是判断result.length?
  • 原因:在javascript数组中,如果你先复制了索引为2的值result[2] = 'a', 此时result.length 会直接变成3,但索引0和1可能还没完成。所以必须使用独立的计数器。

Q3: Promise.resolve(promise)的作用?

  • 请在评论区回复

Q4: Fail-fase(快速失败)机制

  • 一旦其中一个Promise触发了reject,新的Promise的状态就会变成rejected。由于Promise的状态只能改变一次,后续Promise成功的调用会被忽略

4. 测试用例

const p1 = Promise.resolve(1);
const p2 = new Promise((resolve) => setTimeout(() => resolve(2), 1000));
const p3 = 3; // 普通值

promissAll([p1, p2, p3])
  .then(res => console.log('成功:', res)) // 1秒后输出: 成功: [1, 2, 3]
  .catch(err => console.log('失败:', err));

const p4 = Promise.reject('报错了');
promissAll([p1, p4, p2])
  .then(res => console.log(res))
  .catch(err => console.log('失败:', err)); // 立即输出: 失败: 报错了

5. 总结

手写 Promise.all 的口诀:返回一个新 Promise,遍历数组看结果,索引对应存数据,全部成功才 resolve,一个失败就 reject。

细心的老铁肯定也看到了,Q3没有输出答案,欢迎各位大牛在评论区留言

[Python3/Java/C++/Go/TypeScript] 一题一解:位运算(清晰题解)

方法一:位运算

对于一个整数 $a$,满足 $a \lor (a + 1)$ 的结果一定为奇数,因此,如果 $\text{nums[i]}$ 是偶数,那么 $\text{ans}[i]$ 一定不存在,直接返回 $-1$。本题中 $\textit{nums}[i]$ 是质数,判断是否是偶数,只需要判断是否等于 $2$ 即可。

如果 $\text{nums[i]}$ 是奇数,假设 $\text{nums[i]} = \text{0b1101101}$,由于 $a \lor (a + 1) = \text{nums[i]}$,等价于将 $a$ 的最后一个为 $0$ 的二进制位变为 $1$。那么求解 $a$,就等价于将 $\text{nums[i]}$ 的最后一个 $0$ 的下一位 $1$ 变为 $0$。我们只需要从低位(下标为 $1$)开始遍历,找到第一个为 $0$ 的二进制位,如果是第 $i$ 位,那么我们就将 $\text{nums[i]}$ 的第 $i - 1$ 位变为 $1$,即 $\text{ans}[i] = \text{nums[i]} \oplus 2^{i - 1}$。

遍历所有的 $\text{nums[i]}$,即可得到答案。

###python

class Solution:
    def minBitwiseArray(self, nums: List[int]) -> List[int]:
        ans = []
        for x in nums:
            if x == 2:
                ans.append(-1)
            else:
                for i in range(1, 32):
                    if x >> i & 1 ^ 1:
                        ans.append(x ^ 1 << (i - 1))
                        break
        return ans

###java

class Solution {
    public int[] minBitwiseArray(List<Integer> nums) {
        int n = nums.size();
        int[] ans = new int[n];
        for (int i = 0; i < n; ++i) {
            int x = nums.get(i);
            if (x == 2) {
                ans[i] = -1;
            } else {
                for (int j = 1; j < 32; ++j) {
                    if ((x >> j & 1) == 0) {
                        ans[i] = x ^ 1 << (j - 1);
                        break;
                    }
                }
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    vector<int> minBitwiseArray(vector<int>& nums) {
        vector<int> ans;
        for (int x : nums) {
            if (x == 2) {
                ans.push_back(-1);
            } else {
                for (int i = 1; i < 32; ++i) {
                    if (x >> i & 1 ^ 1) {
                        ans.push_back(x ^ 1 << (i - 1));
                        break;
                    }
                }
            }
        }
        return ans;
    }
};

###go

func minBitwiseArray(nums []int) (ans []int) {
for _, x := range nums {
if x == 2 {
ans = append(ans, -1)
} else {
for i := 1; i < 32; i++ {
if x>>i&1 == 0 {
ans = append(ans, x^1<<(i-1))
break
}
}
}
}
return
}

###ts

function minBitwiseArray(nums: number[]): number[] {
    const ans: number[] = [];
    for (const x of nums) {
        if (x === 2) {
            ans.push(-1);
        } else {
            for (let i = 1; i < 32; ++i) {
                if (((x >> i) & 1) ^ 1) {
                    ans.push(x ^ (1 << (i - 1)));
                    break;
                }
            }
        }
    }
    return ans;
}

时间复杂度 $O(n \times \log M)$,其中 $n$ 和 $M$ 分别是数组 $\text{nums}$ 的长度和数组中的最大值。忽略答案数组的空间消耗,空间复杂度 $O(1)$。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈 😄~

幼儿园前端 #10:表单进阶(下) —— 必填?邮箱?勾选?一行代码立规矩!

前言: 大家好,我是[小奇腾]。欢迎来到 “幼儿园前端” 第 10 集! 上一集我们做出了登录框的“样子”,但它是个“傻白甜”——你不填密码它让你过,你填个乱七八糟的邮箱它也让你过。 今天我们要给表单加点**“智商”**。 不需要写复杂的 JS 代码,只需要在 HTML 标签里加几个单词,浏览器就能自动帮你拦截不听话的用户!

本期详细的视频教程bilibili:幼儿园前端 #10:表单进阶(下) —— 必填?邮箱?勾选?一行代码立规矩!

一、 最强保安:required (必填)

这是表单里最实用的属性,没有之一。 它的作用是: “你不填满,就不准提交!”

写法:只需要在 <input> 标签里添加 required 这个单词

<input type="text" name="username" required>

效果:如果你留空并点击提交按钮,浏览器会弹出一个提示框(气泡):“请填写此字段”。如果系统语言设置是英文就显示英文。

注意:这个验证是当用户点击提交的时候才会触发

二、 格式专家:type="email"

之前我们都用 type="text",用户可以在里面填“哈哈哈哈”。 但如果你要用户填邮箱,怎么办?

HTML5 自带验证:type="text" 改成 type="email"

<label>
    邮箱:<input type="email" name="user_email" required>
</label>

效果

  1. 手机端键盘变了:手机会自动弹出带 @ 符号的专用键盘。
  2. 自动检查:如果用户填了“12345”这种不像邮箱的东西,点击提交时,浏览器会报错:“请在邮箱地址中包含 @ 符号”。

同理还有

  • type="number":只能输数字。(带有增减器)
  • type="tel":调出手机号键盘。(只在手机端生效)
  • type="date":直接弹出一个日历选择器!📅

三、 勾选框:type="checkbox"

登录页通常还有个**“记住我”或者“同意用户协议”**的功能。 这种“是/否”的选择,我们用 复选框 (Checkbox)

如果你想让它一打开页面就是打钩的状态,加个 checked 单词。

<label> <input type="checkbox" name="agree" checked> 我已阅读并同意协议 </label>

四、 动手实战:做一个“智能”登录框

打开 VS Code,新建 10-form-pro.html。 这次我们要把“保安”和“勾选框”全都用上!

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>智能登录框</title>
</head>
<body>

    <h1>用户登录 (Pro版)</h1>

    <form action="#">
        
        <div style="margin-bottom: 15px;">
            <label>
                邮箱:
                <input type="email" name="email" placeholder="请输入邮箱" required>
            </label>
        </div>

        <div style="margin-bottom: 15px;">
            <label>
                密码:
                <input type="password" name="password" required>
            </label>
        </div>

        <div style="margin-bottom: 20px;">
            <label>
                <input type="checkbox" name="remember" checked> 
                下次自动登录
            </label>
        </div>

        <button type="submit">登录</button>
        
    </form>

</body>
</html>

五、 亲自测试(找茬环节)

请大家在 Live Server 里狠狠地“折磨”这个表单:

  1. 什么都不填,直接点登录 -> ❌ 被 required 拦住了吗?
  2. 邮箱栏填“张三”,点登录 -> ❌ 被 type="email" 拦住了吗?
  3. 填好邮箱和密码,点登录 -> ✅ 地址栏出现数据了吗?

能通过这三关,你的表单才算合格!

🔥 Promise-Logic:基于逻辑门理论的声明式异步组合库

一、设计哲学与核心优势

PromiseLogic 构建于一个核心理念之上:

  • 大多数复杂的异步协调逻辑本质上可归结为逻辑门决策问题
  • 通过将数字电路理论中的形式化逻辑系统映射到 Promise 组合领域,该库的实现如下:

1.1 理论框架映射

布尔代数系统 → 异步操作语义
逻辑变量(0/1) → Promise状态(成功/失败)
逻辑运算符 → Promise组合策略
真值表 → 行为可预测性

1.2 工程特色

  • 语义完整性:覆盖从基础AND/OR到复杂XNOR/MAJORITY的完整逻辑门集合
  • 类型安全性:完整的TypeScript泛型支持,编译时验证逻辑正确性
  • 零抽象泄漏:在原生Promise基础上构建,无运行时依赖,无隐藏状态
  • 框架适配高: 近乎适配所有框架,包括react、vue、angular、nuxt\next等

二、核心API完整解析

2.1 基础逻辑门方法

PromiseLogic.and(iterable)

  • 逻辑语义:布尔与运算 (A ∧ B ∧ C)
  • 成功条件:所有Promise均成功
  • 失败条件:任一Promise失败
  • 等价原生Promise.all()
  • 复杂度:O(n),n为Promise数量
// 分布式事务提交:所有服务确认后才提交
const commitSuccess = await PromiseLogic.and([
  paymentService.confirm(orderId),
  inventoryService.reserve(productId),
  notificationService.logTransaction(txId)
]);

// 多因素身份验证:必须全部通过
const isAuthenticated = await PromiseLogic.and([
  validatePassword(password),
  verify2FACode(code),
  checkDeviceFingerprint(deviceId)
]);

PromiseLogic.and()场景: 订单支付验证

import { PromiseLogic } from 'promise-logic';

// 订单支付需要所有验证通过
const validatePayment = async (orderId) => {
  const results = await PromiseLogic.and([
    validateCard(orderId),          // 验证信用卡
    checkFraud(orderId),            // 反欺诈检查
    verifyStock(orderId),           // 库存验证
    calculateTax(orderId)           // 税费计算
  ]);
  
  return results; // 数组形式:[cardResult, fraudResult, stockResult, taxResult]
};

PromiseLogic.or(iterable)

  • 逻辑语义:布尔或运算 (A ∨ B ∨ C)
  • 成功条件:任一Promise成功
  • 失败条件:所有Promise均失败
  • 近似原生Promise.any() + 定制错误处理
// 多CDN源回退策略
const resource = await PromiseLogic.or([
  fetchFromCDN('akamai', url).catch(() => null),
  fetchFromCDN('cloudflare', url).catch(() => null),
  fetchFromCDN('fastly', url).catch(() => null),
  fetchFromOriginServer(url) // 最终回退源
]);

// 高可用配置获取
const config = await PromiseLogic.or([
  fetchConfigFromConsul(),
  fetchConfigFromEtcd().catch(() => null),
  loadConfigFromLocalFile()
]);

1.2 PromiseLogic.or()场景: 多CDN资源获取

// 从多个CDN获取资源,任一成功即可
const getResource = async (resourceId) => {
  const data = await PromiseLogic.or([
    fetchFromCDN('https://cdn1.example.com/' + resourceId),
    fetchFromCDN('https://cdn2.example.com/' + resourceId),
    fetchFromCDN('https://cdn3.example.com/' + resourceId)
  ]);
  
  return data; // 返回第一个成功的CDN返回的数据
};

PromiseLogic.xor(iterable)

  • 逻辑语义:异或运算 (A ⊕ B)
  • 成功条件:恰好一个Promise成功
  • 失败条件:0个或多个Promise成功
  • 原生实现复杂度:高(需手动计数)
// 互斥锁实现:确保单点执行
const lock = await PromiseLogic.xor([
  acquireDistributedLock('resource-1'),
  waitForLockRelease('resource-1').then(() => Promise.reject('already locked'))
]);

// 数据源一致性检查:只能从一个源获取有效数据
const userData = await PromiseLogic.xor([
  cache.getUser(userId).then(data => 
    data ? data : Promise.reject('cache miss')
  ),
  database.getUser(userId)
]);

 PromiseLogic.xor()场景: 分布式锁

// 确保同一资源只被一个客户端访问
const acquireLock = async (resourceId) => {
  try {
    const lock = await PromiseLogic.xor([
      redis.set(`lock:${resourceId}`, 'locked', 'NX', 'EX', 30),
      Promise.reject(new Error('Resource already locked'))
    ]);
    
    return { success: true, lock };
  } catch (error) {
    return { success: false, reason: 'Resource already in use' };
  }
};

2.2 其他的逻辑门方法

PromiseLogic.nand(iterable)

  • 逻辑语义:与非运算 ¬(A ∧ B)
  • 成功条件:并非所有Promise都成功(至少一个失败)
  • 失败条件:所有Promise均成功
// 容错系统:允许部分组件失败
const systemStatus = await PromiseLogic.nand([
  healthCheck('service-a'),
  healthCheck('service-b'),
  healthCheck('service-c')
]);
// 只要不是所有服务都健康,就触发降级

// 灰度发布验证:新版本不能在所有节点都成功
const canaryTest = await PromiseLogic.nand([
  deployToCanary('v2.0', 'node-1'),
  deployToCanary('v2.0', 'node-2'),
  deployToCanary('v2.0', 'node-3')
]);

PromiseLogic.nand()场景: 容错监控系统

// 监控系统,允许部分组件失败
const monitorSystemHealth = async () => {
  const healthStatus = await PromiseLogic.nand([
    healthCheck('api-server'),
    healthCheck('database'),
    healthCheck('cache'),
    healthCheck('queue')
  ]);
  
  // 如果不是所有都健康,触发降级
  if (healthStatus) {
    await triggerDegradedMode();
  }
  
  return healthStatus;
};

PromiseLogic.nor(iterable)

  • 逻辑语义:或非运算 ¬(A ∨ B)
  • 成功条件:所有Promise均失败
  • 失败条件:任一Promise成功
// 灾难恢复测试:模拟完全故障场景
const disasterRecovery = await PromiseLogic.nor([
  pingPrimaryDatacenter(),
  pingSecondaryDatacenter(),
  pingBackupSite()
]);
// 只有所有数据中心都不可达时,才触发灾难恢复

// 安全熔断:所有降级方案都失败时的最终处理
const lastResort = await PromiseLogic.nor([
  tryPrimaryPaymentGateway(),
  tryBackupPaymentGateway(),
  tryLegacyPaymentSystem()
]).then(() => useManualProcessing());

 PromiseLogic.nor()场景:  灾难恢复测试

// 测试所有备用服务都不可用时的灾难恢复
const testDisasterRecovery = async () => {
  const allFailed = await PromiseLogic.nor([
    pingService('primary'),
    pingService('backup-1'),
    pingService('backup-2')
  ]);
  
  if (allFailed) {
    await activateEmergencyMode();
  }
};

PromiseLogic.xnor(iterable)

  • 逻辑语义:同或运算 (A ⊙ B) = ¬(A ⊕ B)
  • 成功条件:所有Promise状态一致(全成功或全失败)
  • 失败条件:状态不一致(部分成功部分失败)
// 数据副本一致性验证
const replicasConsistent = await PromiseLogic.xnor([
  readFromReplica('replica-1', key),
  readFromReplica('replica-2', key),
  readFromReplica('replica-3', key)
]);

// 集群节点状态同步检查
const clusterInSync = await PromiseLogic.xnor(
  clusterNodes.map(node => node.healthCheck())
);

PromiseLogic.xnor()场景:  数据副本一致性检查

// 检查分布式数据库副本是否一致
const checkDataConsistency = async (key) => {
  const isConsistent = await PromiseLogic.xnor([
    databaseReplica1.get(key),
    databaseReplica2.get(key),
    databaseReplica3.get(key)
  ]);
  
  return isConsistent; // true: 所有副本一致,false: 不一致
};

PromiseLogic.majority(iterable)

  • 逻辑语义:多数表决函数
  • 成功条件:成功Promise数 > 总数/2
  • 失败条件:成功Promise数 ≤ 总数/2
// 分布式共识算法(类似Raft)
const consensus = await PromiseLogic.majority(
  clusterNodes.map(node => node.propose(value))
);

// 多AI模型投票系统
const finalPrediction = await PromiseLogic.majority([
  modelA.predict(input),
  modelB.predict(input),
  modelC.predict(input),
  modelD.predict(input)
]);

PromiseLogic.majority()场景: 多AI模型决策

// 多个AI模型投票决策
const makePrediction = async (input) => {
  const prediction = await PromiseLogic.majority([
    modelA.predict(input),
    modelB.predict(input),
    modelC.predict(input),
    modelD.predict(input)
  ]);
  
  return prediction; // 多数模型同意的预测结果
};

2.3 扩展分析方法

PromiseLogic.allFulfilled(iterable)

  • 行为:始终resolve,返回成功Promise的结果数组
  • 失败处理:忽略失败,仅收集成功结果
// 批量数据采集:容忍部分失败
const sensorsData = await PromiseLogic.allFulfilled([
  readSensor('temperature'),
  readSensor('humidity'),
  readSensor('pressure').catch(() => 'sensor_offline'),
  readSensor('co2')
]);

// 并行API调用聚合
const [user, posts, comments] = await Promise.all([
  PromiseLogic.allFulfilled([getUser(1), getUser(2), getUser(3)]),
  PromiseLogic.allFulfilled([getPosts(1), getPosts(2), getPosts(3)]),
  PromiseLogic.allFulfilled([getComments(1), getComments(2)])
]);

 PromiseLogic.allFulfilled()场景: 批量用户通知

// 发送批量通知,收集成功发送的记录
const sendBulkNotifications = async (userIds, message) => {
  const promises = userIds.map(userId => 
    notificationService.send(userId, message)
      .catch(() => null) // 失败时返回null
  );
  
  const successfulSends = await PromiseLogic.allFulfilled(promises);
  const actualResults = successfulSends.filter(result => result !== null);
  
  return {
    total: userIds.length,
    successful: actualResults.length,
    failed: userIds.length - actualResults.length
  };
};

PromiseLogic.allRejected(iterable)

  • 行为:始终resolve,返回失败Promise的原因数组
  • 成功处理:忽略成功,仅收集失败原因
// 错误统计分析
const failures = await PromiseLogic.allRejected([
  processBatch(1),
  processBatch(2),
  processBatch(3),
  processBatch(4)
]);

console.log(`失败统计: ${failures.length}个`);
failures.forEach((error, index) => {
  logger.error(`批次${index + 1}失败:`, error.message);
});

PromiseLogic.allRejected()场景: 错误统计分析

// 批量处理任务,分析失败原因
const analyzeFailures = async (tasks) => {
  const failures = await PromiseLogic.allRejected(
    tasks.map(task => task.execute())
  );
  
  // 按错误类型分类
  const errorTypes = {};
  failures.forEach(error => {
    const type = error.message.split(':')[0];
    errorTypes[type] = (errorTypes[type] || 0) + 1;
  });
  
  return {
    totalTasks: tasks.length,
    failedTasks: failures.length,
    errorBreakdown: errorTypes
  };
};

2.4 实用工具方法

PromiseLogic.race(iterable)

行为:与原生Promise.race()相同 应用场景:超时控制、竞态条件处理

// 带超时的请求
const response = await PromiseLogic.race([
  fetch('/api/data'),
  timeout(5000).then(() => Promise.reject(new Error('请求超时')))
]);

// 多个数据源竞速
const fastestData = await PromiseLogic.race([
  queryCache().then(data => ({ source: 'cache', data })),
  queryDatabase().then(data => ({ source: 'database', data })),
  queryExternalAPI().then(data => ({ source: 'api', data }))
]);

PromiseLogic.race()场景:  API调用超时控制

// 带超时的API调用
const fetchWithTimeout = async (url, timeoutMs = 5000) => {
  const response = await PromiseLogic.race([
    fetch(url),
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error('Request timeout')), timeoutMs)
    )
  ]);
  
  return response;
};

PromiseLogic.allSettled(iterable)

  • 行为:与ES2020的Promise.allSettled()相同
  • 返回:包含所有Promise最终状态的数组
// 兼容性包装
const results = await PromiseLogic.allSettled(requests);
const fulfilledValues = results
  .filter(r => r.status === 'fulfilled')
  .map(r => r.value);

 PromiseLogic.allSettled()场景:  兼容性包装

// 兼容不同环境的Promise.allSettled
const getAllSettledResults = async (promises) => {
  if (Promise.allSettled) {
    return await Promise.allSettled(promises);
  }
  
  // 使用PromiseLogic的allSettled作为fallback
  return await PromiseLogic.allSettled(promises);
};

PromiseLogic.createFlipFlop(initialState?)

  • 创建:一个具有记忆功能的触发器
  • 方法.toggle().setState().getState().waitForState()
// 分布式限流器实现
const rateLimiter = PromiseLogic.createFlipFlop(false);

async function makeRequest() {
  // 等待限流器关闭
  await rateLimiter.waitForState(false);
  
  // 打开限流器
  await rateLimiter.setState(true);
  
  try {
    return await api.request();
  } finally {
    // 延迟关闭限流器
    setTimeout(() => rateLimiter.setState(false), 1000);
  }
}

 PromiseLogic.createFlipFlop()场景:  状态机控制

// 创建状态锁控制并发访问
const createConcurrentLimiter = (maxConcurrent = 3) => {
  const availableSlots = Array(maxConcurrent).fill(null)
    .map(() => PromiseLogic.createFlipFlop(true));
  
  return {
    async acquire() {
      // 等待任一可用slot
      const availableIndex = await PromiseLogic.or(
        availableSlots.map((slot, index) =>
          slot.waitForState(true).then(() => index)
        )
      );
      
      // 占用slot
      await availableSlots[availableIndex].setState(false);
      
      return {
        slotIndex: availableIndex,
        release: () => availableSlots[availableIndex].setState(true)
      };
    }
  };
};

// 使用示例
const limiter = createConcurrentLimiter(3);
const { slotIndex, release } = await limiter.acquire();
try {
  await processTask();
} finally {
  release();
}

三、工厂模式与自定义配置

3.1 基础工厂模式

import { createPromiseLogic } from 'promise-logic';

// 创建定制实例
const AsyncCircuit = createPromiseLogic({
  prefix: 'circuit_', //方法前缀
  suffix: '_gate',  //方法后缀
  rename: {              //为不同的promise组合逻辑自定义名称
    and: 'conjunction', 
    or: 'disjunction',
    xor: 'exclusiveOr',
    majority: 'quorum'
  }
});

// 使用定制方法名:  circuit(前缀名) + and: 'conjunction'(自定义名称) + _gate(后缀名)
const result = await AsyncCircuit.circuit_conjunction_gate([
  promise1,
  promise2
]);

3.2 领域特定语言(DSL)构建

// 构建微服务编排DSL
const MicroserviceOrchestrator = createPromiseLogic({
  rename: {
    and: 'requireAll',
    or: 'requireAny',
    xor: 'requireExactlyOne',
    majority: 'requireQuorum',
    allFulfilled: 'collectSuccesses',
    allRejected: 'collectFailures'
  }
});

// 使用DSL编排服务
class ServiceCoordinator {
  async registerUser(userData) {
    return MicroserviceOrchestrator.requireAll([
      this.validateUser(userData),
      this.checkDuplicate(userData.email),
      this.createAccount(userData),
      this.sendWelcomeEmail(userData.email)
    ]);
  }
  
  async fetchUserData(userId) {
    return MicroserviceOrchestrator.requireAny([
      this.cache.getUser(userId),
      this.database.getUser(userId),
      this.backupService.getUser(userId)
    ]);
  }
}

3.3 组合式工厂

// 组合多个配置策略
function createAdvancedLogic(config = {}) {
  const baseLogic = createPromiseLogic(config);
  
  // 添加自定义方法
  return Object.assign(baseLogic, {
    // 链式回退策略
    fallbackChain: async function(promises) {
      for (const promise of promises) {
        try {
          return await promise();
        } catch (error) {
          continue;
        }
      }
      throw new Error('All fallbacks failed');
    },
    
    // 带重试的逻辑门
    withRetry: function(gateName, promises, options) {
      return async function retryLogic() {
        for (let i = 0; i < options.retries; i++) {
          try {
            return await baseLogic[gateName](promises);
          } catch (error) {
            if (i === options.retries - 1) throw error;
            await delay(options.delay);
          }
        }
      };
    }
  });
}

四、错误处理与类型安全

4.1 结构化错误类型

interface PromiseLogicError<T> extends Error {
  type: 'LOGIC_GATE_ERROR';
  gate: keyof typeof LogicGates;
  input: Array<PromiseSettledResult<T>>;
  expected: LogicCondition;
  actual: LogicCondition;
  
  // 详细的错误信息
  getSuccessCount(): number;
  getFailureCount(): number;
  getSuccessValues(): T[];
  getFailureReasons(): string[];
}

// 类型守卫
function isPromiseLogicError(error: Error): error is PromiseLogicError {
  return error?.type === 'LOGIC_GATE_ERROR';
}

4.2 完整的TypeScript支持

import { PromiseLogic, PromiseLogicError } from 'promise-logic';

// 泛型类型推断
const numbers: number[] = await PromiseLogic.and<number>([
  Promise.resolve(1),
  Promise.resolve(2),
  Promise.resolve(3)
]);

// 元组类型支持
const tuple: [string, number, boolean] = await PromiseLogic.and([
  Promise.resolve('text'),
  Promise.resolve(42),
  Promise.resolve(true)
]);

// 错误处理类型安全
try {
  await PromiseLogic.xor(operations);
} catch (error: unknown) {
  if (error instanceof PromiseLogicError) {
    console.error(`${error.gate} gate failed:`, error.getFailureReasons());
  }
}

五、性能优化与最佳实践

5.1 性能特征分析

// 基准测试对比(Node.js 18,10000次迭代)
const benchmark = {
  'Promise.all': '45.2ms ± 1.8',
  'PromiseLogic.and': '47.1ms ± 2.1',  // +4.2%开销
  'Promise.any': '38.7ms ± 1.5',
  'PromiseLogic.or': '40.2ms ± 1.7',   // +3.9%开销
  '手动实现xor': '62.3ms ± 3.2',
  'PromiseLogic.xor': '49.8ms ± 2.3'   // -20.1%优化
};

// 内存使用分析
const memoryUsage = {
  '基础逻辑门': '~2.3KB 每个实例',
  '完整库(gzipped)': '4.8KB',
  'Tree-shaken最小包': '< 1KB'
};

5.2 生产环境最佳实践

批量操作模式

// 分页批处理优化
async function processInBatches(items, batchSize, operation) {
  const batches = [];
  
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    batches.push(
      PromiseLogic.allFulfilled(
        batch.map(item => operation(item).catch(() => null))
      )
    );
  }
  
  // 使用and确保所有批次完成
  const batchResults = await PromiseLogic.and(batches);
  return batchResults.flat().filter(Boolean);
}

资源池管理

// 连接池并发控制
class ConnectionPool {
  constructor(size) {
    this.semaphore = PromiseLogic.createFlipFlop(false);
    this.connections = Array.from({ length: size }, () => 
      PromiseLogic.createFlipFlop(true) // true表示可用
    );
  }
  
  async acquire() {
    // 等待任一连接可用
    const available = await PromiseLogic.or(
      this.connections.map((conn, index) =>
        conn.waitForState(true).then(() => index)
      )
    );
    
    // 标记为占用
    await this.connections[available].setState(false);
    return {
      connection: available,
      release: () => this.connections[available].setState(true)
    };
  }
}

六、复杂场景综合应用

6.1 分布式事务协调器

class DistributedTransaction {
  constructor(participants) {
    this.participants = participants;
  }
  
  async execute() {
    // 阶段1:准备(两阶段提交)
    const prepareResults = await PromiseLogic.allResults(
      this.participants.map(p => p.prepare())
    );
    
    // 检查多数节点同意
    const consensus = await PromiseLogic.majority(
      prepareResults.map(r => Promise.resolve(r.status === 'fulfilled'))
    );
    
    if (!consensus) {
      // 回滚所有
      await PromiseLogic.allFulfilled(
        this.participants.map(p => p.rollback())
      );
      throw new Error('Transaction prepare failed');
    }
    
    // 阶段2:提交
    try {
      await PromiseLogic.and(
        this.participants.map(p => p.commit())
      );
    } catch (error) {
      // 部分失败,触发补偿事务
      await this.compensate();
      throw error;
    }
  }
  
  async compensate() {
    // 使用allFulfilled确保尽可能多的补偿执行
    await PromiseLogic.allFulfilled(
      this.participants.map(p => p.compensate())
    );
  }
}

6.2 智能负载均衡器

class SmartLoadBalancer {
  constructor(endpoints) {
    this.endpoints = endpoints;
    this.healthChecks = endpoints.map(() => 
      PromiseLogic.createFlipFlop(true)
    );
  }
  
  async request(path, options = {}) {
    // 1. 选择健康端点
    const healthyEndpoints = await PromiseLogic.or(
      this.endpoints.map((endpoint, index) =>
        this.healthChecks[index].waitForState(true)
          .then(() => endpoint)
          .catch(() => null)
      )
    );
    
    // 2. 并行尝试所有健康端点
    return PromiseLogic.or(
      healthyEndpoints.map(endpoint =>
        fetch(`${endpoint}${path}`, options)
          .then(response => {
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return response;
          })
          .catch(error => {
            // 标记端点不健康
            const index = this.endpoints.indexOf(endpoint);
            this.healthChecks[index].setState(false);
            
            // 延迟恢复检查
            setTimeout(() => {
              this.checkEndpointHealth(index);
            }, 30000);
            
            return Promise.reject(error);
          })
      )
    );
  }
  
  async checkEndpointHealth(index) {
    const isHealthy = await this.endpoints[index].healthCheck();
    await this.healthChecks[index].setState(isHealthy);
  }
}

七、迁移策略与兼容性

7.1 渐进式迁移路径

// 阶段1:混合使用
import { Promise } from 'promise-logic/compat';

// 此时Promise包含所有原生方法+逻辑门扩展
const results = await Promise.and([p1, p2, p3]);
const data = await Promise.all([p4, p5]); // 仍然可用

// 阶段2:完全迁移
import { PromiseLogic } from 'promise-logic';
// 仅使用逻辑门语义

7.2 向后兼容性包装

// 包装旧代码
function backwardsCompatible(originalFunction) {
  return async function(...args) {
    try {
      // 尝试新逻辑
      return await PromiseLogic.and([
        originalFunction(...args),
        validateResult(...args)
      ]);
    } catch (error) {
      // 回退到原始行为
      if (error.type === 'LOGIC_GATE_ERROR') {
        return originalFunction(...args);
      }
      throw error;
    }
  };
}

八、总结与推荐场景

8.1 适用场景评级

场景类别 关键方法 收益评估
微服务编排 and, or, majority 显著降低协调复杂度
分布式事务 and, xor, allResults 提供事务语义保证
批量数据处理 allFulfilled, allRejected 简化错误处理逻辑
容错降级系统 or, nor, nand 明确降级触发条件
实时竞态处理 race, xor 提供标准解决方案

8.2 长期维护建议

// 版本升级检查
if (PromiseLogic.version.major >= 2) {
  // 适配新API
  module.exports = createPromiseLogic({ 
    prefix: 'v2_',
    rename: API_V2_MAPPING
  });
} else {
  // 保持向后兼容
  module.exports = PromiseLogic;
}

PromiseLogic 实现了异步编程从命令式的流程控制转向声明式的逻辑描述。通过提供完整的逻辑门语义,它将复杂的异步协调问题转化为可验证、可推理的逻辑表达式。


项目资源

🚀 AI 全栈项目第六天:Notes 实战项目 —— 幻灯片与 Mock 数据的“欺骗”艺术

哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战 的第六天。

昨天,我们像装修新房一样,引入了 shadcn/ui 这位“顶级设计师”,搭建了 Notes 应用的骨架,写了路由,还搞定了一个带动画的“回到顶部”按钮。感觉如何?是不是觉得自己离全栈大佬又近了一步?

今天,我们要继续给这个“毛坯房”添砖加瓦,把它变成精装房!🏠 今天的任务量有点“硬核”,但放心,我会像剥小龙虾一样,把知识点一个个剥好喂到你嘴里。我们不仅要优化性能,还要实现 App 首页最核心的**幻灯片(轮播图)**功能,甚至在没有后端的情况下,用 Mock.js “伪造”出真实的数据流。

准备好了吗?系好安全带,我们要发车了!🏎️


⚡ 一、 性能优化的“魔法棒”:防抖与节流

还记得昨天那个可爱的 BackToTop 组件吗?它会在我们滚动页面时通过监听 scroll 事件来决定是否显示。

但是!有一个严重的问题。🙅‍♂️ scroll 事件触发得太频繁了!你鼠标滚轮轻轻一滑,浏览器可能就触发了几十次事件。如果在事件处理函数里做一些复杂的计算(比如 DOM 操作),页面就会变得像 PPT 一样卡顿。

这时候,我们需要请出前端性能优化的两大护法:防抖 (Debounce)节流 (Throttle)。 (关于这两者的详细原理,可以去翻翻我之前的文章 前端性能优化魔法:防抖与节流,这里咱们直接实战!)

1.1 编写节流工具函数

对于“滚动”这种高频触发且需要持续响应的场景,节流 (Throttle) 是最佳选择。它的作用就像水龙头每隔一段时间滴一滴水,而不是一直哗啦啦地流。

打开 src/utils/index.ts,让我们来看看这个神奇的函数:

// 定义一个通用的函数类型,接收任意参数,没有返回值
type ThrottleFunction = (...args: any[]) => void;

// 节流函数:接收一个要执行的函数 fun,和一个延迟时间 delay
export function throttle(fun: ThrottleFunction, delay: number): ThrottleFunction {
  let last: number | undefined; // 上次执行的时间戳
  let deferTimer: NodeJS.Timeout | undefined; // 定时器引用

  // 返回一个新的函数(闭包),这个函数就是实际绑定到事件上的函数
  return function (...args: any[]) {
    const now = +new Date(); // 获取当前时间戳

    // 如果上次执行过,且当前时间距离上次执行还不到 delay 毫秒
    if (last && now < last + delay) {
      // 清除之前的定时器(如果有的话)
      clearTimeout(deferTimer);
      // 设置一个新的定时器,保证最后一次操作也能被执行
      deferTimer = setTimeout(function () {
        last = now;
        fun(args);
      }, delay);
    } else {
      // 如果是第一次执行,或者已经超过了 delay 时间
      last = now;
      fun(args); // 立即执行
    }
  };
}

🧐 代码深度解析:

  • 闭包 (Closure)throttle 返回了一个新函数,这个新函数“记住”了 lastdeferTimer 变量。这就是闭包的魔力,它让状态得以保留。
  • 时间戳判断now < last + delay 是核心逻辑。如果不满足这个条件(说明时间间隔够了),就立即执行函数。
  • 兜底定时器:为什么要加 setTimeout?是为了防止用户最后一次操作(比如停止滚动)刚好卡在 delay 之间,导致最后一次状态没更新。

1.2 在组件中使用节流

回到 src/components/BackToTop.tsx,我们来改造它。

import React, { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button';
import { ArrowUp } from 'lucide-react';
// 1️⃣ 引入刚才写的节流函数
import { throttle } from '@/utils';

interface BackToTopProps {
    threshould?: number;
}

const BackToTop:React.FC<BackToTopProps> = ({ threshould = 400 }) =>{
  const [isVisible, setIsVisible] = useState<boolean>(false);

  // ... scrollTop 函数省略 ...

  useEffect (() => {
    // 原始的判断逻辑
    const toggleVisibility = () => {
        setIsVisible(window.scrollY > threshould);
    }
    
    // 2️⃣ 给它穿上“节流铠甲”
    // 每 200ms 最多执行一次 toggleVisibility
    const thtottled_func = throttle(toggleVisibility, 200);

    // 3️⃣ 监听在这个节流后的函数上
    window.addEventListener('scroll', thtottled_func);
    
    // 4️⃣ ⚠️ 非常重要:组件卸载时移除监听!
    // 如果不移除,当组件消失了,监听还在,就会导致内存泄漏(Memory Leak)。
    return () => {
      window.removeEventListener('scroll', thtottled_func);
    }
  }, [threshould]) // 依赖项

  if(!isVisible) return null;

  return (
    // ... 按钮 JSX ...
    <Button
      variant="outline"
      size="icon"
      onClick={scrollTop}
      className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50"
    >
        <ArrowUp className="w-4 h-4"/>
    </Button>
  )
}
export default BackToTop;

现在,你的 BackToTop 组件不仅功能完善,而且性能极佳,哪怕用户疯狂滚动页面,它也稳如泰山。🏔️


🎠 二、 首页的门面担当:幻灯片 (SlideShow)

打开任何一个主流 App(淘宝、B站、京东),首页最显眼的位置永远是——幻灯片(轮播图)。它是展示核心内容、吸引用户点击的绝佳位置。 今天,我们就用 shadcn/ui 提供的 Carousel 组件,来实现一个功能完备的幻灯片。

2.1 组件结构与基础配置

首先,我们需要创建一个新组件 src/components/SlideShow.tsx。 shadcn 的 Carousel 其实是基于 embla-carousel-react 封装的,性能非常好,而且 API 设计得很优雅。

import {
    useRef,
    useEffect,
    useState
} from 'react';
// 1️⃣ 引入自动播放插件 (npm install embla-carousel-autoplay)
import Autoplay from 'embla-carousel-autoplay';
import {
    Carousel,
    CarouselItem,
    CarouselContent,
    type CarouselApi, // 这是一个类型,用于 TS 提示
} from '@/components/ui/carousel';

// 2️⃣ 定义数据接口:TS 的好习惯
export interface SlideData {
    id: number | string;
    image: string;
    title?: string;
}

interface SlideShowProps {
    slides: SlideData[]; // 幻灯片数据数组
    autoPlay?: boolean;  // 开关:是否自动播放
    autoPlayDelay?: number; // 延迟时间
}

2.2 实现主体逻辑

接下来是组件的具体实现。我们要解决三个核心问题:

  1. 自动播放:怎么让它自己动?
  2. 状态同步:怎么知道当前滚到第几张了?
  3. 指示器:下面那几个小圆点怎么做?
const SlideShow:React.FC<SlideShowProps> = ({
    slides,
    autoPlay = true,
    autoPlayDelay = 3000
}) => {
    // 记录当前显示的是第几张(用于渲染小圆点)
    const [selectedIndex, setSelectedIndex] = useState<number>(0);
    // 获取 Carousel 的 API 实例,通过它我们可以控制轮播图
    const [api, setApi] = useState<CarouselApi | null>(null);

    // 🌟 自动播放插件配置
    // useRef 可以持久化存储对象,不会因为组件重渲染而重置
    const plugin = useRef(
        autoPlay ? Autoplay({
            delay: autoPlayDelay,
            stopOnInteraction: true // 用户触摸时停止自动播放,体验更好
        }) : null
    );

    // 🌟 监听轮播图的变化
    useEffect(() => {
        if(!api) return; // 如果 API 还没准备好,直接返回

        // 初始化时,获取当前索引
        setSelectedIndex(api.selectedScrollSnap());

        // 定义监听函数
        const onSelect = () => {
            // 当轮播图切换时,更新 selectedIndex
            setSelectedIndex(api.selectedScrollSnap());
        }

        // 绑定事件监听:当 'select' 事件发生时,执行 onSelect
        api.on("select", onSelect);

        // ⚠️ 清理函数:组件卸载时取消监听
        return () => {
            api.off("select", onSelect);
        }
    }, [api]) // 依赖项是 api,只有 api 变化时才执行

    return (
        <div className="relative w-full">
            <Carousel
              className="w-full"
              setApi={setApi} //  api 实例传出来
              plugins={plugin.current ? [plugin.current] : []} // 挂载插件
              opts={{loop: true}} // 开启无限循环模式
              onMouseEnter={() => plugin.current?.stop()} // 鼠标移入暂停
              onMouseLeave={() => plugin.current?.reset()} // 鼠标移出恢复
            >
                <CarouselContent>
                    {
                        slides.map(({id, image, title}, index) => (
                            <CarouselItem key={id}>
                                {/* aspect-[16/9] 是 Tailwind 的高宽比类,保持图片比例 */}
                                <div className="relative aspect-[16/9] w-full rounded-xl overflow-hidden">
                                    <img 
                                      src={image}
                                      alt={title || `slide${index+1}`}
                                      className="w-full h-full object-cover" // object-cover 保证图片填满且不变形
                                    />
                                    {/* 渐变遮罩层:让文字更清晰 */}
                                    {
                                        title && (
                                            <div className="absolute bottom-0 left-0 right-0
                                            bg-gradient-to-t from-black/60 to-transparent
                                            p-4 text-white">
                                                <h3 className="text-lg font-bold">{title}</h3>
                                            </div>
                                        )
                                    }
                                </div>
                            </CarouselItem>
                        ))
                    }
                </CarouselContent>
            </Carousel>
            
            {/* 👇 指示器小圆点 */}
            <div className="absolute bottom-3 left-0 right-0 flex justify-center gap-2">
                {   
                    slides.map((_, i) => (
                        <button 
                          key={i}
                          // 动态类名如果是当前页变成宽一点的长条(w-6),否则是小圆点(w-2)
                          // transition-all 让变化过程有动画
                          className={`h-2 w-2 rounded-full transition-all
                            ${selectedIndex === i ? "bg-white w-6" : "bg-white/50"}
                            `}
                        />
                    ))
                }
            </div>
        </div>
    )
}
export default SlideShow;

🎨 视觉魔法解析:

  • aspect-[16/9]: 这是一个非常现代的 CSS 属性。它锁定了盒子的长宽比。无论屏幕多宽,高度都会自动计算,保证图片不会被拉伸成“大长脸”。
  • bg-gradient-to-t: 从下到上的渐变。from-black/60 (60%透明度的黑色) 到 to-transparent (透明)。这是 UI 设计中处理“图片上加文字”的经典手法,保证文字在任何背景图上都清晰可见。
  • 指示器逻辑:我们用 map 遍历 slides 生成按钮,但按钮并不需要点击功能(虽然可以加),这里主要作为视觉反馈。通过 selectedIndex === i 来判断哪个点应该“亮”起来。

2.3 数据注入 (Zustand)

组件写好了,数据从哪来? 当然是我们的老朋友 Zustand。我们在 src/store/home.ts 里预置一些 Banner 数据。

import { create } from 'zustand';
// ... 

interface HomeStore {
    banners: SlideData[];
    // ...
}

export const useHomeStore = create<HomeStore>((set) => ({
    banners: [{
      id: 1,
      title: "React 生态系统",
      image: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?q=80&w=2070&auto=format&fit=crop",
    },
    // ... 更多图片
    ],
    // ...
}));

现在,在 Home.tsx 里引入使用,你的首页瞬间就“动”起来了!💃


🎭 三、 后端还没好?Mock.js 来救场!

做前端开发最痛苦的是什么? “接口还没好。” 😭 后端兄弟还在埋头苦干,你的页面却因为没有数据一片空白。为了不让进度停滞,我们需要学会**“造假”**。

Mock.js 就是前端界的“伪钞印制机”(合法的哈)。它可以拦截 Ajax 请求,并返回你定义好的随机数据。

3.1 编写 Mock 数据规则

我们在 mock/posts.js 里定义一个生成文章列表的规则。Mock.js 的语法非常有趣,像写正则表达式一样。

import Mock from 'mockjs'

const tags = ["前端", "后端", "职场", "AI", "副业", "面经", "算法"];

// Mock.mock 生成数据
const posts = Mock.mock({
    "list|45": [ // 生成 45 条数据
        {
            // @ctitle 是 Mock 的占位符,生成中文标题,长度 8-20 字
            title: '@ctitle(8,20)',
            brief: '@ctitle(20,100)', // 简介
            totalComments: '@integer(1,30)', // 随机整数
            totalLikes: '@integer(0,500)',
            publishedAt: '@datetime("yyyy-MM-dd HH:mm")', // 随机时间
            user: {
                id: '@integer(1,10)',
                name: '@ctitle(2,4)',
                avatar: '@image(300x200)' // 生成随机图片 URL
            },
            // 自定义函数:从 tags 数组里随机挑 2 个
            tags: () => Mock.Random.pick(tags, 2),
            thumbnail:  '@image(300x200)',
            id: '@increment(1)' // 自增 ID
        }
    ]
}).list // 取出 list 属性

看,不到 20 行代码,我们就拥有了 45 条包含标题、作者、头像、点赞数的逼真数据!

3.2 模拟分页接口 (GET)

有了数据,还得模拟接口逻辑。真实的后端接口通常支持分页(Pagination)。我们也得像模像样地实现它。

export default [
    {
        url: '/api/posts', // 拦截这个 URL
        method: 'get',     // 拦截 GET 请求
        response: ({ query }, res) => {
            console.log('Mock 拦截到了请求:', query);
            
            // 1️⃣ 解析查询参数 (Query Params)
            // 默认第1页,每页10条
            const { page = '1', limit = '10' } = query;
            const currentPage = parseInt(page);
            const size = parseInt(limit, 10);

            // 2️⃣ 参数校验 (模拟后端的严谨)
            if(isNaN(currentPage) || isNaN(size) || currentPage < 1 || size < 1){
                return {
                    code: 400,
                    msg: 'Invalid page or pageSize',
                    data: null
                }
            }

            // 3️⃣ 核心算法:分页切片
            const total = posts.length;
            const start = (currentPage - 1) * size; // 起始索引
            const end = start + size; // 结束索引
            
            // slice(start, end) 提取当前页的数据
            // 注意:如果 end 超过数组长度,slice 会自动处理,不会报错
            const paginatedData = posts.slice(start, end);

            // 4️⃣ 返回标准格式的响应体
            return {
                code: 200,
                msg: 'success',
                items: paginatedData, // 当前页数据
                pagination: {         // 分页元数据
                    current: currentPage,
                    limit: size,
                    total,
                    totalpages: Math.ceil(total / size) // 总页数
                }
            }
        }
    }
]

💡 面试题预警: 面试官经常问:“前端怎么做分页?” 或者 “如果后端一次性给了 1000 条数据,前端怎么处理?” 这里的 slice 逻辑就是前端分页的核心。虽然在真实项目中我们推荐后端分页(数据库查询时 LIMIT),但在 Mock 阶段或者处理小规模数据时,前端分页非常实用。


🔗 四、 接口对接:Axios 封装与调用

现在假数据准备好了,我们要用 Axios 去请求它。 为了方便管理,我们将 API 请求模块化。

4.1 基础配置 (config.ts)

import axios from 'axios';

// 设置基准 URL
// 这里的技巧是:Mock 服务通常也会拦截 xhr 请求
// 等后端做好了,把这个地址改成真实服务器地址,其他代码一行都不用动!
axios.defaults.baseURL = 'http://localhost:5173/api';

export default axios;

4.2 业务 API (posts.ts)

import axios from './config';
// 引入类型定义,保持类型安全
import type { Post } from '@/types';

// 获取文章列表
export const fetchPosts = async (page: number = 1, limit: number = 10) => {
    try {
        const response = await axios.get('/posts', {
            params: { // axios 会自动把 params 拼接到 URL 后面 ?page=1&limit=10
                page,
                limit
            }
        })
        console.log('API 响应:', response);
        return response.data; // 返回 response.data,因为 axios 包了一层 data
    } catch(err) {
        console.error("请求失败", err);
        return { items: [], pagination: {} }; // 失败返回空数据,防止页面崩坏
    }
}

4.3 在 Store 中调用

回到 src/store/home.ts,添加 loadMore 方法。

// ...
    posts: [], // 初始为空
    loadMore: async () => {
        // 调用 API
        const data = await fetchPosts(1, 10); // 暂时写死第一页
        // 更新状态
        if (data.code === 200) {
             console.log("获取到的文章数据:", data.items);
             // set({ posts: data.items }); // 后面会实现这个 set
        }
    }
// ...

4.4 在 Home 页面触发

最后,在 Home.tsx 中使用 useEffect 触发加载。

    const {
      banners,
      loadMore
    } = useHomeStore();

    useEffect(() => {
      // 组件挂载时,请求第一页数据
      loadMore();
    }, [])

打开浏览器控制台,刷新页面。你应该能看到一行绿色的 Mock 拦截到了请求,紧接着打印出了 10 条伪造得像模像样的文章数据。

🎉 Bingo! 我们成功实现了“无后端开发”。


📝 总结与预告

今天我们的含金量是不是超高?

  1. 性能优化:用节流 (Throttle) 搞定了高频滚动的性能隐患。
  2. UI 实战:手写了一个功能完备、带自动播放和指示器的幻灯片组件
  3. Mock 实战:学会了用 Mock.js 伪造数据,拦截请求,实现了分页逻辑
  4. 架构思维:API 模块化封装,Store 数据分发,让代码结构清晰可维护。

现在的 Notes 应用已经有了漂亮的首页和真实的数据流。 明天,我们将把这些数据渲染到页面上,实现真正的文章列表,并且添加无限滚动加载(Infinite Scroll)功能。还要处理登录状态的真实校验。

全栈之路,道阻且长,但风景独好!我们明天见!👋


如果你觉得这篇文章对你有帮助,记得点赞、收藏、关注三连哦!你的支持是我更新的最大动力! 💖*

❌