普通视图

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

澳大利亚AI初创公司Firmus获100亿美元黑石领投融资

2026年2月9日 14:24
2月9日,澳大利亚人工智能基础设施公司Firmus Technologies宣布,已获得黑石集团旗下黑石战术机会基金、黑石信贷与保险基金及关联基金领投,并由金融投资机构Coatue共同参与的100亿美元债务融资。Firmus表示,此次融资将用于该公司数据中心扩建的下一阶段,其计划到2028年在澳大利亚建造总容量高达1.6千兆瓦的数据中心。(新浪财经)

“木头姐”旗下太空ETF首度买入特斯拉,或押注特斯拉与SpaceX合并

2026年2月9日 14:21
方舟投资掌门人凯茜•伍德近期的一项不同寻常的投资决策引发关注:旗下太空ETF首度买进特斯拉股票。有分析认为,此举似乎是在押注这家电动汽车巨头与SpaceX进行合并,并为潜在的合并提前布局。特斯拉正积极推进擎天柱人形机器人项目,且未来有望用机器人参与外星文明建设,这或许是方舟投资将这家以电动汽车闻名的公司纳入太空ETF的原因。伍德日前还在一场公开对话中表示,考虑到特斯拉横跨多个行业的长远野心,该车企有望实现100万亿美元的市值。(新浪财经)

首台量产版铂智4X Robotaxi正式下线

2026年2月9日 14:20
36氪获悉,2月9日,首台量产版铂智4X Robotaxi正式下线。此次下线标志着小马智行与丰田中国、广汽丰田的战略合作,正式迈入规模化生产与运营的全新阶段。据介绍,小马智行与丰田中国、广汽丰田三方的合资公司在2026年将部署千台级铂智4X Robotaxi,并在中国的一线城市开展运营。

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 工具将其『软件化』实现了一遍。

Kimi K2.5在OpenRouter一周总榜、编程项目榜等榜单中持续排名第一

2026年2月9日 14:11
36氪获悉,据OpenRouter数据,国产开源大模型Kimi K2.5发布近两周后,在OpenRouter一周总榜、编程项目榜、工具调用榜、Python项目榜及OpenClaw调用榜等多个榜单中持续排名第一。在最能反映模型使用量的一周总榜中,以1.16万亿token,超过Gemini 3 Flash Preview和Claude Sonnet4.5两个闭源模型,领先幅度超50%。

前端性能杀手竟然不是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商业,使用过程全程无广,请放心食用

Omdia报告:中国具身智能AI云市场,百度智能云市占率第一

2026年2月9日 13:12
36氪获悉,Omdia发布的《中国具身智能AI云市场1H25》报告显示,百度智能云以35%的市场份额位居中国具身智能AI云服务市场第一,领先优势超过第二名两倍以上。Omdia测算,2024年中国具身智能AI云服务市场规模约为1800万美元,到2030年将增长至4.08亿美元,年复合增长率达69%。在高速增长窗口期内,云厂商的技术深度与行业适配能力,正成为拉开差距的核心变量。

搜狐2025年Q4营收1.42亿美元,全年总收入5.84亿美元

2026年2月9日 13:01
2月9日,搜狐公司公布2025年第四季度及全年财务报告。财报显示,2025年第四季度,搜狐公司总收入为1.42亿美元,同比增长6%。其中,营销服务收入为1700万美元,在线游戏收入为1.20亿美元。2025年全年,搜狐公司总收入为5.84亿美元。其中,营销服务收入为6000万美元,在线游戏收入为5.06亿美元。剔除冲销畅游预提所得税影响后,2025年搜狐公司非美国通用会计准则下亏损5100万美元,相比2024年亏损8300万美元减亏近40%。

金凯生科:公司目前没有涉及礼来减肥药的产品

2026年2月9日 12:53
36氪获悉,金凯生科在互动平台表示,公司目前没有涉及礼来减肥药的产品;原研药厂的专利保护一般是多重机制的,有化合物专利、有制剂和配方专利、还有医疗用途专利等,对单一化合物专利到期的影响目前难以准确预计,公司会持续关注相关变化。

中国人寿等在上海成立私募基金,出资额50.5亿元

2026年2月9日 12:38
36氪获悉,爱企查App显示,近日,汇智长三角(上海)私募基金合伙企业(有限合伙)成立,出资额50.5亿元人民币,经营范围包括以私募基金从事股权投资、投资管理、资产管理等活动。合伙人信息显示,该企业由中国人寿保险股份有限公司等共同出资。
❌
❌