普通视图

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

📰 前端资讯 - 2025年12月10日

作者 ZED
2025年12月12日 09:58

🔒 安全警告(重要)

React Server Components 严重安全漏洞:React2Shell(CVE-2025-55182)

  • React 团队披露了影响 React Server Components(RSC)的严重安全漏洞,被社区命名为 “React2Shell”,部分 Next.js 应用和使用 RSC 的框架都可能受影响。
  • Vercel 提供了针对 Next.js 用户的排查与升级指引,Cloudflare 在尝试提供自动防护时一度引发约 25 分钟的服务中断,说明云端防护与应用层安全之间的复杂互动。
  • 建议:尽快查看所用框架的安全公告和升级指南,评估是否启用 RSC、是否暴露潜在攻击面,并在升级前后做好监控与回滚策略。
  • 原文链接:React2Shell 官方站点React 团队安全公告Vercel 针对 Next.js 的安全通告Cloudflare 相关事件报道

Node.js 计划于 12 月 15 日发布多条安全更新

  • Node.js 官方预计在 2025 年 12 月 15 日左右对 25.x、24.x、22.x 和 20.x 多个版本分支发布安全更新,涉及 5 个漏洞,其中 3 个为高危级别。
  • 这些更新会影响当前在生产环境中广泛使用的长期支持版本,建议提前关注发布说明、规划升级窗口并确保 CI/CD 流程覆盖关键路径回归测试。
  • 原文链接:December 2025 Security Releases 预告

npm 供应链与发布安全:Token 管理与工作流加固

  • GitHub 宣布撤销所有旧版 npm classic tokens,并引入 2 小时会话 token 与粒度更细的访问 token,以降低长期泄露风险。Seattle Times 工程团队分享了如何借助 pnpm 的客户端安全控制来增强依赖供应链防护。
  • 社区文章《No More Tokens》提倡彻底梳理 npm 发布链路:限制谁能发包、在哪里登录、如何减少持久 token 使用,并给出了多种锁定发布工作流的实践建议;Liran Tal 进一步补充了从组织维度制定 npm 安全策略的最佳实践。
  • 建议:审计组织中所有 npm token 和发布脚本,转向短时/粒度访问 token,限制发包机器和账号,必要时引入独立发布服务账户和双人审批流程。
  • 原文链接:GitHub 关于 npm token 的更新新闻编辑部如何防御 npm 供应链攻击No More Tokens: Locking Down npm Publishing Workflowsnpm 安全最佳实践(Liran Tal)

🎉 重要里程碑

JavaScript 诞生 30 周年

  • 1995 年 12 月 4 日,Netscape 与 Sun 在新闻稿中正式宣布 “JavaScript”,至今已 30 年,这门最初在 10 天内完成原型的脚本语言,已经成为 Web 平台和大量应用生态的核心。
  • 本期回顾了当年新闻稿及 JavaScript 从 Mocha、LiveScript 到今天的演进,也提到围绕 JavaScript 商标的长期纠纷依然未完全解决。
  • 原文链接:JavaScript 30 周年回顾1995 年官方新闻稿 PDF

Let’s Encrypt 十周年与 HTTPS 普及

  • Let’s Encrypt 已经运行十年,免费证书服务从早期推广 HTTPS 到如今即将覆盖近 10 亿站点,对 Web 安全和隐私起到了基础设施级的推动作用。
  • 文章通过数据对比展示了十年来 HTTPS 覆盖度的显著提升,以及自动化证书续期对开发者与运营效率的改善。
  • 原文链接:Ten Years of Let’s Encrypt

AV1 视频编码获得艾美奖

  • 开源视频编解码标准 AV1 因其对全球在线视频分发的影响,获得技术类艾美奖,体现了开放标准在画质与带宽效率上的长期价值。
  • 对前端与流媒体平台而言,AV1 的普及意味着在同等带宽下能提供更高质量的播放体验,同时也推动浏览器与硬件解码能力持续演进。
  • 原文链接:Alliance for Open Media Wins Emmy for AV1

📦 版本更新

React 19.2:进一步优化 INP 响应性能

  • 文章重点讨论了 React 19.2 在 Interaction to Next Paint(INP)指标上的改进,包括对交互调度、更新中断与调和策略的优化,以及相关调试工具的增强。
  • 对大规模交互页面,合理利用这些新机制可以降低长任务与卡顿概率,改善用户感知的响应速度。
  • 原文链接:React 19.2 Further Advances INP OptimizationINP 指标介绍

前端与全栈工具链重要发布

  • Vite 8 Beta

    • 版本号:v8.0.0-beta
    • 更新说明:构建管线迁移至 Rolldown,显著加快生产构建速度,并为后续能力扩展打下基础。
    • 原文链接:Announcing Vite 8 Beta
  • React Datepicker 9.0

    • 版本号:v9.0
    • 更新说明:新增时区支持、跨日期范围选择时间,以及一批可访问性与自定义能力相关的属性(可参考项目仓库和发布说明)。
    • 原文链接:React Datepicker 项目主页
  • Ant Design 6.x

    • 版本号:v6.x(本期聚焦 6 系列持续演进)
    • 更新说明:作为广泛使用的 React 组件库,新版本继续完善组件体验与设计语言细节。
    • 原文链接:Ant Design 更新日志

其他库更新

  • React Grid Layout 2.0:响应式拖拽布局系统,适用于仪表盘类应用。原文链接
  • Yet Another React Lightbox 3.27:现代 React Lightbox 组件更新。原文链接
  • react-geo v32.7.0:用于构建地图应用的 React 组件集。原文链接
  • Jotai 2.16:轻量灵活的原子化状态管理库更新。原文链接
  • jsdom 27.3:WHATWG DOM/HTML 标准的纯 JS 实现更新。原文链接
  • Prisma 7.1:流行的 Node.js/TypeScript ORM 版本更新。原文链接
  • pnpm 10.25:高效包管理器新版本,侧重性能与安全控制。原文链接
  • Prettier 3.7:主流代码格式化工具的版本更新。原文链接
  • Drizzle ORM 0.45:类型友好的 ORM 更新。原文链接
  • Express 5.2.x:经典 Node.js Web 框架小版本更新。原文链接
  • Neutralinojs 6.4:比 Electron 更轻量的桌面应用解决方案更新。原文链接
  • Chokidar 5.0:跨平台文件监听库更新。原文链接

🚀 技术动态

TypeScript 7:Go 重写带来的 10 倍编译性能展望

  • TypeScript 6.0 将是最后一个以 JavaScript 实现的版本,之后的 TypeScript 7.0 将基于 Go 重写,目标是实现约 10 倍的性能提升。
  • 文章介绍了迁移路线:6.0 作为“桥接版本”,未来 Go 版编译器将兼容现有工具生态,同时为更快的增量构建和大型代码库提供支持。
  • 原文链接:Progress on TypeScript 7 – December 2025

WebGPU 已在主流浏览器全面可用

  • WebGPU 现已在所有主要浏览器中获得支持,为前端带来更现代的 GPU 编程能力,可用于高性能 3D 渲染、科学可视化和机器学习推理等场景。
  • 相比 WebGL,WebGPU 抽象更贴近现代图形 API,支持更高效的资源管理与并行计算,为浏览器内高性能计算打开新空间。
  • 原文链接:WebGPU is now supported across all major browsers

Bun 被 Anthropic 收购:AI 驱动时代的 JS Runtime 走向

  • Anthropic(Claude 背后的公司)收购了 JavaScript/TypeScript 运行时 Bun,计划用于其 Claude Code 等智能开发工具,Bun 仍将保持开源并持续演进。
  • Bun 基于 JavaScriptCore,集成打包、测试和运行时能力,目标是“开箱即用”的高性能开发体验,这次收购也凸显了 AI 工具对基础运行环境的重视。
  • 原文链接:Bun joins Anthropic

Delayed Message Timing API 提案:提升多上下文 Web 应用性能

  • 面对多 iframe、Web Worker、多窗口等并行上下文带来的性能问题,微软和社区提出 Delayed Message Timing API,希望在浏览器层面更好地调度与测量延迟消息。
  • 此提案仍处于早期阶段,但为复杂 Web 应用在跨上下文通信和延迟诊断方面提供了新的思路。
  • 原文链接:Delayed Message Timing API 提案说明

🛠️ 工具与库

React Grab:面向 Agent 的 React 组件上下文抓取工具

  • 工具名称:React Grab
  • 功能描述:为编码智能体(如 AI 助手)提供 React 组件上下文抓取能力,使其能够理解组件层级、props/state 结构,从而在有上下文的前提下修改代码。

TanStack AI & TanStack Pacer:统一的 LLM 接口与节流/限流工具集

  • 工具名称:TanStack AI

  • 功能描述:提供统一、框架无关的接口,适配多家 LLM/AI 提供商,支持流式输出与基于 Zod 的模式推断,React 集成示例包含一个完整的聊天应用。

  • 工具名称:TanStack Pacer

  • 功能描述:框架无关的函数级“节奏控制”工具集,提供防抖、节流、限流、队列和批处理等能力,适用于手写 API 调用节流逻辑或与智能体集成。

Remend & Streamdown:更稳健的流式 Markdown 渲染

  • 工具名称:Remend
  • 功能描述:用于自动修复不完整的流式 Markdown,特别适用于处理 LLM 输出中被中断或截断的片段,避免前端渲染出错。

性能与调试相关工具

  • Browser Score:用于检测浏览器对 Web 平台特性的支持度,可按 CSS 属性、选择器、实验特性等维度过滤结果。原文链接
  • Telescope:跨浏览器 Web 性能测试代理,支持 Edge、Chrome、Safari、Firefox,可配置网络限速、视口大小并生成 HTML 报告。(原始仓库尚未稳定公开,可关注 Cloudflare 相关开源项目与文档)
  • Tinybench 6.0:跨运行时的微基准测试库,基于高精度计时 API,支持按次数或时长运行并输出统计数据。原文链接

Node.js 与数据相关工具

  • ts-exec:由 AdonisJS 作者推出的 TypeScript 执行工具,基于 SWC,在 Node 中直接运行 TS,支持 JSX 和装饰器(官方资料仍在完善,可关注作者后续发布)。
  • iceberg-js:面向 Apache Iceberg REST Catalog API 的 JavaScript 客户端(由 Supabase 发布,生态仍在演进,可关注 Supabase 官方博客与 GitHub)。

UI 组件与前端资源

  • SurveyJS:可在应用内渲染品牌化、可配置表单 UI 的库,支持从现有数据库填充字段以及导出 PDF,适合医疗表单、申请表等场景。原文链接
  • NativeWindUI:为 React Native 提供 30+ 可复用组件与界面流,基于 NativeWind 构建,主打原生风格与快速开发体验。原文链接
  • CanIUse Embed:用于在站点中嵌入 Can I Use 兼容性表格的工具(项目仍在演进,可参考 Ire Aderinokun 的 Can I Use Embed 文章与相关开源实现)。

📖 技术文章

性能优化与 Web 运行时

  • How Fast Can Browsers Process Base64 Data?:通过在多浏览器中基准测试 Base64 处理速度,发现现代浏览器中性能通常可达到 GB/s 等级,Firefox/Servo 目前相对较慢但有优化计划。原文链接
  • How We Made @platformatic/kafka 223% Faster:Platformatic 团队对 Kafka 客户端进行系统性基准测试与优化(包括 I/O、批量发送等),展示了如何挖掘 Node.js 服务端应用的性能瓶颈。原文链接
  • Comparing AWS Lambda Arm vs x86 Performance Across Runtimes:在多版本 Node.js 与不同架构(Arm/x86)下对 Lambda 进行对比测试,结果表明 Arm 在多数场景下具有明显吞吐与成本优势。原文链接
  • The Anatomy of a Web Performance Report:拆解性能报告的典型结构,解释各指标背后的含义及其对产品决策的启示(原文发表于 Web Performance Calendar,当前公开链接较难检索,可关注 calendar.perfplanet.com 2025 年专栏)。

JavaScript 与类型系统

  • The Nuances of JavaScript Typing Using JSDoc:面向“不想用 TypeScript,但又想要类型提示”的团队,系统介绍如何使用 JSDoc 在 JS 项目中获得类似 TS 的类型体验(原文发表于 thathtml.blog,当前公开链接受限,可参考 TypeScript 官方 JSDoc 文档)。
  • TypeScript Strictness is Non-Monotonic:分析 strictNullChecksnoImplicitAny 等严格选项之间的交互,指出“更严格”未必在所有维度上单调提高安全性,需要按项目策略组合配置。原文链接
  • Category Theory for JavaScript Developers:以 JavaScript/TypeScript 为载体讲解范畴论的基本概念,为理解函数式编程中的抽象提供直观切入点。原文链接

HTML/CSS 与可访问性

  • CSS Wrapped 2025:回顾 2025 年 Web 平台新增的 CSS 能力,如 CSS Carousel、自定义 select、scroll state 查询等,并配有一段较为轻松的视频概览。原文链接
  • Did You Know Your Browser Has Two Accessibility Trees?:从浏览器 DOM 树与无障碍树的关系入手,讲解辅助技术如何“看见”页面,可结合 MDN 的无障碍树文档与 Max Design 的相关演讲阅读。MDN 介绍Max Design Talks
  • Accessible by Design: The Role of the lang Attribute:强调 HTML lang 属性在可访问性中的基础作用,指出其对屏幕阅读器语音选择、拼写检查等的影响。原文链接
  • NoLoJS: Reducing the JS Workload with HTML and CSS:鼓励在现代 HTML & CSS 能力增强的背景下,重新审视一些“理所当然”的 JS 实现模式,用更少的 JS 减轻性能和维护负担。原文链接

框架与工程实践

  • Building a Monorepo-Based Next.js App with Prisma:演示如何在 Monorepo 架构下统一前后端数据模型,结合 Next.js 与 Prisma 组织共享逻辑(可参考 Prisma 官方 Monorepo 指南)。参考文档
  • Next.js Image Optimization with the next/image Component:系统梳理 next/image 的最佳实践,涵盖懒加载、响应式图片和 CDN 配合等。官方文档
  • Some Do's and Don'ts of useEffectEvent:总结 React 新 API useEffectEvent 的使用建议与误用陷阱,帮助避免难以察觉的行为差异。参考文章
  • How We're Protecting Our Newsroom from npm Supply Chain Attacks:以媒体组织为例,展示如何用 pnpm 与严格的依赖管理策略降低供应链攻击风险。原文链接
  • No More Tokens: Locking Down npm Publishing Workflows:从个人与项目视角梳理 npm 发布链路,提出“去 token 化”的工作流设计思路。原文链接

🎄 12 月活动与资源

Advent of Code 2025

  • 著名的年度编程解谜活动,适合练习算法与数据结构,也常被用作团队/社区内部的编程打卡活动。
  • 原文链接:Advent of Code 2025

AdventJS:JavaScript 专题 Advent 挑战

  • 以 JavaScript/TypeScript/Python 题目为主的线上 Advent 挑战,可在浏览器中直接完成练习,适合前端和 Node.js 开发者日常刷题。
  • 原文链接:AdventJS

Advent of Svelte:Svelte 技巧每日一更

  • 面向 Svelte 社区的 Advent 活动,连续 24 天发布与 Svelte 相关的实践技巧与小知识点。
  • 原文链接:Advent of Svelte 2025

Cypress vs Playwright Advent 日历

🌐 生态系统动态

GitHub Spec Kit 与 Spec-Driven Development(SDD)实践

  • 文章分享了如何使用 GitHub 的 Spec Kit 构建现代 Svelte 应用,采用“规范驱动开发”的方式,让人类与 AI 协同围绕需求文档与规格共同演进代码。
  • 对希望系统性引入 AI 到日常开发流程的团队具有一定参考价值。
  • 原文链接:GitHub Spec Kit 官方文档

DebugBear 的 2025 Web 性能回顾

  • DebugBear 团队梳理了 2025 年与 Web 性能相关的重要话题,包括 DevTools 增强、TTFB/LCP/INP 指标统计方式变化以及 Firefox 对 Scheduler API 的支持情况。
  • 有助于理解“指标如何影响工具和框架设计”,为长期性能规划提供上下文。
  • 原文链接:2025 in Web Performance

VS Code Insiders Podcast 上线

  • 微软推出了 VS Code Insiders 播客节目,由 VS Code 团队成员从“发布说明之外”的角度探讨编辑器特性与生态发展。
  • 对重度 VS Code 用户和扩展开发者来说,这是了解产品方向与思路的一个新窗口。
  • 原文链接:VS Code Insiders Podcast 官网

Kagi Orion 浏览器 1.0 发布

  • 搜索引擎 Kagi 发布以 WebKit 为内核的 Orion 浏览器 1.0,区别于当前主流的 Chromium 阵营,为桌面浏览体验提供新的选择。
  • 原文链接:Orion 浏览器 1.0 发布介绍

核心要点:

  1. ⚠️ React Server Components 出现严重安全漏洞 React2Shell(CVE-2025-55182),涉及多框架与云厂商,需尽快排查升级。
  2. ⚠️ Node.js 计划在 12 月 15 日前后为多个版本分支发布高危安全修复,建议提前规划升级窗口与回归测试。
  3. 🔐 npm 发布与供应链安全成为焦点,GitHub 撤销旧版 token,多篇文章给出从个人到组织的安全加固实践。
  4. 🎉 JavaScript 迎来 30 周年、Let’s Encrypt 与 AV1 分别在 HTTPS 与视频编码领域取得里程碑式进展。
  5. 📦 构建与运行时生态持续演进:Vite 8 Beta、React Datepicker 9.0、Ant Design 6.x 及多款核心库完成重要更新。
  6. 🚀 TypeScript 7 将以 Go 重写编译器,带来显著性能提升;WebGPU 也在主流浏览器全面可用,为浏览器内高性能计算打开新空间。
  7. 🛠️ 新工具集中涌现:React Grab、TanStack AI/Pacer、Remend/Streamdown、Browser Score、Telescope 等帮助开发者和 AI 更好地理解与优化前端应用。
  8. 📖 多篇文章聚焦性能优化、类型系统与可访问性,为 JS/TS、React、Next.js 以及 Web 平台开发提供系统性实践参考。
  9. 🎄 Advent of Code、AdventJS、Advent of Svelte 与 Cypress vs Playwright Advent 日历,为 12 月提供了丰富的学习与练习资源。
  10. 🌐 工具链与生态层面,GitHub Spec Kit、DebugBear 性能年度回顾、VS Code Insiders Podcast 与 Orion 浏览器共同勾勒出 2025 年 Web 开发现状与趋势。

Vite 到底能干嘛?为什么 Vue3 官方推荐它做工程化工具?

作者 刘大华
2025年12月12日 08:26

很多朋友在开发项目的时候其实都有用过Vite,也知道它是现代化构建工具,但却不清楚它是怎么用的。

只是知道项目里集成了Vite,开发的时候启动很快,配置文件也很清晰,但很少去了解它是什么,起到了什么作用。

这篇文章我们就来了解一下。

一、Vite 是什么?

它的名字来源于法语单词"vite",意思是"快速",由 Vue.js 作者尤雨溪开发的一款现代化前端构建工具

它的目标很简单:让开发体验更快、更简单

Vite不仅支持Vue,还原生支持React、Svelte、Preact、Lit等主流框架,甚至可以用于纯 HTML + JavaScript 项目。


二、Vite 的核心优势

Vite之所以火,是因为它解决了传统构建工具的几个痛点:

启动极快 开发服务器启动几乎秒开,不打包!

热更新飞快 修改代码后,浏览器只更新改动的部分,毫秒级响应

原生 ES 模块支持 直接利用现代浏览器的 ES Module 能力

零配置上手 创建项目只需一条命令,无需复杂配置

插件生态丰富 兼容 Rollup 插件,生态强大

开箱即用的丰富功能 支持 TypeScript、JSX、CSS 预处理器等,无需复杂配置。


三、与传统工具(如 Webpack)对比

1. 工作方式的根本不同

Webpack:开发时会先打包整个项目(把所有 JS、CSS 合并成 bundle),然后启动服务器。项目越大,打包越慢。

Vite:开发时不打包!直接利用浏览器原生支持的 ES 模块(<script type="module">),按需加载文件。

2. 举个实际例子

假设你有一个简单的项目结构:

src/
├── main.js
└── utils.js

使用 Webpack(传统方式)

// utils.js
export const add = (a, b) => a + b;

// main.js
import { add } from './utils.js';
console.log(add(1, 2));

Webpack 会: 1.把main.jsutils.js打包成一个bundle.js 2.启动本地服务器,返回这个bundle 3.浏览器加载整个bundle

即使你只改了utils.js中的一行,Webpack也要重新分析依赖、重新打包整个应用(虽然有缓存优化,但仍有延迟)。

使用 Vite

Vite 在开发时直接生成这样的 HTML:

<!-- index.html -->
<script type="module" src="/src/main.js"></script>

浏览器会: 1.请求 /src/main.js 2.发现里面 import './utils.js',自动再请求 /src/utils.js 3.按需加载,无需打包!

当你修改utils.js,Vite只告诉浏览器:“喂,utils.js更新了”, 浏览器只重新加载这一个文件,热更新速度接近实时

Vite 在开发阶段用原生 ESM,在生产阶段会用 Rollup 打包,兼顾速度和兼容性。


四、Vite 的工作原理:为什么这么快?

关键就两点:

1. 利用现代浏览器的原生 ES 模块(ESM)

现代浏览器(Chrome、Firefox、Edge 等)早就支持 <script type="module">,可以直接 import/export 模块,无需打包。

Vite 直接把这个能力用起来——开发时不打包,让浏览器自己去加载模块

2. 依赖预构建(Dependency Pre-bundling)

你可能会问:那 node_modules 里的包怎么办?它们很多不是 ESM 格式啊!

Vite会在首次启动时,用 esbuild(超快的 JS 打包器)把 node_modules 里的依赖预构建为 ESM 格式,并缓存起来。

esbuild 是用 Go 写的,比 Webpack 快 10~100 倍!

预构建只做一次,后续开发直接用缓存

这样既保证了兼容性,又不影响开发速度。


五、如何使用 Vite?

1. 创建项目

# 使用 npm
npm create vite@latest my-vue-app -- --template vue

# 使用 yarn
yarn create vite my-react-app --template react

# 使用 pnpm
pnpm create vite my-vanilla-app --template vanilla

2. 项目结构

my-vite-project/
├── index.html
├── package.json
├── vite.config.js
├── public/
│   └── favicon.ico
└── src/
    ├── main.js
    ├── App.vue
    ├── components/
    └── styles/

3. 开发服务器

# 进入项目目录
cd my-vue-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

访问 http://localhost:5173 即可看到你的应用!

在这里插入图片描述

4. 基础配置示例

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  // 插件配置
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 5173,
    open: true // 自动打开浏览器
  },
  
  // 构建配置
  build: {
    outDir: 'dist',
    sourcemap: true
  },
  
  // 路径别名
  resolve: {
    alias: {
      '@': '/src'
    }
  }
})

5. 完整的Vue组件示例

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>我的Vite应用</title>
</head>
<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
</body>
</html>
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

createApp(App).mount('#app')
<!-- src/App.vue -->
<template>
  <div class="app">
    <h1>欢迎使用 Vite!</h1>
    <p>当前计数: {{ count }}</p>
    <button @click="increment">点击我</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

<style>
.app {
  text-align: center;
  padding: 2rem;
}

button {
  padding: 10px 20px;
  font-size: 16px;
  cursor: pointer;
}
</style>
/* src/style.css */
body {
  margin: 0;
  font-family: Arial, sans-serif;
  background-color: #f5f5f5;
}

* {
  box-sizing: border-box;
}

6. 构建生产版本

# 构建生产版本
npm run build

# 预览生产版本
npm run preview

结语

总的来说,Vite 通过利用现代浏览器的原生能力,在开发阶段省去了打包步骤,大大提升了开发效率。同时,它配置简单、上手容易,还拥有强大的插件生态,非常适合现代前端项目。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《如何查看 SpringBoot 当前线程数?3 种方法亲测有效》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

PDF.js 在 Vue 中的使用指南

2025年12月11日 23:32

环境准备

1. 安装依赖

npm install pdfjs-dist

2. 配置 Worker

PDF.js 需要 Worker 来处理 PDF 解析。推荐使用本地 Worker 文件:

步骤 1:复制 Worker 文件到 public 目录

# 从 node_modules 复制 worker 文件到 public 目录
copy node_modules\pdfjs-dist\build\pdf.worker.min.mjs public\pdf.worker.min.mjs

步骤 2:在代码中配置 Worker

import * as pdfjsLib from 'pdfjs-dist'

// 方式1: 使用本地 worker 文件(推荐,稳定可靠)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

// 方式2: 使用 CDN(备选方案,需要网络连接)
// pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`

3. Vite 配置(可选)

如果需要支持 ES2015 打包场景,在 vite.config.ts 中添加:

export default defineConfig({
  // ... 其他配置
  build: {
    target: 'es2015',
    rollupOptions: {
      output: {
        format: 'es'
      }
    }
  },
  optimizeDeps: {
    include: ['pdfjs-dist', 'pdfjs-dist/web/pdf_viewer.mjs']
  }
})

方式一:单页查看器(基础版)

特点

  • ✅ 单页显示,分页导航
  • ✅ 支持缩放功能
  • ✅ 代码简单,易于理解
  • ❌ 不支持文本选择和复制
  • ❌ 一次只显示一页

核心代码

<template>
  <div class="pdf-viewer-container">
    <!-- 文件上传 -->
    <el-upload :on-change="handleFileChange">
      <el-button>选择 PDF 文件</el-button>
    </el-upload>
    <!-- 控制按钮 -->
    <div v-if="pdfDoc">
      <el-button @click="previousPage">上一页</el-button>
      <el-button>{{ currentPage }} / {{ totalPages }}</el-button>
      <el-button @click="nextPage">下一页</el-button>
      <el-button @click="zoomIn">放大</el-button>
      <el-button @click="zoomOut">缩小</el-button>
    </div>
    <!-- Canvas 渲染 -->
    <div class="pdf-viewer">
      <canvas ref="pdfCanvas"></canvas>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'

// 设置 worker
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

// 响应式数据
const pdfDoc = ref<any>(null)
const currentPage = ref(1)
const totalPages = ref(0)
const scale = ref(1.5)
const pdfCanvas = ref<HTMLCanvasElement | null>(null)

// 加载 PDF
const loadPdf = async (data: Uint8Array) => {
  const loadingTask = pdfjsLib.getDocument({ data })
  pdfDoc.value = await loadingTask.promise
  totalPages.value = pdfDoc.value.numPages
  await renderPage(1)
}

// 渲染页面
const renderPage = async (pageNum: number) => {
  const page = await pdfDoc.value.getPage(pageNum)
  const viewport = page.getViewport({ scale: scale.value })
  
  const canvas = pdfCanvas.value
  if (!canvas) return
  
  canvas.height = viewport.height
  canvas.width = viewport.width
  
  const context = canvas.getContext('2d')
  await page.render({
    canvasContext: context,
    viewport: viewport
  }).promise
}
</script>

演示

适用场景

  • 简单的 PDF 预览需求
  • 只需要单页查看
  • 不需要文本选择功能

方式二:多页查看器(性能优化版)

特点

  • ✅ 显示所有页面,垂直滚动
  • ✅ 懒加载优化(只渲染可见页面)
  • ✅ 支持文本选择和复制
  • ✅ 性能优化,适合大文档
  • ✅ 使用 Intersection Observer 实现懒加载

核心代码

<template>
  <div class="pdf-viewer-container">
    <!-- 文件上传 -->
    <el-upload :on-change="handleFileChange">
      <el-button>选择 PDF 文件</el-button>
    </el-upload>
    <!-- 所有页面容器 -->
    <div class="pdf-viewer" ref="pdfViewer">
      <div v-for="pageNum in totalPages" :key="pageNum" :data-page="pageNum">
        <div class="page-header">第 {{ pageNum }} 页</div>
        <div class="page-content">
          <canvas class="page-canvas"></canvas>
          <div class="text-layer"></div>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'

// 懒加载相关
const renderingPages = ref<Set<number>>(new Set())
const renderedPages = ref<Set<number>>(new Set())
let intersectionObserver: IntersectionObserver | null = null

// 初始化懒加载
const initLazyLoading = async () => {
  intersectionObserver = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const pageNum = parseInt(entry.target.getAttribute('data-page') || '0')
          if (pageNum > 0 && !renderedPages.value.has(pageNum)) {
            renderPage(pageNum)
          }
        }
      })
    },
    {
      rootMargin: '200px 0px', // 提前 200px 开始加载
      threshold: 0.01
    }
  )

  // 观察所有页面容器
  for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
    const pageWrapper = pdfViewer.value?.querySelector(`[data-page="${pageNum}"]`)
    if (pageWrapper) {
      intersectionObserver.observe(pageWrapper)
    }
  }

  // 立即渲染前几页
  for (let pageNum = 1; pageNum <= Math.min(3, totalPages.value); pageNum++) {
    renderPage(pageNum)
  }
}

// 渲染单个页面(包含文本层)
const renderPage = async (pageNum: number) => {
  if (renderingPages.value.has(pageNum) || renderedPages.value.has(pageNum)) {
    return
  }

  renderingPages.value.add(pageNum)

  const pageWrapper = pdfViewer.value?.querySelector(`[data-page="${pageNum}"]`)
  const canvas = pageWrapper?.querySelector('canvas') as HTMLCanvasElement
  const textLayer = pageWrapper?.querySelector('.text-layer') as HTMLElement

  const page = await pdfDoc.value.getPage(pageNum)
  const viewport = page.getViewport({ scale: scale.value })

  // 渲染 Canvas
  canvas.height = viewport.height
  canvas.width = viewport.width
  const context = canvas.getContext('2d')
  await page.render({
    canvasContext: context,
    viewport: viewport
  }).promise

  // 渲染文本层(用于文字选择和复制)
  const textContent = await page.getTextContent()
  // ... 文本层渲染逻辑

  renderingPages.value.delete(pageNum)
  renderedPages.value.add(pageNum)
}
</script>

性能优化要点

  1. 懒加载:使用 Intersection Observer 只渲染可见页面
  2. 分批渲染:缩放时按批次重新渲染,避免阻塞 UI
  3. 状态管理:跟踪已渲染页面,避免重复渲染

演示

适用场景

  • 需要查看所有页面
  • 大文档(100+ 页)
  • 需要文本选择和复制
  • 需要性能优化

方式三:官方组件化查看器(推荐)

特点

  • ✅ 使用 PDF.js 官方组件(PDFViewer、PDFLinkService)
  • ✅ 自动文本层和注释层渲染
  • ✅ 完整的事件系统
  • ✅ 官方维护,稳定可靠
  • ✅ 可自定义样式和行为
  • 这是 PDF.js 官方推荐的生产环境使用方式

核心代码

<template>
  <div class="pdf-viewer-container">
    <div class="pdf-controls">
      <el-upload :on-change="handleFileChange">
        <el-button>选择 PDF 文件</el-button>
      </el-upload>
    </div>
    <div class="pdf-viewer-wrapper">
      <div ref="pdfViewerContainer" class="pdf-viewer-container-inner"></div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import { PDFViewer, EventBus, PDFLinkService } from 'pdfjs-dist/web/pdf_viewer.mjs'
import 'pdfjs-dist/web/pdf_viewer.css'

// 设置 worker
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

let pdfViewer: PDFViewer | null = null
let eventBus: EventBus | null = null
const pdfViewerContainer = ref<HTMLElement | null>(null)

// 初始化 PDF 查看器
const initPDFViewer = async (pdfDocument: any) => {
  const container = pdfViewerContainer.value
  
  // 创建滚动容器(必须是绝对定位)
  const scrollContainer = document.createElement('div')
  scrollContainer.className = 'pdf-viewer-scroll'
  scrollContainer.style.position = 'absolute'
  scrollContainer.style.top = '0'
  scrollContainer.style.left = '0'
  scrollContainer.style.width = '100%'
  scrollContainer.style.height = '100%'
  scrollContainer.style.overflow = 'auto'
  
  // 创建查看器容器
  const viewer = document.createElement('div')
  viewer.className = 'pdfViewer'
  
  scrollContainer.appendChild(viewer)
  container.appendChild(scrollContainer)

  // 创建事件总线和链接服务
  eventBus = new EventBus()
  const linkService = new PDFLinkService({
    eventBus: eventBus,
    externalLinkTarget: 2
  })

  // 创建 PDFViewer 实例
  pdfViewer = new PDFViewer({
    container: scrollContainer,  // 外层滚动容器(必须绝对定位)
    viewer: viewer,              // 内层查看器容器
    eventBus: eventBus,
    linkService: linkService
  })

  // 设置 PDF 文档
  pdfViewer.setDocument(pdfDocument)

  // 监听事件
  eventBus.on('pagesinit', () => {
    totalPages.value = pdfViewer?.pagesCount || 0
  })

  eventBus.on('scalechanging', (evt: any) => {
    currentScale.value = evt.scale
  })
}

// 清理
onUnmounted(() => {
  if (pdfViewer) {
    pdfViewer.cleanup()
  }
})
</script>

重要配置

  1. Container 必须是绝对定位
scrollContainer.style.position = 'absolute'
  1. 需要两个容器
    • container: 外层滚动容器(绝对定位)
    • viewer: 内层查看器容器(.pdfViewer
  1. 导入样式
import 'pdfjs-dist/web/pdf_viewer.css'

演示

适用场景

  • 生产环境(最推荐)
  • 需要完整功能(文本选择、注释、链接)
  • 需要官方维护和更新
  • 需要自定义样式和行为

方式四:iframe 嵌入官方 HTML

特点

  • ✅ 直接使用官方 HTML 文件
  • ✅ 功能完整,无需额外开发
  • ❌ CDN 版本无法访问 Blob URL(跨域限制)
  • ❌ 样式隔离,难以自定义
  • ❌ 无法深度集成到 Vue 应用
  • ⚠️ 不推荐用于生产环境

核心代码

<template>
  <div class="pdf-viewer-container">
    <el-upload :on-change="handleFileChange">
      <el-button>选择 PDF 文件</el-button>
    </el-upload>
    <div class="pdf-viewer-wrapper">
      <iframe
        v-if="viewerUrl"
        :src="viewerUrl"
        class="pdf-viewer-iframe"
        frameborder="0"
      ></iframe>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'

// 设置 worker(注意:路径已更新)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-dist/build/pdf.worker.min.mjs'

const pdfUrl = ref<string>('')
const viewerUrl = ref<string>('')

// 检查本地查看器是否存在
const checkLocalViewer = async (): Promise<boolean> => {
  try {
    const response = await fetch('/pdfjs-dist/web/viewer.html', { method: 'HEAD' })
    return response.ok
  } catch {
    return false
  }
}

// 加载 PDF
const loadPdf = async (file: File) => {
  // 清理旧的 URL
  if (pdfUrl.value) {
    URL.revokeObjectURL(pdfUrl.value)
  }

  // 创建 Blob URL
  const blob = new Blob([file], { type: 'application/pdf' })
  const url = URL.createObjectURL(blob)
  pdfUrl.value = url

  // 检查本地查看器是否存在
  const hasLocalViewer = await checkLocalViewer()
  
  if (!hasLocalViewer) {
    // 本地查看器不存在,提示用户配置
    console.error('本地查看器未找到,请按照以下步骤配置:')
    console.error('1. 访问 https://github.com/mozilla/pdf.js/releases 下载最新版本')
    console.error('2. 解压后将 web 目录复制到 public/pdfjs-dist/web/')
    console.error('3. 确保 viewer.html 的路径是 /pdfjs-dist/web/viewer.html')
    return
  }

  // ✅ 必须使用本地查看器(避免跨域问题)
  // ⚠️ CDN 版本无法访问 Blob URL,会导致跨域错误
  const viewerPath = '/pdfjs-dist/web/viewer.html'
  const encodedUrl = encodeURIComponent(url)
  viewerUrl.value = `${viewerPath}?file=${encodedUrl}`
}

// 清理资源
onUnmounted(() => {
  if (pdfUrl.value) {
    URL.revokeObjectURL(pdfUrl.value)
  }
})
</script>

使用本地查看器

⚠️ 重要:必须使用本地查看器,CDN 版本无法访问 Blob URL(跨域限制)

  1. 下载 PDF.js 官方查看器:
    • 访问 github.com/mozilla/pdf…
    • 下载最新版本的预构建包(例如:pdfjs-5.4.449-dist.zip
    • 解压后找到 web 目录
  1. 复制到项目:
# 将 web 目录复制到 public/pdfjs-dist/web/
# 最终结构应该是:
public/
  └── pdfjs-dist/
      ├── build/
      │   └── pdf.worker.min.mjs
      └── web/
          ├── viewer.html
          ├── viewer.js
          ├── viewer.css
          └── ... (其他文件)
  1. 验证配置:
    • 启动开发服务器后,访问 http://localhost:xxxx/pdfjs-dist/web/viewer.html 应该能看到 PDF.js 官方查看器界面
    • 代码会自动检测本地查看器是否存在,如果不存在会提示配置步骤

演示

适用场景

  • 快速原型
  • 演示和测试
  • 不需要深度集成
  • ⚠️ 不推荐用于生产环境

最佳实践

1. Worker 配置

推荐:使用本地 Worker 文件

// ✅ 推荐方式1: 放在 public 根目录
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

// ✅ 推荐方式2: 放在 public/pdfjs-dist/build/ 目录(与官方查看器结构一致)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-dist/build/pdf.worker.min.mjs'

// ❌ 不推荐(可能有跨域问题)
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/...`

注意:如果使用方式四(iframe),建议将 worker 文件放在 public/pdfjs-dist/build/ 目录,与官方查看器结构保持一致。

2. 文件验证

始终验证 PDF 文件:

// 验证文件类型
if (file.type !== 'application/pdf' && !file.name.endsWith('.pdf')) {
  throw new Error('请选择 PDF 格式的文件')
}

// 验证 PDF 文件头
const header = String.fromCharCode(...uint8Array.slice(0, 4))
if (header !== '%PDF') {
  throw new Error('无效的 PDF 文件格式')
}

3. 错误处理

提供详细的错误信息:

try {
  await loadPdf(data)
} catch (error: any) {
  let errorMessage = 'PDF 加载失败'
  if (error?.name === 'InvalidPDFException') {
    errorMessage = '无效的 PDF 文件结构,请确认文件是否损坏'
  } else if (error?.name === 'MissingPDFException') {
    errorMessage = 'PDF 文件缺失或无法访问'
  }
  $message?.error(errorMessage)
}

4. 性能优化

  • 懒加载:使用 Intersection Observer 只渲染可见页面
  • 分批渲染:缩放时按批次重新渲染
  • 内存管理:及时清理 Blob URL 和查看器实例
// 清理 Blob URL
onUnmounted(() => {
  if (pdfUrl.value) {
    URL.revokeObjectURL(pdfUrl.value)
  }
  if (pdfViewer) {
    pdfViewer.cleanup()
  }
})

5. 文本选择支持

如果需要文本选择,必须添加文本层:

// 获取文本内容
const textContent = await page.getTextContent()

// 创建文本层元素
textContent.items.forEach((item: any) => {
  const span = document.createElement('span')
  span.textContent = item.str
  span.style.position = 'absolute'
  span.style.color = 'transparent' // 透明,不影响视觉效果
  span.style.cursor = 'text'
  textLayer.appendChild(span)
})

常见问题

Q1: Worker 加载失败

错误Setting up fake worker failed

解决方案

  1. 确保 worker 文件在 public 目录
  2. 使用本地 worker 文件而不是 CDN
  3. 检查路径是否正确

Q2: PDF 文件无法加载

错误Invalid PDF structure

解决方案

  1. 验证文件是否为有效的 PDF 格式

  2. 检查文件是否损坏

  3. 添加文件头验证

Q3: 打包后无法运行

错误:CORS 错误

解决方案

  1. 不要直接用 file:// 打开打包后的文件
  2. 使用 npm run preview 预览
  3. 或使用本地服务器(http-server、Python 等)

Q4: PDFViewer 初始化失败

错误Invalid container and/or viewer option

解决方案

  1. 确保 container 是绝对定位
  2. 提供 containerviewer 两个参数
  3. 等待 DOM 更新后再初始化

Q5: iframe 方式跨域错误

错误SecurityError: Failed to read a named property 'document' from 'Window': Blocked a frame with origin "https://mozilla.github.io" from accessing a cross-origin frame

原因

  • 使用 CDN 版本的官方查看器(https://mozilla.github.io
  • Blob URL 无法在跨域 iframe 中访问

解决方案

  1. 必须使用本地查看器(推荐)
    • 下载 PDF.js 官方查看器
    • web 目录复制到 public/pdfjs-dist/web/
    • 代码会自动检测本地查看器是否存在
  1. 路径配置
// ✅ 正确:使用本地查看器
const viewerPath = '/pdfjs-dist/web/viewer.html'

// ❌ 错误:CDN 版本无法访问 Blob URL
const viewerPath = 'https://mozilla.github.io/pdf.js/web/viewer.html'
  1. 验证配置
    • 访问 http://localhost:3016/pdfjs-dist/web/viewer.html 应该能看到查看器界面

    • 如果不存在,代码会提示配置步骤


总结

选择建议

场景 推荐方式
生产环境 方式三(官方组件化)
需要查看所有页面 方式二(多页查看器)
简单预览 方式一(单页查看器)
快速原型 方式四(iframe)

核心要点

  1. Worker 配置:使用本地 worker 文件
  2. 文件验证:始终验证 PDF 文件格式
  3. 错误处理:提供详细的错误信息
  4. 性能优化:使用懒加载和分批渲染
  5. 内存管理:及时清理资源
  6. 生产环境:推荐使用官方组件化方式

参考资源

Java里的空指针

2025年12月11日 23:23

空指针(NullPointerException)堪称Java程序员的“噩梦常客”,尤其是进阶路上的新手,总被一些看似无害的代码突然“背刺”。今天揪出3个高频空指针场景,附避坑代码,让你精准避雷~

1. 字符串拼接的“隐形炸弹”

当你用 + 拼接字符串时,若其中一个对象为 null ,不会报错;但调用 String.concat() 方法时, null 参数直接触发空指针!

public class NullConcatTest {
    public static void main(String[] args) {
        String str1 = "掘金";
        String str2 = null;

        // + 拼接:自动将null转为"null"字符串,安全
        System.out.println(str1 + str2); // 输出:掘金null

        // concat()方法:参数为null直接抛空指针
        try {
            System.out.println(str1.concat(str2));
        } catch (NullPointerException e) {
            System.out.println("触发空指针:concat()不接受null参数");
        }
    }
}

 

2. 包装类拆箱的“暗箭”

包装类自动拆箱时,若包装类对象为 null ,会直接抛出空指针——因为拆箱本质是调用 xxxValue() 方法, null 调用方法必炸!

public class UnboxingNullTest {
    public static void main(String[] args) {
        Integer num = null;

        // 自动拆箱:等价于num.intValue(),null调用方法触发空指针
        try {
            int result = num + 10;
        } catch (NullPointerException e) {
            System.out.println("触发空指针:包装类null不能拆箱");
        }
    }
}

 

3. 集合/数组的“越界+空值”双重坑

遍历集合时,若集合本身为 null ,调用 size() / get() 等方法直接空指针;若集合非空但索引越界,抛的是 IndexOutOfBoundsException ,别搞混!

import java.util.ArrayList;
import java.util.List;

public class CollectionNullTest {
    public static void main(String[] args) {
        List<String> list = null;

        // 集合为null时调用size():空指针
        try {
            System.out.println(list.size());
        } catch (NullPointerException e) {
            System.out.println("触发空指针:null集合不能调用方法");
        }

        // 集合非空但索引越界:不是空指针,是索引越界
        list = new ArrayList<>();
        list.add("Java");
        try {
            System.out.println(list.get(1));
        } catch (IndexOutOfBoundsException e) {
            System.out.println("触发索引越界:不是空指针!");
        }
    }
}

 

避坑小技巧

1. 判空优先:用 Objects.isNull() / Objects.nonNull() 替代手动 == null ,更优雅且避免漏判;

2. 拆箱前校验:包装类拆箱前先确认非 null ;

3. 工具类兜底:字符串拼接用 String.join() ,集合判空用 CollectionUtils.isEmpty() (Apache Commons)

空指针不可怕,只要摸清它的“藏身之处”,提前设防就能轻松拿捏~

HTML5新增特性有哪些

作者 代码猎人
2025年12月11日 23:23

一、语义化标签

新增了更具语义的 HTML 元素,提升页面结构和可访问性:

<!-- 结构标签 -->
<header>头部内容</header>
<nav>导航栏</nav>
<main>主内容</main>
<section>内容区块</section>
<article>独立文章</article>
<aside>侧边栏/附加内容</aside>
<footer>页脚</footer>

<!-- 文本语义标签 -->
<mark>高亮文本</mark>
<time datetime="2024-01-15">日期时间</time>
<figure>
  <img src="image.jpg" alt="示例">
  <figcaption>图片说明</figcaption>
</figure>
<details>
  <summary>展开/收起标题</summary>
  详细内容
</details>

二、多媒体支持

无需插件即可播放音视频:

<!-- 视频 -->
<video controls width="640">
  <source src="video.mp4" type="video/mp4">
  <source src="video.webm" type="video/webm">
  您的浏览器不支持 video 标签
</video>

<!-- 音频 -->
<audio controls>
  <source src="audio.mp3" type="audio/mpeg">
  <source src="audio.ogg" type="audio/ogg">
</audio>

三、Canvas 绘图

提供 JavaScript 绘图 API:

<canvas id="myCanvas" width="500" height="300"></canvas>
<script>
  const canvas = document.getElementById('myCanvas');
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'red';
  ctx.fillRect(10, 10, 100, 100);
</script>

四、SVG 支持

矢量图形支持:

<svg width="100" height="100">
  <circle cx="50" cy="50" r="40" fill="blue"/>
</svg>

五、表单增强

新增输入类型和属性:

<!-- 新输入类型 -->
<input type="email" placeholder="邮箱">
<input type="url" placeholder="网址">
<input type="number" min="1" max="10" step="1">
<input type="range" min="0" max="100">
<input type="date">
<input type="color">
<input type="search">

<!-- 新属性 -->
<input required>              <!-- 必填 -->
<input placeholder="提示">     <!-- 占位文本 -->
<input autofocus>            <!-- 自动聚焦 -->
<input pattern="[A-Za-z]{3}"><!-- 正则验证 -->
<datalist id="list">         <!-- 数据列表 -->
  <option value="选项1">
</datalist>
<input list="list">

六、本地存储

浏览器端数据存储:

// Web Storage
localStorage.setItem('key', 'value');  // 永久存储
sessionStorage.setItem('key', 'value'); // 会话存储

// Web SQL(已废弃)和 IndexedDB
// 更复杂的客户端数据库存储

七、Web Workers

多线程支持,避免阻塞 UI:

// main.js
const worker = new Worker('worker.js');
worker.postMessage('开始工作');
worker.onmessage = function(e) {
  console.log('收到结果:', e.data);
};

// worker.js
onmessage = function(e) {
  const result = e.data + '处理完成';
  postMessage(result);
};

八、WebSocket

全双工通信:

const socket = new WebSocket('ws://example.com');
socket.onopen = () => {
  socket.send('Hello Server!');
};
socket.onmessage = (event) => {
  console.log('收到消息:', event.data);
};

九、地理位置

获取用户位置:

if (navigator.geolocation) {
  navigator.geolocation.getCurrentPosition(
    (position) => {
      console.log('纬度:', position.coords.latitude);
      console.log('经度:', position.coords.longitude);
    },
    (error) => console.error('获取位置失败:', error)
  );
}

十、拖放 API

原生拖放支持:

<div draggable="true" id="dragElement">可拖拽元素</div>
<div id="dropZone">放置区域</div>

<script>
  dragElement.addEventListener('dragstart', (e) => {
    e.dataTransfer.setData('text/plain', '拖拽数据');
  });
  
  dropZone.addEventListener('dragover', (e) => {
    e.preventDefault(); // 允许放置
  });
  
  dropZone.addEventListener('drop', (e) => {
    e.preventDefault();
    const data = e.dataTransfer.getData('text/plain');
    dropZone.textContent = `收到: ${data}`;
  });
</script>

十一、其他重要特性

1. 离线应用

<!DOCTYPE html>
<html manifest="app.manifest">
<!-- manifest 文件定义缓存资源 -->

2. 历史管理

// pushState 和 replaceState
history.pushState({page: 1}, "title", "page1.html");
window.onpopstate = (event) => {
  console.log('状态变化:', event.state);
};

3. 页面可见性 API

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    console.log('页面被隐藏');
  } else {
    console.log('页面可见');
  }
});

4. 全屏 API

// 进入全屏
element.requestFullscreen();

// 退出全屏
document.exitFullscreen();

5. 微数据(Microdata)

<div itemscope itemtype="http://schema.org/Person">
  <span itemprop="name">张三</span>
  <span itemprop="jobTitle">前端工程师</span>
</div>

十二、废弃的元素

HTML5 移除了一些表现性元素:

  • <font><center><big><strike>
  • <frame><frameset><noframes>
  • <acronym><applet><basefont><dir>

总结

类别 主要特性
结构 语义化标签、新的文档结构
媒体 <audio>, <video>, <canvas>, SVG
表单 新的输入类型、验证属性、数据列表
存储 localStorage, sessionStorage, IndexedDB
通信 WebSocket, Web Workers, Server-Sent Events
设备API 地理位置、摄像头API、震动API等
图形 Canvas 2D, WebGL, SVG
性能 Web Workers, 应用缓存

HTML5 从单纯的标记语言演变为一个应用平台,提供了丰富的 API 和功能,支持构建复杂的 Web 应用。

iframe 事件无法冒泡到父窗口的解决方案

2025年12月11日 23:22

背景介绍

问题场景

在现代 Web 应用中,iframe 被广泛用于嵌入第三方内容、文档预览、富文本编辑器等场景。然而,iframe 作为一个独立的文档上下文,其内部的事件无法直接冒泡到父窗口,这导致了一些问题:

  1. 事件监听失效:父窗口无法监听到 iframe 内部的鼠标事件(如 mouseupmousedown
  2. 组件交互问题:某些依赖全局事件监听的组件(如拖拽组件、分割面板等)无法正常工作
  3. 用户体验影响:用户在 iframe 内操作时,父窗口的某些功能无法响应

实际案例

在聊天抽屉组件中,当内容区域包含 iframe(如 PDF 预览、Office 文档预览)时,用户在 iframe 内松开鼠标后,父窗口的 mouseup 事件监听器无法触发,导致依赖该事件的组件(如 splitpanes)无法正常工作。


解决思路

核心思路

将 iframe 内部的事件"转发"到父窗口,通过创建合成事件(Synthetic Event)使其能够在父窗口正常冒泡。

技术方案

1. 同源 iframe(推荐方案)

对于同源 iframe,可以直接访问其 contentDocument,在 iframe 内部添加事件监听器:

// 在 iframe 内部添加事件监听
iframe.contentDocument.addEventListener('mouseup', (e) => {
  // 通过 postMessage 发送事件数据到父窗口
  window.parent.postMessage({
    type: 'mouseup',
    clientX: e.clientX,
    // ... 其他事件属性
  }, '*');
});

2. 跨域 iframe(备选方案)

对于跨域 iframe,由于浏览器的同源策略限制,无法访问其 contentDocument。需要在 iframe 内部主动发送 postMessage

// iframe 内部代码
document.addEventListener('mouseup', (e) => {
  window.parent.postMessage({
    type: 'mouseup',
    // ... 事件数据
  }, '*');
});

3. 父窗口处理

父窗口监听 postMessage,接收事件数据后创建合成事件并派发:

window.addEventListener('message', (event) => {
  const syntheticEvent = new MouseEvent('mouseup', {
    bubbles: true,
    clientX: convertedX, // 转换后的坐标
    // ... 其他属性
  });
  document.dispatchEvent(syntheticEvent); // 派发到 document,使其能够冒泡
});

坐标转换

iframe 内部的坐标是相对于 iframe 的,需要转换为相对于主页面的坐标:

const rect = iframe.getBoundingClientRect();
const clientX = rect.left + iframeEvent.clientX;
const clientY = rect.top + iframeEvent.clientY;

组件封装

为了避免在每个需要 iframe 事件转发的组件中重复实现相同逻辑,我们将该功能封装成了 Vue Composable(useIframeEventForwarder)。

import { onMounted, onBeforeUnmount, Ref, nextTick, watchEffect, watch } from "vue";

/**
 * iframe 事件转发配置选项
 */
export interface IframeEventForwarderOptions {
  /**
   * 要转发的事件类型列表,默认为 ['mouseup']
   */
  eventTypes?: string[];

  /**
   * 是否自动在 iframe 内部添加事件监听
   * - 同源 iframe:可以自动添加监听器(默认为 true)
   * - 跨域 iframe:无法自动添加,需要 iframe 内部主动发送 postMessage
   *   此时应设置为 false,并在 iframe 内部使用提供的脚本模板
   * 默认为 true
   */
  autoSetupIframeListeners?: boolean;

  /**
   * 延迟设置 iframe 监听器的时间(毫秒),默认为 1000
   */
  setupDelay?: number;

  /**
   * 是否监听 DOM 变化以动态添加新 iframe 的监听,默认为 true
   */
  watchDomChanges?: boolean;

  /**
   * 事件处理回调函数
   * @param eventType 事件类型
   * @param syntheticEvent 合成的事件对象
   */
  onEvent?: (eventType: string, syntheticEvent) => void;

  /**
   * 是否验证 postMessage 的 origin,提高安全性
   * @param origin 消息来源
   * @returns 是否允许该来源的消息
   */
  validateOrigin?: (origin: string) => boolean;

  /**
   * 限制监听的 iframe 范围(可选)
   * - 如果提供字符串,则作为 CSS 选择器使用
   * - 如果提供 HTMLElement 或 Ref<HTMLElement>,则只监听该元素内的 iframe
   * - 如果不提供,则监听页面中所有的 iframe
   */
  iframeSelector?: string | HTMLElement | Ref<HTMLElement | null>;
}

/**
 * 生成跨域 iframe 事件转发脚本
 * 如果 iframe 是跨域的,需要在 iframe 内部添加此脚本
 *
 * @param eventTypes 要转发的事件类型列表,默认为 ['mouseup']
 * @param targetOrigin postMessage 的目标 origin,默认为 '*'(允许所有来源)
 * @returns 可执行的 JavaScript 代码字符串
 *
 */
export function generateCrossOriginIframeScript(
  eventTypes: string[] = ['mouseup'],
  targetOrigin: string = '*'
): string {
  const eventTypesStr = JSON.stringify(eventTypes);
  return `
(function() {
  var eventTypes = ${eventTypesStr};

  eventTypes.forEach(function(eventType) {
    document.addEventListener(eventType, function(e) {
      var eventData = {
        type: eventType,
        source: 'iframe',
        screenX: e.screenX,
        screenY: e.screenY,
        clientX: e.clientX,
        clientY: e.clientY,
        button: e.button,
        buttons: e.buttons,
        ctrlKey: e.ctrlKey,
        shiftKey: e.shiftKey,
        altKey: e.altKey,
        metaKey: e.metaKey,
      };
      window.parent.postMessage(eventData, ${JSON.stringify(targetOrigin)});
    });
  });
})();
`.trim();
}

/**
 * 跨域 iframe 事件转发脚本模板(默认配置)
 * 使用默认事件类型 ['mouseup']
 */
export const CROSS_ORIGIN_IFRAME_SCRIPT = generateCrossOriginIframeScript();

/**
 * iframe 事件转发器
 * 用于将 iframe 内部的事件转发到父窗口,使其能够正常冒泡
 *
 * **同源 vs 跨域 iframe:**
 *
 * 1. **同源 iframe**(推荐):
 *    - 可以自动在 iframe 内部添加事件监听器
 *    - 无需 iframe 内部代码配合
 *    - 设置 `autoSetupIframeListeners: true`(默认)
 *
 * 2. **跨域 iframe**:
 *    - 由于浏览器安全策略,无法访问跨域 iframe 的 contentDocument
 *    - 需要 iframe 内部主动发送 postMessage
 *    - 设置 `autoSetupIframeListeners: false`
 *    - 在 iframe 内部使用 `CROSS_ORIGIN_IFRAME_SCRIPT` 脚本模板

 */
export function useIframeEventForwarder(
  options: IframeEventForwarderOptions = {}
) {
  const {
    eventTypes = ["mouseup"],
    autoSetupIframeListeners = true,
    setupDelay = 1000,
    watchDomChanges = true,
    onEvent,
    validateOrigin,
    iframeSelector,
  } = options;

  let messageHandler: ((event: MessageEvent) => void) | null = null;
  let observer: MutationObserver | null = null;
  const iframeEventHandlers = new Map<HTMLIFrameElement, Set<() => void>>();

  /**
   * 获取实际的容器元素
   */
  const getContainer = (): HTMLElement | null => {
    if (!iframeSelector) {
      return null;
    }
    if (typeof iframeSelector === "string") {
      return null; // 字符串选择器,不需要容器
    }
    // 如果是 Ref,获取其 value
    if ('value' in iframeSelector) {
      return iframeSelector.value;
    }
    // 如果是 HTMLElement,直接返回
    return iframeSelector;
  };

  /**
   * 获取要监听的 iframe 列表
   */
  const getIframes = (): NodeListOf<HTMLIFrameElement> => {
    const container = getContainer();
    if (container) {
      // 有容器,在容器内查找
      return container.querySelectorAll<HTMLIFrameElement>("iframe");
    }
    if (typeof iframeSelector === "string") {
      // 字符串选择器
      return document.querySelectorAll<HTMLIFrameElement>(iframeSelector);
    }
    // 没有限制,查找所有 iframe
    return document.querySelectorAll<HTMLIFrameElement>("iframe");
  };

  /**
   * 计算相对于主页面的坐标
   */
  const calculateCoordinates = (
    eventData: any,
    eventSource: Window | null
  ): { clientX: number; clientY: number; screenX: number; screenY: number } => {
    let clientX = eventData.clientX || 0;
    let clientY = eventData.clientY || 0;
    const screenX = eventData.screenX || 0;
    const screenY = eventData.screenY || 0;

    // 尝试找到发送消息的 iframe,并转换坐标
    if (eventSource) {
      try {
        const iframes = getIframes();

        for (const iframe of iframes) {
          if (iframe.contentWindow === eventSource) {
            // 找到对应的 iframe,计算相对于主页面的坐标
            const rect = iframe.getBoundingClientRect();
            clientX = rect.left + (eventData.clientX || 0);
            clientY = rect.top + (eventData.clientY || 0);
            break;
          }
        }
      } catch (error) {
        console.warn("[useIframeEventForwarder] 无法计算 iframe 坐标,使用原始值", error);
      }
    }

    return { clientX, clientY, screenX, screenY };
  };

  /**
   * 创建合成事件
   */
  const createSyntheticEvent = (
    eventType: string,
    eventData: any,
    eventSource: Window | null
  ): Event | null => {
    try {
      const coords = calculateCoordinates(eventData, eventSource);

      // 根据事件类型创建不同的事件对象
      if (eventType === "mouseup" || eventType === "mousedown" || eventType === "mousemove" || eventType === "click") {
        return new MouseEvent(eventType, {
          bubbles: true,
          cancelable: true,
          view: window,
          detail: eventData.detail || 0,
          screenX: coords.screenX,
          screenY: coords.screenY,
          clientX: coords.clientX,
          clientY: coords.clientY,
          button: eventData.button || 0,
          buttons: eventData.buttons || 0,
          ctrlKey: eventData.ctrlKey || false,
          shiftKey: eventData.shiftKey || false,
          altKey: eventData.altKey || false,
          metaKey: eventData.metaKey || false,
        });
      } else if (eventType === "keyup" || eventType === "keydown" || eventType === "keypress") {
        return new KeyboardEvent(eventType, {
          bubbles: true,
          cancelable: true,
          view: window,
          key: eventData.key || "",
          code: eventData.code || "",
          ctrlKey: eventData.ctrlKey || false,
          shiftKey: eventData.shiftKey || false,
          altKey: eventData.altKey || false,
          metaKey: eventData.metaKey || false,
        });
      } else {
        // 对于其他事件类型,创建通用事件
        return new Event(eventType, {
          bubbles: true,
          cancelable: true,
        });
      }
    } catch (error) {
      console.error(`[useIframeEventForwarder] 创建合成事件失败 (${eventType}):`, error);
      return null;
    }
  };

  /**
   * 处理来自 iframe 的 postMessage
   */
  const handleIframeMessage = (event: MessageEvent) => {
    // 验证 origin(如果提供了验证函数)
    if (validateOrigin && !validateOrigin(event.origin)) {
      return;
    }

    const eventData = event.data;
    if (!eventData || !eventData.type) {
      return;
    }

    // 检查是否是我们要转发的事件类型
    if (!eventTypes.includes(eventData.type)) {
      return;
    }

    // 创建合成事件
    const syntheticEvent = createSyntheticEvent(
      eventData.type,
      eventData,
      event.source as Window | null
    );

    if (!syntheticEvent) {
      return;
    }

    // 派发事件到 document,让事件能够冒泡到 window
    document.dispatchEvent(syntheticEvent);

    // 调用用户提供的回调
    if (onEvent) {
      onEvent(eventData.type, syntheticEvent);
    }
  };

  /**
   * 检测 iframe 是否跨域
   * 跨域时无法访问 contentDocument,会抛出异常
   */
  const isCrossOrigin = (iframe: HTMLIFrameElement): boolean => {
    try {
      // 尝试访问 contentDocument,跨域时会抛出异常
      const doc = iframe.contentDocument;
      return doc === null;
    } catch (error) {
      // 跨域访问会抛出 SecurityError
      return true;
    }
  };

  /**
   * 在 iframe 内部添加事件监听
   * - 同源 iframe:可以直接访问并添加监听器
   * - 跨域 iframe:无法访问,需要 iframe 内部主动发送 postMessage
   */
  const setupIframeListeners = () => {
    const iframes = getIframes();
    iframes.forEach((iframe) => {
      // 如果已经设置过监听器,跳过
      if (iframeEventHandlers.has(iframe)) {
        return;
      }

      const handlers = new Set<() => void>();

      // 检测是否跨域
      if (isCrossOrigin(iframe)) {
        // 跨域 iframe:无法直接访问,需要 iframe 内部主动发送 postMessage
        console.warn(
          `[useIframeEventForwarder] 检测到跨域 iframe,无法自动添加事件监听器。` +
          `请设置 autoSetupIframeListeners: false,并在 iframe 内部使用 CROSS_ORIGIN_IFRAME_SCRIPT 脚本模板。`
        );
        return;
      }

      try {
        // 检查 iframe 是否已加载
        if (iframe.contentWindow && iframe.contentDocument) {
          // 同源 iframe:可以直接访问并添加监听器
          // 延迟一点时间,确保 iframe 内容完全加载
          setTimeout(() => {
            setupIframeDocumentListeners(iframe, handlers);
          }, 100);
        } else {
          // iframe 未加载完成,等待加载
          const loadHandler = () => {
            try {
              // 再次检测是否跨域(加载后可能变成跨域)
              if (isCrossOrigin(iframe)) {
                console.warn(
                  `[useIframeEventForwarder] iframe 加载后检测为跨域,无法添加事件监听器。`
                );
                return;
              }
              if (iframe.contentDocument) {
                // 延迟一点时间,确保 iframe 内容完全加载
                setTimeout(() => {
                  setupIframeDocumentListeners(iframe, handlers);
                }, 100);
              }
            } catch (error) {
              console.warn(
                "[useIframeEventForwarder] 无法访问 iframe 内容(可能是跨域)",
                error
              );
            }
          };
          iframe.addEventListener("load", loadHandler);
          handlers.add(() => {
            iframe.removeEventListener("load", loadHandler);
          });
        }
      } catch (error) {
        console.warn(
          "[useIframeEventForwarder] 无法访问 iframe 内容(可能是跨域)",
          error
        );
      }

      if (handlers.size > 0) {
        iframeEventHandlers.set(iframe, handlers);
      }
    });
  };

  /**
   * 在 iframe 的 document 和 window 上设置事件监听
   */
  const setupIframeDocumentListeners = (
    iframe: HTMLIFrameElement,
    handlers: Set<() => void>
  ) => {
    const iframeDoc = iframe.contentDocument;
    if (!iframeDoc) {
      console.warn("[useIframeEventForwarder] iframe document 或 window 不可用");
      return;
    }

    // 创建事件处理函数
    const createEventHandler = (eventType: string) => {
      return (e: Event) => {
        const mouseEvent = e as MouseEvent;
        const keyboardEvent = e as KeyboardEvent;

        // 构建要发送的事件数据
        const eventData: any = {
          type: eventType,
          source: "iframe",
        };

        // 如果是鼠标事件,添加鼠标相关属性
        if (mouseEvent instanceof MouseEvent) {
          eventData.screenX = mouseEvent.screenX;
          eventData.screenY = mouseEvent.screenY;
          eventData.clientX = mouseEvent.clientX;
          eventData.clientY = mouseEvent.clientY;
          eventData.button = mouseEvent.button;
          eventData.buttons = mouseEvent.buttons;
          eventData.ctrlKey = mouseEvent.ctrlKey;
          eventData.shiftKey = mouseEvent.shiftKey;
          eventData.altKey = mouseEvent.altKey;
          eventData.metaKey = mouseEvent.metaKey;
        }

        // 如果是键盘事件,添加键盘相关属性
        if (keyboardEvent instanceof KeyboardEvent) {
          eventData.key = keyboardEvent.key;
          eventData.code = keyboardEvent.code;
          eventData.ctrlKey = keyboardEvent.ctrlKey;
          eventData.shiftKey = keyboardEvent.shiftKey;
          eventData.altKey = keyboardEvent.altKey;
          eventData.metaKey = keyboardEvent.metaKey;
        }

        // 发送消息到父窗口
        if (iframe.contentWindow) {
          iframe.contentWindow.parent.postMessage(eventData, "*");
        }
      };
    };

    eventTypes.forEach((eventType) => {
      const handler = createEventHandler(eventType);

      iframeDoc.addEventListener(eventType, handler);

      handlers.add(() => {
        iframeDoc.removeEventListener(eventType, handler);
      });
    });
  };

  /**
   * 清理 iframe 监听器
   */
  const cleanupIframeListeners = () => {
    iframeEventHandlers.forEach((handlers, iframe) => {
      handlers.forEach((cleanup) => cleanup());
    });
    iframeEventHandlers.clear();
  };

  /**
   * 初始化 postMessage 监听(只需要初始化一次)
   */
  const initMessageListener = () => {
    if (messageHandler) {
      return;
    }
    messageHandler = handleIframeMessage;
    window.addEventListener("message", messageHandler);
  };

  /**
   * 设置 DOM 变化监听器
   */
  const setupMutationObserver = () => {
    // 如果已经有 observer,先清理
    if (observer) {
      observer.disconnect();
      observer = null;
    }

    if (!watchDomChanges) {
      return;
    }

    const container = getContainer();
    const targetContainer = container || document.body;

    observer = new MutationObserver(() => {
      setupIframeListeners();
    });
    observer.observe(targetContainer, {
      childList: true,
      subtree: true,
    });
  };

  // 初始化 postMessage 监听(只需要一次)
  initMessageListener();

  // 使用 watchEffect 自动监听容器和 iframe 的变化
  watchEffect(() => {
    // 访问 iframeSelector(如果是 Ref,watchEffect 会自动追踪其 value 的变化)
    let container: HTMLElement | null = null;
    if (iframeSelector) {
      if (typeof iframeSelector === "string") {
        // 字符串选择器,不需要容器
      } else if ('value' in iframeSelector) {
        // Ref,访问 value 以触发响应式追踪
        container = iframeSelector.value;
      } else {
        // HTMLElement
        container = iframeSelector;
      }
    }

    // 如果容器存在或者是字符串选择器,设置监听器
    if (container || typeof iframeSelector === "string" || !iframeSelector) {
      if (autoSetupIframeListeners) {
        // 延迟执行,确保 DOM 已更新
        setTimeout(() => {
          setupIframeListeners();
        }, setupDelay);
      }

      // 设置 DOM 变化监听
      setupMutationObserver();
    }
  });

  // 组件卸载时自动清理
  onBeforeUnmount(() => {
    if (messageHandler) {
      window.removeEventListener("message", messageHandler);
      messageHandler = null;
    }
    if (observer) {
      observer.disconnect();
      observer = null;
    }
    cleanupIframeListeners();
  });

  return {
    /**
     * 手动设置 iframe 监听器
     */
    setupListeners: setupIframeListeners,

    /**
     * 清理所有监听器(通常不需要手动调用,组件卸载时会自动清理)
     */
    cleanup: () => {
      if (messageHandler) {
        window.removeEventListener("message", messageHandler);
        messageHandler = null;
      }
      if (observer) {
        observer.disconnect();
        observer = null;
      }
      cleanupIframeListeners();
    },
  };
}

核心功能

主要特性

  1. 自动事件转发:自动将 iframe 内部的事件转发到父窗口
  2. 坐标自动转换:自动将 iframe 坐标转换为父窗口坐标
  3. 支持多种事件类型:支持鼠标事件、键盘事件等
  4. 自动清理:组件卸载时自动清理所有监听器
  5. 动态监听:自动监听 DOM 变化,为新添加的 iframe 设置监听
  6. 作用域限制:支持限制监听范围,只监听指定容器内的 iframe

支持的事件类型

  • 鼠标事件mouseupmousedownmousemoveclick
  • 键盘事件keyupkeydownkeypress
  • 其他事件:支持任意事件类型(通过通用 Event 创建)

使用方法

基础用法

<script setup>
import { ref } from "vue";
import { useIframeEventForwarder } from "@/hooks/useIframeEventForwarder";

const containerRef = ref<HTMLElement | null>(null);

// 最简单的用法:转发 mouseup 事件
useIframeEventForwarder({
  iframeSelector: containerRef, // 只监听组件内的 iframe
});
</script>
<template>
  <div ref="containerRef">
    <iframe src="..."></iframe>
  </div>
</template>

完整配置

<script setup>
import { ref } from "vue";
import { useIframeEventForwarder } from "@/hooks/useIframeEventForwarder";

const containerRef = ref<HTMLElement | null>(null);

useIframeEventForwarder({
  // 要转发的事件类型
  eventTypes: ["mouseup", "mousedown", "click"],
  
  // 限制监听范围(传入 ref)
  iframeSelector: containerRef,
  
  // 事件处理回调
  onEvent: (eventType, syntheticEvent) => {
    console.log(`收到 iframe 的 ${eventType} 事件`, syntheticEvent);
  },
  
  // 验证消息来源(跨域场景)
  validateOrigin: (origin) => {
    return origin === "https://trusted-domain.com";
  },
  
  // 是否自动设置监听器(同源 iframe 默认为 true)
  autoSetupIframeListeners: true,
  
  // 延迟设置时间(毫秒)
  setupDelay: 1000,
  
  // 是否监听 DOM 变化
  watchDomChanges: true,
});
</script>

跨域 iframe 使用

<script setup>
import { useIframeEventForwarder, generateCrossOriginIframeScript } from "@/hooks/useIframeEventForwarder";

// 父窗口:只监听 postMessage
useIframeEventForwarder({
  eventTypes: ["mouseup"],
  autoSetupIframeListeners: false, // 跨域时设置为 false
  validateOrigin: (origin) => {
    return origin === "https://trusted-domain.com";
  },
});

// iframe 内部需要添加脚本(通过其他方式注入)
// const script = generateCrossOriginIframeScript(['mouseup']);
</script>

最佳实践

1. 限制监听范围

推荐:使用 iframeSelector 传入容器 ref,只监听组件内的 iframe

// ✅ 推荐:只监听组件内的 iframe
const containerRef = ref<HTMLElement | null>(null);
useIframeEventForwarder({
  iframeSelector: containerRef,
});

// ❌ 不推荐:监听全局所有 iframe(可能影响性能)
useIframeEventForwarder({});

2. 只监听必要的事件

推荐:只监听实际需要的事件类型

// ✅ 推荐:只监听需要的事件
useIframeEventForwarder({
  eventTypes: ["mouseup"], // 只监听 mouseup
});

// ❌ 不推荐:监听过多事件(影响性能)
useIframeEventForwarder({
  eventTypes: ["mouseup", "mousedown", "mousemove", "click", "keyup", "keydown"],
});

3. 跨域场景的安全验证

推荐:始终验证消息来源

// ✅ 推荐:验证 origin
useIframeEventForwarder({
  validateOrigin: (origin) => {
    return origin === "https://trusted-domain.com";
  },
});

// ❌ 不推荐:不验证 origin(安全风险)
useIframeEventForwarder({
  // 没有 validateOrigin
});

4. 性能优化

推荐:根据实际需求调整配置

// 如果不需要监听动态添加的 iframe
useIframeEventForwarder({
  watchDomChanges: false, // 关闭 DOM 变化监听
});

// 如果 iframe 加载很快,可以减少延迟
useIframeEventForwarder({
  setupDelay: 500, // 减少延迟时间
});

5. 组件内使用

推荐:在 setup 顶层调用,传入容器 ref

<script setup>
import { ref } from "vue";
import { useIframeEventForwarder } from "@/hooks/useIframeEventForwarder";

const containerRef = ref<HTMLElement | null>(null);

// ✅ 推荐:在 setup 顶层调用
useIframeEventForwarder({
  iframeSelector: containerRef,
  eventTypes: ["mouseup"],
});

// composable 会自动:
// 1. 监听容器变化
// 2. 自动更新事件监听
// 3. 组件卸载时自动清理
</script>

注意事项

1. 同源策略限制

  • 同源 iframe:可以自动添加事件监听器,推荐使用
  • 跨域 iframe:无法访问 contentDocument,需要 iframe 内部配合

2. 性能考虑

  • 避免监听过多事件类型
  • 使用 iframeSelector 限制监听范围
  • 如果不需要动态监听,设置 watchDomChanges: false

3. 安全建议

  • 跨域场景务必使用 validateOrigin 验证消息来源
  • 不要在生产环境使用 "*" 作为 postMessage 的 targetOrigin
  • 验证事件数据的合法性

相关资源

react hooks中的useState

2025年12月11日 23:21

hooks 是 React 函数组件中的重要部分,理解其原理和用法对于编写高效、可维护的 React 应用至关重要。 现介绍一下 useState

🔧 核心原理:闭包与链表

useState 的核心原理是利用闭包在函数组件的多次渲染间保持状态,并通过链表结构来管理多个 Hook 的顺序。

简化版原理示意

// 极简模拟(实际React源码复杂得多)
let hookStates = []; // 存储所有hook状态的数组
let hookIndex = 0;   // 当前hook的索引

function useState(initialState) {
  // 1. 通过索引获取当前hook的状态
  const currentIndex = hookIndex;
  
  // 2. 初始化或获取已存在的状态
  hookStates[currentIndex] = hookStates[currentIndex] ?? 
    (typeof initialState === 'function' ? initialState() : initialState);
  
  // 3. 创建setState函数(闭包保存当前索引)
  const setState = (newState) => {
    // 计算新状态:支持函数式更新
    const nextState = typeof newState === 'function' 
      ? newState(hookStates[currentIndex])
      : newState;
    
    // 如果状态变化,触发重新渲染
    if (!Object.is(hookStates[currentIndex], nextState)) {
      hookStates[currentIndex] = nextState;
      // React内部会调度重新渲染
      scheduleRerender();
    }
  };
  
  // 4. 索引递增,准备下一个hook
  hookIndex++;
  
  // 5. 返回当前状态和更新函数
  return [hookStates[currentIndex], setState];
}

// 每次组件渲染前重置索引
function renderComponent() {
  hookIndex = 0; // 关键:确保hook调用顺序一致
  // ... 执行组件函数
}

React Fiber 中的实际实现

  • 基于 Fiber 节点的链表:每个组件对应一个 Fiber 节点,Hooks 状态存储在 Fiber.memoizedState 链表中
  • 更新队列setState 会将更新加入队列,React 会批量处理
  • 调度机制:更新可能异步执行,React 会根据优先级调度

📚 详细用法与最佳实践

1. 基本使用

import React, { useState } from 'react';

function Counter() {
  // 声明一个状态变量count,初始值为0
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>点击次数: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        点击
      </button>
    </div>
  );
}

2. 初始化状态的两种方式

// 方式1:直接值(每次渲染都会计算)
const [count, setCount] = useState(0);

// 方式2:函数式初始化(仅首次渲染执行,性能更优)
const [complexState, setComplexState] = useState(() => {
  // 这里可以进行复杂计算
  const initialValue = calculateExpensiveValue(props);
  return initialValue;
});

3. 函数式更新(重要!)

当新状态依赖旧状态时,必须使用函数式更新:

function Counter() {
  const [count, setCount] = useState(0);
  
  // ❌ 错误:连续调用可能无法得到预期结果
  const incrementTwiceWrong = () => {
    setCount(count + 1); // 使用当前闭包中的count
    setCount(count + 1); // 仍然使用旧的count值
  };
  
  // ✅ 正确:使用函数式更新
  const incrementTwiceCorrect = () => {
    setCount(prevCount => prevCount + 1); // 获取最新状态
    setCount(prevCount => prevCount + 1); // 基于更新后的状态
  };
  
  return <button onClick={incrementTwiceCorrect}>+2</button>;
}

4. 状态合并与对象更新

useState 不会自动合并对象,需要手动处理:

function UserProfile() {
  const [user, setUser] = useState({
    name: '张三',
    age: 25,
    email: 'zhangsan@example.com'
  });
  
  // ❌ 错误:会丢失age和email字段
  const updateNameWrong = (newName) => {
    setUser({ name: newName });
  };
  
  // ✅ 正确:使用扩展运算符合并
  const updateNameCorrect = (newName) => {
    setUser(prevUser => ({
      ...prevUser,     // 保留其他属性
      name: newName    // 更新name
    }));
  };
  
  // ✅ 更新多个属性
  const updateUser = (updates) => {
    setUser(prevUser => ({
      ...prevUser,
      ...updates
    }));
  };
  
  return <div>{/* ... */}</div>;
}

5. 惰性初始化与性能优化

function ExpensiveComponent({ userId }) {
  // ✅ 优化:避免每次渲染都执行昂贵计算
  const [data, setData] = useState(() => {
    console.log('仅首次渲染执行');
    return fetchExpensiveData(userId);
  });
  
  // ❌ 不佳:每次渲染都会执行函数(虽然结果被丢弃)
  const [badData, setBadData] = useState(fetchExpensiveData(userId));
  
  return <div>{/* ... */}</div>;
}

6. 状态重置与Key的妙用

function UserForm({ userId, initialData }) {
  // 当userId变化时,组件会重置状态
  const [formData, setFormData] = useState(initialData);
  
  // 手动重置状态
  const resetForm = () => {
    setFormData(initialData);
  };
  
  return (
    <form>
      <input 
        value={formData.name} 
        onChange={e => setFormData({...formData, name: e.target.value})}
      />
      <button type="button" onClick={resetForm}>重置</button>
    </form>
  );
}

// 父组件中使用key强制重置
function ParentComponent() {
  const [key, setKey] = useState(0);
  
  const resetChild = () => {
    setKey(prevKey => prevKey + 1); // 改变key会使UserForm重新挂载
  };
  
  return (
    <div>
      <UserForm key={key} initialData={{name: ''}} />
      <button onClick={resetChild}>完全重置表单</button>
    </div>
  );
}

7. 批量更新与异步行为

function BatchUpdateDemo() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
  
  const handleClick = () => {
    // React 17及之前:在事件处理函数中会批量处理
    // React 18+:在createRoot下自动批量所有更新
    setCount(c => c + 1);
    setFlag(f => !f);
    // 这里count和flag的值都还是旧的
    
    // 如果需要立即获取更新后的状态,可以使用useEffect
  };
  
  console.log('渲染:', count, flag); // 点击一次,只打印一次
  
  return <button onClick={handleClick}>点击</button>;
}

⚠️ 常见陷阱与解决方案

1. 闭包陷阱(Stale Closure)

function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      // ❌ 问题:总是使用初始闭包中的count(0)
      setCount(count + 1);
    }, 1000);
    
    return () => clearInterval(interval);
  }, []); // 依赖数组为空,effect只运行一次
  
  // ✅ 解决方案1:使用函数式更新
  useEffect(() => {
    const interval = setInterval(() => {
      setCount(prev => prev + 1); // 总是获取最新状态
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);
  
  // ✅ 解决方案2:将count加入依赖
  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    
    return () => clearInterval(interval);
  }, [count]); // count变化时重新创建定时器
  
  return <div>{count}</div>;
}

2. 状态依赖先前状态的计算

function MovingAverage() {
  const [values, setValues] = useState([]);
  const [average, setAverage] = useState(0);
  
  const addValue = (newValue) => {
    // ❌ 错误:average计算基于可能过时的values
    const newValues = [...values, newValue];
    const newAverage = newValues.reduce((a, b) => a + b) / newValues.length;
    
    setValues(newValues);
    setAverage(newAverage);
  };
  
  // ✅ 正确:使用useEffect派生状态
  const [values, setValues] = useState([]);
  
  // average根据values自动计算
  const average = values.length > 0 
    ? values.reduce((a, b) => a + b) / values.length 
    : 0;
  
  const addValue = (newValue) => {
    setValues(prev => [...prev, newValue]);
    // average会自动重新计算
  };
  
  // ✅ 或者使用useMemo优化计算
  const average = useMemo(() => {
    return values.length > 0 
      ? values.reduce((a, b) => a + b) / values.length 
      : 0;
  }, [values]);
  
  return <div>{/* ... */}</div>;
}

3. 状态提升与共享状态

// 当多个组件需要共享状态时,提升到最近的共同祖先
function ParentComponent() {
  const [sharedState, setSharedState] = useState('');
  
  return (
    <div>
      <ChildA value={sharedState} onChange={setSharedState} />
      <ChildB value={sharedState} />
    </div>
  );
}

// 或者使用Context
const MyContext = React.createContext();

function App() {
  const [state, setState] = useState({});
  
  return (
    <MyContext.Provider value={{ state, setState }}>
      <ChildComponent />
    </MyContext.Provider>
  );
}

🚀 高级模式

1. 自定义Hook封装状态逻辑

// 自定义useToggle hook
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  
  const toggle = () => setValue(prev => !prev);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);
  
  return [value, { toggle, setTrue, setFalse }];
}

// 使用
function Component() {
  const [isOn, { toggle, setTrue, setFalse }] = useToggle(false);
  
  return (
    <div>
      <p>状态: {isOn ? '开' : '关'}</p>
      <button onClick={toggle}>切换</button>
      <button onClick={setTrue}>打开</button>
      <button onClick={setFalse}>关闭</button>
    </div>
  );
}

2. 状态机模式

function useTaskManager() {
  const [task, setTask] = useState({
    status: 'idle', // idle, loading, success, error
    data: null,
    error: null
  });
  
  const startLoading = () => setTask({ status: 'loading', data: null, error: null });
  const setSuccess = (data) => setTask({ status: 'success', data, error: null });
  const setError = (error) => setTask({ status: 'error', data: null, error });
  
  return {
    task,
    startLoading,
    setSuccess,
    setError,
    isLoading: task.status === 'loading',
    isSuccess: task.status === 'success',
    isError: task.status === 'error'
  };
}

📊 性能优化建议

  1. 状态分割:将不相关的状态拆分为独立的useState调用

    // ❌ 不佳:位置变化会导致userInfo重新创建
    const [state, setState] = useState({
      x: 0, y: 0,
      username: '',
      email: ''
    });
    
    // ✅ 更好:分离变化频率不同的状态
    const [position, setPosition] = useState({ x: 0, y: 0 });
    const [userInfo, setUserInfo] = useState({ username: '', email: '' });
    
  2. 使用useReducer处理复杂状态逻辑

    const [state, dispatch] = useReducer(reducer, initialState);
    
  3. 避免在渲染函数中直接调用setState(会导致无限循环)

深入理解useState的关键是:

  • 掌握闭包原理,避免过时闭包问题
  • 始终使用函数式更新当新状态依赖旧状态时
  • 合理组织状态结构,平衡粒度与复杂度
  • 利用派生状态减少不必要的useState调用

useState看似简单,但其中蕴含了React函数组件的核心设计思想,熟练掌握将极大提升你的React开发能力。

Vue-TodoList 项目详解

作者 _一两风
2025年12月11日 22:55

Vue 3 Todo List 项目详解

1.图片展示

image.png

2. 核心数据结构

项目使用 ref 定义了核心的响应式数据 todos。数组中的每一项是一个对象,包含三个关键属性:

  • id: 唯一标识符(用于 Diff 算法优化)
  • context: 任务文本内容
  • done: 完成状态 (true/false)
const todos = ref([
  { id: 1, context: '打王者', done: false },
  { id: 2, context: '吃饭', done: true },
  // ...
])

3. 功能实现细节

A. 添加任务 (Add Todo)

逻辑

  1. 双向绑定:使用 v-model 绑定输入框与 newTodoText 变量。
  2. 事件监听:监听键盘的 Enter 键 (@keydown.enter)。
  3. 数据变更:校验非空后,向 todos 数组 push 新对象,并重置输入框。

亮点:使用了 nextId 自增变量,确保每个新任务都有独立的 ID,避免渲染时的 Key 冲突。

B. 列表渲染与性能优化

逻辑:

使用 v-for 指令循环渲染列表。

关键点:

必须绑定 :key="todo.id"。Vue 的虚拟 DOM 机制依赖这个 Key 来进行高效的 Diff 对比。如果数据项顺序改变,Vue 可以直接复用 DOM 元素,而不是销毁重建,从而提升性能。

C. 智能状态计算 (Computed)

项目中大量使用了 computed 计算属性,它的优势在于缓存——只有依赖的数据变化时才会重新计算。

  1. 剩余任务统计 (active):

    实时计算 !todo.done 的数量,用于底部显示 "X items left"。

    const active = computed(() => todos.value.filter(todo => !todo.done).length)
    
  2. 全选/反选 (allDone) - 高级用法:

    这是一个可写计算属性 (Writable Computed),它巧妙地实现了双向逻辑:

    • 读 (Get) :如果所有任务都完成了,全选框自动勾选。
    • 写 (Set) :当你点击全选框时,它触发 set 方法,将所有任务的 done 状态同步为当前全选框的状态。

D. 删除与清理

  • 删除单项:通过 filter 过滤掉指定 ID 的任务。

  • 清除已完成:通过 filter 过滤掉所有 done 为 true 的任务。

    这里的操作都是生成新数组替换旧数组,Vue 的响应式系统会自动检测到引用变化并更新视图。


4. Computed 讲解

计算属性 (Computed Properties):形式上是函数,结果是属性。

核心特性:
  1. 依赖追踪:自动感知它所使用的响应式数据(如 refreactive)。
  2. 缓存机制 (Caching) :这是它与普通函数(Methods)最大的区别。如果依赖的数据没变,多次访问 computed 会直接返回上一次计算的结果,不会重复执行函数体。
高级用法:可写的计算属性(Getter & Setter)

在 Vue 3 中,计算属性(computed)通常默认为“只读”的(即只传入一个 getter 函数)。但在需要实现双向绑定的场景下(例如“全选/反选”复选框),我们需要使用它的高级写法:传入一个包含 getset 的对象。

// 引入 computed
import { computed } from 'vue';

// 定义可写的计算属性
const allDone = computed({
  // getter: 读取值(决定全选框是否勾选)
  // 当依赖的 todos 数据变化时,会自动重新计算
  get() {
    // 逻辑:如果列表不为空,且每一项都已完成 (done === true),则返回 true
    return todos.value.length > 0 && todos.value.every(todo => todo.done)
  },

  // setter: 写入值(当用户点击全选框时触发)
  // val 是用户操作后的新值(true 或 false)
  set(val) {
    // 逻辑:遍历所有 todos,将它们的完成状态强制改为当前全选框的状态
    todos.value.forEach(todo => todo.done = val)
  }
})

在 Vue 中:

  • 使用 Methods (函数) : function getActive() { ... }。每次页面重新渲染(哪怕是无关的 DOM 更新),这个函数都会被执行一遍。如果计算量大,会浪费性能。
  • 使用 Computed (计算属性) : 只有依赖变了才算。对于像“过滤列表”、“遍历大数组”这种操作,computed 是性能优化的关键。

5. 项目源码 (src/App.vue)

<script setup>
  import { ref, computed } from 'vue';
  
  // 1. 响应式数据定义
  const title = ref("todos");
  const newTodoText = ref("");
  const todos = ref([
    { id: 1, context: '打王者', done: false },
    { id: 2, context: '吃饭', done: true }, 
    { id: 3, context: '睡觉', done: false },
    { id: 4, context: '学习Vue', done: false }
  ])

  let nextId = 5;

  // 2. 计算属性:统计未完成数量
  // 优势:computed 有缓存,性能优于 method
  const active = computed(() => {
    return todos.value.filter(todo => !todo.done).length
  })

  // 计算属性:统计已完成数量(用于控制清除按钮显示)
  const completedCount = computed(() => {
    return todos.value.filter(todo => todo.done).length
  })

  // 3. 核心业务:添加任务
  const addTodo = () => {
    const text = newTodoText.value.trim();
    if (!text) return;
    todos.value.push({
      id: nextId++, // 确保 ID 唯一
      context: text,
      done: false
    });
    newTodoText.value = "";
  }

  // 4. 核心业务:全选/反选 (可写计算属性)
  const allDone = computed({
    get() {
      return todos.value.length > 0 && todos.value.every(todo => todo.done)
    },
    set(val) {
      todos.value.forEach(todo => todo.done =val)
    }
  })

  // 5. 核心业务:删除任务
  const removeTodo = (id) => {
    todos.value = todos.value.filter(item => item.id !== id)
  }

  // 6. 核心业务:清除已完成
  const clearCompleted = () => {
    todos.value = todos.value.filter(todo => !todo.done)
  }
</script>

<template>
  <div class="todoapp">
    
    <header class="header">
      <h1>{{ title }}</h1>
      <input 
        class="new-todo" 
        type="text" 
        v-model="newTodoText" 
        @keydown.enter="addTodo" 
        placeholder="What needs to be done?"
        autofocus
      >
    </header>

    <section class="main" v-if="todos.length">
      <input id="toggle-all" class="toggle-all" type="checkbox" v-model="allDone">
      <label for="toggle-all">Mark all as complete</label>

      <ul class="todo-list">
        <li v-for="todo in todos" :key="todo.id" :class="{ completed: todo.done }">
          <div class="view">
            <input class="toggle" type="checkbox" v-model="todo.done">
            <label>{{ todo.context }}</label>
            <button class="destroy" @click="removeTodo(todo.id)"></button>
          </div>
        </li>
      </ul>
    </section>

    <footer class="footer" v-if="todos.length">
      <span class="todo-count">
        <strong>{{ active }}</strong> items left
      </span>
      
      <button class="clear-completed" @click="clearCompleted" v-show="completedCount > 0">
        Clear completed
      </button>
    </footer>

  </div>
</template>

Prompts 组件实现

作者 鹘一
2025年12月11日 22:05

原型

目录

  1. [组件设计思路]
  2. [Props 设计]
  3. [TypeScript 类型设计]
  4. [组件实现]
  5. [样式实现]
  6. [总结]

1. 组件设计思路

1.1 组件的作用

Prompts 组件是一个提示词列表组件,通常用于 AI 应用中展示预设的问题或提示,用来:

  • 展示一组可点击的提示词卡片
  • 支持图标、标签、描述的展示
  • 支持嵌套的子提示词(多级结构)
  • 支持禁用状态
  • 支持垂直/水平布局、换行等布局方式

1.2 特性

  • 支持提示词列表数据
  • 支持嵌套子提示词(递归渲染)
  • 支持点击回调
  • 支持禁用状态
  • 支持多种布局方式(垂直、换行)
  • 支持语义化样式定制

1.3 设计原则

  1. 保持 API 一致性:与原组件保持相同的 Props 接口

  2. 适配 Arco Design:使用 Arco 的组件和设计规范

  3. 类型安全:完整的 TypeScript 类型定义

  4. 递归渲染:支持嵌套的子提示词

  5. 可定制性:支持样式和类名的语义化定制


2. Props 设计

2.1 核心 Props 分析

Prompts 组件比 Welcome 组件更复杂,因为它需要处理列表数据和嵌套结构。

// 单个提示词的数据结构
interface PromptProps {
  key: string;                    // 唯一标识
  icon?: React.ReactNode;         // 图标
  label?: React.ReactNode;        // 标签文字
  description?: React.ReactNode;  // 描述文字
  disabled?: boolean;             // 是否禁用
  children?: BasePromptItem[];    // 子提示词(支持嵌套)
}

// 组件的 Props
interface PromptsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick' | 'title'> {
  items?: PromptProps[];          // 提示词列表
  title?: React.ReactNode;        // 标题
  onItemClick?: (info: { data: PromptProps }) => void;  // 点击回调
  vertical?: boolean;             // 垂直布局
  wrap?: boolean;                 // 是否换行
  classNames?: Partial<Record<SemanticType, string>>;  // 语义化类名
  styles?: Partial<Record<SemanticType, React.CSSProperties>>;  // 语义化样式
  prefixCls?: string;             // 类名前缀
  rootClassName?: string;         // 根元素类名
}

2.2 为什么这样设计?

Q: 为什么 PromptsProps 要继承 HTMLAttributes?

interface PromptsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick' | 'title'>

A: 这样设计有以下好处:

  1. 支持所有原生 div 属性
  • 用户可以传递 classNamestyleiddata-* 等原生属性
  • 组件会自动将这些属性传递给根元素
  1. 为什么使用 Omit 排除 onClick 和 title?
  • onClick:组件有自己的 onItemClick 回调,避免与原生 onClick 冲突
  • title:组件有自己的 title prop(React.ReactNode 类型),与原生 title(string 类型)冲突
  1. 实际使用示例
<Prompts
  items={items}
  className="my-prompts"      // ✅ 支持
  style={{ padding: 20 }}     // ✅ 支持
  id="prompts-1"              // ✅ 支持
  data-testid="prompts"       // ✅ 支持
  onClick={() => {}}          // ❌ 不支持(被 Omit 排除)
/>

Q: 为什么需要 children 字段?

A: 支持嵌套的提示词结构,例如:

{
  key: '1',
  label: '学习 Arco Design',
  children: [
    { key: '1-1', label: '快速开始' },
    { key: '1-2', label: '组件文档' },
  ]
}

Q: 为什么 onItemClick 传递 { data } 对象?

A:

  • 提供完整的提示词数据,方便使用
  • 未来可以扩展更多信息(如 event, index 等)

Q: 为什么需要 vertical 和 wrap?

A: 提供灵活的布局方式:

  • vertical={false}, wrap={false}:水平单行(默认)
  • vertical={false}, wrap={true}:水平多行(自动换行)
  • vertical={true}:垂直排列

Q: 为什么需要 prefixCls 和 rootClassName?

A:

  • prefixCls:自定义类名前缀,避免样式冲突(如微前端场景)
  • rootClassName:为根元素添加额外类名,方便样式定制

3. TypeScript 类型设计

3.1 第一步:创建 interface.ts

创建文件 packages/x/src/Prompts/interface.ts

import type React from 'react';

/**
 * 基础提示词项
 */
export interface BasePromptItem {
  key: string;
  icon?: React.ReactNode;
  label?: React.ReactNode;
  description?: React.ReactNode;
  disabled?: boolean;
}

/**
 * 提示词项(支持嵌套)
 */
export interface PromptProps extends BasePromptItem {
  children?: BasePromptItem[];
}

/**
 * 语义化类型
 */
export type SemanticType =
  | 'list'        // 列表容器
  | 'item'        // 单个提示词
  | 'itemContent' // 提示词内容
  | 'title'       // 标题
  | 'subList'     // 子列表
  | 'subItem';    // 子提示词

/**
 * Prompts 组件的 Props
 */
export interface PromptsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick' | 'title'> {
  items?: PromptProps[];
  title?: React.ReactNode;
  onItemClick?: (info: { data: PromptProps }) => void;
  vertical?: boolean;
  wrap?: boolean;
  styles?: Partial<Record<SemanticType, React.CSSProperties>>;
  classNames?: Partial<Record<SemanticType, string>>;
  prefixCls?: string;
  rootClassName?: string;
}

3.2 类型设计要点详解

1. 为什么要分 BasePromptItem 和 PromptProps?

export interface BasePromptItem {
  key: string;
  icon?: React.ReactNode;
  label?: React.ReactNode;
  description?: React.ReactNode;
  disabled?: boolean;
}

export interface PromptProps extends BasePromptItem {
  children?: BasePromptItem[];  // 注意:children 的类型是 BasePromptItem[]
}

这样设计的原因:

  • BasePromptItem:定义提示词的基础字段
  • PromptProps:在基础上添加 children 字段
  • children 使用 BasePromptItem[] 而不是 PromptProps[],避免无限嵌套的类型定义

2. 为什么使用 Omit?

extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick' | 'title'>

详细说明:

  1. 继承 HTMLAttributes 的好处
  • 组件自动支持所有原生 div 属性
  • 用户可以传递 classNamestyleiddata-*aria-*
  • 不需要手动定义每个属性
  1. 为什么要排除 onClick?
// ❌ 如果不排除,会有两个 onClick
interface PromptsProps extends React.HTMLAttributes<HTMLDivElement> {
  onClick?: (e: React.MouseEvent) => void;        // 来自 HTMLAttributes
  onItemClick?: (info: { data: PromptProps }) => void;  // 我们自己的
}

// ✅ 排除后,只有我们自己的 onItemClick
interface PromptsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> {
  onItemClick?: (info: { data: PromptProps }) => void;
}
  • 组件的点击逻辑是针对单个提示词的,不是整个容器
  • 使用 onItemClick 更语义化,提供更多上下文信息
  1. 为什么要排除 title?
// HTMLAttributes 中的 title
title?: string;  // 原生 HTML title 属性(鼠标悬停提示)

// 我们组件的 title
title?: React.ReactNode;  // 提示词列表的标题(可以是任何 React 元素)
  • 类型不同:原生是 string,我们的是 React.ReactNode
  • 用途不同:原生是悬停提示,我们的是列表标题
  • 必须排除才能使用我们自己的定义
  1. 实际使用效果
<Prompts
  items={items}
  title={<h3>选择一个提示词</h3>}  // ✅ 我们的 title(ReactNode)
  onItemClick={(info) => {}}        // ✅ 我们的点击回调
  className="my-prompts"            // ✅ 来自 HTMLAttributes
  style={{ padding: 20 }}           // ✅ 来自 HTMLAttributes
  id="prompts-1"                    // ✅ 来自 HTMLAttributes
  onClick={() => {}}                // ❌ 被排除,不支持
/>

3. 语义化类型的设计

export type SemanticType =
  | 'list'        // 列表容器
  | 'item'        // 单个提示词
  | 'itemContent' // 提示词内容
  | 'title'       // 标题
  | 'subList'     // 子列表
  | 'subItem';    // 子提示词

为什么需要这么多语义类型?

  • list vs subList:区分父级列表和子级列表
  • item vs subItem:区分父级提示词和子级提示词
  • itemContent:提示词的内容部分(图标、标签、描述)

这样用户可以精确控制每个部分的样式。


4. 组件实现

4.1 第二步:创建组件骨架

创建文件 packages/x/src/Prompts/index.tsx,先写基本结构:

import React from 'react';
import type { PromptsProps, PromptProps } from './interface';
import './index.less';

const Prompts: React.FC<PromptsProps> = (props) => {
  // 组件逻辑将在这里实现
  return <div>Prompts Component</div>;
};

export default Prompts;
export type { PromptsProps, PromptProps } from './interface';

注意:

  • Prompts 组件不需要 forwardRef(因为通常不需要访问其 DOM 节点)
  • 直接使用 React.FC<PromptsProps> 即可

4.2 第三步:解构 Props 和设置默认值

const Prompts: React.FC<PromptsProps> = (props) => {
  const {
    // 基础属性
    prefixCls = 'arco-x-prompts',        // 默认类名前缀
    rootClassName,
    className,
    style,

    // 内容属性
    items,
    title,
    onItemClick,

    // 布局属性
    vertical,
    wrap,

    // 语义化样式
    styles = {},
    classNames: customClassNames = {},

    // 其他 HTML 属性
    ...htmlProps
  } = props;

  // ...
};

要点说明:

  1. Props 分组
  • 基础属性:prefixCls、rootClassName、className、style
  • 内容属性:items、title、onItemClick
  • 布局属性:vertical、wrap
  • 语义化样式:styles、classNames
  1. 默认值设置
  • prefixCls = 'arco-x-prompts':提供默认类名前缀
  • styles = {}:避免后续访问 undefined
  • classNames: customClassNames = {}:重命名为 customClassNames,避免与变量名冲突
  1. ...htmlProps 的作用
...htmlProps  // 收集所有其他 HTML 属性(id、data-*、aria-* 等)
  • 因为 PromptsProps 继承了 HTMLAttributes<HTMLDivElement>
  • 用户可以传递任何原生 div 属性
  • 这些属性会通过 ...htmlProps 传递给根元素
  1. 实际使用示例
<Prompts
  items={items}
  id="my-prompts"           // → htmlProps.id
  data-testid="prompts"     // → htmlProps['data-testid']
  aria-label="提示词列表"    // → htmlProps['aria-label']
/>

// 在组件内部
<div {...htmlProps} className={mergedCls} style={style}>
  {/* id、data-testid、aria-label 都会被应用到这个 div 上 */}
</div>

4.3 第四步:获取上下文和构建类名

import classnames from 'classnames';
import { useXProviderContext } from '../XProvider';

const Prompts: React.FC<PromptsProps> = (props) => {
  const { /* ... */ } = props;

  // ============================ Context ===========================
  const { direction } = useXProviderContext();

  // ============================ ClassNames ===========================
  // 构建根元素的类名
  const mergedCls = classnames(
    prefixCls,
    className,
    rootClassName,
    {
      [`${prefixCls}-rtl`]: direction === 'rtl',
    },
  );

  // 构建列表的类名
  const mergedListCls = classnames(
    `${prefixCls}-list`,
    customClassNames.list,
    {
      [`${prefixCls}-list-wrap`]: wrap,
      [`${prefixCls}-list-vertical`]: vertical,
    },
  );

  // ...
};

要点说明:

  1. 使用 classnames 库
  • 方便处理条件类名
  • 自动过滤 undefined 和 false
  • 支持对象语法:{ 'class-name': condition }
  1. 支持 RTL 模式
{
  [`${prefixCls}-rtl`]: direction === 'rtl',
}
  • 从 XProviderContext 获取全局方向配置
  • 当 direction 为 'rtl' 时,添加 arco-x-prompts-rtl 类名
  • 支持阿拉伯语、希伯来语等从右到左的语言
  1. 为什么要分 mergedCls 和 mergedListCls?
  • mergedCls:根元素的类名(包含标题和列表)
  • mergedListCls:列表容器的类名(只包含提示词列表)
  • 这样可以分别控制它们的样式
  1. 条件类名的应用
{
  [`${prefixCls}-list-wrap`]: wrap,      // wrap=true 时添加
  [`${prefixCls}-list-vertical`]: vertical,  // vertical=true 时添加
}

4.4 第五步:获取上下文和构建类名

import { useXProviderContext } from '../XProvider';

const Prompts: React.FC<PromptsProps> = (props) => {
  const { /* ... */ } = props;

  // ============================ Context ===========================
  const { direction } = useXProviderContext();

  // ============================ ClassNames ===========================
  const mergedCls = classnames(
    prefixCls,
    className,
    rootClassName,
    {
      [`${prefixCls}-rtl`]: direction === 'rtl',
    },
  );

  const mergedListCls = classnames(
    `${prefixCls}-list`,
    customClassNames.list,
    {
      [`${prefixCls}-list-wrap`]: wrap,
      [`${prefixCls}-list-vertical`]: vertical,
    },
  );

  // ...
};

关键概念:

  1. useXProviderContext
  • 获取全局上下文配置
  • 主要用于获取 direction(文本方向:ltr 或 rtl)
  • 支持国际化(如阿拉伯语、希伯来语等从右到左的语言)
  1. mergedCls(根元素类名)
classnames(
  prefixCls,           // 基础类名:arco-x-prompts
  className,           // 用户传递的类名
  rootClassName,       // 根元素类名
  {
    [`${prefixCls}-rtl`]: direction === 'rtl',  // RTL 模式
  },
)
  • 使用 classnames 库合并多个类名
  • 支持条件类名(对象语法)
  • 自动过滤 undefinedfalse
  1. mergedListCls(列表容器类名)
classnames(
  `${prefixCls}-list`,              // 基础类名:arco-x-prompts-list
  customClassNames.list,            // 用户自定义的列表类名
  {
    [`${prefixCls}-list-wrap`]: wrap,      // 换行模式
    [`${prefixCls}-list-vertical`]: vertical,  // 垂直模式
  },
)
  • 根据 wrapvertical 属性动态添加类名
  • 支持用户自定义语义化类名
  1. 为什么使用 classnames 库?
// ❌ 手动拼接(容易出错)
const cls = [prefixCls, className, wrap && `${prefixCls}-list-wrap`]
  .filter(Boolean)
  .join(' ');

// ✅ 使用 classnames(简洁、安全)
const cls = classnames(
  prefixCls,
  className,
  { [`${prefixCls}-list-wrap`]: wrap },
);

4.5 第六步:渲染完整的 TSX

const Prompts: React.FC<PromptsProps> = (props) => {
  // ... 前面的所有逻辑

  return (
    <div {...htmlProps} className={mergedCls} style={style}>
      {/* Title */}
      {title && (
        <Typography.Title
          heading={5}
          className={classnames(`${prefixCls}-title`, customClassNames.title)}
          style={styles.title}
        >
          {title}
        </Typography.Title>
      )}

      {/* Prompt List */}
      <div className={mergedListCls} style={styles.list}>
        {items?.map((info, index) => {
          const isNest = info.children && info.children.length > 0;

          return (
            <div
              key={info.key || `key_${index}`}
              style={styles.item}
              className={classnames(
                `${prefixCls}-item`,
                customClassNames.item,
                {
                  [`${prefixCls}-item-disabled`]: info.disabled,
                  [`${prefixCls}-item-has-nest`]: isNest,
                },
              )}
              onClick={() => {
                if (!isNest && !info.disabled && onItemClick) {
                  onItemClick({ data: info });
                }
              }}
            >
              {/* Icon */}
              {info.icon && <div className={`${prefixCls}-icon`}>{info.icon}</div>}

              {/* Content */}
              <div
                className={classnames(
                  `${prefixCls}-content`,
                  customClassNames.itemContent,
                )}
                style={styles.itemContent}
              >
                {/* Label */}
                {info.label && <h6 className={`${prefixCls}-label`}>{info.label}</h6>}

                {/* Description */}
                {info.description && <p className={`${prefixCls}-desc`}>{info.description}</p>}

                {/* Children (Nested Prompts) */}
                {isNest && (
                  <Prompts
                    className={`${prefixCls}-nested`}
                    items={info.children}
                    vertical
                    onItemClick={onItemClick}
                    classNames={{
                      list: customClassNames.subList,
                      item: customClassNames.subItem,
                    }}
                    styles={{
                      list: styles.subList,
                      item: styles.subItem,
                    }}
                  />
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

关键点详解:

1. 根<div> 的属性顺序

<div {...htmlProps} className={mergedCls} style={style}>

顺序说明:

  • {...htmlProps} 在前:先展开所有 HTML 属性(id、data- 、aria- 等)

  • className={mergedCls} 在后:覆盖 htmlProps 中可能存在的 className

  • style={style} 在后:覆盖 htmlProps 中可能存在的 style

实际效果:

<Prompts
  items={items}
  id="my-prompts"           // → htmlProps.id
  data-testid="prompts"     // → htmlProps['data-testid']
  className="custom-class"  // → 会被合并到 mergedCls 中
/>

2. 标题渲染

{title && (
  <Typography.Title
    heading={5}
    className={classnames(`${prefixCls}-title`, customClassNames.title)}
    style={styles.title}
  >
    {title}
  </Typography.Title>
)}

要点:

  • 使用 Arco 的 Typography.Title 组件
  • heading={5}:对应 <h5> 标签
  • 支持用户自定义类名和样式
  • 只有 title 存在时才渲染

3. 遍历渲染提示词

{items?.map((info, index) => {
  const isNest = info.children && info.children.length > 0;

  return (
    <div key={info.key || `key_${index}`}>
      {/* ... */}
    </div>
  );
})}

要点:

  • 使用 items?.map():可选链操作符,避免 items 为 undefined 时报错
  • isNest:判断是否有子提示词
  • key={info.key || key_${index}}:优先使用 info.key,否则使用 index

4. 提示词的类名

className={classnames(
  `${prefixCls}-item`,           // 基础类名:arco-x-prompts-item
  customClassNames.item,         // 用户自定义的类名
  {
    [`${prefixCls}-item-disabled`]: info.disabled,  // 禁用状态
    [`${prefixCls}-item-has-nest`]: isNest,         // 有子提示词
  },
)}

条件类名:

  • arco-x-prompts-item-disabled:当 info.disabled === true 时添加
  • arco-x-prompts-item-has-nest:当有子提示词时添加

5. 点击事件处理

onClick={() => {
  if (!isNest && !info.disabled && onItemClick) {
    onItemClick({ data: info });
  }
}}

逻辑说明:

  • !isNest:只有非嵌套的提示词才能点击
  • !info.disabled:禁用的提示词不能点击
  • onItemClick:回调函数存在时才调用

为什么这样设计?

// 示例数据
{
  key: '1',
  label: '学习 Arco Design',  // ← 这个不能点击(因为有 children)
  children: [
    { key: '1-1', label: '快速开始' },  // ← 这个可以点击
    { key: '1-2', label: '组件文档' },  // ← 这个可以点击
  ]
}

6. 图标渲染

{info.icon && <div className={`${prefixCls}-icon`}>{info.icon}</div>}
  • 只有 info.icon 存在时才渲染
  • icon 可以是任何 React 元素(图标组件、文字、图片等)

7. 内容区域

<div
  className={classnames(
    `${prefixCls}-content`,
    customClassNames.itemContent,
  )}
  style={styles.itemContent}
>
  {/* Label */}
  {info.label && <h6 className={`${prefixCls}-label`}>{info.label}</h6>}

  {/* Description */}
  {info.description && <p className={`${prefixCls}-desc`}>{info.description}</p>}

  {/* Children */}
  {isNest && <Prompts ... />}
</div>

结构:

  • content:内容容器
  • label:标签(使用 <h6> 标签)
  • desc:描述(使用 <p> 标签)
  • 嵌套的 <Prompts> 组件

8. 递归渲染嵌套提示词

{isNest && (
  <Prompts
    className={`${prefixCls}-nested`}
    items={info.children}
    vertical
    onItemClick={onItemClick}
    classNames={{
      list: customClassNames.subList,
      item: customClassNames.subItem,
    }}
    styles={{
      list: styles.subList,
      item: styles.subItem,
    }}
  />
)}

关键点:

  • 递归组件:直接调用 <Prompts> 组件本身

  • items={info.children} :传递子提示词数据

  • vertical:子提示词强制垂直排列

  • onItemClick={onItemClick} :保持点击回调一致

  • 语义化样式:使用 subListsubItem 区分父子样式

为什么使用组件递归而不是函数递归?

// ❌ 函数递归(旧方式)
const renderItem = (item: PromptProps) => {
  return (
    <div>
      {item.children?.map((child) => renderItem(child))}
    </div>
  );
};

// ✅ 组件递归(新方式)
{isNest && <Prompts items={info.children} />}

优势:

  • 更简洁:不需要额外的函数
  • 更直观:JSX 结构清晰
  • 更易维护:所有逻辑在一个地方
  • 更符合 React 最佳实践

9. 为什么不使用 renderItem 函数?

❌ 旧方式(使用 renderItem):

const renderItem = (item: PromptProps, isSubItem = false) => {
  // ...
  return <div>...</div>;
};

return (
  <div>
    {items.map((item) => renderItem(item, false))}
  </div>
);

✅ 新方式(直接在 JSX 中渲染):

return (
  <div>
    {items?.map((info, index) => (
      <div>...</div>
    ))}
  </div>
);

优势:

  1. 更简洁:减少一层函数抽象

  2. 更直观:JSX 结构一目了然

  3. 更易维护:所有逻辑都在一个地方

  4. 递归更清晰:使用组件递归而不是函数递归

  5. 性能更好:减少函数调用开销

10. 完整的组件结构

<div {...htmlProps}>                      ← 根容器(支持所有 HTML 属性)
  {title && <Typography.Title>}           ← 标题(可选)
  <div className="list">                  ← 列表容器
    {items?.map((info) => (               ← 遍历提示词
      <div className="item">              ← 单个提示词
        <div className="icon">            ← 图标
        <div className="content">         ← 内容
          <h6 className="label">          ← 标签
          <p className="desc">            ← 描述
          {isNest && <Prompts>}           ← 递归渲染子提示词
        </div>
      </div>
    ))}
  </div>
</div>

4.6 完整的组件代码

import { Typography } from '@arco-design/web-react';
import classnames from 'classnames';
import React from 'react';

import type { PromptsProps, PromptProps } from './interface';
import { useXProviderContext } from '../XProvider';
import './index.less';

const Prompts: React.FC<PromptsProps> = (props) => {
  const {
    prefixCls = 'arco-x-prompts',
    title,
    className,
    items,
    onItemClick,
    vertical,
    wrap,
    rootClassName,
    styles = {},
    classNames: customClassNames = {},
    style,
    ...htmlProps
  } = props;

  // ============================ Context ===========================
  const { direction } = useXProviderContext();

  const mergedCls = classnames(
    prefixCls,
    className,
    rootClassName,
    {
      [`${prefixCls}-rtl`]: direction === 'rtl',
    },
  );

  const mergedListCls = classnames(
    `${prefixCls}-list`,
    customClassNames.list,
    {
      [`${prefixCls}-list-wrap`]: wrap,
      [`${prefixCls}-list-vertical`]: vertical,
    },
  );

  return (
    <div {...htmlProps} className={mergedCls} style={style}>
      {/* Title */}
      {title && (
        <Typography.Title
          heading={5}
          className={classnames(`${prefixCls}-title`, customClassNames.title)}
          style={styles.title}
        >
          {title}
        </Typography.Title>
      )}
      {/* Prompt List */}
      <div className={mergedListCls} style={styles.list}>
        {items?.map((info, index) => {
          const isNest = info.children && info.children.length > 0;

          return (
            <div
              key={info.key || `key_${index}`}
              style={styles.item}
              className={classnames(
                `${prefixCls}-item`,
                customClassNames.item,
                {
                  [`${prefixCls}-item-disabled`]: info.disabled,
                  [`${prefixCls}-item-has-nest`]: isNest,
                },
              )}
              onClick={() => {
                if (!isNest && !info.disabled && onItemClick) {
                  onItemClick({ data: info });
                }
              }}
            >
              {/* Icon */}
              {info.icon && <div className={`${prefixCls}-icon`}>{info.icon}</div>}
              {/* Content */}
              <div
                className={classnames(
                  `${prefixCls}-content`,
                  customClassNames.itemContent,
                )}
                style={styles.itemContent}
              >
                {/* Label */}
                {info.label && <h6 className={`${prefixCls}-label`}>{info.label}</h6>}

                {/* Description */}
                {info.description && <p className={`${prefixCls}-desc`}>{info.description}</p>}

                {/* Children */}
                {isNest && (
                  <Prompts
                    className={`${prefixCls}-nested`}
                    items={info.children}
                    vertical
                    onItemClick={onItemClick}
                    classNames={{
                      list: customClassNames.subList,
                      item: customClassNames.subItem,
                    }}
                    styles={{
                      list: styles.subList,
                      item: styles.subItem,
                    }}
                  />
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

Prompts.displayName = 'Prompts';

export default Prompts;
export type { PromptsProps, PromptProps };

代码说明:

  1. 导入 Arco Design 组件
import { Typography } from '@arco-design/web-react';
  • 使用 Arco 的 Typography.Title 组件
  • heading={5} 对应 <h5> 标签(Arco 的 API)
  1. 导入类型定义
import type { PromptsProps, PromptProps } from './interface';
  • interface.ts 导入类型
  • 使用 type 关键字(仅类型导入)
  1. 导入样式文件
import './index.less';
  • 使用传统的 Less 样式文件
  • 不使用 CSS-in-JS(与 Ant Design 不同)
  1. 使用 useXProviderContext
const { direction } = useXProviderContext();
  • 获取全局上下文配置
  • 主要用于 RTL 模式支持
  1. 直接在 JSX 中渲染
  • 不使用 renderItem 函数
  • 使用 items?.map() 直接渲染
  • 递归使用 <Prompts> 组件本身
  1. 简洁的样式处理
  • 不使用 wrapCSSVar(Arco 不需要)
  • 不使用 contextConfig(Arco 没有全局组件配置)
  • 直接使用 stylescustomClassNames
  1. displayName 设置
Prompts.displayName = 'Prompts';
  • 方便 React DevTools 调试
  • 不需要 process.env.NODE_ENV 判断
  1. 导出类型
export type { PromptsProps, PromptProps };
  • 导出类型供外部使用

5. 样式实现

5.1 第八步:创建样式文件

创建文件 packages/x/src/Prompts/index.less

@prefix: arco-x-prompts;

.@{prefix} {
  // 标题样式
  .arco-typography {
    margin-bottom: 12px;
  }

  // 列表容器
  &-list {
    display: flex;
    gap: 8px;
    overflow-x: auto;
    overflow-y: hidden;

    // 隐藏滚动条
    &::-webkit-scrollbar {
      display: none;
    }
    scrollbar-width: none;

    // 垂直布局
    &-vertical {
      flex-direction: column;
    }

    // 换行布局
    &-wrap {
      flex-wrap: wrap;
    }
  }

  // 单个提示词
  &-item,
  &-subitem {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 12px 16px;
    background-color: var(--color-bg-2);
    border: 1px solid var(--color-border-2);
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.2s;
    flex-shrink: 0;

    // hover 状态
    &:hover {
      background-color: var(--color-fill-3);
      border-color: var(--color-border-3);
    }

    // active 状态
    &:active {
      transform: scale(0.98);
    }

    // 禁用状态
    &-disabled {
      cursor: not-allowed;
      opacity: 0.5;

      &:hover {
        background-color: var(--color-bg-2);
        border-color: var(--color-border-2);
      }

      &:active {
        transform: none;
      }
    }
  }

  // 提示词内容
  &-item-icon {
    display: inline-flex;
    align-items: center;
    margin-right: 8px;
  }

  &-item-text {
    flex: 1;
  }

  &-item-label {
    font-size: 14px;
    font-weight: 500;
    color: var(--color-text-1);
    margin-bottom: 4px;
  }

  &-item-description {
    font-size: 12px;
    color: var(--color-text-3);
    line-height: 1.5;
  }

  // 子提示词列表
  &-subitem {
    background-color: var(--color-bg-1);
    padding: 8px 12px;
  }
}

5.2 样式设计要点

1. 滚动条隐藏

&-list {
  overflow-x: auto;
  overflow-y: hidden;

  // 隐藏滚动条
  &::-webkit-scrollbar {
    display: none;
  }
  scrollbar-width: none;  // Firefox
}

为什么要隐藏滚动条?

  • 提供更简洁的视觉效果
  • 用户仍然可以通过鼠标拖拽或触摸滑动来滚动
  • 适合移动端和桌面端

2. Flexbox 布局

&-list {
  display: flex;
  gap: 8px;

  &-vertical {
    flex-direction: column;
  }

  &-wrap {
    flex-wrap: wrap;
  }
}
  • 默认:flex-direction: row(水平排列)
  • verticalflex-direction: column(垂直排列)
  • wrapflex-wrap: wrap(自动换行)

3. 交互状态

&-item {
  cursor: pointer;
  transition: all 0.2s;

  &:hover {
    background-color: var(--color-fill-3);
    border-color: var(--color-border-3);
  }

  &:active {
    transform: scale(0.98);  // 点击时轻微缩小
  }
}

为什么要添加这些状态?

  • hover:鼠标悬停时改变背景色,提供视觉反馈
  • active:点击时缩小,提供触觉反馈
  • transition:平滑过渡,提升用户体验

4. 禁用状态

&-item-disabled {
  cursor: not-allowed;
  opacity: 0.5;

  &:hover {
    background-color: var(--color-bg-2);  // 保持原样
    border-color: var(--color-border-2);
  }

  &:active {
    transform: none;  // 不缩放
  }
}

禁用状态的设计:

  • opacity: 0.5:视觉上变淡
  • cursor: not-allowed:鼠标指针变为禁止图标
  • 覆盖 hover 和 active 状态,保持不变

5. 子提示词样式

&-subitem {
  background-color: var(--color-bg-1);  // 比父级更浅
  padding: 8px 12px;                     // 比父级更小
}

为什么要区分父级和子级?

  • 视觉层次:子级更浅的背景色
  • 尺寸差异:子级更小的内边距
  • 帮助用户理解嵌套关系

5.3 响应式设计

如果需要在移动端优化,可以添加:

@media (max-width: 768px) {
  .@{prefix} {
    &-list {
      flex-direction: column;  // 移动端强制垂直布局
    }

    &-item {
      width: 100%;  // 占满宽度
    }
  }
}

6. 总结

6.1 实现步骤回顾

我们按照以下步骤实现了 Prompts 组件:

  1. 设计思路:明确组件的作用和嵌套结构

  2. Props 设计:定义支持嵌套的数据结构

  3. 类型定义:创建 interface.ts,定义复杂的 TypeScript 类型

  4. 组件骨架:创建基础结构

  5. Props 解构:解构 props 并设置默认值

  6. 类名构建:构建多个语义化的类名

  7. 递归渲染:实现嵌套提示词的递归渲染

  8. 样式实现:使用 LESS 编写样式,支持多种布局

6.2 关键技术点

  1. 递归渲染
const renderItem = (item: PromptProps, isSubItem = false) => {
  // ...
  {children && children.length > 0 && (
    <div className={classNames.subList}>
      {children.map((child) => renderItem(child, true))}  // 递归
    </div>
  )}
};

2. 条件类名

const itemCls = [
  isSubItem ? `${prefixCls}-subitem` : `${prefixCls}-item`,
  disabled && `${prefixCls}-item-disabled`,
  isSubItem ? classNames.subItem : classNames.item,
].filter(Boolean).join(' ');

3. 禁用状态处理

const handleClick = () => {
  if (disabled) return;  // 关键:禁用时不触发
  onItemClick?.({ data: item });
};

4. 灵活的布局

  • 通过 verticalwrap props 控制布局
  • 使用 Flexbox 实现响应式布局

6.3 与 Welcome 组件的对比

特性 Welcome 组件 Prompts 组件
复杂度 简单 中等
数据结构 扁平 嵌套(树形)
渲染方式 直接渲染 递归渲染
交互 点击、禁用
布局 固定 灵活(垂直/水平/换行)
使用场景 欢迎页 提示词列表

6.4 使用示例

import { Prompts } from '@curry-ai/x';
import { IconBulb, IconBook } from '@arco-design/web-react/icon';

function App() {
  return (
    <Prompts
      title="快速开始"
      items={[
        {
          key: '1',
          icon: <IconBulb />,
          label: '学习 Arco Design',
          description: '从基础开始学习',
          children: [
            { key: '1-1', label: '快速开始' },
            { key: '1-2', label: '组件文档' },
          ],
        },
        {
          key: '2',
          icon: <IconBook />,
          label: '查看示例',
          description: '浏览示例代码',
          disabled: true,
        },
      ]}
      onItemClick={(info) => {
        console.log('点击了:', info.data.label);
      }}
      wrap
    />
  );
}

6.5 扩展思考

如果要进一步优化这个组件,可以考虑:

  1. 虚拟滚动:当提示词数量很多时,使用虚拟滚动优化性能

  2. 拖拽排序:支持用户拖拽调整提示词顺序

  3. 搜索过滤:添加搜索框,过滤提示词

  4. 键盘导航:支持键盘上下键选择提示词

  5. 动画效果:添加展开/收起子提示词的动画


附录:完整文件清单

packages/x/src/Prompts/
├── interface.ts      # TypeScript 类型定义
├── index.tsx         # 组件实现
├── index.less        # 样式文件
├── index.md          # 组件文档
└── demo/             # 示例代码
    ├── basic.tsx
    ├── disabled.tsx
    ├── flex-vertical.tsx
    ├── flex-wrap.tsx
    ├── flex-wrap-fixed.tsx
    └── nest.tsx

对比总结:Welcome vs Prompts

通过实现这两个组件,我们学习了:

Welcome 组件(简单组件)

  • ✅ 基础的 Props 设计
  • ✅ TypeScript 类型定义
  • ✅ forwardRef 的使用
  • ✅ 条件渲染
  • ✅ 语义化样式

Prompts 组件(复杂组件)

  • ✅ 嵌套数据结构
  • ✅ 递归渲染
  • ✅ 复杂的类名逻辑
  • ✅ 交互状态处理
  • ✅ 灵活的布局系统

恭喜!你已经掌握了从简单到复杂的组件实现方法。 🎉

下一步,你可以尝试实现更复杂的组件,如:

  • Bubble:消息气泡,支持打字机效果

  • Sender:消息发送器,集成多个子组件

  • Attachments:文件上传,处理文件状态

Vue中mixin与mixins:全面解析与实战指南

作者 北辰alk
2025年12月11日 21:30

一、引言:为什么需要混入?

在Vue.js开发中,我们经常会遇到多个组件需要共享相同功能或逻辑的情况。例如,多个页面都需要用户认证检查、都需要数据加载状态管理、都需要相同的工具方法等。为了避免代码重复,提高代码的可维护性,Vue提供了混入(Mixin)机制。

今天,我将为你详细解析Vue中mixin和mixins的区别,并通过大量代码示例和流程图帮助你彻底理解这个概念。

二、基础概念解析

1. 什么是mixin?

mixin(混入) 是一个包含可复用组件选项的JavaScript对象。它可以包含组件选项中的任何内容,如data、methods、created、computed等生命周期钩子和属性。

2. 什么是mixins?

mixins 是Vue组件的一个选项,用于接收一个混入对象的数组。它允许组件使用多个mixin的功能。

三、核心区别详解

让我们通过一个对比表格来直观了解二者的区别:

特性 mixin mixins
本质 一个JavaScript对象 Vue组件的选项属性
作用 定义可复用的功能单元 注册和使用mixin
使用方式 被mixins选项引用 组件内部选项
数量 单个 可包含多个mixin

关系流程图

graph TD
    A[mixin定义] -->|混入到| B[Component组件]
    C[另一个mixin定义] -->|混入到| B
    D[更多mixin...] -->|混入到| B
    B --> E[mixins选项<br/>接收mixin数组]

四、代码实战演示

1. 基本mixin定义与使用

创建第一个mixin:

// mixins/loggerMixin.js
export const loggerMixin = {
  data() {
    return {
      logMessages: []
    }
  },
  
  methods: {
    logMessage(message) {
      const timestamp = new Date().toISOString()
      const logEntry = `[${timestamp}] ${message}`
      this.logMessages.push(logEntry)
      console.log(logEntry)
    }
  },
  
  created() {
    this.logMessage('组件/混入已创建')
  }
}

创建第二个mixin:

// mixins/authMixin.js
export const authMixin = {
  data() {
    return {
      currentUser: null,
      isAuthenticated: false
    }
  },
  
  methods: {
    login(user) {
      this.currentUser = user
      this.isAuthenticated = true
      this.$emit('login-success', user)
    },
    
    logout() {
      this.currentUser = null
      this.isAuthenticated = false
      this.$emit('logout')
    }
  },
  
  computed: {
    userRole() {
      return this.currentUser?.role || 'guest'
    }
  }
}

在组件中使用mixins:

<template>
  <div>
    <h1>用户仪表板</h1>
    <div v-if="isAuthenticated">
      <p>欢迎, {{ currentUser.name }} ({{ userRole }})</p>
      <button @click="logout">退出登录</button>
    </div>
    <div v-else>
      <button @click="login({ name: '张三', role: 'admin' })">登录</button>
    </div>
    <div>
      <h3>日志记录:</h3>
      <ul>
        <li v-for="(log, index) in logMessages" :key="index">{{ log }}</li>
      </ul>
    </div>
  </div>
</template>

<script>
import { loggerMixin } from './mixins/loggerMixin'
import { authMixin } from './mixins/authMixin'

export default {
  name: 'UserDashboard',
  
  // mixins选项接收mixin数组
  mixins: [loggerMixin, authMixin],
  
  created() {
    // 合并生命周期钩子
    this.logMessage('用户仪表板组件已创建')
  },
  
  methods: {
    login(user) {
      // 调用mixin的方法
      authMixin.methods.login.call(this, user)
      this.logMessage(`用户 ${user.name} 已登录`)
    }
  }
}
</script>

2. 选项合并策略详解

Vue在处理mixins时遵循特定的合并策略:

// mixins/featureMixin.js
export const featureMixin = {
  data() {
    return {
      message: '来自mixin的消息',
      sharedData: '共享数据'
    }
  },
  
  methods: {
    sayHello() {
      console.log('Hello from mixin!')
    },
    
    commonMethod() {
      console.log('mixin中的方法')
    }
  }
}
<template>
  <div>
    <p>{{ message }}</p>
    <p>{{ componentData }}</p>
    <button @click="sayHello">打招呼</button>
    <button @click="commonMethod">调用方法</button>
  </div>
</template>

<script>
import { featureMixin } from './mixins/featureMixin'

export default {
  mixins: [featureMixin],
  
  data() {
    return {
      message: '来自组件的消息', // 与mixin冲突,组件数据优先
      componentData: '组件特有数据'
    }
  },
  
  methods: {
    // 与mixin中的方法同名,组件方法将覆盖mixin方法
    commonMethod() {
      console.log('组件中的方法')
      // 如果需要调用mixin中的原始方法
      featureMixin.methods.commonMethod.call(this)
    },
    
    componentOnlyMethod() {
      console.log('组件特有方法')
    }
  }
}
</script>

3. 生命周期钩子的合并

生命周期钩子会被合并成数组,mixin的钩子先执行

// mixins/lifecycleMixin.js
export const lifecycleMixin = {
  beforeCreate() {
    console.log('1. mixin的beforeCreate')
  },
  
  created() {
    console.log('2. mixin的created')
  },
  
  mounted() {
    console.log('4. mixin的mounted')
  }
}
<script>
import { lifecycleMixin } from './mixins/lifecycleMixin'

export default {
  mixins: [lifecycleMixin],
  
  beforeCreate() {
    console.log('1. 组件的beforeCreate')
  },
  
  created() {
    console.log('3. 组件的created')
  },
  
  mounted() {
    console.log('5. 组件的mounted')
  }
}
</script>

// 控制台输出顺序:
// 1. mixin的beforeCreate
// 2. 组件的beforeCreate
// 3. mixin的created
// 4. 组件的created
// 5. mixin的mounted
// 6. 组件的mounted

4. 全局混入

除了在组件内使用mixins选项,还可以创建全局mixin:

// main.js或单独的文件中
import Vue from 'vue'

// 全局混入 - 影响所有Vue实例
Vue.mixin({
  data() {
    return {
      globalData: '这是全局数据'
    }
  },
  
  methods: {
    $formatDate(date) {
      return new Date(date).toLocaleDateString()
    }
  },
  
  mounted() {
    console.log('全局mixin的mounted钩子')
  }
})

五、高级用法与最佳实践

1. 可配置的mixin

通过工厂函数创建可配置的mixin:

// mixins/configurableMixin.js
export function createPaginatedMixin(options = {}) {
  const {
    pageSize: defaultPageSize = 10,
    dataKey = 'items'
  } = options
  
  return {
    data() {
      return {
        currentPage: 1,
        pageSize: defaultPageSize,
        totalItems: 0,
        [dataKey]: []
      }
    },
    
    computed: {
      totalPages() {
        return Math.ceil(this.totalItems / this.pageSize)
      },
      
      paginatedData() {
        const start = (this.currentPage - 1) * this.pageSize
        const end = start + this.pageSize
        return this[dataKey].slice(start, end)
      }
    },
    
    methods: {
      goToPage(page) {
        if (page >= 1 && page <= this.totalPages) {
          this.currentPage = page
        }
      },
      
      nextPage() {
        if (this.currentPage < this.totalPages) {
          this.currentPage++
        }
      },
      
      prevPage() {
        if (this.currentPage > 1) {
          this.currentPage--
        }
      }
    }
  }
}
<template>
  <div>
    <h1>用户列表</h1>
    <ul>
      <li v-for="user in paginatedData" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
    
    <div class="pagination">
      <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
      <span>第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
      <button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
    </div>
  </div>
</template>

<script>
import { createPaginatedMixin } from './mixins/configurableMixin'

export default {
  name: 'UserList',
  
  mixins: [createPaginatedMixin({ pageSize: 5, dataKey: 'users' })],
  
  data() {
    return {
      users: [] // 会被mixin处理
    }
  },
  
  async created() {
    // 模拟API调用
    const response = await fetch('/api/users')
    this.users = await response.json()
    this.totalItems = this.users.length
  }
}
</script>

2. 合并策略自定义

// 自定义合并策略
import Vue from 'vue'

// 为特定选项自定义合并策略
Vue.config.optionMergeStrategies.customOption = function(toVal, fromVal) {
  // 返回合并后的值
  return toVal || fromVal
}

// 自定义方法的合并策略:将方法合并到一个数组中
Vue.config.optionMergeStrategies.myMethods = function(toVal, fromVal) {
  if (!toVal) return [fromVal]
  if (!fromVal) return toVal
  return toVal.concat(fromVal)
}

六、mixin与mixins的完整执行流程

sequenceDiagram
    participant G as 全局mixin
    participant M1 as Mixin1
    participant M2 as Mixin2
    participant C as 组件
    participant V as Vue实例
    
    Note over G,M2: 初始化阶段
    G->>M1: 执行全局mixin钩子
    M1->>M2: 执行Mixin1钩子
    M2->>C: 执行Mixin2钩子
    C->>V: 执行组件钩子
    
    Note over G,M2: 数据合并
    V->>V: 合并data选项<br/>(组件优先)
    
    Note over G,M2: 方法合并
    V->>V: 合并methods选项<br/>(组件覆盖mixin)
    
    Note over G,M2: 钩子函数合并
    V->>V: 合并生命周期钩子<br/>(全部执行,mixin先执行)

七、替代方案与Composition API

虽然mixins非常有用,但在大型项目中可能导致一些问题:

  1. 命名冲突
  2. 隐式依赖
  3. 难以追踪功能来源

Vue 3引入了Composition API作为更好的替代方案:

<template>
  <div>
    <p>计数: {{ count }}</p>
    <p>双倍: {{ doubleCount }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

<script>
import { ref, computed, onMounted } from 'vue'

// 使用Composition API复用逻辑
function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const doubleCount = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  onMounted(() => {
    console.log('计数器已挂载')
  })
  
  return {
    count,
    doubleCount,
    increment
  }
}

export default {
  setup() {
    // 明确地使用功能,避免命名冲突
    const { count, doubleCount, increment } = useCounter(10)
    
    return {
      count,
      doubleCount,
      increment
    }
  }
}
</script>

八、总结与最佳实践

mixin vs mixins总结:

  • mixin是功能单元,mixins是使用这些功能单元的接口
  • 一个组件可以通过mixins选项使用多个mixin
  • 合并策略:组件选项通常优先于mixin选项
  • 生命周期钩子会合并执行,mixin钩子先于组件钩子

最佳实践:

  1. 命名规范:为mixin使用特定前缀,如mixinwith
  2. 单一职责:每个mixin只关注一个特定功能
  3. 明确文档:记录mixin的依赖和副作用
  4. 避免全局混入:除非确实需要影响所有组件
  5. 考虑Composition API:在Vue 3项目中优先使用

适用场景:

  • 适合使用mixin:简单的工具函数、通用的生命周期逻辑、小型到中型项目
  • 考虑替代方案:复杂的状态管理、大型企业级应用、需要明确依赖关系的场景

希望通过这篇文章,你已经全面理解了Vue中mixin和mixins的区别与用法。在实际开发中,合理使用混入可以显著提高代码复用性和可维护性,但也要注意避免过度使用导致的复杂性问题。

如果你觉得这篇文章有帮助,欢迎分享给更多开发者!

Vue3-全局组件 && 递归组件

作者 YaeZed
2025年12月11日 21:21

1.全局组件

全局组件是在 main.ts 中一次性注册,之后就可以在项目中的任何组件模板内直接使用,无需 import

组件本身就是一个标准的 SFC (单文件组件),使用 <script setup lang="ts"> 编写。

举个栗子,button组件

<template>
  <button class="base-button">
    <slot></slot>
  </button>
</template>

<script setup lang="ts">
// 这里可以定义 props, emits 等
// 例如:
// defineProps<{ type: 'primary' | 'secondary' }>()
</script>

<style scoped>
.base-button {
  padding: 8px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
.base-button:hover {
  background-color: #f0f0f0;
}
</style>

在创建 Vue 实例后、挂载 (.mount()) 之前来注册它。

app.component('组件名', 组件对象)

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 1. 导入要全局注册的组件
import BaseCard from './components/BaseCard.vue'
import BaseButton from './components/BaseButton.vue'
import SvgIcon from './components/SvgIcon.vue'

const app = createApp(App)

// 2. 使用 app.component() 进行全局注册
// app.component('组件名', 组件对象)
app.component('BaseCard', BaseCard)
app.component('BaseButton', BaseButton)
app.component('SvgIcon', SvgIcon)

// 3. 挂载应用
app.mount('#app')

注册后,在任何其他组件中都可以直接使用 <BaseButton>,无需导入。

<template>
  <div>
    <h1>欢迎!</h1>
    <BaseButton>点我</BaseButton>
    <BaseCard>
      <SvgIcon name="user" />
      <p>一些内容</p>
    </BaseCard>
  </div>
</template>

<script setup lang="ts">
// 无需 import BaseButton, BaseCard, SvgIcon
</script>
使用场景
  1. 基础 UI 组件: 这是最常见的场景。项目中使用频率极高的组件,例如 Button, Icon, Modal, Card, Input 等。通常会以 Base-App- 作为前缀,以示区分。
  2. UI 库集成: 当使用像 Element Plus, Naive UI 或 Vuetify 这样的库时,它们通常会提供一个 app.use(Library) 的方式,这背后其实就是全局注册了它们所有的组件。
  3. 布局组件: AppHeader, AppFooter, Sidebar 等几乎每个页面都会用到的布局框架。

⚠️ 注意: 全局注册会轻微增加应用的初始加载体积,因为所有全局组件都会被打包到主 chunk 中。因此,请对那些真正常用的组件使用全局注册,避免滥用。


2.递归组件

递归组件是指在其模板中调用自身的组件。

举个栗子:树形菜单

首先,定义数据结构 ( types.ts)

// types.ts
export interface TreeNodeData {
  id: string;
  label: string;
  children?: TreeNodeData[]; // 关键:children 数组的类型是它自身
}

然后,创建递归组件 (TreeNode.vue)

<template>
  <div class="tree-node">
    <div class="node-label">{{ node.label }}</div>
    <!-- 可选链操作符?.:如果 node.children 存在且有值,则渲染子节点列表,否则返回undefined,隐式转换为false -->
    <ul v-if="node.children && node.children.length > 0" class="children-list">
      <li v-for="child in node.children" :key="child.id">
        <!-- 递归 -->
        <TreeNode :node="child" />
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
// 导入我们定义的类型
import type { TreeNodeData } from './types'

// 1. 定义 Props,接收父组件传递的数据
interface Props {
  node: TreeNodeData;
}
defineProps<Props>()

</script>

<style scoped>
.tree-node {
  margin-left: 20px;
}
.node-label {
  font-weight: bold;
}
.children-list {
  list-style-type: none;
  padding-left: 15px;
  border-left: 1px dashed #ccc;
}
</style>

在父组件中导入并渲染“根节点”即可。

<template>
  <div>
    <h1>文件结构树</h1>
    <TreeNode :node="fileTree" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import TreeNode from './components/TreeNode.vue'
import type { TreeNodeData } from './components/types' // 确保路径正确

// 准备一个符合 TreeNodeData 结构的 TS 数据
const fileTree = ref<TreeNodeData>({
  id: 'root',
  label: '项目根目录 (src)',
  children: [
    {
      id: 'c1',
      label: 'components',
      children: [
        { id: 'c1-1', label: 'TreeNode.vue' },
        { id: 'c1-2', label: 'BaseButton.vue' },
      ],
    },
    {
      id: 'c2',
      label: 'views',
      children: [
        { id: 'c2-1', label: 'HomePage.vue' },
      ],
    },
    {
      id: 'c3',
      label: 'App.vue',
      // 这个节点没有 children,递归将在此处停止
    },
  ],
})
</script>
使用场景
  1. 树形结构: 任何需要展示层级关系的数据。例如:文件浏览器、组织架构图、导航菜单(尤其是多级下拉菜单)。
  2. 嵌套评论: 社交媒体或论坛中的评论区,一条评论可以有“回复”(子评论),子评论又可以有回复。
  3. JSON 格式化器: 展示一个 JSON 对象,如果某个值是对象或数组,就递归地调用组件来展示其内部。

参考文章

小满zs 学习Vue3 第十五章(全局组件,局部组件,递归组件)xiaoman.blog.csdn.net/article/det…

JavaScript 基础语法知识点(变量类型 + 函数 + 事件监听 + 实战案例)

2025年12月11日 20:47

大家好!JavaScript 作为前端开发的核心语言,也是全栈开发的必备技能,其灵活的语法特性既让开发者爱不释手,也让新手容易踩坑。

这篇文章会从 JavaScript 的核心语法入手,拆解变量类型与函数的核心逻辑,讲解事件监听,再结合实战案例巩固用法,帮你夯实 JS 基础,避开新手常见的语法陷阱。

一、JavaScript 核心认知:先搞懂 “它的定位与特点”

1.1 什么是 JavaScript?

JavaScript(简称 JS)是一门解释型、弱类型、面向对象(基于原型)  的脚本语言,最初设计用于前端页面交互,如今已扩展到后端(Node.js)、移动端、桌面端等领域,成为全栈开发的核心语言。

1.2 核心特点

  • 弱类型:变量类型无需提前声明,可动态改变(如let a = 1; a = "hello");
  • 解释执行:无需编译,由 JS 引擎(如 V8)逐行解析执行;
  • 单线程:同一时间只能执行一个任务,通过事件循环实现异步;
  • 基于原型:没有类的概念,通过原型链实现继承。

1.3 JavaScript的组成

  • ECMAScript: 规定了JS基础语法核心知识,包括变量、数据类型、流程控制、函数、对象等。
  • BOM:浏览器对象模型,用于操作浏览器本身,如:页面弹窗、地址栏操作、关闭窗口等。
  • DOM:文档对象模型,用于操作HTML文档,如:改变标签内的内容、改变标签内字体样式等。

1.4 为什么要学好 JS 基础语法?

基础语法是编写高效、可维护代码的前提:

  • 避免语法错误:比如变量提升、类型转换导致的逻辑 bug;
  • 理解框架原理:Vue、React 等框架的底层都基于 JS 核心语法(如原型、闭包);
  • 提升调试效率:能快速定位 “看似正确却运行异常” 的语法问题。

二、语法一:js基础语法(基础中的基础)

2.1 1. JS引入方式

js代码也是书写在html中的,那么html中如何引入js代码呢?主要通过下面的3种引入方式:

代码示例:对比三种引入方式

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>JS-引入方式</title>
</head>

<body>

  <!-- 
        ctrl+shift+/生成了html代码注释
        Ctrl+S 保存  -->
  <!-- 
        写js代码方式1:行内方式,在html标签里面写js代码
        <html标签  on属性="js代码"></html标签> 
        alert(字符串) 是js的函数,弹出提示框消息
        onclick 是标签点击的行为,当用户点击标签元素就会触发js代码的执行
  -->
  <button onclick="alert('方式1')">按钮</button>
  <!-- 
        写js代码方式2:内部方式,js代码写在<script>标签内部 -->
  <script>
    alert('方式2')
  </script>
  <!-- 
        写js代码方式3:外部方式,js代码写在外部的.js文件中
        步骤: 1.创建js文件,编写js代码
               2.在html网页中引入js文件,才会运行里面的js代码 
        注意: 用于引入外部js文件的<script src="./js/out.js"></script>
              标签里面不能写js代码,因为这个src是用于引入外部的所以不会运行标签之间的代码
              所有js代码都是从上往下运行的。 -->
  <script src="./js/out.js">
    alert('无效的代码位置')
  </script>
  <script src="js/demo01.js"></script>
</body>

</html>

核心总结

  • 疑问:三种方式,以后会使用哪种?
  • 答:都会使用,但是第三种的复用性最好,因为不仅可以给当前页面使用,还可以给其他网页使用。
  • 一般推荐,如果js编程操作某个元素推荐使用方式1,如果是多个页面都要用推荐方式3,其他就推荐方式2

2.2 输出语句

  • Jdocument.write() :向HTML的body内输出内容
  • window.alert() :弹出警告框
  • console.log() :写入浏览器控制台(程序员调试使用)

代码示例

<script>
    //目标1:输出语句

    // document.write():向HTML的body内输出内容
    document.write('<h1>hello javascript')

    // window.alert():弹出警告框, window代表当前窗口,是可以省略的
    alert('hello javascript')

    // console.log():写入浏览器控制台(程序员调试使用)
    console.log('javascript');
</script>

三、语法二:变量与数据类型(基础中的基础)

2.1 变量的声明方式(核心区别与使用场景)

JS 中有三种变量声明方式,其作用域、提升规则、可修改性差异巨大,是新手最易混淆的点。

声明方式 作用域 变量提升 可重复声明 可修改值 适用场景
var 函数作用域 / 全局作用域 有(提升到作用域顶部,值为 undefined) 可以 可以 旧代码兼容(不推荐)
let 块级作用域({} 内) 有(暂时性死区,不可提前使用) 不可以 可以 需修改的变量(推荐)
const 块级作用域 有(暂时性死区) 不可以 不可以(引用类型可修改内部属性) 常量、不修改的变量(推荐)

代码示例:对比三种声明方式

// 1. var的问题:函数作用域+重复声明
var a = 1;
var a = 2; // 允许重复声明
if (true) {
    var a = 3;
}
console.log(a); // 3(块级作用域失效)

// 2. let的优势:块级作用域+不可重复声明
let b = 1;
// let b = 2; // 报错:Identifier 'b' has already been declared
if (true) {
    let b = 3;
}
console.log(b); // 1(块级作用域有效)

// 3. const的特性:不可修改值(引用类型除外)
const c = 1;
// c = 2; // 报错:Assignment to constant variable
const obj = { name: "张三" };
obj.name = "李四"; // 允许修改引用类型的内部属性
console.log(obj.name); // 李四

核心总结:开发中优先使用letconst,避免var的坑;常量用const,变量用let

2.2 数据类型(基本类型 + 引用类型 + 类型判断)

JS 的数据类型分为基本类型引用类型,两者的存储方式差异是导致 “值传递” 与 “引用传递” 的根本原因。

(1)基本类型(7 种)

包括:StringNumberBooleanNullUndefinedSymbol(ES6)、BigInt(ES11)。

特点

  • 存储在栈内存中,直接保存值;
  • 赋值时是 “值传递”,修改新变量不影响原变量。

代码示例

运行

    //目标:数据类型
    // 基本数据类型:
    // number:数字(整数、小数、NaN(Nota Number))
    // boolean:布尔。true,false
    // null:对象为空。JavaScript是大小写敏感的,因此null、Null、NULL是完全不同的
    // undefined:当声明的变量未初始化时,该变量的默认值是undefined
    // string:字符串,单引号、双引号、反引号皆可,推荐使用单引号

    let c = 10
    let d = 3.14
    let e = true
    let f = null
    let g
    let h = 'abc'

    document.write('<p>1 的类型:' + typeof c + '</p>')
    document.write('<p>2 的类型:' + typeof d + '</p>')
    document.write('<p>3 的类型:' + typeof e + '</p>')
    document.write('<p>4 的类型:' + typeof f + '</p>')
    document.write('<p>5 的类型:' + typeof g + '</p>')
    document.write('<p>6 的类型:' + typeof h + '</p>'

    let num1 = 10;
    let num2 = num1;
    num2 = 20;
    console.log(num1); // 10(值传递,互不影响)

字符串

对于字符串类型的数据,除了可以使用双引号("...")、单引号('...')以外,还可以使用反引号 (``)。 而使用反引号引起来的字符串,也称为 模板字符串

  • 模板字符串的使用场景:拼接字符串和变量。

  • 模板字符串的语法:

    • ` :反引号 (英文输入模式下键盘 tab 键上方波浪线 ~ 那个键)
    • 内容拼接时,使用 ${ } 来引用变量

具体示例如下:

    //目标4:模板字符串
    //js字符串类型支持3种:''  ""  ``
    //    ''  ""  使用区别:外部是""内部使用'',获取外部''内部双引号
    let x = '您好,"程序员根根"'
    let y = "您好,'程序员根根'"

    // `` 用于模板字符串简化字符串拼接
    let name = '根根'
    let age = 24

    //传统拼接字符串
    let str1 = '你好我的名字叫' + name + ',今年' + age + '岁了!'

    //模板字符串拼接
    let str2 = `你好我的名字叫${name},今年${age}岁了!`;
    document.write(`<p>${str2}<\p>`)
(2)引用类型

包括:Object(含ArrayFunctionDateRegExp等)。

特点

  • 存储在堆内存中,栈内存只保存指向堆的引用地址;
  • 赋值时是 “引用传递”,修改新变量会影响原变量。

代码示例

let obj1 = { name: "张三" };
let obj2 = obj1;
obj2.name = "李四";
console.log(obj1.name); // 李四(引用传递,指向同一内存)
(3)类型判断的正确方式(面试高频)

新手常误用typeof判断所有类型,但其对Null和引用类型的判断有局限,需结合Object.prototype.toString.call()使用。

类型 typeof 结果 Object.prototype.toString.call () 结果
String "string" "[object String]"
Number "number" "[object Number]"
Boolean "boolean" "[object Boolean]"
Null "object"(历史 bug) "[object Null]"
Undefined "undefined" "[object Undefined]"
Array "object" "[object Array]"
Object "object" "[object Object]"
Function "function" "[object Function]"

代码示例

console.log(typeof null); // "object"(坑)
console.log(Object.prototype.toString.call(null)); // "[object Null]"

console.log(typeof []); // "object"
console.log(Object.prototype.toString.call([])); // "[object Array]"

2.3 流程控制

在JS中,当然也存在对应的流程控制语句。常见的流程控制语句如下:

  • if ... else if ... else ...
  • switch
  • for
  • while
  • do ... while

而JS中的流程控制语句与JAVA中的流程控制语句的作用,执行机制都是一样的。就不做过多说明。

代码

//目标:实现1~100的累加
    //循环语法: for(let i=1;i<=100;i++){循环体代码}
    function sum4(number) {
      //1.定义累加变量 total1
      let total1 = 0
      //2.使用循环语法累加
      for (let i = 1; i <= number; i++) {
        total1 += i//相当于 total1 = total1 + i
      }
      return total1

    }
    //3.打印结果
    document.write(`1~100的累加为:${sum4(100)}<br\>`)//<br/>是html标签换行
    document.write(`abc<br\>`)//<br/>是html标签换行

四、语法三:函数

函数(function) 是被设计用来执行特定任务的代码块,方便程序的封装复用。 那我们学习函数,主要就是学习JS中函数的定义及调用的语法。

注意

因为JavaScript是弱数据类型的语言,所以有如下几点需要注意:

  • 形参不需要声明类型,并且JS中不管什么类型都是let去声明,加上也没有意义。
  • 返回值也不需要声明类型,直接return即可

作用域决定了变量的可访问范围,JS 的作用域规则可总结为:

  • 全局作用域:在所有函数外部声明的变量,可在整个程序中访问;
  • 函数作用域:在函数内部声明的变量,仅在函数内部可访问;
  • 块级作用域let/const声明的变量,在{}(如 if、for、while)内可访问。

3.1 方式一:命名函数

分类1:命名函数,需要定义函数名的函数

定义命名语法:function 函数名(形参列表){ 函数体代码 }

代码示例

    //方式1
    function sum1(a, b, c) {
      return a + b + c
    }

    //调用函数
    let total = sum1(10, 20, 30)
    document.write(`<p>${total}<\P>`)

3.2 方式二:匿名函数

刚才我们定义函数,是为函数指定了一个名字。 那我们也可以不为函数指定名字,那这一类的函数,我们称之为匿名函数。那接下来,方式二,就来介绍一下匿名函数的定义和调用。

分类2:匿名函数,不需要定义函数名的函数,分为函数表达式和箭头函数

1.函数表达式定义语法: let 变量 = function(形参列表){ 函数体代码 }

代码示例

    //分类2:
    let sum2 = function (a, b, c) {
      return a + b + c
    }
    
    document.write(`<p>${sum2(10, 20, 30)}<\P>`)//60

2. 箭头定义语法: let 变量 = (形参列表)=>{ 函数体代码 }

代码示例

  let sum3 = (a, b, c) => {
      return a + b + c
    }
     document.write(`<p>${sum2(10, 20, 30, 40)}<\P>`)//60, 40是多余,函数没有定义,所以不接收
    //注意:js中函数传递参数可以与形参不匹配,但是建议要匹配才有意义。

浏览器打开,发现没有错误,并且依然弹出60,这是为什么呢?

因为在JavaScript中,函数的调用只需要名称正确即可,参数列表不管的。如上述案例,10传递给了变量a,20传递给了变量b,而30和40没有变量接受,但是不影响函数的正常调用。

注意:由于JS是弱类型语言,形参、返回值都不需要指定类型。在调用函数时,实参个数与形参个数可以不一致,但是建议一致。

五、语法四:事件监听

5.1 事件介绍

什么是事件呢?HTML事件是发生在HTML元素上的 “事情”,例如:

  • 按钮被点击

  • 鼠标移到元素上

  • 输入框失去焦点

  • 按下键盘按键

  • ........

而我们可以给这些事件绑定函数,当事件触发时,可以自动的完成对应的功能,这就是事件监听。

例如:对于我们所说的百度注册页面,我们给用户名输入框的失去焦点事件绑定函数,当我们用户输入完内容,在标签外点击了鼠标,对于用户名输入框来说,失去焦点,然后执行绑定的函数,函数进行用户名内容的校验等操作。

JS事件是JS非常重要的一部分,接下来我们进行事件的学习。那么我们对于JavaScript事件需要学习哪些内容呢?我们得知道有哪些常用事件,然后我们得学会如何给事件绑定函数。

所以主要围绕2点来学习:①. 事件监听、②. 常用事件

5.2 事件监听

JS事件监听的语法: 事件源.addEventListener('事件类型', 要执行的函数);

在上述的语法中包含三个要素:

  • 事件源: 哪个dom元素触发了事件, 要获取dom元素
  • 事件类型: 用什么方式触发, 比如: 鼠标单击 click, 鼠标经过 mouseover
  • 要执行的函数: 要做什么事 代码示例
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>JS事件</title>
</head>

<body>

  <!-- 方式1:标签事件属性直接绑定(推荐方式) -->
  <input type="button" id="btn1" value="点我一下试试" onclick="demo1()">
  <input type="button" id="btn2" value="点我一下试试">
  <input type="button" id="btn3" value="点我一下试试">

  <script>
    function demo1() {
      alert("按钮1")
    }

    //方式2:使用js代码推荐方式绑定
    //语法:语法:事件源.addEventListener('事件类型',事件触发执行的函数);
    //先获取dom元素id=btn2的标签对象
    let btn2 = document.querySelector("#btn2")//#开头是id选择器,根据标签属性id="选择器名字"筛选获取标签
    //再绑定事件
    btn2.addEventListener('click', () => {
      alert('按钮2');//会运行
    })
    btn2.addEventListener('click', () => {
      alert('按钮22');//也会运行
    })

    //方式3:使用早期js代码绑定(不推荐)
    //语法:事件源.on事件=()=>{}
    document.querySelector('#btn3').onclick = () => {
      alert('按钮3')//不会运行
    }
    document.querySelector('#btn3').onclick = () => {
      alert('按钮33')//会运行,这个事件覆盖了上面相同类型的事件
    }

  
  </script>
</body>

</html>

小结

方式1和方式2都是常用推荐的方式

方式2和方式3区别

方式2可以绑定相同事件类型多个 方式3不可以绑定相同事件类型多个,如果有多个下面的会覆盖上面的。

5.3 常见的事件

代码示例

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>JS-事件-常见事件</title>
</head>

<body>
    <form action="" style="text-align: center;">
        <input type="text" name="username" id="username">
        <input type="text" name="age" id="age">
        <input id="b1" type="submit" value="提交">
        <input id="b2" type="button" value="单击事件">
    </form>

    <br><br><br>

    <table width="800px" border="1" cellspacing="0" align="center">
        <tr>
            <th>学号</th>
            <th>姓名</th>
            <th>分数</th>
            <th>评语</th>
        </tr>
        <tr align="center">
            <td>001</td>
            <td>张三</td>
            <td>90</td>
            <td>很优秀</td>
        </tr>
        <tr align="center" id="last">
            <td>002</td>
            <td>李四</td>
            <td>92</td>
            <td>优秀</td>
        </tr>
    </table>



    <script>
        //click: 鼠标点击事件
        document.querySelector('#b2').addEventListener('click', () => {
            console.log("我被点击了...");
        })

        //mouseenter: 鼠标移入
        document.querySelector('#last').addEventListener('mouseenter', () => {
            console.log("鼠标移入了...");
        })

        //mouseleave: 鼠标移出
        document.querySelector('#last').addEventListener('mouseleave', () => {
            console.log("鼠标移出了...");
        })

        //keydown: 某个键盘的键被按下
        document.querySelector('#username').addEventListener('keydown', () => {
            console.log("键盘被按下了...");
        })

        //keydown: 某个键盘的键被抬起
        document.querySelector('#username').addEventListener('keyup', () => {
            console.log("键盘被抬起了...");
        })

        //blur: 失去焦点事件
        document.querySelector('#age').addEventListener('blur', () => {
            console.log("失去焦点...");
        })

        //focus: 元素获得焦点
        document.querySelector('#age').addEventListener('focus', () => {
            console.log("获得焦点...");
        })

        //input: 用户输入时触发
        document.querySelector('#age').addEventListener('input', () => {
            console.log("用户输入时触发...");
        })

        //submit: 提交表单事件
        document.querySelector('form').addEventListener('submit', () => {
            alert("表单被提交了...");
        })
    </script>
</body>

</html>

六、 踩坑高频点

6.1 常见踩坑点

  1. =====的区别==会进行隐式类型转换,===严格比较类型和值,开发中优先用===
  2. nullundefined的区别null表示 “空对象”,undefined表示 “未定义”;
  3. 数组的length属性:修改length可截断数组,但增加length不会添加元素;
  4. 函数参数的默认值:默认参数的作用域是函数自身,避免与外部变量冲突。

七、总结与延伸

7.1 核心总结

  1. 变量声明优先用let/const,避免var的作用域问题;
  2. 区分基本类型和引用类型的存储差异,掌握正确的类型判断方法;

Electron 应用中 Sharp 模块跨架构兼容性问题解决方案

作者 八点
2025年12月11日 20:22

项目背景

最近针对公司的一款Electron软件,对图片处理相关功能进行统一优化,又之前的canvas实现改成了sharp。在应用的核心功能中,大量使用了图片处理能力,包括:

  • 图片缩放:将图片调整到指定尺寸
  • 图片旋转:支持图片旋转操作
  • 图片裁剪:按百分比裁剪图片区域
  • 图片合并:前景图和背景图的合成处理

技术选型

项目选择了 Sharp 作为图片处理的核心库,原因如下:

  • 高性能:基于 libvips 的 C++ 实现,性能远超基于 Canvas 的方案
  • 功能丰富:支持多种图片格式和操作
  • API 简洁:提供友好的 Node.js API

Sharp 在项目中的使用示例:

// 图片缩放
const imageScale = (inputImage: string, w: number, h: number, outImage: string) => {
  return sharp(inputImage).resize(w, h, { fit: 'fill' }).toFile(outImage)
}

// 图片旋转
const imageRotate = (imagePath: string, outPath: string) => {
  return sharp(imagePath).rotate(90).toFile(outPath)
}

问题描述

问题现象

在 macOS ARM 芯片(M系列)机器上构建的 Electron 应用包,分发到 macOS Intel 芯片机器上运行时,应用启动失败,出现以下错误:

Error: Could not load the "sharp" module using the darwin-x64 runtime

image.png

错误分析

从错误信息可以看出:

  1. 核心错误Could not load the "sharp" module using the darwin-x64 runtime

    • 应用尝试加载 darwin-x64(Intel 架构)的 sharp 模块失败
    • 说明应用包中缺少 Intel 架构的二进制文件
  2. 错误位置:错误发生在应用启动时,sharp 模块加载阶段

    • 路径:app.asar/node_modules/sharp/lib/sharp.js:121
    • 说明 sharp 模块已经被打包,但缺少对应架构的二进制文件
  3. 平台信息

    • 构建平台:macOS ARM64 (darwin-arm64)
    • 运行平台:macOS Intel (darwin-x64)
    • 架构不匹配导致运行时失败

原因分析

根本原因

Sharp 是一个原生 Node.js 模块,它依赖于平台特定的二进制文件。Sharp 的架构如下:

sharp (主模块)
├── @img/sharp-darwin-arm64 (ARM 架构二进制)
├── @img/sharp-darwin-x64 (Intel 架构二进制)
├── @img/sharp-libvips-darwin-arm64 (ARM 架构依赖库)
└── @img/sharp-libvips-darwin-x64 (Intel 架构依赖库)

问题根源

  1. npm 的平台检查机制:当在 ARM 机器上运行 npm install 时,npm 会检查包的 oscpu 字段,只安装当前平台匹配的包
  2. 默认行为:npm 默认只安装 darwin-arm64 架构的包,不会安装 darwin-x64 架构的包
  3. 打包结果:构建时只包含了 ARM 架构的二进制文件,导致在 Intel 机器上无法运行

验证过程

为了确认问题,进行了以下验证:

解包查看

解压构建后的应用包,检查 node_modules 目录:

npm install -g asar

asar extract app.asar ./

image.png

从解包结果可以看到,node_modules/@img/ 目录下只有:

  • sharp-darwin-arm64
  • sharp-libvips-darwin-arm64

缺少

  • sharp-darwin-x64
  • sharp-libvips-darwin-x64

这证实了问题:应用包中只包含了 ARM 架构的二进制文件。

检查 npm 包信息

sharp包很常用,我想大概其他人应该也会遇到类型问题,npm如此强大的库也应该有类型的处理包,于是在npm仓库搜索了一下electron sharp相关发现没有:

image.png

Electron Universal Binary 构建

虽然 Electron Builder 支持构建 Universal Binary(通用二进制),配置如下:

mac: {
  target: [
    {target: 'mas', arch: ['universal']}
  ]
}

但是,Universal Binary 只对 Electron 主程序和系统库有效,对于 node_modules 中的原生模块(如 Sharp),需要手动确保包含所有架构的二进制文件。

解决方案

解决思路

核心思路:在构建前,确保 Sharp 模块包含所有目标架构的二进制文件

实现方式:

  1. 创建构建前钩子脚本,检查并安装缺失的架构二进制文件
  2. 使用 npm install --force 强制安装跨平台包
  3. 在 electron-builder 的 beforePack 钩子中自动执行

实现方案

1 创建构建前检查脚本

创建 build/scripts/ensure-sharp-universal.js 脚本:

function ensureSharpUniversal() {
  console.log('开始检查 sharp 模块的架构支持...')
  
  // 检查必需的包是否存在
  const requiredPackages = [
    '@img/sharp-darwin-arm64',
    '@img/sharp-darwin-x64',
    '@img/sharp-libvips-darwin-arm64',
    '@img/sharp-libvips-darwin-x64'
  ]
  
  // 安装缺失的包
  for (const pkg of requiredPackages) {
    if (!checkModuleExists(pkg)) {
      installPackage(pkg)
    }
  }
  
  // 验证所有包都已安装
  verifyAllPackages()
}

// 安装包(使用 --force 绕过平台检查)
function installPackage(packageName) {
  execSync(`npm install --no-save --force --ignore-scripts ${packageName}`, {
    stdio: 'inherit',
    cwd: PROJECT_ROOT,
    env: env
  })
}

2 配置 electron-builder 钩子

为了方便操作,在electron macOS 构建配置文件中添加 beforePack 钩子,无需额外操作一键完成资源包下载:

// build/config/builder.mas.config.js
module.exports = {
  // ... 其他配置
  beforePack: async (context) => {
    // 确保 sharp 模块包含两种架构的二进制文件
    const { ensureSharpUniversal } = require('../scripts/ensure-sharp-universal')
    ensureSharpUniversal()
  },
  // ... 其他配置
}

3 关键配置说明

1. asarUnpack 配置

确保 Sharp 模块不被压缩到 asar 中,保持原生二进制文件的可访问性:

asarUnpack: [
  '**/node_modules/sharp/**/*',
  '**/node_modules/@img/**/*',
  // ... 其他原生模块
]

2. 强制安装跨平台包

这里有个坑,npm 默认不允许在 ARM 机器上安装 x64 包,所以要使用 --force--ignore-scripts 标志强制安装:

  • --force:强制 npm 忽略平台检查
  • --ignore-scripts:跳过安装脚本中的平台验证
  • --no-save:不更新 package.json,避免污染依赖
npm install --no-save --force --ignore-scripts @img/sharp-darwin-x64

解决方案流程图

以下是完整的解决方案流程图:

graph TD
    A[开始构建] --> B[执行 beforePack 钩子]
    B --> C[检查 Sharp 模块架构支持]
    C --> D{是否包含所有架构?}
    D -->|是| E[验证通过]
    D -->|否| G[安装缺失的架构包]
    G -->|成功| H[验证安装结果]
    G -->|失败| P
    H --> L{所有架构都存在?}
    L -->|是| E
    L -->|否| M[构建失败]
    E --> N[继续 electron-builder 打包]
    N --> O[打包完成]
    M --> P[结束]
    O --> P
    
    style A fill:#e1f5ff
    style E fill:#c8e6c9
    style O fill:#c8e6c9
    style M fill:#ffcdd2
    style P fill:#f5f5f5

使用方法

1 自动执行

运行任何 macOS 构建命令时,脚本会自动执行:

# 开发环境构建
npm run build:mas:dev

# PKG 格式构建
npm run build:pkg

2 手动执行

也可以手动运行检查和修复:

npm run ensure-sharp-universal

验证结果

修复后,验证 node_modules/@img/ 目录:

image.png

所有必需的架构二进制文件都已存在,应用包可以在两种架构的 macOS 上正常运行。

技术要点总结

关键知识点

  1. 原生模块的架构依赖:原生 Node.js 模块需要为每个目标平台/架构编译二进制文件
  2. npm 的平台检查:npm 默认只安装当前平台匹配的包,需要特殊处理才能安装跨平台包
  3. Electron Universal Binary:只对 Electron 主程序有效,node_modules 中的原生模块需要手动处理
  4. asarUnpack 配置:原生模块必须从 asar 中解包,保持二进制文件的可访问性

最佳实践

  1. 构建前检查:使用 beforePack 钩子确保所有必需的架构文件都存在
  2. 强制安装:使用 --force--ignore-scripts 绕过平台检查
  3. 自动化:将检查流程集成到构建流程中,避免手动操作

适用场景

本解决方案适用于所有使用原生 Node.js 模块的 Electron 应用,包括:

  • Sharp(图片处理)
  • node-sqlite3(数据库)
  • fsevents(文件系统监控)
  • 其他包含平台特定二进制文件的模块

总结

通过创建构建前检查脚本,使用 npm install --force 强制安装跨平台包,成功解决了在 ARM 机器上构建的 Electron 应用在 Intel 机器上无法运行的问题。

  • 自动化处理,无需手动干预
  • 支持所有 macOS 构建配置
  • 可扩展到其他原生模块
  • 在 ARM 机器上构建的包可以在 Intel 机器上正常运行
  • 构建流程更加可靠和自动化
  • 解决了跨平台兼容性问题

下面是自动处理完整代码,有遇到类型问题的同学可以参考

// ensure-sharp-universal.js

const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')

// 获取项目根目录(从脚本位置向上两级,或从当前工作目录查找 package.json)
function getProjectRoot() {
  // 尝试从脚本位置向上查找
  let currentDir = __dirname
  while (currentDir !== path.dirname(currentDir)) {
    if (fs.existsSync(path.join(currentDir, 'package.json'))) {
      return currentDir
    }
    currentDir = path.dirname(currentDir)
  }
  // 如果找不到,尝试从当前工作目录查找
  const cwd = process.cwd()
  if (fs.existsSync(path.join(cwd, 'package.json'))) {
    return cwd
  }
  // 默认使用脚本位置向上两级
  return path.join(__dirname, '../..')
}

const PROJECT_ROOT = getProjectRoot()
const SHARP_MODULE_PATH = path.join(PROJECT_ROOT, 'node_modules/sharp')
const SHARP_ARM64_PATH = path.join(PROJECT_ROOT, 'node_modules/@img/sharp-darwin-arm64')
const SHARP_X64_PATH = path.join(PROJECT_ROOT, 'node_modules/@img/sharp-darwin-x64')
const SHARP_LIBVIPS_ARM64_PATH = path.join(PROJECT_ROOT, 'node_modules/@img/sharp-libvips-darwin-arm64')
const SHARP_LIBVIPS_X64_PATH = path.join(PROJECT_ROOT, 'node_modules/@img/sharp-libvips-darwin-x64')

function checkModuleExists(modulePath) {
  return fs.existsSync(modulePath)
}

function installSharpArchitecture(arch) {
  const platform = 'darwin'
  const sharpPackage = `@img/sharp-${platform}-${arch}`
  const libvipsPackage = `@img/sharp-libvips-${platform}-${arch}`
  
  console.log(`正在安装 ${sharpPackage}...`)
  
  // 设置环境变量来绕过 npm 的平台检查
  const env = {
    ...process.env,
    // 强制 npm 忽略平台检查
    npm_config_force: 'true',
    // 设置目标架构(虽然不会真正改变当前架构,但可以帮助某些包管理器)
    npm_config_target_arch: arch === 'x64' ? 'x64' : 'arm64',
    npm_config_target_platform: platform
  }
  
  try {
    // 使用 --force 和 --ignore-scripts 来强制安装跨平台包
    // --ignore-scripts 可以避免安装脚本中的平台检查
    execSync(`npm install --no-save --force --ignore-scripts ${sharpPackage} ${libvipsPackage}`, {
      stdio: 'inherit',
      cwd: PROJECT_ROOT,
      env: env
    })
    console.log(`✓ ${sharpPackage} 安装成功`)
  } catch (error) {
    // TODO: 如果上面的方法失败,可以尝试使用 npm pack + 手动安装,这里暂未实现
      console.error(`✗ 安装 ${sharpPackage} 失败:`, error.message)
  }
}

function ensureSharpUniversal() {
  console.log('开始检查 sharp 模块的架构支持...')
  
  // 检查 sharp 主模块是否存在
  if (!checkModuleExists(SHARP_MODULE_PATH)) {
    console.error('错误: sharp 模块未找到,请先运行 npm install')
    process.exit(1)
  }
  
  let needInstall = false
  
  // 检查 ARM64 架构
  if (!checkModuleExists(SHARP_ARM64_PATH)) {
    console.log('警告: 未找到 @img/sharp-darwin-arm64,需要安装')
    needInstall = true
  }
  
  if (!checkModuleExists(SHARP_LIBVIPS_ARM64_PATH)) {
    console.log('警告: 未找到 @img/sharp-libvips-darwin-arm64,需要安装')
    needInstall = true
  }
  
  // 检查 X64 架构
  if (!checkModuleExists(SHARP_X64_PATH)) {
    console.log('警告: 未找到 @img/sharp-darwin-x64,需要安装')
    needInstall = true
  }
  
  if (!checkModuleExists(SHARP_LIBVIPS_X64_PATH)) {
    console.log('警告: 未找到 @img/sharp-libvips-darwin-x64,需要安装')
    needInstall = true
  }
  
  if (needInstall) {
    console.log('\n正在安装缺失的架构二进制文件...')
    
    // 安装 ARM64 架构(如果缺失)
    if (!checkModuleExists(SHARP_ARM64_PATH) || !checkModuleExists(SHARP_LIBVIPS_ARM64_PATH)) {
      installSharpArchitecture('arm64')
    }
    
    // 安装 X64 架构(如果缺失)
    if (!checkModuleExists(SHARP_X64_PATH) || !checkModuleExists(SHARP_LIBVIPS_X64_PATH)) {
      installSharpArchitecture('x64')
    }
    
    console.log('\n✓ 所有必需的架构二进制文件已安装')
  } else {
    console.log('✓ sharp 模块已包含所有必需的架构二进制文件')
  }
  
  // 最终验证
  const allExists = 
    checkModuleExists(SHARP_ARM64_PATH) &&
    checkModuleExists(SHARP_X64_PATH) &&
    checkModuleExists(SHARP_LIBVIPS_ARM64_PATH) &&
    checkModuleExists(SHARP_LIBVIPS_X64_PATH)
  
  if (allExists) {
    console.log('\n✓ 验证通过: sharp 模块支持 macOS Universal (arm64 + x64)')
    return true
  } else {
    console.error('\n✗ 验证失败: 某些架构的二进制文件仍然缺失')
    process.exit(1)
  }
}

// 如果直接运行此脚本
if (require.main === module) {
  ensureSharpUniversal()
}

module.exports = { ensureSharpUniversal }

给 CS2 Major 竞猜做了个在线抄作业网站

作者 一只Viki
2025年12月11日 20:15

major-winner

背景:为什么要做这个项目?

Counter-Strike 2(反恐精英 2,也称 CS2) 是由 Valve 开发并发行的一款在线射击游戏,是 Counter-Strike: Global Offensive(反恐精英:全球攻势,也称 CSGO)的续作。CS2 采用全新的起源 2(Source2)引擎开发,不仅推出了新式烟雾弹和子刷新频率构架等更新,游戏的画质也得到了大幅升级、地图也翻新制作,是 Steam 上最热门的游戏

cs2

Major 是 CS2 中最重要的赛事,一般每年举办两次。在 Major 期间,游戏内会推出相应的活动,其中就有竞猜系统,玩家可以通过这个竞猜系统来预测比赛结果,预测成功可以完成竞猜任务,完成一定数量的任务可以持续升级赛事纪念币,并获得丰厚奖励。

major-bonus

major-pick-em

Major 竞猜凭借着它 3000 万+ 月活的庞大玩家群体基础,和它本身的趣味性和娱乐性,吸引了大量玩家关注,其中就包括我,虽然上半年的奥斯汀 Major 我绞尽脑汁只拿了个银币,都怪菊花队,这届奥斯汀 Major 能拿钻石币的都是什么神人。

在近几次的 Major 期间,有多位 UP 主搜集了大量主播竞猜情况并将其汇总成图片,在各大社交媒体、群聊间被转发传阅参考。今年的 Major 在布达佩斯(Budapest,匈牙利的首都)举办,依然有不少 UP 主在赛前搜集主播的竞猜作业(比赛竞猜情况,俗称「作业」,参考他人的竞猜作业,也称「抄作业」)并制作了汇总图片供大家参考。例如来自 B 站的 @原劫色@三米七七 等 UP 主,他们发布了多篇相关视频,汇总了大量主播竞猜作业:

major-up

然而,单纯依靠图片来追踪竞猜进度并不方便,需要自己一个个人眼对照核对,非常的反人类。比如上图的布达佩斯 Major 第一阶段作业汇总,在比赛进行到一半时,通过人眼核对可以知道大家的 3-0 和 0-3 都炸的差不多了,极少主播的竞猜还能生还,真惨吧,但具体是谁还需要一个个去对照,效率极低。

这不,需求来了!作为一个搬了两三年砖的前端开发,为了更高效地参考和跟踪主播竞猜进度,我决定动手写一个在线抄作业项目,将这些主播竞猜作业数据结构化,并提供便捷的查询和排名等功能。

技术选型:为什么选 Next.js 16?

做这个项目的时候,我的核心诉求其实很明确:

  1. 要快 - Major 比赛周期很紧凑,我需要在比赛开始前就上线
  2. 数据量小 - 就十几二十个主播的竞猜数据,根本不需要后端数据库
  3. 要好看 - 毕竟是给玩家用的,UI 得过得去
  4. 要能快速迭代 - 比赛期间可能需要频繁更新数据和功能

基于这些需求,我选择了以下技术栈:

Next.js 16 (Canary) + App Router

最开始其实纠结过要不要用 Next.js,因为这个项目完全不需要 SSR,纯静态部署就行。但后来发现 Next.js 的几个特性太香了:

  • App Router 的文件路由:不用手写路由配置,app/predictors/page.tsx 直接就是 /predictors 路由
  • 静态生成next build 直接输出静态 HTML,部署到 Vercel 零配置
  • React Server Components:大部分页面都是 RSC,bundle size 小很多
  • Turbopack:开发时编译飞快,pnpm dev --turbopack 启动不到 1 秒

版本选了 16.1.0-canary.13,主要是想试试最新的 React 19 和 Turbopack。虽然是 canary 版本,但实际用下来很稳定,没遇到什么坑。

React 19 - 最新特性

React 19 带来了一些不错的优化:

  • 自动批处理优化:连续的 setState 会自动合并
  • use() Hook:可以在组件中直接 use(promise),不过这个项目还没用到
  • 优化的 hydration:虽然我这个项目是纯静态的,但未来可能会加一些交互功能

TypeScript - 严格模式

这个必须的,竞猜数据结构比较复杂(瑞士轮、淘汰赛、不同阶段),没有类型约束很容易写出 bug。

我在 types/index.ts 里定义了完整的类型系统:

// 瑞士轮结果包含进行中的战绩和最终结果
export interface SwissResult {
  // 进行中的战绩
  '1-0': string[]
  '2-1': string[]
  '2-2': string[]
  // ... 其他战绩

  // 最终结果
  '3-0': string[]
  '3-1': string[]
  '3-2': string[]
  // ... 其他结果
}

// 阶段类型
export type SwissStageType = 'stage-1' | 'stage-2' | 'stage-3'
export type FinalStageType = '8-to-4' | '4-to-2' | '2-to-1'

严格的类型定义让我在写业务逻辑时几乎不用担心类型错误,IDE 的智能提示也非常准确。

Tailwind CSS v4 - 新一代样式方案

这个选择有点激进,Tailwind v4 现在还处于早期阶段,但我看了官方文档后觉得值得一试。

v4 最大的变化是配置方式改了

  • 不再需要 tailwind.config.ts:配置全部写在 CSS 文件里
  • 使用 CSS 变量定义主题:完美支持明暗主题切换
  • PostCSS 插件简化:只需要 @tailwindcss/postcss

我的主题配置全部在 app/globals.css 里:

@import 'tailwindcss';

:root {
  /* 明亮主题变量 */
  --foreground: #18181b;
  --color-surface-0: #ffffff;
  --color-primary-500: #ff6b35;
  /* ... 其他变量 */
}

.dark {
  /* 暗色主题变量 */
  --foreground: #fafafa;
  --color-surface-0: #0a0a0a;
  /* ... 其他变量 */
}

然后定义一些语义化的工具类:

.text-primary {
  color: var(--foreground);
}

.hover-text-primary:hover {
  color: var(--foreground);
}

这样写组件的时候就不用到处写 text-zinc-900 dark:text-white,直接用 text-primary 就能自适应主题,代码简洁很多。

架构设计:纯静态数据驱动

这个项目的架构其实很简单,核心理念就是 Data as Code

数据层设计

所有数据都存在两个 JSON 文件里:

data/
├── events.json       # 赛事数据(队伍、赛程、结果)
└── predictions.json  # 竞猜数据(各主播的竞猜)

events.json 的结构:

{
  "id": "budapest-2025",
  "name": "布达佩斯 2025 Major",
  "teams": [...],
  "stage-1": {
    "name": "第一阶段",
    "teams": ["Liquid", "Spirit", ...],
    "result": {
      "3-0": ["Spirit"],
      "3-1": ["Liquid", "G2", "Vitality"],
      // 进行中的战绩
      "2-1": ["FaZe", "MOUZ"],
      "1-2": ["NAVI", "Heroic"]
    }
  }
}

predictions.json 的结构:

{
  "id": "budapest-2025",
  "predictions": [
    {
      "id": "s1mple",
      "name": "s1mple",
      "platform": "twitch",
      "stage-1": {
        "3-0": ["Spirit", "Liquid"],
        "3-1-or-3-2": ["G2", "Vitality", "FaZe", "MOUZ", "NAVI", "Heroic"],
        "0-3": ["BIG", "9z"]
      }
    }
  ]
}

核心业务逻辑

所有计算逻辑都在 lib/data.ts 里,这个文件有 800+ 行,是整个项目最复杂的部分。主要包含:

1. 竞猜准确度计算

瑞士轮的规则比较复杂:

  • 竞猜 3-0 的队伍,必须实际 3-0 才算对(不能是 3-1 或 3-2)
  • 竞猜 3-1-or-3-2 的队伍,实际 3-13-2 都算对
  • 竞猜 0-3 的队伍,必须实际 0-3 才算对

这里有个细节:比赛进行中时,有些队伍的最终结果还没出来(比如现在战绩是 2-1),但我们可以提前判断某些竞猜已经"死了"。比如你竞猜某队 3-0,但它现在已经 2-1 了(输了一场),那这个竞猜就不可能对了。

export function isPredictionPossible(
  teamName: string,
  predictionBucket: '3-0' | '3-1-or-3-2' | '0-3',
  result: SwissResult | undefined,
): boolean {
  if (!result) return true

  // 检查队伍当前战绩
  for (const record of progressRecords) {
    if (result[record]?.includes(teamName)) {
      const [wins, losses] = record.split('-').map(Number)

      // 3-0 竞猜:有任何失败就不可能
      if (predictionBucket === '3-0') {
        return losses === 0
      }

      // 0-3 竞猜:有任何胜利就不可能
      if (predictionBucket === '0-3') {
        return wins === 0
      }

      // 3-1-or-3-2:只要还在比赛就有可能
      return true
    }
  }

  return true
}

这个函数让我们可以在比赛进行中就实时显示"已失败"的竞猜数量,体验好很多。

2. 阶段通过判定

每个阶段都有通过标准:

  • 瑞士轮:10 个竞猜中 5 个对就通过
  • 八进四:4 个竞猜中 2 个对就通过
  • 半决赛:2 个竞猜中 1 个对就通过
  • 决赛:猜对冠军才通过
function checkSwissStagePass(
  stageId: TaskStageType,
  prediction: StagePrediction | undefined,
  actual: SwissResult | undefined,
): StagePassStatus {
  const requiredCount = 5  // 需要 5 个正确

  let correctCount = 0
  let impossibleCount = 0

  // 检查 3-0 竞猜
  for (const team of prediction['3-0']) {
    if (actual['3-0'].includes(team)) {
      correctCount++
    } else if (!isPredictionPossible(team, '3-0', actual)) {
      impossibleCount++
    }
  }

  // ... 检查其他类别

  // 判断通过状态
  return {
    passed:
      correctCount >= requiredCount ? true :
      impossibleCount > 5 ? false :  // 已经不可能通过了
      null  // 还不确定
  }
}

这里的 passed 有三种状态:true(通过)、false(失败)、null(待定),这样可以在比赛进行中就提前判断某些主播"已经寄了"。

3. 赛事进度跟踪

这个功能花了我不少时间。一开始我想的是手动配置当前进行到哪个阶段,但后来发现这样太麻烦了,每次比赛结束都要改代码。

最后我实现了一个自动进度检测的逻辑:

export function getEventProgress(event: MajorEvent): EventProgress {
  const stagesProgress: StageProgress[] = []

  // 检查每个阶段的结果完整度
  for (const stage of ['stage-1', 'stage-2', 'stage-3', 'finals']) {
    const hasResults = hasStageResults(event, stage)
    const isComplete = isStageComplete(event, stage)

    stagesProgress.push({
      stageId: stage,
      status: isComplete ? 'completed' :
              hasResults ? 'in_progress' :
              'not_started'
    })
  }

  // 自动判断当前阶段
  const currentStage = stagesProgress.find(s => s.status === 'in_progress')

  return {
    eventStatus: determineEventStatus(stagesProgress),
    currentStage: currentStage?.stageId || null,
    completedStages: stagesProgress
      .filter(s => s.status === 'completed')
      .map(s => s.stageId)
  }
}

现在我只需要更新 events.json 里的比赛结果,页面会自动:

  • 显示当前进行到哪个阶段
  • 隐藏还没开始的阶段
  • 高亮正在进行的阶段

完全不用改代码,数据驱动一切!

组件设计

组件结构遵循 Next.js App Router 的最佳实践:默认 Server Components,必要时才用 Client Components

项目里只有 5 个 Client Component:

// components/ThemeProvider.tsx
'use client'
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('system')
  // 使用 localStorage 和 useEffect
}

// components/ThemeToggle.tsx
'use client'
export function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  // 使用 onClick 交互
}

// components/EventContext.tsx
'use client'
export const EventContext = createContext(...)
// 使用 Context 和 State

// components/EventSelector.tsx
'use client'
export function EventSelector() {
  // 表单交互
}

// app/predictions/StageNav.tsx
'use client'
export function StageNav() {
  // 客户端路由状态
}

其他所有页面和组件都是 Server Components,好处是:

  • Bundle size 更小:Server Components 的代码不会打包到客户端
  • 首屏渲染更快:直接返回 HTML,不需要 hydration
  • 数据获取更简单:可以直接 import JSON 数据

举个例子,排行榜页面的代码非常简洁:

// app/predictors/page.tsx
import { getAllPredictorStats } from '@/lib/data'

export default function PredictorsPage() {
  const stats = getAllPredictorStats('budapest-2025')

  return (
    <div>
      {stats.map(stat => (
        <div key={stat.id}>
          {stat.name}: {stat.totalPassed}/{stat.totalStages}
        </div>
      ))}
    </div>
  )
}

不需要 useStateuseEffectfetch,直接调用函数拿数据就行,太爽了。

开发体验:Claude Code 真香

这个项目大概 70% 的代码是我和 Claude Code 配合完成的。我主要负责:

  1. 产品需求和交互设计
  2. 数据结构设计
  3. 核心业务逻辑的思路
  4. Code Review 和 Bug 修复

Claude Code 主要负责:

  1. 页面组件实现
  2. 样式调整
  3. 类型定义完善
  4. 重复性工作

具体流程大概是这样的:

我:「帮我实现一个排行榜页面,显示每个主播通过了几个阶段」

Claude: 「好的,我先看看数据结构...
        需要调用 getAllPredictorStats 函数...
        布局用 table 还是 card?」

我:「移动端用 card,桌面端用 table」

Claude: 「收到,开始实现...」
        [写完代码]
        「已完成,使用了响应式布局,mobile 是 card view...」

我:「主题色不对,应该用 CS2 的橙色」

Claude: 「了解,我看看配置...
        需要在 globals.css 里加 --color-primary-500...」

这种协作模式效率很高,我只需要关注"做什么"和"为什么",具体的"怎么做"交给 Claude。

当然也不是完全没问题,遇到过几次比较坑的:

坑 1:Tailwind v4 配置混乱

一开始 Claude 老是想创建 tailwind.config.ts,我说了好几次"v4 不需要 config 文件"它才理解。后来我直接在项目的 CLAUDE.md 里写明了配置方式,就没问题了。

坑 2:类型推断不够准确

有些复杂的类型推断 Claude 会搞错,比如这个:

// 我期望的类型
type StageId = SwissStageType | 'finals'

// Claude 写的类型
type StageId = string  // 太宽泛了

这种情况我会直接改类型定义,然后让它重新生成相关代码。

坑 3:过度工程

Claude 有时候会想得太多,比如我让它实现一个简单的队伍 Logo 组件,它给我写了个支持 lazy loading、error boundary、skeleton loading 的复杂组件...

我的原则是:先实现最简单能用的版本,需要优化再说

性能优化:从 3.2MB 到 180KB

最开始打包完发现 bundle size 有 3.2MB,吓了一跳。分析了一下主要问题:

1. JSON 数据没有 tree-shaking

events.jsonpredictions.json 虽然是静态导入的,但打包时会全部打进 bundle。

解决方案:把数据处理逻辑全部放在 Server Component 里,JSON 数据永远不会发送到客户端。

2. 图标库太大

一开始用的 react-icons,发现打包后有 800KB+。

解决方案:删掉 react-icons,手动写几个 SVG 图标:

// components/icons.tsx
export function MoonIcon() {
  return (
    <svg width="20" height="20" viewBox="0 0 20 20">
      <path d="M..." fill="currentColor" />
    </svg>
  )
}

只需要月亮、太阳、链接这几个图标,手写 SVG 体积可以忽略不计。

3. 不必要的 Client Component

最开始有些组件没必要是 Client Component,但我习惯性写了 'use client'

解决方案:仔细检查每个组件,只在必要时才用 'use client'

  • 需要 useStateuseEffect 等 hooks → Client Component
  • 需要事件处理(onClick、onChange) → Client Component
  • 纯展示逻辑 → Server Component

优化后的 bundle size:

  • JS (First Load): 180KB
  • CSS: 12KB
  • Images: 按需加载(Next.js Image 组件自动优化)

Lighthouse 跑分:

  • Performance: 98
  • Accessibility: 100
  • Best Practices: 100
  • SEO: 100

响应式设计:移动优先

CS2 玩家很多都是用手机查看竞猜数据的,所以移动端体验特别重要。

我的设计原则是 Mobile First

/* 默认是移动端样式 */
.container {
  padding: 1rem;
  font-size: 0.875rem;
}

/* sm: 640px 以上 */
@media (min-width: 640px) {
  .container {
    padding: 1.5rem;
    font-size: 1rem;
  }
}

/* md: 768px 以上 */
@media (min-width: 768px) {
  .container {
    padding: 2rem;
  }
}

在 Tailwind 里就是:

<div className="px-4 sm:px-6 md:px-8 text-sm sm:text-base">

移动端特殊优化

1. 刘海屏适配

iPhone 的刘海屏需要特殊处理:

body {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

2. 表格转卡片

桌面端用表格,移动端用卡片:

{/* 移动端:Card View */}
<div className="md:hidden">
  {stats.map(stat => (
    <div key={stat.id} className="card">
      <div>{stat.name}</div>
      <div>{stat.totalPassed}/{stat.totalStages}</div>
    </div>
  ))}
</div>

{/* 桌面端:Table View */}
<table className="hidden md:table">
  <thead>...</thead>
  <tbody>
    {stats.map(stat => (
      <tr key={stat.id}>
        <td>{stat.name}</td>
        <td>{stat.totalPassed}/{stat.totalStages}</td>
      </tr>
    ))}
  </tbody>
</table>

3. 触摸优化

移动端的点击区域要足够大:

.touch-target {
  min-height: 44px; /* iOS 建议的最小触摸尺寸 */
  padding: 0.75rem;
}

明暗主题实现

主题切换是个很常见的需求,但 Tailwind v4 的实现方式和之前不太一样。

主题状态管理

用 React Context + localStorage 实现:

// components/ThemeProvider.tsx
'use client'

type Theme = 'light' | 'dark' | 'system'

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState<Theme>('system')

  useEffect(() => {
    const saved = localStorage.getItem('theme') as Theme
    if (saved) setTheme(saved)
  }, [])

  useEffect(() => {
    const root = document.documentElement
    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
      .matches ? 'dark' : 'light'

    const effectiveTheme = theme === 'system' ? systemTheme : theme

    root.classList.remove('light', 'dark')
    root.classList.add(effectiveTheme)

    localStorage.setItem('theme', theme)
  }, [theme])

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

CSS 变量定义

globals.css 里定义主题变量:

:root {
  --foreground: #18181b;
  --color-surface-0: #ffffff;
  --color-primary-500: #ff6b35;
  --color-win: #22c55e;
  --color-lose: #ef4444;
}

.dark {
  --foreground: #fafafa;
  --color-surface-0: #0a0a0a;
  --color-primary-500: #ff8c61;
  --color-win: #4ade80;
  --color-lose: #f87171;
}

语义化类名

避免到处写 dark: 前缀:

/* 不好的做法 */
.text {
  @apply text-zinc-900 dark:text-white;
}

/* 好的做法 */
.text-primary {
  color: var(--foreground);
}

使用时就很简洁:

<h1 className="text-primary">标题</h1>

主题切换时会自动应用对应的颜色,不需要任何额外的类名。

部署:Vercel 一把梭

部署选择了 Vercel,理由很简单:

  1. 和 Next.js 深度集成:零配置部署
  2. 自动 HTTPS:不用自己搞证书
  3. 全球 CDN:访问速度快
  4. 预览部署:每个 PR 自动生成预览链接

部署流程:

# 1. 推送到 GitHub
git push origin main

# 2. Vercel 自动检测并构建
# 3. 几分钟后自动上线

就这么简单,连 CI/CD 配置都不用写。

域名配置

在 Vercel 后台添加自定义域名 major.viki.moe,然后在域名服务商那里添加一条 CNAME 记录指向 Vercel 就行了。HTTPS 证书 Vercel 会自动申请和续期。

遇到的问题和解决方案

问题 1:竞猜数据录入太慢

一开始我是手动看图片一个个录入数据,十几个主播录了两个小时,太痛苦了。

解决方案:写了个简单的数据录入脚本:

// scripts/add-prediction.ts
const prediction = {
  id: 's1mple',
  name: 's1mple',
  platform: 'twitch',
  'stage-1': {
    '3-0': ['Spirit', 'Liquid'],
    '3-1-or-3-2': ['G2', 'Vitality', 'FaZe', 'MOUZ', 'NAVI', 'Heroic'],
    '0-3': ['BIG', '9z']
  }
}

// 追加到 predictions.json

虽然还是需要手动输入,但至少不用在 JSON 里手写引号和逗号了。

未来如果数据量大了,可能会考虑做个后台管理界面,或者直接 OCR 识别图片。

问题 2:队伍 Logo 加载失败

有些战队的 Logo 在 Steam CDN 上找不到,导致页面出现破图。

解决方案:实现了一个带 fallback 的 Logo 组件:

export function TeamLogo({ team }: { team: string }) {
  const [error, setError] = useState(false)

  if (error) {
    return (
      <div className="w-8 h-8 rounded bg-surface-1 flex items-center justify-center">
        <span className="text-xs font-bold">{team.slice(0, 2)}</span>
      </div>
    )
  }

  return (
    <Image
      src={`https://steamcdn-a.akamaihd.net/apps/csgo/images/teams/${team}.png`}
      alt={team}
      width={32}
      height={32}
      onError={() => setError(true)}
    />
  )
}

找不到 Logo 就显示战队名称的缩写,体验好很多。

问题 3:阶段进度判断逻辑复杂

这个是最头疼的问题。赛事有多个阶段,每个阶段又有不同的完成标准:

  • 瑞士轮:需要所有战绩组都有数据(3-0 两队,3-1 三队...)
  • 淘汰赛:需要 winners 和 losers 都有数据
  • 决赛:只需要有 winner

而且还要区分"进行中"和"已完成"两种状态。

解决方案:写了一套状态机逻辑 getEventProgress(),根据实际数据自动判断当前状态。这个函数有 100+ 行,但把所有边界情况都考虑进去了。

测试方法也很简单:手动修改 events.json 里的数据,看页面显示是否正确。

未来计划

目前项目还比较基础,后续打算加一些功能:

1. 历史 Major 数据

把之前几届 Major 的数据也录入进来,可以看主播的历史战绩。

2. 统计图表

用 Chart.js 或者 Recharts 做一些可视化:

  • 每个主播的通过率趋势
  • 各队伍被竞猜的次数
  • 3-0 和 0-3 的命中率对比

3. 竞猜推荐

根据历史数据和当前形势,给出一个"推荐竞猜",帮助玩家做决策。

不过这个得小心,别搞成"割韭菜"工具...

4. 数据导出

支持导出成图片或者 Excel,方便分享。

5. PWA 支持

加个 Service Worker,支持离线访问和桌面安装。

总结

这个项目从想法到上线大概花了一周时间,其中:

  • 需求分析和设计:1 天
  • 开发和调试:4 天
  • 数据录入和测试:2 天

虽然功能还比较简单,但已经能满足基本需求。通过这个项目,我也对 Next.js 16、React 19、Tailwind v4 有了更深的理解。

最大的感受是:好的工具链真的能大幅提升开发效率。Next.js 的 App Router、Tailwind 的语义化类名、TypeScript 的类型检查,每一个都让开发体验更好。

如果你也对 CS2 Major 竞猜感兴趣,欢迎试用这个项目:major.viki.moe

如果你有任何建议或者想分享竞猜数据,欢迎通过以下方式联系我:

最后,祝大家都能拿到钻石币!🏆

Molecule Framework - ExplorerService API 详细文档

作者 大菜菜
2025年12月11日 18:28

Molecule Framework - ExplorerService API 详细文档

本文档详细描述了 IExplorerService 接口和 ExplorerService 类的所有方法和功能。


服务概述

ExplorerService(资源管理器服务)是 Molecule 框架中用于管理 IDE 资源管理器(Explorer)面板的核心服务。

资源管理器是 IDE 中用于显示和管理文件树、文件夹结构等资源的面板区域。它支持多个面板(Panel),每个面板可以有自己的工具栏和自定义渲染内容。

它提供了完整的资源管理器操作功能,包括:

  • 添加、删除、更新面板
  • 管理面板的展开/折叠状态
  • 管理工具栏操作项
  • 监听各种事件(点击、移除、折叠等)

继承关系:

ExplorerService extends Component<IExplorer> implements IExplorerService

1. 面板管理方法

addPanel(panel: IExplorerPanelItem | IExplorerPanelItem[]): void

功能: 添加新的资源管理器面板

参数:

  • panel: 单个面板数据或面板数据数组

说明:

  • 添加面板的同时,也会添加对应的工具栏数据
  • 可以一次添加单个或多个面板
  • 面板 ID 必须在资源管理器中唯一

示例:

// 添加单个面板
molecule.explorer.addPanel({
  id: 'file-tree',
  name: '文件树',
  sortIndex: 1,
  toolbar: [
    {
      id: 'refresh',
      title: '刷新',
      icon: 'refresh',
    },
  ],
});

// 添加多个面板
molecule.explorer.addPanel([
  {
    id: 'file-tree',
    name: '文件树',
    sortIndex: 1,
  },
  {
    id: 'outline',
    name: '大纲',
    sortIndex: 2,
  },
]);

updatePanel(data: Partial): void

功能: 更新面板数据

参数:

  • data: 面板的部分数据(Partial 类型,只需提供要更新的字段)

说明:

  • 更新面板数据的同时,也会修改对应的工具栏数据
  • 使用面板 ID 来定位要更新的面板

示例:

// 更新面板名称和排序
molecule.explorer.updatePanel({
  id: 'file-tree',
  name: '项目文件',
  sortIndex: 0,
});

// 更新面板的隐藏状态
molecule.explorer.updatePanel({
  id: 'outline',
  hidden: true,
});

removePanel(id: UniqueId): void

功能: 通过 ID 移除面板

参数:

  • id: 要移除的面板 ID

说明:

  • 移除面板的同时,也会移除对应的操作栏(Action Bar)

示例:

molecule.explorer.removePanel('file-tree');

togglePanel(id: UniqueId): void

功能: 切换面板的显示/隐藏状态

参数:

  • id: 要切换的面板 ID

说明:

  • 切换面板隐藏状态的同时,也会切换工具栏状态

示例:

// 切换面板显示/隐藏
molecule.explorer.togglePanel('file-tree');

setExpandedPanels(activePanelKeys: UniqueId[]): void

功能: 设置资源管理器中展开的面板

参数:

  • activePanelKeys: 要展开的面板 ID 数组

说明:

  • 设置哪些面板应该处于展开状态
  • 传入空数组可以折叠所有面板

示例:

// 展开指定的面板
molecule.explorer.setExpandedPanels(['file-tree', 'outline']);

// 折叠所有面板
molecule.explorer.setExpandedPanels([]);

2. 工具栏管理方法

addAction(action: IMenuItemProps): void

功能: 在工具栏操作中添加一个操作项

参数:

  • action: 菜单项属性对象

说明:

  • 仅添加工具栏操作,不影响面板数据
  • 可以添加单个或多个操作项

示例:

// 添加单个操作项
molecule.explorer.addAction({
  id: 'new-file',
  name: '新建文件',
  icon: 'file-add',
  onClick: (e, item) => {
    console.log('创建新文件');
  },
});

// 添加多个操作项(实际实现支持数组)
molecule.explorer.addAction({
  id: 'refresh',
  name: '刷新',
  icon: 'refresh',
});

getAction(id: UniqueId): IMenuItemProps | undefined

功能: 获取工具栏中指定的操作项

参数:

  • id: 操作项 ID

返回值:

  • 找到的操作项对象,如果不存在则返回 undefined

示例:

const action = molecule.explorer.getAction('new-file');
if (action) {
  console.log('操作项名称:', action.name);
}

updateAction(action: Partial): void

功能: 更新工具栏中的操作项

参数:

  • action: 操作项的部分数据(Partial 类型)

说明:

  • 使用操作项 ID 来定位要更新的操作项

示例:

// 更新操作项的禁用状态
molecule.explorer.updateAction({
  id: 'new-file',
  disabled: true,
});

// 更新操作项的名称和图标
molecule.explorer.updateAction({
  id: 'refresh',
  name: '重新加载',
  icon: 'reload',
});

removeAction(id: UniqueId): void

功能: 移除指定的工具栏操作项

参数:

  • id: 要移除的操作项 ID

示例:

molecule.explorer.removeAction('new-file');

toggleHeaderBar(id: UniqueId): void

功能: 仅切换工具栏状态

参数:

  • id: 工具栏 ID

说明:

  • 只切换工具栏的显示状态,不影响面板数据

示例:

molecule.explorer.toggleHeaderBar('file-tree-toolbar');

3. 状态重置方法

reset(): void

功能: 重置 ExplorerService 状态

说明:

  • 主要用于自定义资源管理器时重置状态
  • 清除所有面板和工具栏数据,恢复到初始状态

示例:

// 重置资源管理器,然后重新添加自定义面板
molecule.explorer.reset();
molecule.explorer.addPanel({
  id: 'custom-panel',
  name: '自定义面板',
});

4. 事件监听方法

onClick(callback: (e: MouseEvent, item: IActionBarItemProps) => void): any

功能: 监听资源管理器头部工具栏点击事件

参数:

  • callback: 回调函数
    • e: 鼠标事件对象
    • item: 被点击的操作栏项属性

返回值:

  • 返回取消监听的函数(如果支持)

说明:

  • 当用户点击资源管理器头部工具栏的操作项时触发

示例:

molecule.explorer.onClick((e, item) => {
  console.log('点击了工具栏项:', item.id);
  console.log('操作项标题:', item.title);
});

onRemovePanel(callback: (panel: IExplorerPanelItem) => void): void

功能: 监听资源管理器面板移除事件

参数:

  • callback: 回调函数
    • panel: 被移除的面板数据

说明:

  • 当面板被移除时触发此事件

示例:

molecule.explorer.onRemovePanel((panel) => {
  console.log('面板已移除:', panel.id);
  console.log('面板名称:', panel.name);
});

onCollapseAllFolders(callback: () => void): void

功能: 监听文件夹树面板折叠所有文件夹事件

参数:

  • callback: 回调函数(无参数)

说明:

  • 当文件夹树面板触发"折叠所有文件夹"操作时触发

示例:

molecule.explorer.onCollapseAllFolders(() => {
  console.log('所有文件夹已折叠');
});

onPanelToolbarClick(callback: (panel: IExplorerPanelItem, toolbarId: string) => void): void

功能: 监听资源管理器面板工具栏点击事件

参数:

  • callback: 回调函数
    • panel: 面板数据对象
    • toolbarId: 被点击的工具栏项 ID

说明:

  • 当用户点击面板工具栏中的操作项时触发

示例:

molecule.explorer.onPanelToolbarClick((panel, toolbarId) => {
  console.log('面板:', panel.name);
  console.log('工具栏项 ID:', toolbarId);

  if (toolbarId === 'refresh') {
    // 刷新面板内容
    refreshPanelContent(panel.id);
  }
});

5. 类型定义

IExplorerPanelItem

资源管理器面板项接口

属性:

  • id: UniqueId - 面板唯一标识符(必需,必须在资源管理器中唯一)
  • name: string - 面板标题(必需)
  • sortIndex: number - 排序索引(可选,数字越大越靠前)
  • className: string - 自定义 CSS 类名(可选)
  • toolbar: IActionBarItemProps[] - 工具栏操作项数组(可选)
  • renderPanel: RenderFunctionProps - 自定义渲染函数(可选)
  • hidden: boolean - 是否在资源管理器中隐藏(可选)
  • [key: string]: any - 允许添加其他自定义属性

示例:

const panel: IExplorerPanelItem = {
  id: 'file-tree',
  name: '文件树',
  sortIndex: 1,
  className: 'custom-panel',
  toolbar: [
    {
      id: 'refresh',
      title: '刷新',
      icon: 'refresh',
      onClick: (e, item) => {
        console.log('刷新文件树');
      },
    },
  ],
  renderPanel: (props) => {
    return <FileTreeComponent {...props} />;
  },
  hidden: false,
};

IExplorer

资源管理器数据接口

属性:

  • data: IExplorerPanelItem[] - 面板数据数组
  • headerToolBar: IActionBarItemProps - 头部工具栏操作项(可选)
  • activePanelKeys: UniqueId[] - 当前展开的面板 ID 数组(可选)

示例:

const explorerState: IExplorer = {
  data: [
    {
      id: 'file-tree',
      name: '文件树',
    },
    {
      id: 'outline',
      name: '大纲',
    },
  ],
  headerToolBar: {
    id: 'explorer-actions',
    title: '操作',
    icon: 'more',
  },
  activePanelKeys: ['file-tree'],
};

IMenuItemProps

菜单项属性接口(用于工具栏操作项)

属性:

  • id: UniqueId - 操作项唯一标识符(必需)
  • icon: string | JSX.Element - 图标名称或元素(可选)
  • type: 'divider' - 分隔线类型(可选)
  • name: string - 操作项名称(可选)
  • disabled: boolean - 是否禁用(可选)
  • keybinding: string - 快捷键描述(可选,例如:⇧⌘P
  • render: (data: IMenuItemProps) => React.ReactNode - 自定义渲染函数(可选)
  • onClick: (e: React.MouseEvent, item: IMenuItemProps) => void - 点击事件处理函数(可选)
  • sortIndex: number - 排序索引(可选)
  • [key: string]: any - 允许添加其他自定义属性

示例:

const menuItem: IMenuItemProps = {
  id: 'new-file',
  name: '新建文件',
  icon: 'file-add',
  keybinding: '⌘N',
  disabled: false,
  onClick: (e, item) => {
    console.log('创建新文件');
  },
};

IActionBarItemProps

操作栏项属性接口(用于面板工具栏)

属性:

  • id: UniqueId - 操作项唯一标识符(必需)
  • title: string | JSX.Element - 操作项标题(可选)
  • name: React.ReactNode - 操作项名称(可选)
  • icon: string | JSX.Element - 图标名称或元素(可选)
  • disabled: boolean - 是否禁用(可选)
  • checked: boolean - 是否选中(可选)
  • data: T - 自定义数据(可选,泛型类型)
  • contextMenu: IMenuItemProps[] - 上下文菜单项数组(可选)
  • onContextMenuClick: (e: React.MouseEvent, item: IMenuItemProps | undefined) => void - 上下文菜单点击事件(可选)
  • onClick: (event: React.MouseEvent, item: IActionBarItemProps) => void - 点击事件处理函数(可选)
  • [key: string]: any - 允许添加其他自定义属性

示例:

const actionBarItem: IActionBarItemProps = {
  id: 'refresh',
  title: '刷新',
  name: '刷新文件树',
  icon: 'refresh',
  disabled: false,
  checked: false,
  onClick: (e, item) => {
    console.log('刷新操作');
  },
  contextMenu: [
    {
      id: 'refresh-all',
      name: '刷新全部',
    },
  ],
};

RenderFunctionProps

面板渲染函数类型

类型定义:

type RenderFunctionProps = (props: any) => React.ReactNode;

说明:

  • 用于自定义面板内容的渲染函数
  • 接收任意属性对象,返回 React 节点

示例:

const renderPanel: RenderFunctionProps = (props) => {
  return <CustomPanelComponent data={props.data} />;
};

const panel: IExplorerPanelItem = {
  id: 'custom-panel',
  name: '自定义面板',
  renderPanel: renderPanel,
};

ExplorerEvent

资源管理器事件枚举

枚举值:

  • onClick - 头部工具栏点击事件
  • onPanelToolbarClick - 面板工具栏点击事件
  • onCollapseChange - 折叠状态变化事件
  • onRemovePanel - 面板移除事件
  • onCollapseAllFolders - 折叠所有文件夹事件

6. 使用示例

完整示例:创建自定义资源管理器面板

import molecule from '@dtinsight/molecule';
import React from 'react';

// 1. 重置资源管理器
molecule.explorer.reset();

// 2. 定义自定义面板组件
const CustomFileTree = (props) => {
  return <div>自定义文件树组件</div>;
};

// 3. 添加自定义面板
molecule.explorer.addPanel({
  id: 'custom-file-tree',
  name: '文件树',
  sortIndex: 1,
  toolbar: [
    {
      id: 'refresh',
      title: '刷新',
      icon: 'refresh',
      onClick: (e, item) => {
        console.log('刷新文件树');
      },
    },
    {
      id: 'new-file',
      title: '新建文件',
      icon: 'file-add',
      onClick: (e, item) => {
        console.log('创建新文件');
      },
    },
  ],
  renderPanel: (props) => {
    return <CustomFileTree {...props} />;
  },
});

// 4. 设置展开的面板
molecule.explorer.setExpandedPanels(['custom-file-tree']);

// 5. 监听面板工具栏点击事件
molecule.explorer.onPanelToolbarClick((panel, toolbarId) => {
  console.log('面板:', panel.name);
  console.log('工具栏项:', toolbarId);
});

// 6. 监听面板移除事件
molecule.explorer.onRemovePanel((panel) => {
  console.log('面板已移除:', panel.id);
});

示例:动态更新面板

// 更新面板名称和工具栏
molecule.explorer.updatePanel({
  id: 'file-tree',
  name: '项目文件树',
  toolbar: [
    {
      id: 'refresh',
      title: '重新加载',
      icon: 'reload',
    },
    {
      id: 'collapse-all',
      title: '折叠全部',
      icon: 'collapse',
    },
  ],
});

// 切换面板显示/隐藏
molecule.explorer.togglePanel('outline');

// 更新工具栏操作项
molecule.explorer.updateAction({
  id: 'refresh',
  disabled: true,
  name: '刷新中...',
});

示例:管理多个面板

// 添加多个面板
molecule.explorer.addPanel([
  {
    id: 'file-tree',
    name: '文件树',
    sortIndex: 3,
  },
  {
    id: 'outline',
    name: '大纲',
    sortIndex: 2,
  },
  {
    id: 'timeline',
    name: '时间线',
    sortIndex: 1,
  },
]);

// 展开多个面板
molecule.explorer.setExpandedPanels(['file-tree', 'outline']);

// 移除指定面板
molecule.explorer.removePanel('timeline');

7. 最佳实践

  1. 面板 ID 唯一性

    • 确保每个面板的 ID 在资源管理器中唯一
    • 建议使用有意义的命名,如 'file-tree''outline'
  2. 排序控制

    • 使用 sortIndex 控制面板显示顺序
    • 数字越大越靠前显示
  3. 工具栏管理

    • 面板工具栏和头部工具栏是分开管理的
    • 使用 addAction 添加头部工具栏操作
    • 在面板的 toolbar 属性中定义面板工具栏
  4. 事件监听

    • 在组件初始化时注册事件监听器
    • 注意避免重复注册导致的内存泄漏
  5. 状态同步

    • 使用 setExpandedPanels 控制面板展开状态
    • 在面板数据变化时及时更新展开状态
  6. 自定义渲染

    • 使用 renderPanel 属性自定义面板内容
    • 可以传递自定义属性给渲染函数
  7. 与 FolderTree 的关系

    • ExplorerService 管理多个面板
    • FolderTree 通常是其中一个面板的内容
    • 两者可以配合使用,但职责不同

8. 注意事项

  1. 面板与工具栏的关联

    • 添加面板时会自动添加对应的工具栏数据
    • 移除面板时会自动移除对应的操作栏
  2. 展开状态管理

    • activePanelKeys 控制哪些面板处于展开状态
    • 使用 setExpandedPanels 更新展开状态
  3. 隐藏 vs 移除

    • hidden: true 只是隐藏面板,数据仍然存在
    • removePanel 会完全移除面板数据
  4. 工具栏操作项类型

    • 头部工具栏使用 IMenuItemProps
    • 面板工具栏使用 IActionBarItemProps
    • 两者属性略有不同,注意区分
  5. 事件回调参数

    • 不同事件的回调函数参数不同
    • 注意查看类型定义以正确使用参数

9. 相关服务

  • FolderTreeService: 管理文件夹树的具体实现
  • ActivityBarService: 管理左侧活动栏,用于切换不同的侧边栏视图
  • SidebarService: 管理侧边栏的整体状态

10. 总结

ExplorerService 提供了完整的资源管理器面板管理功能,支持:

  • 面板的增删改查
  • 工具栏操作项的管理
  • 面板展开/折叠状态控制
  • 丰富的事件监听机制
  • 自定义渲染支持

通过合理使用这些 API,可以构建出功能强大且灵活的资源管理器界面。

纯 CSS 复刻星战开场:让文字在宇宙中滚动

作者 闲云ing
2025年11月8日 22:33

前言

提到《星球大战》,很多人会想到那句经典的黄色字幕从屏幕深处缓缓升起、消失在星海中的开场。这个极具辨识度的视觉语言,已成为电影史上的标志性镜头。

本文将以一个极具表现力的案例——CSS3 实现星球大战片头动画效果——带你走进前端如何像电影导演一样,用代码编排一场震撼人心的视觉盛宴。


一、故事背景:我们想做什么?

我们想要制作星球大战经典的片头文字滚动效果:

银河系的星空背景下,巨大的黄色标题“STAR WARS”缓缓浮现,紧接着一句标语(如“The Force Awake”)从屏幕深处沿着斜坡向上远去,消失在星海之中。

但这次,我们不用视频,也不用 JavaScript 动画库,只用 HTML + CSS3 来实现这个电影级动效。


二、HTML 结构:搭建舞台的结构

<div class="starwars">
  <img src="./star.svg" alt="star" class="star">
  <img src="./wars.svg" alt="wars" class="wars">
  <h2 class="byline" id="byline">
    <span>T</span>
    <span>h</span>
    <span>e</span>
    <span>F</span>
    <span>o</span>
    <span>r</span>
    <span>c</span>
    <span>e</span>
    <span>A</span>
    <span>w</span>
    <span>a</span>
    <span>k</span>
    <span>e</span>
  </h2>
</div>

语义化思考:

  • 使用 <h2> 包裹副标题,语义合理;
  • 每个字母包裹在 <span> 中,便于逐字控制动画
  • 图标采用 SVG 格式,确保高清无锯齿,且易于样式控制。

三、基础样式与重置

为了确保跨浏览器一致性,我们使用了一套现代 CSS Reset:

*,
*::before,
*::after {
  box-sizing: border-box;
}

并重置了所有默认样式(如边距、字体、列表等),让所有元素在一个干净、可控的环境中渲染。


四、视觉舞台:构建 3D 宇宙空间

body {
  height: 100vh;
  background: #000 url(./bg.jpg); /* 星空背景图 */
}

我们为页面设置了一个全屏黑色背景,并加载一张星空图,模拟银河系的深邃感。

关键技术点:CSS 3D 变换

.starwars {
  perspective: 800px;
  transform-style: preserve-3d;
  width: 34em;
  height: 17em;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
  • perspective: 800px:定义观察 3D 元素的距离,数值越小,3D 效果越强烈。
  • transform-style: preserve-3d:确保子元素也处于 3D 空间中,不会被扁平化。
  • translate(-50%, -50%):经典的水平垂直居中技巧,让整个动画容器居于屏幕中央。

五、动画设计:制作经典片头

CSS 动画的本质,就是一部时间轴上的视觉剧本。我们通过 @keyframes 编写关键帧,定义每个元素在不同时间点的状态。

1. “STAR” 与 “WARS” 的登场:淡入 + 缩放

@keyframes star {
  0% {
    opacity: 0;
    transform: scale(1.5) translateY(-0.75em);
  }
  20% { opacity: 1; }
  89% { transform: scale(1); }
  100% { opacity: 0; transform: translateZ(-1000em); }
}
  • 初始放大并上移,营造“从远处冲来”的感觉。
  • 20% 时完全显示,随后缓慢缩小至正常大小。
  • 最后消失在 Z 轴深处,仿佛飞向宇宙尽头。

2. 副标题的 3D 滚动:深度移动 + 逐字旋转

.byline {
  animation: move-byline 10s linear infinite;
}

@keyframes move-byline {
  0% { transform: translateZ(5em); }
  100% { transform: translateZ(0); }
}
  • 使用 translateZ 让文字从“屏幕内部”向前推进,产生纵深感
  • 配合 .byline span 的逐字动画,实现字母依次出现、旋转、消失的效果。
@keyframes spin-letters {
  0%, 10% {
    opacity: 0;
    transform: rotateY(90deg);
  }
  30% { opacity: 1; }
  70%...95% { opacity: 0; }
}
  • 每个字母像“跳钢管舞”一样,从侧面旋转入场,再淡出。
  • 时间节奏精心设计,形成流畅的阅读节奏。

六、细节优化 & 兼容性处理

优化点 说明
颜色统一 使用 #ffe851 还原电影原色
字体大小响应式 可结合 vw 单位适配不同屏幕
动画性能 所有变换均使用 transformopacity,触发 GPU 加速
循环同步 所有动画时长统一为 10s infinite,确保节奏一致

七、技术反思:CSS 能做到什么?

这个案例展示了 CSS3 的强大能力:

技术 作用
transform 实现缩放、位移、旋转、3D 变换
animation + @keyframes 控制时间轴上的状态变化
perspective 构建真实感的 3D 空间
translate(-50%, -50%) 精准定位
inline-block + span 实现逐字动画

结语:前端,是数字世界的导演

在这个《星球大战》的 CSS 实现中,我们看到:

  • HTML 是剧本:定义角色与结构。
  • CSS 是摄影与特效:控制光影、运动、氛围。
  • 开发者是导演:统筹全局,调度资源,讲好一个故事。

通过纯 CSS 实现《星球大战》片头动画,不仅是一次对经典视觉语言的致敬,更展现了现代前端技术在表现力与创造力上的无限可能。无需复杂框架,仅凭样式与动画,我们便能在浏览器中构建出属于自己的银河史诗——代码即艺术,前端亦可浪漫。

90%前端都踩过的JS内存黑洞:从《你不知道的JavaScript》解锁底层逻辑与避坑指南

作者 zzpper
2025年12月12日 00:20
在前端开发中,“内存”似乎是个“隐形选手”——平时不显山露水,一旦出问题就可能让页面越用越卡、甚至直接崩溃。多数开发者对JS内存的理解停留在“栈存基础类型,堆存引用类型”的表层,却忽略了《你不知道的J
昨天 — 2025年12月11日掘金 前端
❌
❌