普通视图

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

太极实业:联合体预中标华虹FAB9B项目工程总承包,投标报价37.78亿元

2026年2月5日 16:29
36氪获悉,太极实业公告,子公司十一科技与上海四建组成联合体,参与了华虹宏力半导体(无锡)有限公司的华虹FAB9B项目工程总承包的投标。根据公示,联合体为项目拟确定中标人,投标报价为37.78亿元。十一科技为联合体牵头人,预计合同工作量占比为98.46%,金额约为37.19亿元。项目如能确定中标并签订正式合同,因项目跨年实施,对公司2026年度业绩的影响存在不确定性。

马士基将裁员近1000人,每年缩减1.8亿美元公司管理费用

2026年2月5日 16:27
马士基2月5日宣布,为持续提升生产力和控制成本,将简化组织架构并降低公司管理成本。公司将在全球总部、区域和各国家公司层面每年缩减1.8亿美元公司管理费用。在约6000个公司职能岗位中将裁撤约15%,相当于近1000个岗位。相关告知和协商流程已启动。(界面)

从泰国、墨西哥到乌兹别克,我陪中国企业“闯世界”的20年|出海踏浪者

2026年2月5日 16:23

品牌出海、供应链出海、跨境电商……路径众多,却从无一份放之四海而皆准的答案;北美、东南亚、中东、拉美……市场广阔,也各有风浪挑战。每一段远征背后,都是中国企业躬身入局的探索;每一个遥远海岸线上,正涌现更多来自中国的身影。

每个星期,都会有14架航班往返于北京与乌兹别克斯坦首都塔什干。在这条距离4000公里的航线上,胡海几乎是过去2年来最频繁出现的乘客之一。

“飞乌兹别克斯坦的航班永远都是爆满。不止我们看好中亚市场,去做商务考察的企业也越来越多。”胡海是华立集团的海外副总裁、乌兹别克华塔(中亚)工业园开发有限公司负责人。从2005年参与建设华立在泰国的泰中罗勇工业园,到2015年在墨西哥荒漠中从零开始开发华富山工业园,再到如今开拓乌兹别克斯坦市场,胡海的职业生涯几乎与中国制造业出海的大潮同步。

虽然已经有20多年海外工作经验,但胡海还是觉得,每一个接手的新项目都意味着一次从零到一的创业,没有现成的经验可以简单复制。

“你们的海外园区是怎么做起来的?”“中国企业走出去,真的那么难吗?”是胡海被问到最多的问题。但对他来说,企业出海的宏大叙事之下,是“工厂怎么通电通水”“员工吃不惯当地饭菜”“怎么交税不会被罚款”这些具体而繁杂的日常。

正是无数这样的小细节,构成了中国企业出海最真实的剖面,也是每一个在海外乘风破浪的出海人丰富经历的注脚。以下是胡海的自述。

01泰国首次“试水”:做好本土化,成为服务者

25年前,董事长汪力成提出将“国际化”作为华立的三大发展战略之一。当时华立已经是国内头部的仪器仪表制造企业,但只有少量产品以贴牌代工的方式出口到海外。

此前华立与一家泰国电表企业有过合作关系,对当地电表技术要求相对熟悉,所以选择泰国作为“走出去”的第一站。

那时候我对泰国的认知,还停留在泰国是一个旅游国家,完全不了解当地工业发展情况。但是实地考察后发现,泰国是一个投资热土,产业生态很好。

当地最大的工业地产商安美德工业园区的老板与汪董事长交流,他们都认为中国企业“走出去”是大势所趋,会有更多公司和华立一样,从产品外贸出口变成销地生产,就想何不利用我们在海外的经验,给以后也会来到这里的中国企业提供一个产业平台。

这就有了华立与安美德合资开发的泰中罗勇工业园。项目需要有个人“扎进去”负责长期招商运作,当时我刚加入华立的海外业务拓展部,就直接去泰国公司任职,成为项目团队成员之一。

项目里有的人在国内,有的人在当地,一起分工合作完成前期建设,我则是两边跑,有时候在泰国住几个月,有时候回国宣传招商。刚开始派驻当地的中国员工只有总经理和财务总监,其余员工均在本地招聘。这种做法也为华立后来的本地化发展模式定下基调:本土化不是口号,而是企业的生存能力,因为只有当地人才最懂当地的政策、文化和人情。

很快我们就赶上了中国企业出海的第一波浪潮,接待更多对泰国感兴趣的企业来园区考察和落地。随着园区越做越大,我们意识到硬件只是基础,中国企业真正需要的是一种确定性,也就是在陌生环境中,企业们需要一个既懂中国、又懂当地的“中间平台”,提供从建厂到运营的全流程支持。

这决定了我们的角色,不是简单的“房东”,给企业提供一个落脚的地方,而是全方位的服务者和“桥梁”。

不过在当时,即使中国企业做全球化资源配置的意识和能力已经在慢慢变强,多数还是“成本导向”,因为本地化生产要求或原材料优势而走出去。但我们已经隐约感受到,服务与确定性,会逐渐成为比成本更重要的竞争力

02墨西哥“从零到一”:荒地变园区,布局竞争力

2010年,泰国园区已经有一定规模,我被派往印尼负责当地一个农业项目。4年后,公司着手第二个海外园区——北美华富山工业园的设立,我又带着团队开始做调研论证和从0到1的初创。

从选址考察、商务谈判,到墨西哥公司的设立、在当地招兵买马、获取园区所有资质、基础设施建设,再到国内的招商和园区的运营管理,我在墨西哥常驻了8年多,直到前年年初才回国。

华立的第二个园区选择设在墨西哥,是基于当时我们看到,中国企业供应链出海的趋势越来越明确。更关键的是,出海动机已经从“成本优先”转向“稳定优先”。许多以北美为主要市场的企业开始考虑到墨西哥建厂,首先是为了更加贴近美国市场,规避关税的不确定性,确保供应链的稳定与安全,其次才是降低成本。

所以我们希望给这些企业进入北美市场提供一条安全便捷的通道。在这个过程中,华立的核心竞争力不仅仅是提供土地和厂房,更是基于在海外的深耕,能够以更前瞻的眼光给中国企业在海外投资做引导,提供符合原产地规则、贴近目标市场的完整生产环境和一站式的当地服务。

很多刚开始出海的企业没有长期在当地运营的经验,往往更关注初次进入的一次性或固定成本,认为土地价格越便宜越好,但实际上不同的价格关联着不同的配套条件。比如很多企业不知道,海外的工业园区都是商业化运营,不像国内的工业用地会有政府提供水电等基础设施。而我们在墨西哥拿地以后,要自己先做基础设施建设,把“生地”变成“熟地”后再做招商。

但这也是建设初期最难的地方,墨西哥的气候很干燥,我们拿的那片地,面积将近9平方公里,最初就是一片荒地,像在电影里看到的那样,零零星星长着仙人掌、骆驼草,非常萧条荒凉。土地上的水、电资源都需要去和墨西哥政府申请、谈判,自己想办法解决。

我们先是建了一排5000平米的厂房,从大门进去,除了孤零零的厂房,周围什么都没有。如果我是企业去选址,心里肯定也七上八下的,谁敢到那儿去投资呢。而且当时墨西哥已经有非常多美国、欧洲企业投资的工业园区,条件都还不错。

最后还是一家浙江企业比较有胆量,愿意成为第一家入驻华富山工业园的企业。他们先后来墨西哥考察了很多次,从项目小组、投资经理到老板亲自过来,和我们反复沟通相关规划、发展与资金支持,加上华立在浙江当地有良好口碑,泰国园区也已经有一定规模,最终决定入驻。后来浙江省商务厅的领导还专程飞到墨西哥参加这家企业的开业典礼,一方面是支持企业这种勇于开拓的浙商��神,另一方面也是给我们园区的一种鼓励吧。

虽然我们已经有泰国园区的经验,但出海不是搬家,因为每个国家的法律、文化、商业习惯完全不同,所以每一次都相当于重新创业,都要重新适应。

我们刚到墨西哥的时候不懂西班牙语,很难和当地人沟通,Taco再好吃,时间长了也有点受不了。所以刚开始我们要求招聘的本地员工必须会说英语,中国员工也积极学习西语,了解当地文化。

而长期外派更大的问题是,每年只能借休假或者出差的机会回国和家人见面,很容易与国内的社会关系脱节。

我们鼓励家属随行,我的家人也带着一岁的宝宝来墨西哥生活过一段时间。但是家人因为不习惯当地饮食,报纸、电视也看不懂,很难长期坚持。我们又尽量为随行家属在园区里安排工作和语言培训,帮他们更好地在当地建立生活圈。公司也特别鼓励同事在当地成家落户,真正在当地扎根。

现在回想,我能在墨西哥坚持8年,一方面是本身适应能力比较强,另一方面也是事业心和责任感的驱使,觉得这件事对我个人、公司和更多企业有切实的价值,想把它做成,就这么坚持了下来。

从泰国到墨西哥,如果一定要说有什么共通的经验,我觉得关键还是尊重本地文化。如果企业带着“我的产品更先进,我的员工效率更高”这种“降维打击”的思维去海外经营,一定会被当地反感,也不会成功的。本地化是出海必须坚持的方向,因为公司的运营不可能永远靠外国人支撑,肯定有很多水土不服的地方,而且也不稳定。

03乌兹别克:在蓝海市场解决“有和无”的问题

前年因为家庭原因,我从墨西哥回到杭州,没多久又接手了在乌兹别克斯坦建设工业园的新项目,重点布局中亚地区。

中亚是“一带一路”沿线的重要区域,这几年当地政策趋于外向型,人口基数大,本地市场需求与未来发展空间很大,是一个快速增长的区域市场。此外,中亚五个国家和中国的关系都比较友好,当地市场竞争较少,相对来说还是一片蓝海市场。

在中亚五国里,我们对比后觉得,乌兹别克斯坦尤其处在对外开放、积极招商引资的快速增长期,人口规模较大,贸易政策更加开放,市场需求爆发式增长,地理位置也处在中亚中心,适合作为产业发展的中心,就在当地布局了华立的第三个境外工业园——乌兹别克·中亚华塔工业园。

和华富山工业园主要吸引大型制造企业不同,乌兹别克斯坦的新园区更多面向中小企业。这些企业可能在国内竞争力不足,出海反而不失为一个好选择,要么去找尚未被大企业关注的蓝海市场,要么跟随上下游、龙头企业出海。中亚市场就属于前者,中国企业去得少,竞争小。

但问题在于,当地市场化程度不足,基础设施配套仍不成熟。和最初在墨西哥的情况类似,我们刚开始建设乌兹别克园区的时候,当地无法提供120兆瓦的电,受制于政府预算我们甚至拿不到计划和承诺。我们只能想办法争取,不能一步到位,就5兆瓦、10兆瓦地一步步扩容,尝试各种可能性从政府那里争取资源配置,把这些“不成熟”一点点变成“成熟”,最后让园区成为具备“七通一平”的成熟土地,这样才能吸引企业来入驻。

现在我虽然不需要长期派驻在乌兹别克斯坦当地,但每个月都会去一两趟,能明显感受到中国和中亚地区的商业往来变得越来越密切,就像不管美国的对外政策怎么变化,企业也不会停下投资墨西哥的脚步。从更长的时间周期来看,中国企业走向全球,长期做本地化深耕的需求和趋势是不会变的。

全柴动力:使用3000万元自有资金购买券商理财产品

2026年2月5日 16:22
36氪获悉,全柴动力公告,公司已使用3000万元自有资金购买广发证券收益凭证“收益宝”11号,预计年化收益率为1.00%/上不封顶,预计收益金额为14.88万元/上不封顶,期限为181天。该投资经公司第九届董事会第八次会议、第九届监事会第八次会议及2024年度股东会审议通过。公司表示,尽管投资理财产品属于低风险投资品种,但金融市场受宏观经济的影响较大,未来不排除本次现金管理收益将受到市场波动的影响。

尼康2025财年预亏850亿日元

2026年2月5日 16:14
尼康2月5日披露,预计截至2026年3月期的合并最终损益(按国际会计准则)将亏损850亿日元(上一财年为盈利61亿日元)。此前公司预计将实现200亿日元的盈利。此次下调主要是由于在金属3D打印机业务中计提减值损失。作为销售额的营业收入预计为6750亿日元,营业损益预计亏损1000亿日元,分别较此前预测下调50亿日元和1140亿日元。全年每股分红将为40日元,较此前预测下调10日元。(界面)

腾讯游戏发布2026年寒假限玩日历:未成年玩家最多可玩15小时

2026年2月5日 16:13
36氪获悉,2月5日,腾讯游戏发布2026年寒假暨春节假期前后未成年人游戏限玩通知,2月5日-3月5日,未成年玩家仅能在可玩游戏日期的20时至21时登录,寒假29天内游戏时长最多15小时。同时,腾讯游戏成长守护平台探索性引入AI技术,推出“AI游戏周报”、“AI一键管控”、“家长AI助手”3项新功能,降低家长管控门槛,帮助家长从“被动管控”转向“科学管理”。目前,相关功能已开始灰度测试。

日本1月进口车销量同比减少12%,纯电动车增长68%

2026年2月5日 16:12
日本汽车进口商协会(JAIA)2月5日发布数据显示,1月日本进口车销量(不包括日本厂商)同比减少12%,降至13019辆。纯电动汽车增长68%至2041辆,时隔1个月实现增长。梅赛德斯-奔驰减少13%,以3031辆排在首位;大众销量为1528辆,减少41%;宝马销量为1383辆,减少17%;比亚迪销量增至3.4倍,达到180辆。(界面)

恒指收涨0.14%,恒生科技指数涨0.74%

2026年2月5日 16:10
36氪获悉,恒指收涨0.14%,恒生科技指数涨0.74%;硬件设备、汽车、耐用消费品板块领涨,地平线机器人涨超3%,蔚来、泡泡玛特涨超2%,理想汽车涨超1%;有色金属、半导体、软件服务板块走弱,天齐锂业跌超13%,兆易创新、小马智行跌超4%;南向资金净买入249.77亿港元。

大疆升至招聘平台雇主排名第一

2026年2月5日 16:02
近日,多名大疆前员工在社交媒体发文称,临近春节,收到了大疆寄过来的新春礼盒,“很意外也很感动。” 对此,大疆官方在评论区回复网友称:“相逢的人会再次相逢,祝福大家在各处绽放光彩,也勿忘初心,用技术推动人类文明进步!” 此前,脉脉平台显示,大疆已升至平台雇主排名第一。

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

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…

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

两周搓出的 Claude Cowork,让硅谷一夜蒸发 2 万亿,AI 真要杀死软件?

作者 莫崇宇
2026年2月5日 15:52

本周,硅谷上演最惊悚的剧情。

全球资本市场对软件板块进行了无差别的抛售。Salesforce、Workday、Intuit……这些过去十年美股最坚挺的收租公,在短短一天内市值蒸发近 2580 亿美元(折合人民币 19785.13 亿元)。

抛售潮来得异常凶猛。美股软件股 2 月 3 日率先跳水,标普北美软件指数连续三周收跌,1 月累计跌幅达 15%,创下 2008 年以来最差单月表现。紧接着,恐慌蔓延至亚太市场,多家行业龙头股价暴跌。

没有宏观经济崩盘,没有黑天鹅事件。引发这场震荡的导火索,仅仅是因为一家 AI 公司 Anthropic,给它的 AI 装上了「手脚」。

华尔街用脚投票,给出了一个极度残酷的预判:在 AI 真正动手抢人类饭碗之前,传统的软件行业可能要先经历一次彻底的洗牌。

当 AI 只有一层窗户纸

引发海啸的那只蝴蝶,叫 Claude Cowork。

这是 Anthropic 在 2026 年开年甩出的王炸,一个桌面智能体应用。简单说,它不再是一个只会在对话框里陪你聊天的「大脑」,它开始具备了点击鼠标、管理文件、操作软件的「手脚」。

上周,Anthropic 发布了 11 款针对特定岗位的插件,覆盖法律、销售、财务、市场营销等核心业务领域。尤其是它推出的「法律插件」,表现得太像一个熟练的高知白领了。

通过连接 Slack 等企业工具,Claude Cowork 甚至能够自主完成「研究-起草-审核-归档」的全流程,而无需人类在不同软件间切换。

但所谓的法律插件,本质上只是一套提示词和配置设定。而这才是让投资者彻夜难眠的地方。

过去,像汤森路透(Thomson Reuters)这样的公司,靠着昂贵的法律数据库和所谓的专业软件壁垒赚得盆满钵满。他们的核心产品 CoCounsel,甚至底层就是跑在 OpenAI 之上的。

那时候大家相安无事,是因为 Claude 和 ChatGPT 只是一个 API,通过接口卖算力。汤森路透在上面盖楼,通过封装好的产品卖给用户。

但在 Claude Cowork 发布后,逻辑变了。

分析师们一针见血地指出:Anthropic 的野心早已不止于「卖模型」,而是要直接「掌控工作流」。

当房东开始直接卖精装修公寓时,原来的包工头就没饭吃了。Anthropic 直接发布了现成的垂直行业解决方案,平台本身就瞬间变成了软件公司的竞争对手。

Anthropic 首席执行官 Dario Amodei 更是直言不讳地警告:「未来 1 到 5 年内,50% 的入门级白领工作岗位可能会受到冲击。」
从法律到金融再到咨询,那些我们以为只有人能做的知识型工作,AI 都在陆续接手。而那些为这些工作提供软件工具的公司,正站在悬崖边上。

难以自证的「AI 冲击波」

眼下,软件供应商陷入了一个极其艰难的处境。它们必须证明自己不会受到 AI 的冲击。如果能展示营收增速回升,或许还能缓解市场对 AI 冲击的担忧。

但在当下的经济环境里,这几乎是一个不可能完成的任务。

就在上周,美国联合包裹运送服务公司 UPS 宣布计划今年再裁约 3 万人,同日,社交媒体公司 Pinterest 也宣布将裁员近 15%,短短几天后,亚马逊也再砍 1.6 万个工作岗位。

大厂都在勒紧裤腰带过日子。在企业开支收紧、裁员潮蔓延的背景下,CFO 们审批软件采购预算时也比以往任何时候都苛刻。

毕竟,既然 AI Agent 能以极低的成本完成工作,为什么还要花大价钱去买 SaaS 软件?这也就引出了一个令所有 SaaS 厂商窒息的趋势,软件的「降级」。

在一片哀嚎声中,那个卖铲子的男人站了出来。

英伟达 CEO 黄仁勋出席由思科系统公司主办的 AI 会议时,直接回怼了这种市场情绪:「有一种观点认为软件行业正在衰落,并将被 AI 取代。这是世界上最不合逻辑的说法,时间会证明一切。」

他的逻辑是:无论是人类还是 AI 智能体,执行任务最高效的方式是使用现有的工具,而不是重新发明工具。AI 将成为软件的「超级用户」。

老黄的话有道理。AI 不会凭空变出记录的合规性,企业依然需要记录系统来存储数据、管理权限、应对审计。代码不会消失,数据库不会消失。但他同时只说对了一半。

我们要厘清一个概念:AI 取代的不是软件背后的代码或逻辑,它取代的是「人类操作软件」这一中间环节,以及为了人类设计的「图形界面(GUI)」。

回想一下,过去你是怎么用 Photoshop 的?

你需要学习什么是图层、什么是蒙版、什么是通道,你需要记住几十个快捷键。Adobe 的护城河,很大程度上建立在你学会使用它所花费的时间成本上。

但现在呢?你对 AI 随口说出一句,「把背景换成赛博朋克风格」。在这个过程中,Photoshop 复杂的界面、密密麻麻的按钮、层层叠叠的菜单,统统变得毫无意义。

未来,Photoshop 可能只会变成 AI 调用的一个后台插件(甚至已经是了)。当软件从前台退守到后台,它的品牌溢价、它的用户粘性,甚至它的估值逻辑,都要大打折扣。

与之形成鲜明对比的是,OpenAI CEO Sam Altman 却认为 10 亿日活用户比最先进的模型更具价值。

殊不知当 OpenAI 还在试图通过堆砌产品功能、把自己变成一家更像样的 SaaS 公司时,Anthropic 却通过 Claude Cowork 证明了另一件事:最好的 AI 产品,不是为了成为下一个 SaaS 巨头,而是为了终结现在的 SaaS 模式。

AI 正在吞噬世界

多年前,马克·安德森曾经预言:「软件正在吞噬世界。」

现在,轮到 AI 开始吞噬软件了。这是一次残酷的 「物种筛选」。在 AI 的冲击下,软件行业正在分裂成两个物种。

第一种是「工具型」,它们注定会被淘汰。

那些功能单一、逻辑简单、纯粹靠堆砌按钮的软件。比如简单的 PDF 编辑器、初级报税软件、格式转换工具。它们在 AI Agent 面前没有任何还手之力,因为 AI 可以直接完成结果,用户不再需要中间的工具箱。

第二种是「系统型」,它们会活下来,但必须换个活法。

比如微软的 Office 体系,或者深度的 CRM(客户关系管理)系统。它们背后不仅仅是文档,而是企业的组织架构、合规流程和历史数据。

AI 可以帮你写文档,但 AI 很难凭空构建一套符合审计要求的企业合规系统。

此外,未来的软件公司,必须学会不再按「人头」收费,因为用软件的可能根本不是人。AI 智能体的崛起不仅仅是技术升级,它正在从根本上瓦解 SaaS 行业过去二十年赖以生存的商业模式——

按使用人数收费的软件订阅模式(Per-Seat Subscription)。

传统模式下,软件公司的收入与客户的员工数量正相关。你雇的人越多,购买的账号就越多,软件公司赚得越多。但 AI Agent 的核心价值是自动化,是减少完成任务所需的人力。

如果 Salesforce 真的推出了一个完美的 AI 销售代理,能顶替 5 个销售员,那么客户要做的第一件事,就是取消这 5 个人的软件订阅账号。

简言之,软件公司自动化程度越高,它的收入反而越低。这是一个死亡螺旋。

为了不被 AI 淘汰,SaaS 厂商必须在一个新的坐标系里寻找活路。既然人头注定会越来越少,那么收费的锚点,就必须从人转移到事上。

据知名分析机构 Gartner 的预测,到 2026 年底,40% 的企业级 SaaS 将包含基于结果的定价要素。这是一个巨大的思维转变。软件公司将不再参考 IT 工具定价,而是参考人类员工的薪资定价。

对于打工人而言,正如国际象棋大师 Garry Kasparov 在 1997 年提出人类与 AI 明确分工协作的半人马模式——未来人类只会负责战略决策,AI 负责计算/数据处理,各司其职。

过去二十年,我们简历上最显眼的位置,往往写着「精通 Office」、「熟练使用 Photoshop」、「擅长 SPSS 数据分析」。我们花费了无数个日夜,去学习如何适应软件的逻辑,去记忆那些反人类的菜单路径。

我们甚至产生了一种错觉:掌握了工具的操作,就掌握了工作的核心。

Claude Cowork 和它引发的软件暴跌,无情地戳破了这个泡沫。它告诉我们,那些我们引以为傲的软件操作技能,在 AI 面前可能一文不值。

在这个半人马合作的新世界里,你的审美、你的判断力、你定义问题的能力,才是真正让你脱颖而出的能力。

以后,别再夸自己会用软件了。在这个 AI 掌控工作流的新时代,只有一种人不会被淘汰,那就是清楚地知道自己想要什么,并能指挥千军万马(AI)去实现它的人。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


马斯克个人财富突破8000亿美元,4个月内4次刷新个人财富纪录

2026年2月5日 15:52
据福布斯网站报道,在美国企业家埃隆·马斯克旗下的SpaceX完成对xAI的合并之后,其个人财富突破8000亿美元,稳居全球首富宝座。马斯克的个人财富目前比《福布斯》全球个人财富榜第二位、谷歌联合创始人拉里·佩奇高出约5700亿美元。福布斯报道称,马斯克在四个月内四次刷新个人财富纪录。(央视财经)

从 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% 覆盖率。
❌
❌