普通视图

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

fetch-event-source源码解读

2026年2月9日 14:13

SSE协议规范

SSE协议实际上是在 HTTP 之上定义了一套严格的数据组织规范,是属于应用层的协议。

  • 固定请求头类型:必须是 Content-Type: text/event-stream
  • 固定文本格式:数据必须以 field: value\n 的形式返回
  • 固定结束符:消息之间必须以 \n\n 分隔。
{
    id: '', // 消息唯一标识,实现“断点续传”的关键
    event: '',  // 事件类型,缺失则默认触发 onmessage
    data: '', // 消息内容
    retry: undefined,  // 重连时间
}

fetchEventSource 执行流程

fetch-event-source底层是使用 fetch 配合 ReadableStream 实现的

1.建立 Fetch 长连接

fetch 读取到的是 Uint8Array(原始二进制字节码),需要手动转换为 SSE 字段。

自动补全 Accept: text/event-stream,确保后端返回正确格式。

2.流式读取

通过 response.body.getReader() 开启循环读取字节流。

3.字节解码与“缓冲区”拼装

网络传输中,数据包可能在任何地方断开(例如:data: hello 被切成了 data: hello)。 源码中维护了一个 buffer (Uint8Array) 和一个 position (当前偏移量),这是该库最核心的逻辑。

  • 缓冲区逻辑:库内部维护一个 buffer 字符串。新到的片段会拼接到 buffer 后。

    • 扫描 buffer 中是否存在 \n\n
    • 如果有,说明消息完整,切出来进行解析。
    • 如果没有,继续等待下一个数据块。

4.字段解析与分发

一旦识别出完整的消息块,它会按行拆分,解析出 data:, event:, id: 等字段,并手动调用你在配置中传入的 onmessage, onopen 等回调函数。

核心方法解析

1、fetchEventSource

export interface FetchEventSourceInit extends RequestInit {
    headers?: Record<string, string>,
    onopen?: (response: Response) => Promise<void>,
    onmessage?: (ev: EventSourceMessage) => void;
    onclose?: () => void;
    onerror?: (err: any) => number | null | undefined | void,
    openWhenHidden?: boolean;
    fetch?: typeof fetch; // 要使用的 Fetch 函数。默认为 window.fetch
}
export function fetchEventSource(input: RequestInfo, {
    signal: inputSignal,
    headers: inputHeaders,
    onopen: inputOnOpen,
    onmessage,
    onclose,
    onerror,
    openWhenHidden,
    fetch: inputFetch,
    ...rest
}: FetchEventSourceInit) {
    return new Promise<void>((resolve, reject) => {
        // 复制请求头,确保 accept 为 text/event-stream。
        const headers = { ...inputHeaders };
        if (!headers.accept) {
            headers.accept = EventStreamContentType;
        }

        // 当前请求的 AbortController
        let curRequestController: AbortController;
        function onVisibilityChange() {
            curRequestController.abort(); // 页面隐藏时中断请求
            if (!document.hidden) {
                create(); // 页面恢复可见时重新建立连接
            }
        }

        // 如果openWhenHidden为false,则监听文档可见性变化事件
        if (!openWhenHidden) {
            document.addEventListener('visibilitychange', onVisibilityChange);
        }

        // 资源清理
        let retryInterval = DefaultRetryInterval;
        let retryTimer = 0;
        function dispose() {
            // 清理事件监听
            document.removeEventListener('visibilitychange', onVisibilityChange);
            // 清理定时器和中断请求
            window.clearTimeout(retryTimer);
            // 中断当前请求
            curRequestController.abort();
        }

        // 如果外部传入了 abort 信号,响应中断并清理资源。
        inputSignal?.addEventListener('abort', () => {
            dispose();
            resolve(); // 不要浪费资源在重试上
        });

        // 使用传入的 fetch 实现(若有),否则使用全局的 window.fetch。
        const fetch = inputFetch ?? window.fetch;
        // 使用传入的 onopen 回调(用于验证/处理响应),没有则使用默认的 content-type 校验函数。
        const onopen = inputOnOpen ?? defaultOnOpen;
        async function create() {
            // 为本次请求创建 AbortController,用于后续中止当前请求(可见性变化或外部 abort)。
            curRequestController = new AbortController();
            try {
                // 发起 fetch 请求,合并剩余 init 配置、headers,并将当前 controller 的 signal 传入以便可中止。
                const response = await fetch(input, {
                    ...rest,
                    headers,
                    signal: curRequestController.signal,
                });

                await onopen(response);
                
                // 链式处理响应流:
                // getMessages返回按行组装成EventSourceMessage对象的online函数
                // 第一个参数是id,如果有id就写入headers中,用于在下次重连发送last-event-id
                // 第二个参数是retry,如果有retry就更新retryInterval,用于下次重连等待时间
                // 第三个参数是onmessage回调,用于处理完整的EventSourceMessage消息

                // getLines负责把响应体字节流按行切分,调用上面的online函数
                // getBytes负责从response.body中读取响应体的字节流,并把每块传入getLines进行处理,直到结束或终止
                await getBytes(response.body!, getLines(getMessages(id => {
                    if (id) {
                        // store the id and send it back on the next retry:
                        headers[LastEventId] = id;
                    } else {
                        // don't send the last-event-id header anymore:
                        delete headers[LastEventId];
                    }
                }, retry => {
                    retryInterval = retry;
                }, onmessage)));

                onclose?.(); // 流正常结束后,调用可选的 onclose 回调
                dispose(); // 清理(移除可见性监听、清除定时器、abort 当前 controller 等资源)
                resolve(); // 完成外部 Promise(表示工作完成,不再重试)。
            } catch (err) {
                // 只有在不是主动中止的情况下才考虑重试(如果是主动 abort,就不重试)
                if (!curRequestController.signal.aborted) {
                    try {
                        // 调用用户的 onerror 回调,允许其返回一个重试间隔(毫秒);若未提供或返回 undefined,则使用当前的 retryInterval(来自服务器 retry 字段或默认值)
                        const interval: any = onerror?.(err) ?? retryInterval;
                        window.clearTimeout(retryTimer); // 清除之前可能存在的重试定时器。
                        retryTimer = window.setTimeout(create, interval);
                    } catch (innerErr) {
                        dispose(); // 清理资源。
                        reject(innerErr); // 拒绝外部 Promise,结束整个流程并向调用者报告错误
                    }
                }
            }
        }

        create();
    });
}

2、 getBytes() :循环读取流信息

函数接收两个参数:stream和onChunk

  • stream:代表一个可读取的二进制数据流
  • onChunk: 是一个回调函数,每当从流中读取到一块数据时,就会调用这个函数,并将读取到的数据作为参数传递给这个函数。
export async function getBytes(stream: ReadableStream<Uint8Array>, onChunk: (arr: Uint8Array) => void) {
    const reader = stream.getReader(); // 创建一个流的阅读器 reader
    let result: ReadableStreamDefaultReadResult<Uint8Array>;
    while (!(result = await reader.read()).done) { // 循环读取数据块,直到流结束
        onChunk(result.value); // 对每个数据块调用回调, onChunk是 getLines()方法的返回值
    }
}

3、 getLines() :将字节块解析为EventSource行信息

接收一个回调函数 onLine 作为参数,并返回一个新的函数 onChunk。

  • onLine:每当检测到一行数据时就会调用它

    规定以`\r`、`\n` 或 `\r\n`作为一行结束的标志
    规定以`\n\n`或`  \r\n\r\n ` 作为一个消息结束的标志
    
  • onChunk:用于处理传入的字节块。逐个解析传入的字节块,找到数据中的行结束符。将字节块解析为 EventSource 行缓冲区,并在检测到完整行时调用 onLine 回调。

它解决了这样一个问题:网络传输的数据可能不是一行一行到达的,而是分块到达的,甚至一行可能被拆成多个块。这个函数负责把这些块拼起来,遇到换行符(\r、\n 或 \r\n)就认为是一行,然后把这一行交给 onLine 处理。

// 模拟字节块的返回
// 块1:data: hello\r\ndata: wo
// 块2:rld\r\n\r\nevent: update\r\ndata: 123\r\n
export function getLines(onLine: (line: Uint8Array, fieldLength: number) => void) {
    let buffer: Uint8Array | undefined;
    let position: number; // 当前读取位置
    let fieldLength: number; // 当前行中有效“字段”部分的长度
    let discardTrailingNewline = false; // 标记是否需要跳过紧跟在\r后的\n

    // 返回一个函数,处理每个字节块
    return function onChunk(arr: Uint8Array) {
        if (buffer === undefined) { // 初始化buffer、position、fieldLength,如果未定义也就是意味着这是第一次调用或者前一个缓存区已完全处理完毕
            buffer = arr;
            position = 0;
            fieldLength = -1;
        } else {
            // 如果buffer已定义(既正在处理一个较大的数据块或连续的数据块),将新的数据块arr追加到现有的buffer后面,主要处理前一个字节处理完还有剩余字节的情况
            buffer = concat(buffer, arr);
        }

        const bufLength = buffer.length;
        let lineStart = 0; // 当前行的起始位置
        while (position < bufLength) { // 遍历buffer,使用position指针来追踪当前读取的位置
            if (discardTrailingNewline) { // 如果设置了discardTrailingNewline标志,则跳过行结束符之后的新行字符,如果上次遇到\r,这次要跳过\n
                if (buffer[position] === ControlChars.NewLine) {
                    lineStart = ++position; // 跳过\n
                }
                
                discardTrailingNewline = false;
            }
            
            // 查找本行的结束符
            let lineEnd = -1;
            for (; position < bufLength && lineEnd === -1; ++position) {
                switch (buffer[position]) {
                    case ControlChars.Colon:
                        if (fieldLength === -1) { // 记录第一个冒号的位置
                            fieldLength = position - lineStart;
                        }
                        break;
                    case ControlChars.CarriageReturn: // \r
                        discardTrailingNewline = true; // 标记下次要跳过\n
                    case ControlChars.NewLine: // \n
                        lineEnd = position; // 行结束
                        break;
                }
            }

            if (lineEnd === -1) {
                // 没找到行结束符,等下一个字节块
                break;
            }

            // 取出完整的一行,调用 onLine,onLine是 getMessages()方法的返回值
            onLine(buffer.subarray(lineStart, lineEnd), fieldLength); // 获取完整的行,并调用onLine回调函数,处理这一行数据
            lineStart = position; // 下一行的起始位置
            fieldLength = -1; // 更新 fieldLength 为 -1,准备处理下一行的 field 部分
        }

        if (lineStart === bufLength) {
            buffer = undefined; // 全部处理完
        } else if (lineStart !== 0) {
             // 还有未处理的内容
            buffer = buffer.subarray(lineStart); // 把 buffer 变成还没处理完的部分,丢弃已经处理过的内容。这样下次新数据块到来时,可以直接拼接到剩余部分后面。
            position -= lineStart; // 更新 position 指针,保证它指向新的 buffer 的正确位置。其实可以直接置为0,因为新的 buffer 是从 lineStart 开始的,但是这样写更通用一些。防止极端情况下 position 指向错误。
        }
    }
}

4、getMessages():把 EventSource 行组装成完整的 SSE 消息对象

接收三个回调为参数:onIdonRetryonMessage,并返回一个新的函数onLine

  • onId:回调,在每次检测到消息 ID 时调用,传递 ID 字符串作为参数
  • onRetry:回调,在每次检测到重试时间时调用,传递重试时间的数值作为参数
  • onMessage:回调,在每次消息结束时调用,传递完整的消息对象作为参数
  • onLine:处理每一行的数据
export function getMessages(
    onId: (id: string) => void,
    onRetry: (retry: number) => void,
    onMessage?: (msg: EventSourceMessage) => void
) {
    let message = newMessage(); // 初始化一个空消息对象
    const decoder = new TextDecoder(); // 用于把字节数组解码为字符串。

    // 返回一个函数,每当解析出一行 EventSource 行时就会调用,(由 getLines 传入)
    return function onLine(line: Uint8Array, fieldLength: number) {
        if (line.length === 0) { // 如果是空行表示消息结束
            onMessage?.(message); // 调用 onMessage 回调,将完整的消息对象传递出去
            message = newMessage(); // 重置 message 对象
        } else if (fieldLength > 0) { // 如果这一行包含有效数据(即不是注释或空行),继续处理。
            // 解析字段名(field)和字段值(value)
            // 字段名是从行开头到冒号前的部分,字段值是冒号后面,可能有一个空格(协议允许),所以判断是否有空格决定偏移量。
            const field = decoder.decode(line.subarray(0, fieldLength));
            const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
            const value = decoder.decode(line.subarray(valueOffset));

            switch (field) {
                case 'data':
                    // 如果字段名是 data,把 value 加到 message.data 上
                    // 如果已经有 data,追加一行(\n),否则直接赋值
                    message.data = message.data
                        ? message.data + '\n' + value
                        : value; 
                    break;
                case 'event':
                    // 如果字段名是 event,设置消息的事件类型
                    message.event = value;
                    break;
                case 'id':
                    // 如果字段名是 id,设置消息的 id,并调用 onId 回调。
                    onId(message.id = value);
                    break;
                case 'retry':
                    // 如果字段名是 retry,尝试解析为整数,合法则设置消息的 retry 并调用 onRetry 回调
                    const retry = parseInt(value, 10);
                    if (!isNaN(retry)) { // per spec, ignore non-integers
                        onRetry(message.retry = retry);
                    }
                    break;
            }
        }
    }
}

源码流程详细图解

whiteboard_exported_image.png

总结

SSE 的本质是:一种基于文本行的、约定俗成的 HTTP Body 消费方式。 原生 API 是将这种消费方式『硬件化』在了浏览器里, 而 fetch-event-source 则是用 JS 工具将其『软件化』实现了一遍。

前端性能杀手竟然不是JS?图片优化才是绝大多数人忽略的"降本增效"方案

2026年2月9日 14:03

咱们先聊个扎心的事实:

你花三周时间把 Webpack 配置调到极致,Code Splitting 拆得比饺子馅还细,Tree Shaking 摇得比筛子还勤,结果首屏加载时间从 3.2 秒降到了 2.9 秒。你正准备庆祝的时候,产品经理给首屏 Banner 换了一张"超清大图",一下子又回到了 4.1 秒。

这不是段子,这是字节、阿里、腾讯的前端天天遇到的真实场景。

图片才是现代 Web 应用里最重的资源,没有之一。

但绝大多数前端工程师对图片优化的认知还停留在"用 WebP"、"开启 CDN 压缩"这种表层操作。真正能把图片优化做到极致的,往往是那些理解浏览器渲染机制、懂网络协议、会写 JavaScript 运行时优化的"杂家"。 今天这篇文章,咱们就从 JavaScript 的视角来重新审视图片优化,用代码把那些模糊的"最佳实践"变成可落地的工程方案。

第一层:懒加载不是设个 loading="lazy" 就完事了

原生懒加载的局限性

很多人以为图片懒加载就是这样:

<img src="photo.jpg" loading="lazy">

浏览器确实会帮你延迟加载,但这个策略完全不受你控制。浏览器决定什么时候加载,你只能接受。

真正的懒加载策略应该是:在图片进入视口前 200-500px 就开始预加载,这样用户滚动到位置时图片已经准备好了,既节省了带宽又保证了体验。

JavaScript 接管控制权

这时候 JavaScript 的 IntersectionObserver API 就派上用场了:

// 创建一个观察器,提前 200px 开始加载
const lazyObserver = new IntersectionObserver(
(entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      
      const img = entry.target;
      const realSrc = img.dataset.src;
      
      // 开始加载真实图片
      img.src = realSrc;
      
      // 加载完成后停止观察
      img.onload = () => {
        img.classList.add('loaded');
        lazyObserver.unobserve(img);
      };
    });
  },
  {
    // 关键参数:提前 200px 触发
    rootMargin: '200px 0px'
  }
);

// 批量观察所有待加载图片
document.querySelectorAll('img[data-src]').forEach(img => {
  lazyObserver.observe(img);
});

工作流程图:

用户滚动页面
     ↓
图片距离视口还有 200px
     ↓
IntersectionObserver 触发回调
     ↓
JavaScript 将 data-src 赋值给 src
     ↓
浏览器开始下载图片
     ↓
用户滚动到图片位置时
     ↓
图片已经加载完成 ✅

这种方式在电商网站的商品列表页特别有效。以某头部电商平台为例,他们的商品图在列表中使用 1px 占位符,滚动到距离视口 300px 时才开始加载真图,首屏图片请求数从 50 张降到 12 张,首屏渲染时间直接砍半。

降级策略

但问题来了:老浏览器不支持 IntersectionObserver 怎么办?

答案是渐进增强:

// 检测 API 支持情况
if ('IntersectionObserver' in window) {
  // 使用高级策略
  lazyObserver.observe(img);
} else {
  // 降级到原生懒加载
  img.loading'lazy';
  img.src = img.dataset.src;
}

第二层:根据设备和网络动态选择图片

屏幕分辨率不等于图片尺寸

很多人以为响应式图片就是写几个 srcset:

<img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w">

但这只考虑了屏幕宽度,没考虑 DPR(设备像素比)。iPhone 14 Pro 的屏幕宽度是 393px,但 DPR 是 3,实际需要的图片宽度是 393 × 3 = 1179px

用 JavaScript 动态计算才是正解:

function calculateOptimalImageWidth() {
// 获取设备像素比,默认为 1
const dpr = window.devicePixelRatio || 1;

// 获取视口宽度,限制最大值避免过大
const viewportWidth = Math.min(window.innerWidth, 1920);

// 计算实际需要的物理像素宽度
const physicalWidth = Math.ceil(viewportWidth * dpr);

return physicalWidth;
}

// 使用示例
const heroImage = document.querySelector('.hero-banner');
const optimalWidth = calculateOptimalImageWidth();

// 向 CDN 请求对应尺寸的图片
heroImage.src = `https://cdn.example.com/hero.jpg?w=${optimalWidth}`;

Network Information API:根据网络降级

现在进阶一步:根据用户的网络状况动态调整图片质量

function getImageQuality() {
// 检测 Network Information API 支持
const connection = navigator.connection || 
                     navigator.mozConnection || 
                     navigator.webkitConnection;

if (!connection) return80; // 默认质量

// 用户开启了流量节省模式
if (connection.saveData) {
    console.log('用户开启省流模式,降低图片质量');
    return40;
  }

// 根据网络类型调整质量
const effectiveType = connection.effectiveType;

const qualityMap = {
    'slow-2g'30,
    '2g'30,
    '3g'60,
    '4g'80,
    '5g'90
  };

return qualityMap[effectiveType] || 80;
}

// 完整的图片加载策略
function loadSmartImage(imageElement) {
const width = calculateOptimalImageWidth();
const quality = getImageQuality();

const imageUrl = new URL(imageElement.dataset.src);
  imageUrl.searchParams.set('w', width);
  imageUrl.searchParams.set('q', quality);

  imageElement.src = imageUrl.toString();

console.log(`加载图片: 宽度=${width}px, 质量=${quality}`);
}

真实场景:

某短视频 App 的移动端 Web 版,用户在地铁里用 4G 浏览时,图片质量默认 80%;一进隧道切到 3G,立刻降到 60%;用户主动开启省流模式,直接降到 40%。这套策略让他们的图片流量消耗降低了 35% ,用户投诉"费流量"的工单减少了一半。

第三层:解码优先级,别让图片阻塞渲染

图片解码是性能杀手

很多人不知道的冷知识:浏览器下载图片和解码图片是两回事

一张 500KB 的 JPEG,下载可能只要 200ms,但解码可能要 800ms。如果你在首屏同时加载 10 张大图,解码会完全阻塞主线程,导致页面卡顿。

异步解码救命

JavaScript 提供了 decoding 属性来控制解码策略:

// 首屏关键图片:同步解码,优先显示
const heroImage = document.querySelector('.hero');
heroImage.decoding'sync';     // 立即解码
heroImage.fetchPriority'high'; // 高优先级下载

// 非关键图片:异步解码,不阻塞渲染
const thumbnails = document.querySelectorAll('.thumbnail');
thumbnails.forEach(img => {
  img.decoding'async';         // 异步解码
  img.fetchPriority'low';      // 低优先级
});

解码策略对比:

同步解码 (sync):
下载图片 → 阻塞主线程 → 解码完成 → 渲染页面
    ↓
主线程被占用,页面卡顿 ❌

异步解码 (async):
下载图片 → 不阻塞 → 后台解码 → 解码完成后渲染
    ↓
主线程继续执行,页面流畅 ✅

预加载图片获取尺寸

还有一个高级技巧:在插入 DOM 前预加载图片,提前获取宽高比,避免 CLS(累积布局偏移)

async function preloadImageWithDimensions(src) {
returnnewPromise((resolve, reject) => {
    const img = new Image();
    
    img.onload = () => {
      resolve({
        element: img,
        width: img.naturalWidth,
        height: img.naturalHeight,
        aspectRatio: img.naturalWidth / img.naturalHeight
      });
    };
    
    img.onerror = reject;
    img.src = src;
  });
}

// 使用示例
const imageData = await preloadImageWithDimensions('/photo.jpg');

// 提前设置宽高比,避免布局偏移
const container = document.querySelector('.image-container');
container.style.aspectRatio = imageData.aspectRatio;

// 图片已经加载完成,直接插入
container.appendChild(imageData.element);

这招在动态生成内容的场景特别有用,比如用户上传头像、生成分享海报等,可以完全避免"图片加载后页面突然跳动"的问题。

第四层:客户端压缩,上传前就优化

为什么要在前端压缩图片?

传统思路是:用户上传 → 服务端压缩 → 存储到 CDN。

但这有几个问题:

  1. 用户上传 10MB 原图,流量浪费
  2. 服务端要处理大量压缩任务,CPU 成本高
  3. 用户要等服务端处理完才能看到预览

更好的方案是:前端直接压缩,上传压缩后的图片

Canvas API + OffscreenCanvas

JavaScript 的 Canvas API 可以实现客户端压缩:

async function compressImageOnClient(file, maxWidth = 1920) {
// 使用 createImageBitmap 读取文件
const bitmap = await createImageBitmap(file);

// 计算缩放比例
const scale = Math.min(1, maxWidth / bitmap.width);
const newWidth = Math.floor(bitmap.width * scale);
const newHeight = Math.floor(bitmap.height * scale);

// 使用 OffscreenCanvas 处理,不阻塞主线程
const canvas = new OffscreenCanvas(newWidth, newHeight);
const ctx = canvas.getContext('2d');

// 绘制缩放后的图片
  ctx.drawImage(bitmap, 00, newWidth, newHeight);

// 转换为 WebP 格式,质量 0.8
const blob = await canvas.convertToBlob({
    type'image/webp',
    quality0.8
  });

return blob;
}

// 用户上传图片时触发
document.querySelector('#upload').addEventListener('change'async (e) => {
const file = e.target.files[0];

console.log(`原始文件: ${(file.size / 10241024).toFixed(2)} MB`);

// 前端压缩
const compressed = await compressImageOnClient(file, 1920);

console.log(`压缩后: ${(compressed.size / 10241024).toFixed(2)} MB`);
console.log(`压缩率: ${((1 - compressed.size / file.size) * 100).toFixed(1)}%`);
  
  // 上传压缩后的图片
  uploadToServer(compressed);
});

实测数据(iPhone 拍摄的照片):

原始文件: 8.3 MB (4032 × 3024, JPEG)
     ↓
前端压缩 (1920px, WebP, quality=0.8)
     ↓
压缩后: 0.6 MB
压缩率: 92.8% ✅

Web Worker 优化

如果要处理多张图片,可以用 Web Worker 避免阻塞主线程:

// imageCompressor.worker.js
self.addEventListener('message', async (e) => {
const { file, maxWidth } = e.data;

const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxWidth / bitmap.width);

const canvas = new OffscreenCanvas(
    Math.floor(bitmap.width * scale),
    Math.floor(bitmap.height * scale)
  );

const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);

const blob = await canvas.convertToBlob({
    type: 'image/webp',
    quality: 0.8
  });

// 发送回主线程
  self.postMessage({ blob, originalSize: file.size });
});

// 主线程使用
const worker = new Worker('imageCompressor.worker.js');

worker.postMessage({ file: uploadedFile, maxWidth: 1920 });

worker.onmessage = (e) => {
const { blob, originalSize } = e.data;
const ratio = ((1 - blob.size / originalSize) * 100).toFixed(1);
console.log(`压缩完成,节省 ${ratio}% 流量`);

  uploadToServer(blob);
};

某社交平台用了这套方案后,用户上传图片的流量成本降低了 87% ,服务端 CPU 使用率降低了 60%。

第五层:Cache API 让图片真正"只加载一次"

HTTP 缓存的局限

浏览器的 HTTP 缓存很好,但有个问题:缓存策略完全由服务端控制,而且在隐私模式下会失效。

更激进的方案是用 Cache API 手动管理图片缓存:

const IMAGE_CACHE_NAME'image-cache-v1';

// 缓存图片
asyncfunction cacheImage(url) {
const cache = await caches.open(IMAGE_CACHE_NAME);

// 检查是否已缓存
const cached = await cache.match(url);
if (cached) {
    console.log(`命中缓存: ${url}`);
    return cached;
  }

// 未缓存,立即下载
console.log(`下载并缓存: ${url}`);
const response = await fetch(url);

// 只缓存成功的响应
if (response.ok) {
    await cache.put(url, response.clone());
  }

return response;
}

// 加载图片时使用缓存
asyncfunction loadImageWithCache(imgElement) {
const url = imgElement.dataset.src;

const response = await cacheImage(url);
const blob = await response.blob();

// 创建 Object URL 显示图片
  imgElement.src = URL.createObjectURL(blob);
}

缓存清理策略

Cache API 不会自动清理,需要手动控制缓存大小:

async function cleanOldCache(maxSize = 50 * 1024 * 1024) { // 50MB
const cache = await caches.open(IMAGE_CACHE_NAME);
const requests = await cache.keys();

let totalSize = 0;
const items = [];

// 统计每个缓存项的大小和时间
for (const request of requests) {
    const response = await cache.match(request);
    const blob = await response.blob();
    
    items.push({
      request,
      size: blob.size,
      url: request.url
    });
    
    totalSize += blob.size;
  }

// 超出限制,删除最早的缓存
if (totalSize > maxSize) {
    console.log(`缓存超限: ${(totalSize / 10241024).toFixed(2)} MB`);
    
    // 按时间排序,删除旧的
    items.sort((a, b) => a.url.localeCompare(b.url));
    
    let cleaned = 0;
    for (const item of items) {
      if (totalSize - cleaned < maxSize) break;
      
      await cache.delete(item.request);
      cleaned += item.size;
      console.log(`删除缓存: ${item.url}`);
    }
  }
}

// 定期清理
setInterval(cleanOldCache, 5 * 60 * 1000); // 每 5 分钟检查一次

真实效果:

某新闻 App 的 PWA 版本,使用 Cache API 后:

  • 二次访问图片加载时间从 800ms 降到 50ms
  • 离线状态下依然能浏览已访问过的图片
  • 用户流量消耗降低 70%

完整的图片优化工作流

把上面的技术组合起来,就是一套完整的图片优化系统:

1. 用户滚动页面
    ↓
2. IntersectionObserver 触发(提前 200px)
    ↓
3. JavaScript 检测网络状况(Network Info API)
    ↓
4. 计算最优尺寸和质量(DPR + 网络类型)
    ↓
5. 检查 Cache API 是否有缓存
    ↓
6. 如果有缓存 → 直接使用
   如果无缓存 → 向 CDN 请求
    ↓
7. 下载完成后存入 Cache API
    ↓
8. 设置 decoding='async' 异步解码
    ↓
9. 图片显示,避免 CLS

性能对比:优化前 vs 优化后

以某电商平台的商品详情页为例:

指标 优化前 优化后 提升
首屏图片请求数 18 张 6 张 ↓ 67%
图片总大小 4.2 MB 0.8 MB ↓ 81%
首屏渲染时间 3.8 秒 1.2 秒 ↓ 68%
CLS 评分 0.25 0.02 ↓ 92%
二次访问加载时间 2.1 秒 0.3 秒 ↓ 86%

这些数据不是实验室跑出来的,是真实线上环境、千万级 PV 验证过的。

写在最后

图片优化不是"换个格式"或"开个 CDN"那么简单,它是一套完整的运行时策略系统:

  1. 延迟加载:IntersectionObserver 精准控制
  2. 动态选择:根据设备和网络调整尺寸和质量
  3. 解码优化:async decoding 避免阻塞
  4. 客户端压缩:前端直接处理,节省流量和服务器成本
  5. 缓存管理:Cache API 手动控制,离线可用

这些技术的共同点是:JavaScript 掌握了主动权,不再被动依赖浏览器或 CDN 的默认行为。

但更重要的是,要理解为什么这么做

浏览器只关心字节数、解码时间、布局稳定性和渲染时机。你的每一行代码,都应该为这四个目标服务。

记住:性能不是锦上添花,性能本身就是功能。用户不会夸你的代码写得优雅,但会直接感受到你的页面是快是慢。

而图片优化,往往是性能优化中 ROI 最高的那个环节。

开源 Claude Code + Codex + 面板 的未来vibecoding平台

作者 朱昆鹏
2026年2月9日 13:38

一句话介绍

CodeMoss =

  • 多AI联动:Claude Code + Codex + Gemini + OpenCode + ......
  • 多端使用:客户端 + Jetbrains + Vscode + 移动端
  • 多周边集成:AI面板 + AI记忆 + Superpowers + OpenSpec + Spec-kit + ...

说了这么多功能,直接放实机图更容易理解

image.png

image.png


总之一句话:CodeMoss 目标打造 下一代的vibecoding 入口

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…


详细介绍

对话过程页面

image.png

侧边栏GIT模块

image.png

侧边栏文件管理模块

真的可以编辑哦~

image.png

面板模式

这不是普通的面板哦~,是真的可以并行执行任务,有完整交互的AI面板哦~

image.png

image.png

侧边栏展示

支持claude code + codex 多cli数据共同展示

image.png

终端展示

image.png

支持多平台

支持Mac 和 window 多平台


下载安装体验

功能太多了,就不赘述了,大家可以下载之后自行探索

下载地址(纯开源,无商业,放心食用):www.codemoss.ai/download


未来迭代

目前虽然能用,但是细节打磨的还不满意,我至少会每天迭代一个版本,先迭代100个版本,欢迎大家使用提出问题

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…

再次声明:本项目完全开源,0商业,使用过程全程无广,请放心食用

在 VS Code中,vue2-vuex 使用终于有体验感增强的插件了。

作者 小书包酱
2026年2月9日 12:30

Vuex Helper

适用于 Vuex 2 的 VS Code 插件,提供 跳转定义代码补全悬浮提示 功能。支持 State, Getters, Mutations 和 Actions。

引言

在 AI 时代,为什么要搞一个老掉牙的 vue2 的 vuex 增强插件?可以想象,现在起步应该都会是 vue3 或者 react 的框架。但老项目永远不会少,除非下定决心去重构,否则永远都要面对老项目,那在vscode中,遇到 vue2 项目的调试过程中,vuex 的跳转定义永远是我开发与迭代时遇到的痛点,AI 给了我机会,让我无需在繁重的业务需求之外,额外耗费太多的时间去学习插件怎么使用,而直接上手去把我的思路交予实现。感谢 AI,让我有能力去完成一些平时不可及的小事情。

功能特性

1. 跳转定义 (Go to Definition)

从组件中直接跳转到 Vuex Store 的定义处。

演示:跳转定义

jump_definition.gif

  • 支持: this.$store.state/getters/commit/dispatch
  • Map 辅助函数: mapState, mapGetters, mapMutations, mapActions
  • 命名空间: 完美支持 Namespaced 模块及其嵌套。

2. 智能代码补全 (Intelligent Code Completion)

智能提示 Vuex 的各种 Key 以及组件中映射的方法。

演示:智能补全

auto_tips_and_complete_for_var.gif

auto_tips_and_complete_for_func.gif

  • 上下文感知: 在 dispatch 中提示 Actions,在 commit 中提示 Mutations。
  • 命名空间过滤: 当使用 mapState('user', [...]) 时,会自动过滤并仅显示 user 模块下的内容。
  • 组件映射方法: 输入 this. 即可提示映射的方法(例如 this.increment 映射自 ...mapMutations(['increment']))。
  • 语法支持: 支持数组语法和对象别名语法 (例如 ...mapActions({ alias: 'name' }))。

3. 悬浮提示与类型推导 (Hover Information & Type Inference)

无需跳转即可查看文档、类型详情。

演示:悬浮文档

hover_info_and_type_inference.gif

  • JSDoc 支持: 提取并显示 Store 定义处的 /** ... */ 注释文档。
  • State 类型: 在悬浮提示中自动推导并显示 State 属性的类型 (例如 (State) appName: string)。
  • 详细信息: 显示类型(State/Mutation等)及定义所在的文件路径。
  • 映射方法: 支持查看映射方法的 Store 文档。

4. Store 内部调用 (Store Internal Usage)

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

演示:Store 内部 代码补全、跳转、悬浮提示

internal_usage.gif

  • 模块作用域: 当在模块文件(如 user.js)中编写 Action 时,commitdispatch 的代码补全会自动过滤并仅显示当前模块的内容。

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

支持的语法示例

  • 辅助函数 (Helpers):
    ...mapState(['count'])
    ...mapState('user', ['name']) // 命名空间支持
    ...mapActions({ add: 'increment' }) // 对象别名支持
    ...mapActions(['add/increment'])
    
  • Store 方法:
    this.$store.commit("SET_NAME", value);
    this.$store.dispatch("user/updateName", value);
    
  • 组件方法:
    this.increment(); // 映射自 mapMutations
    this.appName; // 映射自 mapState
    

使用要求

  • 使用 Vuex 的 Vue 2 项目。
  • Store 入口位于 src/store/index.jssrc/store/index.ts(支持自动探测)。
  • 若无法自动找到,请在设置中配置 vuexHelper.storeEntry

配置项

  • vuexHelper.storeEntry: 手动指定 Store 入口文件路径。支持:
    • 别名路径: @/store/index.js (需在 jsconfig/tsconfig 中配置)
    • 相对路径: src/store/index.js
    • 绝对路径: /User/xxx/project/src/store/index.js

更新日志

0.0.1

初始版本,支持功能:

  • 全面支持 State, Getters, Mutations, Actions
  • 支持命名空间过滤 (Namespace Filtering)
  • 支持 JSDoc 悬浮文档显示

「九九八十一难」第一难:前端数据mock指南(TS + VUE)

作者 从文处安
2026年2月9日 11:58

Vue3 + TypeScript 项目中使用 Mock 数据指南

背景

产品:这里有个需求,计划月底上线,你们评估下开发时间,保证月底能上线现网。

测试:我需要一周的测试时间,包括功能测试、性能测试、兼容性测试等。

UI:我需要一周时间,包括页面布局、交互设计、颜色方案等。

后端:我需要两周时间,包括数据库设计、接口开发、业务逻辑实现等。

前端:我走?

产品:你想想办法。

前端:我可以牺牲自己的开发时间,通过接口mock来并行开发,需要后端提前提供接口文档,我同步进行页面开发和逻辑实现。

为了需求正常上线,无私的前端又为自己找了个加班的机会。

前言

在前端开发过程中,我们经常会遇到后端接口尚未完成,但前端需要提前开发页面和功能的情况。 这时,使用 Mock 数据就成为了一种非常有效的解决方案。 本文将介绍如何在 Vue3 + TypeScript 项目中搭建和使用 Mock 数据。

什么是 Mock 数据

概念

Mock 数据是指在开发过程中,为了模拟后端接口返回的数据,而创建的虚假数据。

作用

  1. 并行开发:前端可以与后端同时开发,不需要等待后端接口完成

  2. 独立测试:可以模拟各种边界情况和错误场景

  3. 性能测试:可以模拟大量数据,测试前端性能

  4. 演示效果:在没有后端服务的情况下,也能展示完整的功能

优势

  1. 提高开发效率:减少等待后端接口的时间

  2. 增强代码健壮性:可以测试各种异常情况

  3. 改善团队协作:明确接口规范,减少沟通成本

  4. 简化测试流程:可以快速模拟各种场景

环境搭建

安装依赖

在 Vue3 + TypeScript 项目中,需要安装以下依赖:

# 安装 mockjs 库
npm install mockjs --save-dev

# 安装 vite-plugin-mock 插件
npm install vite-plugin-mock --save-dev

# 安装 @types/mockjs 类型定义(可选但推荐)
npm install --save-dev @types/mockjs

配置 Vite

vite.config.ts 文件中配置 vite-plugin-mock 插件:

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import { viteMockServe } from "vite-plugin-mock";

export default defineConfig({
  plugins: [
    vue(),
    viteMockServe({
      // mock 文件存放路径
      mockPath: "./mock",
      // 启用 mock 功能
      enable: true,
      // 显示请求日志
      logger: true,
    }),
  ],
  resolve: {
    alias: {
      "@": resolve(__dirname, "./src"),
    },
  },
});

配置 TypeScript

tsconfig.app.json 文件中配置路径别名,确保 TypeScript 能够正确解析 @/ 路径:

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "paths": {
      "@/*": ["./src/*"]
    },
    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

配置 Mock 数据

快速开始 - 最简单的用例

模拟第一个简单接口

第一步:创建最简单的 Mock 接口

mock 文件夹下创建一个简单的 hello.ts 文件:

// mock/hello.ts
import { MockMethod } from "vite-plugin-mock";

export default [
  {
    url: "/api/hello",
    method: "get",
    response: () => {
      return {
        code: 200,
        message: "success",
        data: "Hello, Mock!",
      };
    },
  },
] as MockMethod[];
第二步:在组件中调用
<template>
  <div>
    <h2>{{ message }}</h2>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import axios from "axios";

const message = ref("");

onMounted(async () => {
  try {
    const response = await axios.get("/api/hello");
    message.value = response.data.data;
  } catch (error) {
    console.error("请求失败:", error);
  }
});
</script>

运行结果:页面显示 "Hello, Mock!"


中级用例 - 简单数据列表

这个用例展示如何返回一个简单的数据列表。

创建 Mock 接口

mock 文件夹下创建 products.ts 文件:

// mock/products.ts
import { MockMethod } from "vite-plugin-mock";

export default [
  {
    url: "/api/products",
    method: "get",
    response: () => {
      return {
        code: 200,
        message: "success",
        data: [
          { id: 1, name: "商品1", price: 99 },
          { id: 2, name: "商品2", price: 199 },
          { id: 3, name: "商品3", price: 299 },
        ],
      };
    },
  },
] as MockMethod[];
在组件中调用
<template>
  <div>
    <h2>商品列表</h2>
    <ul>
      <li v-for="product in products" :key="product.id">
        {{ product.name }} - ¥{{ product.price }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import axios from "axios";

interface Product {
  id: number;
  name: string;
  price: number;
}

const products = ref<Product[]>([]);

onMounted(async () => {
  try {
    const response = await axios.get("/api/products");
    products.value = response.data.data;
  } catch (error) {
    console.error("获取商品列表失败:", error);
  }
});
</script>

运行结果:页面显示商品列表,包含3个商品的信息。


进阶用例 - 用户数据管理

接下来,我们创建一个更完整的贴合业务场景的用例。

创建 Mock 文件

创建一个 user.ts 文件来模拟用户相关的接口:

// mock/user.ts
import { MockMethod } from "vite-plugin-mock";

export default [
  {
    url: "/api/user/list", // 接口路径
    method: "get", // 请求方法
    response: ({ query }: { query: Record<string, string> }) => {
      // 模拟分页数据
      const page = parseInt(query.page) || 1;
      const limit = parseInt(query.limit) || 10;
      const total = 100;

      // 生成模拟数据
      const list = [];
      for (let i = 0; i < limit; i++) {
        const index = (page - 1) * limit + i;
        if (index < total) {
          list.push({
            id: index + 1,
            name: `用户${index + 1}`,
            age: Math.floor(Math.random() * 30) + 18,
            email: `user${index + 1}@example.com`,
            createdAt: new Date().toISOString(),
          });
        }
      }

      return {
        code: 200,
        message: "success",
        data: {
          list,
          total,
          page,
          limit,
        },
      };
    },
  },
  {
    url: "/api/user/detail",
    method: "get",
    response: ({ query }: { query: Record<string, string> }) => {
      const id = query.id;

      return {
        code: 200,
        message: "success",
        data: {
          id,
          name: `用户${id}`,
          age: Math.floor(Math.random() * 30) + 18,
          email: `user${id}@example.com`,
          createdAt: new Date().toISOString(),
          address: "北京市朝阳区",
          phone: "13800138000",
        },
      };
    },
  },
  {
    url: "/api/user/create",
    method: "post",
    response: ({ body }: { body: Record<string, any> }) => {
      return {
        code: 200,
        message: "success",
        data: {
          id: Math.floor(Math.random() * 10000),
          ...body,
        },
      };
    },
  },
] as MockMethod[];

调用 Mock 数据

mock数据的调用使用 axios 实现,本文暂不做过多覆盖。

创建 API 服务

src/api 目录下创建 user.ts 文件,定义 API 调用函数:

// src/api/user.ts
import axios from "axios";

/**
 * 获取用户列表
 * @param params 分页参数
 * @returns Promise 响应数据
 */
export const getUserList = (params: { page: number; limit: number }) => {
  return axios.get("/api/user/list", { params });
};

/**
 * 获取用户详情
 * @param id 用户ID
 * @returns Promise 响应数据
 */
export const getUserDetail = (id: number) => {
  return axios.get("/api/user/detail", { params: { id } });
};

/**
 * 创建用户
 * @param data 用户数据
 * @returns Promise 响应数据
 */
export const createUser = (data: {
  name: string;
  age: number;
  email: string;
}) => {
  return axios.post("/api/user/create", data);
};

在组件中使用

在 Vue 组件中使用 API 服务调用 Mock 数据:

<template>
  <div class="user-list">
    <h2>用户列表</h2>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <ul>
        <li v-for="user in userList" :key="user.id">
          {{ user.name }} - {{ user.age }}岁 - {{ user.email }}
        </li>
      </ul>
      <div class="pagination">
        <button @click="changePage(1)" :disabled="currentPage === 1">
          首页
        </button>
        <button
          @click="changePage(currentPage - 1)"
          :disabled="currentPage === 1"
        >
          上一页
        </button>
        <span>第 {{ currentPage }} 页,共 {{ totalPages }} 页</span>
        <button
          @click="changePage(currentPage + 1)"
          :disabled="currentPage === totalPages"
        >
          下一页
        </button>
        <button
          @click="changePage(totalPages)"
          :disabled="currentPage === totalPages"
        >
          末页
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { getUserList } from "@/api/user";

// 响应式数据
const userList = ref<any[]>([]);
const loading = ref(false);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);

// 计算属性
const totalPages = computed(() => {
  return Math.ceil(total.value / pageSize.value);
});

/**
 * 获取用户列表数据
 */
const fetchUserList = async () => {
  loading.value = true;
  try {
    const response = await getUserList({
      page: currentPage.value,
      limit: pageSize.value,
    });
    userList.value = response.data.data.list;
    total.value = response.data.data.total;
  } catch (error) {
    console.error("获取用户列表失败:", error);
  } finally {
    loading.value = false;
  }
};

/**
 * 切换页码
 * @param page 页码
 */
const changePage = (page: number) => {
  currentPage.value = page;
  fetchUserList();
};

// 组件挂载时获取数据
onMounted(() => {
  fetchUserList();
});
</script>

常见用例和最佳实践

常见用例

  1. 分页数据:模拟带分页的列表数据

  2. 详情数据:模拟单个资源的详细信息

  3. 表单提交:模拟创建、更新操作

  4. 错误场景:模拟各种错误状态码和错误信息

  5. 文件上传:模拟文件上传接口

最佳实践

  1. 目录结构清晰:按模块组织 mock 文件

  2. 数据结构一致:与后端接口保持一致的数据结构

  3. 模拟真实场景:包括正常、异常、边界等各种场景

  4. 使用 TypeScript:为 mock 数据添加类型定义

  5. 注释完善:为复杂的 mock 逻辑添加注释

  6. 定期更新:根据后端接口变化及时更新 mock 数据

故障排除提示

  1. mock 数据不生效
  • 检查 vite.config.ts 中的 mockPath 配置是否正确

  • 检查 mock 文件是否在正确的目录下

  • 检查接口路径是否匹配

  1. TypeScript 类型错误
  • 确保安装了 @types/mockjs 类型定义

  • 为 mock 数据添加正确的类型注解

  1. 生产环境泄露
  • 确保在生产环境中禁用 mock 功能
  1. 性能问题
  • 避免在 mock 函数中执行复杂的计算

  • 对于大量数据,考虑使用分页或虚拟滚动

常见问题

1. 路径别名问题

问题:找不到模块 "@/api/user" 或其相应的类型声明。

原因:虽然在 vite.config.ts 中配置了路径别名,但在 tsconfig.app.json 中没有配置相应的 paths

解决方案:在 tsconfig.app.json 中添加 paths 配置:

{
  "compilerOptions": {
    // ... 其他配置
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

2. 接口实现不完整

问题:调用某个 API 函数时返回 404 错误。

原因:在 mock 文件中没有实现对应的接口。

解决方案:确保所有 API 调用都有对应的 mock 接口实现。

3. 类型定义缺失

问题:使用 mockjs 时缺少类型定义。

原因:没有安装 @types/mockjs 类型定义文件。

解决方案:安装类型定义文件:

npm install --save-dev @types/mockjs

替代方案比较

Mock.js vs JSON Server

  • Mock.js:专注于数据模拟,功能强大,支持各种数据类型和随机数据生成

  • JSON Server:快速创建 RESTful API,基于 JSON 文件,适合简单场景

Mock.js vs MSW (Mock Service Worker)

  • Mock.js:在构建工具层面拦截请求,配置简单

  • MSW:在浏览器层面拦截请求,支持 Service Worker,更接近真实网络请求

Mock.js vs 手写本地存储

  • Mock.js:功能完整,支持各种 HTTP 方法和场景

  • 手写本地存储:简单直接,适合非常简单的场景

注意事项

  1. 环境隔离:确保 mock 功能只在开发和测试环境启用

  2. 数据安全:不要在 mock 数据中使用真实的敏感信息

  3. 接口一致性:与后端保持接口规范一致,避免后期大量修改

  4. 代码管理:将 mock 相关代码与业务代码分离,便于维护

  5. 性能考虑:避免生成过多数据,影响前端性能

  6. 测试覆盖:确保真实接口对接后,进行完整的回归测试

总结

Mock 数据是前端开发中的重要工具,它可以帮助我们提高开发效率,增强代码健壮性,改善团队协作。

「九九八十一难,难难皆是修行」

pgadmin的导出图实现,还在搞先美容后拍照再恢复?

作者 lyrieek
2026年2月9日 11:20

PostgreSQL18的pgadmin中有一个ERDTool.jsx有1132行,这个体量理论上说非常庞大,但做过现实工程的都知道,其实只能算重组件中的mini尺寸了。pgadmin功能并不算有多丰富,怎么还是做成这样呢,当然不是维护团队不会拆分,毕竟还是做了199个jsx的。

首先映入眼帘的是registerEvents对19个EventBus的监听,这东西让人倒吸一口凉气,其中最醒目的莫过于this.eventBus.registerListener(ERD_EVENTS.DOWNLOAD_IMAGE, this.onImageClick),名字就不对劲,实现能如何呢?那么看一下这个onImageClick()

onImageClick() {
    this.setLoading(gettext('Preparing the image...'));

    /* Move the diagram temporarily to align it to top-left of the canvas so that when
     * taking the snapshot all the nodes are covered. Once the image is taken, repaint
     * the canvas back to original state.
     * Code referred from - zoomToFitNodes function.
     */
    this.diagramContainerRef.current?.classList.add('ERDTool-html2canvasReset');
    const margin = 10;
    let nodesRect = this.diagram.getEngine().getBoundingNodesRect(this.diagram.getModel().getNodes());
    let linksRect = this.diagram.getBoundingLinksRect();

    // Check what is to the most top left - links or nodes?
    let topLeftXY = {
      x: nodesRect.getTopLeft().x,
      y: nodesRect.getTopLeft().y
    };
    if(topLeftXY.x > linksRect.TL.x) {
      topLeftXY.x = linksRect.TL.x;
    }
    if(topLeftXY.y > linksRect.TL.y) {
      topLeftXY.y = linksRect.TL.y;
    }
    topLeftXY.x -= margin;
    topLeftXY.y -= margin;

    let canvasRect = this.canvasEle.getBoundingClientRect();
    let canvasTopLeftOnScreen = {
      x: canvasRect.left,
      y: canvasRect.top
    };
    let nodeLayerTopLeftPoint = {
      x: canvasTopLeftOnScreen.x + this.diagram.getModel().getOffsetX(),
      y: canvasTopLeftOnScreen.y + this.diagram.getModel().getOffsetY()
    };
    let nodesRectTopLeftPoint = {
      x: nodeLayerTopLeftPoint.x + topLeftXY.x,
      y: nodeLayerTopLeftPoint.y + topLeftXY.y
    };

    let prevTransform = this.canvasEle.querySelector('div').style.transform;
    this.canvasEle.childNodes.forEach((ele)=>{
      ele.style.transform = `translate(${nodeLayerTopLeftPoint.x - nodesRectTopLeftPoint.x}px, ${nodeLayerTopLeftPoint.y - nodesRectTopLeftPoint.y}px) scale(1.0)`;
    });

    // Capture the links beyond the nodes as well.
    const linkOutsideWidth = linksRect.BR.x - nodesRect.getBottomRight().x;
    const linkOutsideHeight = linksRect.BR.y - nodesRect.getBottomRight().y;
    this.canvasEle.style.width = this.canvasEle.scrollWidth + (linkOutsideWidth > 0 ? linkOutsideWidth : 0) + margin + 'px';
    this.canvasEle.style.height = this.canvasEle.scrollHeight + (linkOutsideHeight > 0 ? linkOutsideHeight : 0) + margin + 'px';

    setTimeout(()=>{
      let width = this.canvasEle.scrollWidth + 10;
      let height = this.canvasEle.scrollHeight + 10;
      let isCut = false;
      /* Canvas limitation - https://html2canvas.hertzen.com/faq */
      if(width >= 32767){
        width = 32766;
        isCut = true;
      }
      if(height >= 32767){
        height = 32766;
        isCut = true;
      }
      toPng(this.canvasEle, {width, height, pixelRatio: this.state.preferences.image_pixel_ratio || 1})
        .then((dataUrl)=>{
          DownloadUtils.downloadBase64UrlData(dataUrl, `${this.getCurrentProjectName()}.png`);
        }).catch((err)=>{
          console.error(err);
          let msg = gettext('Unknown error. Check console logs');
          if(err.name) {
            msg = `${err.name}: ${err.message}`;
          }
          pgAdmin.Browser.notifier.alert(gettext('Error'), msg);
        }).then(()=>{
          /* Revert back to the original CSS styles */
          this.diagramContainerRef.current.classList.remove('ERDTool-html2canvasReset');
          this.canvasEle.style.width = '';
          this.canvasEle.style.height = '';
          this.canvasEle.childNodes.forEach((ele)=>{
            ele.style.transform = prevTransform;
          });
          this.setLoading(null);
          if(isCut) {
            pgAdmin.Browser.notifier.alert(gettext('Maximum image size limit'),
              gettext('The downloaded image has exceeded the maximum size of 32767 x 32767 pixels, and has been cropped to that size.'));
          }
        });
    }, 1000);
  }

这种百行级函数,存在合理性且不论,无论如何它都不应该叫xxClick了,毕竟谁敢相信它所有的代码都是为了完成一个导出png功能?

当然只笼统的说它完成了「一个功能」,那也是委屈它了,这函数实质上究竟做了什么?

  • 状态管理: setLoading。
  • DOM 劫持: 直接操作样式和类名。
  • 复杂的几何计算: 处理包围盒(Bounding Box)。
  • IO 操作: 生成图片并触发下载。
  • 异常处理: 恢复状态弹出 notifier 警告。

每一项都是焦点,如果换成Java,这段代码还能再套上10个trycatch膨胀到500行,当然如果换成Java必然能规矩许多,不至于如此粗糙。

当然这函数远不止违法单一原则那么简单,几何计算中 margin = 10 和屏幕坐标转换逻辑非常硬核且粗糙,几乎宣判了这块UI已经不可更改了,同时否定了缩放/偏移变化,非常容易出 off-by-one 错误,已经消耗极大了,不想做复杂只想简单实现也可以克隆DOM做一个离屏渲染,还不需要关心什么margin偏移。

toPng还是html-to-image的,这种场景用这个本身就如同儿戏,而且既然都做这么复杂了,哪怕直接再补上一套原生代码,手动绘制,全丢这函数里,不用任何库,这段代码也不会更丑了。

检测到图片到了浏览器 canvas 限制,就直接剪裁+警告,不做一个执行前popup确认和zoom,可以说有些不可理喻了,现实中这个警告几乎不可能弹出来,因为符合的这个逻辑时,其占用的原始内存将达到惊人的 4GB,做这种巨型 DOM 树时UI会进行密集的像素计算。而且计算是同步的,会直接锁死浏览器主线程!程序早已卡死,一行代码都别想执行了。

还有setTimeout为什么 1000ms?为什么不是 500 或 2000?这是典型的“等它渲染完”的 hack,因为修改 transform / width/height 后,浏览器需要时间重排/重绘,html2canvas才能捕获正确内容。用requestAnimationFrame 循环检查或MutationObserver / ResizeObserver来检测实际变化完成不好么?

最后还是回到名字上,一个函数如果叫“xx点击”,它就没有资格去负责“计算并导出32767像素的位图”。

不过综合来说,这东西整体上也算勉强还行了,毕竟它是一个最终节点组件,而且基本上不太可能被依赖,只是功能性问题,不像它旁边那个1560行有15个useEffect的ResultSet.jsx,那都不能叫组件了,那是试图给React塞一个子系统,等PostgreSQL20发布,估计就没人敢改它了。还有用1300行的FormInput.tsx管理着FormIcon、StyledGrid、FormInput、InputSQL、FormInputSQL等子组件的超级组件,这几乎是想做一套扩展UI库。

不要在简历上写精通 Vue3?来自面试官的真实劝退

作者 ErpanOmer
2026年2月9日 11:27

image.png

最近在面试,说实话,每次看到 精通 这俩字,我这心里就咯噔一下。不是我不信你,是这俩字太重了。这不仅仅是自信,这简直就是给面试官下战书😥。

你写 熟悉,我问你 API 怎么用,能干活就行。

你写 精通,那我身体里的胜负欲瞬间就被你点燃了:既然你都精通了,那咱们就别聊怎么写代码了,咱们聊聊尤雨溪写这行代码时在想啥吧😒。

结果呢?三个问题下去,我看对面兄弟的汗都下来了,我都不好意思再问。

今天真心给大伙提个醒,简历上这 精通 二字,就是个巨大的坑,谁踩谁知道。

来,我给你们复盘一下,什么叫面试官眼里的精通。

你别只背八股文

我上来通常先问个简单的热身:

Vue3 到底为啥要用 Proxy 换掉 Object.defineProperty?

大部分人张口就来:因为 defineProperty 监听不到数组下标,还监听不到对象新增属性。Proxy 啥都能拦,所以牛逼。

这话错没错?没错。

但这只是 60 分的回答,属于背诵全文🤔。

敢写精通的,你得这么跟我聊:

老哥,其实数组和新增属性那都是次要的。最核心的痛点是 性能,特别是初始化时候的性能。

Vue2 那个 defineProperty 是上来就得递归,把你对象里里外外每一层都给劫持了。对象一深,初始化直接卡顿。

Vue3 的 Proxy 是 惰性的。你访问第一层,我劫持第一层;你访问深层,我再临时去劫持深层。我不访问,我就不干活。

而且,这里面还有个 this 指向 的坑。Vue3 源码里用 Reflect.get 传了个 receiver 参数进去,就是为了保证有继承关系时,this 能指对地方,不然依赖收集就乱套了。

能力 Vue2(defineProperty) Vue3(Proxy)
监听对象新增/删除
监听数组索引/length
一次性代理整个对象
性能上限 ❌ 越大越慢 ✅ 更平滑
Map / Set ⚠️ 部分支持
实现复杂度

你要能说到 懒劫持Reflect 的 receiver 这一层,我才觉得你可能看过源码🙂‍↔️。

Diff 算法别光扯最长递增子序列

第二个问题,稍微上点强度:

Vue3 的 diff 算法快在哪?

别一上来就跟我背什么最长递增子序列,那只是最后一步。

你得从 编译阶段 开始聊。

Vue2 是个老实人,数据变了,它就把整棵树拿来从头比到尾,哪怕你那是个静态的写死的 div,它也要比一下。

Vue3 变聪明了,它搞了个 动静分离

在编译的时候,它就给那些会变的节点打上了标记,叫 PatchFlag。这个是文本变,那个是 class 变,都记好了。

等到真要 diff 的时候,Vue3 直接无视那些静态节点,只盯着带标记的节点看。

这就好比老师改卷子,以前是从头读到尾,现在是只看你改过的错题。这效率能一样吗?

这叫 靶向更新。能扯出这个词,才算摸到了 Vue3 的门道。

Ref 的那些坑说一说?

最后问个细节,看你平时踩没踩过坑:

Ref 在模板里不用写 .value,在 reactive 里也不用写。那为啥有时候在 Map 里又要写了呢?

很多人这就懵了:啊?不都是自动解包吗?

精通 的人会告诉我:

Vue 的自动解包是有底线的。

模板里那是亲儿子待遇,帮你解了。

reactive 对象里那是干儿子待遇,get 拦截器里帮你解了。

但是 MapSet 这种数据结构,Vue 为了保证语义不乱,是不敢乱动的。你在 Map 里存个 ref,取出来它还是个 ref,必须得手写 .value。👇

const count = ref(0)

const map = new Map()
map.set('count', count)

map.get('count')        // 拿到的是 ref 对象
map.get('count').value // 这是正确取值

Map / Set / WeakMap 不是 Vue 的响应式代理对象

这种细枝末节,没在真实项目里被毒打过,是很难注意到的。


面试其实就是一场 心理博弈

你写 精通,我对你的预期就是 行业顶尖。你答不上来,落差感太强,直接挂。

你写 熟练掌握 或者 有丰富实战经验,哪怕你答出上面这些深度的 50%,我都觉得这小伙子爱钻研,是个惊喜🥱。

在这个行业里,精通 真的不是终点,而是一个无限逼近的过程。

我自己写了这么多年代码,现在简历上也只敢写 熟练🤷‍♂️。

精通 换成 实战案例 吧,比如 我在项目中重写了虚拟列表,或者 我给 Vue 生态贡献过 PR

这比那两个干巴巴的汉字,有力一万倍。

听哥一句劝,Flag 别乱搞,Offer 自然就会来😒。

你们说呢?

Suggestion.gif

单点登录(SSO)系统

作者 Aniugel
2026年2月9日 11:07

一、整体架构设计(核心原则)

先明确整体流程和核心约束,确保 Cookies 仅存储在认证中心域名下:

deepseek_mermaid_20260209_441885.png

核心约束:

  • SSO 认证中心域名 下存储登录态 Cookie(如 sso_token);
  • 各业务系统前端不存储任何登录态 Cookie,仅在内存 /localStorage 存储临时业务 token;
  • 跨域登录态通过「授权码模式」传递,避免 Cookie 跨域问题。

二、各角色职责与提供的服务

1. 前端(Vue3):核心职责是「无 Cookie 登录态管理 + 跨域认证跳转」

核心职责
职责项 具体操作 技术实现
登录态检测 初始化时检测当前是否有有效业务 token,无则跳转认证中心 路由守卫(beforeEach)
认证跳转 拼接认证中心地址 + 业务系统回调地址,跳转至 SSO 登录页 动态拼接 URL 参数
授权码处理 认证中心重定向回业务系统时,解析 URL 中的授权码 URLSearchParams
临时 token 管理 存储业务后端返回的临时 token(内存 /localStorage),无 Cookie Pinia/Vuex + 内存变量
接口请求拦截 所有接口请求携带临时 token(Header 中),无 Cookie 传递 Axios 拦截器
登出处理 跳转认证中心登出接口,清除本地临时 token 跳转 SSO 登出地址 + 清除本地存储
前端提供的服务
  • 标准化的认证跳转组件(可复用的 SSO 登录跳转逻辑);
  • 统一的 token 管理工具(Pinia/Vuex 模块,封装 token 增删查);
  • 跨域认证回调处理页面(callback.vue);
  • 无 Cookie 的接口请求封装(Axios 拦截器)。

2. 后端:核心职责是「授权码校验 + 业务 token 生成 + 跨域认证接口」

核心职责(分「认证中心后端」和「业务系统后端」)
角色 职责项 具体操作
认证中心后端 登录接口 验证用户名密码,生成 sso_token,存储至认证中心 Cookie(仅本域名)
认证中心后端 授权码生成 验证业务系统合法性,生成一次性授权码,重定向回业务系统
认证中心后端 授权码校验 接收业务后端的校验请求,验证授权码有效性,返回 sso_token
认证中心后端 登出接口 清除认证中心 Cookie 中的 sso_token,并重定向至各业务系统登出页
业务系统后端 授权码兑换 接收前端的授权码,调用认证中心接口校验,获取 sso_token
业务系统后端 业务 token 生成 基于 sso_token 生成业务系统专属临时 token(JWT),返回前端
业务系统后端 接口鉴权 校验前端携带的业务 token,无 Cookie 校验
后端提供的服务
  • 认证中心:登录 / 登出 / 授权码生成 / 授权码校验接口;
  • 业务系统:授权码兑换接口、业务 token 校验接口、统一鉴权拦截器;
  • 跨域配置:允许业务系统前端跨域调用认证中心接口(CORS 配置);
  • 安全策略:Cookie 的 HttpOnly/Secure/SameSite 配置,防止 CSRF/XSS。

3. 运维:核心职责是「域名 / 网络配置 + 安全策略 + 部署运维」

核心职责
职责项 具体操作 技术实现
域名规划 独立的认证中心域名(如sso.yourdomain.com),与业务系统域名隔离 DNS 解析配置
HTTPS 配置 所有域名强制 HTTPS(Cookie 的 Secure 属性要求) Nginx 配置 + SSL 证书部署
跨域配置 Nginx 层面配置 CORS,允许业务系统跨域访问认证中心 Nginx 的 add_header Access-Control-*
Cookie 安全配置 确保认证中心 Cookie 仅在本域名生效,禁止跨域携带 Nginx / 后端双重配置 Cookie 属性
部署架构 认证中心服务高可用部署,业务系统与认证中心网络互通 负载均衡(LB)+ 集群部署
日志监控 监控认证中心登录 / 登出日志,排查跨域认证问题 ELK/Prometheus + Grafana
运维提供的服务
  • 独立的 SSO 认证中心域名及 SSL 证书部署;
  • 各业务系统域名与认证中心域名的 DNS 解析;
  • Nginx 层面的 HTTPS 强制跳转、CORS 配置、Cookie 安全配置;
  • 认证中心服务的高可用部署(集群 / 负载均衡);
  • 日志监控系统(认证中心登录日志、跨域访问日志);
  • 安全策略配置(WAF 防护、接口限流、Cookie 防篡改)。

三、关键安全注意事项

  1. 认证中心 Cookie 必须配置:HttpOnly=true(防止 XSS)、Secure=true(仅 HTTPS)、SameSite=Strict(禁止跨域携带);
  2. 授权码必须是一次性、短期有效(如 5 分钟),防止复用;
  3. 业务系统临时 token 建议短期有效(如 2 小时),前端定期静默刷新(调用业务后端刷新接口,再调用认证中心校验 sso_token);
  4. 所有接口必须 HTTPS,防止 token 明文传输。

总结

  1. 前端(Vue3) :核心是「无 Cookie 登录态管理」,通过路由守卫跳转认证中心,解析授权码兑换临时 token,接口请求携带 token(Header);
  2. 后端:认证中心负责生成 sso_token 并存储至自身 Cookie,业务系统负责校验授权码、生成业务临时 token;
  3. 运维:核心是域名隔离、HTTPS 配置、Cookie 安全策略,确保仅认证中心存储 Cookie,杜绝跨域 Cookie 风险。

用一个粒子效果告别蛇年迎来马年~

作者 苏武难飞
2026年2月9日 11:00

我们即将迎来马年,随手整了一个粒子切换效果,在这里分享给大家,本期功能实现主要是运用了Three.JS!

cover2

1.加载模型

这种物体的形状很难通过纯数学公式推导出来,所以我是在sketchfab上找的两个模型

20260208174832

20260208174916

这两个模型都是.glb类型的,在Three.JS中我们可以通过GLTFLoaderDRACOLoader很轻松的加载这种类型的模型文件!

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const gltf = await gltfLoader.loadAsync(path);
const model = gltf.scene;

关于DRACOLoader

简单来说,DRACOLoaderThree.js 中专门用来解压经过 Draco 压缩过的 3D 模型的“解压器”。

如果你在开发 WebGL 项目时发现模型文件(通常是 .gltf 或 .glb)太大,导致加载缓慢,你通常会使用 Google 开发的 Draco 算法 对模型进行压缩。而 DRACOLoader 就是为了让浏览器能读懂这些压缩数据而存在的。

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const modelFiles = [
    {path: '/snake_model.glb', scale: 8, position: {x: 0, y: 0, z: 0}},
    {path: '/horse.glb', scale: 18, position: {x: 0, y: -14, z: 0}}
];


for (const modelConfig of modelFiles) {
    try {
        const gltf = await gltfLoader.loadAsync(modelConfig.path);
        const model = gltf.scene;
        model.scale.set(modelConfig.scale, modelConfig.scale, modelConfig.scale);
        model.position.set(modelConfig.position.x, modelConfig.position.y, modelConfig.position.z);
        model.updateMatrixWorld(true);
        scene.add(model);

        console.log(`Loaded: ${modelConfig.path}`);
    } catch (error) {
        console.error(`Failed to load ${modelConfig.path}:`, error);
    }
}

20260209091146

2.模型粒子化

现在我们的两个模型已经成功加载,我们的模型粒子化的思路是拿到模型的顶点数据然后使用new THREE.Points来展示,所以我们先隐藏我们的模型文件

for (const modelConfig of modelFiles) {
    try {
        ...
        ...
      - scene.add(model);
      + model.visible = false;
    
    } catch (error) {
        
    }
}

2.1 MeshSurfaceSampler

MeshSurfaceSampler 是 Three.js 扩展库(three/examples/jsm/math/MeshSurfaceSampler.js)中的一个实用类。它通过加权随机算法,根据模型表面的几何面积分布,在三角形网格上提取随机点的坐标、法线以及颜色。

通俗的来说我们的模型是由许多个三角形组成的,MeshSurfaceSampler通过算法会判断三角形面积,如果更大的三角形则权重更多被分配的点也就更多!

举个栗子🌰

import { MeshSurfaceSampler } from 'three/examples/jsm/math/MeshSurfaceSampler.js';

// 1. 创建采样器
const sampler = new MeshSurfaceSampler(yourLoadedMesh)
    .setWeightAttribute('color') // 可选:如果有颜色属性,可以按颜色密度采样
    .build();

// 2. 采样循环
const tempPosition = new THREE.Vector3();
const tempNormal = new THREE.Vector3();

for (let i = 0; i < particleCount; i++) {
    sampler.sample(tempPosition, tempNormal);
    
    // 将采样到的位置存入数组或属性中
    positions.push(tempPosition.x, tempPosition.y, tempPosition.z);
}

2.2 合并Mash

从上面的例子我们能看到MeshSurfaceSampler接收的是一个单一的Mesh,但是我们的模型可能会包含多个Mesh,比如本次案例中的都是有两个Mesh,所以在使用MeshSurfaceSampler前我们需要把多个Mesh合并成一个!

BufferGeometryUtils.mergeGeometries 是 Three.js 扩展库 BufferGeometryUtils 中的一个静态方法。它的主要作用是将一组 BufferGeometry 合并成一个单一的几何体。

function getMergedMeshFromScene(scene) {
    const geometries = [];

    scene.updateMatrixWorld(true);

    scene.traverse((child) => {
        if (child.isMesh) {
            const clonedGeom = child.geometry.clone();
            clonedGeom.applyMatrix4(child.matrixWorld);
            for (const key in clonedGeom.attributes) {
                if (key !== 'position') clonedGeom.deleteAttribute(key);
            }
            geometries.push(clonedGeom);
        }
    });

    // 合并所有几何体
    const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
    return new THREE.Mesh(mergedGeometry);
}

2.3 展示粒子


function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;
    }

    return {positions};
}


 const modelData = generatePositionsFromModel(getMergedMeshFromScene(model), particleCount);
 modelDataArray.push(modelData);

现在我们已经有了模型的顶点坐标只需要使用THREE.Points配合THREE.PointsMaterial

function makeParticles(modelData) {

    const {positions} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    
    const material = new THREE.PointsMaterial({
        color: 0xffffff,      
        size: 0.5,             
        sizeAttenuation: true, 
        transparent: true,
        opacity: 0.8
    });

    return new THREE.Points(geometry, material);
}

const particles = makeParticles(modelDataArray[0]);
scene.add(particles);

20260209093343

我们的粒子小蛇就展示出来了,只不过现在这个粒子还很粗糙,我们会在后面优化~

3.粒子切换

现在我们的粒子已经成功展示!根据前面两步我们能知道粒子的展示就是根据模型的顶点来计算的,所以从一个模型切换到另一个模型就是单纯的顶点切换!

function beginMorph(index) {
    isTrans = true;
    prog = 0;

    const fromPts = new Float32Array(particles.geometry.attributes.position.array);
    const modelData = generatePositionsFromModel(getMergedMeshFromScene(rawModel[index]), particleCount);
    const toPts = new Float32Array(modelData.positions);

    particles.userData = {from: fromPts, to: toPts};
}

通过beginMorph我们把当前的粒子状态和目标状态存入到userData中,然后在tick中进行动画处理

const morphSpeed = .03;

const tick = () => {
    window.requestAnimationFrame(tick)
    controls.update()

    if (isTrans) {
        prog += morphSpeed;
        // 使用平滑的缓动函数
        const eased = prog >= 1 ? 1 : 1 - Math.pow(1 - prog, 3);

        const { from, to } = particles.userData;
        const particleArr = particles.geometry.attributes.position.array;

        for (let i = 0; i < particleArr.length; i++) {
            particleArr[i] = from[i] + (to[i] - from[i]) * eased;
        }
        // 通知 GPU 更新
        particles.geometry.attributes.position.needsUpdate = true;
        if (prog >= 1) isTrans = false;
    }


    renderer.render(scene, camera);
}

change

此时我们基础的粒子切换效果就已经实现啦!

4.粒子优化

此时我们的粒子效果还是存在几个问题的!

  • 大小固定/粒子是正方形
  • 没有颜色
  • 效果单调

要解决上面几个问题我们还使用THREE.PointsMaterial就有点不够看了,接下来我们使用THREE.ShaderMaterial搭配自定义着色器来优化效果!

4.1 大小随机化/粒子改为圆形

我们想让粒子的大小产生一个随机变化就要考虑通过顶点着色器中gl_PointSize来随机改变粒子大小!粒子改为圆形就要在片元着色器中修改gl_FragColor!

function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);
    const sizes = new Float32Array(totalCount);
    const rnd = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sizes[i] = .7 + Math.random() * 1.1;
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;

        rnd[i3] = Math.random() * 10;
        rnd[i3 + 1] = Math.random() * Math.PI * 2;
        rnd[i3 + 2] = .5 + .5 * Math.random();
    }

    return {positions, sizes, rnd};
}

首先修改generatePositionsFromModel方法,针对每个顶点坐标产生一组随机数范围在.7 ~ .77

function makeParticles(modelData) {

    const {positions, sizes, rnd} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
    geometry.setAttribute("random", new THREE.BufferAttribute(rnd, 3));

    const material = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}, hueSpeed: {value: 0.12}},
        vertexShader: ..., 
        fragmentShader: ...,
        transparent: true, 
        depthWrite: false, 
        vertexColors: true, 
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geometry, material);
}
uniform float time;
attribute float size;
attribute vec3 random;
varying vec3 vCol;
varying float vR;
void main(){
    vec3 p=position;
    vec4 mv=modelViewMatrix*vec4(p,1.);
    float pulse=.9+.1*sin(time*1.15+random.y);
    gl_PointSize=size*pulse*(350./-mv.z);
    gl_Position=projectionMatrix*mv;
}
uniform float time;
void main() {
    float d = length(gl_PointCoord - vec2(0.5));
    float alpha = 1.0 - smoothstep(0.4, 0.5, d);
    if (alpha < 0.01) discard;
    gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
}

20260209100331

此时的粒子就大小改为随机并且是圆形粒子了~

4.2 粒子添加颜色

粒子添加颜色和上一步的粒子大小类似都需要针对每一个顶点生成一个随机的颜色

const palette = [0xff3c78, 0xff8c00, 0xfff200, 0x00cfff, 0xb400ff, 0xffffff, 0xff4040].map(c => new THREE.Color(c));

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        ...
        ...

        const base = palette[Math.random() * palette.length | 0], hsl = {h: 0, s: 0, l: 0};
        base.getHSL(hsl);
        hsl.h += (Math.random() - .5) * .05;
        hsl.s = Math.min(1, Math.max(.7, hsl.s + (Math.random() - .5) * .3));
        hsl.l = Math.min(.9, Math.max(.5, hsl.l + (Math.random() - .5) * .4));

        const c = new THREE.Color().setHSL(hsl.h, hsl.s, hsl.l);
        colors[i3] = c.r;
        colors[i3 + 1] = c.g;
        colors[i3 + 2] = c.b;

        ...
    }

修改片元着色器

uniform float time;
uniform float hueSpeed;
varying vec3 vCol;
varying float vR;

vec3 hueShift(vec3 c, float h) {
    const vec3 k = vec3(0.57735);
    float cosA = cos(h);
    float sinA = sin(h);
    return c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA);
}

void main() {
    vec2 uv = gl_PointCoord - 0.5;
    float d = length(uv);

    float core = smoothstep(0.05, 0.0, d);
    float angle = atan(uv.y, uv.x);
    float flare = pow(max(0.0, sin(angle * 6.0 + time * 2.0 * vR)), 4.0);
    flare *= smoothstep(0.5, 0.0, d);
    float glow = smoothstep(0.4, 0.1, d);

    float alpha = core * 1.0 + flare * 0.5 + glow * 0.2;

    vec3 color = hueShift(vCol, time * hueSpeed);
    vec3 finalColor = mix(color, vec3(1.0, 0.95, 0.9), core);
    finalColor = mix(finalColor, color, flare * 0.5 + glow * 0.5);

    if (alpha < 0.01) discard;

    gl_FragColor = vec4(finalColor, alpha);
}

20260209100747

4.3 设置亮度差

现在我们的粒子看着还是略显单调!我们可以给粒子做局部提亮!


function createSparkles() {

    const geo = new THREE.BufferGeometry();
    const pos = new Float32Array(particleSparkCount * 3);
    const size = new Float32Array(particleSparkCount);
    const rnd = new Float32Array(particleSparkCount * 3);

    for (let i = 0; i < particleSparkCount; i++) {
        size[i] = 0.5 + Math.random() * 0.8;
        rnd[i * 3] = Math.random() * 10;
        rnd[i * 3 + 1] = Math.random() * Math.PI * 2;
        rnd[i * 3 + 2] = 0.5 + 0.5 * Math.random();
    }
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    geo.setAttribute('size', new THREE.BufferAttribute(size, 1));
    geo.setAttribute('random', new THREE.BufferAttribute(rnd, 3));

    const mat = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}},
        vertexShader: `
            uniform float time;
            attribute float size;
            attribute vec3 random;
            void main() {
                vec3 p = position;
                float t = time * 0.25 * random.z;
                float ax = t + random.y, ay = t * 0.75 + random.x;
                float amp = (0.6 + sin(random.x + t * 0.6) * 0.3) * random.z;
                p.x += sin(ax + p.y * 0.06 + random.x * 0.1) * amp;
                p.y += cos(ay + p.z * 0.06 + random.y * 0.1) * amp;
                p.z += sin(ax * 0.85 + p.x * 0.06 + random.z * 0.1) * amp;
                vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
                gl_PointSize = size * (300.0 / -mvPosition.z);
                gl_Position = projectionMatrix * mvPosition;
            }`,
        fragmentShader: `
            uniform float time;
            void main() {
                float d = length(gl_PointCoord - vec2(0.5));
                float alpha = 1.0 - smoothstep(0.4, 0.5, d);
                if (alpha < 0.01) discard;
                gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
            }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geo, mat);
}

const particlesSpark = createSparkles(modelDataArray[0])
scene.add(particlesSpark);


const targetPositions = modelDataArray[0].positions;

const particleArr = particles.geometry.attributes.position.array;
const sparkleArr = particlesSpark.geometry.attributes.position.array;

for (let j = 0; j < particleCount; j++) {
    const idx = j * 3;

    // 直接从 targetPositions 拷贝三个连续的数值 (x, y, z)
    particleArr[idx] = targetPositions[idx];
    particleArr[idx + 1] = targetPositions[idx + 1];
    particleArr[idx + 2] = targetPositions[idx + 2];

    // 同步更新闪烁粒子
    if (j < particleSparkCount) {
        sparkleArr[idx] = targetPositions[idx];
        sparkleArr[idx + 1] = targetPositions[idx + 1];
        sparkleArr[idx + 2] = targetPositions[idx + 2];
    }
}

// 必须通知 GPU 更新
particles.geometry.attributes.position.needsUpdate = true;
particlesSpark.geometry.attributes.position.needsUpdate = true;

20260209102034转存失败,建议直接上传图片文件

我们又添加了一个createSparkles然后粒子位置和最开始的模型粒子位置一致,只不过颜色我们设置成白色!但是到这还没结束!我们的提亮魔法还要依靠THREE的后期处理能力!

EffectComposerThree.js 的 后期处理(Post-processing)管理器。它负责管理一个“通道(Pass)”队列。它不再直接将场景渲染到画布上,而是渲染到一个或多个缓冲帧中,经过各种视觉特效处理后,再呈现给用户。

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), .45, .5, .85));
const after = new AfterimagePass();
after.uniforms.damp.value = .92;
composer.addPass(after);
composer.addPass(new OutputPass());
  • UnrealBloomPass 是用来做荧光、发光的效果
  • AfterimagePass 是用来做拖尾影效果

结束语

希望所有人 2026 事事如意!

参考代码

Three.js & GLSL Particle Metamorphosis

移动端H5项目,还需要react-fastclick解决300ms点击延迟吗?

作者 鹏多多
2026年2月9日 10:57

今天整理旧项目的时候发现,在之前开发React移动端项目时,总会习惯性引入react-fastclick来处理点击延迟问题。但这次的项目是React18搭配Vite5的技术栈,突然产生了一个疑问:在当前的技术环境下,使用现代浏览器,移动端项目还需要依赖react-fastclick来解决300ms点击延迟吗?带着这个疑问,我整理了相关知识点,和大家一起探讨一下。

1. 300ms 延迟的来源

要弄清楚是否需要react-fastclick,首先得回顾一下300ms点击延迟的由来。300ms延迟并不是移动端浏览器的bug,而是早期移动浏览器(主要是旧版iOS Safari / Android WebView)为了适配用户操作而设计的机制:

  • 核心原因:为了判断用户的点击操作是否是「双击缩放」(双击页面可以放大显示内容,这是早期移动端的核心交互之一)。
  • 延迟表现:浏览器在检测到用户的一次click事件后,会强制等待约300ms,确认用户没有进行第二次点击后,才会真正触发click事件。

正因为这个300ms的等待时间,导致早期移动端H5页面的点击操作总会有明显的延迟感,影响用户体验,这也是FastClick类库诞生的原因——通过绕过浏览器的这个等待机制,消除300ms延迟。


2. 现代浏览器已默认移除 300ms 延迟

随着移动端技术的发展,「双击缩放」的使用场景越来越少,且用户对交互流畅度的要求越来越高,主流移动端浏览器早已默认移除了300ms点击延迟,具体支持情况如下:

✅ iOS 环境

  • iOS 9及以上版本的Safari浏览器
  • 所有基于WKWebView的内嵌页面(目前绝大多数iOS App的内嵌H5都采用WKWebView)

✅ Android 环境

  • Chrome 32及以上版本
  • Android系统自带的WebView(Android 4.4及以上版本基本都已支持)

需要注意的是,浏览器移除300ms延迟需要满足两个简单条件,而这两个条件在React18+Vite5项目中几乎是默认配置:

条件1:正确设置viewport

这是最基础的条件,只要在HTML头部设置了正确的viewport标签,浏览器就会认为页面已适配移动端,无需通过双击缩放来优化显示:

<meta name="viewport" content="width=device-width, initial-scale=1">

重点说明:Vite + React的默认模板中,已经自带了这个viewport配置,无需我们额外手动添加。

条件2:禁止双击缩放(或页面已完全适配)

如果页面不需要支持双击缩放,只需在viewport标签中添加相关配置,即可彻底杜绝浏览器的双击缩放判断,进一步确保无延迟:

<meta
  name="viewport"
  content="width=device-width, initial-scale=1, user-scalable=no"
/>

或通过限制最大缩放比例来实现:

<meta
  name="viewport"
  content="width=device-width, initial-scale=1, maximum-scale=1"
/>

实际上,现在大多数移动端H5项目都会添加上述配置,一方面是为了保证页面适配一致性,另一方面也间接消除了300ms延迟的可能。


3. FastClick / react-fastclick 已过时

既然现代浏览器已经默认移除了300ms延迟,那么FastClick及其React封装版react-fastclick,不仅不再必要,反而可能成为项目的负担。

官方态度:项目已停止维护

FastClick的作者早在2016年后就明确表示:「Modern browsers don’t have 300ms delay anymore.」(现代浏览器已不再有300ms延迟)。此后,FastClick项目基本停止维护,不再适配新的浏览器版本和前端技术栈。

React 18 项目中的潜在问题

在React 18项目中强行引入react-fastclick,不仅无法带来收益,还可能引发一系列兼容性问题,具体如下:

  • ❌ 事件重复触发:react-fastclick的实现机制与React 18的合成事件体系存在冲突,可能导致点击事件被触发两次(比如一次由react-fastclick触发,一次由浏览器原生触发)。
  • ❌ 与Pointer Events不兼容:React 18已全面支持Pointer Events(统一处理鼠标、触摸、笔等输入事件),而react-fastclick未适配该特性,可能导致事件监听异常。
  • ❌ iOS偶发点击穿透:在部分iOS设备上,react-fastclick可能导致点击穿透问题(点击上层元素,却触发了下层元素的点击事件),影响交互体验。

综合来看,在React 18+Vite5项目中使用react-fastclick,完全是「风险大于收益」的操作。


4. 现代解决方案

虽然大多数情况下,只要保证viewport配置正确,就不会有300ms延迟,但如果你的项目需要适配一些特殊环境(比如老旧WebView、小众国产浏览器),或者确实感受到了点击延迟,可以尝试以下现代解决方案,比react-fastclick更安全、更高效。

✅ 4.1. 使用 touch / pointer 事件

React 18已全面支持Pointer Events,该事件的优先级高于原生click事件,无需等待300ms,可实现零延迟点击。使用方式非常简单,只需将onClick替换为onPointerDown或onPointerUp即可:

<button onPointerDown={handleClick}>点击按钮</button>

注意:优先使用onPointerDown(触摸开始时触发),响应速度最快;如果需要避免“误触”,也可以使用onPointerUp(触摸结束时触发)。


✅ 4.2. 使用 CSS touch-action(推荐)

这是最推荐的解决方案,通过CSS的touch-action属性,直接告诉浏览器该元素的触摸行为,禁止不必要的手势判断,从而消除延迟。

针对可点击元素(如按钮、链接),添加如下CSS:

button {
  touch-action: manipulation;
}

touch-action: manipulation的作用:

  • 禁止双击缩放、双指缩放等手势操作;
  • 告诉浏览器该元素仅用于点击交互,无需等待300ms判断手势,直接派发click事件。

如果项目中可点击元素较多,也可以全局设置(仅对可点击元素生效,不影响页面滚动):

* {
  touch-action: manipulation;
}

✅ 4.3. 避免 300ms 的错误姿势

除了上述方案,还要注意避免一些可能间接导致点击延迟的错误写法,比如:

// ❌ 错误:同时使用onTouchStart和onClick
<button onTouchStart={handleClick} onClick={handleClick}>点击按钮</button>

这种写法会导致触摸时触发一次handleClick,300ms后(如果浏览器有延迟)又触发一次onClick,不仅会出现“延迟感”,还可能导致逻辑异常,务必避免。


5. 最终结论与可直接使用的模板

对于 React 18 + Vite 5 的移动端项目:

  • ✅ 不需要引入 react-fastclick
  • ❌ 不推荐使用任何版本的 fastclick
  • ✅ 只要保证 viewport 配置正确,就能消除绝大多数场景的300ms延迟
  • ✅ 优化CSS的touch-action配置,可兼容所有极端环境

当前配置达标情况(参考)

项目 是否达标
viewport配置 ✅(Vite默认配置)
禁止缩放 ✅(添加max-scale=1或user-scalable=no即可)
click延迟 基本无(主流浏览器)
兼容性 ⚠️ 中等(需优化touch-action配置)
极端环境 可能残留(优化后可解决)

推荐最终模板(可直接复制使用)

整合所有最佳实践,提供可直接套用的HTML和CSS模板,确保项目无点击延迟、兼容性拉满:

5.1. HTML viewport 配置

<meta
  name="viewport"
  content="width=device-width,
           initial-scale=1,
           maximum-scale=1,
           user-scalable=no,
           viewport-fit=cover"
/>

说明:viewport-fit=cover用于适配iPhone刘海屏,避免页面被刘海遮挡,不影响点击延迟。

5.2. CSS 配置

html,
body {
  -webkit-user-drag: none;
  touch-action: pan-y;
}

button,
a,
[role='button'] {
  touch-action: manipulation;
}

6. 总结

回到最初的疑问:React18+Vite5移动端项目,还需要react-fastclick吗?答案很明确——不需要。

现代浏览器早已解决了300ms点击延迟的问题,而React18+Vite5的默认配置(正确的viewport)已经满足了浏览器消除延迟的条件。react-fastclick作为过时的类库,不仅多余,还可能引发兼容性问题。

我们只需要做好两件事:一是保证viewport配置正确,二是优化CSS的touch-action属性,就能轻松实现零延迟的移动端点击交互。如果遇到极端环境的延迟问题,再辅以Pointer Events,就能完美解决所有场景。

希望这篇整理能帮到有同样疑问的同学,避免在新项目中引入不必要的依赖,让项目更轻量、更稳定。

本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

端云一体 一天开发的元服务-奇趣故事匣经验分享

作者 万少
2026年2月9日 10:51

端云一体 一天开发的元服务-奇趣故事匣经验分享

万少:华为HDE、鸿蒙极客

个人主页:blog.zbztb.cn/

2025年参与孵化了20+鸿蒙应用、技术文章300+、鸿蒙知识库用户500+、鸿蒙免费课程2套。

如果你也喜欢交流AI和鸿蒙技术,欢迎扣我。

前言

学习技术最好的做法就是应用技术,带着这个目标。

我这次打算深度使用华为-鸿蒙的端云一体,做一个完整的作品 奇趣故事匣

先看成果。


奇趣故事匣其实在25年的时候就已经上架了成了一个元服务

那时候的是传统架构

  1. java后端
  2. 鸿蒙前端

因为是传统后端开发的方式,所以需要一定的后端能力:

  1. 后端编程语言
  2. 服务器-域名-运维等

这个过程还是很耗人的,尤其对于一个初学者来说。

端云一体的优势

云开发(Serverless)是一种由云端平台提供服务器和环境、让开发者只需关注业务逻辑的按需服务架构,具备零运维、弹性伸缩、安全可靠等优势,并支持端云一体化开发。

总的来说,开发者只需要在两个窗口内基本就可以完成整个应用的开发。

环境一览

  1. AGC平台-管理云端资源
  2. DevEco Studio 实现全链路开发


其中关于云端的调试,DevEco Studio中也提供了调试面板

端云一体的概念详解

认证服务:助力应用快速构建安全可靠的用户认证系统。 云函数: 提供Serverless化的代码开发与运行平台。 云数据库:提供端云数据的协同管理。 云缓存:为云函数提供Key-Value型高速缓存。 云存储:助力应用存储图片、音频、视频等内容,并提供高品质的上传、下载、分享能力。 云监控:提供云开发服务的运行指标、日志和告警,助力实时洞察服务运行状态。 API网关:一个API开放平台,支持对多种API源的全生命周期管理。 云托管:提供网站的托管和静态CDN加速。 云应用引擎:提供包括部署、运行、运维在内的一站式应用托管方案。


其中最基本的云函数云数据库云存储足够支撑起一个差不多的应用了。

经验分享

过程中确实还是少不了一些问题

  1. 端云一体的环境,但是数据库、数据表以及它们的关联,其实都需要自己先去设计。
  2. 那另外比如我们所做的这个故事类的应用,它有一些故事,然后一些图片,可能还包括一些视频等等,这些素材其实也需要自己去设计的。
  3. 过程当中还有不少是关于一些权限的问题,就是你怎么样把本地设计好的一些数据表同步到这个AGC平台上。
  4. 如果是应用内用户产生的数据,它又是否有什么权限,然后可以同步上去,这一块其实都要踩一些坑
  5. 最后再说一些场面话,我们学技术其实最好的方式就是做一些产品,这个踩坑其实是必然的。过后呢,我们相信再做第二个第三个应用之前的一些坑就会变成我们实际的开发经验了。

开发过程中的技术分享

25年、26年已经有苗头都在宣传 一个人公司,所以作为独立的个体只要是关于提高生产力的,可能都需要了解包括使用。

这里分享一下我的开发 配套技术:

  1. 使用了海外版本的Trae+Gemini3-Pro完成目前功能的开发
  2. 使用了智能体、skill、rule、mcp等主流AI技术
  3. 使用了HarmonyOS自动构建脚本
  4. 使用的是元服务+V2状态管理+Navigation
  5. 使用了AI脚本批量生产素材

这会的成功

此时的时间是2026年2月7日22:35:25,边聊天的时候我也给这个应用接入了用户系统了。

总结

  1. AI时代,要学会工具来提效
  2. 鸿蒙的端云使用很方便,值得一试

参考链接

  1. 端云一体

    developer.huawei.com/consumer/cn…

  2. 端云一体教程

    blog.zbztb.cn/鸿蒙开发技巧/Harm…

关注我,持续分享鸿蒙开发 + AI 提效的实战技巧。

从零实现富文本编辑器#11-Immutable状态维护与增量渲染

作者 WindRunnerMax
2026年2月9日 10:48

在先前我们讨论了视图层的适配器设计,主要是全量的视图初始化渲染,包括生命周期同步、状态管理、渲染模式、DOM映射状态等。在这里我们需要处理变更的增量更新,这属于性能方面的考量,需要考虑如何实现不可变的状态对象,以此来实现Op操作以及最小化DOM变更。

从零实现富文本编辑器系列文章

行级不可变状态

在这里我们先不引入视图层的渲染问题,而是仅在Model层面上实现精细化的处理,具体来说就是实现不可变的状态对象,仅更新的节点才会被重新创建,其他节点则直接复用。由此想来此模块的实现颇为复杂,也并未引入immer等框架,而是直接处理的状态对象,因此先从简单的更新模式开始考虑。

回到最开始实现的State模块更新文档内容,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。

delta.eachLine((line, attributes, index) => {
  const lineState = new LineState(line, attributes, this);
  lineState.index = index;
  lineState.start = offset;
  lineState.key = Key.getId(lineState);
  offset = offset + lineState.length;
  this.lines[index] = lineState;
});

这种方式简单直接,全量更新状态能够保证在React的状态更新,然而这种方式的问题在于性能。当文档内容非常大的时候,全量计算将会导致大量的状态重建,并且其本身的改变也会导致Reactdiff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。

那么通常来说我们就需要基于变更来确定状态的更新,首先我们需要确定更新的粒度,例如以行为基准则未变更的时候就直接取原有的LineState。相当于尽可能复用Origin List然后生成Target List,这样的方式自然可以避免部分状态的重建,尽可能复用原本的对象。

整体思路大概是先执行变成生成最新的列表,然后分别设置旧列表和新列表的rowcol两个指针值,然后更新时记录起始row,删除和新增自然是正常处理,对于更新则认为是先删后增。对于内容的处理则需要分别讨论单行和跨行的问题,中间部分的内容就作为重建的操作。

最后可以将这部分增删LineState数据放置于Changes中,就可以得到实际增删的Ops了,这样我们就可以优化部分的性能,因为仅原列表和目标列表的中间部分才会重建,其他部分的行状态直接复用。此外这部分数据在applydelta中是不存在的,同样可以认为是数据的补充。

  Origin List (Old)                          Target List (New)
+-------------------+                      +-------------------+
| [0] LineState A   | <---- Retain ------> | [0] LineState A   | (Reused)
+-------------------+                      +-------------------+
| [1] LineState B   |          |           | [1] LineState B2  | (Update)
+-------------------+       Changes        |     (Modified)    | (Del C)
| [2] LineState C   |          |           +-------------------+
+-------------------+          V           | [2] NewState X    | (Inserted)
| [3] LineState D   | ---------------\     +-------------------+
+-------------------+                 --> | [3] LineState D   | (Reused)
| [4] LineState E   | <---- Retain ------> | [4] LineState E   | (Reused)
+-------------------+                      +-------------------+

那么这里实际上是存在非常需要关注的点,我们现在维护的是状态模型,也就是说所有的更新就不再是直接的compose,而是操作我们实现的状态对象。本质上我们是需要实现行级别的compose方法,这里的实现非常重要,假如我们对于数据的处理存在偏差的话,那么就会导致状态出现问题。

此外在这种方式中,我们判断LineState是否需要新建则是根据整个行内的所有LeafState来重建的。也就是说这种时候我们是需要再次将所有的op遍历一遍,当然实际上由于最后还需要将compose后的Delta切割为行级别的内容,所以其实即使在应用变更后也最少需要再遍历两次。

那么此时我们需要思考优化方向,首先是首个retain,在这里我们应该直接完整复用原本的LineState,包括处理后的剩余节点也是如此。而对于中间的节点,我们就需要为其独立设计更新策略,这部分理论上来说是需要完全独立处理为新的状态对象的,这样可以减少部分Leaf Op的遍历。

new Delta().retain(5).insert("xx")
insert("123"), insert("\n") // skip 
insert("456"), insert("\n") // new line state

其中,如果是新建的节点,我们直接构建新的LineState即可,删除的节点则不从原本的LineState中放置于新的列表。而对于更新的节点,我们需要更新原本的LineState对象,因为实际上行是存在更新的,而重点是我们需要将原本的LineStatekey值复用。

这里我们先简单实现实现描述一下复用的问题,比较方便的实现则是直接以\n的标识为目标的State,这就意味着我们要独立\n为独立的状态。即如果在123|456\n|位置插入\n的话,那么我们就是123是新的LineState456是原本的LineState,以此来实现key的复用。

[
  insert("123"), insert("\n"), 
  insert("456"), insert("\n")
]
// ===>
[ 
  LineState(LeafState("123"), LeafState("\n")), 
  LineState(LeafState("456"), LeafState("\n"))
]

其实这里有个非常值得关注的点是,LineStateDelta中是没有具体对应的Op的,而相对应的LeafState则是有具体的Op的。这就意味着我们在处理LineState的更新时,是不能直接根据变更控制的,因此必须要找到能够映射的状态,因此最简单的方案即根据\n节点映射。

LeafState("\n", key="1") <=> LineState(key="L1")

实际上我们可以总结一下,最开始我们考虑先更新再diff,后来考虑的是边更新边记录。边更新边记录的优点在于,可以避免再次遍历一边所有Leaf节点的消耗,同时也可以避免diff的复杂性。但是这里也存在个问题,如果内部进行了多次retain操作,则无法直接复用LineState

不过通常来说,最高频的操作是输入内容,这种情况下首操作一般都是retain,尾操作为空会收集剩余文档内容,因此这部分优化是会被高频触发的。而如果是多次的内容部分变更操作,这部分虽然可以通过判断行内的叶子结点是否变更,来判断是否复用行对象,但是也存在一定复杂性。

关于这部分的具体实现,在编辑器的状态模块里存在独立的Mutate模块,这部分实现在后边实现各个模块时会独立介绍。到这里我们就可以实现一个简单的Immutable状态维护,如果Leaf节点发生变化之后,其父节点Line会触发更新,而其他节点则可以直接复用。

Key 值维护

至此我们实现了一套简单的Immutable Delta+Iterator来处理更新,这种时候我们就可以借助不可变的方式来实现React视图的更新,那么在React的渲染模式中,key值的管理也是个值的探讨的问题。

在这里我们就可以根据状态不可变来生成key值,借助WeakMap映射关系获取对应的字符串id值,此时就可以借助key的管理以及React.memo来实现视图的复用。其实在这里初步看起来key值应该是需要主动控制强制刷新的时候,以及完全是新节点才会用得到的。

但是这种方式也是有问题的,因为此时我们即使输入简单的内容,也会导致整个行的key发生改变,而此时我们是不必要更新此时的key的。因此key值是需要单独维护的,不能直接使用不可变的对象来索引key值,那么如果是直接使用index作为key值的话,就会存在潜在的原地复用问题。

key值原地复用会导致组件的状态被错误保留,例如此时有个非受控管理的input组件列表,在某个输入框内已经输入了内容,当其发生顺序变化时,原始输入内容会跟随着原地复用的策略留在原始的位置,而不是跟随到新的位置,因为其整体列表顺序key未发生变化导致React直接复用节点。

LineState节点的key值维护中,如果是初始值则是根据state引用自增的值,在变更的时候则是尽可能地复用原始行的key,这样可以避免过多的行节点重建并且可以控制整行的强制刷新。

而对于LeafState节点的key值最开始是直接使用index值,这样实际上会存在隐性的问题,而如果直接根据Immutable来生成key值的话,任何文本内容的更改都会导致key值改变进而导致DOM节点的频繁重建。

export const NODE_TO_KEY = new WeakMap<Object.Any, Key>();
export class Key {
  /** 当前节点 id */
  public id: string;
  /** 自动递增标识符 */
  public static n = 0;

  constructor() {
    this.id = `${Key.n++}`;
  }

  /**
   * 根据节点获取 id
   * @param node
   */
  public static getId(node: Object.Any): string {
    let key = NODE_TO_KEY.get(node);
    if (!key) {
      key = new Key();
      NODE_TO_KEY.set(node, key);
    }
    return key.id;
  }
}

通常使用index作为key是可行的,然而在一些非受控场景下则会由于原地复用造成渲染问题,diff算法导致的性能问题我们暂时先不考虑。在下面的例子中我们可以看出,每次我们都是从数组顶部删除元素,而实际的input值效果表现出来则是删除了尾部的元素,这就是原地复用的问题。在非受控场景下比较明显,而我们的ContentEditable组件就是一个非受控场景,因此这里的key值需要再考虑一下。

const { useState, Fragment, useRef, useEffect } = React;
function App() {
  const ref = useRef<HTMLParagraphElement>(null);
  const [nodes, setNodes] = useState(() => Array.from({ length: 10 }, (_, i) => i));

  const onClick = () => {
    const [_, ...rest] = nodes;
    console.log(rest);
    setNodes(rest);
  };

  useEffect(() => {
    const el = ref.current;
    el && Array.from(el.children).forEach((it, i) => ((it as HTMLInputElement).value = i + ""));
  }, []);

  return (
    <Fragment>
      <p ref={ref}>
        {nodes.map((_, i) => (<input key={i}></input>))}
      </p>
      <button onClick={onClick}>slice</button>
    </Fragment>
  );
}

考虑到先前提到的我们不希望任何文本内容的更改都导致key值改变引发重建,因此就不能直接使用计算的immutable对象引用来处理key值,而描述单个op的方法除了insert就只剩下attributes了。

但是如果基于attributes来获得就需要精准控制合并insert的时候取需要取旧的对象引用,且没有属性的op就不好处理了,因此这里可能只能将其转为字符串处理,但是这样同样不能保持key的完全稳定,因此前值的索引改变就会导致后续的值出现变更。

const prefix = new WeakMap<LineState, Record<string, number>>();
const suffix = new WeakMap<LineState, Record<string, number>>();
const mapToString = (map: Record<string, string>): string => {
  return Object.keys(map)
    .map(key => `${key}:${map[key]}`)
    .join(",");
};
const toKey = (state: LineState, op: Op): string => {
  const key = op.attributes ? mapToString(op.attributes) : "";
  const prefixMap = prefix.get(state) || {};
  prefix.set(state, prefixMap);
  const suffixMap = suffix.get(state) || {};
  suffix.set(state, suffixMap);
  const prefixKey = prefixMap[key] ? prefixMap[key] + 1 : 0;
  const suffixKey = suffixMap[key] ? suffixMap[key] + 1 : 0;
  prefixMap[key] = prefixKey;
  suffixMap[key] = suffixKey;
  return `${prefixKey}-${suffixKey}`;
};

slate中我先前认为生成的key跟节点是完全一一对应的关系,例如当A节点变化时,其代表的层级key必然会发生变化。然而在关注这个问题之后,我发现其在更新生成新的Node之后,会同步更新Path以及PathRef对应的Node节点所对应的key值。

for (const [pathRef, key] of pathRefMatches) {
  if (pathRef.current) {
    const [node] = Editor.node(e, pathRef.current)
    NODE_TO_KEY.set(node, key)
  }
  pathRef.unref()
}

在后续观察Lexical实现的选区模型时,发现其是用key值唯一地标识每个叶子结点的,选区也是基于key值来描述的。整体表达上比较类似于Slate的选区结构,或者说是DOM树的结构。这里仅仅是值得Range选区,Lexical实际上还有其他三种选区类型。

{
  anchor: { key: "51", offset: 2, type: "text" },
  focus: { key: "51", offset: 3, type: "text" }
}

在这里比较重要的是key值变更时的状态保持,因为编辑器的内容实际上是需要编辑的。然而如果做到immutable话,很明显直接根据状态对象的引用来映射key会导致整个编辑器DOM无效的重建。例如调整标题的等级,就由于整个行key的变化导致整行重建。

那么如何尽可能地复用key值就成了需要研究的问题,我们的编辑器行级别的key是被特殊维护的,即实现了immutable以及key值复用。而目前叶子状态的key依赖了index值,因此如果调研Lexical的实现,同样可以将其应用到我们的key值维护中。

通过在playground中调试可以发现,即使我们不能得知其是否为immutable的实现,依然可以发现Lexicalkey是以一种偏左的方式维护。因此在我们的编辑器实现中,也可以借助同样的方式,合并直接以左值为准复用,拆分时若以0起始直接复用,起始非0则创建新key

  1. [123456(key1)][789(bold-key2)]文本,将789的加粗取消,整段文本的key值保持为key1
  2. [123456789(key1)]]文本,将789这段文本加粗,左侧123456文本的key值保持为key1789则是新的key
  3. [123456789(key1)]]文本,将123这段文本加粗,左侧123文本的key值保持为key1456789则是新的key
  4. [123456789(key1)]]文本,将456这段文本加粗,左侧123文本的key值保持为key1456789分别是新的key

因此,此时在编辑器中我们也是用类似偏左的方式维护key,由于我们需要保持immutable,所以这里的表达实际上是尽可能复用先前的key状态。这里与LineStatekey值维护方式类似,都是先创建状态然后更新其key值,当然还有很多细节的地方需要处理。

// 起始与裁剪位置等同 NextOp => Immutable 原地复用 State
if (offset === 0 && op.insert.length <= length) {
  return nextLeaf;
}
const newLeaf = new LeafState(retOp, nextLeaf.parent);
// 若 offset 是 0, 则直接复用原始的 key 值
offset === 0 && newLeaf.updateKey(nextLeaf.key);

这里还存在另一个小问题,我们创建LeafState就立即去获得对应的key值,然后再考虑去复用原始的key值。这样其实就会导致很多不再使用的key值被创建,导致每次更新的时候看起来key的数字差值比较大。当然这并不影响整体的功能与性能,只是调试的时候看起来比较怪。

因此我们在这里还可以优化这部分表现,也就是说我们在创建的时候不会去立即创建key值,而是在初始化以及更新的时候再从外部设置其key值。这个实现其实跟indexoffset的处理方式比较类似,我们整体在update时处理所有的相关值,且开发模式渲染时进行了严格检查。

// BlockState
let offset = 0;
this.lines.forEach((line, index) => {
  line.index = index;
  line.start = offset;
  line.key = line.key || Key.getId(line);
  const size = line.isDirty ? line.updateLeaves() : line.length;
  offset = offset + size;
});
this.length = offset;
this.size = this.lines.length;
// LineState
let offset = 0;
const ops: Op[] = [];
this.leaves.forEach((leaf, index) => {
  ops.push(leaf.op);
  leaf.offset = offset;
  leaf.parent = this;
  leaf.index = index;
  offset = offset + leaf.length;
  leaf.key = leaf.key || Key.getId(leaf);
});
this._ops = ops;
this.length = offset;
this.isDirty = false;
this.size = this.leaves.length;

此外,在实现单元测试时还发现,在leaf上独立维护了key值,那么\n这个特殊的节点自然也会有独立的key值。这种情况下在line级别上维护的key值倒是也可以直接复用\n这个leafkey值。当然这只是理论上的实现,可能会导致一些意想不到的刷新问题。

视图增量渲染

在视图模块最开始的设计上,我们的状态管理形式是直接全量更新Delta,然后使用EachLine遍历重建所有的状态。并且实际上我们维护了DeltaState两个数据模型,建立其关系映射关系本身也是一种损耗,渲染的时候的目标状态是Delta而非State

这样的模型必然是耗费性能的,每次Apply的时候都需要全量更新文档并且再次遍历分割行状态。当然实际上只是计算迭代的话,实际上是不会太过于耗费性能,但是由于我们每次都是新的对象,那么在更新视图的时候,更容易造成性能的损耗,计算的性能通常可接受,而视图更新操作DOM成本更高。

实际上,我们上边复用其key值,解决的问题是避免整个行状态视图re-mount。而即使复用了key值,因为重建了整个State实例,React也会继续后边的re-render流程。因此我们在这里需要解决的问题是,如何在无变更的情况下尽可能避免其视图re-render

由于我们实现了行级不可变状态维护,那么在视图中就可以直接对比状态对象的引用是否变化来决定是否需要重渲染。因此只需要对于ViewModel的节点补充了React.memo,在这个场景下甚至于不需要重写对比函数,只需要依赖我们的immutable状态复用能够正常起到效果。

const LeafView: FC<{ editor: Editor; leafState: LeafState; }> = props => {
  return (
    <span {...{ [LEAF_KEY]: true }} >
      {runtime.children}
    </span>
  );
}
export const LeafModel = React.memo(LeafView);

同样的,针对LineView也需要补充memo,而且由于组件内本身可能存在状态变化,例如Composing组合输入的控制,所以针对于内部节点的计算也会采用useMemo来缓存结果,避免重复计算。

const LineView: FC<{ editor: Editor; lineState: LineState; }> = props => {
  const elements = useMemo(() => {
     // ...
    return nodes;
  }, [editor, lineState]);
  return (
    <div {...{ [NODE_KEY]: true }} >
      {elements}
    </div>
  );
}
export const LineModel = React.memo(LineView);

而视图刷新仍然还是直接控制lines这个状态的引用即可,相当于核心层的内容变化与视图层的重渲染,是直接依赖于事件模块通信就可以实现的。由于每次取lines状态时都是新的引用,所以React会认为状态发生了变化,从而触发重渲染。

const onContentChange = useMemoFn(() => {
  if (flushing.current) return void 0;
  flushing.current = true;
  Promise.resolve().then(() => {
    flushing.current = false;
    setLines(state.getLines());
  });
});

而虽然触发了渲染,但是由于key以及memo的存在,会以line的状态为基准进行对比。只有LineState对象的引用发生了变化,LineModel视图才会触发更新逻辑,否则会复用原有的视图,这部分我们可以直接依赖Reactdevtools录制或Highlight就可以观察到。

视图增量更新这部分其实比较简单,主要是实现不可变对象以及key值维护的逻辑都在核心层实现,视图层主要是依赖其做计算,对比是否需要重渲染。其实类似的实现在低代码的场景中也可以应用,毕竟实际上富文本也就是相当于一个零代码的编辑器,只不过组装的不是组件而是文本。

总结

在先前我们主要讨论了视图层的适配器设计,主要是全量的视图初始化渲染,以及状态模型到DOM结构性的规则设定。在这里则主要考虑更新处理时性能的优化,主要是在增量更新时,如何最小化DOM以及Op操作、key值的维护、以及在React中实现增量渲染的方式。

其实接下来需要考虑输入内容时,如何避免规定的DOM的结构被破坏,主要涉及脏DOM检查、选区更新、渲染Hook等,这部分内容在#8#9的输入法处理中已经有了详细的讨论,因此这里就不再次展开了。

那么接下来我们需要讨论的是编辑节点的组件预设,例如零宽字符、Embed节点、Void节点等。主要是为编辑器的插件扩展提供预设的组件,在这些组件内存在一些默认的行为,并且同样预设了部分DOM结构,以此来实现在规定范围内的编辑器操作。

每日一题

参考

上万级文件一起可视化,怎么办?答案是基于 ParaView 的远程可视化

作者 serioyaoyao
2026年2月9日 10:42

一、概述

在 CFD/FEA 等仿真场景里,我们经常会遇到一种“看起来很朴素、做起来很要命”的需求:

  • 一次任务导出上万帧(上万级文件)结果,单帧可能是 .vtu/.vtk
  • 需要在浏览器里流畅拖动时间轴、切换物理量、裁剪/切片、对比多视图;
  • 数据体量大、用户网络环境复杂,而且还希望多人同时访问。

(这三点,就是我开发时,产品经理提的需求)

【注释:】

1.CFD:Computational Fluid Dynamics,计算流体力学

 典型仿真场景:

Improving Aircraft Aerodynamics With CFD Simulation

2.FEA(Finite Element Analysis,有限元分析)

What Is FEA | Finite Element Analysis? (Ultimate Guide) | SimScale

如果把这件事当成“前端把所有文件下载下来,用 WebGL 自己画”,基本会踩到:下载、解析、内存、GPU、交互延迟等一系列问题(我最开始接到需求时,就是这样做的,辛辛苦苦做完,遇上上万个文件加载、渲染,直接浏览器卡死😤)

这篇文章分享一个在工程上更稳妥的答案:基于 ParaView 的远程可视化(Remote Rendering)。(遇上浏览器卡顿、卡死后,项目负责人提供了另一个思路,于是开启技术调研、写demo、放入实际项目使用。)

本文基于我在项目中的落地实践:

  • 后端使用 pvpython(ParaView 自带 Python)+ wslink + paraview.web 进行渲染与 RPC;
  • 前端使用 vite + vue + @kitware/vtk.jsvtkRemoteView 接收图像流并转发交互事件;
  • 对“dev 开发模式”提供了 网关隔离:每个浏览器连接启动独立的 pvpython 子进程,避免多窗口互相覆盖。

你可以把它理解为:

浏览器只负责“看图 + 交互”,服务端负责“读数据 + 建管线 + GPU 渲染”。

对“万级文件/时间序列”的科学可视化,最容易规模化的做法往往是:把渲染留在服务端,把交互留在浏览器

二、为什么“上万级文件”会把常规方案打爆?

先把问题拆开看,“上万级文件可视化”通常同时包含 4 个压力源:

1.I/O 压力

  • 文件数量上来以后,目录遍历、排序、打开文件句柄、元数据读取都会变慢。
  • 即使单帧不大,上万次打开/关闭也很可观。

2.解析与内存压力

  • .vtu/.vtk 解析成本高,尤其是复杂网格/高阶单元/多数组。
  • 浏览器内存、GPU 显存都更紧张,稍不注意就崩溃或卡死。

3.网络与带宽压力

  • 把数据下发到浏览器意味着:传输成本高、等待时间长、弱网体验差。

4.交互延迟与工程复杂度

  • “拖时间轴 + 实时更新画面”对端到端延迟非常敏感。
  • 前端自己管理 time steps、颜色映射、裁剪滤镜等,会把可视化系统变成一个“重客户端”。

如果你希望最终体验接近 ParaView 桌面端,同时还要浏览器可用、多人可访问:

把渲染放在服务端(最好有 GPU),让浏览器只做交互与显示,通常是最划算的架构选择。

三、方案:ParaView 远程渲染(Remote Rendering)

  • 服务端pvpython 进程内运行 ParaView pipeline,离屏渲染,把画面编码为图片流,经 WebSocket 推送。

  • 客户端vtkRemoteView 接收图像并显示到 canvas;鼠标/键盘事件通过 wslink 发回服务端。

  • 数据留在服务端;

  • 浏览器拿到的是“每一帧的渲染结果(图像)”;

  • 交互是“事件/指令”,不是“传模型”。

现在的架构,变成:

  • 服务端是“可视化引擎”:读数据、建管线、算颜色映射、做裁剪/切片、渲染。

  • 浏览器是“远程控制器”:渲染结果是图像流;交互就是事件和参数。

四、核心功能

  • 万级文件时间序列回放(目录下 .vtu/.vtk 序列):播放/暂停、逐帧切换、循环、帧率。

  • 场数据切换:点/单元字段(POINTS/CELLS)自动识别,向量支持 Magnitude/分量选择。

  • 交互与相机:旋转/平移/缩放、重置相机、围绕物体中心旋转。

  • 裁剪/切片:X/Y/Z 轴切片或裁剪(项目中 demo2/demo5/dev 具备相关能力)。

  • 多视图对比:同一数据不同物理量并排渲染(demo6/dev 的 multiview)。

  • 远程加载:支持从网络盘/挂载盘读取任务数据(DEV_NETWORK_ROOT)。

五、排坑清单

如果你也准备落地 ParaViewWeb,这些坑我建议你在 README 或运维文档里明确写出来:

  1. 后端必须用 pvpython 启动(paraview自带,不是 python)。

  2. 多人访问要考虑隔离(最简单就是每连接一个进程)。

  3. 图像流带宽/消息大小要提前评估,尤其是 4K/多视图。

  4. 时间序列一定要做“时间步兜底”(读不到 TimestepValues 就用 0..N-1)。

  5. 项目部署与文件存储服务器不在一起时,考虑用挂载远程服务器数据进行开发

    运行可能需要输入服务器密码,建议配置 SSH 免密登录以实现全自动启动。

六、结语

“上万级文件一起可视化”本质上不是一个前端工程问题,而是一个端到端系统问题:数据存储、I/O、渲染、交互、网络、多人隔离,每一个环节都可能成为瓶颈。

基于 ParaView 的远程可视化,把最重的那部分(读取、管线、GPU 渲染)放回服务端,让浏览器专注“交互 + 显示”,在工程上往往是最划算、最可持续的路线(目前来看,是这样,有不有更好的方案,大佬们可以说说~~)。

(yaoyao从技术调研、到项目使用,时间也不长,这个方案有问题,请大佬们指正~~)

本文是纯方案讨论,下期,上代码!!!

我彻底搞懂了 SSE,原来流式响应效果还能这么玩的?(附 JS/Dart 双端实战)

2026年2月9日 10:33

前言

大家好,我是【小林】

说起来有点意思,最近我在做 AI 项目的时候,突然对 "流式响应效果" 产生了浓厚兴趣也就是所谓的打字机效果。你知道那种感觉吧,AI 回答的时候,文字像被人敲出来一样,一个字一个字地蹦出来。

以前我以为是前端用 setTimeout 模拟的,直到有一次网络抖动,我发现它居然能从断开的地方继续输出,而不是重新开始。这就像看直播卡顿后,会自动从卡住的地方继续播放,而不是重播一遍。

这不就是断点续传吗?但 HTTP 请求不是无状态的吗?

带着这个疑问,我开始深入研究,才发现这背后藏着一套完整的流式传输协议——SSE(Server-Sent Events)

更让我意外的是,我发现业界对于"AI 流式响应该用 SSE 还是 WebSocket"这个话题,争议还挺大。有人说 WebSocket 功能更强大,有人说 SSE 更简单。

到底该选哪个?

我干脆从零开始实现了一套完整的 Demo,包含后端服务、JavaScript 客户端、Dart 客户端,甚至还实现了断线重连、指数退避、粘包处理等生产级特性。

这篇文章,我就把这背后的原理、坑点、实战经验分享出来。


篇章一:为什么 AI 聊天首选 SSE 而非 WebSocket?

在讲代码之前,我们先搞清楚一个核心问题:为什么 ChatGPT、Claude 这些 AI 助手都选 SSE,而不是看起来更强大的 WebSocket?

1.1 场景分析:AI 对话的"一问多答"模式

我们先看 AI 对话的典型特征:

用户:如何学好 Flutter?
AI:  【开始一段一段地输出,持续十几秒甚至更长】

这就是典型的**"一问多答"模式**:

  • 用户发送的 Prompt 通常很短(几个字到几百字)
  • AI 的回复可能很长(几千字,甚至更长)
  • 数据流向是单向的:Server → Client

1.2 SSE vs WebSocket 核心对比

我们用一个表格来看两者的差异:

特性 SSE WebSocket
通信方向 单工(Server → Client) 全双工(双向通信)
协议基础 HTTP 标准 自定义 WS 协议
连接方式 标准 HTTP 请求 需要握手升级
鉴权方式 ✅ 自定义 Header(如 Authorization) ❌ 只能在握手时带 Header
断线重连 ✅ 内置 Last-Event-ID 机制 ❌ 需要手动实现
浏览器调试 ✅ DevTools 直接查看 EventStream ⚠️ 需要在 WS Frames 面板查看
服务端实现 ✅ 简单,标准 HTTP 响应 ⚠️ 需要维护连接状态
AI 场景契合度 ✅ 完美匹配"一问多答" ❌ 过度设计

1.3 一个餐厅大厨的比喻

让我用一个好懂的比喻来解释:

SSE 就像"自助餐厅的传菜口"

  • 你点完菜(发送 HTTP 请求)
  • 厨师开始炒菜,炒好一道就传出来一道(Server 持续推送数据)
  • 你坐在那里等,菜一道一道地上来(Client 接收流式数据)
  • 如果突然停电了,来电后厨师会问你:"刚才上到第几道了?"然后继续上(断线重连)

WebSocket 就像"打电话订外卖"

  • 你和骑手保持通话(双向通信通道)
  • 骑手一边送一边向你汇报位置(实时双向交互)
  • 如果电话断了,你得重新打过去,还得从头说(需要手动重连)

对于 AI 聊天这种"我点菜,你上菜"的场景,SSE 的传菜口模式显然更合适。WebSocket 更适合"我和骑手实时沟通位置"这种需要频繁交互的场景。

1.4 为什么不用原生 EventSource?

看到这里你可能会问:浏览器不是有原生 EventSource API 吗?为什么还要自己实现?

问题在于,原生 EventSource 有几个致命限制:

// 原生 EventSource 的问题
const eventSource = new EventSource('/stream');  // ❌ 只支持 GET

// ❌ 无法自定义 Header(比如 Authorization)
// ❌ 无法发送请求体(AI 场景的 Prompt 可能很长)
// ❌ 只能在 URL 里传参数,不安全也不优雅

在 AI 场景下,我们需要:

  • POST 请求发送长 Prompt
  • 在 Header 里带 Authorization Token
  • 支持自定义错误处理和重连策略

所以,我们需要基于 fetch + ReadableStream 自己实现一个 SSEManager。


篇章二:直击底层:SSE 协议原理剖析

2.1 SSE 协议格式

SSE 是基于 HTTP 的,协议格式非常简单:

event: message
id: 1234567890
data: {"type": "content", "payload": "我"}

event: message
id: 1234567891
data: {"type": "content", "payload": "喜"}

event: close
data: [DONE]

协议要点

  • 每条消息由 event:id:data: 三个字段组成
  • 字段顺序不重要,但每条消息后必须有一个空行作为分隔符
  • event: 表示事件类型(message、error、close 等)
  • id: 用于断线重连时恢复(客户端会记录 Last-Event-ID)
  • data: 是实际数据,通常是 JSON 字符串

2.2 核心挑战:粘包和半包问题

这是 SSE 实现中最容易踩的坑。

什么是粘包?

服务器一次发送:
event: message\ndata: {"type":"content","payload":"我"}\n\nevent: message\ndata: {"type":"content","payload":"喜"}\n\n

客户端可能收到:
event: message
data: {"type":"content","payload":"我"}
event: message    ← 两条消息粘在一起了
data: {"type":"content","payload":"喜"}

什么是半包?

服务器发送一条完整消息:
event: message\ndata: {"type":"content","payload":"我是中文"}\n\n

客户端可能分两次收到:
第一次:event: message\ndata: {"type":"content","payload": "我
第二次:是中文"}\n\n                              ← JSON 被截断了!

解决方案: 维护一个 buffer 缓冲区,每次收到 chunk 后:

  1. 追加到 buffer
  2. \n\n 分割出完整消息
  3. 剩下的部分留在 buffer,等下次 chunk 到来

篇章三:实战实现

3.1 后端实现(Node.js + Express)

先看后端怎么实现 SSE 接口:

app.get('/stream-sse', async (req, res) => {
  // 设置 SSE 必需的 HTTP Headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲

  const query = req.query.query || '默认问题';
  const text = '这是 AI 的回复内容...';
  const chars = text.split('');

  // 逐字发送
  for (let i = 0; i < chars.length; i++) {
    const message = `event: message\nid: ${Date.now()}\ndata: ${JSON.stringify({
      type: 'content',
      payload: chars[i],
      index: i,
      total: chars.length
    })}\n\n`;

    res.write(message);

    // 模拟 AI 生成延迟(打字机效果)
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  // 发送完成信号
  res.write('event: close\ndata: [DONE]\n\n');
  res.end();
});

关键点

  • Content-Type: text/event-stream 告诉浏览器这是 SSE 流
  • Cache-Control: no-cache 禁止缓存,确保实时性
  • Connection: keep-alive 保持长连接
  • 逐字符发送,模拟 AI 打字机效果
  • 最后发送 [DONE] 信号告诉客户端流结束了

3.2 JavaScript 客户端:手写 SSEManager

这是核心部分。我们基于 fetch + ReadableStream 实现一个完整的 SSEManager:

class SSEManager {
  constructor(url, options = {}) {
    this.url = url;
    this.options = {
      headers: {},
      body: null,
      maxRetries: 5,
      initialRetryDelay: 1000,
      enableRetry: true,
      ...options
    };

    this.onMessageCallback = null;
    this.onErrorCallback = null;
    this.onCompleteCallback = null;

    this.abortController = null;
    this.retryCount = 0;
    this.lastEventId = null;
    this.isConnecting = false;
  }

  async connect() {
    if (this.isConnecting) return;

    this.isConnecting = true;
    this.abortController = new AbortController();

    try {
      const fetchOptions = {
        method: this.options.body ? 'POST' : 'GET',
        headers: {
          'Content-Type': 'application/json',
          ...this.options.headers
        },
        signal: this.abortController.signal
      };

      if (this.options.body) {
        fetchOptions.body = JSON.stringify(this.options.body);
      }

      // 如果有 Last-Event-ID,带上(用于断线重连)
      if (this.lastEventId) {
        fetchOptions.headers['Last-Event-ID'] = this.lastEventId;
      }

      const response = await fetch(this.url, fetchOptions);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');

      // 🔥 关键:消息缓冲区(处理粘包和半包)
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        buffer += chunk;

        // 解析缓冲区中的完整消息
        buffer = this._parseBuffer(buffer);
      }

      // 如果不是主动断开,尝试重连
      if (this.options.enableRetry && !this.abortController.signal.aborted) {
        this._scheduleRetry();
      }

    } catch (error) {
      if (error.name === 'AbortError') return;

      if (this.onErrorCallback) {
        this.onErrorCallback(error);
      }

      if (this.options.enableRetry && this.retryCount < this.options.maxRetries) {
        this._scheduleRetry();
      }
    } finally {
      this.isConnecting = false;
    }
  }

  // 🔥 核心方法:解析缓冲区中的 SSE 消息
  _parseBuffer(buffer) {
    const lines = buffer.split('\n');
    let currentEvent = { event: null, id: null, data: null };

    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];

      // 空行表示一条消息结束
      if (line === '') {
        if (currentEvent.data !== null) {
          this._handleEvent(currentEvent);
          currentEvent = { event: null, id: null, data: null };
        }
        continue;
      }

      // 解析字段
      if (line.startsWith('event:')) {
        currentEvent.event = line.substring(7).trim();
      } else if (line.startsWith('id:')) {
        currentEvent.id = line.substring(4).trim();
        this.lastEventId = currentEvent.id;
      } else if (line.startsWith('data:')) {
        currentEvent.data = line.substring(6).trim();
      }
    }

    // 返回未处理的缓冲区(最后一条不完整的消息)
    return lines[lines.length - 1] === '' ? '' : lines[lines.length - 1];
  }

  _handleEvent(event) {
    if (event.event === 'close') {
      if (this.onCompleteCallback) this.onCompleteCallback();
      return;
    }

    if (event.event === 'error') {
      if (this.onErrorCallback) {
        this.onErrorCallback(new Error(event.data));
      }
      return;
    }

    if (this.onMessageCallback) {
      try {
        const data = JSON.parse(event.data);
        this.onMessageCallback(data);
      } catch (e) {
        console.error('Failed to parse SSE data:', e);
      }
    }
  }

  // 🔥 指数退避重连算法
  _scheduleRetry() {
    this.retryCount++;
    const delay = Math.min(
      this.options.initialRetryDelay * Math.pow(2, this.retryCount - 1),
      30000 // 最大 30 秒
    );

    console.log(`[SSE] Retry ${this.retryCount}/${this.options.maxRetries} after ${delay}ms`);

    setTimeout(() => {
      this.connect();
    }, delay);
  }

  onMessage(callback) {
    this.onMessageCallback = callback;
    return this;
  }

  onError(callback) {
    this.onErrorCallback = callback;
    return this;
  }

  onComplete(callback) {
    this.onCompleteCallback = callback;
    return this;
  }

  disconnect() {
    if (this.abortController) {
      this.abortController.abort();
    }
    this.isConnecting = false;
  }
}

使用示例

const sse = new SSEManager('http://localhost:3000/stream-sse', {
  body: { query: '如何学好 Flutter?' },
  headers: { 'Authorization': 'Bearer token123' },
  enableRetry: true,
  maxRetries: 5
});

sse.onMessage((data) => {
  console.log('收到数据:', data.payload);
  // 逐字显示到界面上
})
.onError((error) => {
  console.error('发生错误:', error);
})
.onComplete(() => {
  console.log('传输完成');
})
.connect();

3.3 Dart 客户端:UTF-8 安全处理

Dart 端有个特殊问题:中文字符的 UTF-8 编码问题

中文字符在 UTF-8 中占 3 个字节,如果流正好把一个字符的 3 个字节截断了,就会出现乱码。

解决方案:使用 utf8.decoder + LineSplitter() 的流转换链:

class SSEManager {
  final String url;
  final Map<String, String> headers;
  final Map<String, dynamic> body;

  int _retryCount = 0;
  String? _lastEventId;
  bool _isConnecting = false;

  SSEManager({
    required this.url,
    this.headers = const {},
    this.body = const {},
  });

  Future<void> connect() async {
    if (_isConnecting) return;
    _isConnecting = true;

    try {
      final client = HttpClient();
      final request = await client.postUrl(Uri.parse(url));

      // 设置 Headers
      headers.forEach((key, value) {
        request.headers.set(key, value);
      });

      if (_lastEventId != null) {
        request.headers.set('Last-Event-ID', _lastEventId!);
      }

      // 设置 Body
      if (body.isNotEmpty) {
        request.add(utf8.encode(jsonEncode(body)));
      }

      final response = await request.close();

      // 🔥 核心流转换链
      response
        .transform(utf8.decoder)      // ByteStream → String
        .transform(const LineSplitter()) // String → Lines
        .listen(_parseLine);

    } catch (e) {
      _scheduleRetry();
    } finally {
      _isConnecting = false;
    }
  }

  void _parseLine(String line) {
    // 解析 SSE 协议...
    // (类似 JS 版本的逻辑)
  }

  void _scheduleRetry() {
    _retryCount++;
    final delay = min(1000 * pow(2, _retryCount - 1), 30000).toInt();

    Future.delayed(Duration(milliseconds: delay), () {
      connect();
    });
  }
}

3.4 实际运行效果

让我们看看实际运行的效果:

主页面.png

传输中状态

  • AI 响应区域逐字显示
  • 系统日志实时滚动
  • 性能指标动态更新

传输中.png

连接错误

  • 当服务器未启动时,显示红色错误提示
  • 自动触发重连机制

连接错误.png

重试中状态

  • 显示当前重试次数和延迟时间
  • 使用指数退避算法(1s → 2s → 4s → 8s...)

重试中.png

传输完成

  • 显示完整的输出内容
  • 性能指标:总字数、总耗时、平均延迟

传输结束.png


篇章四:踩坑总结

做这个 Demo 的过程中,我踩了不少坑。这里挑几个最经典的分享给你。

4.1 粘包/半包处理

:一开始我直接用 split('\n\n') 分割消息,结果经常出现 JSON 解析错误。

原因:一个 chunk 可能包含半个 JSON,或者两条消息粘在一起。

解决:维护 buffer,每次解析后把剩余部分留给下次:

let buffer = '';
buffer += chunk;           // 追加新数据
const messages = buffer.split('\n\n');
buffer = messages.pop();   // 保留最后一个(可能不完整)
// 处理前面的完整消息
messages.forEach(msg => parseMessage(msg));

4.2 UTF-8 字符截断

:Dart 端经常出现乱码,特别是中文字符。

原因:中文字符在 UTF-8 中占 3 字节,流可能把 3 字节截断。

解决:使用 utf8.decoder 自动处理字节边界:

response
  .transform(utf8.decoder)      // ✅ 自动处理 UTF-8 边界
  .transform(const LineSplitter())
  .listen(_parseLine);

4.3 重连时机判断

:服务器正常结束时也触发重连,导致死循环。

原因:没区分"正常结束"和"异常断开"。

解决:检查 [DONE] 信号:

_handleEvent(event) {
  if (event.event === 'close' && event.data === '[DONE]') {
    // 正常结束,不重连
    this.onCompleteCallback();
    return;
  }
  // ... 其他处理
}

// 在流关闭时判断
while (true) {
  const { done, value } = await reader.read();
  if (done) {
    // 如果收到了 [DONE],说明正常结束
    if (receivedDoneSignal) return;
    // 否则可能是异常断开,触发重连
    this._scheduleRetry();
    break;
  }
}

4.4 Nginx 缓冲问题

:部署到生产环境后,SSE 流半天不输出。

原因:Nginx 默认会缓冲响应,等积累到一定大小才发送。

解决:在响应头添加 X-Accel-Buffering: no

res.setHeader('X-Accel-Buffering', 'no');

或者在 Nginx 配置中:

proxy_buffering off;

最终章:总结与展望

5.1 技术选型建议

什么时候用 SSE?

  • ✅ AI 聊天助手(一问多答)
  • ✅ 实时通知推送
  • ✅ 股票/加密货币价格推送
  • ✅ 服务器日志实时监控

什么时候用 WebSocket?

  • ✅ 即时通讯(IM、聊天室)
  • ✅ 在线协作(多人同时编辑)
  • ✅ 游戏直播(需要高频双向交互)
  • ✅ 远程桌面/控制

5.2 本项目的核心特性

我实现的这个 Demo,包含了以下生产级特性:

  • ✅ 支持 POST 请求(可以发送长 Prompt)
  • ✅ 自定义 Header(支持 Authorization)
  • ✅ 粘包/半包处理(buffer 缓冲区)
  • ✅ 指数退避重连(1s → 2s → 4s → 8s...)
  • ✅ Last-Event-ID 机制(断线续传)
  • ✅ UTF-8 安全处理(Dart 端)
  • ✅ 错误处理和日志

5.3 开源地址

项目已完全开源,欢迎 Star 和 PR:

🔗 GitHub: github.com/xinqingaa/s…

包含:

  • Node.js 后端(Express)
  • JavaScript 客户端(原生 JS,无依赖)
  • Dart 客户端(Flutter 友好)
  • 交互式演示界面

5.4 写在最后

回看这一周的学习,我发现:

技术选型没有银弹。SSE 不是万能的,WebSocket 也不是过时的。关键是要理解你的场景需求。

对于 AI 聊天这种"一问多答"的单向流式场景,SSE 就像量身定做的一样:

  • 简单(基于 HTTP)
  • 可靠(内置重连)
  • 高效(没有全双工的开销)
  • 可调试(DevTools 直接看)

而 WebSocket 的强大在于双向实时交互,但这在 AI 聊天场景下是"杀鸡用牛刀"。

最后,如果这篇文章对你有帮助,点个赞吧~

(完)


往期文章回顾

LangGraph + React + Nest 全栈Agent

掘金文章 | github

Flutter 图片编辑器

掘金文章 | pub.dev | github

Flutter 全链路监控 SDK

掘金文章 | pub.dev | github

Flutter 全场景弹框组件

掘金文章 | pub.dev | github


关于作者

大家好,我是【小林】,一名 Flutter 开发工程师。近期在研究 AI Agent 和流式传输技术,欢迎关注我的掘金账号,获取更多技术分享。

Vue3 封装 Axios 实战:从基础到生产级,新手也能秒上手

2026年2月7日 11:16

在 Vue3 项目开发中,Axios 是最常用的 HTTP 请求库,但直接在组件中裸写 Axios 会导致代码冗余、难以维护——比如每个请求都要写重复的 baseURL、请求头、错误处理,接口变更时要改遍所有组件。

合理封装 Axios 能解决这些问题:统一管理请求配置、全局处理拦截器、标准化错误提示、支持取消重复请求……既能提升开发效率,又能让代码更健壮。

今天这篇文章,就带你从零实现 Vue3 + Vite 项目中 Axios 的生产级封装,从基础结构到进阶优化,每一步都有完整代码示例,直接复制就能用!适配 Vue3 组合式 API(

一、前置准备:安装 Axios

首先确保你的 Vue3 项目已搭建完成(推荐用 Vite 搭建),然后安装 Axios,TS 项目需额外安装类型声明:

# 安装核心 Axios 库
npm install axios
# 可选:TS 项目必装(提供类型提示,避免报错)
npm install @types/axios --save-dev

二、基础版封装:核心结构(新手友好)

基础版封装聚焦「统一配置 + 简化调用」,适合小型项目或新手入门,核心实现 3 个功能:统一 baseURL、全局请求/响应拦截、简化请求调用。

封装步骤:在 src 目录下新建 utils/request.js(JS 项目)或 utils/request.ts(TS 项目),作为 Axios 封装的核心文件。

2.1 JS 版本(基础版)

// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus' // 可选:结合UI库做错误提示(推荐)

// 1. 创建 Axios 实例,配置基础参数
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 环境变量(推荐,区分开发/生产)
  timeout: 5000, // 超时时间(单位:ms),超过则中断请求
  headers: {
    'Content-Type': 'application/json;charset=utf-8' // 默认请求头
  }
})

// 2. 请求拦截器(请求发送前执行)
// 作用:添加token、统一修改请求参数格式等
service.interceptors.request.use(
  (config) => {
    // 示例:添加token(登录后存储在localStorage,根据实际项目调整)
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}` // 拼接token格式(后端约定)
    }
    return config // 必须返回config,否则请求会中断
  },
  (error) => {
    // 请求发送失败(如网络中断、参数错误)
    ElMessage.error('请求发送失败,请检查网络或参数')
    return Promise.reject(error) // 抛出错误,供组件捕获处理
  }
)

// 3. 响应拦截器(请求返回后执行,先于组件接收)
// 作用:统一处理响应数据、拦截错误(如token过期、接口报错)
service.interceptors.response.use(
  (response) => {
    // 只返回响应体中的data(多数后端接口会包裹一层code/message/data)
    const res = response.data

    // 示例:根据后端约定的code判断请求是否成功(常见约定:200=成功)
    if (res.code !== 200) {
      // 非200状态码,视为业务错误(如参数错误、权限不足)
      ElMessage.error(res.message || '接口请求失败,请重试')
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    return res.data // 返回真正的业务数据,组件可直接使用
  },
  (error) => {
    // 响应失败(如超时、后端报错、404/500状态码)
    let errorMsg = '请求异常,请联系管理员'
    // 区分不同错误类型,给出更精准提示
    if (error.response) {
      // 有响应,但状态码非2xx(如401token过期、404接口不存在、500后端报错)
      switch (error.response.status) {
        case 401:
          errorMsg = '登录已过期,请重新登录'
          // 额外操作:清除过期token,跳转到登录页(结合Vue Router)
          localStorage.removeItem('token')
          window.location.href = '/login'
          break
        case 404:
          errorMsg = '请求的接口不存在'
          break
        case 500:
          errorMsg = '后端服务异常,请稍后重试'
          break
        default:
          errorMsg = error.response.data?.message || errorMsg
      }
    } else if (error.request) {
      // 无响应(如网络中断、超时)
      errorMsg = '网络异常或请求超时,请检查网络'
    }
    ElMessage.error(errorMsg)
    return Promise.reject(error)
  }
)

// 4. 封装常用请求方法(get/post/put/delete),简化组件调用
// get请求:params传参(拼接在URL后)
export const get = (url, params = {}) => {
  return service({
    url,
    method: 'get',
    params
  })
}

// post请求:data传参(请求体中)
export const post = (url, data = {}) => {
  return service({
    url,
    method: 'post',
    data
  })
}

// put请求(修改数据)
export const put = (url, data = {}) => {
  return service({
    url,
    method: 'put',
    data
  })
}

// delete请求(删除数据)
export const del = (url, params = {}) => {
  return service({
    url,
    method: 'delete',
    params
  })
}

// 导出Axios实例(特殊场景可直接使用,如取消请求)
export default service

2.2 TS 版本(基础版,补充类型提示)

TS 项目需添加类型声明,避免类型报错,提升开发体验,核心修改的是「请求/响应类型」和「参数类型」:

// src/utils/request.ts
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { ElMessage } from 'element-plus'

// 定义后端响应的统一格式(根据你的后端接口调整)
interface ResponseData<T = any> {
  code: number
  message: string
  data: T
}

// 1. 创建Axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 5000,
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  }
})

// 2. 请求拦截器
service.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    const token = localStorage.getItem('token')
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error: AxiosError) => {
    ElMessage.error('请求发送失败,请检查网络或参数')
    return Promise.reject(error)
  }
)

// 3. 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse<ResponseData>) => {
    const res = response.data
    if (res.code !== 200) {
      ElMessage.error(res.message || '接口请求失败,请重试')
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    return res.data // 返回业务数据,自动推导类型
  },
  (error: AxiosError<ResponseData>) => {
    let errorMsg = '请求异常,请联系管理员'
    if (error.response) {
      const status = error.response.status
      switch (status) {
        case 401:
          errorMsg = '登录已过期,请重新登录'
          localStorage.removeItem('token')
          window.location.href = '/login'
          break
        case 404:
          errorMsg = '请求的接口不存在'
          break
        case 500:
          errorMsg = '后端服务异常,请稍后重试'
          break
        default:
          errorMsg = error.response.data?.message || errorMsg
      }
    } else if (error.request) {
      errorMsg = '网络异常或请求超时,请检查网络'
    }
    ElMessage.error(errorMsg)
    return Promise.reject(error)
  }
)

// 4. 封装请求方法,添加类型声明
// get请求
export const get = <T = any>(url: string, params?: Record<string, any>, config?: AxiosRequestConfig): Promise<T> => {
  return service({
    url,
    method: 'get',
    params,
    ...config
  })
}

// post请求
export const post = <T = any>(url: string, data?: Record<string, any>, config?: AxiosRequestConfig): Promise<T> => {
  return service({
    url,
    method: 'post',
    data,
    ...config
  })
}

// put请求
export const put = <T = any>(url: string, data?: Record<string, any>, config?: AxiosRequestConfig): Promise<T> => {
  return service({
    url,
    method: 'put',
    data,
    ...config
  })
}

// delete请求
export const del = <T = any>(url: string, params?: Record<string, any>, config?: AxiosRequestConfig): Promise<T> => {
  return service({
    url,
    method: 'delete',
    params,
    ...config
  })
}

export default service

2.3 环境变量配置(关键步骤)

上面封装中用到的import.meta.env.VITE_API_BASE_URL,是 Vite 的环境变量,用于区分「开发环境」和「生产环境」的接口地址,避免手动修改。

在项目根目录新建 2 个文件:.env.development(开发环境)和 .env.production(生产环境):

# .env.development(开发环境,npm run dev 时生效)
VITE_API_BASE_URL = 'http://localhost:3000/api' # 本地后端接口地址

# .env.production(生产环境,npm run build 时生效)
VITE_API_BASE_URL = 'https://api.yourdomain.com' # 线上后端接口地址

注意:Vite 环境变量必须以VITE_ 开头,否则无法读取。

2.4 组件中如何使用(简化调用)

封装完成后,在 Vue3 组件(支持

<script setup>
// 导入封装好的请求方法
import { get, post } from '@/utils/request'
import { ref, onMounted } from 'vue'

const userList = ref([])

// 1. get请求(获取用户列表,params传参)
const getUserList = async () => {
  try {
    // 直接调用,无需写baseURL、请求头
    const res = await get('/user/list', { page: 1, size: 10 })
    userList.value = res // 直接使用响应数据(已过滤外层code/message)
  } catch (error) {
    // 可选:组件内单独处理错误(全局已处理过,这里可省略)
    console.log('获取用户列表失败:', error)
  }
}

// 2. post请求(提交表单,data传参)
const submitForm = async (formData) => {
  try {
    const res = await post('/user/add', formData)
    ElMessage.success('提交成功')
  } catch (error) {
    // 无需额外提示,全局响应拦截器已做错误提示
  }
}

// 页面挂载时调用get请求
onMounted(() => {
  getUserList()
})
</script>

对比裸写 Axios,封装后的调用更简洁,且所有请求的配置、错误处理都统一管理,后续修改接口地址、token 格式,只需改 request.js/ts 一个文件。

三、进阶版封装:生产级优化(必看)

基础版封装能满足小型项目,但在中大型项目中,还需要补充「取消重复请求、请求loading、接口加密、异常重试」等功能,让封装更健壮、更贴合生产需求。

3.1 优化1:取消重复请求(避免接口冗余)

场景:用户快速点击两次按钮,会发起两次相同的请求(如提交表单),导致后端重复处理。解决方案:用 Axios 的 CancelToken(Axios 0.x)或 AbortController(Axios 1.x+)取消重复请求。

以下是 Axios 1.x+ 版本(当前最新版)的实现方式(AbortController 更规范):

// src/utils/request.js(仅修改新增部分,其余代码不变)
import axios from 'axios'
import { ElMessage } from 'element-plus'

// 存储正在请求的接口(key:请求标识,value:AbortController实例)
const pendingRequests = new Map()

// 生成请求标识(url + method + 参数,确保唯一)
const generateRequestKey = (config) => {
  const { url, method, params, data } = config
  // 序列化参数,避免相同请求因参数顺序不同被误判为不同请求
  const paramsStr = JSON.stringify(params || {})
  const dataStr = JSON.stringify(data || {})
  return `${url}-${method}-${paramsStr}-${dataStr}`
}

// 取消重复请求
const cancelPendingRequest = (config) => {
  const requestKey = generateRequestKey(config)
  // 如果有重复请求,取消之前的
  if (pendingRequests.has(requestKey)) {
    const controller = pendingRequests.get(requestKey)
    controller.abort() // 取消请求
    pendingRequests.delete(requestKey) // 移除取消的请求
  }
}

// 1. 创建Axios实例(新增signal配置)
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 5000,
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  }
})

// 2. 请求拦截器(修改:添加取消重复请求逻辑)
service.interceptors.request.use(
  (config) => {
    // 取消重复请求(发起当前请求前,取消之前相同的请求)
    cancelPendingRequest(config)
    // 创建AbortController实例,用于取消请求
    const controller = new AbortController()
    config.signal = controller.signal
    // 存储当前请求
    const requestKey = generateRequestKey(config)
    pendingRequests.set(requestKey, controller)
    
    // 添加token(原有逻辑不变)
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    ElMessage.error('请求发送失败,请检查网络或参数')
    return Promise.reject(error)
  }
)

// 3. 响应拦截器(修改:移除已完成的请求)
service.interceptors.response.use(
  (response) => {
    const config = response.config
    const requestKey = generateRequestKey(config)
    pendingRequests.delete(requestKey) // 请求完成,移除存储
    
    const res = response.data
    if (res.code !== 200) {
      ElMessage.error(res.message || '接口请求失败,请重试')
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    return res.data
  },
  (error) => {
    // 处理取消请求的错误(单独捕获,不提示用户)
    if (axios.isCancel(error)) {
      console.log('请求已取消:', error.message)
      return Promise.reject(new Error('请求已取消'))
    }
    
    // 移除失败的请求
    if (error.config) {
      const requestKey = generateRequestKey(error.config)
      pendingRequests.delete(requestKey)
    }
    
    // 原有错误处理逻辑不变
    let errorMsg = '请求异常,请联系管理员'
    if (error.response) {
      // ... 原有状态码判断逻辑
    } else if (error.request) {
      errorMsg = '网络异常或请求超时,请检查网络'
    }
    ElMessage.error(errorMsg)
    return Promise.reject(error)
  }
)

// 4. 封装请求方法(不变)
export const get = (url, params = {}) => { /* ... */ }
export const post = (url, data = {}) => { /* ... */ }
// ... 其余方法

3.2 优化2:全局请求 Loading(提升交互体验)

场景:请求耗时较长时,用户不知道是否在加载,容易重复点击。解决方案:添加全局 Loading,所有请求发起时显示 Loading,全部请求完成后隐藏。

结合 Element Plus 的 ElLoading 实现(需安装 Element Plus):

// src/utils/request.js(新增Loading相关逻辑)
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'

// 新增:Loading实例和请求计数
let loadingInstance = null // Loading实例
let requestCount = 0 // 请求计数器(避免多个请求重复显示/隐藏Loading)

// 显示Loading
const showLoading = () => {
  if (requestCount === 0) {
    // 只有当没有请求时,才显示Loading
    loadingInstance = ElLoading.service({
      lock: true,
      text: '加载中...',
      background: 'rgba(0, 0, 0, 0.5)'
    })
  }
  requestCount++
}

// 隐藏Loading
const hideLoading = () => {
  requestCount--
  if (requestCount === 0) {
    // 所有请求完成后,才隐藏Loading
    loadingInstance?.close()
  }
}

// 1. 创建Axios实例(不变)
const service = axios.create({ /* ... */ })

// 2. 请求拦截器(新增:显示Loading)
service.interceptors.request.use(
  (config) => {
    showLoading() // 发起请求时显示Loading
    // ... 原有取消重复请求、添加token逻辑
    return config
  },
  (error) => {
    hideLoading() // 请求失败,隐藏Loading
    ElMessage.error('请求发送失败,请检查网络或参数')
    return Promise.reject(error)
  }
)

// 3. 响应拦截器(新增:隐藏Loading)
service.interceptors.response.use(
  (response) => {
    hideLoading() // 请求成功,隐藏Loading
    // ... 原有移除重复请求、处理响应逻辑
    return res.data
  },
  (error) => {
    hideLoading() // 响应失败,隐藏Loading
    // ... 原有错误处理逻辑
    return Promise.reject(error)
  }
)

注意:requestCount 计数器是关键,避免多个请求同时发起时,单个请求完成就隐藏 Loading。

3.3 优化3:接口模块化管理(中大型项目必做)

场景:项目接口较多时,所有请求都写在组件中,会导致代码混乱,后续维护困难。解决方案:将接口按模块拆分,统一管理在 api 文件夹中。

步骤:在 src 目录下新建api 文件夹,按业务模块拆分文件(如 api/user.jsapi/goods.js):

// src/api/user.js(用户模块接口)
import { get, post, put, del } from '@/utils/request'

// 接口模块化封装,每个接口对应一个函数
export const userApi = {
  // 获取用户列表
  getUserList: (params) => get('/user/list', params),
  // 添加用户
  addUser: (data) => post('/user/add', data),
  // 修改用户信息
  editUser: (id, data) => put(`/user/${id}`, data),
  // 删除用户
  deleteUser: (id) => del('/user/delete', { id }),
  // 用户登录
  login: (data) => post('/user/login', data)
}

// src/api/goods.js(商品模块接口)
import { get, post } from '@/utils/request'

export const goodsApi = {
  // 获取商品详情
  getGoodsDetail: (id) => get(`/goods/${id}`),
  // 搜索商品
  searchGoods: (params) => get('/goods/search', params)
}

组件中使用时,直接导入对应模块的接口,代码更清晰、更易维护:

<script setup>
// 导入用户模块接口
import { userApi } from '@/api/user'
import { ref, onMounted } from 'vue'

const userList = ref([])

const getUserList = async () => {
  try {
    // 直接调用接口函数,参数清晰
    const res = await userApi.getUserList({ page: 1, size: 10 })
    userList.value = res
  } catch (error) {
    console.log(error)
  }
}

onMounted(() => {
  getUserList()
})
</script>

3.4 其他生产级优化(可选,按需添加)

  1. 请求重试:针对网络波动导致的请求失败,自动重试 1-2 次(避免用户手动重试),用 axios-retry 插件实现。
  2. 请求加密:敏感接口(如登录、支付)的参数加密(如 AES 加密),在请求拦截器中处理参数加密。
  3. 接口日志:开发环境打印请求/响应日志(便于调试),生产环境关闭日志(避免泄露敏感信息)。
  4. 自定义请求头:支持部分接口单独设置请求头(如文件上传接口设置 Content-Type: multipart/form-data)。

四、避坑指南(新手必看)

  1. 环境变量读取失败:Vite 环境变量必须以 VITE_ 开头,且只能在客户端代码中读取,不能在服务端代码中使用。
  2. token 失效未跳转:确保响应拦截器中 401 状态码的判断逻辑正确,且 window.location.href = '/login' 没有被注释,同时检查 token 是否正确存储/清除。
  3. 重复请求取消无效:请求标识(requestKey)必须唯一,确保 params 和 data 被正确序列化(避免因参数顺序不同导致标识不同)。
  4. Loading 闪烁:请求耗时过短(如 100ms 内完成),会导致 Loading 一闪而过,可添加 Loading 延迟显示(如 300ms 后显示,避免闪烁)。
  5. TS 类型报错:确保后端响应格式和定义的 ResponseData 接口一致,否则会出现类型不匹配报错。
  6. 文件上传接口失败:文件上传接口需单独设置请求头 'Content-Type': 'multipart/form-data',且传参用 FormData 格式。

五、总结

Vue3 封装 Axios 的核心是「统一管理 + 简化调用 + 异常处理」,从基础版的拦截器封装,到进阶版的重复请求取消、Loading 优化、接口模块化,一步步提升封装的健壮性和实用性。

总结几个关键要点:

  • axios.create() 创建实例,统一配置 baseURL、超时时间等。
  • 请求拦截器:添加 token、取消重复请求、显示 Loading。
  • 响应拦截器:统一处理响应数据、拦截错误(token 过期、404/500)、隐藏 Loading。
  • 中大型项目:接口按模块拆分,提升代码可维护性。
  • 生产环境:补充取消重复请求、请求加密等优化,让封装更健壮。

封装完成后,后续开发只需专注于业务逻辑,无需关注请求的底层配置,极大提升开发效率。本文的封装方案适配绝大多数 Vue3 项目,大家可根据自己的后端接口规范和业务需求,灵活调整拦截器逻辑和接口格式。

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

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

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

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

前言

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

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

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

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

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

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

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

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

整体架构

先看全貌,方案分三块:

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

下面一个个说。

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

MCP 是什么?

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

为什么要用 Chrome DevTools MCP?

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

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

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

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

怎么配置

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

chrome devtools mcp github 地址

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

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

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

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

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

Skill 是什么?

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

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

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

api-add Skill 怎么设计的

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

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

# API from Swagger Doc

## skill 触发场景

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

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

## 工作流程

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

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

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

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

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

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

这里说几个我做的选择:

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

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

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

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

3. 为什么用 Agent Team?

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

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

三、Agent 定义 -- 分工干活

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

api-define Agent

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

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

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

mock-create Agent

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

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

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

几个值得说的点:

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

四、实战演示

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

Step 1:触发 Skill

只需要输入一句话:

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

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

image.png

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

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

image.png

它做了这几件事:

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

我两个都选了。

Step 3:Agent Team 并行干活

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

image.png

截图里能看到:

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

Step 4:完成,收工

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

image.png

最终生成了这些文件:

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

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

五、看看生成的代码

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

TypeScript 类型定义

部分展示:

// src/types/supply-company.ts

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

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

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

API 函数

部分展示:

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

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

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

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

  return { total, data };
}

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

Mock 数据

部分展示:

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

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

Mock 控制器

部分展示:

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

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

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

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

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

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

module.exports = router;

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

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

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

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

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

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

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

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

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

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

八、效果对比

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

总结

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

三、完整操作流程

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

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

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

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

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

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

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

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

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

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

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

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

五、那 IndexedDB 会造成滥用吗?

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

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

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

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

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

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

六、一个简单测试例子

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

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

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

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

2026年2月9日 10:06

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

如下就是一个标签组:

image.png

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

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

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

核心策略:

解决方案一句话总结:

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

流程设计

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

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

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

  4. 提交 (Commit)

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

为什么不用 navigator.sendBeacon?

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

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

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

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

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

关键实现细节

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

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

为什么单线程也有竞态?

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

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

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

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

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

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

解决方案

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

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

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

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

  return runSequentially(task);
}

原理解析:

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

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

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

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

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

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

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

优点:

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

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

缺点:

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

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

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

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

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

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

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

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

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

    灵魂拷问:为什么不用 setInterval

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

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

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

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

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

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

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

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

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

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

背景知识:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

代码解释:

  1. 关于 saveReport 的熔断逻辑

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

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

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

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

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

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

这里就轮到 base64ToUint8Array 登场了:

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

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

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

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

总结:数据流转全景

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

完整流程图

image.png

总结

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

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

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

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

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

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

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

渲染管线兼容性

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

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

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

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

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

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

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

端口详解

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

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

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

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

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

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

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

曝光类型深度解析

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

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

CurrentMultiplier(当前帧曝光乘数)

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

使用场景示例:

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

技术特点:

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

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

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

使用场景示例:

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

技术特点:

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

PreviousMultiplier(上一帧曝光乘数)

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

使用场景示例:

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

技术特点:

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

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

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

使用场景示例:

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

技术特点:

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

实际应用案例

HDR自发光材质

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

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

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

曝光自适应效果

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

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

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

曝光不变元素

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

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

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

性能考虑与最佳实践

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

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

最佳实践建议:

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

故障排除与常见问题

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

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

调试技巧:

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

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

❌
❌