普通视图
Vol1.人才留言板|第一期:这20家AI明星公司正在等你
![]()
图源:36kr
每周精选30+优质岗位,覆盖AI、硬科技等领域,帮你找到下一个职业爆发点。
本期合作企业:红杉中国、高瓴、鼎晖、经纬被投企业|B轮及以上占比65%。
卷首语
卷首语我们见过太多这样的场景:一家硬科技公司融完B轮,创始人却在朋友圈悄悄挂出招聘启事;一个专注大模型的算法专家,翻遍猎头推荐却找不到真正懂技术的团队。
这不是供需错配,而是信息失真。
过去十年,36氪记录了数千家公司的成长轨迹。我们看过他们最早的BP,见证他们一轮轮融资,知道哪家公司的技术栈真正扎实,哪个创始团队对人才有足够耐心。这些认知,不该只留在报道里。
所以有了“人才留言板”。
我们想做一个更笨拙也更真诚的连接:一边是经过多轮融资、资金充沛、真正尊重技术的公司——尤其是AI、机器人领域的佼佼者,他们手握充足弹药,唯独渴望能一起冲锋陷阵的顶尖人才;一边是愿意用硬实力换取长期回报的技术高手。36氪不做简单的简历搬运,而是用我们对行业的理解,帮双方建立一条更短、更准的通道。
这里没有花哨的职位包装,只有我们了解并愿意背书的团队。
同时,我们也欢迎人才主动“留言”——无论你是名校出身的算法大神、深耕硬件的架构师,还是操盘过亿级业务、身经百战的职场高管,只要你想找到真正看懂你价值的公司和团队,欢迎联系我们。
—— 36氪“人才留言板”
![]()
图源:36kr
本期亮点速览
![]()
图源:36kr
![]()
图源:36kr
精选岗位
| 金山办公 · 已上市 · AI应用
一句话卖点:
做难而正确的事,向上生长
地点:北京
招聘职位:
AI应用研发工程师(数据结构|智能交互系统|NLP|LLM|25-40K)
职位介绍:
技术立业的民族情怀、极具竞争力的薪酬体系
清晰的职业发展路径与开放包容的组织文化
丨中控技术 · 已上市 · 工业智控
一句话卖点:
工业自动化领军企业,全栈技术实战平台,多元成长通道
地点:杭州
招聘职位:
高级软件测试工程师(锂电池原理|本科及以上|15-17K)
职位介绍:
工业自动化领军企业、“高壁垒技术平台”与“人性化成长生态”的完美结合
前沿技术落地,解决石化、化工等复杂工业场景的“真问题”
丨宇树科技 · IPO · 具身智能
一句话卖点:
全球最优秀的机器人团队,等待你的加入,让我们一起变革世界!
地点:杭州
招聘职位:
深度强化学习算法工程师(C++/Python|机器人仿真|本科及以上|25-50K)
机器人运动控制算法工程师(机器人控制/自动控制/机械电子|硕士及以上|30-60K)
职位介绍:
全球人形机器人领跑者、打破年龄限制
打破常规的职业机会、极具竞争力的薪酬回报以及产教融合的成长生态
丨声网agora · 已上市 · 人工智能
一句话卖点:
加入我们,让实时互动像空气和水一样无处不在
地点:上海
招聘职位:
实时AI应用开发工程师(计算机软件|Golang/Python|本科及以上|薪资面议)
音频算法工程师(声纹/3A/ASR/TTS|Python/C/C++|硕士及以上|薪资面议)
职位介绍:
全球领先的实时互动技术壁垒、顶尖的精英团队
清晰的全球化职业发展通道
丨云深处科技 · Pre-IPO · 具身智能
一句话卖点:
杭州“六小龙”之一,全球具身智能行业应用引领者,多项性能全球领先
地点:杭州
招聘职位:
算法工程师(感控融合/具身导航/SLAM|VLM/Diffusion优先|硕士及以上|30-50K·14薪)
电机驱动工程师(无刷电机FOC控制|嵌入式开发|本科及以上|20-40K·14薪)
机器人产品经理(实施工具链|现场效率提升|本科及以上|20-40K·14薪)
职位介绍:
处于上市前窗口期,期权价值可期
核心产业中推动机器人技术规模化落地
承担国家级项目,参与制定行业标准,收获技术深度与行业影响力的双重成长
丨追觅集团 · D轮 · 智能硬件
一句话卖点:
敢于追梦 敢于行动
地点:苏州
招聘职位:
电池研发岗(锂电池原理|本科及以上|薪酬面议)
职位介绍:
新人直接参与核心项目,而非从基础工作做起、独特培养机制
高难度项目与内部竞合,为新人提供顶级的成长快车道
丨酷哇机器人 · D轮 · 通用 AI 机器人
一句话卖点:
让每一颗种子都发芽
地点:芜湖
招聘职位:
机械工程师(机械/车辆工程|运动机构设计|本科及以上|薪资面议)
规划算法工程师(计算机/数学/控制/车辆工程|轨迹优化方向|硕士及以上|薪资面议)
职位介绍:
已跑通的商业闭环带来职业确定性、Physical AI 前沿技术落地带来成长上限
接触行业最顶尖的通用大模型技术、硬科技领域实现“自我造血”的样本
丨松延动力 · B轮 · 具身智能
一句话卖点:
专注双足机器人强化学习运动控制,深耕算法极限性能探索
地点:北京
招聘职位:
强化学习运控工程师(双足运动控制|RL/IL方向|硕士及以上|30-60K)
职位介绍:
2026马年春晚“小布米”、专属量产基地、全产业链整合优势
团队以95后为主、“技术+产能+产业资源”三重优势、“双足+仿生”双线驱动
丨阶跃星辰 · B轮 · AI大模型
一句话卖点:
聚焦多模态通用AI,凭高效模型、深度终端绑定与多元产业落地获资本青睐
地点:北京/上海
招聘职位:
GUI Agent算法研究员(大模型应用|分布式训练|博士优先|薪资面议)
AI Agent系统工程师(AI应用工程|多框架经验|本科及以上|薪资面议)
职位介绍:
投身通用人工智能(AGI)的终极探索,参与构建行业领先的大模型矩阵
与领域内顶尖人才共事,在技术驱动的环境中获得前沿的全栈研发视野和学术影响力
丨银河通用机器人 · B轮 · 具身智能
一句话卖点:
市场领先的具身多模态大模型机器人,2026年央视春晚指定合作伙伴
地点:北京
招聘职位:
机器人遥操算法专家(主从/VR/动捕|全身控制|硕士优先|薪资面议)
机器人应用开发工程师(具身智能&情感交互|多模态行为设计|薪资面议)
职位介绍:
参与国家级标杆项目,央视春晚指定合作机器人
基于顶尖硬件与自研大模型,攻克核心难题,实现从算法到产品的完整闭环
丨小雨智造 · B轮 · 具身智能
一句话卖点:
工业级具身智能,已在真实产线规模化验证与世界500强深度合作。
地点:北京
招聘职位:
应用软件工程类(35k-55k)
机器人本体类(35k-55k)
机器人感知硬件类现场解决方案类(35k-55k)
职位介绍:
聚焦“工业非标场景”的落地策略,“一脑多形”技术架构的提出
清晰的技术愿景与工程闭环能力,技术成果在真实产线(如建筑钢结构、航空航天、船舶制造)中规模化应用的可能性
丨魔法原子 · B轮 · 具身智能
一句话卖点:
为梦想全力以赴
地点:无锡
招聘职位:
战略BP(咨询/科技企业战略/商业分析|本科及以上|薪酬面议)
运动控制算法专家(自动化/机械工程|机器人控制|硕士及以上|薪酬面议)
电控硬件工程师(电子/电气|硬件电路设计|本科及以上|薪酬面议)
职位介绍:
全栈自研能力、高爆发技术落地、开放生态体系与稀缺行业地位
顶尖薪酬与激励机制、全球顶尖团队构成
丨智元机器人 · A轮 · 具身智能
一句话卖点:
AI+机器人融合创新,业内唯一实现全产品系列、全场景布局
地点:北京/上海/深圳
招聘职位:
多模态交互大模型专家(机器人交互|VLM/VLA方向|本科及以上|薪资面议)
世界模型算法专家(具身智能/视频生成|Transformer/Diffusion|硕士及以上|薪资面议)
AI编译器工程师(自研编译器|TVM/TensorRT经验|薪资面议)
职位介绍:
“全栈自研+生态构建”的路径、“小脑+大脑”的完整解决方案、覆盖从工业到消费端
与“华为系”高管、顶尖科学家、产业精英团队共事
丨赛博格机器人 · A轮 · 具身智能
一句话卖点:
我们基于“科技服务人类,解决高危工种”为初心,用技术赋能百业,为中国新质生产力,中国高科技的崛起,贡献力量。
地点:深圳
招聘职位:
机器人测试工程师(计算机/软件工程|本科及以上|薪资面议)
职位介绍:
顶尖科学家领衔的“硬核”技术高地、聚焦工业刚需的实战场景
极高密度的智力集群、极具竞争力的薪酬回报
丨诺瓦聚变 · 天使+ 轮 · 核聚变
一句话卖点:
站在能源革命的历史拐点,我们提供的不只是一个职位,而是让你在创造人类未来百年基业的同时,成为这一万亿美金级市场的定义者和早期开拓者。
地点:上海
招聘职位:
诊断(高级)工程师(等离子体物理|核聚变|硕士及以上|薪资面议)
高压脉冲电源(高级)工程师(高电压|电气工程|高压脉冲电源|硕士优先|薪资面议)
等离子体实验运行(高级)工程师(等离子体|核科学|硕士及以上|薪资面议)
电子学工程师(电子信息工程|信息工程|硕士及以上|薪资面议)
职位介绍:
推动“人造太阳”从科研走向产业落地、“Fusion for AI”使命
团队汇聚国际顶尖科学家与工程师,融合核聚变与人工智能技术,推动“AI+聚变”交叉创新
丨乐享科技 · 天使+轮 · 具身智能
一句话卖点:
消费级具身智能产品开创者,两款原型即将面市
地点:上海/苏州/深圳/北京
招聘职位:
具身智能全栈工程师(端侧性能|仿真闭环与自动化|20-50K)
机械臂运控算法工程师(运动控制算法|本科及以上|20-40K)
职位介绍:
定义消费级机器人新赛道,全程参与从产品原型到规模量产的完整征程
与清北、中科院、CMU等顶尖背景团队并肩
丨维他动力 · 天使轮 · 具身智能
一句话卖点:
国内首家消费级具身智能公司,成立一年完成3亿融资,百人团队快速扩张
地点:北京/上海
招聘职位:
机器人端到端算法工程师(VLA/导航|多模态大模型|硕士优先|薪资面议)
机器人运控算法工程师(强化学习|足式机器人|Isaac Gym经验|硕士优先|薪资面议)
职位介绍:
从0到1打造产品、实现技术到产品的直接转化
百人明星团队,充足的资金支持
丨Xhorse · 桌面CNC
一句话卖点:
技术全栈能力,产品矩阵完整,长期稳定的发展空间和赛道优势
地点:深圳
招聘职位:
Agent工程师(人工智能|VLM/VLA方向|本科及以上|年薪本科15-20万,硕士20-45万)
数字IC设计工程师(固体电子学|集成电路工程|硕士及以上|年薪硕士20-45万,博士45万以上)
FPGA工程师(FPGA开发流程|Verilog语言|硕士及以上|年薪硕士20-45万,博士45万以上)
职位介绍:
深圳老牌机床企业、连续多年研发投入增幅保持在50%以上、软硬件全线自研
深耕智能制造核心领域、参与从底层算法到硬件设计的全链条研发
丨璇玑动力 · 具身智能
一句话卖点:
璇玑动力在机器人核心零部件、运动控制算法、感知系统等领域技术领先,团队源自全球顶尖硬科技企业,具备机器人从设计、研发到量产落地的全链路实战能力。
地点:深圳
招聘职位:
机器人运动控制算法工程师(机器人控制|自动控制|本科及以上|50-80K)
SLAM算法工程师(C/C++|OpenCV/Eigen/GTSAM/Ceres|本科及以上|40-60K)
职位介绍:
完成近亿元天使轮融资,资金用于行业级机器人量产与消费级新品研发
聚焦智能机器人“全栈自研”,构建从电机、驱动器到运动控制算法的完整技术闭环
![]()
图源:36kr
投递或留言规则
岗位投递
邮件标题:岗位投递+公司/姓名+职位(例如:36氪推荐+深光科技+光学工程师),发送至:rclyb@36kr.com
投递须知
所有简历由36氪统一接收,筛选后直达企业HR
每人最多可投递3个岗位,请在邮件正文注明优先顺序
投递后7个工作日内未回复可视为未通过筛选,不再另行通知
公司投稿
投递内容:公司名称、公司简介、一句话卖点、轮次、招聘职位、职位介绍、地点、联系人及联系方式(以上信息可选公开)
发送至:rclyb@36kr.com,邮件标题注明“公司投稿”
人才自荐
适用人群:希望被好公司发现的顶尖技术人才、资深业务高管
投递内容:个人背景(教育/工作经历/核心业绩)、专业方向、意向公司类型/地点、联系方式(可选公开或私密)
发送至:rclyb@36kr.com,邮件标题注明“人才自荐”。我们将精选优质人才,在征得同意后通过栏目“广而告之”
所有投递默认接受36氪筛选与编辑,如需匿名请备注
![]()
图源:36kr
中上协:2月全市场总市值再创历史新高,突破116.8万亿
南宁轨道未来交通建设公司注册资本增至约29.5亿元
你删过 lock 文件吗?聊聊包管理器迁移中 90% 的人会踩的坑
"删掉 node_modules 和 package-lock.json,重新 npm install 一下。"
这句话你一定听过,甚至自己也说过。遇到依赖安装报错,删 lock 重装是最常见的"万能解法"。大部分时候确实管用——但它管用的原因和你想的不一样,而且在某些场景下,这个操作的代价比你预期的要大得多。
最近越来越多的项目开始从 npm 迁移到 pnpm。迁移本身不复杂,但很多人的做法是直接删掉 package-lock.json,然后 pnpm install。对于小项目,这通常没问题。但如果你的项目有几百个依赖、跑在生产环境、团队多人协作——这样做可能会引入一些很难排查的问题。
这篇文章聊的就是这个:lock 文件到底在锁什么,删掉它意味着什么,以及迁移包管理器时怎么做才是安全的。
lock 文件在锁什么
package.json 里的版本号不是精确版本,而是一个范围:
{
"dependencies": {
"react": "^18.3.1",
"axios": "~1.7.0"
}
}
^18.3.1 允许安装 18.3.1 到 18.x.x 之间的任何版本,~1.7.0 允许 1.7.0 到 1.7.x。也就是说,同一份 package.json,今天装和三个月后装,拿到的依赖版本可能完全不同。
而 lock 文件记录的是某一次 install 之后所有依赖的精确版本——不光是你在 package.json 里写的那几个,还包括它们背后的几十上百个传递依赖。
一句话总结:package.json 描述意图,lock 文件记录事实。
有了 lock 文件,团队成员用 npm ci(或 pnpm install --frozen-lockfile)安装时,拿到的依赖版本和你本地测试通过的完全一致。CI 构建、生产部署,都是同一份版本快照。
semver 是个"君子协议"——很多包不遵守
你可能会想:用 ^ 锁定大版本,minor 和 patch 升级不是应该向下兼容吗?
理论上是。但现实中,不少知名包在 patch 或 minor 版本里引入过 breaking change:
-
TypeScript 明确声明不遵守 semver。它的 minor 版本(比如
5.3→5.4)经常改变类型推断行为,一次升级可能导致几十个编译错误。 -
esbuild 长期处于
0.x阶段,按 semver 规范0.x的任何变更都可能是 breaking,但很多打包工具用^0.21.0这样的范围引用它。 - PostCSS 的 minor 升级曾导致部分插件不兼容,表现为构建时样式输出错误——构建不报错,但页面样式不对,排查成本很高。
这就是为什么 lock 文件是生产环境的最后一道防线:你本地测试通过的版本组合,lock 文件帮你锁住了。删掉它重新安装,等于放弃了这个保障。
删 lock 重装,到底丢了什么
回到开头的问题:删掉 lock 文件再重装,你丢掉了两样东西。
第一,版本锁定。 所有依赖会按 package.json 的范围重新解析,取当前最新的可用版本。如果某个传递依赖在这段时间发了一个有问题的 patch,你就会拿到它。
第二,git 历史。 lock 文件的每次变更都有 git 记录。当你需要用 git bisect 排查"代码没改但线上表现变了"的问题时,lock 文件的 diff 是最关键的线索。删掉重建意味着这条追溯链断了。
对于一个依赖不到 50 个的小项目,这两个问题都不大——验证成本低,出了问题也容易定位。但对于依赖几百个、有完整 CI/CD 流水线的生产项目,这两个代价都不可接受。
迁移到 pnpm:三种策略,选错会出事
既然越来越多团队在迁移到 pnpm,那怎么迁才是安全的?根据项目规模,有三种策略。
策略 A:直接删 lock 重装
rm -rf node_modules package-lock.json
pnpm install
所有版本重新解析,传递依赖不可控。适合依赖少、刚起步的新项目。
策略 B:pnpm import 无损导入
pnpm import # 从 package-lock.json 导入精确版本
rm package-lock.json # 导入成功后删除旧 lock
pnpm install # 安装依赖
pnpm import 会读取现有的 package-lock.json(也支持 yarn.lock),生成一个版本完全一致的 pnpm-lock.yaml。所有依赖——包括传递依赖——的精确版本都会被保留,零版本漂移。
这是大多数项目应该选择的方式。
策略 C:渐进式迁移
对于生产环境有高可用要求的项目,在策略 B 的基础上增加一个完整的验证周期:
git checkout -b chore/migrate-to-pnpm
pnpm import
rm package-lock.json
pnpm install
# 跑完所有测试
pnpm test
pnpm build
pnpm e2e
# staging 环境验证后再合入 main
怎么选
简单判断:项目依赖超过 50 个,或者跑在生产环境——用策略 B。如果还有高可用要求——用策略 C。只有刚起步的小项目才适合策略 A。
迁移后最常遇到的问题:phantom dependencies
从 npm 切到 pnpm 后,最常见的报错不是版本问题,而是 Module not found。
这是因为 npm 的 flat node_modules 会把所有包平铺在根目录,你的代码可以 import 任何已安装的包,哪怕你没在 package.json 里声明。pnpm 的 symlink 结构不允许这样做。
// package.json 里没有声明 "ms"
// 但 "debug" 依赖了 "ms",npm 会把它平铺
import ms from 'ms' // npm: 正常 | pnpm: Module not found
修复方式很直接:把实际用到的包显式加到 package.json 里。
pnpm build 2>&1 | grep "Module not found"
pnpm add ms # 逐个添加缺失的依赖
大项目可能需要修几十个,但这是一次性的工作,修完之后项目的依赖关系会清晰很多。
迁移后别忘了更新 CI
很多人本地迁完就提交了,CI 里还是 npm ci——然后 CI 就挂了。
GitHub Actions 的改动并不大,核心是加一个 pnpm/action-setup 步骤:
# 迁移前
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
# 迁移后
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
另外建议在 package.json 里加上 packageManager 字段:
{
"packageManager": "pnpm@10.29.2"
}
pnpm/action-setup@v4 会读取这个字段自动安装对应版本,Corepack 也会据此约束团队成员使用正确的包管理器。
lock 文件的 Git 管理:几条铁律
最后聊几个关于 lock 文件日常管理的要点。
lock 文件必须提交到 Git。 这一点怎么强调都不过分。不提交 lock 文件,团队成员的依赖版本可能各不相同,CI 构建不可复现,出了问题无法回滚到已知良好的状态。把 lock 文件加到 .gitignore 里是一个常见但严重的错误。
lock 文件冲突不要手动解。 多人开发时 lock 文件冲突是家常便饭。正确做法是接受一方的版本,然后重新生成:
git checkout --theirs pnpm-lock.yaml
pnpm install
git add pnpm-lock.yaml
git commit
pnpm install 会根据 package.json 重新解析 lock 文件,同时尽量保留已有的版本锁定。比手动合并几千行 YAML 安全得多。
CI 里永远用 --frozen-lockfile。 pnpm install --frozen-lockfile 等价于 npm ci,严格按 lock 文件安装。如果 lock 文件和 package.json 不一致就直接报错,而不是悄悄更新 lock 文件。
迁移 Checklist
最后附一个可以直接用的清单:
- 确认项目能通过 build(最好有测试覆盖)
-
pnpm import从现有 lock 文件导入 - 删除旧 lock 文件
-
pnpm install安装依赖 - 修复 phantom dependency 报错
-
package.json添加"packageManager": "pnpm@x.x.x" - 更新 CI workflow
- 全量测试 + 构建验证
- 通知团队成员
以上就是关于 lock 文件和包管理器迁移的完整分析。核心观点只有一个:小项目随便迁,大项目用 pnpm import,别直接删 lock 文件。
你们团队在迁移包管理器或者管理 lock 文件的时候踩过什么坑?欢迎在评论区聊聊。
A股三大指数集体收涨,沪指重返3900点
Fixed 定位的失效问题
通常情况下position: fixed元素相对于视口定位,但是某些情况下,比如祖先元素设置了transform、filter、perspective、will-change: transform的时候,子元素的固定定位会失效,不在相对于视口定位,而是相对于该祖先元素定位,约等于绝对定位。
比如:
<div style="position: fixed; top: 50vh; right: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>
<div style="transform: translate(0, 0); padding-top: 25vh">
<div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcoral"></div>
</div>
<div style="filter: blur(0); padding-top: 25vh">
<div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightcyan"></div>
</div>
<div style="perspective: 0; padding-top: 25vh">
<div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgoldenrodyellow"></div>
</div>
<div style="will-change: transform; padding-top: 25vh">
<div style="position: fixed; top: 10vh; width: 10vh; height: 10vh; background-color: lightgray"></div>
</div>
第一个元素定位正常,但后面的元素定位异常,这是因为这些元素的父元素因为特定的 CSS 属性被放在新的图层之中。
一般情况下,当我们发现了固定定位异常时,排查祖先元素是否含有上述的 CSS 属性即可。但有一种情况,虽然在浏览器的 CSS 面板中看不到上述属性,但元素依然处于不同的图层中。这就是当元素被执行过animate且执行了上述 CSS 属性的动画。
比如:
<div id="moving">
<div style="position: fixed; top: 0; width: 10vh; height: 10vh; background-color: lightblue"></div>
</div>
<script>
let moving = window.document.getElementById('moving');
moving.animate([{ transform: 'translate(0, 0)' }, { transform: 'translate(0, 50vh)' }], { duration: 1000, fill: 'forwards' });
</script>
如果运行上面的代码,可以看到固定定位的元素在跟随父元素移动,同时此时看到浏览器的 CSS 面板中父元素并没有 transform 相关属性。
不得不说,好坑啊。
国家能源局:截至2月底全国累计发电装机容量39.5亿千瓦,同比增长15.9%
兰亭集势:2025年净利润830万美元,实现扭亏为盈
商务部:1-2月我国电子商务稳定发展,数字消费稳中向好
沪深两市成交额突破2万亿元
泡泡玛特:将在4月推出IP小家电产品
金山云海南洋浦信息科技公司注册资本增至7亿美元
泡泡玛特城市乐园2025年营收及客流表现超预期,二期将在2027年启动建设
通用汽车将向韩国子公司投资6亿美元,升级生产设施
直面「猝死」这个话题:你需要了解哪些知识?
MOVA割草机器人预计2026年将突破100万台
泡泡玛特王宁:预计2026年实现不低于20%的增长
React Hooks 闭包陷阱:高级场景与深度思考
前言
闭包陷阱不只是"定时器读不到最新值"那么简单。
在实际工程中,你会遇到:
- 类组件转函数式后的隐性 bug
- 自定义 Hook 里的闭包泄露
- Concurrent Mode 下的闭包过期问题
- 状态机场景下的闭包与 reducer 的相爱相杀
- memo/useCallback 优化反而引发的新问题
- 内存泄漏与闭包的深层关系
场景一:类组件转函数式后,ref 里的闭包成了定时炸弹
问题
你有一个类组件,习惯用 this 解决问题:
// 类组件写法
class SearchPanel extends React.Component {
state = { keyword: '' };
handleSearch = () => {
// 这里直接用 this.state.keyword,永远是最新的
api.search(this.state.keyword);
};
render() {
return <input onChange={e => this.setState({ keyword: e.target.value })} />;
}
}
改成函数式后,你可能这样写:
// ❌ 常见错误写法
function SearchPanel() {
const [keyword, setKeyword] = useState('');
const handleSearch = () => {
// 等等,这里怎么获取 keyword?
// 很多人会想到用一个 ref 存着
};
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<button onClick={handleSearch}>搜索</button>
</div>
);
}
然后你用 ref 来"绕过"闭包问题:
// ❌ 潜在问题
function SearchPanel() {
const [keyword, setKeyword] = useState('');
const keywordRef = useRef(keyword);
// 同步 ref
useEffect(() => {
keywordRef.current = keyword;
}, [keyword]);
const handleSearch = () => {
// 用 ref 获取值
api.search(keywordRef.current); // ⚠️ 这里看起来没问题
};
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<button onClick={handleSearch}>搜索</button>
</div>
);
}
问题在哪?
如果用户快速点击搜索按钮(比点击一次还快),在 useEffect 还没执行之前,ref 里还是旧值。
进阶视角
类组件的 this.state 本质上是一个"永远指向最新值"的 mutable 对象。函数式的 useState 是 immutable 的,每次渲染都是新值。
正确的函数式写法:
// ✅ 正确:不要绕过 React 的响应式系统
function SearchPanel() {
const [keyword, setKeyword] = useState('');
// 直接把当前值传进去,不要通过 ref 间接获取
const handleSearch = () => {
api.search(keyword); // ✅ 这里就是最新的 keyword
};
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<button onClick={handleSearch}>搜索</button>
</div>
);
}
架构思考:
| 类组件 | 函数式组件 |
|---|---|
this.state 是 mutable,引用永远最新 |
useState 是 immutable,每次渲染是新值 |
闭包不是问题,因为用的是 this
|
闭包是问题,因为捕获的是旧值 |
| 解决方案:忘了它,用响应式数据 | 解决方案:让函数组件在正确的时机重新创建 |
场景二:自定义 Hook 里的闭包泄露——你封装的 Hook 可能正在泄露内存
问题
你封装了一个 useInterval Hook:
// ❌ 有问题的 useInterval 实现
function useInterval(callback, delay) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay !== null) {
const id = setInterval(() => {
savedCallback.current(); // 这里调用的是最新的 callback
}, delay);
return () => clearInterval(id);
}
}, [delay]);
}
看起来没问题?好,我们来用一下:
function MyComponent() {
const [count, setCount] = useState(0);
useInterval(() => {
console.log('count:', count); // ⚠️ 这里永远打印 0
}, 1000);
return <div>{count}</div>;
}
这不就是场景一的问题吗?
但更严重的问题在后面:
如果 callback 每次渲染都变化(比如用了一些依赖),savedCallback.current 会不断更新,但旧的 callback 形成的闭包可能被某些地方持有,导致内存无法释放。
源码级分析
// 模拟问题场景
function useDataFetcher(url) {
const [data, setData] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(result => {
if (!cancelled) {
setData(result); // ⚠️ 这个闭包捕获了 url
}
});
return () => {
cancelled = true; // 这里的逻辑其实有漏洞
};
}, [url]); // url 变化 → 新的 effect → 新的闭包
return data;
}
问题: 当 url 快速变化时(比如搜索框输入),旧请求的回调虽然检查了 cancelled,但闭包本身还在内存中。如果这个闭包捕获了大数据(比如列表数据),就有内存泄漏风险。
高级视角:正确的 useInterval 实现
// ✅ 正确的 useInterval(借鉴 ahooks)
import { useEffect, useRef, useCallback } from 'react';
function useInterval(callback, delay) {
const callbackRef = useRef(callback);
// 每次 callback 变化,同步更新 ref
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// 定时器执行时,永远读 ref 里的最新函数
useEffect(() => {
if (delay === null || delay === undefined) {
return;
}
const tick = () => callbackRef.current();
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]); // 注意:这里不依赖 callback,只依赖 delay
}
但真正的架构问题是:
你的自定义 Hook 使用者,可能根本不知道内部有闭包陷阱。他们传入的 callback 如果依赖了外部变量,问题就会隐藏在这里。
最佳实践:
// ✅ 在自定义 Hook 里用 useLatest 统一处理
function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function useInterval(callback, delay) {
const callbackRef = useLatest(callback);
useEffect(() => {
if (delay == null) return;
const tick = () => callbackRef.current();
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
场景三:useReducer 里的闭包——状态机场景的特殊情况
问题
你可能觉得用了 useReducer 就不用管闭包了:
// ❌ 仍然有闭包问题
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
useEffect(() => {
const timer = setInterval(() => {
// ❌ 这里还是闭包陷阱!
dispatch({ type: 'INCREMENT' });
// 等等,dispatch 需要读旧状态吗?
// 让我们看看
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{state.count}</div>;
}
实际上这个例子可以跑,因为 dispatch 的工作方式不同。
源码级解析:dispatch 为什么特殊?
// ReactFiberHooks.js
function updateReducer(reducer, initialArg, init) {
const hook = updateWorkInProgressHook();
const queue = hook.memoizedQueue;
const pending = queue.pending;
// 关键:dispatch 不依赖任何外部变量
// 它的行为是"把 action 放入队列",不是"立即执行"
// 所以 dispatch 本身不会过期
if (pending !== null) {
// ...
}
const newState = hook.memoizedState;
return [newState, dispatch];
}
所以:
| 操作 | 是否受闭包影响 |
|---|---|
setCount(n) |
❌ 不受(但 n 可能是旧值) |
setCount(c => c + 1) |
✅ 不受,函数式更新 |
dispatch({ type: 'INCREMENT' }) |
✅ 不受,dispatch 只是发指令 |
真正的问题:reducer 里的闭包
// ❌ 问题在 reducer 内部
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT_BY':
// 这里需要访问外部的某个"配置"
return { count: state.count + action.amount };
default:
return state;
}
}
function Counter({ defaultAmount = 1 }) { // ⚠️ props 变化
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
// 这里的 defaultAmount 变化时,reducer 不会自动更新
// 你需要确保 action 携带足够的信息
const handleIncrement = () => {
dispatch({ type: 'INCREMENT_BY', amount: defaultAmount });
};
return <button onClick={handleIncrement}>{state.count}</button>;
}
高级视角:
useReducer 并不是闭包的银弹。它的优势是把"如何计算新状态"和"何时触发计算"分开,但如果你在 reducer 外部依赖了某些值,闭包问题依然存在。
场景四:memo 与 useCallback——优化反而引发的新问题
问题
用了 memo + useCallback 做性能优化,结果闭包问题更严重了:
// ❌ 过度优化的陷阱
const Child = memo(function Child({ onClick, data }) {
console.log('Child 渲染了');
return <button onClick={() => onClick(data.id)}>{data.label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [list, setList] = useState([{ id: 1, label: 'A' }]);
// ❌ 用 useCallback 包裹,但依赖了 list
const handleClick = useCallback((id) => {
console.log('点击了', id, list); // ⚠️ 这里永远是旧 list
}, [list]); // list 变化 → handleClick 重建 → Child 重新渲染
return (
<div>
<Child onClick={handleClick} data={list[0]} />
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
</div>
);
}
用 useCallback 是想避免子组件重渲染,结果因为依赖了 list,list 每次变化 handleClick 都会重建,子组件还是重渲染了。
什么时候真正需要 useCallback?
// ✅ 正确的用法:传给子组件的回调
function Parent() {
const [count, setCount] = useState(0);
// 只有当这个函数要传给 memo 过的子组件时,才用 useCallback
const handleClick = useCallback(() => {
console.log(count); // 如果需要读 count,加依赖
}, [count]);
return (
<div>
<MemoChild onClick={handleClick} />
<button onClick={() => setCount(c => c + 1)}>count</button>
</div>
);
}
// ✅ 另一种思路:用 useRef 存最新值,不让子组件依赖变化
function Parent() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
const handleClick = useCallback(() => {
console.log(countRef.current); // ✅ 不依赖变化,函数永远不重建
}, []); // 空依赖,永远是同一个函数
return (
<div>
<MemoChild onClick={handleClick} />
<button onClick={() => setCount(c => c + 1)}>count</button>
</div>
);
}
架构决策:
| 场景 | 推荐方案 |
|---|---|
| 回调需要读最新 state | 加依赖,或用 ref |
| 回调只需要"触发动作" | useCallback + 空依赖 |
| 子组件是 memo 的 | 优先确保 props 不变 |
| 性能问题根源不在这里 | 先用 React DevTools Profiler 定位 |
场景五:Concurrent Mode 下的闭包过期——时间切片带来的新问题
问题
React 18 开启了 Concurrent Mode,同一个组件可能同时存在多个版本的渲染。这让闭包问题更复杂了:
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
// 发起搜索请求
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
// ⚠️ 关键问题:这里拿到的 query 是哪个版本的?
setResults(data);
});
return () => controller.abort();
}, [query]);
return <div>{results.map(r => <li key={r.id}>{r.title}</li>)}</div>;
}
在 Concurrent Mode 下,可能发生这种情况:
- 用户输入 "a",React 开始渲染 "a" 的搜索结果
- 用户快速输入 "ab",React 中断 "a" 的渲染,开始渲染 "ab"
- "ab" 的请求先返回,设置 results = ["ab 结果"]
- "a" 的请求后返回,设置 results = ["a 结果"]
结果:用户看到了"ab"的搜索框,却显示着"a"的结果。
源码级分析:React 18 的 thenable 机制
// ReactFiberCommitWork.js
function commitEffect() {
// ...
if (thenableState !== null) {
// 异步更新可能会被 "插队"
// 这里的状态更新不是线性的
}
}
如何应对 Concurrent Mode 的闭包?
// ✅ 方案一:使用 AbortController 取消旧请求
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
// ✅ 再次检查 query 是否还是当前值
setQuery(current => {
if (current !== query) return current; // 如果已经变了,忽略这次更新
return current;
});
setResults(data);
});
return () => controller.abort();
}, [query]);
// ✅ 方案二:使用 useDeferredValue(React 18)
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
// 用 deferredQuery 做渲染,用 query 做请求
// 渲染可以是"过期"的,但数据请求是最新的
}
// ✅ 方案三:使用 useSyncExternalStore( React 18 官方方案)
import { useSyncExternalStore } from 'react';
// 自己管理订阅,确保读取到的是"已提交的"值
function useSearchQuery(query) {
const snapshot = useSyncExternalStore(
subscribe,
getServerSnapshot,
getClientSnapshot(query)
);
return snapshot;
}
场景六:异步函数在 useEffect 里的闭包——最常见的内存泄漏
问题
这是一个经典但容易被忽视的问题:
// ❌ 内存泄漏的典型案例
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let isMounted = true;
fetchUser(userId).then(user => {
if (isMounted) {
setUser(user); // ⚠️ 如果组件已卸载,这里仍然会执行
}
});
return () => {
isMounted = false; // 这是一个闭包,但它不是过期闭包的锅
};
}, [userId]);
if (!user) return <Loading />;
return <div>{user.name}</div>;
}
等等,这个例子其实是正确的写法(加了 isMounted 标记)。
真正的问题在下面:
// ❌ 真正的内存泄漏
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const subscription = userService.subscribe(userId, (newUser) => {
setUser(newUser); // ⚠️ 组件卸载时没有取消订阅!
});
return () => {
// ❌ 忘记取消订阅
// subscription.unsubscribe();
};
}, [userId]);
return <div>{user?.name}</div>;
}
闭包与内存泄漏的关系
| 问题类型 | 闭包的角色 | 解决方案 |
|---|---|---|
| 过期闭包读旧值 | 闭包捕获旧变量 | 用 ref / 函数式更新 |
| 异步完成后 setState | 组件已卸载 | 用 isMounted 或 AbortController |
| 事件订阅未清理 | 闭包持有组件引用 | useEffect 返回清理函数 |
| 定时器未清理 | 闭包持有组件引用 | clearInterval / clearTimeout |
一个更隐蔽的例子:
// ❌ 定时器 + 闭包 = 内存泄漏
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setSeconds(s => s + 1); // ✅ 函数式更新,没问题
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{seconds}</div>;
}
// 但如果这样写:
function TimerWithBug() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// ❌ 没有返回清理函数
const timer = setInterval(() => {
setSeconds(seconds + 1); // 读的是闭包里的 seconds,永远是 0
}, 1000);
// 组件卸载时 timer 还在运行 → 内存泄漏
}, []); // 依赖数组为空,effect 不重新执行,所以也不会修复
return <div>{seconds}</div>;
}
场景七:Server Components 下的闭包差异—— React 19 的新挑战
⚠️ React 19 / Next.js App Router 场景
问题
Server Components (RSC) 和 Client Components 的闭包行为完全不同:
// ❌ Server Component(默认)
async function Profile({ userId }) {
const user = await fetchUser(userId); // ✅ 直接 await,不需要 useEffect
// 这个函数组件在服务端渲染,不会创建闭包
// 因为它只执行一次,返回 JSX
return <div>{user.name}</div>;
}
// ✅ Client Component
'use client';
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
}
架构差异:
| 特性 | Server Components | Client Components |
|---|---|---|
| 闭包问题 | 无(只渲染一次) | 有(每次渲染可能创建闭包) |
| 数据获取 | 直接 async/await | useEffect + 依赖数组 |
| 状态管理 | 无状态 | useState/useReducer |
| 包体积 | 不打包到客户端 | 打包到客户端 |
如何设计?
原则:尽量把不需要交互的组件写成 Server Components。
// ✅ 正确的分层
// ProfilePage.tsx (Server Component - 默认)
import Profile from './Profile';
export default async function ProfilePage({ params }) {
// 服务端获取数据
const user = await fetchUser(params.userId);
// 只把需要交互的部分交给客户端
return (
<main>
<h1>{user.name}</h1>
<Profile initialUser={user} />
</main>
);
}
// Profile.tsx ('use client')
'use client';
function Profile({ initialUser }) {
const [user, setUser] = useState(initialUser); // 用 initialUser 初始化
// 只有这里的交互逻辑才需要处理闭包
return <EditableUser user={user} onSave={setUser} />;
}
场景八:微前端场景下的闭包——qiankun / single-spa 下的特殊问题
问题
在微前端架构中,主应用和子应用各自有独立的 React 实例。闭包问题可能跨应用传播:
// 主应用
function MainApp() {
const [user, setUser] = useState(null);
// 传递给子应用的回调
const handleUserUpdate = useCallback((newUser) => {
setUser(newUser);
}, []);
return (
<div>
<MicroApp
name="user-profile"
onUserUpdate={handleUserUpdate}
/>
</div>
);
}
// 子应用(独立 React 实例)
function UserProfile({ onUserUpdate }) {
const [user, setUser] = useState({ name: 'Tom' });
useEffect(() => {
// ⚠️ onUserUpdate 是从主应用传过来的
// 它的闭包是在主应用的渲染周期里创建的
// 子应用的状态变化,可能触发主应用的更新
onUserUpdate(user);
}, [user, onUserUpdate]);
return <div>{user.name}</div>;
}
微前端下的闭包治理
// ✅ 方案:使用事件总线或状态管理,不直接传回调
// eventBus.js
import mitt from 'mitt';
export const bus = mitt();
// 主应用
function MainApp() {
useEffect(() => {
bus.on('user-update', (user) => {
setUser(user);
});
return () => bus.off('user-update');
}, []);
return <MicroApp name="user-profile" />;
}
// 子应用
function UserProfile() {
const [user, setUser] = useState({ name: 'Tom' });
useEffect(() => {
bus.emit('user-update', user);
}, [user]);
return <div>{user.name}</div>;
}
为什么这样更好:
- 解耦:子应用不需要知道谁在监听
- 最新值:事件触发时读取的是当前值,不存在闭包捕获旧值
- 可清理:在 useEffect 返回的函数里可以取消监听
总结:闭包问题的本质与架构思考
闭包问题的本质
JavaScript 闭包 = 函数 + 作用域链
React 函数式组件 = 每次渲染 = 新的函数 + 新的作用域
两者结合 = 每次渲染创建新闭包,可能捕获旧值
高级视角的解决思路
| 层级 | 策略 | 工具 |
|---|---|---|
| 代码规范 | exhaustive-deps 强制检查 | eslint-plugin-react-hooks |
| 组件设计 | 避免深层传递 callbacks | Context / 状态管理 |
| 抽象封装 | 自定义 Hook 统一处理 | useLatest / useInterval |
| 架构分层 | Server vs Client 分离 | RSC / 'use client' |
| 运行时 | Concurrent Mode 适配 | useDeferredValue / useSyncExternalStore |
| 微前端 | 跨应用通信用事件总线 | mitt / postMessage |
最后一句
闭包不是 bug,是 JavaScript 的核心特性。React 用函数式范式重新定义了 UI,闭包问题只是这条路上的"学费"。
欢迎关注公众号程序员蜡笔熊,欢迎点赞转发,有什么意见或指正欢迎评论区评论。