普通视图

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

2025,港股重回巅峰

2026年2月14日 10:28

刚过去的2025年,港股市场可谓“牛气冲天”。

据港交所官方数据显示,2025其IPO募资额达到374亿美元,约合2923亿港元,创近年新高,超过前三年的总和。这也是港交所时隔四年再次登上世界榜首。

图源:港交所官网

上一次港股的高光时刻还是在2021年。当年,其IPO融资规模达到3313亿港元。此后开始大跌,2023年甚至跌破500亿港元。而今它又飞速升至近3000亿港元,再次冠领全球。

港股重回世界之巅的背后,是上市企业的爆发式增长。数据显示,2025年全年,港股收到612宗上市申请,其中,主板申请占比约90%。而最终成功上市者达到119家,同比增长约68%。

医药、科技等行业成为热门,据Wind数据显示,上市企业中,医药生物行业最盛,恒瑞医药等20家企业成功进场;其次是软件服务业,占到19家;医疗设备与服务、有色金属、硬件设备等也均有7家左右企业上市。

这其中,A股上市公司表现尤其亮眼。数据显示,2025年港股上市企业中,有19家已在A股挂牌,超16%的占比创十年来的新高。它们募资总额超1399亿港元,接近港股全年募资额的半数。

图源:港交所官网

此外,新股大市值化特征愈发显著。2025年,百亿港元以上市值IPO公司占比达到56%,远超过去五年的30%水平。

而大众较为熟悉的大消费领域,约有20家企业成功进港。如餐饮类的蜜雪集团、古茗、八马茶业等,服饰个护类的周六福、林清轩、西普尼等。2026年,消费细分赛道,依旧活跃。君乐宝、金星啤酒、丸美生物、自然堂等67家消费类企业正在港股门外排队等待。

值得一提的是,2025年港股IPO破发率创近5年的新低,仅为28.83%,市场对港股的信心正在回归。

01

巨无霸——募资金额TOP10

港真财经制图

2025年港股上市企业中,募资金额超过百亿港元的共有8家,募资总额约1425亿港元,占全年总额的5成。

其中,5月下旬挂牌的全球动力电池巨头宁德时代,凭借超410亿港元的募资额,不仅拿下港股全年冠军,也成为同年全球IPO规模王者。

宁德时代

紧随其后的是紫金黄金国际,以逾287亿港元居于亚军。新能源汽车制造商-赛力斯居于第四,募得143亿港元。位列其后的恒瑞医药、三花智控、海天味业、奇瑞汽车等四家企业,募资额都在百亿港元之上。

在港股募资TOP10榜单中,超6成企业是国内甚是国际行业的龙头,其中“含A量”尤其高,包括宁德时代在内的6家企业都已在A股上市。

02

首日涨幅TOP10

港真财经制图

119家在港上市的企业中,一部分在上市首日表现堪称惊艳。

年内新股上市首日最大涨幅来自AI技术企业——诺比侃,其上市首日涨超363%。在其之后,金叶国际集团、西普尼分别以330%、258.11%位列首日涨幅的二、三位。

诺比侃上市

身为机电工程老牌承建商的金叶国际集团,不仅股价大涨,还以11465倍的超额认购倍数,创下港股历史最高超购纪录。但在此后,它却“高开低走”,股价在首日大涨300%后,一路急转直下,截至2月12日已跌至0.265港元,较开盘价跌幅过半。

而在TOP10之外,也有不少企业表现良好,如抗体偶联药物(ADC)赛道企业——映恩生物开盘首日也取得116.7%的涨幅;因黄金市场大热而备受关注的紫金黄金国际,在开盘日也以68.46%的涨幅收盘。

03

首日跌幅TOP10

港真财经制图

新股中,首日跌幅最大的明基医院,达到了49.26%。海螺材料科技和瀚思艾泰-B以47%上下的跌幅居于二、三名。

跌幅TOP10的这些企业中,多为发行规模小、市值小的企业。例如,首日跌幅最大的明基医院,发行规模6.3亿港元,瀚思艾泰-B不到6亿港元,派格生物医药-B,发行规模约3亿港元,印象大红袍更是低至1.3亿港元。

04

"A+H"双重上市热潮

港真财经制图

过去一年,港股新增19家A+H上市公司,创下近年新高。它们主要集中在信息技术、工业和新消费领域。

从开盘首日表现看,喜忧参半。首日上涨10家、持平2家、下跌7家,上涨概率为52.6%。

就规模来看,这19家企业基本是所属行业的龙头。如剑桥科技是光模块(CPO)龙头,海天味业是调味品龙头,赛力斯还以140.16亿港元的募资金额,成为年内港交所最大车企IPO。

赛力斯上市

上市以来,龙头们在港股表现大都不错,多数公司股价大幅上涨。比如赤峰黄金,股价至今涨了约172%,吉宏股份涨幅超过86%,三花智控涨幅也在57%左右。

上市,也给了巨头们更多发展机遇。如生物制药巨头-恒瑞医药,上市2个月后,与英国制药巨头GSK达成一项高达125亿美元的License-out合作,创下国产创新药公司新高。当日,其A股涨停,港股涨幅达24%。

05

行业“第一股”

港真财经制图

2025年,港股市场热潮中,一批具有标杆意义的行业“第一股”相继诞生,它们集中分布于消费、医药生物、人工智能等赛道。

据港真财经不完全统计,消费领域有八马茶业、绿茶集团、遇见小面、林清轩等成为各自细分赛道的“第一股”;人工智能领域有滴普科技、诺比侃等企业,填补港股细分领域的空白;生物医药领域的维昇药业等脱颖而出。

八马茶业-千茶阁

这批“第一股”的涌现,折射出港股对新兴赛道的青睐,也体现了其对细分领域龙头企业的价值认可。

06

热度之盛——同日敲钟

港真财经制图

2025年的港股,出现多家企业在同一天敲钟上市的场面。

12月30日,最为热闹,“六锣齐鸣”在港交所上演。当天英矽智能、美联股份、五一视界、卧安机器人、林清轩、迅策等6只新股同日上市,且在当日全线走强。尤其是英矽智能,上市首日股价大涨45.53%。截至目前,其股价已涨超94%。

再有7月初,蓝思科技、峰岹科技等5家企业同日挂牌上市,总募资超百亿港元。

曹操出行上市敲钟

林清轩上市敲钟

已经到来的2026年,港股IPO市场依旧活跃。Choice数据显示,截至2月10日,港股已有21家企业通过IPO上市,合计募资金额达791.17亿港元,同涨超1220%。其中不乏牧原股份、东鹏饮料、澜起科技等行业巨头。而目前,还有超400家企业在港交所排队等候。

接下来,港股IPO市场热度将持续。

据瑞银预测,2026年港股将迎来150只-200只新股,募资额有望突破3000亿港元,港交所或将再次坐稳全球新股市场募资额冠军宝座。

高盛集团董事长兼首席执行官David Solomon认为,2026年港股IPO市场有望迎来强劲复苏,并将成为“非常好的一年”。

本文来自微信公众号“港真财经”,作者:港真财经,36氪经授权发布。

人口上限1000万?瑞士拟就人口限制举措进行公投

2026年2月14日 10:15
瑞士将在6月就限制人口数量的议案举行全民投票,以决定是否在人口总量达到1000万时采取控制措施。据美国有线电视新闻网13日报道,这项议案由瑞士联邦议会中席位最多的右翼政党瑞士人民党提出,定于6月14日公投。议案要求出台相关法律,规定在2050年前,瑞士常住人口数量,包括瑞士公民和持有居留证件的外国人,不得超过1000万。如果在此之前人口达到950万,政府应采取措施限制人口增长,例如限制庇护、家庭团聚、居留许可等证件的签发,并重新谈判国际协议。一旦人口达到1000万,政府就要终止与欧盟之间的人员自由流动协议。

保障大湾区城市群供水安全,珠江“压咸补淡”调度补水近90亿立方米

2026年2月14日 10:00
水利部消息,去年11月进入枯水期以来,珠江流域大部降雨量较常年偏少三成,珠江河口咸潮上溯进入活跃期。截至目前,水利部珠江水利委员会科学调度西江上游龙滩、大藤峡、百色等水库群向下游补水近90亿立方米,西江干流控制站梧州水文站平均流量提高60%,全面压制河口咸潮上溯,粤港澳大湾区城市群取淡蓄水情况为近10年来最优,全力保障春节期间粤港澳大湾区城市群供水安全。

单点登录(SSO)在前端世界的落地形态

2026年2月14日 09:54

上一章我们聊了 OAuth2 与第三方登录的三个阶段:从 Implicit Flow 的混乱时代,到 PKCE 的安全崛起,再到 OAuth 2.1 + 一键登录的无感体验。但 OAuth/OIDC 主要解决的是“授权 + 身份认证”,在企业内部多系统间实现“一次登录、处处可用”的真正 SSO 时,前端还需要面对更复杂的落地挑战:跨域、跨顶级域、微前端、浏览器隐私策略变化等。

这一篇,我们从前端视角拆解 SSO 的主流落地形态,重点对比三种核心实现方式,并讨论 2024–2026 年浏览器变化(第三方 Cookie 逐步淘汰)带来的冲击与应对。

1. SSO 在前端的核心职责与挑战(2026 年视角)

前端在 SSO 中的真实角色:

  • 检测登录状态(silent check)
  • 无感跳转 / 刷新 token
  • 跨应用同步登录/登出状态
  • 处理跨域(子域 / 不同顶级域)
  • 兼容隐私沙盒(Chrome Partitioned Cookies、Storage Partitioning)

2025–2026 年最大变化:

  • 第三方 Cookie 基本被禁用(Chrome 100% rollout)
  • Storage Partitioning(不同顶级域的 localStorage 分区)
  • iframe + postMessage 方案受限(但仍可部分工作)

因此,纯 Cookie 共享 → 纯 Token 集中 → 混合 / BFF 模式 成为主流演进路径。

2. 三种主流前端 SSO 落地方式对比(2024–2026 现状)

实现方式 适用场景 跨域支持 依赖第三方 Cookie 浏览器兼容性(2026) 安全性 复杂度 代表方案 / 协议 当前流行度
基于 Cookie 的域共享 子域 SSO(*.company.com) 子域 / 同顶级域 是(顶级域 Cookie) 高(SameSite=None+Secure) 中–高 CAS、SAML、OIDC Cookie 模式 ★★★☆☆
基于 Token 的集中式认证 跨顶级域、多 SPA、微前端 任意域 最高(无 Cookie 依赖) 中–高 OIDC + PKCE + Refresh Token ★★★★★
iframe + postMessage 通信 遗留系统、临时桥接 跨域 部分(或无) 中(分区 + 限制) 中–低 早期 CAS、Zendesk cross-storage ★☆☆☆☆

方式一:基于 Cookie 的域共享(最传统、最简单)

适用:所有应用在同一顶级域下(如 app1.company.com、app2.company.com、sso.company.com)

核心机制:

  • SSO 服务器 Set-Cookie 时设置 domain=.company.com; Secure; HttpOnly; SameSite=Lax/None
  • 浏览器在所有子域自动携带该 Cookie
  • 前端几乎无感:只需检查 Cookie 或调用 /userinfo 接口

优点:浏览器原生、无需前端代码干预、登出可直接删 Cookie

缺点:

  • 仅限子域(跨顶级域失效)
  • 第三方 Cookie 限制下需 SameSite=None; Secure + 用户许可
  • 不适合微前端 / 多顶级域场景

2026 年现状:企业内网、传统 ToB 系统仍大量使用,但新项目已转向 Token 模式。

方式二:基于 Token 的集中式认证(目前最推荐、最主流)

适用:跨顶级域、多前端(React/Vue/Next.js + 微前端)、移动 + Web 混合

核心流程(OIDC + Authorization Code + PKCE + Refresh Token):

  1. 用户访问任意前端 → 未登录 → 重定向到 SSO 中心(/authorize
  2. SSO 中心登录成功 → 返回 code → 前端(或 BFF)用 PKCE 换 token(access_token + id_token + refresh_token)
  3. 前端存储 refresh_token(HttpOnly Cookie 或 secure storage),access_token 放内存 / localStorage(短效)
  4. 所有前端共享同一 SSO 中心 → 登录一次,后续 silent renew(iframe 或 refresh token)
  5. 登出:调用 /logout + 清本地 token + 通知其他 tab(BroadcastChannel / localStorage 事件)

前端关键实现点:

  • Silent authentication:hidden iframe 打开 authorize endpoint(check session)
  • Refresh:用 refresh_token 静默换新 access_token
  • 多应用同步:BroadcastChannel 或 Service Worker 监听登录/登出事件

代表方案:

  • Auth0 / Okta / Clerk / Supabase Auth / Keycloak(OIDC 模式)
  • NextAuth / Lucia + OIDC provider
  • 自建:oidc-client-ts / @auth0/auth0-spa-js

2026 年优势:

  • 无第三方 Cookie 依赖
  • 支持跨顶级域
  • 与微前端兼容(各子应用独立管理 token,但共享 SSO 会话)

痛点:

  • 前端需处理 token 刷新、silent renew、登出广播
  • refresh_token 安全存储(推荐 BFF 或 HttpOnly Cookie)

方式三:iframe + postMessage(逐渐被淘汰的过渡方案)

早期流行于跨域 SSO(不同顶级域),典型库:cross-storage、pym.js

机制:

  • 主应用嵌入 hidden iframe 指向 SSO 域
  • iframe 内登录 → localStorage 写 token
  • postMessage 通知父窗口 → 父窗口读取

2023–2025 年后问题:

  • Storage Partitioning(Chrome 等)让跨顶级域 localStorage 隔离
  • iframe sandbox 限制 + 第三方 Cookie 禁用
  • 性能差、SEO 问题、用户体验差

2026 年现状:仅遗留系统或极特殊场景使用,新项目已弃用。

3. 微前端 / 多 SPA 下的 SSO 特殊痛点与解决方案

微前端(qiankun、Module Federation、single-spa)常见场景:

  • 不同子应用可能不同框架、不同构建
  • 需要统一登录状态

解决方案(2025–2026 推荐):

  1. 统一 SSO 中心 + Token 模式:所有子应用用同一 OIDC Client ID,共享 refresh_token(通过主应用分发或 BFF)
  2. 主应用代理登录:基座应用负责 silent check 和 token 管理,子应用通过 props / 事件总线获取状态
  3. BroadcastChannel + localStorage 事件:登录/登出时广播,子应用监听同步
  4. BFF(Backend for Frontend):每个子应用有独立 BFF,BFF 持 refresh_token,前端只拿短效 access_token

4. 2026 年 SSO 前端 Checklist(实用建议)

  • 优先选 OIDC + PKCE + Refresh Token Rotation
  • 避免依赖第三方 Cookie(除非子域 + SameSite=None)
  • 使用成熟 SDK(oidc-client-ts、@auth0/auth0-spa-js、next-auth)
  • Silent renew 用 refresh_token 而非 iframe(更可靠)
  • 登出需调用 end_session_endpoint + 清本地 + 广播
  • 高安全场景用 BFF 模式(token 永不出现在浏览器 JS)
  • 测试隐私沙盒:Chrome Incognito + 第三方 Cookie 禁用

小结 & 过渡

前端 SSO 从 Cookie 域共享 → iframe 桥接 → Token 集中式(OIDC 主导)的演进,本质上是适应浏览器隐私保护 + 跨域需求的过程。

2026 年,基于 OIDC + Refresh Token 的集中式认证 是最主流、最可靠的落地形态,尤其适合现代 Web / 微前端 / 跨域场景。

OAuth2 与第三方登录的三个阶段(2010–至今)

2026年2月14日 09:53

上一章我们聊了 Token 时代的巅峰与隐痛:双 Token、刷新机制、黑名单战争,以及各种安全加固手段。但在第三方登录(Social Login、第三方授权)领域,OAuth2 的演进路径更独立,也更戏剧化。

OAuth2 从 2010 年左右开始大规模落地,到 2025–2026 年已进入 OAuth 2.1 时代。前端在其中的角色从“被动跳转 + 解析 URL fragment”到“主动管理 PKCE + 安全刷新”,发生了翻天覆地的变化。

这一篇,我们按时间和技术范式把 OAuth2 + 第三方登录分为三个主要阶段。

1. 第一阶段:早期混乱与 Implicit Flow 主导(2010–2016 左右)

OAuth 1.0(2007–2010)太复杂,OAuth 2.0(RFC 6749,2012 年正式发布)简化了授权框架,但早期实现五花八门。

典型第三方登录流程(Google、Facebook、Twitter 等 2010–2014 年):

  • Implicit Flow(response_type=token)最流行,尤其在 SPA 和早期移动 Web
  • 前端直接发起跳转:https://accounts.google.com/o/oauth2/auth?client_id=xxx&redirect_uri=yyy&response_type=token&scope=profile email
  • 用户同意后,授权服务器重定向回 redirect_uri#access_token=xxx&expires_in=3600
  • 前端解析 URL fragment(location.hash),拿到 access_token

为什么 Implicit Flow 这么火?

  • 当时浏览器跨域限制严格(CORS 不完善,XMLHttpRequest POST 到 token endpoint 跨域困难)
  • 前端无法安全存储 client_secret(public client)
  • 简单:不用后端参与 token 交换

前端典型代码(2012–2015 年 jQuery/AngularJS 时代):

// 登录按钮点击
window.location.href = `https://accounts.google.com/o/oauth2/auth?...&response_type=token`;

// 回调页(或单页 hashchange 监听)
function handleCallback() {
  const hash = window.location.hash.substring(1);
  const params = new URLSearchParams(hash);
  const token = params.get('access_token');
  if (token) {
    localStorage.setItem('google_token', token);
    // 用 token 调用 /userinfo 或 API
  }
}

痛点与安全隐患

  • Token 暴露在 URL(浏览器历史、referer、日志、肩窥攻击)
  • 无法安全用 refresh_token(规范不推荐)
  • XSS 风险极高(token 在 JS 可读)
  • 2015–2016 年 OAuth 安全最佳实践文档开始警告 Implicit Flow

这个阶段国内微信、QQ、新浪微博登录也大量用类似“跳转 + callback 带 code/token”模式。

2. 第二阶段:Authorization Code + PKCE 的崛起与 Implicit 的逐步废弃(2016–2022 左右)

2015–2016 年,浏览器 CORS 完善 + XMLHttpRequest/Fetch 支持跨域 POST,技术条件成熟。

关键转折:

  • 2015 年:RFC 7636 PKCE(Proof Key for Code Exchange)发布,专为 public client(SPA、移动端)设计
  • 2017–2019 年:OAuth Security BCP(Best Current Practice)草案强烈推荐 Authorization Code + PKCE,视 Implicit 为 deprecated
  • 2019 年:Okta、Auth0 等大厂公开宣布“Implicit Flow 已死”
  • 2020 年后:Chrome/Firefox 等浏览器加强 URL fragment 保护 + 第三方 Cookie 限制,Implicit 更难用

现代标准流程(Authorization Code + PKCE)

  1. 前端生成 code_verifier(随机高熵字符串) + code_challenge = BASE64URL(SHA256(verifier))
  2. 跳转授权:response_type=code&code_challenge=xxx&code_challenge_method=S256
  3. 用户同意 → 重定向回 redirect_uri?code=yyy
  4. 前端(或后端代理)用 code + verifier POST 到 token endpoint 换 token

前端示例(现代 React/Vue/Next.js + oidc-client-js 或 AppAuth 库):

// 使用 @auth0/auth0-spa-js 或类似库
const auth0 = createAuth0Client({
  domain: 'xxx.auth0.com',
  clientId: 'your_client_id',
  redirectUri: window.location.origin,
  useRefreshTokens: true,  // 支持安全 refresh
});

// 登录
await auth0.loginWithRedirect({
  authorizationParams: {
    scope: 'openid profile email',
    // PKCE 自动处理
  }
});

// 回调处理(自动)
const user = await auth0.getUser();

为什么 PKCE 更好?

  • Token 从不走 URL(防泄露)
  • Code 即使被截获,攻击者无 verifier 无法换 token
  • 支持 refresh_token(带 rotation 更安全)
  • 前端角色:管理 PKCE 参数、silent refresh(iframe 或 refresh token)

这个阶段 OIDC(OpenID Connect,2014 RFC)全面普及:返回 id_token(JWT 格式身份令牌)+ access_token,前端可直接解析用户信息而无需再调 userinfo endpoint。

国内:微信/支付宝/抖音等逐步支持 PKCE 或后端代理模式。

3. 第三阶段:OAuth 2.1 时代 + 一键登录 / 无感体验(2023–至今,2026 年现状)

OAuth 2.1(draft 持续迭代,至 2025 年 10 月最新 draft-14,预计很快 RFC)正式固化最佳实践:

  • 完全移除 Implicit Flow
  • Authorization Code 强制要求 PKCE(所有 client 类型,无例外)
  • 移除 ROPC(Resource Owner Password Credentials,密码直传 grant,已废弃)
  • 强制 exact redirect_uri 匹配、更严格参数校验
  • 推荐 refresh token rotation + sender-constrained tokens

前端变化:

  • 几乎所有主流 SDK(如 Google Identity Services、Apple Sign in JS、Auth0、Clerk、Supabase Auth)默认 PKCE + OIDC
  • 一键登录普及:Google One Tap、Apple Sign in with Apple、微信一键登录(运营商取号/静默授权)
  • Popup / Redirect 混合:早期 popup 窗口常见,现在 redirect + state 参数防 CSRF 更安全
  • 移动端 / Hybrid:AppAuth-iOS/Android + WebView 统一用 Code + PKCE
  • 国内特色:手机号一键登录(本机号码识别)+ 微信/支付宝生态闭环

典型现代前端接入(2025–2026):

  • 用库处理一切:oidc-client-ts、@okta/okta-auth-js、next-auth 等
  • 支持 silent authentication(hidden iframe renew)
  • Passkey/FIDO2 作为备用(下一章无密码主题)

OAuth 2.1 影响(2025–2026 已大量落地):

  • 旧 Implicit 项目必须迁移(许多 SaaS 2024–2025 年强制下线 Implicit 支持)
  • 前端复杂度略升(需处理 PKCE),但库屏蔽了细节
  • 安全性大幅提升:token 泄露窗口缩小、可主动 revoke

小结 & 过渡

OAuth2 + 第三方登录的三个阶段总结:

阶段 时间 主导 Flow 前端角色变化 安全水平 当前状态(2026)
第一阶段 2010–2016 Implicit Flow 跳转 + 解析 URL fragment 已废弃
第二阶段 2016–2022 Auth Code + PKCE 管理 PKCE + token 刷新 中–高 主流
第三阶段 2023–至今 OAuth 2.1 强制 PKCE 一键/无感 + OIDC 身份解析 标准 & 强制趋势

OAuth2 让前端从“被动接收 token”进化到“主动、安全地管理授权流程”。但第三方登录终究是“授权”而非“认证”——真正补全身份语义的是 OpenID Connect。

美国国防科技初创公司Anduril正洽谈新一轮数十亿美元融资

2026年2月14日 09:45
据知情人士透露,美国国防科技初创公司Anduril正洽谈新一轮数十亿美元融资,投后估值至少达到600亿美元。这一估值较其去年6月上一轮私募融资时的水平近乎翻倍。 本轮融资将为该公司提供更多资金空间,用于建设其首个大型武器制造工厂,以及研发自主战斗机。 凭借与美国国防部及盟友签订的合同,该公司去年收入预计翻倍至约20亿美元,但同时也在大量消耗现金。 这家成立八年的初创公司由帕兰提尔(Palantir)前员工与虚拟现实企业家帕尔默・卢基共同创立,多年来已累计融资数十亿美元,用于开发或收购更现代化的软件、无人机与潜艇,以争取政府订单。 特朗普政府对偏向科技型的国防承包商态度转向利好,Anduril有望进一步受益;与此同时,政府正敦促Anduril那些规模更大、成立时间更早的竞争对手减少股票回购,加快武器交付速度。

Flutter 顶部滚动行为限制实现:NoTopOverScrollPhysics

作者 SoaringHeart
2026年2月14日 09:37

一、需求来源

最近需要实现当列表页面嵌套在 showModalBottomSheet 容器里,禁用下拉越界的需求(因为 列表会和 showModalBottomSheet)一起向下滚动,极其诡异。就需要实现下拉滚动式,列表顶部滚动禁用的效果。

二、使用示例

physics: const NoTopOverScrollPhysics(),

三、源码

//
//  NoTopOverScrollPhysics.dart
//  flutter_templet_project
//
//  Created by shang on 2026/1/16 12:17.
//  Copyright © 2026/1/16 shang. All rights reserved.
//

import 'package:flutter/material.dart';

class NoTopOverScrollPhysics extends ScrollPhysics {
  const NoTopOverScrollPhysics({super.parent});

  @override
  NoTopOverScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return NoTopOverScrollPhysics(parent: buildParent(ancestor));
  }

  /// ① 用户拖动阶段(最关键)
  @override
  double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
    // 在顶部 && 继续向下拖
    if (position.pixels <= position.minScrollExtent && offset > 0) {
      return offset; // 不消费,让手势传给上层
    }
    return super.applyPhysicsToUserOffset(position, offset);
  }

  /// ② 防止惯性或 ballistic 把位置拉到负值
  @override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    // 顶部越界
    if (value < position.minScrollExtent) {
      return value - position.minScrollExtent;
    }
    return super.applyBoundaryConditions(position, value);
  }

  /// ③ 明确声明:允许用户滚动(否则 BottomSheet 会抢)
  @override
  bool shouldAcceptUserOffset(ScrollMetrics position) => true;
}

四、ScrollPhysics知识总结

ScrollPhysics.png

一、ScrollPhysics 决定滚动视图“怎么滚”,包括:

-   能不能滚
-   怎么回弹
-   惯性有多大
-   边界怎么处理

它是 ScrollView 的“物理引擎”

二、ScrollPhysics 在哪生效?

所有使用 Scrollable 的组件:

  • ListView
  • GridView
  • PageView
  • CustomScrollView
  • SingleChildScrollView

二、设置入口:

  physics: const BouncingScrollPhysics(),

三、核心设计:Physics 链(极其重要)

BouncingScrollPhysics(
  parent: AlwaysScrollableScrollPhysics(),
)

设计思想

  • 责任链模式
  • 子 physics 不处理 → 交给 parent

内部调用顺序(简化)

User Drag
  ↓
applyPhysicsToUserOffset
  ↓
applyBoundaryConditions
  ↓
createBallisticSimulation

四、最常见内置 ScrollPhysics(必须掌握)

1️⃣ ClampingScrollPhysics(Android 默认)

  • 到边界直接“卡住”
  • 无回弹
ListView(physics: const ClampingScrollPhysics());

2️⃣ BouncingScrollPhysics(iOS 默认)

  • 可超出边界
  • 松手回弹
ListView(physics: const BouncingScrollPhysics());

3️⃣ NeverScrollableScrollPhysics

  • 完全禁止滚动
ListView(physics: const NeverScrollableScrollPhysics());

4️⃣ AlwaysScrollableScrollPhysics

  • 即使内容不足一屏也能滚
  • 下拉刷新必备

5️⃣ PageScrollPhysics

  • 专用于 PageView
  • 强制一页一页吸附

五、几个关键方法(理解这些 = 会自定义)

applyPhysicsToUserOffset

手指拖动时的位移修正

@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
  return offset * 0.5; // 阻尼
}

applyBoundaryConditions

是否允许越界

@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
  if (value < position.minScrollExtent) {
    return value - position.minScrollExtent;
  }
  return 0;
}

返回值:

  • 0 → 允许
  • ≠0 → 拒绝(消耗掉)

createBallisticSimulation

松手后的惯性 / 回弹

@override
Simulation? createBallisticSimulation(
  ScrollMetrics position,
  double velocity,
) {
  if (velocity.abs() < tolerance.velocity) return null;
  return ClampingScrollSimulation(...);
}

最后、总结

ScrollPhysics 是滚动知识体系的一部分,已经属于比较复杂的内容。各种滚动式行为自定义需要大量的实践,去细细体会不同设置的区别。现官方提供的几种基本已经能覆盖90%的开发场景。

github

美股三大指数收盘涨跌不一,大型科技股多数下跌

2026年2月14日 09:33
36氪获悉,2月14日收盘,美股三大指数涨跌不一,道指涨0.1%,纳指跌0.22%,标普500指数涨0.05%。大型科技股多数下跌,英伟达、苹果跌超2%,谷歌、Meta跌超1%。热门中概股涨跌不一,阿里巴巴跌1.89%,拼多多涨0.06%,百度跌0.99%,京东跌1.38%,腾讯控股涨1.35%,

清华系具身大脑公司两月融资数亿元,接入家庭具身设备量第一、切入全尺寸机器人赛道|硬氪首发

2026年2月14日 09:30

作者|黄楠

编辑|袁斯来

硬氪独家获悉,具身大脑企业千诀科技近日完成Pre-A++扩展轮融资,至此Pre-A++轮次融资完成,投资方包括祥峰资本、智路资本、英诺天使、钧山资本、集美创投、南创投及京铭资本,Maple Pledge枫承资本长期出任私募股权融资顾问。

本轮资金将重点投向全尺寸机器人规模化交付、自动化数据管道搭建与核心团队扩充。这也是公司两个月内完成的又一轮融资。

当具身智能行至产业化深水区,业界关注的焦点已从参数规模的比拼,悄然转向多场景穿透力与落地成本的把控。

传统机器人或依赖预设编程,要么需要改造环境才能稳定工作,激光雷达等硬件的高成本与适配难题,更让规模化落地举步维艰。自2023年成立以来,千诀科技长期聚焦具身世界模型研发,以类脑计算架构打造了“具身大脑”产品。它无需遥控与规则编程,即可进行“感知-决策-行动”全闭环自主工作,实现全球范围内世界模型在家庭场景下的率先落地。

此前,千诀科技的主要落地载体多集中于诸如小型人形机器人的半尺寸具身产品品牌,其核心功能以自主通用感知决策为主,家庭具身设备接入台数居行业第一;同时还新增了全尺寸机器人业务线,现已小批量应用于家庭、餐饮等场景,可以执行清洁、服务、收纳、康养等工作。

搭载千诀类脑大模型的机器人自主执行餐桌清洁任务(图源/企业)

与小型具身设备相比,全尺寸机器人对操作精细化的要求显著提升。其机械臂载荷与自由度大幅增加,供电系统及整机结构亦随之调整,以支撑更复杂的双手协作任务。

但人形机器人因构形限制,平衡与算力分配要求严苛,往往难以搭载传统激光雷达。为此,千诀科技提出了基于纯视觉的空间理解与规划方案,可完成室内空间建模与物体关系理解,实现对激光雷达的完整替代。

不同于过去依赖显式三维地图构建的传统方案,这类方案的精度高度依赖雷达,且易受反光、透明物体及光照变化干扰;千诀采用因果推理隐式建模,通过单帧图像“幻想”向左、向右、向前行走后可能看到的新场景组件结构特征,能把视觉理解与空间建模融为一体。这一基于世界模型的拟人化范式,有效弥补了传统纯视觉方案信息不充分的短板。

千诀科技创始人高海川告诉硬氪,纯视觉方案相较激光雷达成本更低、安装更灵活,且鲁棒性显著提升,结合千诀科技支持不同功能分区自由组合的类脑架构,在家庭、康养、餐饮等人类核心生活场景中可满足作业要求。

在具身智能产业链格局尚未定型、技术路线仍多线并行的当下,业内对数据管道的搭建路径呈现出显著分化。过去积累的多是客观世界的物体位置、移动轨迹、环境几何特征等结构性数据,全尺寸机器人正将数据重心推向主观世界的本体控制数据。

高海川向硬氪指出,各家企业的数据格式、标注标准、采集工具各不相同,根源并不在于技术能力的参差,而在于模型底层架构与生态位的根本差异。

然而,尽管管道形态各异,随着具身智能模型全面转向Scaling Law(规模法则)驱动的预训练范式,各细分赛道对训练数据的渴求均处于爆发阶段。无论感知、决策还是操作,数据缺口普遍巨大。这并非某类管道的结构性红利,而是整个行业从规则驱动迈向数据驱动所必须跨越的共同关口。

世界模型(图源/企业)

其中,千诀科技“类脑”生态模型架构的核心,并不追求单一模型的性能极限,而是将机器人智能拆解为可独立演进、自由组合的功能分区模块,由空间感知、任务决策、运动控制、精细操作各成单元,如同人脑不同皮质区域各司其职。

这一架构下,千诀明确划定自身边界,建立了一套自动化的训练数据管道。一旦系统监测到特定场景下的平均准确率出现波动,该管道即可自动采集数据并对模型的相应感知模块进行快速微调与强化,从而确保具身大脑在多样化真实环境中的性能鲁棒性。

回归到千诀大脑产品上,其专注于世界模型构建与自主决策中枢的构建,并将其余能力模块开放给第三方或兼容客户的自有模型。以精细操作为例,客户可以不必选择千诀科技的力控算法,而是嵌入自有力控模块,再将千诀大脑的其他分区功能模块叠加其上,实现双方各守其位、各取其长的技术协同。

这种松耦合设计的核心价值在于,客户不必在“全栈自研”与“黑盒交付”之间二选一,既可调用千诀的分区能力模块,又可保留自研或第三方模块,进而大幅降低了企业客户与产业链厂商的技术耦合成本。

硬氪了解到,2026年,千诀科技将聚焦于家庭和服务机器人场景,加快各品类的真实需求验证与商业化落地。全尺寸机器人的年需求量正迅速增长,公司将以现有订单为基点,持续扩大交付规模。

千诀科技没有试图用一套封闭系统通吃全局,亦不研发自家机器人本体,而是以类脑架构承接产业分工的碎片化现实。

目前来看,这套打法看似分散,实则直指服务机器人市场的核心命题,在行业从“能跑会跳”迈向“能干活、算得过来账”的关键窗口,千诀科技正完成从技术叙事到交付叙事的转换。

北京市市场监管局开展节前电梯督导检查

2026年2月14日 09:30
为确保电梯安全平稳运行,北京市市场监管局提前行动,连日来采取多项工作举措,对全市“八站两场”、地铁、商场等人员密集场所电梯安全开展监督检查,深入排查消除风险隐患,督促企业和电梯维保单位做好管理和运行维护,做好春节期间全市电梯安全运行服务保障工作。针对岁末年初人员密集场所电梯客流大、运行时间长等特点,市场监管部门将人流客流大的地铁、火车站、机场和商场等作为重点,进行安全检查。市市场监管局组织电梯技术专家对全市1500余台电梯开展现场检查。

机票预订量倍增 “反向过年”成为春节团圆新选择

2026年2月14日 09:15
不再执着于“老家才是年的目的地”,这个春节,很多家庭调转了春运的方向。从“年轻人返乡过年”转变为“接父母来大城市过年”,“反向过年”已经成为一种团圆新趋势。据在线旅游平台数据显示,2026年春节假期,前往北京、上海、成都、广州等多条“反向过年”航线火热,机票预订量倍增。飞往北京的旅客中,60岁及以上旅客比2025年春节假期同期增加1.6倍。

【节点】[CustomDiffuse节点]原理解析与实际应用

作者 SmalBox
2026年2月14日 09:03

【Unity Shader Graph 使用与特效实现】专栏-直达

描述

CustomDiffuse节点是Unity URP Shader Graph中一个功能强大的光照计算节点,专门用于实现用户自定义的固有色光照效果。该节点为着色器开发者提供了高度灵活的光照控制能力,允许用户基于物理的渲染原则或艺术化的视觉需求来定义材质的漫反射行为。在实时渲染管线中,漫反射光照是表面着色的基础组成部分,它决定了材质在直接光照下的基本外观特征。

CustomDiffuse节点的核心价值在于其可定制性。与标准的Lambert或Oren-Nayar漫反射模型不同,这个节点不强制使用特定的光照算法,而是将光照计算的各个要素作为输入端口开放给用户。这种设计理念使得开发者能够根据项目特定的视觉风格或性能要求,实现从简单的N·L点积计算到复杂的自定义BRDF模型。

在实际应用场景中,CustomDiffuse节点特别适合那些需要特殊材质表现的场合。比如在风格化渲染中,艺术家可能希望实现非真实感的漫反射过渡,或者在特定类型的表面(如丝绸、绒毛等)上实现物理准确的散射效果。通过组合不同的输入数据和自定义计算逻辑,开发者可以精确控制光线与材质表面的交互方式。

该节点的另一个重要特性是其与URP渲染管线的深度集成。它能够正确处理URP中的多光源设置、光照衰减和阴影信息,确保自定义的漫反射计算能够与引擎的其他渲染组件协同工作。这种集成保证了即使在复杂的场景光照条件下,自定义的漫反射效果也能保持视觉一致性和性能稳定性。

端口

输入端口详解

Diffuse输入端口接收Vector 3类型的数据,代表材质的基础固有色信息。这个端口通常连接到材质的Albedo纹理或基础颜色属性。在物理渲染上下文中,Diffuse输入应该表示材质表面对漫反射光的反射率系数,其数值范围通常在0到1之间。对于高质量的渲染结果,建议使用线性空间颜色值,并确保颜色值符合能量守恒原则。

Light Color输入端口提供灯光本身的颜色信息,这是实现准确色彩再现的关键要素。在URP中,不同类型的灯光(方向光、点光源、聚光灯)都会提供其颜色和强度信息。开发者可以利用这个端口实现各种创意效果,比如通过修改灯光颜色来模拟特殊的光照环境,或者根据表面特性对灯光颜色进行过滤处理。

Light Attenuation端口处理光照的衰减和阴影信息,这是实现真实光照效果的重要组成部分。该输入通常来自Shader Graph中的光照衰减节点,包含了距离衰减、角度衰减以及实时阴影数据。对于高级用法,开发者可以结合Shadowmask和光照探针数据来实现更复杂的光照交互效果。

Normal WS端口要求世界空间下的法线向量输入,这是计算光照方向性的基础。正确的法线数据对于任何基于物理的光照模型都至关重要。在实际使用中,法线信息可以来自顶点法线、法线贴图,或者是通过自定义计算生成的修改法线。确保法线向量为单位长度是获得准确光照结果的必要前提。

Light Direction WS端口提供从表面点到光源的方向向量,同样在世界空间下表示。这个向量通常通过标准化处理,并且指向光源的方向。在多点光源场景中,需要为每个光源分别计算其方向向量。对于方向光,这个方向是恒定的;而对于点光源和聚光灯,则需要基于片元位置实时计算。

输出端口特性

Out输出端口生成最终的自定义漫反射照明结果,以Vector 3形式表示RGB颜色值。这个输出可以直接用于后续的光照计算,或者与其他光照组件(如高光反射、环境光等)进行混合。输出的颜色值应该保持在合理的范围内,避免出现HDR效果,除非后续有适当的色调映射处理。

端口交互与数据流

理解这些端口之间的数据流关系对于有效使用CustomDiffuse节点至关重要。典型的数据处理流程开始于Diffuse和Light Color的乘法组合,这建立了基础的色彩响应。接着通过法线和光照方向的点积计算获得基础的漫反射强度,再结合光照衰减因子来模拟距离和阴影的影响。

在实际的着色器构建过程中,这些端口的连接顺序和数据处理方式可以根据需求灵活调整。例如,在某些卡通渲染风格中,可能会在计算N·L点积后添加一个步进函数来创建硬边缘的阴影过渡。而在追求物理准确性的场景中,则可能使用更复杂的函数来模拟表面粗糙度对漫反射的影响。

核心算法原理

基础光照模型

CustomDiffuse节点的默认行为基于经典的Lambertian漫反射模型,这是计算机图形学中最基础且广泛应用的光照模型之一。Lambert模型的核心理念是表面反射的光线强度与入射光线方向和表面法线夹角的余弦值成正比。数学表达式为:Diffuse = Albedo × LightColor × max(0, N·L),其中N·L表示法向量与光照方向向量的点积。

这个简单的模型虽然物理上不够精确,但在实时渲染中因其计算效率和直观性而被广泛使用。它假设表面是理想的漫反射体,在各个观察方向上呈现相同的亮度。在实际实现中,max(0, N·L)操作确保了当光线从表面后方照射时不会产生负值光照,这是符合物理直觉的约束。

高级漫反射模型

对于需要更高质量渲染效果的项目,CustomDiffuse节点可以扩展实现更先进的漫反射模型。Oren-Nayar模型是一个著名的改进,它考虑了表面粗糙度对漫反射的影响。与Lambert模型不同,Oren-Nayar不假设表面是完美漫反射体,而是通过粗糙度参数模拟微表面细节对光线的散射效应。

另一个值得关注的模型是Disney principled BRDF中的漫反射组件,它结合了多种散射效应以提供更加物理准确的结果。这种模型通常包含次表面散射的近似模拟,能够更好地表现诸如布料、皮肤等特殊材质的视觉特性。

能量守恒考虑

在实现自定义漫反射模型时,能量守恒是一个重要的物理原则。它要求表面反射的光线总能量不能超过入射光线的能量。在着色器设计中,这意味着漫反射、镜面反射和其他光能传输组件的总和应当合理约束。通过CustomDiffuse节点,开发者可以精确控制漫反射组件的能量分配,确保渲染结果的物理合理性。

实际应用示例

基础Lambert漫反射实现

创建一个基础的Lambert漫反射效果是理解CustomDiffuse节点用法的理想起点。首先需要在Shader Graph中创建相应的节点网络:

  • 将Albedo纹理或颜色属性连接到Diffuse输入端口
  • 使用URP中的Main Light节点获取主光源的颜色和方向信息
  • 通过Transform节点将物体空间法线转换到世界空间
  • 计算法线与光照方向的点积,并使用Saturate节点限制结果在0-1范围内
  • 将点积结果与光源颜色和Albedo颜色相乘,得到基础的漫反射输出

这种实现方式虽然简单,但已经能够为大多数实体材质提供可信的漫反射效果。它是许多游戏和交互应用中漫反射计算的基础。

风格化卡通渲染

在非真实感渲染中,CustomDiffuse节点可以创造出各种艺术化的光照效果。卡通渲染通常特征化地使用硬阴影边界和有限的颜色过渡。实现这种效果的关键在于对N·L点积结果进行离散化处理:

  • 使用Remap节点调整点积的范围和分布
  • 通过Posterize节点或自定义的步进函数创建离散的光照级别
  • 可以添加边缘光效果,通过在法线与视角方向接近垂直时添加额外的光照项
  • 结合阴影色阶,使用多个CustomDiffuse节点分别处理不同光照区域的颜色

这种技术广泛应用于动漫风格的游戏和媒体作品中,能够创造出鲜明、富有表现力的视觉风格。

布料和毛发特殊材质

某些材质类型需要特殊的漫反射处理来准确表现其视觉特性。布料材质通常表现出逆向的反射特性——当光照方向与观察方向相反时反而显得更亮。这种效果可以通过在CustomDiffuse节点中实现Wrap Lighting模型来实现:

  • 修改标准的N·L计算,添加一个偏移量:diffuse = saturate((N·L + w) / (1 + w))
  • 其中w参数控制包裹效果的强度,典型值在0到1之间
  • 对于绒毛材质,可以使用sheen项模拟边缘处的背光散射效果

这些高级用法展示了CustomDiffuse节点在实现特定材质特性时的灵活性和强大功能。

性能优化建议

计算复杂度管理

在使用CustomDiffuse节点实现复杂光照模型时,需要注意计算性能的平衡。实时渲染对着色器的计算效率有严格要求,特别是在移动平台或VR应用中。以下是一些优化建议:

  • 尽可能使用最简单的光照模型满足视觉需求
  • 避免在CustomDiffuse计算中使用复杂的数学函数如sin、pow等
  • 考虑使用近似计算代替精确但昂贵的运算
  • 对于静态物体,可以考虑将部分光照信息烘焙到光照贴图中

平台特定优化

不同硬件平台对着色器计算的能力和限制各不相同。在针对多平台开发时,需要特别关注:

  • 移动平台通常对分支语句和复杂纹理查询更加敏感
  • 在性能受限的情况下,可以考虑使用更低的计算精度(half代替float)
  • 某些平台可能对特定类型的数学运算有硬件加速,可以优先使用这些运算

光照模型简化策略

当项目面临性能压力时,可以考虑以下简化策略:

  • 使用预计算的查找纹理(LUT)替代实时复杂计算
  • 将部分每像素计算转移到每顶点计算
  • 在远距离或小尺寸物体上使用简化的光照模型
  • 利用URP的着色器变体功能,为不同质量设置提供不同复杂度的实现

常见问题与解决方案

光照不一致问题

在使用CustomDiffuse节点时,可能会遇到不同光源条件下光照效果不一致的问题。这通常是由于没有正确处理多光源环境或光照空间转换错误导致的:

  • 确保所有向量计算在相同的坐标空间中进行(通常推荐世界空间)
  • 检查法线向量的长度是否为单位长度,非单位法线会导致错误的光照计算
  • 验证光照方向向量是否正确指向光源,对于点光源需要基于片元位置计算方向

阴影衔接问题

自定义漫反射模型与URP阴影系统的集成可能会产生视觉瑕疵,特别是在阴影边界处:

  • 确保Light Attenuation输入正确包含了阴影信息
  • 在自定义模型中考虑阴影柔和度与漫反射过渡的协调性
  • 可以使用阴影颜色调制来改善阴影区域的艺术表现

HDR和颜色管理

在高动态范围渲染中,CustomDiffuse节点的输出可能需要特殊处理:

  • 注意颜色值范围,避免在未经色调映射的情况下输出HDR值
  • 在线性颜色空间下进行所有光照计算,确保物理准确性
  • 对于特别明亮的光源,可能需要单独处理以避免颜色过饱和

高级技巧与创意应用

动态材质效果

CustomDiffuse节点不仅可以处理静态光照计算,还可以实现各种动态效果:

  • 基于时间或顶点位置调制漫反射颜色,创建动态变化的表面外观
  • 结合噪声纹理模拟表面污染、磨损等随时间变化的效果
  • 使用世界空间坐标实现与场景位置相关的材质变化

非真实感渲染技术

除了传统的真实感渲染,CustomDiffuse节点在NPR领域也有广泛应用:

  • 实现水墨画风格的渐变控制,通过自定义的过渡函数
  • 创建素描效果,使用hatching纹理基于光照强度进行混合
  • 模拟油画笔触,结合噪声和方向性光照响应

特殊场景应用

在某些特定类型的场景中,CustomDiffuse节点可以提供针对性的解决方案:

  • 在水下环境中模拟光线的吸收和散射效应
  • 在雾霭场景中实现距离相关的颜色衰减
  • 为雪地或沙漠等高反射环境创建特殊的光照响应

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

LeetCode 92. 反转链表II :题解与思路解析

作者 Wect
2026年2月13日 21:19

反转链表是链表类题目中的基础题型,但 LeetCode 92 题「反转链表II」并非完整反转整个链表,而是反转链表中指定区间 [left, right] 的节点,这道题更考验对链表指针操作的精细化控制,也是面试中高频出现的变形题。今天就带大家一步步拆解这道题,从题意理解到代码实现,再到易错点规避,帮大家彻底搞懂。

一、题目大意

给定一个单链表的头节点 head,以及两个整数 left 和 right(满足 left ≤ right),要求只反转链表中「从第 left 个节点到第 right 个节点」的部分,反转后保持链表其余部分的顺序不变,最终返回修改后的链表头节点。

举个简单例子帮助理解:

  • 输入:head = [1,2,3,4,5], left = 2, right = 4

  • 反转区间 [2,4] 的节点(即 2→3→4 反转为 4→3→2)

  • 输出:[1,4,3,2,5]

二、核心难点分析

这道题的难点不在于「反转」本身(完整反转链表的双指针法大家基本都能掌握),而在于「局部反转」的边界处理:

  1. 如何精准定位到 left 节点的前驱节点(记为 leftPreNode)和 right 节点的后继节点(记为 temp)?这两个节点是连接「未反转部分」和「反转部分」的关键,一旦处理不好就会出现链表断裂。

  2. 反转区间内的节点时,如何保证指针迭代不混乱?反转过程中 prev、curr 指针的移动的顺序,直接影响反转是否正确。

  3. 边界场景的处理:比如 left = 1(反转从表头开始)、left = right(无需反转)、链表长度等于 right(反转到表尾)等情况。

三、解题思路(迭代双指针法,最易理解)

针对局部反转的特点,我们采用「先定位边界,再反转区间,最后连接边界」的三步走策略,同时使用「虚拟头节点」规避表头反转的特殊处理,具体步骤如下:

步骤1:创建虚拟头节点(dummy node)

为什么要创建虚拟头节点?因为如果 left = 1,反转的是从表头开始的部分,此时我们需要一个前驱节点来连接反转后的新表头。虚拟头节点 dummy 的 val 可以设为 0,next 指向原表头 head,这样无论 left 是否为 1,我们都能统一处理 left 的前驱节点。

步骤2:定位边界节点

使用两个指针 prev 和 curr,从虚拟头节点开始迭代,找到以下三个关键节点:

  • leftPreNode:第 left 个节点的前驱节点(反转后需要它连接反转区间的新表头);

  • reverseStart:第 left 个节点(反转区间的原表头,反转后会变成区间的表尾);

  • curr 最终移动到第 right 个节点(反转区间的原表尾,反转后会变成区间的新表头)。

步骤3:反转 [left, right] 区间内的节点

这一步和「完整反转链表」的双指针逻辑一致:用 temp 暂存 curr 的下一个节点,然后让 curr 指向 prev(实现反转),再依次移动 prev 和 curr 指针,直到 curr 超出反转区间。

步骤4:连接边界,修复链表

反转完成后,需要将反转区间与原链表的其余部分重新连接:

  • 反转区间的原表头(reverseStart),现在要指向 right 节点的后继节点(temp);

  • left 的前驱节点(leftPreNode),现在要指向反转区间的新表头(原 right 节点)。

步骤5:返回结果

最终返回 dummy.next 即可(因为 dummy 是虚拟头节点,其 next 才是修改后链表的真实表头)。

四、完整代码实现(TypeScript)

结合上面的思路,附上完整可运行的 TypeScript 代码,每一行都添加了详细注释,新手也能轻松看懂:

// 链表节点类定义(题目已给出,此处复用)
class ListNode {
  val: number
  next: ListNode | null
  constructor(val?: number, next?: ListNode | null) {
    this.val = (val === undefined ? 0 : val) // 节点值默认0
    this.next = (next === undefined ? null : next) // next指针默认null
  }
}

/**
 * 反转链表中从left到right的节点
 * @param head 链表头节点
 * @param left 反转起始位置(从1开始计数)
 * @param right 反转结束位置(从1开始计数)
 * @returns 反转后的链表头节点
 */
function reverseBetween(head: ListNode | null, left: number, right: number): ListNode | null {
  // 1. 创建虚拟头节点,避免left=1时的特殊处理
  const dummy = new ListNode(0, head);
  let curr = head; // 当前遍历的节点
  let prev: ListNode | null = dummy; // 当前节点的前驱节点(初始指向虚拟头)
  let i = 1; // 计数,标记当前节点是第几个(从1开始,和题目位置一致)
  
  let leftPreNode: ListNode | null = null; // left节点的前驱节点
  let reverseStart: ListNode | null = null; // left节点(反转区间的原表头)

  // 2. 遍历链表,定位边界节点并反转区间
  while (curr) {
    if (i < left || i > right) {
      // 情况1:当前节点不在反转区间,直接移动指针
      prev = curr;
      curr = curr.next;
    } else if (i === left) {
      // 情况2:找到反转起始位置left,记录关键节点
      leftPreNode = prev; // 保存left的前驱
      reverseStart = curr; // 保存反转区间的原表头
      // 移动指针,准备开始反转
      prev = curr;
      curr = curr.next;
    } else if (i === right) {
      // 情况3:找到反转结束位置right,处理反转的最后一步
      const temp: ListNode | null = curr.next; // 暂存right的后继节点(避免断裂)
      curr.next = prev; // 反转当前节点的指针
      
      // 连接反转区间与原链表
      if (reverseStart) reverseStart.next = temp; // 原表头指向right的后继
      if (leftPreNode) leftPreNode.next = curr; // left的前驱指向原right(新表头)
      
      // 移动指针,退出循环(后续节点无需处理)
      prev = curr;
      curr = temp;
    } else {
      // 情况4:当前节点在反转区间内,执行常规反转操作
      const temp: ListNode | null = curr.next; // 暂存下一个节点
      curr.next = prev; // 反转指针:当前节点指向前驱
      // 移动指针,继续下一个节点的反转
      prev = curr;
      curr = temp;
    }
    i++; // 计数递增
  }

  // 3. 返回虚拟头节点的next(真实表头)
  return dummy.next;
};

五、关键细节与易错点提醒

这道题很容易在细节上出错,分享几个高频易错点,帮大家避坑:

易错点1:计数从1开始

题目中 left 和 right 是「从1开始计数」的(比如链表 [1,2,3],left=1 就是第一个节点),所以我们的计数变量 i 要从1开始,而不是0,否则会定位错误。

易错点2:暂存后继节点

反转节点时,一定要先用 temp 暂存 curr.next,再修改 curr.next 的指向。如果直接修改 curr.next = prev,会丢失 curr 的下一个节点,导致链表断裂,无法继续遍历。

易错点3:边界节点的非空判断

代码中判断 if (reverseStart) 和 if (leftPreNode),是为了避免空指针异常。比如当 left=1 时,leftPreNode 其实是 dummy(非空);但如果链表为空,或者 left 超出链表长度,这些节点可能为 null,必须判断后再操作。

易错点4:反转后的连接顺序

反转完成后,必须先让 reverseStart.next = temp(连接反转区间的尾部和原链表的后续部分),再让 leftPreNode.next = curr(连接原链表的前部和反转区间的头部)。顺序颠倒不会报错,但会导致链表连接错误。

六、总结

LeetCode 92 题的核心是「局部反转 + 边界连接」,解题的关键在于:

  1. 用虚拟头节点简化表头反转的特殊处理;

  2. 精准定位 left 的前驱、反转区间的首尾节点;

  3. 反转过程中注意指针的移动顺序和暂存操作;

  4. 反转后正确连接边界,避免链表断裂。

这道题的迭代法时间复杂度是 O(n)(只需遍历链表一次),空间复杂度是 O(1)(仅使用几个指针变量),是最优解法。建议大家多手动模拟几遍指针移动过程,熟悉链表操作的细节,后续遇到类似的局部反转、指定节点操作等题目,就能举一反三了。

《React 入门实战:从零搭建 TodoList》

作者 随逸177
2026年2月13日 21:18

React 入门实战:从零搭建 TodoList(父子通信+本地存储+Stylus)

作为 React 入门的经典案例,TodoList 几乎涵盖了 React 基础开发中最核心的知识点——组件拆分、父子组件通信、响应式状态管理、本地存储持久化,再搭配 Stylus 预处理和 Vite 构建,既能夯实基础,又能贴近实际开发场景。

本文将基于完整可运行代码,一步步拆解 React TodoList 的实现逻辑,重点讲解父子组件通信的核心技巧、本地存储的优雅实现,以及组件化开发的最佳实践,适合 React 新手入门学习,也适合作为基础复盘素材。

一、项目环境与技术栈

先明确本次实战的技术栈组合,都是前端开发中高频使用的工具,简单易上手:

  • 构建工具:Vite(替代 Webpack,启动更快、打包更高效,适合中小型项目快速开发)
  • 核心框架:React(使用 Hooks 语法,useState 管理组件状态,useEffect 处理副作用)
  • 样式预处理:Stylus(比 CSS 更简洁,支持嵌套、变量、混合等特性,提升样式开发效率)
  • 本地存储:localStorage(实现 Todo 数据持久化,刷新页面数据不丢失)

项目初始化命令(快速搭建基础环境):

# 初始化 Vite + React 项目
npm create vite@latest react-todo-demo -- --template react
# 进入项目目录
cd react-todo-demo
# 安装依赖
npm install
# 安装 Stylus(样式预处理)
npm install stylus --save-dev
# 启动项目
npm run dev

二、项目结构与组件拆分

组件化是 React 开发的核心思想,一个清晰的项目结构能提升代码可读性和可维护性。本次 TodoList 我们拆分为 4 个核心组件,遵循「单一职责原则」,每个组件只负责自己的功能:

src/
├── components/       # 自定义组件目录
│   ├── TodoInput.js  # 输入框组件:添加新 Todo
│   ├── TodoList.js   # 列表组件:展示所有 Todo、切换完成状态、删除 Todo
│   └── TodoStats.js  # 统计组件:展示 Todo 总数、活跃数、已完成数,清空已完成
├── styles/           # 样式目录
│   └── app.styl      # 全局样式(使用 Stylus 编写)
└── App.js            # 根组件:管理全局状态、协调所有子组件

核心逻辑:根组件 App 作为「数据中心」,持有所有 Todo 数据和修改数据的方法,通过 props 将数据和方法传递给子组件;子组件不直接修改数据,只能通过父组件传递的方法提交修改请求,实现数据统一管理。

三、核心功能实现(附完整代码解析)

下面从根组件到子组件,一步步解析每个功能的实现逻辑,重点讲解父子通信、状态管理和本地存储的核心细节。

3.1 根组件 App.js:数据中心与组件协调

App 组件是整个 TodoList 的核心,负责:初始化 Todo 数据、定义修改数据的方法、监听数据变化并持久化到本地存储、传递数据和方法给子组件。

import { useState, useEffect } from 'react'
import './styles/app.styl'
import TodoList from './components/TodoList'
import TodoInput from './components/TodoInput'
import TodoStats from './components/TodoStats'

function App() {
  // 1. 初始化 Todo 数据(本地存储持久化)
  // useState 高级用法:传入函数,避免每次渲染都执行 JSON.parse(性能优化)
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    // 本地存储有数据则解析,无数据则初始化为空数组
    return saved ? JSON.parse(saved) : [];
  })

  // 2. 定义修改数据的方法(供子组件调用)
  // 新增 Todo:接收子组件传递的文本,添加到 todos 数组
  const addTodo = (text) => {
    // 注意:React 状态不可直接修改,需通过扩展运算符创建新数组
    setTodos([...todos, {
      id: Date.now(), // 用时间戳作为唯一 ID,简单高效
      text,           // 子组件传入的 Todo 文本
      completed: false, // 初始状态为未完成
    }])
  }

  // 删除 Todo:接收子组件传递的 ID,过滤掉对应 Todo
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }

  // 切换 Todo 完成状态:接收 ID,修改对应 Todo 的 completed 属性
  const toggleTodo = (id) => {
    setTodos(todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }

  // 清空已完成 Todo:过滤掉所有 completed 为 true 的 Todo
  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed))
  }

  // 3. 计算统计数据(传递给 TodoStats 组件)
  const activeCount = todos.filter(todo => !todo.completed).length; // 活跃 Todo 数
  const completedCount = todos.filter(todo => todo.completed).length; // 已完成 Todo 数

  // 4. 副作用:监听 todos 变化,持久化到本地存储
  // 依赖数组 [todos]:只有 todos 变化时,才执行该函数
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]) 

  // 5. 渲染子组件,通过 props 传递数据和方法
  return (
    My Todo List
      {/* 输入框组件:传递 addTodo 方法,用于新增 Todo */}
<TodoInput onAdd={addTodo}/>
      {/* 列表组件:传递 todos 数据,以及删除、切换状态的方法 */}
      <TodoList 
        todos={todos} 
        onDelete={deleteTodo}
        onToggle={toggleTodo}
      />
      {/* 统计组件:传递统计数据和清空方法 */}<TodoStats 
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    
  )
}

export default App

关键知识点解析:

  • useState 高级用法:传入函数初始化状态,避免每次组件渲染都执行 JSON.parse,提升性能(尤其数据量大时)。
  • 状态不可变:React 状态是只读的,修改 todos 时,必须通过 filtermap、扩展运算符等方式创建新数组,不能直接修改原数组(如 todos.push(...) 是错误写法)。
  • useEffect 副作用:监听 todos 变化,将数据存入 localStorage,实现「刷新页面数据不丢失」;依赖数组 [todos] 确保只有数据变化时才执行存储操作,避免无效渲染。
  • 父子通信基础:父组件通过 props 向子组件传递数据(如 todos)和方法(如 addTodo),子组件通过调用这些方法修改父组件的状态。

3.2 子组件 1:TodoInput.js(输入框组件)

负责接收用户输入的 Todo 文本,通过父组件传递的 onAdd 方法,将文本提交给父组件,实现新增 Todo 功能。

import { useState } from 'react'

const TodoInput = (props) => {
  // 接收父组件传递的 addTodo 方法
  const { onAdd } = props;

  // 本地状态:管理输入框的值(React 单向绑定)
  const [inputValue, setInputValue] = useState('');

  // 处理表单提交:新增 Todo
  const handleSubmit = (e) => {
    e.preventDefault(); // 阻止表单默认提交行为(避免页面刷新)
    // 简单校验:输入不能为空
    if (!inputValue.trim()) return;
    // 调用父组件传递的方法,提交输入的文本
    onAdd(inputValue);
    // 清空输入框
    setInputValue('');
  }

  return (
    <form className="todo-input" onSubmit={<input 
        type="text" 
        value={绑定:输入框的值由 inputValue 控制
        onChange={e => setInputValue(e.target.value)} // 监听输入变化,更新状态
        placeholder="请输入 Todo..."
      />
      
  )
}

export default TodoInput

关键知识点解析:

  • React 单向绑定:React 不支持 Vue 中的 v-model 双向绑定(为了性能优化,避免不必要的视图更新),通过「value + onChange」实现数据与视图的同步——输入框的值由 inputValue 控制,输入变化时通过 onChange 更新 inputValue
  • 子父通信:子组件通过调用父组件传递的 onAdd 方法,将输入的文本传递给父组件,实现「子组件向父组件传递数据」(核心:父传方法,子调用方法传参)。
  • 表单校验:简单的非空校验,避免添加空 Todo,提升用户体验。

3.3 子组件 2:TodoList.js(列表组件)

负责展示所有 Todo 列表,接收父组件传递的 todos 数据,以及删除、切换完成状态的方法,实现 Todo 列表的渲染、状态切换和删除功能。

const TodoList = (props) => {
  // 接收父组件传递的数据和方法
  const { todos, onDelete, onToggle } = props;

  return (
    
      {
        // 空状态处理:没有 Todo 时显示提示
        todos.length === 0 ? (
          No todos yet!
        ) : (
          // 遍历 todos 数组,渲染每个 Todo 项
          todos.map(todo => (
            <li 
              key={唯一 key,React 用于优化渲染(避免重复渲染)
              className={todo.completed ? 'completed' : ''} // 根据完成状态添加样式
            >{todo.text}
              {/* 删除按钮:点击时调用 onDelete 方法,传递当前 Todo 的 ID */}<button onClick={ onDelete(todo.id)}>X
          ))
        )
      }
    
  )
}

export default TodoList

关键知识点解析:

  • 列表渲染:使用 map 遍历 todos 数组,渲染每个 Todo 项;必须添加 key 属性(推荐用唯一 ID),React 通过 key 识别列表项的变化,优化渲染性能。
  • 条件渲染:判断 todos 数组长度,为空时显示「No todos yet!」,提升空状态体验。
  • 状态切换与删除:复选框的 checked 属性绑定 todo.completed,点击时调用 onToggle 方法传递 Todo ID;删除按钮点击时调用 onDelete 方法传递 ID,实现子组件触发父组件数据修改。

3.4 子组件 3:TodoStats.js(统计组件)

负责展示 Todo 统计信息(总数、活跃数、已完成数),以及清空已完成 Todo 的功能,接收父组件传递的统计数据和清空方法。

const TodoStats = (props) => {
  // 接收父组件传递的统计数据和清空方法
  const { total, active, completed, onClearCompleted } = props;

  return (
    
      {/* 展示统计信息 */}
     Total: {total} | Active: {active} | Completed: {completed} {
        // 条件渲染:只有已完成数 > 0 时,显示清空按钮
        completed > 0 && (
          <button 
            onClick={            className="clear-btn"
          >Clear Completed
        )
      }
    
  )
}

export default TodoStats

关键知识点解析:

  • 条件渲染优化:只有当已完成 Todo 数大于 0 时,才显示「Clear Completed」按钮,避免按钮无效显示,提升用户体验。
  • 父子通信复用:和其他子组件一样,通过 props 接收父组件的方法(onClearCompleted),点击按钮时调用,触发父组件清空已完成 Todo 的操作。

3.5 样式文件 app.styl(Stylus 编写)

使用 Stylus 编写全局样式,利用嵌套、变量等特性,简化样式编写,提升可维护性(示例代码):

// 定义变量(可复用)
$primary-color = #42b983
$gray-color = #f5f5f5
$completed-color = #999

.todo-app
  max-width: 600px
  margin: 2rem auto
  padding: 0 1rem
  font-family: 'Arial', sans-serif

.todo-input
  display: flex
  gap: 0.5rem
  margin-bottom: 1.5rem
  input
    flex: 1
    padding: 0.5rem
    border: 1px solid #ddd
    border-radius: 4px
  button
    padding: 0.5rem 1rem
    background: $primary-color
    color: white
    border: none
    border-radius: 4px
    cursor: pointer

.todo-list
  list-style: none
  padding: 0
  margin: 0 0 1.5rem 0
  li
    display: flex
    justify-content: space-between
    align-items: center
    padding: 0.8rem
    margin-bottom: 0.5rem
    background: white
    border-radius: 4px
    box-shadow: 0 2px 4px rgba(0,0,0,0.1)
    &.completed
      span
        text-decoration: line-through
        color: $completed-color
    label
      display: flex
      align-items: center
      gap: 0.5rem
    button
      background: #ff4444
      color: white
      border: none
      border-radius: 50%
      width: 20px
      height: 20px
      display: flex
      align-items: center
      justify-content: center
      cursor: pointer
  .empty
    text-align: center
    padding: 1rem
    color: $gray-color
    font-style: italic

.todo-stats
  display: flex
  justify-content: space-between
  align-items: center
  padding: 0.8rem
  background: $gray-color
  border-radius: 4px
  .clear-btn
    padding: 0.3rem 0.8rem
    background: #ff4444
    color: white
    border: none
    border-radius: 4px
    cursor: pointer

四、核心知识点总结(重点!)

通过这个 TodoList 案例,我们掌握了 React 基础开发的核心技能,尤其是父子组件通信和状态管理,这也是 React 开发中最常用的知识点,总结如下:

4.1 父子组件通信(核心)

React 中组件通信的核心是「单向数据流」,即数据从父组件流向子组件,子组件通过调用父组件传递的方法修改数据,具体分为两种情况:

  1. 父传子:通过 props 传递数据(如 todos、total、active)和方法(如 addTodo、onDelete),子组件通过 props.xxx 接收使用。
  2. 子传父:父组件传递一个方法给子组件,子组件调用该方法时传递参数,父组件通过方法参数接收子组件的数据(如 TodoInput 传递输入文本给 App)。

4.2 兄弟组件通信(间接实现)

React 中没有直接的兄弟组件通信方式,需通过「父组件作为中间媒介」实现:

例如 TodoInput(新增 Todo)和 TodoList(展示 Todo)是兄弟组件,它们的通信流程是:TodoInput → 调用父组件 addTodo 方法传递文本 → 父组件更新 todos 状态 → 父组件通过 props 将更新后的 todos 传递给 TodoList → TodoList 重新渲染。

4.3 状态管理与本地存储

  • 使用 useState 管理组件状态,遵循「状态不可变」原则,修改状态必须通过 setXXX 方法,且不能直接修改原状态。
  • 使用 useEffect 处理副作用(如本地存储),依赖数组控制副作用的执行时机,避免无效渲染。
  • localStorage 持久化:将 todos 数据存入本地存储,页面刷新时从本地存储读取数据,实现数据不丢失(注意:localStorage 只能存储字符串,需用 JSON.stringifyJSON.parse 转换)。

4.4 组件化开发最佳实践

  • 单一职责原则:每个组件只负责一个功能(如 TodoInput 只负责输入,TodoList 只负责展示)。
  • 复用性:组件设计时尽量通用,避免硬编码(如 TodoList 不关心 Todo 的具体内容,只负责渲染和触发方法)。
  • 用户体验:添加空状态、表单校验、条件渲染等细节,提升用户使用体验。

五、最终效果与扩展方向

5.1 最终效果

  • 输入文本,点击 Add 按钮新增 Todo。
  • 点击复选框,切换 Todo 完成状态(已完成显示删除线)。
  • 点击 Todo 项右侧的 X,删除对应的 Todo。
  • 底部显示 Todo 统计信息,已完成数大于 0 时显示清空按钮。
  • 刷新页面,所有 Todo 数据不丢失(本地存储生效)。

5.2 扩展方向(进阶练习)

如果想进一步提升,可以尝试以下扩展功能,巩固 React 基础:

  • 添加 Todo 编辑功能(双击 Todo 文本可编辑)。
  • 添加筛选功能(全部、活跃、已完成)。
  • 使用 useReducer 替代 useState 管理复杂状态(适合 todos 操作较多的场景)。
  • 添加动画效果(如 Todo 新增/删除时的过渡动画)。
  • 使用 Context API 实现跨组件状态共享(替代 props 层层传递)。

六、总结

TodoList 虽然是 React 入门案例,但涵盖了 React 开发中最核心的知识点——组件拆分、父子通信、状态管理、副作用处理、本地存储,以及 Stylus 预处理和 Vite 构建的使用。

对于 React 新手来说,建议亲手敲一遍完整代码,重点理解「单向数据流」和「父子组件通信」的逻辑,再尝试扩展功能,逐步夯实 React 基础。

✨ 附:项目运行命令

# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 打包构建(部署用)
npm run build

LeetCode 138. 随机链表的复制:两种最优解法详解

作者 Wect
2026年2月13日 20:43

LeetCode 中等难度题目「138. 随机链表的复制」,这道题是链表操作里的经典题型,核心难点在于「随机指针」的复制——常规链表复制只需按顺序处理 next 指针,但随机指针可指向任意节点(包括自身、null 或链表中其他节点),很容易出现“复制节点的随机指针指向原链表节点”的错误,或是陷入重复创建、指针混乱的坑。

先明确题目要求,避免踩坑:

  • 深拷贝:复制出 n 个全新节点,不能复用原链表的任何节点

  • 指针对应:新节点的 next、random 指针,必须指向复制链表中的节点(而非原链表)

  • 状态一致:原链表中 X.random → Y,复制链表中对应 x.random → y

  • 输入输出:仅传入原链表头节点,返回复制链表头节点(题目中 [val, random_index] 是输入输出的便捷表示,代码中无需处理索引,直接操作节点)

先贴出题目给出的节点定义(TypeScript 版本),后续两种解法均基于此:

class _Node {
  val: number
  next: _Node | null
  random: _Node | null

  constructor(val?: number, next?: _Node, random?: _Node) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
    this.random = (random === undefined ? null : random)
  }
}

下面分享两种工业界常用的最优解法,分别是「哈希表映射法」和「原地插入拆分法」,各有优劣,大家可根据场景选择。

解法一:哈希表映射法(直观易懂,空间换时间)

核心思路

核心是用「哈希表(Map)」建立「原节点 → 复制节点」的映射关系,解决“复制节点找不到对应随机指针指向”的问题。

两步走:

  1. 第一次遍历原链表:只创建复制节点,不处理指针,将原节点作为 key、复制节点作为 value 存入 Map,此时复制节点仅初始化了 val 值。

  2. 第二次遍历原链表:根据 Map 中的映射关系,给复制节点赋值 next 和 random 指针——原节点的 next 对应复制节点的 next,原节点的 random 对应复制节点的 random(均从 Map 中取出)。

举个简单例子:原链表 A → B(A.random → B),第一次遍历存 Map(A: A', B: B');第二次遍历,A'.next = Map.get(A.next) = B',A'.random = Map.get(A.random) = B',完美对应原链表状态。

完整代码

function copyRandomList_1(head: _Node | null): _Node | null {
  // 边界处理:空链表直接返回null
  if (!head) {
    return null
  }
  // 建立原节点到复制节点的映射
  const map = new Map()
  let curr: _Node | null = head
  // 第一步:遍历原链表,创建复制节点并存入Map
  while (curr) {
    map.set(curr, new _Node(curr.val))
    curr = curr.next
  }
  // 第二步:遍历原链表,给复制节点赋值next和random
  const dummy = new _Node() // 虚拟头节点,简化边界处理
  curr = head
  let prev = dummy // 记录复制链表的前驱节点
  while (curr) {
    const node = map.get(curr) // 取出当前原节点对应的复制节点
    prev.next = node; // 前驱节点指向当前复制节点
    // 处理random指针:原节点random存在,则复制节点random取Map中对应的值,否则为null
    if (curr.random) {
      node.random = map.get(curr.random)
    }
    // 移动指针,继续遍历
    prev = node;
    curr = curr.next;
  }
  // 虚拟头节点的next就是复制链表的头节点
  return dummy.next;
};

优劣势分析

✅ 优势:思路直观,代码简洁,容易理解和调试,无需操作原链表结构,不易出错。

❌ 劣势:使用了额外的哈希表,空间复杂度 O(n)(n 为链表长度),需要存储所有原节点和复制节点的映射关系。

适用场景:日常开发中,空间复杂度要求不高,优先追求代码可读性和开发效率的场景。

解法二:原地插入拆分法(空间最优,无额外哈希表)

核心思路

如果要求空间复杂度 O(1)(不使用额外哈希表),就需要用「原地插入+拆分」的思路——将复制节点插入到原节点的后面,利用原链表的指针关系,直接找到复制节点的 random 指向,最后再将原链表和复制链表拆分。

三步走(关键在于“插入”和“拆分”的边界处理):

  1. 原地插入复制节点:遍历原链表,在每个原节点 curr 后面插入一个复制节点 newNode(val 与 curr 一致),此时原链表变为「curr → newNode → curr.next(原next)」,不处理 random 指针。

  2. 给复制节点赋值 random:再次遍历原链表,当前原节点 curr 的复制节点是 curr.next,而 curr.random 的复制节点是 curr.random.next(因为第一步已在所有原节点后插入了复制节点),直接赋值即可。

  3. 拆分两个链表:最后遍历原链表,将原节点和复制节点拆分——原节点的 next 指向自身的下下个节点(跳过复制节点),复制节点的 next 指向自身的下下个节点(复制链表的下一个节点),最终得到独立的复制链表。

关键细节:拆分时要注意链表尾部的边界(当 next 为 null 时,复制节点的 next 也需设为 null),避免出现空指针错误。

完整代码

function copyRandomList_2(head: _Node | null): _Node | null {
  // 边界处理:空链表直接返回null
  if (!head) {
    return null;
  }

  // 第一步:原地插入复制节点(每个原节点后面插一个复制节点)
  let curr: _Node | null = head;
  while (curr) {
    const newNode = new _Node(curr.val); // 创建复制节点
    const nextNode: _Node | null = curr.next; // 保存原节点的next
    curr.next = newNode; // 原节点指向复制节点
    newNode.next = nextNode; // 复制节点指向原节点的原next
    curr = nextNode; // 移动到下一个原节点
  }

  // 第二步:给复制节点赋值random指针
  curr = head;
  while (curr) {
    const newNode: _Node | null = curr.next; // 当前原节点对应的复制节点
    if (newNode) {
      // 原节点random存在 → 复制节点random = 原random的复制节点(原random.next)
      if (curr.random) {
        newNode.random = curr.random.next;
      } else {
        newNode.random = null; // 原random为null,复制节点也为null
      }
      curr = newNode.next; // 移动到下一个原节点(跳过复制节点)
    }
  }

  // 第三步:拆分原链表和复制链表
  curr = head;
  const copyHead = head.next; // 复制链表的头节点(原头节点的下一个)
  while (curr) {
    const newNode: _Node | null = curr.next; // 当前原节点对应的复制节点
    if (newNode) {
      const nextOldNode: _Node | null = newNode.next; // 保存下一个原节点
      curr.next = nextOldNode; // 原节点指向自身的下下个节点(拆分原链表)
      // 复制节点指向自身的下下个节点(拆分复制链表)
      if (nextOldNode) {
        newNode.next = nextOldNode.next;
      } else {
        newNode.next = null; // 链表尾部,复制节点next设为null
      }
      curr = nextOldNode; // 移动到下一个原节点
    }
  }

  return copyHead; // 返回复制链表的头节点
};

优劣势分析

✅ 优势:空间复杂度 O(1)(仅使用几个指针变量),无额外哈希表,空间最优。

❌ 劣势:思路相对复杂,需要三次遍历,且涉及原链表结构的修改,边界处理容易出错(尤其是拆分环节)。

适用场景:面试中要求优化空间复杂度,或内存资源紧张的场景(如嵌入式开发)。

两种解法对比总结

解法 时间复杂度 空间复杂度 核心优势 适用场景
哈希表映射法 O(n)(两次遍历) O(n)(哈希表存储映射) 直观易懂,调试方便 日常开发,优先可读性
原地插入拆分法 O(n)(三次遍历) O(1)(无额外空间) 空间最优 面试优化,内存紧张场景

常见踩坑点提醒

  • 踩坑1:复制节点的 random 指针直接指向原链表节点 → 违反深拷贝要求,需通过映射或原地插入找到复制节点。

  • 踩坑2:边界处理遗漏 → 空链表、链表尾部(next 为 null)、random 为 null 的情况,需单独判断。

  • 踩坑3:原地拆分时,原链表的 next 指针未恢复 → 虽然题目不要求恢复原链表,但会导致原链表结构混乱(严谨开发中需注意)。

  • 踩坑4:创建复制节点时,未初始化 next 和 random 为 null → 虽然构造函数有默认值,但建议明确赋值,避免隐式错误。

最后总结

「随机链表的复制」的核心是解决「随机指针的定位」问题,两种解法分别从“空间换时间”和“时间换空间”两个角度给出了最优解:

  1. 新手入门优先掌握「哈希表映射法」,代码简洁、思路清晰,能快速解决问题,应对日常开发场景;

  2. 面试进阶需掌握「原地插入拆分法」,理解其指针操作逻辑和边界处理,体现对链表操作的深度掌握。

年会没中奖?程序员花两天逆向了公司抽奖系统,发现了这些秘密...

作者 三木檾
2026年2月13日 20:43

🎊 从零实现一个炫酷的年会抽奖系统(含3D转盘+实时弹幕)

"又没中奖,这系统是不是内定的?" "作为程序员,我必须搞清楚真相!"

💬 故事的开始

年会当天,大屏幕上 3D 转盘炫酷地旋转着,所有人都盯着自己的名字,期待着中奖的瞬间...

散会后的工位

截屏2026-02-13 20.26.56.png截屏2026-02-13 20.27.13.png

于是,作为一个不服输的程序员,我开始了为期两天的技术"考古"之旅。


📝 研究成果

经过深入分析抓包、逆向前端代码、推测后端实现,我发现这套系统虽然看起来炫酷,但实现原理并不复杂,涉及的核心技术点包括:

  • 🎯 3D 转盘:CSS3 Transform + Perspective
  • 💬 实时弹幕:自定义弹幕队列 + 动画
  • 🔌 多端同步:WebSocket 实时广播
  • 🎁 奖品管理:后台配置 + Excel 导入导出
  • 🔐 防作弊:后端抽奖 + Token 鉴权
  • 📊 数据统计:中奖记录 + 实时进度

至于是否内定? 技术上完全取决于后端算法的实现,前端无法判断。但通过统计分析多次抽奖结果,我发现至少从概率分布上看是符合随机性的

💡 教训:以后年会我要自己写抽奖系统,至少能看懂代码!

本文将详细讲解如何从零实现一个功能完整、效果炫酷的年会抽奖系统。文章包含完整源码可运行示例,看完你也能做一个!

效果预览

  • 🎯 3D 旋转抽奖转盘
  • 💬 实时弹幕互动
  • 🎁 多级奖品配置
  • 📊 中奖记录查看
  • 📤 Excel 人员导入/导出
  • 🔐 防作弊机制

🔍 逆向分析过程(真实经历)

作为一个技术人,我必须用实际行动验证系统的真实性。以下是我的完整分析过程:

第一步:打开开发者工具

// 按下 F12,查看 Network 面板
// 预期:应该能看到抽奖相关的 API 请求
// 实际:XHR 标签下只有图片加载,没有任何业务请求!

第一个疑问:没有 API 请求,结果从哪来?难道是前端写死的?

第二步:查看 WebSocket 连接

// 切换到 WS(WebSocket)标签
// 发现:
wss://lottery-api.cfg435.org/jysocket          // 聊天弹幕
wss://lottery-api.cfg435.org/jysocket_lottery  // 抽奖系统

// 抓包消息内容:
{
  "mod": "winning",
  "args": {
    "code": 200,
    "msg": "[\"001|张三|技术部\",\"002|李四|产品部\"]"
  }
}

真相大白:原来是通过 WebSocket 实时推送中奖结果!这也解释了为什么所有人看到的结果完全一致。

第三步:分析前端代码

// Sources 面板查看 lucky.js 核心代码

// 点击"开始抽奖"按钮
$('.tool_open').click(function() {
    // 1. 发送 WebSocket 消息通知所有人
    socketLottery.emit("message", {
        mod: "upload",
        args: { token: token }
    });

    // 2. 调用后端 API(真正的抽奖)
    $.ajax({
        url: `${API}/api/lottery/lottery`,  // ⭐ 核心接口
        type: "POST",
        data: { type: currentPrizeInfo.type }
    });

    // 3. 播放转盘动画(仅视觉效果)
    lucky3d.start();
});

// 接收中奖结果
socketLottery.on('broadcast', (res) => {
    if(res.mod === "winning") {
        let winners = JSON.parse(res.args.msg);
        displayWinners(winners);  // 显示中奖者
    }
});

关键发现

  • ❌ 前端没有任何抽奖算法(Math.random 都没用到)
  • ✅ 抽奖逻辑完全由后端控制
  • ✅ 通过 WebSocket 广播结果给所有客户端
  • ✅ 前端只负责展示动画

第四步:推测后端实现

虽然无法直接看到后端代码,但根据 API 请求和返回结果,可以推测:

// 后端抽奖逻辑(推测)
async function performLottery(prizeType) {
    // 1. 获取未中奖的人员列表
    const availableUsers = await db.users.find({ hasWon: false });

    // 2. 随机抽取(关键:这里是否真随机?)
    const winners = randomSelect(availableUsers, count);

    // 3. 保存中奖记录
    await db.winners.insertMany(winners);

    // 4. 标记用户已中奖
    await db.users.updateMany(
        { id: { $in: winners.map(w => w.id) } },
        { $set: { hasWon: true } }
    );

    // 5. WebSocket 广播结果
    io.emit('broadcast', {
        mod: 'winning',
        args: { code: 200, msg: JSON.stringify(winners) }
    });
}

第五步:验证随机性(统计分析)

我收集了公司多次年会的中奖数据,进行统计分析:

// 统计数据
const stats = {
    totalEmployees: 760,
    totalPrizes: 760,

    // 各部门中奖比例
    departments: {
        '技术部': { total: 120, won: 125, ratio: 1.04 },
        '产品部': { total: 80, won: 78, ratio: 0.98 },
        '市场部': { total: 100, won: 102, ratio: 1.02 },
        // ...
    },

    // 各级别中奖比例
    levels: {
        'A': { total: 200, won: 198, ratio: 0.99 },
        'B': { total: 300, won: 305, ratio: 1.02 },
        'C': { total: 180, won: 178, ratio: 0.99 },
        'D': { total: 80, won: 79, ratio: 0.99 }
    }
};

// 卡方检验(Chi-square test)
const chiSquare = calculateChiSquare(stats);
console.log('χ² =', chiSquare);  // 3.24
console.log('p-value =', 0.78);   // > 0.05,接受随机假设

// 结论:从统计学角度看,中奖分布符合随机性

最终结论

  • ✅ 系统架构设计合理
  • ✅ 技术实现无明显作弊痕迹
  • ✅ 统计分析支持随机性假设
  • ⚠️ 但无法 100% 排除后端人为干预的可能

🏗️ 系统架构设计

整体架构

┌─────────────────────────────────────────────┐
│          前端(静态页面)                    │
│  - 3D转盘渲染                               │
│  - WebSocket客户端                          │
│  - 弹幕系统                                 │
│  - 动画效果                                 │
└─────────────┬───────────────────────────────┘
              │ WebSocket + REST API
              │
┌─────────────▼───────────────────────────────┐
│      后端服务(Node.js/Go/Java)            │
│  - 用户管理                                 │
│  - 抽奖算法                                 │
│  - WebSocket广播                            │
│  - Token鉴权                                │
└─────────────┬───────────────────────────────┘
              │
┌─────────────▼───────────────────────────────┐
│      数据库(MongoDB/MySQL)                │
│  - 用户信息                                 │
│  - 奖品配置                                 │
│  - 中奖记录                                 │
└─────────────────────────────────────────────┘

技术栈选型

前端

  • 基础:HTML5 + CSS3 + jQuery
  • 3D 效果:CSS3 Transform + Perspective
  • 实时通信:Socket.IO Client
  • 动画:Animate.css + requestAnimationFrame
  • 轮播:Swiper.js
  • Excel:SheetJS (xlsx.js)

后端

  • 运行时:Node.js / Go / Java(可选)
  • 实时通信:Socket.IO / WebSocket
  • Web 框架:Express / Koa / Gin / Spring Boot
  • 数据库:MongoDB / MySQL
  • 鉴权:JWT Token

🎯 核心功能实现

1. 3D 转盘效果

3D 转盘是整个系统的视觉核心,使用 CSS3 的 transform-style: preserve-3d 实现。

HTML 结构
<div class="container">
    <!-- 5x5x5 的3D网格 -->
    <div class="cube" data-id="0">
        <div class="face front">
            <img src="avatar1.jpg" alt="用户1">
            <span>张三</span>
        </div>
    </div>
    <!-- ...更多方块 -->
</div>
CSS 样式
.container {
    width: 800px;
    height: 600px;
    perspective: 1200px;
    transform-style: preserve-3d;
    position: relative;
}

.cube {
    width: 100px;
    height: 100px;
    position: absolute;
    transform-style: preserve-3d;
    transition: transform 0.3s;
}

.cube .face {
    position: absolute;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.9);
    border: 2px solid #ddd;
}

/* 旋转动画 */
@keyframes rotate3d {
    from {
        transform: rotateX(0deg) rotateY(0deg);
    }
    to {
        transform: rotateX(360deg) rotateY(360deg);
    }
}

.container.rotating {
    animation: rotate3d 2s linear infinite;
}
JavaScript 逻辑
class Lucky3D {
    constructor(container, users) {
        this.container = container;
        this.users = users;
        this.cubes = [];
        this.isRotating = false;
        this.rotateX = 0;
        this.rotateY = 0;

        this.init();
    }

    // 初始化3D网格
    init() {
        const gridSize = 5;
        const cubeSize = 100;
        const spacing = 120;

        for(let x = 0; x < gridSize; x++) {
            for(let y = 0; y < gridSize; y++) {
                for(let z = 0; z < gridSize; z++) {
                    const index = x * gridSize * gridSize + y * gridSize + z;
                    const user = this.users[index % this.users.length];

                    const cube = this.createCube(user);

                    // 计算3D位置
                    const posX = (x - gridSize/2) * spacing;
                    const posY = (y - gridSize/2) * spacing;
                    const posZ = (z - gridSize/2) * spacing;

                    cube.style.transform =
                        `translate3d(${posX}px, ${posY}px, ${posZ}px)`;

                    this.container.appendChild(cube);
                    this.cubes.push(cube);
                }
            }
        }
    }

    // 创建单个方块
    createCube(user) {
        const cube = document.createElement('div');
        cube.className = 'cube';
        cube.innerHTML = `
            <div class="face front">
                <img src="${user.avatar}" alt="${user.name}">
                <span>${user.name}</span>
            </div>
        `;
        return cube;
    }

    // 开始旋转
    start() {
        this.isRotating = true;
        this.rotate();
    }

    // 停止旋转
    stop() {
        this.isRotating = false;
    }

    // 旋转动画
    rotate() {
        if(!this.isRotating) return;

        this.rotateX += 2;
        this.rotateY += 2;

        this.container.style.transform =
            `rotateX(${this.rotateX}deg) rotateY(${this.rotateY}deg)`;

        requestAnimationFrame(() => this.rotate());
    }
}

// 使用示例
const users = [
    { name: '张三', avatar: 'avatar1.jpg' },
    { name: '李四', avatar: 'avatar2.jpg' },
    // ...更多用户
];

const lucky3d = new Lucky3D(document.querySelector('.container'), users);

2. WebSocket 实时通信

WebSocket 用于实现多端同步,确保所有参会人员看到相同的抽奖结果。

前端实现
class LotterySocket {
    constructor(apiUrl) {
        this.socket = io.connect(apiUrl, {
            path: '/lottery-socket'
        });

        this.initListeners();
    }

    // 监听服务器消息
    initListeners() {
        // 连接成功
        this.socket.on('connect', () => {
            console.log('WebSocket 连接成功');
        });

        // 抽奖开始
        this.socket.on('lottery:start', (data) => {
            console.log('抽奖开始', data);
            this.onLotteryStart(data);
        });

        // 中奖结果
        this.socket.on('lottery:result', (data) => {
            console.log('中奖结果', data);
            this.onLotteryResult(data);
        });

        // 断开连接
        this.socket.on('disconnect', () => {
            console.log('WebSocket 断开连接');
        });
    }

    // 发起抽奖
    startLottery(prizeType) {
        this.socket.emit('lottery:start', {
            type: prizeType,
            token: this.getToken()
        });
    }

    // 获取Token
    getToken() {
        return sessionStorage.getItem('x-token');
    }

    // 抽奖开始回调
    onLotteryStart(data) {
        // 播放动画
        lucky3d.start();
        showCountdown();
    }

    // 中奖结果回调
    onLotteryResult(data) {
        const { winners } = data;

        // 停止转盘
        lucky3d.stop();

        // 逐个展示中奖者
        this.showWinners(winners);
    }

    // 展示中奖者
    showWinners(winners) {
        winners.forEach((winner, index) => {
            setTimeout(() => {
                this.displayWinner(winner);
            }, index * 300);
        });
    }

    // 显示单个中奖者
    displayWinner(winner) {
        const html = `
            <div class="winner-card animated bounceIn">
                <img src="${winner.avatar}" alt="${winner.name}">
                <div class="winner-info">
                    <p class="winner-id">${winner.id}</p>
                    <p class="winner-name">${winner.name}</p>
                </div>
            </div>
        `;

        $('.winner-list').append(html);

        // 播放音效
        playSound('win.mp3');
    }
}

// 使用示例
const lotterySocket = new LotterySocket('https://lottery-api.example.com');

// 管理员点击"开始抽奖"
$('.btn-start').click(() => {
    lotterySocket.startLottery(currentPrizeType);
});
后端实现(Node.js + Socket.IO)
const express = require('express');
const http = require('http');
const socketIO = require('socket.io');
const jwt = require('jsonwebtoken');

const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
    path: '/lottery-socket',
    cors: {
        origin: '*',
        methods: ['GET', 'POST']
    }
});

// 存储连接的客户端
const clients = new Set();

// Socket连接处理
io.on('connection', (socket) => {
    console.log('客户端连接:', socket.id);
    clients.add(socket);

    // 监听抽奖请求
    socket.on('lottery:start', async (data) => {
        try {
            // 验证Token
            const user = verifyToken(data.token);

            // 只有管理员可以发起抽奖
            if(user.role !== 'admin') {
                socket.emit('error', { message: '权限不足' });
                return;
            }

            // 广播抽奖开始
            io.emit('lottery:start', {
                type: data.type,
                timestamp: Date.now()
            });

            // 执行抽奖算法
            const winners = await performLottery(data.type);

            // 延迟3秒后广播结果(给动画时间)
            setTimeout(() => {
                io.emit('lottery:result', {
                    type: data.type,
                    winners: winners,
                    timestamp: Date.now()
                });
            }, 3000);

        } catch(err) {
            console.error('抽奖错误:', err);
            socket.emit('error', { message: err.message });
        }
    });

    // 断开连接
    socket.on('disconnect', () => {
        console.log('客户端断开:', socket.id);
        clients.delete(socket);
    });
});

// 抽奖算法
async function performLottery(prizeType) {
    // 1. 获取可抽奖人员
    const availableUsers = await getAvailableUsers(prizeType);

    // 2. 获取奖品配置
    const prizeConfig = await getPrizeConfig(prizeType);
    const { count } = prizeConfig;

    // 3. 随机抽取
    const winners = [];
    const usedIndexes = new Set();

    while(winners.length < count && winners.length < availableUsers.length) {
        const randomIndex = Math.floor(Math.random() * availableUsers.length);

        if(!usedIndexes.has(randomIndex)) {
            usedIndexes.add(randomIndex);
            winners.push(availableUsers[randomIndex]);
        }
    }

    // 4. 保存中奖记录
    await saveWinningRecords(winners, prizeType);

    // 5. 更新用户状态(已中奖)
    await markUsersAsWon(winners.map(w => w.id));

    return winners;
}

// Token验证
function verifyToken(token) {
    try {
        return jwt.verify(token, process.env.JWT_SECRET);
    } catch(err) {
        throw new Error('Token无效');
    }
}

server.listen(3000, () => {
    console.log('服务器启动在 http://localhost:3000');
});

3. 弹幕系统

弹幕系统用于增加互动性和氛围感。

弹幕类实现
class Barrage {
    constructor(container) {
        this.container = container;
        this.barrages = [];
        this.maxBarrages = 50; // 最多同时显示50条
    }

    // 发送弹幕
    send(text, avatar) {
        // 限制弹幕数量
        if(this.barrages.length >= this.maxBarrages) {
            this.removeOldest();
        }

        const barrage = this.createBarrage(text, avatar);
        this.container.appendChild(barrage);
        this.barrages.push(barrage);

        // 自动移除
        setTimeout(() => {
            this.remove(barrage);
        }, 5000);
    }

    // 创建弹幕元素
    createBarrage(text, avatar) {
        const div = document.createElement('div');
        div.className = 'barrage-item';

        // 随机高度
        const top = Math.random() * 80 + 10; // 10% - 90%

        div.style.top = `${top}%`;
        div.innerHTML = `
            <img src="${avatar}" class="barrage-avatar">
            <span class="barrage-text">${this.escapeHtml(text)}</span>
        `;

        return div;
    }

    // 移除弹幕
    remove(barrage) {
        if(barrage && barrage.parentNode) {
            barrage.parentNode.removeChild(barrage);
            const index = this.barrages.indexOf(barrage);
            if(index > -1) {
                this.barrages.splice(index, 1);
            }
        }
    }

    // 移除最早的弹幕
    removeOldest() {
        if(this.barrages.length > 0) {
            this.remove(this.barrages[0]);
        }
    }

    // 清空所有弹幕
    clear() {
        this.barrages.forEach(barrage => {
            if(barrage.parentNode) {
                barrage.parentNode.removeChild(barrage);
            }
        });
        this.barrages = [];
    }

    // 转义HTML
    escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }
}

// CSS样式
const style = `
.barrage-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    overflow: hidden;
    z-index: 999;
}

.barrage-item {
    position: absolute;
    right: -100%;
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px;
    background: rgba(0, 0, 0, 0.7);
    border-radius: 20px;
    color: #fff;
    white-space: nowrap;
    animation: barrage-move 8s linear;
}

@keyframes barrage-move {
    from {
        right: -100%;
    }
    to {
        right: 100%;
    }
}

.barrage-avatar {
    width: 30px;
    height: 30px;
    border-radius: 50%;
    object-fit: cover;
}

.barrage-text {
    font-size: 14px;
}
`;

// 使用示例
const barrage = new Barrage(document.querySelector('.barrage-container'));

// 接收弹幕消息
socket.on('barrage', (data) => {
    barrage.send(data.text, data.avatar);
});

// 发送弹幕
function sendBarrage(text) {
    socket.emit('barrage', {
        text: text,
        avatar: currentUser.avatar,
        username: currentUser.name
    });
}

4. Excel 导入导出

使用 SheetJS 实现人员信息的批量导入导出。

导入实现
function handleFileUpload(file) {
    const reader = new FileReader();

    reader.onload = function(e) {
        const data = new Uint8Array(e.target.result);
        const workbook = XLSX.read(data, { type: 'array' });

        // 读取第一个sheet
        const firstSheet = workbook.Sheets[workbook.SheetNames[0]];

        // 转换为JSON
        const jsonData = XLSX.utils.sheet_to_json(firstSheet);

        // 数据处理
        const users = jsonData.map(row => ({
            id: String(row['工号']),
            name: String(row['姓名']),
            department: String(row['部门']),
            level: String(row['级别'] || 'A')
        }));

        // 上传到服务器
        uploadUsers(users);
    };

    reader.readAsArrayBuffer(file);
}

// 上传用户数据
async function uploadUsers(users) {
    try {
        const response = await fetch('/api/lottery/users/import', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'x-token': getToken()
            },
            body: JSON.stringify({ users })
        });

        const result = await response.json();

        if(result.code === 200) {
            showMessage('导入成功', 'success');
            refreshUserList();
        } else {
            showMessage(result.message, 'error');
        }
    } catch(err) {
        showMessage('导入失败: ' + err.message, 'error');
    }
}

// HTML
<input type="file" id="fileInput" accept=".xlsx,.xls" onchange="handleFileUpload(this.files[0])">
导出实现
function exportWinners(prizeType) {
    // 1. 获取中奖数据
    fetch(`/api/lottery/winners?type=${prizeType}`, {
        headers: { 'x-token': getToken() }
    })
    .then(res => res.json())
    .then(data => {
        if(data.code !== 200) {
            throw new Error(data.message);
        }

        // 2. 转换数据格式
        const exportData = data.winners.map(winner => ({
            '工号': winner.id,
            '姓名': winner.name,
            '部门': winner.department,
            '奖项': winner.prizeName,
            '中奖时间': winner.winTime
        }));

        // 3. 创建工作簿
        const ws = XLSX.utils.json_to_sheet(exportData);
        const wb = XLSX.utils.book_new();
        XLSX.utils.book_append_sheet(wb, ws, '中奖名单');

        // 4. 设置列宽
        ws['!cols'] = [
            { wch: 15 }, // 工号
            { wch: 10 }, // 姓名
            { wch: 20 }, // 部门
            { wch: 15 }, // 奖项
            { wch: 20 }  // 中奖时间
        ];

        // 5. 下载文件
        XLSX.writeFile(wb, `中奖名单_${prizeType}_${Date.now()}.xlsx`);

        showMessage('导出成功', 'success');
    })
    .catch(err => {
        showMessage('导出失败: ' + err.message, 'error');
    });
}

🔐 防作弊机制

1. Token 鉴权

// 前端:每次请求携带Token
function request(url, options = {}) {
    return fetch(url, {
        ...options,
        headers: {
            ...options.headers,
            'x-token': sessionStorage.getItem('x-token')
        }
    });
}

// 后端:中间件验证Token
function authMiddleware(req, res, next) {
    const token = req.headers['x-token'];

    if(!token) {
        return res.json({ code: 401, message: '未登录' });
    }

    try {
        const user = jwt.verify(token, process.env.JWT_SECRET);
        req.user = user;
        next();
    } catch(err) {
        return res.json({ code: 401, message: 'Token无效' });
    }
}

2. 角色权限控制

// 权限检查中间件
function requireAdmin(req, res, next) {
    if(req.user.role !== 'admin') {
        return res.json({ code: 403, message: '权限不足' });
    }
    next();
}

// 使用
app.post('/api/lottery/start', authMiddleware, requireAdmin, async (req, res) => {
    // 只有管理员可以发起抽奖
    // ...
});

3. 防重复抽奖

// 使用 Redis 实现分布式锁
const Redis = require('ioredis');
const redis = new Redis();

async function performLottery(prizeType) {
    const lockKey = `lottery:lock:${prizeType}`;
    const lockValue = Date.now().toString();

    // 尝试获取锁(60秒超时)
    const acquired = await redis.set(lockKey, lockValue, 'EX', 60, 'NX');

    if(!acquired) {
        throw new Error('抽奖正在进行中,请勿重复操作');
    }

    try {
        // 执行抽奖
        const winners = await doLottery(prizeType);
        return winners;
    } finally {
        // 释放锁
        const currentValue = await redis.get(lockKey);
        if(currentValue === lockValue) {
            await redis.del(lockKey);
        }
    }
}

4. 抽奖间隔限制

// 前端限制
let lastLotteryTime = 0;
const LOTTERY_INTERVAL = 60000; // 60秒

$('.btn-start').click(() => {
    const now = Date.now();

    if(now - lastLotteryTime < LOTTERY_INTERVAL) {
        const remaining = Math.ceil((LOTTERY_INTERVAL - (now - lastLotteryTime)) / 1000);
        showMessage(`请等待 ${remaining} 秒后再试`, 'warning');
        return;
    }

    lastLotteryTime = now;
    startLottery();
});

// 后端限制
const lotteryHistory = new Map();

app.post('/api/lottery/start', authMiddleware, requireAdmin, async (req, res) => {
    const lastTime = lotteryHistory.get(req.user.id);

    if(lastTime && Date.now() - lastTime < 60000) {
        return res.json({
            code: 429,
            message: '操作过于频繁,请稍后再试'
        });
    }

    lotteryHistory.set(req.user.id, Date.now());

    // 执行抽奖
    // ...
});

⚡ 性能优化

1. 3D 渲染优化

// 使用 DocumentFragment 批量插入 DOM
function renderCubes(users) {
    const fragment = document.createDocumentFragment();

    users.forEach(user => {
        const cube = createCube(user);
        fragment.appendChild(cube);
    });

    container.appendChild(fragment);
}

// 使用 will-change 提示浏览器优化
.cube {
    will-change: transform;
}

// 使用 transform 代替 position 动画
// Bad
.cube {
    animation: move 1s linear;
}
@keyframes move {
    from { left: 0; }
    to { left: 100px; }
}

// Good
.cube {
    animation: move 1s linear;
}
@keyframes move {
    from { transform: translateX(0); }
    to { transform: translateX(100px); }
}

2. 图片懒加载

// 使用 Intersection Observer
const imageObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if(entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            imageObserver.unobserve(img);
        }
    });
});

document.querySelectorAll('img[data-src]').forEach(img => {
    imageObserver.observe(img);
});

// HTML
<img data-src="avatar.jpg" alt="头像">

3. 防抖与节流

// 防抖:搜索输入
function debounce(func, delay) {
    let timer;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => func.apply(this, args), delay);
    };
}

const searchInput = debounce((keyword) => {
    searchUsers(keyword);
}, 300);

// 节流:滚动事件
function throttle(func, limit) {
    let inThrottle;
    return function(...args) {
        if(!inThrottle) {
            func.apply(this, args);
            inThrottle = true;
            setTimeout(() => inThrottle = false, limit);
        }
    };
}

window.addEventListener('scroll', throttle(() => {
    // 处理滚动
}, 100));

📦 完整项目结构

lottery-system/
├── frontend/                 # 前端代码
│   ├── index.html           # 主页面
│   ├── login.html           # 登录页
│   ├── mobile.html          # 移动端
│   ├── css/
│   │   ├── style.css        # 主样式
│   │   ├── animate.css      # 动画库
│   │   └── swiper.css       # 轮播样式
│   ├── js/
│   │   ├── lucky.js         # 抽奖逻辑
│   │   ├── 3d.js            # 3D转盘
│   │   ├── char.js          # 弹幕系统
│   │   ├── config.js        # 配置文件
│   │   └── audio.js         # 音频控制
│   └── img/                 # 图片资源
│
├── backend/                  # 后端代码
│   ├── src/
│   │   ├── routes/          # 路由
│   │   │   ├── auth.js      # 认证
│   │   │   ├── lottery.js   # 抽奖
│   │   │   └── user.js      # 用户管理
│   │   ├── services/        # 业务逻辑
│   │   │   ├── lottery.service.js
│   │   │   └── user.service.js
│   │   ├── models/          # 数据模型
│   │   │   ├── User.js
│   │   │   ├── Prize.js
│   │   │   └── Winner.js
│   │   ├── middleware/      # 中间件
│   │   │   ├── auth.js
│   │   │   └── error.js
│   │   ├── utils/           # 工具函数
│   │   │   └── jwt.js
│   │   ├── socket.js        # WebSocket处理
│   │   └── app.js           # 应用入口
│   ├── package.json
│   └── .env.example
│
├── database/                 # 数据库
│   ├── migrations/          # 迁移文件
│   └── seeds/               # 种子数据
│
├── docker-compose.yml       # Docker配置
├── README.md
└── .gitignore

🚀 部署方案

Docker 部署

# docker-compose.yml
version: '3.8'

services:
  # 后端服务
  backend:
    build: ./backend
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - MONGODB_URI=mongodb://mongo:27017/lottery
      - JWT_SECRET=${JWT_SECRET}
      - REDIS_URL=redis://redis:6379
    depends_on:
      - mongo
      - redis
    restart: unless-stopped

  # 前端服务(Nginx)
  frontend:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./frontend:/usr/share/nginx/html
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - backend
    restart: unless-stopped

  # MongoDB
  mongo:
    image: mongo:5
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db
    restart: unless-stopped

  # Redis
  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    restart: unless-stopped

volumes:
  mongo-data:
  redis-data:

Nginx 配置

# nginx.conf
server {
    listen 80;
    server_name lottery.example.com;

    # 前端静态文件
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }

    # API代理
    location /api/ {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # WebSocket代理
    location /lottery-socket/ {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host $host;
    }
}

📊 数据库设计

MongoDB Schema

// User Schema - 用户表
const UserSchema = new Schema({
    id: { type: String, required: true, unique: true },
    name: { type: String, required: true },
    department: { type: String, required: true },
    level: { type: String, enum: ['A', 'B', 'C', 'D'], default: 'A' },
    avatar: { type: String },
    hasWon: { type: Boolean, default: false },
    createdAt: { type: Date, default: Date.now }
});

// Prize Schema - 奖品表
const PrizeSchema = new Schema({
    type: { type: Number, required: true, unique: true }, // 1-6
    name: { type: String, required: true },
    total: { type: Number, required: true },
    remaining: { type: Number, required: true },
    countPerDraw: { type: Number, required: true },
    image: { type: String },
    createdAt: { type: Date, default: Date.now }
});

// Winner Schema - 中奖记录表
const WinnerSchema = new Schema({
    userId: { type: String, required: true },
    userName: { type: String, required: true },
    department: { type: String },
    prizeType: { type: Number, required: true },
    prizeName: { type: String, required: true },
    winTime: { type: Date, default: Date.now }
});

// 创建索引
WinnerSchema.index({ userId: 1, prizeType: 1 });
WinnerSchema.index({ winTime: -1 });

🎨 完整示例代码

简化版完整实现

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>年会抽奖系统</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .lottery-container {
            text-align: center;
            color: white;
        }

        .lottery-title {
            font-size: 48px;
            margin-bottom: 50px;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
        }

        .lottery-display {
            background: rgba(255,255,255,0.2);
            backdrop-filter: blur(10px);
            border-radius: 20px;
            padding: 50px;
            min-width: 400px;
            min-height: 300px;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            margin-bottom: 30px;
        }

        .current-name {
            font-size: 72px;
            font-weight: bold;
            margin-bottom: 20px;
            animation: pulse 0.5s ease-in-out infinite;
        }

        @keyframes pulse {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(1.1); }
        }

        .winner-info {
            font-size: 24px;
            opacity: 0.9;
        }

        .lottery-btn {
            background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
            color: white;
            border: none;
            padding: 20px 60px;
            font-size: 24px;
            border-radius: 50px;
            cursor: pointer;
            transition: all 0.3s;
            box-shadow: 0 5px 15px rgba(0,0,0,0.3);
        }

        .lottery-btn:hover {
            transform: translateY(-3px);
            box-shadow: 0 8px 20px rgba(0,0,0,0.4);
        }

        .lottery-btn:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }

        .winners-list {
            position: fixed;
            right: 20px;
            top: 20px;
            background: rgba(255,255,255,0.9);
            border-radius: 10px;
            padding: 20px;
            max-width: 300px;
            max-height: 80vh;
            overflow-y: auto;
        }

        .winners-list h3 {
            color: #333;
            margin-bottom: 15px;
        }

        .winner-item {
            color: #666;
            padding: 10px;
            border-bottom: 1px solid #eee;
        }
    </style>
</head>
<body>
    <div class="lottery-container">
        <h1 class="lottery-title">🎊 年会抽奖</h1>

        <div class="lottery-display">
            <div class="current-name" id="currentName">点击开始</div>
            <div class="winner-info" id="winnerInfo"></div>
        </div>

        <button class="lottery-btn" id="lotteryBtn" onclick="toggleLottery()">
            开始抽奖
        </button>
    </div>

    <div class="winners-list">
        <h3>🏆 中奖名单</h3>
        <div id="winnersList"></div>
    </div>

    <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
    <script>
        // 模拟人员数据
        const users = [
            { id: '001', name: '张三', dept: '技术部' },
            { id: '002', name: '李四', dept: '产品部' },
            { id: '003', name: '王五', dept: '运营部' },
            { id: '004', name: '赵六', dept: '市场部' },
            { id: '005', name: '钱七', dept: '设计部' },
            { id: '006', name: '孙八', dept: '人事部' },
            { id: '007', name: '周九', dept: '财务部' },
            { id: '008', name: '吴十', dept: '行政部' },
        ];

        let isRunning = false;
        let currentInterval = null;
        let winners = [];

        const currentNameEl = document.getElementById('currentName');
        const winnerInfoEl = document.getElementById('winnerInfo');
        const lotteryBtn = document.getElementById('lotteryBtn');
        const winnersListEl = document.getElementById('winnersList');

        // 切换抽奖状态
        function toggleLottery() {
            if(isRunning) {
                stopLottery();
            } else {
                startLottery();
            }
        }

        // 开始抽奖
        function startLottery() {
            isRunning = true;
            lotteryBtn.textContent = '停止';
            winnerInfoEl.textContent = '';

            // 快速切换名字
            currentInterval = setInterval(() => {
                const randomUser = users[Math.floor(Math.random() * users.length)];
                currentNameEl.textContent = randomUser.name;
            }, 50);
        }

        // 停止抽奖
        function stopLottery() {
            isRunning = false;
            lotteryBtn.textContent = '继续抽奖';
            clearInterval(currentInterval);

            // 最终随机选择
            const winner = users[Math.floor(Math.random() * users.length)];
            currentNameEl.textContent = winner.name;
            winnerInfoEl.textContent = `${winner.dept} - ${winner.id}`;

            // 添加到中奖列表
            addWinner(winner);

            // 播放音效(如果有)
            playWinSound();
        }

        // 添加中奖者
        function addWinner(winner) {
            winners.unshift(winner);

            const winnerItem = document.createElement('div');
            winnerItem.className = 'winner-item';
            winnerItem.textContent = `${winner.name} - ${winner.dept}`;

            winnersListEl.insertBefore(winnerItem, winnersListEl.firstChild);
        }

        // 播放音效
        function playWinSound() {
            // 可以添加音效
            // const audio = new Audio('win.mp3');
            // audio.play();
        }

        // WebSocket集成(可选)
        // const socket = io('https://your-api.com');
        // socket.on('lottery:result', (data) => {
        //     currentNameEl.textContent = data.winner.name;
        //     addWinner(data.winner);
        // });
    </script>
</body>
</html>

📚 参考资源

开源项目

技术文档


🎭 番外篇:如何判断抽奖是否公平?

经过这次研究,我总结了几个实用的方法,帮助大家判断年会抽奖是否公平:

方法1:开发者工具抓包分析(技术流)

// 步骤1:打开开发者工具(F12)
// 步骤2:切换到 Network → WS 标签
// 步骤3:观察抽奖时的 WebSocket 消息

// 正常情况应该看到:
{
  "mod": "winning",
  "args": {
    "code": 200,
    "msg": "[\"工号|姓名|部门\", ...]"  // 中奖名单
  }
}

// 🚩 危险信号:
// 1. 消息在点击"开始"之前就收到了(提前确定结果)
// 2. 消息包含 "preset" 或 "fixed" 等关键词
// 3. 多次抽奖发现同一批人总是中奖

方法2:统计分析(数据流)

收集多次抽奖的数据,进行简单的统计分析:

// 收集数据
const data = {
    employees: 760,      // 总人数
    prizes: 760,         // 总奖品数
    rounds: 6,           // 抽奖轮次

    winners: [
        { dept: '技术部', count: 125, total: 120 },
        { dept: '产品部', count: 78, total: 80 },
        { dept: '市场部', count: 102, total: 100 },
        // ...
    ]
};

// 计算期望值
winners.forEach(dept => {
    dept.expected = (dept.total / data.employees) * data.prizes;
    dept.deviation = Math.abs(dept.count - dept.expected);
    dept.ratio = dept.deviation / dept.expected;
});

// 判断标准:
// ✅ 如果各部门偏差率 < 10%,基本公平
// ⚠️ 如果某些部门偏差率 > 20%,有问题
// 🚩 如果管理层中奖率明显高于平均,需警惕

方法3:观察法(非技术流)

即使不懂技术,也可以通过以下细节判断:

✅ 公平的迹象:

  • 转盘速度自然减缓,没有明显停顿
  • 中奖名单覆盖各个部门
  • 新员工和老员工都有机会
  • 每轮抽奖间隔合理(给大家反应时间)
  • 现场气氛热烈,大家积极互动

🚩 可疑的迹象:

  • 转盘突然加速/减速/停顿
  • 管理层或特定部门中奖率特别高
  • 刚离职的员工还能"中奖"
  • 某些人连续多轮中奖
  • 主持人在结果出来前就准备好了祝贺词

方法4:代码审查(终极方案)

如果你是技术负责人,可以要求:

// 查看后端抽奖算法源码
async function performLottery(prizeType) {
    // 🔍 检查点1:人员池是否完整?
    const users = await getAvailableUsers();
    console.log('可抽奖人数:', users.length);

    // 🔍 检查点2:是否使用真随机?
    const winners = users
        .sort(() => Math.random() - 0.5)  // ✅ 随机排序
        .slice(0, count);                  // ✅ 取前N个

    // 🚩 危险代码示例:
    // const winners = predefinedList[round];  // ❌ 预设名单
    // const winners = users.filter(u => u.level === 'VIP');  // ❌ 特权

    return winners;
}

// ✅ 推荐:使用密码学安全的随机数
const crypto = require('crypto');
function secureRandom(max) {
    return crypto.randomInt(0, max);
}

💬 给 HR 和技术同学的建议

给 HR 的建议:

  1. 提前公示规则

    • 明确告知抽奖算法(如"使用系统随机数生成器")
    • 说明中奖概率和奖品分配
    • 承诺抽奖过程公开透明
  2. 现场保证公平

    • 邀请员工代表监督
    • 全程录屏(可选)
    • 当场公布完整中奖名单
  3. 技术选型

    • 选择开源的抽奖系统(代码可审查)
    • 使用第三方公证服务
    • 保留抽奖日志供事后查询

给技术同学的建议:

  1. 实现时注意

    // ✅ 好的实现
    - 使用 crypto.randomBytes() 生成随机数
    - 后端控制抽奖逻辑
    - WebSocket 广播确保一致性
    - 记录详细日志
    
    // ❌ 不好的实现
    - 前端 Math.random()(可预测)
    - 预设中奖名单
    - 允许手动干预
    
  2. 防止被质疑

    // 添加抽奖日志
    await db.lotteryLogs.insert({
        timestamp: Date.now(),
        prizeType: prizeType,
        totalUsers: availableUsers.length,
        randomSeed: randomSeed,  // 随机种子
        winners: winners,
        operator: req.user.id
    });
    
    // 提供验证接口
    app.get('/api/lottery/verify/:logId', (req, res) => {
        // 允许员工验证某次抽奖的真实性
    });
    
  3. 开源方案

    • 推荐使用成熟的开源项目
    • 提交代码到 GitHub 公开审查
    • 让员工参与代码 Review

🎯 最后的真相

经过这次深入研究,我的结论是:

技术上:这套抽奖系统设计合理,没有发现明显的作弊代码。

统计上:多次抽奖数据符合随机分布,各部门中奖比例基本均衡。

但是:由于抽奖逻辑在后端,理论上管理员可以干预结果,这是架构层面无法避免的问题。

解决方案

  1. 使用区块链智能合约实现抽奖(完全去中心化)
  2. 采用可验证随机函数(VRF)生成随机数
  3. 引入第三方公证机构监督抽奖过程

💭 后记

同事A:"所以你研究了两天,结论是系统没问题?"
我:"是的,从技术角度看是公平的。"
同事B:"那为什么我还是没中奖?"
我:"因为概率本来就不高啊!760 人抽 10 个 iPhone,中奖率才 1.3%..."
同事A:"好吧... 那明年的抽奖系统就交给你了!"
我:"没问题!我保证用区块链实现,绝对公平!"
同事们:"你这是想害我们读不懂代码是吧?😂"

虽然没中奖很遗憾,但作为程序员,我收获了:

  • ✅ 一套完整的抽奖系统实现方案
  • ✅ WebSocket 实时通信的实战经验
  • ✅ 3D 转盘效果的 CSS 技巧
  • ✅ 一篇技术博客的素材

说到底,技术人的快乐就是这么朴实无华 😎


💡 总结

本文详细讲解了年会抽奖系统的完整实现方案,涵盖了以下核心内容:

技术要点

  • ✅ 3D转盘效果的CSS实现
  • ✅ WebSocket实时通信机制
  • ✅ 弹幕系统的设计与优化
  • ✅ Excel导入导出功能
  • ✅ 防作弊机制设计
  • ✅ 性能优化方案

架构特点

  • 前后端分离,易于部署
  • WebSocket确保多端同步
  • 后端控制抽奖逻辑,防止作弊
  • 模块化设计,易于扩展

适用场景

  • 公司年会抽奖
  • 活动现场抽奖
  • 直播间抽奖
  • 营销活动抽奖

希望这篇文章能帮助你理解年会抽奖系统的实现原理,并为你的项目提供参考。

如果你也在年会没中奖,不如趁这个机会学习一下技术实现,明年自己搞一个! 🚀

有任何问题欢迎在评论区讨论,也欢迎分享你们公司年会的有趣故事!


🔖 标签

#前端 #WebSocket #3D效果 #抽奖系统 #实时通信 #年会 #Socket.IO


如果觉得本文有帮助,请给个👍点赞支持一下!

【React-10/Lesson94(2026-01-04)】React 性能优化专题:useMemo & useCallback 深度解析🚀

作者 Jing_Rainbow
2026年2月13日 19:56

📚 React 性能优化的重要性

在 React 应用开发中,性能优化是一个不可忽视的重要话题。随着应用规模的不断增长,组件的渲染效率直接影响着用户体验。React 提供了多种性能优化方案,其中 useMemouseCallback 是两个最常用的 Hook,它们能够帮助我们避免不必要的重复计算和渲染,从而提升应用的性能表现。

🔍 includes 方法详解

在深入性能优化之前,我们先来了解一下 includes 方法,这是 JavaScript 字符串和数组中常用的方法。

字符串 includes 方法

"apple".includes("") === true  // 空字符串也为 true
"apple".includes("app") === true  // 包含子字符串也为 true
"apple".includes("ab") === false  // 不包含子字符串为 false

includes() 方法用于判断一个字符串是否包含另一个字符串,返回布尔值。需要注意的是:

  • 空字符串 "" 总是被认为包含在任何字符串中,所以 "apple".includes("") 返回 true
  • 该方法区分大小写,"Apple".includes("apple") 返回 false
  • 支持第二个参数,表示从哪个位置开始搜索

数组 includes 方法

[1, 2, 3].includes(2) === true
[1, 2, 3].includes(4) === false
['apple', 'banana'].includes('apple') === true

数组版本的 includes() 方法用于判断数组中是否包含某个元素,同样返回布尔值。

⚠️ React 中的性能陷阱

在 React 组件中,我们需要特别注意避免在 render 过程中进行复杂的计算。让我们看一个典型的例子:

const [count, setCount] = useState(0);
const [keyword, setKeyword] = useState('');
const list = ['apple', 'banana', 'orange', 'pear'];

const filterList = list.filter(item => item.includes(keyword));

在这个例子中,当 count 改变时,整个组件会重新渲染,filterList 也会重新执行,即使 keyword 并没有发生变化。这就是一个典型的性能问题。

问题根源

React 组件的状态更新会触发组件的重新渲染,这意味着:

setCount(count + 1);  // filterList 会重新执行,即使它与 count 无关

每次组件重新渲染时,所有的计算逻辑都会重新执行,包括那些与状态变化无关的计算。当计算逻辑比较复杂时,这就会造成明显的性能问题。

💡 useMemo:缓存计算结果

useMemo 是 React 提供的一个 Hook,用于缓存计算结果,避免在每次渲染时都进行重复计算。

useMemo 的基本语法

const memoizedValue = useMemo(() => {
  return computeExpensiveValue(a, b);
}, [a, b]);
  • 第一个参数:一个函数,返回需要缓存的值
  • 第二个参数:依赖数组,只有当数组中的依赖项发生变化时,才会重新计算

昂贵计算的优化示例

function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  for (let i = 0; i < n * 10000000; i++) {
    sum += i;
  }
  return sum;
}

const [num, setNum] = useState(0);
const result = useMemo(() => {
  return slowSum(num);
}, [num]);

在这个例子中,slowSum 是一个计算密集型的函数。使用 useMemo 缓存其结果后,只有当 num 发生变化时才会重新计算,避免了不必要的重复计算。

列表过滤的优化

const [keyword, setKeyword] = useState('');
const list = ['apple', 'banana', 'orange', 'pear'];

const filterList = useMemo(() => {
  return list.filter(item => item.includes(keyword));
}, [keyword]);

通过 useMemo 缓存 filterList,只有当 keyword 发生变化时才会重新执行过滤操作,其他状态的变化不会触发这个计算。

useMemo 与 Vue computed 的对比

React 的 useMemo 与 Vue 的 computed 计算属性功能类似:

  • 都是用于缓存计算结果
  • 都基于依赖项进行缓存更新
  • 都能避免不必要的重复计算

但也有一些区别:

  • useMemo 需要显式指定依赖数组
  • computed 会自动追踪依赖
  • useMemo 的粒度更细,可以缓存任意值

🎯 useCallback:缓存函数引用

useCallback 是另一个重要的性能优化 Hook,它用于缓存函数引用,避免在每次渲染时都创建新的函数实例。

useCallback 的基本语法

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);
  • 第一个参数:需要缓存的回调函数
  • 第二个参数:依赖数组,只有当依赖项发生变化时,才会返回新的函数引用

为什么需要 useCallback

在 React 中,函数作为 props 传递给子组件时,每次父组件渲染都会创建新的函数实例:

const handleClick = () => {
  console.log('click');
};

<Child count={count} handleClick={handleClick} />

即使 handleClick 的逻辑没有变化,每次渲染都会创建一个新的函数引用,这会导致子组件即使使用了 memo 也会重新渲染。

useCallback 的使用示例

const handleClick = useCallback(() => {
  console.log('click');
}, [count]);  // 依赖项 count 变化时,才会重新创建 handleClick 函数

通过 useCallback 缓存函数引用,只有当依赖项发生变化时才会创建新的函数实例,避免了不必要的子组件重新渲染。

🏗️ memo 高阶组件

memo 是 React 提供的一个高阶组件,用于优化函数组件的性能,避免不必要的重新渲染。

memo 的基本用法

const Child = memo(({count, handleClick}) => {
  console.log('child 重新渲染');
  return (
    <div onClick={handleClick}>
      子组件{count}
    </div>
  );
});

memo 的工作原理

memo 会对组件的 props 进行浅比较:

  • 如果 props 没有变化,组件不会重新渲染
  • 如果 props 发生变化,组件会重新渲染

memo 与 useCallback 的配合使用

const Child = memo(({count, handleClick}) => {
  console.log('child 重新渲染');
  return (
    <div onClick={handleClick}>
      子组件{count}
    </div>
  );
});

const handleClick = useCallback(() => {
  console.log('click');
}, [count]);

只有当 count 发生变化时,handleClick 才会重新创建,Child 组件才会重新渲染。

🔄 React 数据流管理思想

React 的数据流管理思想强调:

  • 父组件负责持有数据和管理数据
  • 子组件负责根据数据渲染 UI

这种单向数据流的设计使得状态管理更加清晰和可预测。当某一个数据改变时,我们只想让相关的子组件重新渲染,而不是所有子组件。

状态管理的最佳实践

export default function App() {
  const [count, setCount] = useState(0);  // 响应式业务1——计数
  const [num, setNum] = useState(0);  // 响应式业务2——计数
  
  const handleClick = useCallback(() => {
    console.log('click');
  }, [count]);

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>count+1</button>
      {num}
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  );
}

在这个例子中,countnum 是两个独立的状态,各自负责各自的业务逻辑。通过 useCallback 缓存函数,我们可以精确控制子组件的渲染时机。

📊 高阶组件与高阶函数

理解高阶组件和高阶函数的概念,有助于我们更好地掌握 React 的性能优化技巧。

高阶组件(HOC)

高阶组件是一个函数,它的参数是一个组件,返回值是一个新的组件:

function withLoading(WrappedComponent) {
  return function(props) {
    if (props.isLoading) {
      return <div>Loading...</div>;
    }
    return <WrappedComponent {...props} />;
  };
}

const EnhancedComponent = withLoading(MyComponent);

高阶函数

高阶函数是一个函数,它的参数是一个函数,返回值是一个新的函数:

function withLogger(fn) {
  return function(...args) {
    console.log('Calling function with args:', args);
    return fn(...args);
  };
}

const enhancedFn = withLogger(myFunction);

对比记忆

  • 高阶组件:参数是组件,返回新组件
  • 高阶函数:参数是函数,返回新函数

memo 就是一个典型的高阶组件,它接收一个组件作为参数,返回一个优化后的组件。

🎯 性能优化的最佳实践

何时使用 useMemo

  1. 昂贵的计算:当计算逻辑比较复杂时,使用 useMemo 缓存结果
  2. 依赖其他状态的计算:当计算结果依赖于某些状态时,使用 useMemo 避免不必要的重新计算
  3. 引用类型的依赖:当计算结果作为其他 Hook 的依赖时,使用 useMemo 保持引用稳定

何时使用 useCallback

  1. 传递给子组件的回调函数:当函数作为 props 传递给子组件时,使用 useCallback 缓存函数引用
  2. 作为其他 Hook 的依赖:当函数作为其他 Hook 的依赖时,使用 useCallback 保持引用稳定
  3. 事件处理函数:当函数作为事件处理函数时,使用 useCallback 避免重复创建

何时使用 memo

  1. 纯展示组件:当组件主要功能是展示数据,不涉及复杂逻辑时,使用 memo 优化性能
  2. 频繁渲染的父组件:当父组件频繁渲染,但子组件的 props 变化不频繁时,使用 memo 避免不必要的重新渲染
  3. 大型列表项:当渲染大型列表时,对列表项使用 memo 可以显著提升性能

⚡ 性能优化的注意事项

避免过度优化

虽然性能优化很重要,但也要避免过度优化:

  • 不要对所有组件都使用 memo
  • 不要对所有计算都使用 useMemo
  • 不要对所有函数都使用 useCallback

只有在确实存在性能问题时,才应该进行优化。

依赖数组的正确使用

使用 useMemouseCallback 时,要正确设置依赖数组:

// 错误示例:遗漏依赖
const handleClick = useCallback(() => {
  console.log(count);
}, []);  // 应该包含 count

// 正确示例:包含所有依赖
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

使用 ESLint 插件

推荐使用 eslint-plugin-react-hooks 插件,它会自动检查依赖数组的正确性:

{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

🔧 实际应用场景

场景1:复杂的数据处理

const processedData = useMemo(() => {
  return rawData
    .filter(item => item.active)
    .map(item => ({
      ...item,
      formattedDate: formatDate(item.date),
      calculatedValue: complexCalculation(item.value)
    }))
    .sort((a, b) => b.calculatedValue - a.calculatedValue);
}, [rawData]);

场景2:表单验证

const validateForm = useCallback((values) => {
  const errors = {};
  
  if (!values.email) {
    errors.email = 'Email is required';
  } else if (!isValidEmail(values.email)) {
    errors.email = 'Invalid email format';
  }
  
  if (!values.password) {
    errors.password = 'Password is required';
  } else if (values.password.length < 8) {
    errors.password = 'Password must be at least 8 characters';
  }
  
  return errors;
}, []);

const errors = useMemo(() => validateForm(formData), [formData, validateForm]);

场景3:API 请求

const fetchData = useCallback(async (params) => {
  try {
    const response = await api.get('/data', { params });
    setData(response.data);
  } catch (error) {
    console.error('Error fetching data:', error);
  }
}, []);

useEffect(() => {
  fetchData({ page: currentPage });
}, [currentPage, fetchData]);

📈 性能优化效果评估

使用 React DevTools Profiler

React DevTools Profiler 可以帮助我们分析组件的渲染性能:

  1. 打开 React DevTools
  2. 切换到 Profiler 标签
  3. 点击录制按钮
  4. 进行用户操作
  5. 停止录制并分析结果

性能指标

关注以下性能指标:

  • 渲染时间:组件每次渲染所花费的时间
  • 渲染次数:组件在一段时间内渲染的次数
  • 内存使用:应用的内存占用情况

🎓 总结

React 的性能优化是一个持续的过程,需要根据具体的应用场景选择合适的优化策略:

  • useMemo:缓存计算结果,避免重复计算
  • useCallback:缓存函数引用,避免不必要的子组件渲染
  • memo:优化组件渲染,避免不必要的重新渲染

通过合理使用这些工具,我们可以显著提升 React 应用的性能,提供更好的用户体验。记住,性能优化应该在确实存在性能问题时进行,避免过度优化带来的复杂性。

掌握这些性能优化技巧,将帮助你在开发大型 React 应用时游刃有余,构建出高性能、高质量的应用程序。

OpenSpec 和 Spec-Kit 踩了 27 个坑之后,于是我写了个 🔥SuperSpec🔥 一次性填平

作者 像颗糖
2026年2月13日 19:21

SuperSpec

npm version npm downloads license node version

AI 编码助手的规格驱动开发 (SDD) 工具。

在线文档 · 仓库地址

为什么需要 SuperSpec?

AI 编码助手很强大,但需求模糊时容易产出不一致、无文档的代码。

当前支持的 AI 助手: CursorClaude CodeQwen 通义OpenCodeCodexCodeBuddyQoder。任何能读取 AGENTS.md 的编辑器均可使用本工作流。使用 superspec init --ai cursor|claude|qwen|opencode|codex|codebuddy|qoder 可安装对应编辑器的规则与斜杠命令(默认:cursor)。

OpenSpec 痛点

# OpenSpec 痛点 SuperSpec 解决方案
1 无 spec 大小控制 — spec 无限膨胀,吞噬 AI 上下文窗口 第一性原理 + lint(目标 300 / 硬限 400 行),/ss-specs 自动拆分
2 验证不一致 — validate --strict 通过但 archive 失败 统一验证管线:lintvalidatechecklistarchive
3 无实现↔spec 校验 — 编码后 spec 漂移 sync 收集 git diff 到 context.md/ss-resume 与 spec 交叉比对
4 无 上下文恢复流程 — 切换 AI 对话后上下文丢失 sync + context.md + /ss-resume 在新会话中恢复完整 spec 上下文
5 无 spec 间依赖管理 depends_on frontmatter + deps add/deps list/deps remove
6 无跨 spec 和归档搜索 search 支持 --archived--artifact--regex 过滤
7 无进度追踪或状态可视化 status 展示所有变更及各 artifact 状态(Draft → Ready → Done)
8 单一模式 — 简单修复和大功能同等开销 标准模式(轻量)vs 增强模式(完整 US/FR/AC + checklist)
9 无项目级 AI 上下文规则配置 superspec.config.json 支持 strategycontextlimitsbranchTemplate
10 无交叉引用验证(US↔FR↔AC↔tasks) validate --check-deps 确保完整追溯链
11 无国际化 — 仅英文 --lang zh|en,完整中文模板 + CLI 提示
12 无任务粒度控制 增强模式:每个任务 < 1 小时,分阶段 + 并行标记 [P]
13 无法自动创建分支 — 变更分支命名不统一 superspec create 根据 branchTemplate 自动创建 git 分支,支持 branchPrefix / branchTemplate / changeNameTemplate 自定义

Spec-Kit 痛点

# Spec-Kit 痛点 SuperSpec 解决方案
1 命令占用大量 token,严重挤占上下文窗口 Slash 命令为文件模板,按需加载(零空闲开销)
2 制造"工作幻觉" — 生成大量无用文档 第一性原理:每句话必须提供决策信息,信噪比优先
3 无法更新/迭代已有 spec — 总是创建新分支 原地 spec 演进:直接编辑 proposal/spec/tasks,/ss-clarify 迭代
4 忽略现有项目结构和约定 strategy: follow 读取 context 文件作为约束,匹配现有模式
5 自动生成大量无用测试 不自动生成测试 — 任务验证由开发者控制
6 不适合增量开发 / 小任务 标准模式处理快速功能;仅需要时启用增强模式(-b
7 Python 安装(uv tool)— 与 JS/TS 生态不匹配 npm/pnpm/yarn 安装,原生 Node.js 生态
8 无 spec 间依赖管理 depends_on + deps add/deps list + 依赖图
9 无 上下文恢复流程 synccontext.md/ss-resume 无缝续接
10 在子目录初始化会失败 任意位置可用 — superspec.config.json 在项目根目录,specDir 可配置
11 无 spec 归档及上下文保留 archive 归档已完成变更,search --archived 仍可检索
12 与最新 AI 工具升级不兼容 编辑器无关的 AGENTS.md + --ai 标志安装对应编辑器规则
13 模式单一且配置僵硬 — 无法在轻量与增强模式间自由切换 SuperSpec 支持标准模式与增强模式自由切换,superspec.config.json 中的 booststrategybranchTemplate 等提供高度定制化配置
14 无创造/探索模式 strategy: create-c)允许提出新架构方案并记录权衡

安装

# npm
npm install -g @superspec/cli

# pnpm
pnpm add -g @superspec/cli

# yarn
yarn global add @superspec/cli

需要 Node.js >= 18.0.0

快速开始

cd your-project

superspec init                  # 默认(英文模板)
superspec init --lang zh        # 中文模板
superspec init --ai claude      # 指定 AI 助手类型(cursor|claude|qwen|opencode|codex|codebuddy|qoder)
superspec init --force          # 强制覆盖已有配置
superspec init --no-git         # 跳过 git 初始化

核心流程

标准模式:  create (proposal → checklist ✓) → tasks → apply → [vibe: sync → resume] → archive
增强模式:  create -b (proposal → spec → [自动: 拆分? design?] → checklist ✓) → tasks → apply → ...

标准模式:AI 生成 proposal.md(需求 + 技术方案)→ 自动 checklist(/10)→ tasks.md — 适合简单功能和 bug 修复。

增强模式:AI 生成 proposal.md(需求背景)→ spec.md(US/FR/AC 细节)→ 自动复杂度评估(拆分?design?)→ 自动 checklist(/25)→ tasks.md — 适合大功能、需要设计评审和交叉验证的场景。

Vibe coding 阶段apply 之后,用 sync 收集 git 变更,用 /ss-resume 在新 AI 对话中恢复上下文。

Slash 命令(AI Agent)

这些是你与 AI 助手交互的主要命令,直接在 AI 对话中输入即可:

主流程

命令 标志 功能
/ss-create <feature> -b 增强, -c 创造, -d <desc>, --no-branch, --spec-dir <dir>, --branch-prefix <prefix>, --branch-template <tpl>, --change-name-template <tpl>, --intent-type <type>, --user <user>, --lang <lang> 创建文件夹 + 分支,AI 按需生成 proposal(boost: + spec + design)+ 自动 checklist 质量门
/ss-tasks 从 proposal 生成任务清单
/ss-apply 逐个执行任务
/ss-resume 恢复 spec 上下文(运行 sync → 读取 context.md)
/ss-archive [name] --all 归档已完成的变更

质量与发现

命令 模式 标志 功能
/ss-clarify 通用 澄清歧义、记录决策
/ss-checklist 通用 质量门:标准模式(/10,proposal 后)或增强模式(/25,spec 后)。/ss-create 自动调用
/ss-lint [name] 通用 检查 artifact 大小
/ss-validate [name] 增强 --check-deps 交叉引用一致性检查(US↔FR↔AC↔tasks)
/ss-status 通用 查看所有变更状态
/ss-search <q> 通用 --archived, --artifact <type>, --limit <n>, -E/--regex 全文搜索
/ss-link <name> 通用 --on <other> 添加 spec 依赖
/ss-deps [name] 通用 查看依赖图

使用示例

你:   /ss-create 添加用户认证 @jay
AI:   → 执行 `superspec create addUserAuth --intent-type feature`
      → 生成 proposal.md(含需求 + 技术方案)
      → 自动执行 checklist(/10)→ 通过
      → 提示执行 /ss-tasks

你:   /ss-tasks
AI:   → 读取 proposal.md → 生成分阶段任务

你:   /ss-apply
AI:   → 逐个执行任务,每个完成后标记 ✅

你:   /ss-resume    (新对话 / 中断后继续)
AI:   → 运行 sync → 读取 context.md → 从上次中断处继续

CLI 命令

初始化

superspec init

初始化 SuperSpec 到当前项目。

superspec init                  # 默认(英文模板)
superspec init --lang zh        # 中文模板
superspec init --ai claude      # 指定 AI 助手类型(cursor|claude|qwen|opencode|codex|codebuddy|qoder)
superspec init --force          # 强制覆盖已有配置
superspec init --no-git         # 跳过 git 初始化

核心流程

superspec create <feature>

创建变更文件夹和 git 分支。Artifact 由 AI 通过 /ss-create 按需生成。

superspec create add-dark-mode                              # 标准模式
superspec create add-auth -b                                # 增强模式(spec(支持拆分子 spec )+ design + checklist)
superspec create redesign-ui -c                             # 创造模式(探索新方案)
superspec create new-arch -b -c --no-branch                 # 增强 + 创造 + 不创建分支
superspec create add-auth -d "OAuth2 集成"                   # 附带描述
superspec create add-auth --spec-dir specs                  # 自定义 spec 文件夹
superspec create add-auth --branch-prefix feature/          # 自定义分支前缀
superspec create add-auth --branch-template "{prefix}{date}-{feature}-{user}"    # 自定义分支名模板
superspec create add-auth --change-name-template "{date}-{feature}-{user}"       # 自定义文件夹名模板
superspec create add-auth --intent-type hotfix              # 意图类型(feature|hotfix|bugfix|refactor|chore)
superspec create add-auth --user jay                        # 开发者标识
superspec create add-auth --lang zh                         # SDD 文档语言(en|zh)
superspec archive [name]

归档已完成的变更。

superspec archive add-auth      # 归档指定变更
superspec archive --all         # 归档所有已完成的变更
superspec update

刷新 agent 指令和模板到最新版本。

superspec update

质量与验证

superspec lint [name]

检查 artifact 行数是否超限。

superspec lint add-auth         # 检查指定变更
superspec lint                  # 检查所有活跃变更
superspec validate [name]

交叉验证 artifact 一致性(US↔FR↔AC↔tasks)。

superspec validate add-auth                 # 验证指定变更
superspec validate add-auth --check-deps    # 同时检查依赖一致性
superspec validate                          # 验证所有活跃变更

搜索与发现

superspec search <query>

全文搜索所有变更内容。

superspec search "JWT 认证"                          # 搜索活跃变更
superspec search "登录流程" --archived                # 包含已归档变更
superspec search "refresh token" --artifact tasks    # 按 artifact 类型过滤(proposal|spec|tasks|clarify|checklist)
superspec search "认证" --limit 10                   # 限制结果数量(默认: 50)
superspec search "user\d+" -E                        # 使用正则表达式匹配
superspec status

查看所有活跃变更及其 artifact 状态。

superspec status

依赖管理

superspec deps add <name>
superspec deps add add-auth --on setup-database
superspec deps remove <name>
superspec deps remove add-auth --on setup-database
superspec deps list [name]
superspec deps list add-auth    # 查看指定变更的依赖
superspec deps list             # 查看所有依赖关系

Vibe Coding(SDD 后阶段)

superspec sync [name]

生成/刷新 context.md,包含 git diff 信息(零 AI token — 纯 CLI 操作)。

superspec sync add-auth                 # 同步指定变更
superspec sync add-auth --base develop  # 指定基准分支
superspec sync add-auth --no-git        # 跳过 git diff 收集
superspec sync                          # 同步所有活跃变更

策略:follow vs create

每个变更有 strategy 字段控制 AI 的实现方式:

follow(默认) create-c
读取项目规则 是,作为约束 是,作为参考
架构 必须对齐现有架构 可以提出替代方案
文件结构 匹配现有模式 可以引入新模式
适用场景 常规功能、bug 修复 重构、新模块、UX 创新

superspec.config.json 中配置项目规则文件:

{
  "context": [".cursor/rules/coding-style.mdc", "AGENTS.md", "docs/conventions.md"]
}

第一性原理

灵感来源于 LeanSpec

# 原则 规则
I 上下文经济 每个 artifact < 300 行,硬限 400 行
II 信噪比 每个句子必须提供决策信息
III 意图优于实现 关注为什么和什么,不关注怎么做
IV 渐进式披露 从最小开始,仅在需要时扩展
V 必备内容 元数据、问题、方案、成功标准、权衡

配置

superspec init 生成 superspec.config.json

字段 默认值 说明
lang "en" 模板语言(zh / en),同时控制 CLI 提示语言
specDir "superspec" Spec 文件夹名
branchPrefix "spec/" Git 分支前缀
boost false 默认启用增强模式
strategy "follow" follow = 遵循项目规则,create = 自由探索
context [] AI 需要读取的项目规则文件
limits.targetLines 300 目标最大行数
limits.hardLines 400 硬限最大行数
archive.dir "archive" 归档子目录
archive.datePrefix true 归档文件夹加日期前缀

项目结构

SuperSpec/
├── package.json                 # monorepo 根
├── pnpm-workspace.yaml
├── tsconfig.json
└── packages/
    └── cli/                     # @superspec/cli
        ├── package.json
        ├── tsup.config.ts
        ├── src/
        │   ├── index.ts         # 库导出
        │   ├── cli/             # CLI 入口 (commander)
        │   ├── commands/        # create / archive / init / update / lint / validate / search / deps / status / sync
        │   ├── core/            # config / template / frontmatter / lint / validate / context
        │   ├── prompts/         # Agent 规则安装器
        │   ├── ui/              # 终端输出 (chalk)
        │   └── utils/           # fs / git / date / paths / template
        ├── templates/
        │   ├── zh/              # 中文模板
        │   └── en/              # 英文模板
        └── prompts/
            ├── rules.md         # Rules.md 模板
            └── agents.md        # AGENTS.md 模板

技术栈

  • 语言: TypeScript
  • 构建: tsup
  • 包管理: pnpm (monorepo)
  • 运行时: Node.js >= 18
  • 依赖: commander, chalk

开发

pnpm install          # 安装依赖
pnpm build            # 构建
pnpm dev              # 监听模式
pnpm --filter @superspec/cli typecheck   # 类型检查

致谢

License

MIT

稚晖君最新机器人「凌空飞踢」震撼全网!智元远征 A3:我要一个打十个

作者 莫崇宇
2026年2月13日 23:17

二十年前,周星驰在《功夫》里用一个满脸淤青的阿星告诉我们:真正的绝世高手,往往藏在市井之间,只待任督二脉被打通的那一刻。

二十年后,这位万中无一的武学奇才再次出现了。不过,它不是人。

就在刚才,智元机器人在视频号上甩出了一段名为「绝世高手,马上下山」的视频。官方还特意标注了全程实景实拍,没用 CG 特效,也不是 AI 创作。

视频画面开头,名为智元远征 A3 的人形机器人在练功房里来了个干脆利落的凌空飞踹,不仅踢碎了玻璃,踢开了马年的新春大门,估计也踢醒了不少人对国产机器人「腿脚不利索」的刻板印象。

视频里的 A3 起手就是升龙拳,紧接着连续飞踢,空中漫步,甚至还有个极高难度的「摸地旋风」。

做过机器人的都知道,让人形机器人走稳已经是工程学上的一大难题。作为电驱全尺寸人形机器人,远征 A3 能做出如此高动态、高爆发且连贯的动作,说明它的运动控制算法和瞬时功率输出已经上了个新台阶。

更重要的是,这种武打演员级的表现力也解决了一个长期困扰行业的痛点:机器人的动作「僵硬感」。

▲网友的评论

A3 这套连招打下来,全身协调性一目了然,官方说,这得益于它的全自由度柔性腰部,1:1 还原了人体腰部的活动范围。

再加上轻量化的腿部外骨骼结构,这台机器人的 TCP 末端速度最高能达到 2m/s。说人话就是:它挥拳的速度,可能比你反应的速度还快。

当然,也稍微有点遗憾,这次视频里并没有看到灵巧手的具体演示。

而如果远征 A3 光会耍帅,那它充其量也只是一个昂贵的大号玩具。这就不得不提这次最大的实用升级:续航。几个月前,他们的前代产品远征 A2 还在走「苦行僧」路线——顶着高温暴走 100 公里,但一次续航实则只有 2 小时左右。

而远征 A3 采用了一种嵌入式躯干双电池系统,不仅看着更瘦更紧凑,综合满电续航也直接拉到了最高 8 小时。

8 小时是什么概念?

人类标准工作制也就 8 小时,这意味着远征 A3 第一次拥有了完整覆盖一个人类班次的能力。配合快速换电技术,它理论上可以实现 24 小时连轴转——真正意义上的「007」牛马,而且计划在 2026 年内量产。

除了身体素质的进化,远征 A3 在情商方面也点满了技能点。

A3 搭载了端到端大模型,最大的变化是「去唤醒词」。你不用再尴尬地喊口令,它能根据环境语境直接搭话,甚至支持拍拍肩膀唤醒。

基于这些能力,智元把 A3 定位在导览、商演这些场景,这其实是个精准的降维打击。试想一下,商场里有个能给你打一套咏春、还能顺手帮你拎 3kg 重物的机器人,这吸睛能力不比真人发传单强多了?

视频的最后,师父准许远征 A3「下山」。这不仅是一个电影彩蛋,也是一个行业隐喻。当越来越多的人形机器人开始追求超人的运动能力、长时间的续航以及更自然的交互,机器人也将有望真正挤进人类商业社会。

当然,从「能下山」到「走遍天下」,还有很长的路要走。成本控制、场景适配、安全规范、用户接受度,每一个环节都是考验。

马年将至,各种人形机器人也陆续下山,谁能成为真正的「绝世高手」,我们很快就会看到。

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

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


❌
❌