普通视图

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

不要在简历上写精通 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 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

⏰前端周刊第 452 期(2026年2月2日-2月8日)

2026年2月9日 09:49

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

前端周刊封面


💬 推荐语

本期聚焦“交互组件选择 + 浏览器行为细节 + 生态工具更新”。Web 开发部分从组合框/多选/列表框的选型指南、浏览器对“意外”变更的敏感反应,到“不要把单词拆成字母”的可访问性提醒;工具与性能板块涵盖 Deno 生态新进展、ESLint 10 发布、ViteLand 月报、以及 SVG/视频与 Node.js 版本演进的性能分析。CSS 方面关注 @scope、@container scroll-state()、bar chart 与 clamp() 等现代特性;JavaScript 则有 Temporal 提案、显式资源管理、框架选型与 React/Angular 的新范式探讨。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

⚡️ 性能

🎨 CSS

💡 JavaScript

前端向架构突围系列 - 状态数据设计 [8 - 3]:服务端状态与客户端状态的架构分离

2026年2月9日 09:48

写在前面

架构师的核心能力之一是分类。 如果你觉得状态管理很痛苦,通常是因为你试图用同一种工具处理两种截然不同的东西:

  1. 客户端状态 (Client State): 比如“侧边栏是否展开”、“当前的夜间模式”。它们是同步的、瞬间完成的、由前端完全控制。
  2. 服务端状态 (Server State): 比如“用户订单列表”。它们是异步的、可能失效的、由后端控制。

Redux 并不擅长管理 Server State。 真正专业的做法是:让 Redux 回归 UI,让 TanStack Query (React Query) 接管 API。

image.png


一、 为什么要把 API 赶出 Redux?

1.1 消失的“样板代码”

在传统的 Redux 处理 API 流程中,你需要写:

  • 一个 Constant 定义 FETCH_USER_REQUEST
  • 一个 Action Creator
  • 一个处理 Pending/Success/ErrorReducer
  • 一个 useEffect 来触发请求

而在 TanStack Query 中,这只需要一行代码:

const { data, isLoading } = useQuery({ queryKey: ['user'], queryFn: fetchUser });

1.2 缓存与失效:Redux 的盲区

Server State 最难的不是“获取”,而是**“维护”**。

  • 用户离开页面 5 分钟后回来,数据还是新的吗?
  • 两个组件同时请求同一个接口,会发两次请求吗?
  • 弱网环境下,请求失败了会自动重试吗? 如果要用 Redux 实现这些,你需要写几百行复杂的 Middleware。而这些,是 Server State 管理工具的标配

二、 架构模型:双层数据流

现代前端架构推荐采用 “双层分离” 模型:

2.1 外部层:服务端状态 (Server State)

  • 工具: TanStack Query (React Query) 或 SWR。
  • 职责: 缓存管理、自动预取、失效检查 (Stale-While-Revalidate)、请求去重。
  • 特点: 它是异步的。

2.2 内部层:客户端状态 (Client State)

  • 工具: Zustand, Pinia, Jotai 或简单的 React Context。
  • 职责: 管理纯粹的 UI 逻辑(开关、多语言、主题、临时草稿)。
  • 特点: 它是同步的。

三、 实战战术:从“手动挡”切换到“自动挡”

3.1 自动化的依赖追踪

想象一个场景:你修改了用户的头像,你需要更新所有显示头像的地方。

  • 旧模式 (Redux): 修改成功后,手动发起一个 updateUserAction 去修改 Redux 里的那个大对象。
  • 新模式 (Query): 只需要执行一次“失效(Invalidate)”。
// 当用户修改个人资料成功时
const mutation = useMutation({
  mutationFn: updateProfile,
  onSuccess: () => {
    // 告诉系统:['user'] 这个 key 下的数据脏了,请自动重新拉取
    queryClient.invalidateQueries({ queryKey: ['user'] })
  },
})

架构意义: 你的代码不再需要关心“数据怎么同步”,只需要关心“数据何时失效”。

3.2 乐观更新 (Optimistic Updates)

这是架构高级感的核心。当用户点赞时,我们不等后端返回,直接改 UI。

TanStack Query 允许你在 onMutate 中手动修改缓存副本,如果请求失败,它会自动回滚。这种复杂的逻辑如果写在 Redux 里,会让 Reducer 逻辑变得极度臃肿。


四、 选型决策:什么时候该用谁?

作为架构师,你需要给团队划清界限:

状态类型 典型例子 推荐工具 存储位置
API 数据 商品列表、用户信息 TanStack Query 专用 Cache 池
全局 UI 状态 登录 Token、全局主题 Zustand / Pinia 全局 Store
局部 UI 状态 某个弹窗的开关 useState 组件内部
复杂表单 多步骤注册表单 React Hook Form 专用 Form State

导出到 Google 表格


五、 总结:让 Redux 变“瘦”

通过把 API 逻辑剥离出去,你会发现你的 Redux(或者 Zustand)Store 瞬间缩水了 80% 。 剩下的代码变得极其纯粹:只有纯同步的 UI 逻辑。

这种**“分治”**带来的好处是巨大的:

  1. 心智负担降低: 你不再需要管理复杂的 loading 状态机。
  2. 性能提升: TanStack Query 的细粒度缓存比 Redux 的全量对比快得多。
  3. 开发效率: 团队成员可以更专注地编写业务逻辑,而不是在样板代码中挣扎。

结语:控制的艺术

我们已经成功地将 API 数据和 UI 状态分开了。 但还有一种状态最让架构师头疼:流程状态。 当你的业务逻辑包含“待支付 -> 支付中 -> 支付成功/失败 -> 申请退款 -> 已关闭”这种复杂的链路时,无论你用什么工具,代码里都会充满 if/else

这种逻辑该如何优雅地管理?

Next Step: 下一节,我们将引入一个在航天和游戏领域应用了几十年的数学模型。 我们将学习如何用“图”的思想,终结代码里的逻辑乱麻。

业务方上压力了,前端仔速通RGB转CMYK

2026年2月9日 09:40

一、开端

"又双叒叕大事不好了,咱们导出的图片有问题,印刷出来有色差!业务方都被逼着要去外采软件了!"

下班前,产品突然在群里丢了一颗重磅炸弹。

外采软件?什么情况?要是真把业务方逼去外采了,咱们 IT 往后的日子可就不好过了。

事不宜迟,咱们赶紧看看是怎么个事。

二、问题背景

我们团队负责的是加盟商报货相关业务,其中有一个定制宣传物料的模块,业务流程是这样的:

  1. 设计师在后台创建可定制的模板(使用 Fabric 实现的一个可视化编辑器)
  2. 加盟商通过小程序填写定制信息(门店名称、图片、文案等)下单
  3. 设计师在后台审核并合成最终设计稿(使用离屏 Canvas 渲染并直接上传 OSS)
  4. 导出高清原图发往印刷厂印刷,最终交付给加盟商

这个系统的前端部分使用了 Fabric 来实现图片编辑功能,基于浏览器 Canvas API 导出图片,而 Canvas 只支持 RGB 色彩模式。但印刷厂需要的是 CMYK 模式,这导致印刷出来有非常明显的色差。

三、颜色的本质:色彩学

要搞明白为什么有色差,我们首先要知道,什么是颜色。

这部分比较冗长,如果你已经具备了相关前置知识,可以直接跳转至「为什么有色差」一节

1. 色彩模式

1672年,牛顿通过一块棱镜,发现了光的色散,从而揭示了白光由不同颜色光谱组成的本质。

而后,物理学家大卫·布儒斯特进一步发现染料的原色只需要红、黄、蓝三种颜色,基于这三种颜色,就可以调配出任何其他颜色。

随着科技的进步,生理学家托马斯杨根据人类眼球的视觉生理特征又提出了新的三原色,即红、绿、蓝三种颜色。

此后,人类开始意识到,色光和颜料的原色及其混合规律是有不同的,这实际上引出的是 加色模式减色模式 两种色彩模式。

减色模式

我们知道,人类并不能直接看到物体本身的颜色,我们看到的物体的颜色,实际上是物体反射的光的颜色。红色的物体,实际上是吸收了除红光以外的所有光,才让唯一的红光可以进入我们的眼球。

因此,在现实世界我们看到的所有不自发光物体的颜色,都应当按照减色模式进行调配和描述,如美术中使用到的颜料、印染工艺中使用的染料等。

减法三原色为青色(C)、品红色(M)、黄色(Y),合称 CMY。而现如今的印刷行业普遍采用的 CMYK 模式,则是因为使用三种颜色的颜料无法正确混合出纯正的黑色(通常是深灰色),因此需要额外单独的黑色(K)染料来印染黑色。

减色法的颜色效果完全依赖于环境光的照射和白纸的反射能力——油墨本身会吸收一部分光,白纸也无法 100% 反射所有光线,并且油墨染料的化学特性限制了其反射光谱的纯度。

加色模式

而针对可以直接发出光线的物体,人类所看到的颜色就直接是其发出光线颜色本身了。

和减色的三原色不同,加法三原色为红(R)、绿(G)、蓝(B),也就是大家熟知的 RGB 模式。

加色法是主动光源。主动光源通常可以发出非常纯净、高饱和度的单色光,并能将亮度提升到很高。这使它能够呈现非常鲜艳、明亮的颜色。

就比如你看到这篇文章时使用的显示器,每个像素都是由红绿蓝三种颜色的发光二极管组成的。

曾经红极一时、如雷贯耳的“周冬雨排列”

由于物理世界的限制,印刷品很难达到显示器那种发光体的亮度和饱和度。

2. 色彩空间

色彩模式告诉你,使用青、品红、黄三种颜色的调料可以调配出任何你想要的颜色,但是却没有告诉你,如果我想要调配出正红色,要用多少青色、多少品红、和多少黄色?

甚至你想要的正红色,其自身都没法用一个统一的标准来表述——这正红得多红才叫正红呀?

想要定量地描述颜色,我们需要引入色彩空间(Color Space)的概念。

CIE XYZ

1931 年,国际照明委员会(CIE)创建了 CIE XYZ 色彩空间,这是第一个基于人眼视觉特性的标准色彩空间。

基于 XYZ 三个坐标,我们可以用唯一确定的数值形式表示出人类肉眼可见的所有颜色。如此一来,我们便能给每一种颜色精准定位了。

sRGB

虽然有了 CIE XYZ 这个“统一语言”,但在 90 年代末,电脑普及和互联网爆发带来了一个极其现实的挑战:显示器的显示能力是有限的,而当时的网络带宽更是寸土寸金。

如果说 CIE XYZ 是一本包含了几十万词条、大而全的《牛津英语大词典》,那么我们在日常交流中,其实只需要一本几千词的《日常口语手册》就足够了。 强行传输 XYZ 这种海量数据,既超出了显示器的承载能力,也拖慢了网速。

为了在显示效果、传输效率和跨设备一致性之间找到那个平衡点,1996 年,微软和惠普选取了当时主流 CRT 显示器(大头电视)荧光粉能发出的红绿蓝,作为基准三原色,由此创造了流行至今的 sRGB 色彩空间,其中 s 意为标准(Standard)

CMYK

与显示器不同,印刷时选取不同的印刷介质和油墨,都会导致最终的印刷效果不同,因此针对特定的纸张和油墨组合,诞生了一系列不同的 CMYK 色彩空间,例如:

  • FOGRA / ISO Coated (欧洲标准): 针对欧洲常用的铜版纸印刷。
  • GRACoL / SWOP (美国标准): 常见于美国的出版物。
  • Japan Color (日本标准): 针对亚洲人视觉偏好的冷调印刷。

3. 色域

为了方便比较,我们通常会将不同的色彩空间统一映射到 CIE XYZ 色彩空间内进行比对。

如果我们将 Z 坐标进行归一化和压缩,再将所有该色彩空间内所有颜色的 X、Y 坐标连起来,就会得到一个封闭的二维图形,这个封闭的图形就是色域(Color Gamut),就是该色彩模式所能表示的颜色范围。

其中马蹄形区域是可见光的色域,通常被称作“全色域”

通过图像我们不难看出,sRGB 的色域并不能完全覆盖 CMYK,这意味着,一个在 sRGB 下能表示出的颜色,在 CMYK 模式下可能根本没有对应的颜色,这会导致风光摄影中一些常见的绿色无法在印刷时体现。因此,传统印刷行业对微软和 Adobe 等公司制定的 sRGB 标准提出了强烈的反对和质疑。

面对印刷业巨头的联合抵制和抗议,微软并没有认怂,他们之间的纠纷战争维持了三年之久,最后在 Adobe 公司的调解下,制定了 Adobe RGB 色域,这一更广阔的色域完美地包含了印刷所需的所有颜色。

但是摄影及印刷行业的从业者毕竟是少数,绝大多数的互联网用户并不需要关心 CMYK 这种印刷时才会遇到的色彩模式,传统的 sRGB 依旧可以满足网上冲浪的全部需求。

此外,更广色域的图片也需要更专业更贵的显示器、搭配专业软件才能正常显示,这也是为什么即便到了今天,sRGB 在互联网领域依旧占据绝对统治地位。

Tips: 显示器的色域

当你挑选显示器的时候,可能常常会听到诸如“120% sRGB”、“97% sRGB”等关键词,这里的百分比,实际上就是显示器色域占 sRGB 色域的范围。如果不考虑专业设计场景,理论上只要显示器能达到 100% 的 sRGB 色域,便可以满足你日常上网的全部需求

而类似“Adobe RGB 100% 色域”、“P3 广色域” 、“杜比视界”等更广阔的色域,随着时代的发展也逐渐有了更多的日常使用场景,如 B 站现在就支持 HDR 杜比视界的视频播放;在大型单机游戏领域,也越来越多地支持的 P3 色域。

四、为什么有色差?

了解了色彩学的基础知识后,我们重新审视一下最开始的那个问题:

我们知道,印刷厂的印刷机,最终印刷一定是使用 CMYK 四种颜色的墨水进行印刷的,因此当我们给出 RGB 原图时,必然经过了印刷厂的一次转换,这可能发生在机器内部,也可能发生在印刷厂的内部系统流程中;

而设计师手动转换色彩空间后,印刷没有色差,这就说明,色差的根源就在于印刷厂的这一次转换!

现阶段,想要将 RGB 转为 CMYK,通常有两种转换方式:

1. 基于基础数学公式

这是最简单、最基础的算法,通常用于不要求颜色精确度的场景。

转换步骤:

  1. 归一化: 将 R, G, B 的值(0-255)除以 255,使其范围变为 0~1
  2. 计算黑色(K):K = 1 - Max(R, G, B)
  3. 计算 C, M, Y:
    • C = (1 - R - K) / (1 - K)
    • M = (1 - G - K) / (1 - K)
    • Y = (1 - B - K) / (1 - K)

注意: 如果 K = 1(纯黑),则 C, M, Y 均为 0

这种算法的思路很朴素:既然 RGB 是加色,CMYK 是减色,那就通过数学关系做个映射。理论上确实可以完成转换,但问题在于——这种纯数学转换完全不考虑现实世界的设备差异。

同样是显示一个红色 RGB(255, 0, 0),不同品牌、不同型号的显示器,实际发出的光的波长和强度都不一样。你的显示器可能偏冷色调,我的显示器可能偏暖色调,但在算法眼里,它们都是 RGB(255, 0, 0)

同样是印刷 C0 M100 Y100 K0,不同的打印机、不同的纸张、不同的油墨,印出来的颜色也千差万别。这家印刷厂的红色油墨偏橙,那家印刷厂的红色油墨偏紫,但算法根本不知道这些差异。

而最令人头疼的是色域映射问题——RGB 能显示的某些鲜艳颜色,比如荧光绿 RGB(0, 255, 0),在 CMYK 的色域里根本没有对应的颜色。算法会强行把它映射成 C100 M0 Y100 K0,但印出来的绿色会明显发灰、发暗,完全不是你在屏幕上看到的那种鲜艳的绿。

纯算法转换假设所有设备都是"标准"的,假设色域可以完美映射,但现实世界里这两个假设都不成立。

2. 基于 ICC 特性文件

这是目前设计软件(如 Adobe Illustrator、Photoshop 等)和专业印刷流程采用的标准方式。

正如上一节中提到,RGB 和 CMYK 都有各自的色彩空间,显示器和打印机之间各说各话,你在显示器上看到的颜色,打印出来可能是另一个颜色。

为了解决这个问题,1993 年,包括 Adobe、Apple、Microsoft、Sun 等八家科技公司联合成立了国际色彩联盟 ICC(International Color Consortium,国际色彩联盟),目标就是建立一个开放、跨平台的色彩管理标准。ICC 配置文件规范也由此诞生。

ICC 来色彩管理界只办三件事:公平!公平!还是他**的公平!

不好意思串台了,但是其实某种意义上来说也没错。ICC 的出现是为了确保"所见即所得",它的最终目标是让你在屏幕上看到的红色,在打印纸上也是同样的红色。

PCS:色彩转换的中间人

为了做到“所见即所得”,ICC 系统引入了一个中间色彩空间 PCS(Profile Connection Space,特性连接空间)。这是一个设备无关的、中介的、与人眼感知相关的色彩空间(通常使用前面提到的 CIE XYZ 或者基于其演化出的 CIE Lab 色彩空间)。

有了 ICC 规范之后,每个设备的 ICC 文件都是通过专业仪器实际测量出来的:

  • 显示器的 ICC 文件:厂商用校色仪测量这台显示器,记录下 RGB(255, 0, 0) 在这台显示器上实际发出的光对应的 Lab 值(比如 Lab(53.23, 80.11, 67.22)

在你系统的显示器设置中,你可以看到当前显示器的颜色描述文件,它通常以你的显示器型号命名

这个文件就是显示器厂商针对这一型号制作的 ICC 文件,其内部包含了整台显示器所能展示的全部颜色,通常会随着驱动文件自动下载到你的电脑中。

大多数厂商所提供的只是一个通用 ICC 文件,实际上,哪怕是相同厂商、相同型号的显示器,受品控、原料批次及使用老化等因素影响,其显示效果也会有细微的差别。在某些对色彩准确性要求比较高的场景下(如影视、平面设计等)通常还需要针对单台设备进行颜色校准,并且制作一份矫正后的 ICC 或 LUT,才能够保证最终产出的图像和肉眼看到的一致。

  • 印刷机的 ICC 文件:印刷厂用分光光度计测量,记录下 C0 M100 Y100 K0 在这台印刷机、这种纸张、这种油墨上实际印出来的颜色对应的 Lab 值(比如 Lab(47.82, 68.30, 48.05)

每个设备的 ICC 文件都描述了该设备色彩空间与 PCS 之间的转换关系,就像不同国家的语言都可以通过英语作为中介进行翻译,如此一来,当你在显示器上看到一张照片并想打印出来时,只要经过如下转换:

  1. 显示器的 ICC 配置文件把 RGB 信号转换到 Lab 色彩空间
  2. 印刷机的 ICC 配置文件再把这个 Lab 值翻译成印刷机需要的 CMYK 信号

因为 Lab 是基于人眼感知的绝对色彩空间,所以这样转换后,你在屏幕上看到的红色,和印刷出来的红色,在人眼看来就是同一个颜色了。反之亦是同理。

渲染意图:当色域溢出时怎么办?

虽然 ICC 文件可以实现从 RGB 到 CMYK 的双向映射,但是还记得我们前文提到的 RGB 的色域要比 CMYK 更广吗?这必然会导致有部分 RGB 颜色,无法和 CMYK 颜色进行映射。这时就轮到 渲染意图(Rendering Intent) 登场了。

在 ICC 规范中,一共有四种法定意图,它们决定了如何处理色域外的颜色。

可感知意图(Perceptual)

可感知意图的核心原理是等比例压缩,以 RGB 转 CMYK 为例,它将 RGB 的色域等比例缩放到 CMYK 的色域,颜色之间的相对关系(层次、过渡)保留得比较好。虽然整体饱和度可能会稍微下降,但图片看起来非常自然,不会有色块断层。

可以看出,图片虽然整体饱和度下降,但是颜色渐变过渡被保留得很好,不存在明显的断层,文本颜色也依旧可以辨识。

相对比色意图(Relative Colorimetric)

相对比色意图的核心逻辑是精准对齐 + 硬性裁剪,同样以 RGB 转 CMYK 为例,如果颜色在 CMYK 的色域内,就不会做任何改动;如果颜色超出了 CMYK 的色域就会直接截取为 CMYK 的边缘色彩。

这种方式转换的颜色最"准",因为它尽可能保持了大部分原始数值。但在极鲜艳、极暗的区域,可能会出现"并色"(Clipping)现象,即原本有层次的颜色变成了相同的颜色,丢失了层次感。

可以看出,图片在中部颜色没有溢出的部分保持了相同的色彩,但在两侧出现了较为明显的色域断层和边界。边界外颜色的渐变效果已被截断,且和同样超出色域范围的文本颜色被压缩成了相同的颜色,导致文本无法辨识。

此外还有饱和度意图(Saturation)和绝对比色意图(Absolute Colorimetric),由于篇幅限制这里就不多做赘述了。

黑场补偿:保留暗部细节

可感知意图为了让所有颜色都能塞进目标色域,会移动所有颜色(甚至是那些本来就在色域内的颜色)。这意味着你看到的颜色虽然“和谐”,但已经不再是原始定义的那个准确的数值了,色差会比较明显。

而相对比色虽然尽可能多地保证了色准,但是面对色域外的颜色(尤其是深色)时又极易丢失细节

左图为 RGB 原图,右图为 CMYK 使用相对比色意图,不开启黑场补偿

可以看出白框中蓝莓的暗区细节已经完全丢失

那么有没有办法,能够让我们在保证色准的同时,尽可能多地保留暗部细节呢?

有的兄弟,有的,这门技术就是黑场补偿(Black Point Compensation)

黑场补偿的原理,本质上就是将原图的暗区进行缩放:它会先找到源文件(RGB)中最黑的点,再找到目标输出(CMYK)能达到的最黑的点,并将整个画面的亮度范围进行等比例的"缩放",让 RGB 的黑点刚好对应上 CMYK 的黑点。

如此一来,原本深灰和全黑之间的相对比例就被保留了下来,虽然整体看起来可能没那么深邃了,但暗部的细节纹理被成功"挤"进了 CMYK 能表达的范围内。

开启了黑场补偿后,可以看出暗区细节被完好地保留了下来

并非所有 ICC 文件的可感知意图都完美

除了解决相对比色意图的暗部细节丢失以外,BFC 也同样可以给可感知意图兜底。

我们知道,ICC 文件是由厂商自行制作的,那必然会出现:有些厂商的“可感知”算法做得很好,暗部过渡自然;而有些厂商的算法却过于保守,或者在处理某些特定颜色时产生了意料之外的偏色。

而 BPC 是一种标准化的算法(由 Adobe 提出并贡献给 ICC)。它不依赖于 ICC 内部复杂的查表映射,而是在转换阶段进行一次数学上的端点对齐。因此,BPC 提供了一层额外的保险,确保无论你使用哪种意图,最黑的点始终能对应到输出设备的最黑点。

在 Photoshop、Illustrator 等软件中,通常建议默认开启黑场补偿;而部分图像处理工具则可能不提供这一功能。

五、如何解决?

到这里,我们几乎可以确定了色差的根源,原因无非以下几个:

  • 印刷厂根本直接用的算法公式转换
  • 印刷厂的转换工具不支持渲染意图和黑场补偿
  • 印刷厂的渲染意图和黑场补偿选错了
  • 印刷厂用的 ICC 文件不对

但是不管到底是哪个问题,我们都有一个万能的解法——将 RGB 原图按照设计师的要求一比一转好后,再发给印刷厂。毕竟设计师转出来的发过去,印出来就是对的嘛。

依葫芦画瓢,和设计师一番沟通之后,我们确定了转换的过程与目标:

  • RGB 原图:ICC 文件使用浏览器内置的 sRGB IEC61966-2.1,这是 Canvas 导出图片的默认配置
  • CMYK 转换:使用 Adobe Illustrator 软件中的默认预设——日本常规用途2
    • ICC 文件:Japan Color 2001 Coated
    • 渲染意图:可感知
    • 黑场补偿:开

方案确定了,接下来进行技术调研吧。

六、技术选型

我们最初的调研方向是使用服务端转换,因为相对成熟的 npm 包大多都只支持 Node 环境,而非浏览器环境。

1. Sharp

首先,我们找到的是 Sharp 这个 Node 库,其底层基于 C/C++libvips,宣称_比使用最快的 ImageMagick 和 GraphicsMagick 设置快 4 到 5 倍_,在 Node 中可以开箱即用,也是大多数 Node 应用的首选。

使用 Sharp 完成 RGB 到 CMYK 的转换非常简单,核心代码仅四行:

import { Injectable } from '@nestjs/common';
import * as sharp from 'sharp';

@Injectable()
export class ImageService {
  async transformToCMYK(file: Express.Multer.File): Promise<Buffer> {
    return sharp(file.buffer)
      .withIccProfile('./profiles/JapanColor2001Coated.icc')
      .jpeg({ quality: 100, chromaSubsampling: '4:4:4' })
      .toBuffer();
  }
}

美中不足的是,Sharp 毕竟是一个精简的图像处理框架,它仅支持纯算法和纯 ICC 文件的 CMYK 转换,前文提到的渲染意图和黑场补偿等均未支持。

2. ImageMagick

ImageMagick 是一个非常老牌的图像处理框架,堪比音视频领域的 ffmpeg。而最重要的是它支持指定渲染意图和开启黑场补偿。

本地安装后,你可以使用如下命令行命令来实现 RGB 到 CMYK 的转换:

magick convert input.jpg \
  -profile "sRGB_v4_ICC_preference.icc" \
  -intent Relative \
  -black-point-compensation \
  -profile "Your_Target_CMYK.icc" \
  output.jpg

除了直接使用命令行调用二进制文件,我们还可以使用 magickwand.js,这是一个基于 swigemnapi 的库,同时实现了 Node.js 原生和浏览器 WASM 版本。

magickwand.js 的 Node.js 原生版本专为与 Express.js 等框架配合使用而设计,非常适合服务器端应用。官方文档宣称它_经过内存泄漏调试,并且在仅使用异步方法时,绝不会阻塞事件循环_。

在 Node 中使用 magickwand.js 也非常简单,代码示例如下:

import { Injectable, Logger } from "@nestjs/common";
import { Intent } from "./dto/cmyk.dto";
import { Magick } from "magickwand.js/native";
import * as fs from "fs";
import * as path from "path";

@Injectable()
export class ImageService {
  private logger = new Logger(ImageService.name);
  private readonly profiles: Record<string, Magick.Blob> = {};

  constructor() {
    // Japan Color 2001 Coated
    this.loadIccProfile(
      "JapanColor2001Coated",
      "./profiles/JapanColor2001Coated.icc"
    );
    // 普通CMYK描述文件
    this.loadIccProfile(
      "Generic CMYK Profile",
      "./profiles/Generic CMYK Profile.icc"
    );
  }

  private loadIccProfile(profileName: string, profilePath: string) {
    if (this.profiles[profileName]) {
      this.logger.warn(`${profileName} 配置文件已存在,跳过加载`);
      return;
    }

    const fullPath = path.join(__dirname, profilePath);
    const buffer = fs.readFileSync(fullPath).buffer;
    const blob = new Magick.Blob(buffer);
    this.profiles[profileName] = blob;
  }

  async transformToCMYK(
    file: Express.Multer.File,
    intent: Intent,
    blackPointCompensation: boolean
  ): Promise<Buffer> {
    const inputBlob = new Magick.Blob(file.buffer.buffer as ArrayBuffer);
    const inputImage = new Magick.Image(inputBlob);
    // 指定渲染意图
    await inputImage.renderingIntentAsync(intent);
    // 设置黑场补偿
    await inputImage.blackPointCompensationAsync(blackPointCompensation);
    // 转换 ICC 配置文件
    await inputImage.iccColorProfileAsync(
      this.profiles["JapanColor2001Coated"]
    );
    // 指定输出格式
    await inputImage.magickAsync("JPEG");

    const outputBlob = new Magick.Blob();
    await inputImage.writeAsync(outputBlob);
    const outputBuffer = await outputBlob.dataAsync();

    return Buffer.from(outputBuffer);
  }
}

这个库的主要问题是它没有 JS/TS 的文档,只有 C/C++ 的文档,使用时往往需要你根据 TS 的参数类型连蒙带猜去传参。

3. PIL/Pillow

除了使用 Node,在 Python 中我们也有很多的选择,例如 PIL/Pillow,它同样非常强大易用,代码示例如下:

from PIL import Image, ImageCms

img = Image.open("input.jpg")
rgb_profile = ImageCms.getOpenProfile("sRGB Color Space Profile.icm")
cmyk_profile = ImageCms.getOpenProfile("JapanColor2001Coated.icc")

transform = ImageCms.buildTransform(
    rgb_profile,
    cmyk_profile,
    "RGB",
    "CMYK",
    renderingIntent=ImageCms.Intent.RELATIVE_COLORIMETRIC,  # 相对比色
    flags=ImageCms.Flags.BLACKPOINTCOMPENSATION,  # 黑场补偿
)

cmyk_img = ImageCms.applyTransform(img, transform)
cmyk_img.save("output.jpg", quality=95, icc_profile=cmyk_profile.tobytes())

七、困难重重

既然有这么多现成的库,而且代码看着也没多少,一定很好实现吧。

很可惜,理想很美好,现实很悲催。在实际落地过程中,我们遇到了很多问题。

问题一:CI/CD 构建失败

最开始,我们选择了功能最完善的 magickwand.js。它天然支持渲染意图和黑场补偿,正好满足我们的需求。本地编码调试一切正常,但提交到 CI/CD 平台后,构建直接失败了:

排查后发现,magickwand.js 依赖 xpm 这个 C/C++ 包管理器。在执行 npm install 时,xpm 会去 npm 源查找 package.json 中声明的 xpack 字段,然后从 GitHub 下载对应平台的二进制文件:

{
  "xpack": {
    "binaries": {
      "baseUrl": "https://github.com/xpack-dev-tools/ninja-build-xpack/releases/download/v1.13.1-1",
      "platforms": {
        "darwin-arm64": {
          "fileName": "xpack-ninja-build-1.13.1-1-darwin-arm64.tar.gz",
          ...
        },
        ...
      }
    }
  }
}

构建容器内无法访问 Github,这个问题我们无法解决,只能放弃 magickwand.js,转而考虑其他方案。

实际上我们还有一个方案,就是绕过 xpm,直接将预编译好的 ImageMagick 二进制文件都下载到本地,然后在 Node 中写一个平台适配层,封装下命令调用,也可以满足需求。

但是使用 child_process 来调用会有很多问题:

  1. 性能开销大:涉及进程创建、销毁和上下文切换成本;
  2. 通信效率低:需通过标准输入输出进行数据序列化与反序列化,增加了额外的处理延迟;
  3. 并发控制复杂:需手动管理进程池和资源竞争,避免系统资源耗尽;
  4. 异步编程繁琐:必须处理流控制、背压和错误恢复机制,代码复杂度显著增加;
  5. 稳定性风险高:子进程崩溃可能影响主进程稳定性,且进程间状态难以共享。

综合考虑下来,这个也只能作为实在没有办法的备选,不应当作为首选方案。

问题二:图像传输的性能瓶颈

除了构建上的难题,最致命的实际是后端处理所带来的用户体验问题。

在我们的业务场景中,设计稿需要以 300 DPI 导出,一张海报的分辨率通常是 7087×9449,RGB 原图约 30MB;而门店横幅、围挡等大尺寸设计稿,原图甚至会达到 100MB+。

虽然前段时间运维升级了公司的网络带宽,由原先的 25Mb 调整到 100Mb,但是即便是跑满带宽,下载速度也只能达到约 12MB/s,而这还是建立在不考虑服务器带宽的前提下,完整的转换流程仍然需要:

  1. 前端上传原图到后端(30-100MB 上行)
  2. 后端处理转换(4 核 8G 的处理器需要 10s 以上的处理时间)
  3. 后端返回 CMYK 图片(30-100MB 下行)
  4. 前端手动上传到 OSS(30-100MB 再次上行)

整个 RTT 实测下来超过了 100 秒,还要承受网络波动导致传输失败的风险。这种体验完全无法接受。

我们也想过优化方案——把 Fabric 的渲染逻辑移到服务端:

  1. 前端只传 JSON 配置文件(体积小)
  2. 后端用 fabric + node-canvas 渲染图片
  3. 就地转换为 CMYK 并直接上传 OSS
  4. 返回图片 URL

理论上可以减少一次上行和一次下行,将 RTT 缩短至 30 秒以内。但这个方案评估下来,问题更多:

1. 渲染场景复杂,迁移成本极高

我们有两个场景需要适配:

  • 设计师编辑模板:直接导出 Canvas 内容
  • 加盟商生成终稿:先替换占位内容,再导出;还需前置生成低分辨率预览稿,以及展示处理进度

如果将 Fabric 渲染逻辑迁移到 Node:

  • 一套代码适配:需要从头梳理两套逻辑的异同点,工作量巨大
  • 两套代码分离:后续维护成本会直线上升

而且前端现有的历史渲染代码本就错综复杂,要保证 Node 生成的图片和浏览器完全一致,需要投入更多的开发和测试资源。

2. 字体合规风险

设计团队使用的字体都是免费或商业授权的,但大多数字体的授权范围仅限于桌面使用。如果把字体文件上传到服务器,属于"网络传播"或"网络嵌入"用途,需要单独授权。

要合法使用服务端渲染,我们需要:

  1. 对所有免费、商业字体进行全面审计
  2. 申请新的适用范围授权(费时费力,成本高昂)

这期间,一旦出现纰漏,可能收到律师函、侵权通知或高额赔偿。

作为一家上市公司,古茗在全国有上万家加盟门店。如此大的体量,任何合规风险都可能给公司造成无法估量的损失。

3. 服务端性能问题

使用服务端渲染还有一个绕不过的问题就是性能问题,在服务端执行图像处理,同样需要耗费 CPU 和内存性能,我们需要对使用场景进行梳理,根据埋点信息统计出调用频次,以评估接口性能,并对接口进行压测。如果性能不能满足,我们还需要申请更高配置的服务器。

这同样需要我们花费更多的时间,测试资源本就紧张,难以协调,线上稳定性也难以保障。

客户端方案的探索

服务端方案成本太高,必须另寻出路,而客户端方案,JS 处理肯定是不行了,性能太差。而除了 JS 我们还有一条路可以走——WebAssembly。

ImageMagick 是用 C/C++ 编写的,理论上我们可以用 Emscripten 编译为 WASM。但想要打通整条链路,我们需要:

  1. 搭建 emscripten 环境
  2. 使用 cmake/autotools 编译依赖库
  3. 链接和编译 ImageMagick 主代码库
  4. 编写 JS/WASM 胶水层代码

参考:WebAssembly实战-在浏览器中使用ImageMagick-腾讯云开发者社区-腾讯云

这套流程虽然很明确,但学习和上手成本确实不低。受限于工期,我们先尝试寻找现成的方案:

1. magickwand.js WASM 版本

magickwand.js 本身就提供了 WASM 版本,但使用后发现它依赖 SharedArrayBuffer,这要求启用跨域隔离(Cross-Origin Isolation)。这不仅需要改造现有的构建脚手架,发布时还需要改造网关配置。加之这个库之前在 CI/CD 环节就有问题,我们只能放弃。

2. 其他 WASM 库

ImageMagick 官网推荐的 WASM-ImageMagick 已经 6 年没更新了。我们在 npm 上找到了 @imagemagick/magick-wasm,其作者是 ImageMagick 的核心开发者之一,下载量排名靠前,更新活跃,非常可靠。

最重要的是,它不存在我们前面提到的任何一个问题!

八、工程接入

问题解决,接下来只需要将 magick-wasm 接入到工程中即可。

1. 前置准备

magick-wasm 这个库内部使用 BigInt,如果你的 browserslist 指定版本过低,Babel 编译时可能会报错,添加一个 supports bigint 即可:

{
  "browserslist": [
    "supports bigint",
    "not dead"
  ]
}

2. WASM 初始化

我们需要在页面组件中加载 WASM 模块,这里我们要求必须初始化成功,因为如果 WASM 模块无法加载,设计师转换色彩模式失败,仍会影响后续印刷。

const WASM_LOCATION = new URL('@imagemagick/magick-wasm/magick.wasm', import.meta.url);

const App: React.FC = () => {
  useMount(() => {
    setLoading(true);
    initializeImageMagick(WASM_LOCATION)
      .then(() => console.log('ImageMagick 初始化成功'))
      .catch(() => {
        const message = 'ImageMagick 初始化失败';
        CustomReport.sendWarning(ArmsLogs.initializeImageMagickFailed, { message });
        Modal.error({
          title: message,
          content: '请使用最新版本的 Chrome 浏览器!',
          onOk: () => window.close(),
        });
      })
      .finally(() => setLoading(false));
  });
}

初始化逻辑中需要注意添加 Loading 提示,因为初始化 WASM 是需要通过网络请求获取 .wasm 文件的,如果网速过慢就有可能导致触发转换时 WASM 模块还没有初始化完成。

此外,在初始化失败时还要接入埋点告警,以便我们感知线上的使用情况。

3. 色彩模式转换

这部分的核心转换逻辑也并不多,大致流程如下:

const RGB_PROFILE_LOCATION = new URL('@/assets/icc/sRGB Color Space Profile.icm', import.meta.url);
const CMYK_PROFILE_LOCATION = new URL('@/assets/icc/JapanColor2001Coated.icc', import.meta.url);

const readFile = async (url: URL): Promise<Uint8Array> => {
  const response = await fetch(url);
  const arrayBuffer = await response.arrayBuffer();
  return new Uint8Array(arrayBuffer);
};

export const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  const [rgbProfileUint8Array, cmykProfileUint8Array] = await Promise.all([
    readFile(RGB_PROFILE_LOCATION),
    readFile(CMYK_PROFILE_LOCATION),
  ]);
  const rgbProfile = new ColorProfile(rgbProfileUint8Array);
  const cmykProfile = new ColorProfile(cmykProfileUint8Array);

  return new Promise((resolve, reject) => {
    ImageMagick.read(uint8Array, MagickFormat.Jpeg, (image) => {
      image.blackPointCompensation = true;
      image.renderingIntent = RenderingIntent.Perceptual;
      /**
       * 必须同时指定 source 和 target,否则在 safari 下会有 bug
       * https://github.com/dlemstra/magick-wasm/blob/main/src/magick-image.ts#L3976
       * safari canvas 导出的图片无法检测出 icc,会导致转换失败
       */
      const success = image.transformColorSpace(
        rgbProfile,
        cmykProfile,
        ColorTransformMode.HighRes
      );
      if (!success) {
        message.error('色彩空间转换失败!');
        CustomReport.sendWarning(ArmsLogs.colorSpaceTransformFailed, {
          message: '色彩空间转换失败!',
        });
        reject(new Error('色彩空间转换失败!'));
      } else {
        image.write(MagickFormat.Jpeg, (result) => {
          // 需要拷贝一份,否则 result 会被 GC 回收
          resolve(new Uint8Array(result));
        });
      }
    });
  });
};

但是这里有两个坑点需要注意:

  1. Safari 下 ICC 检测失败

transformColorSpace 在源码中判断了图像是否内嵌了 profile,如果没有嵌入,会直接返回失败。

源码位置:github.com/dlemstra/ma…

在 Chrome 中通过 Canvas 导出的图片,调用 ImageMagick 查询 ICC 文件时可以正常找到,但是通过 Safari 导出的图片则无法检出。

奇怪的是,使用 macOS 自带预览查看颜色描述文件信息时却恰好得到了相反的结果——使用 Safari 导出的图片正确嵌入了 sRGB IEC61966-2.1 文件,而 Chrome 导出的图片却没有显示颜色描述文件。

这个问题笔者没有深入研究,如果有了解原因的朋友也欢迎在评论区回复解答下疑惑

因此在 Safari 下 transformColorSpace 方法不会执行任何操作,直接返回了 true。

阅读源码后发现要规避这个问题,只需要同时传入 source 和 target 即可:

const RGB_PROFILE_LOCATION = new URL('@/assets/icc/sRGB Color Space Profile.icm', import.meta.url);
const CMYK_PROFILE_LOCATION = new URL('@/assets/icc/JapanColor2001Coated.icc', import.meta.url);

export const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  const [rgbProfileUint8Array, cmykProfileUint8Array] = await Promise.all([
    readFile(RGB_PROFILE_LOCATION),
    readFile(CMYK_PROFILE_LOCATION),
  ]);
  const rgbProfile = new ColorProfile(rgbProfileUint8Array);
  const cmykProfile = new ColorProfile(cmykProfileUint8Array);

  return new Promise((resolve, reject) => {
    ImageMagick.read(uint8Array, MagickFormat.Jpeg, (image) => {
      image.blackPointCompensation = true;
      image.renderingIntent = RenderingIntent.Perceptual;
      /**
       * 必须同时指定 source 和 target,否则在 safari 下会有 bug
       * https://github.com/dlemstra/magick-wasm/blob/main/src/magick-image.ts#L3976
       * safari canvas 导出的图片无法检测出 icc,会导致转换失败
       */
      const success = image.transformColorSpace(
        rgbProfile,
        cmykProfile,
        ColorTransformMode.HighRes
      );
      if (!success) {
        reject(new Error('色彩空间转换失败!'));
      } else {
        image.write(MagickFormat.Jpeg, resolve);
      }
    });
  });
};

当然别忘记在代码中留下对应的注释说明,防止后人维护重复踩坑。

  1. WASM GC 导致数据丢失

image.write 回调中的 data 对象来自 magick-wasm 的内存,它的生命周期不受 JS 控制,回调结束或后续写入时那段内存可能已经被复用/释放。

要解决这个问题也很简单,原地复制一份即可:

image.write(MagickFormat.Jpeg, (data) => {
  // 需要拷贝一份,否则 result 会被 GC 回收
  resolve(new Uint8Array(data));
});

同样留下一个贴心的注释,后续只需适配对应的业务代码即可

4. 性能优化

功能是实现了,但业务实际用下来还是发现不少问题,主要集中在性能方面。

业务使用的是统一采购的 16G 的 M1 芯片 iMac,按理来讲不会卡,但是深入了解了业务的操作习惯后,发现了几个很有意思的点:

  • 业务习惯同时多开 4、5 个标签页,同时操作
  • 业务在页面操作的同时,本地会开着 AI/PS 以方便作图

虽然 WebAssembly 运行速度非常快,但它与 JavaScript 共享同一个事件循环(Event Loop)。如果你在主线程直接调用一个耗时较长的 WASM 函数,它依然会阻塞 UI 响应,导致页面卡顿。

在现代浏览器中,同一个域名的不同标签页,通常也是共用的同一个进程,这还会导致,我们在一个标签页下处理图像,同域的其他标签页也无法操作(主线程被阻塞),浏览器还会弹出页面无响应的提示

因此,我们还需要做针对性的性能优化。

Worker 多线程

性能优化的第一步,就是将 WebAssembly 从主线程中移出去。我们可以使用 Web Worker 将 WASM 的逻辑单独放在 worker 线程中执行,从而避免阻塞主线程。

想要使用 worker 很简单,你只需要创建一个 worker.js 文件,随后在主线程中使用:

const myWorker = new Worker("worker.js");

即可将 worker.js 中的代码放在独立的 worker 线程中执行。

注意这里不能用 SharedWorker,一方面 Safari 长期以来对 SharedWorker 支持不佳,另一方面 SharedWorker 更多使用在是跨标签通信,或者某些需要共享资源的场景,对于上面提到的多标签并发图像处理反而起到负作用(多个标签共享一个 Worker,处理是串行的),无法最大程度利用现代多核 CPU 的性能。

此外,由于单个标签页可能会触发多次图像处理,我们还可以使用单例模式减少重复的 WASM 初始化,从而进一步优化性能,代码示例如下:

// Worker 实例
let workerInstance: Worker | null = null;

/**
 * 获取 Worker 实例(单例模式)
 */
const getWorker = (): Worker => {
  if (!workerInstance) {
    workerInstance = new Worker(new URL('./magick.worker.ts', import.meta.url), { 
      type: 'module' 
    });
    // 监听 Worker 返回的消息
    workerInstance.onmessage = (event) => {};
    // 监听 Worker 错误
    workerInstance.onerror = (error) => {};
  }
  return workerInstance;
};

Worker 同源限制

在上线前我们还遇到一个问题,我们的前端构建产物是托管在 OSS 上的,这里使用 new URL 获取到的 worker 资源不同源,导致无法加载。

为了解决这个问题,我们将 worker 内部的逻辑单独抽离到一个 npm 包中,连同依赖项一起打包成 UMD 格式,在业务工程中通过 fetch 方式获取脚本内容。

const WORKER_URL = new URL('@guming/magick-worker/build/umd/index.js', import.meta.url);
// Fetch worker 文件内容
const response = await fetch(WORKER_URL);
const workerCode = await response.text();
// 创建 Blob 和 Blob URL
const blob = new Blob([workerCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
// 创建 Worker
const worker = new Worker(blobUrl);

如果你使用 Vite,也可以使用 Vite 的 import MyWorker from'./worker.js?worker'语法。

或者也可以使用 remote-web-worker 这样的库来少写点代码。

Comlink 零拷贝传输

Worker 通过 postMessage 与主线程通信,数据传输有两种模式:

  1. 结构化克隆(Structured Clone)

这也是最常用的一种写法,代码示例如下:

const worker = new Worker('worker.js');
const imageBuffer = new ArrayBuffer(100 * 1024 * 1024);

worker.postMessage({ type: 'process', data: imageBuffer });

这种方式会为接收方创建一个数据的完整副本。对于 100MB 的图片,传输瞬间会导致内存占用翻倍(变为 200MB)。如果是 5 个标签页同时操作,内存峰值将迅速堆叠,引发浏览器 OOM(内存溢出)崩溃。

  1. 可转移对象(Transferable Objects)

除了结构化克隆之外,worker 还提供了一种允许你直接转交对象内存的方式,代码示例如下:

const worker = new Worker('worker.js');
const imageBuffer = new ArrayBuffer(100 * 1024 * 1024);

worker.postMessage(
  { type: 'process', data: imageBuffer },
  [imageBuffer]  // 第二个参数:要转移的对象列表
);

// 转移后,imageBuffer 在主线程不可用
console.log(imageBuffer.byteLength); // 0 —— 所有权已转移

通过这种方式,我们可以避免对大对象进行拷贝,从而减少通信时上下文结构化的性能开销。

在实际开发工作中,我们通常还需要写一套复杂的事件通信逻辑,来保障和 worker 之间的通信,代码可能长这样:

// 主线程
let workerInstance: Worker | null = null;
let messageId = 0;
const pendingRequests = new Map<number, { resolve: Function; reject: Function }>();

/**
 * 获取 Worker 实例(单例模式)
 */
const getWorker = (): Worker => {
  if (!workerInstance) {
    workerInstance = new Worker(new URL('./magick.worker.ts', import.meta.url), { 
      type: 'module' 
    });

    // 监听 Worker 返回的消息
    workerInstance.onmessage = (event) => {
      const { id, type, data, error } = event.data;
      const request = pendingRequests.get(id);

      if (request) {
        if (type === 'success') {
          request.resolve(data);
        } else if (type === 'error') {
          request.reject(new Error(error));
        }
        pendingRequests.delete(id);
      }
    };

    // 监听 Worker 错误
    workerInstance.onerror = (error) => {
      console.error('Worker error:', error);
      // 拒绝所有等待中的请求
      pendingRequests.forEach(({ reject }) => reject(error));
      pendingRequests.clear();
    };
  }
  return workerInstance;
};

/**
 * 向 Worker 发送消息并等待响应
 */
const sendMessageToWorker = <T>(
  method: string, 
  data?: any,
): Promise<T> => {
  return new Promise((resolve, reject) => {
    const id = messageId++;
    const worker = getWorker();
    // 保存 promise 的 resolve 和 reject
    pendingRequests.set(id, { resolve, reject });
    // 发送消息到 Worker
    worker.postMessage({ id, method, data });
  });
};

const initializeWorker = (): Promise<void> => {
  return sendMessageToWorker('initializeWorker');
};

export const transformColorSpace = (uint8Array: Uint8Array): Promise<Uint8Array> => {
  return sendMessageToWorker<Uint8Array>('transformColorSpace', uint8Array);
};
// worker
import { initMagick, ImageMagick, MagickImage } from '@imagemagick/magick-wasm';

let initialized = false;

const initializeWorker = async (): Promise<void> => {};
const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {};

// 监听主线程的消息
self.onmessage = async (event) => {
  const { id, method, data } = event.data;
  
  try {
    let result;
    // 根据方法名调用对应的函数
    switch (method) {
      case 'initializeWorker':
        await initializeWorker();
        result = undefined;
        break;
      case 'transformColorSpace':
        result = await transformColorSpace(data);
        break;
      default:
        throw new Error(`Unknown method: ${method}`);
    }
    // 返回成功结果
    self.postMessage({ id, type: 'success', data: result });
  } catch (error) {
    // 返回错误
    self.postMessage({ id, type: 'error', error });
  }
};

比较复杂,有一定的学习和理解成本。我们可以使用 Comlink 库来封装 worker 的通信逻辑,从而避免手动维护一套事件通信逻辑,代码可以精简如下:

// 主线程
import * as Comlink from 'comlink';
import type { WorkerApi } from './magick.worker';

let workerInstance: Worker | null = null;
let workerApi: Comlink.Remote<WorkerApi> | null = null;

const getWorkerApi = (): Comlink.Remote<WorkerApi> => {
  if (!workerApi) {
    workerInstance = new Worker(new URL('./magick.worker.ts', import.meta.url), { type: 'module' });
    workerApi = Comlink.wrap<WorkerApi>(workerInstance);
  }
  return workerApi;
};

export const initializeWorker = async (): Promise<void> => {
  const api = getWorkerApi();
  await api.initializeWorker();
};

export const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  const api = getWorkerApi();
  return api.transformColorSpace(Comlink.transfer(uint8Array, [uint8Array.buffer]));
};
// worker
import * as Comlink from 'comlink';

const initializeWorker = async (): Promise<void> => {};

const transformColorSpace = async (uint8Array: Uint8Array): Promise<Uint8Array> => {
  return new Promise((resolve, reject) => {
    ImageMagick.read(uint8Array, MagickFormat.Jpeg, (image) => {
      ...
      image.write(MagickFormat.Jpeg, (result) => {
        const output = new Uint8Array(result);
        // 使用 Transferable,避免大数据复制
        resolve(Comlink.transfer(output, [output.buffer]));
      });
    });
  });
};

const workerApi = {
  initializeWorker,
  transformColorSpace,
};

export type WorkerApi = typeof workerApi;

Comlink.expose(workerApi);

写法非常简单,仿佛根本没有 worker 的存在,Comlink 帮你封装了所有通信的细节。

静态资源缓存

原先的 transformColorSpace 写法中,每次执行都会重复请求一次 ICC 文件,我们完全可以将请求做前置缓存,统一放到 initializeWorker 内部,实测下来可以减少每次 2s 以上的重复请求耗时:

/**
 * 初始化 ImageMagick WASM
 */
const initializeWasm = async (wasmUrl: string): Promise<void> => {
  const wasmBytes = await readFile(wasmUrl);
  await initializeImageMagick(wasmBytes);
};

/**
 * 初始化 ICC profiles
 */
const initializeProfiles = async (rgbProfileUrl: string, cmykProfileUrl: string): Promise<void> => {
  const [rgbProfileUint8Array, cmykProfileUint8Array] = await Promise.all([
    readFile(rgbProfileUrl),
    readFile(cmykProfileUrl),
  ]);
  const rgbProfile = new ColorProfile(rgbProfileUint8Array);
  const cmykProfile = new ColorProfile(cmykProfileUint8Array);
  profiles = { rgb: rgbProfile, cmyk: cmykProfile };
};

/**
 * 初始化 Worker
 */
const initializeWorker = async (config: {
  wasmUrl: string;
  rgbProfileUrl: string;
  cmykProfileUrl: string;
}): Promise<void> => {
  if (initialized) return;
  return Promise.all([
    initializeWasm(config.wasmUrl),
    initializeProfiles(config.rgbProfileUrl, config.cmykProfileUrl),
  ]).then(() => {
    initialized = true;
  });
};

5. 性能测试

我们将优化前后各操作的性能进行对比,测试基准条件如下:

  • 图片大小:127.3MB
  • 芯片:Apple M4
  • 核心数:10(4 性能和 6 能效)
  • 内存:32G
  • 浏览器:Chrome 144.0.7559.110(正式版本) (arm64)

单标签处理性能

阶段 主线程方案 Worker 方案(结构化克隆) Worker 方案(零拷贝传输)
初始化 - 619.20ms 730.10ms
图像处理 42710.70ms(42.71s) 48494.60ms(48.5s) 48281.70ms(48.27s)
通信耗时 - 61.40ms 53.00ms
组装 Blob 74.15ms 140.80ms 154.00ms
总耗时 42784.85ms(42.79s) 48696.8ms(48.7s) 48494.60ms(48.5s)

大图的处理时间稍长,实际上处理 20M 左右的图片,处理速度均控制在 10-20s 内。

多标签并发处理性能

指标 主线程方案 Worker 方案
标签 1 完成时间 43.25s 45.17s
标签 2 完成时间 40.39s 42.28s
标签 3 完成时间 无法处理 41.84s
全部完成时间 页面等待超 5 分钟才可以交互 45.17s
其他标签是否卡顿 所有同域标签全部卡死

内存使用对比

在 Chrome 中可以使用 performance.memory 获取当前的内存使用情况,其中返回对象的 jsHeapSizeLimit 字段表示当前 JavaScript 页面可以使用的最大堆内存限制。

在 64 位系统中,物理内存大于 16G 的,堆内存最大限制为 4G;小于等于 16G 的,最大堆内存限制为 2G。

在 32位系统中,最大堆内存限制为 1G。

参考:Performance.memory - Web API | MDN

场景 主线程方案 Worker 方案(结构化克隆) Worker 方案(零拷贝传输)
初始化前 536.96 MB 134.92 MB 153.93 MB
初始化后 653.57 MB 171.09 MB 156.86 MB
Blob组装前 653.57 MB 238.52 MB 230.86 MB
发送前 3105.70 MB (对应图像处理中) 355.71 MB 348.05 MB
接收后 415.65 MB 364.07 MB
Blob 组装后 3105.70 MB 415.65 MB 364.07 MB

在主线程方案的测试过程中,第二个标签页在处理图像过程中,堆内存来到了 5492.76 MB,已经超出了 4G 的堆内存限制,这直接导致了第三个标签页的白屏崩溃。而 Worker 方案,页面全部正常展示 Loading,未出现白屏等情况,所有页面几乎同时输出了转换后的图片。

设计师使用的设备为公司统一采购的 M1 芯片 iMac,16G 内存。

在设计师的机子上 Chrome 最大堆内存限制为 2G,主线程方案仅支持同时开启一个标签页处理

优化效果总结

  1. 稳定性:突破 4GB 堆内存瓶颈

这是本次优化最显著的成果。在 64 位 Chrome 中,即便物理内存高达 32GB,单个标签页的 JS 堆内存限制(jsHeapSizeLimit)通常仍被锁定在 4GB

主线程方案在处理 120MB+ 大图时,瞬时内存飙升至 3.1GB。当开启 3 个标签页并发处理时,内存占用迅速叠加至 5.5GB 左右,触发 OOM,导致浏览器标签页直接白屏崩溃

通过将计算密集型任务移出主线程,主线程内存始终维持在 300MB-400MB 的较低水平。Worker 方案成功绕过了单线程堆内存限制,实现了 5 个以上标签页的稳定并发。

  1. 用户体验:从“全域卡死”到“流畅加载”

主线程方案在处理期间,由于执行栈被 ImageMagick 完全阻塞,导致同域下的所有标签页失去响应,用户无法进行任何交互。

Worker 方案虽然在单线程处理耗时上略慢于主线程(约增加 13% 的上下文开销),但它保证了 UI 的绝对响应速度。用户在处理百兆大图的同时,依然可以平滑地切换标签页、点击按钮或观看 Loading 动画。

  1. 数据传输优化:零拷贝的价值

使用结构化克隆时,数据发送前后有 60MB 的内存差值,而零拷贝将内存波动降至 16MB,在大数据量下,这个差距会随着并发量的增加而变得极度明显。

通过使用零拷贝传输,我们避免了 CPU 密集的序列化过程,同时减少了内存峰值和 GC 压力,保证了并发情况下页面的正常使用。

  1. 综合对比看板
维度 主线程方案 Worker 方案 (优化后) 结论
单图总耗时 42.79s 48.5s 主线程略快,但牺牲了交互性
并发可靠性 极差 (仅支持2次并发) 极优秀 (并发无压力) Worker 解决了生存问题
主线程内存峰值 3105.70 MB 364.07 MB 降低了 88% 的主线程内存压力
交互体验 页面完全冻结 始终流畅 核心体验提升

九、总结

本次需求从一个看似简单的"颜色不对"问题出发,最终演变成了一次涉及色彩科学、图像处理、Web 技术栈选型以及前端性能优化的综合技术攻坚。

回顾整个过程,我们遇到的困难主要集中在三个方面:

技术选型的权衡:从 Sharp 到 ImageMagick,从 Node.js 到 Python,再到 WebAssembly,每一种方案都有其适用场景和局限性。我们需要在功能完整性、性能表现、开发成本以及基建适配性之间反复权衡。

基础设施的限制:CI/CD 环境的网络策略、服务器性能、字体授权合规等"非技术"因素,往往会成为技术方案落地的最大障碍。这提醒我们,技术方案的设计不能脱离实际的业务环境。

用户体验的坚守:最初的服务端方案虽然功能简单完善,但超 100s 的等待时间完全无法接受。正是对用户体验的坚持,驱使我们最终找到了客户端 WASM 方案,并通过性能优化将处理时间大大缩短到 20 秒内。

最终,通过在浏览器端集成 @imagemagick/magick-wasm,我们实现了:

  • 完整的 ICC Profile 支持,精确控制色彩转换
  • 统一的渲染意图和黑场补偿配置,转换效果相较专业设计软件(AI/PS)色差低于 1%。
  • 无需服务端参与,避免了网络传输问题和字体合规风险。
  • 本地多线程处理,支持并发图像处理,最大程度利用设备性能。
  • 解决印刷色差问题,节约 80% 设计师重复劳动

这次经历让我们深刻认识到:解决问题的过程往往比问题本身更有价值。在探索过程中积累的色彩管理知识、WASM 技术和性能优化经验、以及对业务场景的深入理解,都将成为团队宝贵的技术资产。

更重要的是,这次技术改造不仅解决了燃眉之急,更为后续的图像处理需求奠定了坚实基础。当下次遇到类似的图像处理问题时,我们已经有了一套成熟的解决思路和技术储备。

技术服务业务,业务驱动技术。希望这次实践能为遇到类似问题的朋友们提供一些参考和启发。

参考文章

High performance Node.js image processing

ImageMagick | Mastering Digital Image Alchemy

Photoshop功能|使用颜色配置文件

Troubleshooting Common Problems

Relative Colorimetric or Perceptual? Which Rendering Intent Should I Use? - YouTube

What is LAB Color Space? [HD] - YouTube

浅谈显示器色域:从sRGB到广色域 - 知乎

可转移对象 - Web API | MDN

150万开发者“被偷家”!这两款浓眉大眼的 VS Code 插件竟然是间谍

作者 JarvanMo
2026年2月9日 09:14

两款看起来完全合法的常用 VS Code 插件刚刚上演了我调查过最复杂的供应链攻击。如果你是一名开发者,很有可能你已经中招了。

数据触目惊心:总计 150 万次安装。两款插件运行起来都毫无瑕疵,提供的 AI 编程辅助功能与宣传的一模一样。而这恰恰是它们最危险的地方。

当你正忙着敲代码、开发新功能时,这些插件却在后台悄无声息地搜刮你打开的每一个文件、记录你的每一次按键,并将所有内容传输到位于中国的服务器。没有警告,没有授权弹窗。只有一场沉默而系统性的窃取。

让我带你看看事情的真相,因为这不仅仅是又一个“安全警示”,它更是现代供应链攻击的教科书式案例,揭示了为什么你的开发环境可能是整个安全防护体系中最薄弱的一环。


完美伪装:功能强大的“良性”插件

这就是被 Koi Security 的安全研究员命名为“恶搞柯基(MaliciousCorgi)”行动真正令人感到恐怖的地方:这两款插件确实提供了价值。

安装 ChatGPT — 中文版ChatMoss,你确实能得到正儿八经的 AI 代码建议。询问关于代码的问题,你会收到准确且有帮助的回答。自动补全很丝滑,错误解释也合情合理。一切功能都表现得像一个现代 AI 编程助手该有的样子。

这不是那种会导致 IDE 崩溃或满屏弹窗的拙劣恶意软件。攻击者明白一个核心道理:功能性等于信任。当一个工具兑现了它的承诺,开发者就会放下戒备,不再追问。

根据 Koi Security 安全研究员的调查,这两款恶意 VS Code 插件在被发现之前,总计安装量已达到惊人的 150 万次。其中,名为 “ChatGPT — 中文版” 的插件占据了绝大部分的市场份额。

这两款插件都顺利通过了微软的市场审核流程。它们累积了超过一百万次的下载量,在 VS Code 官方市场上公开存在了数月之久,甚至还收获了大量不知情用户的五星好评,而这些用户完全没意识到自己运行的是间谍软件。

三大隐秘通道,一场毁灭性打击

在提供宣传中的 AI 功能之余,这些插件在后台静默运行着三个并行的辅助数据采集机制。你可以把它们想象成从不同角度钻入你开发环境的抽油井。

通道 1:实时文件监视

  • 触发机制:只要你在 VS Code 中打开任何文件(无需编辑或保存),插件就会读取其全部内容。
  • 操作方式:文件内容会被 Base64 编码,并传输到一个隐藏的追踪 iframe 中。
  • 监控力度:正常的 AI 自动补全仅需光标周围约 20 行上下文,但这些插件会抓取每一份文件,且在每次通过 onDidChangeTextDocument 事件进行编辑时都会重复此操作。
  • 泄露内容:你的核心算法、构建数周的 API 集成、甚至是写给自己的临时处理方案注释,都被发送到了未经授权的服务器。

通道 2:服务器受控的精准收割

  • 机制:插件维持着一个命令控制通道(C&C),随时等待指令。
  • 指令执行:当服务器发送类似 {"type": "getFilesList"} 的 JSON 指令时,插件会从你的工作区批量抓取多达 50 个文件。
  • 目标选择:这种抓取并非随机,而是极具针对性地锁定除图片以外的所有内容,包括数据库凭证、包含 API 密钥的 .env 文件、AWS 访问令牌和 SSH 私钥。
  • 隐蔽性:整个过程完全静默,没有进度条,没有通知,你根本察觉不到整个项目正在被有计划地窃取。

通道 3:画像引擎

  • 技术手段:插件的 webview 中嵌入了一个完全透明且不可见的零像素 iframe。
  • 专业平台:该 iframe 加载了诸葛 IO(Zhuge.io)、GrowingIO、TalkingData 和百度统计四个商业分析平台。
  • 深度分析:这些企业级系统旨在构建全面的用户画像,追踪你的身份、地理位置、所属公司、核心项目以及日常活动模式。
  • 攻击意图:画像引擎会告诉攻击者哪些人的文件最值得偷。如果你身处头部科技公司或独角兽企业,或者是负责核心基础设施的高级工程师,你的工作区将成为高价值目标。

这是一种在开发者层面实施的典型供应链攻击方法:先画像,再窃取,最后将其武器化。

为什么这次攻击能大获全胜(以及这对你意味着什么)

AI 编程助手从根本上改变了开发者工具的信任模型。这些插件为了实现其功能,必须获得广泛的访问权限。它们需要读取你的代码来提供建议,也需要理解你的项目结构来给出相关的回答。

这种合法的需求为恶意行为提供了完美的伪装。你该如何区分一个 AI 助手是在读取 20 行上下文,还是在窃取整个文件?你又该如何分辨是有益的数据分析还是侵入性的用户画像? 除非使用专业的安全工具,否则你根本无法区分,而大多数开发者并不会运行这类工具。

VS Code 市场采取了多重安全措施:包括多种杀毒引擎的恶意软件扫描、异常使用模式监测、名称抢注预防以及插件签名验证。然而,这些插件通过了所有审核。

究其原因,是因为功能完善的恶意软件看起来与合法软件无异。其代码运行正常,行为逻辑合理,发布者看起来也完全可信,没有任何明显的危险信号。这就是当下的新现实:恶意插件不再需要看起来鬼鬼祟祟,它们只需要“好用”就行了。


更广泛的趋势:你的 IDE 已沦为攻击目标

“恶搞柯基(MaliciousCorgi)”行动并非孤立事件。它是针对开发环境日益升级的连环攻击中的一部分。

  • 官方清理:仅在 2025 年,微软就从 VS Code 市场中移除了 110 个恶意插件。
  • GlassWorm 行动:该攻击劫持了多个 OpenVSX 插件,将其转化为自传播蠕虫,在 36,000 次安装中窃取了来自 GitHub、npm 和加密货币钱包的凭证。
  • Shai-Hulud 蠕虫:它攻击了 npm 生态系统,向 100 多个包注入了自复制恶意软件,用于抓取令牌并自动发布到更多包中。其第二版更加激进:如果窃取凭证失败,它会尝试摧毁受害者的整个家目录。
  • PackageGate 漏洞:在 npm、pnpm、vlt 和 Bun 中发现了 6 个零日漏洞,这些漏洞能绕过 lockfile 完整性检查并自动执行恶意代码。pnpm 修复了两个严重漏洞(CVE-2025–69263 和 CVE-2025–69264),而微软旗下的 npm 却以“行为符合预期”为由关闭了漏洞报告。

你看清其中的规律了吗?攻击者正在系统性地瞄准开发者供应链的每一个环节。无论是包管理器、IDE 插件还是构建工具,只要是代码流经的开发环节,现在都被视为可利用的攻击面。

这些攻击之所以屡屡得手,是因为我们的开发工具在设计初衷上优先考虑的是便捷性,而非安全性

你现在应该立刻采取的操作

如果你正在阅读本文且电脑上安装了 VS Code,请按照以下紧急行动计划执行:

第一步:检查是否存在恶意插件

打开 VS Code 并进入“扩展(Extensions)”面板,搜索以下插件:

  • ChatGPT — 中文版(发布者:WhenSunset)
  • ChatMossCodeMoss(发布者:zhukunpeng)

如果你发现了其中任何一个,请立即将其卸载。

第二步:假设环境已失守

如果你曾安装过上述任一插件,请务必视工作区内的所有凭证为已泄露状态。这意味着你必须采取以下补救措施:

  • 更替所有的 API 密钥和访问令牌
  • 更改数据库密码
  • 重新生成 SSH 密钥
  • 撤销并重新签发云服务凭证(如 AWS、GCP、Azure 等)
  • 审查过去几个月的云服务商日志,排查是否存在未经授权的访问记录

没错,这确实很痛苦。但比起几个月后才猛然发现有人一直在盗用你的 AWS 凭证挖掘加密货币,或者发现你的核心专利算法出现在了竞争对手的产品中,现在的这点麻烦显然要轻得多。

第三步:审计你的其他插件

你现在安装了多少个插件?你还记得当初为什么要安装它们吗?你确切知道每一个插件都在后台做些什么吗?

请逐一过遍你的插件列表,并针对每一个插件自问以下问题:

  • 它最后一次更新是什么时候?
  • 它的安装量有多少?
  • 发布者是否经过验证,或者至少是你认识的可靠来源?
  • 它请求的权限是否超出了其宣称功能所需的合理范围?

请移除所有你并非活跃使用的插件。请记住,每一个插件都是一个潜在的攻击面。

构建更稳固的防御体系

个人的警惕虽然有帮助,但还远远不够,这个问题需要系统性的解决方案。

  • 对于开发团队:建议实施插件白名单制度。

    • 创建一份经过安全团队审核的核准插件清单。
    • 在将新插件加入白名单前,利用 ExtensionTotal 或 VScan 等工具进行风险评估。
  • 对于企业组织:可以使用集中化的插件管理工具(如 JFrog 的 IDE Extensions Control)。

    • 这类工具提供经过审核的本地缓存仓库。
    • 能够防止开发者安装未经授权的附加组件。
  • 对于个人开发者:对插件采取“零信任”心态。

    • 高下载量并不等同于安全,功能好用也不代表值得信任。
    • 功能性与安全性是两种完全独立的属性。
    • 监控更新:长期停更的项目突然发布新版本是一个危险信号,这可能意味着账号已被劫持。
    • 审查改动:开启插件更新通知,以便在自动更新前查看具体改动内容。
    • 环境隔离:针对不同的工作场景使用独立的 VS Code Profile。
    • 保持纯净:在处理敏感项目时使用仅含最少插件的纯净配置;而那些插件成堆的配置仅用于实验和学习,而非生产工作。

展望未来

截至 2026 年 1 月 26 日,“恶搞柯基(MaliciousCorgi)”的两款插件仍可在 VS Code 市场上获取。虽然微软安全团队已收到通知,这些插件最终会被移除并加入黑名单以触发自动卸载,但这终究是亡羊补牢,而非未雨绸缪。

新的攻击行动可能已经在某处悄然展开,它们使用不同的发布者账号、不同的插件名称,甚至更先进的技术。攻击者在每一次迭代中都在学习。

现在的问题不再是是否还会发生此类攻击,而是开发者和企业能否足够快地转变安全习惯来应对威胁。你的开发环境现在就是基础设施,请务必审计它、监控它,并应用与生产系统同等严苛的安全纪律。

因为下一次,受害者可能不再是 150 万开发者,而仅仅是你自己。等到你察觉时,你的代码、凭证以及公司的知识产权,或许早已易手。

你的团队在安装插件前是如何进行审核的?欢迎在评论区分享你的做法,我很想知道不同组织是如何应对这一挑战的。

jQuery 4.0 发布,IE 终于被放弃了

2026年2月9日 09:10

那个曾经风靡一时的 jQuery,它 20 岁了。

说实话,第一次看到 jQuery 4.0 发布 这个消息的时候,我是愣了一下的。

因为我以为它早就不会再有什么大版本了。

一个诞生于 2006 年的 JavaScript 库,在 Vue、React、Svelte、各种框架层出不穷的今天,居然还能在 2026 年,发布一个 Major 版本。

而且不是简单的修修补补,是一次真正意义上的大更新。


这次升级,把该砍掉的砍掉了,向现代浏览器靠拢。

1、不再支持 IE10 及以下

这个其实一点都不意外

  • IE10 及以下:直接放弃
  • IE11:暂时还活着,但已经开始拆支撑代码了
  • 官方已经明说:jQuery 5.0 移除专门支持 IE 11 及更早版本的代码

在这里插入图片描述

如果你现在的业务对 IE 的依赖很强,那么还是老老实实的用 jQuery3.x 吧。


2、大批 API 被移除了

下面这些 API,其实很多人都没有在用了。

比如:

  • jQuery.isArray
  • jQuery.trim
  • jQuery.parseJSON
  • jQuery.now
  • jQuery.isFunction
  • jQuery.isNumeric

官方态度也很直接:

浏览器早就有原生实现了,不会再重复造轮子

对应的替代方案也很清晰:

  • Array.isArray()
  • String.prototype.trim()
  • JSON.parse()
  • Date.now()

在这里插入图片描述

这一步,对老项目可能有点费劲,但对整个生态来说,反而是好事。


3、jQuery 终于现代化了

以前的 jQuery:AMD、RequireJS、构建方式很可以说是很老了。

现在源码直接是 ES Module,用 Rollup 打包,可以更好地和现代构建工具配合。

这意味着 jQuery 不再只能靠 script 标签活着了,终于可以被当成现代模块来使用

4、focus / blur 事件顺序变了

以前 jQuery 自己统一了一套事件顺序,现在它选择:

完全遵循 W3C 标准

也就是说,如果你项目里有比较复杂的事件联动:

  • focus
  • blur
  • focusin
  • focusout

那么升级前一定要多测一下。


5、Deferred 和 Callbacks 被彻底移除

jQuery 4.0 的 slim 版

  • 没有 Deferred
  • 没有 Callbacks
  • gzip 后只有 19.5KB

官方态度也很明确:

Promise 都是原生的了,还留这些干嘛

如果你还在用:

$.Deferred()

那升级前,最好先想好迁移方案。


我已经很多年没在新项目里用 jQuery 了,但看到 4.0 这个版本,还是觉得挺震撼的。

它可能不是最标准的技术选型,但在合适的地方,依然是个让人放心的工具,这其实已经很难得了。

本文首发于公众号:程序员大华,专注前端、Java开发,AI应用和工具的分享。关注我,少走弯路,一起进步!

记录overflow:hidden和scrollIntoView导致的页面问题

作者 EchoEcho
2026年2月9日 08:54

问题描述:

在一个编辑器中开发页面组件,组件内部对子元素设置了position:absolute定位,并且元素内容区域设置了overflow:hidden属性。

启动项目后,可以在编辑器中可以对该组件进行相关设置和修改。当切换选中内容时,页面会自动滚动,将选中组件显示到浏览器视口中,修改对应属性也会重新渲染对应组件。

第一次渲染时UI展示正常。但是当对该组件切换选中元素或者对设置了定位的子元素设置新属性时都会导致下图中子元素的定位异常。但是在调试面板中查询该元素属性值,也没有任何改变。尝试重新在控制面板中赋值对应的top值,模型又会显示到指定位置。

图片

解决过程

尝试使用内容监听器在组件被选中后,重新赋值对应的topleft值无法解决此问题。

后来通过浏览器断点调试,发现在触发监听器之前,该组件执行了scrollIntoView方法,见下图

图片

相关分析:

在执行ScrollIntoView期间会多次重绘【reflow/repaint】页面布局。而子元素中定位相关属性值会在重绘时基于父元素的当前视口上下文重新计算,导致位置偏移,比如上图中的子元素底部与父元素对齐现象。

  1. 平滑滚动动画(behavior: 'smooth'):
  • 动画过程会逐步改变滚动位置,触发多次布局计算。
  • 如果父元素有 overflow: hidden,子元素超出部分在动画中可能被“拉回”或重定位。
  1. block: 'center' 配置:
  • 这会尝试将父元素置于视口中心,如果父元素高度不是固定值(例如依赖内容或响应式),百分比 top 会基于新滚动位置重新计算,导致子元素“滑动”到底部对齐。
  1. 绝对定位的参考点变化:
  • absolute 元素依赖最近的 position: relative 祖先。在滚动动画中,如果祖先的可见区域变化,子元素的计算位置会偏移。
  1. 浏览器特定行为:
  • Chrome/Safari 在 smooth scroll 时有时会错误处理百分比定位,尤其是结合 overflow: hidden 时。

而这里遇到的问题就是在组件相关容器中设置了overflow:hidden

解决:

overflow:hidden改成overflow:clip就解决此问题了。

解析overflow:hiddenoverflow:clip

  • overflow: hidden
    • 隐藏超出元素边界的内容,但内容在内部仍然“存在”。
    • 不显示滚动条,但可以通过JavaScript(如 element.scrollLeft)或嵌套滚动访问隐藏内容。
    • 这是较早的标准值,广泛支持所有现代浏览器。
  • overflow: clip
    • 完全“剪切”超出边界的内容,就好像超出部分不存在一样。
    • 不允许任何形式的滚动访问(即使通过JS),内容被彻底丢弃。
    • 这是CSS Overflow Module Level 3 中的新值(引入于 2020 年左右),浏览器支持较新(Chrome 90+、Firefox 75+、Safari 15+)。在旧浏览器中可能回退到 hidden

2. 关键区别

方面 overflow: hidden overflow: clip
内容可见性 隐藏超出部分,但内容仍可通过JS 滚动访问。 完全剪切超出部分,无法通过任何方式访问。
滚动行为 创建一个隐形的滚动容器;滚动事件可冒泡到父元素。 不创建滚动容器;滚动事件直接传递给父元素,不被捕获。
性能影响 可能导致浏览器计算隐藏内容的布局和渲染(较低性能)。 优化性能:浏览器忽略超出内容的渲染和布局(更快,尤其在复杂页面)。
定位/粘性影响 支持 position: sticky 等行为;创建新的块格式化上下文 (BFC)。 不支持 position: sticky(元素不会粘性);不创建 BFC
JS 交互 可以用JS 修改滚动位置(如 scrollTo())。 无法用 JS 滚动;超出内容被视为不存在。
浏览器支持 所有现代浏览器(IE6+)。 较新浏览器;需检查兼容性(polyfill 有限)。
用例 适合需要隐藏但可能内部滚动的场景(如裁剪图片但允许JS动画)。 适合纯静态剪切场景(如性能敏感的游戏/UI),或防止意外滚动。
  • 核心差异总结:hidden 是“隐藏但可访问”的(像盖了个盖子),而 clip 是“彻底删除超出部分”的(像用剪刀剪掉)。clip 更严格,旨在提高性能,但牺牲了一些灵活性。
❌
❌