普通视图

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

DevEco Studio 使用技巧全面解析

作者 Keya
2025年12月29日 14:24

DevEco Studio 使用技巧全面解析

引言

在鸿蒙生态蓬勃发展的当下,DevEco Studio作为华为提供的一站式集成开发环境,专为鸿蒙操作系统应用和服务开发设计。掌握其使用技巧,能够大幅提升开发效率,让开发者更加专注于应用的创新和实现。本文将从快捷键、实用功能、界面操作等多个方面,为你详细介绍DevEco Studio的使用技巧。

快捷键大全

编辑类快捷键

编辑是开发过程中最频繁的操作,以下快捷键能让代码编写、修改更流畅:

快捷键(Win) 快捷键(Mac) 中文说明
Alt + J ^G 选择相同词,设置多个光标。(常用,批量选中)
Alt + 1 ⌘1 显示 或 隐藏 项目区。(常用)
Ctrl + E ⌘E 最近的文件(常用,切换文件、切换面板,强烈推荐)
Ctrl + P ⌘P 展示方法的参数信息。(常用,类型提示神器)
Ctrl + Q 展示组件的 API 说明文档。(常用,查文档神器)
Ctrl + Alt + L ⌥⌘L 格式化代码 。(推荐设置保存自动格式化)
Shift + Enter ⇧↩ 换行输入。(常用,换行添加新属性)
Ctrl + 单击 / Ctrl + B ⌘单击 / ⌘B 跳转源码、跳转文件。(常用,强烈推荐)
Ctrl + Alt + T ⌥⌘T 自动生成具有环绕性质的代码。(推荐,生成 if…else,try…catch 等代码块)
Ctrl + / ⌘/ 单行注释 // (常用)
Ctrl + Shift + / ⌥⌘/ 代码块注释 /**/ (常用)
Tab / Shift + Tab Tab / ⇧Tab 缩进或者不缩进一次所选择的代码段。(常用)
Ctrl + X ⌘X 剪切选中代码、剪切行、删除行。 (常用)
Ctrl + C ⌘C 复制选中代码、复制行。 (常用)
Ctrl + D ⌘D 复印选中代码、复印行。(常用)
Ctrl + V ⌘V 粘贴代码。(常用)
Ctrl + Shift + V ⇧⌘V 剪贴板,复制过的内容都在这里。(推荐)
Ctrl + Z ⌘Z 撤消。(常用)
Ctrl + Shift + Z / Ctrl + Y ⇧⌘Z 重做。
Ctrl + Shift + J ^⇧J 把下一行的代码接续到当前的代码行。(常用,合并行)
Ctrl + Shift + U ⇧⌘U 切换大小写。(推荐)
Ctrl + (+/-) ⌘+ / ⌘- 折叠或展开代码。 (推荐)
Shift + F6 ⇧F6 重构修改命名。(常用,能同步更新路径、变量名、函数名的重命名)

查找与替换快捷键

高效的查找和替换能减少重复劳动,提升代码维护效率:

快捷键(Win) 快捷键(Mac) 中文说明
Ctrl + F ⌘F 在当前文件中查找
Ctrl + R ⌘R 替换文本
Ctrl + Shift + F ⇧⌘F 在文件中查找
Ctrl + Shift + R ⇧⌘R 在文件中替换
Shift + Shift Shift + Shift 快速查找

编译与运行快捷键

编译和运行是开发过程中的关键步骤,这些快捷键让操作更高效:

快捷键(Win) 快捷键(Mac) 中文说明
Shift + F10 ^R 运行 entry。 (常用,特别好用)
Shift + F9 ^D 调试 entry。
Alt + Shift + F10 ^⌥D 会打开一个已经配置的运行列表,让你选择一个后,再运行。
Alt + Shift + F9 ^⌥D 会打开一个已经配置的运行列表,让你选择一个后,再以调试模式运行。

调试快捷键

高效的调试能力是解决问题的关键,以下是常用的调试快捷键:

快捷键 中文说明
F8 步过(执行下一行,不进入方法)
F7 步入(进入调用的方法)
Alt + F9 运行到光标处
Alt + F8 评估表达式
F9 恢复程序运行(跳到下一个断点)
Ctrl + Shift + F8 查看断点

实用功能介绍

代码高亮

代码高亮功能可以让代码中的关键类、运算符、字符串等重要部分以高亮形式呈现。在DevEco Studio中,我们可以通过简单的设置来自定义高亮显示。打开 File > Settings (Windows系统)或 DevEco Studio > Preferences (macOS系统),然后找到Editor > Code Style 选项,在这里可以自定义各字段的高亮显示颜色。

代码跳转

在阅读和编辑复杂代码时,快速定位函数、方法等定义的位置至关重要。DevEco Studio提供了便捷的代码跳转功能。只需使用快捷键Ctrl (Windows系统)或 Command (macOS系统)并配合鼠标单击,就能直接跳转到代码中引用的目标定义处。而且,当存在多个引用目标时,还会弹出选择窗口,方便我们准确找到想要查看的定义。

跨语言跳转

对于涉及跨语言开发,特别是声明调用了Native接口的项目,DevEco Studio的跨语言跳转功能十分实用。在代码中选中相关调用,然后选择 Go To > Implementation(C++) ,就能迅速跳转到对应的C++实现代码处。这一功能极大地提高了联合开发时不同语言代码之间的切换和查阅效率,减少了在不同文件和语言之间切换的繁琐操作。

代码格式化

代码格式化功能可以帮我们快速整理代码的缩进和结构。默认情况下,它已经有一套代码格式化配置,但我们也可以根据自己的喜好进行调整。通过File > Settings (Windows系统)或 DevEco Studio > Preferences (macOS系统)进入Editor > Code Style ,在这里可以设置各种格式化规则。使用快捷键Ctrl + Alt + L (Windows系统)或 Option + Command + L (macOS系统),就能对选定的代码进行格式化。此外,还能设置在代码中添加特定的格式化标记,让代码在格式化时更符合我们的需求。

//@formatter:off
// 此区域内的代码将跳过自动格式化
const specialFormatting = {
needToKeep: "original_format"
};
//@formatter:on

代码折叠

当代码量较大时,代码折叠功能可以帮助我们隐藏不必要的代码块,使代码结构更加简洁明了。我们可以通过右键菜单选择相应的折叠选项,也可以使用快捷键实现代码的折叠与展开。比如,使用快捷键 Ctrl + Shift + - (Windows系统)或 Command + Shift + - (macOS系统)可以全部折叠代码,方便我们从整体上把握代码的逻辑结构。

代码快速注释

给代码添加注释是良好的编程习惯,但手动逐行添加注释有时比较繁琐。DevEco Studio提供了代码快速注释功能,使用快捷键Ctrl + / (Windows系统)或 Command + / (macOS系统),就能快速为选中的代码行添加注释,再次使用则可以取消注释。这一功能让注释编写变得轻松快捷,有助于提高代码的可读性和可维护性。

代码结构树

代码结构树功能可以让我们快速了解文件的代码架构。通过快捷键Alt + 7 (Windows系统)或 Command + 7 (macOS系统)打开代码结构树窗口,在这里我们可以清晰地看到文件中全局变量、函数、类的成员变量和方法等内容,并且可以直接点击跳转到对应的代码行,方便我们在复杂的代码文件中快速定位关键部分。 代码结构树转存失败,建议直接上传图片文件

代码引用查找

在大型项目中,了解代码中某个符号的引用关系十分重要。DevEco Studio的代码引用查找功能可以帮助我们实现这一点。使用快捷键Alt + F7 (Windows系统)或Option + F7 (macOS系统),或者通过右键菜单选择相关选项,就能在代码编辑区快速查找符号被引用的位置。这有助于我们理解代码的调用逻辑,排查问题时也能更加高效。

函数注释生成

在定义函数时,手动编写详细的注释往往比较耗时。DevEco Studio提供了函数注释生成功能,在定义函数的代码编辑区,输入“/**” ,就能快速生成函数注释模板。这一功能支持多种语言,如C++等,极大地提高了函数注释的编写速度,让我们的代码更加规范和易于理解。

代码导航

代码导航功能可以帮助我们在文件、类型文件的标签之间快速切换和定位。通过代码导航,我们可以快速找到相关的文件和工程资源,特别是在大型项目中,多个文件和模块相互关联时,这一功能能让我们更加便捷地在代码之间穿梭,提高开发效率。

快速查阅API接口及组件参考文档

在编辑代码过程中,当遇到不熟悉的API接口或组件时,DevEco Studio能让我们快速查阅相关参考文档。当API/组件被高亮显示时,编辑器会迅速显示对应的参考文档链接,我们只需单击就能查阅详细内容。此外,对于带有decorateobsolete 标识的API,也有相应的显示和关注方式,方便我们及时了解API的状态和使用注意事项。

Optimize Import功能

在项目开发过程中,代码中的 import 语句可能会变得杂乱,存在未使用的导入等情况。DevEco Studio的 Optimize Import 功能可以帮助我们优化这些导入语句。使用快捷键Ctrl+Alt+O (Windows系统)或 Control+Option+O (macOS系统),并选择 Code > Optimize Imports ,就能自动移除未使用的导入,整理导入语句的顺序,让我们的代码更加简洁、规范。

界面操作技巧

界面布局认识

DevEco Studio界面布局遵循“高效开发流”设计,核心区域分工明确,无需额外配置即可满足基础开发需求:

  1. 核心功能菜单区:集成项目创建(File)、编辑工具(Edit)、运行调试(Run)等全流程操作入口,支持通过快捷键快速调用(如Ctrl+N 新建文件)。
  2. 项目文件导航区:以树形结构展示项目文件,支持“按模块筛选”“关键词搜索”,右键菜单可直接创建页面/组件,大幅提升文件管理效率。
  3. 代码编辑区:支持ArkTS语法高亮、自动缩进、括号匹配,内置“代码折叠”功能(点击左侧- 符号),可折叠复杂布局代码,聚焦当前编辑内容。
  4. 快捷工具栏区:包含项目视图切换(Project/Structure)、代码格式化(Ctrl+Alt+L )、版本控制等高频操作按钮,减少菜单层级跳转。
  5. 运行与设备控制区:一键切换运行设备(模拟器/真机),支持“运行”(▶️)、“调试”(🐞)、“热重载”(♻️),修改代码后无需重启应用即可预览效果。
  6. 辅助功能区:集成“问题检查”(实时显示语法错误)、“终端”(执行ohpm命令)、“预览器”(UI实时渲染),一站式解决开发中的辅助需求。 DevEco Studio界面

目录精简操作

  1. 基础精简:左侧Project面板顶部,点击“Project”下拉菜单,选择“Project Files”,隐藏冗余配置目录。
  2. 深度精简:选择“Ohos”视图,仅保留与业务开发相关的核心目录(如pagesresources ),进一步减少视觉干扰。

模拟器操作指南

操作 路径/指令 效果
新建模拟器 Tools > Device Manager > New 添加P50/Pixel等设备镜像
旋转屏幕 模拟器面板点击🔄图标 测试横竖屏适配
模拟传感器 面板右上角⚡ > Simulate Sensor 调试GPS/光线传感器

模拟器界面

异常排查技巧

现象 定位方法 修复方案
预览器白屏 检查build() 函数返回值 确保返回单个根组件(如Column
页面跳转失败 查看router.push 路径 确认目标页已在 main_pages.json 注册
应用启动闪退 查看Logcat日志 过滤HARMONY 标签定位原生层错误

扩展工具链

自动化测试

使用@ohos/hypium 框架编写UI测试脚本,实现自动化测试,提高测试效率和准确性。

发布应用前必做

  1. 代码混淆:启用build.gradle中的minifyEnabled true缩减体积,保护代码安全。
  2. 签名配置:通过Project Structure > Signing Configs添加华为AppGallery证书,确保应用可以正常发布。
  3. 编译HAP:执行Build > Build HAP(s)生成安装包,准备发布应用。

总结

DevEco Studio作为鸿蒙生态的官方开发工具,提供了丰富的功能和便捷的操作方式。掌握这些使用技巧,能够让开发者在鸿蒙应用开发过程中事半功倍,更加高效地完成应用的开发和调试。希望本文的介绍能够帮助你更好地使用DevEco Studio,开发出更加优秀的鸿蒙应用。


End


2025年我是怎么用AI写代码的

2025年12月29日 13:11

Agent 大爆发的一年,我写代码的方式也加入了除手工写之外的其他两种方式。

使用 Copilot debug 和 写样板代码

在去年我使用AI写代码的时候,最多的场景还是问解决方案,今年模型的 agent 能力开发出来之后,更多的时候是用来直接 debug 和写 样板代码。

debug

一般来说,debug 的过程是,遇到问题,我判断问题可能出在哪个页面,是哪个功能,通过什么操作复现的,然后我会在 vscode 的 chat 框引入对应的文件,并且把操作过程和错误以及我的猜测都输入进去,开启 agent 模式,发送,然后等待结果返回。一开始,在 claude sonnet 3.5,3.7的很多时候其实没有那么好用,会给很多看似改对了但是再去操作一遍还是没用的代码,不得不说这个是真的浪费了不少时间,换在以前自己早都改完了,还有耐心在这和它聊噢。但是想了想,后面模型能力提升了和AI协作必不可少,也就继续和它“沟通”。

后面发现可以不让它一步直接修改,而是让它先确定问题具体出现在哪一行或者是由于哪个设计引起的,先加上调试日志,通过调试日志来确定问题,最后一步才是改具体的业务代码。

写样板代码

新功能页面的开发,为了符合页面风格,以及一些接口复用,会需要写样板代码。这种样板代码又不像功能复用要是做成高度抽象的到时候个性化又太麻烦,于是之前是手动复制一份,然后修修改改,可能要几个小时,但是今年就完全不一样了。遇到这种情况直接让AI去做,我把参考页面引入做上下文,然后再把新页面的个性化需求加上,发送之后过几分钟页面就出来了,然后再自己调整一下,最多也就10分钟就把样板代码部分搞完了。

使用 Claude code 进行 vibe coding

Vibe coding 这个概念今年特别火热,经常有人说自己不懂技术的朋友利用 vibe coding 开发了自己的 app 赚了钱或者实现了零的突破之类的。特别是 Claude code(后文简称 cc) 和 aistudio 的推出和流行。前者是完全的引领了这股风潮,通过自然语言让AI搭建项目,开发功能,自主debug,提交代码到github,甚至运行在部署过程。而后者则是凭借 google 生态和 gemini3 的多模态 SOTA 能力构建了一个从开发到部署都在一个地方的完美体验。

Feishu Doc Exporter

我用 cc 主要是在小项目,或者说 demo 演示这种情况。比如我今年用飞书比较多,我发现我自己要把飞书文本同步到公众号的时候,文档格式会丢,于是我看chrome store 里没有之后,就产生了写一个浏览器插件来解决这个问题的想法,于是就用 cc 来完成这件事,这个项目叫 Feishu Doc Exporter 。这个项目我从头到尾没有写过一行代码,我只是把飞书的前段元素结构发给它,然后把我的需求说了。在最近的更新里,它甚至把我给的图片自动切分了,因为它觉得这个图片太大不利于审核。

使用 cc 写代码唯一担心的是钱包。也正因为这个我才知道L站,才知道很多公益站,还有很多低价的中转站,说到这个我真的很期待国产AI的崛起,到时候直接用国内的模型,真好!加油啊,minimax 和 glm 和 deepseek 和 qwen!

参加 Aistudio 的黑客松活动

其实这一节比较像产品经理,当然我这几年我认为我也没遇到几个非常牛的产品经理。

在黑客松活动中,在 aistudio 里我直接构建了一个今年apple的年度app。

image.png 我的体验是,在这个环境里我就只需要说说说就像,哪里不对我就说,改整体颜色我就说换个更可爱的颜色,改布局我就说想想 apple 会怎么设计这个功能。

加入了 google 的多模态功能识别物品的名称和英文,过度效果,还有国际化需求,支持不同母语和目标语言,这些费时费力的事,在这里就是几句话,几分钟。

So fast,so hard!

一晚上我改了五六七八遍。

我遇到的问题

贵,真的贵

第一个大问题就是太贵了,好用的模型价格太贵了。最新的 opus 模型价格是 sonnet 的3倍,而无论是 cursor 还是 claude 或者 openai 基础会员都是 20 美刀,用量完全不够用,而最新的模型往往能力都更强,体验过之后会很难再用老模型了。

使用起来门槛很多

  • 门槛高 首先第一个就是国内能直接使用的都落后。比如 trae 国内就只能用落后最前沿一两个版本的模型,codebuddy 国内版本也是,qorder 也是一样,想用最前沿的模型,就要用他们的国际版或者想别的办法。

  • 配置多 无论是 vscode 的 agent 能力还是 cc 的自主代理,这些都是体验起来很美好,但是想降低开销,想提高准确率,会需要各种各样的配置文件。我领导最近发觉 cursor 额度用的很快,一查发现怎么单次会话几千万 token。

image.png

比如 vscode 就推出 .github/agents.md.github/copilot-instructions.md 等文件来进行项目级别的对于 agent 每次会话的前情提要,而在 cc 中,配置更是多到让人眼花缭乱,有子代理,允许执行的命令,skills,允许的 mcp 列表等等,这些都需要你自己去配置,去编写,你需要理解这些配置文件的作用,是不是有一种 webpack 的感觉?甚至 cc 可以分为全局(整台电脑)的配置和项目级别还有目录级别的,我自己认为把 cc 配置文件精细化到这种程度是超级复杂的一件事。

image.png

总结

AI在今年已经不是我的实习生了,而是我的代码搭子,我向它灌输需求,我给它装配能力,让它知道它负责的项目要求,让它知道现在要做的是什么,让它先搞一版出来,然后我再和它调调调,来回几次之后算是把任务完成了,我的收获就是原来需求是这样去考虑的,代码能力的增长主要是体现在和AI说需求的过程中我的预期也越来越清晰了。

2026年我感觉可能更多是要vibe coding了,虽然公司也没有那么多新产品要推,但是对于个人去解决遇到的问题,去为了自己的一点好奇心或者玩,可能更容易去写代码了,反正不用手写了。

Set/Map+Weak三剑客的骚操作:JS 界的 “去重王者” ,“万能钥匙”和“隐形清洁工”

2025年12月29日 13:02

前言

家人们,咱写 JS 的时候是不是总被 “数组里的重复元素” 烦到挠头?是不是吐槽过 “对象的 key 只能是字符串,太死板了”?今天这俩 JS 界的 “宝藏工具人”——SetMap,直接给你把这些痛点按在地上摩擦!还有它们的 “低调兄弟” WeakSet/WeakMap,偷偷帮你解决内存泄漏,这波骚操作直接把 JS 玩明白了~

篇幅有点长,但是干货拉满!没搞懂你找我😄

一、Set:数组的 “洁癖管家”,重复元素一键劝退

先给 Set 下个性感定义长得像数组,却容不下任何重复成员,主打一个 “宁缺毋滥”。不管你往里面塞多少个一样的,它都只留一个!就是这么洁癖,你不服也得服!

1. 基础操作:add/delete/has/clear/size,一套组合拳

先来看add(往里面添加元素):

// 初始化一个空的Set实例
let s = new Set();
s.add(1); // 向 Set中添加数字1
s.add(2); // 向 Set中添加数字2
console.log(s);  

image.png

你会发现,咦?为啥打印出的结果是这个奇怪样子?前面还有个Set(2)是个什么玩意,其实这是控制台对 Set 实例“友好提示”——Set(2)表示这是一个包含 2 个成员的 Set 集合,后面跟着的{1, 2}才是 Set 里的具体成员,并不是打印结果 “奇怪”,而是控制台为了让你直观看到 Set 的类型和长度,特意做的格式展示~,这也印证了Set不属于数组。

所以这里我们如果用解构的方法就不会有前面的东西:

console.log(...s);   // 直接输出成员:1 2(解构为独立参数)
console.log([...s]); // 输出数组:[1, 2](转换为普通数组)

image.png

简单说,Set(2)只是控制台的 “类型标签”,不是 Set 本身的内容,真正的成员就是12,这也是 Set 和数组在控制台展示的核心区别~

在一起看看deletehasclear

let s = new Set([1, 2, 3, 4, 5]);
s.delete(2);  // 删除 Set中的元素2
console.log(s);  // 输出 Set(4) { 1, 3, 4, 5 },没有 2
console.log(s.has(3));  // 判断 Set中是否存在元素 3,ture
s.clear();  // 清空 Set中的所有元素,Set(0) {}
console.log(s);

image.png

❗️⭐当然这里有一个要注意的点:如果用has判断[]

let s = new Set([1, 2, 3, 4, 5]);
s.add([]);  // 增加一个[]
console.log(s.has([]));  // false,引用地址不一样

image.png

任何涉及到引用地址的,都会判断为false核心原因就是引用类型的 “地址唯一性”,数组是引用类型,每一次 [] 都会创建一个全新的、内存地址不同的数组对象。

Set 的 has 方法判断元素是否存在时,对于引用类型(数组、对象等),是通过 “内存地址是否一致” 来判断,而非值是否相同。因此 has([]) 找不到之前添加的那个数组,最终返回 false

最后就是用size获得set的长度(不要把数组的length搞混哦⚠️):

let s = new Set([1, 2, 3, 4, 5]);
console.log(s.size);

image.png

2. 最实用技能:数组去重!

这绝对是 Set“成名作”,一行代码解决数组重复问题,我们大部分时候用Set目的就是为了去重,好用的飞起,不需要再用for一个一个遍历啦!

const arr = [1, 2, 3, 2, 1];
let arr2 = [...new Set(arr)];  // Set 里面是允许存放数组的!
console.log(arr2);  // 解构的结果为 [ 1, 2, 3 ]

image.png

不只是数组,字符串也能去重:

const str = 'abcba';
console.log(new Set(str));

image.png

3. 遍历 Set:keys/values/entries/forEach,其实都差不多😝

Set 里的 “键” 和 “值” 是同一个东西(毕竟它是单值集合),所以keys()values()遍历出来的结果一毛一样。看似花里胡哨,实则逻辑超简单:

let set = new Set(['a','b','c']);

// 1. keys():获取Set的“键”(Set的键和值是同一个)
for(let key of set.keys()){
    console.log(key); // 依次输出a、b、c
}

// 2. values():获取Set的值,和keys()结果完全一致
for(let val of set.values()){
    console.log(val); // 依次输出a、b、c
}

// 3. entries():返回[key, value]形式的迭代器,键值相同
for(let item of set.entries()){
    console.log(item); // 依次输出['a','a']、['b','b']、['c','c']
}

// 4. forEach遍历:和数组forEach用法一致
set.forEach((val, key) => {
    console.log(key + ':' + val); // 依次输出a:a、b:b、c:c
});

image.png

4. ⚠ 遍历不改变原数组!return返回也没用!⚠️

当我们把 SetforEach 的特性结合起来时,还能发现更多有趣的细节 —— 比如用 Set 对数组去重后,再通过 forEach 修改数组元素,依然要遵循 “直接改 item 无效、需通过索引修改原数组” 的规则:

const arr = [1, 2, 3];
arr.forEach((item, i, array) => {
    item *= 10;  // 直接修改是没用滴!
})
console.log(arr); // 还是输出 [1, 2, 3]

image.png

const arr = [1, 2, 3];
arr.forEach((item, i, array) => {
    arr[i] = item * 10;  // 必须通过索引!
})
console.log(arr); // 输出 [10, 20, 30]

image.png

forEach 里的 return 也依旧无法终止遍历

const arr = [1, 2, 3];
arr.forEach((item, i, array) => {
    if(i < 2) {
        console.log(item);
        return;  // 正常打印完 1就退出,但是结果为 1,2
    }
})

image.png

这段代码既体现 Set “成员唯一” 的核心特性,又完整复现了 forEach 修改数组的关键规则 —— 直接操作 item 无法改变原数组、return 仅终止当前循环而非整个遍历,把 Set 和 forEach 的核心逻辑紧密串联了起来。

5.🌈判断能否遍历的小技巧

假设你不知道Set可以遍历,那怎么判断呢?一招搞定,那就是直接去浏览器上打印出一个Set对象,看看里面有没有iterator这个方法,如果有,那就👌(^o^)/~,大胆放心遍历!

image.png

二、Map:传统对象的 “超级进化版”,key 想放啥就放啥

传统 JS 对象的痛点:key 只能是字符串或 Symbol,想拿对象当 key?门都没有!但 Map 直接打破这个限制 —— 数字、数组、对象、甚至 null 和 undefined! 啥都能当 key,堪称 “万能键值对容器”

1. 基础操作:set/get/has/size/delete/clear(跟Map差不多!)

const m = new Map();
// 各种奇奇怪怪的 key 都能放
m.set('hello', 'world'); // key是字符串
m.set([], 1); // key是数组
m.set(null, 2); // key是null
console.log(m.size); // Map 的长度,输出 3

console.log(m.get(null)); // 输出 2,精准取到 null对应的值
console.log(m.has([])); // 输出 false!注意:数组是引用类型,这里的[]和set的[]不是同一个对象!
m.delete(null); // 删除 key为 null的项
m.clear(); // 清空 Map

image.png

2. 遍历 Map:比对象遍历爽多了

Map 天生支持遍历,不用像对象那样 “转数组再遍历”。好我现在假设不知道可以遍历,大声告诉我怎么办?😮看来你会了:

image.png

const m = new Map([['name', 'henry'], ['age', 18]]);
// 直接用for...of遍历,拿到[key, value]
for (let [key, val] of m) {
    console.log(key, val); // 输出name henry、age 18
}

image.png

这里依旧跟Map一样的问题 (引用地址不同)

const arrKey = [];
const m = new Map();
m.set(arrKey, '我是数组键的值');
console.log(m.get(arrKey)); // 输出"我是数组键的值"(引用地址一致)
console.log(m.get([])); // 输出undefined(新数组,地址不同)

image.png

三、WeakSet/WeakMap:JS 内存的 “隐形清洁工”,弱引用太香了

聊完 SetMap,必须提它们的 “低调兄弟”——WeakSetWeakMap,这俩主打一个 “弱引用”,堪称内存泄漏的 “克星”,一个守护 Set 体系,一个守护 Map 体系,分工明确又超实用!甚至有些前端开发者都不知道有这俩玩意!必须补充上⬆️!

1. WeakSet:Set 的 “内存友好版”,只存对象 + 自动回收

WeakSetSet“轻量版”,核心规则先划重点:

  • 只能存储对象类型(数字、字符串等原始类型一概不收,存了也白存);
  • 对存储的对象是弱引用:如果外部没有其他引用指向这个对象,垃圾回收机制会自动把 WeakSet 里的这个对象清理掉,绝不占内存;
  • 不可遍历(没有 keys ()/values ()/forEach 等遍历方法),也没有 size 属性,主打一个 “默默干活不露面”

错误示例:向WeakSet添加原始类型(会直接报错)

const wsError = new WeakSet();
try {
    // 尝试添加数字(原始类型),会抛出TypeError
    wsError.add(123); 
} catch (err) {
    console.log('报错信息:', err.message); // 输出:Invalid value used in weak set
}

image.png

正确示例:WeakSet仅存储对象+弱引用特性

// 1. 初始化WeakSet
const ws = new WeakSet();

// 2. 定义对象(只有对象能存入WeakSet)
let obj1 = { name: 'JS玩家1' };
let obj2 = { name: 'JS玩家2' };

// 3. 向WeakSet添加对象(正常生效,无报错)
ws.add(obj1);
ws.add(obj2);

// 4. 判断对象是否存在(返回布尔值)
console.log('obj1是否在WeakSet中:', ws.has(obj1)); // 输出:true
console.log('obj2是否在WeakSet中:', ws.has(obj2)); // 输出:true

// 5. 删除指定对象(返回布尔值,存在则删除并返回true)
ws.delete(obj2);
console.log('删除obj2后,obj2是否存在:', ws.has(obj2)); // 输出:false

// 6. 弱引用核心演示:外部销毁obj1的引用
console.log('销毁obj1前,obj1是否存在:', ws.has(obj1)); // 输出:true
obj1 = null; // 外部不再引用obj1
// 此时JS垃圾回收器(GC)会在合适时机自动清理WeakSet中obj1的引用
// 注意:无法通过代码直接验证回收结果(WeakSet不可遍历、无size属性),但原理是确定的

image.png

补充:WeakSet不支持的操作(避免踩坑)

try {
    // WeakSet无size属性,访问会报错
    console.log(ws.size); 
} catch (err) {
    console.log('访问size报错:', err.message); // 输出:ws.size is undefined
}

try {
    // WeakSet不可遍历,forEach会报错
    ws.forEach(item => console.log(item)); 
} catch (err) {
    console.log('forEach遍历报错:', err.message); // 输出:ws.forEach is not a function
}

image.png

2. WeakMap:Map 的 “内存友好版”,键仅对象 + 自动回收

WeakMapMap“专属内存管家”,核心规则和 WeakSet 呼应,更贴合键值对场景:

  • 键只能是对象类型(原始类型当键直接无效);
  • 对键的引用是弱引用:如果外部没有其他引用指向这个键对象,垃圾回收机制会自动回收这个键值对,彻底杜绝内存泄漏;
  • 不可遍历(没有 keys ()/values ()/entries () 等方法),也没有 clear () 方法,主打 “用完即走不拖沓”。
// 1. 初始化WeakMap
let wm = new WeakMap();

// 2. 定义对象作为键(符合 WeakMap的键要求)
let obj = {name: 'JS玩家'};

// 3. 添加键值对(键是对象,正常生效)
wm.set(obj, '这是WeakMap的值');

// 4. 查看值:成功获取
console.log(wm.get(obj)); // 输出:这是WeakMap的值

// 5. 外部销毁obj的引用
obj = null;
// 此时WeakMap中obj对应的键值对会被垃圾回收器自动清理(无法通过代码直接验证,是内存层面的行为)

// 6. 尝试用原始类型(字符串)当键:直接报错!
try {
    wm.set('hello', 'world'); // WeakMap不允许原始类型作为键,执行到这行就会抛错
    console.log(wm.get('hello')); // 这行代码永远不会执行
} catch (err) {
    console.log('错误原因:', err.message); // 输出:错误原因: Invalid value used as weak map key
}

image.png

3. 为啥需要 WeakSet/WeakMap?为啥有些开发者甚至不知道它俩?

比如做 DOM 元素的状态管理,用 WeakMap 存 DOM 元素对应的状态:

// 假设页面有个按钮元素
const btn = document.querySelector('#myBtn');

// 用 WeakMap存按钮的点击次数
const btnClickCount = new WeakMap();
btnClickCount.set(btn, 0);

// 按钮点击时更新次数
btn.addEventListener('click', () => {
    let count = btnClickCount.get(btn);
    btnClickCount.set(btn, count + 1);
    console.log('点击次数:', count + 1);
});

// 如果后续按钮被移除(比如btn = null),WeakMap里的键值对会自动回收,不会内存泄漏!
// 要是用普通Map,即使btn被移除,Map依然持有强引用,内存会一直被占用,这就是差距~

四、总结

1. 最后唠两句(核心点):

  • Set 核心是唯一值集合,主打数组去重,支持 add/delete/has/clear 等操作,无法通过索引取值;

  • Map 核心是万能键值对,键可以是任意类型,弥补传统对象短板,支持 set/get/delete/has/clear;

  • WeakSet/WeakMap 主打弱引用 + 自动回收,仅存 / 仅以对象为键,不可遍历,是解决内存泄漏的绝佳方案。

2. 一张表理清 Set/Map/WeakSet/WeakMap 核心区别:

特性 Set Map WeakSet WeakMap
存储形式 单值集合(无键值) 键值对集合 单值集合(仅对象) 键值对集合(键仅对象)
成员 / 键类型 任意类型 键:任意类型; 值:任意 仅对象 键:仅对象;值:任意
引用类型 强引用 强引用 弱引用 弱引用(仅键)
遍历性 可遍历 可遍历 不可遍历 不可遍历
内存回收 手动清空 手动清空 自动回收无引用对象 自动回收无引用键对象
特殊属性 有 size 有 size 无 size 无 size

结语

Set 就像 “去重神器”,解决数组重复问题手到擒来;Map“万能键值对”,弥补了传统对象的短板;WeakSet/WeakMap 则是 “内存管家”,默默帮你清理无用内存,杜绝泄漏。

记住核心用法

  • 去重、存唯一值 → 用 Set;
  • 非字符串键的键值对存储 → 用 Map;
  • 存对象且怕内存泄漏 → 存单值用 WeakSet,存键值对用 WeakMap。

把这四个玩明白,JS 数据存储的坑能少踩一大半,效率直接拉满!赶快用起来!

需要了解其他数据类型的读者可以看我的文章:栈与堆的精妙舞剧:JavaScript 数据类型深度解析

附上ES6的原文资料:es6.ruanyifeng.com/#docs/set-m…

前端登录加密与Token管理实践

作者 saberxyL
2025年12月29日 12:26

前端登录加密与Token管理实践

前后端分离架构下的登录安全、Token存储方案。涵盖登录加密、JWT、Cookie+httpOnly等核心内容。


一、登录加密流程

方案:前端哈希 + HTTPS + 后端强哈希

// 前端
async function login(username, password) {
  // 1. 前端哈希(防中间人攻击)
  const hashedPwd = CryptoJS.SHA256(password).toString();
  
  // 2. HTTPS传输
  const res = await axios.post('/api/login', { username, password: hashedPwd });
  
  // 3. Token自动存储(浏览器查看后端响应头自动存储token并处理httpOnly Cookie)
}

// 后端(必须)
// 1. 二次加盐哈希存储
const bcryptHash = await bcrypt.hash(hashedPwd, 12);
// 2. 生成JWT放入httpOnly Cookie

核心原则

  • HTTPS 是强制前提:所有登录请求必须通过HTTPS传输
  • 后端必须用 bcrypt/Argon2:即使前端已哈希,后端仍需强哈希存储
  • 前端哈希是可选的:主要防传输层泄露,非必需但推荐

二、JWT详解

结构

Header.Payload.Signature

1. Header(头部)

  • 作用:声明算法和类型。
  • 示例: json json { "alg": "HS256", // 签名算法(如 HMAC SHA256) "typ": "JWT" // Token 类型 }
  • Base64Url 编码后成为第一部分。

2. Payload(载荷)

  • 作用:存放实际数据(称为“声明” Claims)。
  • 标准声明(可选):
    • iss(签发者)
    • exp(过期时间)
    • sub(主题,如用户ID)
    • aud(接收者)
  • 自定义声明:业务相关数据(如用户角色、权限)。 json json { "sub": "1234567890", "name": "John Doe", "role": "admin", "exp": 1717986919 // 过期时间戳(UTC) }
  • Base64Url 编码后成为第二部分。 ⚠️ 注意:Payload 未加密,仅防篡改,不要存储敏感信息(如密码)。

3. Signature(签名)

  • 作用:验证 Token 未被篡改。
  • 生成方式: text text HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret_key )
  • 结果:签名 + 前两部分 = 最终 JWT。

安全实践

  • 短有效期:15分钟访问令牌 + 7天刷新令牌
  • 不存敏感数据:密码、手机号等绝不在Payload中
  • httpOnly Cookie存储:防XSS

三、Cookie + httpOnly 存储方案(核心)

完整流程

后端设置(Node.js示例)

res.cookie('token', jwt, {
  httpOnly: true,    // ✅ JS无法读取,防XSS
  secure: true,      // ✅ 仅HTTPS(生产环境)
  sameSite: 'Strict', // ✅ 防CSRF
  maxAge: 15 * 60 * 1000 // 15分钟过期
});

前端无感

  • 无需手动存储:浏览器会自动读取响应头中set-cookie
  • 无需手动携带:浏览器自动在请求头中附加Cookie
  • 登出:后端调用 res.clearCookie('token')

四、前端鉴权问题与解决

问题

httpOnly Cookie导致前端无法直接读取Token,无法判断登录状态。

解决方案:返回Userinfo给前端

// 后端登录接口
app.post('/api/login', (req, res) => {
  const token = generateJWT(user);
  res.cookie('token', token, { httpOnly: true, secure: true });
  
  // 返回非敏感用户信息
  res.json({
    userinfo: { id: user.id, role: user.role, name: user.name }
  });
});

// 前端存储
// 方式1:普通Cookie(前端可读)
document.cookie = `userinfo=${encodeURIComponent(JSON.stringify(userinfo))}; Path=/; SameSite=Strict`;

// 方式2:localStorage(需防XSS)
localStorage.setItem('userinfo', JSON.stringify(userinfo));

// 路由守卫
router.beforeEach((to, from, next) => {
  const userinfo = localStorage.getItem('userinfo') || getCookie('userinfo');
  if (userinfo) next();
  else next('/login');
});

安全增强

  • Userinfo仅含非敏感信息:ID、角色、昵称,绝不存密码、手机号
  • 过期时间一致:userinfo与token同时失效
  • 登出同步清除
    // 后端
    res.clearCookie('token');
    res.clearCookie('userinfo');
    // 前端
    localStorage.removeItem('userinfo');
    

高安全场景增强

关键路由(如支付、个人中心)增加后端验证:

router.beforeEach(async (to, from, next) => {
  const userinfo = localStorage.getItem('userinfo');
  if (!userinfo) return next('/login');
  
  if (to.meta.requiresAuth) {
    try {
      await axios.get('/api/auth/verify', { withCredentials: true });
      next();
    } catch {
      localStorage.removeItem('userinfo');
      next('/login');
    }
  } else {
    next();
  }
});

五、总结与最佳实践

核心配置速查

组件 配置 目的
Token Cookie httpOnly: true, secure: true, sameSite: 'Strict' 防XSS、CSRF,仅HTTPS
Userinfo存储 普通Cookie或localStorage 前端路由鉴权
JWT有效期 访问令牌15分钟,刷新令牌7天 平衡安全与体验
路由守卫 检查userinfo存在性 无权限不渲染

常见误区

  1. ❌ Token返回给前端:应仅存httpOnly Cookie,前端无需知道
  2. ❌ 长有效期:应短有效期+刷新机制
  3. ❌ userinfo存敏感信息:仅存非敏感数据
  4. ❌ 登出不清除:必须同步清除前后端存储

六、核心代码片段

1. 后端登录完整示例

app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;
  
  // 1. 前端哈希值 → 后端bcrypt验证
  const user = await db.users.findOne({ username });
  const isValid = await bcrypt.compare(password, user.passwordHash);
  
  if (!isValid) return res.status(401).json({ error: '认证失败' });
  
  // 2. 生成JWT
  const token = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  // 3. 设置httpOnly Cookie
  res.cookie('token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict',
    maxAge: 15 * 60 * 1000
  });
  
  // 4. 返回用户信息
  res.json({
    userinfo: { id: user.id, role: user.role, name: user.name }
  });
});

2. 前端路由守卫

router.beforeEach(async (to, from, next) => {
  const hasToken = !!document.cookie.includes('token='); // 仅检查存在性
  const userinfo = localStorage.getItem('userinfo');
  
  if (to.path === '/login') {
    next(hasToken ? '/' : '/login');
    return;
  }
  
  if (!hasToken || !userinfo) {
    next('/login');
    return;
  }
  
  // 高安全页面验证Token有效性
  if (to.meta.requiresAuth) {
    try {
      await axios.get('/api/auth/verify', { withCredentials: true });
      next();
    } catch {
      localStorage.removeItem('userinfo');
      next('/login');
    }
  } else {
    next();
  }
});

3. 登出同步清除

// 前端
const logout = async () => {
  await axios.post('/api/logout', {}, { withCredentials: true });
  localStorage.removeItem('userinfo');
  // 或清除普通Cookie: document.cookie = 'userinfo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
  router.push('/login');
};

// 后端
app.post('/api/logout', (req, res) => {
  res.clearCookie('token');
  res.clearCookie('userinfo');
  res.json({ success: true });
});

单页应用路由怎么搞?React Router 从原理到实战全解析!

2025年12月29日 12:19

在 Web 开发的发展历程中,页面的跳转和展示方式经历了显著的变化,其中路由机制的演变尤为关键。本文将从多页应用与单页应用的区别入手,详细介绍 React 生态中主流的路由解决方案 ——react-router-dom 的使用方法。

一、从多页应用到单页应用

1. 传统多页应用

早期的 Web 应用大多是多页应用。每一个页面都是一个独立的 HTML 文件,当用户在不同页面间跳转时,浏览器会重新请求新的 HTML 文件,页面会整体刷新。这种模式下,URL 的变化直接对应着不同的 HTML 资源。

2. 现代单页应用

随着前端技术的发展,单页应用逐渐成为主流。单页应用只有一个 HTML 文件,所有的页面内容都通过 JavaScript 动态生成和切换。当 URL 发生变化时,并不会重新加载整个页面,而是通过路由机制,将对应的组件渲染到固定的 HTML 容器中:

  • 访问 http://localhost:5173/home 时,将首页组件加载到 HTML 中

  • 访问 http://localhost:5173/about 时,将关于页组件加载到同一个 HTML 中

这种方式使得页面切换更加流畅,用户体验更接近原生应用。

二、页面与组件的区别

在单页应用的路由体系中,我们通常将 "配路由的组件" 称为 "页面",而普通的组件则用于构建页面的各个部分。简单来说,页面是可以通过 URL 直接访问的组件,是路由配置的基本单位。

三、React Router 的使用

在 React 项目中,我们通常使用 react-router-dom 来实现路由功能。首先需要安装这个库:

npm i react-router-dom

下面我们按照核心组件和 API 的使用顺序,介绍 react-router-dom 的基本用法:

1. BrowserRouter:路由模式

BrowserRouter 是 react-router-dom 提供的一种路由模式(history 模式),它使用 HTML5 的 history API 来管理路由状态。我们需要将整个应用的路由配置包裹在 BrowserRouter 中:

import { BrowserRouter } from 'react-router-dom'

function App() {
  return (
    <BrowserRouter>
      {/* 路由配置和应用内容 */}
    </BrowserRouter>
  )
}

2. Routes:路由容器

Routes 提供了一个路由出口,它会根据当前 URL,从其子路由配置中选择匹配的路由进行渲染。可以理解为一个路由的容器,里面包含多个 Route 配置项:

import { BrowserRouter, Routes } from 'react-router-dom'

function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* 多个Route配置项 */}
      </Routes>
    </BrowserRouter>
  )
}

3. Route:路由配置项

Route 用于定义 URL 路径与组件的对应关系,通过 path 属性指定 URL 路径,通过 element 属性指定对应的组件:

import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Home from './views/home/Home'
import Login from './views/login/Login'

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/login' element={<Login />} />
        <Route path='/home' element={<Home />} />
      </Routes>
    </BrowserRouter>
  )
}

还可以通过 Navigate 组件实现路由重定向:

<Route path='/' element={<Navigate to="/login" />} />

对于不存在的路径,可以配置 404 页面

<Route path='*' element={<h2>NOT FOUND</h2>} />

4. Outlet:二级路由出口

当我们需要实现嵌套路由(二级路由)时,Outlet 组件用于指定子路由组件的渲染位置。父组件中放置 Outlet,子路由的内容就会在这里显示:

// 父组件 Home.jsx
import { Outlet, Link } from 'react-router-dom'

export default function Home() {
  return (
    <div className="home">
      {/* 其他内容 */}
      <main className='content'>
        <Outlet /> {/* 子路由组件会在这里渲染 */}
      </main>
    </div>
  )
}

在路由配置中,可以这样定义嵌套路由:

<Route path='/home' element={<Home />}>
  <Route path='class' element={<Class />} />
  <Route path='leetcode' element={<LeetCode />} />
</Route>

5. Link:导航链接

Link 组件用于创建路由导航链接,类似于 HTML 中的 <a> 标签,但不会引起页面刷新,只会更新 URL 和对应的组件:

import { Link } from 'react-router-dom'

function Sidebar() {
  return (
    <ul>
      <li><Link to="/home/class">课程</Link></li>
      <li><Link to="/home/leetcode">算法</Link></li>
    </ul>
  )
}

6. useNavigate:编程式导航

useNavigate 是一个 React Hook,用于在代码中实现路由跳转(编程式导航),比如在登录成功后跳转到首页:

import { useNavigate } from 'react-router-dom'

export default function Login() {
  const navigate = useNavigate()

  const handleLogin = () => {
    // 登录逻辑处理...
    navigate('/home?id=123') // 登录成功后跳转到首页,携带参数
  }

  return (
    <div>
      <button onClick={handleLogin}>登录</button>
    </div>
  )
}

四、总结

react-router-dom 提供了一套完整的路由解决方案,通过 BrowserRouterRoutesRouteOutletLinkuseNavigate 等核心 API,我们可以轻松实现单页应用的路由功能。掌握这些基础用法,能够帮助我们构建出流畅的页面跳转体验,为用户提供更好的交互感受。

React 中 Context 的作用与用法:从主题切换案例说起

作者 冻梨政哥
2025年12月29日 12:01

React 中 Context 的作用与用法:从主题切换案例说起

在 React 开发中,组件通信是一个核心问题。对于父子组件,我们可以通过 props 轻松传递数据;但当组件层级较深或跨多个层级时,使用 props 逐层传递数据(即 "props drilling")会变得繁琐且低效。这时,React 的 Context 功能就成了最佳解决方案。本文将结合一个主题切换的实际案例,详细讲解 Context 的作用与用法。

一、Context 的核心作用

Context(上下文)是 React 提供的一种跨组件数据共享机制,它允许我们在组件树中创建一个 "全局" 数据空间,让任意层级的组件都能直接访问和使用这些数据,而无需通过 props 逐层传递。

简单来说,Context 解决了以下问题:

  • 跨层级组件通信时的 "props 传递链过长" 问题
  • 多个组件需要共享同一状态(如主题、用户信息、权限等)的场景
  • 避免了深层组件必须接收不直接使用的 props(仅为了传递给子组件)

二、Context 的基本用法(结合案例代码)

下面我们结合提供的 "主题切换" 案例,拆解 Context 的使用步骤。整个案例实现了一个可切换 "明亮 / 暗黑" 主题的功能,核心代码涉及ThemeContext.jsxHeader.jsxApp.jsx等文件。

步骤 1:创建 Context 容器

首先需要通过createContext创建一个 Context 容器,用于存储需要共享的数据。

ThemeContext.jsx中:

// 导入createContext
import { createContext } from "react";

// 创建Context容器,默认值为null(可自定义)
export const ThemeContext = createContext(null);

createContext接收一个默认值(当组件找不到对应的 Provider 时使用),返回一个 Context 对象,该对象包含两个属性:Provider(提供数据)和Consumer(消费数据,现代 React 中更推荐用useContext)。

步骤 2:创建 Provider 提供数据

Context 需要通过Provider组件将数据 "注入" 到组件树中,所有被Provider包裹的子组件(无论层级多深)都能访问这些数据。

ThemeContext.jsx中,我们创建了ThemeProvider组件作为数据提供者:

import { useState, useEffect } from "react";
import { createContext } from "react";

export const ThemeContext = createContext(null);

// ThemeProvider组件接收children(子组件树)
export default function ThemeProvider({ children }) {
  // 维护主题状态(light/dark)
  const [theme, setTheme] = useState('light');
  
  // 定义切换主题的方法
  const toggleTheme = () => {
    setTheme((t) => t === 'light' ? 'dark' : 'light');
  };
  
  // 监听theme变化,同步到DOM(用于CSS主题切换)
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);
  
  // 通过Provider的value属性提供数据和方法
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children} {/* 子组件树 */}
    </ThemeContext.Provider>
  );
}

这里的关键是:

  • ThemeProvider内部管理共享状态(theme)和修改状态的方法(toggleTheme
  • 通过ThemeContext.Providervalue属性,将需要共享的数据(theme)和方法(toggleTheme)传递给子组件
  • 所有被ThemeContext.Provider包裹的子组件,都能访问value中的内容

步骤 3:在组件中消费 Context 数据

子组件需要使用useContext钩子(或Consumer组件)来获取 Context 中的数据。

Header.jsx中,我们实现了一个显示当前主题并提供切换按钮的组件:

import { useContext } from "react";
import { ThemeContext } from "../contexts/ThemeContext";

export default function Header() {
  // 通过useContext获取ThemeContext中的数据
  const { theme, toggleTheme } = useContext(ThemeContext);
  
  return (
    <div style={{ marginBottom: 24 }}>
      <h2>当前主题: {theme}</h2>
      {/* 点击按钮调用toggleTheme切换主题 */}
      <button className="button" onClick={toggleTheme}>切换主题</button>
    </div>
  );
}

这里的useContext(ThemeContext)直接获取了ThemeProvider提供的themetoggleTheme,无需通过 props 传递。即使Header组件嵌套在多层组件之下,只要它在ThemeProvider的子树中,就能直接访问这些数据。

步骤 4:在根组件中使用 Provider

最后需要在组件树的某个顶层位置使用ThemeProvider,确保其包裹所有需要访问 Context 数据的组件。

App.jsx中:

import ThemeProvider from "./contexts/ThemeContext";
import Page from './pages/Page'

export default function App() {
  return (
    <>
      {/* 用ThemeProvider包裹Page组件,使其子树能访问主题数据 */}
      <ThemeProvider>
        <Page />
      </ThemeProvider>
    </>
  );
}

Page组件及其内部的Header组件,由于被ThemeProvider包裹,因此都能访问到主题相关的数据。

步骤 5:配合样式实现主题切换

案例中还通过 CSS 变量和data-theme属性,实现了主题样式的切换,这也体现了 Context 的实际价值:

theme.css中:

/* 定义默认(明亮主题)变量 */
:root {
  --bg-color: #ffffff;
  --text-color: #222;
  --primary-color: #1677ff;
}

/* 暗黑主题变量 */
[data-theme='dark'] {
  --bg-color: #141414;
  --text-color: #f5f5f5;
  --primary-color: #4e8cff;
}

/* 使用变量定义样式 */
body {
  margin: 0;
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: all 0.3s;
}

ThemeProvider中的theme变化时,useEffect会更新document.documentElementdata-theme属性,CSS 会自动应用对应主题的变量,实现样式切换。这正是 Context 传递的数据驱动 UI 变化的完整流程。

三、Context 的使用总结

通过上述案例,我们可以总结出 Context 的核心使用流程:

  1. 创建 Contextconst MyContext = createContext(默认值)
  2. 提供数据:通过MyContext.Providervalue属性传递数据,包裹需要访问数据的组件树
  3. 消费数据:在子组件中通过useContext(MyContext)获取数据

Context 特别适合共享 "全局" 性质的数据(如主题、用户信息、语言设置等),但需注意:不要过度使用 Context(会增加组件耦合度),且 Context 变化时会导致所有消费它的组件重渲染,需合理设计状态粒度。

通过这个主题切换案例,我们可以清晰地看到 Context 如何简化跨层级组件通信,让数据共享变得高效而直观。

cloudflare + github 实现留言板

作者 Ankkaya
2025年12月29日 12:00

在 小白服务器踩坑(1)中,我们成功部署网站上线,现在为网站实现留言板功能

实现原理

采用 Github Issues + CloudFlare Worker 实现,在 Worder 添加函数实现留言内容接收,并提交至 Github Issues,Github Issues 来存放留言数据,也方便网站显示留言记录

这样做的好处不需要数据库,也不需要部署后台服务,Github Issues 审核,分类,标签也方便管理留言

创建 Worker

Worker 只是一个函数,前端调用接口实际是执行这个函数,进入 cloudflare,选择计算机和AI/Workders和Pages创建应用程序,选择从 Hello World!开始

可以先部署程序,部署成功后修改程序。在程序详情页面,右上角点击编辑代码,添加提交留言的代码

const corsHeaders = {
  "Access-Control-Allow-Origin""*"// 本地 + 生产都能用
  "Access-Control-Allow-Methods""POST, GET, OPTIONS",
  "Access-Control-Allow-Headers""Content-Type",
};

export default {
  async fetch(request, env) {
    // =========================
    // 1️⃣ 处理 CORS 预检请求
    // =========================
    if (request.method === "OPTIONS") {
      return new Response(null, {
        status204,
        headers: corsHeaders,
      });
    }

    // =========================
    // 2️⃣ GET:健康检查
    // =========================
    if (request.method === "GET") {
      return new Response(
        JSON.stringify({
          status"ok",
          message"Comment API is running",
        }),
        {
          headers: {
            "Content-Type""application/json",
            ...corsHeaders,
          },
        }
      );
    }

    // =========================
    // 3️⃣ 只允许 POST
    // =========================
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", {
        status405,
        headers: corsHeaders,
      });
    }

    // =========================
    // 4️⃣ 正常处理留言
    // =========================
    let data;
    try {
      data = await request.json();
    } catch {
      return new Response("Invalid JSON", {
        status400,
        headers: corsHeaders,
      });
    }

    const { content } = data;

    if (!content) {
      return new Response("Content is required", {
        status400,
        headers: corsHeaders,
      });
    }

    // -------------------------
    // 创建 GitHub Issue
    // -------------------------
    const issueBody = content;

    const res await fetch(
      `https://api.github.com/repos/${env.GITHUB_REPO}/issues`,
      {
        method"POST",
        headers: {
          Authorization: `Bearer ${env.GITHUB_TOKEN}`,
          "Content-Type""application/json",
          "User-Agent""CF-Worker",
        },
        body: JSON.stringify({
          title"用户留言",
          body: issueBody,
          labels: ["pending"],
        }),
      }
    );

    if (!res.ok) {
      const err = await res.text();
      return new Response(err, {
        status500,
        headers: corsHeaders,
      });
    }

    // -------------------------
    // 发送通知(Telegram 或邮件)
    // -------------------------
    try {
      // Telegram 机器人通知
      if (env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID) {
        const telegramMessage = `📝 新留言\n\n${content}`;
        await fetch(
          `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`,
          {
            method"POST",
            headers: {
              "Content-Type""application/json",
            },
            body: JSON.stringify({
              chat_id: env.TELEGRAM_CHAT_ID,
              text: telegramMessage,
            }),
          }
        );
      }

      // 邮件通知(使用 SendGrid)
      if (env.SENDGRID_API_KEY && env.NOTIFICATION_EMAIL) {
        await fetch("https://api.sendgrid.com/v3/mail/send", {
          method"POST",
          headers: {
            Authorization: `Bearer ${env.SENDGRID_API_KEY}`,
            "Content-Type""application/json",
          },
          body: JSON.stringify({
            personalizations: [
              {
                to: [{ email: env.NOTIFICATION_EMAIL }],
                subject"新留言通知",
              },
            ],
            from: { email: env.FROM_EMAIL || env.NOTIFICATION_EMAIL },
            content: [
              {
                type"text/plain",
                value: content,
              },
            ],
          }),
        });
      }
    } catch (notifyError) {
      // 通知失败不影响主流程,只记录错误
      console.error("通知发送失败:", notifyError);
    }

    return new Response(
      JSON.stringify({ successtrue }),
      {
        headers: {
          "Content-Type""application/json",
          ...corsHeaders,
        },
      }
    );
  },
};

机器人和邮箱通知可选,因为要用我们自己的 Github 账号提交 Issues,还需要在 Worker 里配置变量

GITHUB_REPO是保存 Issues 的项目名,GITHUB_TOKEN需要在 github 生成 token

Github Token

登录 github,打开Settings/Developer settings,选择Personal access tokens/Token(classic)

点击Generate new token/Generate new token(classic),填写 token 备注,选择有效时长,不需要勾选其他权限,创建 token,复制 token 到上一步设置的GITHUB_TOKEN变量

前端提交

前端核心就是调用 cloudflare 创建的 worker api 地址

  const response await fetch("https://comment.staryou.workers.dev", {
    method"POST",
    headers: {
      "Content-Type""application/json",
    },
    body: JSON.stringify({
      content: content,
    }),
  });

前端调用 api 成功,worker 获取前端提交 content 内容,将 issues 状态设为 pending(github 自行设定),我们可以修改 issues 状态,方便前端筛选留言

前端调用留言 api,这样就实现了留言过滤和分页显示

https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/issues?labels=approved&state=open&per_page=${ISSUES_PER_PAGE}&page=${page}

其他

worker 中仓库名配置错误,前端控制台报错

生成 github token 最好选择 classic,避免权限问题

前端实战:让表格Header优雅吸顶的魔法

作者 JS_Likers
2025年12月29日 11:17

❤ 写在前面
如果觉得对你有帮助的话,点个小❤❤ 吧,你的支持是对我最大的鼓励~
个人独立开发wx小程序,感谢支持! small.png


引言:当表格太长时,表头去哪了?

你有没有遇到过这样的尴尬场景:查看一个超长的数据表格,滚动到下面时,完全忘记了每一列代表什么意思?只能不停地上下来回滚动,就像在玩“表头捉迷藏”游戏。

今天,我要分享的正是解决这个痛点的实用技巧——表格Header吸顶效果。就像给你的表格表头装上“磁铁”,无论怎么滚动,表头都会固定在顶部!

效果展示

想象一下这样的体验:

  • 默认状态:表头在表格顶部
  • 向下滚动:表头“粘”在浏览器顶部
  • 继续滚动:表头始终可见
  • 向上滚动:表头回归原位

是不是很酷?让我们一步步实现它!

技术方案对比

方案一:CSS position: sticky(简单但有限制)

thead {
  position: sticky;
  top: 0;
  background: white;
  z-index: 10;
}

优点:一行代码搞定! 缺点:父容器不能有overflow: hidden,兼容性需要考虑

方案二:JavaScript动态计算(灵活可控)

// 监听滚动,动态切换样式
window.addEventListener('scroll', () => {
  if (table到达顶部) {
    表头添加固定定位
  } else {
    表头移除固定定位
  }
});

优点:兼容性好,控制精细 缺点:需要写更多代码

完整实现方案(JavaScript版)

第一步:HTML结构准备

<div class="container">
  <div class="page-header">
    <h1>员工信息表</h1>
    <p>共128条记录,滚动查看详情</p>
  </div>
  
  <div class="table-wrapper">
    <table id="sticky-table">
      <thead class="table-header">
        <tr>
          <th>ID</th>
          <th>姓名</th>
          <th>部门</th>
          <th>职位</th>
          <th>入职时间</th>
          <th>状态</th>
        </tr>
      </thead>
      <tbody>
        <!-- 数据行会通过JS生成 -->
      </tbody>
    </table>
  </div>
</div>

第二步:CSS样式设计

/* 基础样式 */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.page-header {
  background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
  color: white;
  padding: 30px;
  border-radius: 10px;
  margin-bottom: 30px;
  text-align: center;
}

.table-wrapper {
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}

/* 表格基础样式 */
#sticky-table {
  width: 100%;
  border-collapse: collapse;
}

/* 表头默认样式 */
.table-header {
  background-color: #2c3e50;
  color: white;
}

.table-header th {
  padding: 16px 12px;
  text-align: left;
  font-weight: 600;
  border-bottom: 2px solid #34495e;
}

/* 表头吸顶时的样式 */
.table-header.sticky {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 1000;
  background-color: #2c3e50;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  animation: slideDown 0.3s ease;
}

/* 吸顶动画 */
@keyframes slideDown {
  from {
    transform: translateY(-100%);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

/* 数据行样式 */
tbody tr {
  border-bottom: 1px solid #eee;
  transition: background-color 0.2s;
}

tbody tr:hover {
  background-color: #f9f9f9;
}

tbody td {
  padding: 14px 12px;
  color: #333;
}

.status-active {
  color: #27ae60;
  font-weight: bold;
}

.status-inactive {
  color: #e74c3c;
  font-weight: bold;
}

第三步:JavaScript实现逻辑

class StickyTableHeader {
  constructor(tableId) {
    this.table = document.getElementById(tableId);
    this.header = this.table.querySelector('thead');
    this.placeholder = null;
    this.isSticky = false;
    
    this.init();
  }
  
  init() {
    // 1. 创建占位元素(防止表头固定后表格内容跳动)
    this.createPlaceholder();
    
    // 2. 生成测试数据
    this.generateTableData();
    
    // 3. 监听滚动事件
    window.addEventListener('scroll', this.handleScroll.bind(this));
    
    // 4. 监听窗口大小变化(重新计算位置)
    window.addEventListener('resize', this.handleResize.bind(this));
  }
  
  createPlaceholder() {
    // 创建与表头相同大小的透明占位元素
    this.placeholder = document.createElement('div');
    this.placeholder.style.height = `${this.header.offsetHeight}px`;
    this.placeholder.style.display = 'none';
    this.table.parentNode.insertBefore(this.placeholder, this.table);
  }
  
  handleScroll() {
    // 获取表格相对于视口的位置
    const tableRect = this.table.getBoundingClientRect();
    const headerHeight = this.header.offsetHeight;
    
    // 判断逻辑:表格顶部是否滚动出视口
    if (tableRect.top <= 0 && !this.isSticky) {
      this.activateSticky();
    } else if (tableRect.top > 0 && this.isSticky) {
      this.deactivateSticky();
    }
    
    // 额外优化:如果表格底部已经在视口中,取消固定
    if (tableRect.bottom <= headerHeight + 100 && this.isSticky) {
      this.deactivateSticky();
    }
  }
  
  handleResize() {
    // 窗口大小变化时,更新占位元素高度
    if (this.placeholder) {
      this.placeholder.style.height = `${this.header.offsetHeight}px`;
    }
    
    // 如果当前是吸顶状态,需要重新计算宽度
    if (this.isSticky) {
      this.setHeaderWidth();
    }
  }
  
  activateSticky() {
    this.isSticky = true;
    
    // 添加吸顶类名
    this.header.classList.add('sticky');
    
    // 显示占位元素
    this.placeholder.style.display = 'block';
    
    // 设置表头宽度与表格一致
    this.setHeaderWidth();
  }
  
  deactivateSticky() {
    this.isSticky = false;
    
    // 移除吸顶类名
    this.header.classList.remove('sticky');
    
    // 隐藏占位元素
    this.placeholder.style.display = 'none';
  }
  
  setHeaderWidth() {
    // 确保固定表头的宽度与表格容器一致
    const tableWidth = this.table.offsetWidth;
    this.header.style.width = `${tableWidth}px`;
    
    // 同步每一列的宽度
    const ths = this.header.querySelectorAll('th');
    const tbodyFirstRow = this.table.querySelector('tbody tr');
    
    if (tbodyFirstRow) {
      const tds = tbodyFirstRow.querySelectorAll('td');
      
      ths.forEach((th, index) => {
        if (tds[index]) {
          th.style.width = `${tds[index].offsetWidth}px`;
        }
      });
    }
  }
  
  generateTableData() {
    // 生成模拟数据
    const departments = ['技术部', '市场部', '设计部', '人力资源', '财务部'];
    const positions = ['工程师', '经理', '设计师', '专员', '总监', '助理'];
    const statuses = ['active', 'inactive'];
    
    const tbody = this.table.querySelector('tbody');
    
    for (let i = 1; i <= 50; i++) {
      const row = document.createElement('tr');
      
      const department = departments[Math.floor(Math.random() * departments.length)];
      const position = positions[Math.floor(Math.random() * positions.length)];
      const status = statuses[Math.floor(Math.random() * statuses.length)];
      const startDate = new Date(2018 + Math.floor(Math.random() * 5), 
                                 Math.floor(Math.random() * 12), 
                                 Math.floor(Math.random() * 28) + 1);
      
      row.innerHTML = `
        <td>${1000 + i}</td>
        <td>员工${i}</td>
        <td>${department}</td>
        <td>${position}</td>
        <td>${startDate.getFullYear()}-${String(startDate.getMonth() + 1).padStart(2, '0')}-${String(startDate.getDate()).padStart(2, '0')}</td>
        <td class="status-${status}">${status === 'active' ? '在职' : '离职'}</td>
      `;
      
      tbody.appendChild(row);
    }
  }
}

// 初始化表格
document.addEventListener('DOMContentLoaded', () => {
  new StickyTableHeader('sticky-table');
  
  // 添加滚动提示
  const hint = document.createElement('div');
  hint.style.cssText = `
    position: fixed;
    bottom: 20px;
    right: 20px;
    background: #3498db;
    color: white;
    padding: 10px 15px;
    border-radius: 20px;
    font-size: 14px;
    z-index: 1001;
    box-shadow: 0 3px 10px rgba(0,0,0,0.2);
    animation: bounce 2s infinite;
  `;
  hint.innerHTML = '👇 滚动试试,表头会吸顶哦!';
  document.body.appendChild(hint);
  
  // 添加提示动画
  const style = document.createElement('style');
  style.textContent = `
    @keyframes bounce {
      0%, 100% { transform: translateY(0); }
      50% { transform: translateY(-5px); }
    }
  `;
  document.head.appendChild(style);
  
  // 5秒后隐藏提示
  setTimeout(() => {
    hint.style.opacity = '0';
    hint.style.transition = 'opacity 1s';
    setTimeout(() => hint.remove(), 1000);
  }, 5000);
});

实现原理流程图

graph TD
    A[开始] --> B[初始化表格和表头]
    B --> C[创建占位元素]
    C --> D[生成表格数据]
    D --> E[监听滚动事件]
    
    E --> F{表格顶部是否滚动出视口?}
    F -->|是| G[激活吸顶效果]
    F -->|否| H[检查是否已吸顶]
    
    H -->|是| I[取消吸顶效果]
    H -->|否| E
    
    G --> J[添加sticky类名]
    J --> K[显示占位元素]
    K --> L[设置表头宽度]
    L --> E
    
    I --> M[移除sticky类名]
    M --> N[隐藏占位元素]
    N --> E

关键技巧和注意事项

1. 占位元素的重要性

吸顶效果会让表头脱离文档流,导致下面的内容突然上跳。占位元素在表头固定时显示,保持布局稳定。

2. 性能优化

  • 使用requestAnimationFrame优化滚动事件
  • 添加防抖处理,避免频繁计算
  • 缓存DOM查询结果

优化后的滚动处理:

handleScroll() {
  // 使用requestAnimationFrame优化性能
  if (!this.ticking) {
    requestAnimationFrame(() => {
      this.updateStickyState();
      this.ticking = false;
    });
    this.ticking = true;
  }
}

3. 边界情况处理

  • 表格数据很少时,不需要吸顶
  • 窗口大小变化时,重新计算宽度
  • 表格完全滚动出视口时,取消吸顶

4. 视觉细节

  • 添加平滑过渡动画
  • 固定时添加阴影,增强层次感
  • 保持表头列宽与数据列对齐

响应式设计考虑

在移动设备上,我们可能需要调整吸顶策略:

/* 移动端调整 */
@media (max-width: 768px) {
  .table-header.sticky {
    /* 移动端可以缩小内边距 */
    padding: 8px 4px;
  }
  
  /* 表格水平滚动 */
  .table-wrapper {
    overflow-x: auto;
  }
  
  #sticky-table {
    min-width: 600px;
  }
}

总结

实现表格Header吸顶效果,就像给用户提供了一个"阅读助手",让长表格的浏览体验大大提升。通过今天分享的方法,你可以:

  1. 用少量代码实现核心功能
  2. 处理各种边界情况
  3. 优化性能确保流畅体验
  4. 适配不同设备屏幕

记住,好的用户体验往往就藏在这些细节中。下次当你遇到长表格时,不妨试试这个"吸顶魔法",让你的页面变得更加友好!

动手试试

你可以复制上面的代码到本地HTML文件,或者访问我在CodePen上创建的示例(模拟链接),直接体验和修改代码。

小挑战:尝试添加一个功能,当表头吸顶时,右侧显示一个"回到顶部"的按钮,点击后平滑滚动到表格开始位置。祝你好运!


希望这篇教程对你有所帮助!如果有任何问题或改进建议,欢迎在评论区留言讨论。Happy coding! 🚀

前端必备技能:彻底搞懂JavaScript深浅拷贝,告别数据共享的坑!

作者 JS_Likers
2025年12月29日 11:15

❤ 写在前面
如果觉得对你有帮助的话,点个小❤❤ 吧,你的支持是对我最大的鼓励~
个人独立开发wx小程序,感谢支持! small.png


开篇故事:为什么我的数据“打架”了?

想象一下这个场景:你在开发一个购物车功能,复制了一个商品对象准备修改数量,结果发现原始商品的数据也变了!这种“灵异事件”让很多前端开发者头疼不已。

let originalProduct = {
  name: "JavaScript高级程序设计",
  price: 99,
  details: {
    publisher: "人民邮电出版社",
    pages: 728
  }
};

// 你以为的“复制”
let copiedProduct = originalProduct;
copiedProduct.price = 79; // 修改副本的价格

console.log(originalProduct.price); // 79?!原对象也被修改了!

这就是我们今天要解决的“深浅拷贝”问题!下面,让我们一步步解开这个谜团。

内存模型:理解深浅拷贝的基础

在深入之前,我们先看看JavaScript中数据是如何存储的:

┌─────────────┐      ┌───────────────┐
│  栈内存     │      │   堆内存       │
│ (Stack)     │      │   (Heap)      │
├─────────────┤      ├───────────────┤
│ 基本类型    │      │               │
│ 变量名|值   │      │  引用类型     │
│ a -> 10     │      │  的对象数据   │
│ b -> true   │      │  {name: "xxx"}│
│             │      │  [1,2,3]      │
│ obj1 -> ↗═══╪══════> {x: 1, y: 2}  │
│ obj2 -> ↗═══╪══════> {name: "test"}│
└─────────────┘      └───────────────┘

基本类型(Number, String, Boolean等)直接存储在栈内存中,而引用类型(Object, Array等)在栈中只存储地址指针,真正的数据在堆内存中。

深浅拷贝对比流程图

flowchart TD
    A[原始对象] --> B{选择拷贝方式}
    B --> C[浅拷贝]
    B --> D[深拷贝]
    
    C --> E[只复制第一层属性]
    E --> F[嵌套对象仍共享内存]
    F --> G[修改嵌套属性会影响原对象]
    
    D --> H[递归复制所有层级]
    H --> I[完全独立的新对象]
    I --> J[新旧对象互不影响]
    
    G --> K[适用场景:简单数据结构]
    J --> L[适用场景:复杂嵌套对象]

浅拷贝:只挖第一层

浅拷贝就像只复制房子的钥匙,不复制房子里的家具。

常见的浅拷贝方法

方法1:展开运算符(最常用)

let shallowCopy = { ...originalObject };
let shallowCopyArray = [...originalArray];

方法2:Object.assign()

let shallowCopy = Object.assign({}, originalObject);

方法3:数组的slice()和concat()

let shallowCopyArray = originalArray.slice();
let anotherCopy = originalArray.concat();

浅拷贝的陷阱

let user = {
  name: "小明",
  settings: {
    theme: "dark",
    notifications: true
  }
};

let userCopy = { ...user };
userCopy.name = "小红"; // ✅ 不会影响原对象
userCopy.settings.theme = "light"; // ❌ 原对象的theme也被修改了!

console.log(user.settings.theme); // "light" 中招了!

深拷贝:连根拔起的复制

深拷贝是真正的"克隆",创建一个完全独立的新对象。

方法1:JSON大法(最简单但有局限)

let deepCopy = JSON.parse(JSON.stringify(originalObject));

注意限制:

  • 不能复制函数、undefined、Symbol
  • 不能处理循环引用
  • Date对象会变成字符串

方法2:手写递归深拷贝函数

function deepClone(obj, hash = new WeakMap()) {
  // 处理基本类型和null
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  // 处理Date
  if (obj instanceof Date) {
    return new Date(obj);
  }
  
  // 处理数组
  if (Array.isArray(obj)) {
    return obj.map(item => deepClone(item, hash));
  }
  
  // 处理普通对象
  if (hash.has(obj)) {
    return hash.get(obj); // 解决循环引用
  }
  
  let cloneObj = Object.create(Object.getPrototypeOf(obj));
  hash.set(obj, cloneObj);
  
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloneObj[key] = deepClone(obj[key], hash);
    }
  }
  
  return cloneObj;
}

// 测试
let complexObj = {
  name: "测试",
  date: new Date(),
  nested: { x: 1 },
  arr: [1, 2, [3, 4]]
};
complexObj.self = complexObj; // 循环引用

let cloned = deepClone(complexObj);
console.log(cloned !== complexObj); // true
console.log(cloned.nested !== complexObj.nested); // true
console.log(cloned.self === cloned); // true,循环引用正确处理

方法3:使用现成库

// lodash
import _ from 'lodash';
let deepCopy = _.cloneDeep(originalObject);

// 或者使用structuredClone(现代浏览器原生API)
if (typeof structuredClone === 'function') {
  let deepCopy = structuredClone(originalObject);
}

实战场景:什么时候用什么拷贝?

场景1:表单编辑(推荐深拷贝)

// 编辑前深拷贝原始数据
let editData = deepClone(originalData);

// 用户随意编辑...
editData.user.profile.avatar = "new_avatar.jpg";

// 点击取消,原始数据完好无损
// 点击保存,提交editData

场景2:状态管理中的状态更新(浅拷贝足够)

// Redux reducer中的状态更新
function reducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_SETTINGS':
      return {
        ...state, // 浅拷贝
        settings: {
          ...state.settings, // 嵌套对象也需要展开
          theme: action.payload
        }
      };
    // ...
  }
}

场景3:配置对象合并(浅拷贝+深度合并)

function mergeConfig(defaultConfig, userConfig) {
  return {
    ...defaultConfig,
    ...userConfig,
    // 深度合并嵌套对象
    options: {
      ...defaultConfig.options,
      ...userConfig.options
    }
  };
}

性能考虑:深浅拷贝的选择

方法 速度 内存占用 适用场景
浅拷贝 ⚡⚡⚡⚡⚡(快) 简单对象,无嵌套修改
JSON深拷贝 ⚡⚡⚡(中) 数据简单,无函数/日期
递归深拷贝 ⚡⚡(慢) 复杂对象,需要完整复制
structuredClone ⚡⚡⚡⚡(较快) 现代浏览器,需要原生支持

总结:拷贝选择速查表

  1. 只需要复制第一层数据? → 使用展开运算符 ...
  2. 需要完全独立的副本? → 使用深拷贝
  3. 数据简单,不含函数/日期?JSON.parse(JSON.stringify())
  4. 需要处理复杂类型和循环引用? → 手写递归或使用lodash
  5. 现代浏览器环境? → 尝试 structuredClone

记住这个黄金法则:当你不知道嵌套属性是否需要独立修改时,选择深拷贝更安全!

互动挑战

试试看你能看出下面代码的输出吗?

let puzzle = {
  a: 1,
  b: { inner: 2 },
  c: [3, 4]
};

let copy1 = { ...puzzle };
let copy2 = JSON.parse(JSON.stringify(puzzle));

copy1.b.inner = 999;
copy1.c.push(888);

console.log(puzzle.b.inner); // 是多少?
console.log(puzzle.c.length); // 是多少?
console.log(copy2.b.inner); // 是多少?

答案:第一个是999(浅拷贝共享嵌套对象),第二个是4(数组也被修改了),第三个是2(深拷贝完全独立)。

希望这篇博客能帮你彻底理解JavaScript中的深浅拷贝!下次遇到数据"打架"的情况,你就知道该怎么处理了。

React 跨层级组件通信:使用 `useContext` 打破“长安的荔枝”困境

2025年12月29日 11:01

在 React 开发中,组件通信是绕不开的核心话题。当应用结构逐渐复杂,父子组件之间的简单 props 传递就显得力不从心——尤其是当数据需要跨越多层组件传递时,开发者常常陷入“一路往下传”的泥潭。这种模式不仅代码冗余,还极难维护,被戏称为 “长安的荔枝” :为了把一颗荔枝从岭南送到长安,要层层接力,劳民伤财。

幸运的是,React 提供了 useContext + createContext 的组合拳,让我们能在任意深度的子组件中直接获取顶层数据,彻底告别 props drilling(属性层层透传)。

本文将通过一个完整示例,带你掌握 useContext 的使用方法、原理和最佳实践。


一、问题场景:为什么需要跨层级通信?

假设我们有如下组件树:


App
 └── Page
      └── Header
           └── UserInfo   ← 需要显示用户信息
  • 用户信息(如 name: 'Andrew')在最顶层的 App 中定义。

  • 而真正需要展示它的组件是深层嵌套的 UserInfo

  • 如果用传统 props 传递:

    • App → 传给 Page
    • Page → 传给 Header
    • Header → 传给 UserInfo

中间的 PageHeader 根本不关心用户数据,却被迫成为“传话筒”。这就是典型的 props drilling 问题。

🍒 “长安的荔枝”比喻
就像唐代为杨贵妃运送荔枝,从岭南到长安,沿途设驿站接力传递。中间每一站都不吃荔枝,只为传递而存在——效率低下,成本高昂。


二、解决方案:React Context + useContext

React 的 Context API 允许我们在组件树中创建一个全局可访问的数据容器,任何后代组件都可以直接“订阅”这个容器,无需中间组件介入。

✅ 核心三要素:

角色 API 作用
1. 创建上下文 createContext(defaultValue) 创建一个 Context 对象
2. 提供数据 <Context.Provider value={data}> 在顶层包裹组件树,注入数据
3. 消费数据 const data = useContext(Context) 在任意子组件中读取数据

三、实战:用 useContext 实现用户信息共享

步骤 1:创建 Context 容器(通常在 App.js 或单独文件)


// App.jsx
import { createContext, useContext } from 'react';
import Page from './views/Page';

// 1. 创建 Context(可导出供其他文件使用)
export const UserContext = createContext(null);

export default function App() {
  // 2. 定义要共享的数据
  const user = {
    name: 'Andrew',
    role: 'Developer'
  };

  return (
    // 3. 用 Provider 包裹整个子树,提供 value
    <UserContext.Provider value={user}>
      <Page />
    </UserContext.Provider>
  );
}

💡 createContext(null) 中的 null 是默认值,当组件未被 Provider 包裹时使用。


步骤 2:在深层子组件中消费数据


// components/UserInfo.jsx
import { useContext } from 'react';
import { UserContext } from '../App'; // 导入 Context

function UserInfo() {
  // 4. 使用 useContext 获取数据
  const user = useContext(UserContext);

  console.log(user); // { name: 'Andrew', role: 'Developer' }

  return (
    <div>
      <h3>欢迎你,{user?.name}!</h3>
      <p>角色:{user?.role}</p>
    </div>
  );
}

export default UserInfo;

✅ 注意:

  • UserInfo 不需要任何 props
  • 即使它嵌套在 Header → Page 之下,也能直接访问 user

步骤 3:中间组件完全“无感”


// components/Header.jsx
import UserInfo from './UserInfo';

function Header() {
  // Header 完全不知道 user 的存在!
  return (
    <header>
      <UserInfo /> {/* 直接使用,无需传 props */}
    </header>
  );
}

export default Header;

// views/Page.jsx
import Header from '../components/Header';

function Page() {
  // Page 也完全无感
  return (
    <main>
      <Header />
    </main>
  );
}

export default Page;

🎯 关键优势
中间组件 零耦合、零负担,只负责自己的 UI 结构。


四、useContext 的工作原理

你可以把 UserContext 想象成一个全局广播站

  • <UserContext.Provider value={user}>:开启广播,内容为 user
  • useContext(UserContext):在任意位置“收听”这个频道
  • 数据变化时,所有“听众”组件自动重新渲染(类似 state)

⚠️ 注意:Context 适合低频更新的全局状态(如用户信息、主题、语言)。高频状态(如表单输入)建议用 Zustand、Redux 或 useState 提升。


五、最佳实践与注意事项

✅ 1. 将 Context 抽离到单独文件(推荐)

避免循环依赖,提高可维护性:


// contexts/UserContext.js
import { createContext } from 'react';

export const UserContext = createContext(null);
jsx
编辑
// App.jsx
import { UserContext } from './contexts/UserContext';

✅ 2. 提供默认值或空对象

防止未包裹 Provider 时崩溃:


const user = useContext(UserContext) || {};

✅ 3. 避免滥用 Context

  • 不要为每个小状态都创建 Context
  • 合并相关状态到一个 Context(如 AuthContext 包含 user、login、logout)

✅ 4. 性能优化:拆分 Context

如果多个不相关的数据放在一起,会导致无关组件不必要的重渲染


// ❌ 不好:一个 Context 包含所有
<UserContext.Provider value={{ user, theme, lang }}>

// ✅ 好:按功能拆分
<AuthContext.Provider value={auth}>
<ThemeContext.Provider value={theme}>

六、useContext vs 其他状态管理方案

方案 适用场景 学习成本 适用规模
useState + Props 简单父子通信 小型组件
useContext 跨层级、低频全局状态 ⭐⭐ 中小型应用
Zustand / Jotai 复杂状态、高频更新 ⭐⭐ 中大型应用
Redux 超大型应用、时间旅行调试 ⭐⭐⭐ 大型团队项目

💡 对于大多数 React 应用,useContext + useReducer 已足够应对 80% 的状态管理需求


七、总结:告别“长安的荔枝”,拥抱 Context

  • 问题:props drilling 导致中间组件冗余、维护困难。
  • 方案:使用 createContext + Provider + useContext 创建全局数据通道。
  • 效果:任意深度子组件直接访问数据,中间组件零负担。
  • 原则:用于跨层级、低频更新的共享状态。

🌟 记住
Context 不是万能的,但它是解决“跨层级通信”最轻量、最 React 原生的方式。

现在,你可以自信地重构那些“传了五层 props 才到目标组件”的代码了!让数据像空气一样,在组件树中自由流动,而无需层层搬运 🍃。


动手试试吧!

Three.js:Web 最重要的 3D 渲染引擎的技术综述

作者 AlanHou
2025年12月29日 10:49

理解赋能 Web 实时 3D 图形的抽象层、渲染管线和性能特征。

现代 Web 越来越依赖丰富的视觉体验——数据可视化、模拟仿真、产品预览、生成艺术以及沉浸式 UI。虽然浏览器早已通过 Canvas 和 SVG 支持 2D 图形,但实时 3D 渲染需要一套复杂得多的 GPU 驱动操作。Three.js 已成为填补这一空白的事实标准(de facto standard)

虽然大多数介绍将 Three.js 描述为“一个 JavaScript 3D 库”,但它在架构上的角色更为基础。Three.js 是 WebGL 之上的一个结构化抽象层,旨在减少直接与 GPU 交互时的脚手架代码、复杂性和脆弱性。开发者无需手动管理着色器(shaders)、缓冲区(buffers)和渲染状态,而是使用连贯的高级构造——场景、相机、网格、材质——而库则负责高效地编排底层的 GPU 管线。

本文将从技术角度概述 Three.js 的工作原理、支持其性能的内部系统,以及为什么一旦应用程序超越简单的演示(demo)阶段,理解这些系统就变得至关重要。

I. Three.js 作为 WebGL 的抽象层

WebGL 是一个低级 API,它将可编程图形管线暴露给浏览器。在其核心,WebGL 要求开发者手动处理:

  • 着色器的编译和链接
  • 顶点和索引缓冲区的创建
  • 属性(Attribute)和统一变量(Uniform)的绑定
  • 纹理上传
  • 状态变更
  • 绘制调用(Draw call)的执行

WebGL:底层图形 API 和状态机 Three.js 通过统一的渲染架构抽象了这些职责。

架构目的:结构化的 GPU 交互

Three.js 不是 WebGL 的替代品;它是一个控制层,旨在消除冗余的复杂性,同时保留对底层 GPU 特性的访问能力。

它提供了:

  • 场景图(Scene graph)
  • 几何体(Geometry)和材质(Material)抽象
  • 中心化的渲染器(Renderer)
  • 相机系统
  • 对光照、阴影、动画和加载器的内置支持

这在不降低能力的情况下减少了认知负荷。

Three.js 不是 WebGL 的替代品;它是一个控制层

II. 场景图:核心数据结构

Three.js 将 3D 世界组织成一个分层的场景图(Scene Graph) ,其中每个对象都表示为一个具有变换(transform)和可选子节点的节点。

Scene (场景)
 ├── Mesh (网格)
 │     ├── Geometry (几何体)
 │     └── Material (材质)
 ├── Group (组)
 ├── Camera (相机)
 └── Lights (光源)

场景图在技术上的重要性

每个节点都携带:

  • 局部变换矩阵(Local transformation matrix)
  • 世界变换矩阵(World transformation matrix)
  • 位置、旋转、缩放
  • 父子关系

在渲染期间,Three.js 遍历场景图以计算:

  • 更新后的世界矩阵
  • 基于视锥体剔除(Frustum culling)的可见性
  • 材质 + 几何体的组合
  • 渲染顺序和绘制调用

这种层级结构使得复杂的动画、实例化(instancing)和空间组织变得可预测且高效。如果没有场景图,开发者将需要手动同步数以百计或千计的独立 GPU 绑定对象。

III. 几何体、缓冲区和类型化数组

在 GPU 层面,所有 3D 网格最终只是结构化的数字数组。Three.js 通过 BufferGeometry 暴露了这一点,它直接反映了 GPU 如何使用顶点数据。

一个 BufferGeometry 包含:

  • position 属性:每个顶点的 3D 坐标
  • normal 属性:用于光照计算
  • uv 属性:用于纹理映射
  • 可选的 index 缓冲区 — 定义哪些顶点构成三角形

每个属性都由类型化数组(Typed Array)支持,如 Float32ArrayUint16Array

为什么类型化数组是必要的

类型化数组提供:

  • 连续的内存布局
  • 可预测的性能
  • 到 GPU 缓冲区的直接二进制传输
  • 每帧更新时的最小开销

JavaScript 对象无法匹配这种级别的可预测性或效率。通过将几何体结构化为连续的缓冲区,Three.js 最小化了 CPU-GPU 的同步开销,即使在包含数万个顶点的场景中也能确保稳定的性能。

为什么类型化数组是必要的

IV. 材质和着色器程序的生成

Three.js 提供了多种材质类型——MeshBasicMaterialMeshStandardMaterialMeshPhysicalMaterialShaderMaterial 等。无论抽象级别如何,所有材质最终都会编译成在 GPU 上执行的 GLSL 着色器程序。

内部着色器系统

Three.js 根据以下内容动态生成着色器:

  • 光照配置
  • 阴影设置
  • 雾化参数
  • 纹理使用情况
  • 材质类型和参数
  • 精度和性能指令

这种动态编译允许材质保持灵活性,同时确保着色器程序针对特定的场景配置进行优化。

为什么理解着色器仍然重要

虽然 Three.js 抽象了着色器的创建,但开发者通常需要理解:

  • 法线映射(Normal mapping)
  • 粗糙度/金属度工作流(Roughness/metalness workflows)
  • BRDF 计算
  • 片元操作(Fragment operations)
  • 渲染目标(Render target)行为

自定义材质或高级效果几乎总是需要手动编写着色器,这使得 GLSL 读写能力成为严肃的 Three.js 开发的一项宝贵技能。

V. 渲染循环和帧生命周期

Three.js 运行在一个可预测的渲染循环上,通常由 requestAnimationFrame 驱动。

每一帧涉及:

  1. 处理动画更新
  2. 更新相机矩阵
  3. 遍历场景图
  4. 运行视锥体剔除
  5. 准备材质 + 着色器程序
  6. 准备几何体缓冲区
  7. 执行 WebGL 绘制调用
  8. 呈现帧

整个过程必须在约 16 毫秒内完成,以维持 60 FPS。

关于渲染成本的技术观察

  • 每个网格至少触发一次绘制调用
  • 材质切换会产生 GPU 状态变更
  • 阴影需要额外的渲染通道(Render passes)
  • 动态对象比静态对象更昂贵

渲染循环的效率直接决定了应用程序的性能。

VI. 性能架构:Three.js 中真正关键的因素

Three.js 的性能瓶颈通常不在于 JavaScript 的执行,而在于 GPU 限制、显存带宽和绘制调用的开销。

以下是影响实际性能的领域:

1. 最小化绘制调用(Draw Call)

GPU 执行少量的大型绘制调用比执行许多小型绘制调用更高效。每次绘制调用都需要状态绑定、程序切换和缓冲区设置。

优化措施包括:

  • 几何体合并(Geometry merging)
  • 实例化网格(InstancedMesh
  • 减少材质变体
  • 策略性地使用图层(Layers)和分组

2. 纹理和内存策略

高分辨率纹理会增加:

  • VRAM(显存)使用
  • 上传成本
  • Mipmap 生成时间

WebGPU 将改善某些方面,但在 WebGL 上,使用压缩纹理格式(如 Basis/KTX2)可提供显著的性能提升。

3. CPU-GPU 同步约束

在渲染循环内分配对象或每帧修改几何体属性会导致垃圾回收(GC)压力和缓冲区重新上传。

性能准则包括:

  • 避免在循环内重新创建向量或矩阵
  • 除非必要,避免修改缓冲区几何体
  • 优先使用基于着色器的变换

4. 材质复杂性

基于物理的渲染(PBR)材质(如 MeshStandardMaterial)计算量大,原因在于:

  • 环境采样
  • 多光源计算
  • 基于 BRDF 的着色

选择最简单的适用材质通常能立即带来 FPS 的提升。

VII. Three.js 与 TypeScript:强大的架构组合

Three.js 提供了健壮的 TypeScript 定义,强制执行:

  • 属性类型安全
  • 几何体一致性
  • 材质参数正确性
  • 相机和渲染器配置的有效性

在大型应用程序——可视化仪表盘、模拟仿真或产品配置器——中,Three.js 与类型化场景定义的结合显著减少了运行时缺陷。

VIII. 技术学习路径:从高级 API 到 GPU 理解

Three.js 让人可以在几分钟内构建出功能性的 3D 场景。然而,一旦项目对性能、保真度或自定义视觉效果提出要求,深入的理解就变得不可或缺。

关键领域包括:

  • GLSL 着色器开发
  • WebGL 管线状态机行为
  • GPU 内存限制
  • 纹理流式传输(Streaming)和 Mipmapping
  • 实例化(Instancing)和批处理(Batching)
  • 渲染目标管理
  • 后处理链(Post-processing chains)

Three.js 提供了脚手架,但高性能的 3D 开发要求对库本身以及底层图形学原理都能熟练掌握。

技术学习路径:从高级 API 到 GPU 理解

结论

Three.js 远不止是一个 WebGL 的便捷包装器。它是一个精心设计的渲染层,旨在使 GPU 编程变得易于上手,同时在需要时保留低级控制权。它在几何体、着色器、渲染循环和性能优化方面的结构化方法,为 JavaScript 和实时 3D 图形之间搭建了一座高效的桥梁。

随着 3D 界面、模拟仿真、数字孪生和 AR/VR 驱动的应用程序变得越来越普遍,从技术层面理解 Three.js 将成为一种有意义的工程优势。那些既理解抽象层又理解其背后 GPU 层面含义的开发者,将能够设计出不仅视觉震撼,而且稳健、高性能且可扩展的系统。

翻译整理自:Three.js: A Technical Overview of the Web’s Most Important 3D Rendering Engine

通用管理后台组件库-3-vue-i18n国际化集成

作者 没想好d
2025年12月29日 10:48

i18n国际化集成

说明:使用vue-i18n库实现系统国际化,结合@intlify/unplugin-vue-i18n插件实现预加载(开发中国际化显示),同时集成elementplus中en和zh-cn文件的组件国际化。

1.国际化文件

/locales/en.json

{
  "anything": "anything",
  "hello": "Hello"
}

/locales/zh-CN.json

{
  "hello": "你好"
}

2.插件i18n ally, 使用命令添加文本国际化翻译

.vscode/setting.json

{
  "i18n-ally.localesPaths": ["locales"],
  "i18n-ally.keystyle": "nested",
  "i18n-ally.sortKeys": true,
  "i18n-ally.namespace": true,
  "i18n-ally.enabledParsers": ["yaml", "js", "json"],
  "i18n-ally.sourceLanguage": "en",
  "i18n-ally.displayLanguage": "zh-CN",
  "i18n-ally.enabledFrameworks": ["vue"],
  "i18n-ally.translate.engines": [
    "baidu",
    "google"
  ]
}

3.国际化i18n.ts

import type { App } from 'vue'
import { createI18n, type Locale } from 'vue-i18n'

// Legacy模式(选项式API):语言设置是直接赋值,vue2的赋值方式
// Composition模式(组合式API):语言设置是赋值给响应式变量

// 创建一个i18n实例
const i18n = createI18n({
  legacy: false,
  locale: '',
  messages: {}
})

// 解析locales目录下的所有语言文件,转换为对象,例如:{ en: () => import('../../locales/en.js'), zh-CN: () => import('../../locales/zh-CN.js') }
const localesMap = Object.fromEntries(
  Object.entries(import.meta.glob('../../locales/*.json')).map(([path, loadLocale]) => [
    path.match(/([\w-]*)\.json$/)?.[1],
    loadLocale
  ])
) as Record<Locale, () => Promise<{ default: Record<string, string> }>>

// 集成elementplus中的国际化文件en和zh-CN
const elementPlusLocaleMap = Object.fromEntries(
  Object.entries(import.meta.glob('../../node_modules/element-plus/dist/locale/*.mjs')).map(
    ([path, loadLocale]) => [path.match(/([\w-]*)\.mjs$/)?.[1], loadLocale]
  )
) as Record<Locale, () => Promise<{ default: Record<string, string> }>>

// 获取存在的语言数组
export const availableLocales = Object.keys(localesMap)

// 过滤elementplus中的国际化文件,只保留en和zh-CN
const filterEPLocaleMap = availableLocales.reduce(
  (acc: Record<Locale, () => Promise<{ default: Record<string, string> }>>, locale: Locale) => {
    return {
      ...acc,
      //locale.toLowerCase()将zh-CN转换为小写,elementplus中的语言文件都是小写的
      [locale]: elementPlusLocaleMap[locale.toLowerCase()]
    }
  },
  {}
)
// 记住用户选择的语言
const loadedLanguages: string[] = []

// 设置国际化(i18n)语言的函数
export function setI18nLanguage(locale: string) {
  // Composition模式赋值
  i18n.global.locale.value = locale
  if (typeof document !== 'undefined') {
    document.querySelector('html')?.setAttribute('lang', locale)
  }
}

// 加载国际化(i18n)语言包的异步函数
export async function loadLocaleMessages(lang: string) {
  // 如果语言包i18n已经加载过,则直接设置i18n.locale
  if (i18n.global.locale.value === lang || loadedLanguages.includes(lang)) {
    return setI18nLanguage(lang)
  }
  // 通过语言代码从预定义的语言映射中获取对应的语言包加载函数并执行
  const messages = await localesMap[lang]()
  // 获取elementplus的语言包
  const messagesEP = await filterEPLocaleMap[lang]()
  // 将加载的语言包设置到i18n实例中
  i18n.global.setLocaleMessage(lang, { ...messagesEP.default, ...messages.default })
  loadedLanguages.push(lang)
  return setI18nLanguage(lang)
}

// 挂载到app上
export default {
  install(app: App) {
    app.use(i18n)
    // 设置默认语言为中文
    loadLocaleMessages('zh-CN')
  }
}

main.ts中挂载到app上

import i18n from './modules/i18n'
app.use(i18n)

4.构建和打包优化,因elementplus包中不止en和zh-cn文件,还有其他语言文件,所有在构建时过滤出en和zh-cn文件打包到dist中。

vite.config.ts

import { fileURLToPath, URL } from 'node:url'
import path from 'node:path'
import fs from 'node:fs'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import UnoCSS from 'unocss/vite'

import VueRouter from 'unplugin-vue-router/vite'
import { VueRouterAutoImports } from 'unplugin-vue-router'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import Layouts from 'vite-plugin-vue-layouts'
import { VitePWA } from 'vite-plugin-pwa'
import { viteMockServe } from 'vite-plugin-mock'
// import dotenv from 'dotenv'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// Load environment variables
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'

// https://vite.dev/config/
export default defineConfig(({ mode }) => {
  // 加载环境变量
  const env = loadEnv(mode, process.cwd()) // 加载 `.env.[mode]`

  const enablePWADEBUG = env.VITE_PWA_DEBUG === 'true'
  const enableMock = env.VITE_MOCK_ENABLE === 'true'

  /**
   * elementplus国际化文件打包和构建优化,只保留zh-cn和en文件到dist中。
   * 过滤elementplus的.mjs文件,不打包不需要的locales
   * 判断,/locales中对应的文件名的.mjs文件作为过滤条件->保留
   */
  function filterElementPlusLocales(id: string) {
    // 返回true表示打包,false表示不打包
    // locales文件查找
    const localesDir = path.resolve(__dirname, 'locales')
    const localesFiles = fs
      .readdirSync(localesDir)
      .map((file) => file.match(/([\w-]*)\.json$/)?.[1] || '')
    if (id.includes('element-plus/dist/locale')) {
      // 获取id的basename
      const basename = path.basename(id, '.mjs')
      // 判断basename是否在localesFiles中
      return !localesFiles.some((file) => basename === file.toLowerCase())
    }
    return false
  }

  return {
    build: {
      rollupOptions: {
        // id:文件名,external:是否打包
        external: (id) => filterElementPlusLocales(id)
      }
    },
    plugins: [
      VueRouter(),
      vue(),
      vueJsx(),
      vueDevTools(),
      UnoCSS(),
      AutoImport({
        include: [
          /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
          /\.vue$/,
          /\.vue\?vue/, // .vue
          /\.md$/ // .md
        ],

        // global imports to register
        imports: [
          // presets
          'vue',
          // 'vue-router'
          VueRouterAutoImports,
          '@vueuse/core'
        ],
        resolvers: [ElementPlusResolver()]
      }),
      Components({
        directoryAsNamespace: false,
        collapseSamePrefixes: true,
        resolvers: [ElementPlusResolver()]
      }),
      Layouts({
        layoutsDirs: 'src/layouts',
        defaultLayout: 'default'
      }),
      VitePWA({
        injectRegister: 'auto',
        manifest: {
          name: 'Vite App',
          short_name: 'Vite App',
          theme_color: '#ffffff',
          icons: [
            {
              src: '/192x192.png',
              sizes: '192x192',
              type: 'image/png'
            },
            {
              src: '/512x512.png',
              sizes: '512x512',
              type: 'image/png'
            }
          ]
        },
        registerType: 'autoUpdate',
        workbox: {
          navigateFallback: '/',
          // 如果大家有很大的资源文件,wasm bundle.js
          globPatterns: ['**/*.*']
        },
        devOptions: {
          enabled: enablePWADEBUG,
          suppressWarnings: true,
          navigateFallbackAllowlist: [/^\/$/],
          type: 'module'
        }
      }),
      viteMockServe({
        mockPath: 'mock',
        enable: enableMock
      }),
      createSvgIconsPlugin({
        // 指定需要缓存的图标文件夹
        iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
        // 指定symbolId格式
        symbolId: 'icon-[dir]-[name]'
      }),
      VueI18nPlugin({
        include: [path.resolve(__dirname, './locales/**')],
        // 组合式赋值方式,契合Vue3.0
        compositionOnly: true
      })
    ],
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url))
      }
    }
  }
})

5.使用和效果

<div>自定义国际化:{{ $t('hello') }}</div>
<div>elementplus国际化:{{ $t('el.colorpicker.confirm') }}</div>
<div>{{ $t('anything') }}</div>
<el-select v-model="locale" placeholder="请选择" @change="changeLocale"
  <el-option label="中文" value="zh-CN" />
  <el-option label="英文" value="en" />
</el-select>

image.png

image.png

用 React Context 实现全局主题切换:从零搭建暗黑/亮色模式系统

作者 Yira
2025年12月29日 10:21

用 React Context 实现全局主题切换:从零搭建暗黑/亮色模式系统

在现代 Web 应用中,主题切换(如白天/夜间模式)已成为提升用户体验的标配功能。用户希望界面能随环境光线自动适应,或按个人偏好自由切换。然而,如何在 React 应用中高效、优雅地实现这一功能?答案就是:React Context + 自定义 Provider 封装

本文将带你从零开始,手把手构建一个完整的主题管理系统,涵盖状态共享、UI 响应、持久化存储等核心环节,并深入解析其背后的设计思想与最佳实践。


一、为什么需要 Context?告别“Props Drilling”之痛

假设我们想在应用顶部放一个“切换主题”按钮,而底部某个卡片组件需要根据主题改变背景色。若使用传统 props 传递:

<App theme={theme} toggleTheme={toggleTheme}><Header theme={theme} toggleTheme={toggleTheme}><Content><Card theme={theme} />

每一层组件都必须接收并透传 themetoggleTheme,即使它们自身并不使用。这种 “属性层层透传” (Props Drilling)不仅代码冗余,还导致组件耦合度高、难以维护。

React Context 正是为解决此类跨层级状态共享问题而生。它提供了一种机制:

父组件创建一个“数据广播站”,所有后代组件都能直接“收听”,无需中间人传话。


二、核心架构:三大组件协同工作

我们的主题系统由三个关键部分组成:

1. ThemeContext:数据通道

// contexts/ThemeContext.js
import { createContext } from 'react';
export const ThemeContext = createContext(null);
  • 使用 createContext(null) 创建一个全局可访问的上下文对象;
  • null 是默认值,当组件未被 Provider 包裹时返回。

2. ThemeProvider:状态管理 + 数据广播

// contexts/ThemeContext.js (续)
import { useState, useEffect } from 'react';

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  };

  // 关键:同步主题到 HTML 根元素
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
  • 状态管理:用 useState 维护当前主题('light' 或 'dark');
  • 操作封装toggleTheme 函数封装切换逻辑;
  • DOM 同步:通过 useEffect 将主题写入 <html data-theme="dark">,便于 CSS 选择器响应。

3. Header:消费主题状态

// components/Header.js
import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';

export default function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  
  return (
    <div style={{ marginBottom: 24 }}>
      <h2>当前主题: {theme}</h2>
      <button onClick={toggleTheme}>切换主题</button>
    </div>
  );
}
  • 使用 useContext(ThemeContext) 直接获取主题状态和切换函数;
  • 完全解耦:无需父组件传递 props,无论嵌套多深都能访问。

三、应用组装:自上而下的数据流

根组件 App:启动主题服务

// App.js
import ThemeProvider from './contexts/ThemeContext';
import Page from './Pages/Page';

export default function App() {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  );
}
  • 用 <ThemeProvider> 包裹整个应用,确保所有子组件处于主题上下文中。

页面组件 Page:透明中转

// Pages/Page.js
import Header from '../components/Header';

export default function Page() {
  return (
    <div style={{ padding: 24 }}>
      Page
      <Header />
    </div>
  );
}
  • Page 无需知道主题存在,直接渲染 Header,实现零耦合

四、CSS 如何响应主题变化?

虽然你的示例未使用 Tailwind,但原理相通。关键在于 利用 data-theme 属性编写条件样式

/* 全局样式 */
body {
  background-color: white;
  color: black;
}

/* 暗色模式覆盖 */
html[data-theme='dark'] body {
  background-color: #1a1a1a;
  color: #e0e0e0;
}

/* 组件级样式 */
.card {
  background: #f5f5f5;
}

html[data-theme='dark'] .card {
  background: #2d2d2d;
}

✅ 优势:

  • 不依赖 JavaScript 动态设置 class;
  • 样式集中管理,易于维护;
  • 支持服务端渲染(SSR)。

若使用 Tailwind CSS,只需配置 darkMode: 'class',然后写:

<div class="bg-white dark:bg-gray-900 text-black dark:text-white">

并通过 JS 切换 <html class="dark"> 即可。


五、进阶优化:持久化用户偏好

当前实现刷新后会重置为 'light'。要记住用户选择,只需两步:

1. 初始化时读取 localStorage

const [theme, setTheme] = useState(() => {
  if (typeof window !== 'undefined') {
    return localStorage.getItem('theme') || 'light';
  }
  return 'light';
});

2. 切换时保存到 localStorage

const toggleTheme = () => {
  const newTheme = theme === 'light' ? 'dark' : 'light';
  setTheme(newTheme);
  localStorage.setItem('theme', newTheme); // 👈 保存
};

💡 注意:需判断 window 是否存在,避免 SSR 报错。


六、设计思想:为什么这样封装?

1. 单一职责原则

  • ThemeContext:只负责创建通道;
  • ThemeProvider:只负责状态管理与广播;
  • Header:只负责 UI 展示与交互。

2. 高内聚低耦合

  • 中间组件(如 Page)完全 unaware 主题存在;
  • 新增组件只需调用 useContext,无需修改父组件。

3. 可复用性

  • ThemeProvider 可直接复制到新项目;
  • 配合自定义 Hook(如 useTheme())进一步简化调用。

七、常见陷阱与解决方案

问题 原因 解决方案
useContext 返回 null 组件未被 Provider 包裹 确保根组件正确包裹
切换无效 CSS 未响应 data-theme 检查选择器优先级
SSR 不一致 客户端/服务端初始状态不同 在 useEffect 中初始化状态
性能问题 高频更新导致重渲染 拆分 Context,避免大对象

八、总结:Context 是 React 的“神经系统”

通过这个主题切换案例,我们看到:

  • Context 不是“传数据”,而是“建通道”
  • Provider 是数据源,useContext 是接收器
  • 中间组件完全透明,实现极致解耦

这种模式不仅适用于主题,还可用于:

  • 用户登录状态
  • 国际化语言
  • 购物车数据
  • 应用配置

掌握 Context,你就掌握了 React 全局状态管理的第一把钥匙

未来,你可以在此基础上集成 useReducer 管理复杂状态,或结合 Zustand/Jotai 等轻量库进一步简化。但无论如何,理解 Context 的底层机制,永远是进阶之路的基石

现在,打开你的编辑器,亲手实现一个主题切换吧——让用户在白天与黑夜之间,自由穿梭! 🌓☀️

【高斯泼溅】3DGS城市模型从“硬盘杀手”到“轻盈舞者”?看我们如何实现14倍压缩

作者 Mapmost
2025年12月29日 10:13

如何把一座城市渲染出来?

三年前,NeRF给出的答案是**“隐式网络+无尽采样”**,渲染的算力黑洞让人望而却步;如今,3D Gaussian Splatting(3DGS)用“显式高斯椭球”消除了渲染阶段对网络的依赖,却悄悄把问题翻了个面——模型体量大成了新瓶颈。

3DGS模型与椭球分布

当场景从“桌面摆件”扩展到“十字街头”再到“万亩城区”,数据像吹气球一样膨胀:数千万椭球、上百GB模型文件,训练时卡爆GPU,加载时撑爆显卡,传输时堵爆带宽,保存时挤占硬盘

“怎样在保真的同时变得更轻”,本文将主要分析3DGS模型潜在的冗余椭球问题及探讨相应的解决方法。

冗余椭球与训练缓慢

当我们训练一个3DGS模型时,总会有一个疑问:这个场景到底需要多少椭球,才能既保证渲染质量又保证训练速度?

01椭球数量的增长

3DGS原始实现并没有设置一个固定的数量上限,而是靠着人为设置的阈值在指定训练次数后停止增长椭球。在典型的小区域环绕场景中椭球可以增长到500~600万左右。这个数量一般远超想要表达的主要兴趣区域所需。

图中在仅使用234万椭球的情况下即可达到570万的渲染指标。

原生算法和简化效果对比

02训练速度的降低

随着椭球数量的增长,随之而来的是显卡显存的占用和计算压力的提升,带来的直接影响便是训练时长的显著增加。3DGS原始实现训练常用的小场景数据集时间平均在20~40分钟之间,而且我们可以显著的观察到训练速度是随着点数增长而相应降低的。

如何用更少的椭球数量实现同等的渲染质量是一个必须研究的方向。识别冗余椭球并删除,是一个显而易见的可行方案。

主流删除方案:直接法与剪枝法

直接法剪枝法目的都是删除冗余点,但他们之间最主要的区别是:删除依据是可学习的还是人工设计的。

01直接法

直接法一般不参与训练中的梯度计算,仅在训练中/后直接计算每个椭球的重要性分数,逐步/一次删除至设定的点数。

这方面的代表有LightGaussianTamingGSMini-splatting等论文。重要性分数的计算共有步骤有:

  1. 输入训练视角的所有图片和位姿
  2. 遍历图片,每张图片计算与其有关椭球的重要性分数(不透明度、命中像素次数等)
  3. 累加所有视角,计算出最终每个椭球的重要性分数
  4. 根据需要,删除低分数椭球

LightGaussian重要性计算

02剪枝法

剪枝法认为直接限制椭球数量上限难以满足渲染质量的要求,因此一般是在训练过程中识别并逐渐删除对渲染质量贡献较小的冗余高斯。

剪枝法的核心思想是为每个椭球增加一个可训练的参数,用于表示其对渲染的贡献程度,从而可以依据该参数直接进行删除。该方法的相关工作有CompactGS、MaskGS、GaussianSpa等。

MaskGS流程图,M为可学习参数

与直接法重要性得分计算类似,剪枝法需要构建一个可以微分的重要性得分。在上述工作中,主要与椭球的不透明度、形状、透射率等参数相关联,依托椭球本身的属性来构建重要性。

比如CompactGS方法思路较为直接,直接构建mask掩膜,为每个椭球分配一个二值变量(0或1),其中1表示渲染,0表示删除。虽然二值操作本身不可导,不能被优化,但是可以通过构造可导的间接变量,使得梯度可以传导至掩码。

CompactGS掩码公式

从下表可以看出:

  • 原始3DGS中确实存在较多冗余椭球
  • 两类方法均可以在较大的压缩比下实现接近甚至超出的质量
  • 删除椭球后训练时间有不同程度降低(GaussianSPA训练轮数更多)

建模质量与数量对比

两种方法的适用情况也不尽相同,直接法的重要性分数一般可以在训练后算出剪枝法一般需要伴随一定量的训练步骤。如果是已经训练完的原始模型,最好使用直接法删除椭球,以降低可能的额外训练时间。

Mapmost 高斯泼溅建模平台

结合学术界压缩方法,我们在Mapmost高斯泼溅建模平台综合并改进了一套模型轻量化算法,可以在保证细节的情况下,最高达到14倍的压缩比。

图中14倍压缩率情况下,模型体积从170MB降低至16MB,文字细节和屋顶轮廓仍然保持较高还原度。这样不仅减小了存储占用,同时也有利于建模和渲染性能的提升。

Mapmost高斯泼溅建模平台正式开放体验!让专业级城市三维建模,从此没有门槛。上传航拍图,即可获得一个高精度、轻量化、即拿即用的3DGS模型。

登录Mapmost 3DGS Builder体验版(studio.mapmost.com/3dgs),立即开始建模!

申请试用,请至Mapmost官网联系客服

手把手实现 Gin + Socket.IO 实时聊天功能

2025年12月29日 10:10

手把手实现 Gin + Socket.IO 实时聊天功能

在 Web 开发中,实时通信场景(如在线聊天、实时通知、协同编辑等)十分常见,而 Socket.IO 作为一款成熟的实时通信库,支持 WebSocket 协议并提供轮询降级方案,能很好地兼容各类浏览器和场景。本文将手把手教你使用 Go 语言的 Gin 框架整合 Socket.IO,搭建一套完整的前后端实时聊天系统,包含房间广播、跨域处理、静态资源托管等核心功能。

一、项目准备

1. 技术栈说明

  • 后端:Go 1.18+、Gin 框架(轻量高性能 HTTP 框架)、googollee/go-socket.io(Socket.IO Go 服务端实现)
  • 前端:原生 JavaScript、Socket.IO 客户端(兼容服务端版本)
  • 运行环境:Windows/Linux/Mac(本文以 Windows 为例,跨平台无差异)

2. 项目目录结构

先搭建规范的项目目录,便于后续开发和维护:

plaintext

chat-demo/
├── go.mod       // Go 模块依赖配置
├── main.go      // 后端核心代码
└── static/      // 前端静态资源目录
    ├── index.html       // 前端聊天页面
    ├── jquery-3.6.0.min.js  // jQuery(可选,本文未实际依赖)
    ├── socket.io-1.2.0.js   // Socket.IO 客户端
    └── favicon.ico      // 网站图标(可选)

3. 初始化 Go 模块

打开终端,进入项目目录,执行以下命令初始化 Go 模块:

bash

运行

go mod init chat-demo

然后安装所需依赖:

bash

运行

# 安装 Gin 框架
go get github.com/gin-gonic/gin
# 安装 Socket.IO Go 服务端
go get github.com/googollee/go-socket.io

二、后端实现:Gin + Socket.IO 服务搭建

后端核心功能包括:Gin 引擎配置、跨域处理、静态资源托管、Socket.IO 服务初始化、房间管理与消息广播。

1. 完整后端代码(main.go)

go

运行

package main

import (
"github.com/gin-gonic/gin"
socketio "github.com/googollee/go-socket.io"
"github.com/googollee/go-socket.io/engineio"
"github.com/googollee/go-socket.io/engineio/transport"
"github.com/googollee/go-socket.io/engineio/transport/polling"
"github.com/googollee/go-socket.io/engineio/transport/websocket"
"log"
"net/http"
)

func main() {
// 1. Gin 引擎优化:生产环境启用 Release 模式,关闭调试日志
gin.SetMode(gin.ReleaseMode)
router := gin.Default()

// 2. 跨域中间件配置:解决前后端跨域通信问题
router.Use(func(c *gin.Context) {
// 允许所有来源跨域(生产环境可指定具体域名,更安全)
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
// 允许的 HTTP 请求方法
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
// 允许的请求头
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// 处理 OPTIONS 预检请求
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
})

// 3. 静态资源托管:映射 static 目录,提供前端页面和静态文件
router.Static("/static", "./static")

// 4. Socket.IO 服务器配置:支持 polling(轮询)和 websocket(优先推荐)
sio := socketio.NewServer(&engineio.Options{
Transports: []transport.Transport{
polling.Default,
websocket.Default,
},
})

// 5. Socket.IO 事件监听:处理连接、消息、加入房间、断开连接等事件
// 5.1 客户端连接事件
sio.OnConnect("/", func(s socketio.Conn) error {
log.Println("客户端已连接:", s.ID())
return nil
})

// 5.2 接收客户端发送的消息事件,并广播到 chat 房间
sio.OnEvent("/", "message", func(s socketio.Conn, msg string) {
log.Println("收到消息:", msg, "(来自:", s.ID(), ")")
// 广播消息到 / 命名空间下的 chat 房间
sio.BroadcastToRoom("/", "chat", "message", msg)
})

// 5.3 客户端加入房间事件
sio.OnEvent("/", "join", func(s socketio.Conn, room string) {
// 让当前客户端加入指定房间
s.Join(room)
log.Println("客户端", s.ID(), "已加入房间:", room)
})

// 5.4 客户端断开连接事件
sio.OnDisconnect("/", func(s socketio.Conn, reason string) {
log.Println("客户端", s.ID(), "已断开连接;原因:", reason)
})

// 5.5 错误处理事件
sio.OnError("/", func(s socketio.Conn, e error) {
log.Println("客户端", s.ID(), "发生错误:", e)
})

// 6. 注册 Socket.IO 路由:将 Socket.IO 请求委托给 Gin 处理
router.GET("/socket.io/*any", gin.WrapH(sio))
router.POST("/socket.io/*any", gin.WrapH(sio))

// 7. 根路径路由:访问 http://127.0.0.1:8080/ 直接返回前端聊天页面
router.GET("/", func(c *gin.Context) {
c.File("./static/index.html")
})

// 8. 启动 Socket.IO 服务器(异步启动,不阻塞 Gin 启动)
go sio.Serve()
defer sio.Close() // 程序退出时关闭 Socket.IO 服务

// 9. 启动 Gin 服务器,监听 8080 端口
if err := router.Run(":8080"); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}

2. 后端核心功能说明

  • Gin 优化:启用 gin.ReleaseMode 关闭调试日志,提升服务性能,适合生产环境部署。

  • 跨域处理:通过自定义中间件设置 CORS 响应头,处理 OPTIONS 预检请求,解决前后端跨域通信障碍。

  • 静态资源托管:通过 router.Static 将 ./static 目录映射到 /static 路由,前端可通过该路径访问 JS、图片等静态资源。

  • Socket.IO 配置:同时支持 polling 和 websocket 传输方式,websocket 为高性能全双工通信,polling 作为降级方案兼容低版本浏览器。

  • 事件处理

    • OnConnect:监听客户端连接,打印客户端唯一 ID;
    • OnEvent("message"):接收客户端消息,并通过 BroadcastToRoom 广播到 chat 房间;
    • OnEvent("join"):处理客户端加入房间请求,通过 s.Join(room) 让客户端加入指定房间;
    • OnDisconnect/OnError:监听客户端断开连接和错误事件,便于问题排查和日志监控。
  • 路由配置:根路径 / 直接返回前端 index.html,无需手动拼接静态资源路径,使用更便捷;Socket.IO 路由注册后,可处理前端的 Socket.IO 连接请求。

三、前端实现:Socket.IO 客户端与页面交互

前端核心功能包括:页面布局搭建、Socket.IO 客户端连接、加入房间、消息发送与接收、页面渲染。

1. 完整前端代码(static/index.html)

html

预览

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

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Socket.IO 实时聊天示例</title>
    <!-- 引入 jQuery(本文未实际使用,可按需移除) -->
    <script src="/static/jquery-3.6.0.min.js"></script>
    <!-- 引入 Socket.IO 客户端库(需与服务端协议兼容) -->
    <script src="/static/socket.io-1.2.0.js"></script>
    <!-- 网站图标(可选) -->
    <link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>

<body>
    <!-- 聊天界面布局:输入框、发送按钮、消息展示区域 -->
    <input type="text" id="message-input" placeholder="输入消息">
    <button id="send-button">发送</button>
    <div id="messages"></div>

    <script>
        // 1. 连接 Socket.IO 服务端
        var socket = io('http://127.0.0.1:8080/', {
            transports: ['websocket', 'polling'], // 优先使用 websocket,降级为 polling
            timeout: 5000 // 连接超时时间:5 秒
        });

        // 2. 监听连接成功事件,连接后立即加入 chat 房间
        socket.on('connect', () => {
            // 发送 join 事件,加入 chat 房间
            socket.emit('join', 'chat');
            console.log('已连接到服务器');
        });

        // 3. 监听服务端广播的 message 事件,渲染消息到页面
        socket.on('message', function (msg) {
            const messagesDiv = document.getElementById('messages');
            const newMessage = document.createElement('p');
            newMessage.textContent = msg;
            messagesDiv.appendChild(newMessage);
        });

        // 4. 绑定发送按钮点击事件,发送消息到服务端
        const sendButton = document.getElementById('send-button');
        const messageInput = document.getElementById('message-input');
        sendButton.addEventListener('click', function () {
            const message = messageInput.value;
            if (message) {
                // 发送 message 事件,携带输入的消息内容
                socket.emit('message', message);
                // 清空输入框
                messageInput.value = '';
            }
        });
    </script>
</body>

</html>

2. 前端核心功能说明

  • Socket.IO 连接:通过 io() 方法连接服务端地址 http://127.0.0.1:8080/,配置传输方式优先级和连接超时时间。
  • 连接成功处理:监听 connect 事件,连接成功后立即发送 join 事件,加入服务端的 chat 房间,确保能接收房间内的广播消息。
  • 消息接收与渲染:监听服务端的 message 事件,收到消息后创建 <p> 标签,将消息内容插入到页面的消息展示区域。
  • 消息发送:绑定按钮点击事件,获取输入框内容,通过 socket.emit('message', message) 发送到服务端,发送后清空输入框,提升交互体验。

四、项目运行与测试

1. 启动服务

  1. 将前端文件(index.htmlsocket.io-1.2.0.js 等)放入 static 目录;

  2. 在项目目录终端执行以下命令启动后端服务:

    bash

    运行

    go run main.go
    
  3. 服务启动成功后,终端会打印日志,监听端口为 8080

2. 测试步骤

  1. 打开多个浏览器窗口(或不同浏览器),访问 http://127.0.0.1:8080/
  2. 在任意一个窗口的输入框中输入消息,点击「发送」按钮;
  3. 观察其他窗口,会实时收到该消息,实现多客户端实时聊天功能;
  4. 查看后端终端,可看到客户端连接、加入房间、接收消息、断开连接等日志信息。

五、常见问题与优化建议

1. 常见问题排查

  • 前后端无法通信:大概率是 Socket.IO 客户端与服务端版本不兼容,建议客户端使用 1.x 或 2.x 版本,与 googollee/go-socket.io 保持协议兼容;
  • 跨域报错:检查后端跨域中间件配置,确保 Access-Control-Allow-Origin 配置正确,生产环境建议指定具体域名而非 *
  • 无法接收广播消息:确认前端已发送 join 事件加入 chat 房间,服务端广播时指定了正确的命名空间和房间名。

2. 优化建议

  • 性能优化:后端可调整 Socket.IO 传输方式优先级,优先使用 websocket;Gin 框架可自定义 http.Server 配置,优化 TCP 连接复用和并发处理能力;
  • 体验优化:前端可添加回车键发送消息、消息区分发送者与接收者、自动滚动到最新消息等功能;
  • 安全优化:生产环境中,跨域配置指定具体域名,添加身份验证(如 Token 验证),防止非法客户端连接;
  • 部署优化:可将静态资源部署到 CDN,提升前端加载速度;后端可使用进程管理工具(如 supervisor)保障服务稳定运行。

六、总结

本文通过 Gin 框架与 Socket.IO 的整合,实现了一套完整的前后端实时聊天系统,核心亮点如下:

  1. 后端完成了跨域处理、静态资源托管、Socket.IO 事件监听与房间广播;
  2. 前端实现了 Socket.IO 连接、房间加入、消息发送与接收渲染;
  3. 项目结构清晰,代码可直接复用,支持多客户端实时通信,可扩展为在线客服、实时通知等场景。

通过本文的实战,你不仅能掌握 Gin 与 Socket.IO 的使用方法,还能理解实时通信的核心原理,为后续复杂实时系统的开发打下坚实基础。

从 0 到 1:前端 CI/CD 实战(第二篇:用Docker 部署 GitLab)

作者 饼饼饼
2025年12月29日 09:59

前言

在完成云服务器的 Docker 环境搭建后,下一步就是部署整个 CI/CD 体系中最核心的组件 —— GitLab。本篇将继续通过 Docker 的方式,在云服务器上部署一套稳定、可维护的 GitLab 服务,涵盖容器运行、端口映射、数据持久化以及虚拟内存配置等关键步骤。完成本篇后,你将拥有一套真正可以长期使用的 GitLab 服务,为后续接入 CI/CD 流水线打下基础。

Docker Compose 简介

虽然可以直接使用 docker run 启动 GitLab,但实际操作中命令会非常冗长,而且端口、数据目录、环境变量等配置分散在命令行里,后期维护成本很高。为了解决这些问题,本文使用 Docker Compose 来统一管理容器。

Docker Compose 允许将一个或多个 docker run 命令的配置集中写入一个 YAML 文件,一次性定义镜像、端口映射、数据挂载、环境变量和重启策略。这样做有三个明显好处:

  • 配置集中:所有关键配置都在一个文件中,清晰可读
  • 易于维护:修改配置只需要改文件,不必反复敲命令
  • 可复现性强:换服务器或重建环境,只需一条命令即可恢复

常用命令如下:

# 启动服务(后台运行)
docker compose up -d

# 停止并删除容器
docker compose down

# 查看服务运行状态
docker compose ps

相比直接使用 docker run,Docker Compose 更适合长期运行的基础服务,也是实际生产环境中的常见选择。


GitLab 容器目录规划

规划宿主机目录

在部署 GitLab 之前提前规划宿主机目录,并不是为了“规范好看”,而是为了数据安全、后期维护和可迁移性。GitLab 属于典型的有状态服务,如果不将配置、日志和数据明确挂载到宿主机,一旦容器被删除或服务器重装,仓库和用户数据都会一起丢失。清晰的目录结构也是生产环境中的常见做法,后续无论是升级、迁移服务器,还是接入 GitLab Runner 和其他基础服务,都可以直接复用这套结构,一次规划,长期受益。

创建目录

本文将 GitLab 安装在宿主机的 /apps/infra/gitlab 目录下,用于存放 GitLab 的所有相关数据,并按 配置、日志、数据 三类进行拆分。

在服务器上执行以下命令创建目录:

mkdir -p /apps/infra/gitlab/{config,logs,data}

目录说明如下:

  • config:GitLab 核心配置文件目录(如 gitlab.rb)
  • logs:GitLab 运行日志目录,用于排查启动和运行问题
  • data:仓库、数据库、CI 产物等核心数据目录

后续将在 docker-compose.yml 中,将这三个目录分别挂载到容器内对应位置,实现数据持久化。


编写 docker-compose.yml

基础服务定义

在 docker-compose.yml 中,image、container_name 和 restart 是最基础但也最重要的配置。

  • image 使用 GitLab 官方社区版 gitlab/gitlab-ce,功能完整、社区成熟,对中小规模团队和学习环境已经完全足够
  • container_name 明确指定为 gitlab,避免 Docker 自动生成随机名称,方便后续查看状态和排查问题
  • restart: always 用于保证容器在异常退出或服务器重启后能够自动拉起,是长期运行服务的必选项

示例配置如下:

gitlab:
  image: gitlab/gitlab-ce:latest
  container_name: gitlab
  restart: always

端口映射设计思路

GitLab 对外主要提供三类访问能力:Web 页面、HTTPS 服务以及 Git SSH,因此端口映射需要提前规划。

  • 80:HTTP 访问 GitLab Web 页面
  • 443:预留 HTTPS 端口,后续接入证书时无需改配置
  • 2222:宿主机 SSH 端口,映射到容器内的 22

将 SSH 端口映射为 2222,一方面可以避免与宿主机自身 SSH 服务冲突,另一方面也能降低被自动化脚本扫描的概率。需要注意的是,端口映射修改后,GitLab 内部的 SSH 端口配置也必须同步修改,否则会导致 git clone 或 git push 失败。

示例配置如下:

ports:
  - "80:80"
  - "443:443"
  - "2222:22"

提醒:2222 端口默认未放行,需要在云服务器安全组中手动放行。

volumes 挂载与数据持久化

GitLab 是一个强依赖数据的有状态服务,数据持久化不是可选项,而是必选项。本文中将 GitLab 的数据按用途拆分为三类并分别挂载:

  • /etc/gitlab:核心配置目录
  • /var/log/gitlab:运行日志目录
  • /var/opt/gitlab:仓库、数据库和 CI 产物等核心数据

配置如下:

volumes:
  - /apps/infra/gitlab/config:/etc/gitlab
  - /apps/infra/gitlab/logs:/var/log/gitlab
  - /apps/infra/gitlab/data:/var/opt/gitlab

只要宿主机目录仍然存在,即使容器被删除,也可以通过重新启动容器快速恢复整套 GitLab 服务。

资源限制与性能取舍

默认情况下 Docker 不会限制容器的资源使用,而 GitLab 在启动和运行过程中会主动占用可用资源。如果不加限制,在 4G 或 8G 的服务器上很容易导致系统响应变慢甚至不可用。

本文中通过 deploy.resources.limits 对资源进行限制:

  • CPU:2.5 核
  • 内存:3200M

示例配置:

deploy:
  resources:
    limits:
      cpus: "2.5"
      memory: "3200M"

资源限制的目的不是压榨性能,而是保证服务器整体稳定性,这对学习环境和中小团队来说更加重要。

共享内存与稳定性

GitLab 内部包含数据库和缓存组件,对共享内存(shm)比较敏感。Docker 默认的共享内存较小,容易引发一些难以定位的异常问题,因此建议显式设置:

shm_size: '1gb'

配置并启动 GitLab 容器

在 /apps/infra/gitlab 目录下创建 docker-compose.yml 文件,并写入完整配置内容后,执行以下命令启动服务:

services:
  gitlab:
    image: gitlab/gitlab-ce:latest
    container_name: gitlab
    restart: always
    # 填写真实云服务器ip 地址
    hostname: "xxx.xxx.195.160"
    ports:
      - "80:80"
      - "443:443"
      - "2222:22"
    volumes:
      - /apps/infra/gitlab/config:/etc/gitlab
      - /apps/infra/gitlab/logs:/var/log/gitlab
      - /apps/infra/gitlab/data:/var/opt/gitlab
    deploy:
      resources:
        limits:
          cpus: "2.5"
          memory: "3200M"
    shm_size: '1gb'
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        # 填写真实云服务器ip 地址
        external_url "http://xxx.xxx.195.160"
        gitlab_rails['gitlab_shell_ssh_port'] = 2222
        unicorn['worker_processes'] = 1
        sidekiq['concurrency'] = 2
        prometheus_monitoring['enable'] = false
        registry['enable'] = false
        node_exporter['enable'] = false
        gitlab_exporter['enable'] = false
        mattermost['enable'] = false

运行

docker compose up -d

首次启动会拉取镜像并初始化 GitLab,通常需要 3~5 分钟。完成后,在浏览器中访问:

http://<你的服务器 IP>

即可看到 GitLab 登录页面。

如果发现页面加载缓慢或服务器内存占用接近上限,这是 GitLab 初始化阶段的正常现象,下一节将通过配置虚拟内存进行优化。


配置虚拟内存(Swap)

在内存较小的服务器上,为 GitLab 配置 Swap 可以显著提升稳定性。

fallocate -l 8G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

验证是否生效:

free -h

当出现 Swap: 8.0Gi 证明配置成功

设置开机自动挂载:

echo '/swapfile swap swap defaults 0 0' >> /etc/fstab

GitLab 初始化

确认容器运行状态:

docker ps

获取初始 root 密码:

docker exec -it gitlab grep 'Password:' /etc/gitlab/initial_root_password

成功登录后点击右上角头像 > Edit profile > Password 中修改初始密码


本篇小结

本篇完成了使用 Docker Compose 在云服务器上部署 GitLab 的全过程,包括目录规划、资源限制、数据持久化以及虚拟内存优化。通过这些配置,即使在低配服务器上,也可以稳定运行一套可长期使用的 GitLab 服务。

在下一篇中,我们将部署 GitLab Runner,把这套 GitLab 真正变成一条可以自动构建和发布的 CI/CD 流水线。

动态配色方案:在 Next.js 中实现 Shadcn UI 主题色切换

2025年12月29日 09:28

前言

Hi,大家好,我是白雾茫茫丶!

你是否厌倦了千篇一律的网站配色?想让你的 Next.js 应用拥有像 Figma 那样灵活的主题切换能力?在当今追求个性化和用户体验的时代,单一的配色方案早已无法满足用户多样化的审美需求。无论是适配品牌形象、响应节日氛围,还是提供用户自定义选项,动态主题色切换已成为现代 Web 应用的重要特性。

本文将带你深入探索如何在 Next.js 应用中实现专业级的主题色切换系统。我们将利用 Shadcn UI 的设计系统架构,结合 CSS 自定义属性(CSS Variables)的强大能力,打造一个不仅支持多套预设配色方案,还能保持代码优雅和性能高效的主题切换方案。无论你是想为用户提供“蓝色商务”、“绿色生态”还是“紫色创意”等不同视觉主题,这篇文章都将为你提供完整的实现路径。

告别单调,迎接多彩——让我们一起构建让用户眼前一亮的动态主题系统!

开发思路

我的实现思路主要基于 CSS 自定义属性(CSS Variables)。每套主题配色对应一组预定义的变量值,以独立的类型(或类名)标识。在切换主题时,只需为 <html> 根元素动态添加对应的类型类名,即可通过 CSS 变量的作用域机制,全局应用相应的配色方案,从而高效、无缝地完成主题切换。

主题构建工具

当然,要高效地实现基于 CSS 变量的动态主题系统,离不开一个强大的主题构建工具来生成和管理不同配色方案。在这里,我强烈推荐一款专为 shadcn/ui 打造的主题编辑与生成工具:

tweakcn.com/

TweakCN 不仅界面简洁直观,更深度集成了 shadcn/ui 的设计规范,支持实时预览、一键导出 Tailwind CSS 配置及 CSS 变量定义。你可以自由调整主色、辅助色、语义色(如成功、警告、错误等),并自动生成适配深色/浅色模式的完整配色方案。更重要的是,它输出的代码可直接用于 Next.js 项目,配合 CSS 变量策略,轻松实现主题切换——无需手动计算颜色值或反复调试样式,极大提升了开发效率与设计一致性。对于希望快速定制品牌化 UI 风格的开发者来说,TweakCN 无疑是一个强大而贴心的助手。

定义多套配色方案

1、在主题编辑页面,TweakCN 默认提供了 43 套精心设计的配色方案。你可以逐一浏览并实时预览每种方案在实际 UI 组件中的呈现效果。从中挑选几套符合项目风格或个人审美的配色,也可以基于现有方案进一步微调主色、辅助色或语义色,打造完全属于你自己的定制化主题。

202512/w0sy4ok3lelxx4fjx2tafw52lk1kunjr.gif

2、在确认主题配色后,点击右上角的 {} Code 按钮,点击 Copy 复制样式:

202512/ucjfomwfkpjmbuz9n82kgyx1u9ia8eym.png

3、新建一个 theme.css 文件,用来保存不同的主题配色:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.1450 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.1450 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.1450 0 0);
  --primary: oklch(0.2050 0 0);
  --primary-foreground: oklch(0.9850 0 0);
  --secondary: oklch(0.9700 0 0);
  --secondary-foreground: oklch(0.2050 0 0);
  --muted: oklch(0.9700 0 0);
  --muted-foreground: oklch(0.5560 0 0);
  --accent: oklch(0.9700 0 0);
  --accent-foreground: oklch(0.2050 0 0);
  --destructive: oklch(0.5770 0.2450 27.3250);
  --destructive-foreground: oklch(1 0 0);
  --border: oklch(0.9220 0 0);
  --input: oklch(0.9220 0 0);
  --ring: oklch(0.7080 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.9850 0 0);
  --sidebar-foreground: oklch(0.1450 0 0);
  --sidebar-primary: oklch(0.2050 0 0);
  --sidebar-primary-foreground: oklch(0.9850 0 0);
  --sidebar-accent: oklch(92.2% 0 0);
  --sidebar-accent-foreground: oklch(0.2050 0 0);
  --sidebar-border: oklch(0.9220 0 0);
  --sidebar-ring: oklch(0.7080 0 0);
  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  --radius: 0.625rem;
  --shadow-x: 0;
  --shadow-y: 1px;
  --shadow-blur: 3px;
  --shadow-spread: 0px;
  --shadow-opacity: 0.1;
  --shadow-color: oklch(0 0 0);
  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
  --tracking-normal: 0em;
  --spacing: 0.25rem;
}

.dark {
  --background: oklch(0.1450 0 0);
  --foreground: oklch(0.9850 0 0);
  --card: oklch(0.2050 0 0);
  --card-foreground: oklch(0.9850 0 0);
  --popover: oklch(0.2690 0 0);
  --popover-foreground: oklch(0.9850 0 0);
  --primary: oklch(0.9220 0 0);
  --primary-foreground: oklch(0.2050 0 0);
  --secondary: oklch(0.2690 0 0);
  --secondary-foreground: oklch(0.9850 0 0);
  --muted: oklch(0.2690 0 0);
  --muted-foreground: oklch(0.7080 0 0);
  --accent: oklch(0.3710 0 0);
  --accent-foreground: oklch(0.9850 0 0);
  --destructive: oklch(0.7040 0.1910 22.2160);
  --destructive-foreground: oklch(0.9850 0 0);
  --border: oklch(0.2750 0 0);
  --input: oklch(0.3250 0 0);
  --ring: oklch(0.5560 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.2050 0 0);
  --sidebar-foreground: oklch(0.9850 0 0);
  --sidebar-primary: oklch(0.4880 0.2430 264.3760);
  --sidebar-primary-foreground: oklch(0.9850 0 0);
  --sidebar-accent: oklch(0.2690 0 0);
  --sidebar-accent-foreground: oklch(0.9850 0 0);
  --sidebar-border: oklch(0.2750 0 0);
  --sidebar-ring: oklch(0.4390 0 0);
  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  --radius: 0.625rem;
  --shadow-x: 0;
  --shadow-y: 1px;
  --shadow-blur: 3px;
  --shadow-spread: 0px;
  --shadow-opacity: 0.1;
  --shadow-color: oklch(0 0 0);
  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}

4、这样我们就默认一个主题,如果是多套配色,我们可以加上主题类名区分,例如:

/* Amber Minimal */
:root.theme-amber-minimal{
}
.dark.theme-amber-minimal{
}

/* Amethyst Haze */
:root.theme-amethyst-haze{
}
.dark.theme-amethyst-haze {
}

5、然后把 theme.css 导入到全局样式中,Next.js 项目一般是 global.css

@import "./themes.css";

到这里,我们的准备工作就算完成了,接下来我们就完成主题色的切换逻辑!

具体实现

1、这里我们需要用到 zustand 来保存主题色的状态:

pnpm add zustand

2、创建主题配色枚举:

    /**
     * @description: 主题色
     */
    export const THEME_PRIMARY_COLOR = Enum({
      DEFAULT: { value: 'default', label: 'Default', color: 'oklch(0.205 0 0)' },
      AMBER_MINIMAL: { value: 'amber-minimal', label: 'Amber', color: 'oklch(0.7686 0.1647 70.0804)' },
      AMETHYST_HAZE: { value: 'amethyst-haze', label: 'Amethyst', color: 'oklch(0.6104 0.0767 299.7335)' },
      CANDYLAND: { value: 'candyland', label: 'Candyland', color: 'oklch(0.8677 0.0735 7.0855)' },
      DARKMATTER: { value: 'darkmatter', label: 'Darkmatter', color: 'oklch(0.6716 0.1368 48.5130)' },
      ELEGANT_LUXURY: { value: 'elegant-luxury', label: 'Elegant', color: 'oklch(0.4650 0.1470 24.9381)' },
      SAGE_GARDEN: { value: 'sage-garden', label: 'Garden', color: 'oklch(0.6333 0.0309 154.9039)' },
      SUPABASE: { value: 'supabase', label: 'Supabase', color: 'oklch(0.8348 0.1302 160.9080)' },
      TWITTER: { value: 'twitter', label: 'Twitter', color: 'oklch(0.6723 0.1606 244.9955)' },
    });

3、新建 store/useAppStore.ts 文件:

    'use client'
    import { create } from 'zustand'
    import { createJSONStorage, persist } from 'zustand/middleware'

    import { THEME_PRIMARY_COLOR } from '@/enums';
    import { initializePrimaryColor } from '@/lib/utils';

    type AppState = {
      primaryColor: typeof THEME_PRIMARY_COLOR.valueType; // 主题色
      setPrimaryColor: (color: typeof THEME_PRIMARY_COLOR.valueType) => void; // 设置主题色
    }

    export const useAppStore = create(
      persist<AppState>(
        (set) => ({
          primaryColor: THEME_PRIMARY_COLOR.DEFAULT, // 默认主题色
          setPrimaryColor: (color) => {
            set({ primaryColor: color })
            initializePrimaryColor(color);
          }
        }),
        {
          name: 'app-theme', // 用于存储在 localStorage 中的键名
          storage: createJSONStorage(() => localStorage)// 指定使用 localStorage 存储
        }))

4、创建主题色初始化函数:

    /**
     * @description: 初始化主题色
     * @param {typeof} color
     */
    export const initializePrimaryColor = (color: typeof THEME_PRIMARY_COLOR.valueType) => {
      if (typeof document !== 'undefined') {
        // 清空 theme- 开头的类名
        const html = document.documentElement;
        Array.from(html.classList)
          .filter((className) => className.startsWith("theme-"))
          .forEach((className) => {
            html.classList.remove(className)
          })
        // 如果不是默认主题色,则添加对应的类名
        if (color !== THEME_PRIMARY_COLOR.DEFAULT) {
          html.classList.add(`theme-${color}`);
        }
      }
    }

5、创建主题切换按钮:

    import { type FC, useCallback } from "react";

    import { getClipKeyframes } from '@/components/animate-ui/primitives/effects/theme-toggler';
    import { Button } from '@/components/ui';
    import { THEME_PRIMARY_COLOR } from '@/enums';
    import { useAppStore } from '@/store/useAppStore';

    const PrimaryColorPicker: FC = () => {
      const primaryColor = useAppStore((s) => s.primaryColor);
      const setPrimaryColor = useAppStore((s) => s.setPrimaryColor);
      const themeModeDirection = useAppStore((s) => s.themeModeDirection);

      const [fromClip, toClip] = getClipKeyframes(themeModeDirection);

      // 点击颜色切换
      const onChangeColor = useCallback(async (color: typeof THEME_PRIMARY_COLOR.valueType) => {
        if (primaryColor === color) {
          return;
        }
        if ((!document.startViewTransition)) {
          setPrimaryColor(color);
          return;
        }
        await document.startViewTransition(async () => {
          setPrimaryColor(color);
        }).ready;
        document.documentElement
          .animate(
            { clipPath: [fromClip, toClip] },
            {
              duration: 700,
              easing: 'ease-in-out',
              pseudoElement: '::view-transition-new(root)',
            },
          )
      }, [primaryColor, setPrimaryColor, fromClip, toClip])
      return (
        <>
          <div className="grid grid-cols-3 gap-2">
            {THEME_PRIMARY_COLOR.items.map(({ value, label, raw }) => (
              <Button
                size="sm"
                aria-label="PrimaryColorPicker"
                variant={primaryColor === value ? "secondary" : "outline"}
                key={value}
                className="text-xs justify-start"
                onClick={() => onChangeColor(value)}
              >
                <span className="inline-block size-2 rounded-full"
                  style={{ backgroundColor: raw.color }} />
                {label}
              </Button>
            ))}
          </div>
          <style>{`::view-transition-old(root), ::view-transition-new(root){animation:none;mix-blend-mode:normal;}`}</style>
        </>
      )
    }
    export default PrimaryColorPicker;

这里我加了切换过渡动画,不需要的可以自行去掉!

6、页面刷新的时候需要同步,在 Provider.tsx 中初始化:

    import { initializePrimaryColor } from '@/lib/utils';
    const primaryColor = useAppStore((s) => s.primaryColor);

    // 初始化主题色
    useEffect(() => {
      if (primaryColor) {
        initializePrimaryColor(primaryColor);
      }
    }, [primaryColor])

效果预览

202512/fzqxxw5cxexwp2kqeklcevk6o2dwulqi.gif

总结

实现动态主题配色的方式多种多样——从 CSS-in-JS、Tailwind 的 class 切换,到运行时注入样式表等,各有优劣。本文分享的是基于 CSS 自定义属性(CSS Variables)HTML 根元素类名切换 的轻量级方案,配合 TweakCN 这样的可视化工具,能够快速构建出结构清晰、易于维护的主题系统。当然,这仅是我个人在项目中的一种实践思路,如果你有更优雅、更高效的实现方式,欢迎在评论区留言交流!技术因分享而进步,期待看到你的创意方案 🌈。

线上预览:next.baiwumm.com

Github 地址:github.com/baiwumm/nex…

❌
❌