普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月5日技术

每日一题-生成交替二进制字符串的最少操作数🟢

2026年3月5日 00:00

给你一个仅由字符 '0''1' 组成的字符串 s 。一步操作中,你可以将任一 '0' 变成 '1' ,或者将 '1' 变成 '0'

交替字符串 定义为:如果字符串中不存在相邻两个字符相等的情况,那么该字符串就是交替字符串。例如,字符串 "010" 是交替字符串,而字符串 "0100" 不是。

返回使 s 变成 交替字符串 所需的 最少 操作数。

 

示例 1:

输入:s = "0100"
输出:1
解释:如果将最后一个字符变为 '1' ,s 就变成 "0101" ,即符合交替字符串定义。

示例 2:

输入:s = "10"
输出:0
解释:s 已经是交替字符串。

示例 3:

输入:s = "1111"
输出:2
解释:需要 2 步操作得到 "0101" 或 "1010" 。

 

提示:

  • 1 <= s.length <= 104
  • s[i]'0''1'

async/await高级模式:async迭代器、错误边界与并发控制

2026年3月4日 22:41

掌握async/await的进阶技巧,让你的异步代码更加优雅、健壮、高效。本文深入探讨async生成器、取消机制、并发控制等高级特性,帮助架构师和中高级开发者写出生产级的异步代码。

目录


为什么需要高级异步模式

如果你已经熟练掌握了 async/await 的基本用法:

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    return user;
  } catch (error) {
    console.error('获取用户失败:', error);
    throw error;
  }
}

那么恭喜你,你已经能够处理80%的异步场景了。但在生产环境中,你会遇到更棘手的问题:

  • 分页数据流: 如何优雅地处理无限滚动的分页数据?
  • 请求取消: 用户离开页面时,如何取消正在进行的请求?
  • 并发控制: 100个并发请求会打爆服务器,如何控制到最多5个?
  • 错误隔离: 一个请求失败不应该拖垮整个应用,如何实现错误边界?
  • 资源清理: 如何确保文件句柄、数据库连接等资源被正确释放?

这些问题需要高级异步模式来解决。本文将带你深入这5个核心领域,让你写出像V8引擎源码一样优雅的异步代码。


Async Generator: 异步迭代器

什么是异步生成器

异步生成器是 ES2018 引入的特性,它结合了三个概念:

  1. 异步(async): 支持await操作
  2. 生成器(generator): 可以暂停执行,惰性求值
  3. 迭代器(iterator): 可以用for...of遍历

简单来说,异步生成器让你可以一边异步获取数据,一边逐个产出结果,而不是等所有数据加载完才返回。

基础语法

// 定义异步生成器函数
async function* asyncGenerator() {
  yield Promise.resolve(1);
  yield Promise.resolve(2);
  yield Promise.resolve(3);
}

// 消费异步生成器
async function consume() {
  for await (const value of asyncGenerator()) {
    console.log(value); // 1, 2, 3
  }
}

关键点:

  • 使用async function*定义
  • yield可以产出Promise或普通值
  • 必须用for await...of来消费
  • 每个yield都会等待前面的异步操作完成

实际应用场景

场景1: 分页API数据流

假设你有一个分页API,需要获取所有数据:

// 传统方式:一次性加载所有数据
async function fetchAllPages(url) {
  const allData = [];
  let page = 1;
  
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    
    if (data.items.length === 0) break;
    
    allData.push(...data.items);
    page++;
  }
  
  return allData; // 可能几千条数据,内存爆炸!
}

// 异步生成器:流式处理
async function* fetchPaginatedData(url) {
  let page = 1;
  
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    
    if (data.items.length === 0) break;
    
    yield data; // 每次只产出当前页数据
    page++;
  }
}

// 消费:内存友好,处理一页释放一页
async function processLargeDataset() {
  for await (const page of fetchPaginatedData('/api/users')) {
    await processPage(page.items); // 处理当前页
    // 处理完成后,当前页数据可被垃圾回收
  }
}

优势:

  • 内存占用恒定,不随数据量增长
  • 真正的流式处理,边加载边处理
  • 更早开始处理,不用等所有数据加载完

场景2: 文件逐行读取

async function* readLines(filepath) {
  const fileHandle = await fs.promises.open(filepath, 'r');
  const buffer = Buffer.alloc(1024);
  let leftover = '';
  
  try {
    while (true) {
      const { bytesRead } = await fileHandle.read(buffer, 0, 1024);
      
      if (bytesRead === 0) {
        if (leftover) yield leftover;
        break;
      }
      
      const chunk = leftover + buffer.toString('utf8', 0, bytesRead);
      const lines = chunk.split('\n');
      
      // 最后一行可能不完整,留到下次处理
      leftover = lines.pop() || '';
      
      for (const line of lines) {
        yield line;
      }
    }
  } finally {
    await fileHandle.close(); // 确保文件句柄被释放
  }
}

// 使用
async function processLogFile() {
  for await (const line of readLines('/var/log/app.log')) {
    if (line.includes('ERROR')) {
      console.error(line);
    }
  }
}

关键点: 使用try/finally确保资源清理,这是异步生成器的最佳实践。

场景3: 实时数据流

假设你在对接WebSocket实时数据流:

async function* streamMessages(websocketUrl) {
  const ws = new WebSocket(websocketUrl);
  const messageQueue = [];
  let resolveMessage = null;
  
  ws.onmessage = (event) => {
    if (resolveMessage) {
      resolveMessage(JSON.parse(event.data));
      resolveMessage = null;
    } else {
      messageQueue.push(JSON.parse(event.data));
    }
  };
  
  try {
    while (true) {
      if (messageQueue.length > 0) {
        yield messageQueue.shift();
      } else {
        yield await new Promise(resolve => {
          resolveMessage = resolve;
        });
      }
    }
  } finally {
    ws.close(); // 确保WebSocket连接关闭
  }
}

// 消费实时流
async function monitorRealtimeData() {
  const abortController = new AbortController();
  
  setTimeout(() => abortController.abort(), 60000); // 1分钟后停止
  
  for await (const message of streamMessages('wss://api.example.com/realtime')) {
    if (abortController.signal.aborted) break;
    await processMessage(message);
  }
}

高级技巧

1. yield* 委托

async function* userPosts(userId) {
  const posts = await fetchUserPosts(userId);
  yield* posts; // 委托给数组的同步迭代器
}

async function* allUsersPosts(userIds) {
  for (const userId of userIds) {
    yield* userPosts(userId); // 委托给另一个异步生成器
  }
}

// 平铺所有用户的文章
for await (const post of allUsersPosts([1, 2, 3])) {
  console.log(post.title);
}

2. 从异步生成器创建ReadableStream

async function* generateChunks() {
  yield 'chunk1';
  yield 'chunk2';
  yield 'chunk3';
}

const stream = new ReadableStream({
  async start(controller) {
    for await (const chunk of generateChunks()) {
      controller.enqueue(chunk);
    }
    controller.close();
  }
});

// 可以用于fetch的body
await fetch('/api/upload', {
  method: 'POST',
  body: stream
});

AbortController: 优雅的取消机制

为什么需要取消机制

想象一个场景:用户在搜索框输入,每次输入都会触发API请求。如果用户快速输入"JavaScript",会连续触发10个请求,但只有最后一个请求是有意义的,前9个都是浪费:

// ❌ 问题代码
let currentQuery = '';
async function handleSearch(query) {
  currentQuery = query;
  const results = await fetch(`/api/search?q=${query}`);
  if (query === currentQuery) { // 竞态条件检查
    displayResults(results);
  }
}

问题:

  • 前9个请求仍在执行,浪费带宽
  • 无法真正中断请求,只能忽略结果
  • 代码逻辑复杂,容易出现竞态条件

解决方案: AbortController

AbortController基础

const controller = new AbortController();
const signal = controller.signal;

// 发起可取消的请求
fetch('/api/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('请求被取消');
    } else {
      console.error('请求失败:', error);
    }
  });

// 取消请求
controller.abort();

核心API:

  • controller.abort(reason?): 取消所有关联的操作
  • controller.signal: 一个AbortSignal对象,可以传递给异步操作
  • signal.aborted: 布尔值,表示是否已取消
  • signal.reason: 取消的原因
  • signal.throwIfAborted(): 如果已取消,则抛出错误

实际应用场景

场景1: 搜索请求取消

let abortController = null;

async function search(query) {
  // 取消前一个请求
  if (abortController) {
    abortController.abort();
  }
  
  // 创建新的控制器
  abortController = new AbortController();
  
  try {
    const response = await fetch(`/api/search?q=${query}`, {
      signal: abortController.signal
    });
    const results = await response.json();
    return results;
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('搜索请求被取消');
      return [];
    }
    throw error;
  }
}

// 用户快速输入: J -> Ja -> Jav -> Java -> JavaS -> ...
// 只有最后一个请求会执行,前面的都被取消

场景2: React组件卸载清理

import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const abortController = new AbortController();
    
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`, {
          signal: abortController.signal
        });
        const data = await response.json();
        setUser(data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('获取用户失败:', error);
        }
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
    
    // 组件卸载时取消请求
    return () => abortController.abort();
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  return <div>{user?.name}</div>;
}

关键点: useEffect的清理函数会在组件卸载时或userId变化时执行,确保旧请求被取消。

场景3: 超时控制

现代浏览器支持直接创建超时signal:

// 方式1: AbortSignal.timeout (现代浏览器)
async function fetchWithTimeout(url, timeout = 5000) {
  try {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(timeout)
    });
    return response.json();
  } catch (error) {
    if (error.name === 'AbortError' || error.name === 'TimeoutError') {
      throw new Error(`请求超时 (${timeout}ms)`);
    }
    throw error;
  }
}

// 方式2: 手动超时控制 (兼容性更好)
async function fetchWithManualTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    return response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

场景4: 取消多个操作

AbortController可以同时取消多个操作:

async function fetchDashboardData(userId) {
  const controller = new AbortController();
  const signal = controller.signal;
  
  try {
    // 同时发起多个请求,共享同一个signal
    const [user, posts, comments] = await Promise.all([
      fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
      fetch(`/api/users/${userId}/posts`, { signal }).then(r => r.json()),
      fetch(`/api/users/${userId}/comments`, { signal }).then(r => r.json())
    ]);
    
    return { user, posts, comments };
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Dashboard数据请求被取消');
      return null;
    }
    throw error;
  }
}

// 用户离开页面时取消所有请求
window.addEventListener('beforeunload', () => {
  controller.abort();
});

自定义可取消操作

AbortController不仅限于fetch,你可以为任何异步操作添加取消功能:

// 可取消的延时
function cancellableDelay(ms, signal) {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(resolve, ms);
    
    signal.addEventListener('abort', () => {
      clearTimeout(timeoutId);
      reject(new DOMException('Aborted', 'AbortError'));
    }, { once: true }); // once: true 确保只触发一次
  });
}

// 使用
async function countdown(seconds, signal) {
  for (let i = seconds; i > 0; i--) {
    console.log(i);
    await cancellableDelay(1000, signal);
  }
  console.log('完成!');
}

const controller = new AbortController();
countdown(10, controller.signal).catch(console.error);

// 3秒后取消
setTimeout(() => controller.abort(), 3000);
// 输出: 10, 9, 8, 然后抛出AbortError

并发控制: 从失控到可控

为什么需要并发控制

Promise.all()虽然方便,但没有并发限制:

// ❌ 危险代码:可能同时发起1000个请求!
const userIds = Array.from({ length: 1000 }, (_, i) => i + 1);

await Promise.all(
  userIds.map(id => fetch(`/api/users/${id}`))
);
// 服务器会被瞬间打爆,触发限流,甚至被拉黑

现实问题:

  • 浏览器对同一域名的并发请求有限制(通常6个)
  • 服务器有速率限制(rate limit)
  • 数据库连接池有限
  • 内存占用会飙升

并发控制方案对比

方案 特点 npm包 周下载量
Promise.all 无限制并发 原生 -
p-limit 简单限流 p-limit 2.04亿
p-map 映射处理 p-map 6780万
p-queue 队列管理 p-queue 1890万

方案1: p-limit (推荐)

最简单轻量的并发控制库:

npm install p-limit
import pLimit from 'p-limit';

// 创建并发限制器(最多同时执行3个任务)
const limit = pLimit(3);

async function fetchUsers() {
  const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  
  // 将每个任务包装到limit()中
  const tasks = userIds.map(id => 
    limit(() => fetch(`/api/users/${id}`).then(r => r.json()))
  );
  
  // 执行所有任务,但最多同时执行3个
  const users = await Promise.all(tasks);
  console.log('获取用户:', users.length); // 10
  
  return users;
}

工作原理:

  1. pLimit(3)创建一个限制器
  2. limit(fn)将任务加入队列
  3. 如果当前执行数<3,立即执行
  4. 如果当前执行数>=3,等待前面的任务完成
  5. 任务完成后,从队列中取出下一个执行

方案2: p-map

专门用于映射处理的并发控制:

npm install p-map
import pMap from 'p-map';

async function processImages(imageUrls) {
  const results = await pMap(
    imageUrls,
    async (url) => {
      const response = await fetch(url);
      const blob = await response.blob();
      return processImage(blob);
    },
    {
      concurrency: 5, // 最多同时处理5张图片
      stopOnError: false // 即使有错误也继续处理其他图片
    }
  );
  
  return results;
}

高级选项:

  • concurrency: 并发数
  • stopOnError: 是否在遇到错误时停止(默认true)
  • signal: AbortSignal,支持取消

方案3: p-queue

功能最全面的队列管理:

npm install p-queue
import PQueue from 'p-queue';

// 创建队列
const queue = new PQueue({
  concurrency: 3,      // 最多同时执行3个任务
  interval: 1000,      // 每秒最多开始3个任务
  intervalCap: 3,      // 每个interval期间最多执行的任务数
});

// 监听事件
queue.on('active', () => {
  console.log(`正在执行: ${queue.pending}/${queue.concurrency}`);
});

queue.on('idle', () => {
  console.log('队列为空,所有任务完成');
});

queue.on('error', (error) => {
  console.error('任务执行出错:', error);
});

// 添加任务
async function fetchUserData() {
  for (let i = 1; i <= 100; i++) {
    queue.add(() => 
      fetch(`/api/users/${i}`)
        .then(r => r.json())
        .then(user => {
          console.log(`获取用户: ${user.name}`);
          return user;
        })
    );
  }
  
  // 等待所有任务完成
  await queue.onIdle();
  console.log('所有用户获取完成');
}

高级特性:

  • 优先级: queue.add(fn, { priority: 10 })
  • 暂停/恢复: queue.pause() / queue.start()
  • 清空队列: queue.clear()
  • 统计信息: queue.pending, queue.size

方案4: 手动实现并发控制

如果你不想引入依赖,可以手动实现:

async function limitConcurrency(tasks, concurrency) {
  const results = [];
  const executing = new Set();
  
  for (const [index, task] of tasks.entries()) {
    const promise = task().then(result => {
      executing.delete(promise);
      results[index] = result;
      return result;
    });
    
    executing.add(promise);
    
    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
  }
  
  return Promise.all(executing).then(() => results);
}

// 使用
const tasks = userIds.map(id => () => fetch(`/api/users/${id}`).then(r => r.json()));
const users = await limitConcurrency(tasks, 3);

核心算法:

  1. 维护一个执行中的Promise集合executing
  2. 每次添加新任务时,检查执行数是否达到上限
  3. 达到上限时,等待任意一个任务完成(Promise.race)
  4. 任务完成后自动从集合中移除,空出位置给下一个任务

并发控制最佳实践

1. 根据资源设置并发数

// 根据CPU核心数
import os from 'os';
const cpuCount = os.cpus().length;
const limit = pLimit(cpuCount);

// 根据数据库连接池
const dbPoolSize = 10;
const dbLimit = pLimit(dbPoolSize - 2); // 留2个备用

// 根据API速率限制
const rateLimitPerSecond = 50;
const apiLimit = pLimit(rateLimitPerSecond / 2); // 留buffer

2. 动态调整并发数

const queue = new PQueue({ concurrency: 5 });

// 根据响应时间动态调整
setInterval(() => {
  const avgResponseTime = calculateAverageResponseTime();
  
  if (avgResponseTime > 2000) {
    // 响应慢,减少并发
    queue.concurrency = Math.max(1, queue.concurrency - 1);
  } else if (avgResponseTime < 500) {
    // 响应快,增加并发
    queue.concurrency = Math.min(20, queue.concurrency + 1);
  }
}, 10000);

3. 错误处理

async function fetchWithErrorHandling(items) {
  const errors = [];
  
  const results = await pMap(
    items,
    async (item) => {
      try {
        return await processItem(item);
      } catch (error) {
        errors.push({ item, error });
        return null; // 返回null而不是抛出错误
      }
    },
    {
      concurrency: 5,
      stopOnError: false
    }
  );
  
  if (errors.length > 0) {
    console.error(`处理失败: ${errors.length}/${items.length}`);
    errors.forEach(({ item, error }) => {
      console.error(`项目${item.id}处理失败:`, error);
    });
  }
  
  return results.filter(r => r !== null);
}

高级错误处理模式

Try-Catch的局限性

传统的try-catch在复杂场景中不够用:

async function processOrder(orderId) {
  try {
    const order = await fetchOrder(orderId);
    const inventory = await checkInventory(order.items);
    const payment = await processPayment(order);
    const shipment = await createShipment(order);
    
    return { order, inventory, payment, shipment };
  } catch (error) {
    // 不知道是哪一步出错
    console.error('处理订单失败:', error);
    throw error;
  }
}

问题:

  1. 无法区分哪一步出错
  2. 嵌套try-catch导致代码膨胀
  3. 错误吞噬(catch后未处理)
  4. 无法针对不同错误类型采取不同策略

模式1: 错误分类

// 定义自定义错误类型
class NetworkError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = 'NetworkError';
    this.statusCode = statusCode;
  }
}

class ValidationError extends Error {
  constructor(message, fields) {
    super(message);
    this.name = 'ValidationError';
    this.fields = fields;
  }
}

class PaymentError extends Error {
  constructor(message, orderId) {
    super(message);
    this.name = 'PaymentError';
    this.orderId = orderId;
  }
}

// 使用
async function processOrder(orderId) {
  try {
    const order = await fetchOrder(orderId);
    
    // 验证错误
    if (!order.items || order.items.length === 0) {
      throw new ValidationError('订单没有商品', ['items']);
    }
    
    // 网络错误
    const inventory = await checkInventory(order.items);
    if (inventory.status === 503) {
      throw new NetworkError('库存服务不可用', 503);
    }
    
    // 支付错误
    const payment = await processPayment(order);
    if (payment.status === 'failed') {
      throw new PaymentError('支付失败', orderId);
    }
    
    return { order, inventory, payment };
  } catch (error) {
    // 根据错误类型采取不同策略
    if (error instanceof ValidationError) {
      return { success: false, reason: 'validation', fields: error.fields };
    } else if (error instanceof NetworkError) {
      // 重试逻辑
      return retryOperation(() => processOrder(orderId), 3);
    } else if (error instanceof PaymentError) {
      // 发送告警
      alertSupport(error.orderId, error.message);
      return { success: false, reason: 'payment' };
    } else {
      throw error; // 未知错误,向上抛出
    }
  }
}

模式2: 结果包装器

// Result类型 (类似Rust的Result)
class Result {
  static ok(value) {
    return { success: true, value };
  }
  
  static error(error) {
    return { success: false, error };
  }
}

async function safeAsync(promise) {
  try {
    const value = await promise;
    return Result.ok(value);
  } catch (error) {
    return Result.error(error);
  }
}

// 使用
async function processOrders(orderIds) {
  const results = await Promise.all(
    orderIds.map(id => safeAsync(processOrder(id)))
  );
  
  const successful = results.filter(r => r.success).map(r => r.value);
  const failed = results.filter(r => !r.success).map(r => r.error);
  
  console.log(`成功: ${successful.length}, 失败: ${failed.length}`);
  
  // 失败的错误可以记录日志、重试或告警
  failed.forEach(error => {
    console.error('订单处理失败:', error);
  });
  
  return { successful, failed };
}

模式3: 错误包装器

特别适用于Express.js等框架:

// Express错误包装器
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// 使用
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await fetchUser(req.params.id);
  res.json(user);
}));

// 全局错误处理中间件
app.use((error, req, res, next) => {
  if (error instanceof ValidationError) {
    res.status(400).json({ error: 'validation', fields: error.fields });
  } else if (error instanceof NetworkError) {
    res.status(502).json({ error: 'network', message: error.message });
  } else {
    console.error('未处理的错误:', error);
    res.status(500).json({ error: 'internal' });
  }
});

模式4: Promise.allSettled并发错误处理

当多个并发任务且不想一个失败导致全部失败时:

async function fetchAllData(userId) {
  const results = await Promise.allSettled([
    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()),
  ]);
  
  const [user, posts, comments, followers] = results.map((result, index) => {
    if (result.status === 'fulfilled') {
      return result.value;
    } else {
      console.error(`数据${index}获取失败:`, result.reason);
      return null; // 返回null作为fallback
    }
  });
  
  return {
    user: user || {},
    posts: posts || [],
    comments: comments || [],
    followers: followers || [],
  };
}

模式5: 全局错误捕获

作为最后一道防线,捕获未处理的异常:

// Node.js
process.on('unhandledRejection', (reason, promise) => {
  console.error('未处理的Promise rejection:', reason);
  // 发送到错误追踪服务
  Sentry.captureException(reason);
  process.exit(1); // 可选:强制退出进程
});

process.on('uncaughtException', (error) => {
  console.error('未捕获的异常:', error);
  Sentry.captureException(error);
  process.exit(1);
});

// 浏览器
window.addEventListener('unhandledrejection', (event) => {
  console.error('未处理的Promise rejection:', event.reason);
  event.preventDefault(); // 阻止默认行为(控制台错误)
});

window.addEventListener('error', (event) => {
  console.error('全局错误:', event.error);
});

// React Error Boundary
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>出错了:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>重试</button>
    </div>
  );
}

<ErrorBoundary FallbackComponent={ErrorFallback}>
  <App />
</ErrorBoundary>

实战案例:分页API完整实现

让我们把学到的知识整合起来,实现一个生产级的分页数据获取方案:

/**
 * 分页API客户端
 * 支持:
 * - 异步生成器流式处理
 * - AbortController取消
 * - 并发控制
 * - 错误处理
 */
class PaginatedAPIClient {
  constructor(baseUrl, options = {}) {
    this.baseUrl = baseUrl;
    this.concurrency = options.concurrency || 5;
    this.timeout = options.timeout || 30000;
    this.cache = new Map();
  }
  
  /**
   * 异步生成器:流式获取分页数据
   */
  async *fetchPaginated(endpoint, options = {}) {
    const signal = options.signal || new AbortController().signal;
    let page = options.startPage || 1;
    let hasMore = true;
    
    while (hasMore && !signal.aborted) {
      const url = `${this.baseUrl}${endpoint}?page=${page}&${new URLSearchParams(options.params || {})}`;
      
      try {
        const response = await this.fetchWithTimeout(url, signal);
        const data = await response.json();
        
        if (!data.items || data.items.length === 0) {
          hasMore = false;
          break;
        }
        
        yield data;
        
        hasMore = data.hasMore !== false;
        page++;
        
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log('请求被取消');
          break;
        }
        throw error;
      }
    }
  }
  
  /**
   * 批量处理分页数据(支持并发)
   */
  async processPaginated(endpoint, processor, options = {}) {
    const results = [];
    const errors = [];
    const batchSize = options.batchSize || 10;
    const signal = options.signal || new AbortController().signal;
    
    let batch = [];
    
    for await (const page of this.fetchPaginated(endpoint, options)) {
      if (signal.aborted) break;
      
      batch.push(page);
      
      if (batch.length >= batchSize) {
        const batchResults = await this.processBatch(batch, processor, signal);
        results.push(...batchResults.successful);
        errors.push(...batchResults.errors);
        batch = [];
      }
    }
    
    // 处理剩余批次
    if (batch.length > 0) {
      const batchResults = await this.processBatch(batch, processor, signal);
      results.push(...batchResults.successful);
      errors.push(...batchResults.errors);
    }
    
    return { results, errors };
  }
  
  /**
   * 批量处理(内部方法,支持并发控制)
   */
  async processBatch(pages, processor, signal) {
    const results = await Promise.allSettled(
      pages.map(page => processor(page, signal))
    );
    
    return {
      successful: results
        .filter(r => r.status === 'fulfilled')
        .map(r => r.value),
      errors: results
        .filter(r => r.status === 'rejected')
        .map(r => r.reason)
    };
  }
  
  /**
   * 带超时的fetch
   */
  async fetchWithTimeout(url, signal) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeout);
    
    // 合并外部signal和timeout signal
    if (signal) {
      signal.addEventListener('abort', () => controller.abort());
    }
    
    try {
      const response = await fetch(url, { signal: controller.signal });
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      return response;
    } finally {
      clearTimeout(timeoutId);
    }
  }
  
  /**
   * 带缓存的get请求
   */
  async get(endpoint, options = {}) {
    const cacheKey = `${endpoint}:${JSON.stringify(options.params)}`;
    
    if (options.useCache && this.cache.has(cacheKey)) {
      return this.cache.get(cacheKey);
    }
    
    const url = `${this.baseUrl}${endpoint}?${new URLSearchParams(options.params || {})}`;
    const response = await this.fetchWithTimeout(url, options.signal);
    const data = await response.json();
    
    if (options.useCache) {
      this.cache.set(cacheKey, data);
    }
    
    return data;
  }
  
  /**
   * 取消所有正在进行的请求
   */
  cancelAll() {
    this.activeRequests.forEach(controller => controller.abort());
    this.activeRequests.clear();
  }
}

// 使用示例
async function main() {
  const client = new PaginatedAPIClient('https://api.example.com', {
    concurrency: 5,
    timeout: 15000
  });
  
  // 示例1: 流式处理大数据集
  const abortController = new AbortController();
  
  setTimeout(() => {
    console.log('30秒后取消请求');
    abortController.abort();
  }, 30000);
  
  for await (const page of client.fetchPaginated('/users', {
    signal: abortController.signal,
    params: { status: 'active' }
  })) {
    console.log(`处理第${page.currentPage}页,共${page.items.length}条数据`);
    await processUserData(page.items);
  }
  
  // 示例2: 批量处理(带并发控制)
  const { results, errors } = await client.processPaginated(
    '/posts',
    async (page, signal) => {
      // 每页数据的处理逻辑
      const processedItems = [];
      
      for (const post of page.items) {
        if (signal.aborted) break;
        
        // 处理post
        const enriched = await enrichPost(post);
        processedItems.push(enriched);
      }
      
      return processedItems;
    },
    {
      batchSize: 10,
      startPage: 1,
      signal: abortController.signal
    }
  );
  
  console.log(`成功: ${results.length}, 失败: ${errors.length}`);
}

// 启动
main().catch(console.error);

这个实现整合了:

  1. 异步生成器: 流式处理分页数据
  2. AbortController: 支持取消请求
  3. 并发控制: 批量处理时限制并发
  4. 错误处理: Promise.allSettled捕获错误
  5. 超时控制: 防止请求hang住
  6. 缓存: 减少重复请求
  7. 类型安全: 可配合TypeScript使用

性能优化建议

1. 避免async/await性能陷阱

陷阱1: 不必要的await

// ❌ 低效:串行执行两个不相关的操作
async function getData() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  return { user, posts };
}

// ✅ 高效:并行执行
async function getData() {
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts()
  ]);
  return { user, posts };
}

陷阱2: 循环中的await

// ❌ 低效:串行执行
async function fetchUsers(userIds) {
  const users = [];
  for (const id of userIds) {
    const user = await fetch(`/api/users/${id}`).then(r => r.json());
    users.push(user);
  }
  return users;
}

// ✅ 高效:并发控制
async function fetchUsers(userIds) {
  const limit = pLimit(5); // 限制并发数为5
  const tasks = userIds.map(id => 
    limit(() => fetch(`/api/users/${id}`).then(r => r.json()))
  );
  return Promise.all(tasks);
}

2. 内存优化

// ❌ 内存占用高
async function fetchAllData() {
  const allData = [];
  
  for await (const page of fetchPages()) {
    allData.push(...page.items); // 数据越来越多,内存爆炸
  }
  
  return allData;
}

// ✅ 流式处理,内存友好
async function processAllData() {
  for await (const page of fetchPages()) {
    await processItems(page.items); // 处理完就释放
  }
}

3. 超时和重试策略

async function fetchWithRetry(url, options = {}) {
  const { retries = 3, timeout = 5000 } = options;
  
  for (let i = 0; i < retries; i++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
      
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });
      
      clearTimeout(timeoutId);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      return response.json();
      
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log(`请求超时,第${i + 1}次重试`);
      } else {
        console.error(`请求失败(${error.message}),第${i + 1}次重试`);
      }
      
      if (i === retries - 1) {
        throw error;
      }
      
      // 指数退避
      await new Promise(resolve => 
        setTimeout(resolve, Math.pow(2, i) * 1000)
      );
    }
  }
}

4. 使用性能监控工具

// 测量异步函数执行时间
async function measurePerformance(name, fn) {
  const start = performance.now();
  const result = await fn();
  const duration = performance.now() - start;
  console.log(`${name}耗时: ${duration.toFixed(2)}ms`);
  return result;
}

// 使用
await measurePerformance('fetchUsers', () => fetchUsers([1, 2, 3]));

总结

本文深入探讨了async/await的5个高级使用模式:

核心要点回顾

  1. Async Generator

    • 流式处理大数据集
    • 惰性求值,内存友好
    • 使用for await...of消费
    • try/finally确保资源清理
  2. AbortController

    • 优雅取消异步操作
    • 组件卸载时清理请求
    • 避免竞态条件
    • 支持超时控制
  3. 并发控制

    • Promise.all无限并发,危险!
    • p-limit简单轻量
    • p-map适合映射处理
    • p-queue功能最全
  4. 错误处理

    • 区分错误类型
    • Promise.allSettled并发容错
    • 全局错误捕获作为防线
    • React Error Boundary
  5. 性能优化

    • 避免不必要的await
    • 使用并发控制代替串行
    • 流式处理减少内存占用
    • 添加超时和重试

知识检查清单

  • 能用async生成器处理分页API
  • 能用AbortController取消fetch请求
  • 能选择合适的并发控制库(p-limit/p-map/p-queue)
  • 能区分不同的错误处理场景并选择合适策略
  • 能实现生产级的分页API客户端

进阶学习路线

  1. 源码阅读: 阅读p-limit、p-queue的源码(都很小,易于理解)
  2. 实践项目: 用async生成器实现一个文件流处理器
  3. 深入原理: 了解V8引擎的async/await实现
  4. 性能对比: Benchmark不同并发控制方案的性能差异

参考链接

【宫水三叶】简单模拟题

作者 AC_OIer
2022年11月29日 09:18

模拟

最终结果只有「从 0 开始的交替串」和「从 1 开始的交替串」两种。

对于一个长度为 n 的未知序列 A 而言,假设我们需要花费 cnt 次操作将其变为「从 0 开始的交替串」,那么我们想要将其变为「从 1 开始的交替串」则需要 n - cnt 次操作:原本操作的 cnt 个位置不能动,而原本没操作的位置则都需要翻转,从而确保两种交替串对应位均相反。

代码:

###Java

class Solution {
    public int minOperations(String s) {
        int n = s.length(), cnt = 0;
        for (int i = 0; i < n; i++) cnt += (s.charAt(i) - '0') ^ (i & 1);
        return Math.min(cnt, n - cnt);
    }
}

###TypeScript

function minOperations(s: string): number {
    let n = s.length, cnt = 0
    for (let i = 0; i < n; i++) cnt += (s.charCodeAt(i) - '0'.charCodeAt(0)) ^ (i & 1)
    return Math.min(cnt, n - cnt)
}

###Python3

class Solution:
    def minOperations(self, s: str) -> int:
        n, cnt = len(s), 0
        for i, c in enumerate(s):
            cnt += (ord(c) - ord('0')) ^ (i & 1)
        return min(cnt, n - cnt)
  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(1)$

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

生成交替二进制字符串的最少操作数

2022年11月28日 09:58

方法一:模拟

思路

根据题意,经过多次操作,$s$ 可能会变成两种不同的交替二进制字符串,即:

  • 开头为 $0$,后续交替的字符串;
  • 开头为 $1$,后续交替的字符串。

注意到,变成这两种不同的交替二进制字符串所需要的最少操作数加起来等于 $s$ 的长度,我们只需要计算出变为其中一种字符串的最少操作数,就可以推出另一个最少操作数,然后取最小值即可。

代码

###Python

class Solution:
    def minOperations(self, s: str) -> int:
        cnt = sum(int(c) != i % 2 for i, c in enumerate(s))
        return min(cnt, len(s) - cnt)

###Java

class Solution {
    public int minOperations(String s) {
        int cnt = 0;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c != (char) ('0' + i % 2)) {
                cnt++;
            }
        }
        return Math.min(cnt, s.length() - cnt);
    }
}

###C#

public class Solution {
    public int MinOperations(string s) {
        int cnt = 0;
        for (int i = 0; i < s.Length; i++) {
            char c = s[i];
            if (c != (char) ('0' + i % 2)) {
                cnt++;
            }
        }
        return Math.Min(cnt, s.Length - cnt);
    }
}

###C++

class Solution {
public:
    int minOperations(string s) {
        int cnt = 0;
        for (int i = 0; i < s.size(); i++) {
            char c = s[i];
            if (c != ('0' + i % 2)) {
                cnt++;
            }
        }
        return min(cnt, (int)s.size() - cnt);
    }
};

###C

#define MIN(a, b) ((a) < (b) ? (a) : (b))

int minOperations(char * s) {
    int cnt = 0, len = strlen(s);
    for (int i = 0; i < len; i++) {
        char c = s[i];
        if (c != ('0' + i % 2)) {
            cnt++;
        }
    }
    return MIN(cnt, len - cnt);
}

###JavaScript

var minOperations = function(s) {
    let cnt = 0;
    for (let i = 0; i < s.length; i++) {
        const c = s[i];
        if (c !== (String.fromCharCode('0'.charCodeAt() + i % 2))) {
            cnt++;
        }
    }
    return Math.min(cnt, s.length - cnt);
};

###go

func minOperations(s string) int {
    cnt := 0
    for i, c := range s {
        if i%2 != int(c-'0') {
            cnt++
        }
    }
    return min(cnt, len(s)-cnt)
}

func min(a, b int) int {
    if a > b {
        return b
    }
    return a
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 为输入 $s$ 的长度,仅需遍历一遍字符串。

  • 空间复杂度:$O(1)$,只需要常数额外空间。

一个判断(炒鸡简单):两种情况比大小,小的就是答案辽

2021年2月14日 12:12

两种情况:
1.偶数位为0,奇数位为1
这种情况下,任意位的值和索引奇偶性相同,即s[i]%2==i%2,若不满足,即需要变动该位,则计数cnt1++
2.偶数位为1,奇数位为0
这种情况下,任意位的值和索引奇偶性不同,即s[i]%2!=i%2,若不满足,即需要变动该位,则计数cnt2++

比较哪种需要变动的位数小

class Solution{
public:
    int minOperations(string s) {
      int n=s.size(),cnt1=0,cnt2=0;
      for(int i=0;i<n;i++){
        if(s[i]%2!=i%2)  cnt1++; 
        else cnt2++;
      }
      return min(cnt1,cnt2);
    }
};
昨天 — 2026年3月4日技术

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

作者 SmalBox
2026年3月4日 22:11

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

在Unity URP Shader Graph中,Camera节点是一个功能强大的工具,它允许着色器访问当前渲染摄像机的各种属性和参数。这个节点为着色器提供了与摄像机交互的能力,使得开发者能够创建更加动态和响应式的视觉效果。通过Camera节点,着色器可以根据摄像机的状态、位置和投影特性来调整渲染行为,这在实现高级视觉效果如屏幕空间效果、距离相关效果和视角相关效果时尤为重要。

Camera节点的核心价值在于它打破了传统着色器与场景环境的隔离状态。在传统的着色器编程中,着色器通常只能处理传入的顶点和纹理数据,而无法直接感知场景中的摄像机状态。Camera节点填补了这一空白,为着色器提供了"感知"摄像机的能力,从而开启了更多创意可能性。

描述

Camera节点是Shader Graph中一个专门用于访问和利用摄像机属性的功能节点。它充当着色器与渲染摄像机之间的桥梁,提供了一系列与摄像机相关的数据输出端口。这些数据不仅包括摄像机游戏对象本身的基本属性,如在世界空间中的位置和朝向方向,还涵盖了摄像机的投影参数和渲染设置。

摄像机数据访问的深度解析

Camera节点提供的摄像机参数访问能力可以分为几个主要类别:

空间属性:包括摄像机的位置和方向矢量。这些属性对于实现基于视角的效果至关重要,比如菲涅耳效应、视差映射和动态环境映射。

投影属性:涵盖摄像机的投影类型(透视或正交)、近远平面距离以及Z缓冲区配置。这些参数在实现深度相关效果和屏幕空间效果时非常有用。

正交投影特定属性:专门针对正交摄像机的宽度和高度参数,可用于创建等距视角效果或2D渲染中的特定行为。

技术实现原理

在底层实现上,Camera节点实际上是对Unity内置着色器变量和函数的封装。这些变量包括_WorldSpaceCameraPos_ProjectionParamsunity_OrthoParams等。Shader Graph通过将这些底层变量暴露为节点端口,使得即使不熟悉底层着色器编程的开发者也能轻松使用这些功能。

应用场景广度

Camera节点的应用范围非常广泛,从简单的距离淡化效果到复杂的屏幕空间反射都能看到它的身影。在URP渲染管线中,由于渲染路径和特性集的限制,Camera节点提供的标准化访问方式显得尤为重要,它确保了在不同平台和设备上的一致行为。

支持的渲染管线

Camera节点在Unity的不同渲染管线中有不同的支持情况:

  • 通用渲染管线(URP):完全支持Camera节点,所有端口功能正常可用。URP的设计理念强调轻量化和跨平台兼容性,Camera节点在这一管线中发挥着关键作用,帮助开发者创建高性能的视觉效果。
  • 高清渲染管线(HDRP)支持此节点。HDRP拥有自己的一套摄像机数据访问机制和节点系统,这是由于其架构复杂性和功能集差异所决定的。HDRP提供了更专门的节点来处理摄像机交互,如HD Camera节点。

这种差异主要源于两种渲染管线的设计目标和架构差异。URP旨在提供轻量级、跨平台的渲染解决方案,而HDRP则专注于高端图形效果和物理精确的渲染。因此,在HDRP中,摄像机数据的访问方式更加精细和复杂,无法通过简单的Camera节点来涵盖所有功能。

端口

Camera节点提供了多个输出端口,每个端口都对应着摄像机的一个特定属性或参数。理解这些端口的含义和使用方法是有效利用Camera节点的关键。

Position(位置)端口

Position端口输出摄像机游戏对象在世界空间中的位置坐标,类型为Vector 3。

技术细节

  • 该端口对应内置着色器变量_WorldSpaceCameraPos
  • 返回的是世界空间中的绝对位置坐标
  • 在着色器中可以直接用于距离计算和方向向量构建

应用示例

  • 计算片段到摄像机的距离:float distance = length(WorldPos - _Camera_Position)
  • 创建基于距离的淡化效果
  • 实现视差遮挡映射时计算视角方向

使用技巧

HLSL

// 计算视角方向的标准方法
float3 viewDirection = normalize(_Camera_Position - IN.WorldSpacePosition);

Direction(方向)端口

Direction端口输出摄像机的前向矢量方向,类型为Vector 3。

技术实现

  • 该端口的计算相对复杂,涉及多个矩阵变换
  • 本质上表示摄像机观察方向的单位向量
  • 在世界空间中表示,可以直接用于光照计算和反射计算

核心应用

  • 反射效果中的视角向量计算
  • 基于视角的材质效果(如各向异性材质)
  • 屏幕空间效果的方向基准

重要注意事项

Direction端口输出的方向向量与常见的视角方向计算有所不同。传统上,视角方向计算为摄像机位置 - 表面位置,而Direction端口直接提供摄像机的前向方向。在使用时需要根据具体需求选择合适的向量。

Orthographic(正交)端口

Orthographic端口返回一个浮点值,用于指示摄像机当前是否处于正交模式。

返回值含义

  • 返回1.0表示摄像机是正交摄像机
  • 返回0.0表示摄像机是透视摄像机

技术背景

  • 对应unity_OrthoParams.w变量
  • 在渲染管线内部用于区分不同的投影计算方式

应用场景

  • 创建在透视和正交模式下表现一致的效果
  • 针对2D和3D不同场景的着色器优化
  • UI元素和世界空间元素的协调渲染

使用示例

HLSL

// 根据摄像机模式调整效果强度
float effectStrength = lerp(perspectiveStrength, orthographicStrength, _Camera_Orthographic);

Near Plane(近平面)端口

Near Plane端口输出摄像机的近裁剪平面距离,类型为Float。

技术细节

  • 对应_ProjectionParams.y变量
  • 表示从摄像机位置到近裁剪平面的距离
  • 在深度计算和雾效中起重要作用

主要应用

  • 深度值的重新映射和标准化
  • 基于距离的效果的起始点控制
  • 优化计算,避免处理过于接近摄像机的片段

实际使用

HLSL

// 计算标准化深度值
float linearDepth = (depth - _Camera_NearPlane) / (_Camera_FarPlane - _Camera_NearPlane);

Far Plane(远平面)端口

Far Plane端口输出摄像机的远裁剪平面距离,类型为Float。

技术关联

  • 对应_ProjectionParams.z变量
  • 与Near Plane配合使用定义摄像机的可视范围

核心用途

  • 深度缓冲区的范围定义
  • 雾效和大气效果的远距离控制
  • LOD(细节层次)系统的距离判断

典型应用模式

HLSL

// 判断片段是否在摄像机范围内
float inCameraRange = saturate((distanceToCamera - _Camera_NearPlane) / (_Camera_FarPlane - _Camera_NearPlane));

Z Buffer Sign(Z缓冲区符号)端口

Z Buffer Sign端口返回一个浮点值,指示当前使用的Z缓冲区方向。

返回值解释

  • 返回-1表示使用反转的Z缓冲区
  • 返回1表示使用传统的Z缓冲区

技术背景

  • 对应_ProjectionParams.x变量
  • 反转Z缓冲区是现代图形API中的常见优化技术
  • 影响深度值的比较和计算方式

应用重要性

  • 正确的深度值处理需要考虑到Z缓冲区方向
  • 自定义深度效果必须适应不同的Z缓冲区配置
  • 跨平台兼容性的关键因素

使用示例

HLSL

// 适应不同Z缓冲区配置的深度处理
float adjustedDepth = depth * _Camera_ZBufferSign;

Width(宽度)端口

Width端口输出正交摄像机的宽度值,类型为Float。

特定条件

  • 仅在正交摄像机模式下有实际意义
  • 对于透视摄像机,返回值可能不一致或为0

技术对应

  • 对应unity_OrthoParams.x变量
  • 表示正交摄像机在世界单位中的宽度覆盖范围

应用场景

  • 2D游戏中的像素完美渲染
  • UI元素的世界空间定位
  • 等距视角游戏中的坐标计算

Height(高度)端口

Height端口输出正交摄像机的高度值,类型为Float。

与Width端口的关联

  • 同样仅在正交模式下有效
  • 与Width共同定义正交摄像机的视口范围

实用价值

  • 计算正交摄像机下的屏幕比例
  • 实现响应式2D视觉效果
  • 世界坐标到屏幕坐标的转换

综合使用示例

HLSL

// 计算正交摄像机下的UV坐标
float2 orthoUV = (worldPos.xz - _Camera_Position.xz) / float2(_Camera_Width, _Camera_Height) + 0.5;

生成的代码示例

理解Camera节点在底层生成的代码对于高级着色器开发和调试非常重要。以下是对生成代码的详细解析:

完整代码结构

HLSL

float3 _Camera_Position = _WorldSpaceCameraPos;
float3 _Camera_Direction = -1 * mul(UNITY_MATRIX_M, transpose(mul(UNITY_MATRIX_I_M, UNITY_MATRIX_I_V)) [2].xyz);
float _Camera_Orthographic = unity_OrthoParams.w;
float _Camera_NearPlane = _ProjectionParams.y;
float _Camera_FarPlane = _ProjectionParams.z;
float _Camera_ZBufferSign = _ProjectionParams.x;
float _Camera_Width = unity_OrthoParams.x;
float _Camera_Height = unity_OrthoParams.y;

代码解析与优化建议

位置向量计算

HLSL

float3 _Camera_Position = _WorldSpaceCameraPos;

这是最直接的映射,_WorldSpaceCameraPos是Unity内置的全局变量,在所有着色器 passes 中都可用。

方向向量计算

HLSL

float3 _Camera_Direction = -1 * mul(UNITY_MATRIX_M, transpose(mul(UNITY_MATRIX_I_M, UNITY_MATRIX_I_V)) [2].xyz);

这个计算相对复杂,涉及多个矩阵运算:

  • UNITY_MATRIX_I_V是观察矩阵的逆矩阵
  • UNITY_MATRIX_I_M是模型矩阵的逆矩阵
  • 通过提取第三行([2].xyz)获取前向向量
  • 最后的矩阵乘法将其转换到合适空间

投影参数映射

HLSL

float _Camera_Orthographic = unity_OrthoParams.w;
float _Camera_NearPlane = _ProjectionParams.y;
float _Camera_FarPlane = _ProjectionParams.z;
float _Camera_ZBufferSign = _ProjectionParams.x;

_ProjectionParams是float4向量,各分量存储不同的投影参数:

  • x: Z缓冲区符号
  • y: 近平面距离
  • z: 远平面距离
  • w: 1.0 + Far/Near(用于深度计算)

正交参数访问

HLSL

float _Camera_Width = unity_OrthoParams.x;
float _Camera_Height = unity_OrthoParams.y;

unity_OrthoParams也是float4向量:

  • x: 正交摄像机宽度
  • y: 正交摄像机高度
  • z: 未使用
  • w: 正交模式标志

性能考虑与最佳实践

常量优化

大多数摄像机参数在单帧内是常量,可以考虑在SubShader级别或Pass级别进行预计算,避免逐片段计算。

条件编译

针对不同平台和渲染路径,可以使用条件编译来优化代码:

HLSL

#if defined(ORTHOGRAPHIC_CAMERA)
    // 使用正交特定优化
#else
    // 透视摄像机处理
#endif

矩阵运算优化

复杂的矩阵运算如方向计算可以考虑在顶点着色器中执行,然后通过插值传递给片段着色器,减少计算负担。

实际应用案例

案例1:基于距离的透明度渐变

需求场景

创建一个材质,使得物体在距离摄像机特定范围内逐渐变得透明,用于实现淡入淡出效果。

实现方案

HLSL

// 在Fragment着色器阶段
float3 cameraPos = _Camera_Position;
float nearFadeStart = _Camera_NearPlane + 1.0; // 近平面外1单位开始淡化
float nearFadeEnd = nearFadeStart + 2.0; // 2单位范围内完成淡化

float distanceToCamera = length(worldPos - cameraPos);
float nearAlpha = 1.0 - saturate((distanceToCamera - nearFadeStart) / (nearFadeEnd - nearFadeStart));

// 远距离淡化
float farFadeStart = _Camera_FarPlane - 5.0;
float farFadeEnd = _Camera_FarPlane;
float farAlpha = saturate((distanceToCamera - farFadeStart) / (farFadeEnd - farFadeStart));

float finalAlpha = nearAlpha * farAlpha;

案例2:屏幕空间雪花效果

需求场景

实现一个在下雪天气中,雪花似乎落在屏幕上的效果,而非3D空间中的真实雪花。

技术实现

HLSL

// 使用正交摄像机参数创建屏幕空间效果
float2 screenSpaceUV = IN.ScreenPosition.xy / IN.ScreenPosition.w;

// 根据摄像机模式调整效果
float isOrtho = _Camera_Orthographic;
float2 effectSize = lerp(float2(1.0, 1.0), float2(_Camera_Width, _Camera_Height), isOrtho);

// 创建雪花UV
float2 snowUV = screenSpaceUV * effectSize;
float snow = GenerateSnowPattern(snowUV, _Time.y);

// 混合到最终颜色
color.rgb = lerp(color.rgb, snowColor, snow * isOrtho);

案例3:自适应视差映射

需求场景

创建一种视差映射效果,能够根据摄像机是透视还是正交模式自动调整视差强度。

解决方案

HLSL

// 计算基础视差偏移
float2 parallaxOffset = CalculateParallaxOffset(texcoord, viewDir);

// 根据摄像机模式调整强度
float perspectiveStrength = 0.1;
float orthographicStrength = 0.02; // 正交模式下减弱效果

float adaptiveStrength = lerp(perspectiveStrength, orthographicStrength, _Camera_Orthographic);
parallaxOffset *= adaptiveStrength;

// 应用调整后的偏移
float2 newTexcoord = texcoord + parallaxOffset;

高级技巧与注意事项

性能优化策略

计算时机选择

  • 在顶点着色器中计算摄像机相关向量可以减少片段着色器的负担
  • 对于静态摄像机场景,可以考虑将摄像机参数作为常量传递

精度管理

  • 在世界空间很大的场景中,需要注意浮点精度问题
  • 可以考虑使用相对位置而非绝对位置进行计算

跨平台兼容性

移动平台考虑

  • 在移动设备上,复杂的矩阵运算可能影响性能
  • 建议使用简化计算或查找表方法

图形API差异

  • 不同图形API在Z缓冲区处理上可能有细微差异
  • 建议进行充分的跨平台测试

调试与故障排除

常见问题

  • 方向向量不正确:检查矩阵乘法顺序和空间转换
  • 深度计算错误:验证Z缓冲区符号和深度范围
  • 正交模式异常:确认摄像机设置和参数映射

调试技巧

  • 使用颜色编码可视化各个摄像机参数
  • 创建调试模式,单独测试每个端口的功能
  • 对比内置着色器变量与Camera节点输出的一致性

Camera节点作为URP Shader Graph中的重要组件,为着色器开发提供了强大的摄像机交互能力。通过深入理解其各个端口的功能和底层实现原理,开发者可以创建出更加动态、响应式和视觉丰富的效果。无论是简单的距离淡化还是复杂的屏幕空间效果,Camera节点都能提供必要的技术支持。掌握Camera节点的使用,将显著提升在URP管线中开发高级视觉效果的能力和效率。


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

ArcGIS Pro 中的 Notebooks 入门

作者 GIS之路
2026年3月4日 20:27

^ 关注我,带你一起学GIS ^

前言

Python 脚本使自动化 ArcGIS Pro 中的工作流成为可能。

本教程来源于ESRI官方示例如何在ArcGIS Pro中学习使用Notebooks

文中以ArcGIS Pro3.5为例,默认你已经具备了Python的基础知识。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2026年

系统:Windows 11

ArcGIS Pro:3.5

Python:3.11.11

2. 数据准备

俗话说巧妇难为无米之炊,数据就是软件开发的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载GIS数据。

别急,GIS之路公众号都给你准备好了

在公众号后台回复关键字:vector,获取数据下载链接。

而本文示例数据来源于ESRI官方教程,在此感谢ESRI相关工作人员的辛勤付出与免费共享。

数据下载地址https://arcgis.com/sharing/rest/content/items/efb53cb7d7dc4ed8af06821369cf196c/data

下载数据解压完成,将工程添加到地图,可在ArcGIS Pro目录窗口查看图层数据,存储在Toronto.gdb数据库中。

etobicoke、fire_stationsgreenspace要素类添加到当前地图中

对于ArcGIS Pro底图失效的同学,可查看以下文章进行解决。****

ArcGIS Pro 添加底图的方式

3. 创建 notebooks 并运行 Python 代码

ArcGIS Notebooks是一个基于JupyterLab构建的开源 web 应用程序 ,可用于创建和共享包含实时 Python 代码、可视化效果和叙事文本的文档(名为 Notebooks)。

详情请参考文章:ArcGIS Pro 中的 notebook 初识

在ArcGIS Pro的笔记本中创建并运行Python代码具有以下两种方式。

方式一: 点击插入选项卡,在工程窗口中选择New Notebook下拉菜单,然后点击New Notebook。或者存在保存过的笔记本的话,也可以通过Add and Open Notebook打开。

方式二: 点击分析选项卡,选择Python下拉菜单,点击Python Notebook

打开notebook笔记本窗口显示如下,由标题栏、工具栏和代码区组成,主要包括保存、新建、剪切、复制、运行等工具。

笔记本创建完成后将作为新视图在 ArcGIS Pro 的主窗口中显示。新笔记本将以.ipnyb 文件格式存储在工程主目录文件夹中。 新文件夹也会显示在目录窗格的笔记本文件夹下。

单击 Notebook 中的空单元格,轮廓变为蓝色,写入以下代码并运行。

print("Hello Notebook!")

其他使用方法请参考官方教程。

4. 管理单元格中的代码

Notebooks 中的代码将在单元格中运行。 单元格运行后,其顺序通过单元格旁的数字指示。 Notebooks 提供了用于管理单元格的工具。

下面以使用Python列表为例进行演示。

在单元格中定义一个数据列表mylist

mylist = [1, 2, 3, 4, 5]

列表是 Python 中的一种重要数据类型,其中包含一系列元素。 这里的元素为数值,但是列表也可以包含其他数据类型。 列表中的元素以逗号分隔。

在下一个空单元格中,输入下列代码行并运行单元格。

mylist[-1]

系统会对列表中的元素建立索引,索引编号从零开始。 可以使用元素的索引编号获得特定索引。 索引编号 -1 表示第一个元素从列表的末尾(即,最后一个元素)开始, 这会返回数值 6。索引编号为-2则会返回列表中倒数第二个元素,5

通过添加更多元素来更改定义 **mylist** 变量的单元格中的代码,但不要运行单元格。

mylist = [1, 2, 3, 4, 5, 6, 7, 8]

在此单元格下方添加新的单元格,使用代码 mylist[-1] 并单击运行按钮。结果是否与您的预期相符?结果为数值 6。 为什么不是数值 8

Notebook 中的代码逐个单元格输入,上一次使用的变量会存储在内存中。除非您使用重新定义 mylist 变量的代码来运行单元格,否则 mylist 值仍然是存储在内存中的值 [1, 2, 3, 4, 5,6],该列表中位置 -1 处的值仍然为 6

单击定义 mylist = [1, 2, 3, 4, 5, 6, 7, 8] 的行,并运行。然后单击调用代码 mylist[-1] 的单元格运行。输入结果如下,显示为8。

为此,可以选择单元格逐个运行,或者点击运行全部单元格按钮。

5. 在 notebook 中运行地理处理工具

通过以上内容你已对在 notebook 中输入代码进行了一些练习,现在可以打开一个新的笔记本运行一些地理处理工具。在代码开头导入arcpy包。

import arcpy

ArcPy 是 Python 包,它具有 Python 中的大部分可用 ArcGIS Pro 功能,包括地理处理功能。

以下是来自官方的建议。

由于是在 ArcGIS Pro 中使用此 notebook,因此,如果您未导入 ArcPy,使用地理处理工具的代码不会产生错误。 但是,建议您始终在地理处理代码的顶部包括 import arcpy,以便其在 ArcGIS Pro 以外可正常运行。

在同一单元格中添加新行运行以下代码。

GetCount 是 ArcPy 的函数,可运行数据管理工具工具箱中的获取计数地理处理工具。

结果显示在代码单元格下方。 要素类中有 84 行(要素)。 结果与使用 ArcGIS Pro 中的工具对话框运行工具时看到的消息非常相似。Notebooks 集成在 ArcGIS Pro 的地理处理框架中。

这表示在 notebook 中运行某一工具与使用工具对话框运行此工具类似。 在 notebook 中运行的任何工具也同时会显示在历史窗格中。

将 arcpy.GetCount 代码更改为如下所示,运行单元格。

arcpy.GetCount_management("ambulances")

此代码失败,并在消息的末尾,显示以下信息:

ExecuteError: Failed to execute. Parameters are not valid.
ERROR 000732: Input Rows: Dataset ambulances does not exist or is not supported
Failed to execute (GetCount).

很明显,代码运行失败了,用于获取消防站计数的代码在什么情况下正常运行?

要素类 fire_stations 是活动地图中的图层。 在 notebook 中,当使用地理处理工具的图形用户界面以交互方式运行地理处理工具时,可以通过活动地图中的图层名称引用数据集。

要素类 ambulances 未显示在活动地图的图层中,它也不是工程的默认地理数据库中的要素类。 对于不在活动地图或默认地理数据库中的要素类,可以通过指定指向此要素类的完整路径来引用它。

接下来,将查找指向 ambulances 要素类的路径。

目录窗格中,展开数据库部分,然后展开 Toronto.gdb

右键单击 ambulances,然后单击复制路径

即会复制 ambulances 要素类的文件路径。 该路径还包含要素类名称。

E:NotebookStartToronto.gdbambulances

再次运行计数代码,将要素类名称改为前面复制的数据路径。

arcpy.GetCount_management("E:NotebookStartToronto.gdbambulances")

当你以为成功的时候,跳出了最刺眼的红色,你知道废了。

路径看起来没问题,为什么仍然运行失败呢?因为缺少一些内容,还需要添加字母 r,告诉 Python 此路径是原始字符串。

Windows 计算机使用反斜线 () 作为路径分隔符。 在 Python 中,反斜线字符位于字符串中其他字符旁边时将作为转义符,对制表符、换行符或其他特殊字符进行编码。

这就意味着,当路径中 NotebookStart 旁边出现N 时,Python 读取字符串时会认为其中包含换行符。 在字符串之前放置字母 r 旨在告诉 Python 忽略转义符。

添加字母r并运行。

arcpy.GetCount_management(r"E:dataNotebookStartToronto.gdbambulances")

显示结果如下。

如果不想添加字母r,可在数据路径中使用斜杠"/"或者两个反斜杠"\"。以下为在Python中书写数据路径的方式。

  • r"E:\NotebookStart\Toronto.gdb\ambulances"
  • "E:/NotebookStart/Toronto.gdb/ambulances"
  • "E:\NotebookStart\Toronto.gdb\ambulances"

如果你不想为文件设置完整的数据路径,则可以使用工作空间。在导入arcpy包后定义workspace

arcpy.env.workspace = r"E:datashpNotebookStartNotebookStartToronto.gdb"

结果输出信息如下。

如果你想要知道地理数据库中每个要素类的计数。 可以复制单元格并编辑要素类的名称,然而,可以使用Python获取所有要素类的列表,然后对其运行 GetCount 函数。

# 展示工作空间中的要素类列表
fc_list = arcpy.ListFeatureClasses()
# 打印列表
print("##################要素类列表##################")
print(fc_list)
print("##################要素类列表##################n")
# 便利列表

print("##################要素类及其计数##################")
for fc in fc_list:
    # 要素类计数
    count = arcpy.GetCount_management(fc)
    # 打印要素类及其计数
    print(fc, count)
print("##################要素类及其计数##################")

在单元格中运行代码显示结果如下。

6. 使用 notebook 运行分析

本节将使用 notebook 进行一些 GIS 分析工作。 假设用户想要了解 Etobicoke 行政区内哪些区域距离消防站最远。 则可以在 notebook 中使用地理处理工具表示这些区域。

在代码中键入arcpy.,将光标置于"."之后,接着按下tab键可打开代码提示。接着输入模块或工具名称可对提示信息进行过滤,如输入Bu

单机Buffer_analysis工具,将光标至于Buffer_analysis后或者其中,按住【shift+tab】组合键,可以打开工具说明文档。Buffer_analysis 工具的三个必要参数为:输入要素类、输出要素类和缓冲距离。 还有其他可选参数,但只有上述三个为必需的参数。

代码中将缓冲 fire_stations 要素类,命名输出要素类 fire_buffer,使工具以 1000 米的距离建立消防站缓冲区。在单元格中输入以下代码并运行:

arcpy.Buffer_analysis("fire_stations","fire_buffer","1000 METERS")

生成的要素类会添加至当前地图,结果将显示落在距消防站 1000 米(即 1 千米)以内的区域,以及以外的区域。

将缓冲距离更改为 1750 米,并再次运行单元格。

arcpy.Buffer_analysis("fire_stations","fire_buffer","1750 METERS")

显示结果如下。

如果收到错误消息,“ExecuteError: 无法执行。 参数无效”,提到 fire_buffer 已经存在,然后您的 ArcGIS Pro 环境设置、地理处理选项未设置为允许覆盖现有要素类。要修复此问题,在单元格的 arcpy.Buffer_analysis 行前插入新行。 在新的行中,添加以下代码:arcpy.env.overwriteOutput = True,这将允许缓冲区工具覆盖以前的输出。 单元格现在应包含:

arcpy.env.overwriteOutput = True
arcpy.Buffer_analysis("fire_stations""fire_buffer""1750 METERS")

运行单元格。

添加一个新的单元格,并写入以下代码并运行。

arcpy.PairwiseErase_analysis("etobicoke""fire_buffer""no_service")

地图显示结果如下。

此代码会针对 etobicoke 要素类调用 PairwiseErase_analysis 工具,从中擦除 fire_buffer 要素类中的区域,并将结果写入名为 "no_service" 的新要素类。

在图层目录窗格中,右键单击 no_service 图层,然后单击缩放至图层,图层 no_service 将显示距离消防站较远的地方。

对于消防服务而言,得到的区域可能是受影响最大的区域。 通过在 notebook 中以单个单元格的方式运行多行代码可获得更新后的结果。 如果你已在工具的图形用户界面中使用过此工具,则需要再次运行缓冲区工具和成对擦除工具才能获得更新后的结果。

仅两个工具所节省的时间非常有限,但是包含更长工具序列的工作流会节省很多时间。 此外,对于针对多个输入运行同一流程的情况,在循环中运行一个或多个工具的功能,会让 Python 非常有用。

7. 参考资料

  • Notebooks 入门:https://learn.arcgis.com/zh-cn/projects/get-started-with-notebooks-in-arcgis-pro

GIS之路-开发示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集(全)

GDAL 开发合集(全)

GIS 影像数据源介绍

GeoJSON 数据源介绍

GIS 名词解释

ArcPy,一个基于 Python 的 GIS 开发库简介

GIS 开发库 Turf 介绍

GIS 开发库 GeoTools 介绍

GIS 开发库 GDAL 介绍

地图网站大全

从微信指数看当前GIS框架的趋势

Landsat 卫星数据介绍

OGC:开放地理空间联盟简介

中国地图 GeoJSON 数据集网站介绍

《vue 2 升级vue3 父组件 子组件 传值: value 和 v-model

作者 lemon_yyds
2026年3月4日 18:09

🧩 v-model 与 value 的关系

当你在一个原生 <input> 上使用 v-model 时,Vue 会将其展开为以下代码:

html

预览

1<!-- 你写的代码 -->
2<input v-model="message">
3
4<!-- Vue 展开后的代码 -->
5<input :value="message" @input="message = $event.target.value">

可以看到,v-model 自动利用了 value 属性来展示数据,并监听 input 事件来更新数据。

🆚 Vue 2 与 Vue 3 的核心区别

1. 响应式原理的变革 (根本原因)

这是导致所有行为差异的根源。

  • Vue 2: 使用 Object.defineProperty。它只能劫持对象的已有属性,无法检测到对象属性的动态添加或删除5。
  • Vue 3: 使用 Proxy。它代理整个对象,可以检测到对象属性的任意变化(增、删、改)25。

2. 组件上 v-model 的默认行为

这是你在开发中感受最明显的区别,尤其是在封装自定义组件时。

表格

特性 Vue 2 Vue 3
默认 Prop value modelValue
默认事件 input update:modelValue
多 Model 支持 不支持,需使用 .sync 修饰符 原生支持多个 v-model

代码对比:

  • Vue 2 中的组件使用

    html

    预览

    1<!-- 父组件 -->
    2<MyComponent v-model="title" />
    3
    4<!-- MyComponent 内部 -->
    5<template>
    6  <!-- 接收 value,触发 input -->
    7  <input :value="value" @input="$emit('input', $event.target.value)" />
    8</template>
    9<script>
    10export default {
    11  props: ['value'] // 默认接收 value
    12}
    13</script>
    
  • Vue 3 中的组件使用

    html

    预览

    1<!-- 父组件 -->
    2<MyComponent v-model="title" />
    3
    4<!-- MyComponent 内部 -->
    5<template>
    6  <!-- 接收 modelValue,触发 update:modelValue -->
    7  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
    8</template>
    9<script setup>
    10defineProps(['modelValue']) // 默认接收 modelValue
    11</script>
    

3. v-model 修饰符与多绑定

Vue 3 的 v-model 变得更加强大和灵活。

  • 多个 v-model:Vue 3 允许你在同一个组件上绑定多个 v-model,通过参数名区分14。

    html

    预览

    1<!-- Vue 3 语法 -->
    2<UserEditor 
    3  v-model:name="userName" 
    4  v-model:age="userAge"
    5/>
    

    这在 Vue 2 中是无法直接实现的,通常需要配合 .sync 修饰符来模拟。

  • 自定义修饰符:Vue 3 支持更灵活的修饰符扩展2。

4. 在原生元素上的表现

对于原生的 <input><textarea><select> 等元素,v-model 的用法在 Vue 2 和 Vue 3 中基本一致,都用于简化双向绑定的代码。主要区别体现在自定义组件的封装上。


💡 总结与迁移建议

  1. 核心变化:Vue 3 将组件 v-model 的默认 prop 从 value 改为了 modelValue,事件从 input 改为了 update:modelValue
  2. 迁移注意:当你将 Vue 2 项目升级到 Vue 3 时,所有自定义表单组件如果依赖默认的 v-model 行为,都需要将 props 中的 value 改为 modelValue,并将 $emit('input') 改为 $emit('update:modelValue')1。
  3. 兼容写法:如果你在 Vue 3 中需要兼容旧的组件库(如 Ant Design Vue),它们可能仍然使用 value prop,这时你需要显式地使用 v-model:value 来绑定,而不是简写的 v-model3。

TinyVue 支持 Skills 啦!现在你可以让 AI 使用 TinyVue 组件搭建项目

2026年3月4日 18:08

你好,我是 Kagol,个人公众号:前端开源星球

一个月前,有用户建议 TinyVue 出几个 Skills,方便 AI 编程。

image.png

必须安排上!

目前 TinyVue 组件库和 TinyRobot AI 对话组件均已支持 Agent Skills,你可以在支持 Skills 的 IDE(比如 VSCode、Cursor、Trae 等) 上配置和使用。

1 演示视频

先看下使用效果(以 Trae 为例)。

TinyVue Skills:让 AI 使用 TinyVue 组件生成前端页面:www.bilibili.com/video/BV1d6…

以 Trae 为例,给大家介绍如何安装和配置 TinyVue Skills。

2 安装 TinyVue Skills

在命令行终端中执行以下命令:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent trae

image.png

安装方式选择 Symlink (Recommended)

安装成功!

image.png

查看 Skills 是否安装成功:

npx skills list -g

查看全局skills.png

3 开启 TinyVue Skills

打开 Trae 的设置页面,在左侧的【规则和技能】菜单中找到【技能】,开启【tiny-vue-skill】这个技能即可。

Trae启用Skill.png

4 在 AI 对话框中使用 TinyVue Skills

在 Trae 中打开 AI 侧栏,输入以下内容:

使用TinyVue组件创建一个登录组件,并集成到App.vue中

AI 会去调用 tiny-vue-skill 技能,根据其中的 SKILL.md 中的描述,去查看对应的组件 API/Demo 文档,然后使用适当的 TinyVue 组件搭建你需要的页面。

这样比 AI 去海量互联网信息中寻找 TinyVue 的用法要准确得多,而且消耗更少的 Token,也不容易产生幻觉。

ai对话.png

如果你正在使用 TinyVue 组件库,强烈推荐你配置上 tiny-vue-skill,让 AI 辅助编码,效率更高!

如果你用的是 VSCode Copilot、Cursor 等其他 IDE也没关系,安装 TinyVue Skills 遵循类似的步骤,只需要把命令中的 --agent 修改成对应的 IDE 即可,以下是对应表格。

比如在 Cursor 中安装 tiny-vue-skill:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent cursor
Agent --agent 项目内路径 全局路径
Amp amp .agents/skills/ ~/.config/agents/skills/
Antigravity antigravity .agent/skills/ ~/.gemini/antigravity/skills/
Claude Code claude-code .claude/skills/ ~/.claude/skills/
Clawdbot clawdbot skills/ ~/.clawdbot/skills/
Codex codex .codex/skills/ ~/.codex/skills/
Cursor cursor .cursor/skills/ ~/.cursor/skills/
Droid droid .factory/skills/ ~/.factory/skills/
Gemini CLI gemini-cli .gemini/skills/ ~/.gemini/skills/
GitHub Copilot github-copilot .github/skills/ ~/.copilot/skills/
Goose goose .goose/skills/ ~/.config/goose/skills/
Kilo Code kilo .kilocode/skills/ ~/.kilocode/skills/
Kiro CLI kiro-cli .kiro/skills/ ~/.kiro/skills/
OpenCode opencode .opencode/skills/ ~/.config/opencode/skills/
Roo Code roo .roo/skills/ ~/.roo/skills/
Trae trae .trae/skills/ ~/.trae/skills/
Windsurf windsurf .windsurf/skills/ ~/.codeium/windsurf/skills/

联系我们

GitHub:github.com/opentiny/ag…(欢迎 Star ⭐)

TinyVue 官网:opentiny.design/tiny-vue

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

Cursor配置MasterGo MCP:一键读取设计稿生成高还原度前端代码

作者 simple_lau
2026年3月4日 18:05

通过在 Cursor 的 MCP 设置中添加命令 npx -y @mastergo/magic-mcp --token=YOUR_TOKEN,即可让 AI 直接读取 MasterGo 设计稿数据并自动生成高还原度的前端代码。

前言:告别像素级手磨代码

在传统的前端开发流程中,"从设计到代码"往往是最耗时的环节。开发者需要反复在设计工具和编辑器之间切换,手动测量间距、提取颜色值、计算布局比例。即便如此,最终的样式还原度也难以保证。随着 Cursor 引入了 MCP(Model Context Protocol)协议,MasterGo 官方推出的 Magic MCP 服务彻底打破了这一壁垒。它允许 Cursor AI 直接访问设计稿的底层 DSL 数据,这意味着 AI 不再是"看图说话",而是直接读取精确的设计规范,实现 99% 以上的样式还原。

第一步:获取 MasterGo 访问令牌 (Token)

要让 Cursor 有权限读取你的 MasterGo 文件,首先需要生成一个个人访问令牌。请登录 MasterGo 官网,点击右上角头像进入"个人设置",随后在"设置"菜单中找到"安全设置"选项。在这里你可以看到"生成令牌"的按钮,点击并为该令牌命名(如 Cursor-Integration)。系统会生成一段长字符串,请务必立即复制并妥善保存,因为出于安全考虑,该令牌在关闭窗口后将无法再次查看。 MasterGo MCP - MasterGo 帮助中心

第二步:环境准备与 Node.js 检查

MasterGo MCP 服务是基于 Node.js 运行的,因此在配置之前,请确保你的电脑已安装 Node.js 环境。你可以打开终端(Terminal 或 CMD),输入 node -v 来验证。如果显示版本号(建议 v18 及以上),则说明环境已就绪。如果尚未安装,请前往 Node.js 官网下载长期支持版(LTS)。此外,确保你的 Cursor 版本是最新的,以获得对 MCP 协议的最佳支持。 MasterGo MCP - MasterGo 帮助中心

第三步:在 Cursor 中激活 MasterGo MCP

Cursor 提供了两种配置 MCP Server 的方式,你可以根据自己的习惯选择:

方式一:通过 UI 界面配置(推荐新手)

打开 Cursor 的设置面板(快捷键 Ctrl + Shift + J 或点击右上角齿轮图标),导航至 Features 选项卡下的 MCP 栏目。点击 + Add New MCP Server 按钮,在弹出的窗口中填入以下信息:

  • Name: 建议填写 MasterGo
  • Type: 选择 command
  • Command: 输入 npx -y @mastergo/magic-mcp --token=你的Token(请将"你的Token"替换为第一步中获取的字符串)。

对于 Windows 用户,如果遇到执行权限问题,建议将 Command 栏的内容修改为 cmd /c npx -y @mastergo/magic-mcp --token=你的Token。点击保存后,如果看到绿色的连接状态,说明配置成功。

方式二:通过 mcp.json 配置文件(推荐进阶用户)

如果你更喜欢直接编辑配置文件,或者需要在多个项目中复用相同的 MCP 配置,可以手动创建或修改 mcp.json 文件。该文件通常位于以下位置之一:

  • 项目级别:.cursor/mcp.json(仅当前项目生效)
  • 全局级别:~/.cursor/mcp.json(所有项目生效)

将以下内容粘贴到配置文件中,并将 YOUR_TOKEN 替换为你实际获取的 Token:

{
  "mcpServers": {
    "mastergo-magic-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "@mastergo/magic-mcp",
        "--token=YOUR_TOKEN",
        "--url=https://mastergo.com"
      ],
      "env": {
        "NPM_CONFIG_REGISTRY": "https://registry.npmjs.org/"
      }
    }
  }
}

这里的 env 字段配置了 npm 镜像源,可以有效避免国内网络环境下可能出现的包下载超时问题。保存文件后,重启 Cursor 使配置生效。 mastergo-design/mastergo-magic-mcp

第四步:实战演练——从设计稿到 React/Vue 组件

配置完成后,你就可以在 Cursor Chat 中通过链接直接### **通过在 Cursor 的 MCP 设置中添加命令 npx -y @mastergo/magic-mcp --token=YOUR_TOKEN,即可让 AI 直接读取 MasterGo 设计稿数据并自动生调用设计数据了。在 MasterGo 设计稿中,选中你想要生成的图层或容器,右键选择"复制链接"。回到 Cursor 的对话框,输入类似这样的指令:"参考这个设计稿链接 https://mastergo.com/file/xxxx?layer=yyyy,帮我用 React 和 Tailwind CSS 写一个响应式的商品卡片组件"。

此时,Cursor 会通过 MCP 插件调用 MasterGo 的 API,解析该图层的宽度、高度、圆角、阴影、字体样式以及 Flex 布局信息。它生成的代码将不再是模糊的猜测,而是带有精确像素值和 CSS 变量的高质量组件代码。

进阶技巧与常见问题排查

为了获得更佳的体验,你可以尝试一些高级配置。例如,在配置命令中添加 --cleanLayers=true 参数,可以自动过滤掉设计稿中冗余的编组层,让生成的代码结构更加扁平化。如果遇到连接超时,可以检查是否设置了网络代理,或者尝试在 env 中配置其他 npm 镜像源(如 https://registry.npmmirror.com)。

如果 Cursor 提示找不到工具,请检查 MCP 设置中的状态是否为 Active。有时重启 Cursor 能够解决大部分配置不生效的问题。此外,请确保你提供的 MasterGo 链接包含具体的 layer 参数,否则 AI 可能无法准确定位到具体的设计元素。 mastergo-design/mastergo-magic-mcp

希望这份指南能帮你成功开启 AI 驱动的设计开发新流。祝你的编码过程如丝般顺滑。

从零打造 AI 全球趋势监测大屏

作者 柳杉
2026年3月4日 18:02

前言

在 AI 浪潮席卷全球的今天,如何直观展示 AI 领域的发展态势成为一个有趣的课题。本文将分享一个「AI 全球趋势监测大屏」的完整技术实现,从技术选型到功能设计,带你一步步构建一个炫酷的数据可视化大屏。

截屏2026-03-03 18.29.52.png

技术栈选型

项目采用现代化的前端技术栈:

技术 版本 用途
React 19.2.3 组件化 UI 框架
TypeScript 5.9.3 类型安全
Vite 7.2.4 极速构建工具
ECharts 6.0.0 数据可视化引擎
Tailwind CSS 4.1.17 原子化 CSS
autofit.js 3.2.8 大屏自适应方案

为什么选择这套技术栈?

1. React 19 + TypeScript

  • 最新的 React 19 带来更好的并发渲染性能
  • TypeScript 提供完善的类型推导,开发体验极佳

2. Vite 7 极速构建

  • 冷启动时间 < 300ms
  • HMR 热更新几乎无感知
  • 生产构建优化,支持单文件打包

3. ECharts 6 数据可视化

  • 支持世界地图、飞线动效、涟漪散点
  • 丰富的交互能力和动画效果
  • 优秀的性能表现

4. autofit.js 大屏适配

  • 一行代码实现 1920×1080 等比缩放
  • 自动处理各种分辨率屏幕

功能模块解析

整个大屏采用经典的「左-中-右」三栏布局,包含 9 大核心组件

🗺️ 全球 AI 模型分布地图(WorldMap)

核心亮点:

  • GeoJSON 动态加载:从 CDN 加载世界地图数据
  • 涟漪散点效果:模型数量越多,散点越大
  • 飞线动画:展示各国 AI 技术交流路径
  • 优雅降级:地图加载失败时自动切换为散点图模式
typescript
// 涟漪效果配置
rippleEffect: {
  brushType: 'stroke',
  scale: 5,
  period: 4,
}

📊 AI 模型热度趋势(TrendChart)

展示 Claude、Gemini、GPT-5、DeepSeek 等主流模型的热度变化曲线:

  • 平滑曲线 + 渐变填充
  • 多系列对比展示
  • 交互式 Tooltip

🏆 各国 AI 模型数量排行(ModelRanking)

  • 动态进度条动画
  • 金银铜牌样式排名
  • 自动高亮轮播效果

🔥 火热模型排行榜(HotModels)

实时展示全球 Top 10 AI 模型:

  • 模型名称、所属公司、国旗标识
  • 涨跌趋势指示(▲/▼)
  • 自动轮播高亮

☁️ AI 热门词汇(HotTerms)

词云式展示当前 AI 领域热词:

  • 点击切换中英文显示
  • 字体大小反映热度权重
  • 浮动动画效果

📈 汇总统计卡片(StatsCards)

四大核心指标数字动画展示:

  • 全球 AI 模型总数
  • 覆盖国家数量
  • 模型参数总量
  • 日均调用量

采用缓动函数实现数字滚动动画:

typescript
const eased = 1 - Math.pow(1 - progress, 3); // 缓出动画

📰 AI 实时资讯(NewsTicker)

无缝滚动的新闻跑马灯:

  • CSS 动画驱动,性能优异
  • 渐变遮罩实现淡入淡出

项目架构

采用模块化组件设计,每个功能独立成文件:

plaintext
src/
├── components/
│   ├── DashboardCard.tsx    # 可复用卡片容器
│   ├── WorldMap.tsx         # 世界地图
│   ├── TrendChart.tsx       # 趋势图表
│   ├── ModelRanking.tsx     # 国家排行
│   ├── HotTerms.tsx         # 热门词汇
│   ├── HotModels.tsx        # 模型排行
│   ├── NewsTicker.tsx       # 新闻滚动
│   ├── StatsCards.tsx       # 统计卡片
│   └── index.tsx            # 统一导出
├── data.ts                  # 数据源
└── App.tsx                  # 主页面

视觉设计要点

科技感配色方案

  • 主色调:#00d4ff(科技蓝)
  • 辅助色:#00ff88(活力绿)、#ffdd00(警示黄)
  • 背景:深邃渐变 + 网格线装饰

细节打磨

  • 卡片四角装饰边框
  • 标题栏渐变 + 指示灯
  • 全局光晕效果
  • 脉冲呼吸动画

大屏适配方案

一行代码搞定所有分辨率:

typescript
autofit.init({
  el: 'body',
  dw: 1920,  // 设计稿宽度
  dh: 1080,  // 设计稿高度
  resize: true,
});

总结

本项目展示了如何利用现代前端技术栈,快速构建一个功能丰富、视觉炫酷的数据可视化大屏。核心要点:

  1. 技术选型:React + TypeScript + Vite + ECharts 黄金组合
  2. 组件化设计:高内聚、低耦合,便于维护扩展
  3. 视觉体验:科技感配色 + 丰富动效
  4. 适配方案:autofit.js 一键解决多分辨率问题

希望这篇文章对你有所启发,欢迎在评论区交流讨论!

欢迎 Star ⭐,一起探索智慧医疗可视化的无限可能!


我放在公众号(柳杉前端) 回复 AI全球趋势监测大屏 获取源码

#前端开发 #数据可视化 #React #智慧城市 #大屏设计

初学React:请求数据参数未更新 && 数据异步状态更新问题

作者 gxp123
2026年3月4日 18:02
 // 请求参数
const [params, setParams] = useState({
    page: 1,
    per_page: 4,
    begin_pubdate: null,
    end_pubdate: null,
    status: '',
    channel_id: null
  })
  // 点击按钮
 const onFinish = (formValue)=>{
    // 设置参数
    setParams({
      ...params,
      channel_id:formValue.channel_id,
      status: formValue.status,
      begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
      end_pubdate:  formValue.date ? formValue.date[1].format('YYYY-MM-DD')  :  null
    })
  // 更新表格数据
    getTableList()
  }

首次点击按钮导致请求参数没有更新问题。

原因在于 React 的状态更新是异步的。在 onFinish 函数中,先调用了 setParams 更新筛选参数,然后立即调用 getTableList。但由于 setParams 不会立即修改 params 的值,此时 getTableList 内部读取的仍然是旧的 params,因此第一次请求没有带上新选择的筛选条件。第二次点击时,params 已经更新为上一次的值,所以请求能带上上次的条件,但这次又可能因为同样的原因滞后。

解决方案

1. 在 onFinish 中构造新参数并直接传给 getTableList(推荐)

修改 getTableList 使其接受参数,调用时传入最新的筛选条件。

// 修改 getTableList,增加参数
const getTableList = async (reqParams) => {
  // 如果没有传入参数,则使用当前 state 中的 params(用于首次加载)
  const finalParams = reqParams || params;
  try {
    const res = await http.get('/mp/articles', { params: finalParams });
    const { results, total_count } = res.data;
    setArticleTableList({
      list: results,
      count: total_count
    });
  } catch (error) {
    console.log(error);
  }
};

// 修改 onFinish
const onFinish = (formValue) => {
  // 基于当前 params 和表单值构造新参数对象
  const newParams = {
    ...params,
    channel_id: formValue.channel_id,
    status: formValue.status,
    begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
    end_pubdate: formValue.date ? formValue.date[1].format('YYYY-MM-DD') : null
  };
  setParams(newParams);          // 更新状态用于后续操作(如分页)
  getTableList(newParams);       // 立即用新参数请求数据
};

2. 使用 useEffect 监听 params 变化自动请求

删除 onFinish 中手动调用 getTableList 的代码,改为依赖 params 的副作用。

useEffect(() => {
  getTableList();
}, [params]); // params 变化时重新请求

const onFinish = (formValue) => {
  setParams({
    ...params,
    channel_id: formValue.channel_id,
    status: formValue.status,
    begin_pubdate: formValue.date ? formValue.date[0].format('YYYY-MM-DD') : null,
    end_pubdate: formValue.date ? formValue.date[1].format('YYYY-MM-DD') : null
  });
  // 不需要再手动调用 getTableList
};

注意:使用 useEffect 时需要确保 params 的引用变化(每次更新都创建新对象),并且首次加载也会触发,因此初始 useEffect 中的手动调用可以移除。

总结

两种方式均可解决问题。第一种更直观,请求时机完全由开发者控制;第二种更符合 React 数据流,但需注意避免额外副作用。根据你的场景选择即可。

如何设计一个真正可扩展的表单生成器?

2026年3月4日 18:00

🧩 如何设计一个真正可扩展的表单生成器?

🧠 你写过多少次 CRUD 表单?登录表单、搜索表单、配置表单、后台管理表单……
有没有想过:为什么不抽象成一套“表单引擎”?

封装一个可以扩展的表单生成器,尤其是对一些中后台系统、低代码平台、企业级项目,会非常有价值。


🚀 一、从“写表单”到“设计表单系统”

我们先看一个普通的表单:

<el-form :model="form">
  <el-form-item label="用户名">
    <el-input v-model="form.username" />
  </el-form-item>

  <el-form-item label="年龄">
    <el-input-number v-model="form.age" />
  </el-form-item>
</el-form>

问题在哪?

  • ❌ 每个页面都写一遍
  • ❌ 字段变化要改代码
  • ❌ 无法动态生成
  • ❌ 后端配置驱动做不了

这时候我们会思考一个问题:

能不能用 JSON 描述表单?


📦 二、Schema 驱动:表单的核心抽象

理想状态是这样:

const schema = [
  {
    type: 'input',
    label: '用户名',
    field: 'username',
    props: {
      placeholder: '请输入用户名'
    }
  },
  {
    type: 'number',
    label: '年龄',
    field: 'age',
  }
]

然后我们写一个 <SchemaForm />

<SchemaForm :schema="schema" v-model="formData" />

这就是:

Schema Driven UI(配置驱动 UI)


🧠 三、设计表单生成器的核心架构

一个成熟的表单生成系统,至少包含 5 个层次:

Schema 配置层
      ↓
字段解析层
      ↓
组件映射层
      ↓
状态管理层
      ↓
渲染引擎层

我们逐层拆解。


🧩 四、组件映射系统设计(核心关键)

最重要的一步:

type → 组件映射

const componentMap = {
  input: ElInput,
  number: ElInputNumber,
  select: ElSelect,
}

渲染逻辑:

const Component = componentMap[item.type]

return h(Component, {
  ...item.props,
  modelValue: formData[item.field],
  'onUpdate:modelValue': (val) => {
    formData[item.field] = val
  }
})

这样我们就实现了:

  • ✅ 动态组件渲染
  • ✅ 双向绑定
  • ✅ 可扩展组件类型

🧱 五、真正高级的地方:扩展能力设计

一个“玩具级”表单生成器和一个“工程级”的区别在于:

可扩展能力

必须支持:

1️⃣ 动态显隐

{
  type: 'input',
  field: 'company',
  visible: (form) => form.role === 'admin'
}

解析时:

if (typeof item.visible === 'function') {
  return item.visible(formData)
}

2️⃣ 联动机制

{
  type: 'select',
  field: 'province',
  onChange: (val, form) => {
    form.city = ''
  }
}

3️⃣ 异步字段(远程选项)

{
  type: 'select',
  field: 'user',
  asyncOptions: () => fetchUserList()
}

4️⃣ 插槽扩展

<SchemaForm>
  <template #customField="{ field }">
    <MyCustomComponent />
  </template>
</SchemaForm>

🧠 六、状态管理怎么设计才优雅?

很多人会这样写:

const formData = reactive({})

但在复杂场景中:

  • 校验
  • dirty 状态
  • touched 状态
  • 提交状态
  • 异步 loading

你需要抽象出一个 FormStore

class FormStore {
  values = reactive({})
  errors = reactive({})
  touched = reactive({})

  setFieldValue(field, value) {
    this.values[field] = value
  }

  validate() {}
}

⚙️ 七、企业级表单生成器的高级能力

真正成熟的系统会支持:

能力 说明
嵌套表单 object / array 结构
动态增删字段 表单列表
表单分组 step 表单
表单布局系统 grid / col 配置
表单 JSON 导出 支持保存配置
拖拽编辑器 低代码场景
远程 schema 后端下发表单配置

🔥 八、进阶认知:为什么很多大厂都在做 Schema Form?

原因很简单:

  • 后端驱动 UI
  • 多系统复用
  • 业务快速迭代
  • 统一规范
  • 降低重复开发成本

Ant Design Pro、阿里飞冰、字节内部平台,核心都在做这件事。


🧠 九、总结:表单生成器的本质是什么?

不是为了“少写代码”。

而是:

把 UI 抽象成数据
把行为抽象成规则
把渲染抽象成引擎

模块化与组件化:90%的前端开发者都没搞懂的本质区别

2026年3月4日 17:56

一位刚入职不久的网友留言问我:"我们一直在说模块化开发、组件化设计,这两个概念到底有什么区别?我感觉它们不就是把代码拆分开来吗?"

今天,我想从自己的角度,聊聊我对这两个概念的深度理解。

什么是模块化?

模块化是代码组织层面的哲学,关注的是"职责边界"。

简单来说,模块化就是把一个复杂的系统,按照功能职责拆分成独立的文件或代码单元。每个模块负责完成特定的功能,对外暴露必要的接口,隐藏内部实现细节。

看一个最朴素的例子:

// math.js - 一个纯粹的数学计算模块
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// 内部实现细节,不对外暴露
function validateNumber(num) {
  if (typeof num !== 'number') {
    throw new Error('参数必须是数字');
  }
}
// app.js - 使用模块
import { add, multiply } from './math.js';

console.log(add(5, 3)); // 8

模块化的核心特征是:

  1. 高内聚:相关功能紧密放在一起
  2. 低耦合:模块之间通过明确定义的接口通信
  3. 封装性:隐藏内部实现细节
  4. 关注点分离:每个模块解决一个特定问题

在ES6之前,我们通过IIFE实现模块化,现在有了原生的ES Module,模块化已经成为JavaScript的基础设施。

什么是组件化?

组件化是UI构建层面的哲学,关注的是"呈现与交互"。

组件化将用户界面拆分成独立的、可复用的部件。每个组件封装了自己的结构(HTML)、样式(CSS)和行为(JavaScript),可以被组合成更复杂的界面。

看一个React组件的例子:

// Button.jsx - 一个UI组件
import React from 'react';
import './Button.css'; // 组件自己的样式

const Button = ({ variant = 'primary', size = 'medium', children, onClick }) => {
  // 内部状态管理
  const [isHovered, setIsHovered] = useState(false);
  
  // 内部逻辑处理
  const handleMouseEnter = () => setIsHovered(true);
  const handleMouseLeave = () => setIsHovered(false);
  
  return (
    <button
      className={`btn btn-${variant} btn-${size} ${isHovered ? 'hovered' : ''}`}
      onClick={onClick}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {children}
    </button>
  );
};

export default Button;
// App.jsx - 组合组件
import React from 'react';
import Button from './Button';

const App = () => {
  return (
    <div>
      <Button variant="primary" size="large" onClick={() => alert('点击')}>
        主要按钮
      </Button>
      <Button variant="secondary" size="small">
        次要按钮
      </Button>
    </div>
  );
};

组件化的核心特征是:

  1. 可组合性:组件可以嵌套组合成复杂界面
  2. 可复用性:同一组件可在不同地方重复使用
  3. 自包含:组件包含自身所需的资源
  4. 接口明确:通过props定义清晰的输入输出

本质区别:一个思想实验

假设我们要开发一个电商网站的用户中心页面。

模块化视角

  • 把用户相关的API请求封装成 userAPI.js 模块
  • 把价格格式化功能封装成 priceFormatter.js 模块
  • 把购物车计算逻辑封装成 cartCalculator.js 模块
  • 这些模块可以在任何地方使用,甚至不在浏览器环境

组件化视角

  • 把用户头像区域做成 UserAvatar 组件
  • 把订单列表做成 OrderList 组件
  • 把商品卡片做成 ProductCard 组件
  • 这些组件组合在一起形成完整的页面

现在,最关键的区别来了:

模块化解决的是"如何组织代码"的问题,组件化解决的是"如何构建界面"的问题。

更本质地说:

  • 模块化的最小单位是函数或文件,关注的是逻辑、数据、功能的封装
  • 组件化的最小单位是UI元素,关注的是视图、交互、样式的封装

但最深刻的认识是:模块化是组件化的基础,组件化是模块化在UI层的具体体现。

实战中的混淆与重构

让我用一个真实的重构案例来说明这两者的区别。

重构前(混淆概念)

// UserProfile.jsx - 一个"组件",但实际上什么都做
import React, { useState, useEffect } from 'react';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  
  // 直接在这里写API调用
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
      
    fetch(`/api/users/${userId}/orders`)
      .then(res => res.json())
      .then(setOrders);
  }, [userId]);
  
  // 直接在这里写复杂的数据处理
  const totalSpent = orders.reduce((sum, order) => {
    // 各种复杂的价格计算逻辑
    return sum + order.amount;
  }, 0);
  
  // 格式化函数直接写在组件里
  const formatDate = (dateStr) => {
    const date = new Date(dateStr);
    return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`;
  };
  
  return (
    <div>
      <h1>{user?.name}</h1>
      <p>总消费: ¥{totalSpent}</p>
      <div>
        {orders.map(order => (
          <div key={order.id}>
            <span>{formatDate(order.createdAt)}</span>
            <span>¥{order.amount}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

这个"组件"的问题在于:它混淆了组件化和模块化的边界,导致:

  • 组件臃肿难以维护
  • 业务逻辑无法复用
  • 难以测试
  • 代码重复

重构后(明确职责)

// modules/userAPI.js - 纯模块,处理用户数据获取
export const fetchUser = async (userId) => {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
};

export const fetchUserOrders = async (userId) => {
  const response = await fetch(`/api/users/${userId}/orders`);
  return response.json();
};
// modules/orderCalculator.js - 纯模块,处理订单计算逻辑
export const calculateTotalSpent = (orders) => {
  return orders.reduce((sum, order) => sum + order.amount, 0);
};

export const formatCurrency = (amount) => {
  return new Intl.NumberFormat('zh-CN', { 
    style: 'currency', 
    currency: 'CNY' 
  }).format(amount);
};
// modules/dateFormatter.js - 纯模块,处理日期格式化
export const formatDate = (dateStr, format = 'simple') => {
  const date = new Date(dateStr);
  if (format === 'simple') {
    return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`;
  }
  // 其他格式...
  return date.toLocaleDateString();
};
// components/OrderItem.jsx - 纯粹的展示组件
const OrderItem = ({ order }) => {
  return (
    <div className="order-item">
      <span>{formatDate(order.createdAt)}</span>
      <span>{formatCurrency(order.amount)}</span>
    </div>
  );
};
// components/UserProfile.jsx - 组合组件,只负责组合和状态管理
import React, { useState, useEffect } from 'react';
import { fetchUser, fetchUserOrders } from '../modules/userAPI';
import { calculateTotalSpent, formatCurrency } from '../modules/orderCalculator';
import OrderItem from './OrderItem';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
    fetchUserOrders(userId).then(setOrders);
  }, [userId]);
  
  const totalSpent = calculateTotalSpent(orders);
  
  return (
    <div className="user-profile">
      <h1>{user?.name}</h1>
      <p className="total-spent">
        总消费: {formatCurrency(totalSpent)}
      </p>
      <div className="order-list">
        {orders.map(order => (
          <OrderItem key={order.id} order={order} />
        ))}
      </div>
    </div>
  );
};

重构后的代码清晰地体现了:

  • 模块负责数据获取、计算逻辑、格式化等非UI相关的功能
  • 组件负责UI渲染和交互逻辑
  • 模块可以在任何地方使用(甚至在Node.js环境)
  • 组件专注于界面呈现,通过props接收数据和回调

总结

回到最初的问题:模块化和组件化的本质区别是什么?

模块化是一种代码组织思想,它让我们能够将复杂的系统分解成独立的、可维护的代码单元。它关注的是功能的内聚和依赖的管理,解决的是"代码怎么写才不乱"的问题。

组件化是一种UI构建思想,它让我们能够将界面分解成独立的、可复用的部件。它关注的是视图的拆分和组合,解决的是"界面怎么搭才灵活"的问题。

当你能清晰区分这两个概念,你的代码会变得更清晰、更可维护、更容易测试。

互动

看完这篇文章,你对模块化和组件化有了新的认识吗?欢迎在评论区分享你的想法。

如果你觉得这篇文章对你有帮助,点赞、收藏、转发给更多需要的朋友。我们下期再见!

Flutter 如何给图片添加多行文字水印

作者 明君87997
2026年3月4日 17:41

Flutter 如何给图片添加多行文字水印

最近在做一个工程评估的 App,需要给拍摄的现场照片批量加上多行水印(项目名称、时间、地点等信息),研究了一圈发现网上大多数方案要么太简陋,要么性能拉胯。折腾了几天,总算搞出一套还算满意的方案,记录一下。


效果目标

  • 图片右下角(或底部)显示多行水印文字
  • 文字带阴影,保证在亮色图片上也清晰可见
  • 批量处理时不卡顿,支持大图
  • 可以直接复制使用

实现方案有哪几种?

在 Flutter 里给图片加水印,大体上有三条路可以走,我逐一说一下优缺点,最后也说说我为什么选了第三种。

方案一:Widget 叠加(Stack + Positioned)

最直觉的做法,用 Stack 把水印 Text 覆盖在 Image 上面,用 RepaintBoundary + RenderRepaintBoundary.toImage() 截图导出。

// 示意
Stack(
  children: [
    Image.file(file),
    Positioned(
      bottom: 20,
      left: 20,
      child: Column(
        children: lines.map((l) => Text(l, style: style)).toList(),
      ),
    ),
  ],
)
// 截图导出
final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);

优点: 写起来最简单,和 Flutter UI 完全一致。
缺点:

  • 必须把 Widget 渲染到屏幕(或离屏树)才能截图,流程繁琐
  • 分辨率受 pixelRatio 控制,原图是 4000px 的大图的话,截出来的质量无法保证
  • 批量处理多张图时,需要反复 build/dispose Widget,性能差

适用场景: 只需要截一张图、预览展示用,不在乎原始分辨率。


方案二:image 包纯 CPU 绘制

image 包自带的 drawString 直接在像素级别写文字。

import 'package:image/image.dart' as img;

final font = img.arial14;   // 内置字体,只有英文
img.drawString(
  imageFile,
  'Hello Watermark',
  font: font,
  x: 20,
  y: imageFile.height - 40,
  color: img.ColorRgb8(255, 255, 255),
);

优点: 纯 Dart 实现,不依赖 Flutter engine,可以丢进 Isolate 完全不阻塞 UI。
缺点:

  • 内置字体只有英文,中文默认无法显示
  • 支持中文需要提前用 BMFont / Hiero 等工具把汉字"烧"进位图字体(BitmapFont),生成 .fnt + atlas PNG 后打包进 assets 加载:
    final font = await img.BitmapFont.fromZip(await rootBundle.load('assets/fonts/chinese.zip'));
    img.drawString(imageFile, '项目名称', font: font, x: 20, y: 100);
    
    但这条路有三个硬伤:① 常用汉字 3500 个,一个字号的 atlas PNG 就可能超过 5MB;② 一个字号需要一套文件,无法动态缩放;③ 水印内容里出现图集里没收录的字,直接空白无报错。
  • 没有文字阴影、不支持自动换行等排版功能

适用场景: 纯英文水印、或水印汉字内容完全固定且字符集可控、同时对 Isolate 隔离有强需求的场景。


方案三:Canvas + TextPainter(本文方案)

借助 Flutter 的 CanvasTextPainter 绘制文字,最终通过 PictureRecorder 录制导出。

image_utils.Image → RGBA 像素 → ui.ImageCanvas 绘制 → JPEG 输出

优点:

  • 完美支持中文、自定义字体、文字阴影、换行等所有排版特性
  • 直接操作像素,输出分辨率和原图完全一致
  • 通过缓存 TextPainterTextStyle,批量处理性能优秀
  • 不需要把 Widget 渲染到屏幕

缺点:

  • 依赖 Flutter engine(dart:ui),不能用纯 Isolate 执行,需要在 UI 线程或 compute 配合使用
  • 代码比方案一复杂一些

适用场景: 需要中文水印、大图高质量输出、批量处理场景,也就是大多数实际业务需求。


三种方案对比

Widget 截图 image 包绘制 Canvas + TextPainter
中文支持
原始分辨率 ⚠️ 依赖 pixelRatio
批量性能
代码复杂度
可用 Isolate
文字阴影/换行

综合下来,方案三是实际项目里最合适的选择,下面直接看实现。


依赖

dependencies:
  image: ^4.0.0   # 用于图片编码/解码

pubspec.yaml 里加上 image 这个包,它提供了 JPEG 编解码能力。Flutter 自带的 dart:ui 负责 Canvas 绘制。


核心思路

整体流程如下:

原始图片字节 → image_utils.Image
     ↓
转为 ui.Image(避免二次编解码)
     ↓
用 Canvas + TextPainter 绘制多行文字
     ↓
录制 Picture → 转回 ui.Image
     ↓
导出 RGBA 字节 → 编码为 JPEG

关键点在于直接用像素数据构建 ui.Image,而不是把图片先编码成 JPEG 再解码,节省了一次无谓的编解码开销。


完整实现

import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image/image.dart' as image_utils;

class WatermarkUtils {
  // 缓存 TextStyle,同字号复用同一个对象
  static final Map<String, TextStyle> _textStyleCache = {};

  // 缓存 TextPainter,相同文字+字号+宽度直接复用
  static final Map<String, TextPainter> _textPainterCache = {};

  // 复用 Paint 对象,避免重复创建
  static final Paint _imagePaint = Paint()
    ..filterQuality = FilterQuality.medium;

  /// 给 image_utils.Image 添加多行水印,返回 JPEG 字节
  static Future<Uint8List> addWatermark({
    required image_utils.Image imageFile,
    required List<String> lines,
  }) async {
    // 水印从下往上排,先把顺序反转
    final watermarkLines = lines.reversed.toList();

    // 字体大小按图片短边的 1/38 计算,自适应不同分辨率
    final int imageWidth =
        imageFile.width > imageFile.height ? imageFile.height : imageFile.width;
    final int fontSize = imageWidth ~/ 38;

    // Step 1: image_utils.Image → ui.Image(直接用像素,跳过编码)
    final ui.Image originalImage =
        await _createUIImageFromImageUtils(imageFile);

    // Step 2: 用 Canvas 绘制原图 + 水印文字
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);

    canvas.drawImage(originalImage, Offset.zero, _imagePaint);

    _drawWatermarkTexts(
      canvas,
      watermarkLines,
      imageFile.height,
      fontSize,
      imageWidth,
    );

    // Step 3: 录制结束,生成带水印的 ui.Image
    final watermarkedImage = await recorder
        .endRecording()
        .toImage(originalImage.width, originalImage.height);

    // Step 4: 导出 RGBA 字节
    final ByteData? byteData =
        await watermarkedImage.toByteData(format: ui.ImageByteFormat.rawRgba);

    watermarkedImage.dispose();
    originalImage.dispose();

    if (byteData == null) throw Exception('图片数据转换失败');

    // Step 5: RGBA → JPEG
    return _rgbaToJPEG(
        byteData.buffer.asUint8List(), imageFile.width, imageFile.height);
  }

  // ──────────────────────────────────────────
  //  私有方法
  // ──────────────────────────────────────────

  /// image_utils.Image → ui.Image(不经过 JPEG 编解码)
  static Future<ui.Image> _createUIImageFromImageUtils(
      image_utils.Image img) async {
    final bytes = img.getBytes(order: image_utils.ChannelOrder.rgba);
    final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
    final descriptor = ui.ImageDescriptor.raw(
      buffer,
      width: img.width,
      height: img.height,
      pixelFormat: ui.PixelFormat.rgba8888,
    );
    final codec = await descriptor.instantiateCodec();
    final frameInfo = await codec.getNextFrame();

    descriptor.dispose();
    codec.dispose();

    return frameInfo.image;
  }

  /// 从底部向上逐行绘制水印文字
  static void _drawWatermarkTexts(
    Canvas canvas,
    List<String> lines,
    int imageHeight,
    int fontSize,
    int imageWidth,
  ) {
    double startY = imageHeight - (fontSize * 2.0);
    const double lineGap = 20;
    final double maxWidth = imageWidth - 40.0;

    for (final line in lines) {
      if (line.isNotEmpty) {
        final rect = _drawText(canvas, line, startY, fontSize,
            maxWidth: maxWidth);
        startY = rect.top - lineGap;
      }
    }
  }

  /// 绘制单行文字,返回绘制区域 Rect(用于计算下一行位置)
  static Rect _drawText(Canvas canvas, String text, double y, int fontSize,
      {double maxWidth = double.infinity}) {
    if (text.isEmpty) return Rect.zero;

    final cacheKey = '${text}_${fontSize}_${maxWidth.toInt()}';
    TextPainter? painter = _textPainterCache[cacheKey];

    if (painter == null) {
      final styleKey = fontSize.toString();
      TextStyle? style = _textStyleCache[styleKey];

      if (style == null) {
        style = TextStyle(
          color: Colors.white,
          fontSize: fontSize.toDouble(),
          shadows: [
            Shadow(
              offset: const Offset(1, 1),
              blurRadius: 3.0,
              color: Colors.black.withOpacity(0.5),
            ),
          ],
        );
        _textStyleCache[styleKey] = style;
      }

      painter = TextPainter(
        text: TextSpan(text: text, style: style),
        textDirection: TextDirection.ltr,
        maxLines: 2,
        textAlign: TextAlign.left,
      )..layout(maxWidth: maxWidth);

      // 缓存上限 50 条,超出时清理一半
      if (_textPainterCache.length >= 50) {
        final keys = _textPainterCache.keys.take(25).toList();
        for (final k in keys) {
          _textPainterCache.remove(k);
        }
      }
      _textPainterCache[cacheKey] = painter;
    }

    final offset = Offset(20, y - painter.height);
    painter.paint(canvas, offset);

    return Rect.fromLTWH(20, y - painter.height, painter.width, painter.height);
  }

  /// RGBA 字节 → JPEG Uint8List
  static Uint8List _rgbaToJPEG(Uint8List rgba, int width, int height) {
    final img = image_utils.Image.fromBytes(
      width: width,
      height: height,
      bytes: rgba.buffer,
      numChannels: 4,
    );
    return Uint8List.fromList(image_utils.encodeJpg(img, quality: 95));
  }

  /// 手动清理缓存(内存敏感场景可调用)
  static void clearCache() {
    _textStyleCache.clear();
    _textPainterCache.clear();
  }
}

调用方式

// 准备水印文字,每个元素一行
final lines = [
  '项目:XX大厦改造工程',
  '位置:3号楼-东立面',
  '时间:2024-06-18 14:32',
  '拍摄人:张三',
];

// imageFile 是通过 image.decodeJpg() 解码的 image_utils.Image
final Uint8List result = await WatermarkUtils.addWatermark(
  imageFile: imageFile,
  lines: lines,
);

// 写入文件
await File('/path/to/output.jpg').writeAsBytes(result);

几个细节说明

1. 为什么不直接用 drawImage + drawParagraph

Flutter 的 Canvas 是基于 ui.Image 工作的,而 image 包解码出来的是自己的 image_utils.Image
最朴素的做法是先把它 encodeJpgdecodeImageFromList,但这样白白多了一次编解码。
更好的方案是直接拿 RGBA 像素数据,通过 ui.ImageDescriptor.raw 构建 ui.Image,速度快很多。

2. 字体大小自适应

final int fontSize = imageWidth ~/ 38;

取图片短边除以 38,这个比例在 1000px~4000px 的图片上效果都比较好,文字不会太小也不会太大。根据实际效果可以调整这个除数。

3. TextPainter 缓存

TextPainter.layout() 是相对耗时的操作。在批量处理多张图片时,如果水印内容相同(比如同一个项目的照片),可以直接复用已经 layout 好的 TextPainter,避免重复计算。

缓存键由 文字内容 + 字号 + 最大宽度 组成,三者相同才复用。

4. 水印位置

目前是从图片底部向上排列,代码里 startYimageHeight - fontSize * 2 开始,每绘制一行就往上移一个文字高度 + 间距(20px)。

如果想改成右下角对齐,把 Offset(20, ...) 里的 20 换成 imageWidth - painter.width - 20 即可。


踩过的坑

  1. toByteData 必须在主线程(或 Isolate 里用 compute
    ui.Image.toByteData 是异步的,但它内部依赖 Flutter engine,不能随意放到普通 Isolate 里,否则会直接崩。

  2. image 包的 Image.fromBytes 默认通道顺序是 RGB
    Flutter 导出的是 RGBA,所以一定要加 numChannels: 4,否则颜色会错乱。

  3. 缓存要设上限
    TextPainter 持有 ParagraphBuilder 等原生资源,不加上限的话批量处理几百张图内存会飙升。


小结

核心就三步:用像素数据直接构建 ui.ImageCanvas 绘文字RGBA 转 JPEG
避开了多余的编解码,加上 TextPainter 缓存,即使批量处理几十张图也不会感觉到卡顿。

代码可以直接复制使用,有问题欢迎留言。

AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

2026年3月4日 17:29

AI 代码审查工具链搭建:用 AST 解析 + LLM 实现自动化 Code Review 的前端工程方案

团队到了 15 人以上,Code Review 就开始变味了。

不是没人 review,而是 review 变成了"LGTM 流水线"——打开 PR,滚动两屏,留一句 "looks good to me",合并。真正的逻辑问题、潜在的性能隐患、不符合团队规范的写法,全靠运气。

人工 review 的瓶颈不是态度,是带宽。一个资深工程师一天能认真 review 多少个 PR?3 到 5 个,顶天了。剩下的要么排队,要么糊弄。

所以我们开始想:能不能让机器先过一遍,把"明显有问题"的地方标出来,人再去看真正需要判断力的部分?

这就是这篇文章要聊的事——用 AST 解析做结构化分析,用 LLM 做语义级审查,把两者串成一条自动化 Code Review 工具链。


先搞清楚:人工 Review 到底哪里不行?

不是人不行,是人干了太多不该干的活。

一次典型的 Code Review,reviewer 的注意力大概分布在这几个层面:

层面 举例 能否自动化
格式规范 缩进、命名、import 顺序 ESLint/Prettier 已解决
模式违规 组件里直接调 fetch、没用 hooks 封装 AST 可以搞定
逻辑隐患 useEffect 依赖缺失、竞态条件 AST + 规则引擎可以搞定
业务语义 这个字段不该在这里改、这段逻辑和需求不符 需要 LLM
架构决策 该不该拆微服务、该不该用新方案 需要人

ESLint 覆盖了第一层,但第二到第四层基本是裸奔状态。我们要做的,就是把中间这三层自动化掉。


整体架构:两阶段流水线

核心思路一句话:AST 做确定性分析,LLM 做模糊判断

┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Git Diff    │────▶│  AST 结构化分析   │────▶│  规则引擎    │
│  提取变更文件 │     │  提取函数/组件/依赖│     │  输出确定问题 │
└─────────────┘     └──────────────────┘     └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  LLM 语义审查 │
                                              │  上下文 + Diff │
                                              └──────┬──────┘
                                                     │
                                              ┌──────▼──────┐
                                              │  结果聚合     │
                                              │  发 PR Comment│
                                              └─────────────┘

为什么不直接把代码丢给 LLM?后面讲,先看怎么搭。


第一阶段:AST 结构化分析

拿到 Diff,先别急着分析

第一步不是分析代码,是搞清楚改了什么

import { execSync } from 'child_process'

function getChangedFiles(baseBranch = 'main'): string[] {
  const output = execSync(
    `git diff --name-only --diff-filter=ACMR ${baseBranch}...HEAD`
  ).toString()

  return output
    .split('\n')
    .filter(f => f.endsWith('.ts') || f.endsWith('.tsx')) // 只关心 TS/TSX
    .filter(Boolean)
}

拿到文件列表后,逐个解析 AST。这里用 @typescript-eslint/typescript-estree,因为它对 TSX 的支持最好,而且输出的 AST 和 ESLint 生态兼容。

从 AST 中提取"审查素材"

我们不是要遍历整棵树,而是提取 reviewer 真正关心的结构信息:

import { parse } from '@typescript-eslint/typescript-estree'
import { simpleTraverse } from '@typescript-eslint/typescript-estree'

interface ComponentMeta {
  name: string
  hooks: string[]           // 用了哪些 hooks
  deps: string[]            // import 了什么
  stateCount: number        // 多少个 useState
  effectCount: number       // 多少个 useEffect
  lineCount: number         // 函数体行数
  hasCleanup: boolean[]     // useEffect 是否有清理函数
}

function extractComponentMeta(code: string): ComponentMeta[] {
  const ast = parse(code, { jsx: true, loc: true })
  const components: ComponentMeta[] = []

  simpleTraverse(ast, {
    enter(node) {
      // 找到函数组件(大写开头的函数声明/箭头函数)
      if (
        node.type === 'FunctionDeclaration' &&
        node.id?.name?.[0] === node.id?.name?.[0]?.toUpperCase()
      ) {
        const meta = analyzeComponentBody(node, code)
        components.push(meta)
      }
    },
  })

  return components
}

关键在 analyzeComponentBody 里,我们要识别几个高价值信号:

function analyzeComponentBody(node: any, code: string): ComponentMeta {
  const hooks: string[] = []
  let stateCount = 0
  let effectCount = 0
  const hasCleanup: boolean[] = []

  simpleTraverse(node, {
    enter(child) {
      if (
        child.type === 'CallExpression' &&
        child.callee.type === 'Identifier'
      ) {
        const name = child.callee.name

        if (name.startsWith('use')) hooks.push(name)
        if (name === 'useState') stateCount++
        if (name === 'useEffect') {
          effectCount++
          // 检查回调是否返回了清理函数
          const callback = child.arguments[0]
          if (callback?.type === 'ArrowFunctionExpression') {
            const body = callback.body
            // 简化判断:函数体内是否有 return 语句
            const hasReturn = code
              .slice(body.range![0], body.range![1])
              .includes('return')
            hasCleanup.push(hasReturn)
          }
        }
      }
    },
  })

  return {
    name: node.id?.name ?? 'Anonymous',
    hooks,
    deps: [], // 从 import 声明中单独提取
    stateCount,
    effectCount,
    lineCount: node.loc!.end.line - node.loc!.start.line,
    hasCleanup,
  }
}

规则引擎:把经验变成代码

有了结构化信息,规则就好写了。这不是玄学,就是把资深工程师脑子里的"直觉"翻译成条件判断:

interface ReviewIssue {
  level: 'error' | 'warning' | 'info'
  message: string
  file: string
  component: string
}

function applyRules(meta: ComponentMeta, file: string): ReviewIssue[] {
  const issues: ReviewIssue[] = []

  // 规则 1:组件超过 200 行,大概率该拆了
  if (meta.lineCount > 200) {
    issues.push({
      level: 'warning',
      message: `组件 ${meta.name}${meta.lineCount} 行,考虑拆分`,
      file,
      component: meta.name,
    })
  }

  // 规则 2:useState 超过 5 个 → 该用 useReducer 或抽 custom hook
  if (meta.stateCount > 5) {
    issues.push({
      level: 'warning',
      message: `${meta.name}${meta.stateCount} 个 useState,状态管理可能需要重构`,
      file,
      component: meta.name,
    })
  }

  // 规则 3:useEffect 没有清理函数 → 可能有内存泄漏
  meta.hasCleanup.forEach((has, i) => {
    if (!has) {
      issues.push({
        level: 'info',
        message: `${meta.name} 的第 ${i + 1} 个 useEffect 没有 cleanup,确认是否需要`,
        file,
        component: meta.name,
      })
    }
  })

  return issues
}

这一层的好处是零成本、零延迟、百分百确定性。不调 API,不花钱,跑一遍就是几百毫秒的事。


第二阶段:LLM 语义级审查

AST 能告诉你"这个 useEffect 没有 cleanup",但它没法告诉你"这段逻辑有竞态条件"或者"这个状态更新的时机不对"。

这就是 LLM 上场的地方。

Prompt 工程:别把整个文件丢进去

最常见的错误是把整个文件甚至整个 PR 一股脑扔给 LLM。这样做的问题:

  1. Token 浪费严重——一个 PR 改了 20 个文件,8000 行代码,光 input 就烧掉大量 token
  2. 注意力稀释——LLM 在长上下文里容易"走神",真正的问题反而漏掉
  3. 结果不可控——返回一堆格式/命名建议,全是噪音

正确的做法是只给 LLM 它该看的东西

interface LLMReviewContext {
  diff: string              // 只给变更部分,不给整文件
  componentMeta: ComponentMeta  // AST 阶段提取的结构信息
  astIssues: ReviewIssue[]  // 第一阶段已发现的问题(避免重复)
  projectContext: string    // 项目级约定(简短)
}

function buildPrompt(ctx: LLMReviewContext): string {
  return `你是一个资深前端工程师,正在 review 一个 React + TypeScript 项目的 PR。

## 项目约定
${ctx.projectContext}

## 已知问题(AST 分析已发现,不需要重复指出)
${ctx.astIssues.map(i => `- ${i.message}`).join('\n')}

## 组件结构信息
- 组件名:${ctx.componentMeta.name}
- 使用的 Hooks:${ctx.componentMeta.hooks.join(', ')}
- useState 数量:${ctx.componentMeta.stateCount}
- useEffect 数量:${ctx.componentMeta.effectCount}

## 代码变更(Diff)
\`\`\`diff
${ctx.diff}
\`\`\`

请从以下角度审查,只输出有价值的问题,不要指出格式或命名问题:
1. 是否存在竞态条件或时序问题
2. 状态更新逻辑是否正确
3. 是否有潜在的性能问题(不必要的重渲染等)
4. 错误处理是否完整
5. 是否有安全隐患(XSS、注入等)

输出格式:
- [严重程度: high/medium/low] 问题描述
- 涉及代码行
- 建议修改方式`
}

注意看,我们把 AST 阶段的分析结果也传进去了,明确告诉 LLM"这些我已经知道了,别重复说"。这是减少 LLM 输出噪音的关键手段。

调用层:流式 + 超时 + 降级

生产环境不能像 demo 那样裸调 API:

async function callLLMReview(
  prompt: string,
  options: { timeout?: number; model?: string } = {}
): Promise<string> {
  const { timeout = 30_000, model = 'claude-sonnet-4-6' } = options
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeout)

  try {
    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': process.env.ANTHROPIC_API_KEY!,
        'anthropic-version': '2023-06-01',
      },
      body: JSON.stringify({
        model,
        max_tokens: 2000,    // review 结果不需要太长
        messages: [{ role: 'user', content: prompt }],
      }),
      signal: controller.signal,
    })

    const data = await response.json()
    return data.content[0].text
  } catch (err: any) {
    if (err.name === 'AbortError') {
      // 超时降级:只返回 AST 分析结果,LLM 部分跳过
      console.warn('LLM review timeout, falling back to AST-only')
      return ''
    }
    throw err
  } finally {
    clearTimeout(timer)
  }
}

超时不是异常,是常态。LLM 接口抖一下太正常了。降级策略必须在 Day 1 就写好,不是等线上出事再补。


结果聚合:发到 PR 评论里

两个阶段的结果合并后,通过 GitHub API 写回 PR:

async function postReviewComments(
  prNumber: number,
  issues: ReviewIssue[]
): Promise<void> {
  // 按严重程度排序,error 在前
  const sorted = issues.sort((a, b) => {
    const priority = { error: 0, warning: 1, info: 2 }
    return priority[a.level] - priority[b.level]
  })

  // 限制评论数量,超过 10 条就只保留 error 和 warning
  const filtered = sorted.length > 10
    ? sorted.filter(i => i.level !== 'info')
    : sorted

  const body = filtered
    .map(i => {
      const icon = { error: '🔴', warning: '🟡', info: '🔵' }[i.level]
      return `${icon} **[${i.level.toUpperCase()}]** ${i.message}\n> 📍 \`${i.file}\` - \`${i.component}\``
    })
    .join('\n\n---\n\n')

  await octokit.rest.issues.createComment({
    owner: 'your-org',
    repo: 'your-repo',
    issue_number: prNumber,
    body: `## 🤖 Auto Code Review\n\n${body}\n\n---\n*AST 分析 + LLM 审查 | 如有误报请标记 👎*`,
  })
}

为什么限制评论数量?因为一次性抛 30 条 review 意见,等于没说。 没人会看的。


设计权衡:为什么不直接全用 LLM?

这是被问最多的问题。答案很简单——成本、速度、确定性

维度 纯 LLM AST + LLM
单次 PR 成本 0.05 0.05 ~ 0.30 0.01 0.01 ~ 0.08
延迟 15~45 秒 AST < 1秒,LLM 10~30秒
确定性问题检出 可能漏,也可能幻觉 AST 部分 100% 准确
可调试性 黑盒 AST 规则可单步调试

用类比来说:AST 是安检机器,LLM 是安检员。 机器先过一遍,把明确违禁的拦下来;安检员再看机器标记可疑的,做人工判断。你不会让安检员一个一个翻包检查所有人,那队伍排到明年。

还有一个更实际的原因——LLM 会产生幻觉,AST 不会。 当 LLM 告诉你"这里有内存泄漏"的时候,你还得去验证。但 AST 告诉你"这个 useEffect 没有 cleanup",那就是没有,不用验证。


CI 集成:GitHub Actions 实现

# .github/workflows/ai-review.yml
name: AI Code Review
on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - 'src/**/*.ts'
      - 'src/**/*.tsx'

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 需要完整 git 历史来算 diff

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npx ts-node scripts/ai-review.ts
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}

有一个细节:fetch-depth: 0。默认 checkout 只拉最新一个 commit,算不了 diff。写到这里我开始怀疑人生——每次都有人忘这个配置然后来问"为什么 git diff 是空的"。


可扩展性:从工具到平台

当这套东西跑稳了之后,自然会有新需求冒出来:

1. 规则可配置化

把 AST 规则从硬编码变成配置文件:

// .ai-review.json
{
  "rules": {
    "max-component-lines": { "level": "warning", "threshold": 200 },
    "max-useState-count": { "level": "warning", "threshold": 5 },
    "require-effect-cleanup": { "level": "info", "enabled": true }
  },
  "llm": {
    "model": "claude-sonnet-4-6",
    "maxTokens": 2000,
    "timeout": 30000,
    "focusAreas": ["race-conditions", "security", "performance"]
  },
  "ignore": ["**/*.test.ts", "**/*.spec.ts"]
}

2. 误报反馈闭环

在 PR 评论里加 👎 按钮,收集误报数据。积累到一定量后:

  • 调整 AST 规则阈值
  • 优化 LLM prompt(few-shot 加入真实误报案例)
  • 对特定模式建立白名单

3. 团队知识沉淀

把高频 review 意见提炼成团队规范文档,反哺到 AST 规则库。这不是一次性工具,是一个持续进化的系统。


边界与风险:这东西不是万能的

几个踩过的坑,提前说:

LLM 输出格式不稳定。 你让它按固定格式输出,它大部分时候听话,偶尔抽风。解析 LLM 返回结果时,必须做容错处理,不能假设格式永远正确。用 JSON mode 或者 structured output 会好很多,但也不是 100%。

跨文件分析是个深坑。 AST 解析天然是单文件粒度的。如果一个 PR 改了 A 文件的接口定义,又改了 B 文件的调用方,要关联分析就需要额外做依赖图。TypeScript 的 Language Service API 能帮上忙,但复杂度直接起飞。

不要试图替代人工 review。 这套工具是过滤器,不是替代品。架构决策、业务逻辑的合理性、代码的"品味"——这些东西目前还是得靠人。工具能做的是把 reviewer 的精力从"找明显问题"释放到"思考设计决策"上

成本控制。 一个活跃项目一天可能有几十个 PR,每个 PR 可能触发多次 review(每次 push 都触发)。按 $0.08/次算,一个月也是一笔钱。可以考虑:只在目标分支是 main/release 时触发、只分析变更超过一定行数的 PR、加缓存避免重复分析同一个 commit。


总结:这类问题的通用模型

退一步看,这其实是一个"结构化预处理 + 智能判断"的通用模式。

不只是 Code Review,很多场景都是这个套路:

  • 日志分析:正则提取结构 → LLM 判断根因
  • 文档审查:AST/Schema 校验格式 → LLM 检查内容质量
  • 测试生成:AST 提取函数签名 → LLM 生成测试用例

核心原则就一条:能确定性解决的,不要浪费智能;需要判断力的,不要硬编规则。

把确定性的事交给确定性的工具,把模糊的事交给擅长模糊推理的模型。两者的接缝处——也就是"AST 提取出来的结构化信息如何变成 LLM 的上下文"——才是真正考验工程能力的地方。

这不是什么前沿技术,就是把现有的东西用对地方。但往往最难的,就是"用对地方"这四个字。

[转][译] 从零开始构建 OpenClaw — 第五部分(对话压缩)

作者 juejin_cn
2026年3月4日 17:27

[转][译] 从零开始构建 OpenClaw — 第一部分(智能体核心)

[转][译] 从零开始构建 OpenClaw — 第二部分(技能插件系统)

[转][译] 从零开始构建 OpenClaw — 第三部分(元技能)

[转][译] 从零开始构建 OpenClaw — 第四部分(工具循环检测)

[转][译] 从零开始构建 OpenClaw — 第五部分(对话压缩)

原文:Building Openclaw from Scratch — Part 5 (Conversation Compaction)

在本部分,我们添加了区分玩具智能体和真实智能体的功能:上下文窗口管理。~75 行 TypeScript 代码,零自定义摘要代码。

image.png

在第四部分,我们添加了工具循环检测。在本篇中,我们处理了基于 LLM 的任何智能体中最重要的资源限制:上下文窗口是有限的,你的智能体将撞上墙壁。

问题:上下文窗口作为硬资源限制

这里有一个在使用 AI 编程智能体进行实际工作时会立刻显现出来的问题:对话会不断增长。非常快。

一个典型的编程会话看起来是这样的:

Turn 1:  "Read the auth module and explain it"
         → agent reads 3 files (2,000 tokens of tool results)
Turn 2:  "Now refactor it to use JWT"
         → agent reads, edits, runs tests (8,000 tokens)
Turn 3:  "The tests are failing, fix them"
         → agent reads errors, edits 2 files, re-runs (6,000 tokens)
Turn 4:  "Add refresh token support"
         → agent reads docs, creates new file, edits 3 others (12,000 tokens)
...
Turn 15: "Now update the API docs"
         → ERROR: request_too_large

每一轮都会累积消息——用户提示词、助手响应、工具调用、工具结果。工具结果尤其占用空间:单个 bash("cat src/auth.ts") 就可能向上下文中倾倒 500+ 行内容。经过 15-20 轮的积极编程,你将耗尽整个 200K token 的上下文窗口。

这时,智能体就会崩溃。不是优雅地崩溃——它只是抛出一个 API 错误,而你的会话就结束了。

这是演示智能体和生产智能体之间的区别。演示智能体工作 5 轮。实际编码会话持续 50 轮。

为什么要压缩?内存类比

将上下文窗口视为工作内存,而不是存储。它是会议室中的白板——LLM 可以同时看到和推理的一切。当白板满时,你不能简单地贴上更多的白板。你需要擦除旧内容。

但你不能盲目擦除。如果你删除了四小时架构讨论中前一个小时的笔记,你会做出矛盾的决定。你实际做的事情是任何好的记录员都会做的:总结要点,存档细节,继续进行。

这正是对话压缩的作用。大型语言模型将旧轮次总结为精简的回顾,丢弃原始消息,并继续以摘要作为上下文。智能体“记住”发生了什么——做出的决定、修改的文件、遇到的错误——而无需携带完整的对话记录。

这里有一个重要的权衡需要理解:

                    ┌─────────────────┐
                    │  Session Length │
                    └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              │              │              │
    ┌─────────▼──────┐   ┌───▼────┐   ┌─────▼──────┐
    │Context Fidelity│   │  Cost  │   │  Latency   │
    └────────────────┘   └────────┘   └────────────┘
  • 更长的会话需要更多的压缩,这意味着更多的信息丢失
  • 更高的保真度意味着保留更多的消息,这意味着更早地达到限制
  • 成本随上下文大小而变化——一个 200K token 的请求比一个 20K 的请求贵 10 倍

压缩让你选择:长时间段压缩历史记录,或短时间段完美回忆。对于编码智能体,长时间段几乎总是赢家——你宁愿记住第 3 回合的模糊记忆,也不希望在第 15 回合崩溃。

“为什么不直接使用更大的上下文窗口?”这是显而易见的问题。即使使用 1M-token 模型,经济性也不会改变。1M 上下文窗口在 60 回合而不是 15 回合内填满。成本急剧增加。延迟增加。最终,你仍然会撞上墙壁。压缩不是解决小上下文窗口的权宜之计——它是如何在任何规模下管理上下文作为资源的方法。

解决方案:总结,而不是累积

修复在概念上很简单:当对话变得太长时,总结旧的部分并丢弃它们。

Before compaction:
┌──────────────────────────────────────────┐
│ System prompt                            │
│ Turn 1: user + assistant + tool results  │  ← old, summarizable
│ Turn 2: user + assistant + tool results  │  ← old, summarizable
│ Turn 3: user + assistant + tool results  │  ← old, summarizable
│ ...                                      │
│ Turn 14: user + assistant + tool results │  ← recent, keep
│ Turn 15: user + assistant + tool results │  ← recent, keep
└──────────────────────────────────────────┘
  Total: 195,000 tokens (at the limit!)


After compaction:
┌──────────────────────────────────────────┐
│ System prompt                            │
│ [Summary of turns 1-13]                  │  ← 500 tokens
│ Turn 14: user + assistant + tool results │  ← kept intact
│ Turn 15: user + assistant + tool results │  ← kept intact
└──────────────────────────────────────────┘
  Total: 45,000 tokens (back to comfortable)

旧消息已经消失了。取而代之的是一个简洁的摘要,它保留了智能体继续智能工作的关键决策、文件操作和上下文。

智能体甚至没有注意到。它将摘要视为先前的上下文,并继续工作。

摘要链如何工作

压缩不是一次性操作。在一个长时间会话中,它多次触发,每次压缩都基于上一次。这形成了一个摘要链:

Compaction #1 (turn 11):
  Input:  [Turn 1] [Turn 2] ... [Turn 10]
  Output: "Summary A: User refactored auth module, added JWT tokens,
           fixed 3 test failures, created refresh token endpoint."

Compaction #2 (turn 21):
  Input:  [Summary A] [Turn 11] [Turn 12] ... [Turn 20]
  Output: "Summary B: (Builds on Summary A) Also added rate limiting,
           updated API docs, migrated database schema."

Compaction #3 (turn 31):
  Input:  [Summary B] [Turn 21] [Turn 22] ... [Turn 30]
  Output: "Summary C: (Builds on Summary B) Deployed to staging,
           fixed CORS issue, added integration tests."

每个摘要都包含上一个摘要加上新消息。大型语言模型收到一个特殊提示词:“这是上一个摘要。这是从那时起的新消息。创建一个更新的摘要,涵盖所有内容。”这是分层压缩——摘要的摘要。

想象一下 git squash 的样子。你失去了单个提交,但最终结果得到了保留。第一轮询问了认证架构。到第 3 次压缩时,该决定被捕获为“用户选择了基于 JWT 的认证”——讨论过程消失了,但结果仍然存在。

信息丢失是渐进的和前重量的。最近的回合被逐字保留。较旧的回合仅以摘要形式存在。最旧的回合在摘要中被摘要——两级压缩。这反映了人类记忆的工作方式:你详细记得今天早上的事情,上周的大致情况,以及上个月的要点。

对于编码智能体来说,这通常是可以的。智能体不需要记住第 3 回合的确切错误输出。它需要知道“我们通过从会话 cookie 切换到 JWT 修复了认证错误”——决策本身,而不是调试日志。

这为什么很难(以及 SDK 为什么很重要)

概念很简单。实现方面有锋利的边缘:

  1. 你在哪里切割?你不能随意在消息边界处切割。如果你在工具调用及其结果之间切割,API 会拒绝格式错误的对话。如果你在回合中途切割(用户问了问题,助手正在回答过程中),你会丢失关键上下文。切割点必须是回合感知的。

SDK 通过逆向遍历算法解决此问题: findCutPoint() 从最新消息开始逆向遍历,累积 token 估计值。当它拥有足够的 token 以保持( keepRecentTokens ,默认 20K)时停止。关键在于,它仅在回合边界处切割——用户消息或助手消息且没有待处理的工具结果。如果切割发生在回合中间,它会检测到分割并单独总结回合前缀。

  1. 你如何总结?你需要调用 LLM 生成总结——但你已经达到上下文限制。总结请求本身可能会溢出。你需要将消息分块,独立地总结每个块,然后合并总结。

SDK 的 generateSummary() 通过 token 预算处理此问题。它为总结提示词的开销预留 token( reserveTokens ,默认 16K),然后在每次总结调用中尽可能多地适配消息。如果消息太大无法单个调用处理,它会将它们分块,总结每个块,然后将块总结合并为最终总结。之前的压缩总结(如果有)被作为上下文传递,以便新总结在此基础上构建而不是从头开始。

  1. 如果摘要失败会怎样?摘要调用本身就是一个 LLM 调用。它可能会失败(速率限制、超时、溢出)。你需要备用策略。

SDK 实现了一个三级备用链:首先,尝试完整摘要。如果失败(例如,消息太大),尝试部分摘要——排除过大的消息并将其标注为 "[Large message (~XK tokens) omitted]" 。如果仍然失败,则返回一个通用的 "Summary unavailable due to context limits" 标记。会话无论如何都会继续——降低的上下文比死会话要好。

  1. 关于恢复循环?当智能体在 session.prompt() 时遇到上下文溢出,你需要检测它、压缩并重试——可能需要多次使用逐步升级的策略。

SDK 运行一个溢出恢复循环,最多尝试 3 次:

Attempt 1: compact() → retry the prompt
Attempt 2: truncate oversized tool results → retry
Attempt 3: compact again → retry
Give up:   return "context_overflow" error to the application

每次尝试都使用不同的策略。工具结果截断尤为重要——单个 bash("find . -type f") 可能会输出 100K 个文件列表项。SDK 将任何单个工具结果限制在上下文窗口的 30%,并截断其余部分。

  1. 什么时候触发?你不想等到溢出——那是最糟糕的压缩时机(你已经处于错误状态)。你想检测到即将接近限制,并主动压缩。

SDK 使用带安全边界的 token 估计。每轮之后,它估计总上下文使用量(使用 chars / 4 启发式,这会略微高估——在这里保守是正确的)。当 contextTokens > contextWindow - reserveTokens 时触发压缩。对于 200K 模型,保留 16K,这意味着大约 184K。这种主动触发意味着智能体几乎从不达到硬溢出——它在到达墙之前就压缩了。

PI SDK 处理所有这五项。 AgentSession 类提供:

  • shouldCompact() — 主动阈值检测
  • prepareCompaction() → compact() — 完整流程
  • 带溢出恢复循环的自动压缩
  • 用于调优的可配置设置

我们的工作是将其连接起来并展示事件。引擎已经构建完成;我们正在将其连接到仪表盘。

实现细节

整个更改在一个文件中,共 75 行: entry.ts 。

1. 两个新的状态变量

let autoCompact = true;    // auto-compaction enabled by default
let lastSession: any = null; // session reference for manual /compact

为什么是 lastSession ?之前,会话是在每个提示词中创建和销毁的。但是 /compact 需要在提示词之间访问会话。所以我们保持会话活跃,并在下一个提示词开始时销毁它(或在 /new 时销毁)。

2. /compact 命令

一个命令中包含三种模式:

if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
  const arg = trimmed.slice("/compact".length).trim().toLowerCase();
  if (arg === "on") {
    autoCompact = true;
    console.log(`Auto-compaction: on`);
  } else if (arg === "off") {
    autoCompact = false;
    console.log(`Auto-compaction: off`);
  } else {
    if (!lastSession) {
      console.log(`No active session to compact. Send a message first.`);
    } else {
      console.log(`Compacting...`);
      try {
        const result = await lastSession.compact();
        console.log(`Compacted: ${result.tokensBefore} tokens summarized.`);
        const preview = result.summary.length > 200
          ? result.summary.slice(0, 200) + "..."
          : result.summary;
        console.log(`Summary: ${preview}`);
      } catch (compactErr: any) {
        console.error(`Compaction failed: ${compactErr.message}`);
      }
    }
  }
  continue;
}

用法:

  • /compact — 现在手动触发压缩
  • /compact on — 启用自动压缩(默认)
  • /compact off — 禁用自动压缩

手动压缩调用 session.compact() ,该函数:

  1. 找到一个考虑转向的切割点(保留最近的消息,总结其余部分)
  2. 调用 LLM 生成旧消息的摘要
  3. 用会话存储中的摘要替换旧消息
  4. 返回一个 CompactionResult ,包含 summary , tokensBefore 和 firstKeptEntryId

3. 自动压缩线路

在 createAgentSession() 后一行:

session.setAutoCompactionEnabled(autoCompact);

就这样。SDK 现在会在每轮之后监控上下文使用情况。当使用量超过阈值(根据模型的上下文窗口减去安全边界计算得出)时,它会自动压缩。当 API 返回上下文溢出错误时,它会压缩并重试——最多重试 3 次。

4. 压缩事件日志记录

SDK 在压缩发生时会发出事件。我们将它们连接到现有的事件订阅器:

session.subscribe((event: any) => {
  switch (event.type) {
    // Compaction events — always shown (significant lifecycle events)
    case "auto_compaction_start":
      console.error(dim(`[compaction] auto-compacting (${event.reason})...`));
      break;
    case "auto_compaction_end":
      if (event.result) {
        console.error(dim(
          `[compaction] done — ${event.result.tokensBefore} tokens summarized`
        ));
      } else if (event.aborted) {
        console.error(dim(`[compaction] aborted`));
      } else if (event.errorMessage) {
        console.error(dim(`[compaction] failed: ${event.errorMessage}`));
      }
      if (event.willRetry) {
        console.error(dim(`[compaction] will retry...`));
      }
      break;
    // ... verbose-only events (tool calls, thinking, etc.)
  }
});

请注意设计选择:压缩事件总是可见的,不受 /verbose 的限制。工具调用细节大部分时间是噪音,但压缩是一个重要的生命周期事件——这意味着智能体正在重组其内存。用户应该始终知道何时发生这种情况。

这两个事件讲述了一个完整的故事:

  • auto_compaction_start 以 reason: "threshold" (主动)或 reason: "overflow" (被动)的方式触发
  • auto_compaction_end 发送结果或错误信息,如果 SDK 将要重试,还会加上 willRetry

5. 溢出错误检测

当压缩完全失败(3 次重试尝试完毕)时,错误会冒泡到我们的 catch 块中。我们检测到它,并显示一个有用的消息,而不是原始的堆栈跟踪:

} catch (err: any) {
  const msg = err.message ?? "";
  if (/request_too_large|context.*(window|length)|prompt.*too long|request size exceeds/i.test(msg)) {
    console.error("Context overflow: conversation too large for model.");
    console.error("Try /compact to summarize history, or /new to start fresh.");
  } else {
    console.error(`Error: ${msg}`);
  }
}

正则表达式涵盖了来自不同提供者的各种错误格式——Anthropic 说“request_too_large”,OpenAI 说“context length exceeded”,Google 说“prompt is too long”,等等。

6. 会话生命周期变更

之前:

prompt → create session → run → dispose → prompt → create session → ...

现在:

prompt → dispose previous → create session → run → keep alive → prompt → ...

变更虽小但很重要:

// Before the prompt
if (lastSession) { lastSession.dispose(); lastSession = null; }
// ... create session, run prompt ...
// After the prompt (was: session.dispose())
lastSession = session;

会话保持活动状态,以便 /compact 可以访问它。清理操作会在 /new 上、在下一次提示词时或退出 REPL 时发生:

// On /new
if (lastSession) { lastSession.dispose(); lastSession = null; }
// On exit
if (lastSession) lastSession.dispose();

完整的压缩流程

长时间编码会话期间会发生以下情况:

Turn 1-10: Normal operation
  │ Messages accumulate in the session store
  │ Context usage: 20K → 40K → 80K → 120K → 150K tokens
  │
Turn 11: Context hits threshold (~160K of 200K)
  │
  ├─ SDK fires: auto_compaction_start { reason: "threshold" }
  │   └─ You see: [compaction] auto-compacting (threshold)...
  │
  ├─ SDK internally:
  │   1. findCutPoint() — walk backwards, keep ~20K recent tokens
  │   2. prepareCompaction() — extract messages to summarize
  │   3. generateSummary() — LLM call to summarize old messages
  │   4. sessionManager.appendCompaction() — persist summary
  │   5. Reload session with summary + recent messages
  │
  ├─ SDK fires: auto_compaction_end { result, willRetry: false }
  │   └─ You see: [compaction] done — 140,000 tokens summarized
  │
  │ Context usage: 150K → 35K tokens
  │
Turn 12-20: Normal operation again
  │ Context grows from 35K → 130K
  │
Turn 21: Threshold hit again → compaction fires again
  │ This time, the summary includes the PREVIOUS summary
  │ ("Summarize summaries" — hierarchical compaction)
  │
Turn 22+: Continues indefinitely

关键点:压缩会创建一系列摘要。每次压缩都会总结旧消息以及之前的摘要。这是一种分层压缩——摘要的摘要。对话可以无限运行,因为旧上下文会逐渐被压缩。

当事情出错时:

Turn N: API returns "request_too_large"
  │
  ├─ SDK detects: isLikelyContextOverflowError() = true
  │
  ├─ Attempt 1: compact() + retry prompt
  │   └─ Still overflowing? →
  │
  ├─ Attempt 2: truncate oversized tool results + retry
  │   └─ Still overflowing? →
  │
  ├─ Attempt 3: compact again + retry
  │   └─ Still overflowing? →
  │
  └─ Give up: "Context overflow: conversation too large for model."
             "Try /compact to summarize history, or /new to start fresh."

三次重试,策略越来越激进。如果还不够,用户会收到一条清晰的带有可操作选项的消息——而不是原始的 API 错误。

变更内容:差异

src/entry.ts | 75 insertions(+), 9 deletions(-)
 1 file changed

没有新文件。没有新的依赖项。75 行用于支持任意长度的编码会话的连接代码。

以下是分解说明:

改变行目的状态变量 2 autoCompact , lastSession/compact 命令 26 手动压缩 + 开关切换压缩事件 16 始终开启的生命周期日志自动压缩连接 1 session.setAutoCompactionEnabled(autoCompact) 会话生命周期 6 在提示词之间保持活动状态 /compact 溢出错误处理 8 检测溢出,显示有帮助的消息横幅 + 状态 3 显示 /compact 和自动压缩状态

/compact 命令被添加到启动横幅中, /status 现在显示自动压缩状态。

为什么理解这一点比构建它更重要

在大多数教程中,75 行的接线代码不值得用一整篇文章来讲解。但对话压缩则不同,因为概念比代码更重要。

你应该记住以下几点:

  1. 上下文窗口是一个硬资源限制,而不是软限制。它不像 RAM 那样你可以获得交换空间。当你超出它时,API 会拒绝你的请求。就这样。每个严肃的智能体都必须像嵌入式系统对待内存字节一样对待上下文标记——将其视为一个需要预算的有限资源。

  2. 摘要是有损压缩。当你压缩时,你会丢失细节。智能体不会记住第 3 回合的确切错误消息或它在第 7 回合中编辑的具体行号。它会记住它做了什么以及为什么,但不会记住每一个细节。这是一个基本的权衡:对话长度与上下文保真度。

  3. 恢复循环使其达到生产级标准。任何智能体在被要求时都可以进行压缩。区别在于自动检测、使用升级策略重试以及在所有其他方法都失败时进行优雅降级。溢出→压缩→重试→截断→重试→放弃的链式操作是将演示与可用于 8 小时编码会话的东西区分开来的关键。

  4. SDK 级别的关注与应用程序级别的关注。压缩涉及标记估计、上下文感知的切割点、基于 LLM 的摘要、会话存储变异和错误分类。这些都是引擎层面的关注点。我们的应用程序级别的任务是:连接这些组件、展示事件、赋予用户控制权。这种区分——知道什么需要构建与什么需要委托——是智能体开发中的关键技能。

底层的压缩设置

SDK 的压缩行为由三个设置控制(可通过 SettingsManager 进行配置):

interface CompactionSettings {
  enabled: boolean;        // Master switch (default: true)
  reserveTokens: number;   // Reserved for system prompt + overhead (default: 16,384)
  keepRecentTokens: number; // Recent messages to preserve (default: 20,000)
}

算法:

  1. 每轮之后,估计总上下文令牌
  2. 如果 contextTokens > contextWindow - reserveTokens :触发压缩
  3. 从最新消息开始向后遍历,累积令牌估计
  4. 停止当累积 >= keepRecentTokens — 那是切割点
  5. 在切割点之前的内容将被总结
  6. 摘要替换会话存储中的旧消息

默认值对于大多数模型来说是合理的。对于一个 200K 的上下文窗口:

  • 为系统提示词、工具定义和开销预留 16K
  • 保留最近的 2 万条消息
  • 当总数超过~184K 时进行压缩
  • 压缩后,上下文降至约 20K + 摘要(~1-2K)

这为你提供了约 160K 的额外空间,直到下一次压缩——大约有 40-80 轮更多的主动编码空间。

下一步是什么

通过压缩,openclaw-mini 可以处理任意长度的会话。我们从 5 轮的演示转变到了一个可以投入生产的智能体——工具、技能、自我扩展、安全性,现在还有无限内存。

openclaw-mini 的完整源代码可以在 GitHub 上找到。

CSS进阶: background-clip

作者 helloweilei
2026年3月4日 17:23

background-clip 是 CSS 中一个用于控制背景(背景颜色或背景图片)的显示范围的属性。简单来说,它可以决定背景是铺满整个盒子(包括边框)、只铺到边框内部,还是只铺到文字下方。

它的核心作用是限制背景的绘制区域

基本语法与三个主要属性值

1. border-box(默认值)

背景延伸到边框区域的下方(即背景会覆盖边框)。

  • 效果: 如果边框是半透明或点线样式,你能看到边框下面的背景。
  • 示例:
    .box {
        background-clip: border-box;
        /* 背景会铺满整个元素区域,包括边框部分 */
    }
    

image.png

2. padding-box

背景只延伸到内边距(padding)区域,边框下面没有背景

  • 效果: 背景在边框内部就停止了,边框保持纯色(通常是元素本身的背景色或透明)。
  • 示例:
    .box {
        background-clip: padding-box;
        /* 背景只铺到内边距边缘,边框区域无背景 */
    }
    

image.png

3. text(最炫酷、最常用)

将背景裁剪成文字的形态。

  • 效果: 背景只在文字的形状内显示,文字以外的区域背景不可见。
  • 关键配合: 通常需要配合 color: transparent 将文字颜色设为透明,才能看到被裁剪出来的背景。
  • 示例: 实现渐变文字、图片文字效果。
    .text {
        background-image: linear-gradient(45deg, #f00, #00f); /* 设置渐变背景 */
        color: transparent; /* 把文字本身的颜色变透明 */
        background-clip: text; /* 把背景裁剪成文字的形状 */
        -webkit-background-clip: text; /* 某些浏览器需要加前缀 */
    }
    

image.png

直观理解

想象一个带有内边距(padding)、边框(border)和背景色的盒子:

  • border-box:油漆刷满整个盒子,连边框(即使边框是虚线)也覆盖了背景色。
  • padding-box:油漆刷到边框内侧就停止了,边框区域没有油漆,保持原色。
  • text:油漆只涂在文字笔画上,其他地方(包括文字内部的镂空部分)都是透明的。

主要应用场景

  1. 渐变文字(最流行): 使用 background-clip: text 配合渐变色,制作醒目的标题。
  2. 特殊边框效果: 当希望边框是纯色,而背景在边框内部显示时(例如实现双层边框效果),可以使用 padding-box
  3. 精确控制背景平铺: 当不希望背景图延伸到边框下时,通过 padding-box 可以精确控制背景的边界。

浏览器兼容性

我们来具体看一下 background-clip 属性的浏览器兼容性情况。

总的来说,background-clip 的基础功能(border-boxpadding-boxcontent-box)兼容性非常好,可以放心使用。但它的“明星”功能 text 值兼容性稍复杂一些,需要特别注意写法。

我把它们的兼容性情况整理成了表格,方便你查看:

属性值 支持情况 主要细节 兼容性概览
基础值
(border-box, padding-box, content-box)
全面支持 所有现代浏览器及 IE9+ 均支持。 ✅ 很好
text 值
(background-clip: text)
广泛支持,但有细节 Chrome、Edge、Opera:从较早期版本就开始支持。
Safari:从 15.5 版本开始完全支持,早期版本(3.2-15.4)需加 -webkit- 前缀且为部分支持。
Firefox:从 49 版本开始支持,但早期版本(2-48)不支持。
Internet Explorer:全系不支持
移动端:主流浏览器(iOS Safari、Chrome for Android、Samsung Internet 等)基本都支持,但 Opera Mini 全系不支持。
🟡 良好,需注意

关键知识点与最佳实践

结合你之前问到的 background-clip 作用,这里有几个实践中的要点:

  1. text 值的标准写法 为了让 background-clip: text 在所有支持的浏览器上生效,必须同时使用带 -webkit- 前缀和不带前缀的写法。同时,记得将文字颜色设置为透明,背景图才能透出来。

    .gradient-text {
      background-image: linear-gradient(45deg, #ff6b6b, #4ecdc4);
      -webkit-background-clip: text; /* 为基于 WebKit 内核的浏览器添加 */
      background-clip: text;        /* 标准属性 */
      color: transparent;            /* 让文字颜色透明,露出背景 */
      -webkit-text-fill-color: transparent; /* 为 Safari 浏览器添加,增强兼容性 */
    }
    

    这里额外添加了 -webkit-text-fill-color: transparent,可以进一步增强在 Safari 等浏览器上的表现。

  2. Firefox 的特别注意事项 虽然 Firefox 从 49 版本开始支持 background-clip: text,但网上一些资料提到它在部分 Firefox 版本中可能存在问题,或者效果不如 Chrome/Safari 稳定。为了稳妥,可以结合 @supports 进行特性检测,为不支持(或支持不完美)的浏览器提供一个优雅的降级样式。

    .gradient-text {
      /* 默认样式(降级方案),比如一个纯色 */
      color: #ff6b6b;
    }
    
    /* 当浏览器支持 background-clip: text 时,应用渐变效果 */
    @supports (background-clip: text) or (-webkit-background-clip: text) {
      .gradient-text {
        background-image: linear-gradient(45deg, #ff6b6b, #4ecdc4);
        -webkit-background-clip: text;
        background-clip: text;
        color: transparent;
        -webkit-text-fill-color: transparent;
      }
    }
    
  3. 避开已知的坑

    • 不要只写不带前缀的属性:在现代浏览器中,仅写 background-clip: text 可能被忽略。
    • 背景必须用 background-image:使用渐变或图片,纯色背景无法体现裁切效果。
    • 留意边缘渲染:在一些非整数缩放比例或高分辨率屏幕上,文字边缘可能会出现轻微发虚或锯齿。通常使用稍粗一点的字体 (font-weight: 600 或更粗) 可以缓解。

总结一下,background-clip 的基础功能可以无忧使用。如果要用 text 值实现炫酷的文字效果,遵循上述的双前缀、透明文字和降级方案这“三板斧”,就能在绝大多数现代浏览器上获得理想且稳定的效果。

别再用 scoped 了!Vue 项目中真正安全的 CSS 封装方案,第 3 种连尤雨溪都在用

作者 前端Hardy
2026年3月3日 10:31

上周,设计师跑来问我:“为什么这个按钮在 A 页面是蓝色,在 B 页面变成紫色了?”

我一查代码,发现两个组件都写了:

.btn {
  background: blue;
}

<style scoped> 根本没生效——因为某个第三方 UI 库用了 :global(.btn),污染了全局。

那一刻我悟了:scoped 不是银弹,它只是“看起来安全”。

今天,我就带你盘点 Vue 项目中 4 种真正可靠的 CSS 封装方案,从“能用”到“企业级”,尤其第 3 种,连 Vue 官方文档和 Vite 团队都在悄悄推广。


先看一张对比表(建议收藏)

方案 隔离性 可维护性 支持动态主题 学习成本
<style scoped> ⚠️ 中(会被 :global 破坏) 低(命名仍可能冲突) ❌ 难
CSS Modules ✅ 强 ⚠️ 需额外处理
CSS-in-JS(如 Vanilla Extract) ✅✅ 极强 ✅ 原生支持 中高
CSS 变量 + 作用域类名(推荐!) ✅ 强 ✅✅ 极高 ✅✅ 天然支持

核心原则:隔离靠机制,不是靠“看起来不一样”


方案 1:<style scoped> —— 谨慎使用!

Vue 的 scoped 通过给元素加 data-v-xxxx 属性实现样式隔离:

<template>
  <button class="btn">Click</button>
</template>

<style scoped>
.btn { color: red; } /* 编译后 → .btn[data-v-f3f3eg9] */
</style>

致命缺陷

  • 无法防止 全局样式污染(比如 reset.css 或 UI 库)
  • 深度选择器>>>:deep())容易误伤其他组件
  • 动态插入的 HTML(如富文本)无法应用 scoped 样式

适用场景:内部工具、小型页面、快速原型

不要用在:对外组件库、多团队协作项目、需要主题切换的系统


方案 2:CSS Modules —— 经典但略重

启用后,每个 class 会被哈希化:

// Button.module.css
.primary { background: blue; }

// Button.vue
import styles from './Button.module.css';
// styles.primary → "Button_primary__aB3cD"
<template>
  <button :class="styles.primary">OK</button>
</template>

优点:

  • 100% 隔离,不怕任何全局污染
  • 支持组合(composes

缺点:

  • 模板里写 :class="styles.xxx" 略啰嗦
  • 不支持原生 CSS 嵌套(除非配合 PostCSS)
  • 动态主题需配合 JS 重新生成

在 Vite 中开启:

// vite.config.ts
export default defineConfig({
  css: { modules: { localsConvention: 'camelCase' } }
})

方案 3:CSS 变量 + 作用域类名(尤雨溪团队推荐!)

这是 Vue 官方新文档Vite 插件生态 中越来越主流的做法。

核心思想:用 CSS 变量定义设计 token,用唯一类名包裹组件

<template>
  <div class="my-button--root">
    <button class="my-button--inner">Submit</button>
  </div>
</template>

<style>
.my-button--root {
  /* 定义局部变量 */
  --btn-bg: var(--theme-primary, #3b82f6);
  --btn-color: white;
}

.my-button--inner {
  background: var(--btn-bg);
  color: var(--btn-color);
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
}
</style>

神奇在哪?

  1. 天然支持主题切换
/* 全局定义亮色主题 */
:root {
  --theme-primary: #3b82f6;
}
/* 暗色主题 */
.dark {
  --theme-primary: #60a5fa;
}

只需切换 <html class="dark">,所有组件自动适配!

  1. 无构建时哈希,调试友好
  2. 类名前缀化(如 my-button--)避免冲突,比随机 hash 更语义化

这正是 ShadCN VueRadix Vue 等现代组件库的做法。


方案 4:零运行时 CSS-in-JS(Vanilla Extract)

如果你追求极致工程化,试试 编译时 CSS-in-JS

// Button.css.ts
import { style } from '@vanilla-extract/css';

export const root = style({
  vars: {
    '--btn-bg': '#3b82f6'
  }
});

export const inner = style({
  background: 'var(--btn-bg)',
  color: 'white',
  borderRadius: 4,
  selectors: {
    '&:hover': { opacity: 0.9 }
  }
});
<script setup lang="ts">
import * as styles from './Button.css';
</script>

<template>
  <div :class="styles.root">
    <button :class="styles.inner">OK</button>
  </div>
</template>

优势:

  • 100% 类型安全(TS 直接提示拼写错误)
  • 零运行时(编译成静态 CSS 文件)
  • 自动作用域(生成哈希类名)
  • 支持主题变量、条件样式

配合 Vite 插件 @vanilla-extract/vite-plugin 即可使用。


实战建议:怎么选?

项目类型 推荐方案
内部后台系统 CSS 变量 + 作用域类名(方案 3)
对外组件库 CSS 变量 + 作用域类名 or Vanilla Extract
快速原型 scoped(但警惕全局污染)
超大型应用(含多主题/国际化) Vanilla Extract(方案 4)

永远不要:

  • 在 scoped 中大量使用 :deep()
  • 把业务样式写进全局 app.css
  • 用 BEM 命名试图“人工隔离”(治标不治本)

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌
❌