普通视图
GRAB:面向广告CTR预测的生成式排序框架,突破序列建模与泛化瓶颈
近日,百度商业技术团队释出生成式排序框架GRAB(Generative Ranking for Ads at Baidu)技术细节论文。传统深度学习推荐模型(DLRM)长期存在的泛化能力不足、行为序列建模瓶颈,百度商业技术团队以大语言模型(LLM)规模化经验为启发,推出生成式排序建模范式,将用户序列建模重塑为第一级结构。我们设计了因果动作感知多通道注意力(CamA)、先序列后表征训练(STS)等关键算法,实现了开箱式端到端序列化建模;线上结果显示,GRAB相较传统DLRM体系收入提升3.05%、CTR提升3.49%,并呈现出随交互序列、模型规模增长的稳定Scaling能力。
论文链接:[arxiv.org/abs/2602.01…]
中文解读:[微信公众号]
01 面向CTR预测的“生成式排序”新范式
长期以来,DLRM体系在广告推荐/排序场景中占据主流,但在复杂用户行为序列下,往往需要重度特征工程与稀疏/稠密特征协同,仍可能出现对长序列利用不足、跨场景泛化受限等问题。GRAB以端到端生成式框架重构CTR建模流程,通过统一建模与训练策略,增强对长历史交互信息的吸收能力,并将用户行为中的关键“动作信号”纳入因果视角下的注意力建模,以更稳定地刻画时序动态与意图演化。
![]()
△GRAB模型设计核心结构
02 三项关键创新:从结构到训练的系统性升级
1. 端到端生成式框架(End-to-End Generative Framework)将CTR预测问题重构生成式排序范式,降低对传统DLRM中显式特征工程与复杂组件堆叠的依赖,使整体建模路径更统一、更可扩展。
2. 因果动作感知多通道注意力(Causal Action-aware Multi-channel Attention, CamA)在多通道注意力结构中显式刻画用户行为序列中的动作信号及时空关系,更有效捕捉“时序动态 + 行为动作”的耦合信息,从而提升预测质量与稳定性。
3. 面向规模化的训练策略(Sequence-Then-Sparse, STS)提出“先序列、后稀疏(STS)”训练组织方式,在保证序列建模能力的同时兼顾稀疏特征与训练效率需求,为工业级大规模ID特征与自回归序列化训练与部署提供可落地的优化路径。
03 线上核心场景全量部署:收益与CTR实现稳定提升
在线上部署实验中,GRAB相较既有DLRM体系取得显著改进:收入提升3.05%,CTR提升3.49%。同时,模型呈现出明确的Scaling-Law:随着纳入更长的用户交互序列,更大的模型尺寸,其表达能力提升表现为单调、近似线性增长,显示出对长序列信息的更强利用效率与更好的扩展潜力。
GRAB的价值不仅体现在指标提升,更在于其面向工业推荐系统的可扩展路径:通过生成式建模范式与推荐场景的结合,在“数据、计算、算法”的约束下,提供了可复用的算法框架与工程化实现方案,为后续更长上下文、更强泛化能力的广告排序模型演进奠定基础。
Sed Cheatsheet
How to Fix SSH "Permission Denied (publickey)" Error
彻底搞懂大文件上传:从幼儿园到博士阶段的全栈方案
第一层:幼儿园阶段 —— 为什么要搞复杂上传?
想象一下:你要把家里的**一万本书(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 哈希值。
流程:
- 前端用 spark-md5 计算文件哈希。
- 上传前先问后端:"这个 MD5 对应的文件你有没有?"
- 后端查 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 记录已收到的分片序号。
流程:
- 重新上传前,调用 checkChunks 接口
- 后端查库返回:{ uploadedList: [1, 2, 5] }
- 前端过滤掉已存在的序号,只发剩下的
为什么选择后端记录?
- 前端存储不可靠(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请求导致网络拥堵
- 平衡上传速度和系统稳定性
一、 为什么要搞并发控制?(痛点)
-
浏览器"自保"机制: Chrome 浏览器对同一个域名的 HTTP 连接数有限制(通常是 6 个)。 如果你瞬间发起 1000 个请求,剩下的 994 个会处于 Pending(排队)状态。虽然不会直接崩溃,但会阻塞该域名的其他所有请求(比如你同时想加载一张图片,都要排在 900 多个切片后面)。
-
内存与性能压力: 前端: 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);
}
五、 关键思路拆解(给面试官讲透)
-
为什么要传入 tasks 函数数组,而不是 Promise 数组? 回答: "因为 Promise 一旦创建就会立即执行。如果我传 [axios(), axios()],那并发控制就没意义了。我必须传 [() => axios(), () => axios()],这样我才能在循环里手动控制什么时候执行它。"
-
Promise.race 起到了什么作用? 回答: "它充当了'阻塞器'。当池子满了,await Promise.race(pool) 会让 for 循环停下来。只有当池子里最快的一个请求完成了,race 才会解除阻塞,循环继续,从而发起下一个请求。"
-
为什么是 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 监控每个分片,结合已上传分片数量计算加权平均进度)。
生成器下(生成器异步)
生成器下(生成器异步)
上一章讨论了生成器作为一个产生值的机制的特性,但生成器远远不止这些,更多的时候我们关注的是生成器在异步编程中的使用
生成器 + 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转前端代码了!
最近在折腾设计稿转代码(D2C)这事儿,试了不少工具:有 Figma 插件直出的,有 AI 一键生成的,还有各种半自动的 CLI。
说实话,大部分体验都停留在看起来还行,但一跑起来就露馅——padding 歪了、字体渲染不一样、嵌套一堆 div 地狱、交互状态完全没考虑……最后还是得自己从头抠一遍,省的时间有限,气的血压倒是不少。
直到刷到 CodeRio 这个开源项目,抱着“再试一次死马当活马医”的心态玩了玩,结果这次有点不一样:生成的代码居然基本能直接用,视觉偏差小到我愿意 commit,结构也比较工程化。
简单聊聊我的使用感受和它为什么让我改观。
一、先说痛点:为什么传统 D2C 总差口气?
市面上的 D2C 工具大多是“一次性映射”:解析 Figma JSON → 直接转 CSS/JSX。
简单静态页还凑合,复杂一点的就不大行了:
- 层级嵌套深 → 生成一堆无语义 div
- 动态间距/响应式 → 直接崩
- 组件状态(hover/active) → 基本忽略
- 最要命的:Figma 渲染和浏览器渲染本来就不 100% 一致(子像素、字体、抗锯齿等),工具生成完就不管了,开发者打开浏览器一看:“这谁点的赞?”
结果就是:工具省了 20% 时间,后续修 bug 花 200% 时间。
二、CodeRio 的思路:不求一步到位,先求“能闭环”
CodeRio 没走“AI 一键神还原”的玄学路线,而是老老实实模仿了我们平时写代码的真实流程:写 → 跑 → 看效果 → 改 → 再跑……
它把整个过程拆成多步流水线,由几个智能体协作完成,每个环节都有校验,而不是一次性赌。
核心几个步骤大概是这样的:
- 协议生成(D2P)。 先不急着出代码,从 Figma 提取结构、布局、样式,生成一份中间协议(Protocol)。 这个协议很聪明:会自动识别组件层级(哪些是可复用组件、哪些是布局容器)、把 Figma 的各种约束转成 flex/grid 描述、处理图片资源路径…… 简单说,就是把乱七八糟的设计数据“翻译”成前端能理解的结构化蓝图,避免 AI 直接啃原始 JSON 乱猜。
- 代码生成(P2C)。 基于协议出 React + TypeScript + Tailwind 代码。 选这套栈我很认可:Tailwind 原子类几乎能 1:1 对应设计决策,TS 保证类型安全,React 生态最稳。
- 自动跑起来 + 视觉验证。 这步是杀手锏:代码生成完,系统自动 npm install && npm run dev,在真实浏览器(headless Chromium)里把页面跑起来,然后截图。 再把截图和原设计稿像素级对比(用 MAE、SSIM 等指标量化),甚至生成热力图标出偏差区域。 如果没达到阈值,就触发优化智能体针对性修(比如调 padding、z-index、字体 fallback 等),修完再验证……直到通过或到最大轮次。
- 工程化兜底。 考虑到大文件容易超时/断网,它加了检查点 + 断点续传:每个阶段自动存状态,断了也能从断点接着来,不用重头跑。 省 Token,也省心态。
用下来最爽的点是:它真的会“看”浏览器效果,而不是只管生成完拉倒。这让我第一次觉得 D2C 不是在做 Demo,而是在接近生产级输出。
三、实际体验
放几个case让大家看看效果,还是非常不错的!
![]()
![]()
![]()
最后说两句
现在的 D2C 工具很多,但大多数还是“转出来凑合能看”。
CodeRio 让我看到另一种可能:通过协议定蓝图、浏览器闭环修偏差、工程化兜底,把“看起来差不多”升级成“基本能用”。
它不是要完全取代开发者,而是把最烦人的像素对齐和初稿调整交给机器,让我们把精力放在业务逻辑和交互上。
对日常开发来说,这就已经很实用了。
项目是开源的,感兴趣的可以去玩玩。👉github.com/MigoXLab/co…
有玩过的同学也欢迎评论区交流体验~
从 N 个 useXxxModal 到 async-modal-render:让弹窗也能被 await
从 N 个 useXxxModal 到 async-modal-render:让弹窗也能被 await
背景:从回调地狱到线性流程
在 React 里,传统的弹窗调用方式通常长这样:
- 组件里用
useState维护visible - 点击按钮时把
visible置为true - 把业务逻辑塞进
Modal的onOk/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 回去 - 下次用同一个
persistentkey 打开,拿到的是同一个组件实例,内部状态自然能被保留
对应地,还提供了一个 destroy 方法,用来:
- 按
persistent定位并销毁某个持久化弹窗 - 可选地按可见性筛选(仅销毁 visible / hidden 的实例)
为了防止误用,在实现里加了一个 PersistentComponentConflictError:
- 同一个
persistentkey 如果对应了不同的组件构造器,会直接抛错 - 避免出现“你以为是同一个弹窗,其实已经换了组件,导致 React 状态错乱”的隐性 bug
6. Quiet 模式:给调用方一个“不吵闹”的选择
在很多场景里,“用户取消”本质上并不是一个错误:
- 用户点了“取消”,业务上通常认为是一个正常分支
- 如果每次都
reject,调用方就必须在catch里区分取消和真实错误
为此抽象出了 Quiet 模式:
- 普通模式:
onCancel→reject AsyncModalRenderCancelError - Quiet 模式:
onCancel→resolve(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/openField与destroyAPI,将持久化能力变成一等特性 - 补充测试:验证多次打开状态保留、不同 key 的隔离,以及各种销毁组合
- 为持久化场景增加
PersistentComponentConflictError,防止相同 key 绑定不同组件 - 在文档中补充与 NiceModal、传统写法的对比说明,讲清楚设计取舍
后知后觉:遇见 NiceModal 之后
做到这一步之后,我才后知后觉地发现:社区里其实已经有了一个类似的库——@ebay/nice-modal-react。
第一次看 NiceModal 的文档时,心情大概是:
- 一方面“啊,原来大家都在为弹窗这件事头疼”
- 另一方面“还好我走的路线跟它不完全一样”
更重要的是,NiceModal 确实给了我不少启发,尤其是在两个点上:
1. 持久化:让弹窗更像“页面的一部分”
NiceModal 里很早就有“隐藏而不卸载”的设计,这和我后来做的 persistent 能力在理念上非常契合:弹窗不一定是一次性的,它可以像页面一样长期存在,只是偶尔被展示出来。
这也从侧面印证了当初在业务里感受到的那种“不想每次都重置状态”的痛点是普遍存在的。于是我也更加坚定地把:
-
persistent/openField -
destroyAPI - 以及相关的错误保护
当成库的一等公民来维护,而不是“某个高级用法”。
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插件
插件介绍
SQLite作为轻量级数据库,被用在很多小型APP中。之前在开发electron应用时,还到处找SQLite软件,但是没一个满意的,现在AI编程最大的便利,便是让很多人以最小代价去实现软件应用。
顺便用国产模型第一梯队的GLM,对比下经常用的Claude,看看能否平替。
utools 应该很多人都用过,有很多提升开发效率的小工具。这个插件就是在utools上架的,大家可以搜索体验一下,顺便附上插件链接:www.u-tools.cn/plugins/det…
1.表数据预览
2.表结构编辑
3.新建表,支持表单模型/sql模式
4.全局搜索
5.AI辅助功能当然要有
![]()
开发工具
个人觉得最适合前端的就是安装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的示例写法,但还是会写偏,表现在:
-
useSnapshot后的对象React才能感知到变化,经常忘记使用。 - 状态混用React hooks,属于屡教不改了
- antd的表单应该有form实例去管理内部值,ai习惯直接将valtio的响应式数据,放到表单Value上
不过随着代码量增多,也可能和CLAUDE.md的约束越来越完善,上面这些情况就比较少了
另外说一下为什么要使用valtio这种proxy机制的状态管理工具
- React的useEffect经常造成逻辑混乱,依赖越加越多,心智负担重
- 很多时候为了拿到最新的值,不得不定义额外的ref来存储临时值
- 性能考虑会又会大量使用memo、useCallback等优化
valtio优点:
- 使用很简单,就一个proxy和一个useSnapshot
- 只有你用到的值变化才会触发组件更新
- 脱离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 方面的疑问,我很乐意帮助您!"
请用简洁、准确的语言回答问题。`
];
效果:
![]()
但是实测小模型有时候可能不会严格按照你的系统提示词来,偶尔还是会回答无关内容,所以测试的时候最好找个好点的模型调试。
接下来可以添加memory,让模型记住上下文
const history: (HumanMessage | AIMessage)[] = [];
history.push(new HumanMessage(userInput));
history.push(new AIMessage(response.content.toString());
这样大模型就可以根据我们之前的对话内容回答问题了
![]()
总结
这里演示了如何使用langchain发送流式请求、管理上下文。
后面还会加上一些tools调用,让ai可以自动完成表查询、新建表、执行sql等能力,目前还待实现。后面会单独写一篇文章详细介绍。
还有最近比较火的skills、MCP功能,也会结合项目讲一讲。
前端国际化(i18n)体系设计与工程化落地
🌍 前端国际化(i18n)体系设计与工程化落地
✈️ 当一个前端项目开始支持多语言时,问题就不再是“翻译几行文案”那么简单。 国际化(i18n)考验的是:架构设计能力、工程化能力和长期维护能力。
在这篇文章中,咱们不讲“Hello World”,而是讲真正能在项目里落地的 i18n 方案。
🎯 为什么前端国际化一定要提前设计?
很多项目的国际化,都是这样开始的:
“老板说下个月要支持英文版,我们先用
if/else顶一下。”
然后很快就会出现:
- 文案散落在代码各处,无法统一管理
- 新增语言成本极高
- 翻译人员无法参与,只能靠开发
- 改一行中文,所有语言一起崩
- 多语言切换导致页面闪烁、状态丢失
👉 问题的根源只有一个:i18n 没有工程化。
🧩 一、前端国际化的核心设计目标
一个成熟的 i18n 体系,至少要满足:
- 文案与业务逻辑彻底解耦
- 支持动态语言切换
- 支持多人协作(开发 / 产品 / 翻译)
- 支持规模化扩展(10+ 语言)
- 对性能影响可控
🧱 二、国际化的基础结构设计
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.usernamelogin.form.passworduser.list.emptyorder.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️⃣ 语言状态存储
推荐优先级:
- 用户显式选择(localStorage / cookie)
- 浏览器语言(
navigator.language) - 默认语言
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-手写系列:防抖与节流
前言
在前端开发中,某些事件(如 resize、scroll、input、mousemove)会在短时间内频繁触发。如果处理函数涉及 DOM 操作或网络请求,频繁执行会导致页面卡顿或服务器压力过大。防抖和节流正是解决这一问题的两把“手术刀”。
一、 防抖(Debounce)
1. 核心概念
触发事件后 秒内函数只会执行一次。如果 秒内事件再次被触发,则重新计算时间。“等最后一个人说完再行动。”
2. 使用场景
- 搜索框输入:用户连续输入文字,只在停止输入后的 毫秒发送搜索请求。
-
窗口调整:
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. 核心概念
连续触发事件,但在 秒内只允许执行一次。节流会显著稀释函数的执行频率。“技能冷却中。”
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 开发笔记
背景:在 Vue 3 (JS/TS) + Vite 项目中集成代码编辑器 Monaco Editor。
目标:实现代码高亮、自定义主题、中文汉化、语言切换、双向绑定等功能。
1. 方案选择与安装
在 Vite 中集成 Monaco Editor 主要有两种方式:
-
原生 Worker 方式:最稳定,利用 Vite 的
?worker特性,但汉化极其困难。 -
插件方式 (
vite-plugin-monaco-editor) :推荐。配置简单,自带汉化支持,但需要处理导入兼容性问题。
安装依赖
Bash
# 核心库
npm install monaco-editor
# Vite 插件 (用于处理 Worker 和汉化)
npm install -D vite-plugin-monaco-editor
2. 核心配置 (Vite)
🔴 常见报错与修复
在使用插件时,可能会遇到以下报错:
TypeError: monacoEditorPlugin is not a functionTypeError: 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) 、语言切换、自定义主题 的通用组件。
核心逻辑点:
-
主题生效顺序:必须先
defineTheme,再create实例,并在配置中显式指定theme。 -
语言切换:使用
monaco.editor.setModelLanguage动态切换。 -
双向绑定:同时支持内容 (
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。
-
Rich Languages (JSON, TS, CSS, HTML):有独立 Worker,支持高级语法检查。路径在
-
结论:配置插件时,
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>
基线对齐:让文字和图标“看起来齐”的那门细节功夫
基线对齐:让文字和图标“看起来齐”的那门细节功夫
在做 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】 优雅处理竞态请求
但行好事,莫问前程
前言
最近收到运营反馈 —— 订单列表在连续搜索时,经常出现数据失真的情况。
排查了下代码,发现是因为 没有处理竞态请求,这个问题很常见且因为接口延迟的原因,特别容易复现。
竞态请求 - 例如先发送查询请求A,然后切换发送查询请求B,(A,B属于同页面的同一接口,但条件不一样),如果A的响应比B慢,就会导致展示数据与预期内容不符。
本文会介绍如何使用 Abort Controller 解决竞态问题。
如果对你有所帮助,还望点赞、收藏、关注三连😽。
方案
整理一下思路,对这种连续请求导致的竞态场景,有哪些解决方法
-
防抖、节流、loading
- 直接有效的限制请求操作
- 但无法根绝竞态的出现,且loading会存在阻塞用户操作的风险
-
canceltoken
-
axios专用,局限性较大 - 官方已推荐使用
AbortController替代它
-
-
AbortController
- JS 原生API,更现代的、标准的、更具通用性
所以更推荐使用 AbortController 优雅地处理 竞态请求 问题,避免数据错乱。
(最好让后端优化下接口😹)
AbortController
AbortController是一个可以让你 主动取消异步操作 的浏览器API,已被主流浏览器(除IE)外所兼容。
![]()
它的使用方法如下:
-
new AbortController()创建一个新的
AbortController对象实例。 -
controller.signal返回一个
AbortSignal只读的对象实例,可以用它来和异步操作进行通信或者中止这个操作。 -
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 全球化项目图片资源优化实战指南
摘要:针对全球化场景(跨国延迟、弱网环境)下的图片加载痛点,本文提供了一套基于 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 返回给支持的浏览器。
- 利弊:开发零感知,完全透明,强烈推荐。
- CDN 检查请求头
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
-
操作:
- 打开 Chrome 隐身模式。
- F12 -> Lighthouse -> 选择 "Mobile" (模拟弱网) 或 "Desktop"。
- 点击 "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)
-
基准测试 (Baseline):
- 关闭所有优化开关(Vite 插件、组件回退到普通 img)。
- 使用 Chrome Network 面板 "Fast 3G" 模拟弱网。
- 记录 LCP 时间和 Network 面板的
Transferred总大小。
-
实施优化:
- 启用
vite-plugin-image-optimizer。 - 部署 HTTP/3 CDN。
- 替换
OptimizedImage组件。
- 启用
-
对比测试:
- 同样环境(Fast 3G)再次测量。
-
验收标准:
- 图片总传输体积减少 > 40%。
- LCP 时间减少 > 30%。
- Network 面板 Protocol 列显示
h3或h2。
结语:性能优化没有银弹,只有对细节的极致追求。通过上述方案,我们建立了一套可维护、自动化的图片治理体系,为全球用户提供丝滑的浏览体验。
【坑位提醒】Flutter 鸿蒙开发:Mac 跑得好好的,Windows 报 Git 依赖版本错误?半天心血总结出的真相!
前言
在进行 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. 强力清除“有毒”缓存
这是最关键的一步,必须手动清理已经出错的本地缓存:
-
删除 Pub 缓存中的 Git 目录:
直接删除
C:\Users\你的用户名\AppData\Local\Pub\Cache\git下的所有内容。 -
删除项目锁文件:
删除项目根目录下的
pubspec.lock。 -
重新获取:
运行
flutter pub get。
四、 总结
鸿蒙适配之路本就坎坷,Windows 的这个环境坑更是杀人于无形。
- Mac 没事: 因为 Unix 系系统没有这种古老的路径长度限制。
- Windows 报错: 往往是因为路径过深导致 Git 切换分支失败。
一句话经验:在 Windows 上搞 Flutter 开发,第一件事就是把系统的 LongPathsEnabled 给开了!
希望这篇文档能帮到正在绝望中的你。如果解决了你的问题,记得点个赞!
互动环节
你在鸿蒙适配过程中还遇到了哪些“离谱”的报错?欢迎在评论区交流,我们一起排坑!
怎样自定义 Exception Filter?
Exception Filter 在 Nest 应用抛异常时,捕获它并返回
比如
![]()
新建项目
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;
}
}
![]()
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,
});
}
}
![]()
但其实这也有个问题。
当我们用 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;
}
使用
![]()
这个是 filter 的
![]()
打断点先看下
![]()
根据结构 更新内容
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
})
}
}
![]()
nice!
现在,ValidationPipe 的错误和其他的错误就都返回了正确的格式
想在 Filter 里注入 AppService 怎么做?
![]()
其余的全局 Guard、Interceptor、Pipe 也是这样注册
![]()
Nest 会把所有 token 为 APP_FILTER 的 provider 注册为全局 Exception Filter
比如我注入了 AppService,然后调用它的 getHello 方法
![]()
![]()
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
![]()
在 AppController 里抛出这个异常
![]()
![]()
服务拆分之旅:测试过程全揭秘|得物技术
一、引言
代码越写越多怎么办?在线等挺急的! Bidding-interface服务代码库代码量已经达到100w行!!
Bidding-interface应用是出价域核心应用之一,主要面向B端商家。跟商家后台有关的出价功能都围绕其展开。是目前出价域代码量最多的服务。
随着出价业务最近几年来的快速发展,出价服务承接的流量虽然都是围绕卖家出价,但是已远远超过卖家出价功能范围。业务的快速迭代而频繁变更给出价核心链路高可用、高性能都带来了巨大的风险。
经总结有如下几个痛点:
-
核心出价链路未隔离:
出价链路各子业务模块间代码有不同程度的耦合,迭代开发可扩展性差,往往会侵入到出价主流程代码的改动。每个子模块缺乏独立的封装,而且存在大量重复的代码,每次业务规则调整,需要改动多处,容易出现漏改漏测的问题。
-
大单体&功能模块定义混乱:
历史原因上层业务层代码缺乏抽象,代码无法实现复用,需求开发代码量大,导致需求估时偏高,经常出现20+人日的大需求,需求开发中又写出大量重复代码,导致出价服务代码库快速膨胀,应用启动耗时过长,恶性循环。
-
B/C端链路未隔离:
B端卖家出价链路流量与C端价格业务场景链路流量没有完全隔离,由于历史原因,有些B端出价链路接口代码还存在于price应用中,偶尔B端需求开发会对C端应用做代码变更。存在一定的代码管控和应用权限管控成本。
-
发布效率影响:
代码量庞大,导致编译速度缓慢。代码过多,类的依赖关系更为复杂,持续迭代逐步加大编译成本,随着持续迭代,新的代码逻辑 ,引入更多jar 依赖,间接导致项目部署时长变长蓝绿发布和紧急问题处理时长显著增加;同时由于编译与部署时间长,直接影响开发人员在日常迭代中的效率(自测,debug,部署)。
-
业务抽象&分层不合理:
历史原因出价基础能力领域不明确,出价底层和业务层分层模糊,业务层代码和出价底层代码耦合严重,出价底层能力缺乏抽象,上层业务扩展需求频繁改动出价底层能力代码。给出价核心链路代码质量把控带来较高的成本, 每次上线变更也带来一定的风险。
以上,对于Bidding服务的拆分和治理,已经箭在弦上不得不发。否则,持续的迭代会继续恶化服务的上述问题。
经过前期慎重的筹备,设计,排期,拆分,和测试。目前Bidding应用经过四期的拆分节奏,已经马上要接近尾声了。服务被拆分成三个全新的应用,目前在小流量灰度放量中。
本次拆分涉及:1000+Dubbo接口,300+个HTTP接口,200+ MQ消息,100+个TOC任务,10+个 DJob任务。
本人是出价域测试一枚,参与了一期-四期的拆分测试工作。
项目在全组研发+测试的ALL IN投入下,已接近尾声。值此之际输出一篇文章,从测试视角复盘下,Bidding服务的拆分与治理,也全过程揭秘下出价域内的拆分测试过程。
二、服务拆分的原则
首先,在细节性介绍Bidding拆分之前。先过大概过一下服务拆分原则:
-
单一职责原则 (SRP): 每个服务应该只负责一项特定的业务功能,避免功能混杂。
-
高内聚、低耦合: 服务内部高度内聚,服务之间松耦合,尽量减少服务之间的依赖关系。
-
业务能力导向: 根据业务领域和功能边界进行服务拆分,确保每个服务都代表一个完整的业务能力。
拆分原则之下,还有不同的策略可以采纳:基于业务能力拆分、基于领域驱动设计 (DDD) 拆分、基于数据拆分等等。同时,拆分时应该注意:避免过度拆分、考虑服务之间的通信成本、设计合理的 API 接口。
服务拆分是微服务架构设计的关键步骤,需要根据具体的业务场景和团队情况进行综合考虑。合理的服务拆分可以提高系统的灵活性、可扩展性和可维护性,而不合理的服务拆分则会带来一系列问题。
三、Bidding服务拆分的设计
如引言介绍过。Bidding服务被拆分出三个新的应用,同时保留bidding应用本身。目前共拆分成四个应用:Bidding-foundtion,Bidding-interface,Bidding-operation和Bidding-biz。详情如下:
- 出价基础服务-Bidding-foundation:
出价基础服务,对出价基础能力抽象,出价领域能力封装,基础能力沉淀。
- 出价服务-Bidding-interfaces:
商家端出价,提供出价基础能力和出价工具,提供商家在各端出价链路能力,重点保障商家出价基础功能和出价体验。
- 出价运营服务-Bidding-operation:
出价运营,重点支撑运营对出价业务相关规则的维护以及平台其他域业务变更对出价域数据变更的业务处理:
- 出价管理相关配置:出价规则配置、指定卖家规则管理、出价应急隐藏/下线管理工具等;
- 业务大任务:包括控价生效/失效,商研鉴别能力变更,商家直发资质变更,品牌方出价资质变更等大任务执行。
- 业务扩展服务-Bidding-biz:
更多业务场景扩展,侧重业务场景的灵活扩展,可拆出的现有业务范围:国补采购单出价,空中成单业务,活动出价,直播出价,现订现采业务,预约抢购,新品上线预出价,入仓预出价。
应用拆分前后流量分布情况:
![]()
四、Bidding拆分的节奏和目标收益
服务拆分是项大工程,对目前的线上质量存在极大的挑战。合理的排期和拆分计划是重点,可预期的收益目标是灵魂。
经过前期充分调研和规划。Bidding拆分被分成了四期,每期推进一个新应用。并按如下六大步进行:
![]()
Bidding拆分目标
- 解决Bidding大单体问题: 对Bidding应用进行合理规划,完成代码和应用拆分,解决一直以来Bidding大单体提供的服务多而混乱,维护成本高,应用编译部署慢,发布效率低等等问题。
- 核心链路隔离&提升稳定性: 明确出价基础能力,对出价基础能力下沉,出价基础能力代码拆分出独立的代码库,并且部署在独立的新应用中,实现出价核心链路隔离,提升出价核心链路稳定性。
- 提升迭代需求开发效率: 完成业务层代码抽象,业务层做组件化配置化,实现业务层抽象复用,降低版本迭代需求开发成本。
- 实现出价业务应用合理规划: 各服务定位、职能明确,分层抽象合理,更好服务于企/个商家、不同业务线运营等不同角色业务推进。
预期的拆分收益
-
出价服务应用结构优化:
完成对Bidding大单体应用合理规划拆分,向下沉淀出出价基础服务应用层,降低出价基础能力维护成功;向上抽离出业务扩展应用层,能够实现上层业务的灵活扩展;同时把面向平台运营和面向卖家出价的能力独立维护;在代码库和应用层面隔离,有效减少版本迭代业务需求开发变更对应用的影响面,降低应用和代码库的维护成本。
-
完成业务层整体设计,业务层抽象复用,业务层做组件化配置化,提升版本迭代需求开发效率,降低版本迭代需求开发成本:
按业务类型对业务代码进行分类,统一设计方案,提高代码复用性,支持业务场景变化时快速扩展,以引导降价为例,当有类似降价换流量/降价换销量新的降价场景需求时,可以快速上线,类似情况每个需求可以减少10-20人日开发工作量。
-
代码质量提升 :
通过拆分出价基础服务和对出价流程代码做重构,将出价基础底层能力代码与上层业务层代码解耦,降低代码复杂度,降低代码冲突和维护难度,从而提高整体代码质量和可维护性。
-
开发效率提升 :
- 缩短应用部署时间: 治理后的出价服务将加快编译和部署速度,缩短Bidding-interfaces应用发布(编译+部署)时间 由12分钟降低到6分钟,从而显著提升开发人员的工作效率,减少自测、调试和部署所需的时间。以Bidding服务T1环境目前一个月编译部署至少1500次计算,每个月可以节约150h应用发布时间。
- 提升问题定位效率: 出价基础服务层与上层业务逻辑层代码库&应用分开后,排查定位开发过程中遇到的问题和线上问题时可以有效缩小代码范围,快速定位问题代码位置。
五、测试计划设计
服务拆分的前期,研发团队投入了大量的心血。现在代码终于提测了,进入我们的测试环节:
为了能收获更好的质量效果,同时也为了不同研发、测试同学的分工。我们需要细化到最细粒度,即接口维度整理出一份详细的文档。基于此文档的基础,我们确定工作量和人员排期:
如本迭代,我们投入4位研发同学,2位测试同学。完成该200个Dubbo接口和100个HTTP接口,以及20个Topic迁移。对应的提测接口,标记上负责的研发、测试、测试进度、接口详细信息等内容。
基于该文档的基础上,我们的工作清晰而明确。一个大型的服务拆分,也变成了一步一步的里程碑任务。
接下来给大家看一下,关于Bidding拆分。我们团队整体的测试计划,我们一共设计了五道流程。
-
第一关:自测接口对比:
每批次拆分接口提测前,研发同学必须完成接口自测。基于新旧接口返回结果对比验证。验证通过后标记在文档中,再进入测试流程。
对于拆分项目,自测卡的相对更加严格。由于仅做接口迁移,逻辑无变更,自测也更加容易开展。由研发同学做好接口自测,可以避免提测后新接口不通的低级问题。提高项目进度。
在这个环节中。偶尔遇见自测不充分、新接口参数传丢、新Topic未配置等问题。(三期、四期测试中,我们加强了对研发自测的要求)。
-
第二关:测试功能回归
这一步骤基本属于测试的人工验证,同时重点需关注写接口数据验证。
回归时要测的细致。每个接口,测试同学进行合理评估。尽量针对接口主流程,进行细致功能回归。由于迁移的接口数量多,历史逻辑重。一方面在接口测试任务分配时,要尽量选择对该业务熟悉的同学。另一方面,承接的同学也有做好历史逻辑梳理。尽量不要产生漏测造成的问题。
该步骤测出的问题五花八门。另外由于Bidding拆分成多个新服务。两个新服务经常彼此间调用会出现问题。比如二期Bidding-foundation迁移完成后,Bidding-operation的接口在迁移时,依赖接口需要从Bidding替换成foundation的接口。
灰度打开情况下,调用新接口报错仍然走老逻辑。(测试时,需要关注trace中是否走了新应用)。
-
第三关:自动化用例
出价域内沉淀了比较完善的接口自动化用例。在人工测试时,测试同学可以借助自动化能力,完成对迁移接口的回归功能验证。
同时在发布前天,组内会特地多跑一轮全量自动化。一次是迁移接口开关全部打开,一次是迁移接口开关全部关闭即正常的自动化回归。然后全员进行排错。
全量的自动化用例执行,对迁移接口问题拦截,有比较好的效果。因为会有一些功能点,人工测试时关联功能未考虑到,但在接口自动化覆盖下无所遁形。
-
第四关:流量回放
在拆分接口开关打开的情况下,在预发环境进行流量回放。
线上录制流量的数据往往更加复杂,经常会测出一些意料之外的问题。
迭代过程中,我们组内仍然会在沿用两次回放。迁移接口开关打开后回放一次,开关关闭后回放一次。(跟发布配置保持一致)。
-
第五关:灰度过程中,关闭接口开关,功能回滚
为保证线上生产质量,在迁移接口小流量灰度过程中。我们持续监测线上问题告警群。
以上,就是出价域测试团队,针对服务拆分的测试流程。同时遵循可回滚的发布标准,拆分接口做了非常完善的灰度功能。下一段落进行介绍。
六、各流量类型灰度切量方案
出价流程切新应用灰度控制从几个维度控制:总开关,出价类型范围,channel范围,source范围,bidSource范围,uid白名单&uid百分比(0-10000):
- 灰度策略
-
支持 接口维度 ,按照百分比进行灰度切流;
-
支持一键回切;
Dubbo接口、HTTP接口、TOC任务迁移、DMQ消息迁移分别配有不同的灰度策略。
七、结语
拆分的过程中,伴随着很多迭代需求的开发。为了提高迁移效率,我们会在需求排期后,并行处理迭代功能相关的接口,把服务拆分和迭代需求一起完成掉。
目前,我们的拆分已经进入尾声。迭代发布后,整体的技术项目就结束了。灰度节奏在按预期节奏进行~
值得一提的是,目前我们的流量迁移仍处于第一阶段,即拆分应用出价域内灰度迁移,上游不感知。目前所有的流量仍然通过bidding服务接口进行转发。后续第二阶段,灰度验证完成后,需要进行上游接口替换,流量直接请求拆分后的应用。
往期回顾
2.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践
3.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术
4.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法
文 /寇森
关注得物技术,每周一、三更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
uni-app使用非uni_modules的ucharts组件,本地运行没问题,部署到线上出问题?
问题背景:使用非uni_modules的ucharts组件,本地运行没用问题,发布为h5后未见报错,但ucharts却始终出不来。
步骤复现:
-
手上有个需求,需要使用uni-app开发微信小程序,初始时使用h5作为演示系统,需求里面存在图表展示功能。这时,网上去找对应适合的charts组件,发现ucharts可以用于移动端图表展示。
-
引入官方非uni_modules组件(官方有说明文档),进行开发。

-
按照步骤引入qiun-data-charts.vue组件后,本地运行可以正常出来,也没见报错,这时build为h5后发布到服务器,神奇的一幕出现了,charts竟然出不来!!!赶紧某度,甚至上了AI,但都没解决问题,后面我就琢磨是否配置有问题,比如opts或者eopts出问题了,但对比了一下官方api均未发现问题,只能去翻ucharts源码。
-
看了一下源码后发现有一段代码用到了路径,就怀疑是不是路径解析出了问题,加了个打印再次部署查看,发现果真多了个./ 然后去找了一下为什么会多出这个东西,原因是打包的时候配置了指定资源打包路径,重新修改ucharts资源路径之后就可以正常出来了。


