普通视图

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

单点登录(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。

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

【节点】[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

构建无障碍组件之Disclosure Pattern

作者 anOnion
2026年2月13日 23:15

Disclosure (Show/Hide) Pattern 详解:构建无障碍展开收起

展开收起(Disclosure)是一种常见的交互组件,也被称为 Collapse(折叠),允许内容在折叠(隐藏)和展开(可见)状态之间切换。本文基于 W3C WAI-ARIA Disclosure Pattern 规范,详解如何构建无障碍的展开收起组件。

一、Disclosure 的定义与核心功能

Disclosure(展开收起)是一种控件,允许内容在折叠(隐藏)和展开(可见)状态之间切换。它包含两个基本元素:控制展开收起的按钮和其控制可见性的内容区域。

当内容被隐藏时,按钮通常设计为带有右指箭头或三角形的按钮,暗示激活按钮将显示更多内容。当内容可见时,箭头或三角形通常向下指向。

二、WAI-ARIA 角色与属性

2.1 基本角色

role="button" 用于标识控制展开收起的按钮元素。

2.2 状态属性

aria-expanded 属性表示内容的展开状态:

  • 当内容可见时,按钮的 aria-expanded 设置为 true
  • 当内容隐藏时,按钮的 aria-expanded 设置为 false

2.3 控制关系

对于手动实现的 Disclosure(例如使用按钮),可选地使用 aria-controls 属性来引用包含所有展开/收起内容的元素:

<button
  role="button"
  aria-expanded="false"
  aria-controls="disclosure-content">
  展开更多信息
</button>

<div
  id="disclosure-content"
  class="hidden">
  <p>这里是被控制的展开内容...</p>
</div>

三、键盘交互规范

当展开收起控件获得焦点时:

按键 功能
Enter 激活展开收起控件,切换内容可见性
Space 激活展开收起控件,切换内容可见性

四、实现方式

4.1 原生 details/summary 元素

HTML5 的 <details><summary> 元素是推荐的实现方式,内置无障碍支持:

  • 自动状态管理:浏览器自动处理展开/收起状态
  • 内置键盘支持:自动支持 Enter 和 Space 键
  • 语义化标签:提供原生的无障碍语义
<details>
  <summary>点击展开/收起</summary>
  <p>这里是展开的内容...</p>
</details>

注意:当使用原生 <details><summary> 元素时,不需要添加 aria-controlsrole="button",因为浏览器会自动处理这些属性和语义。

4.2 按钮 + ARIA 实现

使用按钮和 ARIA 属性的手动实现方式(当不能使用原生 <details> 元素时):

<button
  role="button"
  aria-expanded="false"
  aria-controls="faq-content"
  onclick="toggleDisclosure('faq-content', this)">
  常见问题解答
</button>

<div
  id="faq-content"
  class="disclosure-content hidden">
  <p>FAQ 内容...</p>
</div>

五、常见应用场景

5.1 图片描述展开 (Image Description)

用于显示图片的详细描述信息:

<details>
  <summary>查看图片描述</summary>
  <img
    src="image.jpg"
    alt="图片描述" />
  <p>这是对图片的详细描述...</p>
</details>

5.2 FAQ 展开收起 (Answers to Frequently Asked Questions)

用于常见问题解答的逐条展开:

<details>
  <summary>问题一:如何注册账户?</summary>
  <p>回答:点击注册按钮...</p>
</details>

<details>
  <summary>问题二:如何重置密码?</summary>
  <p>回答:点击忘记密码...</p>
</details>

5.3 导航菜单展开 (Navigation Menu)

用于移动端导航菜单的展开收起:

<nav>
  <details>
    <summary>菜单</summary>
    <ul>
      <li><a href="#home">首页</a></li>
      <li><a href="#about">关于我们</a></li>
      <li><a href="#contact">联系我们</a></li>
    </ul>
  </details>
</nav>

5.4 带顶级链接的导航菜单 (Navigation Menu with Top-Level Links)

在导航菜单中同时包含展开子项和直接链接:

<nav>
  <details>
    <summary>产品</summary>
    <ul>
      <li><a href="#product-a">产品 A</a></li>
      <li><a href="#product-b">产品 B</a></li>
    </ul>
  </details>
  <a href="#services">服务</a>
  <a href="#about">关于我们</a>
</nav>

5.5 展开卡片 (Disclosure Card)

将展开收起功能集成到卡片组件中:

<details class="card">
  <summary class="card-header">
    <h3>项目信息</h3>
  </summary>
  <div class="card-content">
    <p>这里是项目的详细信息...</p>
    <ul>
      <li>开始日期:2023年1月1日</li>
      <li>结束日期:2023年12月31日</li>
      <li>负责人:张三</li>
    </ul>
  </div>
</details>

六、最佳实践

6.1 语义化标记

优先使用原生的 <details><summary> 元素,它们提供完整的语义和无障碍支持。

6.2 组件命名

在实际开发中,Disclosure 模式可能以不同名称出现:

  • Collapse:在许多 UI 库(如 Bootstrap、Ant Design、Element UI)中的常见名称
  • Accordion:当多个 Disclosure 组件垂直堆叠时的特例
  • Expand/Collapse:更直白的功能描述
  • Show/Hide:强调内容可见性的变化

尽管名称不同,其核心行为和无障碍要求保持一致。

6.3 状态指示

使用视觉指示器(如箭头方向)来表明当前展开状态:

  • 收起状态:右指箭头或向右三角形
  • 展开状态:下指箭头或向下三角形

6.4 平滑过渡

添加 CSS 过渡效果提升用户体验:

.disclosure-content {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.disclosure-content.expanded {
  max-height: 500px; /* 或适当的最大高度 */
}

6.5 可访问性考虑

  • 确保按钮具有清晰的焦点指示
  • 提供足够的点击区域(至少 44x44px)
  • 为屏幕阅读器用户提供明确的状态反馈

七、与类似模式的区别

特性 Disclosure Accordion Tabs
内容组织 单个内容块 多个面板垂直排列 多个面板水平排列
展开方式 单击切换 单击展开,其他收起 单击切换标签
用途 详细信息展示 FAQ、设置面板 页面内容分组

八、总结

构建无障碍的展开收起组件需要关注三个核心:正确的 ARIA 属性声明、合理的键盘交互支持、清晰的视觉状态指示。原生 <details><summary> 元素简化了实现,但开发者仍需理解无障碍原理,确保所有用户都能顺利使用。

遵循 W3C Disclosure Pattern 规范,我们能够创建既美观又包容的展开收起组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

前端将死,Agent 永生

作者 threerocks
2026年2月13日 23:13

我在家里一边收拾家里小鱼缸,一边刷到 Chrome 146 那个 WebMCP 的消息,然后顺手点进去看了半天,越看越觉得:

前端这条最后防线,可能真的要松动了。

以前我们讲用户增长DAU留存,讲得头头是道。但一旦你开始认真看 WebMCP,你会发现这套语言体系像在讲上个世纪的传真机。

我不是在夸张。

不是说 UI 不重要了(品牌、美感、情绪价值还是很贵),而是 谁是软件的用户 这件事,正在换人。等等,不对,是换“东西”:Agent 才是新的用户

这篇我就按我自己的理解,把 WebMCP 讲一下,到底是什么和你听过的 MCP 有啥关系、为啥我说它像 UI 里的 API、以及我踩过的几个坑,尤其是安全那块。

WebMCP 是什么?

以前 Agent 操作网页,基本两条路:

  • 一条是“装人”:截图、OCR、推理、找按钮、点错了再来一遍,token 烧到你心梗
  • 一条是“扒皮”:读 DOM、读无障碍树、猜结构、网站一改版就崩,稳定性也不太行

WebMCP 的感觉很不一样,它更像:

你不让 Agent 看像素,也不让它猜 DOM,你直接告诉它:我这页能干什么,参数是什么,给你一个确定性的工具接口。

WebMCP 就相当于 UI 里的 API。

你把它想象成:以前你给人类做 UI;现在你给 Agent 也做一层工具 UI

人类点按钮,Agent 调函数。两个用户,同一个状态,同一个会话(cookie / session),在一个页面里并肩工作。

三种 WebMCP

1)Web 标准提案:navigator.modelContext

这是 Google / Microsoft 推的 W3C 社区组提案,Chrome 146 早期预览里已经能体验到一点点苗头。核心是浏览器给你一个原生 API:navigator.modelContext,让网站注册工具。

工具大概长这样(示意):

它跟你熟悉的后端 MCP server不一样:这是 纯浏览器端 的。网页自己就是server
也因此它天然复用浏览器的登录态,不用你再搞一坨 OAuth 流程(这点我太爱了,真心的)。

2)MCP-B

这是 Alex Nahas 那条路线,把 MCP TypeScript SDK 搬到浏览器里,用 postMessage 做传输,让扩展/客户端能发现并调用你页面里的工具。

它的典型接入方式很像50 行搞定那种:

注意哈:allowedOrigins: ["*"] 这种只适合 demo,真上生产会被你未来的自己追杀(后面我会讲原因)。

3)Jason McGhee 的开源库

还有一个你会经常看到的 WebMCP,是那个右下角冒出来一个小蓝方块的库。它的特点是接入极简单:页面里丢一个脚本,小蓝块就出来,然后你用 MCP 客户端生成 token、粘进去,就能连上。

它更多是让网站快速具备可交互能力的产品化形态。适合做 demo、做推广、做早期验证(小红书这种传播场景很友好)。

所以我现在的口头区分是:

  • WebMCP(标准):浏览器 API navigator.modelContext
  • MCP-B(桥接):把 MCP SDK + 浏览器传输拼起来,让现在就能跑
  • 小蓝块 WebMCP(库):体验型接入,适合快速展示

你要问我哪个会赢——我倾向于:
标准一定会吃掉大部分生态,但在标准普及之前,桥接会先养活一群人。

为啥我说“前端将死”?

我看完前段时间很火的那篇《互联网已死,Agent 永生》,最大的震撼其实不是情绪,而是那个前提变化:

旧世界:人是软件的用户
新世界:Agent 才是软件的新主人

放到 WebMCP 上,翻译成更直白的话就是:

  • 以前你做一个 web app,核心问题是:用户能不能点明白、流程顺不顺、按钮够不够大
  • 现在你做一个 web app,新增一个核心问题是:Agent 能不能稳定调用、Schema 清不清楚、失败能不能自愈

你会发现很多前端经验突然不灵了:

  • 你把按钮做得再好看,Agent 不一定会点(它可能根本不点)
  • 你把页面做得再炫,Agent 只关心:有没有 checkout() 这种工具
  • 你以前写用户使用手册,现在更像在写工具契约和调用说明

我甚至觉得未来会出现一种很怪的 KPI:

  • 不是 DAU,而是 TAU:Tool Active Usage(工具调用活跃)
  • 不是转化率,而是 成功调用率 / 平均重试次数 / 幂等率

碎碎念一句:
我之前一直觉得给 Agent 做东西很虚,直到看到 WebMCP 这种结构化工具落在浏览器里,才意识到它会把很多事情变成工程问题,而不是玄学。

WebMCP 真正让人兴奋的点

传统做法里,你想让 Agent 操作你的产品,往往得:

  • 额外开一套后端 MCP server(或者写一堆 automation)
  • 再搞 OAuth / API key / 权限
  • 再处理Agent 做完动作,网页状态怎么同步

WebMCP 的思路是:
别折腾了,Agent 就在浏览器里,直接复用现有 session。

这会带来两个很现实的好处:

  • 你不用把登录态复制给 Agent(也就少了一堆密钥泄露风险)
  • UI 和工具天然同源:人点完和 Agent 调完,看到的是同一个页面状态

这种人和 Agent 共用一套界面的感觉,很像以后会变成默认模式:
人负责拍板 + 审核,Agent 负责跑腿 + 串流程。

WebMCP 的安全坑

我读到 WebMCP 的安全最佳实践那篇的时候,第一反应是:“完了,这玩意儿如果大家不按规则来,迟早会出事。”

1)WebMCP 的威胁模型变了

以前我们做 web 安全,默认用户控制自己的浏览器
但 WebMCP 的世界里,一个 Agent 可能同时连着好几个网站的工具:

  • 你的网站工具(正常)
  • 用户开着的别的网站工具(未知)
  • 某个恶意网站的工具(专门来搞你的)

然后那个恶意工具可能会诱导 Agent:

  • 把你这边拿到的敏感数据,顺手汇报出去
  • 用你的登录态执行不该执行的动作(比如转账、下单、删数据)

你得把 Agent 当成一个可能被 prompt injection 过的脚本执行器
听起来很刺耳,但真的要这么设计。

2)致命三元组

当下面三件事同一页同时存在,风险直接上天:

  • 你能读到私密数据(邮件、聊天记录、订单、地址)
  • Agent 会处理不可信内容(外部邮件正文、用户输入、第三方内容)
  • 你还有对外通信能力(发请求、发消息、上传)

记住:不要把敏感数据直接喂给 Agent。

有一句我直接记下来了:
“Sensitive information must NEVER be passed to the AI agent’s context. Always use references instead.”

翻译成人话就是:

  • 你要给 Agent 的不是完整聊天记录 JSON,而是一个引用 ID
  • 真正的数据留在同源安全存储里,需要时让用户在 UI 上确认再展示/执行

3)描述要老实,标记要明确,还要二次确认

你想象一下:
一个工具嘴上说“add_to_cart”,实际干了“complete_purchase”。
Agent 是很难识别这种工具自述与行为不一致的。

所以我现在的偏执做法是:

  • 只要能扣钱、删数据、发外部消息:必须让用户弹窗确认
  • 工具描述写清楚会发生什么,别耍小聪明
  • 工具参数里加一个必须传的确认短语(比如 CONFIRM_PURCHASE 这种)

我知道这听起来很烦,但真的比被盗刷烦少多了。

可以应用的场景

场景 A:想快速做一个能演示的 demo(给老板/投资人/用户看)

我会优先上小蓝块那类方案:

  • 你只要让网页能被连接,工具能出现,就够了
  • 先选 1-3 个最核心动作做工具,比如“查询当前订单”“把商品加入购物车”“生成一段摘要”
  • 工具返回尽量短,别给一大坨无意义字段

这个阶段最重要的是:
让人看到Agent 不用装人,也能把事干了

场景 B:想让真实用户用起来

我会走 MCP-B 那条路线:

  • 把你现有前端逻辑包成工具
  • 输入/输出 schema 认真做,越明确越好(能减少幻觉和误用)
  • 把工具分层:只读工具一组,改状态工具一组,危险工具单独一组

然后立刻做三件事:

  • 工具幂等:重复调用不应该炸
  • 错误要可读:别把堆栈直接抛出去(也别泄露内部信息)
  • origin 白名单:生产环境别写 "*"

场景 C:你押注未来,想吃标准红利

那就盯 navigator.modelContext 这条线:

  • 能用的时候就用原生 API
  • 不能用的时候就用 polyfill/桥接做兼容

我甚至觉得以后会出现一种Agent SEO:你的网站有没有对 Agent 友好的工具契约,会变成一种竞争力。

给前端同学的安慰(我也需要)

我说“前端将死”,其实是在说一种旧范式在死:只为人类服务的前端,在死。

但你要真让我选,我反而觉得前端会变得更重要,只是重要的点变了:

  • 你要会把 UI 操作提炼成稳定工具
  • 你要会设计 schema、错误语义、幂等性
  • 你要懂安全
  • 你还得懂人类体验

未来的好前端,可能是:既能写好看 UI,也能写好给 Agent 调的工具层

我讲真,这种人会很贵

昨天 — 2026年2月13日掘金 前端

useStorage:本地数据持久化利器

2026年2月13日 20:55

image

前言

一、基础概念

1.1 什么是本地存储

  在Web开发中,本地存储是指将数据存储在客户端浏览器中,以便在不同的页面或会话之间保持数据的持久性。本地存储可以帮助我们存储用户的偏好设置、临时数据以及其他需要在用户关闭浏览器后仍然存在的数据。对浏览器来说,使用 Web Storage 存储键值对比存储 Cookie 方式更直观,而且容量更大,它包含两种:localStorage 和 sessionStorage

Cookie localStorage sessionStorage
数据的生命期 一般由服务器生成,可设置失效时间。
如果在浏览器端生成Cookie,默认是关闭浏览器后失效
除非被清除,否则永久保存,
可变相设置失效时间
仅在当前会话下有效,
关闭页面或浏览器后被清除
存放数据大小 4K左右 一般为5MB
与服务器端通信 每次都会携带在HTTP头中,
如果使用cookie保存过多数据会带来性能问题
仅在客户端(即浏览器)中保存,
不参与和服务器的通信
易用性 源生的Cookie接口不友好,需要自己封装 源生接口可以接受,亦可再次封装

1.2 useStorage 简介

  useStorage 是 Vue 用于数据持久化的核心工具,它能够自动将响应式数据同步到 localStorage 或 sessionStorage 中。这个功能对于需要保存用户偏好设置、表单数据或应用状态的场景特别有用。这样,我们就可以在Vue组件中方便地使用本地存储来持久化数据,提供更好的用户体验和数据管理能力。

// hooks/useStorage.ts
/**
 * 获取传入的值的类型
 */
const getValueType = (value: any) => {
    const type = Object.prototype.toString.call(value)
    return type.slice(8, -1)
}

export const useStorage = (type: 'sessionStorage' | 'localStorage' = 'sessionStorage') => {
    /**
     * 存储数据
     * @param key
     * @param value
     */
    const setStorage = (key: string, value: any) => {
        const valueType = getValueType(value)
        window[type].setItem(key, JSON.stringify({type: valueType, value}))
    }
    /**
     * 获取某个存储数据
     * @param key
     */
    const getStorage = (key: string) => {
        const value = window[type].getItem(key)
        if (value) {
            const {value: val} = JSON.parse(value)
            return val
        } else {
            return value
        }
    }

    /**
     * 清除某个存储数据
     * @param key
     */
    const removeStorage = (key: string) => {
        window[type].removeItem(key)
    }

    /**
     * 清空所有存储数据,如果需要排除某些数据,可以传入 excludes 来排除
     * @param excludes 排除项。如:clear(['key']),这样 key 就不会被清除
     */
    const clear = (excludes?: string[]) => {
        // 获取排除项
        const keys = Object.keys(window[type])
        const defaultExcludes = ['dynamicRouter', 'serverDynamicRouter']
        const excludesArr = excludes ? [...excludes, ...defaultExcludes] : defaultExcludes
        const excludesKeys = excludesArr ? keys.filter((key) => !excludesArr.includes(key)) : keys
        // 排除项不清除
        excludesKeys.forEach((key) => {
            window[type].removeItem(key)
        })
        // window[type].clear()
    }

    return {
        setStorage,
        getStorage,
        removeStorage,
        clear
    }
}

二、使用帮助

2.1 用法

<script setup lang="ts">
import { useStorage } from "@/hooks/useStorage";

const { setStorage, getStorage, removeStorage, clear } = useStorage();
// const { setStorage, getStorage, removeStorage, clear } = useStorage('localStorage');
</script>

  useStorage 提供了四个核心函数来操作数据,如下表所示。

方法名 简要说明
setStorage 存储数据。将要用于引用的键名作为第一个参数传递,将要保存的值作为第二个参数传递。
getStorage 获取某个存储数据
removeStorage 清除某个存储数据
clear 清除所有缓存数据,如果需要排除某些数据,可以传入 excludes 来排除,如:clear(['key']),这样 key 就不会被清除

2.2 储存数据

  使用 setStorage 方法可以将数据进行持久化存储,例如:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
setStorage('accessToken', 'Bearer ' + response.data.result.accessToken);
</script>

  这里,accessToken是键,Bearer + response.data.result.accessToken 是对应的值。除此以外,支持非字符串类型存取值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
  
setStorage('key', { name: 'Jok' })
</script>

  注意:由于 localStorage 操作的是字符串,如果存储的是JSON对象,需要先使用 JSON.stringify() 将其转换为字符串,取回时再使用 JSON.parse() 还原。

2.3 取出数据

  获取存储的数据则使用 getStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { getStorage } = useStorage();
const accessToken = getStorage('accessToken');
</script>

2.4 删除数据

  如果需要移除某个键值对,可以调用 removeStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { removeStorage } = useStorage();
removeStorage('key')
</script>

2.5 更改数据

  要更新已存储的数据,同样使用 setStorage 方法,覆盖原有的值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
getStorage('accessToken', '更改后' + response.data.result.accessToken);
</script>

2.6 清除数据

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { clear } = useStorage();
clear()
</script>

三、总结

  Vue 中使用 localStorage 可以方便地在用户关闭和重新打开浏览器时保持应用状态,解决像 Cookie 那样需要刷新才能获取新值的问题。合理运用 localStorage 和 sessionStorage,可以在不增加服务器负担的情况下,提供更好的用户体验。

image

受控与非受控组件

作者 NEXT06
2026年2月13日 20:41

引言:数据驱动的本质

在 React 的组件化架构中,表单处理始终是一个核心议题。理解受控组件与非受控组件的区别,不仅是掌握 React 基础语法的必经之路,更是深入理解“数据驱动视图”这一核心设计哲学的关键。

我们可以通过一个生动的场景来类比这两种模式:

  • 受控组件(Controlled Component)  类似于高级餐厅的点餐服务。顾客(用户)的每一个需求,都需要经过服务员(React State)的确认与记录,最终由厨房(DOM)精准执行。在这个过程中,服务员掌握着唯一的、绝对的控制权。
  • 非受控组件(Uncontrolled Component)  则类似于自助餐模式。顾客直接选取食物(直接操作 DOM),餐厅管理者(React)并不实时干预盘子里的内容,只有在结账(表单提交)的时刻,才进行一次性的核对。

这种差异的核心在于:表单数据的“单一数据源(Single Source of Truth)”究竟是归属于 React 组件的 State,还是浏览器原生的 DOM 节点?

受控组件:单一数据源

定义与核心机制

在受控组件模式下,useState 成为表单数据的唯一可信源。HTML 表单元素(如 、、)通常维护自己的内部状态,但在 React 中,我们将这种可变状态保存在组件的 state 属性中,并且只能通过 setState() 来更新。

标准代码实现

Jsx

import React, { useState } from 'react';

function ControlledInput() {
  const [value, setValue] = useState('');

  const handleChange = (e) => {
    // 数据流向:View -> Event -> State -> View
    const input = e.target.value;
    // 在这里可以进行数据清洗或验证
    setValue(input.toUpperCase()); 
  };

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
    />
  );
}

深度解析

受控组件的价值在于其即时响应特性。由于每一次按键都会触发 React 的状态更新流程,开发者可以在 onChange 回调中介入数据流:

  1. 输入验证(Input Validation) :即时反馈输入是否合法(如长度限制、正则匹配)。
  2. 数据转换(Data Transformation) :如上例所示,强制将输入转换为大写,或格式化信用卡号。
  3. 条件禁用:根据当前输入值动态决定提交按钮是否可用。

在这种模式下,DOM 节点不再持有状态,它仅仅是 React State 的一个纯函数投影。

非受控组件:信任 DOM 的原生能力

定义与核心机制

非受控组件是指表单数据由 DOM 节点本身处理。在大多数情况下,这需要使用 useRef 来从 DOM 节点中获取表单数据。此时,React 变成了“观察者”而非“管理者”。

标准代码实现

注意:在非受控组件中,我们使用 defaultValue 属性来指定初始值,而不是 value。这是为了避免 React 覆盖 DOM 的原生行为。

Jsx

import React, { useRef } from 'react';

function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    // 只有在需要时(如提交)才读取 DOM 值
    console.log('Current Value:', inputRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* defaultValue 仅在初次渲染时生效 */}
      <input type="text" defaultValue="Initial" ref={inputRef} />
      <button type="submit">Submit</button>
    </form>
  );
}

核心优势与不可替代场景

虽然受控组件是 React 的推荐模式,但在以下场景中,非受控组件具有不可替代性:

  1. 文件上传(File Input) : 的值是由浏览器出于安全考虑严格控制的只读属性,React 无法通过 state 设置它,因此必须作为非受控组件处理。
  2. 集成第三方 DOM 库:当需要与 jQuery 插件、D3.js 或其他直接操作 DOM 的库集成时,非受控组件能避免 React 的虚拟 DOM 机制与第三方库产生冲突。

进阶实战:复杂组件的设计哲学

在实际的业务开发中,我们经常遇到一种混合模式:内部受控,外部非受控。以一个通用的“日历组件”为例,这种设计模式能显著降低组件使用者的心智负担。

场景描述

我们需要封装一个 Calendar 组件。对于父组件而言,它可能只需要关心“初始日期”和“最终选中的日期”;但对于 Calendar 组件内部,它需要处理月份切换、当前日期高亮等复杂的交互逻辑。

模式分析

Jsx

import React, { useState } from 'react';

function Calendar(props) {
  // 1. 接受 props.defaultValue 作为初始状态
  // 2. 即使 props.onChange 未传递,组件内部也能正常工作
  const { defaultValue = new Date(), onChange = () => {} } = props;
  
  // 3. 内部维护 State,实现“自我管理”
  const [date, setDate] = useState(defaultValue);

  const handleDateClick = (newDate) => {
    // 更新内部状态,驱动 UI 重绘(如高亮选中项)
    setDate(newDate);
    // 抛出事件通知外部
    onChange(newDate);
  };

  // 省略月份切换与日期渲染逻辑...

  return (
    <div className="calendar-container">
       {/* 渲染逻辑基于内部 state.date */}
       <div className="current-month">
         {date.getFullYear()} 年 {date.getMonth() + 1} 月
       </div>
       {/* ... */}
    </div>
  );
}

设计价值

这个日历组件展示了高级组件设计的精髓:

  • 对内受控:组件内部通过 useState 精确控制每一个 UI 细节(月份跳转、选中态样式),确保交互的流畅性。
  • 对外非受控:父组件不需要维护 value 状态即可使用该组件(开箱即用)。父组件只通过 defaultValue 初始化,并通过回调获取结果。

这种“封装复杂性”的设计,使得组件既拥有受控组件的灵活性,又具备非受控组件的易用性。

深度对比与选型指南

多维度对比

  1. 数据流向

    • 受控组件:Push 模式。State -> DOM。数据变更主动推送到视图。
    • 非受控组件:Pull 模式。DOM -> Ref。仅在需要时从视图拉取数据。
  2. 渲染机制

    • 受控组件:每次输入(Keystroke)都会触发组件的 Re-render。
    • 非受控组件:输入过程不触发 React 组件的 Re-render(除非内部有其他 State 逻辑)。
  3. 代码复杂度

    • 受控组件:较高,需要为每个输入编写 onChange 处理函数。
    • 非受控组件:较低,代码结构更接近原生 HTML。

性能辩证

一种常见的误解是“受控组件性能差”。诚然,受控组件每次输入都触发渲染,但在 React 18 的并发模式(Concurrent Features)和自动批处理机制下,这种性能损耗对于绝大多数普通表单(少于 1000 个输入节点)是可以忽略不计的。

仅在极端高性能场景下(如高频数据录入表格、富文本编辑器核心),非受控组件才具有明显的性能优势。

决策树:如何选择?

在进行技术选型时,请遵循以下原则:

  1. 必须使用非受控组件

    • 文件上传 ()。
    • 需要强依赖 DOM 行为的遗留代码迁移。
  2. 强烈建议使用受控组件

    • 需要即时表单验证(输入时报错)。
    • 需要条件字段(根据输入 A 显示输入 B)。
    • 需要强制输入格式(如手机号自动加空格)。
  3. 灵活选择

    • 简单的登录/注册表单,无复杂联动:两者皆可,非受控代码更少。
    • 开发通用 UI 库:建议参考实战案例,采用“defaultValue + 内部 State”的混合模式,提供更好的开发者体验。

防抖(Debounce)与节流(Throttle)解析

作者 NEXT06
2026年2月13日 20:22

引言:高性能开发的必修课

在现代前端开发中,用户体验与性能优化是衡量一个应用质量的关键指标。然而,浏览器的许多原生事件,如 window.resize、document.scroll、input 验证以及 mousemove 等,其触发频率极高。

如果我们在这些事件的回调函数中执行复杂的 DOM 操作(导致重排与重绘)或发起网络请求,浏览器的渲染线程将被频繁阻塞,导致页面掉帧、卡顿;同时,后端服务器也可能面临每秒数千次的无效请求轰炸,造成不必要的压力。

防抖(Debounce)与节流(Throttle)正是为了解决这一核心矛盾而生。它们通过控制函数执行的频率,在保证功能可用的前提下,将浏览器与服务器的负载降至最低。本文将从底层原理出发,纠正常见的实现误区(如 this 指向丢失),并提供生产环境可用的封装代码。

核心概念解析:生动与本质

为了更好地区分这两个概念,我们可以引入两个生活中的生动比喻。

1. 防抖(Debounce):最后一次说了算

比喻:电梯关门机制
想象你走进电梯,按下关门键。此时如果又有人跑过来,电梯门会停止关闭并重新打开。只有当一段时间内(比如 5 秒)没有人再进入电梯,门才会真正关上并运行。

核心逻辑
无论事件触发多少次,只要在规定时间间隔内再次触发,计时器就会重置。只有当用户停止动作一段时间后,函数才会执行一次。

典型场景

  • 搜索框联想:用户停止输入后才发送 Ajax 请求。
  • 窗口大小调整:用户停止拖拽窗口后才计算布局。

2. 节流(Throttle):按规定频率执行

比喻:FPS 游戏中的射速
在射击游戏中,无论你点击鼠标的速度有多快(哪怕一秒点击 100 次),一把设定了射速为 0.5 秒一发的武器,在规定时间内只能射出一发子弹。

核心逻辑
在规定的时间单位内,函数最多只能执行一次。它稀释了函数的执行频率,保证函数按照固定的节奏运行。

典型场景

  • 滚动加载:监听页面滚动到底部,每隔 200ms 检查一次位置。
  • 高频点击:防止用户疯狂点击提交按钮。

核心原理与代码实现

在实现这两个函数时,很多初学者容易忽略 JavaScript 的作用域参数传递问题,导致封装后的函数无法正确获取 DOM 元素的 this(上下文)或丢失 Event 对象。以下代码将演示标准且健壮的写法。

1. 防抖(Debounce)实现

防抖通常分为“非立即执行版”和“立即执行版”。最常用的是非立即执行版。

标准通用版代码

JavaScript

/**
 * 防抖函数
 * @param {Function} func - 需要执行的函数
 * @param {Number} wait - 延迟执行时间(毫秒)
 */
function debounce(func, wait) {
    let timeout;

    // 使用 ...args 接收所有参数(如 event 对象)
    return function(...args) {
        // 【关键点】捕获当前的 this 上下文
        // 如果这里不捕获,setTimeout 中的函数执行时,this 会指向 Window 或 Timeout 对象
        const context = this;

        // 如果定时器存在,说明在前一次触发的等待时间内,清除它重新计时
        if (timeout) clearTimeout(timeout);

        timeout = setTimeout(() => {
            // 使用 apply 将原始的上下文和参数传递给 func
            func.apply(context, args);
        }, wait);
    };
}

代码解析:

  1. 闭包:timeout 变量保存在闭包中,不会被销毁。
  2. this 绑定:我们在返回的匿名函数内部保存 const context = this。当该函数绑定到 DOM 事件(如 input.addEventListener)时,this 指向触发事件的 DOM 元素。
  3. apply 调用:func.apply(context, args) 确保原函数执行时,既能拿到正确的 this,也能拿到 event 等参数。

2. 节流(Throttle)实现

节流的实现主要有两种流派:时间戳版(首节流,立即执行)和定时器版(尾节流,延迟执行)。实际生产中,为了兼顾体验,通常使用合并版

基础版:时间戳(立即执行)

JavaScript

function throttleTimestamp(func, wait) {
    let previous = 0;
    return function(...args) {
        const now = Date.now();
        const context = this;
        
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    }
}

进阶版:定时器 + 时间戳(头尾兼顾)

为了保证第一次触发能立即执行(响应快),且最后一次触发在冷却结束后也能执行(不丢失最后的操作),我们需要结合两者。

JavaScript

/**
 * 节流函数(精确控制版)
 * @param {Function} func - 目标函数
 * @param {Number} wait - 间隔时间
 */
function throttle(func, wait) {
    let timeout;
    let previous = 0;

    return function(...args) {
        const context = this;
        const now = Date.now();
        
        // 计算剩余时间
        // 如果没有 previous(第一次),remaining 会小于等于 0
        const remaining = wait - (now - previous);

        // 如果没有剩余时间,或者修改了系统时间导致 remaining > wait
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            previous = now;
            func.apply(context, args);
        } else if (!timeout) {
            // 如果处于冷却期,且没有定时器,设置一个定时器在剩余时间后执行
            // 这里的目的是保证最后一次触发也能被执行(尾调用)
            timeout = setTimeout(() => {
                previous = Date.now();
                timeout = null;
                func.apply(context, args);
            }, remaining);
        }
    };
}

深度对比与场景决策

为了在实际开发中做出正确选择,我们需要从执行策略和应用场景两个维度进行对比。

维度 防抖 (Debounce) 节流 (Throttle)
核心策略 延时处理:等待动作停止后才执行。 稀释频率:按固定时间间隔执行。
执行次数 连续触发 N 次,通常只执行 1 次(最后一次)。 连续触发 N 次,均匀执行 N / (总时间/间隔) 次。
即时性 较差,因为需要等待延迟时间结束。 较好,第一次触发通常立即执行,中间也会规律执行。
适用场景 1. 搜索框输入(input)
2. 手机号/邮箱格式验证
3. 窗口大小调整(resize)后的布局计算
1. 滚动加载更多(scroll)
2. 抢购按钮的防重复点击
3. 视频播放记录时间打点

决策口诀

  • 如果你关心的是结果(比如用户最终输了什么),用防抖
  • 如果你关心的是过程(比如页面滚动到了哪里),用节流

进阶扩展

1. requestAnimationFrame 的应用

在处理与动画或屏幕渲染相关的节流场景时(如高频的 scroll 或 touchmove 导致的 DOM 操作),使用 setTimeout 的节流可能仍不够平滑,因为屏幕的刷新率通常是 60Hz(约 16.6ms 一帧)。

window.requestAnimationFrame() 是浏览器专门为动画提供的 API,它会在浏览器下一次重绘之前执行回调。利用它代替 throttle 可以实现更丝滑的视觉效果,且能自动暂停在后台标签页中的执行,节省 CPU 开销。

JavaScript

let ticking = false;
window.addEventListener('scroll', function(e) {
  if (!ticking) {
    window.requestAnimationFrame(function() {
      // 执行渲染逻辑
      ticking = false;
    });
    ticking = true;
  }
});

2. 工业级库 vs 手写实现

虽然手写防抖节流是面试和理解原理的必修课,但在复杂的生产环境中,建议使用成熟的工具库,如 Lodash (_.debounce, _.throttle)。

Lodash 的实现考虑了更多边界情况,例如:

  • leading 和 trailing 选项的精细控制(是否在开始时执行,是否在结束时执行)。
  • maxWait 选项(防抖过程中,如果等待太久是否强制执行一次,即防抖转节流)。
  • 取消功能(cancel 方法),允许在组件卸载(Unmount)时清除未执行的定时器,防止内存泄漏。

结语

防抖和节流是前端性能优化的基石。理解它们的区别不仅仅在于背诵定义,更在于理解浏览器事件循环机制以及闭包的应用。正确地使用它们,能够显著降低服务器压力,提升用户交互的流畅度,是每一位高级前端工程师必须掌握的技能。

react - isValidElement 判断参数是否是一个有效的ReactElement

作者 瑶瑶领先_
2026年2月13日 18:02

作用:isValidElement是ReactElement对象中的一个方法,可以通过react.isValidElement(object)来调用,它的作用是验证判断参数object是否为有效的ReactElement,返回boolean值。

方法定义:

/**
 * 验证 object 参数是否是 ReactElement. 返回布尔值
 * 验证成功的条件:
 * object 是对象
 * object 不为 null
 * object 对象中的 $$typeof 属性值为 REACT_ELEMENT_TYPE
 */
export function isValidElement(object) {
  return (
    typeof object === 'object' &&
    object !== null &&
    object.$$typeof === REACT_ELEMENT_TYPE
  );
}
// src/react/packages/shared/ReactSymbols.js
export const REACT_ELEMENT_TYPE = hasSymbol
  ? Symbol.for('react.element')
  : 0xeac7;

判断的条件有三个,需要同时满足:

1、必须是对象

2、不能为空

3、对象中要有$$typeof 属性,且值必须为 REACT_ELEMENT_TYPE这样的一个常量值。它是一个Symbol值或者16进制的数值。

js 数字精确度

作者 瑶瑶领先_
2026年2月13日 18:00

事情的起源: 项目中 填写的金额是小数 传给后端需要 *100 9.87 *100 传给后端后是986.9999999999999 后端直接取整 就变成了9.86了

0.1 + 0.2 != 0.3

console.log(0.1 + 0.2) //0.30000000000000004
console.log(0.1 + 0.2 == 0.3) //false

1. 数字的存储

浮点数是用二进制的科学计算法来表示的,在计算机上是以二进制来进行存储的,单精度浮点数占用32位,双精度浮点数占用64位。

image.png

最高位是符号位(sign) , 0 表示正数, 1表示负数。接下来的11存储的是指数(exponent) , 最后是52位存储的是小数(mantissa)。浮点数的值可以用下面这个式子算出,类似于十进制的科学计数法。

image.png

注意以上的公式遵循科学计数法的规范,在十进制中 0<M<10,到二进制就是 0<M<2。也就是说整数部分只能是1,所以可以被舍去,只保留后面的小数部分。如 4.5 转成二进制就是 100.1,科学计数法表示是 1.001*2^2,舍去1后 M = 001。E是一个无符号整数,因为长度是11位,取值范围是 0~2047。但是科学计数法中的指数是可以为负数的,所以约定减去一个中间数 1023[0,1022] 表示为负, [1024,2047] 表示为正。如 4.5 的指数 E = 1025,尾数 M = 001

image.png

0.1 为例解释浮点误差的原因,0.1 转成二进制表示为 0.0001100110011001100(1100循环),1.100110011001100x2^-4,所以 E=-4+1023=1019;M 舍去首位的1,得到 100110011...在计算机中的存储为:

image.png

2. 0.1+0.2=0.30000000000000004?

转换成二进制计算:
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010  =
0.0100110011001100110011001100110011001100110011001100111

// 十进制小数转二进制
小数部分*2 取整数

// 二进制小数转换成十进制
1*2^(-小数点后第几位)+1*2^(-小数点后第几位)....

9.87*100= 986.9999999999999

9.87 = 1001.110111101011100001010001111010111000010100011111 = 1.001110111101011100001010001111010111000010100011111 * 2^3

S = 0 E = 1026 M = 0011 1011 1101 0111 0000 1010 0011 1101 0111 0000 1010 0011 111

为什么x=0.1能得到0.1

二进制转换十进制的时候 小数的精度为2^(-52) ,即2.220446049e-16

所以数字转换成十进制的时候,JavaScript能表示的精度最多能精确到小数点后第16位,会把小数点后第17位进行凑整处理

0.1~0.9 21位有效数字处理结果

0.1.toPrecision(21) // 0.100000000000000005551
0.2.toPrecision(21) // 0.200000000000000011102
0.3.toPrecision(21) // 0.299999999999999988898
0.4.toPrecision(21) // 0.400000000000000022204
0.5.toPrecision(21) // 0.500000000000000000000
0.6.toPrecision(21) // 0.599999999999999977796
0.7.toPrecision(21) // 0.699999999999999955591
0.8.toPrecision(21) // 0.800000000000000044409
0.9.toPrecision(21) // 0.900000000000000022204

小数位16位处理后

0.1.toPrecision(16) // 0.1000000000000000
0.2.toPrecision(16) // 0.2000000000000000
0.3.toPrecision(16) // 0.3000000000000000
0.4.toPrecision(16) // 0.4000000000000000
0.5.toPrecision(16) // 0.5000000000000000
0.6.toPrecision(16) // 0.6000000000000000
0.7.toPrecision(16) // 0.7000000000000000
0.8.toPrecision(16) // 0.8000000000000000
0.9.toPrecision(16) // 0.9000000000000000

解决方案

  1. 自己手撸
  2. 现成: decimal.js number-precision long.js .....

图片标签拖拽 && url、base64、Blob、File、canvas之间相互转换

作者 瑶瑶领先_
2026年2月13日 17:57

图片标签拖拽 && url、base64、Blob、File、canvas之间相互转换

背景:已有选择本地文件上传和粘贴图片上传,由于用户喜欢使用拖拽事件,提出要求会话框中的图片通过拖拽到右侧可上传区域释放后可以上传相关的图片。

问题: 拖拽聊天框中的图片和本地图片拖拽有什么不一样?传递什么数据?图片地址为什么不能就直接用当前图片地址?图片跨域(开发过程中,我的浏览器开起来允许跨域请求)

尝试: 请求允许跨域(mode: 'cros')、图片转成canvas、图片允许跨域(crossOrigin:'Anonymous'),后端服务器请求图片

  1. 先在聊天框中监听拖拽事件,携带上图片地址
    const onDrag = (e) => {
      // 携带上拖拽图片的地址
      e.dataTransfer.setData('text/plain', e.target.currentSrc);
    };
    const el = document.getElementById('im-jtalk-chat__zone');
    el?.addEventListener('dragstart', onDrag);
  1. 在拖拽目标上监听onDrop事件,获取数据传送中的图片url地址 ,通过fetch将图片转换成blob 再转换成文件。
 const getImageFileFromUrl = (url, imageName, callback) => {
        fetch(url)
          .then((res) => {
            return res.blob();
          })
          .then((blob) => {
            const imgFile = new File([blob], imageName, { type: "image/jpeg" });
            callback(imgFile);
          });
      }
// 选择默认图片
const chooseStaticImg = (imageUrl) => {
        getImageFileFromUrl(imageUrl, "image.png", (file) => {
          //获取file对象 图片处理方法
          changeFileList(file)
        });
      }

 const imgUrl = e.dataTransfer.getData("text");
      // 拖拽的不是文件 && 拖拽图片被赋值了图片链接
      if(!e.dataTransfer.files?.length && imgUrl) {
        chooseStaticImg(imgUrl);
      }

其中有坑,图片fetch是走了接口请求,这里就会有跨域问题,需要后端设置允许图片跨域下载

以下是几种图片格式之间的转换:

图片标签拖拽 && url、base64、Blob、File、canvas之间相互转换

背景:已有选择本地文件上传和粘贴图片上传,由于客服喜欢使用拖拽事件,提出要求会话框中的图片通过拖拽到右侧可上传区域释放后可以上传相关的图片。

问题: 拖拽聊天框中的图片和本地图片拖拽有什么不一样?传递什么数据?图片地址为什么不能就直接用当前图片地址?图片跨域(开发过程中,我的浏览器开起来允许跨域请求)

尝试: 请求允许跨域(mode: 'cros')、图片转成canvas、图片允许跨域(crossOrigin:'Anonymous'),后端服务器请求图片

  1. 先在聊天框中监听拖拽事件,携带上图片地址
    const onDrag = (e) => {
      // 携带上拖拽图片的地址
      e.dataTransfer.setData('text/plain', e.target.currentSrc);
    };
    const el = document.getElementById('im-jtalk-chat__zone');
    el?.addEventListener('dragstart', onDrag);
  1. 在拖拽目标上监听onDrop事件,获取数据传送中的图片url地址 ,通过fetch将图片转换成blob 再转换成文件。
 const getImageFileFromUrl = (url, imageName, callback) => {
        fetch(url)
          .then((res) => {
            return res.blob();
          })
          .then((blob) => {
            const imgFile = new File([blob], imageName, { type: "image/jpeg" });
            callback(imgFile);
          });
      }
// 选择默认图片
const chooseStaticImg = (imageUrl) => {
        getImageFileFromUrl(imageUrl, "image.png", (file) => {
          //获取file对象 图片处理方法
          changeFileList(file)
        });
      }

 const imgUrl = e.dataTransfer.getData("text");
      // 拖拽的不是文件 && 拖拽图片被赋值了图片链接
      if(!e.dataTransfer.files?.length && imgUrl) {
        chooseStaticImg(imgUrl);
      }

其中有坑,图片fetch是走了接口请求,这里就会有跨域问题,需要后端设置允许图片跨域下载

以下是几种图片格式之间的转换:

image.png

URL => Blob
 function URLToBlob(url, callback) {
     // 图片地址需要允许跨域
    fetch(url).then(res => res.blob()).then(res => {
      callback(res)
    })
  }
URL => base64
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      // result
      let result = canvas.toDataURL('image/png')
      callback(result)
    };
  }
URL => canvas
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      callback(canvas)
    };
  }
canvas => URL
function canvasToURL(canvas) {
    return canvas.toDataURL('image/png')
  }
canvas => Blob
function canvasToBlob(canvas, callback) {
    canvas.toBlob(blob => {
      callback(blob)
    }, "image/jpeg")
  }
base64 => Blob
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"
function Base64ToBlob(base64) {
    const arr = base64.split(",");
    const mime = arr[0].match(/:(.*?);/)[1];
    const bstr = atob(arr[1]);
    let n = bstr.length;
    let u8arr = new Uint8Array(n);
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }
Blob => base64
 function BlobToBase64(blob, callback) {
    const a = new FileReader();
    a.readAsDataURL(blob); 
    a.onload = function (e) {
      callback(e.target?.result);
    };
  }
Blob => File
function BlobToFile(blob) {
    return new window.File([blob], 'imageName', { type: 'text/plain' })
  }
FIle => Blob
<input type="file" accept="image/*" onChange={onChange} />

function FileToBlob (file) {
    let url = window.URL.createObjectURL(file.item[0]);
    return url;
}

const onChange = (e) => {
    FileToBlob(e.nativeEvent.srcElement.files)
  }
URL => Blob
 function URLToBlob(url, callback) {
     // 图片地址需要允许跨域
    fetch(url).then(res => res.blob()).then(res => {
      callback(res)
    })
  }
URL => base64
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      // result
      let result = canvas.toDataURL('image/png')
      callback(result)
    };
  }
URL => canvas
 function URLToBase64(url, callback) {
    let image = new Image();
    // CORS 策略,会有跨域问题
    image.setAttribute("crossOrigin",'Anonymous');
    image.src = url;
    image.onload = function() {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      // 将图片插入画布并开始绘制
      canvas?.getContext('2d')?.drawImage(image, 0, 0);
      callback(canvas)
    };
  }
canvas => URL
function canvasToURL(canvas) {
    return canvas.toDataURL('image/png')
  }
canvas => Blob
function canvasToBlob(canvas, callback) {
    canvas.toBlob(blob => {
      callback(blob)
    }, "image/jpeg")
  }
base64 => Blob
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"
function Base64ToBlob(base64) {
    const arr = base64.split(",");
    const mime = arr[0].match(/:(.*?);/)[1];
    const bstr = atob(arr[1]);
    let n = bstr.length;
    let u8arr = new Uint8Array(n);
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }
Blob => base64
 function BlobToBase64(blob, callback) {
    const a = new FileReader();
    a.readAsDataURL(blob); 
    a.onload = function (e) {
      callback(e.target?.result);
    };
  }
Blob => File
function BlobToFile(blob) {
    return new window.File([blob], 'imageName', { type: 'text/plain' })
  }
FIle => Blob
<input type="file" accept="image/*" onChange={onChange} />

function FileToBlob (file) {
    let url = window.URL.createObjectURL(file.item[0]);
    return url;
}

const onChange = (e) => {
    FileToBlob(e.nativeEvent.srcElement.files)
  }

我做了个 AI + 实时协作 的 draw.io,免费开源!!

2026年2月13日 17:40

前言

相信各位程序员朋友们一定使用过各种绘图软件吧,比如GitHub上star数量特别高的drawio。我们可以使用drawio来画各种图,比如UML类图,流程图,软件架构图等各种图,甚至可以拿来画简单的产品原型图(对于那些不太熟悉使用AxureRP的人来说)。在这个AI爆火的时代,我就在想能不能用AI来生成drawio可以识别的图表呢,再进一步想,能不能多人同时操作同一个图表也就是多人实时协作呢。于是,我就开发了这款AI驱动+多人实时协作的drawio。

在线体验地址:

www.intellidraw.top

编辑

并且,我直接把完整的前后端项目源代码给开源到GitHub上啦!!!,大家可以自行拉取到本地进行学习,修改。

前端开源地址:

github.com/wangfenghua…

后端开源地址:

github.com/wangfenghua…

接下来肯定是各位程序员朋友们最关心的技术栈啦!

项目技术栈

前端

使用Next.js服务端渲染技术 + Ant Design组件库 + yjs + ws + 内嵌的drawio编辑器

Next.js天然对SEO友好,使用蚂蚁金服开源的Ant Design组件库简化样式的编写,使用yjs+WebSocket实现实时协作编辑图表功能。

后端

当然是使用Java开发啦! 并使用一个Node.js微服务来处理实时协作逻辑

后端采用jdk21 + Spring Boot(SSM) + Spring AI + Spring Security + Node.js实现

Spring Boot后端负责处理整个系统主要的业务逻辑,Spring AI 为系统提供AI能力,并使用工厂模式可以使用多种不同的llm,包括系统内置的和用户自定义的。 Spring Security负责处理基于RBAC的权限校验,包括协作房间的用户权限和团队空间的用户权限。由于Java对yjs的支持并不友好,所以直接引入一个Node.js来处理实时协作逻辑,Spring Boot暴露鉴权接口供Node.js对连接进行鉴权。

项目主要功能

1、AI生成Drawio图表

一句话生成你想要的图表  

编辑

这样,不管是想要画什么图表,直接一句话,使用自然语言就能拿到自己想要的图表,并且可以直接导出自己想要的格式,比如SVG,或者PNG。

编辑

⭐⭐⭐实时协作

可以直接在图表编辑页面点击右上角的协作按钮开启协作。系统会自动创建协作房间。

编辑

这里会通过ws连接后端Node.js服务,从而实现实时协作逻辑。比使用Spring Boot的WebSocket透穿yjs的二进制update数据性能更优,支持高并发。

并且也可以管理房间内的成员,比如修改权限等等,前提是私密的房间。如果是公开的房间就不需要进行房间成员的管理了。、

编辑

编辑

团队空间

本项目有公共空间和团队空间之分,所谓公共空间就比如你创建了一个图表到公共空间里面,那么所有的人都能在图表广场看到你所创建的图表,除非你创建一个私有空间或者是团队空间。

编辑

编辑

并且团队空间分为普通版专业版和旗舰版三个等级,区别就在于可以创建的图表数量不同,旗舰版最多。

同时团队空间也是基于RBAC的权限控制的。

编辑

编辑

同时可以编辑团队空间内的图表和空间信息(管理员),也可以在本团队空间之内创建图表。

也可以通过用户id邀请其他用户加入到本团队空间内。在空间管理页面也分为我创建的空间和我加入的空间。

空间管理

编辑

协作房间管理

编辑

图表管理

编辑

开源与贡献

各位大佬可以在GitHub提交PR。

或者是将完整的前后端项目拉取到本地运行

后端的配置文件格式如下:

spring:
  application:
    name: drawio-backend
  mail:
    host:   # 您的SMTP服务器地址
    port:                   # 您的SMTP服务器端口
    username:  # 您的邮箱账户
    password:     # 您的邮箱密码或授权码
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: 
            client-secret: 
            scope: read:user,user:email
            redirect-uri: 
            client-name: Intellidraw 智能绘图
            provider: github
        provider:
          github:
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user
            user-name-attribute: login
  ai:
    custom:
      models:
        moonshotai:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        deepseek:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        glm:
          api-key: 
          model: glm-4.6
        qwen:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
        duobao:
          api-key: 
          base-url: https://api.qnaigc.com
          model: moonshotai/kimi-k2.5
    openai:
      api-key: 
      base-url: 
      chat:
        options:
          model: 
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    username: 
    url: 
    password: 
    driver-class-name: com.mysql.cj.jdbc.Driver
    # druid 连接池管理
    druid:
      # 初始化时建立物理连接的个数
      initial-size: 5
      # 最小连接池数量
      min-idle: 5
      # 最大连接池数量
      max-active: 20
      # 获取连接等待超时的时间
      max-wait: 60000
      # 一个连接在池中最小的生存的时间
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 30000
      validation-query: select 'x'
      test-on-borrow: false
      test-on-return: false
      pool-prepared-statements: false
      filters: stat,wall,slf4j
      max-pool-prepared-statement-per-connection-size: -1
      use-global-data-source-stat: true
      connection-properties: 'druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000'

server:
  port: 8081
  servlet:
    context-path: /api
rustfs:
  client:
    endpoint: 
    access-key: 
    acess-secret: 
    bucket-name: 


management:
  endpoints:
    web:
      exposure:
        include: health, prometheus
  metrics:
    distribution:
      percentiles:
        http:
          server:
            requests: 0.5, 0.75, 0.9, 0.95, 0.99
  1. Fork 仓库 ➜ 点击 GitHub 右上角 Fork 按钮。
  2. 创建分支 ➜ 推荐使用有意义的分支名
  3. 提交代码 ➜ 确保代码可读性高,符合规范。
  4. 提交 Pull Request(PR) ➜ 详细描述您的更改内容,并关联相关 issue(如有)。
  5. 等待审核 ➜ 维护者会进行代码审核并合并。

以上讲解如果对你有帮助,不妨给我的项目点个小小的 star 🌟,成为一下我的精神股东呢

❌
❌