普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月1日技术

饺子的 2025 年终总结

作者 清汤饺子
2025年12月31日 23:46

往年的总结总是有迹可循:工作项目做了什么事、学了什么技术、读了什么书、爬了哪些山、去了哪里旅游。

2025 年我依然在高速奔跑,但不同的是我的脚下有了更踏实的实土,耳边少了很多焦虑的阵风。


💻 前端:从“开荒”到“架构”

2025 年,是结束一份 5 年长期稳定工作后的全新起点。进入新环境、新同事、新项目、新节奏,一切都是新的。我以为我会需要一段适应期,但出乎意料的是,这种变化反而激发了我更多的热情和活力。

业务与成长

今年我跟随团队作战,跟随着项目一起经历高速的迭代。在开放真诚的氛围里,我拿到了入职后的第一个 S 绩效
如果说过去我是在“做项目”,今年我更多地是在“沉淀系统”。我开始意识到,技术不只是为了实现功能,更是为了在混沌中建立秩序。

博客与输出

今年在内部博客产出了 10 篇深度文章。相比往年追求“技术的广度”,今年的每一篇都更偏向于「问题总结」和「Tech Design」。
我喜欢这种“解码”的过程:从 AIGC 的埋点设计,到毫秒级下载的性能瓶颈,再到支付能力的架构演进。每一篇文章,都是一次认知版本更新。


🌿 生活:在烟火气中“回血”

2025 年,把自己重养一遍。

身体与自我

今年我对自己的身体进行了两次重要的“重塑”:做了正畸和近视手术。

  • 舞蹈:上了 100+ 节舞蹈课。在舞蹈室里,那种全神贯注的投入让我享受心流的快乐。

image.png

  • 户外:去了日本,去了川西,徒步过,也听了 Livehouse。

cd9b1833d9d0e06a2cc8fab565162439.jpg

45da7c02380a2083fc55a1d9e3dc1836.jpg

59b6240a284749797d0eb51222c20e59.jpg

认知与内在

2025 年,我几乎戒掉了电视剧,手机使用时长保持在 2-3 小时。

  • 阅读:继续保持阅读的习惯,内心更加充盈。

f402fffe62e92044912021e28c3cac26.jpg

  • 厨艺:有了拿手菜后,喜欢上了做饭。在切菜和翻炒的频率中,我感受到了生活最本质的踏实感。

8288a9a4707b3fe680df00df9095c986.jpg

  • 整理:我变得非常喜欢整理房间。每一次收纳,都是对内心世界的一次“重构”。

陪伴

  • 带父母旅游: 今年最值得的一件事,就是有能力也有时间带父母出去走走。

a35c7cf7c7d0ef2102e0cf67a30342ec.jpg

  • 养的猫比以前更黏人了: 也许是因为陪伴它的时间变多了,它也感受到了我的松弛。

bbc10cbd3b6ef25332d24aae632cad36.jpg

我不再是一个人在战斗,而是在一个温馨、稳定的系统里,不断向内探索。


✍️ 写在最后:你好,30 岁

2025 年,我的目标更加明确,我不再害怕“停下来”,在平淡的生活中遇到了一个更喜欢的自己。我喜欢这样的 30 岁。

2026 年,继续保持热爱❤️

昨天 — 2025年12月31日技术

HBuilderX 4.87 无法正常读取 macOS 环境配置的解决方案

作者 狗鸽
2025年12月31日 22:27

我的电脑配置是 macOS Sequoia 15.6.1(M2 芯片)。最近使用 HBuilderX 4.87 打包 APP 时出现卡住、无法正常打包的问题。本文给出解决方案。

隔了几个月,最近要用 uni-app 打包一下 APP。我像之前那样打开了 HBuilderX,更新并打包 Android APP,却发现提示我需要配置 Node.js。

我很奇怪,我都是用 fnm 管理 Node.js,几个项目 Node.js 用得好好的,没啥问题啊,为什么会这样呢?🤔

我百思不得其解,直到我打开了 HBuilderX 提示的 关联链接

HBuilderX macOS 环境变量

Hbuilder X 从 4.41 版本开始调整 cli 项目使用本地的 node 执行编译,如果用户未安装并正确配置 Node.js 会警告报错。

目前只从 bash 中读取环境变量,需要确保配置到 bash 中

Bro,从 4.41 版本更新到 4.87 版本,还是不能从 macOS 10.15 开始默认使用的 zsh 读取环境变量,体验真是太差了。😅

我搜索了 DCloud 论坛里面,有好哥哥提到将 ~/.zshrc 里的内容复制粘贴到 ~/.bash_profile 就能用了。如果你感兴趣可以尝试一下这个方案,但我尝试时发现还是会卡住无法继续,HBuilderX 日志提示 fnm 不存在。

进一步地,如果我去掉了 ~/.bash_profile 里面的 fnm,只是按照官方指南那样设置 Node.js PATH,仍然没办法正常使用。

# HBuilderX
export PATH="$PATH:$HOME/Library/Application Support/fnm/aliases/default/bin"

# Android Studio
export ANDROID_HOME=$HOME/Library/Android/sdk
export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_SDK_ROOT/emulator
export PATH=$PATH:$ANDROID_SDK_ROOT/tools
export PATH=$PATH:$ANDROID_SDK_ROOT/tools/bin
export PATH=$PATH:$ANDROID_SDK_ROOT/platform-tools

# Java
alias use-java17='/usr/libexec/java_home -v 17 --exec java -version'
alias use-java8='/usr/libexec/java_home -v 1.8 --exec java -version'

直到我偶然设置了全局的 Node.js 版本,重启 HBuilderX,结果可以正常打包了!但如果我去掉 ~/.bash_profile 里面的 Node.js PATH,重启 HBuilderX,又没法正常打包了!

我综合几次实验,得出的结论是,~/.bash_profile 里面 Node.js PATH 只起到了一个欺骗 HBuilderX 的作用,并不会使用里面 PATH 对应的 Node.js,实际上使用的是 fnm 在全局设置的 Node.js。我完全无法理解这种事情,也不好说这是 fnm 的弊端,还是 HBuilderX 的缺陷 😱

如果你尝试好哥哥的方案无法正常运行,那可以尝试一下我的方案:

  1. 设置 ~/.bash_profile,如上所示;
  2. 切换到 ~ 目录下,运行一下 fnm use [打包项目所需的 Node.js]
  3. 重启 HBuilderX,打包 APP。

希望对你有所帮助!元旦快乐!

我的2025年终总结

作者 奈德丽
2025年12月31日 22:05

我的2025年终总结

时间过得好快呀,2025年就这么快过去了,明天开始就是2026年啦,小蛇游得可真快,到马儿来接力了。

我与家人以及朋友

首先值得高兴的是今年身体棒棒的,很少感冒,感冒了也不用吃药几分钟之后就好了。

常回家看看。今年端午回家啦,去看了外公外婆和奶奶,今年国庆回家了,特别开心,给爸爸妈妈买了一些羊奶牛奶。 老姐经常会给我带很多好吃的,像酱牛肉(很下饭)、橘子

丽给我买了好多好吃的,酱板鸭,童胖子、杨矮子、仙都、新金泰,常德的酱板鸭都吃了个遍

收到铁子从东北寄来的哈尔冰红肠

收到了组长给我做的牛肉辣酱,比超市里面买的好吃噢

户外运动

今年进行的户外运动比较少,麻田岭徒步、北灵山徒步 & 骑马、跑了几次香山、去坡风岭看红叶、刷了刷百望山,甚至和我姐和丽丽去了一次小土包(景山)

在掘金上持续输出文章

一篇《程序员自己设计开发的五子棋,炫酷又不失可爱》发布在掘金之后,无意之中爆火了,上了掘金综合榜第一名,那会儿我还隔几十分钟就去掘金看看,有没有新的点赞和收藏,特别逗的就是,我当时点赞才四五十的时候,心里想着,要是能突破100就好了,过了几个小时,也如愿以偿,收获了那么多的点赞, 这篇文章的初衷,是分享一下我写的五子棋小游戏,我觉得很有创意的一点是不同于经典的黑白子,它可以选取不同样式的棋子,还有朋友说希望它能上线,最终因为非技术因素,没有将它上线。 这一篇文章的爆火,鼓舞了我继续写作,由此也开启了我在掘金的写作之路,之后我持续总结了自己在工作中遇到的一些问题,将这些问题展现在了博客上面,希望对其他人也有一些用处。 在这个创作的过程中,除了得到了一些小伙伴的认可,也有一些小惊喜,那就是第一次收到了一个来自hongkong的小伙伴的百元赞赏,在这再次特别感谢!

和 li 过了一周年纪念日

和丽在一起的一周年,我们来吃了她喜欢吃的烤牛肠,她喜欢烤牛肠到了什么程度呢,甚至是想要跑去韩国专门吃一顿。我说北京也有正宗的烤牛肠店呢,以后有机会去韩国吃,要不先去六日都尝尝呢 qaq 一会和她一块吃火锅,一起跨个年!

对未来的展望

2026年持续学习! 2026多花时间陪伴家人! 2026 期待更健康的自我,多进行户外运动,去拥抱大自然!

月哥创业3年,还活着!

2025年12月31日 20:36

说什么呢

  • 18年9月入行,到现在7年多了。。真特码快!粉丝们一步一步看着月哥的成长,感谢大家一直以来的陪伴,和支持!谢谢大家!
  • 写了很多东西,删掉了很多,怕发不出来,思来想去分享一些踩坑经验,和一些浅薄的所见所闻
  • 话会很难听,心态弱的人别看,看完你会骂我!
  • 本故事纯属虚构,如有雷同纯属巧合!

营收篇-大家最感兴趣的

  • 1000w的越南盾是肯定有的,全浪掉了。。。。

  • 挣钱在某些成面来说是一件很简单的事情

  • 月入10w越南盾,和呼吸一样简单。

  • 哈哈,不逗你们了,正片开始!

创业篇

回到地方创业

  • 水深的一批,和zf打交道,做了学历提升、考g、考研之类的。。。。然后就是竹篮打水一场空,没啥可聊的其实,反正这玩意,不是短期的生意,你得去做长期打算才能起来量。

  • 核心原因就是竞争太激烈,市场调研没做好,光喝酒了,,,哈哈。转头一看一大堆。我日,有些时候得服输,硬上没的好。

  • 那个时候招聘销售,我们都给交社保,很多竞品企业压根就不交社保,可能是上海回去的不理解当地行情,有点小钱,不在乎,我觉得这个是应该做的,但是确实是提高了很多成本,然后挣的还不够交给员工社保,笑死了。。。

  • 本来去年9月份关掉的话其实亏的不多,我说再熬一年,然后今年9月份全部关停,宣告失败。其实早都要关掉了,但是一个员工怀孕了,没办法,我们得配合她拿完各种补贴。不然说不过去,虽然我可以直接关,我们都是实交的工资注册资金,打官司都没用,公司没钱,早都亏完了,但是亏就亏了,不差这一点了,落个心安,员工也很理解我们,确实一直在亏钱。。好聚好散,等月哥好起来。哈哈!

  • 没有欠一分钱工资,还是可以的,不算太失败,哈哈!

  • 还是年少轻狂,就像打德州扑克一样,你被套池了,flod 掉你又不甘心,然后all in ,全输掉了。及时止损是一种很强的能力,现在想想,当时还不如换辆迈巴赫,最起码现在还有残值。那个时候每个月的成本都是几十万,现在想想真的可笑,哈哈!

  • 这些东西是没有任何技术壁垒的,就像奶茶店,火锅店一样,有钱就能干,失败的比例相当大,先手+关系,才能在本地业务吃的深。外来户前期会交很多学费,如果你手里只有几十万,或者小百十万,老老实实守住钱袋子,别乱搞,,你那点钱,打不出水花。。。

跨境电商

  • 我当时做跨境卖货培训,其实这个一直在做,美区,东南亚,现在已经有前端同学全职在做了,夫妻两个在做,挺不错的。当时业务太多,扩大规模干,确实出成绩了。。坚持做就行。一个月整个几万不是难事。
  • 我们当时去贵州和当地的茶叶企业谈的,当时都谈的差不多,第一笔大概500个就够了,我们在上海有仓库,但是当时涉及到gq收购,和贸易战生意没谈成。现在tt的茶叶生意,依旧很火爆,挺可惜的,手里没那么多现金,这个事情现在依旧可以做。。。前期吃人脉,吃资金!
  • 现在保留一些挣钱的品类,慢慢做。培训暂时没精力搞,程序员培训明年稳定之后可能继续跨境继续开课一波。

足疗店趣事

  • 这个在某些情况是暴利的。但是我胆子比较小,我只喜欢做合法的生意,我们就讲合法的生意哈!
  • 当时我们准备在我们老家开一个足疗店,纯绿色哈,别想歪。然后就开始选址,敲定规模,包括前期投入的资金,一堆破事。。。
  • 老家房租便宜,最好的地段当时看了400多平,然后前期投入在200个+,投资人大概5个,后面一算细账感觉很奇怪。
  • 装修是大头,这里水更深,我艹。。。
  • 共计11个房间,客单价在150左右,每个月上满,纯利在11个w左右,然后5个投资人,一个人也就2个w左右,这特么做什么,不够费事的。这还是最优的情况下。
  • 没开店你就要开始培训技师,在开店之前都是要正常付工资的,一般第一个月保底在12000左右,看颜值和技术,你10个技师,你每个月的运营成本就在10几个w,但是客单价小房间这么少,肯定不好挣钱的。
  • 我当时给到的意见就是,扩大规模,投资人不能超过3个,不然没意思。
  • 回本周期最起码三年,最后算笔细账,就没干了。没太大的搞头,遍地都是,没有壁垒,朋友都劝我别搞,然后就不了了之了。
  • 什么奶茶店,饭店 ,,,别碰就完了。

海外班

  • 成绩出来的不少,我们一直在坚持做,第一年的时候就已经亏本了,到现在我们每周3次的直播,依旧在坚持做,但是你看我一次没有宣传,学员问我们为什么还继续做,因为我知道这件事是正确的事,语言的学习是长期的,我想通过自己的努力告诉你们,坚持正确的事。亏就亏,我认。我只是没宣传,不代表我没继续服务。

  • 我在这个阶段认识了非常多国外牛批的人,提升了我自己的个人认知,我们明年会开班,德语+英语+技术,全球选择工作的权利,,,

  • 本来日语在计划之中,身为退役军人的我,情感比较强烈,最近中日关系大家也之后,这个钱不挣也罢,现在就是去你md,tmd我要干你们小日子。cnmd!

  • 我们后续会推出,月票,季票,年票,国外班的面试辅导,会单独的抽离出来。

  • 现在出海的很多业务,都是商机!

总结-精力过于分散

  • 失败的原因,精力过于分散,我无论哪一行,我如果垂直下去,我肯定能做出来,为什么关掉跳出来,第一持续亏损,第二因为小孩要回上海读书,最重要一点是觉得没意义了。。。
  • 专注自己擅长的事,把事做好。
  • 低调做人,

投资篇

  • 不擅长,但是圈子里面一堆牛批的人。。。他们真的很牛批。没精力去整这些,我只能说月入百万都是弟弟!
  • 基金目前小亏
  • 黄金大赚,我400多1g买的,因为之前缺钱,600多卖掉的,因为此事,我老婆骂了我好久。没办法,,,我不喜欢借钱,不喜欢欠人情,所以卖掉周转,白干一场,本金也没了。。。
  • 当年全仓黄金,yyds,哈哈!
  • 有些东西该你的就是你的,不该你的成为回忆,内心其实没太大波澜。

人脉篇

  • 结识了一堆老板,,,,结识了很多技术大佬
  • 人脉的第一性原则,利益互换,,当你自身没能力的时候,你没有人脉可言。
  • 一定要记住,你强了之后,周围人都是强者,你弱的时候,周围都是弱者,想要变强革新自己,或者拿钱开路。等量才能交换。为此,我不知送了多礼,请了多少饭局!
  • 别和负能量的人为伍,人性弱点,很多人见不得你好的。。。。你可以要饭,但是你不能变强,,,这点不细聊。技术圈子相对来说还是比较单纯的。
  • 我现在已经戒酒了,你们懂的。很多都是虚的,我八块腹肌都喝没了,我回老家的时候是120多斤,现在快160了,艹,,,专心搞技术。
  • 我老婆说我肚子像怀孕6个月了。fck,怀念从前,这张照片是我刚干前端那会,我日了。现在就不发了,哈哈,大肚皮。99归1

培训篇

市场一直都在,现在就是人多,竞争激烈。岗位也很多。ai在杀死一批人,也在创造一批就业。对于很多人来说,行情好你依旧找不到好工作!

线上

  • 我们现在有前端培训,后端培训,ai全栈培训。
  • 面试培训是刚需,存量市场比较大,割韭菜的比较多。我们做了这么久,基本上都是拼口碑的。
  • 大家比较谨慎的原因就是怕被割韭菜,有些机构的宣传真敢,,我艹。看了一眼假,但是噱头强,,不知道的人以为很牛批,就业人的数量,比学员人数还多,我艹,你敢信,我艹。。。。
  • 我们在认真的改革,做好培训

线下

  • 目前在上海,我们现在一直在亏,因为人少,我们收的都是有工作经验的,离职的同学,目前不收0基础,学历低就业太难了。

前端篇

你记住一点,你只要技术强,你就能有各种出路,,,无关年龄和学历,剩下的就是拼搏,去锻炼自己的技术,技术这一行,技术是核心竞争力。

前端还能干吗?

  • 目前前端被自媒体说的,已经死一万次了,每次出来新的ai,前端已死,前端已死,后面发现,这玩意能够高度的提升效率,但是替代程序员,目前来看不可能。。。。
  • 再问你有退路吗?如果没有,别想七想八,疯狂的学习技术,才能有出路。
  • 行情卷,因为人多,岗位不少,优质人才依旧难求,菜逼一堆,天天叫嚣,,,你除了菜,而且还不学,你肯定难找到好工作,因为你已经放弃了自己。

你为啥迟迟进不了大厂

  • 菜、懒、不愿意学。三分钟热度!
  • 别和我说,年纪大、学历低,早干啥去了。
  • 坑不还是你自己踩的,而且越踩越深,直到爬不出来了,才会后悔,
  • 然后还是老样子,道理你都知道,想得到,又不愿意做到,那些专科的年纪大的同学,拿到价值,你又说别人是卷王,,,你怎么不说别人努力,你玩的时候别人在学,你浪费的时间别人都熬住了,拿到价值不是顺理成章!
  • 对于当下来说,很多履历是进不了了,因为之前的机会被你浪费掉了,但是涨薪,多挣钱是可以做到的。

转行

  • 我就一句话, 前端你都学不好,你转个蛋!转了还是菜。只能掉进另一个坑!浪费钱,浪费时间,不如时间花在自己提升这块。

保持良好的现金流(单位越南盾)

  • 如果你月入5万,存款只有100多个,别去上车500个以上的fz,现在行情下千万别成为f奴,,,,量力而为。
  • 因为程序员这行,可能不是可持续性高收入的工作。
  • 很多人说月入5w很多,在我的视野里也就一般,别去透支未来,就像月哥一样,明明可以退休,偏要迎难而上,最后碰了一头的包!

活的精彩

  • 有钱才会精彩,别犟,等你真正几百个的时候再来和我犟,还没到这个水平的时候,低调挣钱,存款在你手,天下你才有。
  • 有钱之后你才能玩的开始,你才能拥有精彩人生。。。
  • 不然的话就是活的难受,想干的事,干不了,然后说我无欲无求,你要无欲无求,你把钱捐给我,你倒是出家啊,你为什么没去,不还是脱离不了世俗。
  • 你躺平不了的,一旦你有了家庭,有了孩子,还要赡养父母,你已经不是在为你自己而活,你已不是你,,,,

心态篇

失败的原因,就是放弃了自己,每个人都可以变好,从任何年纪开始,都是新生,别去顾虑他人眼光,都关注自己技术深度和荷包厚度。

焦虑

  • 你为什么会焦虑?比方让你一年挣100个小目标,你肯定不会焦虑,因为这个压根就完不成,因为没几个马云、马斯克,,,但是让你去做涨薪,进大厂这种事,你就会焦虑,因为你有完成这件事的实力,但是你不去做,你自然焦虑。
  • 我身边有大龄、专科、还有大龄+专科的同学都有进大厂的案例,,,,,这时候有人就会跳出来说,幸存者偏差了,,,我想说很难吗?你来说一说难在哪里?
  • 难在你没实力,难在你执行力差,难在你懒惰,难在你躺平十多年,,,,还是说公司卡年纪,卡学历,卡履历!
  • 在前端圈,你花十年进阶自己,你不牛批才怪。那时候你就会说,我为什么要进大厂!
  • 和优秀的人对比,找差距,然后补齐差价,不一定能大成,但是绝对会让你的收入大涨!
  • 抱怨和焦虑,说明你内心还想变好。那就利用这股劲,去行动。结果或许不由人,但过程每一步,都算数,持续学习,有事情做,焦虑的状态自然无!

保持自信

  • 失败!什么特么叫做失败,,,就算我现在嘎了,我也不会后悔我现在所做,我觉得很精彩呀。我死的那一刻回忆我的所做所为,不错,优秀,,,这辈子不枉此生!

强者论

  • 你弱你有理,所以你的职场发展不顺利,你想成为强者,但是你的执行力太差,然后导致你的能力太差,在就业市场上你就是弱者,你没有更好的选择,别人大厂offer拿到手软,你是外包都嫌弃,不愿意给你涨薪水。
  • 话不好听,因为要拿实力说话,没有实力,别抱怨,你会活的舒服些。毕竟吃技术饭的,你技术不行,你有什么理由抱怨。。。。抱怨别人太强,还是招聘方要求太高,,,买方市场,肯定要优中择优!

没苦硬吃

  • 在某些情况下,你不得不去吃苦,更多的是当下已经很苦了,想改变现状,
  • 但是谁特么脑子有病,没苦硬吃,更多的现实是不得不。因为还不够格说这句话:没苦硬吃。
  • 提升自己,,这叫奋斗

现在努力还有用吗?

  • 月哥,我都30多岁了,我还有机会吗?
  • 月哥我专科,我还有机会吗?
  • 月哥我失业一年了,我还有机会吗?
  • 。。。。
  • 身处低谷,怎么走都是向上,但是你要去走,因为挡在你面前的只剩你自己了。。。
  • 突破自己,才能获得新生,,,最起码你的内心深处不会后悔了,最后一搏,死也要死的明白!
  • 送你们一句话,只要开始,虽晚不迟,,

多吸正能量的空气

  • 永远要乐观,悲观者永远正确,,,,比方说,在月哥的圈子里,基本上都是很努力的人,认识很多p7,p8的技术专家,大家的共性都很努力,不会抱怨,专注自己的提升,而不是人云亦云。
  • 千万别在垃圾里面去证明自己,多想上沟通,你强了之后,身边的乌烟瘴气就会少很多,身边的人才会强!

屏蔽恶意

  • 之前报警抓了恶意诽谤的人,对我的心态影响不小,有些人就是坏,,,但是你无可奈何,现在总结就是:屏蔽恶意,专注自己!

失败,难听

  • 你觉得我说的难听,当你一次又一次被拒,面试失败的时候,煎熬的时候,家人缺钱的时候,你就知道月哥说的有多对了,
  • 你越盯着结果,你越容易失败,没人会知道结果,先把眼前的路走扎实,你越急于求成,你越容易摔跟头,前端这行,踏实学习的人,走的更远,你哪怕走的慢一点,只要方向对,坚持在走,你的每一步都值得,别贪快,别偷懒,坚持的学习,才是好本事!

你还有梦吗

  • 每一个创业者,心里都会想,,,,,追梦!
  • 苦还是快乐,是一念之差,,,沉醉其中,何以为苦!何以为乐!
  • 不放弃、不抛弃!老兵不死,,,

创业不易

  • 敢冒险的人,才配赢,,,敢于突破自己的人,不会输,,,虽然说月哥这两年事业上不顺,但是单凭自己能力而言,我抛弃现在的公司,自己单干,依旧能活的不错,因为我确实挺努力的,吃饭的本事练的还行,,,看能不能突破自己的心智瓶颈了。
  • 因为我还没有败,那就要按照自己所想,坚持一下,万一成了呢。在我干游泳教练的时候,我就想,我命不该如此,,,,所以果断换方向,干了前端,努力至今,小起小落,但是对比之前,强太多了。。。内心富足,,,剩下的看天!

你合适做什么

  • 去年前年,手里有点小钱,跨行测试一些事情,,,总结下来,酒喝的挺多,认识的人很多,钱浪费的很多,全亏,,,,不喜欢和某某打交道,,,心累!
  • 然后又想,如果我投资的一些东西,挣到钱了呢,心态是否有改观,,但是并没有,压根不喜欢,我基本上都是托管,只出钱,出不了人,然后就很难做,因为压根不喜欢,虽然亏钱了,我依旧没有多少时间精力在其他事情上。
  • 最后发现,我还是喜欢做培训,我很有耐心,很有经验,大家也喜欢听我吹牛批,我也喜欢吹牛批。
  • 很多人觉得我培训挣了很多,其实并没有多少,你看我这两年的宣传就知道,很少,,,今年8月份,开始改革,9月份回到上海,办了新的公司,9月份,公司营收回正,在向上走,但是前期投的钱还没有收回,还是负债状态,每个月发完工资,其他7788,我个人是一分不挣。小亏一点
  • 但是这么多学员出成绩,内心是非常开心的。这就是做培训的意义。
  • 兜兜转转,我又回到了培训,以前想换个行业挣钱,你会发现隔行如隔山,心态不一样,做自己擅长的事。。。我觉得这就是我擅长的事。
  • 不乱搞了,继续在这个培训这个行业做下去,,,
  • 这段时间,都在准备课程。录制视频。

观望未来

  • 越来越多的超级个体出来了。
  • 非常多的出海业务的涌出。
  • AI,,,让全栈变得无比容易,让普通程序员变得更加全能,让我们每个人变成超级个体的概率变得相当的大。
  • 因为包括我在内,我身边的很多人,都在搞,属于自己的智能体创业,成本贼低,,,因为最大的成本就是你自己的经历。
  • AI+英语+全栈 === 王炸

最后

  • 认清自己的定位,脚踏实地的努力!
  • 天总是会亮的,我们需要一点耐心,绝大多数人都死在了天亮之前,,,无论现在有多黑暗,天总会亮的!
  • 最后送大家一句诗:长风破浪会有时,直挂云帆济沧海!
  • 我是月哥,创业交流v:843655240

理清 https 的加密逻辑

作者 imoo
2025年12月30日 16:47

前言

http 是前端的老朋友了,也是面试常考点,最近看了《图解 http》,结合着 ai 重新理了一遍 https 相关的知识,在此分享。

http 的隐患

众所周知,前端与后端的交互,都需要走接口。绝大部分接口都是 http 协议,随便举一个登录例子:

GET /login?user=alice&password=123456 HTTP/1.1
Host: example.com
User-Agent: Chrome

在这个请求发出时,你的信息实际上在整条网络上都是透明的。假如你的网络经过路由器,那么路由器的主人就可以看到你这个请求,也就是能看到你的密码 user=alice&password=123456

所以国家总会提醒你,不要乱连 wifi,你的信息有可能被监听和泄露。

老前端们过去也总说,前端实际上没有安全性可言。在这个背景下,https 的推出也是理所当然的了。

升级之 https

https 实际上只是在 http 的基础上,增加了一个加密层 TLS,该加密层使用了非对称加密来保证数据不泄露。

非对称加密,区分了公钥和私钥,这里有个很巧妙的数学公式,使信息能任何人都能使用公钥进行加密,但又只能用私钥进行解密

这个非对称加密的过程相当消耗性能,几乎是原先的数倍甚至数十倍。有没有既可以加密,又保证性能的方案呢?

http 给出的答案是,在第一次通信时,使用非对称加密传递一个密码,随后使用该密码进行对称加密。这样一来,由于密码不会被中间者拦截,所以后续的对称加密也无法被轻易破解。

虽然在控制台上还是会看到正常的信息:

POST /api/login HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 42

{"username": "alice", "password": "123456"}

但这只对自己可见。数据在传递过程中完全被加密,抓包只能看到类似如下内容:

9a f1 23 8b ... 8e d0

也就是完全转化为了二进制(在抓包软件内会直接显示为十六进制),这样在没有私钥的情况下,就无法将数据还原。

细说加密逻辑

我们来稍微详细了解一下中间的流程。

首先复习下 http 的三次握手:

  1. 客户端发起链接请求,发送一个 SYN 标识
  2. 服务器确认可以收到客户端消息,发送一个 SYN-ACK 标识
  3. 客户端确认可以收到服务端消息,发送一个 ACK 标识

有个很好的比喻:1.喂?听得到吗, 2.我听得到,你听得到我吗 3. 我听得到

https 的加密流程,发生在三次握手之后

目前主流是 TLS 1.3 版本,但旧的 TLS 1.2 版本仍然有部分存量。这两者的加密有所不同,我们分开讨论:

在 TLS 1.2 中:

  1. 客户端发送 TLS 版本,加密算法,随机数 Client Random
  2. 服务器端发送确认使用的 TLS 版本,加密算法,随机数 Server Random
  3. 客户端校验证书后,再生成一个随机预主密钥 Pre-Master Key,并将 Pre-Master Key 使用公钥加密后,发送给服务端
  4. 服务端使用私钥,解开加密,随后双方根据这三个随机数Client RandomServer RandomPre-Master Key 生成对话主密钥 Master Key
  5. 根据 Master Key 生成临时对话秘钥
  6. 根据临时对话秘钥进行对称加密通信

但这里存在两个问题,1. 基于 rsa 算法的非对称加密,性能消耗还是比较严重 2. 过程繁琐。所以在 TLS 1.3 中对这两点进行了优化。

在 TLS 1.3 中:

  1. 客户端发送 TLS 版本,加密算法,客户端 Key Share
  2. 服务端发送确认使用的 TLS 版本,加密算法,服务端 Key Share
  3. 各自根据 Key Share,使用 ECDHE 算法算出共享密钥
  4. 使用共享密钥进行对称加密

可以看到,使用 ECDHE 算法,不仅降低了步骤数量,而且由于 ECHDE 所需要的秘钥长度更短,所以性能也更加优秀。

用 Flutter、SwiftUI 和 Compose 写同一个界面:一份真实开发者的实测报告

作者 JarvanMo
2025年12月30日 08:15

嘿,朋友!👋

还记得咱们以前老是为“哪种框架才是坠吊的”吵得不可开交吗?这回,我终于干了件每个开发者都放过狠话、但从来没真干过的事:我用 Flutter、SwiftUI 和 Jetpack Compose 把同一个界面撸了出来——而且全都严格遵循了 Clean Architecture(整洁架构) 原则。

欢迎关注我的微信公众号:OpenFlutter

说实话,最后的结果真把我给惊到了。

为什么这篇文章值得你看(没错,说的就是你)

听着,我懂。你点进来多半是因为:

  • 你正陷入“框架焦虑(FOMO)”中,急需定夺到底学哪一个
  • 老板问你“咱们项目用哪个好?”,你当时就慌了
  • 你是个 Flutter 开发者,总惦记着隔壁的草是不是更绿一些
  • 你要去面试了,想在聊跨平台开发时显得更有深度
  • 你心里其实已经选好了,就指望我能夸夸你的选择(剧透一下:没准儿真会哦)。

不管你是为了啥来的,我保证这不是那种“Hello World”级别的流水账。这是一个刚熬了三个通宵,在状态管理、依赖注入以及“等等,我这层数据到底怎么传给下一层来着?”这些地狱级难题里反复横跳后的真实感悟

终极挑战:一个界面,三套框架,拒绝借口

我做的是一个实战级别的“用户个人资料”页面,毕竟现在谁还稀罕看计数器(Counter App)啊?这玩意儿包含了:

  • 真实的远程 API 调用(带报错处理的那种);
  • 真正管用的本地缓存
  • 不至于丑得像垃圾一样的加载状态
  • 严谨的 Clean Architecture 划分(UseCases, Repositories,一个都不少);
  • 不会让你想摔电脑的路由导航

同样的设计,同样的架构,同样的功能。但实现它的过程,却是三种截然不同的体验。


我的实测体会(那些官宣里不会告诉你的事)

1. Flutter:真香,它是真的能干活

Flutter 最让我感到莫名的爽感的一点是:它居然…真的能跑?而且是哪儿都能跑。

我只写了一套代码,它就能完美运行在我的 iPhone、安卓测试机,甚至是网页浏览器上,而且我还没给编译器大神“烧香”。它的 Hot Reload(热重载) 快到离谱,刚开始我甚至以为它坏了——按下保存,修改立现。没有编译等待,没有漫长加载。这种生产力,简直是纯粹的享受。

Clean Architecture 的搭建过程也意外地顺滑。用 get_it 做依赖注入?简直绝了(Chef's kiss) 。这种目录结构让你觉得,这框架的设计者是真的考虑过开发者的心路历程的。

但反转来了: Dart 这门语言有点怪。它不差,只是…异类。如果你习惯了 Kotlin 或 Swift,你头一周肯定会反复纠结:“为什么这儿要写下划线?”以及“这个 late 关键字到底是个什么鬼?”

2. SwiftUI:果粉的快乐(但仅限果粉)

用 SwiftUI 开发就像开特斯拉:顺滑、极具未来感,但一旦你离开了它的生态圈,偶尔会觉得心累。

它的声明式语法太漂亮了。真的,写下 @State 然后看着 UI 自动更新,简直像在施魔法。那种只加一个 .animation() 就能搞定动画的感觉?凌晨两点,我在空无一人的公寓里忍不住叫了声“哇塞”。

但在 SwiftUI 里搞 Clean Architecture,事情就开始变得“复杂”了。SwiftUI 强迫你用 Combine 或 async/await(这本身挺好),但紧接着你就要处理各种 Publishers 和 Cancellables,搞得你的 ViewModel 看起来像飞船仪表盘一样。

最扎心的一点? 仅限 iOS(行吧,还有 macOS 和 watchOS,但你懂我意思)。你那一套漂亮的代码只能留在苹果的“后花园”里。没安卓,没网页。有的只是优雅的格调和高耸的围墙。

3. Jetpack Compose:安卓开发者的“救赎之路”

如果你曾深受 XML 布局的折磨,那 Compose 就像是沙漠里的甘露。Google 终于搞清楚开发者到底想要什么了。

Composable 函数写起来直观得一塌糊涂。全是 Kotlin,这意味着如果你懂 Kotlin,你就已经掌握了 70%。预览系统也很稳(前提是 Android Studio 决定配合你,不过绝大多数时候它还挺乖)。

Clean Architecture 实现? 确实很干净。Kotlin 的协程(Coroutines)让异步操作变得非常自然。用 Hilt 做依赖注入的集成极其丝滑,你会纳闷 Google 为什么花了这么多年才开窍。

但那头“房间里的大象”依然存在: 它目前基本只属于安卓。虽然 Compose Multiplatform 已经出来了,但咱们说实话——它还没到那种谁都能直接拿来跑生产环境的程度。你现在入坑,赌的是未来。


代码大对决(动真格的了)

来看看这段让我直呼“好家伙”的部分:在三套框架里分别实现同一个 UseCase。

Flutter (Dart):

class GetUserProfileUseCase {
  final UserRepository repository;
  
  GetUserProfileUseCase(this.repository);
  
  Future<Result<UserProfile>> call(String userId) async {
    return await repository.getUserProfile(userId);
  }
}

代码点评: 干净、简洁、异步优先。这个 call() 方法意味着你可以像调用函数一样直接运行它。这就是典型的 Dart 范儿(Dart vibes)。

SwiftUI (Swift):

protocol GetUserProfileUseCase {
    func execute(userId: String) async throws -> UserProfile
}

class GetUserProfileUseCaseImpl: GetUserProfileUseCase {
    private let repository: UserRepository
    
    init(repository: UserRepository) {
        self.repository = repository
    }
    
    func execute(userId: String) async throws -> UserProfile {
        try await repository.getUserProfile(userId: userId)
    }
}

这个 async throws 写法简直美如画。Swift 的类型安全机制强得惊人,但同时也意味着会有更多模板代码(Boilerplate)。就看你更中意哪一个了(Pick your poison)。

Compose (Kotlin):

class GetUserProfileUseCase @Inject constructor(
    private val repository: UserRepository
) {
    suspend operator fun invoke(userId: String): Result<UserProfile> {
        return repository.getUserProfile(userId)
    }
}

那个 operator fun invoke?简直是神来之笔(Chef's kiss) 。Kotlin 的简洁性让我的大脑极其舒适。再加上 Hilt 的 @Inject,依赖注入这件事变得像呼吸一样自然。

诚实豆沙包:到底什么才重要?

在把同一个功能写了三遍之后,我总结出了一些能真正帮你做决定的建议:

  • 选 Flutter,如果:

    • 你需要快速推向多个平台。
    • 你的团队懂 Dart(或者愿意学点新东西)。
    • 你把开发体验和热重载(Hot Reload)看作生命线。
    • 你在搞创业,恨不得昨天就能把 iOS + 安卓 + Web 版全上线。
    • 你不介意 Dart 的生态没有 Swift/Kotlin 那么“国民级”。
  • 选 SwiftUI,如果:

    • 你只打算在苹果的生态里混。
    • 你追求极致的 iOS 原生质感和性能。
    • 你的团队已经是 Swift 老手了。
    • 你爱“类型安全”胜过爱你的家人。
    • 你能接受最低系统要求是 iOS 14+(那些老机型用户只能说声抱歉了)。
  • 选 Compose,如果:

    • 你是安卓优先(或者目前只做安卓)。
    • 你的团队是 Kotlin 死忠粉。
    • 你想逃离 XML 布局的“创伤”,拥抱现代安卓开发。
    • 你愿意在 Compose Multiplatform 的未来上赌一把。
    • 你非常心水 Material Design 3 那种丝滑的集成感。

聊聊性能(毕竟总有人爱问这个)

说句实在话:对于 90% 的应用来说,性能差异几乎可以忽略不计。 你的用户发现不了,你的老板发现不了。甚至你自己也发现不了——除非你在写什么极其复杂的玩意儿。

但既然要比,那就拆开说:

  • Flutter: 编译成原生代码。快,丝滑的 60fps。Skia 渲染引擎已经经受了时间的考验。
  • SwiftUI: iOS 亲儿子。快。有时候在优化上显得“聪明过头”了(没错,说的就是那些神秘的视图更新逻辑)。
  • Compose: 安卓亲儿子。快。Kotlin 的运行时已经非常成熟且经过深度优化。 真正的性能瓶颈通常出在你的代码上,而不是框架。 很扎心,但这是事实。

开发体验(DX)的真相

有一点没人会告诉你:对于大多数项目来说,开发体验(DX)比原始性能更重要。

  • Flutter 赢在: 热重载速度(真的,快到没朋友)、跨平台的一致性、极其丰富的插件生态(pub.dev 是座宝库)、文档质量(Flutter 的文档堪称行业标杆)。
  • SwiftUI 赢在: IDE 集成度(Xcode 虽有槽点,但它是为 Swift 而生的)、类型安全和编译时纠错、Xcode Previews 预览(只要它不抽风)。
  • Compose 赢在: Kotlin 强大的语言特性、Android Studio 的深度整合、Material Design 的官方实现、以及那种“终于永远删掉 XML 文件”的解脱感。

社区现状(这关乎你的饭碗)

聊聊找工作和社区,毕竟大家都要吃饭:

  • Flutter 岗位: 增长极快。初创公司最爱,大厂也在观望。社区极其活跃且乐于助人。FlutterFlow 甚至让很多不写代码的人也开始入坑了。
  • SwiftUI 岗位: 在专注于苹果生态的公司里非常稳。想进大厂做 iOS,SwiftUI 是必修课。薪资普遍偏高,因为 iOS 开发一直自带溢价。
  • Compose 岗位: 现在的安卓岗位几乎都要求会 Compose。如果你精通 Compose,你就是安卓职场上的“独角兽”。各家公司都在从 XML 迁移,急需大腿。

我的真心推荐(我知道你们都在等这一段)

如果明天要开个新项目,只能选一个

我会选 Flutter。 在 SwiftUI/Compose 的粉丝开喷之前,听我解释: 一套代码搞定 iOS、安卓和 Web,这个价值实在太诱人了。它的开发体验始终如一地出色,社区充满活力,插件生态也已经熟透了。说真的,光是一个“热重载”省下来的时间,就足以抵消学习 Dart 的成本了。

但是(划重点):

  • 如果你只给 iOS 写 App?选 SwiftUI,别犹豫。
  • 如果你只给安卓写 App?选 Compose,冲就完了。
  • 如果你是在现有的原生 App 上修修补补?老老实实守着原有的技术栈。

关于架构的惊喜

有一点挺出乎意料:Clean Architecture 在 Kotlin/Compose 里感觉最自然。 Kotlin 的语言特性(密封类、协程、数据类)跟整洁架构原则简直是天作之合。Flutter 紧随其后。而 SwiftUI 则需要你跟协议(Protocols)和类型系统进行最多的“搏斗”。 但核心结论是:这三者都能完美适配架构。 这种原则是超越框架的,不管你写的是 Dart、Swift 还是 Kotlin,你的领域层(Domain Layer)看起来都应该差不多。

这对你意味着什么?

能看到这儿,你不是在摸鱼,就是真的感兴趣(或者两者都有,我懂,我也经常这样)。

基于这次实验,我的建议是:

  • 如果你是学生/新人:Flutter。练好 Dart,多动手做东西,发布到多个平台。攒一个能证明你具备“交付产品能力”的作品集。这种跨平台能力会让初创公司对你垂青有加。
  • 如果你是 iOS 开发: 还没学 SwiftUI 的赶紧学。但也建议你在业余项目里试试 Flutter,感受一下跨平台到底在火什么。
  • 如果你是安卓开发: 搞定 Compose。同时盯着点 Compose Multiplatform。未来的发展会非常有趣。
  • 如果你是做决策的技术 Leader: 看看你团队的技能树、项目工期和目标平台。其实选这三个里的任何一个都能做出成功的 App。框架选型的重要性,远不如团队的执行力重要。

那些让我彻夜难眠的坑

分享几个我踩过的坑,希望能帮你省下几个小时:

  1. 状态管理: 三个框架各有 47 种管理状态的方法。我最后选了 Provider (Flutter), Observable/Combine (SwiftUI) 和 ViewModel (Compose)。都行得通,但也各有各的小脾气。
  2. 导航: Flutter 的路由很直观;SwiftUI 的 NavigationStack 现在好多了,但以前真是折磨;Compose 的 Navigation 组件很稳,就是代码有点啰嗦。
  3. 测试: 都支持测试,但 Flutter 的 Widget Testing 感觉最成熟。SwiftUI 的预览测试很酷。Compose 的测试最全面,但配置起来稍麻烦。

最后的一点感言

说到底,这些都只是工具。你用它们造出了什么,才最重要。

我见过这三个框架分别写出的惊艳 App,也见过三个框架分别写出的垃圾。框架不能决定 App 的好坏,你才能。

选一个,深挖下去。做个让人惊艳的东西。然后再去学下一个,或者干脆不学,都没问题。这里没有标准答案。

轮到你了(该你发言了)

来,咱们评论区见:

  • 你是 Flutter 铁粉? 用它做出了赚钱的项目?来分享一下你的故事,社区需要真实的回馈。
  • 刚入坑移动开发? 随便问,这里的社区氛围很友好。
  • 职场老司机? 传授点面试经验。你招人时最看重什么技能?
  • 遇到瓶颈了? 把你的烦心事说出来,没准儿评论区就有大神帮过你解决了。

无论你是刚起步,还是已经发布过 10 个 App 的 Flutter 大佬——你都很棒。继续写码,继续学习。你的下一个 App 没准儿就能改变世界。

致所有的移动开发者: 给刚入行的新人留句鼓励的话吧。大家都经历过萌新阶段,让这个圈子更温暖一点。

最后一句话

我花了 72 小时做这个对比,就是为了让你不用再纠结。不管你是 Flutter 党、SwiftUI 党还是 Compose 党,我们其实都在干同一件事:做点酷的东西,让别人觉得好用。

选好你的框架。写出整洁的代码。发布你的 App。无视那些喷子。 记住:最好的框架,是那个能让你真正把项目做完的框架。

别看了,快去写代码吧!🚀

老王请假、客户开喷、我救火:一场递归树的性能突围战

2025年12月30日 08:04

前言:

上周,负责核心业务组件的同事老王突然请假(据说去相亲了),留下一堆代码和风中凌乱的我。

结果前脚刚走,后脚核心客户就炸锅了:“你们这个系统怎么回事?我每次要给员工赋个权,浏览器就直接卡死!打开弹窗挺快,一点开部门就未响应,关掉弹窗还要卡半天!”

看着客户发来的 十几秒卡顿录屏,和老板投来的“和善”目光,我只能硬着头皮接下了这个“锅”。

本文记录了我是如何从吐槽同事代码,到深度排查,最终通过深度优化解决这个 递归组件性能灾难的全过程。

省流版

  • 现象:展开树卡 10s+,关闭弹窗再卡 5s
  • 根因:递归组件隐式全量挂载 + 多实例并发导致 rAF 任务在单帧扎堆 + 高成本 height变化动画
  • 方案:懒加载可见节点 + 以“藏”代“删”(opacity/v-show)+ 合成层优化 (transform: scaleY)

看完本文,你将掌握

  1. 性能侦探能力:如何用 Performance 面板精准定位 Long Task 的“底层元凶”(不只是看红条)。

  2. 分帧渲染陷阱:为什么你写的 requestAnimationFrame 可能会让页面更卡?(含深度图解)。

  3. 高性能交互与动画:利用合成线程进行优化,绕过主线程的样式计算/布局/绘制阶段,实现 0 重排的丝滑展开/收起与销毁

一、 客户现场还原(案发经过)

授权弹窗虽然实现了秒开,但一旦点击展开根节点,瞬间卡死,足足卡顿数秒后才渲染出子节点。

组织架构树响应慢问题排查.gif

业务背景

客户正在使用我们的 “企业级权限管理系统”

出问题的组件是一个核心的 组织架构选择器(TreeSelect) ,它被嵌入在一个高频使用的 “授权弹窗” 中。

  • 高频场景:管理员需要频繁地给不同的员工或部门分配权限。

  • 操作路径:点击“可见范围” → 弹出授权弹窗 → 在树形组件中找到对应部门/人员 → 勾选 → 确定/关闭。

数据规模

该客户是中大型企业,全量组织架构节点约为 2700 个(包含多级部门和人员)。这个数量级在 ToB 业务中其实不算特别大,但足以压垮未经优化的代码。

故障现象

  1. 打开弹窗(快) :点击授权按钮,弹窗秒出(老王用了分帧渲染进行首屏优化,这点挺好)。

  2. 寻找部门(卡死) :管理员试图展开根部门去寻找下级单位,点击小三角的瞬间,界面失去响应,Long Task 持续 10s+ 。管理员以为死机了,疯狂点击,结果还是没反应。

  3. 关闭/取消(卡死) :好不容易选完了,点击“确定”或“关闭”弹窗,界面再次假死 4-5s,才能关掉弹窗。

二、 深度侦查:谁在谋杀主线程?

工程化思考:线上监控能发现吗?

Sentry 或其它性能监控能抓到这个问题吗?

线上监控通常只能告诉你 “页面卡了”(监控到 Long TaskINP 指标飙升),但很多时候难告诉你具体的 “为什么卡”。 要确诊是 JS 逻辑阻塞,还是 CSS 动画引发的 Reflow (重排),必须依赖本地 Chrome DevTools 的 Performance 面板 进行“CT 扫描”。

💡 小贴士:如何使用 Performance? 打开 F12 开发者工具 -> 切换到 Performance 面板 -> 点击左上角“圆点”开始录制 -> 在页面操作复现卡顿 -> 点击 Stop 停止,即可生成分析报告。

我在本地 Mock 了 2700 条左右的数据,模拟了“打开 -> 展开 -> 关闭”的全流程,打开 Chrome Performance 一看,好家伙,红得跟过年一样。

第一步:看“心电图” (Performance 面板)

image.png

现象

很多人看到 Performance 密密麻麻的图表就头晕,其实诀窍就一句话:从上往下看

  1. 看顶部(Main 线程):红色的色块代表 Long Task(长任务),是导致页面卡顿的直接元凶。
  2. 看中间(调用栈):像倒置的火焰一样,越宽代表耗时越久,越往下代表函数调用越深。
  3. 找凶手:顺着红条往下找,最底下的那个“宽条条”,通常就是罪魁祸首。

颜色图例(快速看懂图)

  • 红色斜纹:Long Task,主线程连续超 50ms
  • 黄色:JavaScript 执行(事件、定时器、requestAnimationFrame)
  • 紫色:样式与布局(Recalculate Style、Layout)
  • 绿色:绘制与合成(Paint、Composite)
  • 看到“Animation Frame Fired + Run Microtasks”连续出现,通常是每帧都在做很多 JS,并在帧内清空 Promise/nextTick,导致超预算

回到本案分析

  1. 顶部报警:最显眼的是一条红色斜纹带,说明主线程一直处于阻塞状态。

  2. 中间密集:顺着红色区域往下看,全是密密麻麻的黄色小条,提示 Animation Frame Fired(动画帧requestAnimationFrame触发)。

  3. 底部实锤:继续顺着调用栈 从上往下 找,能看到频繁的 cloneNode(克隆节点)调用。这说明核心耗时在 DOM 操作,浏览器在不断创建新元素。

结论:核心耗时在 DOM 操作,浏览器在疯狂加班造 DOM。cloneNode 就像是在复印文件,这说明代码在没完没了地制造新的页面元素,而且是一刻不停地在造,直接把主线程干趴下了。

第二步:顺藤摸瓜 (找代码)

image.png

线索: Performance 面板明确指出了凶手是 ReSubMenu.vue 里的 renderVisibleData 函数。

代码长这样

// 这是一个分帧渲染函数
function renderVisibleData(data, index = 0) {
  // 1. 先渲染一小部分(比如 20 个)
  visibleChildrenData.value.push(...data.slice(index, index + 20));

  // 2. 如果还有数据没渲染完,申请下一帧接着干
  if (还剩有数据) {
    requestAnimationFrame(() => {
      renderVisibleData(data, index + 20) // <--- 案发地点!
    })
  }
}

原本的算盘: 写这段代码的初衷是好的——试图利用 “分帧渲染” 技术来优化 首屏渲染体验不想让用户盯着白屏干等整棵树渲染完才看到,而是让第一帧渲染出的节点立刻显示给用户看

也就是代码里表达的意思:“浏览器大哥,您别急,先给用户看 20 个解解馋,剩下的咱们每帧画 20 个,慢慢来。”

💡 知识点:什么是分帧渲染?

浏览器的渲染就像一条流水线,比如我们的屏幕刷新率是 60Hz,那么屏幕每秒钟能刷新 60 次(即 60FPS),这样画面才流畅。这意味着每一帧的时间只有 16.6ms (1000ms / 60 ≈ 16.66ms)。

如果你一口气要在页面上画几千个节点,需要耗时就不止 16 毫秒。在渲染这几千个节点的过程中,浏览器的主线程被完全霸占,这时候浏览器就没空理会用户的点击、滚动,这就叫 “掉帧”“卡死”

requestAnimationFrame (rAF) 就是为了解决这个问题。它的作用是:“把大任务切碎”

这里的逻辑是:

  1. 这一帧,我先画 20 个节点,让用户 立刻看到东西(减少首屏等待)。
  2. 剩下的数据,我预约在 “下一帧” (requestAnimationFrame) 再慢慢画。
  3. 这样,既能快速展示内容,又能让浏览器每一帧都有空闲时间去响应用户的操作。

但奇怪的是,明明已经用了分帧渲染,为什么 Performance 面板里还是满屏红色的 Long Task?为什么点击展开时浏览器依然卡死了?

这就得说到那个被忽略的致命细节了……“分帧失效”的真正原因,请看下面的第三步

第三步:真相大白 (逻辑漏洞)

问题出在哪? 看似优雅的“慢慢来”,忽略了一个致命的前提:递归组件的叠加效应

我们的树形组件结构大概是这样的(典型的无限套娃):

  • 入口 (AuthorizedMenu.vue)
    • TreeSelect (外层容器)
      • ReSubMenu (递归的开始)
        • SubMenu (动画/折叠)
          • MenuItem (节点项)
          • ReSubMenu (子节点 -> 套娃开始)

这意味着,如果你的树数据有 5 层深,组件就会自己把自己嵌套 5 层。

我又看了一眼负责展开收起菜单动画的父组件 SubMenu.vue

<!-- 这里只用了 CSS 控制高度来折叠,没有用 v-if -->
<div class="subItem" :style="{ height: ... }">
  <slot></slot>
</div>

案情还原

  1. 看不见的“大军”: 界面上菜单虽然是收起的,但因为没加 v-if 控制按需加载,整棵树 2700 多个节点其实都在后台悄悄挂载了。这就好比你只点了一盘花生米,后厨却把满汉全席都备好了。

  2. 分帧策略失效: 老王的“分帧渲染”本意是好的:“大家别急,排好队,一帧画 20 个。” 但在递归组件中,几百个非叶子组件实例是同时启动的

  3. 菜市场效应

    • 第一层组件喊:“浏览器老师,麻烦帮我画 20 个!”
    • 第二层组件也喊:“我也要画 20 个!”
    • 第 N 层组件齐声喊:“还有我!还有我!”

结果:浏览器瞬间懵了。虽然每个人只请求画 20 个,但这几百个组件同时请求,瞬间就堆积了几千个 cloneNode 任务。这就好比几百只鸭子同时在叫,主线程直接被高并发的 DOM 操作给冲垮了。

你可能会问:“JS 不是单线程的吗?不是按顺序执行吗?怎么会‘并发’呢?”

  • 没错,JS 是单线程。但问题在于,Vue 的 v-for 循环是同步执行的

  • Vue 的机制是:必须等 v-for 这一轮循环彻底跑完,所有的子组件都初始化完毕(setup 执行结束),才会把控制权交还给浏览器去进行样式计算和绘制。

  • 这就导致了一个严重的后果:

    • 树组件中的所有节点(尤其是有子级的非叶子节点)在这一轮同步循环中,都启动了自己的分帧渲染逻辑,纷纷向浏览器申请:“下一帧请运行我的渲染回调”。

过程拆解如下:

  1. 同步初始化: Vue 遇到递归结构时,会一口气同步创建所有子组件实例。假设有 100 个子菜单,Vue 就会同步执行 100 次 setup 函数,中间不会停顿。

  2. 集体预约分帧渲染: 这 100 个组件在初始化时,都立刻启动了自己的分帧渲染逻辑,每个人都向浏览器申请了:“下一帧请运行我的渲染函数”。

  3. 扎堆执行: 到了下一帧,浏览器一看 requestAnimationFrame 的任务队列——好家伙,里面整整齐齐排了 100 个回调任务! 虽然 JS 是挨个执行这些任务的,但它必须把这 100 个回调任务全部跑完才能去刷新屏幕。

最终结果就是

  • 理想情况:1 个组件分帧 → 每帧耗时 2ms左右 → ✅ 丝滑流畅
  • 实际情况:100 个组件在同一帧里扎堆干活 → 总耗时 > 200ms → ❌ 严重掉帧 (Long Task)

这才是“分帧失效”的真正原因:同一帧的 rAF 回调队列被大量组件“同时”塞满,导致单帧工作量远远超出 16.66ms 的预算。

这也完美解释了为什么 Performance 火焰图 顶部会出现那条显眼的 红色长条 —— 它不仅仅是一个长任务,而是 n 多个组件中的分帧回调在这一帧内“排队”连续执行所耗费的总时长

你可能会有疑问:“rAF 不是宏任务吗?不应该是执行一个歇一下吗?”

  • 关键误区就在这! 准确地说,requestAnimationFrame 的回调不属于我们常说的“宏任务队列 (Macrotask Queue)”,它有自己独立的 “动画帧回调队列”。 根据 HTML 事件循环标准,这个队列的执行时机非常特殊:它夹在 “JS 执行”“样式计算/布局” 之间。

  • 致命的“批次执行”机制: 浏览器在这一帧的渲染更新阶段会连续执行本帧的所有 rAF 回调,执行完才进入样式/布局。也就是说,如果同一帧登记了 100 个 rAF,它们会在本帧内连续跑完,而不是留到后续帧。

    • :浏览器在刷新屏幕前会清空本帧的 rAF 队列,整体表现为一段连续的脚本执行;若本帧总耗时超过 50ms,就会被标记为 Long Task(否则只是普通task片段,不显示红条)。

在我们的场景里,这段连续执行远超 50ms,所以 DevTools 顶部标记为 Long Task

为了更直观地理解,我画了一张图来解释下:

image.png

三、 紧急救援:学会“偷懒”

吐槽归吐槽,Bug 还是得修。解决办法就是三个字:懒加载

核心逻辑

看不见的菜单,坚决不渲染! 只有当用户点击“展开”按钮那一刻,我才开始去渲染子菜单。

代码改动

把原来的“一上来就渲染”改成“盯着开关看”:

// ReSubMenu.vue

// 只有当 isExpand (展开状态) 变成 true 时,才开始干活
watch(
  () => props.data.isExpand,
  val => {
    if (val) {
      // 只有展开时,且之前没渲染过,才启动分帧渲染
      if (visibleChildrenData.value.length === 0) {
        renderVisibleData(props.data?.children)
      }
    } else {
       visibleChildrenData.value = []
    }
  },
  { immediate: true }
)

遗留问题:关闭弹窗卡死

关闭弹窗慢问题排查.gif

客户之前反馈“关闭弹窗也卡”,是因为同事老王的代码让组件一开始加载了几千个节点,内存中就堆积了几千个复杂的组件实例。

点击关闭弹窗的那一刻,主线程被 GC(垃圾回收)和 Vue组件的卸载任务(beforeUnmount / unmounted等)瞬间挤爆。

截图证据

image.png

大家看这张截图,关闭弹窗的一瞬间,removeChild 操作竟然耗时 5.41s!这意味着浏览器主线程被这个巨大的“拆迁工程”彻底堵死,用户只能对着屏幕发呆。

四、 深度优化方案

单纯的懒加载解决了“初始化”问题,但为了达到极致体验,我们还引入了其他维度的优化。

1. 渲染性能优化:用 ScaleY 替代 Height 动画

排查

  • 我看了下 CSS,菜单的展开/收起是用 transition: height 做的“窗帘”效果。

  • 每一级菜单都是改高度 height(收起为 0,展开为节点内容高度)。

  • height 是布局属性,动它就会触发 Reflow(重排),浏览器需要把受影响的布局链路重新算一遍。

  • 递归树会层层传导:你点开一层会牵动下面整段子树一起算,链路很长,低端机分分钟卡住

展开节点卡顿现象图:

展开菜单卡顿.gif

优化: 改为使用 CSS3 的 transform: scaleY,同样可以达到丝滑展开的效果。

简单理解 使用ScaleY实现展开动画效果的原理

  • 变的是什么? scaleY 改变的是元素在 Y 轴(竖向) 的缩放比例。
  • 怎么变?0(压扁成一条线,完全看不见)过渡到 1(拉伸回原本的高度)。
  • 为什么像展开? 配合 transform-origin: top(固定顶部),就像把卷帘门从上往下拉下来一样,视觉上就是完美的“展开”效果。

深度原理transform 属性不会触发 Reflow,只会触发 Composite (合成),这个过程完全由 GPU 处理,不占用主线程 CPU 资源。

浏览器渲染三兄弟

  • Reflow (重排):牵一发而动全身。修改 heightwidth 时,浏览器要重新计算所有元素位置,开销最大
  • Repaint (重绘):换汤不换药。修改 colorbackground 时,不影响位置,只重画样子,开销中等
  • Composite (合成)VIP 绿色通道。修改 transformopacity 时,浏览器直接把图层交给 GPU 处理,跳过布局和绘制,开销最小
/* 优化前:卡顿源头 */
.subItem {
  transition: height 0.3s ease-in-out;
}

/* 优化后:丝滑无比 */
.subItem {
  transform-origin: top; /* 确保从顶部开始展开 */
  transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}

.subItem.expanded {
  transform: scaleY(1);
}

.subItem.collapsed {
  transform: scaleY(0);
}

效果: 动画流畅、交互响应即时。(GIF图压缩严重,请自动脑补德芙广告的丝滑质感)

节点展开动画优化.gif

⚠️ 避坑指南:关于 will-change:

有同学可能会问:“为什么不加 will-change: transform,开启gpu加速?”

  • 少量元素时可以考虑;大规模递归树千万别全局加。
  • 参考 MDN:不到不得已不要使用;仅在确有必要时短时添加,过度使用会导致内存占用增加与性能下降。
  • 文档:developer.mozilla.org/zh-CN/docs/…
  • 建议:如需使用,按需、短时、少量元素,并在动画结束后移除。

2. 销毁层:以“藏”代“删”

痛点: 即使用户只是临时关闭弹窗,下次还要再开,原来的逻辑也是直接 v-if="false" 销毁整个组件树。这就导致了那 5 秒的 removeChild 卡顿。

优化: 对于这种巨型组件,关闭弹窗时不要销毁它,而是把它“藏起来”。

代码改动: 将弹窗的显隐控制从 v-if 改为 v-show,或者使用透明度方案:

v-if 、 Opacity、 v-show的区别

  • v-if:真正的条件渲染。切换时,组件及其内部所有的事件监听器和子组件都会被销毁和重建。对于 2500 个节点的树,这意味着成千上万次的 DOM 插入/删除操作。

  • v-show:简单的 CSS 切换(display: none)。组件实例始终保留,开销极小。

  • Opacity (透明)opacity: 0。DOM 保留,配合 GPU 加速仅触发合成 (Composite),切换成本最低(本案最终方案)。

/* 只是看不见,但 DOM 还在,避免了昂贵的卸载过程 */
.dialog-hidden {
  opacity: 0;
  pointer-events: none; /* 确保点不到 */
  z-index: -1;
}

效果: 关闭弹窗时,耗时从 5s 瞬间变为 0ms(只是改了个 CSS 属性)。下次再打开时,因为 DOM 都在,直接恢复透明度即可,实现了真正的“秒开”。

3. 交互层:手风琴模式(Accordion)

🤔 什么是手风琴效果?

简单来说,就是 同一级只有一个节点展开。 就像手风琴的风箱,拉开这一折,那一折自然合上。比如财务部和研发部是兄弟节点,当你点击展开“财务部”的子节点,之前点开的“研发部”会自动收起。

我们先来看优化后的效果图:

手风琴效果.gif

痛点: ToB 系统的用户有时操作很“野”,他们可能会把所有部门一层层全部点开。 如果不加限制,随着用户不断展开,页面上的 DOM 节点数量依然会无限制增长,最终再次拖慢浏览器。

优化: 为组件增加 accordion(手风琴)模式配置。 原理:开启后,同级节点同时只能展开一个。当你展开“财务部”时,之前展开的“研发部”会自动收起。

代码示例

// 伪代码:手风琴逻辑
function onNodeExpand(node) {
  if (accordionMode) {
    // 兄弟节点,统统收起!
    siblings.forEach(sib => {
      if (sib !== node) sib.isExpand = false
    })
  }
  node.isExpand = true
}

这从交互设计层面对 DOM 峰值设定了上限:不管怎么点,同级只保留一个展开项,页面同时存在的 DOM 数始终处于低位且可控。

番外篇:如果数据量再大 10 倍怎么办?

虽然这次 2000多条数据搞定了,但肯定有小伙伴会问:“如果有 2万多条 甚至更多怎么办?”

这时候,单纯的懒加载也不够用了,因为 DOM 节点的总数依然可能突破浏览器极限。我们需要引入核武器——虚拟树 (Virtual Tree)

简单来说,就是只渲染你屏幕里看得到的那些节点。 不管树有几万层,屏幕就那么高(比如 800px),我只渲染这几十个节点,其他的用个空 div 撑开高度把滚动条骗过去就行。

思路也很直白

  1. 拍平:把树拆成一个大的一维数组。
  2. 计算:算算当前滚动条在什么位置,对应数组里的哪几条数据。
  3. 渲染:只把这几条画出来,绝对定位到正确的地方。

那为什么这次没用? 还是那句老话:ROI(投入产出比)

手写虚拟树要处理动态高度、复选框联动等难点,头发都要掉一把;引入现成的库又会增加包体积。 对于 几千条数据,现在的方案已经够用了,再上虚拟树就是“大炮打蚊子”,没必要增加维护成本。

技术选型没有银弹,只有最适合当下的方案。

五、 优化结果与客户反馈

效果图:

优化后结果.gif

我们将优化后的补丁发给客户验证:

  1. 打开弹窗:保持秒开。
  2. 寻找部门(展开):从 十几秒卡死 变为 即时响应
  3. 完成授权(关闭):从 5s 卡死 变为 0 延迟

Performance 面板再次查看,那条心电图终于平稳了,只有零星的几个小波峰,代表正常的渲染任务。

客户反馈:“终于顺畅了,这才是专业系统该有的样子。”(老板终于露出了满意的微笑)

六、 总结:一点心里话

这次“救火”经历其实挺典型的。

刚接手时,看着那密密麻麻的代码和满屏的红色 Long Task,心里确实有点发怵。但静下心来,用 Performance 面板这把“手术刀”切下去,病灶其实很清晰:无节制的 DOM 操作昂贵的重排开销

回顾一下咱们这趟“排雷”之旅:

  1. 排查靠证据:Performance 面板诚不欺我,一眼就看到了 cloneNodeRecalculate Style 在疯狂作案。
  2. 手段要精准
    • 懒加载:别贪多,用多少拿多少,把几千个节点的并发压力拆解到每一次点击中。
    • 合成层优化:能用 GPU 解决的动画,坚决不麻烦 CPU,scaleYopacity 真是好东西。
    • 策略先行:有时候技术手段到了瓶颈,换个交互思路(比如手风琴模式),问题就迎刃而解了。

其实做性能优化,最忌讳的就是“凭感觉瞎猜”。这次虽然没用上高大上的虚拟列表(Virtual List),但对于当前的数据量级,这套组合拳不仅成本最低,效果也最好。

最后,老王回来后,我必须得请他吃顿好的——毕竟没有他这代码,我也没机会写这篇几千字的复盘文章,更没机会在掘金骗大家的赞(手动狗头)。

如果你觉得这篇文章对你有启发,欢迎 点赞、收藏、转发,这对我很重要!

Gemini 3 最新版!Node.js 代理调用教程

作者 木西
2025年12月31日 18:14

前言

本文以零基础视角出发,手把手教你使用 Node.js 结合代理配置调用 Gemini 3 模型,完整实现对话机器人的三种核心交互模式:单轮对话、流式调用与多轮对话。文中提供的所有代码均经过验证,可直接复制粘贴运行,无需额外修改(仅需配置个人代理和 API Key)。

一、核心流程梳理

1. 前置准备(必做)

这是调用 Gemini 模型的基础,缺一不可:

  • 代理配置:必须开启代理(因 Gemini 服务访问限制),并验证代理有效性:

    # Windows cmd 验证命令(替换 xxxx 为代理端口)
    curl -x http://127.0.0.1:xxxx -i https://httpbin.org/ip
    
  • API Key 获取:在 Google AI Studio 中创建并保存 GEMINI_API_KEY(后续配置到环境变量)。

  • Node 环境:通过 nvm 管理 Node 版本(无特殊版本限制,建议 22+):

    # 示例命令
    nvm install xx.xx.x  # 安装版本
    nvm use xx.xx.x      # 使用版本
    node -v              # 验证版本
    

2. 项目初始化

(1)初始化项目 & 安装依赖
# 初始化 package.json(一路回车即可)
npm init
# 安装核心依赖
npm install @google/genai dotenv undici
(2)依赖说明
依赖包 作用
@google/genai Gemini 官方 SDK,用于调用模型
dotenv 管理环境变量(存放 API Key)
undici 配置全局代理,让请求走代理通道
(3)目录结构(规范)

plaintext

project-name/
├── .env                # 配置 GEMINI_API_KEY(必加 .gitignore)
├── index.js            # 核心代码文件
├── package.json        # 项目配置
└── package-lock.json   # 依赖版本锁定
(4).env 文件配置

env

# .env 文件内容(替换为你的实际 API Key)
GEMINI_API_KEY=你的gemini_api_key_here

3. 核心调用方式(3 种)

所有调用方式都需要先配置代理和初始化 SDK,基础代码如下:

// index.js 通用前置代码
import 'dotenv/config'; // 加载 .env 环境变量
import { GoogleGenAI } from '@google/genai';
import { ProxyAgent, setGlobalDispatcher } from 'undici';

// 配置全局代理(替换 xxxx 为你的代理端口)
setGlobalDispatcher(new ProxyAgent('http://127.0.0.1:xxxx'));

// 初始化 Gemini 客户端
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
(1)单轮对话(一次性提问)

适用于无需上下文的单次提问,比如 “解释什么是 AI”:

async function singleTurn() {
  const result = await ai.models.generateContent({
    model: 'gemini-3-flash-preview', // 轻量版模型,响应快
    contents: [{ parts: [{ text: '解释一下什么是ai' }] }],
  });
  // 安全获取返回文本(避免 undefined 报错)
  const candidate = result.candidates?.[0];
  const text = candidate?.content?.parts?.map(p => p.text).join('') || '';
  console.log('单轮结果:', text);
}
singleTurn();
(2)流式调用(实时返回结果)

适用于生成较长内容(如写诗、写文章),实时输出内容,避免等待:

async function streamTurn() {
  const stream = await ai.models.generateContentStream({
    model: 'gemini-3-flash-preview',
    contents: '写一首 4 行短诗',
    // 配置生成参数:温度(随机性)、最大输出长度等
    config: { temperature: 0.9, topP: 0.9, maxOutputTokens: 300 }
  });
  // 逐块读取流式返回内容并输出
  for await (const chunk of stream) {
    const piece = chunk.candidates?.[0]?.content?.parts?.map(p => p.text || '').join('') || '';
    process.stdout.write(piece); // 实时输出,不换行
  }
  console.log('\n--- 流式完成 ---');
}
streamTurn();
(3)多轮对话(带上下文)

适用于需要上下文的连续提问,比如 “先学 Node.js 什么模块”,需维护对话历史:

async function chatTurn() {
  // 存储对话历史(用户+模型的消息)
  const history = [];
  
  async function ask(prompt) {
    // 调用模型,传入历史记录+当前提问
    const result = await ai.models.generateContent({
      model: 'gemini-3-pro-preview', // 专业版模型,理解能力更强
      contents: [...history, { role: 'user', parts: [{ text: prompt }] }]
    });
    // 安全获取模型返回文本
    const candidate = result.candidates?.[0];
    const text = candidate?.content?.parts?.map(p => p.text).join('') || '';
    
    // 更新对话历史(必须存,否则下一轮无上下文)
    history.push({ role: 'user', parts: [{ text: prompt }] });
    history.push({ role: 'model', parts: [{ text: text }] });
    return text;
  }

  // 连续提问示例
  console.log('AI:', await ask('你好,我想学 Node.js'));
  console.log('AI:', await ask('先学什么模块比较好?'));
}    
chatTurn();

4. 运行代码

# 直接运行 index.js(Node 22+ 需开启 ES 模块,或改后缀为 .mjs)
node index.js
# 若报错 "Cannot use import statement outside a module",修改 package.json 加一行:
# "type": "module",

二、关键注意事项

  1. 代理端口:所有代码中的 xxxx 需替换为你实际的代理端口(如 Clash 常用 7890),否则无法访问 Gemini。

  2. 模型选择

    • gemini-3-flash-preview:轻量版,响应快,适合简单提问 / 流式生成;
    • gemini-3-pro-preview:专业版,理解能力强,适合多轮对话 / 复杂问题。
  3. 免费额度限制:Google 免费版有调用次数 / Token 限制,超出后需付费,建议避免高频大量调用。

  4. 错误处理:实际使用时建议加 try/catch 捕获异常(如网络问题、API Key 错误),示例:

    async function singleTurn() {
      try {
        // 原有代码
      } catch (error) {
        console.error('调用失败:', error.message);
      }
    }
    

总结

  1. 核心前提:开启并验证代理、获取有效 GEMINI_API_KEY、配置 Node 环境和依赖;
  2. 调用方式:单轮(一次性提问)、流式(实时返回)、多轮(带上下文,需维护历史);
  3. 关键细节:替换代理端口、选择合适模型、处理可能的异常,注意免费额度限制。

祝大家 2026 年新年快乐,代码无 bug,需求一次过

作者 前端Hardy
2025年12月31日 18:05

新年将至,你是否想为亲友制作一个特别的新年祝福页面?今天我们就来一起拆解一个精美的 2026 新年祝福页面的完整实现过程。这个页面包含了加载动画、动态倒计时、雪花特效、悬浮祝福卡片等炫酷效果,完全使用 HTML、CSS 和 JavaScript 实现。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

代码讲解

雪花生成算法

function createSnowflake(container, index) {
  const snowflake = document.createElement('div');

  // 随机属性:大小、位置、时长
  const size = Math.random() * 5 + 2;
  const x = Math.random() * 100;
  const duration = Math.random() * 10 + 5;
  const delay = Math.random() * 5;

  // 设置基础样式
  snowflake.classList.add('snowflake');
  snowflake.style.left = `${x}%`;
  snowflake.style.width = `${size}px`;
  snowflake.style.height = `${size}px`;
}

雪花飘落动画

snowflake.animate(
  [
    { transform: 'translate(0, -20px) rotate(0deg)' },
    { transform: `translate(${Math.sin(duration)*50}px, 100vh) rotate(90deg)` },
    { transform: `translate(${-Math.sin(duration)*25}px, 100vh) rotate(180deg)` },
    { transform: 'translate(0, 100vh) rotate(360deg)' },
  ],
  {
    duration: duration * 1000,
    delay: delay * 1000,
    iterations: Infinity,
    easing: 'linear'
  }
);
  • 使用 Math.sin()制造左右摆动的飘落路径
  • 为每个雪花设置不同的延迟和时长,增加真实感
  • 无限循环(Infinity)实现持续飘落
  • 响应式设计:根据屏幕宽度调整雪花数量

时间计算逻辑

function updateCountdown() {
  const now = new Date();
  const newYear = new Date(2026, 0, 1, 0, 0, 0, 0);
  const difference = newYear.getTime() - now.getTime();

  // 计算天、时、分、秒
  const days = Math.floor(difference / (1000 * 60 * 60 * 24));
  const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
  const minutes = Math.floor((difference / 1000 / 60) % 60);
  const seconds = Math.floor((difference / 1000) % 60);
}

数字格式化

// 两位数格式化
document.getElementById('seconds').textContent =
  String(seconds).padStart(2, '0');
  • 使用 setInterval(updateCountdown, 1000)实现秒级更新
  • padStart()确保始终显示两位数字
  • 防抖处理避免性能问题

自定义动画定义

@keyframes float {
  0% { transform: translateY(0px); }
  50% { transform: translateY(-15px); }
  100% { transform: translateY(0px); }
}

@keyframes glow {
  0%, 100% {
    text-shadow: 0 0 5px rgba(255, 215, 0, 0.5);
  }
  50% {
    text-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
  }
}

一键复制源码

<!doctype html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>2026新年祝福 - 元旦快乐</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
        crossorigin="anonymous" />
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
        // Tailwind CSS 配置
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        primary: '#ef4444',
                        secondary: '#f59e0b',
                    },
                    fontFamily: {
                        sans: ['Inter', 'system-ui', 'sans-serif'],
                    },
                },
            }
        }
    </script>
    <style type="text/tailwindcss">
        @layer utilities {
        .content-auto {
          content-visibility: auto;
        }
        .text-shadow-glow {
          text-shadow: 0 0 10px rgba(255, 215, 0, 0.7), 0 0 20px rgba(255, 215, 0, 0.5);
        }
        .animate-float {
          animation: float 4s ease-in-out infinite;
        }
        .animate-float-delay-1 {
          animation: float 4s ease-in-out 1s infinite;
        }
        .animate-float-delay-2 {
          animation: float 4s ease-in-out 2s infinite;
        }
        .animate-float-delay-3 {
          animation: float 4s ease-in-out 3s infinite;
        }
        .animate-pulse-soft {
          animation: pulseSoft 2s ease-in-out infinite;
        }
        .animate-fade-in {
          animation: fadeIn 1s ease-out forwards;
        }
        .animate-slide-up {
          animation: slideUp 1s ease-out forwards;
        }
        .animate-slide-up-delay-1 {
          animation: slideUp 1s ease-out 0.3s forwards;
        }
        .animate-slide-up-delay-2 {
          animation: slideUp 1s ease-out 0.6s forwards;
        }
        .animate-slide-up-delay-3 {
          animation: slideUp 1s ease-out 0.9s forwards;
        }
        .animate-scale-in {
          animation: scaleIn 0.5s ease-out forwards;
        }
        .animate-glow {
          animation: glow 2s ease-in-out infinite;
        }
      }

      @keyframes float {
        0% { transform: translateY(0px); }
        50% { transform: translateY(-15px); }
        100% { transform: translateY(0px); }
      }

      @keyframes pulseSoft {
        0% { opacity: 0.7; }
        50% { opacity: 1; }
        100% { opacity: 0.7; }
      }

      @keyframes fadeIn {
        from { opacity: 0; }
        to { opacity: 1; }
      }

      @keyframes slideUp {
        from { opacity: 0; transform: translateY(50px); }
        to { opacity: 1; transform: translateY(0); }
      }

      @keyframes scaleIn {
        from { opacity: 0; transform: scale(0.9); }
        to { opacity: 1; transform: scale(1); }
      }

      @keyframes glow {
        0%, 100% { text-shadow: 0 0 5px rgba(255, 215, 0, 0.5); }
        50% { text-shadow: 0 0 20px rgba(255, 215, 0, 0.8); }
      }

      /* 全局样式 */
      html, body {
        height: 100%;
        overflow-x: hidden;
        scroll-behavior: smooth;
      }

      body {
        margin: 0;
        padding: 0;
      }

      /* 雪花样式 */
      .snowflake {
        position: absolute;
        background-color: white;
        border-radius: 50%;
        pointer-events: none;
        z-index: 0;
      }

      /* 加载动画 */
      .loading-screen {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: #dc2626;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 9999;
        transition: opacity 0.8s ease-out;
      }

      .loading-hidden {
        opacity: 0;
        pointer-events: none;
      }
    </style>
</head>

<body class="bg-gradient-to-b from-red-800 via-red-700 to-red-900 text-white">
    <!-- 加载动画 -->
    <div id="loading-screen" class="loading-screen">
        <h1 class="text-4xl font-bold text-white" id="loading-text">2026</h1>
    </div>

    <!-- 主要内容容器 -->
    <div class="relative min-h-screen overflow-hidden">
        <!-- 雪花容器 -->
        <div id="snow-container" class="fixed inset-0 pointer-events-none z-0"></div>

        <!-- 装饰星星 -->
        <div class="absolute top-10 right-10 w-20 h-20">
            <div class="animate-float">
                <i class="fa-solid fa-star text-yellow-300 text-4xl animate-pulse-soft"></i>
            </div>
        </div>

        <div class="absolute bottom-20 left-10 w-16 h-16">
            <div class="animate-float-delay-1">
                <i class="fa-solid fa-star text-yellow-300 text-3xl animate-pulse-soft"></i>
            </div>
        </div>

        <!-- 主要内容 -->
        <div class="container mx-auto px-4 py-16 relative z-10">
            <!-- 标题部分 -->
            <div class="text-center mb-12 opacity-0 animate-slide-up">
                <h1
                    class="text-4xl sm:text-6xl md:text-7xl font-bold mb-4 bg-gradient-to-r from-yellow-300 to-yellow-500 bg-clip-text text-transparent animate-glow">
                    2026新年快乐!
                </h1>
                <p class="text-xl sm:text-2xl text-yellow-100 opacity-0" id="subtitle">
                    愿您新的一年里心想事成,万事如意
                </p>
            </div>

            <!-- 倒计时部分 -->
            <div class="text-center mb-16 opacity-0 animate-slide-up-delay-1">
                <h2 class="text-2xl sm:text-3xl font-semibold mb-6">距离2026年还有</h2>
                <div class="grid grid-cols-4 gap-4 sm:gap-8" id="countdown-container">
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="days">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="hours">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="minutes">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="seconds">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                </div>
            </div>

            <!-- 祝福卡片部分 -->
            <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16 opacity-0 animate-slide-up-delay-2">
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:rotate-1 cursor-pointer opacity-0"
                    id="wish-card-1">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">身体健康,万事如意</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:-rotate-1 cursor-pointer opacity-0"
                    id="wish-card-2">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">事业有成,财源广进</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:-rotate-1 cursor-pointer opacity-0"
                    id="wish-card-3">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">家庭幸福,平安顺遂</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:rotate-1 cursor-pointer opacity-0"
                    id="wish-card-4">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">心想事成,吉祥如意</p>
                    </div>
                </div>
            </div>

            <!-- 新年图片 -->
            <div
                class="relative mx-auto max-w-2xl rounded-2xl overflow-hidden shadow-2xl mb-16 opacity-0 animate-slide-up-delay-3">
                <img src="https://space.coze.cn/api/coze_space/gen_image?image_size=landscape_16_9&prompt=New%20Year%202026%2C%20celebration%2C%20fireworks%2C%20happy%20people%2C%20chinese%20new%20year%20style&sign=7480eb84f78aa7d9bd5edaf5dc5aad19"
                    alt="2026新年庆祝"
                    class="w-full h-auto rounded-2xl transform transition-transform hover:scale-105 duration-700" />
                <div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent flex items-end">
                    <p class="text-white p-6 text-lg">祝大家2026年元旦快乐!</p>
                </div>
            </div>

            <!-- 按钮部分 -->
            <div class="flex flex-wrap justify-center gap-4 opacity-0 animate-slide-up-delay-3">
                <button
                    class="bg-gradient-to-r from-yellow-500 to-amber-500 text-red-900 font-bold py-3 px-8 rounded-full text-lg shadow-lg transform transition-transform duration-300 hover:scale-105 hover:shadow-lg hover:shadow-yellow-500/20 active:scale-95"
                    id="share-button">
                    <i class="fa-solid fa-share-alt mr-2"></i>分享祝福
                </button>
                <button
                    class="bg-transparent border-2 border-yellow-500 text-yellow-500 font-bold py-3 px-8 rounded-full text-lg transform transition-all duration-300 hover:bg-yellow-500/10 hover:scale-105 active:scale-95"
                    id="music-button">
                    <i class="fa-solid fa-music mr-2"></i>播放音乐
                </button>
            </div>

            <!-- 底部装饰 -->
            <div class="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-red-900 to-transparent opacity-0 animate-fade-in"
                id="bottom-decoration"></div>

            <!-- 底部文字 -->
            <footer class="text-center mt-20 text-white/60 text-sm opacity-0 animate-fade-in" id="footer">
                <p>© 2025 新年祝福页面 | 祝您新年快乐,阖家幸福</p>
            </footer>
        </div>
    </div>

    <script>
        // 页面加载动画
        document.addEventListener('DOMContentLoaded', function () {
            const loadingScreen = document.getElementById('loading-screen');
            const loadingText = document.getElementById('loading-text');
            const subtitle = document.getElementById('subtitle');
            const countdownItems = document.querySelectorAll('#countdown-container > div');
            const wishCards = document.querySelectorAll('[id^="wish-card-"]');
            const bottomDecoration = document.getElementById('bottom-decoration');
            const footer = document.getElementById('footer');

            // 加载文字动画
            loadingText.animate([
                { transform: 'scale(1)' },
                { transform: 'scale(1.2)' },
                { transform: 'scale(1)' }
            ], {
                duration: 1000,
                iterations: Infinity
            });

            // 500ms后隐藏加载屏幕,显示主内容
            setTimeout(() => {
                loadingScreen.classList.add('loading-hidden');

                // 显示副标题
                setTimeout(() => {
                    subtitle.classList.add('animate-fade-in');
                }, 500);

                // 显示倒计时项
                countdownItems.forEach((item, index) => {
                    setTimeout(() => {
                        item.classList.add('animate-scale-in');
                    }, 800 + index * 200);
                });

                // 显示祝福卡片
                wishCards.forEach((card, index) => {
                    setTimeout(() => {
                        card.classList.add('animate-scale-in');
                    }, 1600 + index * 200);
                });

                // 显示底部装饰和页脚
                setTimeout(() => {
                    bottomDecoration.classList.add('animate-fade-in');
                }, 2400);

                setTimeout(() => {
                    footer.classList.add('animate-fade-in');
                }, 2800);
            }, 1000);

            // 初始化倒计时
            updateCountdown();
            setInterval(updateCountdown, 1000);

            // 初始化雪花效果
            createSnowflakes();

            // 按钮事件处理
            document.getElementById('share-button').addEventListener('click', function () {
                alert('祝福已分享!');
            });

            document.getElementById('music-button').addEventListener('click', function () {
                alert('音乐播放功能即将上线!');
            });
        });

        // 倒计时功能
        function updateCountdown() {
            const now = new Date();
            const newYear = new Date(2026, 0, 1, 0, 0, 0, 0);
            const difference = newYear.getTime() - now.getTime();

            if (difference > 0) {
                const days = Math.floor(difference / (1000 * 60 * 60 * 24));
                const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
                const minutes = Math.floor((difference / 1000 / 60) % 60);
                const seconds = Math.floor((difference / 1000) % 60);

                document.getElementById('days').textContent = String(days).padStart(2, '0');
                document.getElementById('hours').textContent = String(hours).padStart(2, '0');
                document.getElementById('minutes').textContent = String(minutes).padStart(2, '0');
                document.getElementById('seconds').textContent = String(seconds).padStart(2, '0');
            } else {
                document.getElementById('days').textContent = '00';
                document.getElementById('hours').textContent = '00';
                document.getElementById('minutes').textContent = '00';
                document.getElementById('seconds').textContent = '00';
            }
        }

        // 雪花效果
        function createSnowflakes() {
            const snowContainer = document.getElementById('snow-container');
            const particleCount = window.innerWidth < 768 ? 20 : 50;

            for (let i = 0; i < particleCount; i++) {
                createSnowflake(snowContainer, i);
            }

            // 窗口大小改变时重新创建雪花
            window.addEventListener('resize', function () {
                while (snowContainer.firstChild) {
                    snowContainer.removeChild(snowContainer.firstChild);
                }
                const newParticleCount = window.innerWidth < 768 ? 20 : 50;
                for (let i = 0; i < newParticleCount; i++) {
                    createSnowflake(snowContainer, i);
                }
            });
        }

        function createSnowflake(container, index) {
            const snowflake = document.createElement('div');
            snowflake.classList.add('snowflake');

            // 随机属性
            const size = Math.random() * 5 + 2;
            const x = Math.random() * 100;
            const duration = Math.random() * 10 + 5;
            const delay = Math.random() * 5;
            const opacity = Math.random() * 0.5 + 0.3;

            // 设置样式
            snowflake.style.left = `${x}%`;
            snowflake.style.top = '-20px';
            snowflake.style.width = `${size}px`;
            snowflake.style.height = `${size}px`;
            snowflake.style.opacity = `${opacity}`;

            // 添加到容器
            container.appendChild(snowflake);

            // 创建动画
            snowflake.animate(
                [
                    { transform: 'translate(0, -20px) rotate(0deg)' },
                    { transform: `translate(${Math.sin(duration) * 50}px, 100vh) rotate(90deg)` },
                    { transform: `translate(${-Math.sin(duration) * 25}px, 100vh) rotate(180deg)` },
                    { transform: 'translate(0, 100vh) rotate(360deg)' },
                ],
                {
                    duration: duration * 1000,
                    delay: delay * 1000,
                    iterations: Infinity,
                    easing: 'linear'
                }
            );
        }
    </script>
</body>

</html>

祝大家 2026 年新年快乐,代码无 bug,需求一次过!

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

🎣 拒绝面条代码!手把手带你用自定义 Hooks 重构 React 世界

2025年12月31日 17:41

💡 写在前面:你是否还在为 React 组件里那一堆乱糟糟的 useStateuseEffect 感到头秃?是否觉得业务逻辑和 UI 代码像缠在一起的耳机线一样难解难分?

别慌,今天咱们不聊虚的。我们要化身“代码外科医生”,拿起 Custom Hooks(自定义 Hooks) 这把手术刀,把业务逻辑从组件里漂亮地剥离出来。

本文将通过两个实战案例——“鼠标追踪器”“硬核 TodoList”,带你从零开始领悟 Hooks 的设计哲学。准备好了吗?发车!🚗💨


🧐 第一章:Hooks 到底是个啥?

在 React 16.8 之前,函数组件就是个“花瓶”,只负责渲染 UI,没有状态(State),也没有生命周期。如果你想搞点复杂的逻辑,就得写那个笨重的 Class 组件,this 指针指来指去,指到你怀疑人生。

Hooks 的出现,就是为了给函数组件注入灵魂。

它是一种函数式编程思想的体现。简单来说,Hooks 就是一堆以 use 开头的魔法函数,它们让函数组件也能拥有状态管理和生命周期处理的能力。

常用“双子星”

在我们开始自定义 Hooks 之前,必须先复习一下两个最基础的 Hooks,因为自定义 Hooks 本质上就是对它们的封装复用

  1. useState状态的容器

    • 它让函数组件有了“记忆”。
    • const [state, setState] = useState(initialValue);
    • 记住:React 的状态更新是**不可变(Immutable)**的,不要直接修改 state,要用 setState 传入新值。
  2. useEffect副作用的管家

    • 什么是副作用?数据获取、订阅事件、修改 DOM... 凡是跟渲染 UI 没直接关系的事儿,都叫副作用。
    • 它相当于 Class 组件里的 componentDidMountcomponentDidUpdatecomponentWillUnmount 的合体。
    • useEffect(() => { ... return () => cleanup }, [dependencies])

好,基础复习完毕。现在我们要搞点高级的——自定义 Hooks

🌟 核心概念:自定义 Hooks 就是一个普通的 JavaScript 函数,但它遵循两个规则:

  1. 名字必须以 use 开头(这是给 React 插件看的,也是给队友看的)。
  2. 它可以调用其他的 Hooks(这是它强大的根本原因)。

🐭 第二章:初试牛刀——打造“如影随形”的鼠标追踪器

想象一下,你接到了一个需求:在页面的任何地方,都要实时显示当前鼠标的坐标。

如果你把逻辑直接写在组件里,你的组件很快就会变得臃肿。如果好几个组件都需要这个功能呢?难道要复制粘贴代码?No! DRY (Don't Repeat Yourself)!

我们要封装一个 Hook。

2.1 顺藤摸瓜:从 App.jsx 看起

先来看看我们在 App.jsx 里是想怎么使用它的:


// 引入我们即将编写的神器
import { useMouse } from './hooks/useMouse.js';

// ... 其他引入

function MouseMove() {
 
  const { X, Y } = useMouse();
  
  return (
    <>
      <div>
        鼠标位置:{X}, {Y}
      </div>
    </>
  )
}

😲 看!这里多么清爽!

我们不需要关心鼠标怎么监听,不需要关心事件怎么销毁

我们只管“拿”数据,这就是“声明式”编程的美妙

组件变得极其纯粹,它只负责渲染。所有的脏活累活,都扔给了 useMouse

2.2 核心解密:useMouse.js

接下来,我们潜入 useMouse.js,看看这个 Hook 内部到底长什么样。


🎯 封装响应式的 mouse 业务

  • 为什么要封装?
  • 因为 UI 组件应该更简单,只负责 HTML + CSS。 逻辑复用,是前端团队的核心资产!
import {
    useState,
    useEffect
} from 'react';


export const useMouse = () => {

1️⃣ 定义状态:我们需要记录 X 和 Y 坐标

    const [X, setX] = useState(0);
    const [Y, setY] = useState(0);

2️⃣ 定义事件处理函数

  • 这个函数会在每次鼠标移动时被调用
   useEffect(() => {
     
     const update = (event) => {
       // 更新状态,这将触发使用了该 Hook 的组件重新渲染
       setX(event.clientX);
       setY(event.clientY);
     }

3️⃣ 绑定事件监听

     
      // 相当于 componentDidMount
      window.addEventListener('mousemove', update);

4️⃣ ⚠️ 极其重要:清理副作用!

  • 如果不写这个 return 函数,当组件卸载时,事件监听器依然存在。
  • 这会导致严重的【内存泄漏】,控制台会疯狂报错,浏览器会变卡。
      // 相当于 componentWillUnmount
      return () => {
        window.removeEventListener('mousemove', update);
      }
    }, []); // 👈 注意这个空数组
    // 依赖项为空数组 [],意味着这个 effect 只在组件挂载时执行一次,
    // 并且在组件卸载时执行清理函数。

5️⃣ 返回数据

    // 把组件需要的状态暴露出去
    return {
        X,
        Y
    }
}

🔍 深度解析:

  1. 状态驱动:我们要追踪鼠标,本质上就是追踪 xy 两个数字的变化。所以用了两个 useState
  2. 副作用管理:监听 windowmousemove 事件是一个典型的副作用。
  3. 依赖项陷阱
    • 如果 useEffect 的第二个参数不传,它会在每次渲染后都执行。如果你在这里绑定事件,那完了,你会绑定几千个监听器。
    • 传入 [],告诉 React:“嘿,这事儿只在组件出生和死亡时做一次,中间别烦我。”
  4. 内存泄漏(Memory Leak)
    • 这是 React 面试必考题。
    • readme.md 中也提到了这一点:“组件卸载时需要清除事件监听/定时器,否则会导致内存泄漏”
    • React 的 useEffect 允许返回一个函数,这个函数就是专门用来擦屁股的。一定要记得 removeEventListener

📝 第三章:进阶实战——企业级 TodoList 逻辑分离

鼠标追踪只是热身,现在我们要搞点真家伙。我们要写一个 TodoList(待办事项清单)。

你可能会说:“切,TodoList 我闭着眼都能写。”

别急,这次我们不写面条代码。我们要把所有的业务逻辑(增、删、改、查、持久化)全部抽离到一个 useTodos Hook 中。这就叫 Headless UI(无头组件) 设计思想——逻辑与视图分离。

3.1 顶层设计:App.jsx 的视角

先看 App.jsx,它是怎么组织这个应用的。

import { useTodos } from './hooks/useTodos.js';
// ... 引入组件

export default function App() {
  // ...
  const {
    todos,       // 数据列表
    addTodo,     // 添加方法
    toggleTodo,  // 切换状态方法
    deleteTodo   // 删除方法
  } = useTodos();

✨ 魔法时刻 ✨

  • 一行代码,获取了整个 Todo 应用所需的所有数据和方法!
  • 就像去超市买了一个“Todo大礼包”,回家拆开就能用。
  return (
    <>
      {/* 把方法传给输入组件 */}
      <TodoInput addTodo={addTodo} />
      
      {/* 条件渲染:有数据才显示列表 */}
      {
        todos.length > 0 ? 
        (<TodoList 
          todos={todos} 
          toggleTodo={toggleTodo} 
          deleteTodo={deleteTodo}
        />) : 
        (<div>暂无待办事项</div>)
      }
    </>
  )
}

妙啊! App.jsx 变得极其干净。它根本不知道 Todo 是怎么存的,也不知道删除逻辑是 filter 还是 splice,它只负责传递

3.2 核心引擎:useTodos.js

这是本篇文章的重头戏。我们深入 useTodos.js 看看它是怎么运作的。

import {
    useState,
    useEffect
} from 'react';

⭐常量提取,好维护

const STORAGE_KEY = 'todos'; 

🛠️ 辅助函数:从 LocalStorage 读取数据

  • 放在组件外面,因为它不依赖组件内的任何状态,纯函数
function loadFromStorage() {
    const storedTodos = localStorage.getItem(STORAGE_KEY);
    return storedTodos ? JSON.parse(storedTodos) : [];
}

🛠️ 辅助函数:保存数据到 LocalStorage


function saveToStorage(todos) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

1️⃣. 惰性初始化 (Lazy Initialization)

这里的 useState 接收了一个函数 loadFromStorage,而不是直接通过 loadFromStorage() 调用。

为什么?

  • 因为 localStorage 读取是昂贵的 IO 操作。
  • 如果直接写 loadFromStorage(),每次组件渲染都会读一次 Storage。
  • 传函数引用,React 只会在组件【首次渲染】时调用它。 这是一个非常高级且实用的优化技巧!🚀
export const useTodos = () => {
    
    const [todos, setTodos] = useState(loadFromStorage);

2️⃣ 数据持久化

每当 todos 状态变化时,自动同步到 localStorage。 这样用户刷新页面,数据也不会丢。

    
    useEffect(() => {
        saveToStorage(todos);
    }, [todos]); // 依赖项是 todos

3️⃣ 业务逻辑:

多使用es6新特性如结构,map,filter

    // 添加 Todo~~~~~~~~~
    const addTodo = (text) => {
        // ⚠️ 永远不要直接修改 state,比如 todos.push(...) 是绝对禁止的!
        // 必须创建一个新数组。
        setTodos([
            ...todos, // 展开旧数据
            {
                id: Date.now(), // 用时间戳做 ID
                text,
                completed: false
            }
        ])
    }

    //  切换完成状态~~~~~~~~~~
    const toggleTodo = (id) => {
        setTodos(
            todos.map(todo => {
                if(todo.id === id) {
                    // 同样,不要直接修改 todo.completed = !todo.completed
                    // 要返回一个新的对象
                    return {
                        ...todo,
                        completed: !todo.completed
                    }
                }
                return todo;
            })
        )
    }

    //  删除 Todo~~~~~~~~~
    const deleteTodo = (id) => {
        // filter 返回的是新数组,完美符合 React 的不可变性要求
        setTodos(
            todos.filter(todo => todo.id !== id)
        );
    }

4️⃣ 暴露接口

    // 返回一个对象,方便使用者解构
    return {
        todos,
        addTodo,
        toggleTodo,
        deleteTodo
    }
}

💡 知识点总结:

  • Lazy Initialization(惰性初始化)useState(() => heavyComputation())。这招在处理大数据初始化时非常有用,能显著提升性能。
  • Immutability(不可变性):你看 addTodo 用了 [...todos]toggleTodo 用了 mapdeleteTodo 用了 filter。这些都是生成新数组的方法,而不是修改原数组。这是 React 状态更新的金科玉律。
  • 关注点分离useTodos 只管数据怎么变,不管数据怎么展示

3.3 组件落地:三剑客的配合

逻辑有了,现在看看 UI 组件怎么消费这些逻辑。

1. 也是“大脑”的延伸:TodoInput.jsx

TodoInput.jsx 负责收集用户输入。

import { useState } from 'react';

export default function TodoInput ({addTodo}) {
    // 这个 state 是 UI 状态(输入框里的字),属于组件私有
    const [text, setText] = useState('');

    const handleSubmit = (e) => {
        e.preventDefault(); // 阻止表单默认提交刷新页面
        if(!text.trim()) return; // 空校验
        
        // 调用父组件(其实是 Hook)传来的方法
        addTodo(text.trim());
        
        // 清空输入框
        setText('');
    }

    return (
        <form className="todo-input" onSubmit={handleSubmit}>
            <input 
                type="text"
                value={text} // 受控组件value 绑定 state
                onChange={e => setText(e.target.value)} // onChange 更新 state
            />
            <button type="submit">添加</button>
        </form>
    )
}

这里体现了 React 的**受控组件(Controlled Components)**思想:Input 的值由 React 的 State 掌控,而不是 DOM 自身。

2. 中转站:TodoList.jsx

TodoList.jsx 其实是个“傻瓜组件”(Dumb Component),它只负责遍历。

import TodoItem from './TodoItem';

export default function TodoList({
    todos,
    toggleTodo,
    deleteTodo
}) {
    return (
        <ul className="todo-list">
            {
                todos.map(todo => (
                    // 🔑 key 属性至关重要!
                    // 它帮助 React 识别哪些元素改变了、添加了或删除了。
                    // 这里的 key={todo.id} 是最佳实践,千万别用 key={index}!
                    <TodoItem
                        key={todo.id}
                        todo={todo}
                        toggleTodo={toggleTodo}
                        deleteTodo={deleteTodo}
                    />
                ))
            }
        </ul>
    )
}

3. 最终呈现:TodoItem.jsx

TodoItem.jsx 负责渲染单条数据。

export default function TodoItem({
    todo,
    toggleTodo,
    deleteTodo
}) {
    return (
        <li className="todo-item">
            {/* 复选框:控制完成状态 */}
            <input 
                type="checkbox" 
                checked={todo.completed} 
                onChange={() => toggleTodo(todo.id)} 
            />
            
            {/* 动态类名:控制样式 */}
            <span className={todo.completed ? 'completed' : ''}>
                {todo.text}
            </span>
            
            {/* 删除按钮 */}
            <button onClick={() => deleteTodo(todo.id)}>删除</button>
        </li>
    )
}

🎩 总结:自定义 Hooks 的“心法”

通过这两个例子,我们其实只做了一件事:把逻辑从 View 层剥离

为什么要这么做?

  1. 复用性(Reusability):如果明天老板让你在侧边栏也做一个 Todo 列表,你只需要在侧边栏组件里 const { ... } = useTodos(),一秒搞定。
  2. 可测试性(Testability):测试 useTodos 里的纯逻辑,比测试一个混合了 DOM 操作的组件要简单得多。
  3. 清晰度(Readability):你的组件代码量减少了,逻辑更清晰了,不管是自己看还是同事看,都更舒服。

Hooks 是一种心智模型。当你看到一段复杂的逻辑时,下意识地想:“能不能把它抽成一个 Hook?” 恭喜你,你已经从 React 萌新进阶了!


希望这篇文章能帮你打通 Hooks 的任督二脉!我们下期见!👋

Vue3 防重复点击指令 - clickOnce

2025年12月31日 17:34

Vue3 防重复点击指令 - clickOnce

一、问题背景

在实际的 Web 应用开发中,我们经常会遇到以下问题:

  1. 用户快速多次点击提交按钮:导致重复提交表单,产生多条相同数据
  2. 异步请求未完成时再次点击:可能导致数据不一致或服务器压力增大
  3. 用户体验不佳:没有明确的加载状态反馈,用户不知道操作是否正在进行

这些问题在以下场景中尤为常见:

  • 表单提交(注册、登录、创建订单等)
  • 数据保存操作
  • 文件上传
  • 支付操作
  • API 调用

二、解决方案

clickOnce 指令通过以下机制解决上述问题:

1. 节流机制

使用 @vueuse/coreuseThrottleFn,在 1.5 秒内只允许执行一次点击操作。

2. 按钮禁用

点击后立即禁用按钮,防止用户再次点击。

3. 视觉反馈

自动添加 Element Plus 的 Loading 图标,让用户明确知道操作正在进行中。

4. 智能恢复

  • 如果绑定的函数返回 Promise(异步操作),则在 Promise 完成后自动恢复按钮状态
  • 如果是同步操作,则立即恢复

三、核心特性

自动防重复点击:1.5秒节流时间
自动 Loading 状态:无需手动管理 loading 变量
支持异步操作:自动检测 Promise 并在完成后恢复
优雅的清理机制:组件卸载时自动清理事件监听
类型安全:完整的 TypeScript 支持

四、技术实现

关键技术点

  1. Vue 3 自定义指令:使用 Directive 类型定义
  2. VueUse 节流useThrottleFn 提供稳定的节流功能
  3. 动态组件渲染:使用 createVNoderender 动态创建 Loading 图标
  4. Promise 检测:自动识别异步操作并在完成后恢复状态

工作流程

用户点击按钮
    ↓
节流检查(1.5秒内只执行一次)
    ↓
禁用按钮 + 添加 Loading 图标
    ↓
执行绑定的函数
    ↓
检测返回值是否为 PromisePromise 完成后(或同步函数执行完)
    ↓
移除 Loading + 恢复按钮状态

五、使用方法

1. 注册指令

// main.ts
import clickOnce from '@/directives/clickOnce'

app.directive('click-once', clickOnce)

2. 在组件中使用

<template>
  <!-- 异步操作示例 -->
  <el-button 
    type="primary" 
    v-click-once="handleSubmit">
    提交表单
  </el-button>

  <!-- 带参数的异步操作 -->
  <el-button 
    type="success" 
    v-click-once="() => handleSave(formData)">
    保存数据
  </el-button>
</template>

<script setup lang="ts">
const handleSubmit = async () => {
  // 模拟 API 调用
  await api.submitForm(formData)
  ElMessage.success('提交成功')
}

const handleSave = async (data: any) => {
  await api.saveData(data)
  ElMessage.success('保存成功')
}
</script>

六、优势对比

传统方式

<template>
  <el-button 
    type="primary" 
    :loading="loading"
    :disabled="loading"
    @click="handleSubmit">
    提交
  </el-button>
</template>

<script setup lang="ts">
const loading = ref(false)

const handleSubmit = async () => {
  if (loading.value) return
  
  loading.value = true
  try {
    await api.submit()
  } finally {
    loading.value = false
  }
}
</script>

问题

  • 需要手动管理 loading 状态
  • 每个按钮都要写重复代码
  • 容易遗漏 finally 清理逻辑

使用 clickOnce 指令

<template>
  <el-button 
    type="primary" 
    v-click-once="handleSubmit">
    提交
  </el-button>
</template>

<script setup lang="ts">
const handleSubmit = async () => {
  await api.submit()
}
</script>

优势

  • 代码简洁,无需管理状态
  • 自动处理 loading 和禁用
  • 统一的用户体验

七、注意事项

  1. 仅用于异步操作:该指令主要为异步操作设计,同步操作会立即恢复
  2. 绑定函数必须返回 Promise:对于异步操作,确保函数返回 Promise
  3. 节流时间固定:当前节流时间为 1.5 秒,可根据需求调整 THROTTLE_TIME 常量
  4. 依赖 Element Plus:使用了 Element Plus 的 Loading 图标和样式

八、适用场景

适合使用

  • 表单提交按钮
  • 数据保存按钮
  • 文件上传按钮
  • API 调用按钮
  • 支付确认按钮

不适合使用

  • 普通导航按钮
  • 切换/开关按钮
  • 需要快速连续点击的场景(如计数器)

九、指令源码

import type { Directive } from 'vue'
import { createVNode, render } from 'vue'
import { useThrottleFn } from '@vueuse/core'
import { Loading } from '@element-plus/icons-vue'

const THROTTLE_TIME = 1500

const clickOnce: Directive<HTMLButtonElement, () => Promise<unknown> | void> = {
  mounted(el, binding) {
    const handleClick = useThrottleFn(
      () => {
        // 如果元素已禁用,直接返回(双重保险)
        if (el.disabled) return

        // 禁用按钮
        el.disabled = true
        // 添加 loading 状态
        el.classList.add('is-loading')

        // 创建 loading 图标容器
        const loadingIconContainer = document.createElement('i')
        loadingIconContainer.className = 'el-icon is-loading'

        // 使用 Vue 的 createVNode 和 render 来渲染 Loading 组件
        const vnode = createVNode(Loading)
        render(vnode, loadingIconContainer)

        // 将 loading 图标插入到按钮开头
        el.insertBefore(loadingIconContainer, el.firstChild)

        // 将 loading 图标存储到元素上,以便后续移除
        ;(el as any)._loadingIcon = loadingIconContainer
        ;(el as any)._loadingVNode = vnode

        // 执行绑定的函数(应返回 Promise 或普通函数)
        const result = binding.value?.()

        const removeLoading = () => {
          el.disabled = false
          // 移除 loading 状态
          el.classList.remove('is-loading')
          const icon = (el as any)._loadingIcon
          if (icon && icon.parentNode === el) {
            // 卸载 Vue 组件
            render(null, icon)
            el.removeChild(icon)
            delete (el as any)._loadingIcon
            delete (el as any)._loadingVNode
          }
        }

        // 如果返回的是 Promise,则在完成时恢复;否则立即恢复
        if (result instanceof Promise) {
          result.finally(removeLoading)
        } else {
          // 非异步操作,立即恢复(或根据需求决定是否恢复)
          // 通常建议只用于异步操作,所以这里也可以不处理,或给出警告
          removeLoading()
        }
      },
      THROTTLE_TIME,
    )

    // 将 throttled 函数存储到元素上,以便在 unmount 时移除
    ;(el as any)._throttledClick = handleClick
    el.addEventListener('click', handleClick)
  },

  beforeUnmount(el) {
    const handleClick = (el as any)._throttledClick
    if (handleClick) {
      el.removeEventListener('click', handleClick)
      // 取消可能还在等待的 throttle
      handleClick.cancel?.()
      delete (el as any)._throttledClick
    }
  },
}

export default clickOnce

十、总结

clickOnce 指令通过封装防重复点击逻辑,提供了一个开箱即用的解决方案,让开发者可以专注于业务逻辑,而不用担心重复点击的问题。它结合了节流、状态管理和视觉反馈,为用户提供了更好的交互体验。

这应该是前端转后端最简单的办法了,不买服务器、不配 Nginx,也能写服务端接口,腾讯云云函数全栈实践

2025年12月31日 17:32

前言:后端开发真的太累了

作为一个想做独立开发的前端或全栈工程师,每当你想写个小项目(比如工具箱、记账本、个人博客)时,热情往往在配置后端的瞬间熄灭:

  1. 买服务器:几百块一年,性能还一般。
  2. 配环境:SSH 连上去,装 Node、PM2、Nginx,防火墙配置。
  3. 搞域名:买域名、备案(最劝退的一步)、配置 HTTPS 证书。
  4. 写接口:纠结 RESTful 规范,/api/v1/add 还是 /add?参数放 Body 还是 Query?

我就想写个 简单 的接口,至于这么折腾吗?

今天,我要给你安利一套 “零成本”、“免运维”、“免域名” 的全栈接口方案: 腾讯云 SCF (云函数) + 函数 URL + rpc-server-scf

你只需要写纯粹的 JS 函数,它就能自动变成 HTTP 接口,不用管服务器,甚至不用管 Nginx!


🚀 什么是 rpc-server-scf?

它是 js-rpc 生态中专为 腾讯云云函数 (SCF) 设计的服务端框架。

它的核心理念是:把云函数变成一个 RPC 服务器。 你不需要关心 HTTP 请求的报文结构,不需要关心 API 网关的参数透传。你只需要在 api/ 目录下写函数,前端就能直接调用。


🛠️ 后端实战:3步搭建云端 RPC

我们将使用腾讯云 SCF 的 “函数 URL” 功能,它会直接分配给你一个公网 HTTPS 地址,让你彻底告别 API 网关配置和域名备案。

第一步:准备代码

在本地创建一个文件夹,初始化 npm:

mkdir my-scf-rpc
cd my-scf-rpc
npm init -y
npm install rpc-server-scf

第二步:编写业务逻辑 (api/math.js)

我们不需要写路由,只需要在 api 目录下创建文件。文件名就是模块名。

api/math.js

module.exports = {
  // 加法
  async add(a, b) {
    return a + b;
  },
  
  // 乘法
  async multiply(a, b) {
    return a * b;
  },

  // 稍微复杂点的逻辑
  async calculate(params) {
    // 这里可以连数据库、Redis,或者做任何后端逻辑
    const { x, y, op } = params;
    if (op === 'minus') return x - y;
    return 0;
  }
}

index.js (入口文件)

一行代码,启动 RPC 服务:

const { createRpcServer } = require('rpc-server-scf');

// 导出 main_handler 给云函数调用
exports.main_handler = createRpcServer();

这就写完了! 没有 Express,没有 app.listen,代码极其干净。

第三步:部署到腾讯云 SCF(开启“白嫖”模式)

  1. 登录 腾讯云云函数控制台

  2. 点击 “新建” -> 选择 “从头开始”

    • 函数名称:随便填,比如 rpc-demo
    • 运行环境:Nodejs 16.13 或以上。
  3. 上传代码

    • 将你的项目文件夹(包含 node_modules)压缩成 zip 包上传,或者直接在在线编辑器里创建文件并安装依赖。
  4. 🔥 关键步骤:启用访问服务 (函数 URL)

    • 在“访问服务”或“触发器”配置中,找到 【函数 URL】
    • 点击 “启用”
    • 鉴权方式:选择 “不校验 (PUBLIC)”(为了演示方便,生产环境可在代码里用中间件鉴权)。
    • 你将获得一个长这样的地址:https://你的函数ID.scf.tencentcs.com

    (如图所示,不用买域名,直接送你一个 HTTPS 的公网地址!)


⚡️ 前端如何调用?

现在你的后端已经跑在云端了。前端(小程序、UniApp、Web)怎么调?

我们使用配套的客户端 rpc-client-request(专为小程序/UniApp/HTTP场景设计)。

安装

npm install rpc-client-request

调用代码

在小程序或 UniApp 中:

import { create } from 'rpc-client-request';

// 填入刚才腾讯云分配给你的“函数 URL”
const rpc = create({
  url: 'https://service-xxxx.scf.tencentcs.com/release/rpc-demo' 
});

// 业务调用
async function test() {
  try {
    // 像调用本地函数一样!
    // 自动发请求给 api/math.js 的 add 方法
    const sum = await rpc.math.add(10, 20);
    
    console.log('计算结果:', sum); // 30
    
  } catch (err) {
    console.error('调用出错', err);
  }
}

完事! 你没有写 wx.request,没有拼接 URL,没有处理 POST 参数,一切就像在写本地代码。


🌟 为什么这套方案是“独立开发者”的神器?

1. 真正的“零运维” (Serverless)

你不需要维护服务器进程,不需要配置 Nginx 反向代理,不需要担心服务器挂掉。腾讯云帮你托管,有请求自动唤醒,没请求自动休眠。

2. 免域名,自带 HTTPS

利用 SCF 的 “函数 URL” 功能,你省去了购买域名、备案(通常需要半个月)、申请 SSL 证书的所有繁琐流程。起步阶段直接用官方链接,省时省力。

3. “白嫖”级成本

腾讯云 SCF 有免费额度(或者非常低廉的按量付费)。对于个人项目、测试项目或者低频工具类应用,成本几乎为 0。只有当你的业务真的做大了,才需要支付少量费用。

4. 极致的开发体验

  • 后端:只写业务函数,文件即路由。
  • 前端:RPC 调用,像调本地方法一样顺滑。
  • 全栈:你可以把精力 100% 放在业务逻辑上,而不是浪费在 HTTP 协议的翻译工作中。

🔗 总结与资源

如果你厌倦了传统的后端开发流程,想快速上线一个全栈应用,SCF + 函数 URL + js-rpc 绝对是你现在的最佳选择。

  • 项目 GitHub: github.com/myliweihao/…
  • 服务端 SDK: npm install rpc-server-scf
  • 小程序客户端: npm install rpc-client-request

别再让“服务器运维”阻挡你改变世界的创意了,现在就开始吧!

2025 提效别再卷了:当我把 AI 当“团队”,工作真的顺了

作者 优弧
2025年12月31日 17:27

image.png 哈喽朋友们,我是优弧!

前阵子我有个很真实的崩溃瞬间:凌晨两点,需求还在飞,事儿多到永远做不完,我在工位上跟AI来回对话——聊着聊着我突然发现,我根本不是在“提效”,我是在“聊天”

你们有没有这种感觉:AI明明很强,但你用起来像在打乒乓球——我发一句,它回一段;我再补一句,它又换个方向跑;最后上下文越堆越长,人越看越累,效率还不如自己硬写。

后来我想通了一个点(扎心但很管用):很多人2025年还在卷 提示词 ,其实真正的提效不在提示词,而在“工作方式”

把AI当聊天框,你就只能得到聊天;把AI当团队,你才会得到交付。

先说清楚:这篇会聊到我正在用的一些工具和设备(其中包括我最近入手的明基 RD280U编程显示器),在软件和硬件层面都是付费用户,这些事情会让我的Vibe coding更快,更稳,也欢迎大家与我讨论分享。

所以这篇我就按“软件工作流 → 硬件底盘”的顺序来讲:软件上怎么把AI当团队把活交付出来,硬件上 RD280U 怎么把多窗口和长时间盯屏这两件事稳住——最后你会发现,提效变顺很多时候不是更用力,而是摩擦更少。

01 提效第一原则:先别堆工具,先把“摩擦”拆掉

很多人讲提效,上来就:

  • 装一堆插件
  • 换一堆模型
  • 背一堆提示词“咒语”

结果是:工具越多,切换越多;切换越多,注意力越碎;注意力越碎,越想摆烂。

真正浪费时间的,往往是你每天都在重复的“摩擦动作”:

  • 在窗口之间来回切
  • 在对话框里反复补上下文
  • 滚动条滚到怀疑人生(滚着滚着就丢上下文)
  • 打字打到手指发热(还容易错别字)

所以我现在的2025提效链路,核心只干一件事:把摩擦从高频动作里剔掉

02 把AI当“团队”:靠谱模型各司其职,别指望一个模型包打天下!

我现在的心态很明确:

模型不是“神”,模型是“员工”。员工要分工,不分工就会内耗。

你可以按手里能用的模型替换,但我自己这套分工是按“谁擅长干哪类活”来分的:

  • 前端界面/视觉稿:用更擅长UI生成的模型快速出原型(推荐Gemini 3 Pro)
  • 规划执行文档:用更擅长结构化的模型把任务拆开、把Done写清楚(推荐Codex)
  • 落地写代码/修Bug:用更擅长工程实现、能长期协作的模型跟进落地(推荐Claude Code)

你会发现:当你让模型做它擅长的事,你就不会频繁“返工重问”。这就是最朴素的提效。

03 放弃手动输入:语音 + Prompt 纠错,省下来的不是时间,是意志力!

这一条我真心推荐:别再手动敲那么多字了, 原因很现实:

  • 打字很慢
  • 打字很累
  • 打字会降低你“把信息讲完整”的欲望

我现在基本是: 想到什么先语音讲出来(越口语越好),再让AI按固定Prompt做三件事:

  • 纠错错别字
  • 补齐逻辑结构
  • 输出成可执行的任务清单

你会突然发现:你不是在“写”,你是在“说清楚”。 而“说清楚”本来就是人类最擅长的事。

04 尽早接受“文档驱动”:别等Bug爆了才发现你连需求都没定

我以前也不爱写文档:觉得麻烦、浪费时间。 后来我被现实教育了:不写文档,最后一定会用加班还债。 我现在会尽量用一种“工程化的spec框架”来约束自己(思路类似 spec-kit):

  • 目标是什么(Done 的定义)
  • 输入输出是什么(接口、数据、边界)
  • 不做什么(明确排除项)
  • 风险在哪里(依赖、性能、兼容)

这套东西的价值在于: 你写给AI看,AI更不容易跑偏;你写给自己看,自己也不容易忘;你写给同事看,同事少骂两句。提效的终点不是“更快写代码”,而是“更少返工”。

05 你以为是 AI 不够强?很多时候是你“看不清、看不多”

说到这里,就绕不开硬件:

我觉得2025年很多人提效卡住,是因为屏幕太小/比例不对/护眼不行,导致你看两小时就开始烦躁,然后效率雪崩。

我自己之前是典型症状:

  • 一边IDE一边AI面板,再加终端,屏幕直接挤爆
  • 看日志/看长函数疯狂滚动,滚着滚着就丢上下文
  • 晚上加班开深色主题,还是刺眼、眼干、头疼

后来我换成 明基 RD280U,体验变化很“朴素”,但很关键:

1)3:2 的方屏比例:上下文更完整,脑子更不容易断片

RD280U 是 28.2英寸3840×25603:2。 单看参数不刺激,但实际写代码的时候非常直观:同一屏能看到更多代码行,尤其适合“左中右三栏 + 底部终端”的AI编程布局。

我最明显的感受,是“思路更连续”。以前用 16:9,Code Review 碰到长函数,或者你在日志里追一段异常堆栈,经常滚一下才能看到 if/else 对称结构的另一半;滚动的那一下,其实就是打断。换成 3:2 后,很多时候能在一屏里把前因后果收尾,注意力不容易断片。

体感上,我在IDE里大概能多看到 6-8 行左右的代码(取决于字体/行高)。这在看调用链、对比两份文件、读长日志/SQL 输出的时候最值:不是“多看几行”那么简单,而是你不用频繁在脑子里缓存上下文。

缺点也很现实:它主要是 60Hz 的定位,不是电竞向;另外如果你强需求竖屏,要留意不同型号支架的能力。

2)抗反射 + 夜间方案:减少“看屏的痛苦”,才有持续输出

这一块我觉得特别适合“白天办公室 + 晚上回家接着干”的人。

白天最烦的其实不是亮,而是反光:下午阳光斜射、顶灯一照,你就会下意识调整角度、或者把亮度拉爆去“硬刚”。抗反射面板把眩光压下来之后,你不用再跟环境光对抗,视线更稳,心态也更稳。

可以看到下图是两块屏幕在同一位置、同一角度模拟强光照射的炫光效果(左图明基、右图其他)。

到了晚上,另一个典型痛点是“屏幕亮、环境黑”的反差。我的用法很简单:把亮度压到一个更低的舒适区,同时开夜间相关方案(比如低亮度夜间模式 + 背部的 MoonHalo 环形补光),让环境光更柔和。最直接的变化是:写到后半夜,眼睛干涩来得更慢,注意力也更不容易飘。

当然,任何护眼方案都不是让你无限熬夜的借口;背光也不等同于台灯,需要的话还是得补环境光。

3)编程模式:别把“看代码”当成普通显示器的默认任务

很多显示器谈护眼,更多是“降低刺激”;但程序员真正的痛点是:我们看的不是图片,是结构化文本,是语法高亮,是一堆高频对比的小信息。 RD280U的“编程模式”之所以值得单独拎出来讲,是因为它解决的不是“看得见”,而是“看得清、看得久、看得不烦”。

它的实现方式也很“工程化”:不是在系统里套个滤镜,而是在硬件侧提供专门的色彩模式,去适配深色/浅色两种常见主题,并把“层次感”做出来——关键字、字符串、注释、报错提示之间更容易区分,但又不是靠硬拉亮度去刺激眼睛。再加上一键切换,能把“我又要去调一堆参数”的折腾感直接砍掉。

我自己的体验是:深色编程模式下,红色报错提示、橙色警告、灰色注释的“分层”更明显;盯久了眼睛那种“被迫用力识别”的感觉会少很多,整体是更松弛的。

怎么开启/切换编程模式有很多种方式。我推荐选一个“你最顺手、最不费脑”的方式固定下来。

最简单的方式是触摸280U显示器下方的专属编码键,即可快速切换编程模式。

当然你也可以直接用显示器OSD菜单,路径一般是 菜单色彩模式 → 选 编程(深色)/编程(浅色)

同时你还可以装 Display Pilot 2,在系统里点一下就切,甚至可以按时间自动切(白天浅色、晚上深色)。

至于怎么按个人偏好微调,我的原则是:先保证“舒服”和“清晰”,再追求“好看” 。顺序上通常是:先根据IDE主题选深色或浅色当底座;再把亮度降到“看久不累”;然后小幅调对比度和锐利度,让字和背景更分层、字边缘更清楚但不假锐;最后按环境把色温/低蓝光往中性或偏暖挪一点。如果你用 Display Pilot 2,把这套参数存成预设并绑定快捷切换,会省掉每天重复操作的摩擦。

如果你调完还觉得“不够清楚”,我的经验是:先检查IDE字体、行高、抗锯齿(这是经常被忽略但影响巨大的三件事),别一上来就把显示器亮度拉爆。

4)KVM + USB-C 一线通:减少桌面乱线,减少切换成本

这项功能对“需要在两台设备间切换”的人特别爽:比如白天公司电脑在内网写业务,晚上切回个人电脑做开源/学习;或者一台跑本地服务,一台开资料/会议。

正确连线后,键盘鼠标直接插在显示器上,通过切换信号源实现KVM切换;再加上 USB-C 把视频+数据+供电合一(最高 90W),桌面线材会从“盘丝洞”变成“清爽模式”。它的价值不是省几秒,而是你不会因为嫌麻烦就一直拖着不切设备——很多人不是不想切,是不想拔线、配对、重连。

需要注意的是,KVM 很吃“正确的线材与连接方式”;

90W 的反向充电对大多数笔记本都是够用的,还省去了一根充电线,桌面会更整洁。

5)Display Pilot 2:把“每天重复设置”交给自动化

我以前也觉得显示器软件是鸡肋,直到我真的每天要开一堆窗口:IDE、终端、浏览器文档、API工具、AI面板、日志……这时候你会发现,真正累人的不是某一次拖拽,而是“一天几十次对齐窗口”的高频摩擦。

Display Pilot 2 的桌面分区有点像给屏幕加了“吸附磁铁”,窗口拖过去就自动贴好;场景切换则更像给显示器加了日程表,到点自动切到更适合当下的模式。它不一定会让你惊呼,但会让你少抱怨、少被小动作打断。

它的缺点也很朴素:要装软件,偶尔系统升级后得等适配更新;但整体属于“能用且有用”的那类配套。

06 最后一个提效建议:锻炼身体,保护眼睛,别把自己当机器人

这条听起来像鸡汤,但我认真讲:它是我2025最硬核的提效工具。

你再会用AI、再会写Prompt、再懂框架,如果你:

  • 眼睛不行
  • 颈椎不行
  • 精神不行

那你的输出上限就会被身体锁死……我现在给自己定了很粗暴的规则:

  • 连续专注 45-60 分钟就起身走动
  • 晚上尽量把环境光补起来(别在黑暗里怼屏)
  • 有条件就上更舒服的显示器(别跟眼睛过不去)

提效不是把你卷到报废,是让你更长久地输出。

07 总结:这条“更软”的提效链路,你可以直接抄

如果你只想带走一个可执行版本,我给你压缩成 7 句话:

  1. 模型分工:别一个模型硬扛所有活
  2. 语音输入:别手动敲一大堆上下文
  3. spec框架:先定目标、边界、Done,再开干
  4. 少滚动少切换:能同屏就同屏,能自动就自动
  5. 屏幕要对:比例、可读性、护眼,都是生产力底座
  6. 工具要服务流程:别为了工具而工具
  7. 身体要顶得住:眼睛和颈椎是你最值钱的资产

如果你跟我一样,天天跟代码、日志、AI面板打交道,想把“摩擦”降下来,明基 RD280U 这类编程向显示器确实值得你认真看一眼(我自己就是用上之后不太想回去了)。

lecen:一个更好的开源可视化系统搭建项目--页面设计器(表单设计器)--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人

作者 晴虹
2025年12月31日 17:12

页面设计

页面设计器的使用,主要界面栏目功能及操作说明。

我们在做系统页面的时候,可以不用像传统开发那样,必须在本地启动开发环境,设计你的页面或者组件等,这里提供了可视化构建页面的能力,让你能够在网页中通过鼠标点点点来创建一个新的系统页面。

它也是我们系统的核心内容,我们将通过页面设计器来设计出统一的代码格式组成的页面,然后再用统一的解析页面来渲染整个系统。

换而言之,系统的所有页面全都由设计器设计出来,并且系统的所有页面全部都由一个页面解析并渲染出来。

由页面设计器设计出来的页面,全部都是所见即所得,设计出来什么样子,实际就是什么样子,不会放大缩小或者变形,我们可以对页面进行任何的设置,并且都是在设计页面实时生效的,所有的效果都与实际页面一样,也就是说不用使用预览功能就可以做到预览与交互。

功能分区

拖拽式可视化编辑创建页面。设计器主要界面

页面设计器

整个设计器大体分为三个部分:

  1. 视图绘制区域:整个页面的结构,这里放页面的组件。
  2. 组件面板:相当于设计页面时所需的物料区,所有的组件都能在这里找到。
  3. 属性面板:组件的操作面板,可以设置它的属性、事件、插槽等等。

组件面板

组件面板给我们提供了经常用到的一些 UI 组件,默认使用element-plus来渲染,你也可以指定为naive-ui,甚至使用你自己创建的组件。

组件面板按钮

可直接使用鼠标点击相应图标,并拖拽到视图绘制区域。

主要分为三栏,从左到右分别为:

基本组件栏: 一般的表单元素组件,或者一些基本元素的组件。

复合组件栏: 包含嵌套结构的元素组件等,也就是由多个基本组件组成。

反馈组件栏: 系统提示弹窗组件等,与用户有较强交互效果的组件。

注意:

页面初始需要放置行列组件,也就是说所有组件都是在最外层的行列组件内。在创建一个新页面时,最开始需要拖入的是行列。

行列图标

就是区域2中的第一个组件,为了方便和显眼,我们把它放在了页面居中并偏左的地方,而且为它设置了一点颜色。

行列可以嵌套行列,以达到无限布局

组件面板提供了非常多的组件,还有一些隐藏组件没有显示出来,只有等到用到他们的时候才会自动展示出来。

将鼠标放到某个组件的右上角,会显示相应的提示信息,当通过外观分辨不出是什么组件的时候,可以通过该种方式查看组件的名称。

组件名称提示

快捷操作

为了进行快速的操作,节省页面设计时间,也是为了更友好的交互,提供了一些快捷键,在实际使用中会经常用到。

按键 说明
Alt 唤出右侧属性面板
空格 唤出底部组件面板
Shift + 空格 隐藏组件面板和属性面板
Shift + 上/下/左/右 切换组件面板位置
Shift + A 唤出/隐藏 组件面板(修改层级)
Shift + D 唤出/隐藏 属性面板(修改层级)

视图绘制区

视图绘制

通过拖拽组件区中的行列组件,我们将它放置到视图绘制区中,这样就可以往行列组件中拖拽任意的组件了。 比如这里我们首先放置了一个新增按钮,然后又放置了一个查询输入框,最后我们又放置了一个查询的按钮。 因为查询按钮是最后放置的,因此目前默认它就是可编辑的状态,所有的设置都会对这个按钮生效。

默认情况下,被拖入的空行会用虚线显示出来,以方便查看和拖入其他组件,实际做好的页面中空行不会显示出来。

空行显示虚线

只要行组件被放置了其他组件,那么该行的虚线轮廓示意将会自动消失,显示成它实际该有的样子。

除此之外,还提供了右键的功能,比如我们在行列组件上右键的话,就会出现下面这样:

行列右键

移除该行 的选项是高亮的,表示我们可以操作,点击后就会将该行以及它所包含的所有内容,全部从页面上删除掉。

填入该列 的选项是置灰的,表示我们目前不可以操作,这是因为还没有选中元素,如果有选中元素的话,那么使用该项就会把选中的元素填入到该列当中。

如果我们将右键放到其他组件上面使用的话,会出现不一样的效果:

组件右键

移除 表示将该组件从当前页面移除掉。

移动 表示我们将要移动该组件,点击之后,该组件将处于可移动状态,也就是被标记为将要移动的状态。

放在前面 如果我们目前有可移动组件,那么我们可以将它放置到该组件的前面

放在后面 如果我们目前有可移动组件,那么我们可以将它放置到该组件的后面

比如我们在新增按钮上面右键,选择移动,然后在查询按钮上面右键,选择放在后面,那么页面就会变成这样:

移动组件

而且我们会遇到一些特殊的情况,当你想要编辑一个下拉框的时候,如果是点击下拉框,那么并不会弹出对应的属性面板,而是展开下拉选项

点击下拉框

这时我们就需要一个特殊的手段来处理它。

因此我们给这种特殊的组件增加了右键选中的功能

右键下拉框

其他组件,比如按钮,是没有右键的选中菜单的,因为这类组件直接点击就可以进行编辑

按钮右键

但是也有特殊情况,比如我们给按钮绑定了一些事件,但是我只想编辑属性而不是触发事件,这时能够通过按住ctrl再点击右键来强制调出选中项:

右键强制选中

选择之后就能够编辑对应的组件了:

选中组件

由于我们拖拽的是一个按钮,那么默认也是编辑的这个按钮的配置,但是也可以点击里面的文字,进行文字的更改:

按钮中的文字

点击页面空白处,也就是没有组件的地方,是会隐藏组件面板和属性面板的。

快捷操作

选中某个组件之后,也可以通过快捷键来帮助做一些便捷的处理。

按键
说明
ctrl + ↑ 选择上一级组件,直到根元素的行组件
ctrl + ← 如果左边有兄弟组件,那么选中它
ctrl + ↓ 如果在行组件上操作,那么将会选中它的第一列,如果是在列组件上操作,那么将会选中它的第一个子组件,如果是在非行列组件上操作,并且有兄弟组件的话,那么选中它的最后一个兄弟组件
ctrl + → 如果右边有兄弟组件,那么选中它
ctrl + click 正常click会打开属性面板,但是如果想单纯的点击组件查看效果,那么可以按住ctrl再点击鼠标左键,这样就跟在真实业务场景下的页面中一样,不会弹出属性面板
Ctrl + 鼠标右键 定位当前元素弹出选中

属性面板

当前添加或选择的组件的属性和事件等的相关配置。如图

组件属性面板

当页面中包含一个组件的时候,我们希望能够对它进行任意的定制,组件属性面板给我们提供了入口,主要提供以下五个方面的配置

基础操作区域

所有组件都可以使用的功能,主要是围绕着组件做一些事情,而不是修改配置信息。

公共操作区域

  • 上一级 如果组件形成了嵌套关系,那么可以通过该按钮快速选择上一级,一直到当前的根元素,也就是行组件为止。

行组件操作区域

行组件这里有一些变化,能够进行上移和下移来调整行位置,并给出提示信息,这些都是可配置的,只要找到对应的配置文件进行修改即可。

  • 左移 如果左边有兄弟组件,那么它将移动到该组件的左边。

  • 右移 如果右边有兄弟组件,那么它将移动到该组件的右边。

  • 移除 移除该组件。

  • 固钉图标 属性面板默认是固定在页面右侧的,可以通过点击图标来解除固定,然后用鼠标按住图钉进行拖动就能够移动了。

  • 当前组件 显示了当前组件的名称,如:按钮、输入框、表格等,右边有一个刷新图标,如果更改组件某个配置页面没有自动更新的话,可以点击该图标手动刷新状态。

  • 滑动条 用来快速滚动页面,如果组件属性配置面板比较长,那么可以通过鼠标点击滑动条来快速切换到对应为止,滑动条中滑动块的位置与页面中的滚动条位置相对应,比如点击滑动条尾部,会直接自动跳转到属性面板的底部。

滑动条

并且属性面板向下滚动的时候,滑动条会固定在面板顶部保持始终可见。

  • 之前添加组件 点击该按钮之后,会自动弹出组件面板,选择对应组件之后,所选择的组件就会被插入到该组件的前面。

之前添加组件

并且该按钮会自动转换成取消按钮,可以点击它来取消此次操作。

之前添加组件选择

可以看到,组件面板中的所有组件全部变成高亮状态,并且显示出了很多隐藏的组件以供选择。

  • 替换当前组件 选择的组件会替换掉当前的组件。

  • 之后添加组件 选择的组件会被插入到该组件的后面。

属性区域

除了原生 div 或者 span 等标签元素,其他所有的组件全部都默认都由 element-plus 中的组件来渲染,因此属性区域可配置项默认包含了 element-plus 官网中对应组件的所有属性,但是也有一些公共的属性需要配置,这里对一些属性做一下陈述:

类名和样式属性

其中有所有组件都有的三个属性配置:

类名

可以给该组件添加类名,能够通过手动书写类名或者预置选择以及可视化设置的方式来添加。

比如我们给查询按钮添加一点样式:文字变成红色、宽度变为100px。

按钮添加类名

这里我们使用手输的方式,给按钮添加了两个类名,具体格式之后会详解,详情参见,现在按钮已经变了样子。

按钮添加类名效果

可以看到,按钮已经按照设置变成了我们想要的效果。

现在用预置选择的方式再来看一下,我这里预置了一个效果。

类名预置选择

有一个圆角凸起的预置选项,我们选中它之后,其他什么都不用改,按钮就会发生变化。

按钮圆角凸起效果

而且它也有了鼠标悬浮等交互效果,每个组件的类名预置选项都是可以配置的,不会冲突,彼此之间相互独立。

点击类名旁边的小手,可以唤醒配置面板,

设置类名

例如我们现在设置一个高度为60px的类名,然后再设置一个圆角为50%的类名

按钮设置类名

点击确定之后,按钮就自动添加上相应的类名了,样式实时生效

按钮效果

样式

如果有更定制化的样式设置,或者样式比较复杂,用单元类名写起来比较繁琐,那么可以使用该功能细致化的定制该组件的样式,所写的所有样式都只针对当前组件生效。

点击编写按钮之后,会弹出一个编辑框,里面就可以编写css代码了。

样式编辑弹窗

有三种编写的方式:

① 直接书写属性对。

② 将属性对包裹在一堆大括号中。

③ 以*开头的大括号内写属性对。

推荐使用第三种方式,因为这样会有代码提示:

编写样式代码

我们给按钮添加一个样式,让它的文字变成蓝色:

蓝色字体样式代码

点击对勾之后,我们就能看到变化啦:

蓝色字体样式效果

深层样式

有时候我们不但要改变当前组件的样式,还要更改它子孙组件的样式,这个时候我们就可以使用深层样式来控制内部组件。

有点相当于我们平时写的深层选择器,为了更好的控制样式生效范围,我们规定必须手动指定一个选择器,再在这个基础上去控制深层组件。

比如现在我们有一个行内卡片,它的默认padding是20px:

行内卡片

显然,通过单元类名或者样式都是无法更改头部区域和内容区域的padding值的,这时我们使用深层样式对它进行更改:

修改card的padding代码

根据上面我们的约定,mini-card就是我们手动指定的选择器,编写完深层样式之后,我们还需要对该组件指定这个选择器:

card增加类名

现在我们想要的效果就已经达成啦:

card的padding效果

这时,再新增的card组件或者其他既有card组件不会收到影响,还是展示默认的效果,如果也想同步这样的效果,只需要给那个组件也加上mini-card类名即可。

其实可以发现,我们不但可以修改当前组件内的深层样式,也可以通过父级来修改当前组件的样式,甚至可以通过根组件来统一管理当前行的所有样式。

除了上面这三个公共属性,还有一些非常重要的属性,只不过它们只存在于一些特殊的组件。

行组件的专有属性

行属性设置列

默认一行有一个列,并且最少有一个列,遵循24栏布局,可以继续添加或者删除列。

也可以设置列的间距,以及水平和垂直方向上的对齐方式等。

model绑定

像输入框、下拉框等表单元素组件,都会产生数据交互,包括一些自定义组件也是,它们都支持model的绑定。

数据绑定

model的绑定一共由两个属性的组合来支持,一个是设定它的key,也可以叫做它的name,对于表单数据对象来说就是key-value对中的key,对于提交到后端的数据来说就是字段的名称name。

另一个就是设置的默认值,也就是未赋值的情况下,该组件显示的值。

model的绑定方式有两种:

  1. 直接写名称,那么它们都会被收集到根对象中以供提取和使用:

注:根对象是针对当前页面的,不同的页面有不同的根对象

比如我们设计了三个输入框,分别是手机号、邮箱、住址,它们的model分别是phone、email、address:

model值查看

collectionData中已经有了,现在给这三个输入框分别输入点内容:

输入框有值

看下它们值的变化:

model值的变化

  1. 通过用点(.)来连接多个名称,那么它们会被自动收集到设定的对象里面,并可以通过跟对象进行访问。

现在我们假定上面的三个输入框属于同一个表单,把他们绑定在对象userInfo上面,类似于这样:

model深层绑定

再看下此时它们被收集到了哪里:

model深层绑定值变化

这样就可以通过userInfo来访问三个输入框的值了,同理,支持无限层级的嵌套。

model的默认值也有两种绑定方式:

  1. 普通数据类型,可以指定为数字、字符串、布尔值、数组等等。

比如输入框可以指定为字符串,多选按钮指定为数组:

输入框默认值

多选框的默认值

这个时候页面上这两个组件的默认值就会自动更新了:

输入框默认值回显

多选框默认值回显

  1. 函数,默认值将是该函数的返回值。

默认值为函数

在这里插入图片描述

填充数据

有一些组件的渲染需要一些动态数据,比如表格、下拉框、复选框组等等,这些数据可以是自定义的,也可以是从后端读取的。

这里又追加了一种list的类型,也是可以填充数据的,主要包含了三个功能点:

填充数据

  1. 填充数据,可以绑定接口返回值,这个需要先给当前页面配置接口。

填充数据有三种方式:通过绑定接口返回值、通过绑定作用域插槽数据、通过绑定 collectionData 中的数据。

填充数据如果是 requestData 中的请求链接里返回的数据,直接从下拉选项选择所返回的对象属性名称即可。如果是从 collectionData 中获取可以在输入框中输入 @属性名@ 符号表示从 collectionData 取值。还可以使用 # 符号,表示在当前作用域插槽下取值,比如 #childrens ,表示取当前组件作用域插槽下的childrens数组。

  • 通过接口绑定

比如现在有一个表格,数据需要从接口获取,已经给页面配置好了一个获取歌曲列表的请求链接:

歌曲请求链接

那么就可以从填充数据中绑定它:

绑定填充数据

这个接口返回一个数组,每一项包含四个字段:title、singer、time、hot。详情可参考 请求链接

现在已经将数据绑定到表格上面了,那么接下来就可以指定列绑定相应的字段来渲染了,首先给表格建四个列:

直接修改文字

直接点击表格头部,就可以编辑对应列的名称了。

绑定表格列内容

点击对应的列,编辑它所渲染的内容,# 表示一级作用域插槽数据,也就是当前作用域插槽的数据,如果是##就表示上级作用域插槽的数据,其中row表示el-table列作用域插槽数据中的行数据,title就表示插槽数据的title字段值。

其中#也可以放在最左边,也就是 row#title#row.title 是一样的,#row.title 可能比较好理解,表示当前作用域的行数据的title字段,上图中那样写是为了将对象写在#左边,#右边只写字段,因为它也可以写成 a.b.c#d 的形式。

然后就能看到表格已经自动渲染了我们所绑定的数据:

表格渲染效果

  • 通过作用域插槽

其实上面已经顺带着演示了作用域插槽的使用,而我们填充的数据同样也是可以从作用域插槽中来获取。

  • collectionData

如果我们在页面初始化或者其他地方给 collectionData 中赋值了一个数据对象,那么填充的数据也可以取的到。比如我们有这样是一个数据:

collectionData赋值数据

collectionData中赋值了一个tableData属性,它的值是一个数组。

[{
    title: '稻香',
    singer: '周杰伦',
    hot: 999,
    time: '03:43'
},{
    title: '关不上的窗',
    singer: '周传雄',
    hot: 999,
    time: '04:56'
},{
    title: '口是心非',
    singer: '张雨生',
    hot: 999,
    time: '04:56'
},{
    title: '水手',
    singer: '郑智化',
    hot: 999,
    time: '04:57'
}]

现在我们将表格的数据源绑定到这个数组上面,作用域插槽使用 # 关键字,要引用 collectionData 中的属性使用 @ 关键字。

绑定collectionData属性

现在就绑定完成啦,表格会自动读取该数组来渲染:

collectionData绑定回显

并且现在修改tableData的数据,页面也会实时修改更新。

  1. 测试数据,可以手动更改请求链接的参数,如果填充数据使用的是接口的返回值,那么有可能这个接口在请求的时候需要动态传一些必填的参数。

在设计阶段,如果接口能够直接发起请求并返回数据,那么页面绑定接口之后就会自动发起请求并将返回数据绑定到页面上。

如果需要动态获取或者根据业务实时改变参数的接口,就可以使用测试数据的功能,它能够让我们先临时指定参数的值,来模拟实际场景的效果。

有一个获取字典的接口,可以通过code来查询对应的字典项,默认code是空的:

获取字典接口

将填充数据绑定到这个接口,那么我们就可以通过测试数据来指定某个code值以让接口发起请求:

绑定接口测试数据

  1. 手动指定数据,有一些填充数据不需要通过上面这些方式绑定,比如性别男女,或者选项是否等,就可以通过手动编辑组件需要渲染的数据。

有一个单选按钮组,通过一个列表渲染出多个radio的选项,手动指定列表的数据如下:

手动指定数据

绑定选项的label属性,内容是通过文本组件渲染,从作用域中获取数据:

单选标签插槽

单选内容插槽

现在页面中就会呈现出效果:

单选手动指定数据效果

比如我们再加一个保密的选项,直接修改手动指定数据的内容即可:

[{
  label: '男',
  value: 1
},{
  label: '女',
  value: 0
},{
  label: '保密',
  value: 2
}]

页面也实时回显效果:

单选手动指定数据效果2

事件区域

可配置组件暴露的事件,它们都能通过编写代码的方式进行事件的添加和绑定。

比如输入框暴露出的事件有:

输入框暴露的事件

单选按钮组暴露出的事件有:

单选按钮组暴露的事件

也可以通过单击【添加】设置 on_事件名 来绑定事件。

组件绑定事件

插槽区域

包含了组件支持的所有插槽,能够对插槽进行各种操作。

例如输入框的插槽:

输入框插槽

所有插槽全部包含三个功能:添加、删除、查看元素

  1. 添加

可以对该插槽添加组件,点击之后自动弹出组件面板,选中组件之后,该组件就会被添加到该插槽当中。

给输入框组件添加一个 prepend 插槽和一个 suffix 插槽。

prepend是一个文本:

插槽文本

suffix是一个图标:

插槽图标

两个插槽添加成功:

输入框插槽效果

如果某些组件更适合被添加,那么会自动高亮,比如给 table 添加插槽,table-column 会高亮,给 select 添加插槽,option 会高亮等等。

添加插槽

  1. 删除

可以直接清空插槽,无论该插槽中有多少个组件都会被一次性删除掉,如果想一个一个删除,那么选择到对应组件,使用组件的移除功能。

删除掉刚才的prepend插槽中的组件:

suffix插槽图标

现在就只剩suffix插槽了。

  1. 元素

可以查看当前插槽中所包含的所有组件,用列表的方式展现,单击列表中的项,能够直接跳转到对应组件的配置面板。

我们查看suffix插槽中的组件:

查看插槽内容

单击icon,就能够编辑这个icon组件的配置了:

图标属性面板

其他定义区域

在这里能够增加权限控制、获取页面DOM、获取渲染的视图、添加事件监听等等。

其他自定义区域

上面是书写规范的说明,下面是一个添加的按钮。

比如给按钮添加一个dataset,点击[+添加]可以输入对应的key和value。

dataset自定义属性

其中key为_data-level,value为1。看下DOM元素:

dateset的dom效果

之后可以对它进行引用和操作了。

也可以添加事件,按钮组件默认没有暴露出任何事件,但是我们可以手动添加点击事件。

自定义click事件

它的key为on_click,value为一个函数:

function _() {
  console.log('点击了按钮')
}

然后我们点击按钮就能够看到函数的执行效果:

click事件效果

既可以直接编写逻辑,也可以调用执行寄连,还能够操作组件、修改变量、发起请求等等等等。

在一个方法中你可以做任何事情。

如果一个属性的的值是通过函数返回的,那么这个value也可以设置为函数,只需要把函数命名为 attribute 即可。

比如将刚才的dataset设置为一个函数返回值:

function attribute() {
  return 2
}

那么页面中也是会实时变更的:

通过函数返回属性

如果一个属性的值,本身就需要是一个函数,比如下拉框的过滤属性filter-method,它的值就是一个函数,那么这时你可以任意命名,建议将函数命名成 _

右边侧栏

除了上面的三个大板块,页面设计器还包含了一些其他的配置功能。

主要用来配置当前页面使用的执行寄连、请求链接,还有页面整体的样式设计,以及选择元组等。还列出了当前设计页面所包含的所有组件。

页面配置按钮

默认是虚化状态,鼠标悬浮会高亮

上面从上到下的按钮依次为:全局配置、元素面板、元组面板、保存。

全局配置

针对整个页面进行配置,点击会展开一个菜单:

页面配置操作面板

按逆时针呈现的三个图标,分别表示:

  1. 整页样式调整

包括class设置、style设置、深层样式设置。

全局样式配置

设置规则同组件一样。见上面

  1. 页面执行寄连配置

弹窗分左右两个列表,右侧列表表示当前已添加的执行寄连。

绑定寄连

支持检索,点击添加按钮即可将该执行寄连绑定到该页面上,也可以点击移除从该页面中移除绑定。

  1. 页面请求链接配置

弹窗分左右两个列表,右侧列表表示当前已添加的请求链接。

绑定请求链接

支持检索,点击添加按钮即可将该请求链接绑定到该页面上,也可以点击移除从该页面中移除绑定。

绑定完请求链接,就可以在组件的填充数据中选择绑定的数据项了。

元素面板

当设计页面时,有可能有些组件不太好找到或者被选取到,这样就不容易配置它的属性,这时就可以从元素面板中找到它,点击对应的名称就可以自动定位到该组件,并打开它的属性配置面板。

页面中元素列表

元组面板

如果设计了多个元组,那么就可以通过元组面板来选取,达到快速设计的目的。

比如我们之前设计的歌曲列表页面。

歌曲列表页面

现在使用这个页面生成一个元组,注意元组是用来做复用的,这里只是一个演示。

那么就可以在元组面板中看到了:

选择元组面板

会以缩略的形式展现出来,单击对应的元组就可以添加到当前页面了,这里目前只有一个元组供选择。

添加元组

该元组已经被添加到刚刚做演示的页面中。

默认情况下,被添加进来的元组是不允许编辑的,因为它是公用的复合组件,如果需要修改,可以去修改元组,这样用到该元组的地方就会全部自动更新。

元组默认不能编辑

点击该元组的任何地方,弹出的属性面板都是空白的,无法配置的。

如果确实需要特殊化处理,只是为了快速复用,有一些独立修改的地方,那么可以点击解除关联绑定,这样就可以对元组进行编辑了,并且不会影响其他用到的页面。同样,修改元组之后该页面也不会变化。

保存

也就是设计完成按钮,点击该按钮会对页面进行保存,如果是第一次设计,会弹出基本信息输入的窗口

设计完成

其他

就像元组一样,我们还有模板的概念,它不同于元组,如果我们设定一个页面为模板的话,那么在用页面设计器新建一个页面的时候,会先提供模板选择的功能。

比如我们没有模板的时候新建页面是这样的:

设计器新建页面

因为没有任何模板可供选择,因此只有一个新建的图标,点击新建图标,就可以全新的设计一个页面了。

再将刚才歌曲列表的页面生成一个模板,那么我们在新建页面的时候就会是下面这样:

设计页面选择模板

就跟word一样,使用模板会快速的生成一个页面,可以自建任意多的模板。

每个页面在生成元组或者模板的时候,都会自动生成缩略图,方便选择。

【项目体验】

系统管理端地址www.lecen.top/manage

系统用户端地址www.liudaxianer.com/user

系统文档地址www.lnsstyp.com/web

2026最新React技术栈梳理,全栈必备

作者 怕浪猫
2025年12月31日 17:09

前言

2025年的React生态持续迭代,从核心框架的编译器革新到生态工具的性能优化,都带来了诸多实用特性。对于前端开发者而言,精准把握最新技术栈选型,是提升开发效率、构建高性能应用的关键。本文将从「核心基础」「路由方案」「状态管理」「工程化工具」「性能优化」「全栈拓展」六大模块,系统梳理2025年React技术栈的核心内容,每个模块均附上官方链接与实用资源,助力大家快速上手实践。

一、核心基础:React 19 关键特性

React 19作为2025年的主流版本,最大亮点是内置编译器的稳定发布与服务器组件的全面普及,彻底改变了传统开发与优化模式。

1. 核心升级点

  • 自动性能优化:编译器可自动处理useMemouseCallback等手动优化逻辑,开发者无需关注底层优化细节,代码量减少30%以上。示例:无需手动缓存回调,编译器自动识别依赖并优化
  • 新Hooks增强:新增useFormStatus(统一管理表单提交状态)、useOptimistic(乐观更新提升交互体验)、useActionState(集中处理API状态),简化复杂交互逻辑实现。
  • 服务器组件(RSC) :在服务器端预渲染组件逻辑,流式传输到客户端,首屏加载时间平均缩短30%,尤其适配低带宽环境。

2. 学习资源

官方文档:React 官方文档(英文) | React 中文文档;升级指南:React 19 官方发布公告

二、路由方案:从React Router到全场景适配

路由是SPA应用的核心,2025年React生态的路由方案呈现「功能集成化」「状态一体化」趋势,主流选择仍是React Router,同时Router5等方案在复杂场景中崭露头角。

1. 主流方案:React Router 7+

最新版本的React Router强化了与服务器状态的协同,通过Loaders、Actions机制实现前后端数据同步,减少传统缓存方案的冗余性。核心特性:

  • 嵌套路由与Outlet:通过<Outlet>组件实现布局复用,无需重复编写导航栏、页脚等公共组件,适配复杂页面结构。
  • 路由懒加载:结合React.lazy<Suspense>实现组件按需加载,搭配Loading占位UI提升用户体验,示例代码:
import { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));

function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

路由状态一体化:通过useNavigationuseFetcher钩子直接获取导航状态、表单数据,无需额外维护网络相关状态,避免状态冗余。

2. 进阶方案:Router5

针对复杂应用的路由管理需求,Router5提出「路由即状态」理念,将路由纳入统一状态管理,实现视图与路由的解耦,支持精准更新与灵活订阅机制。适合中大型团队协作的复杂应用,官方文档:Router5 官方文档

3. 实用资源

React Router 官方文档:React Router 7+ 英文文档;实战教程:React 路由实战:构建GitHub仓库管理应用

三、状态管理:轻量化与集中化并存

2025年React状态管理趋势:中小型项目首选轻量化方案,大型项目倾向简洁的集中式管理,传统复杂方案逐渐被替代。

1. 轻量化方案:useContext + useReducer

React原生方案,无需引入第三方依赖,通过上下文实现跨层级状态共享,搭配reducer处理复杂状态逻辑,适合中小型项目。优势是学习成本低、代码简洁,示例:创建全局状态上下文。

import { createContext, useReducer, useContext } from 'react';

// 1. 创建上下文
const GlobalContext = createContext();

// 2. 定义reducer
function repoReducer(state, action) {
  switch (action.type) {
    case 'FETCH_SUCCESS':
      return { ...state, data: action.payload, loading: false };
    case 'FETCH_ERROR':
      return { ...state, error: action.error, loading: false };
    default:
      return state;
  }
}

// 3. 提供状态
export function GlobalProvider({ children }) {
  const [state, dispatch] = useReducer(repoReducer, {
    data: [],
    loading: true,
    error: null
  });
  return (
    <GlobalContext.Provider value={{ state, dispatch }}>
      {children}
    </GlobalContext.Provider>
  );
}

// 4. 消费状态
export function useGlobalState() {
  return useContext(GlobalContext);
}

2. 集中式方案:Zustand vs Redux

  • Zustand:轻量级集中式状态管理库,API简洁,无需冗余的action定义,支持中间件与状态持久化,适合中大型项目快速开发。官方文档:Zustand 官方文档
  • Redux:经典集中式状态管理方案,通过单一状态树与中间件(redux-thunk、redux-saga)处理复杂数据流,适合需要严格分层、多人协作的大型项目。最新版本简化了API,学习成本降低。官方文档:Redux 官方文档

3. 服务端状态管理:React Query/Apollo

专注于服务器数据同步,实现数据获取、缓存、更新一体化,减少重复代码。React Query适用于REST API,Apollo适用于GraphQL。官方链接:TanStack Query(React Query升级版本) | Apollo Client

四、工程化工具:高效开发与构建

工程化是React项目规模化开发的基础,2025年主流工具聚焦「快速构建」「类型安全」「自动化部署」,Vite逐渐替代Webpack成为中小型项目首选。

1. 构建工具:Vite vs Webpack

工具 优势 适用场景 官方链接
Vite 冷启动快、热更新高效、配置简洁 中小型React项目、快速迭代的创业项目 Vite 官方文档
Webpack 生态完善、定制化能力强、支持复杂构建流程 大型企业级项目、需要深度定制构建逻辑的项目 Webpack 官方文档

2. 类型检查:TypeScript

70%的React项目已采用TypeScript,静态类型检查可降低40%的Bug率,提升团队协作效率。核心优势:类型提示、代码补全、编译时错误检查。配置指南:React + TypeScript 官方指南

3. 代码规范与测试

五、性能优化:从编译到运行时

2025年React性能优化已实现「全周期覆盖」,从编译阶段的自动优化到运行时的精准控制,无需开发者手动编写大量优化代码。

1. 编译阶段:React编译器

自动识别不必要的重渲染,缓存计算结果与回调函数,替代手动useMemouseCallback,开发者只需专注业务逻辑。

2. 运行时优化

  • 长列表优化:使用React Window/React Virtualized实现列表虚拟化,仅渲染可视区域元素,减少DOM节点数量。官方链接:React Window
  • 资源加载优化:React 19支持后台异步加载图像、脚本等资源,结合流式渲染(Streaming SSR),避免阻塞主线程。
  • 缓存控制:在React Router的Loaders中配置Cache-Control头,利用浏览器原生缓存减少重复请求。

3. 性能监测:Lighthouse

谷歌开源的性能监测工具,可检测首屏加载时间、交互响应速度等指标,提供优化建议。使用指南:Lighthouse 官方文档

六、全栈拓展:React + Next.js

全栈化是React开发的重要趋势,Next.js作为React的全栈框架,2025年最新版本(Next.js 15)全面支持React 19,提供混合渲染、AI优化、边缘函数等特性,无需独立后端即可构建全栈应用。

核心特性

  • 混合渲染:支持SSR(服务器端渲染)、SSG(静态生成)、ISR(增量静态生成),适配不同内容场景。
  • AI驱动优化:内置AI工具链,支持自动代码优化、Figma设计一键转React代码。
  • 边缘函数:在边缘节点运行后端逻辑,降低延迟,支持地理位置感知。

学习资源

官方文档:Next.js 15 官方文档;实战项目:从React到Next.js 全栈实战

七、2025 React技术栈选型建议

根据项目规模与场景,推荐以下选型方案,帮助大家快速落地:

  • 小型项目/个人项目:React 19 + Vite + React Router 7 + useContext+useReducer
  • 中型项目/创业项目:React 19 + TypeScript + Vite + React Router 7 + Zustand + React Query
  • 大型企业级项目:React 19 + TypeScript + Webpack/Next.js 15 + Redux/Zustand + Apollo + 完整测试体系

总结

2025年的React技术栈呈现「简洁化」「高效化」「全栈化」的趋势,核心框架的编译器革新降低了优化成本,生态工具的集成化提升了开发效率。作为前端开发者,无需追逐所有新技术,只需根据项目需求选择合适的方案,聚焦业务逻辑实现。希望本文的梳理能帮助大家理清React技术栈的脉络,快速上手最新特性。如果有补充或疑问,欢迎在评论区交流~

表格组件封装详解(含完整代码)

作者 婷婷婷婷
2025年12月31日 17:02

表格组件封装详解(含完整代码)

本文详细解析 布局容器 + 动态搜索栏 + 智能表格 三组件的封装逻辑、实现细节与标准用法,附带完整可运行代码。


一、整体架构与协作关系

🧩 组件职责划分

组件 职责 关键能力
LayoutContainer.vue 布局骨架 统一结构、操作按钮(刷新/显隐列/折叠搜索)
DynamicSearchBar.vue 动态表单 根据配置生成 input/select/date 等控件
SmartTable.vue 数据展示 自动请求、分页、字典转换、时间格式化

🔗 数据流图

[LayoutContainer]
   │
   ├─ #search[DynamicSearchBar] ←→ params (v-model)
   │                │
   │                └── tableRef.getList() ←──┐
   │                                          │
   └─ #default[SmartTable] ←───────────────┘
                   ↑
             columns (响应式数组)
             tableRef (expose 方法)

关键设计

  • columns共享状态:LayoutContainer 修改 .hide → SmartTable 自动响应
  • tableRef方法通道:SearchBar 和 LayoutContainer 通过它触发表格刷新

二、智能表格组件(SmartTable.vue)

💡 封装目标

  • 自动处理分页、排序、加载状态
  • 支持字典、时间、链接等常见字段类型
  • 提供插槽覆盖默认渲染

📄 完整代码

<!-- SmartTable.vue -->
<template>
  <div class="smart-table">
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="tableData"
      v-bind="mergedConfig.table"
      @sort-change="handleSortChange"
    >
      <!-- 遍历 columns 渲染列 -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- selection / index 列 -->
        <el-table-column
          v-if="column.type === 'selection'"
          type="selection"
          :width="column.width || 55"
        />
        <el-table-column
          v-else-if="column.type === 'index'"
          type="index"
          :label="column.label"
          :width="column.width || 60"
        />

        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :sortable="column.sortable || false"
          :show-overflow-tooltip="true"
        >
          <template #default="{ row }">
            <!-- 插槽优先 -->
            <slot
              :name="column.slot"
              :row="row"
              :column="column"
              v-if="column.slot"
            />
            <!-- 字典标签 -->
            <dict-tag
              v-else-if="column.dict"
              :value="row[column.prop]"
              :dict-key="column.dict"
            />
            <!-- 时间格式化 -->
            <span v-else-if="column.date">
              {{ formatDate(row[column.prop], column.dateFormat) }}
            </span>
            <!-- 链接 -->
            <el-link
              v-else-if="column.link"
              type="primary"
              @click="handleLinkClick(column, row)"
            >
              {{ row[column.prop] }}
            </el-link>
            <!-- 默认文本 -->
            <span v-else>{{ row[column.prop] }}</span>
          </template>

          <!-- 表头提示 -->
          <template #header>
            <span>{{ column.label }}</span>
            <el-tooltip
              v-if="column.tip"
              :content="column.tip.content"
              placement="top"
            >
              <i class="el-icon-question" style="margin-left: 4px; color: #999"></i>
            </el-tooltip>
          </template>
        </el-table-column>
      </template>

      <!-- 空状态 -->
      <template #empty>
        <div class="no-data">
          <img src="@/assets/images/no-data.png" alt="无数据" />
          <p>暂无数据</p>
        </div>
      </template>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      v-if="!mergedConfig.notPagination && total > 0"
      v-model:current-page="queryParams.pageNum"
      v-model:page-size="queryParams.pageSize"
      :total="total"
      layout="total, sizes, prev, pager, next, jumper"
      @size-change="handleSizeChange"
      @current-change="getList"
      v-bind="mergedConfig.pagination"
    />
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { parseTime } from '@/utils'

// Props
const props = defineProps({
  columns: {
    type: Array,
    required: true
  },
  func: {
    type: Function,
    required: true
  },
  params: {
    type: Object,
    default: () => ({})
  },
  config: {
    type: Object,
    default: () => ({})
  },
  events: {
    type: Object,
    default: () => ({})
  }
})

// Expose
const tableRef = ref(null)
defineExpose({
  getList,
  resetQuery,
  reload
})

// 响应式数据
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const queryParams = reactive({
  pageNum: 1,
  pageSize: 10,
  ...props.params
})

// 合并配置
const mergedConfig = computed(() => {
  return {
    table: {
      border: true,
      stripe: true,
      ...props.config.table
    },
    pagination: {
      background: true,
      pageSizes: [10, 20, 50, 100],
      ...props.config.pagination
    },
    sort: props.config.sort ?? false,
    notPagination: props.config.notPagination ?? false,
    autoPagination: props.config.autoPagination ?? false,
    initResquest: props.config.initResquest ?? true
  }
})

// 可见列(过滤 hide = true 的列)
const visibleColumns = computed(() => {
  return props.columns.filter(col => !col.hide)
})

// 格式化时间
function formatDate(value, format = '{y}-{m}-{d}') {
  if (!value) return ''
  return parseTime(value, format)
}

// 获取数据
async function getList() {
  try {
    loading.value = true

    // 触发 formatParams 事件
    let finalParams = { ...queryParams }
    if (props.events?.formatParams) {
      finalParams = props.events.formatParams(finalParams) || finalParams
    }

    const res = await props.func(finalParams)

    // 触发 formatData 事件
    let finalData = res
    if (props.events?.formatData) {
      finalData = props.events.formatData(res) || res
    }

    // 处理分页数据
    if (mergedConfig.value.autoPagination) {
      // 前端分页
      tableData.value = finalData.data || []
      total.value = tableData.value.length
    } else {
      // 后端分页
      tableData.value = finalData.data?.rows || []
      total.value = finalData.data?.total || 0
    }
  } catch (error) {
    console.error('表格请求失败:', error)
    tableData.value = []
    total.value = 0
  } finally {
    loading.value = false
  }
}

// 重置查询
function resetQuery() {
  queryParams.pageNum = 1
  getList()
}

// 强制重绘
function reload() {
  tableRef.value?.doLayout()
}

// 排序变更
function handleSortChange({ prop, order }) {
  if (mergedConfig.value.sort) {
    const sort = order ? { prop, order: order === 'ascending' ? 'asc' : 'desc' } : null
    if (props.events?.onSortChange) {
      props.events.onSortChange(queryParams, sort)
    }
    getList()
  }
}

// 分页大小变更
function handleSizeChange(val) {
  queryParams.pageSize = val
  getList()
}

// 链接点击
function handleLinkClick(column, row) {
  if (props.events?.onLinkClick) {
    props.events.onLinkClick(column, row)
  } else if (column.link?.name) {
    // 路由跳转
    router.push({
      name: column.link.name,
      params: typeof column.link.params === 'function'
        ? column.link.params(row)
        : column.link.params
    })
  }
}

// 初始化
onMounted(() => {
  if (mergedConfig.value.initResquest) {
    getList()
  }
})

// 监听外部 params 变更
watch(() => props.params, (newVal) => {
  Object.assign(queryParams, newVal)
}, { deep: true })
</script>

<style scoped>
.smart-table {
  width: 100%;
}
.no-data {
  text-align: center;
  padding: 40px 0;
}
.no-data img {
  width: 120px;
  opacity: 0.6;
}
</style>

三、动态搜索栏组件(DynamicSearchBar.vue)

💡 封装目标

  • 根据配置动态生成不同类型的输入控件
  • 自动绑定参数,支持回车查询
  • 超过3项自动折叠

📄 完整代码

<!-- DynamicSearchBar.vue -->
<template>
  <el-form
    ref="formRef"
    :model="localParams"
    :inline="true"
    label-width="80px"
    size="small"
  >
    <!-- 显示项 -->
    <el-form-item
      v-for="(item, index) in displayedItems"
      :key="item.prop"
      :label="item.label"
      v-has-permi="item.permi"
    >
      <!-- input -->
      <el-input
        v-if="item.component.is === 'input'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @keyup.enter="handleQuery"
        clearable
      />
      <!-- select -->
      <el-select
        v-else-if="item.component.is === 'select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
        clearable
      >
        <el-option
          v-for="opt in item.component.options"
          :key="opt.value"
          :label="opt.label"
          :value="opt.value"
        />
      </el-select>
      <!-- date-picker -->
      <el-date-picker
        v-else-if="item.component.is === 'date-picker'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
      <!-- tree-select -->
      <el-tree-select
        v-else-if="item.component.is === 'tree-select'"
        v-model="localParams[item.prop]"
        v-bind="item.component"
        @change="handleQuery"
      />
    </el-form-item>

    <!-- 操作按钮 -->
    <el-form-item>
      <el-button type="primary" @click="handleQuery">查询</el-button>
      <el-button @click="handleReset">重置</el-button>
      <el-button
        v-if="items.length > 3"
        type="text"
        @click="toggleExpand"
      >
        {{ isExpanded ? '收起' : '展开' }}<i :class="`el-icon-arrow-${isExpanded ? 'up' : 'down'}`"></i>
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, computed, watch } from 'vue'
const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  params: {
    type: Object,
    required: true
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

// 响应式数据
const localParams = reactive({})
const isExpanded = ref(false)

// 计算显示项(折叠逻辑)
const displayedItems = computed(() => {
  if (isExpanded.value || props.items.length <= 3) {
    return props.items
  }
  return props.items.slice(0, 3)
})

// 同步外部 params
watch(() => props.params, (newVal) => {
  Object.assign(localParams, newVal)
}, { immediate: true, deep: true })

// 同步到外部
watch(localParams, (newVal) => {
  Object.assign(props.params, newVal)
}, { deep: true })

// 查询
function handleQuery() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
  // 触发事件
  emit('query', { ...localParams })
}

// 重置
function handleReset() {
  // 重置为初始值
  for (const key in localParams) {
    localParams[key] = ''
  }
  handleQuery()
  emit('reset')
}

// 切换展开
function toggleExpand() {
  isExpanded.value = !isExpanded.value
}

// 权限指令(示例)
const vHasPermi = {
  mounted(el, binding) {
    const { value } = binding
    if (value && !checkPermission(value)) {
      el.style.display = 'none'
    }
  }
}

// 模拟权限检查
function checkPermission(permi) {
  // 实际项目中从 store 或全局状态获取用户权限
  const userPermi = ['user:query', 'role:edit'] // 示例
  if (Array.isArray(permi)) {
    return permi.some(p => userPermi.includes(p))
  }
  return userPermi.includes(permi)
}

const emit = defineEmits(['query', 'reset'])
</script>

四、布局容器组件(LayoutContainer.vue)

💡 封装目标

  • 提供标准化布局结构
  • 集成常用操作(刷新/显隐列/折叠搜索)
  • 控制内容区高度自适应

📄 完整代码

<!-- LayoutContainer.vue -->
<template>
  <div class="layout-container">
    <!-- 搜索区 -->
    <div class="search-area" v-if="$slots.search" v-show="store.search">
      <slot name="search"></slot>
    </div>

    <!-- 内容区 -->
    <div :class="['content-area', config.fullContent ? 'full' : '']">
      <!-- 操作栏 -->
      <div class="action-bar" v-if="config.actions.show">
        <div class="left-actions">
          <slot name="actions-data"></slot>
        </div>
        <div class="right-actions" v-if="config.actions.table.show">
          <!-- 折叠搜索 -->
          <el-tooltip content="隐藏搜索" placement="top">
            <el-button
              circle
              @click="store.search = !store.search"
              v-show="config.actions.table.search"
            >
              <i class="el-icon-search"></i>
            </el-button>
          </el-tooltip>

          <!-- 刷新 -->
          <el-tooltip content="刷新" placement="top">
            <el-button
              circle
              v-show="config.actions.table.refresh"
              @click="handleRefresh"
            >
              <i class="el-icon-refresh"></i>
            </el-button>
          </el-tooltip>

          <!-- 显隐列 -->
          <el-tooltip content="显隐列" placement="top">
            <el-dropdown
              trigger="click"
              :hide-on-click="false"
              v-show="config.actions.table.columns"
              popper-class="column-toggle-popper"
            >
              <el-button circle>
                <i class="el-icon-menu"></i>
              </el-button>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item
                    v-for="col in props.columns"
                    :key="col.prop"
                  >
                    <el-checkbox
                      v-if="col.type !== 'selection'"
                      :model-value="!col.hide"
                      @update:model-value="(val) => toggleColumn(col, val)"
                    >
                      {{ col.label }}
                    </el-checkbox>
                  </el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </el-tooltip>
        </div>
      </div>

      <!-- 主体内容 -->
      <div class="main-content" v-if="$slots.default">
        <slot></slot>
      </div>
    </div>
  </div>
</template>

<script setup>
import { reactive, computed } from 'vue'
import { merge } from 'lodash-es'

// 默认配置
const DEFAULT_CONFIG = {
  fullContent: true,
  actions: {
    show: true,
    table: {
      show: true,
      search: true,
      refresh: true,
      columns: true
    }
  }
}

const props = defineProps({
  columns: {
    type: Array,
    default: () => []
  },
  config: {
    type: Object,
    default: () => ({})
  },
  tableRef: {
    type: Object,
    default: () => ({})
  }
})

const config = computed(() => {
  return merge({}, DEFAULT_CONFIG, props.config)
})

const store = reactive({
  search: true
})

// 刷新
function handleRefresh() {
  if (props.tableRef?.getList) {
    props.tableRef.getList()
  }
}

// 切换列显隐
function toggleColumn(column, visible) {
  column.hide = !visible
}
</script>

<style scoped>
.layout-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.search-area {
  padding: 15px;
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  margin-bottom: 16px;
}

.content-area {
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  flex: none;
}

.content-area.full {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.action-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px 0;
  gap: 12px;
}

.left-actions,
.right-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.main-content {
  flex: 1;
  padding: 16px;
  overflow: auto;
}
</style>

<style>
/* 全局样式(非 scoped) */
.column-toggle-popper .el-dropdown-menu__item {
  line-height: 32px;
  padding: 0 16px;
}
</style>

五、标准使用示例

📄 父组件(业务页面)

<template>
  <layout-container 
    :columns="columns" 
    :config="wrapConfig" 
    :table-ref="tableRef"
  >
    <!-- 搜索区 -->
    <template #search>
      <dynamic-search-bar
        :items="searchItems"
        :params="queryParams"
        :table-ref="tableRef"
      />
    </template>

    <!-- 左侧操作 -->
    <template #actions-data>
      <el-button type="primary" @click="handleAdd">新增用户</el-button>
    </template>

    <!-- 表格 -->
    <smart-table
      ref="tableRef"
      :columns="columns"
      :func="getUserList"
      :params="queryParams"
      :config="tableConfig"
      :events="tableEvents"
    >
      <!-- 自定义操作列 -->
      <template #operation="{ row }">
        <el-button size="small" @click="handleEdit(row)">编辑</el-button>
        <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </smart-table>
  </layout-container>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { getUserListAPI } from '@/api/user'

// 查询参数
const queryParams = reactive({
  username: '',
  status: '',
  createTime: []
})

// 表格列
const columns = reactive([
  { label: '用户名', prop: 'username' },
  { label: '状态', prop: 'status', dict: 'sys_normal_disable' },
  { label: '创建时间', prop: 'createTime', date: true },
  { label: '操作', prop: 'operation', slot: 'operation', width: 180 }
])

// 配置
const wrapConfig = {
  fullContent: true
}

const searchItems = [
  { label: '用户名', prop: 'username', component: { is: 'input', placeholder: '请输入' } },
  { 
    label: '状态', 
    prop: 'status',
    component: { 
      is: 'select',
      options: [
        { value: '1', label: '启用' },
        { value: '0', label: '禁用' }
      ]
    }
  },
  {
    label: '创建时间',
    prop: 'createTime',
    component: { is: 'date-picker', type: 'daterange', rangeSeparator: '-' }
  }
]

const tableConfig = {
  sort: true
}

const tableEvents = {
  formatParams(params) {
    // 处理日期范围
    if (params.createTime?.length) {
      params.beginTime = params.createTime[0]
      params.endTime = params.createTime[1]
      delete params.createTime
    }
    return params
  }
}

const tableRef = ref(null)

// API 方法
async function getUserList(params) {
  const res = await getUserListAPI(params)
  return { data: { rows: res.list, total: res.total } }
}

// 操作方法
function handleAdd() { /* ... */ }
function handleEdit(row) { /* ... */ }
function handleDelete(row) { /* ... */ }
</script>

六、关键设计总结

✅ 为什么这样设计?

问题 解决方案 优势
每页重复写表格结构 SmartTable 封装 减少 70% 模板代码
搜索表单千奇百怪 DynamicSearchBar 配置驱动 统一体验,快速开发
刷新/显隐列位置不一 LayoutContainer 标准化 全系统交互一致
列显隐状态难管理 直接修改 columns.hide 无需 emit,天然响应式
业务逻辑耦合 UI events 解耦 + slot 覆盖 高内聚低耦合

⚠️ 使用注意事项

  1. columns 必须是响应式对象

    // ✅ 正确
    const columns = reactive([...])
    
    // ❌ 错误
    :columns="[{ label: 'ID', prop: 'id' }]"
    
  2. 不要在组件内部写业务 API

    • 所有数据请求通过 func prop 传入
    • 参数处理通过 events.formatParams
  3. 复杂 UI 用插槽覆盖

    • 操作列 → slot
    • 自定义单元格 → slot
  4. 权限控制统一接入

    • 搜索项权限 → v-hasPermi
    • 按钮权限 → 父组件控制

在这里插入图片描述

💬 结语:这套组件体系已在多个大型后台项目中验证,显著提升开发效率与代码质量。核心思想是 “配置驱动 UI,事件解耦逻辑,插槽覆盖特例”,在规范性与灵活性之间取得平衡。

Tauri (24)——窗口在隐藏期间自动收起导致了位置漂移

2025年12月31日 17:01

背景

在桌面端应用中,我们为 SearchChat 设计了一种「紧凑模式」:

  • 正常状态下窗口高度较大
  • 当一段时间无操作后,窗口会自动收起到紧凑高度(例如 84px)
  • 收起动作由 compactModeAutoCollapseDelay 控制,比如 5 秒后触发

整体体验在大多数情况下是正常的,但在一次使用中发现了一个非常隐蔽却影响体验的问题


问题现象

问题只会出现在一个特定时序下:

  1. 窗口处于紧凑模式的 “延迟收起倒计时” 中(例如还剩 2~3 秒)
  2. 用户通过快捷键 主动隐藏窗口
  3. 延迟计时器仍然在后台触发
  4. 计时结束后,执行了 setWindowSize 收起逻辑
  5. 用户再次用快捷键唤起窗口

结果是:

窗口位置发生了漂移,不再出现在隐藏前的位置。

这个问题在某些平台或窗口管理器上尤为明显。


问题根因分析

拆开来看,核心原因其实并不复杂:

  • 延迟收起逻辑是一个 纯前端的定时器
  • 窗口被隐藏后,计时器并不会自动停止
  • 计时器触发时,仍然会调用 setWindowSize
  • 某些平台在「窗口不可见」状态下修改窗口尺寸时,会重新计算窗口位置
  • 这个重算过程不是我们可控的

因此,真正的问题不是“收起”本身,而是:

在窗口不可见时发生了尺寸变化,导致系统偷偷帮我们改了位置。


核心设计目标

我们希望做到一件事:

即使窗口在隐藏状态下被触发了尺寸变更,也要保证它在再次显示时,仍然回到隐藏前的位置。

并且要满足几个约束:

  • 不侵入现有窗口尺寸策略
  • 不依赖平台特性 hack
  • 能正确处理高 DPI 场景
  • 修改范围尽量小

解决思路(前端侧)

整体方案分为两步。

一、在窗口失焦 / 隐藏时,记录当前位置

当窗口即将被隐藏时,我们可以认为此刻的位置是“用户认可的位置”。

SearchChat 中:

  • 通过 useTauriFocusonBlur 回调
  • 调用 outerPosition() 获取当前窗口位置
  • 将结果保存到 windowPositionRef

关键点在于:

  • outerPosition() 返回的是 physical position
  • 这个坐标不受 DPI / scale factor 影响
const pos = await window.outerPosition()
windowPositionRef.current = { x: pos.x, y: pos.y }

代码位置:

src/components/SearchChat/index.tsx:113-119

二、延迟收起触发时,如果窗口不可见,强制恢复位置

在自动收起的定时器中:

  1. 正常执行 setWindowSize

  2. 紧接着判断窗口当前是否可见

  3. 如果窗口是隐藏状态,并且我们之前记录过位置:

    • 主动把窗口位置设回去

伪代码逻辑如下:

await platformAdapter.setWindowSize(width, height)

if (!(await window.isVisible()) && windowPositionRef.current) {
  const { x, y } = windowPositionRef.current
  await platformAdapter.setWindowPhysicalPosition(x, y)
}

代码位置:

src/components/SearchChat/index.tsx:158-179

这样即使系统在隐藏期间偷偷“动了手脚”,也会被我们立刻纠正。


为什么要用 Physical Position

这里有一个非常容易踩坑的点:DPI 缩放

  • outerPosition() 返回的是 physical position
  • 项目中原有的 setWindowPosition(x, y) 使用的是 logical position
  • 如果存的是 physical,却用 logical 去设,高 DPI 下会产生明显偏移

因此,我们补充了一个明确的 API:

setWindowPhysicalPosition

Tauri 实现

import { PhysicalPosition, getCurrentWebviewWindow } from '@tauri-apps/api/window'

const win = getCurrentWebviewWindow()
await win.setPosition(new PhysicalPosition(x, y))

代码位置:

src/utils/tauriAdapter.ts:85-89

Web 实现(占位)

Web 模式下不需要真实移动窗口,只保留日志即可:

src/utils/webAdapter.ts:88-90

最终效果

这个方案带来的收益非常明确:

  • ✅ 修复隐藏期间自动收起导致的窗口位置漂移
  • ✅ 正确处理高 DPI 场景,避免 logical / physical 混用
  • ✅ 改动范围小,只在 SearchChat 的定时收起路径兜底
  • ✅ 不影响其他窗口尺寸或动画策略

手动验证步骤

建议按以下流程验证:

  1. 设置 compactModeAutoCollapseDelay = 5
  2. 打开窗口,确保满足进入紧凑模式的条件
  3. 在 5 秒倒计时期间,使用快捷键隐藏窗口
  4. 等待超过 5 秒
  5. 再次用快捷键唤起窗口

预期结果:

窗口应出现在隐藏前的位置,不应发生任何跳动或漂移。


小结

这个问题本质上不是 “窗口 API 用错了”,而是 多个合理行为在特定时序下叠加,暴露出的系统边界问题

解决它的关键,不是阻止自动收起,而是:

尊重用户最后一次看到的窗口状态,并在必要时为系统行为兜底。

这类问题在桌面端应用中非常常见,也非常容易被忽略,希望这次的整理能对你有所帮助。

从一行好奇的代码说起:Vue怎么没有React的props.children

作者 雲墨款哥
2025年12月31日 16:37

引言

最近在学习 React 的过程中,我发现了一个有趣的特性:父子组件间的 props.children 传递。这让我不禁思考:Vue 中是否也有类似的功能?

React 中的 Children 传递

基础示例

先来看一个简单的父子组件示例:

父组件 App.tsx

import Son from './study/Son.tsx';

function App() {
  return (
    <>
     <Son message={'this is a reactApp'}>
      <span>this is a span</span>
     </Son>
    </>
  )
}

export default App

子组件 Son.tsx

function Son(props: any) {
    console.log(props);
    
    return <div>{props.message}</div>;
}
export default Son;

这是如何工作的?

通过控制台的输出内容可以看到,在 React 中,当你在组件标签内部放置内容时,这些内容会作为 props.children 传递给子组件。这是一个非常强大的特性,它允许组件作为容器来包裹其他组件或元素。

3ae12ef1-f807-487c-ab57-7bf1482f1838.png

所以,在子组件中,只要使用props.children就可以把它显示出来。:

Vue 中的类似功能

在我的认知中,Vue 似乎不能这样写?实际上,Vue 也有类似的功能,但语法不同:

Vue 3 中的插槽 (Slots)

<!-- 父组件 -->
<template>
  <Son message="this is a vue app">
    <span>this is a span</span>
  </Son>
</template>

<!-- 子组件 Son.vue -->
<template>
  <div>
    <div>{{ message }}</div>
    <slot></slot> <!-- 这里会渲染父组件传递的内容 -->
  </div>
</template>

<script setup>
defineProps(['message'])
</script>

Vue 使用 <slot> 元素来接收父组件传递的内容,而 React 使用 props.children。两者语法不同,概念相似,但也有一些区别的。

props.children 与 slot 的本质差异

React 的逻辑:数据驱动,责任自负

在 React 中,当父组件这样写:

<Son>
  <span>我是内容</span>
</Son>

实际上被转译成:

React.createElement(Son, {
  children: React.createElement('span', null, '我是内容')
})

关键点

  1. 数据已经送达:子组件无论如何都能在 props.children 中访问到这个 <span> 元素
  2. 处理权完全在子组件:子组件可以选择渲染、修改、忽略,甚至把它放到别的地方
  3. 无法"拒绝接收" :这份数据(React 元素引用)已经作为参数传递给了子组件函数
// 子组件可以有各种处理方式
function Son(props) {
  // 1. 正常渲染
  // return <div>{props.children}</div>
  
  // 2. 包装后再渲染
  // return <div className="wrapper">{props.children}</div>
  
  // 3. 有条件地渲染
  // return props.show ? props.children : null
  
  // 4. 拆分处理
  // const childrenArray = React.Children.toArray(props.children)
  // return childrenArray.map(child => <div className="item">{child}</div>)
  
  // 5. 完全忽略
  return <div>我不需要 children</div>
}

Vue 的逻辑:模板驱动,需要显式声明

现象观察

父组件

<template>
  <Son message="Hello">
    <span class="important">我是重要内容!</span>
  </Son>
</template>

子组件

<!-- 情况1:有 slot 签收 -->
<template>
  <div>
    <div>{{ message }}</div>
    <slot></slot> <!-- span 会被渲染 -->
  </div>
</template>

<!-- 情况2:没有 slot 签收 -->
<template>
  <div>
    <div>{{ message }}</div>
    <!-- 没有 slot,span 就像不存在一样 -->
  </div>
</template>

在 Vue 中:

  1. 编译时处理:Vue 在编译阶段处理插槽内容
  2. 需要显式签收:只有在子组件模板中写了 <slot>,内容才会被渲染
  3. 未签收就丢弃:如果没有 <slot>,内容在渲染阶段就被丢弃了

代码背后的逻辑

// Vue 在编译时决定是否包含插槽内容
// 如果子组件没有 slot,父组件的内容就不会被包含在渲染函数中
  • 插槽是模板特性:slot 是模板语法的一部分
  • 编译时决策:在编译阶段决定是否包含内容
  • 更安全的默认行为:防止意外渲染

React 的优势与风险

优势

  1. 灵活性极高:可以任意操作 children
  2. 模式多样:支持 render props、HOC 等模式
  3. 运行时控制:可以在运行时动态决定如何处理
// React 的灵活模式
function Toggle({ children }) {
  const [isOn, setIsOn] = useState(false);
  
  return (
    <>
      <button onClick={() => setIsOn(!isOn)}>
        Toggle
      </button>
      {/* 运行时决定渲染哪个 child */}
      {isOn ? children : null}
    </>
  );
}

风险

  1. 可能浪费资源:即使不渲染,children 也被创建和传递
  2. 需要更多注意:必须显式处理,否则可能意外遗漏

Vue 的优势与局限

优势

  1. 性能优化:未使用的插槽内容不会被包含
  2. 更安全:不会意外渲染未声明的内容
  3. 模板清晰:在模板中明确显示插槽位置
<!-- Vue 3 组合式 API 中 -->
<script setup>
// 可以访问 slots
import { useSlots } from 'vue'

const slots = useSlots()
// 检查是否有某个插槽内容
const hasHeader = slots.header
</script>

局限

  1. 灵活性较低:操作插槽内容不如 React 灵活
  2. 模板限制:必须在模板中声明插槽位置

Axios 常用配置及使用

作者 28256_
2025年12月31日 16:27

Axios配置详解

{
常用实例配置项
// `baseURL` 将自动加在 `url` 前面,除非 `url` 是一个绝对 URL。
// 它可以通过设置一个 `baseURL` 便于为 axios 实例的方法传递相对 URL
baseURL: 'https://some-domain.com/api/',
// `timeout` 指定请求超时前的毫秒数。 
// 如果请求耗时超过 `timeout`,则请求将被中止。
timeout: 1000, // default is `0` (no timeout)
// `withCredentials`用于指示跨域访问控制请求是否携带凭证
// 请求需要携带token时,需要设置为true
withCredentials: false, // default
常用请求配置项
url: '/user',
method: 'get', // default
headers: {'X-Requested-With': 'XMLHttpRequest'},
// `responseType` 表示服务器将返回的数据类型
// 选项包括:'arraybuffer', 'document', 'json', 'text', 'stream'
// browser only: 'blob'
responseType: 'json', // default
// `params` 是即将与请求一起发送的 URL 参数
// 一般用于get请求携带参数
// 也可post请求时,在url上拼接参数
params: { ID: 12345 },
// `data` 是作为请求主体被发送的数据
// 只适用于这些请求方法 'PUT', 'POST', 和 'PATCH'
data: { firstName: 'Fred' },
不常用配置项
  // `transformRequest` 允许在向服务器发送前,修改请求数据
  // 只能用在 'PUT', 'POST' 和 'PATCH' 这几个请求方法
  // 后面数组中的函数必须返回一个字符串,或 ArrayBuffer,或 Stream
  transformRequest: [function (data, headers) {
    // 对 data 进行任意转换处理
    return data;
  }],

  // `transformResponse` 在传递给 then/catch 前,允许修改响应数据
  transformResponse: [function (data) {
    // 对 data 进行任意转换处理
    return data;
  }],
}

Axios用法

基本用法

axios(config)

axios({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  },
  ...
});

通过别名使用

axios.request(config)

使用起来更简单,方便书写,减少字段的重复书写。
通过别名使用时urlmethoddata 这些字段名可忽略不写。
header之类的需要指明字段名

// 忽略了method url params 等字段
axios.get('/user?ID=12345')
// 也可以是
axios.get('/user', {
    params: {
      ID: 12345
    }
  })
// 忽略了method url data 等字段
axios.post('/user', {
  firstName: 'Fred',
  lastName: 'Flintstone',
})

针对get请求可以简化为下面这种。method默认就是get

axios('/user/12345'); 

Axios如何取消请求

Axios注意事项

  1. 后端返回的长整形数据过长,会导致精度丢失,出现变为0的情况
    原因: axios在处理HTTP响应时,默认使用JSON.parse()解析数据,但JavaScript的number类型安全整数范围有限(最大安全值为2^53 - 1,约16位十进制数),超出时会导致精度丢失,常见于后端返回的长整型ID(如雪花算法生成的19位ID)。‌
❌
❌