普通视图

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

彻底搞懂大文件上传:从幼儿园到博士阶段的全栈方案

2026年2月5日 15:58

第一层:幼儿园阶段 —— 为什么要搞复杂上传?

想象一下:你要把家里的**一万本书(10GB 文件)**搬到新家。

普通方案(简单上传):你试图一次性把一万本书塞进一个小三轮车。结果:车爆胎了(浏览器内存溢出),或者路上堵车太久,新家管理员等得不耐烦把门关了(请求超时)。

全栈方案(分片上传):你把书装成 100 个小箱子,一箱一箱运。即便路上有一箱丢了,你只需要补发那一箱,而不是重新搬一万本书。

为什么这样做?

  • 降低单次传输的数据量,避免内存溢出
  • 减少单个请求的处理时间,降低超时风险
  • 支持断点续传,提高上传成功率

第二层:小学阶段 —— 简单上传的极限

对于小于 10MB 的图片或文档,我们用 FormData。

前端 (Vue):input type="file" 获取文件,封入 FormData,通过 axios 发送。

后端 (Node):使用 multer 或 formidable 中间件接收。

数据库 (MySQL):切记! 数据库不存文件二进制流,只存文件的访问路径(URL)、文件名、大小和上传时间。

为什么数据库不存文件?

  • 文件体积太大,影响数据库性能
  • 数据库备份和迁移变得困难
  • 磁盘空间浪费,难以清理

简单上传的代码示例

// 前端
const formData = new FormData();
formData.append('file', fileInput.files[0]);
axios.post('/upload', formData);

// 后端
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
  // 处理上传
});

第三层:中学阶段 —— 分片上传 (Chunking) 的逻辑公式

这是面试的核心起点。请背诵流程:

切片:利用 File 对象的 slice 方法(底层是 Blob),将大文件切成 N 份。 标识:给每个片起个名字,通常是 文件名 + 下标。 并发发送:同时发送多个 HTTP 请求。 合并:前端发个"指令",后端把所有碎片按顺序合成一个完整文件。

为什么要用slice方法?

  • 它不会复制整个文件到内存,而是创建一个指向原文件部分的引用
  • 内存占用极小,适合处理大文件
  • 可以精确控制每个分片的大小

分片上传的实现逻辑

// 分片函数
function createFileChunks(file, chunkSize = 1024 * 1024 * 2) { // 2MB per chunk
  const chunks = [];
  let start = 0;
  
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end); // 核心API
    chunks.push({
      blob: chunk,
      index: chunks.length,
      start,
      end
    });
    start = end;
  }
  
  return chunks;
}

第四层:大学阶段 —— 秒传与唯一标识 (MD5)

面试官问:"如果用户上传一个服务器已经有的文件,怎么实现秒传?"

核心:文件的"身份证"。不能用文件名,要用文件的内容生成 MD5 哈希值。

流程

  1. 前端用 spark-md5 计算文件哈希。
  2. 上传前先问后端:"这个 MD5 对应的文件你有没有?"
  3. 后端查 MySQL,如果有,直接返回成功——这就是秒传。

为什么用MD5而不是文件名?

  • 文件名可以重复,但内容不同的文件
  • 文件名可以被修改,但内容不变
  • MD5是内容的数字指纹,相同内容必定有相同的MD5

MD5计算示例

import SparkMD5 from 'spark-md5';

function calculateFileHash(file) {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    const spark = new SparkMD5.ArrayBuffer();
    let chunkIndex = 0;
    const chunkSize = 2097152; // 2MB
    
    const loadNext = () => {
      const start = chunkIndex * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      
      fileReader.readAsArrayBuffer(file.slice(start, end));
    };
    
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      chunkIndex++;
      
      if (chunkIndex * chunkSize < file.size) {
        loadNext(); // 继续读取下一个分片
      } else {
        resolve(spark.end()); // 返回最终哈希值
      }
    };
    
    loadNext();
  });
}

第五层:博士阶段 —— 断点续传 (Resumable)

如果传到一半断网了,剩下的怎么办?

方案 A:前端记录(不可靠) 方案 B(推荐):后端 MySQL 记录已收到的分片序号。

流程

  1. 重新上传前,调用 checkChunks 接口
  2. 后端查库返回:{ uploadedList: [1, 2, 5] }
  3. 前端过滤掉已存在的序号,只发剩下的

为什么选择后端记录?

  • 前端存储不可靠(localStorage可能被清除)
  • 支持跨设备续传(从手机上传一半,从电脑继续)
  • 数据一致性更好,不容易出现脏数据

断点续传实现

// 检查已上传分片
async function checkUploadedChunks(fileHash) {
  const response = await api.checkChunks({ fileHash });
  return response.uploadedList || []; // 已上传的分片索引数组
}

// 过滤未上传的分片
const uploadedList = await checkUploadedChunks(fileHash);
const needUploadChunks = allChunks.filter(chunk => 
  !uploadedList.includes(chunk.index)
);

第六层:性能巅峰 —— 只有 1% 的人知道的 Worker 计算

浏览器是"单线程"的,JavaScript 引擎和页面渲染(DOM 树构建、布局、绘制)共用一个线程,如果你在主线程执行一个耗时 10 秒的循环(比如计算大文件的 MD5),浏览器会直接卡死。用户点不动按钮、动画停止、甚至浏览器弹出"页面无响应"。

屏幕刷新率通常是 60Hz,意味着浏览器每 16.7ms 就要渲染一帧。如果你的计算任务占据了这 16.7ms,页面就会掉帧、卡顿。

二、 什么是 Web Worker?(定义)

Web Worker 是 HTML5 标准引入的一项技术,它允许 JavaScript 脚本在后台线程中运行,不占用主线程。

它的地位: 它是主线程的"打工仔"。

它的环境: 它运行在另一个完全独立的环境中,拥有自己的全局对象(self 而不是 window)。

三、 Web Worker 能干什么?(职责与局限)

能干什么:

CPU 密集型计算: MD5 计算、加密解密、图像/视频处理、大数据排序。

网络请求: 可以在后台轮询接口。

不能干什么(面试必考点):

不能操作 DOM: 它拿不到 window、document、parent 对象。

不能弹窗: 无法使用 alert()。

受限通信: 它和主线程之间只能通过 "消息传递"(PostMessage)沟通。

四、 怎么干?(核心 API 实战)

我们要实现的目标是:在不卡顿页面的情况下,计算一个 1GB 文件的 MD5。

步骤 1:主线程逻辑(Vue/JS 环境)

主线程负责雇佣 Worker,并给它派活。

// 1. 创建 Worker 实例 (路径指向 worker 脚本)
const myWorker = new Worker('hash-worker.js');

// 2. 发送任务 (把文件对象传给 Worker)
myWorker.postMessage({ file: fileObject });

// 3. 接收结果 (监听 Worker 回传的消息)
myWorker.onmessage = (e) => {
    const { hash, percentage } = e.data;
    if (hash) {
        console.log("计算完成!MD5 为:", hash);
    } else {
        console.log("当前进度:", percentage);
    }
};

// 4. 异常处理
myWorker.onerror = (err) => {
    console.error("Worker 报错了:", err);
};

步骤 2:Worker 线程逻辑(hash-worker.js)

Worker 负责埋头苦干。

// 引入计算 MD5 的库 (Worker 内部引用脚本的方式)
importScripts('spark-md5.min.js');

self.onmessage = function(e) {
    const { file } = e.data;
    const spark = new SparkMD5.ArrayBuffer(); // 增量计算实例
    const reader = new FileReader();
    const chunkSize = 2 * 1024 * 1024; // 每次读 2MB
    let currentChunk = 0;

    // 分块读取的核心逻辑
    reader.onload = function(event) {
        spark.append(event.target.result); // 将 2MB 数据喂给 spark
        currentChunk++;

        if (currentChunk < Math.ceil(file.size / chunkSize)) {
            loadNext(); // 继续读下一块
            // 反馈进度
            self.postMessage({ 
                percentage: (currentChunk / Math.ceil(file.size / chunkSize) * 100).toFixed(2) 
            });
        } else {
            // 全部读完,生成最终 MD5
            const md5 = spark.end();
            self.postMessage({ hash: md5 }); // 完工,回传结果
        }
    };

    function loadNext() {
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        // 关键点:使用 slice 切片读取,避免一次性读入内存
        reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext(); // 开始任务
};

五、 关键思路过程(给面试官讲出深度)

当你向面试官复述这个过程时,要按照这个逻辑链条:

实例化(New Worker):

"首先,我通过 new Worker 启动一个独立线程,将耗时的计算逻辑从主线程剥离。"

数据传输(PostMessage):

"主线程将 File 对象发送给 Worker。这里要注意,File 对象是 File 句柄,发送它并不会瞬间占据大量内存,因为它是基于 Blob 的,是惰性的。"

分块读取与增量计算(Chunked Hashing):

"这是最核心的一步。即便在 Worker 内部,我也不能直接读取整个文件(比如 5GB 读进内存会直接让 Worker 进程挂掉)。

我使用了 file.slice 配合 FileReader,每次只读取 2MB 数据。

配合 spark-md5 的 append 方法,将数据'喂'给计算引擎,处理完后,之前的内存块会被释放。"

异步通信(Messaging):

"在计算过程中,我不断通过 self.postMessage 向主线程发送计算进度,以便用户在界面上能看到动态的百分比。

最后计算完成,通过消息回传最终 MD5。"

六、 总结:核心 API 记事本

new Worker('path'): 开启招聘。

postMessage(data): 互发短信。

onmessage: 接收短信。

importScripts(): Worker 内部加载插件。

file.slice(): 物理上的"切片",MD5 不崩溃的秘诀。

FileReader.readAsArrayBuffer(): 将二进制内容读入内存进行计算。

七. 谈 内存管理 (Memory Management)

面试官可能会问: "在Worker内部如何避免内存溢出?"

回答: "在 Worker 内部,我没有使用 fileReader.readAsArrayBuffer(file) 直接读取整个文件。因为 4GB 的文件如果直接读入内存,V8 引擎会直接 OOM (Out of Memory) 崩溃。我采用了 '分块读取 -> 增量哈希' 的策略。利用 spark.append() 每次只处理 2MB 的数据,处理完后 V8 的垃圾回收机制会自动释放这块内存,从而实现用极小的内存开销处理极大的文件。"

八. 谈 抽样哈希 (性能杀手锏) —— 只有 1% 的人知道的黑科技

面试官: "如果文件 100GB,Worker 计算也要好几分钟,用户等不及怎么办?"

你的杀手锏: "如果对完整性校验要求不是 100% 严苛,我会采用 '抽样哈希' 方案:

  • 文件头 2MB 全部取样
  • 文件尾 2MB 全部取样
  • 中间部分:每隔一段距离取样几个字节

这样 10GB 的文件我也只需要计算 10MB 左右的数据,MD5 计算会在 1秒内 完成,配合后端校验,能实现'秒级'预判,极大提升用户体验。"

D. 总结:面试加分关键词

  • 增量计算 (Incremental Hashing):不是一次性算,是攒着算
  • Blob.slice:文件切片的核心底层 API
  • 非阻塞 (Non-blocking):Worker 的核心价值
  • OOM 预防:通过分块读取控制内存峰值
  • 抽样哈希:大文件快速识别的有效手段

"其实 Web Worker 也有开销,创建它需要时间和内存。对于几百 KB 的小文件,直接在主线程算可能更快;但对于大文件上传,Worker 是保证 UI 响应性 的唯一正确解。"

第七层:Node.js 后端压测 —— 碎片合并的艺术

当 1000 个切片传上来,Node 如何高效合并?

初级:fs.readFileSync。 瞬间撑爆内存,Node.js(V8)默认内存限制通常在 1GB~2GB 左右。执行 readFile 合并大文件,服务器会瞬间 Crash。

高级:fs.createReadStream 和 fs.createWriteStream。

利用"流(Stream)"的管道模式,边读边写,内存占用极低。

一、 什么是 Stream?(本质定义)

流(Stream)是 Node.js 提供的处理 流式数据 的抽象接口。它将数据分成一小块一小块(Buffer),像流水一样从一头流向另一头。

Readable(可读流): 数据的源头(如:分片文件)。

Writable(可写流): 数据的终点(如:最终合并的大文件)。

Pipe(管道): 连接两者的水管,数据通过管道自动流过去。

二、 能干什么?

在文件上传场景中,它能:

低内存合并: 无论文件多大(1G 或 100G),内存占用始终稳定在几十 MB。

边读边写: 读入一小块分片,立即写入目标文件,不需要等待整个分片读完。

自动背压处理(Backpressure): 如果写的速度慢,读的速度快,管道会自动让读取慢下来,防止内存积压。

三、 怎么干?(核心 API 与实战)

1. 核心 API 记事本

fs.createReadStream(path):创建一个指向分片文件的水龙头。

fs.createWriteStream(path, { flags: 'a' }):创建一个指向目标文件的接收桶。flags: 'a' 表示追加写入(Append)。

reader.pipe(writer):把水龙头接到桶上。

const fs = require('fs');
const path = require('path');

/**
 * @param {string} targetFile 最终文件存放路径
 * @param {string} chunkDir 分片临时存放目录
 * @param {number} chunkCount 分片总数
 */
async function mergeFileChunks(targetFile, chunkDir, chunkCount) {
    // 1. 创建一个可写流,准备写入最终文件
    // flags: 'a' 表示追加模式,如果文件已存在,就在末尾接着写
    const writeStream = fs.createWriteStream(targetFile, { flags: 'a' });

    for (let i = 0; i < chunkCount; i++) {
        const chunkPath = path.join(chunkDir, `${i}.part`);
        
        // 2. 依次读取每个分片
        const readStream = fs.createReadStream(chunkPath);

        // 3. 核心:通过 Promise 封装,确保"按顺序"合并
        // 必须等第 0 片合完了,才能合第 1 片,否则文件内容会错乱
        await new Promise((resolve, reject) => {
            // 将读取流的内容导向写入流
            // 注意:end: false 表示读完这一个分片后,不要关闭目标文件写入流
            readStream.pipe(writeStream, { end: false });
            
            readStream.on('end', () => {
                // 读取完后,删除这个临时分片(节省空间)
                fs.unlinkSync(chunkPath); 
                resolve();
            });
            
            readStream.on('error', (err) => {
                reject(err);
            });
        });
    }

    // 4. 所有分片读完了,手动关闭写入流
    writeStream.end();
    console.log("合并完成!");
}

为什么用Stream而不是readFileSync?

  • readFileSync会将整个文件加载到内存,大文件会爆内存
  • Stream是流式处理,内存占用固定
  • 性能更好,适合处理大文件

吊打面试官:

"在大文件合并的处理上,我绝对不会使用 fs.readFileSync。我会使用 Node.js 的 Stream API。

具体的实现思路是:

首先,创建一个 WriteStream 指向最终文件。

然后,遍历所有分片,通过 createReadStream 逐个读取。

关键点在于利用 pipe 管道将读流导向写流。为了保证文件的正确性,我会通过 Promise 包装实现串行合并。同时,设置 pipe 的 end 参数为 false,确保写入流在合并过程中不被提前关闭。

这种做法的优势在于:利用了 Stream 的背压机制,内存占用极低(通常只有几十 KB),即便是在低配置的服务器上,也能稳定合并几十 GB 的大文件。"

第八层:MySQL 表结构设计 (实战架构)

你需要两张表来支撑这个系统:

文件表 (Files):id, file_md5, file_name, file_url, status(0:上传中, 1:已完成)。 切片记录表 (Chunks):id, file_md5, chunk_index, chunk_name。

查询优化:给 file_md5 加唯一索引,极大提升查询速度。

-- 文件主表
CREATE TABLE files (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) UNIQUE NOT NULL COMMENT '文件MD5',
  file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
  file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
  file_url VARCHAR(500) COMMENT '存储路径',
  status TINYINT DEFAULT 0 COMMENT '状态:0-上传中,1-已完成',
  upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_file_md5 (file_md5)
);

-- 分片记录表
CREATE TABLE chunks (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) NOT NULL,
  chunk_index INT NOT NULL COMMENT '分片索引',
  chunk_name VARCHAR(100) NOT NULL COMMENT '分片文件名',
  upload_status TINYINT DEFAULT 0 COMMENT '上传状态',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (file_md5) REFERENCES files(file_md5),
  INDEX idx_file_chunk (file_md5, chunk_index)
);

为什么file_md5要加索引?

  • 秒传查询时需要快速定位文件是否存在
  • 断点续传时需要快速获取某个文件的所有分片
  • 提升查询性能,避免全表扫描

一、 为什么要这么建表?(核心痛点)

如果不存数据库,只存本地文件系统,你会面临三个死穴:

断点续传没依据: 用户刷新网页,前端内存丢了,怎么知道哪些片传过?必须查库。

秒传无法实现: 10GB 的文件,怎么瞬间判断服务器有没有?全盘扫描物理文件?太慢!必须查索引。

并发合并风险: 多个请求同时触发合并逻辑怎么办?需要数据库的状态锁(Status)来控制。

二、 表结构详解:它们各司其职

1. 文件元数据表 (files) —— "身份证"

file_md5 (核心): 这是文件的唯一物理标识。不管文件名叫"高清.mp4"还是"学习资料.avi",只要内容一样,MD5 就一样。

status: 标记文件状态。

0 (Uploading): 还没传完或正在合并。

1 (Completed): 已经合并成功,可以直接访问。

file_url: 最终合并后的访问路径。

2. 切片记录表 (chunks) —— "进度表"

chunk_index: 记录这是第几个分片。

关系: 通过 file_md5 关联。一个 files 记录对应多个 chunks 记录。

三、 怎么建立联系?(逻辑关联图)

一对多关系:

files.file_md5 (1) <————> chunks.file_md5 (N)

秒传逻辑:

前端传 MD5 给后端。

SQL: SELECT file_url FROM files WHERE file_md5 = 'xxx' AND status = 1;

结果: 有记录?直接返回 URL(秒传成功)。没记录?进入下一步。

续传逻辑:

前端问:"这个 MD5 我传了多少了?"

SQL: SELECT chunk_index FROM chunks WHERE file_md5 = 'xxx';

结果: 后端返回 [0, 1, 2, 5],前端发现少了 3 和 4,于是只补传 3 和 4。

四、 关键思路与实战 SQL

1. 为什么加唯一索引(Unique Index)?

file_md5 必须是 UNIQUE。

面试点: "我给 file_md5 加了唯一索引,这不仅是为了查询快,更是一种业务兜底。在高并发下,如果两个用户同时上传同一个文件,数据库的唯一约束能防止产生两条重复的文件记录。"

2. 复合索引优化

在 chunks 表,我建议建立复合索引:INDEX idx_md5_index (file_md5, chunk_index)。

原因: 续传查询时,我们经常要查"某个 MD5 下的索引情况",复合索引能让这种搜索达到毫秒级。

五、 全流程演练(怎么干)

第一步:初始化 (Pre-check)

用户选好文件,计算 MD5,发给后端。

-- 尝试插入主表,如果 MD5 已存在则忽略(或返回已存在记录)
INSERT IGNORE INTO files (file_md5, file_name, file_size, status) 
VALUES ('abc123hash', 'video.mp4', 102400, 0);

第二步:分片上传 (Chunk Upload)

每收到一个片,存入 chunks 表。

-- 记录已收到的分片
INSERT INTO chunks (file_md5, chunk_index, chunk_name) 
VALUES ('abc123hash', 0, 'abc123hash_0.part');

第三步:合并触发 (Merge)

前端发合并指令,后端校验分片数量。

-- 检查分片是否齐全
SELECT COUNT(*) FROM chunks WHERE file_md5 = 'abc123hash';
-- 如果 Count == 总片数,开始 Stream 合并

第四步:完工 (Finish)

合并成功,更新主表。

-- 更新状态和最终路径
UPDATE files SET status = 1, file_url = '/uploads/2023/video.mp4' 
WHERE file_md5 = 'abc123hash';

六、 总结话术(吊打面试官版)

"我的数据库设计核心思路是 '内容标识胜于文件标识'。

我通过 files 表存储文件的 MD5 和全局状态,配合 UNIQUE 索引实现秒传的快速检索和并发控制。

通过 chunks 表记录每一个分片的到达状态,实现断点续传。

值得注意的细节是:

我使用了 BIGINT 来存储 file_size,因为 4GB 以上的文件 INT 会溢出。

我给 file_md5 做了索引优化,确保在百万级文件记录中,校验文件状态依然是 O(1) 的复杂度。

合并逻辑完成后,我会通过事务或状态锁更新 status 字段,确保数据的一致性。"

第九层:上帝视角 —— 并发控制 (Concurrency Control)

这一层是面试中的高光时刻。如果说 MD5 是为了"准确",Stream 是为了"稳健",那么并发控制就是为了"平衡"。

如果一个文件切了 1000 片,浏览器瞬间发出 1000 个请求,会导致浏览器崩溃或服务器宕机。

面试加分项:异步并发限制队列。

限制同时只有 6 个请求在跑(Chrome 的默认限制)。

async function sendRequest(tasks, limit = 6) {
    const pool = new Set();
    for (const task of tasks) {
        const promise = task();
        pool.add(promise);
        promise.then(() => pool.delete(promise));
        if (pool.size >= limit) await Promise.race(pool);
    }
}

为什么限制6个并发?

  • 浏览器对同一域名有最大连接数限制(通常为6)
  • 避免过多HTTP请求导致网络拥堵
  • 平衡上传速度和系统稳定性

一、 为什么要搞并发控制?(痛点)

  1. 浏览器"自保"机制: Chrome 浏览器对同一个域名的 HTTP 连接数有限制(通常是 6 个)。 如果你瞬间发起 1000 个请求,剩下的 994 个会处于 Pending(排队)状态。虽然不会直接崩溃,但会阻塞该域名的其他所有请求(比如你同时想加载一张图片,都要排在 900 多个切片后面)。

  2. 内存与性能压力: 前端: 1000 个 Promise 对象被创建,会瞬间吃掉大量内存。 后端(Node): 服务器瞬间接收 1000 个并发连接,磁盘 IO 会被占满,CPU 可能会飙升,甚至触发服务器的拒绝服务保护。

二、 什么是并发控制?(本质定义)

并发控制(Concurrency Control) 就像是一个"十字路口的红绿灯"或者"银行的排号机"。

它不改变任务总量。

它控制同一时刻正在运行的任务数量。

三、 怎么干?(核心逻辑公式)

我们要实现一个"工作池(Pool)",逻辑如下:

填满: 先一次性发出 limit(比如 6 个)个请求。

接替: 只要这 6 个请求中任何一个完成了,空出一个位子,就立刻补上第 7 个。

循环: 始终保持有 6 个请求在跑,直到 1000 个全部发完。

四、 代码详解:这 10 行代码值 5k 薪资

这是利用 Promise.race 实现的极其精妙的方案。

/**
 * @param {Array} tasks - 所有的上传任务(函数数组,执行函数才发请求)
 * @param {number} limit - 最大并发数
 */
async function sendRequest(tasks, limit = 6) {
    const pool = new Set(); // 正在执行的任务池
    const results = [];     // 存储所有请求结果

    for (const task of tasks) {
        // 1. 开始执行任务 (task 是一个返回 Promise 的函数)
        const promise = task();
        results.push(promise);
        pool.add(promise);

        // 2. 任务执行完后,从池子里删掉自己
        promise.then(() => pool.delete(promise));

        // 3. 核心:如果池子满了,就等最快的一个完成
        if (pool.size >= limit) {
            // Promise.race 会在 pool 中任何一个 promise 完成时 resolve
            await Promise.race(pool);
        }
    }

    // 4. 等最后剩下的几个也跑完
    return Promise.all(results);
}

五、 关键思路拆解(给面试官讲透)

  1. 为什么要传入 tasks 函数数组,而不是 Promise 数组? 回答: "因为 Promise 一旦创建就会立即执行。如果我传 [axios(), axios()],那并发控制就没意义了。我必须传 [() => axios(), () => axios()],这样我才能在循环里手动控制什么时候执行它。"

  2. Promise.race 起到了什么作用? 回答: "它充当了'阻塞器'。当池子满了,await Promise.race(pool) 会让 for 循环停下来。只有当池子里最快的一个请求完成了,race 才会解除阻塞,循环继续,从而发起下一个请求。"

  3. 为什么是 6 个? 回答: "这是基于 RFC 2616 标准建议和主流浏览器(Chrome/Firefox)的默认限制。超过 6 个,浏览器也会让剩下的排队。所以我们将并发数设为 6,既能榨干带宽,又能保持浏览器的响应顺畅。"

六、 进阶:如果请求失败了怎么办?(断点续传的结合)

在实战中,我们还需要加上重试机制。

// 伪代码:带重试的 task
const createTask = (chunk) => {
    return async () => {
        let retries = 3;
        while (retries > 0) {
            try {
                return await axios.post('/upload', chunk);
            } catch (err) {
                retries--;
                if (retries === 0) throw err;
            }
        }
    };
};

七、 总结

"在大文件上传场景下,盲目发起成百上千个切片请求会导致浏览器网络层阻塞和服务器压力过大。

我的解决方案是实现一个 '异步并发控制队列'。

核心思想是利用 Promise.race。我将并发数限制在 6 个。在 for 循环中,我维护一个 Set 结构的执行池。每当一个切片请求开始,就加入池子;完成后移出。当池子达到限制数时,利用 await Promise.race 阻塞循环,实现**'走一个,补一个'**的动态平衡。

这样做不仅遵守了浏览器的连接限制,更重要的是保证了前端页面的流畅度和后端 IO 的稳定性。如果遇到失败的请求,我还会配合重试逻辑和断点续传记录,确保整个上传过程的强壮性。"

第十层:终极回答策略 (架构师收网版)

如果面试官让你总结一套"完美的文件上传方案",请按照五个维度深度收网:

用户体验层 (Performance):

采用 Web Worker 开启后台线程,配合 Spark-MD5 实现增量哈希计算。

核心亮点:通过"分块读取 -> 增量累加"策略避免 4GB+ 大文件导致的浏览器 OOM(内存溢出),并利用 Worker 的非阻塞特性确保 UI 响应始终保持 60fps。对于超大文件,可选抽样哈希方案,秒级生成指纹。

传输策略层 (Strategy):

秒传:上传前预检 MD5,实现"内容即路径"的瞬间完成。

断点续传:以后端存储为准,通过接口查询 MySQL 中已存在的 chunk_index,前端执行 filter 增量上传。

并发控制:手写异步并发池,利用 Promise.race 实现"走一个,补一个"的槽位控制(限制 6 个并发),既榨干带宽又防止 TCP 阻塞及服务器 IO 爆表。

后端处理层 (Processing):

流式合并:放弃 fs.readFile,坚持使用 Node.js Stream (pipe)。

核心亮点:利用 WriteStream 的追加模式与读流对接,通过 Promise 串行化 保证切片顺序,并依靠 Stream 的背压(Backpressure)机制自动平衡读写速度,将内存占用稳定在 30MB 左右。

持久化设计层 (Database):

文件元数据管理:MySQL 记录文件 MD5、状态与最终存储 URL。

查询优化:给 file_md5 加 Unique Index(唯一索引),不仅提升秒传查询效率,更在数据库层面兜底高并发下的重复写入风险。

安全防护层 (Security):

二进制校验:不信任前端后缀名,后端读取文件流前 8 字节的 Magic Number(魔数) 校验二进制头,防止伪造后缀的木马攻击。

总结话术:一分钟"吊打"面试官

"在处理大文件上传时,我的核心思路是 '分而治之' 与 '状态持久化'。

在前端层面,我通过 Web Worker 配合增量哈希 解决了计算大文件 MD5 时的 UI 阻塞和内存溢出问题。利用 Blob.slice 实现逻辑切片后,我没有盲目发起请求,而是设计了一个基于 Promise.race 的并发控制队列,在遵守浏览器 TCP 限制的同时,保证了传输的平稳性。

在状态管理上,我采用 '后端驱动'的断点续传方案。前端在上传前会通过接口查询 MySQL 获取已上传分片列表,这种方案比 localStorage 更可靠,且天然支持跨设备续传。

在后端处理上,我深度使用了 Node.js 的 Stream 流 进行分片合并。通过管道模式与背压处理,我确保了服务器在处理几十 GB 数据时,内存水位依然保持在极低范围。

在安全性与严谨性上,我通过 MySQL 唯一索引 处理并发写冲突,通过 文件头二进制校验 过滤恶意文件。

这套方案不仅是功能的堆砌,更是对浏览器渲染机制、网络拥塞控制、内存管理以及服务器 IO 瓶颈的综合优化方案。"

💡 面试官可能追问的"补丁"

追问:如果合并过程中服务器断电了怎么办?

回答: 由于我们是通过数据库记录 status 的,合并未完成前 status 始终为 0。服务器重启后,可以根据 status=0 且切片已齐全的记录,重新触发合并任务,或者由前端下次触发预检时重新合并。

追问:切片大小设为多少合适?

回答: 通常建议 2MB - 5MB。太小会导致请求碎片过多(HTTP 头部开销大),太大容易触发网络波动导致的单个请求失败。

追问:上传过程中进度条如何实现?

回答: 两个维度。一是 MD5 进度(Worker 返回),二是上传进度(使用 axios 的 onUploadProgress 监控每个分片,结合已上传分片数量计算加权平均进度)。

生成器下(生成器异步)

2026年2月5日 15:56

生成器下(生成器异步)

上一章讨论了生成器作为一个产生值的机制的特性,但生成器远远不止这些,更多的时候我们关注的是生成器在异步编程中的使用

生成器 + Promise

简略的描述,生成器异步就是我们在生成器中yield出一个 Promise,然后在Promise完成的时候重新执行生成器的后续代码

function foo(x, y) {
    return request(
    "http://some.url?x=1y=2")
}

function *main() {
    try {
        const text = yield foo(11, 31);
        console.log(text);
    } catch (err) {
        console.error( err );
    }
}
const it = main();

const p = it.next().value;

p.then(function (text) {
    it.next(text);
}, function (error) {
    it.throw(error);
});

上述代码是一个生成器+Promise的例子, 要想驱动器我们的main生成器,只需要在步骤后then继续执行即可,这段代码有不足之处,就是无法自动的帮助我们去实现Promise驱动生成器,可以看到上面我还是手动的写then回调函数去执行生成器, 我们需要不管内部有多少个异步步骤,都可以顺序的执行, 而且不需要我们有几个步骤就写几个next这么麻烦,我们完全可以把这些逻辑隐藏于某个工具函数之内,请看下面的例子

//第一个参数是一个生成器,后续的参数是传递给生成器的
//返回一个Promise
//当返回的Promise决议的时候,生成器也就执行完成了
function run(gen, ...args) {
    const it = gen.apply(this, args);
    
    
    return Promise.resolve()
      .then(function handleNext(value) {
        const ans = it.next(value);//执行
        
        return (function handleResult(ans) {
            //ans是执行结果
            if (ans.done) {
                return ans.value;//执行完毕
            }
            //没有执行完毕,我们需要继续异步下去
            return Promise.resolve(ans.value)
              .then(handleNext, function handleError(err) {
                return Promise.resolve(it.throw(err))
                  .then(handleResult);
            });           
        })(ans);
    })
}

下面的代码演示如何使用这个run函数,

<!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>
  <img src="" alt="" data-action="show-dog1">
  <img src="" alt="" data-action="show-dog2">
  <img src="" alt="" data-action="show-dog3">
  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>//引入axios
  <script src="./run.js"> //run.js内容就是上例中定义的函数
  </script>
  <script>

    function *main () {
      const {data: { message }} = yield axios.get("https://dog.ceo/api/breeds/image/random");//一个友爱的能获得狗狗图片链接的api网站, 可以访问其官网https://dog.ceo/dog-api/

      document.querySelector("[data-action='show-dog1']").src = message;

      const {data: { message: message2 }} = yield axios.get("https://dog.ceo/api/breeds/image/random");

      document.querySelector("[data-action='show-dog2']").src =message2;

      const {data: { message: message3 }} = yield axios.get("https://dog.ceo/api/breeds/image/random");

      document.querySelector("[data-action='show-dog3']").src =message3;
    }

    try {
      run(main)
       .then((ans) => {
        console.log("ans", ans); //这里接受生成器最后return的值,在该例中为undefined
       });
    } catch (err) {
      console.log(err);
    }

  </script>
</body>
</html>

run会运行你的生成器,直到结束,这样我们在生成器中就统一了异步和同步,所有的代码都可以以顺序的步骤执行,而我们不必在于是异步还是同步,完全可以避免写异步回调代码,

Async Await

在ES8中引入了async, await,这意味着我们也不需要使用写生成异步和run了,Async Await顺序的代码格式避免回调异步带来的回调地狱,回调信任问题一系列问题,如果按时间描述js异步的发展,大概就是从回调异步时代 到Promise ,然后生成器被大神发掘出来了,发现Promise + 生成器 有c#的async/await的效果,js官方觉得这是一个很好的用法,所以在es8中出了async/await, async/await就是Promise + 生成器的语法糖,可以认为promise + 生成器是其基石。下面再回到生成 + Promise的异步的解析

在生成器中并发执行Promise

在上面的写法中是没办法并发执行的, 想要实现并发

function *foo() {
    const p1 = request("https://some.url.1");
    const p2 = request("https://some.url.2");
    
    const r1 = yield p1;
    const r2 = yield p2;
    
    const r3 = yield request("https://some.url.3/?v=" + r1 + "," + r2);
    
    console.log(r3);   
}

run(foo);

这里实现并发的办法就是让异步请求先出发,等所有请求都执行后我们再yield Promise,也可以使用Promise.all实现并发, 下面覆写这个例子

function *foo() {
    const result = yield Promise.all(
    request("https://some.url.1"),
    request("https://some.url.2"));
    
    const r3 = yield request("https://some.url.3/?v=" + r1 + "," + r2);
    console.log(r3);
}

我们还可以把Promise.all封装在一个函数中,使得foo生成器的简洁性,这样从生成器的角度看并不需要关系底层的异步是怎么实现的,我们实现生成器 + Promise要尽量把异步逻辑封装在底层

生成器委托

怎么在一个生成器中调用另一个生成器,并且重要的是,对待调用的生成器内的异步代码就像直接写在生成器内部一样(也就是说也能顺序执行调用的生成器内部的代码不管同步异步)

function *foo() {
    const r2 = yield request("https://some.url.2");
    const r3 = yield request("htpps://some.url.3/?v=" + r2);
    
    return r3;
}

function *bar() {
    const r1 = yield request("http://some.url.1");
    
    //通过run 函数调用foo
    const r3 = yield run(foo);
    
    console.log(r3);
}

run(bars);

为什么可以这样,因为我们run函数是返回一个Promise的,就像上面说的,我们把Promise的细节封装了,通过run(foo)你只需要直到两件事,run(foo)返回一个Promise,既然是Promise,我们就yield就可以了, 同时run(foo)产生的Promise完成的时候就是foo完成的时候,决议值就是r3,如果对前面说的感到不理解,我再简单的补充一点,就是run本意就是返回一个Promise,,既然是Promise,我们当前可以像yield request那样yield它而不用管底层细节, es有一种称为生成器委托的语法 yield*,先看下面一个简单的用法介绍yield *,

function *foo () {
    console.log("*foo() starting");
    yield 3;
    yield 4;
    console.log("*foo() finished");
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo();
    yield 5;
}


const it = bar();

it.next().value // 1
it.next().value //2
it.next().value 
// *foo() starting
// 3
it.next().value
//4
it.next().value
//*foo() finished
// 5

当我们消费完bar的前两个yield后,再next,这个时候,控制权转给了foo,这个时候控制的是foo而不是bar,这也就是为什么称之为委托,因为bar把自己的迭代控制委托给了foo,要是在控制foo的时候一直不next,bar也没办法进行了,当it迭代器控制消耗完了整个foo后,控制权就会自动转回bar,我们现在可以使用生成器委托覆写上述生成器委托下的第一个例子,在那个例子中使用了run(foo);我们现在可以让生成器更 `干净一点`

function *foo() {
    const r2 = request("https://some.url.2");
    const r3 = request("https://some.url.3?v=" + r2);
    
    return r3;
}


function *bar() {
    const r1 = request("https://some.url.1");
    
    const r3 = yield *foo();
    
    console.log(r3);
}

run(bar);

生成器委托其实就相当于函数调用,用来组织分散的代码,

消息委托

生成器委托的作用不只在于控制生成器,也可以用它实现双向消息传递工作,请看下面一个例子

function *foo() {
    console.log("inside *foo(): ", yield "B");
    
    console.log("inside *foo():", yield "C");
    
    return "D";
}

function *bar() {
    console.log("inside *bar():", yield "A");
    
    console.log("inside *bar(): ", yield *foo());
    
    console.log("inside *bar(): ", yield "E");
    
    return "F";
}

const it = bar();

console.log("outSide:", it.next().value);
//outside: "A"

console.log("outside", it.next(1).value);
//inside *bar:  1
//outside: B;

console.log("outside", it.next(2).value);
//inside *foo: 2
// outside: C


console.log("outside:", it.next(3).value);
//inside *foo 3
//inside *bar: D
//outside: "E"

console.log("outside:", it.next(4).value);
//inside *bar: 4
//outside: E

在这里我们就实现了和委托的生成器传递消息,外界传入2,3都传递到了foo中,foo yield的B,C页传递给了外界迭代器控制方(it),除此之外错误和异常也可以被双向传递,

function *foo() {
    try {
        yield "B";
    } catch (err) {
        console.log("error caught inside *foo():", err);
    }
    
    yield "C";
    
    throw "D";
}
functtion *baz() {
    throw "F";
}
function *bar() {
    yield "A";
    
    try {
        yield *foo();
    } catch (err) {
        console.log("error caugth inside *bar(): ", err);
    }
    
    yield "E";
    
    yield *baz();
    
    yield "G";
}


const it = bar();


console.log("outside:", it.next().value);
//outside:  A;

console.log("outside:", it.next(1).value);
//outside: B

console.log("outside:", it.throw(2).value);//外界向内抛入一个错误
//error caugth inside *foo () 2
//"outside: C"

console.loog("ouside:",it.next(3).value);
//error caugth insde *bar "D";
// ouside: E

try {
    console.log("outside", it.next(4).value);
} catch(err) {
    console.log("error cautgh outside:", err);
}
//error caugth ouside: F
//控制器结束

通过以上,我们可以总结出,当时有生成器委托的时候,和正常生成器其实没有什么区别对于外界的控制器(it)来说,它不在乎控制的是foo还是bar抑或是baz,它把这些看作是一个生成器,就像和一个生成器那样和其内部的各生成器进行双向的信息传递

生成器并发

在上面我们讨论过生成器并发Promise,在这里我们讨论并发生成器,

const res = [];  
function *reqData(url) {
    res.push(yield request(url));
}

 const it1 = reqData("https://some.url.1");
 const it2 = reqData("https://some.url.2");

const p1 = it1.next();
const p2 = it2.next();

p1.
  then(function (data) {
    it1.next(data);
    return p2;
}).then(function (data) {
    it2.next(data);
})

这里的生成器是并发的,并且通过then给这两个生成器安排好了结果位置,但是,这段代码手工程度很高,没办法让生成器自动的协调,

看下面一个例子



function runAll(...args) {
    const result = [];
    //同步并发执行Promise
    args = args.forEach(function (item) {
        item = item();
        item.next();
        return item;
    });
    
   
   function * fn() {
        args.forEach(function (item,idx) {
            let p = item.next();
          res[idx] = yield p; 
        });
    };
    
    run(fn);
    
    return result;
}
runAll(function *() {
    const p1 = request("....");
    
    yield;
    
    res.push(yield p1);
}, function *() {
    const p2 = request(".....");
    
    yield;
    
    res.push(yield p2);
});

这个例子避免了手动的去书写Promise的then链,但是这样的写法也不算是真正实现生成器并发,真正的runAll很复杂,所以没有提出

总结

生成器异步就是生成器加Promise,要求yield出一个Promise,由外部控制,但在现在完全可以使用async/await

试了CodeRio后,我终于敢直接用AI生成的Figma转前端代码了!

作者 NavyPulse
2026年2月5日 15:56

最近在折腾设计稿转代码(D2C)这事儿,试了不少工具:有 Figma 插件直出的,有 AI 一键生成的,还有各种半自动的 CLI。

说实话,大部分体验都停留在看起来还行,但一跑起来就露馅——padding 歪了、字体渲染不一样、嵌套一堆 div 地狱、交互状态完全没考虑……最后还是得自己从头抠一遍,省的时间有限,气的血压倒是不少。

直到刷到 CodeRio 这个开源项目,抱着“再试一次死马当活马医”的心态玩了玩,结果这次有点不一样:生成的代码居然基本能直接用,视觉偏差小到我愿意 commit,结构也比较工程化。

项目地址:github.com/MigoXLab/co…

简单聊聊我的使用感受和它为什么让我改观。

一、先说痛点:为什么传统 D2C 总差口气?

市面上的 D2C 工具大多是“一次性映射”:解析 Figma JSON → 直接转 CSS/JSX。

简单静态页还凑合,复杂一点的就不大行了:

  • 层级嵌套深 → 生成一堆无语义 div
  • 动态间距/响应式 → 直接崩
  • 组件状态(hover/active) → 基本忽略
  • 最要命的:Figma 渲染和浏览器渲染本来就不 100% 一致(子像素、字体、抗锯齿等),工具生成完就不管了,开发者打开浏览器一看:“这谁点的赞?”

结果就是:工具省了 20% 时间,后续修 bug 花 200% 时间。

二、CodeRio 的思路:不求一步到位,先求“能闭环”

CodeRio 没走“AI 一键神还原”的玄学路线,而是老老实实模仿了我们平时写代码的真实流程:写 → 跑 → 看效果 → 改 → 再跑……

它把整个过程拆成多步流水线,由几个智能体协作完成,每个环节都有校验,而不是一次性赌。

核心几个步骤大概是这样的:

  1. 协议生成(D2P)。 先不急着出代码,从 Figma 提取结构、布局、样式,生成一份中间协议(Protocol)。 这个协议很聪明:会自动识别组件层级(哪些是可复用组件、哪些是布局容器)、把 Figma 的各种约束转成 flex/grid 描述、处理图片资源路径…… 简单说,就是把乱七八糟的设计数据“翻译”成前端能理解的结构化蓝图,避免 AI 直接啃原始 JSON 乱猜。
  2. 代码生成(P2C)。 基于协议出 React + TypeScript + Tailwind 代码。 选这套栈我很认可:Tailwind 原子类几乎能 1:1 对应设计决策,TS 保证类型安全,React 生态最稳。
  3. 自动跑起来 + 视觉验证。 这步是杀手锏:代码生成完,系统自动 npm install && npm run dev,在真实浏览器(headless Chromium)里把页面跑起来,然后截图。 再把截图和原设计稿像素级对比(用 MAE、SSIM 等指标量化),甚至生成热力图标出偏差区域。 如果没达到阈值,就触发优化智能体针对性修(比如调 padding、z-index、字体 fallback 等),修完再验证……直到通过或到最大轮次。
  4. 工程化兜底。 考虑到大文件容易超时/断网,它加了检查点 + 断点续传:每个阶段自动存状态,断了也能从断点接着来,不用重头跑。 省 Token,也省心态。

用下来最爽的点是:它真的会“看”浏览器效果,而不是只管生成完拉倒。这让我第一次觉得 D2C 不是在做 Demo,而是在接近生产级输出。

三、实际体验

放几个case让大家看看效果,还是非常不错的!

20260205-154704.jpg

20260205-151818.jpg

20260205-151810.jpg

最后说两句

现在的 D2C 工具很多,但大多数还是“转出来凑合能看”。

CodeRio 让我看到另一种可能:通过协议定蓝图、浏览器闭环修偏差、工程化兜底,把“看起来差不多”升级成“基本能用”。

它不是要完全取代开发者,而是把最烦人的像素对齐和初稿调整交给机器,让我们把精力放在业务逻辑和交互上。

对日常开发来说,这就已经很实用了。

项目是开源的,感兴趣的可以去玩玩。👉github.com/MigoXLab/co…

有玩过的同学也欢迎评论区交流体验~

从 N 个 useXxxModal 到 async-modal-render:让弹窗也能被 await

作者 byte_n
2026年2月5日 15:51

从 N 个 useXxxModal 到 async-modal-render:让弹窗也能被 await

背景:从回调地狱到线性流程

在 React 里,传统的弹窗调用方式通常长这样:

  • 组件里用 useState 维护 visible
  • 点击按钮时把 visible 置为 true
  • 把业务逻辑塞进 ModalonOk / onCancel 回调

看起来很正常,但一旦业务变复杂,问题就开始暴露:

  • 业务流程被拆散在多个回调里,阅读代码时需要在文件里来回跳转
  • 状态管理和副作用交织在一起,出错时不好排查
  • 多步交互(比如表单校验 → 二次确认 → 提交接口)会被拆成一堆嵌套回调

更现实一点的例子是:在一个中大型的后台项目里,你会发现到处都是 XxxxModal 组件,每个组件旁边都配套一个 useXxxxModal Hook,把弹窗的展示和回调逻辑内聚进去。

这种模式有它的好处:调用方只需要 const { open } = useXxxxModal(),然后在按钮点击的时候 open() 即可,业务逻辑相对集中在这个 Hook 里。

但当项目里有N个弹窗,就意味着有N个 useXxxxModal。你打开目录,全是 useXxxModal.ts,看着非常难受——难受到某一天,一怒之下先难受了一下,然后冷静下来想:既然是我难受,那就本着“谁难受谁解决”的原则,把这套模式抽象成一个通用的库。

于是,有了 async-modal-render

反思:为什么一定要自己写N个 Hook?

回头看业务代码里的弹窗,实际上都有几个明显的共同点:

  • UI 形态差不多:都是一个 Modal/Dialog,只是内容不同
  • 行为类似:都是“确定 / 取消”两条路径,要么得到一个结果,要么放弃
  • 展示方式统一:组件通常叫 XxxxModal,配套一个 useXxxxModal,由 Hook 内部负责挂载和卸载

换句话说,我们其实是在重复造同一种轮子:每个业务弹窗都在自己实现“把回调封装成一个 Promise 的返回值”。只不过,这个 Promise 包在了各自的 useXxxxModal 里。

如果我们把视角从“一个个业务 Hook”提升到“弹窗这个交互模式”,会发现这件事完全可以交给一个公共库来做:

  • 弹窗组件只负责展示和触发 onOk / onCancel,不关心调用方式
  • 调用方用 async/await 写线性的业务代码,不需要自己管理状态
  • 中间这层“把回调 Promise 化、负责任务收尾”的脏活累活,全部交给库来完成

于是我给这个库定了几个原则:对业务代码“零侵入”“高复用”,让“写一个弹窗”这件事从“再写一个 Hook”变成“用一行 await”。

设计

当时给自己列了这样一份 checklist:

  • 能够以极低的成本调用 n 个已有的业务弹窗
  • API 方式要简单,调用者只看到 await 和返回值
  • 高内聚、低耦合:弹窗组件完全不知道自己会被这个库调用
  • 有一套友好的说明文档、示例和对比说明
  • 兼容 React 16+ 的所有版本,对构建工具选择足够谨慎(选 dumi 2)
  • 稳定:核心分支都要被用例覆盖

围绕这些目标,逐步演化出了现在的几个核心设计。

1. Promise 化的调用:一个入口,多种形态

最顶层只有一个核心能力:把“弹窗的生命周期”变成一个可 await 的 Promise。在实现上拆成了三种使用姿势:

  • 函数式调用:
asyncModalRender(Component, props, container?, options?)
  • Hook 模式:
function FC () {
  const { render, holder } = useAsyncModalRender()
  render(Component, props?, options?)
  ...
}
  • Context 模式:
function FC () {
  const { render } = useAsyncModalRenderContext()
  render(Component, props?, options?)
  ...
}

function App () {
  ...
  return <AsyncModalRenderProvider>
    <FC/>
  </AsyncModalRenderProvider>
} 

无论哪种方式,调用者看到的都是统一的形态:

const data = await render(MyModal, props, options)
// 或者
const data = await asyncModalRender(MyModal, props, container, { quiet })

内部则通过一个统一的实现 asyncModalRenderImp 来处理:

  • 将弹窗的实例化、挂载都包装到一个 Promise 中
  • 把组件的 onOk / onCancel 中触发 Promise 的 resolve / reject,同时卸载、隐藏弹窗。
  • 在 Quiet 模式下,把 onCancel 改造成 resolve(undefined) 而不是 reject

这样做的好处是:

  • 所有渲染路径(static / hook / context)只在“挂载方式”上有差异
  • “业务逻辑 → Promise → 组件交互”这一条链路只有一个实现,便于测试和演进

2. 高内聚、低耦合:弹窗组件保持“纯”

一个重要的设计目标是:弹窗组件本身对 async-modal-render 无感

也就是说,业务侧写的组件只是一个普通的 React 组件:

  • 接收 1~2 个回调,用于反馈确认、取消两个动作,可以是 onOk / onCancel / onFinished / onFail, onConfirm / onClose ... , 这都可以。
  • 在用户操作时自己选择何时调用它们
  • 不需要引入任何特定 Hook 或上下文

这样一来:

  • 组件可以独立存在,不强制依赖 async-modal-render
  • 如果业务里已经有成熟的弹窗组件,只需要通过高阶函数的形式,将 特定的回调 映射到 onOk / onCancel 即可。

为了解决“现有组件的回调名不统一”的问题,async-modal-render 额外提供了一个 withAsyncModalPropsMapper

  • 比如已有组件用的是 onFinished / onClose
  • 通过 withAsyncModalPropsMapper(Comp, ['onFinished', 'onClose'])
  • 就能生成一个“标准化后的组件”,直接交给 render 使用

这一层映射逻辑做成了 HOC 的形式,并对持久化场景做了缓存和引用检查,避免因为组件引用变化导致 React 状态丢失。

3. 兼容 React 16+ 的静态渲染:staticRender 把坑踩了一遍

为了让 asyncModalRender 能在“任何地方”被调用(不依赖 Hook/Context),必须有一套可靠的静态渲染方案

  • 在 React 16/17 时代用 ReactDOM.render / unmountComponentAtNode
  • 在 React 18 用 createRoot(container).render(element) + root.unmount()
  • 在 React 19+(移除了 ReactDOM.render)只能走 react-dom/client

staticRender 做的事情就是:

  • 根据版本号 或 动态按需加载 react-dom / react-dom/client
  • 根据 react-dom 上的属性,判断是否哪一种 api ( createRoot / render
  • 返回一个统一的卸载函数,用于在弹窗关闭时清理 DOM

这块踩过几个坑:

  • 早期版本在 React 19 下会因为直接调用 ReactDOM.render 报错
  • React 18 的 Root 管理如果不做复用,很容易在文档站/热更新场景下出现重复挂载

最后把这些细节都收敛在 staticRender 里面,业务调用方只需要知道:“给我一个 DOM 容器,我负责把弹窗挂上去并在结束时卸载掉”。

4. Hook + Context:复用一份能力,覆盖不同场景

业务里已经有大量的“用 Hook 控制弹窗”的惯性,并且需要使用一些全局配置、主题的上下文能力,Hook 的调用方式是必不可缺的,因此在 asyncModalRender 之外,还设计了:

  • useAsyncModalRender:在组件内部通过 Hook 管理弹窗
  • AsyncModalRenderProvider + useAsyncModalRenderContext:在应用根部注入能力

这两者本质上都是对同一套实现的不同包装:

  • useAsyncModalRender 内部通过一个 ElementsHolder 组件,把所有弹窗元素挂在一个统一容器里
  • AsyncModalRenderProvider 简单地在 Context 中暴露 Hook 返回的那些方法,并把 holder 一并渲染出来

为了解决“谁来负责销毁”的问题,Context 还多了一层 destroyStrategy

  • hook:跟随消费方组件的卸载自动清理
  • context:不随组件卸载,适合全局控制场景,需要显式调用 destroy

这部分的逻辑在 AsyncModalRenderContext 里做了统一封装,并通过测试确保:

  • Provider 卸载时,不会留下孤儿弹窗 DOM
  • 多次调用 destroyModal 是幂等的
  • 在未注入 Provider 的情况下调用,会抛出清晰的错误提示

5. 持久化和销毁:把“状态留在弹窗里”

在日常业务里,有不少弹窗需要“关掉以后再打开还能保留内部状态”,比如:

  • 复杂表单
  • 富文本编辑器
  • 多步骤导入向导

如果每次都销毁组件,再重新挂载,就意味着内部状态全部丢失。于是引入了两个关键配置:

  • persistent: 标识某个弹窗实例的“持久化 key”,支持 string / number / symbol
  • openField: 指定组件 props 中负责控制显隐的那个 boolean 字段,比如 open / visible

在持久化模式下:

  • 第一次打开时挂载组件,并把 openField 置为 true
  • 关闭时不销毁组件,而是 cloneElement 一份,把 openField 改成 false 再 patch 回去
  • 下次用同一个 persistent key 打开,拿到的是同一个组件实例,内部状态自然能被保留

对应地,还提供了一个 destroy 方法,用来:

  • persistent 定位并销毁某个持久化弹窗
  • 可选地按可见性筛选(仅销毁 visible / hidden 的实例)

为了防止误用,在实现里加了一个 PersistentComponentConflictError

  • 同一个 persistent key 如果对应了不同的组件构造器,会直接抛错
  • 避免出现“你以为是同一个弹窗,其实已经换了组件,导致 React 状态错乱”的隐性 bug

6. Quiet 模式:给调用方一个“不吵闹”的选择

在很多场景里,“用户取消”本质上并不是一个错误:

  • 用户点了“取消”,业务上通常认为是一个正常分支
  • 如果每次都 reject,调用方就必须在 catch 里区分取消和真实错误

为此抽象出了 Quiet 模式:

  • 普通模式:onCancelreject AsyncModalRenderCancelError
  • Quiet 模式:onCancelresolve(undefined),不再抛错

在 API 层面有两种写法:

  • renderQuiet / renderQuietFactory:已经帮你把 quiet: true 填好了
  • render / renderFactory:通过 options.quiet 手动开启

这样调用方可以根据场景选择:

  • 严肃的业务流程(必须区分“用户取消”和“接口出错”)用普通模式 + try/catch
  • 轻量交互(比如一个输入弹窗,用户不想填就直接关掉)用 Quiet 模式,按返回值是否为 undefined 分支即可

7. 类型系统:把“错误的使用方式”尽量挡在编译期

既然是一个强依赖 async/await 的库,类型系统就非常关键,尤其是:

  • asyncModalRender / render 的返回值类型应该能自动推导自组件的 onOk 入参
  • persistent + openField 的组合要有一定的约束,避免传错字段名
  • renderPersistent 这类 API 应该在类型层面强制要求参数完整

这部分主要通过一系列类型体操来完成:

  • 利用条件类型从 D['onOk'] 中提取返回值 R,自动推导 Promise 的 resolve 类型
  • ExtractBooleanKeys<D> 拿到所有 boolean 类型的 prop 名,约束 openField
  • 在 Quiet 模式下通过 ComputeQuiet<Quiet, R> 把返回值包装成 R | undefined

可见的效果是:

  • 业务侧几乎不需要手写泛型,IDE 就能给出正确的返回值提示
  • 很多“潜在的误用”在写代码时就会被 TS 报出来,而不是等到运行时踩坑

8. 文档与测试:让“库”和“业务项目”都心里有数

为了让这个库在真实项目里可用,而不是“我自己能看懂”,当时专门做了两件事:

  • 文档系统选了 dumi 2:既能承载 markdown 文档,又能跑 demo 组件
  • 测试框架选 vitest 4:和 Vite 生态相对契合,性能也足够

围绕核心能力写了一整套用例,包括但不限于:

  • asyncModalRender 的基本行为(挂载、卸载、resolve/reject)
  • Hook 模式的 render / renderFactory / destroy 以及幂等性
  • 持久化模式下的状态保留、按 key 销毁、数字和 symbol Key 支持
  • Context 模式下的 Provider 生命周期、销毁策略、错误提示
  • withAsyncModalPropsMapper 的行为和缓存策略

这些用例并不追求“形式上的 100% 覆盖率”,而是尽量覆盖所有分支和边界条件,让库的行为在不同 React 版本、不同调用路径下都保持一致。

回顾实现过程:从 0.0.1 到 0.0.6 之前

回顾 0.0.6 之前的版本,大致可以分成几步。

0.0.1:最小可用版本

  • 抽出统一的“回调 → Promise” 实现,提供 asyncModalRender 静态函数
  • 定义最小的 AsyncModalProps 接口,只约定 onOk / onCancel
  • 把几个典型业务弹窗迁移到 Promise 链路上,验证写法与边界行为

0.0.2:适配存量组件

  • 发现业务里回调命名五花八门(如 onFinished / onClose 等)
  • 抽象出 withAsyncModalPropsMapper,以 HOC 方式统一映射到 onOk / onCancel
  • 顺带整理了相关文档和类型导出,作为这一版的核心改动

0.0.3:补齐测试与静态渲染

  • 解决 asyncModalRender 卸载不干净、残留 DOM 的问题
  • 为 React 18/19 重写 staticRender,加入版本探测与 createRoot 复用
  • 补齐从挂载到卸载的集成测试,让静态渲染行为收敛、可验证

0.0.4:工具链与类型体验

  • 处理 dumi 2 携带的 React 版本与项目依赖冲突,修正文档构建问题
  • 调整构建和文档配置,优化类型导出与导入方式

0.0.5 及之后:持久化能力与细节打磨

  • 引入 persistent / openFielddestroy API,将持久化能力变成一等特性
  • 补充测试:验证多次打开状态保留、不同 key 的隔离,以及各种销毁组合
  • 为持久化场景增加 PersistentComponentConflictError,防止相同 key 绑定不同组件
  • 在文档中补充与 NiceModal、传统写法的对比说明,讲清楚设计取舍

后知后觉:遇见 NiceModal 之后

做到这一步之后,我才后知后觉地发现:社区里其实已经有了一个类似的库——@ebay/nice-modal-react

第一次看 NiceModal 的文档时,心情大概是:

  • 一方面“啊,原来大家都在为弹窗这件事头疼”
  • 另一方面“还好我走的路线跟它不完全一样”

更重要的是,NiceModal 确实给了我不少启发,尤其是在两个点上:

1. 持久化:让弹窗更像“页面的一部分”

NiceModal 里很早就有“隐藏而不卸载”的设计,这和我后来做的 persistent 能力在理念上非常契合:弹窗不一定是一次性的,它可以像页面一样长期存在,只是偶尔被展示出来

这也从侧面印证了当初在业务里感受到的那种“不想每次都重置状态”的痛点是普遍存在的。于是我也更加坚定地把:

  • persistent / openField
  • destroy API
  • 以及相关的错误保护

当成库的一等公民来维护,而不是“某个高级用法”。

2. 取消不一定是错误:Quiet 模式的诞生

另一个被 NiceModal 强化的直觉是:onCancel 不一定非得走 reject

很多时候,“用户取消”只是业务流程里的一个正常分支:

  • 用户点了“关闭”按钮
  • 用户按了 ESC
  • 用户在某一步操作中选择“算了”

如果统一走 reject,调用方就不得不在 catch 里区分“正常取消”和“真正的错误”,不仅增加了心智负担,还容易在不小心的时候吞掉异常。

因此在 async-modal-render 里我引入了 Quiet 模式:

  • 通过 renderQuiet / renderQuietFactory,把“取消”转化为 resolve(undefined)

  • 调用方只需要写:

    const result = await renderQuiet(MyModal, props)
    if (result === undefined) {
      // 用户取消
    } else {
      // 用户确认,拿着 result 继续走
    }
    

这种写法在语义上更贴近“分支逻辑”,也减少了对异常通道的滥用。

3. 保持“零侵入”的坚持

NiceModal 带来的另一个重要思考是:如何在借鉴功能的同时,保持自己的设计哲学

NiceModal 的一个取舍是:UI 组件内部需要显式引入 useModal、依赖自身的全局状态管理。这在很多场景下很方便,但同时也让组件和库之间形成了强耦合。

而 async-modal-render 从一开始就坚持:

  • 弹窗组件不需要知道自己是被谁调用的
  • 最终导出的组件仍然是一个“普通的 React 组件”
  • 如果哪天不用这个库了,组件本身不需要重写

所以在借鉴 NiceModal 的同时,我没有改变这条原则,而是把:

  • 持久化
  • Quiet 模式
  • 上下文渲染

等能力全部放在调用层来实现,尽量让业务组件保持“纯净”。

尾声:从“谁难受谁解决”到“让别人不再难受”

回头看 async-modal-render 的这段演进,其实就是从一个很朴素的动机出发:

  • 一开始只是因为自己被“N个 useXxxxModal”恶心到了
  • 然后想把这套模式抽象出来,至少先让自己不再重复造轮子
  • 接着在项目里试用、打磨、填坑,把行为收敛成一个稳定的库
  • 最后再参考社区方案(比如 NiceModal),把一些成熟的思路吸收进来

如果你现在也正被“到处都是回调的弹窗逻辑”困扰,希望这篇文章能给你一点启发:

  • 把弹窗当成一个“可 await 的过程”,而不是一个“到处都是回调的组件”
  • 把通用的部分交给库,业务代码只关注“用户点了确定之后要做什么”
  • 在设计自己的基础库时,从真实的痛点出发,再结合社区现有方案,会走得更稳

而对我来说,async-modal-render 也还在继续演进中:React 自己的更新、UI 库的变化、业务场景的新需求,都会推动这个库不断调整设计。但至少有一点不会变——让写弹窗这件事,尽量不要再让人难受

规划

  • 先在业务项目小组内推广试用,等到线上项目稳定运行并获得足够的正向反馈后,再以“新增一个 Hook 能力”的方式集成到统一组件库中。
  • 持续根据业务和实际使用情况修复、扩展功能,同时保持测试用例同步跟进,尽量维持核心逻辑 100% 覆盖率。

用GLM+Claude Code,开发了一款SQLite插件

作者 myway
2026年2月5日 15:41

插件介绍

SQLite作为轻量级数据库,被用在很多小型APP中。之前在开发electron应用时,还到处找SQLite软件,但是没一个满意的,现在AI编程最大的便利,便是让很多人以最小代价去实现软件应用。

顺便用国产模型第一梯队的GLM,对比下经常用的Claude,看看能否平替。

utools 应该很多人都用过,有很多提升开发效率的小工具。这个插件就是在utools上架的,大家可以搜索体验一下,顺便附上插件链接:www.u-tools.cn/plugins/det…

1.表数据预览 image.png 2.表结构编辑 image.png 3.新建表,支持表单模型/sql模式 image.png 4.全局搜索 image.png 5.AI辅助功能当然要有 image.png

开发工具

个人觉得最适合前端的就是安装vscode插件:Claude Code for VS Code,然后用国内的一些中转厂商。还有很多国内的大模型也支持接入Claude Code插件,开了智谱的GLM套餐,搭配起来使用的话,基本能满足大部分编程需求。这个插件大部分也是用GLM开发的,少量复杂功能用的Claude,接下来会详情说下。

开发过程

技术栈vite+React,然后可以调用一些nodejs能力+utools的api。后面增加的ai对话,使用了langchain。

本来是决定一行代码不写,包括提交记录都是AI完成的,但是中间遇到比较复杂的地方,功能是能实现,但是整体架构就是不能按我的思路来,GLM和Claude换着来都不行,最后这块古法编程实现的。

资料检索

AI编程遇到的第一个问题就是,utools的api它不知道,然后就开始自作主张的使用electron的api,或者编一些不存在的,这是我用glm-4.6时的问题,给它贴了URL也没用,然后直接给它贴的文档。后来glm-4.7增加了网络搜索等mcp功能,对URL内容的读取就好很多了,但是每月100次额度有点少,主要是我使用的Claude Code它会在一轮会话中,多次频繁读取仓库或文章。最后我自己写了个skill还方便点。

代码审核

AI写的代码一定要审核+及时调整!!!因为某次对话,让AI美化组件样式(增加class重写antd样式),后面每次迭代,AI就自作主张的参考之前写法,每个组件都要格外写一些样式。后来人工剔除后,又在CLAUDE.md中强调了这点才没再发生。

技术框架规划

这个真的很重要,早点规划好你的技术框架,而不是让模型去猜。AI很容易写出那种“居然可以实现功能,但是代码一层叠一层,最终人类无法插手”。

我在一开始就规定了各种技术栈,底层调用方式、封装方式。开发到一定阶段后,因为uTools的调试并不好用,我想让让AI去mock底层的一些nodejs及uTools方法,然后居然一次性通过,浏览器顺利打开,并完整模拟了包括表结构、表数据等操作,它们是完全基于localStorage的。

那么也就是说,我后面如果把这个程序移植到浏览器、或者vscode插件等,也是很容易的。

让AI做不擅长的事情

如果用nextjs+React+tailwind+zustand,是ai最擅长的技术栈了。但是我项目的状态使用了valtio,而且使用了面相对象的写法,明明告知了AI尽量不使用React hooks,以及valtio的示例写法,但还是会写偏,表现在:

  1. useSnapshot后的对象React才能感知到变化,经常忘记使用。
  2. 状态混用React hooks,属于屡教不改了
  3. antd的表单应该有form实例去管理内部值,ai习惯直接将valtio的响应式数据,放到表单Value上

不过随着代码量增多,也可能和CLAUDE.md的约束越来越完善,上面这些情况就比较少了

另外说一下为什么要使用valtio这种proxy机制的状态管理工具

  1. React的useEffect经常造成逻辑混乱,依赖越加越多,心智负担重
  2. 很多时候为了拿到最新的值,不得不定义额外的ref来存储临时值
  3. 性能考虑会又会大量使用memo、useCallback等优化

valtio优点:

  1. 使用很简单,就一个proxy和一个useSnapshot
  2. 只有你用到的值变化才会触发组件更新
  3. 脱离React组件更新值,也不用考虑闭包问题,永远都是最新的值

只是proxy机制可能很多人就会觉得一股vue味,比较排斥,但是如果你真的使用,尤其是复杂UI交互下,完全没有那么多心智负担。而且它的作者,也是写出zustand的大佬。

模型差异

Claude绝对是最强编程模型了,如果有缺点只有一个,就是贵。国产的glm、kimi、minmax能力都还不错,我只深度体验了glm。总体来说,你需要更详细的描述你的方案,减少AI自由发挥的机会,复杂点的功能直接用plan模式。一样可以有比较好的效果。 另外一方面就是多使用Claude的配置文件,如CLAUDE.md、skills、tools去指导AI写出更好的代码。

AI功能

大模型如此强大的今天,当然可以用来辅助操作数据库。目前使用openAI+langchain+antdX,实现了一个简单的编程智能问答。

token

有很多免费的token可以拿来用,我是用的硅基流动里免费的Qwen/Qwen2.5-Coder-7B-Instruct,对于简单问答已经足够了。

如何接入

import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
    modelName: 'Qwen/Qwen2.5-Coder-7B-Instruct',
    apiKey: "your token",
    configuration: {
        baseURL: 'https://api.siliconflow.cn/v1'
    },
    temperature: 0
});

因为当前大模型支持openAi格式的调用(大部分模型都兼容),所以我们直接用@langchain/openai,你也可以直接用openAi库,这里是为了使用langchain的能力。

modelName对应模型名称,apiKey后台创建一个即可,baseURL是对应模型提供商的接口地址,temperature代表了模型可自由发挥的空间,温度越高自由度越大,比如需要更多的创意,不仅限于给定的提示词,如写作的场景,就可以调高一些。这里我们只需要模型回答我们编程相关问题,并且严格遵循我们的要求,所以直接设置为0。

然后是添加一些系统提示词:

const systemPrompt: ["system", string] = [
  "system",
  `你是一个专业的 SQLite 数据库助手。你的职责是:
**你可以帮助用户:**
- 创建、修改、删除数据库表
- 插入、更新、删除数据
- 编写和优化 SQL 查询语句
- 解释 SQLite 的语法和特性
- 数据库设计和索引优化
- 解决 SQLite 使用中的问题

**重要规则:**
- 只回答与 SQLite 数据库相关的问题
- 对于与 SQLite 无关的问题(如编程语言、其他数据库、生活问题等),请礼貌拒绝并提示:
  "抱歉,我是 SQLite 数据库专属助手,只能回答与 SQLite 相关的问题。如果您有 SQLite 方面的疑问,我很乐意帮助您!"

请用简洁、准确的语言回答问题。`
    ];

效果:

image.png

但是实测小模型有时候可能不会严格按照你的系统提示词来,偶尔还是会回答无关内容,所以测试的时候最好找个好点的模型调试。

接下来可以添加memory,让模型记住上下文

const history: (HumanMessage | AIMessage)[] = [];

history.push(new HumanMessage(userInput));
history.push(new AIMessage(response.content.toString());

这样大模型就可以根据我们之前的对话内容回答问题了 image.png

总结

这里演示了如何使用langchain发送流式请求、管理上下文。

后面还会加上一些tools调用,让ai可以自动完成表查询、新建表、执行sql等能力,目前还待实现。后面会单独写一篇文章详细介绍。

还有最近比较火的skills、MCP功能,也会结合项目讲一讲。

前端国际化(i18n)体系设计与工程化落地

2026年2月5日 15:36

🌍 前端国际化(i18n)体系设计与工程化落地

✈️ 当一个前端项目开始支持多语言时,问题就不再是“翻译几行文案”那么简单。 国际化(i18n)考验的是:架构设计能力、工程化能力和长期维护能力

在这篇文章中,咱们不讲“Hello World”,而是讲真正能在项目里落地的 i18n 方案


🎯 为什么前端国际化一定要提前设计?

很多项目的国际化,都是这样开始的:

“老板说下个月要支持英文版,我们先用 if/else 顶一下。”

然后很快就会出现:

  • 文案散落在代码各处,无法统一管理
  • 新增语言成本极高
  • 翻译人员无法参与,只能靠开发
  • 改一行中文,所有语言一起崩
  • 多语言切换导致页面闪烁、状态丢失

👉 问题的根源只有一个:i18n 没有工程化


🧩 一、前端国际化的核心设计目标

一个成熟的 i18n 体系,至少要满足:

  1. 文案与业务逻辑彻底解耦
  2. 支持动态语言切换
  3. 支持多人协作(开发 / 产品 / 翻译)
  4. 支持规模化扩展(10+ 语言)
  5. 对性能影响可控

🧱 二、国际化的基础结构设计

1️⃣ 文案统一抽离(这是底线)

❌ 错误示例:

if (lang === 'en') {
  title = 'User List';
} else {
  title = '用户列表';
}

✅ 正确做法:Key-Value 语义化文案

title: t('user.list.title')

语言文件示例:

// zh-CN.json
{
  "user.list.title": "用户列表"
}
// en-US.json
{
  "user.list.title": "User List"
}

2️⃣ Key 命名规范(非常重要)

推荐规则:

模块.页面.含义

示例:

  • login.form.username
  • login.form.password
  • user.list.empty
  • order.detail.status.paid

✅ 好处:

  • 语义清晰
  • 避免 Key 冲突
  • 方便批量维护和查找

🛠️ 三、Vue / React 中的 i18n 实现思路

Vue(vue-i18n 思路)

const i18n = createI18n({
  locale: 'zh-CN',
  messages: {
    'zh-CN': zhCN,
    'en-US': enUS
  }
});

使用:

<h1>{{ $t('user.list.title') }}</h1>

React(react-i18next 思路)

const { t } = useTranslation();
<h1>{t('user.list.title')}</h1>

🔁 四、语言切换的正确姿势

1️⃣ 语言状态存储

推荐优先级:

  1. 用户显式选择(localStorage / cookie)
  2. 浏览器语言(navigator.language
  3. 默认语言
const lang =
  localStorage.getItem('lang') ||
  navigator.language ||
  'zh-CN';

2️⃣ 切换语言不刷新页面

i18n.global.locale.value = 'en-US';

⚠️ 注意:

  • 不要强制刷新页面
  • 不要丢失当前路由和状态

📦 五、规模化项目的 i18n 工程化方案

1️⃣ 按模块拆分语言文件

locales/
├─ zh-CN/
│  ├─ login.json
│  ├─ user.json
├─ en-US/
│  ├─ login.json
│  ├─ user.json

动态合并:

const messages = {
  'zh-CN': {
    ...loginZh,
    ...userZh
  }
};

2️⃣ 懒加载语言包(性能关键)

async function loadLocale(lang) {
  const messages = await import(`./locales/${lang}.json`);
  i18n.setLocaleMessage(lang, messages.default);
}

✅ 避免一次性加载所有语言。


3️⃣ 文案校验与缺失检测

建议在 CI 中做:

  • Key 是否重复
  • 是否存在未翻译 Key
  • 是否存在废弃 Key

否则迟早会变成“语言文件垃圾场”。


🌐 六、国际化不仅是文案

真正的 i18n 还包括:

📅 时间与日期格式

new Intl.DateTimeFormat('en-US').format(new Date());

💰 数字与货币

new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
}).format(1000);

🧭 方向性(RTL)

  • 阿拉伯语 / 希伯来语
  • CSS 要支持 dir="rtl"

⚠️ 七、常见踩坑总结

后果
Key 命名随意 后期无法维护
文案写死在组件 无法扩展
一次加载全部语言 性能灾难
翻译人员直接改代码 风险极高
没有 Key 校验 文案混乱

✅ 总结

一个合格的前端 i18n 体系应该是:

  • 🧱 文案完全解耦
  • 📦 支持模块化与懒加载
  • 🔁 支持无刷新切换
  • 🌍 覆盖语言、日期、货币、方向
  • 🛠️ 可维护、可扩展、可协作

国际化不是“翻译问题”,而是工程问题。

JS-手写系列:防抖与节流

2026年2月5日 15:35

前言

在前端开发中,某些事件(如 resizescrollinputmousemove)会在短时间内频繁触发。如果处理函数涉及 DOM 操作或网络请求,频繁执行会导致页面卡顿或服务器压力过大。防抖节流正是解决这一问题的两把“手术刀”。


一、 防抖(Debounce)

1. 核心概念

触发事件后 nn 秒内函数只会执行一次。如果 nn 秒内事件再次被触发,则重新计算时间。“等最后一个人说完再行动。”

2. 使用场景

  • 搜索框输入:用户连续输入文字,只在停止输入后的 nn 毫秒发送搜索请求。
  • 窗口调整window.resize 时,只在用户停止拖拽后重新计算布局。

3. 实现

  function debounce(fn, delay) {
    let timer = null;

    return function (...args) {
      // 如果定时器存在,则清除,重新计时
      if (timer) clearTimeout(timer);

      // 正常的防抖逻辑
      timer = setTimeout(() => {
        fn.apply(this, args);
      }, delay);
    };
  }

  // 测试用例
  let count = 0;
  function handleInput() {
    count++;
    console.log('执行次数:', count);
  }

  const debouncedInput = debounce(handleInput, 1000);

  // 模拟快速调用5次
  debouncedInput();
  debouncedInput();
  debouncedInput();
  debouncedInput();
  debouncedInput();

  // 1秒后只会执行一次
  setTimeout(() => {
    console.log('最终执行次数应该是 1');
  }, 1100);

二、 节流(Throttle)

1. 核心概念

连续触发事件,但在 nn 秒内只允许执行一次。节流会显著稀释函数的执行频率。“技能冷却中。”

2. 使用场景

  • 鼠标点击:抢购按钮不断点击,规定时间内只发一次请求。
  • 滚动监听:页面无限加载时,每隔一段时间请求一次数据,而不是停下才请求。

3. 实现方案对比

方案 A:时间戳版

  • 特点:第一次触发立即执行。
 function throttleTimestamp(fn, delay) {
    let previous = 0;
    return function (...args) {
      const now = Date.now();
      if (now - previous > delay) {
        fn.apply(this, args);
        previous = now;
      }
    };
  }

  // 测试用例
  let count = 0;
  function handleClick() {
    count++;
    console.log('执行次数:', count, '时间:', Date.now());
  }

  const throttledClick = throttleTimestamp(handleClick, 1000);

  // 快速调用5次
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();

  console.log('立即执行次数应该是 1');

  // 1.1秒后再调用,应该执行第二次
  setTimeout(() => {
    throttledClick();
    console.log('1.1秒后执行次数应该是 2');
  }, 1100);

方案 B:定时器版

  • 特点:第一次触发不会立即执行(需等待延迟)
  function throttleTimer(fn, delay) {
    let timer = null;
    return function (...args) {
      if (!timer) {
        timer = setTimeout(() => {
          timer = null;
          fn.apply(this, args);
        }, delay);
      }
    };
  }

  // 测试用例
  let count = 0;
  function handleClick() {
    count++;
    console.log('执行次数:', count, '时间:', Date.now());
  }

  const throttledClick = throttleTimer(handleClick, 1000);

  // 快速调用5次
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();

  console.log('立即执行次数应该是 1');

  // 1.1秒后再调用,应该执行第二次
  setTimeout(() => {
    throttledClick();
    console.log('1.1秒后执行次数应该是 2');
  }, 1100);

三、 防抖与节流的本质区别

为了方便记忆,我们可以通过下表进行对比:

特性 防抖 (Debounce) 节流 (Throttle)
核心逻辑 重置计时器,只认最后一次 锁定计时器,在冷却期内忽略触发
执行频率 连续触发时,可能永远不执行(直到停止) 连续触发时,按固定频率执行
比喻 坐电梯:有人进来门就重新开,直到没人进来才走 坐地铁:每隔 10 分钟发一班车,准点出发

四、 进阶:如何选择?

  • 如果你的需求是 “只需要最终结果” (如输入框验证),选 防抖
  • 如果你的需求是 “过程中的平滑反馈” (如滚动加载、地图缩放),选 节流

Vue 3 + Vite 集成 Monaco Editor 开发笔记

作者 昭阳
2026年2月5日 15:25

背景:在 Vue 3 (JS/TS) + Vite 项目中集成代码编辑器 Monaco Editor。

目标:实现代码高亮、自定义主题、中文汉化、语言切换、双向绑定等功能。

1. 方案选择与安装

在 Vite 中集成 Monaco Editor 主要有两种方式:

  1. 原生 Worker 方式:最稳定,利用 Vite 的 ?worker 特性,但汉化极其困难。
  2. 插件方式 (vite-plugin-monaco-editor)推荐。配置简单,自带汉化支持,但需要处理导入兼容性问题。

安装依赖

Bash

# 核心库
npm install monaco-editor

# Vite 插件 (用于处理 Worker 和汉化)
npm install -D vite-plugin-monaco-editor

2. 核心配置 (Vite)

🔴 常见报错与修复

在使用插件时,可能会遇到以下报错:

  1. TypeError: monacoEditorPlugin is not a function
  2. TypeError: Cannot read properties of undefined (reading 'entry')

这是因为 ESM/CommonJS 模块导入兼容性问题。

✅ 最佳配置 (vite.config.js)

JavaScript

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import monacoEditorPlugin from 'vite-plugin-monaco-editor'

export default defineConfig({
  plugins: [
    vue(),
    // 🟢 核心修复:兼容写法,防止报错
    (monacoEditorPlugin.default || monacoEditorPlugin)({
        // 需要加载 Worker 的语言 (JSON, TS/JS, HTML, CSS 有独立 Worker)
        languageWorkers: ['json', 'editorWorkerService'],
        // 🟢 开启中文汉化
        locale: 'zh-cn', 
    })
  ],
})

注意SQL 属于 Basic Language(基础语言),没有独立的 Worker,不需要加到 languageWorkers 列表中。


3. 组件封装 (MonacoEditor.vue)

封装一个支持 双向绑定 (v-model)语言切换自定义主题 的通用组件。

核心逻辑点:

  1. 主题生效顺序:必须先 defineTheme,再 create 实例,并在配置中显式指定 theme
  2. 语言切换:使用 monaco.editor.setModelLanguage 动态切换。
  3. 双向绑定:同时支持内容 (v-model) 和 语言 (v-model:language)。

完整代码

代码段

<template>
  <div class="monaco-wrapper">
    <select :value="language" class="lang-select" @change="handleLanguageChange">
      <option value="json">JSON</option>
      <option value="sql">SQL</option>
      <option value="javascript">JS</option>
      <option value="css">CSS</option>
    </select>

    <div ref="editorContainer" class="editor-container"></div>
  </div>
</template>

<script setup>
import { onMounted, onBeforeUnmount, ref, watch, toRaw } from 'vue'
import * as monaco from 'monaco-editor'

// 定义 Props
const props = defineProps({
  modelValue: { type: String, default: '' },
  language: { type: String, default: 'json' },
  readOnly: { type: Boolean, default: false }
})

// 定义 Emits (支持双 v-model)
const emit = defineEmits(['update:modelValue', 'update:language', 'change'])

const editorContainer = ref(null)
let editorInstance = null

// 1. 切换语言逻辑
const handleLanguageChange = (e) => {
  const newLang = e.target.value
  emit('update:language', newLang) // 通知父组件
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
}

onMounted(() => {
  if (!editorContainer.value) return

  // 2. 定义自定义主题 (必须在 create 之前)
  monaco.editor.defineTheme('my-dark-theme', {
    base: 'vs-dark',
    inherit: true,
    rules: [
      { token: 'key', foreground: 'dddddd' },
      { token: 'string.key.json', foreground: 'dddddd' },
      { token: 'string.value.json', foreground: 'b4e98c' },
    ],
    colors: {
      'editor.background': '#0e1013', // 背景色
      'editor.lineHighlightBackground': '#1f2329',
    },
  })

  // 3. 创建编辑器实例
  editorInstance = monaco.editor.create(editorContainer.value, {
    value: props.modelValue,
    language: props.language,
    theme: 'my-dark-theme', // 🟢 显式引用主题
    readOnly: props.readOnly,
    automaticLayout: true, // 自动适应宽高
    minimap: { enabled: false }, // 关闭小地图
    scrollBeyondLastLine: false,
  })

  // 4. 监听内容变化 -> 通知父组件
  editorInstance.onDidChangeModelContent(() => {
    const value = editorInstance.getValue()
    emit('update:modelValue', value)
    emit('change', value)
  })
})

// 5. 监听 Props 变化 (外部修改 -> 同步到编辑器)
watch(() => props.modelValue, (newValue) => {
  if (editorInstance && newValue !== editorInstance.getValue()) {
    // toRaw 避免 Vue 代理对象干扰 Monaco 内部逻辑
    toRaw(editorInstance).setValue(newValue)
  }
})

watch(() => props.language, (newLang) => {
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
})

// 销毁
onBeforeUnmount(() => {
  editorInstance?.dispose()
})
</script>

<style scoped>
.monaco-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 300px;
}
.editor-container {
  width: 100%;
  height: 100%;
}
.lang-select {
  position: absolute;
  right: 15px;
  top: 10px;
  z-index: 20;
  background: #1f2329;
  color: #ddd;
  border: 1px solid #555;
  border-radius: 4px;
}
</style>

4. 疑难杂症 (Q&A)

Q1: 为什么在 node_modules 里找不到 SQL 的 Worker 文件?

  • 原因:Monaco 将语言分为两类。

    • Rich Languages (JSON, TS, CSS, HTML):有独立 Worker,支持高级语法检查。路径在 esm/vs/language
    • Basic Languages (SQL, Python, Java 等):没有独立 Worker,只依靠主线程进行简单高亮。路径在 esm/vs/basic-languages
  • 结论:配置插件时,languageWorkers 不需要加 SQL。

Q2: 为什么 import 'monaco-editor/esm/nls.messages.zh-cn.js' 汉化不生效?

  • 原因:在 ESM 模式下,编辑器核心初始化往往早于语言包加载,或者直接被 Tree-shaking 忽略。
  • 解决:使用 vite-plugin-monaco-editor 并配置 locale: 'zh-cn',插件会在编译构建阶段自动注入语言包。

Q3: 为什么 Ctrl+点击 @/... 路径无法跳转?

  • 原因:VS Code 需要配置文件来理解别名。对于 Vue+JS 项目,根目录缺少 jsconfig.json

  • 解决:在根目录创建 jsconfig.json

    JSON

    {
      "compilerOptions": {
        "baseUrl": ".",
        "paths": { "@/*": ["src/*"] }
      },
      "include": ["src/**/*"]
    }
    

    设置完后记得重启 VS Code。


5. 最佳实践:父组件调用

使用 Vue 3 的多 v-model 特性,代码语义最清晰:

HTML

<template>
  <div class="page">
    <MonacoEditor 
      v-model="codeContent" 
      v-model:language="currentLang" 
    />
    
    <button @click="runCode">运行</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import MonacoEditor from '@/components/MonacoEditor/index.vue'

const codeContent = ref('SELECT * FROM users;')
const currentLang = ref('sql') // 切换下拉框会自动更新此变量

const runCode = () => {
  console.log(`正在运行 ${currentLang.value} 代码:`, codeContent.value)
}
</script>

基线对齐:让文字和图标“看起来齐”的那门细节功夫

作者 hypoy
2026年2月5日 15:24

基线对齐:让文字和图标“看起来齐”的那门细节功夫

在做 UI 的时候,我们经常遇到一种“明明对齐了但看起来不齐”的情况:

  • 图标和文字放在一行,图标总像是飘着或下沉了一点
  • 不同字号的文字放一起,视觉上像“高低不平”
  • 按钮里的 icon + 文本,怎么调 padding 都不舒服

很多时候,问题不在于你没对齐,而在于你没对“基线”(Baseline)。

这篇文章会把基线对齐讲清楚:它是什么、为什么重要、在 Web/CSS 里怎么用、常见坑怎么躲。


1. 什么是“基线”(Baseline)

在排版里,基线可以理解为:一行文字“站着”的那条隐形线。
大部分字母(比如 a、e、n)会“坐”在这条线上,而像 g、p 这种有下行部件的字母会“掉”到基线下面。

你可以把它想成小学写字本上的那条线:字不一定都一样高,但它们的“落脚点”一致。

关键点:

  • 基线不是元素的底边(bottom)
  • 基线和字体度量(font metrics)强相关
  • 不同字体/字号的基线位置不同,但浏览器可以把它们对齐

2. 为什么“基线对齐”比“居中对齐”更自然

很多人第一反应是 align-items: center;
center 对齐是几何对齐,而人眼对一行内容的感知往往是排版对齐

举个典型场景:左边图标 + 右边文字

  • center 对齐:图标中心对文字盒子中心
  • baseline 对齐:图标“落脚点”跟文字基线对齐

当文字字号变大、字体换了、行高不同,center 对齐更容易出现“看着不齐”。

经验结论:
同一行里只要有文字参与,默认优先考虑基线对齐,会更“像排版”,更稳。


3. CSS 里怎么做基线对齐

3.1 Flex 布局:align-items: baseline

.row {
  display: flex;
  align-items: baseline;
  gap: 8px;
}

这会让同一行内的 flex items 的基线对齐。

注意:

  • 对齐的“基线”来自每个 flex item 内部的第一行文本(first baseline)
  • 如果某个 item 没文本,或者是 replaced element(比如 img),它的基线规则会比较特殊(下面会讲)

3.2 Grid 布局:align-items: baseline / align-self: baseline

Grid 也支持 baseline,但各浏览器实现细节可能略有差异。一般在组件里,Flex 更常见。

3.3 Inline/inline-block 的天然基线对齐

如果你用的是 display: inline-block;,它默认就会按基线对齐。
这也是为什么“图片底下总有一条缝”的经典 bug 会出现:img 作为 inline 内容时,会给基线留空隙(用于字母下行部件)。


4. 图标/图片为什么总是对不齐?

4.1 img 的基线和“底部留缝”

img(以及很多 replaced elements)参与 inline 排版时,默认会以基线对齐,但它的基线往往表现为“底部边缘附近”,再加上行高空间,就会出现底部缝隙或视觉偏移。

常见解决:

img, svg {
  vertical-align: middle; /* 或 baseline/top/bottom 视情况 */
}

或者直接把图标变成 flex item,由 flex baseline 控制。

4.2 SVG 图标更推荐的做法

如果你是图标 + 文本组合,强烈建议:

  • 图标用 svg(内联或 icon font 也行)
  • 外层用 flex
  • 用 baseline 对齐
  • 必要时给图标一个微调(这在真实项目里很常见,不丢人)
.icon {
  width: 16px;
  height: 16px;
  transform: translateY(1px); /* 微调 0~2px 很常见 */
}

为什么需要微调?
因为不同图标的视觉重心不同、字体的 x-height 不同,“数学上的 baseline 对齐”不一定等于“视觉上的舒适对齐”。


5. 基线对齐的常见坑

坑 1:flex item 没有文本,baseline 对齐不生效或很怪

如果某个 item 只是一个纯容器(里面没有文本第一行),它的 baseline 可能会退化成某种边缘对齐,结果整行就漂了。

解决思路:

  • 让对齐基准的元素内部有文字(哪怕是隐藏文本不推荐)
  • 或者把“需要对齐的那层”单独包一层,确保 baseline 来自文字那层
  • 或者对没有文字的 item 用 align-self 单独处理

坑 2:行高(line-height)过大导致“看着不齐”

行高决定了文字盒子高度,但基线位置不一定在盒子正中。
如果你把 line-height 设得很大,再用 center 对齐,就更容易“看起来不齐”。

建议:

  • 文本和图标混排时,优先 baseline
  • 控制合理的 line-height(比如 1.2~1.6 视字号和字体而定)

坑 3:不同字体/中英混排导致基线差异

中文字体和英文字体混用、fallback 字体切换、数字字体不同,都可能影响基线与视觉重心。

应对策略:

  • 关键 UI 字体尽量统一(尤其按钮/表单)
  • 数字显示可用 font-variant-numeric(比如 tabular-nums)提升稳定性
  • 必要时组件级微调(icon translateY、padding)

6. 实战建议:什么时候用 baseline,什么时候用 center?

优先用 baseline:

  • icon + 文本在一行(按钮、标签、菜单项)
  • 不同字号文本同一行(标题 + 数值、强调词)
  • 表格/列表中需要“像排版一样齐”的场景

优先用 center:

  • 两个都是“块状视觉元素”(比如头像 + 圆点状态)
  • 没有文字参与,或者文字只是次要(比如纯图标按钮)
  • 你追求的是几何居中(比如图标在圆形容器中)

一句话总结:
有字就想 baseline;没字就想 center;不舒服就微调。


7. 一个推荐的“图标+文本”组件模板

你可以把它当成项目里通用的模式:

.inline-with-icon {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
}

.inline-with-icon .icon {
  width: 1em;
  height: 1em;            /* 跟随字号缩放 */
  transform: translateY(0.08em); /* 轻微下压,更贴基线 */
}

优点:

  • 1em 让图标自动跟着字体大小变化
  • baseline 让整体更像“文字的一部分”
  • translateY 用 em 做相对微调,字号变了也更稳

结语:对齐不是“数学问题”,而是“视觉问题”

基线对齐本质上是在借用排版系统的规则,让 UI 更自然、更舒服。
而当规则解决不了视觉差异时,微调就是工程的一部分——别害怕那 1px,它常常是质感的来源。

【Abort Controller】 优雅处理竞态请求

作者 西维
2026年2月5日 15:24

但行好事,莫问前程

前言

最近收到运营反馈 —— 订单列表在连续搜索时,经常出现数据失真的情况。

排查了下代码,发现是因为 没有处理竞态请求,这个问题很常见且因为接口延迟的原因,特别容易复现。

竞态请求 - 例如先发送查询请求A,然后切换发送查询请求B,(A,B属于同页面的同一接口,但条件不一样),如果A的响应比B慢,就会导致展示数据与预期内容不符。

image.png

本文会介绍如何使用 Abort Controller 解决竞态问题。

如果对你有所帮助,还望点赞、收藏、关注三连😽。

方案

整理一下思路,对这种连续请求导致的竞态场景,有哪些解决方法

  • 防抖、节流、loading
    • 直接有效的限制请求操作
    • 但无法根绝竞态的出现,且loading会存在阻塞用户操作的风险
  • canceltoken
    • axios 专用,局限性较大
    • 官方已推荐使用 AbortController 替代它
  • AbortController
    • JS 原生API,更现代的、标准的、更具通用性

所以更推荐使用 AbortController 优雅地处理 竞态请求 问题,避免数据错乱。

(最好让后端优化下接口😹)

AbortController

AbortController 是一个可以让你 主动取消异步操作 的浏览器API,已被主流浏览器(除IE)外所兼容。

QQ_1770107241635.png

它的使用方法如下:

  1. 构造函数

    new AbortController()

    创建一个新的 AbortController 对象实例。

  2. 实例属性

    controller.signal

    返回一个 AbortSignal 只读的对象实例,可以用它来和异步操作进行通信或者中止这个操作。

  3. 实例方法

    AbortController.abort()

    中止一个尚未完成的异步操作。这能够中止 fetch 请求及任何响应体和流的使用。

// 1. 创建一个控制器实例
const controller = new AbortController();

// 2. 将signal实例与请求绑定
fetch('/example', { signal: controller.signal })
.then((response) => response.json())
.catch(err => {})

// 3. 调用abort取消请求
setTimeout(() => { controller.abort() }, 1000);

实践

项目中,可以将单个控制器绑定在多个请求中(订单数据、交易数据、排名数据...),统一管控。

  const abortControllerRef = useRef<AbortController | null>(null);

  // 获取仪表板数据
  const getDashBoardData = (params: GetDashBoardP) => {
    // 取消未完成的请求,避免接口冲突
    abortControllerRef.current?.abort();
    abortControllerRef.current = new AbortController();
    const { signal } = abortControllerRef.current;
    
    setLoading(true);
    Promise.allSettled([
      getTransactionData(params, signal),
      getOrderData(params, signal),
      getRankData(params, signal)
    ])
      .catch(() => {})
      .finally(() => {
        setLoading(false);
      });
  }
  
  // 触发更新
  useEffect(() => {
    getDashBoardData(searchParams);
  }, [searchParams]);

总结

AbortController 是更现代的、标准的、更具通用性的竞态问题解决方案

适用场景,例如 组件卸载时清理请求、表格搜索、文件上传、清理事件监听

注意 请求还是会到服务端,abortcontroller 只是终止了前端的后续逻辑

在IE场景可以使用canceltoken

结语

不要光看不实践哦,希望本文能对你有所帮助。

持续更新前端知识,脚踏实地不水文,真的不关注一下吗~

写作不易,如果有收获还望 点赞+收藏 🌹

才疏学浅,如有问题或建议还望指教~

# 🚀 极致性能:Vue3 全球化项目图片资源优化实战指南

2026年2月5日 15:11

摘要:针对全球化场景(跨国延迟、弱网环境)下的图片加载痛点,本文提供了一套基于 Vue 3 + Vite + TypeScript 的全链路解决方案。从构建时的自动压缩,到运行时的智能组件封装,再到 CSS 背景图的“盲区”攻克,三位一体,拒绝理论空谈,直接上代码实战。

🌏 一、背景与痛点:为什么图片优化是重中之重?

在全球化业务中,图片资源往往占据页面体积的 60% 以上。面临的核心挑战包括:

  • 物理距离远:跨国 RTT(往返时延)高,图片加载慢导致白屏。
  • 网络环境杂:弱网、丢包率高,大图加载极易失败。
  • LCP 考核严:图片通常是 LCP(最大内容绘制)元素,直接影响 Core Web Vitals 评分和 SEO。

我们的目标:在不牺牲视觉质量的前提下,将图片体积压缩 40%-80%,并将首屏加载速度提升 30% 以上


🛠️ 二、构建层:零侵入的自动化压缩流水线

最有效的优化是 “不让未经压缩的图片上线”。我们利用 Vite 插件在构建阶段自动完成格式转换和无损压缩。

1. 核心工具

引入 vite-plugin-image-optimizer,基于 Sharp 和 SVGO 引擎。

2. 实战配置 (vite.config.ts)

// build/plugins.ts
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'

export default defineConfig({
  plugins: [
    ViteImageOptimizer({
      test: /\.(jpe?g|png|gif|tiff|webp|svg|avif)$/i,
      svg: {
        multipass: true,
        plugins: [
          {
            name: 'removeViewBox',
            active: false
          },
          {
            name: 'removeDimensions',
            active: true
          },
        ],
      },
      png: { quality: 80 },
      jpeg: { quality: 80 },
      jpg: { quality: 80 },
      webp: { lossless: true },
      avif: { lossless: true },
    }),
  ]
})

💡 收益:开发同学无需关心图片格式,设计给的 PNG/JPG 原图,打包后自动变成压缩后的版本,体积平均减少 50%。


🧩 三、组件层:智能封装 OptimizedImage

为了让业务开发“无感”使用优化策略,我们将复杂度封装在组件内部。

1. 核心能力

  • 自动降级:利用 <picture> 标签,优先加载 AVIF/WebP,老旧浏览器回退到 JPG。
  • 骨架屏占位:加载中显示 Loading/占位色,防止布局抖动 (CLS)。
  • CDN 动态参数:自动拼接宽、高、质量参数。

2. 组件源码 (src/components/OptimizedImage/index.vue)

<script setup lang="ts">
import { computed, ref } from 'vue'

interface Props {
  src: string
  useCdn?: boolean
  // ...其他 Props
}

const props = withDefaults(defineProps<Props>(), { useCdn: false })

// 自动生成多格式源
const sources = computed(() => {
  if (!props.useCdn || !props.src?.startsWith('http')) return []
  const sep = props.src.includes('?') ? '&' : '?'
  return [
    {
      srcset: `${props.src}${sep}format=avif`,
      type: 'image/avif'
    },
    {
      srcset: `${props.src}${sep}format=webp`,
      type: 'image/webp'
    },
  ]
})
</script>

<template>
  <div class="optimized-image-container">
    <picture v-if="sources.length">
      <source
        v-for="(s, i) in sources"
        :key="i"
        :srcset="s.srcset"
        :type="s.type"
      >
      <img :src="src" loading="lazy">
    </picture>
    <!-- 降级/普通图片 -->
    <img v-else :src="src" loading="lazy">
  </div>
</template>

3. 业务使用

<OptimizedImage src="banner.png" width="800" height="400" use-cdn />

🎨 四、攻克盲区:CSS 背景图优化策略

CSS background-image 是优化的“死角”,因为它不支持 loading="lazy"<picture>。我们通过以下手段攻克:

1. 格式降级:image-set()

利用 CSS 原生语法实现格式选择。

.hero-bg {
  /* 兜底 */
  background-image: url('bg.jpg');
  /* 现代浏览器优先 */
  background-image: image-set(
    url('bg.avif') type('image/avif'),
    url('bg.webp') type('image/webp'),
    url('bg.jpg') type('image/jpeg')
  );
}

2. 智能懒加载:useBackgroundLazy Hook

首屏不可见的背景图,坚决不加载。我们封装了一个 Vue Hook。

源码 (src/composables/ui/useBackgroundLazy.ts)

import type { Ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { ref } from 'vue'

export function useBackgroundLazy(
  targetRef: Ref<HTMLElement | null | undefined>,
  options: IntersectionObserverInit = { rootMargin: '100px' }
) {
  const isVisible = ref(false)
  const { stop } = useIntersectionObserver(
    targetRef,
    ([{ isIntersecting }]) => {
      if (isIntersecting) {
        isVisible.value = true
        stop()
      }
    },
    options
  )
  return isVisible
}

使用示例

<script setup>
import { useBackgroundLazy } from '@/composables/ui/useBackgroundLazy'

const bgRef = ref(null)
const isVisible = useBackgroundLazy(bgRef)
</script>

<template>
  <div ref="bgRef" class="lazy-bg" :class="{ visible: isVisible }">
    ...
  </div>
</template>

<style scoped>
.lazy-bg {
  background-color: #f0f0f0;
}
.lazy-bg.visible {
  background-image: url('heavy-bg.jpg');
}
</style>

⚡ 五、关键路径渲染:LCP 救星

针对首屏最大的那张图(LCP 元素),我们要“特权”对待。

1. 提升优先级 (fetchpriority)

告诉浏览器:这张图最重要,插队加载!

<img src="hero-banner.jpg" fetchpriority="high" loading="eager" />

2. 预加载 (preload)

在 HTML 解析前就提前建立连接并下载。

<link rel="preload" as="image" href="hero-banner.webp" />

🌐 六、网络层:协议选择与 CDN 策略深度解析

网络传输是图片加载的“高速公路”。针对不同基础设施条件,我们提供 进阶版 (HTTP/3)标准版 (HTTP/2) 两套方案,并对比其优劣。

1. 协议选择:HTTP/3 vs HTTP/2

特性 HTTP/2 (标准版) HTTP/3 (进阶版) 核心差异
底层协议 TCP UDP (QUIC) H3 解决了 TCP 的“队头阻塞”问题
弱网表现 丢包时会导致整条连接等待,性能急剧下降 丢包仅影响单个流,其余流正常传输,弱网极大优势
连接建立 3 RTT (TCP+TLS) 0-1 RTT (大幅缩短建连时间)
兼容性 98%+ 浏览器支持 需浏览器 + 服务端/CDN 双向支持
适用场景 绝大多数常规 Web 项目 全球化、移动端、弱网环境重灾区

✅ 方案 A:极致性能 (HTTP/3 + QUIC)

  • 适用:已使用 Cloudflare, AWS CloudFront, 阿里云 CDN 等支持 QUIC 的现代 CDN 服务商。
  • 配置:在 CDN 控制台开启 HTTP/3 (with QUIC) 选项。
  • 收益:在跨国高延迟(RTT > 200ms)或丢包率 > 1% 的环境下,图片加载速度提升 20% - 50%

✅ 方案 B:稳健兼容 (HTTP/2 + 域名分片废弃)

  • 适用:内部私有云或老旧 CDN 不支持 UDP/QUIC。
  • 关键调整
    • 开启 HTTP/2:必须开启,利用多路复用。
    • 废弃域名分片:在 H2/H3 时代,不要再把图片分散到 img1.domain.com, img2.domain.com。多域名会导致多余的 DNS 解析和 TCP 建连,反而降低多路复用效率。保持单一域名(如 assets.domain.com)是最佳实践。

2. CDN 智能策略:Edge Image Manipulation

不要让后端服务器处理图片!利用 CDN 的边缘计算能力。

  • 即时处理 (On-the-fly):URL 传参控制。
    • https://cdn.com/img.jpg?width=400&format=webp
    • 利弊:灵活性极高,但首次访问需回源处理,有轻微延迟(随后即被 CDN 缓存)。
  • 自动格式转换 (Auto-Format)
    • CDN 检查请求头 Accept: image/avif, image/webp
    • 源站只有一张 JPG,CDN 自动转为 AVIF/WebP 返回给支持的浏览器。
    • 利弊:开发零感知,完全透明,强烈推荐。

3. 缓存策略:Immutable

对于带 Hash 的静态资源(如 Vite 打包出的 banner.8a7d9f.png),应设置“永久”缓存。

# Nginx 配置示例
location ~* \.(?:png|jpg|jpeg|gif|webp|avif|svg)$ {
    # 1年有效期,且声明内容不可变(浏览器完全无需发请求验证)
    add_header Cache-Control "public, max-age=31536000, immutable";
}

📊 七、量化验证:拒绝“感觉变快了”

我们需要可复现、可执行的数据来证明优化效果。

1. 实验室数据 (Lab Data) - 开发阶段自测

工具:Chrome DevTools > Lighthouse

  • 操作
    1. 打开 Chrome 隐身模式。
    2. F12 -> Lighthouse -> 选择 "Mobile" (模拟弱网) 或 "Desktop"。
    3. 点击 "Analyze page load"。
  • 核心关注指标
    • LCP (Largest Contentful Paint): 应 < 2.5s。
    • Total Blocking Time (TBT): 图片解码是否阻塞主线程。

2. 真实用户数据 (RUM) - 生产环境监控

仅靠实验室数据是不够的,我们需要脚本自动收集真实加载情况。

✅ 自动化脚本 (复制到控制台运行或集成到监控 SDK)

// 性能监控脚本:计算 LCP 和图片资源耗时
const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries()
  entries.forEach((entry) => {
    // 1. 捕捉 LCP
    if (entry.entryType === 'largest-contentful-paint') {
      console.log(`🚀 [LCP] 耗时: ${entry.startTime.toFixed(2)}ms`, entry)
      if (entry.url) console.log(`   LCP 资源: ${entry.url}`)
    }
    // 2. 捕捉图片资源加载详情
    if (entry.entryType === 'resource' && entry.initiatorType === 'img') {
      const isCache = entry.transferSize === 0 // 缓存命中
      const protocol = entry.nextHopProtocol // h2 或 h3
      console.log(`�️ [Image] ${entry.name.split('/').pop()}`)
      console.log(`   - 耗时: ${entry.duration.toFixed(2)}ms`)
      console.log(`   - 协议: ${protocol}`)
      console.log(`   - 体积: ${(entry.encodedBodySize / 1024).toFixed(2)}KB`)
      console.log(`   - 缓存: ${isCache ? '✅ HIT' : '❌ MISS'}`)
    }
  })
})

observer.observe({
  type: 'largest-contentful-paint',
  buffered: true
})
observer.observe({
  type: 'resource',
  buffered: true
})

3. 验证步骤 (SOP)

  1. 基准测试 (Baseline)
    • 关闭所有优化开关(Vite 插件、组件回退到普通 img)。
    • 使用 Chrome Network 面板 "Fast 3G" 模拟弱网。
    • 记录 LCP 时间和 Network 面板的 Transferred 总大小。
  2. 实施优化
    • 启用 vite-plugin-image-optimizer
    • 部署 HTTP/3 CDN。
    • 替换 OptimizedImage 组件。
  3. 对比测试
    • 同样环境(Fast 3G)再次测量。
    • 验收标准
      • 图片总传输体积减少 > 40%
      • LCP 时间减少 > 30%
      • Network 面板 Protocol 列显示 h3h2

结语:性能优化没有银弹,只有对细节的极致追求。通过上述方案,我们建立了一套可维护、自动化的图片治理体系,为全球用户提供丝滑的浏览体验。

【坑位提醒】Flutter 鸿蒙开发:Mac 跑得好好的,Windows 报 Git 依赖版本错误?半天心血总结出的真相!

作者 浅蓝色
2026年2月5日 15:00

前言

在进行 Flutter 鸿蒙开发时,由于很多插件(如 image_picker, file_selector)需要引用华为或 SIG 维护的特定分支。你可能会遇到一个极其离谱的现象:同一个项目代码,之前在 Mac 上 flutter pub get 秒过,在 Windows 上却死活报错:

ERR: Package not available (the pubspec for image_picker 1.1.2 from git has version 1.0.2).

明明 Git 仓库里版本是对的,为什么 Windows 固执地认为它是旧版本?本文带你一分钟跳坑。


一、 现象描述

pubspec.yaml 中配置了如下依赖:

YAML

image_picker:
  git:
    url: https://gitcode.com/openharmony-sig/flutter_packages.git
    path: packages/image_picker/image_picker
    ref: br_image_picker-v1.1.2_ohos  # 明确指向 1.1.2 版本

报错信息:

Package not available (the pubspec for image_picker 1.1.2 from git has version 1.0.2).

你去浏览器里翻openharmony-sig的源码,明明br_image_picker-v1.1.2_ohos的版本号就是 1.1.2。你甚至怀疑是不是 GitCode 挂了,或者是自己眼睛花了。


二、 幕后真凶:Windows 的“长路径限制”

这其实不是 Flutter 的 Bug,也不是 Git 的 Bug,而是 Windows 文件系统默认限制(MAX_PATH: 260字符)Git 静默失败 共同酿成的惨剧。

1. 路径太深了

Flutter 在缓存 Git 依赖时,路径极深:

C:\Users\你的用户名\AppData\Local\Pub\Cache\git\flutter_packages-xxxx\packages\image_picker\image_picker\pubspec.yaml

这个路径轻轻松松就会突破 260 个字符。

2. Git 的“摆烂”行为

当路径超过限制时,Windows 版 Git 在执行 checkout(切换分支)时会静默失败

  • 结果: Git 以为自己切到了新分支,但磁盘上的文件实际上还是默认分支(可能是 master 或旧版)的内容。
  • Pub 的反应: Pub 工具去读磁盘上的 pubspec.yaml,发现版本号跟预期不符,直接报错退出。

三、 终极解决方案(三步走)

如果你在 Windows 上遇到这个“版本对不上”的逆天报错,请按以下步骤操作:

1. 开启 Windows 系统长路径支持

管理员身份打开 PowerShell,运行以下命令:

PowerShell

New-ItemProperty -Path "HKLM:\System\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force

2. 开启 Git 的长路径支持

在终端运行:

Bash

git config --global core.longpaths true

3. 强力清除“有毒”缓存

这是最关键的一步,必须手动清理已经出错的本地缓存:

  1. 删除 Pub 缓存中的 Git 目录:

    直接删除 C:\Users\你的用户名\AppData\Local\Pub\Cache\git 下的所有内容。

  2. 删除项目锁文件:

    删除项目根目录下的 pubspec.lock

  3. 重新获取:

    运行 flutter pub get


四、 总结

鸿蒙适配之路本就坎坷,Windows 的这个环境坑更是杀人于无形。

  • Mac 没事: 因为 Unix 系系统没有这种古老的路径长度限制。
  • Windows 报错: 往往是因为路径过深导致 Git 切换分支失败。

一句话经验:在 Windows 上搞 Flutter 开发,第一件事就是把系统的 LongPathsEnabled 给开了!

希望这篇文档能帮到正在绝望中的你。如果解决了你的问题,记得点个赞!


互动环节

你在鸿蒙适配过程中还遇到了哪些“离谱”的报错?欢迎在评论区交流,我们一起排坑!

怎样自定义 Exception Filter?

作者 前端付豪
2026年2月5日 14:54

Exception Filter 在 Nest 应用抛异常时,捕获它并返回

比如

image.png

新建项目

nest new exception-filter-demo

自己定义个 exception filter

@Catch 指定要捕获的异常,这里指定 BadRequestException

import {
  ArgumentsHost,
  BadRequestException,
  Catch,
  ExceptionFilter,
} from '@nestjs/common';

@Catch(BadRequestException)
export class HelloFilter implements ExceptionFilter {
  catch(exception: BadRequestException, host: ArgumentsHost) {
    debugger;
  }
}

image.png

hello.filter.ts 更新

import {
  ArgumentsHost,
  BadRequestException,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';
import { Response } from 'express';

@Catch(BadRequestException)
export class HelloFilter implements ExceptionFilter {
  catch(exception: BadRequestException, host: ArgumentsHost) {
    const http = host.switchToHttp();
    const response = http.getResponse<Response>();

    const statusCode = exception.getStatus();

    response.status(statusCode).json({
      code: statusCode,
      message: exception.message,
      error: 'Bad Request',
      addOthers: 222,
    });
  }
}

image.png

但其实这也有个问题。

当我们用 ValidationPipe 时

比如加一个路由

@Post('aaa') 
aaa(@Body() aaaDto: AaaDto ){
    return 'success';
}

添加 src/aaa.dto.ts

export class AaaDto {
    aaa: string;
    
    bbb: number;
}

安装

npm install --save class-validator class-transformer

更新 src/aaa.dto.ts

import { IsEmail, IsNotEmpty, IsNumber } from 'class-validator';

export class AaaDto {
  @IsNotEmpty({ message: 'aaa 不能为空' })
  @IsEmail({}, { message: 'aaa 不是邮箱格式' })
  aaa: string;

  @IsNumber({}, { message: 'bbb 不是数字' })
  @IsNotEmpty({ message: 'bbb 不能为空' })
  bbb: number;
}

使用

image.png

这个是 filter 的

image.png

打断点先看下

image.png

根据结构 更新内容

import { ArgumentsHost, BadRequestException, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Response } from 'express';

@Catch(HttpException)
export class HelloFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const http = host.switchToHttp();
    const response = http.getResponse<Response>();

    const statusCode = exception.getStatus();

    const res = exception.getResponse() as { message: string[] };
    
    response.status(statusCode).json({
       code: statusCode,
       message: res?.message?.join ? res?.message?.join(',') : exception.message,
       error: 'Bad Request',
       xxx: 111
    })
  }
}

image.png

nice!

现在,ValidationPipe 的错误和其他的错误就都返回了正确的格式

想在 Filter 里注入 AppService 怎么做?

image.png

其余的全局 Guard、Interceptor、Pipe 也是这样注册

image.png

Nest 会把所有 token 为 APP_FILTER 的 provider 注册为全局 Exception Filter

比如我注入了 AppService,然后调用它的 getHello 方法

image.png

image.png

hello.filter.ts

import {
  ArgumentsHost,
  BadRequestException,
  Catch,
  ExceptionFilter,
  HttpException,
  Inject,
} from '@nestjs/common';
import { Response } from 'express';
import { AppService } from './app.service';

@Catch(HttpException)
export class HelloFilter implements ExceptionFilter {
  @Inject(AppService)
  private readonly appService: AppService;

  catch(exception: HttpException, host: ArgumentsHost) {
    const http = host.switchToHttp();
    const response = http.getResponse<Response>();

    const statusCode = exception.getStatus();

    const res = exception.getResponse() as { message: string[] };

    response.status(statusCode).json({
      code: statusCode,
      message: res?.message?.join ? res?.message?.join(',') : exception.message,
      error: 'Bad Request',
      xxx: 111,
      yyy: this.appService.getHello(),
    });
  }
}

自定义 Exception 也是可以的

比如添加一个 src/unlogin.filter.ts

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

export class UnLoginException {
  message: string;

  constructor(message?) {
    this.message = message;
  }
}

@Catch(UnLoginException)
export class UnloginFilter implements ExceptionFilter {
  catch(exception: UnLoginException, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse<Response>();

    response
      .status(HttpStatus.UNAUTHORIZED)
      .json({
        code: HttpStatus.UNAUTHORIZED,
        message: 'fail 用户未登录',
        data: exception.message || '用户未登录',
      })
      .end();
  }
}

创建了一个 UnloginException 的异常。

然后在 ExceptionFilter 里 @Catch 了它。

在 AppModule 里注册这个全局 Filter

image.png

在 AppController 里抛出这个异常

image.png

image.png

uni-app使用非uni_modules的ucharts组件,本地运行没问题,部署到线上出问题?

2026年2月5日 14:47

问题背景:使用非uni_modules的ucharts组件,本地运行没用问题,发布为h5后未见报错,但ucharts却始终出不来。

步骤复现

  1. 手上有个需求,需要使用uni-app开发微信小程序,初始时使用h5作为演示系统,需求里面存在图表展示功能。这时,网上去找对应适合的charts组件,发现ucharts可以用于移动端图表展示。

  2. 引入官方非uni_modules组件(官方有说明文档),进行开发。 image.png

  3. 按照步骤引入qiun-data-charts.vue组件后,本地运行可以正常出来,也没见报错,这时build为h5后发布到服务器,神奇的一幕出现了,charts竟然出不来!!!赶紧某度,甚至上了AI,但都没解决问题,后面我就琢磨是否配置有问题,比如opts或者eopts出问题了,但对比了一下官方api均未发现问题,只能去翻ucharts源码。

  4. 看了一下源码后发现有一段代码用到了路径,就怀疑是不是路径解析出了问题,加了个打印再次部署查看,发现果真多了个./ 然后去找了一下为什么会多出这个东西,原因是打包的时候配置了指定资源打包路径,重新修改ucharts资源路径之后就可以正常出来了。 image.pngimage.pngimage.png

以上内容仅供参考

3D环形图

作者 remember_me
2026年2月5日 14:21

image.png

import React, { useRef, useEffect, useState, useCallback } from 'react';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
import 'echarts-gl';

// 生成参数方程
function getParametricEquation(startRatio, endRatio, isSelected, isHovered, k, h) {
    let midRatio = (startRatio + endRatio) / 2;
    let startRadian = startRatio * Math.PI * 2;
    let endRadian = endRatio * Math.PI * 2;
    let midRadian = midRatio * Math.PI * 2;

    if (startRatio === 0 && endRatio === 1) {
        isSelected = false;
    }

    k = typeof k !== 'undefined' ? k : 1 / 3;

    let offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0;
    let offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0;
    let hoverRate = isHovered ? 1.05 : 1;

    return {
        u: {
            min: -Math.PI,
            max: Math.PI * 3,
            step: Math.PI / 32
        },
        v: {
            min: 0,
            max: Math.PI * 2,
            step: Math.PI / 20
        },
        x: function (u, v) {
            if (u < startRadian) {
                return offsetX + Math.cos(startRadian) * (1 + Math.cos(v) * k) * hoverRate;
            }
            if (u > endRadian) {
                return offsetX + Math.cos(endRadian) * (1 + Math.cos(v) * k) * hoverRate;
            }
            return offsetX + Math.cos(u) * (1 + Math.cos(v) * k) * hoverRate;
        },
        y: function (u, v) {
            if (u < startRadian) {
                return offsetY + Math.sin(startRadian) * (1 + Math.cos(v) * k) * hoverRate;
            }
            if (u > endRadian) {
                return offsetY + Math.sin(endRadian) * (1 + Math.cos(v) * k) * hoverRate;
            }
            return offsetY + Math.sin(u) * (1 + Math.cos(v) * k) * hoverRate;
        },
        z: function (u, v) {
            if (u < -Math.PI * 0.5) {
                return Math.sin(u);
            }
            if (u > Math.PI * 2.5) {
                return Math.sin(u) * h * 0.1;
            }
            return Math.sin(v) > 0 ? 1 * h * 0.1 : -1;
        }
    };
}

// 辅助函数:Hex 转 RGBA
function hexToRgba(hex, alpha) {
    let r, g, b;

    if (hex.startsWith('#')) {
        hex = hex.slice(1);
    }

    if (hex.length === 3) {
        r = parseInt(hex[0] + hex[0], 16);
        g = parseInt(hex[1] + hex[1], 16);
        b = parseInt(hex[2] + hex[2], 16);
    } else if (hex.length === 6) {
        r = parseInt(hex.slice(0, 2), 16);
        g = parseInt(hex.slice(2, 4), 16);
        b = parseInt(hex.slice(4, 6), 16);
    } else {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = hex;
        const computed = ctx.fillStyle;
        if (computed.startsWith('rgb')) {
            return computed.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
        }
        return hex;
    }

    return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

// 生成 3D 饼图配置
function getPie3D(pieData, internalDiameterRatio) {
    let series = [];
    let sumValue = 0;
    let startValue = 0;
    let endValue = 0;
    let legendData = [];
    let k = typeof internalDiameterRatio !== 'undefined'
        ? (1 - internalDiameterRatio) / (1 + internalDiameterRatio)
        : 1 / 3;

    // 找出最大值用于中心显示
    const maxItem = pieData.reduce((max, item) => item.value > max.value ? item : max, pieData[0]);
    const total = pieData.reduce((sum, item) => sum + item.value, 0);
    const maxPercent = ((maxItem.value / total) * 100).toFixed(1);

    for (let i = 0; i < pieData.length; i++) {
        sumValue += pieData[i].value;

        let seriesItem = {
            name: typeof pieData[i].name === 'undefined' ? `series${i}` : pieData[i].name,
            type: 'surface',
            parametric: true,
            wireframe: { show: false },
            pieData: pieData[i],
            pieStatus: { selected: false, hovered: false, k: k }
        };

        // 设置半透明颜色
        let itemStyle = {};
        if (typeof pieData[i].itemStyle !== 'undefined') {
            const color = pieData[i].itemStyle.color;
            itemStyle.color = hexToRgba(color, 0.85);
            if (typeof pieData[i].itemStyle.opacity !== 'undefined') {
                itemStyle.opacity = pieData[i].itemStyle.opacity;
            }
        }
        seriesItem.itemStyle = itemStyle;

        series.push(seriesItem);
    }

    // 计算每个扇区的起始和结束比例
    for (let i = 0; i < series.length; i++) {
        endValue = startValue + series[i].pieData.value;
        series[i].pieData.startRatio = startValue / sumValue;
        series[i].pieData.endRatio = endValue / sumValue;
        series[i].parametricEquation = getParametricEquation(
            series[i].pieData.startRatio,
            series[i].pieData.endRatio,
            false,
            false,
            k,
            series[i].pieData.value
        );
        startValue = endValue;
        legendData.push(series[i].name);
    }

    // 透明圆环,用于支撑高亮功能
    series.push({
        name: 'mouseoutSeries',
        type: 'surface',
        parametric: true,
        wireframe: { show: false },
        itemStyle: { opacity: 0 },
        parametricEquation: {
            u: { min: 0, max: Math.PI * 2, step: Math.PI / 20 },
            v: { min: 0, max: Math.PI, step: Math.PI / 20 },
            x: function (u, v) { return Math.sin(v) * Math.sin(u) + Math.sin(u); },
            y: function (u, v) { return Math.sin(v) * Math.cos(u) + Math.cos(u); },
            z: function (u, v) { return Math.cos(v) > 0 ? 0.1 : -0.1; }
        }
    });

    return {
        legend: {
            data: legendData,
            textStyle: { color: '#fff' },
            bottom: 10
        },
        tooltip: {
            formatter: params => {
                if (params.seriesName !== 'mouseoutSeries' && params.seriesIndex < series.length - 1) {
                    return `${params.seriesName}<br/>
            <span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${params.color};"></span>
            ${series[params.seriesIndex].pieData.value}`;
                }
                return '';
            }
        },
        // 中心文字
        graphic: [
            {
                type: 'text',
                left: 'center',
                top: '45%',
                style: {
                    text: maxPercent + '%',
                    textAlign: 'center',
                    fill: '#fff',
                    fontSize: 36,
                    fontWeight: 'bold',
                    textShadowBlur: 10,
                    textShadowColor: 'rgba(0, 212, 255, 0.5)'
                }
            },
            {
                type: 'text',
                left: 'center',
                top: '55%',
                style: {
                    text: maxItem.name,
                    textAlign: 'center',
                    fill: '#a0b3c6',
                    fontSize: 16
                }
            }
        ],
        xAxis3D: { min: -1, max: 1 },
        yAxis3D: { min: -1, max: 1 },
        zAxis3D: { min: -1, max: 1 },
        grid3D: {
            show: false,
            boxHeight: 15,
            viewControl: {
                // 限制视角旋转角度
                alpha: 35,
                beta: 30,
                rotateSensitivity: 0,
                zoomSensitivity: 0,
                panSensitivity: 0,
                autoRotate: false,
                distance: 200
            },
            light: {
                main: {
                    intensity: 1.2,
                    shadow: true,
                    alpha: 30,
                    beta: 30
                },
                ambient: {
                    intensity: 0.3
                }
            },
            postEffect: {
                enable: true,
                bloom: {
                    enable: true,
                    bloomIntensity: 0.15
                },
                SSAO: {
                    enable: true,
                    quality: 'medium',
                    radius: 2
                }
            }
        },
        series: series
    };
}

const Pie3DChart = ({
    data = [
        { name: 'cc', value: 80, itemStyle: { color: 'red' } },
        { name: 'aa', value: 44, itemStyle: { color: '#3edce0' } },
        { name: 'bb', value: 44, itemStyle: { color: '#f94e76' } },
        { name: 'ee', value: 44, itemStyle: { color: '#018ef1' } },
        { name: 'dd', value: 44, itemStyle: { color: '#9e60f9' } }
    ],
    internalDiameterRatio = 0.59,
    style = { width: '100%', height: '600px' },
    showLabels = true,
    labelDistance = 1.4
}) => {
    const chartRef = useRef(null);
    const [option, setOption] = useState(null);
    const selectedIndexRef = useRef('');
    const hoveredIndexRef = useRef('');
    const labelsRef = useRef([]); // 存储标签元素的引用

    // 计算扇区外侧中点的 3D 坐标
    const calculateLabelPosition = useCallback((pieData, index, k, isSelected, isHovered) => {
        const item = pieData[index];
        const startRatio = item.startRatio;
        const endRatio = item.endRatio;
        const midRatio = (startRatio + endRatio) / 2;
        const midRadian = midRatio * Math.PI * 2;

        // 基础半径(hover 时会放大)
        const hoverRate = isHovered ? 1.05 : 1;
        const radius = (1 + k) * hoverRate * labelDistance;

        // 计算中心点偏移(选中效果)
        let offsetX = 0, offsetY = 0;
        if (isSelected) {
            offsetX = Math.cos(midRadian) * 0.1;
            offsetY = Math.sin(midRadian) * 0.1;
        }

        // 计算 3D 坐标 (x, y, z)
        // 注意:z 坐标设为 pieData.value * 0.05 以在扇区中部高度
        const x = offsetX + Math.cos(midRadian) * radius;
        const y = offsetY + Math.sin(midRadian) * radius;
        const z = item.value * 0.05; // 中等高度

        return { x, y, z, midRadian, color: item.itemStyle?.color || '#fff' };
    }, [labelDistance]);

    // 将 3D 坐标转换为屏幕坐标
    const project3DToScreen = useCallback((chart, x3d, y3d, z3d) => {
        if (!chart) return { x: 0, y: 0 };

        // 获取当前的 viewControl 状态
        const option = chart.getOption();
        const viewControl = option.grid3D[0].viewControl;
        const alpha = (viewControl.alpha || 35) * Math.PI / 180; // 绕 x 轴旋转
        const beta = (viewControl.beta || 30) * Math.PI / 180;   // 绕 z 轴旋转

        // 简化的 3D 投影计算(正交投影近似)
        // 旋转矩阵:先绕 Z 轴(beta),再绕 X 轴(alpha)
        const cosA = Math.cos(alpha), sinA = Math.sin(alpha);
        const cosB = Math.cos(beta), sinB = Math.sin(beta);

        // 应用旋转
        const x1 = x3d * cosB - y3d * sinB;
        const y1 = x3d * sinB + y3d * cosB;
        const z1 = z3d;

        const y2 = y1 * cosA - z1 * sinA;
        const z2 = y1 * sinA + z1 * cosA;

        // 投影到 XY 平面,并应用缩放
        const scale = 120; // 缩放因子,需要根据 distance 调整
        const x2d = x1 * scale;
        const y2d = -y2 * scale; // Y 轴翻转(屏幕坐标系)

        // 获取图表容器中心点
        const rect = chart.getDom().getBoundingClientRect();
        const centerX = rect.width / 2 - 40;
        const centerY = rect.height / 2 - 80;

        return {
            x: centerX + x2d,
            y: centerY + y2d,
            z2: z2 // 保留深度信息用于排序
        };
    }, []);

    // 创建或更新标签和指示线
    const updateLabels = useCallback(() => {
        const chart = chartRef.current;
        if (!chart || !showLabels) return;

        const currentOption = chart.getOption();
        const series = currentOption.series;

        labelsRef.current.forEach(el => el.remove());
        labelsRef.current = [];

        const chartDom = chart.getDom();

        series.forEach((s, index) => {
            if (s.name === 'mouseoutSeries' || !s.pieData) return;

            const pos = calculateLabelPosition(
                data,
                index,
                s.pieStatus.k,
                s.pieStatus.selected,
                s.pieStatus.hovered
            );

            const screenPos = project3DToScreen(chart, pos.x, pos.y, pos.z);

            // 计算扇区边缘的起点(用于指示线)
            const edgeRate = s.pieStatus.hovered ? 1.05 : 1.0;
            const edgeX = pos.x / labelDistance * edgeRate;
            const edgeY = pos.y / labelDistance * edgeRate;
            const edgeScreenPos = project3DToScreen(chart, edgeX, edgeY, pos.z);

            // 判断左右
            const isRight = pos.x > 0;

            // ========== 关键修改:添加旋转修正 ==========
            // 获取容器中心
            const rect = chartDom.getBoundingClientRect();
            const centerX = rect.width / 2;
            const centerY = rect.height / 2;

            // 将坐标相对于中心旋转 -30 度(抵消 beta 旋转或对齐视觉)
            const rotatePoint = (x, y, angleDeg) => {
                const angle = angleDeg * Math.PI / 180;
                const dx = x - centerX;
                const dy = y - centerY;
                const rx = dx * Math.cos(angle) - dy * Math.sin(angle);
                const ry = dx * Math.sin(angle) + dy * Math.cos(angle);
                return {
                    x: centerX + rx,
                    y: centerY + ry
                };
            };

            // 对关键点应用旋转修正(顺时针30度)
            const rotationAngle = 30; // 顺时针30度
            const rotatedEdge = rotatePoint(edgeScreenPos.x, edgeScreenPos.y, rotationAngle);
            const rotatedScreen = rotatePoint(screenPos.x, screenPos.y, rotationAngle);

            const elbowX = isRight ? rotatedScreen.x + 10 : rotatedScreen.x - 10;

            // 创建 SVG 容器
            const group = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            group.style.cssText = `
            position: absolute;
            left: 0;
            top: 0;
            width: 100%;
            height: 100%;
            pointer-events: none;
            z-index: 100;
            overflow: visible;
        `;

            // 使用旋转后的坐标绘制指示线
            const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
            const points = `${rotatedEdge.x},${rotatedEdge.y} ${rotatedScreen.x},${rotatedScreen.y} ${elbowX},${rotatedScreen.y}`;

            line.setAttribute('points', points);
            line.setAttribute('fill', 'none');
            line.setAttribute('stroke', pos.color);
            line.setAttribute('stroke-width', '2');
            line.setAttribute('stroke-linecap', 'round');
            line.setAttribute('stroke-linejoin', 'round');
            line.setAttribute('opacity', '0.9');
            group.appendChild(line);

            // 标签背景(使用旋转后的坐标)
            const percent = ((s.pieData.value / data.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1);
            const textContent = `${s.name} ${percent}%`;

            const tempText = document.createElement('span');
            tempText.style.cssText = 'position:absolute;visibility:hidden;font-size:13px;font-weight:bold;';
            tempText.innerText = textContent;
            document.body.appendChild(tempText);
            const textWidth = tempText.offsetWidth + 16;
            const textHeight = 26;
            document.body.removeChild(tempText);

            const labelPadding = 8;
            const labelX = isRight ? elbowX + 5 : elbowX - textWidth - 5;
            const labelY = rotatedScreen.y - textHeight / 2;

            const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
            bg.setAttribute('x', labelX);
            bg.setAttribute('y', labelY);
            bg.setAttribute('width', textWidth);
            bg.setAttribute('height', textHeight);
            bg.setAttribute('rx', '4');
            bg.setAttribute('fill', 'rgba(0,0,0,0.75)');
            bg.setAttribute('stroke', pos.color);
            bg.setAttribute('stroke-width', '1.5');
            group.appendChild(bg);

            const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
            text.setAttribute('x', isRight ? labelX + labelPadding : labelX + textWidth - labelPadding);
            text.setAttribute('y', labelY + textHeight / 2 + 4);
            text.setAttribute('fill', '#fff');
            text.setAttribute('font-size', '13');
            text.setAttribute('font-weight', 'bold');
            text.setAttribute('text-anchor', isRight ? 'start' : 'end');
            text.textContent = textContent;
            group.appendChild(text);

            chartDom.appendChild(group);
            labelsRef.current.push(group);
        });
    }, [data, showLabels, labelDistance, calculateLabelPosition, project3DToScreen]);

    useEffect(() => {
        const initialOption = getPie3D(data, internalDiameterRatio);
        setOption(initialOption);
    }, [data, internalDiameterRatio]);

    // 标签更新定时器
    useEffect(() => {
        if (!option || !showLabels) return;

        // 延迟一点等待渲染完成
        const timer = setTimeout(updateLabels, 500);

        // 监听窗口大小变化
        const handleResize = () => {
            updateLabels();
        };
        window.addEventListener('resize', handleResize);

        return () => {
            clearTimeout(timer);
            window.removeEventListener('resize', handleResize);
            labelsRef.current.forEach(el => el.remove());
        };
    }, [option, showLabels, updateLabels]);

    const onChartReady = (chart) => {
        chartRef.current = chart;

        // 点击事件 - 选中效果
        chart.on('click', function (params) {
            if (params.seriesIndex >= option.series.length - 1) return;

            let currentOption = chart.getOption();
            let series = currentOption.series;

            let isSelected = !series[params.seriesIndex].pieStatus.selected;
            let isHovered = series[params.seriesIndex].pieStatus.hovered;
            let k = series[params.seriesIndex].pieStatus.k;
            let startRatio = series[params.seriesIndex].pieData.startRatio;
            let endRatio = series[params.seriesIndex].pieData.endRatio;

            // 取消之前选中的
            if (selectedIndexRef.current !== '' && selectedIndexRef.current !== params.seriesIndex) {
                series[selectedIndexRef.current].parametricEquation = getParametricEquation(
                    series[selectedIndexRef.current].pieData.startRatio,
                    series[selectedIndexRef.current].pieData.endRatio,
                    false,
                    false,
                    k,
                    series[selectedIndexRef.current].pieData.value
                );
                series[selectedIndexRef.current].pieStatus.selected = false;
            }

            // 设置当前选中状态
            series[params.seriesIndex].parametricEquation = getParametricEquation(
                startRatio, endRatio, isSelected, isHovered, k, series[params.seriesIndex].pieData.value
            );
            series[params.seriesIndex].pieStatus.selected = isSelected;

            if (isSelected) {
                selectedIndexRef.current = params.seriesIndex;
            } else {
                selectedIndexRef.current = '';
            }

            chart.setOption(currentOption);

            // 更新标签位置
            setTimeout(updateLabels, 100);
        });

        // mouseover - 高亮效果
        chart.on('mouseover', function (params) {
            if (params.seriesName === 'mouseoutSeries') return;
            if (hoveredIndexRef.current === params.seriesIndex) return;

            let currentOption = chart.getOption();
            let series = currentOption.series;

            // 取消之前的高亮
            if (hoveredIndexRef.current !== '') {
                let idx = hoveredIndexRef.current;
                let isSelected = series[idx].pieStatus.selected;
                let k = series[idx].pieStatus.k;
                series[idx].parametricEquation = getParametricEquation(
                    series[idx].pieData.startRatio,
                    series[idx].pieData.endRatio,
                    isSelected,
                    false,
                    k,
                    series[idx].pieData.value
                );
                series[idx].pieStatus.hovered = false;
            }

            // 设置新的高亮
            let isSelected = series[params.seriesIndex].pieStatus.selected;
            let k = series[params.seriesIndex].pieStatus.k;
            series[params.seriesIndex].parametricEquation = getParametricEquation(
                series[params.seriesIndex].pieData.startRatio,
                series[params.seriesIndex].pieData.endRatio,
                isSelected,
                true,
                k,
                series[params.seriesIndex].pieData.value + 5
            );
            series[params.seriesIndex].pieStatus.hovered = true;
            hoveredIndexRef.current = params.seriesIndex;

            chart.setOption(currentOption);
            setTimeout(updateLabels, 50);
        });

        // globalout - 取消高亮
        chart.on('globalout', function () {
            if (hoveredIndexRef.current === '') return;

            let currentOption = chart.getOption();
            let series = currentOption.series;
            let idx = hoveredIndexRef.current;

            let isSelected = series[idx].pieStatus.selected;
            let k = series[idx].pieStatus.k;

            series[idx].parametricEquation = getParametricEquation(
                series[idx].pieData.startRatio,
                series[idx].pieData.endRatio,
                isSelected,
                false,
                k,
                series[idx].pieData.value
            );
            series[idx].pieStatus.hovered = false;
            hoveredIndexRef.current = '';

            chart.setOption(currentOption);
            setTimeout(updateLabels, 50);
        });

        // 初始更新标签
        setTimeout(updateLabels, 300);
    };

    if (!option) return null;

    return (
        <div style={{ position: 'relative', ...style }}>
            <ReactECharts
                option={option}
                style={{ width: '100%', height: '100%' }}
                onChartReady={onChartReady}
                opts={{ renderer: 'canvas' }}
            />
        </div>
    );
};

export default Pie3DChart;


JS-一文带你彻底搞懂 Promise 并发控制:all, race, any, allSettled

2026年2月4日 15:40

前言

在处理异步任务时,我们经常需要同时发起多个请求。Promise 提供的静态方法能让我们优雅地控制多个并发异步任务。本文将深度对比 allraceanyallSettled 的区别与应用场景。

一、 方法详解与对比

1. Promise.all() —— “全员通过制”

  • 概念:将多个 Promise 实例包装成一个。

  • 状态决定

    • Fulfilled:所有实例都成功。
    • Rejected:只要有一个失败,整体立即失败。
  • 应用场景:多个接口联动,必须全部拿到数据才能渲染页面。

  const p1 = Promise.resolve(1);
  const p2 = Promise.resolve(2);
  const p3 = Promise.resolve(3);

  Promise.all([p1, p2, p3])
    .then((results) => {
      console.log(results); // [1, 2, 3] 顺序与传入一致
    })
    .catch((err) => {
      console.error('其中一个失败了', err);
    });

2. Promise.race() —— “竞速制”

  • 概念:谁跑得快就听谁的。
  • 状态决定:状态取决于第一个改变状态的实例。
  • 应用场景:请求超时控制。
  const p1 = new Promise((resolve) =>
    setTimeout(() => resolve('1秒后成功'), 1000)
  );
  const p2 = new Promise((resolve) =>
    setTimeout(() => resolve('500毫秒后成功'), 500)
  );
  Promise.race([p1, p2]).then(
    (res) => console.log(`测试1结果:${res} (竞速赢家)`, 'success'),
    (err) => console.log(`测试1失败:${err}`, 'error')
  );

3. Promise.any() —— “择优录取制”

  • 概念:只要有一个成功就算成功。

  • 状态决定

    • Fulfilled:只要有一个成功。
    • Rejected全部都失败时才失败(返回 AggregateError)。
  • 应用场景:从多个备用服务器获取相同资源。

  const p1 = Promise.resolve(1);
  const p2 = Promise.reject(2);
  const p3 = Promise.reject(3);
  Promise.any([p1, p2, p3])
    .then((res) => console.log(`有一个成功了:${res}`, 'success'))
    .catch((err) => console.log(`所有都失败了:${err}`, 'error'));

4. Promise.allSettled() —— “结果导向制”

  • 概念:无论成功失败,我全都要。
  • 状态决定:永远是 fulfilled(在所有实例都结束后)。
  • 应用场景:执行多个互不影响的操作,最后统一统计结果。

二、 核心差异对比表

为了 scannability(易读性),我们通过表格直观对比:

方法 成功条件 (Fulfilled) 失败条件 (Rejected) 结果返回值
.all() 全部成功 任意一个失败 成功结果数组(按序)
.race() 任意一个最先成功 任意一个最先失败 第一个改变状态的值
.any() 任意一个成功 全部失败 第一个成功的值
.allSettled() 所有任务结束 从不(状态永远成功) 包含状态和值的对象数组

三、 总结

1. Promise.all 与 Promise.race 的直观区别

  • Promise.all:照顾“跑得最慢”的。必须等最慢的一个完成,且全员合格,才给最终结果。
  • Promise.race:关注“跑得最快”的。最快的那个一旦过线,无论输赢,比赛立刻结束。

2. 进阶使用

在项目中,配合 async/await 使用更加优雅:

const fetchData = async () => {
  try {
    // 强类型约束结果数组
    const [user, orders] = await Promise.all<[UserType, OrderType[]]>([
      getUserInfo(),
      getOrderList()
    ]);
    console.log(user, orders);
  } catch (error) {
    // 处理第一个捕获到的错误
  }
};

JS-手写系列:从零手写 Promise

2026年2月4日 15:17

前言

Promise 是 JavaScript 处理异步编程的基石。虽然我们在日常开发中频繁使用 async/await,但手动实现一个符合 Promise规范的类,不仅能让你在面试中脱颖而出,更能让你深刻理解微任务与链式调用的本质。


一、 Promise 核心设计方案

实现一个标准的 Promise,必须紧扣以下四个核心点:

  1. 状态机机制:存在 PENDING(等待)、FULFILLED(成功)、REJECTED(失败)三种状态,状态转换不可逆。
  2. 立即执行:构造函数中的执行器 executor(resolve, reject) 是同步立即执行的。
  3. 微任务队列:回调函数的执行必须是异步的,通常使用 queueMicrotask 来实现。
  4. 链式调用then 方法必须返回一个新的 Promise,并将前一个 Promise 的输出作为后一个 Promise 的输入。

二、 代码实现

      // 定义Promise的三种状态
      const PENDING = 'pending';
      const FULFILLED = 'fulfilled';
      const REJECTED = 'rejected';

      /**
       * 自定义Promise实现
       * @param {Function} executor 执行器函数,接收resolve和reject参数
       */
      function MyPromise(executor) {
        const self = this; // 保存this指向,避免回调中丢失
        self.status = PENDING; // 初始状态为pending
        self.value = undefined; // 成功的结果值
        self.reason = undefined; // 失败的原因
        self.onFulfilledCallbacks = []; // 存储成功回调
        self.onRejectedCallbacks = []; // 存储失败回调

        // 成功回调函数
        function resolve(value) {
          // 只有pending状态才能改变
          if (self.status === PENDING) {
            self.status = FULFILLED;
            self.value = value;
            // 异步执行所有成功回调,因为then方法是异步的,所以要等executor执行完再执行
            queueMicrotask(() => {
              self.onFulfilledCallbacks.forEach((callback) => {
                callback(self.value);
              });
            });
          }
        }

        // 失败回调函数
        function reject(reason) {
          // 只有pending状态才能改变
          if (self.status === PENDING) {
            self.status = REJECTED;
            self.reason = reason;
            // 异步执行所有失败回调
            setTimeout(() => {
              self.onRejectedCallbacks.forEach((callback) => {
                callback(self.reason);
              });
            });
          }
        }

        try {
          // 立即执行执行器函数
          executor(resolve, reject);
        } catch (error) {
          // 执行器抛出异常时,直接调用reject
          reject(error);
        }
      }

      /** then方法实现
       * @param {Function} onFulfilled 成功回调
       * @param {Function} onRejected 失败回调
       * @returns {MyPromise} 返回新的Promise实现链式调用
       */
      MyPromise.prototype.then = function (onFulfilled, onRejected) {
        const self = this;
        // 处理默认回调(兼容不传回调的情况)
        onFulfilled =
          typeof onFulfilled === 'function' ? onFulfilled : (value) => value;
        onRejected =
          typeof onRejected === 'function'
            ? onRejected
            : (reason) => {
                throw reason;
              };

        // 返回新的Promise实现链式调用,因为后续函数也可以通过then方法来处理
        return new MyPromise((resolve, reject) => {
          // 处理成功状态
          if (self.status === FULFILLED) {
            setTimeout(() => {
              try {
                // 执行成功回调并获取返回值
                const result = onFulfilled(self.value);
                // 根据返回值处理新Promise的状态
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }


          // 处理失败状态
          if (self.status === REJECTED) {
            setTimeout(() => {
              try {
                // 执行失败回调并获取返回值
                const result = onRejected(self.reason);
                // 根据返回值处理新Promise的状态
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }

          // 处理等待状态(暂存回调)
          if (self.status === PENDING) {
            self.onFulfilledCallbacks.push(() => {
              try {
                const result = onFulfilled(self.value);
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });

            self.onRejectedCallbacks.push(() => {
              try {
                const result = onRejected(self.reason);
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }
        });
      };

      /** catch方法实现(语法糖,等价于then(null, onRejected))
       * @param {Function} onRejected 失败回调
       * @returns {MyPromise}
       */
      MyPromise.prototype.catch = function (onRejected) {
        return this.then(null, onRejected);
      };

      //静态方法resolve - 返回一个已完成的Promise
      MyPromise.resolve = function (value) {
        if (value instanceof MyPromise) {
          return value;
        }
        return new MyPromise((resolve) => {
          resolve(value);
        });
      };

      //静态方法reject - 返回一个已失败的Promise
      MyPromise.reject = function (reason) {
        return new MyPromise((_, reject) => {
          reject(reason);
        });
      };

      // 处理then回调返回值的工具函数
      function resolvePromise(result, resolve, reject) {
        // 如果返回值是当前Promise,抛出循环引用错误
        if (result instanceof MyPromise) {
          result.then(resolve, reject);
        } else {
          // 普通值直接resolve
          resolve(result);
        }
      }
    
      // 测试用例
      console.log('--- 测试用例开始 ---');

      // 1. 基本 resolve
      const p1 = new MyPromise((resolve) => {
        setTimeout(() => resolve('成功1'), 100);
      })
      p1.then(res => {
        console.log('Test 1: 成功1', res); // 成功1
      })
      p1.catch(err => {
        console.log('Test 1: 失败1', err);
      })

      // 2. 基本 reject + catch
      // const p2 = new MyPromise((_, reject) => {
      //   setTimeout(() => reject('失败1'), 100);
      // })
      // p2.then(success => {
      //   console.log('Test 2: 成功1', success); // 失败1
      // }, (err) => {
      //   console.log('Test 2: 失败1', err); // 失败1
      // })
      // p2.catch(err => {
      //   console.log('Test 2: 失败2', err); // 失败1
      // })

      // 3. executor 抛出异常
      // new MyPromise(() => {
      //   throw new Error('executor error');
      // }).catch(err => {
      //   console.log('Test 4: 异常', err.message); // executor error
      // });

      // 4. Promise 链式嵌套
      // const p4 = new MyPromise((resolve) => resolve(1))
      //   .then((v) => v + 1)
      //   .then((v) => MyPromise.resolve(v * 2))
      //   .then((v) => {
      //     console.log('Test 5:', v); // 4
      //   });

三、 深度细节解析

1. 为什么使用 queueMicrotask

根据 Promise规范,onFulfilledonRejected 必须在执行上下文栈仅包含平台代码时执行。这意味着回调必须是异步的。使用微任务而不是宏任务(如 setTimeout),是为了保证在当前任务循环结束前尽可能快地执行回调。

2. 状态不可逆性

resolvereject 函数中,我们首先判断 state === 'pending'。一旦状态变为 fulfilledrejected,后续任何调用都将被忽略,这保证了 Promise 的稳定性。


四、 总结

  1. 同步执行 executor
  2. 异步收集 回调函数。
  3. 递归解析 返回值,实现链式调用。

JS-手写系列:树与数组相互转换

2026年2月5日 11:48

前言

在前端业务中,后端返回的扁平化数组(Array)往往需要转换为树形结构(Tree)来适配 UI 组件(如 Element UI 的 Tree 或 Cascader)。掌握多种转换思路及性能差异,是进阶高级前端的必备技能。

一、 核心概念:结构对比

  • 数组结构:每一项通过 parentId 指向父级。

      const nodes = [
        { id: 3, name: '节点C', parentId: 1 },
        { id: 6, name: '节点F', parentId: 3 },
        { id: 0, name: 'root', parentId: null },
        { id: 1, name: '节点A', parentId: 0 },
        { id: 8, name: '节点H', parentId: 4 },
        { id: 4, name: '节点D', parentId: 1 },
        { id: 2, name: '节点B', parentId: 0 },
        { id: 5, name: '节点E', parentId: 2 },
        { id: 7, name: '节点G', parentId: 2 },
        { id: 9, name: '节点I', parentId: 5 },
      ];
    
  • 树形结构:父级通过 children 数组包裹子级。

      let tree = [
        {
          id: 1,
          name: 'text1',
          parentId: 1,
          children: [
            {
              id: 2,
              name: 'text2',
              parentId: 1,
              children: [
                {
                  id: 4,
                  name: 'text4',
                  parentId: 2,
                },
              ],
            },
            {
              id: 3,
              name: 'text3',
              parentId: 1,
            },
          ],
        },
      ];
    

二、 数组转树

1. 递归思路

原理

  1. 首先需要传递给函数两个参数:数组、当前的父节点id
  2. 设置一个结果数组res,遍历数组,先找到子元素的父节点id与父节点id一致的子项
  3. 将这个子项的id作为父节点id传入函数,继续遍历
  4. 将遍历的结果作为children返回,并给当前项添加children
  5. 将这个当前项,插入到res里面,并返回

注意:如果不想影响原数组,需要先深拷贝一下数组。const cloneArr = JSON.parse(JSON.stringify (arr))

  const nodes = [
    { id: 3, name: '节点C', parentId: 1 },
    { id: 6, name: '节点F', parentId: 3 },
    { id: 0, name: 'root', parentId: null },
    { id: 1, name: '节点A', parentId: 0 },
    { id: 8, name: '节点H', parentId: 4 },
    { id: 4, name: '节点D', parentId: 1 },
    { id: 2, name: '节点B', parentId: 0 },
    { id: 5, name: '节点E', parentId: 2 },
    { id: 7, name: '节点G', parentId: 2 },
    { id: 9, name: '节点I', parentId: 5 },
  ];
  //递归写法
  const arrToTree1 = (arr, id) => {
    const res = [];
    arr.forEach((item) => {
      if (item.parentId === id) {
        const children = arrToTree1(arr, item.id);
        //如果希望每个元素都有children属性,可以直接赋值
        if (children.length !== 0) {
          item.children = children;
        }
        res.push(item);
      }
    });
    return res;
  };
  console.log(arrToTree1(nodes, null));

2. 非递归思路

原理:利用 filter 进行二次筛选。虽然写法简洁,但在大数据量下性能较差(O(n2)O(n^2))。

  1. 函数只需要接受一个参数,也就是需要转换的数组arr
  2. 第一层过滤数组,直接返回一个parentId为根id的元素
  3. 但是在返回之间,需要再根据当前id过滤里面的每一项(过滤规则为如果子项的paentId为当前的id,则在当前项的children插入这个子项)
  const arrToTree2 = (arr) => {
    return arr.filter((father) => {
      const childrenArr = arr.filter((children) => {
        return children.parentId === father.id;
      });
      //如果希望每个元素都有children属性,可以直接赋值
      if (childrenArr.length !== 0) {
        father.children = childrenArr;
      }
      return father.parentId === null;
    });
  };
  console.log(arrToTree2(nodes));

3. Map 对象方案(O(n)O(n) 时间复杂度)

原理:利用对象的引用性质。先将数组转为 Map,再遍历一次即可完成。这是在大数据量下的首选方案。

  const arrToTree3 = (arr) => {
    const map = {};
    const res = [];

    // 1. 建立映射表
    arr.forEach((item) => {
      map[item.id] = { ...item, children: [] };
    });

    // 2. 组装树结构
    arr.forEach((item) => {
      const node = map[item.id];
      if (item.parentId === null) {
        res.push(node);
      } else {
        if (map[item.parentId]) {
          map[item.parentId].children.push(node);
        }
      }
    });
    return res;
  };
  console.log(arrToTree3(nodes));

三、 树转数组

1. 递归遍历思路

原理:定义一个结果数组,递归遍历树的每一层,将节点信息(排除 children)推入数组。

  1. 首先定义一个结果数组res,遍历传入的树
  2. 直接将当前项的id、name、parentId包装在一个新对象里插入
  3. 判断是否有children属性,如果有则遍历children属性每一项,继续执行2、3步骤
  let tree = [
    {
      id: 1,
      name: 'text1',
      parentId: 1,
      children: [
        {
          id: 2,
          name: 'text2',
          parentId: 1,
          children: [
            {
              id: 4,
              name: 'text4',
              parentId: 2,
            },
          ],
        },
        {
          id: 3,
          name: 'text3',
          parentId: 1,
        },
      ],
    },
  ];
  const treeToArr = (tree) => {
    const res = [];
    tree.forEach((item) => {
      const loop = (data) => {
        res.push({
          id: data.id,
          name: data.name,
          parseId: data.parentId,
        });
        if (data.children) {
          data.children.forEach((itemChild) => {
            loop(itemChild);
          });
        }
      };
      loop(item);
    });
    return res;
  };
  console.log(treeToArr(tree));

四、 注意事项:深拷贝的必要性

在处理这些转换时,由于 JS 的对象是引用类型,直接修改 item.children 会改变原始数组的内容。

  • 快捷方案const cloneArr = JSON.parse(JSON.stringify(arr))
  • 避坑点:如果数组项中包含 Date 对象、RegExpFunctionJSON.parse 会导致数据失真,此时应使用其他深拷贝方案。

如果你正在使用 Tiptap 做协同编辑器,那么我建议你使用 Monorepo 架构是最舒服的选择

作者 Moment
2026年2月5日 11:37

昨天把 DocFlow 重构成了 Monorepo 架构,主要是为了解决协同编辑中的 Schema 同步问题。

20260205105346

项目使用 Tiptap 做协同编辑,自定义节点较多,而 Yjs 传递的是二进制数据。像警告框 Alert 这类自定义节点,在前端是具体的 UI 组件,但在 Hocuspocus 后端必须有对应的 Transformer 逻辑,才能将二进制数据准确还原成 JSON 或 HTML。

没有 Monorepo 时,每加一个新功能(如 alert.ts),都要在前端和后端分别维护一套 Schema。一旦漏掉同步,后端解析时就不认识这个节点,辛辛苦苦存的数据可能直接丢失。

采用 Monorepo 后,架构清晰多了:

  • 原子化解耦:每个自定义节点如 @syncflow/alert 都是独立包,职责单一,
  • 逻辑共享:transformer 包统一组装这些节点,导出一个全能的解析器
  • 多端复用:前端编辑器用它来渲染,后端 Hocuspocus 用它做数据转换

最终实现一套 Schema 定义,全链路通用,改一下 alert.ts 的规则,全端自动生效,维护效率大幅提升。

❌
❌