普通视图

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

不如摸鱼去的 2025 年终总结,今年的关键词是直面天命

2025年12月26日 12:57

大家好,我是不如摸鱼去。一转眼又到了年底总结的时候,在这一年我也步入了而立之年。

对我来说,2025 年是很不平凡的一年。工作上匆匆忙忙、连滚带爬、没有涨薪;开源分享和写文章取得了一定成果;生活上经历了父亲患癌治疗、人生第一次手术等变故。

总感觉人生的前三十年像是一直没长大,而世界却无法继续支持我维持这个状态。在学校的时候,每过一年就会有个考试告诉我“你升级了”;而离开学校后,只有残酷的现实跳出来告诉我:“小子,你得升级闯关了”。当然,通过之后得到的也不是分数,而是人生经历。

所以说,今年我的关键词是直面天命。人生是用来经历的,而不是被考核的,喜怒哀乐皆是天命。

关于工作

关于工作的总结就是四个字:变化很小

今年的工作也是匆匆忙忙、连滚带爬,只要肯干活,就有干不完的活。不过出来打工最重要的是赚钱,有工作干就已经很不错了(手动🐶)。

群里好几个小伙伴都经历了失业,后疫情 + AI 时代,前端的工作机会确实是少了。虽然宏观上工作机会的确少了,涨薪不易,但是具体到个人,还是得努力学习、完善自我,争取得到更好的工作和薪资。

关于生活

父亲患病

起初

今年国庆节,我没有回家,和对象一起到哈尔滨旅游,去看了老虎、索菲亚教堂等等。准备去长白山的前一天,接到了我妹的电话,说我爸去医院看痔疮,医生说可能是不好的东西。我当时脑子都懵了,赶紧买了票从哈尔滨连夜回到了商丘老家,路上哭了几次。

到上海治疗

回到家,医生找我谈话,判断下来是直肠CA,县里医院也能做,让我考虑一下是不是在这个医院治疗。唉,这块也没了解过,完全是个盲区。

连夜了解了相关的知识后,还是决定到上海来看病,于是买了第二天的票带我爸到了上海。到复旦肿瘤医院交病理、看医生、住院、手术、造口护理、化疗,直到今天已经接近两个月了。中间请了一个月的假陪我父亲看病。看病陪护的日子感觉好长好长,一周像是过了一年,每天都在恶补相关的知识。还好有我两个妹妹和我对象在,帮我爸度过了最艰难的日子。

费用

我爸是河南的农村医保,并且买了个重疾险。来到上海是异地就医,办理了临时异地就医(其实直接办长期就行,报销比例会高点),住院大概有 50% 的报销比例。

总体医保结算后的费用大概是自己付了 6.1 万,然后保险赔付了 4.9 万。不过有很多非医院的付费项目不能报销,大概整个手术下来有个 4 万左右的自费费用吧。目前还在化疗中,费用比较分散,还没有统计。

小结

其实我爸并不是痔疮。痔疮人人都有,两年前他有点便血,我让他去医院检查,但是他不愿意去(据他解释是听村里人说痔疮做手术会失禁),害怕做手术。去年年底回家问他还有症状吗,他说已经好了。其实他都是装的,八九月份突然暴瘦了,感觉不对劲才去的医院。

唉,要是早点催他去医院检查,就能有更好的治疗方案了。有钱难买早知道,遇事只能向前看,尽力配合医生治疗,希望能够早日康复起来!

我的人生第一次手术

2023 年阳康之后,我不幸得了肛周脓肿,也是个难受的病,去医院做了个门诊切开引流术。本来准备 2025 年国庆节之后做个根治手术解决这个问题,没想到赶上我爸罹患了直肠 CA。

本月初,我爸情况稳定之后,我在仁济医院做了人生第一次全麻手术。全麻的感觉很奇妙,进去的时候还在跟麻醉医生聊天,说了几句话后就没有知觉了,随后醒来就是在复苏室了。

据说人从肛肠科出来后,都会成长不少😭。

目前还在康复中,非常感谢我对象在我康复的过程中帮助我换药,陪我去医院复诊。整个术后的过程,前三天就是疼疼疼,前两周就是上厕所疼。医生说真男人坚持一个月就是康复了😂。

游戏

去年《黑神话·悟空》很火,它是第一部国产 3A 大作,我也买了,并且还做完了成就,体验下来非常不错。其实我也没有玩过什么 3A,只是感觉自己要去玩一玩。 《黑神话·悟空》的画面很精美,游戏也很好玩,真的很期待DLC。 拔根猴毛来拜一拜,调息中...

养鱼

2023 年居家隔离结束的时候,起了个草缸,买了点青鳉养了养,现在又养了点白云金丝和斗鱼。这些鱼都挺好养的,斗鱼的互动性还很强,平时喂鱼跟喂狗一样,一摆手鱼就过来了。写代码累了,就来和鱼们玩一玩,休息休息,哈哈。

领养小猫

我对象一直想养个猫,于是在这周领养了一只小黑猫,暂时取名小黑毛。来了几天了,逐渐熟络了。

它长这样:

这样:

小结

生活总是是五味杂陈的,不能一直甜也没有一直苦,九九百十一难都走过了,还怕这小小生活吗,哈哈,共勉。

开源分享

参与开源项目是个人成长最好的方式之一。不过很遗憾,由于家里和自身身体上的一些原因,近两个月很少在开源项目上投入精力,看看我的GitHub 2025总结吧。

以下是我创建和参与的开源项目,欢迎一起参与!

  • wot-ui: wot-ui.cn/
    一个基于 Vue3 + TS 开发,提供 70+ 高质量组件,支持暗黑模式、国际化和自定义主题,高颜值、轻量化的 uni-app 组件库。
  • wot-starter: starter.wot-ui.cn/
    飞一般开发体验的 uni-app 模板。
  • uni-mini-ci: github.com/Moonofweish…
    一个 uni-app 小程序端构建后支持 CI(持续集成)的插件。
  • @wot-ui/router: github.com/wot-ui/my-u…
    一个轻量级 uni-app 路由库。
  • 等等...

当然,你可以到我的 GitHub 主页 github.com/Moonofweish… 探索更多我的开源项目。

团队成员、朋友们和贡献者

团队成员

我们的小开源团队,已经初具规模,以吾观之,也是人才济济啊。

  • Skiyee: uni-ku 的创立者,重金雇佣兵,精通 JS 和 TS 的全能攻城狮。
  • 二狗:灵活就业大师,问题毁灭者,总能迅速解决各种技术难题。
  • Jasper:新技术狂热分子,始终走在技术前沿,热衷于探索最新的开发趋势。
  • 兔子不想和你说话:萌新守卫者,耐心指导新手,帮助他们快速成长。
  • 人间有味是清欢:Pull Shark,热衷参与开源组件建设。
  • 云阁:新晋队员,初生牛犊猛如虎。
  • 百友:新晋队员,失业再就业专家。
  • xiaohe0601:新晋队员,uni-echarts 创立者,nutui-uniapp的 维护者,npm Trusted Publisher 发包大师。

同时也结交了很多社区朋友,像来自uni-helper团队的各位好朋友们,大哥 ModyQyW,FD 哥 FliPPeDround等等,还有来自unibest菲鸽,还有我的群友们,也非常感谢他们。

贡献者

我们有八十多个贡献者,经常贡献的朋友也有很多个,非常感谢这些朋友们。

2k Star 以及获奖啦

GitHub 达到 2k Star。

在 DCloud 2025 插件大赛中获得了一个小奖,也是很高兴的一个事情。

社群

目前已经创建了 3 个 QQ 互助交流群和 2 个微信互助交流群,群友人数达到了 2k 以上了😌。

写文章

2025 年,我在尝试写文章分享一些前端和 AI 编程相关的知识和最佳实践,例如公众号、掘金、CSDN、小红书、抖音等等。不过在这方面,我还是个拙劣的模仿者,探索中...

目前已经写了几十篇文章,内容主要分为了四个专栏,分别是:uni-app 最佳实践、AI 编程提效、wot-ui 开发指南和前端百宝箱,主要是沉淀和分享我的一些实践和方案。我用 AI 总结了今年的主要内容,做了手绘图如下。

小结

写文章这方面,我是刚刚起步,希望可以作为一个自身的积累,也可以作为一个副业,同时希望可以和大家多多交流、分享。

AI Coding

2024 年以来,AI Coding 取代前端开发的论调层出不穷,出一个新的大模型,前端就会死一次。

虽然不想承认,但是 AI Coding 的确是在推动一场信息行业的工业革命,它正在重塑整个行业的工作流。

不过目前 AI Coding 仍然没有替代任何一个职业,“AI 落地的最后一公里”仍未解决,人仍然在 AI Coding 中扮演着决策者和指导者的角色。

尽管当前 AI Coding 的脉络仍未清晰,但是 AI Coding 未来确实无比值得期待。所以我们要拥抱变化,拥抱未来,拥抱 AI Coding。

我也在努力探索 AI Coding 工具在前端开发上的落地,写了多篇文章被 Trae 公众号收录为最佳实践。

总结与展望

2025 总结

2025 年对我来说是不平凡的一年,很多事情催人成长。坎坷再多也无需多言,只得直面天命,每个人都是自己的大圣、自己的天命人。

2026 展望

2026 年,希望我爸快快康复,希望我的家人、各位和家人身体都健健康康的,也希望自己工作顺利,并且能将我的开源项目、写文章、自媒体的想法能够做得更好,目标就不详细列出来了,有的时候计划不如变化啊。

欢迎评论区沟通、讨论、分享👇👇

Cesium 去掉默认瓦片和地形,解决网络不好时地图加载缓慢的问题

作者 闲云一鹤
2025年12月26日 11:12

抽丝剥茧,找到真凶

甲方反馈地图经常出现偶发性加载缓慢的问题

我寻思在我本地开发环境很快啊,上了测试环境也没毛病,咋个部署到正式环境就地图慢了?

经过排查发现每次地图加载缓慢时 endpoint 接口都在报错(控制台也提示 net::ERR_CONNECTION_TIMED_OUT),并且百度也无法访问(甲方项目部署在内网,访问公网不太流畅)

破案了!罪魁祸首就是这两个接口请求在报错(图片无错误是因为我网络好,这里假装它在报错)

1.png

https://api.cesium.com/v1/assets/1/endpoint?access_token="你的token" https://api.cesium.com/v1/assets/2/endpoint?access_token="你的token"

这两个接口请求分别是加载 cesium 默认的地形数据和地图瓦片(其中 assets/1 是地形,assets/2 是瓦片)由于 cesium 服务器在国外,所以网络波动等原因会导致访问不稳定,从而影响地图加载速度

哪怕你是调用了第三方的地图瓦片,比如天地图瓦片服务,如果网络不稳定也会出现此错误,它会阻塞地图的正常加载

要想彻底解决只有将它禁用

禁用 cesium 默认地图瓦片

默认地图瓦片默认是启用的,需要手动禁用

const viewer = new Cesium.Viewer('CesiumViewer', {
  ...
  imageryProvider: false // 不加载默认底图
  ...
})

我们来测试下效果,这里放图片(算了不放了,节约文章空间)

禁用 cesium 默认地形数据

地形默认是不启用的,如果你自己启动了,把它注释了就可以了

const viewer = new Cesium.Viewer('CesiumViewer', {
  ...
  terrain: Cesium.Terrain.fromWorldTerrain(), // 使用Cesium官方的地形服务 全球地形数据
  ...
})

注意:我的 Cesium 版本为 1.128,不同版本的启动地形和加载默认地图方法会不一样,请根据你项目中的版本去官方查找对应的 api 进行修改

刚接触三维地图的朋友可能会问,地形是个什么玩意?开启与关闭有何区别?这里我放两张截图来作对比(采用同样的视角)

这是加载地形的效果:可以看见,山势有明显的高低起伏

2.png

这是关闭地形的效果:可以看见,山的高度没了,地图的 3D 立体变成了二维扁平

3.png

结语

细心的朋友可能会问,既然地形对于三维地图如此重要,那么把他禁用了那岂不是效果大打折扣吗?是的,那么有什么办法,既能解决网络不好时报错,又能使地形存在呢?

办法是有的,那就是自己部署地形服务并调用,不从 cesium 官方获取地形接口即可

具体如何操作?如果有人感兴趣的话就下次再写吧

🏆2025 AI/Vibe Coding 对我的影响 | 年终技术征文

作者 掘金酱
2025年12月26日 11:04

img_v3_02t9_51022880-c5f3-4f3c-8892-b6f6ca1ba8eg.png

当岁末的钟声临近,我们又站在了一年的重点回望。2025年,对你而言,是怎样的轮廓呢?

它或许是由一行行被AI重构的代码勾勒,是某个深夜与新技术“顿悟时刻”的灵光一现,也可能是生活中因为智能体工具而悄然改变的工作状态。

从智能体(Agent)的横空出世到多模态技术的经验突破,技术愈加深入地流淌尽我们的工作和生活日常,塑造着属于每个人的独特“Vibe”。

今天,稀土掘金正式发起这场专属社区的年终“围炉夜话” ,我们不谈宏大的行业预言,不设苛刻的评审框架。我们只想邀请您,记录下属于你的数字年轮。

✨主题 :2025 AI/Vibe Coding对我的影响

本次征文不再是一场竞赛,而是一次社区的年终“围炉夜话”。我们鼓励所有成员停下脚步,回望2025年技术与个人生活交织的痕迹。无论是代码世界的深刻变革,还是AI工具带来的细微习惯改变,每一次记录,都是我们共同的“数字年轮”。

⁉️如何参与

发布文章时选择带话题 #2025 AI/Vibe Coding 对我的影响# 就可以被统计到哦💁🏻

image.png

🗒️赛道说明

参与者可以任选其一进行投稿,也可同时参与,不限制投稿文章数。

赛道一:2025,我的“Vibe Coding”时刻

主题解读

聚焦技术对“我”的个体影响。可以是一个AI工具、一个开源项目、一次技术决策、一种行业趋势,也可以是技术带来的某种生活方式。

内容方向建议

  • Agent革命

ChatGPT/Gemini如何改变我的开发效率,大模型如何让我的代码如鱼得水?(技术冲破对工作模式/效率带来的影响,任意模型/技术皆可)

  • 生活Vibe

Web3、数字游民等概念开启的新生活尝试,分享自己用AI生成旅行攻略、创作音乐、辅导孩子学习的故事等;

  • 年度总结

以个人视角,复盘你在某个技术领域(前端、后端、AI、运维等)或者生活上这一年的得失与观察。

赛道二:「我和TRAE的这一年」

主题解读

一个针对TRAE的专属回忆录,记录掘金社区TRAE深度用户与其共同成长的故事。

内容方向建议

  • 成长见证

记录自己使用TRAE完成的第一篇文章、第一个获赞、第一次参与开源项目、第一次开发的游戏等;

  • 连接故事

通过社区所结识到的志同道合的TRAE友、参与的线下/线上活动、与TRAE官方运营互动的有趣瞬间;

  • 产品共建

对TRAE功能提出的建议被采纳,或使用各个模式提升学习效率的真实体验瞬间。

❗参加赛道二 的文章除话题 #2025 AI/Vibe对我的影响 外,必须加上#TRAE标签

image.png

☝️征文要求

本次征文活动需要符合主题,对于文章类型以及活动参与方式,我们有以下几点小要求。

  1. 文章须为原创文章,内容符合掘金社区的内容标准和规范

  2. 本次征文不限制文章题材,可以是你对行业的总结和见解,也可以是你对行业未来发展的预测,本次活动不接受下面几种文章:

    1. 资源聚合类文章,例如 Awesome-List;
    2. 翻译类文章和利用AI生产文章;
    3. 与本次主题无关的文章;
    4. 学习笔记/知识点汇总类文章;
    5. 有失中立性、公正性的内容,比如由公司或者公司的代理机构(如公关公司)所撰写,单纯希望宣传自己的商业产品或者公司的内容;
    6. 内容与活动主题不符、非原创内容,有洗稿、营销软文、广告、抄袭嫌疑的文章。
  3. 刷赞、刷量等有作弊行为的文章,直接取消比赛资格,不参与评选;

  4. AI代写文章、AI聊天记录等不计入活动(AI检测超过70% 取消文章获奖资格);

  5. 活动文章需要选择话题:juejin.cn/theme/detai…

  6. 获奖作品,著作权归作者所有,掘金拥有使用权;

🥇奖项设置

文章将根据专家评审得分(占比70%)和文章热度(占比30%)得分加权计算。未获得官方推荐的文章不参与奖项评选。

奖项名称 奖项设置 获奖人数 奖品名称 奖品图
主赛道 优秀文章奖(前10篇) 10名 小熊(Bear)电烧烤炉 多功能料理锅电烤炉 DKL-D12A1 image.png
掘金达人奖(11-40篇) 30名 稀土掘金 Yoyo抱枕新版-450g
阳光普照奖(41-100篇) 60名 稀土掘金 x ByteMall 联名盲盒
TRAE特别赛道鼓励奖(叠加) TOP1-10 10名 价值200元 TRAE连帽卫衣+抱枕
TOP11-30 20名 价值100元 TRAE圆领卫衣
TOP31-50 20名 价值50元 TRAE单肩包
TOP51-100 50名 TRAE小鼠标垫

🤵征文对象

掘金社区全体掘友

⏰活动时间

2025年12月26日——2026年1月25日23:59

👀 活动Q&A

Q1:投稿数量有限制吗?

A:没有。

Q2:一篇文章可以同时参与多个社区活动吗?

A:不行。一篇文章只能参与一个社区活动,可以写多篇文章参与不同的活动。

Q3: 本次活动中写了多篇文章,可以多次获奖吗?

A:参加特别赛道的文章,同时参与主赛道激励文章排名,中奖奖品兼得。但是,主赛道奖项一个用户只能获得一次,多篇文章上榜时选取最高名次获奖。

Q4:如何算是成功参与了活动?

A:活动期间,在掘金平台发布2025 AI/Vibe 对我的影响年终技术征文,选择带话题  #2025 AI/Vibe Coding 对我的影响#  ,即可成功参与活动。注意话题≠标签!

💡注意事项

2026年1月25日活动结束后,约10-15个工作日通过系统消息的形式公示获奖情况,预计填写问卷后的20个工作日内完成奖品发放。

技术浪潮奔涌向前,但属于个体的记忆同样珍贵。让我们用文字,为这不平凡的一年按下存档键。

来掘金,写下你的2025。你的故事,我们都在听。

注:最终解释权归稀土掘金技术社区 官方所有

vue 表格 vxe-table 实现前端分页、服务端分页的用法

2025年12月26日 10:24

vue 表格 vxe-table 实现前端分页、服务端分页的用法,通过设置 pager-config 开启表格分页

vxetable.cn

实现前端分页

通过监听分页的 page-change 事件来来刷新表格数据

<template>
  <div>
    <vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const allList = [
  { id: 10001, name: 'Test1', nickname: 'T1', role: 'Develop', sex: 'Man', age: 28, address: 'Shenzhen' },
  { id: 10002, name: 'Test2', nickname: 'T2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
  { id: 10003, name: 'Test3', nickname: 'T3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
  { id: 10004, name: 'Test4', nickname: 'T4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
  { id: 10005, name: 'Test5', nickname: 'T5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
  { id: 10006, name: 'Test6', nickname: 'T6', role: 'Designer', sex: 'Women', age: 21, address: 'Shenzhen' },
  { id: 10007, name: 'Test7', nickname: 'T7', role: 'Test', sex: 'Man', age: 29, address: 'Shenzhen' },
  { id: 10008, name: 'Test8', nickname: 'T8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
  { id: 10009, name: 'Test9', nickname: 'T9', role: 'Develop', sex: 'Man', age: 35, address: 'Shenzhen' },
  { id: 100010, name: 'Test10', nickname: 'T10', role: 'Develop', sex: 'Man', age: 35, address: 'Guangzhou' },
  { id: 100011, name: 'Test11', nickname: 'T11', role: 'Develop', sex: 'Man', age: 49, address: 'Guangzhou' },
  { id: 100012, name: 'Test12', nickname: 'T12', role: 'Develop', sex: 'Women', age: 45, address: 'Shanghai' },
  { id: 100013, name: 'Test13', nickname: 'T13', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100014, name: 'Test14', nickname: 'T14', role: 'Test', sex: 'Man', age: 29, address: 'Shanghai' },
  { id: 100015, name: 'Test15', nickname: 'T15', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100016, name: 'Test16', nickname: 'T16', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100017, name: 'Test17', nickname: 'T17', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100018, name: 'Test18', nickname: 'T18', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' },
  { id: 100019, name: 'Test19', nickname: 'T19', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100020, name: 'Test20', nickname: 'T20', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100021, name: 'Test21', nickname: 'T21', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100022, name: 'Test22', nickname: 'T22', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' }
]

// 前端本地分页
const mockList = (pageSize, currentPage) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        total: allList.length,
        result: allList.slice((currentPage - 1) * pageSize, currentPage * pageSize)
      })
    }, 200)
  })
}

const loadList = () => {
  const { pageSize, currentPage } = pagerVO
  gridOptions.loading = true
  mockList(pageSize, currentPage).then((data) => {
    gridOptions.data = data.result
    pagerVO.total = data.total
    gridOptions.loading = false
  })
}

const pagerVO = reactive({
  total: 0,
  currentPage: 1,
  pageSize: 10
})

const gridOptions = reactive({
  showOverflow: true,
  border: true,
  loading: false,
  height: 500,
  pagerConfig: pagerVO,
  columns: [
    { type: 'seq', width: 70, fixed: 'left' },
    { field: 'name', title: 'Name', minWidth: 160 },
    { field: 'email', title: 'Email', minWidth: 160 },
    { field: 'nickname', title: 'Nickname', minWidth: 160 },
    { field: 'age', title: 'Age', width: 100 },
    { field: 'role', title: 'Role', minWidth: 160 },
    { field: 'amount', title: 'Amount', width: 140 },
    { field: 'updateDate', title: 'Update Date', visible: false },
    { field: 'createDate', title: 'Create Date', visible: false }
  ],
  data: []
})

const gridEvents = {
  pageChange ({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  }
}

loadList()
</script>

实现服务端分页

前面都已经模拟了前端分页,还看什服务端分页,不就是把前面的代码改成调接口哈

<template>
  <div>
    <vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const allList = [
  { id: 10001, name: 'Test1', nickname: 'T1', role: 'Develop', sex: 'Man', age: 28, address: 'Shenzhen' },
  { id: 10002, name: 'Test2', nickname: 'T2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
  { id: 10003, name: 'Test3', nickname: 'T3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
  { id: 10004, name: 'Test4', nickname: 'T4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
  { id: 10005, name: 'Test5', nickname: 'T5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
  { id: 10006, name: 'Test6', nickname: 'T6', role: 'Designer', sex: 'Women', age: 21, address: 'Shenzhen' },
  { id: 10007, name: 'Test7', nickname: 'T7', role: 'Test', sex: 'Man', age: 29, address: 'Shenzhen' },
  { id: 10008, name: 'Test8', nickname: 'T8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
  { id: 10009, name: 'Test9', nickname: 'T9', role: 'Develop', sex: 'Man', age: 35, address: 'Shenzhen' },
  { id: 100010, name: 'Test10', nickname: 'T10', role: 'Develop', sex: 'Man', age: 35, address: 'Guangzhou' },
  { id: 100011, name: 'Test11', nickname: 'T11', role: 'Develop', sex: 'Man', age: 49, address: 'Guangzhou' },
  { id: 100012, name: 'Test12', nickname: 'T12', role: 'Develop', sex: 'Women', age: 45, address: 'Shanghai' },
  { id: 100013, name: 'Test13', nickname: 'T13', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100014, name: 'Test14', nickname: 'T14', role: 'Test', sex: 'Man', age: 29, address: 'Shanghai' },
  { id: 100015, name: 'Test15', nickname: 'T15', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100016, name: 'Test16', nickname: 'T16', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100017, name: 'Test17', nickname: 'T17', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100018, name: 'Test18', nickname: 'T18', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' },
  { id: 100019, name: 'Test19', nickname: 'T19', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100020, name: 'Test20', nickname: 'T20', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100021, name: 'Test21', nickname: 'T21', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100022, name: 'Test22', nickname: 'T22', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' }
]

// 模拟后端接口分页
const getList = (pageSize, currentPage) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        total: allList.length,
        result: allList.slice((currentPage - 1) * pageSize, currentPage * pageSize)
      })
    }, 200)
  })
}

const loadList = () => {
  const { pageSize, currentPage } = pagerVO
  gridOptions.loading = true
  getList(pageSize, currentPage).then((data) => {
    gridOptions.data = data.result
    pagerVO.total = data.total
    gridOptions.loading = false
  })
}

const pagerVO = reactive({
  total: 0,
  currentPage: 1,
  pageSize: 10
})

const gridOptions = reactive({
  showOverflow: true,
  border: true,
  loading: false,
  height: 500,
  pagerConfig: pagerVO,
  columns: [
    { type: 'seq', width: 70, fixed: 'left' },
    { field: 'name', title: 'Name', minWidth: 160 },
    { field: 'email', title: 'Email', minWidth: 160 },
    { field: 'nickname', title: 'Nickname', minWidth: 160 },
    { field: 'age', title: 'Age', width: 100 },
    { field: 'role', title: 'Role', minWidth: 160 },
    { field: 'amount', title: 'Amount', width: 140 },
    { field: 'updateDate', title: 'Update Date', visible: false },
    { field: 'createDate', title: 'Create Date', visible: false }
  ],
  data: []
})

const gridEvents = {
  pageChange ({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  }
}

loadList()
</script>

gitee.com/x-extends/v…

Vue3 中的 <keep-alive> 详解

2025年12月26日 09:56

<keep-alive> 是 Vue3 内置的抽象组件(自身不会渲染为真实 DOM 元素),核心作用是缓存包裹在其中的组件实例,保留组件的状态和 DOM 结构,避免组件反复创建和销毁带来的性能损耗,常用于需要保留状态的场景(如标签页切换、列表页返回详情页等)。

一、核心特性与作用

1. 核心功能

  • 缓存组件状态:被 <keep-alive> 包裹的组件,在切换隐藏时不会触发 unmounted(销毁),而是被缓存起来;再次显示时不会触发 mounted(重新创建),而是恢复之前的状态。
  • 优化性能:避免组件反复创建 / 销毁、数据重新请求、DOM 重新渲染,减少资源消耗。
  • 保留组件上下文:比如表单输入内容、滚动条位置、组件内部的状态数据等,切换后仍能保持原有状态。

2. 关键特点

  • 是抽象组件,不生成 DOM 节点,也不会出现在组件的父组件链中;
  • 仅对动态组件<component :is="componentName">)或路由组件生效;
  • 可通过属性配置缓存规则(指定缓存 / 排除缓存的组件)。

二、基本使用方式

1. 基础用法:包裹动态组件

用于切换多个组件时,缓存不活跃的组件状态:

<template>
  <div>
    <!-- 切换按钮 -->
    <button @click="currentComponent = 'ComponentA'">组件A</button>
    <button @click="currentComponent = 'ComponentB'">组件B</button>

    <!-- keep-alive 包裹动态组件,缓存组件实例 -->
    <keep-alive>
      <component :is="currentComponent"></component>
    </keep-alive>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';

// 控制当前显示的组件
const currentComponent = ref('ComponentA');
</script>

此时切换组件 A/B,组件不会被销毁,再次切换回来时会保留之前的状态(如 ComponentA 中的输入框内容)。

2. 常用场景:包裹路由组件

在路由切换时缓存页面状态(如列表页滚动位置、筛选条件),是项目中最常用的场景:

<!-- App.vue 或路由出口组件 -->
<template>
  <router-view v-slot="{ Component }">
    <!-- 缓存路由组件 -->
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

三、核心属性:配置缓存规则

<keep-alive> 提供 3 个核心属性,用于灵活控制缓存的组件范围:

1. include:指定需要缓存的组件

  • 类型:String | RegExp | Array

  • 作用:只有名称匹配的组件才会被缓存(组件名称通过 name 选项定义,Vue3 单文件组件中 <script> 内的 name 或 <script setup> 配合 defineOptions({ name: 'xxx' }) 定义)。

  • 示例:

    <!-- 字符串(逗号分隔多个组件名) -->
    <keep-alive include="ComponentA,ComponentB">
      <component :is="currentComponent"></component>
    </keep-alive>
    
    <!-- 正则表达式(需用 v-bind 绑定) -->
    <keep-alive :include="/^Component/">
      <component :is="currentComponent"></component>
    </keep-alive>
    
    <!-- 数组(需用 v-bind 绑定) -->
    <keep-alive :include="['ComponentA', 'ComponentB']">
      <component :is="currentComponent"></component>
    </keep-alive>
    

2. exclude:指定不需要缓存的组件

  • 类型:String | RegExp | Array

  • 作用:名称匹配的组件不会被缓存,优先级高于 include

  • 示例:

    <keep-alive exclude="ComponentC">
      <component :is="currentComponent"></component>
    </keep-alive>
    

3. max:设置缓存组件的最大数量

  • 类型:Number

  • 作用:限制缓存的组件实例数量,当缓存实例超过 max 时,会按照「LRU(最近最少使用)」策略,销毁最久未使用的组件缓存。

  • 示例:

    <!-- 最多缓存 3 个组件实例 -->
    <keep-alive :max="3">
      <component :is="currentComponent"></component>
    </keep-alive>
    

四、缓存组件的生命周期钩子

被 <keep-alive> 缓存的组件,不会触发 mounted/unmounted,而是触发专属的生命周期钩子:

1. onActivated:组件被激活时触发

  • 时机:缓存的组件从隐藏状态切换为显示状态时(第一次渲染时,会在 mounted 之后触发;后续激活时,仅触发 onActivated)。
  • 用途:恢复组件激活后的状态(如重新监听事件、刷新数据等)。

2. onDeactivated:组件被失活时触发

  • 时机:缓存的组件从显示状态切换为隐藏状态时(不会触发 unmounted)。
  • 用途:清理组件失活后的资源(如取消事件监听、清除定时器等)。

示例:组件内使用钩子

<!-- ComponentA.vue -->
<template>
  <div>组件A:<input type="text" v-model="inputValue"></div>
</template>

<script setup>
import { ref, onActivated, onDeactivated, onMounted } from 'vue';

const inputValue = ref('');

// 第一次渲染时触发(后续激活不触发)
onMounted(() => {
  console.log('组件A 首次挂载');
});

// 组件被激活时触发(切换显示时)
onActivated(() => {
  console.log('组件A 被激活');
  // 可在此恢复滚动条位置、重新请求最新数据等
});

// 组件被失活时触发(切换隐藏时)
onDeactivated(() => {
  console.log('组件A 被失活');
  // 可在此取消定时器、取消事件监听等
});
</script>

五、高级用法:结合路由配置缓存

在实际项目中,常需要针对特定路由进行缓存,可通过「路由元信息(meta)」配合 <keep-alive> 实现精准缓存:

1. 配置路由元信息

在 router/index.js 中,给需要缓存的路由添加 meta.keepAlive: true

// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import ListPage from '../views/ListPage.vue';
import DetailPage from '../views/DetailPage.vue';
import HomePage from '../views/HomePage.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: HomePage,
    meta: { keepAlive: false } // 不缓存
  },
  {
    path: '/list',
    name: 'List',
    component: ListPage,
    meta: { keepAlive: true } // 需要缓存
  },
  {
    path: '/detail/:id',
    name: 'Detail',
    component: DetailPage,
    meta: { keepAlive: false } // 不缓存
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

2. 根据路由元信息缓存

在路由出口处,通过 v-if 判断路由的 meta.keepAlive 属性,决定是否缓存:

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 缓存需要保留状态的路由组件 -->
    <keep-alive>
      <component
        :is="Component"
        v-if="route.meta.keepAlive"
      />
    </keep-alive>
    <!-- 不缓存的组件直接渲染 -->
    <component
      :is="Component"
      v-if="!route.meta.keepAlive"
    />
  </router-view>
</template>

六、注意事项与常见问题

1. 注意事项

  • <keep-alive> 仅对动态组件或路由组件生效,对普通组件(直接渲染的组件)无效;
  • 组件名称必须正确定义:<script setup> 中需通过 defineOptions({ name: 'XXX' }) 定义组件名,否则 include/exclude 无法匹配;
  • 缓存的组件会占用内存,若缓存过多组件,可能导致内存泄漏,建议通过 max 属性限制缓存数量;
  • 对于需要实时刷新数据的组件,避免使用 <keep-alive>,或在 onActivated 钩子中手动刷新数据。

2. 常见问题

  • 问题 1:缓存后组件数据不更新?解决方案:在 onActivated 钩子中重新请求数据或更新组件状态,确保激活时获取最新数据。

  • 问题 2include/exclude 配置不生效?解决方案:检查组件名称是否正确定义,正则 / 数组形式是否通过 v-bind 绑定,避免直接写字面量。

  • 问题 3:路由切换后滚动条位置未保留?解决方案:在 onDeactivated 中记录滚动条位置,在 onActivated 中恢复滚动条位置:

    // ListPage.vue
    import { ref, onActivated, onDeactivated } from 'vue';
    
    // 记录滚动条位置
    const scrollTop = ref(0);
    
    onDeactivated(() => {
      // 失活时记录滚动位置
      scrollTop.value = document.documentElement.scrollTop || document.body.scrollTop;
    });
    
    onActivated(() => {
      // 激活时恢复滚动位置
      document.documentElement.scrollTop = scrollTop.value;
      document.body.scrollTop = scrollTop.value;
    });
    

总结

  1. <keep-alive> 是 Vue3 内置抽象组件,核心作用是缓存组件实例、保留组件状态、优化性能;
  2. 基础用法:包裹动态组件或路由组件,通过 include/exclude/max 配置缓存规则;
  3. 生命周期:缓存组件触发 onActivated(激活)和 onDeactivated(失活),替代 mounted/unmounted
  4. 高级用法:结合路由元信息 meta.keepAlive,实现特定路由的精准缓存;
  5. 注意:合理控制缓存数量,避免内存泄漏,需要实时刷新数据的场景在 onActivated 中手动更新。

react-hook-form 初始化值为异步获取的数据的最佳实践

2025年12月26日 09:55

在 React Hook Form 中,直接在 useFormdefaultValues 参数中使用静态默认值是首选方式,因为:

  • defaultValues 是专门设计用于设置表单初始值的,它在钩子初始化时被缓存(cached),性能更好。
  • 它能正确支持 isDirtydirtyFields 等表单状态的计算(以 defaultValues 作为“单一真相来源”)。
  • 官方文档推荐:优先使用 defaultValues 来管理整个表单的默认值,而不是单个输入的 defaultValue

示例:

const { register, handleSubmit } = useForm({
  defaultValues: {
    name: '初始姓名',
    email: 'initial@example.com'
  }
});

但是,如果默认值是异步获取的(如从 API 加载数据),则推荐在 useEffect 中使用 reset(defaultValues)

原因:

  • defaultValues 只在 useForm 初始渲染时读取一次(被缓存),后续 props 或 state 变化不会自动更新表单值。
  • 如果直接把异步数据传给 defaultValues,表单会先渲染为空(或初始空值),然后需要手动重置,导致可能出现“闪烁”(flash)或额外渲染。
  • 使用 reset 可以动态更新表单值,并正确重置表单状态(如清除 errors、touched 等)。

示例(异步加载场景):

const [asyncData, setAsyncData] = useState(null);

useEffect(() => {
  fetch('/api/user').then(res => res.json()).then(data => {
    setAsyncData(data);
  });
}, []);

const { register, reset } = useForm({
  defaultValues: { name: '', email: '' } // 先给空默认值,避免 uncontrolled 警告
});

useEffect(() => {
  if (asyncData) {
    reset(asyncData); // 这里更新表单值
  }
}, [asyncData, reset]);

更现代的推荐(v7+):使用 values prop(而非 defaultValues

从 React Hook Form v7 开始,引入了 values prop,它是响应式的(reactive),会自动在值变化时调用内部 reset,无需手动 useEffect + reset

  • 适合异步数据加载,避免闪烁和额外渲染。
  • defaultValues 仍用于静态初始值,values 用于动态/异步更新。

示例:

const asyncValues = useFetch('/api/user'); // 假设返回 { name: '...', email: '...' }

const { register } = useForm({
  defaultValues: { name: '', email: '' }, // 可选静态初始
  values: asyncValues // 会自动响应变化更新表单
});

总结推荐

场景 推荐方式 原因
静态默认值(已知常量) useForm({ defaultValues: {...} }) 简单、性能好、官方首选
异步默认值(API 等) values prop(首选)
useEffect + reset
values 更优雅、无闪烁;reset 是传统可靠方式
需要手动重置表单 reset(newValues) 可保留/清除特定状态(如 keepDirtyValues)

如果你的默认值是静态的,直接用 defaultValues 最好;如果是动态/异步的,优先试 values,否则用 reset。更多详情可参考官方文档:react-hook-form.com/docs/usefor…

Vue3可动态添加行el-table组件

作者 其尔Leo
2025年12月26日 09:48

一、组件功能:

  1. 动态添加空行
  2. 添加的空行属于子级的话,父级会自动合并

二、效果图展示

  • 图片依次对应:默认界面-一级指标添加-二级指标添加-三级指标添加

image.png

image.png

image.png

image.png

三、代码实现

<template>
  <div class="indicator-table" @click.stop>
    <table class="table">
      <thead>
        <tr>
          <th v-for="header in headers" :key="header" :style="{ width: getHeaderWidth(header) }">
            <span v-if="header !== '待改进选项'">{{ header }}</span>
            <span v-else>
              {{ header }}
              <el-tooltip content="指定触发整改机制的选项值。在实际督导结果选中该值时,系统会判定该指标‘未达标’,并会在报告中列入待改进清单。" placement="top">
                <el-icon style="margin-left: 5px; cursor: pointer;"><InfoFilled /></el-icon>
              </el-tooltip>
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <template v-for="(rowGroup, groupIndex) in rowGroups" :key="groupIndex">
          <tr v-for="(row, rowIndexInGroup) in rowGroup.rows" :key="row.id">

            <td v-if="row.showLevel1" :rowspan="getLevel1Rowspan(groupIndex)" class="level-cell"
              :class="{ 'is-active': isCellActive('level1', groupIndex, rowIndexInGroup) }"
              @click.stop="setActiveCell('level1', groupIndex, rowIndexInGroup)">
              <div class="cell-wrapper">
                <div class="control-side left-side" v-show="isCellActive('level1', groupIndex, rowIndexInGroup)">
                  <div class="move-icon up" @click.stop="moveRowUp(groupIndex, rowIndexInGroup)"></div>
                  <div class="move-icon down" @click.stop="moveRowDown(groupIndex, rowIndexInGroup)"></div>
                </div>

                <div class="input-area">
                  <input v-model="rowGroup.level1" type="text" class="clean-input" placeholder="">
                </div>

                <div class="control-side right-side" v-show="isCellActive('level1', groupIndex, rowIndexInGroup)">
                  <button @click.stop="addLevel1(groupIndex)" class="circle-btn btn-add" title="新增">+</button>
                  <button @click.stop="removeLevel1(groupIndex)" class="circle-btn btn-remove" title="删除">-</button>
                </div>
              </div>
            </td>

            <td v-if="row.showLevel2" :rowspan="getLevel2Rowspan(groupIndex, row.level2Id)" class="level-cell"
              :class="{ 'is-active': isCellActive('level2', groupIndex, rowIndexInGroup) }"
              @click.stop="setActiveCell('level2', groupIndex, rowIndexInGroup)">
              <div class="cell-wrapper">
                <div class="control-side left-side" v-show="isCellActive('level2', groupIndex, rowIndexInGroup)">
                  <div v-if="!row.showLevel1" class="move-group">
                    <div class="move-icon up" @click.stop="moveRowUp(groupIndex, rowIndexInGroup)"></div>
                    <div class="move-icon down" @click.stop="moveRowDown(groupIndex, rowIndexInGroup)"></div>
                  </div>
                </div>

                <div class="input-area">
                  <input v-model="row.level2" type="text" class="clean-input" placeholder=""
                    @input="updateLevel2Content(groupIndex, row.level2Id, row.level2)">
                </div>

                <div class="control-side right-side" v-show="isCellActive('level2', groupIndex, rowIndexInGroup)">
                  <button @click.stop="addLevel2(groupIndex, rowIndexInGroup)" class="circle-btn btn-add">+</button>
                  <button @click.stop="removeLevel2(groupIndex, rowIndexInGroup)"
                    class="circle-btn btn-remove">-</button>
                </div>
              </div>
            </td>

            <td class="level-cell" :class="{ 'is-active': isCellActive('level3', groupIndex, rowIndexInGroup) }"
              @click.stop="setActiveCell('level3', groupIndex, rowIndexInGroup)">
              <div class="cell-wrapper">
                <div class="control-side left-side" v-show="isCellActive('level3', groupIndex, rowIndexInGroup)">
                  <div v-if="!row.showLevel1 && !row.showLevel2" class="move-group">
                    <div class="move-icon up" @click.stop="moveRowUp(groupIndex, rowIndexInGroup)"></div>
                    <div class="move-icon down" @click.stop="moveRowDown(groupIndex, rowIndexInGroup)"></div>
                  </div>
                </div>

                <div class="input-area">
                  <input v-model="row.level3" type="text" class="clean-input" placeholder="">
                </div>

                <div class="control-side right-side" v-show="isCellActive('level3', groupIndex, rowIndexInGroup)">
                  <button @click.stop="addLevel3(groupIndex, rowIndexInGroup)" class="circle-btn btn-add">+</button>
                  <button @click.stop="removeLevel3(groupIndex, rowIndexInGroup)"
                    class="circle-btn btn-remove">-</button>
                </div>
              </div>
            </td>

            <td class="content-cell">
              <div class="input-area">
                <input v-model="row.checkInstructions" type="text" class="clean-input" placeholder="">
              </div>
            </td>

            <td class="content-cell">
              <select 
                v-model="row.isScore" 
                class="clean-select"
                :disabled="!props.isTemplateScoring" 
                :class="{ 'is-disabled': !props.isTemplateScoring }"
              >
                <option value="">请选择</option>
                <option value="是">是</option>
                <option value="否">否</option>
              </select>
            </td>

            <td class="content-cell">
              <select 
                v-model="row.evaluation" 
                class="clean-select"
                :disabled="row.isScore === '是'"
                :class="{ 'is-disabled': row.isScore === '是' }"
              >
                <option value="">请选择</option>
                <option value="好/坏">好/坏</option>
                <option value="优/良/中/差">优/良/中/差</option>
                <option value="有/无">有/无</option>
                <option value="肯定/否定">肯定/否定</option>
                <option value="是/否">是/否</option>
              </select>
            </td>

            <td class="content-cell">
              <select 
                v-model="row.improvement" 
                class="clean-select"
                :disabled="row.isScore === '是'"
                :class="{ 'is-disabled': row.isScore === '是' }"
              >
                <option value="">请选择</option>
                <option v-for="option in getImprovementOptions(row.evaluation)" :key="option" :value="option">
                  {{ option }}
                </option>
              </select>
            </td>
          </tr>
        </template>
      </tbody>
    </table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
import { ElTooltip } from 'element-plus'

// --- 类型定义 ---
interface TableRow {
  id: number
  level2: string
  level3: string
  isScore: string
  checkInstructions: string
  evaluation: string
  improvement: string
  showLevel1: boolean
  showLevel2: boolean
  level2Id: string
}

interface RowGroup {
  id: number
  level1: string
  rows: TableRow[]
}

// --- 新增:接收父组件传来的 isScore ---
const props = defineProps<{
  isTemplateScoring?: boolean // 接收父组件的评分状态
}>()

// --- 状态定义 ---
const headers = ref(['1级指标', '2级指标', '3级指标', '检查说明', '是否评分', '评估选项', '待改进选项'])
const rowGroups = defineModel<RowGroup[]>({ required: true })
let idCounter = 0

// 当前激活的单元格坐标
const activeCell = ref<{ field: string, groupIdx: number, rowIdx: number } | null>(null)

// --- 样式辅助 ---
const getHeaderWidth = (header: string) => {
  if (header.includes('指标')) return '20%'
  if (header === '检查说明') return 'auto'
  return '10%'
}

/**
 * 根据评估选项的值,解析并返回待改进选项的列表。
 * @param evaluationString 来自 row.evaluation 的值 (例如: '优/良/中/差')
 * @returns 选项字符串数组 (例如: ['优', '良', '中', '差'])
 */
const getImprovementOptions = (evaluationString: string): string[] => {
  if (!evaluationString) {
    return []
  }
  // 使用 '/' 分割字符串来获取单个选项
  return evaluationString.split('/')
}

// --- 核心交互逻辑 ---

// 判断单元格是否处于编辑状态
const isCellActive = (field: string, groupIdx: number, rowIdx: number) => {
  return activeCell.value?.field === field &&
    activeCell.value?.groupIdx === groupIdx &&
    activeCell.value?.rowIdx === rowIdx
}

// 设置激活单元格
const setActiveCell = (field: string, groupIdx: number, rowIdx: number) => {
  activeCell.value = { field, groupIdx, rowIdx }
}

// 点击外部清除激活状态
const handleClickOutside = () => {
  activeCell.value = null
}

const generateNewRow = (showLevel1: boolean = true, showLevel2: boolean = true, level2Id?: string): TableRow => ({
  id: idCounter++,
  level2: '',
  level3: '',
  checkInstructions: '',
  isScore: props.isTemplateScoring ? '是' : '否',
  evaluation: '',
  improvement: '',
  showLevel1,
  showLevel2,
  level2Id: level2Id || `level2_${idCounter}`
})

const generateNewGroup = (): RowGroup => ({
  id: idCounter++,
  level1: '',
  rows: [generateNewRow(true, true)]
})

const getLevel1Rowspan = (groupIndex: number): number => rowGroups.value[groupIndex].rows.length

const getLevel2RowCount = (groupIndex: number, level2Id: string): number => {
  const group = rowGroups.value[groupIndex]
  return group.rows.filter(row => row.level2Id === level2Id).length
}

const getLevel2Rowspan = (groupIndex: number, level2Id: string): number => getLevel2RowCount(groupIndex, level2Id)

const updateLevel2Content = (groupIndex: number, level2Id: string, content: string) => {
  const group = rowGroups.value[groupIndex]
  group.rows.forEach(row => {
    if (row.level2Id === level2Id) row.level2 = content
  })
}

const initializeTable = () => {
  if (!rowGroups.value || rowGroups.value.length === 0) {
    rowGroups.value = [generateNewGroup()]
  }
}

const addLevel1 = (groupIndex: number) => {
  const newGroup = generateNewGroup()
  rowGroups.value.splice(groupIndex + 1, 0, newGroup)
}

const removeLevel1 = (groupIndex: number) => {
  if (rowGroups.value.length <= 1) return alert('至少保留一个1级指标')
  rowGroups.value.splice(groupIndex, 1)
  // 删除后清除焦点,避免索引错位报错
  activeCell.value = null
}

const addLevel2 = (groupIndex: number, rowIndexInGroup: number) => {
  const group = rowGroups.value[groupIndex]
  const currentRow = group.rows[rowIndexInGroup]
  let insertIndex = rowIndexInGroup
  for (let i = rowIndexInGroup + 1; i < group.rows.length; i++) {
    if (group.rows[i].level2Id === currentRow.level2Id) insertIndex = i
    else break
  }
  const newLevel2Id = `level2_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  const newRow: TableRow = {
    id: idCounter++,
    level2: '',
    level3: '',
    checkInstructions: '',
    isScore: props.isTemplateScoring ? '是' : '否',
    evaluation: '',
    improvement: '',
    showLevel1: false,
    showLevel2: true,
    level2Id: newLevel2Id
  }
  group.rows.splice(insertIndex + 1, 0, newRow)
  adjustDisplayStates(groupIndex)
}

const getLevel2GroupCount = (groupIndex: number): number => {
  const group = rowGroups.value[groupIndex]
  const uniqueIds = new Set(group.rows.map(r => r.level2Id))
  return uniqueIds.size
}

const removeLevel2 = (groupIndex: number, rowIndexInGroup: number) => {
  const group = rowGroups.value[groupIndex]
  const currentRow = group.rows[rowIndexInGroup]
  if (getLevel2GroupCount(groupIndex) <= 1) return alert('至少保留一个2级指标')
  group.rows = group.rows.filter(row => row.level2Id !== currentRow.level2Id)
  adjustDisplayStates(groupIndex)
  activeCell.value = null
}

const addLevel3 = (groupIndex: number, rowIndexInGroup: number) => {
  const group = rowGroups.value[groupIndex]
  const currentRow = group.rows[rowIndexInGroup]
  const newRow = generateNewRow(false, false, currentRow.level2Id)
  newRow.level2 = currentRow.level2 // 建议显式加上这一句,确保文本同步
  group.rows.splice(rowIndexInGroup + 1, 0, newRow)
}

const removeLevel3 = (groupIndex: number, rowIndexInGroup: number) => {
  const group = rowGroups.value[groupIndex]
  const currentRow = group.rows[rowIndexInGroup]
  const level2Rows = group.rows.filter(row => row.level2Id === currentRow.level2Id)
  if (level2Rows.length <= 1 && rowGroups.value.length <= 1) return alert('至少保留一个3级指标')
  group.rows.splice(rowIndexInGroup, 1)
  const remainingRows = group.rows.filter(row => row.level2Id === currentRow.level2Id)
  if (remainingRows.length > 0) {
    const firstIndex = group.rows.findIndex(row => row.level2Id === currentRow.level2Id)
    if (firstIndex >= 0) {
      group.rows[firstIndex].showLevel2 = true
      if (firstIndex === 0) group.rows[firstIndex].showLevel1 = true
    }
  }
  activeCell.value = null
}

const moveRowUp = (groupIndex: number, rowIndexInGroup: number) => {
  return
  // const group = rowGroups.value[groupIndex]
  // if (rowIndexInGroup === 0) {
  //   if (groupIndex > 0) {
  //     const prevGroup = rowGroups.value[groupIndex - 1]
  //     const row = group.rows.splice(rowIndexInGroup, 1)[0]
  //     row.showLevel1 = false
  //     row.showLevel2 = true
  //     prevGroup.rows.push(row)
  //   }
  //   return
  // }
  // const temp = group.rows[rowIndexInGroup - 1]
  // group.rows[rowIndexInGroup - 1] = group.rows[rowIndexInGroup]
  // group.rows[rowIndexInGroup] = temp
  // adjustDisplayStates(groupIndex)
}

const moveRowDown = (groupIndex: number, rowIndexInGroup: number) => {
  return
  // const group = rowGroups.value[groupIndex]
  // if (rowIndexInGroup === group.rows.length - 1) {
  //   if (groupIndex < rowGroups.value.length - 1) {
  //     const nextGroup = rowGroups.value[groupIndex + 1]
  //     const row = group.rows.splice(rowIndexInGroup, 1)[0]
  //     row.showLevel1 = true
  //     row.showLevel2 = true
  //     nextGroup.rows.unshift(row)
  //   }
  //   return
  // }
  // const temp = group.rows[rowIndexInGroup + 1]
  // group.rows[rowIndexInGroup + 1] = group.rows[rowIndexInGroup]
  // group.rows[rowIndexInGroup] = temp
  // adjustDisplayStates(groupIndex)
}

const adjustDisplayStates = (groupIndex: number) => {
  const group = rowGroups.value[groupIndex]
  const level2Groups: { [key: string]: TableRow[] } = {}
  group.rows.forEach(row => {
    if (!level2Groups[row.level2Id]) level2Groups[row.level2Id] = []
    level2Groups[row.level2Id].push(row)
  })
  group.rows.forEach(row => {
    row.showLevel1 = false
    row.showLevel2 = false
  })
  let currentIndex = 0
  Object.keys(level2Groups).forEach(level2Id => {
    const level2Rows = level2Groups[level2Id]
    if (level2Rows.length > 0) {
      level2Rows[0].showLevel1 = (currentIndex === 0)
      level2Rows[0].showLevel2 = true
    }
    currentIndex += level2Rows.length
  })
  if (group.rows.length > 0) group.rows[0].showLevel1 = true
}

watch(
  // 监听整个 v-model 绑定的数组
  () => rowGroups.value,
  (newValue) => {
    // 确保在数据被清空时,组件能立即初始化为默认行
    if (!newValue || newValue.length === 0) {
      // 避免无限循环,只在父组件传入空数据时执行初始化
      initializeTable();
    }
  },
  // deep: true 用于监听对象内部变化,但在这里监听数组长度变化
  { immediate: true }
);

// --- 新增:监听父组件评分开关的变化 ---
watch(() => props.isTemplateScoring, (newVal) => {
  // 遍历所有分组和所有行
  if (rowGroups.value) {
    rowGroups.value.forEach(group => {
      group.rows.forEach(row => {
        if (newVal === false) {
          // 1. 父组件关:强制变否
          row.isScore = '否'
        } else {
          // 2. 父组件开:如果是 '否' 或 空,自动切回 '是'
          if (row.isScore === '否' || row.isScore === '') {
            row.isScore = '是'
          }
        }
      })
    })
  }
}, { immediate: true }) // immediate: true 确保组件加载时先执行一次判断

// --- 监听 isScore 变化,控制 evaluation 和 improvement 状态 ---
watch(
  () => rowGroups.value, 
  (newValue) => {
    if (newValue) {
      newValue.forEach(group => {
        group.rows.forEach(row => {
          if (row.isScore === '是') {
            // 当选择"是"时,清空评估选项和待改进选项的值
            row.evaluation = ''
            row.improvement = ''
          }
        })
      })
    }
  }, 
  { deep: true } // 需要深度监听数组内部每个 row 的 isScore 变化
)

// 生命周期
onMounted(() => {
  initializeTable()
  document.addEventListener('click', handleClickOutside)
})

onUnmounted(() => {
  document.removeEventListener('click', handleClickOutside)
})



defineExpose({
  addLevel1,
  removeLevel1,
  initializeTable
})
</script>

<style scoped>
/* 样式保持不变 */
.indicator-table {
  width: 100%;
  border: 1px solid #ebeef5;
  border-radius: 4px;
  background: #fff;
}

.table {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  /* 固定列宽,防止跳动 */
}

th {
  background-color: #f5f7fa;
  color: #606266;
  font-weight: 500;
  padding: 12px 8px;
  font-size: 14px;
  border-bottom: 1px solid #ebeef5;
  border-right: 1px solid #ebeef5;
}

td {
  border-bottom: 1px solid #ebeef5;
  border-right: 1px solid #ebeef5;
  padding: 8px;
  /* 恢复基础 padding 以容纳有边框的 select */
  vertical-align: top;
  height: 100%;
  transition: background-color 0.2s;
}

/* 单元格容器布局 */
.cell-wrapper {
  display: flex;
  align-items: flex-start;
  /* 顶部对齐 */
  min-height: 48px;
  /* 保证最小高度 */
  padding: 0;
  /* padding 转移到 td */
  width: 100%;
  box-sizing: border-box;
}

/* 左右控制区域 */
.control-side {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 32px;
  /* 固定宽度 */
  flex-shrink: 0;
  gap: 4px;
  padding-top: 4px;
  /* 微调垂直对齐 */
}

.left-side {
  margin-right: 4px;
}

.right-side {
  margin-left: 4px;
}

/* 输入区域 */
.input-area {
  flex: 1;
  display: flex;
  align-items: center;
  min-width: 0;
  align-self: stretch;
  /* 撑满高度 */
}

/* 核心:输入框样式 */
.clean-input {
  width: 100%;
  border: 1px solid transparent;
  /* 默认透明边框 */
  background: transparent;
  padding: 8px;
  border-radius: 4px;
  font-size: 14px;
  color: #606266;
  outline: none;
  transition: all 0.2s;
  line-height: 1.5;
}


/* 激活状态下的输入框 (图2效果) */
.is-active .clean-input {
  border-color: #409eff;
  background-color: #fff;
}

.clean-input:focus {
  border-color: #409eff;
  background-color: #fff;
}

.clean-select {
  /* 基础填充和尺寸 */
  width: 100%;
  padding: 8px 12px;
  font-size: 14px;
  color: #606266;
  outline: none;
  cursor: pointer;
  line-height: 1.2;

  /* 边框和背景 (根据图片要求) */
  border: 1px solid #dcdfe6;
  /* 浅灰色边框 */
  border-radius: 4px;
  /* 圆角 */
  background-color: #ffffff;
  transition: border-color 0.2s;
}

.clean-select:hover {
  border-color: #c0c4cc;
  /* 悬停时边框颜色稍深 */
}

.clean-select:focus {
  border-color: #409eff;
  /* 聚焦时使用蓝色强调 */
}

/* --- 图标与按钮样式 (保持不变) --- */

/* 移动箭头图标 (纯CSS实现绿色 Chevron) */
.move-icon {
  width: 12px;
  height: 12px;
  cursor: pointer;
  position: relative;
  border-left: 2px solid #67c23a;
  border-top: 2px solid #67c23a;
  transform-origin: center;
}

.move-icon:hover {
  opacity: 0.8;
}

.move-icon.up {
  transform: rotate(45deg) translate(2px, 2px);
  margin-bottom: -2px;
}

.move-icon.down {
  transform: rotate(225deg) translate(2px, 2px);
  margin-top: -2px;
}

/* 圆形操作按钮 */
.circle-btn {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 1px solid;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 16px;
  line-height: 1;
  padding: 0;
  transition: all 0.2s;
}

.btn-add {
  border-color: #409eff;
  color: #409eff;
}

.btn-add:hover {
  background-color: #409eff;
  color: #fff;
}

.btn-remove {
  border-color: #f56c6c;
  color: #f56c6c;
}

.btn-remove:hover {
  background-color: #f56c6c;
  color: #fff;
}

/* 新增:禁用状态样式 */
.clean-select.is-disabled {
  background-color: #f5f7fa;
  color: #c0c4cc;
  cursor: not-allowed;
}

/* 移动端适配 */
@media (max-width: 768px) {
  .cell-wrapper {
    flex-wrap: nowrap;
  }

  .control-side {
    width: 24px;
  }
}
</style>

鸿蒙Tab实战03 - build方法200行怎么优化?AttributeModifier模式实战

作者 Archilect
2025年12月26日 09:20

鸿蒙Tab实战03 - build方法200行怎么优化?AttributeModifier模式实战

build方法200行,样式逻辑和UI结构混在一起。如何实现职责分离,让build方法从200行减少到10行?


一、问题场景:build方法200行,样式逻辑和UI结构混在一起

(本节约3分钟,快速了解问题即可)

1.1 问题的开始

在HarmonyOS UI开发中,我们经常遇到这样的问题:UI组件的build方法变得越来越长,样式逻辑和UI结构混在一起,导致代码难以维护、复用和测试。

1.2 传统方式的痛点

痛点1:build方法过长
// ❌ build方法变得很长,难以阅读
@ComponentV2
export struct NavigationView {
  build() {
    Row() {
      ForEach(this.tabVM.itemList, (item: NavigationItem, index) => {
        Stack() {
          List() {
            ListItem() {
              Image(item.icon)
                .width(this.getIconSize(item, index))
                .height(this.getIconSize(item, index))
                .margin({
                  top: this.getIconMarginTop(item, index),
                  bottom: this.getIconMarginBottom(item, index)
                })
            }
            ListItem() {
              Text(item.name)
                .fontSize(this.getFontSize(item, index))
                .fontColor(this.getFontColor(item, index))
                .fontWeight(this.getFontWeight(item, index))
                .backgroundColor(this.getFontBgColor(item, index))
                .padding(this.getTextPadding(item, index))
            }
          }
          .width(this.getListWidth(item, index))
          .height(this.getListHeight(item, index))
          .backgroundColor(this.getListBgColor(item, index))
          .padding({ bottom: this.getListPaddingBottom(item, index) })
          .onClick(() => this.tabVM.clickTab(index))
        }
        .width(this.getStackWidth(item, index))
        .height(this.getStackHeight(item, index))
        .zIndex(this.tabVM.isActivate(index) ? 1 : 0)
        .onAreaChange((o, n) => {
          // 复杂的尺寸计算逻辑
        })
      })
    }
    .alignSelf(ItemAlign.Start)
    .justifyContent(FlexAlign.SpaceBetween)
    .constraintSize({ minWidth: '100%' })
    .alignItems(VerticalAlign.Bottom)
    .align(Alignment.TopStart)
    .onAreaChange((o, n) => {
      // 复杂的样式计算逻辑
    })
    .backgroundImage(this.getBackgroundImage())
    .backgroundImageSize({ width: '100%', height: '100%' })
  }
}

问题

  • build方法超过200行,难以阅读和维护
  • 样式逻辑和UI结构混在一起,职责不清
  • 每次修改样式都需要在build方法中查找
痛点2:难以复用
// ❌ 样式逻辑无法复用
// 组件A
Row() {
  // ...
}
.alignSelf(ItemAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
.constraintSize({ minWidth: '100%' })

// 组件B需要相同的样式,只能复制粘贴
Row() {
  // ...
}
.alignSelf(ItemAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
.constraintSize({ minWidth: '100%' })

问题

  • 样式逻辑无法复用,需要复制粘贴
  • 修改样式需要在多个地方修改
  • 容易出现不一致的问题
痛点3:难以测试

样式逻辑无法独立测试,要测试样式逻辑,必须创建完整的UI组件,测试成本高,难以覆盖所有场景。

痛点4:语法限制
// ❌ builder属性中只能用三元运算符,复杂条件难以表达
Row() {
  // ...
}
.width(
  this.shouldStickTop
    ? this.isActivate(index)
      ? sz(72)  // 激活且吸顶
      : sz(64)  // 未激活且吸顶
    : this.businessType === '内嵌'
    ? sz(160)   // 未吸顶且内嵌
    : sz(136)   // 未吸顶且独立
) // 多层嵌套,可读性差

.margin({
  start: this.shouldStickTop && this.isActivate(index) ? szM(8) : szM(0),
  end: this.shouldStickTop && this.isActivate(index) ? szM(8) : szM(0),
}) // 复杂条件用三元运算符表达困难

问题

  • 语法限制:在builder属性中,框架不允许使用if语句,只能用三元运算符
  • 可读性差:在多分支时使用多层嵌套的三元运算符难以理解
  • 难以维护:复杂条件逻辑难以表达和维护
  • 功能受限:无法使用switch-case、循环、函数调用等常规语法

二、实战案例:AttributeModifier模式

2.1 解决方案设计

AttributeModifier是HarmonyOS框架提供的特性,允许我们将样式逻辑从UI组件中分离出来,独立成Modifier类。

核心思路:

  1. UI组件只负责结构:build方法只关注UI结构
  2. 样式逻辑独立到Modifier:所有样式逻辑都在Modifier中
  3. 通过attributeModifier应用:一行代码应用所有样式
  4. 语法灵活性:Modifier中可以使用if-else、switch-case、循环、函数调用等所有常规语法,不受builder属性的语法限制

2.2 完整实现示例

2.2.1 UI组件(build方法10行)
// ✅ build方法简洁,只负责UI结构
@ComponentV2
export struct NavigationView {
  @Param tabVM: NavigationViewModel = new NavigationViewModel()
  
  build() {
    Row() {
      ForEach(this.tabVM.itemList, (item: NavigationItem, index) => {
        Stack() {
          List() {
            ListItem() {
              Image(item.icon)
            }
            ListItem() {
              Text(item.name)
            }
          }
          .onClick(() => this.tabVM.clickTab(index))
        }
        .attributeModifier(new StackStyleModifier(this.tabVM, item, index))
      })
    }
    .attributeModifier(new RowStyleModifier(this.tabVM))
  }
}

对比

  • 传统方式:build方法200+行,样式逻辑和UI结构混在一起
  • AttributeModifier方式:build方法10+行,只负责UI结构
2.2.2 Row样式Modifier
// Row样式逻辑独立到Modifier
export class RowStyleModifier implements AttributeModifier<RowAttribute> {
  constructor(private tabVM: NavigationViewModel) {}
  
  applyNormalAttribute(instance: RowAttribute): void {
    // 可以使用if-else、switch-case等所有常规语法
    instance.alignSelf(ItemAlign.Start)
    instance.justifyContent(FlexAlign.SpaceBetween)
    instance.constraintSize({ minWidth: '100%' })
    instance.alignItems(VerticalAlign.Bottom)
    instance.align(Alignment.TopStart)
    
    // 复杂条件逻辑,不再需要三元运算符
    if (this.tabVM.shouldStickTop) {
      instance.backgroundImage(this.tabVM.getStickyBackgroundImage())
    } else {
      instance.backgroundImage(this.tabVM.getNormalBackgroundImage())
    }
    
    instance.backgroundImageSize({ width: '100%', height: '100%' })
    
    instance.onAreaChange((o, n) => {
      // 复杂的样式计算逻辑
      this.tabVM.handleAreaChange(o, n)
    })
  }
}
2.2.3 Stack样式Modifier
// Stack样式逻辑独立到Modifier
export class StackStyleModifier implements AttributeModifier<StackAttribute> {
  constructor(
    private tabVM: NavigationViewModel,
    private item: NavigationItem,
    private index: number
  ) {}
  
  applyNormalAttribute(instance: StackAttribute): void {
    // 复杂条件逻辑,使用if-else更清晰
    if (this.tabVM.shouldStickTop) {
      if (this.tabVM.isActivate(this.index)) {
        instance.width(sz(72))
        instance.height(sz(72))
      } else {
        instance.width(sz(64))
        instance.height(sz(64))
      }
    } else {
      if (this.tabVM.businessType === '内嵌') {
        instance.width(sz(160))
        instance.height(sz(160))
      } else {
        instance.width(sz(136))
        instance.height(sz(136))
      }
    }
    
    instance.zIndex(this.tabVM.isActivate(this.index) ? 1 : 0)
    
    instance.onAreaChange((o, n) => {
      // 复杂的尺寸计算逻辑
      this.tabVM.handleStackAreaChange(this.index, o, n)
    })
  }
}
2.2.4 List样式Modifier
// List样式逻辑独立到Modifier
export class ListStyleModifier implements AttributeModifier<ListAttribute> {
  constructor(
    private tabVM: NavigationViewModel,
    private item: NavigationItem,
    private index: number
  ) {}
  
  applyNormalAttribute(instance: ListAttribute): void {
    // 使用switch-case处理多种场景
    switch (this.tabVM.getListStyleType(this.index)) {
      case 'active':
        instance.width(sz(72))
        instance.height(sz(72))
        instance.backgroundColor(Color.Blue)
        break
      case 'inactive':
        instance.width(sz(64))
        instance.height(sz(64))
        instance.backgroundColor(Color.Gray)
        break
      default:
        instance.width(sz(60))
        instance.height(sz(60))
        instance.backgroundColor(Color.Transparent)
    }
    
    instance.padding({ bottom: this.tabVM.getListPaddingBottom(this.index) })
  }
}

2.3 实际效果

使用AttributeModifier模式后,我们获得了以下效果:

  1. build方法从200+行减少到10+行:代码量减少95%
  2. 职责分离清晰:UI结构、样式逻辑、业务逻辑分离
  3. 样式逻辑可复用:Modifier可以在多个组件中复用
  4. 样式逻辑可测试:Modifier可以独立测试,无需创建完整UI组件
  5. 语法灵活性:Modifier中可以使用if-else、switch-case、循环、函数调用等所有常规语法

三、理论分析:职责分离的架构基础

3.1 职责分离的理论价值

单一职责原则

  • 每个类或模块应该只有一个职责
  • UI组件只负责UI结构
  • Modifier只负责样式逻辑
  • ViewModel只负责业务逻辑

开闭原则

  • 对扩展开放,对修改关闭
  • 新增样式只需新增Modifier,无需修改UI组件
  • 修改样式只需修改Modifier,无需修改UI组件

3.2 声明式UI的架构基础

声明式UI的特点

  • 描述"是什么",而非"如何做"
  • UI结构清晰,样式逻辑独立
  • 状态变化自动更新UI

职责分离如何支撑声明式编程

  • UI组件:声明UI结构,描述"是什么"
  • Modifier:声明样式逻辑,描述"如何样式化"
  • ViewModel:管理状态,描述"数据是什么"

3.3 架构价值

可维护性提升

  • 样式逻辑独立,修改不影响UI结构
  • 代码结构清晰,易于理解和维护

可复用性提升

  • Modifier可以在多个组件中复用
  • 样式逻辑可以独立封装和复用

可测试性提升

  • Modifier可以独立测试,无需创建完整UI组件
  • 测试成本低,易于覆盖所有场景

四、整体架构图

AttributeModifier模式的整体架构如下:

graph TB
    subgraph ViewModel层
        F[NavigationViewModel<br/>业务逻辑和状态]
    end
    
    subgraph Modifier层
        C[RowStyleModifier<br/>Row样式逻辑]
        D[StackStyleModifier<br/>Stack样式逻辑]
        E[ListStyleModifier<br/>List样式逻辑]
    end
    
    subgraph UI组件层
        A[NavigationView组件]
        B[build方法<br/>只负责UI结构]
        A --> B
    end
    
    %% ViewModel到Modifier的连接
    F -->|状态变化| C
    F -->|状态变化| D
    F -->|状态变化| E
    
    %% Modifier到UI组件的连接
    C -->|attributeModifier| B
    D -->|attributeModifier| B
    E -->|attributeModifier| B
    
    %% 可选:添加UI到ViewModel的反向数据流
    B -.->|用户交互事件| F

架构说明

  • UI组件层:只负责UI结构,build方法简洁
  • Modifier层:负责所有样式逻辑,可复用、可测试
  • ViewModel层:负责业务逻辑和状态管理

五、方案能力边界

根据样式复杂度、复用需求、build方法长度等因素,选择合适的方案

考虑使用AttributeModifier的情形:

UI属性存在多个复杂逻辑时

项目中多个组件有共同属性

需要复用和共同维护时

属性数量过多导致build函数臃肿时,例如50行以上

六、总结提升:职责分离的价值

(本节约1分钟,快速总结)

6.1 实践验证理论

在实际项目中,AttributeModifier模式带来了显著的效果:

  • build方法从200+行减少到10+行:代码量减少95%
  • 职责分离清晰:UI结构、样式逻辑、业务逻辑分离
  • 样式逻辑可复用:Modifier可以在多个组件中复用
  • 样式逻辑可测试:Modifier可以独立测试

这些实际效果验证了职责分离架构的理论价值。

6.2 理论指导实践

职责分离理论为实际项目提供了架构方向:

  • 单一职责原则:每个类或模块应该只有一个职责
  • 开闭原则:对扩展开放,对修改关闭
  • 声明式UI:描述"是什么",而非"如何做"

6.3 相互印证

AttributeModifier模式是职责分离的实现方式:

  • 职责分离:UI组件、Modifier、ViewModel各司其职
  • 声明式UI:职责分离支撑声明式编程
  • 架构价值:提升可维护性、可复用性、可测试性

七、下一章预告

在下一章中,我们将探讨:全局工具类架构设计:单例模式的生命周期管理。

通过完整的单例模式设计,我们将看到如何解决生命周期管理、初始化分离、响应式更新等基础设施层面的架构挑战。


说明:本文中的代码示例均经过脱敏处理,部分实现细节已简化,主要用于演示设计思路和架构理念。代码结构符合HarmonyOS规范,但实际使用时请根据具体业务场景调整,并参考HarmonyOS官方文档和最佳实践。

JavaScript 中的深拷贝与浅拷贝详解

2025年12月26日 09:16

深拷贝和浅拷贝是 JavaScript 中处理引用类型数据(对象、数组等)的核心概念,二者的本质区别在于是否复制引用类型的深层嵌套数据,直接影响数据操作的独立性,是开发中避免数据污染的关键。

一、先明确:为什么需要拷贝?(引用类型的特性)

JavaScript 数据类型分为两类,拷贝行为仅对引用类型有区分(原始类型为值传递,不存在深浅拷贝):

数据类型类别 包含类型 拷贝特性
原始类型 String、Number、Boolean、Null、Undefined、Symbol、BigInt 赋值 / 拷贝时传递「值本身」,修改新值不会影响原值
引用类型 Object(普通对象、数组、函数、正则等) 赋值 / 浅拷贝时传递「内存地址(引用)」,修改新数据会影响原数据;深拷贝才会复制数据本身,实现完全独立

示例:引用类型的默认赋值(引用传递,非拷贝)

// 引用类型:数组
const arr1 = [1, 2, { name: "张三" }];
const arr2 = arr1; // 仅传递引用,不是拷贝
arr2[0] = 100;
arr2[2].name = "李四";
console.log(arr1); // [100, 2, { name: "李四" }](原值被修改)
console.log(arr2); // [100, 2, { name: "李四" }]

二、浅拷贝(Shallow Copy):仅复制表层数据

1. 核心定义

浅拷贝是指只复制引用类型的表层属性(第一层数据) ,对于深层嵌套的引用类型(如对象中的对象、数组中的数组),仅复制其内存地址(引用),新旧数据的深层嵌套部分会共享同一块内存,修改其中一个的深层数据会影响另一个。

2. 常见实现方式

(1)数组浅拷贝

  • Array.prototype.slice()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.slice(); // 浅拷贝数组
    // 修改表层数据:不影响原值
    arr2[0] = 100;
    console.log(arr1[0]); // 1
    console.log(arr2[0]); // 100
    // 修改深层引用类型:影响原值
    arr2[2].age = 30;
    console.log(arr1[2].age); // 30(原值被修改)
    console.log(arr2[2].age); // 30
    
  • Array.prototype.concat()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.concat(); // 浅拷贝
    
  • 扩展运算符 [...arr]

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = [...arr1]; // 浅拷贝
    

(2)对象浅拷贝

  • Object.assign(target, ...sources)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = Object.assign({}, obj1); // 浅拷贝到空对象
    // 修改表层数据:不影响原值
    obj2.name = "李四";
    console.log(obj1.name); // 张三
    console.log(obj2.name); // 李四
    // 修改深层引用类型:影响原值
    obj2.info.age = 30;
    console.log(obj1.info.age); // 30(原值被修改)
    console.log(obj2.info.age); // 30
    
  • 扩展运算符 {...obj}

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = { ...obj1 }; // 浅拷贝
    

3. 浅拷贝的特点

  • 优点:实现简单、性能开销小,适合仅包含表层数据的引用类型;
  • 缺点:无法独立深层嵌套数据,修改深层数据会造成原数据污染;
  • 适用场景:只需复制表层数据,无需修改深层嵌套内容的场景(如展示数据副本、临时修改表层属性)。

三、深拷贝(Deep Copy):复制所有层级数据

1. 核心定义

深拷贝是指递归复制引用类型的所有层级数据,不仅复制表层属性,还会对深层嵌套的每个引用类型都创建独立的副本,新旧数据完全隔离,修改其中一个不会影响另一个,实现真正意义上的 “复制”。

2. 常见实现方式

(1)JSON 序列化 / 反序列化(简单场景首选)

通过 JSON.stringify() 将对象转为 JSON 字符串,再通过 JSON.parse() 解析为新对象,实现深拷贝。

const obj1 = { name: "张三", info: { age: 25 }, hobbies: ["篮球", "游戏"] };
const obj2 = JSON.parse(JSON.stringify(obj1)); // 深拷贝

// 修改表层数据:不影响原值
obj2.name = "李四";
// 修改深层数据:不影响原值
obj2.info.age = 30;
obj2.hobbies[0] = "足球";

console.log(obj1.name); // 张三
console.log(obj1.info.age); // 25
console.log(obj1.hobbies[0]); // 篮球
console.log(obj2.name); // 李四
console.log(obj2.info.age); // 30
console.log(obj2.hobbies[0]); // 足球

注意:JSON 方式的局限性(无法处理特殊类型)

  • 无法拷贝函数、正则表达式、Date 对象(会转为字符串 / 对象字面量,丢失原有特性);
  • 无法拷贝 Symbol 类型属性、undefined 类型属性(会被忽略);
  • 无法处理循环引用(如 obj.a = obj,会报错)。

(2)手动递归实现(灵活可控,支持特殊类型)

通过递归遍历对象 / 数组的每一层,对原始类型直接赋值,对引用类型创建新副本,可自定义处理特殊类型。

// 深拷贝工具函数
function deepClone(target) {
  // 1. 处理原始类型和 null
  if (typeof target !== "object" || target === null) {
    return target;
  }

  // 2. 处理 Date 对象
  if (target instanceof Date) {
    return new Date(target);
  }

  // 3. 处理 RegExp 对象
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // 4. 处理数组和普通对象(创建新副本)
  const result = Array.isArray(target) ? [] : {};

  // 5. 递归遍历,拷贝所有层级属性
  for (let key in target) {
    // 仅拷贝自身属性,不拷贝原型链属性
    if (target.hasOwnProperty(key)) {
      result[key] = deepClone(target[key]);
    }
  }

  return result;
}

// 测试
const obj1 = {
  name: "张三",
  info: { age: 25 },
  hobbies: ["篮球", "游戏"],
  birth: new Date("1999-01-01"),
  reg: /abc/gi,
  fn: () => console.log("hello")
};
const obj2 = deepClone(obj1);

obj2.info.age = 30;
obj2.birth.setFullYear(2000);
obj2.fn = () => console.log("world");

console.log(obj1.info.age); // 25(不影响原值)
console.log(obj1.birth.getFullYear()); // 1999(不影响原值)
console.log(obj1.fn()); // hello(函数独立)
console.log(obj2.fn()); // world

(3)第三方库(成熟稳定,推荐生产环境)

  • Lodash 库的 _.cloneDeep()(支持所有类型,处理循环引用)

    // 安装:npm i lodash
    const _ = require("lodash");
    
    const obj1 = { name: "张三", info: { age: 25 }, a: obj1 }; // 循环引用
    const obj2 = _.cloneDeep(obj1); // 深拷贝,正常处理循环引用
    
    obj2.info.age = 30;
    console.log(obj1.info.age); // 25
    
  • jQuery 库的 $.extend(true, {}, obj)(true 表示深拷贝)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = $.extend(true, {}, obj1); // 深拷贝
    

3. 深拷贝的特点

  • 优点:新旧数据完全独立,修改任意一方不会影响另一方,避免数据污染;
  • 缺点:实现复杂(手动递归需处理多种特殊类型)、性能开销大(递归遍历所有层级);
  • 适用场景:需要修改拷贝后的数据,且数据包含深层嵌套引用类型的场景(如表单提交、状态管理、复杂数据处理)。

四、深拷贝 vs 浅拷贝 核心对比

对比维度 浅拷贝(Shallow Copy) 深拷贝(Deep Copy)
拷贝层级 仅拷贝表层(第一层)数据 递归拷贝所有层级数据
引用类型处理 深层嵌套引用类型仅复制内存地址(共享) 深层嵌套引用类型创建独立副本(不共享)
数据独立性 深层数据共享,修改会相互影响 完全独立,修改互不影响
实现难度 简单(原生 API 即可实现) 复杂(需处理特殊类型、循环引用)
性能开销 小(仅遍历表层) 大(递归遍历所有层级)
适用场景 表层数据拷贝、无需修改深层数据 复杂嵌套数据拷贝、需要独立修改数据
常见实现 数组:slice、concat、[...arr];对象:Object.assign、{...obj} JSON.parse (JSON.stringify ())、手动递归、_.cloneDeep ()

五、常见误区

  1. 认为 Object.assign 是深拷贝Object.assign 仅对第一层数据实现值拷贝,深层引用类型仍为引用传递,属于浅拷贝;
  2. JSON 方式能处理所有数据:JSON 序列化无法处理函数、正则、循环引用、Symbol 等类型,仅适用于简单 JSON 数据;
  3. 原始类型需要深浅拷贝:原始类型赋值时直接传递值,不存在引用,无需区分深浅拷贝;
  4. 深拷贝一定优于浅拷贝:深拷贝性能开销大,若数据无深层嵌套,浅拷贝更高效,无需过度使用深拷贝。

总结

  1. 核心区别:是否拷贝深层嵌套的引用类型,决定数据是否独立;
  2. 原始类型无深浅拷贝之分,引用类型才需要区分;
  3. 浅拷贝:简单高效,适合表层数据,推荐 [...arr]/{...obj}/Object.assign
  4. 深拷贝:完全独立,适合复杂嵌套数据,简单场景用 JSON.parse(JSON.stringify()),生产环境推荐 _.cloneDeep()
  5. 选型原则:根据数据结构选择,无需深层独立时优先浅拷贝,避免性能浪费。

NiceGUI 内置Material Design图标库

作者 PieroPC
2025年12月26日 09:00

图:

image.png

代码:

from nicegui import ui, events
from typing import List, Dict

# ========== 1. 扩展到200+ NiceGUI内置Material Design图标 ==========
MATERIAL_ICONS: List[Dict[str, str]] = [
    # 导航类(25个)
    {'name': 'menu', 'description': '菜单', 'category': '导航'},
    {'name': 'home', 'description': '首页', 'category': '导航'},
    {'name': 'search', 'description': '搜索', 'category': '导航'},
    {'name': 'arrow_back', 'description': '返回', 'category': '导航'},
    {'name': 'arrow_forward', 'description': '前进', 'category': '导航'},
    {'name': 'arrow_upward', 'description': '向上', 'category': '导航'},
    {'name': 'arrow_downward', 'description': '向下', 'category': '导航'},
    {'name': 'chevron_left', 'description': '左箭头', 'category': '导航'},
    {'name': 'chevron_right', 'description': '右箭头', 'category': '导航'},
    {'name': 'expand_more', 'description': '展开更多', 'category': '导航'},
    {'name': 'expand_less', 'description': '收起', 'category': '导航'},
    {'name': 'menu_open', 'description': '展开菜单', 'category': '导航'},
    {'name': 'menu_book', 'description': '菜单书', 'category': '导航'},
    {'name': 'close', 'description': '关闭', 'category': '导航'},
    {'name': 'backspace', 'description': '退格', 'category': '导航'},
    {'name': 'arrow_left', 'description': '左箭头', 'category': '导航'},
    {'name': 'arrow_right', 'description': '右箭头', 'category': '导航'},
    {'name': 'arrow_up', 'description': '上箭头', 'category': '导航'},
    {'name': 'arrow_down', 'description': '下箭头', 'category': '导航'},
    {'name': 'navigate_before', 'description': '导航前', 'category': '导航'},
    {'name': 'navigate_next', 'description': '导航后', 'category': '导航'},
    {'name': 'keyboard_arrow_up', 'description': '键盘上箭头', 'category': '导航'},
    {'name': 'keyboard_arrow_down', 'description': '键盘下箭头', 'category': '导航'},
    {'name': 'keyboard_arrow_left', 'description': '键盘左箭头', 'category': '导航'},
    {'name': 'keyboard_arrow_right', 'description': '键盘右箭头', 'category': '导航'},
    
    # 操作类(28个)
    {'name': 'add', 'description': '添加', 'category': '操作'},
    {'name': 'edit', 'description': '编辑', 'category': '操作'},
    {'name': 'delete', 'description': '删除', 'category': '操作'},
    {'name': 'save', 'description': '保存', 'category': '操作'},
    {'name': 'cancel', 'description': '取消', 'category': '操作'},
    {'name': 'check', 'description': '确认', 'category': '操作'},
    {'name': 'undo', 'description': '撤销', 'category': '操作'},
    {'name': 'redo', 'description': '重做', 'category': '操作'},
    {'name': 'copy', 'description': '复制', 'category': '操作'},
    {'name': 'cut', 'description': '剪切', 'category': '操作'},
    {'name': 'paste', 'description': '粘贴', 'category': '操作'},
    {'name': 'select_all', 'description': '全选', 'category': '操作'},
    {'name': 'refresh', 'description': '刷新', 'category': '操作'},
    {'name': 'download', 'description': '下载', 'category': '操作'},
    {'name': 'upload', 'description': '上传', 'category': '操作'},
    {'name': 'share', 'description': '分享', 'category': '操作'},
    {'name': 'send', 'description': '发送', 'category': '操作'},
    {'name': 'clear', 'description': '清除', 'category': '操作'},
    {'name': 'done', 'description': '完成', 'category': '操作'},
    {'name': 'remove', 'description': '移除', 'category': '操作'},
    {'name': 'create', 'description': '创建', 'category': '操作'},
    {'name': 'archive', 'description': '归档', 'category': '操作'},
    {'name': 'restore', 'description': '恢复', 'category': '操作'},
    {'name': 'backup', 'description': '备份', 'category': '操作'},
    {'name': 'import_export', 'description': '导入导出', 'category': '操作'},
    {'name': 'move_to_inbox', 'description': '移入收件箱', 'category': '操作'},
    {'name': 'unarchive', 'description': '取消归档', 'category': '操作'},
    {'name': 'publish', 'description': '发布', 'category': '操作'},
    
    # 功能类(26个)
    {'name': 'settings', 'description': '设置', 'category': '功能'},
    {'name': 'person', 'description': '个人', 'category': '功能'},
    {'name': 'email', 'description': '邮件', 'category': '功能'},
    {'name': 'phone', 'description': '电话', 'category': '功能'},
    {'name': 'lock', 'description': '锁定', 'category': '功能'},
    {'name': 'lock_open', 'description': '解锁', 'category': '功能'},
    {'name': 'visibility', 'description': '可见', 'category': '功能'},
    {'name': 'visibility_off', 'description': '隐藏', 'category': '功能'},
    {'name': 'favorite', 'description': '收藏', 'category': '功能'},
    {'name': 'star', 'description': '星星', 'category': '功能'},
    {'name': 'star_border', 'description': '空心星', 'category': '功能'},
    {'name': 'filter', 'description': '筛选', 'category': '功能'},
    {'name': 'sort', 'description': '排序', 'category': '功能'},
    {'name': 'more_vert', 'description': '更多(竖)', 'category': '功能'},
    {'name': 'more_horiz', 'description': '更多(横)', 'category': '功能'},
    {'name': 'account_circle', 'description': '账户头像', 'category': '功能'},
    {'name': 'badge', 'description': '徽章', 'category': '功能'},
    {'name': 'bookmark', 'description': '书签', 'category': '功能'},
    {'name': 'bookmark_border', 'description': '空心书签', 'category': '功能'},
    {'name': 'contact_page', 'description': '联系人页面', 'category': '功能'},
    {'name': 'dashboard', 'description': '仪表盘', 'category': '功能'},
    {'name': 'edit_note', 'description': '编辑笔记', 'category': '功能'},
    {'name': 'flag', 'description': '旗帜', 'category': '功能'},
    {'name': 'help_outline', 'description': '帮助轮廓', 'category': '功能'},
    {'name': 'history', 'description': '历史', 'category': '功能'},
    {'name': 'login', 'description': '登录', 'category': '功能'},
    
    # 提示类(12个)
    {'name': 'error', 'description': '错误', 'category': '提示'},
    {'name': 'warning', 'description': '警告', 'category': '提示'},
    {'name': 'info', 'description': '信息', 'category': '提示'},
    {'name': 'help', 'description': '帮助', 'category': '提示'},
    {'name': 'check_circle', 'description': '成功圈', 'category': '提示'},
    {'name': 'error_outline', 'description': '错误轮廓', 'category': '提示'},
    {'name': 'warning_amber', 'description': '警告琥珀色', 'category': '提示'},
    {'name': 'info_outline', 'description': '信息轮廓', 'category': '提示'},
    {'name': 'check_circle_outline', 'description': '成功圈轮廓', 'category': '提示'},
    {'name': 'report', 'description': '报告', 'category': '提示'},
    {'name': 'report_problem', 'description': '报告问题', 'category': '提示'},
    {'name': 'notifications', 'description': '通知', 'category': '提示'},
    
    # 文件类(18个)
    {'name': 'folder', 'description': '文件夹', 'category': '文件'},
    {'name': 'folder_open', 'description': '打开文件夹', 'category': '文件'},
    {'name': 'file', 'description': '文件', 'category': '文件'},
    {'name': 'file_copy', 'description': '复制文件', 'category': '文件'},
    {'name': 'image', 'description': '图片', 'category': '文件'},
    {'name': 'video_library', 'description': '视频库', 'category': '文件'},
    {'name': 'music_note', 'description': '音乐', 'category': '文件'},
    {'name': 'description', 'description': '文档', 'category': '文件'},
    {'name': 'file_download', 'description': '文件下载', 'category': '文件'},
    {'name': 'file_upload', 'description': '文件上传', 'category': '文件'},
    {'name': 'file_present', 'description': '文件展示', 'category': '文件'},
    {'name': 'folder_copy', 'description': '复制文件夹', 'category': '文件'},
    {'name': 'insert_drive_file', 'description': '插入文件', 'category': '文件'},
    {'name': 'pdf', 'description': 'PDF文件', 'category': '文件'},
    {'name': 'picture_as_pdf', 'description': 'PDF图片', 'category': '文件'},
    {'name': 'text_snippet', 'description': '文本片段', 'category': '文件'},
    {'name': 'upload_file', 'description': '上传文件', 'category': '文件'},
    {'name': 'cloud_upload', 'description': '云上传', 'category': '文件'},
    
    # 设备类(20个)
    {'name': 'laptop', 'description': '笔记本', 'category': '设备'},
    {'name': 'desktop_windows', 'description': '桌面', 'category': '设备'},
    {'name': 'phone_android', 'description': '安卓手机', 'category': '设备'},
    {'name': 'tablet_android', 'description': '平板', 'category': '设备'},
    {'name': 'print', 'description': '打印', 'category': '设备'},
    {'name': 'wifi', 'description': 'WiFi', 'category': '设备'},
    {'name': 'bluetooth', 'description': '蓝牙', 'category': '设备'},
    {'name': 'battery_full', 'description': '满电', 'category': '设备'},
    {'name': 'battery_half', 'description': '半电', 'category': '设备'},
    {'name': 'battery_empty', 'description': '无电', 'category': '设备'},
    {'name': 'battery_charging_full', 'description': '充电满', 'category': '设备'},
    {'name': 'camera', 'description': '相机', 'category': '设备'},
    {'name': 'headset', 'description': '耳机', 'category': '设备'},
    {'name': 'keyboard', 'description': '键盘', 'category': '设备'},
    {'name': 'mouse', 'description': '鼠标', 'category': '设备'},
    {'name': 'scanner', 'description': '扫描仪', 'category': '设备'},
    {'name': 'speaker', 'description': '扬声器', 'category': '设备'},
    {'name': 'tv', 'description': '电视', 'category': '设备'},
    {'name': 'usb', 'description': 'USB', 'category': '设备'},
    {'name': 'watch', 'description': '手表', 'category': '设备'},
    
    # 时间类(12个)
    {'name': 'calendar_today', 'description': '今日日历', 'category': '时间'},
    {'name': 'access_time', 'description': '时间', 'category': '时间'},
    {'name': 'alarm', 'description': '闹钟', 'category': '时间'},
    {'name': 'timer', 'description': '计时器', 'category': '时间'},
    {'name': 'stopwatch', 'description': '秒表', 'category': '时间'},
    {'name': 'calendar_month', 'description': '月历', 'category': '时间'},
    {'name': 'calendar_week', 'description': '周历', 'category': '时间'},
    {'name': 'clock', 'description': '时钟', 'category': '时间'},
    {'name': 'date_range', 'description': '日期范围', 'category': '时间'},
    {'name': 'schedule', 'description': '日程', 'category': '时间'},
    {'name': 'timer_10', 'description': '10分钟计时器', 'category': '时间'},
    {'name': 'timer_off', 'description': '关闭计时器', 'category': '时间'},
    
    # 购物类(15个)
    {'name': 'shopping_cart', 'description': '购物车', 'category': '购物'},
    {'name': 'payment', 'description': '支付', 'category': '购物'},
    {'name': 'tag', 'description': '标签', 'category': '购物'},
    {'name': 'barcode', 'description': '条形码', 'category': '购物'},
    {'name': 'qr_code', 'description': '二维码', 'category': '购物'},
    {'name': 'attach_money', 'description': '金额', 'category': '购物'},
    {'name': 'card_giftcard', 'description': '礼品卡', 'category': '购物'},
    {'name': 'credit_card', 'description': '信用卡', 'category': '购物'},
    {'name': 'money', 'description': '货币', 'category': '购物'},
    {'name': 'price_check', 'description': '价格检查', 'category': '购物'},
    {'name': 'receipt', 'description': '收据', 'category': '购物'},
    {'name': 'sell', 'description': '出售', 'category': '购物'},
    {'name': 'shopping_bag', 'description': '购物袋', 'category': '购物'},
    {'name': 'store', 'description': '商店', 'category': '购物'},
    {'name': 'local_atm', 'description': 'ATM机', 'category': '购物'},
    
    # 媒体类(18个)
    {'name': 'volume_up', 'description': '音量大', 'category': '媒体'},
    {'name': 'volume_down', 'description': '音量小', 'category': '媒体'},
    {'name': 'volume_mute', 'description': '静音', 'category': '媒体'},
    {'name': 'volume_off', 'description': '无音量', 'category': '媒体'},
    {'name': 'play_arrow', 'description': '播放', 'category': '媒体'},
    {'name': 'pause', 'description': '暂停', 'category': '媒体'},
    {'name': 'stop', 'description': '停止', 'category': '媒体'},
    {'name': 'skip_next', 'description': '下一曲', 'category': '媒体'},
    {'name': 'skip_previous', 'description': '上一曲', 'category': '媒体'},
    {'name': 'fast_forward', 'description': '快进', 'category': '媒体'},
    {'name': 'fast_rewind', 'description': '快退', 'category': '媒体'},
    {'name': 'repeat', 'description': '重复', 'category': '媒体'},
    {'name': 'shuffle', 'description': '随机播放', 'category': '媒体'},
    {'name': 'audiotrack', 'description': '音轨', 'category': '媒体'},
    {'name': 'mic', 'description': '麦克风', 'category': '媒体'},
    {'name': 'mic_off', 'description': '关闭麦克风', 'category': '媒体'},
    {'name': 'photo_camera', 'description': '拍照', 'category': '媒体'},
    {'name': 'video_call', 'description': '视频通话', 'category': '媒体'},
    
    # 工具类(20个)
    {'name': 'zoom_in', 'description': '放大', 'category': '工具'},
    {'name': 'zoom_out', 'description': '缩小', 'category': '工具'},
    {'name': 'fullscreen', 'description': '全屏', 'category': '工具'},
    {'name': 'fullscreen_exit', 'description': '退出全屏', 'category': '工具'},
    {'name': 'rotate_left', 'description': '左转', 'category': '工具'},
    {'name': 'rotate_right', 'description': '右转', 'category': '工具'},
    {'name': 'delete_sweep', 'description': '清除', 'category': '工具'},
    {'name': 'adjust', 'description': '调整', 'category': '工具'},
    {'name': 'build', 'description': '构建', 'category': '工具'},
    {'name': 'calculator', 'description': '计算器', 'category': '工具'},
    {'name': 'color_lens', 'description': '色镜', 'category': '工具'},
    {'name': 'compass_calibration', 'description': '罗盘校准', 'category': '工具'},
    {'name': 'crop', 'description': '裁剪', 'category': '工具'},
    {'name': 'draw', 'description': '绘制', 'category': '工具'},
    {'name': 'extension', 'description': '扩展', 'category': '工具'},
    {'name': 'flash_on', 'description': '闪光灯开', 'category': '工具'},
    {'name': 'flash_off', 'description': '闪光灯关', 'category': '工具'},
    {'name': 'highlight', 'description': '高亮', 'category': '工具'},
    {'name': 'level', 'description': '水平', 'category': '工具'},
    {'name': 'measure', 'description': '测量', 'category': '工具'}
]

# 默认图标颜色
DEFAULT_ICON_COLOR = '#4caf50'

# 获取所有分类(去重并排序)
def get_all_categories() -> List[str]:
    categories = sorted(list(set(icon['category'] for icon in MATERIAL_ICONS)))
    return ['全部图标'] + categories

# 复制图标名称函数(复制纯名称,如menu/home)
def copy_icon_name(name: str):
    ui.run_javascript(f'navigator.clipboard.writeText(`{name}`)')
    ui.notify(f'已复制图标名称: {name}', type='positive', timeout=2000)

def main():
    # 强制加载Material Icons字体(解决国内显示问题)
    ui.add_head_html('''
        <link href="https://fonts.googleapis.cn/css2?family=Material+Icons" rel="stylesheet">
    ''')

    # 页面基础配置
    ui.page_title('NiceGUI 3.4.0 Material Design图标库')
    ui.colors(primary='#4caf50', secondary='#03DAC6')  # Material Design主题色

    # 存储图标卡片引用和筛选状态
    icon_cards = {}  # key: 图标name, value: 卡片元素
    icon_elements = {}  # key: 图标name, value: 图标元素
    current_category = '全部图标'
    current_search_text = ''
    current_icon_color = DEFAULT_ICON_COLOR

    # ========== 顶部导航栏(分类筛选+搜索+颜色选择) ==========
    with ui.header(elevated=True).style('padding: 1rem 2rem; background-color: white; display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap'):
        # 标题
        ui.label('NiceGUI 内置Material Design图标库').style('font-size: 1.8rem; font-weight: bold; color: #4caf50; margin-right: 1rem')
        
        # 分类筛选下拉框
        category_select = ui.select(
            options=get_all_categories(),
            value='全部图标',
            on_change=lambda e: update_filter(category=e.value)
        ).props('rounded outlined').style('width: 180px; min-width: 150px')
        category_select.add_slot('prepend', '<i class="material-icons">folder_category</i>')
        
        # 搜索框(支持搜索名称/描述)
        search_input = ui.input(
            placeholder='搜索图标(名称/描述,如:菜单、arrow、home)',
            on_change=lambda e: update_filter(search_text=e.value)
        ).props('rounded outlined').style('width: 300px; min-width: 200px; flex-grow: 1; max-width: 500px')
        search_input.add_slot('prepend', '<i class="material-icons">search</i>')
        
        # 新增:图标颜色选择器
        color_picker = ui.color_input(
            label='图标颜色',
            value=DEFAULT_ICON_COLOR,
            on_change=lambda e: change_icon_color(e.value)
        ).props('rounded outlined hide-header').style('width: 180px; min-width: 150px')
        color_picker.add_slot('prepend', '<i class="material-icons">color_lens</i>')
        
        # 新增:重置颜色按钮
        ui.button(
            '重置颜色',
            on_click=lambda: reset_icon_color(),
            icon='refresh'
        ).props('outline rounded').style('height: 56px')
        
        # 统计显示
        count_label = ui.label(f'共 {len(MATERIAL_ICONS)} 个图标').style('margin-left: auto; color: #666; white-space: nowrap')

    # ========== 图标展示区域(响应式网格布局) ==========
    with ui.column().classes('w-full box-border'):
        # grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) 表示:
        # - auto-fill: 自动填充列数
        # - minmax(120px, 1fr): 每列最小120px,最大自适应
        grid = ui.grid().style('''
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
            gap: 1.5rem;
            max-width: 1600px;
            margin: 0 auto;
            width: 100%;
        ''')
        
        # 生成所有图标卡片
        for icon in MATERIAL_ICONS:
            icon_name = icon['name']
            icon_desc = icon['description']
            icon_category = icon['category']
            
            with grid:
                # 优化卡片样式:移除固定宽度,使用百分比宽度,保证自适应
                card_style = (
                    'padding: 1rem 0.8rem; '
                    'text-align: center; '
                    'border-radius: 8px; '
                    'box-shadow: 0 2px 4px rgba(0,0,0,0.1); '
                    'width: 100%; '  # 改为100%宽度,适应网格列宽
                    'height: 200px; '
                    'display: flex; '
                    'flex-direction: column; '
                    'align-items: center; '
                    'justify-content: center; '
                    'transition: all 0.2s ease; '
                    'background-color: white; '
                    'overflow: hidden;'
                )
                card = ui.card().style(card_style)
                # 鼠标悬停效果(分开绑定,避免样式字符串解析错误)
                card.on('mouseenter', lambda e: e.sender.style('box-shadow: 0 4px 8px rgba(0,0,0,0.15); transform: translateY(-2px)'))
                card.on('mouseleave', lambda e: e.sender.style('box-shadow: 0 2px 4px rgba(0,0,0,0.1); transform: translateY(0)'))
                
                with card:
                    with ui.column().classes('gap-0 items-center w-full'):
                        # 适配尺寸的图标 - 保存图标元素引用
                        icon_elem = ui.icon(icon_name).style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {current_icon_color}')
                        # 紧凑的文字样式
                        name_style = (
                            'font-family: Consolas, monospace; '
                            'font-size: 12px; '
                            'font-weight: bold; '
                            'margin-bottom: 0.2rem; '
                            'color: #333; '
                            'word-break: break-all; '
                            'line-height: 1.2;'
                        )
                        ui.label(icon_name).style(name_style)
                        ui.label(icon_desc).style('font-size: 11px; color: #666; margin-bottom: 0.3rem')
                        ui.label(icon_category).props('color=secondary text-xs').style('margin-bottom: 0.3rem')
                        # 小巧的复制按钮
                        ui.button('复制', on_click=lambda n=icon_name: copy_icon_name(n)) \
                            .props('outline rounded').style('width: 80px; height: 30px; font-size: 11px')
                
                # 保存卡片和图标元素引用
                icon_cards[icon_name] = card
                icon_elements[icon_name] = icon_elem

    # ========== 颜色修改函数 ==========
    def change_icon_color(new_color: str):
        """修改所有显示图标的颜色"""
        nonlocal current_icon_color
        current_icon_color = new_color
        
        # 遍历所有图标元素更新颜色
        for icon_name, icon_elem in icon_elements.items():
            # 只更新显示中的图标
            if icon_cards[icon_name].style.get('display', 'flex') == 'flex':
                icon_elem.style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {new_color}')
        
        ui.notify(f'图标颜色已更新为: {new_color}', type='info', timeout=2000)

    def reset_icon_color():
        """重置图标颜色为默认值"""
        nonlocal current_icon_color
        current_icon_color = DEFAULT_ICON_COLOR
        
        # 更新颜色选择器值
        color_picker.value = DEFAULT_ICON_COLOR
        
        # 重置所有图标颜色
        for icon_name, icon_elem in icon_elements.items():
            if icon_cards[icon_name].style.get('display', 'flex') == 'flex':
                icon_elem.style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {DEFAULT_ICON_COLOR}')
        
        ui.notify('图标颜色已重置为默认值', type='success', timeout=2000)

    # ========== 组合筛选函数(分类+搜索) ==========
    def update_filter(category: str = None, search_text: str = None):
        nonlocal current_category, current_search_text
        
        # 更新筛选状态
        if category is not None:
            current_category = category
        if search_text is not None:
            current_search_text = search_text.lower().strip()
        
        match_count = 0
        # 遍历所有图标应用筛选
        for icon in MATERIAL_ICONS:
            icon_name = icon['name']
            card = icon_cards[icon_name]
            icon_elem = icon_elements[icon_name]
            
            # 条件1:分类匹配(全部图标则跳过分类筛选)
            category_match = (current_category == '全部图标') or (icon['category'] == current_category)
            # 条件2:搜索匹配(名称/描述包含关键词)
            search_match = True
            if current_search_text:
                search_match = (current_search_text in icon_name.lower()) or (current_search_text in icon['description'].lower())
            
            # 显示/隐藏卡片,并确保显示的图标使用当前颜色
            if category_match and search_match:
                card.style('display: flex')
                # 确保显示的图标使用当前选中的颜色
                icon_elem.style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {current_icon_color}')
                match_count += 1
            else:
                card.style('display: none')
        
        # 更新统计
        total_in_category = len(MATERIAL_ICONS) if current_category == '全部图标' else len([i for i in MATERIAL_ICONS if i['category'] == current_category])
        count_label.set_text(f'找到 {match_count} 个图标({current_category}{total_in_category} 个)')

    # ========== 页脚 ==========
    with ui.footer().style('padding: 0.8rem; text-align: center; background-color: #4caf50; color: white'):
        ui.label('NiceGUI 3.4.0 原生Material Design图标 | 200+常用图标 | 支持分类筛选+关键词搜索+颜色自定义 | 可直接复制图标名称使用')

# 兼容多进程的主程序保护语句
if __name__ in {"__main__", "__mp_main__"}:
    main()
    # 启动NiceGUI
    ui.run(
        title='NiceGUI Material图标库',
        port=8080,
        reload=True,
        show=True,
        uvicorn_logging_level='warning'
    )

NiceGUI Material Design 图标库

项目介绍

本项目是基于 NiceGUI 3.4.0 开发的 Material Design 图标库工具,整合了 200+ 常用内置图标,支持按分类筛选、关键词搜索、图标颜色自定义及图标名称一键复制功能。界面简洁直观、响应式布局适配多种屏幕尺寸,旨在为 NiceGUI 开发者提供便捷的图标选型与使用体验。

核心功能

  • 丰富的图标资源:包含 200+ 个 Material Design 内置图标,涵盖导航、操作、功能、提示、文件、设备等 10 大分类,满足日常开发需求。
  • 多维度筛选:支持按分类筛选(如“导航”“操作”“文件”等),同时支持关键词搜索(匹配图标名称或描述),快速定位目标图标。
  • 自定义图标颜色:内置颜色选择器,可自由切换图标显示颜色,支持十六进制颜色值输入,同时提供“重置颜色”功能快速恢复默认色。
  • 一键复制功能:每个图标卡片配备“复制”按钮,点击即可复制图标名称(如 menuhome),直接用于 NiceGUI 项目开发。
  • 响应式布局:采用自适应网格布局,在电脑、平板等不同尺寸设备上均能良好展示。
  • 友好的视觉反馈:图标卡片hover效果、操作成功提示(复制、颜色修改)等,提升使用体验。

环境要求

  • Python 3.8+
  • NiceGUI 3.4.0+(推荐与项目依赖版本一致)

安装步骤

1. 克隆/下载项目

将项目代码下载到本地,或通过 git 克隆(若使用版本控制):

git clone <项目仓库地址>
cd nicegui-material-icon-library

2. 安装依赖

使用 pip 安装所需依赖(主要为 NiceGUI):

pip install nicegui==3.4.0

若需快速安装最新兼容版本,可简化为:

pip install nicegui

运行方法

在项目根目录下,执行以下命令启动程序:

python icon_library.py

程序启动后,会自动在默认浏览器中打开页面(默认地址:http://localhost:8080)。若浏览器未自动打开,可手动访问该地址。

使用指南

1. 浏览与筛选图标

  • 分类筛选:点击顶部导航栏的“分类筛选”下拉框,选择目标分类(如“导航”“操作”),页面将仅显示该分类下的图标。选择“全部图标”可恢复显示所有图标。
  • 关键词搜索:在顶部搜索框中输入关键词(支持图标名称或描述,如“菜单”“arrow”“home”),页面将实时筛选出匹配的图标。清空搜索框可恢复显示所有图标。

2. 自定义图标颜色

  • 选择颜色:点击顶部导航栏的“图标颜色”选择器,通过颜色面板选择所需颜色,或直接输入十六进制颜色值(如 #ff0000、#2196f3),页面中所有显示的图标将实时更新为所选颜色。
  • 重置颜色:点击“重置颜色”按钮,可快速将所有图标恢复为默认颜色(#4caf50,Material Design 绿色)。

3. 复制图标名称

找到目标图标后,点击图标卡片下方的“复制”按钮,系统将自动复制该图标的名称(如 searchdelete),并显示“已复制图标名称”的成功提示。复制后可直接在 NiceGUI 项目中使用该名称创建图标,示例:

from nicegui import ui

ui.icon('menu')  # 使用复制的图标名称
ui.run()

4. 查看图标信息

每个图标卡片包含以下信息:

  • 图标预览:直观展示图标样式
  • 图标名称:NiceGUI 中使用的图标标识(可复制)
  • 图标描述:中文说明,便于理解图标用途
  • 图标分类:该图标所属的分类

项目结构

nicegui-material-icon-library/
├── icon_library.py  # 主程序文件(核心代码)
└── README.md        # 项目说明文档

核心代码说明:

  • MATERIAL_ICONS:存储所有图标的信息(名称、描述、分类),可在此扩展或修改图标列表。
  • get_all_categories():获取所有图标分类(去重并排序),用于分类筛选下拉框。
  • copy_icon_name():实现图标名称复制功能,并显示提示信息。
  • change_icon_color():实现图标颜色更新功能。
  • reset_icon_color():实现图标颜色重置功能。
  • update_filter():组合分类筛选和关键词搜索功能,控制图标显示/隐藏。

扩展与定制

1. 增加更多图标

MATERIAL_ICONS 列表中添加新的图标字典,格式如下:

{
    'name': '图标名称',  # NiceGUI 支持的 Material Design 图标名称
    'description': '中文描述',  # 图标用途说明
    'category': '分类名称'  # 所属分类(如“导航”“操作”,可新增分类)
}

2. 修改默认配置

  • 默认颜色:修改 DEFAULT_ICON_COLOR 变量的值,可更改图标默认颜色。
  • 页面主题:修改 ui.colors() 中的 primary(主色)和 secondary(辅助色),可更改页面主题色。
  • 端口号:修改 ui.run() 中的 port 参数,可更改程序运行端口(如 port=8081)。

3. 调整界面样式

可修改代码中相关的 style 属性,调整图标卡片大小、颜色、间距,导航栏样式,字体大小等,实现个性化界面定制。

常见问题

1. 图标无法正常显示?

  • 检查 NiceGUI 版本是否兼容(推荐 3.4.0+),若版本过低,可能导致部分图标无法显示。
  • 确保网络正常,程序需加载 Material Icons 字体(已配置国内镜像:fonts.googleapis.cn),网络异常可能导致图标加载失败。

2. 复制功能无效?

部分浏览器可能限制剪贴板访问权限,建议在浏览器地址栏旁确认权限(允许剪贴板访问),或尝试更换主流浏览器(如 Chrome、Edge、Firefox)。

3. 程序启动失败?

  • 检查端口是否被占用,若 8080 端口已被其他程序使用,可修改 ui.run() 中的 port 参数,使用其他空闲端口。
  • 检查 Python 版本是否符合要求(3.8+),过低版本可能导致语法错误。

致谢

  • 基于 NiceGUI 框架开发,感谢 NiceGUI 团队提供的简洁高效的开发工具。
  • 图标资源基于 Material Design Icons,感谢 Google 提供的开源图标库。

由于局域网不能复制改变下用(pyperclip):

from nicegui import ui, events
from typing import List, Dict
import pyperclip  # 核心:引入pyperclip实现服务端复制
import sys

class MaterialIconLibrary:
    # ========== 类级常量定义 ==========
    MATERIAL_ICONS: List[Dict[str, str]] = [
        # 导航类(25个)
        {'name': 'menu', 'description': '菜单', 'category': '导航'},
        {'name': 'home', 'description': '首页', 'category': '导航'},
        {'name': 'search', 'description': '搜索', 'category': '导航'},
        {'name': 'arrow_back', 'description': '返回', 'category': '导航'},
        {'name': 'arrow_forward', 'description': '前进', 'category': '导航'},
        {'name': 'arrow_upward', 'description': '向上', 'category': '导航'},
        {'name': 'arrow_downward', 'description': '向下', 'category': '导航'},
        {'name': 'chevron_left', 'description': '左箭头', 'category': '导航'},
        {'name': 'chevron_right', 'description': '右箭头', 'category': '导航'},
        {'name': 'expand_more', 'description': '展开更多', 'category': '导航'},
        {'name': 'expand_less', 'description': '收起', 'category': '导航'},
        {'name': 'menu_open', 'description': '展开菜单', 'category': '导航'},
        {'name': 'menu_book', 'description': '菜单书', 'category': '导航'},
        {'name': 'close', 'description': '关闭', 'category': '导航'},
        {'name': 'backspace', 'description': '退格', 'category': '导航'},
        {'name': 'arrow_left', 'description': '左箭头', 'category': '导航'},
        {'name': 'arrow_right', 'description': '右箭头', 'category': '导航'},
        {'name': 'arrow_up', 'description': '上箭头', 'category': '导航'},
        {'name': 'arrow_down', 'description': '下箭头', 'category': '导航'},
        {'name': 'navigate_before', 'description': '导航前', 'category': '导航'},
        {'name': 'navigate_next', 'description': '导航后', 'category': '导航'},
        {'name': 'keyboard_arrow_up', 'description': '键盘上箭头', 'category': '导航'},
        {'name': 'keyboard_arrow_down', 'description': '键盘下箭头', 'category': '导航'},
        {'name': 'keyboard_arrow_left', 'description': '键盘左箭头', 'category': '导航'},
        {'name': 'keyboard_arrow_right', 'description': '键盘右箭头', 'category': '导航'},
        
        # 操作类(28个)
        {'name': 'add', 'description': '添加', 'category': '操作'},
        {'name': 'edit', 'description': '编辑', 'category': '操作'},
        {'name': 'delete', 'description': '删除', 'category': '操作'},
        {'name': 'save', 'description': '保存', 'category': '操作'},
        {'name': 'cancel', 'description': '取消', 'category': '操作'},
        {'name': 'check', 'description': '确认', 'category': '操作'},
        {'name': 'undo', 'description': '撤销', 'category': '操作'},
        {'name': 'redo', 'description': '重做', 'category': '操作'},
        {'name': 'copy', 'description': '复制', 'category': '操作'},
        {'name': 'cut', 'description': '剪切', 'category': '操作'},
        {'name': 'paste', 'description': '粘贴', 'category': '操作'},
        {'name': 'select_all', 'description': '全选', 'category': '操作'},
        {'name': 'refresh', 'description': '刷新', 'category': '操作'},
        {'name': 'download', 'description': '下载', 'category': '操作'},
        {'name': 'upload', 'description': '上传', 'category': '操作'},
        {'name': 'share', 'description': '分享', 'category': '操作'},
        {'name': 'send', 'description': '发送', 'category': '操作'},
        {'name': 'clear', 'description': '清除', 'category': '操作'},
        {'name': 'done', 'description': '完成', 'category': '操作'},
        {'name': 'remove', 'description': '移除', 'category': '操作'},
        {'name': 'create', 'description': '创建', 'category': '操作'},
        {'name': 'archive', 'description': '归档', 'category': '操作'},
        {'name': 'restore', 'description': '恢复', 'category': '操作'},
        {'name': 'backup', 'description': '备份', 'category': '操作'},
        {'name': 'import_export', 'description': '导入导出', 'category': '操作'},
        {'name': 'move_to_inbox', 'description': '移入收件箱', 'category': '操作'},
        {'name': 'unarchive', 'description': '取消归档', 'category': '操作'},
        {'name': 'publish', 'description': '发布', 'category': '操作'},
        
        # 功能类(26个)
        {'name': 'settings', 'description': '设置', 'category': '功能'},
        {'name': 'person', 'description': '个人', 'category': '功能'},
        {'name': 'email', 'description': '邮件', 'category': '功能'},
        {'name': 'phone', 'description': '电话', 'category': '功能'},
        {'name': 'lock', 'description': '锁定', 'category': '功能'},
        {'name': 'lock_open', 'description': '解锁', 'category': '功能'},
        {'name': 'visibility', 'description': '可见', 'category': '功能'},
        {'name': 'visibility_off', 'description': '隐藏', 'category': '功能'},
        {'name': 'favorite', 'description': '收藏', 'category': '功能'},
        {'name': 'star', 'description': '星星', 'category': '功能'},
        {'name': 'star_border', 'description': '空心星', 'category': '功能'},
        {'name': 'filter', 'description': '筛选', 'category': '功能'},
        {'name': 'sort', 'description': '排序', 'category': '功能'},
        {'name': 'more_vert', 'description': '更多(竖)', 'category': '功能'},
        {'name': 'more_horiz', 'description': '更多(横)', 'category': '功能'},
        {'name': 'account_circle', 'description': '账户头像', 'category': '功能'},
        {'name': 'badge', 'description': '徽章', 'category': '功能'},
        {'name': 'bookmark', 'description': '书签', 'category': '功能'},
        {'name': 'bookmark_border', 'description': '空心书签', 'category': '功能'},
        {'name': 'contact_page', 'description': '联系人页面', 'category': '功能'},
        {'name': 'dashboard', 'description': '仪表盘', 'category': '功能'},
        {'name': 'edit_note', 'description': '编辑笔记', 'category': '功能'},
        {'name': 'flag', 'description': '旗帜', 'category': '功能'},
        {'name': 'help_outline', 'description': '帮助轮廓', 'category': '功能'},
        {'name': 'history', 'description': '历史', 'category': '功能'},
        {'name': 'login', 'description': '登录', 'category': '功能'},
        
        # 提示类(12个)
        {'name': 'error', 'description': '错误', 'category': '提示'},
        {'name': 'warning', 'description': '警告', 'category': '提示'},
        {'name': 'info', 'description': '信息', 'category': '提示'},
        {'name': 'help', 'description': '帮助', 'category': '提示'},
        {'name': 'check_circle', 'description': '成功圈', 'category': '提示'},
        {'name': 'error_outline', 'description': '错误轮廓', 'category': '提示'},
        {'name': 'warning_amber', 'description': '警告琥珀色', 'category': '提示'},
        {'name': 'info_outline', 'description': '信息轮廓', 'category': '提示'},
        {'name': 'check_circle_outline', 'description': '成功圈轮廓', 'category': '提示'},
        {'name': 'report', 'description': '报告', 'category': '提示'},
        {'name': 'report_problem', 'description': '报告问题', 'category': '提示'},
        {'name': 'notifications', 'description': '通知', 'category': '提示'},
        
        # 文件类(18个)
        {'name': 'folder', 'description': '文件夹', 'category': '文件'},
        {'name': 'folder_open', 'description': '打开文件夹', 'category': '文件'},
        {'name': 'file', 'description': '文件', 'category': '文件'},
        {'name': 'file_copy', 'description': '复制文件', 'category': '文件'},
        {'name': 'image', 'description': '图片', 'category': '文件'},
        {'name': 'video_library', 'description': '视频库', 'category': '文件'},
        {'name': 'music_note', 'description': '音乐', 'category': '文件'},
        {'name': 'description', 'description': '文档', 'category': '文件'},
        {'name': 'file_download', 'description': '文件下载', 'category': '文件'},
        {'name': 'file_upload', 'description': '文件上传', 'category': '文件'},
        {'name': 'file_present', 'description': '文件展示', 'category': '文件'},
        {'name': 'folder_copy', 'description': '复制文件夹', 'category': '文件'},
        {'name': 'insert_drive_file', 'description': '插入文件', 'category': '文件'},
        {'name': 'pdf', 'description': 'PDF文件', 'category': '文件'},
        {'name': 'picture_as_pdf', 'description': 'PDF图片', 'category': '文件'},
        {'name': 'text_snippet', 'description': '文本片段', 'category': '文件'},
        {'name': 'upload_file', 'description': '上传文件', 'category': '文件'},
        {'name': 'cloud_upload', 'description': '云上传', 'category': '文件'},
        
        # 设备类(20个)
        {'name': 'laptop', 'description': '笔记本', 'category': '设备'},
        {'name': 'desktop_windows', 'description': '桌面', 'category': '设备'},
        {'name': 'phone_android', 'description': '安卓手机', 'category': '设备'},
        {'name': 'tablet_android', 'description': '平板', 'category': '设备'},
        {'name': 'print', 'description': '打印', 'category': '设备'},
        {'name': 'wifi', 'description': 'WiFi', 'category': '设备'},
        {'name': 'bluetooth', 'description': '蓝牙', 'category': '设备'},
        {'name': 'battery_full', 'description': '满电', 'category': '设备'},
        {'name': 'battery_half', 'description': '半电', 'category': '设备'},
        {'name': 'battery_empty', 'description': '无电', 'category': '设备'},
        {'name': 'battery_charging_full', 'description': '充电满', 'category': '设备'},
        {'name': 'camera', 'description': '相机', 'category': '设备'},
        {'name': 'headset', 'description': '耳机', 'category': '设备'},
        {'name': 'keyboard', 'description': '键盘', 'category': '设备'},
        {'name': 'mouse', 'description': '鼠标', 'category': '设备'},
        {'name': 'scanner', 'description': '扫描仪', 'category': '设备'},
        {'name': 'speaker', 'description': '扬声器', 'category': '设备'},
        {'name': 'tv', 'description': '电视', 'category': '设备'},
        {'name': 'usb', 'description': 'USB', 'category': '设备'},
        {'name': 'watch', 'description': '手表', 'category': '设备'},
        
        # 时间类(12个)
        {'name': 'calendar_today', 'description': '今日日历', 'category': '时间'},
        {'name': 'access_time', 'description': '时间', 'category': '时间'},
        {'name': 'alarm', 'description': '闹钟', 'category': '时间'},
        {'name': 'timer', 'description': '计时器', 'category': '时间'},
        {'name': 'stopwatch', 'description': '秒表', 'category': '时间'},
        {'name': 'calendar_month', 'description': '月历', 'category': '时间'},
        {'name': 'calendar_week', 'description': '周历', 'category': '时间'},
        {'name': 'clock', 'description': '时钟', 'category': '时间'},
        {'name': 'date_range', 'description': '日期范围', 'category': '时间'},
        {'name': 'schedule', 'description': '日程', 'category': '时间'},
        {'name': 'timer_10', 'description': '10分钟计时器', 'category': '时间'},
        {'name': 'timer_off', 'description': '关闭计时器', 'category': '时间'},
        
        # 购物类(15个)
        {'name': 'shopping_cart', 'description': '购物车', 'category': '购物'},
        {'name': 'payment', 'description': '支付', 'category': '购物'},
        {'name': 'tag', 'description': '标签', 'category': '购物'},
        {'name': 'barcode', 'description': '条形码', 'category': '购物'},
        {'name': 'qr_code', 'description': '二维码', 'category': '购物'},
        {'name': 'attach_money', 'description': '金额', 'category': '购物'},
        {'name': 'card_giftcard', 'description': '礼品卡', 'category': '购物'},
        {'name': 'credit_card', 'description': '信用卡', 'category': '购物'},
        {'name': 'money', 'description': '货币', 'category': '购物'},
        {'name': 'price_check', 'description': '价格检查', 'category': '购物'},
        {'name': 'receipt', 'description': '收据', 'category': '购物'},
        {'name': 'sell', 'description': '出售', 'category': '购物'},
        {'name': 'shopping_bag', 'description': '购物袋', 'category': '购物'},
        {'name': 'store', 'description': '商店', 'category': '购物'},
        {'name': 'local_atm', 'description': 'ATM机', 'category': '购物'},
        
        # 媒体类(18个)
        {'name': 'volume_up', 'description': '音量大', 'category': '媒体'},
        {'name': 'volume_down', 'description': '音量小', 'category': '媒体'},
        {'name': 'volume_mute', 'description': '静音', 'category': '媒体'},
        {'name': 'volume_off', 'description': '无音量', 'category': '媒体'},
        {'name': 'play_arrow', 'description': '播放', 'category': '媒体'},
        {'name': 'pause', 'description': '暂停', 'category': '媒体'},
        {'name': 'stop', 'description': '停止', 'category': '媒体'},
        {'name': 'skip_next', 'description': '下一曲', 'category': '媒体'},
        {'name': 'skip_previous', 'description': '上一曲', 'category': '媒体'},
        {'name': 'fast_forward', 'description': '快进', 'category': '媒体'},
        {'name': 'fast_rewind', 'description': '快退', 'category': '媒体'},
        {'name': 'repeat', 'description': '重复', 'category': '媒体'},
        {'name': 'shuffle', 'description': '随机播放', 'category': '媒体'},
        {'name': 'audiotrack', 'description': '音轨', 'category': '媒体'},
        {'name': 'mic', 'description': '麦克风', 'category': '媒体'},
        {'name': 'mic_off', 'description': '关闭麦克风', 'category': '媒体'},
        {'name': 'photo_camera', 'description': '拍照', 'category': '媒体'},
        {'name': 'video_call', 'description': '视频通话', 'category': '媒体'},
        
        # 工具类(20个)
        {'name': 'zoom_in', 'description': '放大', 'category': '工具'},
        {'name': 'zoom_out', 'description': '缩小', 'category': '工具'},
        {'name': 'fullscreen', 'description': '全屏', 'category': '工具'},
        {'name': 'fullscreen_exit', 'description': '退出全屏', 'category': '工具'},
        {'name': 'rotate_left', 'description': '左转', 'category': '工具'},
        {'name': 'rotate_right', 'description': '右转', 'category': '工具'},
        {'name': 'delete_sweep', 'description': '清除', 'category': '工具'},
        {'name': 'adjust', 'description': '调整', 'category': '工具'},
        {'name': 'build', 'description': '构建', 'category': '工具'},
        {'name': 'calculator', 'description': '计算器', 'category': '工具'},
        {'name': 'color_lens', 'description': '色镜', 'category': '工具'},
        {'name': 'compass_calibration', 'description': '罗盘校准', 'category': '工具'},
        {'name': 'crop', 'description': '裁剪', 'category': '工具'},
        {'name': 'draw', 'description': '绘制', 'category': '工具'},
        {'name': 'extension', 'description': '扩展', 'category': '工具'},
        {'name': 'flash_on', 'description': '闪光灯开', 'category': '工具'},
        {'name': 'flash_off', 'description': '闪光灯关', 'category': '工具'},
        {'name': 'highlight', 'description': '高亮', 'category': '工具'},
        {'name': 'level', 'description': '水平', 'category': '工具'},
        {'name': 'measure', 'description': '测量', 'category': '工具'}
    ]
    
    # 默认图标颜色
    DEFAULT_ICON_COLOR = '#4caf50'

    def __init__(self):
        """初始化图标库实例,设置状态变量和UI组件引用"""
        # 状态变量
        self.current_category = '全部图标'
        self.current_search_text = ''
        self.current_icon_color = self.DEFAULT_ICON_COLOR
        
        # UI组件引用
        self.icon_cards = {}  # key: 图标name, value: 卡片元素
        self.icon_elements = {}  # key: 图标name, value: 图标元素
        self.category_select = None  # 分类筛选下拉框
        self.color_picker = None  # 颜色选择器
        self.count_label = None  # 统计标签

    @staticmethod
    def get_all_categories() -> List[str]:
        """获取所有分类(去重并排序)- 静态方法"""
        categories = sorted(list(set(icon['category'] for icon in MaterialIconLibrary.MATERIAL_ICONS)))
        return ['全部图标'] + categories

    def copy_icon_name(self, name: str):
        """
        核心改造:使用pyperclip实现服务端复制,兼容局域网
        复制完整的使用代码(参考案例的做法),而不只是图标名称
        """
        # 生成完整的使用代码(包含当前选中的颜色)
        if self.current_icon_color and self.current_icon_color != self.DEFAULT_ICON_COLOR:
            # 如果修改了颜色,复制带颜色的代码
            code = f"ui.icon('{name}', color='{self.current_icon_color}')"
        else:
            # 默认颜色,复制基础代码
            code = f"ui.icon('{name}')"
        
        try:
            # 使用pyperclip复制到剪贴板(服务端操作,不受浏览器限制)
            pyperclip.copy(code)
            ui.notify(f'已复制图标代码: {code}', type='positive', timeout=3000)
        except Exception as e:
            # 复制失败的兼容处理
            error_msg = f'复制失败: {str(e)}'
            ui.notify(f'{error_msg}\n请手动复制: {code}', type='negative', timeout=5000)
            # 备选方案:回退到文本框选中方案
            self.fallback_copy(name, code)

    def fallback_copy(self, name: str, code: str):
        """复制失败时的备选方案:创建临时文本框让用户手动复制"""
        # 创建隐藏的文本框(只在复制失败时显示)
        copy_input = ui.input(
            value=code,
            label='请手动复制图标代码'
        ).style('''
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 9999;
            width: 300px;
            opacity: 0.98;
        ''').props('readonly')
        
        # 自动选中文本框内容
        ui.run_javascript(f'''
            const input = document.querySelector('input[aria-label="请手动复制图标代码"]');
            if (input) {{
                input.focus();
                input.select();
                input.setSelectionRange(0, input.value.length);
            }}
        ''')
        
        # 5秒后自动隐藏文本框
        ui.timer(5.0, copy_input.delete, once=True)

    def change_icon_color(self, new_color: str):
        """修改所有显示图标的颜色 - 实例方法"""
        self.current_icon_color = new_color
        
        # 遍历所有图标元素更新颜色
        for icon_name, icon_elem in self.icon_elements.items():
            # 只更新显示中的图标
            if self.icon_cards[icon_name].style.get('display', 'flex') == 'flex':
                icon_elem.style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {new_color}')
        
        ui.notify(f'图标颜色已更新为: {new_color}', type='info', timeout=2000)

    def reset_icon_color(self):
        """重置图标颜色为默认值 - 实例方法"""
        self.current_icon_color = self.DEFAULT_ICON_COLOR
        
        # 更新颜色选择器值
        self.color_picker.value = self.DEFAULT_ICON_COLOR
        
        # 重置所有图标颜色
        for icon_name, icon_elem in self.icon_elements.items():
            if self.icon_cards[icon_name].style.get('display', 'flex') == 'flex':
                icon_elem.style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {self.DEFAULT_ICON_COLOR}')
        
        ui.notify('图标颜色已重置为默认值', type='success', timeout=2000)

    def update_filter(self, category: str = None, search_text: str = None):
        """组合筛选函数(分类+搜索)- 实例方法"""
        # 更新筛选状态
        if category is not None:
            self.current_category = category
        if search_text is not None:
            self.current_search_text = search_text.lower().strip()
        
        match_count = 0
        # 遍历所有图标应用筛选
        for icon in self.MATERIAL_ICONS:
            icon_name = icon['name']
            card = self.icon_cards[icon_name]
            icon_elem = self.icon_elements[icon_name]
            
            # 条件1:分类匹配(全部图标则跳过分类筛选)
            category_match = (self.current_category == '全部图标') or (icon['category'] == self.current_category)
            # 条件2:搜索匹配(名称/描述包含关键词)
            search_match = True
            if self.current_search_text:
                search_match = (self.current_search_text in icon_name.lower()) or (self.current_search_text in icon['description'].lower())
            
            # 显示/隐藏卡片,并确保显示的图标使用当前颜色
            if category_match and search_match:
                card.style('display: flex')
                # 确保显示的图标使用当前选中的颜色
                icon_elem.style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {self.current_icon_color}')
                match_count += 1
            else:
                card.style('display: none')
        
        # 更新统计
        total_in_category = len(self.MATERIAL_ICONS) if self.current_category == '全部图标' else len([i for i in self.MATERIAL_ICONS if i['category'] == self.current_category])
        self.count_label.set_text(f'找到 {match_count} 个图标({self.current_category}{total_in_category} 个)')

    def setup_ui(self):
        """设置UI界面 - 核心实例方法"""
        # 强制加载Material Icons字体(解决国内显示问题)
        ui.add_head_html('''
            <link href="https://fonts.googleapis.cn/css2?family=Material+Icons" rel="stylesheet">
        ''')

        # 页面基础配置
        ui.page_title('NiceGUI 3.4.0 Material Design图标库')
        ui.colors(primary='#4caf50', secondary='#03DAC6')  # Material Design主题色

        # ========== 顶部导航栏(分类筛选+搜索+颜色选择) ==========
        with ui.header(elevated=True).style('padding: 1rem 2rem; background-color: white; display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap'):
            # 标题
            ui.label('NiceGUI 内置Material Design图标库').style('font-size: 1.8rem; font-weight: bold; color: #4caf50; margin-right: 1rem')
            
            # 分类筛选下拉框 - 保存引用
            self.category_select = ui.select(
                options=self.get_all_categories(),
                value='全部图标',
                on_change=lambda e: self.update_filter(category=e.value)
            ).props('rounded outlined').style('width: 180px; min-width: 150px')
            self.category_select.add_slot('prepend', '<i class="material-icons">folder_category</i>')
            
            # 搜索框(支持搜索名称/描述)
            search_input = ui.input(
                placeholder='搜索图标(名称/描述,如:菜单、arrow、home)',
                on_change=lambda e: self.update_filter(search_text=e.value)
            ).props('rounded outlined').style('width: 300px; min-width: 200px; flex-grow: 1; max-width: 500px')
            search_input.add_slot('prepend', '<i class="material-icons">search</i>')
            
            # 图标颜色选择器 - 保存引用
            self.color_picker = ui.color_input(
                label='图标颜色',
                value=self.DEFAULT_ICON_COLOR,
                on_change=lambda e: self.change_icon_color(e.value)
            ).props('rounded outlined hide-header').style('width: 180px; min-width: 150px')
            self.color_picker.add_slot('prepend', '<i class="material-icons">color_lens</i>')
            
            # 重置颜色按钮
            ui.button(
                '重置颜色',
                on_click=lambda: self.reset_icon_color(),
                icon='refresh'
            ).props('outline rounded').style('height: 56px')
            
            # 统计显示 - 保存引用
            self.count_label = ui.label(f'共 {len(self.MATERIAL_ICONS)} 个图标').style('margin-left: auto; color: #666; white-space: nowrap')

        # ========== 图标展示区域(响应式网格布局) ==========
        with ui.column().classes('w-full box-border'):
            # grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) 表示:
            # - auto-fill: 自动填充列数
            # - minmax(120px, 1fr): 每列最小120px,最大自适应
            grid = ui.grid().style('''
                display: grid;
                grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
                gap: 1.5rem;
                max-width: 1600px;
                margin: 0 auto;
                width: 100%;
            ''')
            
            # 生成所有图标卡片
            for icon in self.MATERIAL_ICONS:
                icon_name = icon['name']
                icon_desc = icon['description']
                icon_category = icon['category']
                
                with grid:
                    # 优化卡片样式:移除固定宽度,使用百分比宽度,保证自适应
                    card_style = (
                        'padding: 1rem 0.8rem; '
                        'text-align: center; '
                        'border-radius: 8px; '
                        'box-shadow: 0 2px 4px rgba(0,0,0,0.1); '
                        'width: 100%; '  # 改为100%宽度,适应网格列宽
                        'height: 200px; '
                        'display: flex; '
                        'flex-direction: column; '
                        'align-items: center; '
                        'justify-content: center; '
                        'transition: all 0.2s ease; '
                        'background-color: white; '
                        'overflow: hidden;'
                    )
                    card = ui.card().style(card_style)
                    # 鼠标悬停效果(分开绑定,避免样式字符串解析错误)
                    card.on('mouseenter', lambda e: e.sender.style('box-shadow: 0 4px 8px rgba(0,0,0,0.15); transform: translateY(-2px)'))
                    card.on('mouseleave', lambda e: e.sender.style('box-shadow: 0 2px 4px rgba(0,0,0,0.1); transform: translateY(0)'))
                    
                    with card:
                        with ui.column().classes('gap-0 items-center w-full'):
                            # 适配尺寸的图标 - 保存图标元素引用
                            icon_elem = ui.icon(icon_name).style(f'font-size: 2.5rem; margin-bottom: 0.5rem; color: {self.current_icon_color}')
                            # 紧凑的文字样式
                            name_style = (
                                'font-family: Consolas, monospace; '
                                'font-size: 12px; '
                                'font-weight: bold; '
                                'margin-bottom: 0.2rem; '
                                'color: #333; '
                                'word-break: break-all; '
                                'line-height: 1.2;'
                            )
                            ui.label(icon_name).style(name_style)
                            ui.label(icon_desc).style('font-size: 11px; color: #666; margin-bottom: 0.3rem')
                            ui.label(icon_category).props('color=secondary text-xs').style('margin-bottom: 0.3rem')
                            # 小巧的复制按钮(参考案例,改用content_copy图标更直观)
                            ui.button(
                                icon='content_copy',  # 新增:添加复制图标,参考案例
                                on_click=lambda n=icon_name: self.copy_icon_name(n)
                            ).props('outline rounded').style('width: 80px; height: 30px; font-size: 11px').tooltip('复制')
                    
                    # 保存卡片和图标元素引用
                    self.icon_cards[icon_name] = card
                    self.icon_elements[icon_name] = icon_elem

        # ========== 页脚 ==========
        with ui.footer().style('padding: 0.8rem; text-align: center; background-color: #4caf50; color: white'):
            ui.label('NiceGUI 3.4.0 原生Material Design图标 | 200+常用图标 | 支持分类筛选+关键词搜索+颜色自定义 | 局域网环境下已支持一键复制')

# ========== 修复核心:将页面注册改为函数式,避免self参数冲突 ==========
# 创建全局实例(保证单例)
icon_library_instance = MaterialIconLibrary()

# 页面路由注册为普通函数,内部调用实例的setup_ui方法
@ui.page('/icon')
def icon_page():
    """图标库页面入口 - 普通函数,避免self参数问题"""
    icon_library_instance.setup_ui()

# ========== 程序调用示例 ==========
if __name__ in {"__main__", "__mp_main__"}:
    # 安装pyperclip提示(首次运行时)
    try:
        import pyperclip
    except ImportError:
        print("正在安装pyperclip库...")
        import subprocess
        subprocess.check_call([sys.executable, "-m", "pip", "install", "pyperclip"])
        import pyperclip
    
    # 启动NiceGUI(关键:添加host=0.0.0.0允许局域网访问)
    ui.run(
        title='NiceGUI Material图标库',
        reload=True,
        show=True,
        uvicorn_logging_level='warning',
        host='0.0.0.0'  # 必须:允许局域网其他设备访问
    )

用 10 行代码就能当 “服务器老板”+“网络小偷”+“文件管家”?Node.js:别不信!

2025年12月26日 00:46

前言

当你叩开 Node.js 的大门,会发现它的内核逻辑恰似一套精密的 “后端工具链”http模块是搭建服务的 “基建脚手架”,以极简代码就能拉起可被浏览器访问的 Web 端点https模块是对接外部世界的 “数据导管”,能安全拉取远程接口的资源流;而fs模块则是连接本地存储的 “文件算子”,实现磁盘内容的读写调度。这三者如同后端开发的 “基础三角”,支撑起服务端程序最核心的能力骨架。

就像学会用锅铲、炒勺、漏勺搞定一桌菜,Node.jshttphttpsfs“三板斧”,能帮你轻松搞定服务搭建、网络请求、文件读写这三件大事。那我们就来看看这 “三板斧”,它们到底咋用?

第一斧:用http模块,10 行代码搭个 Web 服务器

想象一下:你对着电脑喊 “我要开个网站”,Node.js 的http模块立刻给你搬来服务器的 “零件”,10 行代码就能让浏览器访问到你的页面。

比如这串代码:

const http = require('http') // 搬来HTTP“工具包”

// 造一个服务器:收到请求就“回应”
const server = http.createServer((req, res) => {
    if (req.url === '/home') { // 如果访问“/home”路径
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) // 告诉浏览器:我给你的是HTML,编码是utf-8
        res.end('<h2>首页</h2>') // 把“首页”这两个字扔给浏览器
    }
})

// 让服务器“蹲在”3000端口等请求
server.listen(3000, () => {
    console.log('server is running at http://localhost:3000')
})

image.png

效果:打开浏览器输入http://localhost:3000/home,就能看到 “首页” 两个大字 —— 是不是就10来行代码就出来了?

image.png

第二斧:用https模块,“偷” 点网上的数据

如果说http“开商店”,那https模块就是 “去别人家商店买东西”—— 比如从掘金 API 里扒点热门文章:

const https = require('https'); // 搬来HTTPS“购物袋”

// 去掘金API“买”热门文章数据
https.get(
    'https://api.juejin.cn/content_api/v1/content/article_rank?category_id=1&type=hot&count=3',
    (res) => {
        let content = '';
            res.on('data', (chunk) => { // 数据“碎片”过来了,先攒着
            content += chunk;
        })
        res.on('end', () => { // 数据攒够了,打印出来看看
            console.log(content);
        })
    }
)

效果:运行代码后,控制台会跳出掘金的文章数据 —— 相当于 Node.js 帮你 “爬” 了个网页,是不是很神奇?

image.png

可能很多人觉得控制台的东西很乱,但中文你总认得了吧?明显看到热搜第一的文章:2025快手直播至暗时刻。(也就是title那一行)。

第三斧:用fs模块,让 Node.js 当 “文件管家”

电脑里的文件,Node.js 能用fs模块随便折腾:既能 “读” 文件里的内容,也能 “写” 新内容进去。

比如读文件

const fs = require('fs'); // 搬来文件操作“管家”

// 读text.txt里的内容(就是那句“床前明月光”)
fs.readFile('./text.txt', 'utf-8', (err, data) => {
    if (!err) {
        console.log(data); // 打印出“床前明月光,疑是地上霜”
    }
})

然后我们创建一个文件夹text.txt

image.png

node运行,就可以看到文本里的内容了:

image.png

再比如写文件

// 往readme.md里写“你好”
fs.writeFile('./readme.md', '你好', (err) => {
    if (!err) {
        console.log('写入成功'); // 成功后控制台提示
    }
})

image.png

image.png

效果:运行后,文件夹里会多出一个readme.md,打开就是 “你好”—— 相当于 Node.js 帮你写了个小文档!

总结:Node.js 的 “三板斧”,其实是 “三把钥匙”

  • http开服务器的钥匙:让你从 “网页浏览者” 变成 “网页搭建者”;
  • https连互联网的钥匙:让你从 “数据消费者” 变成 “数据获取者”;
  • fs控本地文件的钥匙:让你从 “文件操作者” 变成 “文件自动化管理者”。

结语

从拉起一个 HTTP 服务,到拉取远程接口数据,再到操控本地文件,Node.js 的httphttpsfs模块,本质是将 “网络通信”“本地操作” 的底层能力封装成了开发者可直接调用的接口。掌握这三者,就相当于拿到了 Node.js 后端开发的 “入门密钥”,后续无论是构建 API 服务、处理数据流转,还是管理文件资源,都能以此为基,向外延伸出更复杂的应用场景。

本篇依旧是Node.js的基础知识,感兴趣的话可以配套另外一篇基础知识一起👀:

Steam玩累了?那用 Node.js 写个小游戏:手把手玩懂 JS 运行环境

鸿蒙ArkUI状态管理新宠:@Local装饰器全方位解析与实战

作者 Darsunn
2025年12月26日 00:02

一、引言:为什么需要@Local装饰器?

在状态管理V1版本中,我们使用@State装饰器来定义组件的内部状态。然而,@State存在一个明显的局限性: "内外不分"

@State的痛点:状态被意外覆盖

 class ComponentInfo {
   name: string;
   count: number;
   message: string;
   
   constructor(name: string, count: number, message: string) {
     this.name = name;
     this.count = count;
     this.message = message;
   }
 }
 
 @Component
 struct Child {
   @State componentInfo: ComponentInfo = new ComponentInfo('Child', 1, 'Hello World');
   
   build() {
     Column() {
       Text(`componentInfo.message is ${this.componentInfo.message}`)
     }
   }
 }
 
 @Entry
 @Component
 struct Index {
   build() {
     Column() {
       // 父组件传入的值会覆盖子组件的初始状态!
       Child({ componentInfo: new ComponentInfo('Unknown', 0, 'Error') })
     }
   }
 }

在上面的代码中,Child组件期望componentInfo的初始值为'Hello World',但父组件传入的值'Error'将其覆盖。这种"暗箱操作"使得组件的内部状态管理变得不可预测。

@Local的解决方案:纯粹的内部状态

@Local装饰器应运而生,它的核心设计理念是:被装饰的变量必须是纯内部状态,不允许从外部初始化。这从语法层面确保了组件状态的封装性和可预测性。

二、@Local装饰器核心特性详解

1. 强制内部初始化

 @ComponentV2
 struct MyComponent {
   @Local message: string = 'Hello World'; // ✅ 正确:内部初始化
   
   build() {
     // ...
   }
 }
 
 @ComponentV2
 struct ParentComponent {
   build() {
     ChildComponent({ message: 'Hello' }) // ❌ 错误:编译时报错
   }
 }

2. 强大的观测能力

@Local提供了不同粒度下的观测能力:

数据类型 观测能力 示例
简单类型 赋值变化 this.count = 1
类对象 整体赋值 this.user = new User()
数组 整体赋值 + API调用 this.items.push(newItem)
内置类型 整体赋值 + 特定API this.date.setFullYear(2024)

3. 与@State的对比

特性 @State @Local
从父组件初始化 ✅ 可以 ❌ 不允许
观察能力 变量本身 + 一层属性 变量本身,深度观测需@Trace
使用场景 可能内外交互的状态 纯粹的组件内部状态
设计理念 灵活但边界模糊 严谨且封装性好

三、@Local装饰器实战案例

案例1:基础类型状态管理

 @Entry
 @ComponentV2
 struct BasicTypesExample {
   @Local count: number = 0
   @Local message: string = 'Hello'
   @Local isActive: boolean = false
 
   build() {
     Column({ space: 15 }) {
       Text(`计数器: ${this.count}`)
         .fontSize(20)
         .fontColor('#FF6200')
       
       Text(`消息: ${this.message}`)
         .fontSize(18)
       
       Text(`状态: ${this.isActive ? '激活' : '未激活'}`)
         .fontColor(this.isActive ? '#00A653' : '#FF3B30')
 
       Button('增加计数')
         .onClick(() => {
           this.count++ // ✅ 触发UI刷新
         })
     }
     .padding(20)
     .width('100%')
   }
 }

💡 关键点:对基础类型的直接赋值操作能够被@Local观测并触发UI更新。

案例2:类对象与深度观测

 // 普通类 - 无深度观测能力
 class NormalUser {
   name: string
   age: number
   
   constructor(name: string, age: number) {
     this.name = name
     this.age = age
   }
 }
 
 // 可观测类 - 使用@ObservedV2和@Trace
 @ObservedV2
 class ObservableUser {
   @Trace name: string
   @Trace age: number
   
   constructor(name: string, age: number) {
     this.name = name
     this.age = age
   }
 }
 
 @Entry
 @ComponentV2
 struct ObjectExample {
   @Local normalUser: NormalUser = new NormalUser('张三', 25)
   @Local observableUser: ObservableUser = new ObservableUser('李四', 30)
 
   build() {
     Column({ space: 20 }) {
       // 普通对象属性修改不会触发刷新
       Button('修改普通对象属性')
         .onClick(() => {
           this.normalUser.name = '王五' // ❌ UI不会刷新
         })
       
       // 可观测对象属性修改会触发刷新
       Button('修改可观测对象属性')
         .onClick(() => {
           this.observableUser.name = '赵六' // ✅ UI会刷新
         })
     }
   }
 }

🚨 重要提示:深度观测需要@ObservedV2@Trace的配合使用!

案例3:数组操作完整示例

 @Entry
 @ComponentV2
 struct ArrayExample {
   @Local numbers: number[] = [1, 2, 3, 4, 5]
   @Local tasks: string[] = ['任务A', '任务B', '任务C']
 
   build() {
     Column({ space: 15 }) {
       // 显示数组内容
       ForEach(this.numbers, (item: number, index?: number) => {
         Text(`${index! + 1}. ${item}`)
           .padding(8)
           .backgroundColor('#F2F2F2')
           .borderRadius(8)
       })
 
       // 数组操作按钮
       Row({ space: 10 }) {
         Button('添加')
           .onClick(() => {
             this.numbers.push(this.numbers.length + 1) // ✅ 触发刷新
           })
         
         Button('删除')
           .onClick(() => {
             this.numbers.pop() // ✅ 触发刷新
           })
         
         Button('反转')
           .onClick(() => {
             this.numbers.reverse() // ✅ 触发刷新
           })
       }
     }
     .padding(20)
   }
 }

🌟 亮点@Local能够观测到数组API调用引起的变化,这大大提升了开发效率。

案例4:内置类型实战(Date、Map、Set)

 @Entry
 @ComponentV2
 struct BuiltInTypesExample {
   @Local currentDate: Date = new Date()
   @Local scoreMap: Map<string, number> = new Map([['张三', 90], ['李四', 85]])
   @Local tagSet: Set<string> = new Set(['重要', '紧急'])
 
   build() {
     Column({ space: 20 }) {
       // Date操作
       Text(this.currentDate.toLocaleDateString())
       Button('明天')
         .onClick(() => {
           this.currentDate.setDate(this.currentDate.getDate() + 1) // ✅ 触发刷新
         })
 
       // Map操作
       Button('添加分数')
         .onClick(() => {
           this.scoreMap.set('王五', 95) // ✅ 触发刷新
         })
 
       // Set操作
       Button('添加标签')
         .onClick(() => {
           this.tagSet.add('新标签') // ✅ 触发刷新
         })
     }
   }
 }

案例5:父子组件通信最佳实践

 // 子组件 - 使用@Local管理内部状态
 @ComponentV2
 struct ChildComponent {
   @Local internalCount: number = 0 // ✅ 纯内部状态
   @Param messageFromParent?: string // ✅ 从父组件接收数据
 
   build() {
     Column() {
       Text(`内部计数: ${this.internalCount}`)
       Button('自增')
         .onClick(() => {
           this.internalCount++ // 只有子组件自己能修改
         })
       
       Text(`父组件消息: ${this.messageFromParent}`)
     }
   }
 }
 
 // 父组件
 @Entry
 @ComponentV2
 struct ParentComponent {
   @Local parentMessage: string = '初始消息'
 
   build() {
     Column() {
       ChildComponent({ messageFromParent: this.parentMessage })
       
       Button('更新消息')
         .onClick(() => {
           this.parentMessage = '更新消息'
         })
     }
   }
 }

🎯 设计理念@Local用于内部状态,@Param用于接收父组件数据,职责分明!

四、高级特性与注意事项

1. 联合类型支持

 @Entry
 @ComponentV2
 struct UnionExample {
   @Local data: string | null = '初始数据'
   @Local status: 'loading' | 'success' | 'error' = 'loading'
 
   build() {
     Column() {
       Text(this.data ?? '暂无数据')
       Text(`状态: ${this.status}`)
       
       Button('切换为null')
         .onClick(() => {
           this.data = null // ✅ 类型安全
         })
     }
   }
 }

2. 避免不必要的刷新

 import { UIUtils } from '@kit.ArkUI'
 
 @Entry
 @ComponentV2
 struct OptimizeExample {
   @Local data: string[] = ['a', 'b', 'c']
 
   build() {
     Column() {
       Button('优化赋值')
         .onClick(() => {
           const newData = ['a', 'b', 'c']
           // 使用UIUtils.getTarget()避免不必要的刷新
           if (UIUtils.getTarget(this.data) !== newData) {
             this.data = newData
           }
         })
     }
   }
 }

五、总结与最佳实践

@Local装饰器的核心价值

  1. 状态封装:确保组件内部状态不被外部意外修改
  2. 类型安全:支持多种数据类型和联合类型
  3. 观测精准:提供不同粒度的观测能力
  4. 开发体验:配合现代IDE提供更好的类型提示

使用场景推荐

场景 推荐装饰器 理由
纯内部状态 @Local 防止外部修改,确保封装性
需要内外通信 @State 支持父组件初始化
复杂对象深度观测 @Local + @ObservedV2 + @Trace 提供完整的观测能力
数组/集合操作 @Local 内置API观测支持

迁移建议

对于现有项目,建议逐步将纯内部状态的@State变量迁移为@Local,新项目则直接采用@Local来管理内部状态。

六、结语

@Local装饰器的引入标志着鸿蒙ArkUI状态管理进入了更加成熟和规范的阶段。它通过强制性的内部初始化规则,解决了@State在状态管理边界上的模糊性问题,为开发者提供了更加可靠和可预测的状态管理方案。

无论是简单的计数器应用,还是复杂的企业级项目,@Local都能帮助你构建出更加健壮和可维护的鸿蒙应用。希望本文能够帮助您深入理解并熟练运用这一重要的新特性!

LangChain 实战:让 LLM 拥有记忆与结构化输出能力

作者 不会js
2025年12月25日 23:56

LangChain 实战:让 LLM 拥有记忆与结构化输出能力

在稀土掘金社区,大家都在热烈讨论大模型应用落地。其中两个最常见、最棘手的痛点就是:

  1. LLM 没有记忆:每次调用都像第一次见面,问“你叫什么名字”,它永远回答“我是AI助手”……
  2. LLM 输出不听话:让你返回 JSON,它偏要加解释、前言后语、甚至格式错得离谱。

今天,我们就用 LangChain.js彻底解决这两个问题。通过真实代码 + 底层原理剖析,手把手带你实现:

  • 有状态的多轮对话(带记忆)
  • 强制结构化 JSON 输出(带运行时校验)

一、为什么 LLM 天生“失忆”?

先来一个最简单的实验:

const res1 = await model.invoke('我叫彭于晏,一个演员');
console.log(res1.content); // 助手愉快回应

const res2 = await model.invoke('我叫什么名字');
console.log(res2.content); // “我不知道你叫什么名字……”

为什么会这样?

因为所有主流 LLM API(OpenAI、DeepSeek、Claude 等)都是无状态的,就像普通的 HTTP 请求一样:

  • 你发一个请求 → 模型处理 → 返回响应
  • 下一次请求 → 模型完全不记得上一次发生了什么

这就像你去饭店点菜,每次都要重新自我介绍:“你好,我是彭于晏,今天想吃麻辣烫”……服务员永远一脸茫然。

传统解决方案:手动维护消息历史

最原始的做法是自己维护一个 messages 数组:

let messages = [
  { role: "user", content: "我叫彭于晏,一个演员" },
  { role: "assistant", content: "好的,彭于晏先生!" },
  { role: "user", content: "我叫什么名字?" }
];

await model.invoke(messages); // 这次就能答对了

这确实能工作,但问题很快暴露:

  • 对话越长,messages 越长 → Token 消耗雪球式增长
  • 每次都要手动拼接历史,代码丑陋且容易出错
  • 多用户场景?需要为每个用户维护一个消息列表,复杂度爆炸

这时候,LangChain 登场了。


二、LangChain 如何优雅实现“记忆”?

先看完整代码

03998dfb2be956b19c909a672ec27e78.jpg

import { ChatDeepSeek } from "@langchain/deepseek";
import { ChatPromptTemplate } from "@langchain/core/prompts";
//带上历史记录的可运行对象
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import 'dotenv/config';
//存在内存之中
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";


const model = new ChatDeepSeek({
    model:'deepseek-chat',
    temperature:0
});
//chat 模式 数组
const prompt = ChatPromptTemplate.fromMessages([
    ['system',"你是一个有记忆的助手"],
    ['placeholder',"{history}"],
    ['human',"{input}"]
] )

const runnable = prompt
    .pipe((input)=>{// debug 节点
        console.log('>>>最终传给模型的信息(prompt 内存)');
        console.log(input);
        return input
        
    })
    .pipe(model);
//对话历史实例
    const messageHistory = new InMemoryChatMessageHistory();
    const chain = new RunnableWithMessageHistory({
        runnable,
        getMessageHistory:async ()=> messageHistory,
        inputMessagesKey:'input' ,
        historyMessagesKey:'history'
    })

    const res1 = await chain.invoke({
        input:'我叫彭于晏,一个演员',
        
    },
    {
            configurable:{
                sessionId:'makefriend'
            }
        }
    )

    console.log(res1.content);

  const res2 = await chain.invoke({
        input:'我叫什么名字',
        
    },
    {
            configurable:{
                sessionId:'makefriend'
            }
        }
    )
console.log(res2.content);

    

带记忆的链

JavaScript

const chain = new RunnableWithMessageHistory({
    runnable,                                      // ①
    getMessageHistory: async () => messageHistory, // ②
    inputMessagesKey: 'input',                     // ③
    historyMessagesKey: 'history'                  // ④
})

这四个参数就是整个记忆机制的命脉,缺一个都不行。下面我详细解释每个参数的作用、为什么需要它、底层到底是怎么工作的。


① runnable: 你的“核心处理链”是什么?

作用:这是你要“加记忆”的那条原始链,也就是不带记忆时的完整处理流程。

在代码中:

JavaScript

const runnable = prompt
    .pipe(debug节点)
    .pipe(model);

它本质上是一个 Runnable 对象,负责把输入 → 处理 → 输出(即:把 {input, history} 格式化成消息列表 → 传给模型 → 返回回复)。

为什么需要它? RunnableWithMessageHistory 本身不负责业务逻辑,它只是一个“包装器”(wrapper)。它要包装的对象就是这个 runnable——它会自动在每次调用前,后对 runnable 的输入和输出做增强(注入历史 + 保存新消息)。

底层逻辑: 当你调用 chain.invoke(...) 时,实际上是 RunnableWithMessageHistory 先接管请求,改造输入后再调用 runnable.invoke(...),最后再处理输出。


② getMessageHistory: 如何获取/存储当前会话的历史?

作用:一个异步函数,每次调用时根据会话 ID 返回对应的聊天历史对象。

JavaScript

getMessageHistory: async () => messageHistory

这里用了同一个 InMemoryChatMessageHistory() 实例,相当于所有 sessionId 共用一个历史。

真实项目中应该怎么写?

JavaScript

// 推荐:用 Map 存储多个会话的历史
const store = new Map<string, InMemoryChatMessageHistory>();

const chain = new RunnableWithMessageHistory({
  // ...
  getMessageHistory: async (sessionId: string) => {
    if (!store.has(sessionId)) {
      store.set(sessionId, new InMemoryChatMessageHistory());
    }
    return store.get(sessionId)!;
  },
})

为什么是 async? 因为生产环境你可能要从 Redis、MongoDB、MySQL 等外部存储读取历史,必须是异步操作。

每次 invoke 时发生了什么?

  1. 你传了 configurable: { sessionId: 'makefriend' }
  2. LangChain 自动从 getMessageHistory('makefriend') 拿到历史对象
  3. 取出里面的所有历史消息(AIMessage / HumanMessage 列表)

③ inputMessagesKey: 'input'

作用:告诉 LangChain,“你每次 invoke 传进来的对象里,哪一个 key 是当前用户的新输入”。

你调用时是这样写的:

JavaScript

chain.invoke({
  input: '我叫彭于晏,一个演员'
}, { configurable: { sessionId: 'makefriend' } })

所以这里必须写 'input'。

它干了三件事

  1. 注入到 Prompt:把这个值填充到 prompt 里的 {input} 占位符(对应 ['human', "{input}"])
  2. 转为 HumanMessage:内部会把 input 的值包装成一条 HumanMessage
  3. 保存到历史:调用结束后,这条 HumanMessage + 模型的回复(AIMessage)会被自动添加到 messageHistory 中

如果写错会怎样? 比如你写成 inputMessagesKey: 'question',但 invoke 时传的是 { input: '...' } → 报错:找不到当前输入消息。


④ historyMessagesKey: 'history'

作用:告诉 LangChain,“我要把历史消息列表注入到 runnable 的输入对象里,用哪个 key 名?”

它和 prompt 模板里的 ['placeholder', "{history}"] 必须完全对应。 我们一步步看 LangChain 是怎么把输入变成最终发给模型的消息列表的。

底层执行流程是这样的

每次 chain.invoke 时,LangChain 会构造一个新输入对象:

JavaScript

{
  input: "用户当前说的话",                  // 来自 inputMessagesKey
  history: [                               // 来自 getMessageHistory 取出的历史
    HumanMessage("我叫彭于晏,一个演员"),
    AIMessage("好的,记住了!"),
    // ... 更多历史
  ]
}

然后把这个对象传给你的 runnable(也就是 prompt → model)。

你的 prompt 正好有:

JavaScript

['placeholder', "{history}"],  // 会把整个历史消息列表塞进去
['human', "{input}"]

所以模型看到的完整消息列表就是:

text

System: 你是一个有记忆的助手
... 所有历史消息(从 {history} 注入)
Human: 我叫什么名字(从 {input} 注入)

完美闭环!

如果 key 不匹配会怎样? 比如你写 historyMessagesKey: 'chatHistory',但 prompt 用的是 {history} → 历史根本不会注入,模型还是失忆。


完整执行时序图

image.png 这就是为什么第二次问“我叫什么名字”时,模型能答对——历史已经被正确注入并保存了。


易错点大汇总(必看!)

  1. sessionId 没传或传错 → 每次都是新历史,永远失忆
  2. inputMessagesKey / historyMessagesKey 和 prompt 不匹配 → 历史不注入或当前输入丢失
  3. getMessageHistory 没根据 sessionId 分隔存储 → 多用户串历史(演示代码的坑)
  4. 用了 InMemoryChatMessageHistory 但没考虑服务重启 → 重启后记忆全丢(生产必须换持久化存储)
  5. prompt 没用 MessagesPlaceholder(即 placeholder) → 历史不会正确展开成多条消息
我们来深入了解一下placeholder
情况1:正确使用 MessagesPlaceholder(placeholder)

输入对象:

JavaScript

{
  input: "我叫什么名字",
  history: [
    HumanMessage("我叫彭于晏,一个演员"),
    AIMessage("好的,记住了!你好彭于晏!")
  ]
}

经过 ChatPromptTemplate 处理后,最终发给模型的消息数组是:

JavaScript

[
  { role: "system", content: "你是一个有记忆的助手" },
  { role: "human", content: "我叫彭于晏,一个演员" },     // 来自 history 展开
  { role: "assistant", content: "好的,记住了!你好彭于晏!" }, // 来自 history 展开
  { role: "human", content: "我叫什么名字" }                 // 来自 {input}
]

注意:历史消息被一条一条展开,每一轮的 role 都完整保留!

模型看到的是一个完整的多轮对话上下文,自然就能“记住”你叫彭于晏。

情况2:错误地用普通字符串替换 {history}

同样的输入对象:

JavaScript

{
  input: "我叫什么名字",
  history: [ ... 两条消息对象 ... ]
}

但因为你用了普通字符串模板,LangChain 在填充 {history} 时,会调用 .toString() 或直接序列化这个消息数组。

结果可能是:

text

历史对话:[{"type":"human","content":"我叫彭于晏,一个演员"},{"type":"assistant","content":"好的,记住了!你好彭于晏!"}]

最终发给模型的消息数组变成:

JavaScript

[
  { role: "system", content: "你是一个有记忆的助手" },
  { role: "human", content: "历史对话:[{"type":"human",...}]" },  // 一大坨 JSON 字符串!
  { role: "human", content: "我叫什么名字" }
]

这时候模型看到的上下文是:

text

系统:你是一个有记忆的助手
用户:历史对话:[{"type":"human","content":"我叫彭于晏,一个演员"}, ... ]
用户:我叫什么名字

模型根本不知道这坨 JSON 字符串是上一轮对话!它只会觉得你在说一段奇怪的代码,或者直接忽略。

结果:完全失忆


总结:这四个参数的“灵魂配合”

参数 对应位置 作用本质
runnable 你的核心链 被包装的对象
getMessageHistory 外部存储 / Map 读写历史的地方
inputMessagesKey invoke 时传的 key + prompt 当前用户输入的标识与注入点
historyMessagesKey prompt 中的 placeholder 历史消息列表的注入点

它们就像四个齿轮,缺一不可,咬合得天衣无缝,才实现了“有状态的 LLM 调用”。

三、为什么 LLM 输出 JSON 这么不靠谱?

另一个经典场景:你想让模型返回结构化数据。

最 naive 的写法:

prompt = "请用 JSON 格式返回前端概念信息,包含 name、core、useCase、difficulty 字段。话题:Promise"

结果往往是:

好的,以下是 Promise 的信息:

{
  "name": "Promise",
  "core": "...",
  // ... 可能缺字段、多字段、键名写错
}

如果你有其他问题欢迎继续提问!

问题出在:

  • 模型是“生成型”的,不是“服从型”的
  • 它更倾向于“自然对话”,而不是严格遵守格式
  • 即使提示写得再严,也偶尔会“叛变”

传统解决方案:正则 + 手动解析

const jsonStr = response.match(/\{.*\}/s)[0];
JSON.parse(jsonStr); // 祈祷别出错

风险极高,一出错整个链崩。


四、LangChain + Zod:强制结构化输出的终极方案

来看最佳实践:

const FrontendConceptSchema = z.object({
  name: z.string().describe("概念名称"),
  core: z.string().describe("核心要点"),
  useCase: z.array(z.string()).describe("常见使用场景"),
  difficulty: z.enum(['简单','中等','复杂']).describe("学习难度")
});

const jsonParser = new JsonOutputParser(FrontendConceptSchema);

Zod 是什么?为什么这么强?

Zod 是一个 TypeScript 第一的运行时类型校验库

你用代码定义数据契约:

type FrontendConcept = z.infer<typeof FrontendConceptSchema>;
// 自动推导为:
interface FrontendConcept {
  name: string;
  core: string;
  useCase: string[];
  difficulty: '简单' | '中等' | '复杂';
}

完整链路是怎么工作的?

const chain = prompt.pipe(model).pipe(jsonParser);

执行流程:

  1. prompt 中插入 jsonParser.getFormatInstructions()
    • 自动生成一段精确的 JSON 格式说明,注入到 {format_instructions}
  2. 模型看到严格指令,更大概率输出正确 JSON
  3. 模型输出文本 → 进入 JsonOutputParser
  4. 解析器做两件事:
    • JSON.parse() 转对象
    • schema.parse() 用 Zod 严格校验
  5. 任意一项失败 → 抛错(你可以 catch 重试)

实际生成的 format_instructions(自动!)

The output should be a valid JSON formatted according to the following schema:
{
  "name": "string",
  "core": "string",
  "useCase": ["string"],
  "difficulty": "enum(['简单', '中等', '复杂'])"
}
Only return the JSON object, no additional text.

为什么这比手动写 prompt 强 100 倍?

项目 手动写 prompt Zod + JsonOutputParser
格式说明一致性 容易写错、漏改 自动生成,永远正确
修改字段成本 要改多处 prompt 只改一处 Schema
运行时安全 无校验,祈祷模型听话 严格 parse,错就报错
TypeScript 支持 res 是 any 自动推导精确类型,IDE 提示完美
复杂结构支持 嵌套、联合类型很难描述 原生支持 transform、refine 等
可复用性 每个链都要复制 prompt Schema 定义一次,全局复用

真实案例:你后来想加 relatedConcepts: string[] 字段
→ 只需改一行 Zod,其他全部自动同步!

永远记得:Zod Schema 的 key 名必须和提示中要求的完全一致!


五、从早期 JS 模块化看现代工程化演进

早期前端的尴尬:

<script src="./a.js"></script>
<script>
  const p = new Person('张三',18);
  p.sayName();
</script>
  • 全局污染严重
  • 依赖顺序必须手动控制
  • 没有作用域隔离

这才有了:

  • CommonJS(Node.js)
  • AMD/CMD(RequireJS)
  • 最终 ES6 Modules(import/export

LangChain 的设计哲学也是如此:

  • 早期:手动拼接 messages、手动解析 JSON
  • 现在:模块化、可组合、类型安全、自动管理

这正是现代 AI 工程化的方向。


总结:两个核心能力,缺一不可

能力 解决方案 核心类/工具 推荐程度
多轮对话记忆 RunnableWithMessageHistory InMemoryChatMessageHistory / Redis ⭐⭐⭐⭐⭐
结构化输出 JsonOutputParser + Zod z.object() + getFormatInstructions() ⭐⭐⭐⭐⭐

掌握了这两招,你的 LLM 应用就从“玩具”升级为“生产级工具”:

  • 聊天机器人能记住用户
  • 数据提取接口稳定可靠
  • 前端直接对接类型安全的响应
  • 维护成本大幅降低

最后送上一句心得

大模型很强大,但“强大”不等于“可靠”。
真正的工程能力,是在不可靠的生成模型之上,构建一层可靠的、类型安全的、可维护的系统。

这才是 LangChain 存在的意义。

新手必读:React组件从入门到精通,一篇文章搞定所有核心概念

作者 AY1024
2025年12月25日 23:53

组件化概念

在我们学习react之前,我们先来思考一下,现在你要实现一个这样的界面及对应的功能:

image.png

你肯定会先写一个大的div标签,然后里面分割,上边栏,下边栏...,如果在现代化框架我们还这样写那就太落后了,后期维护复杂不说,代码复用效率还低。那我们应该怎么做才更有效率,答案是---组件化

  • 何为组件:在react中,组件主要分为函数组件和类组件。现代React开发主要使用函数组件,它是一个返回JSX(JavaScript XML)的JavaScript函数,可以让你在JavaScript中编写类似HTML的结构。JSX看起来像HTML,但实际上是JavaScript语法的扩展。
import './App.css'

function App() {

  return (//返回了一段JSX
    <>
    <h1>你的第一个组件!!!</h1>
    </>
  )
}

export default App

image.png

定义组件教程:

  • 创建一个函数

    • 组件名称首字母必须大写,否则当你调用这个组件时,react会把这个组件当作html标签,产生报错
function App(){

}
  • 返回一段JSX

    • 如果返回多条JSX元素记得用()包裹,如果返回一条,可以不用()包裹,但是一定得和return在同一行。

    • 在写JSX时,只能有一个最外层标签,因为JSX最终会被编译为React.createElement()函数调用,而一个函数只能返回一个值。如果含多个最外层标签,那么会报错:Adjacent JSX elements must be wrapped in an enclosing tag,翻译过来就是:相邻的 JSX 元素必须被包裹在一个封闭的标签中。

    • 可以使用React片段(Fragment)作为最外层标签,它不会在DOM中创建额外的节点:

      // 简写形式
      <>
        <h1>标题</h1>
        <p>内容</p>
      </>
      
      // 完整形式(可以添加key属性)
      <React.Fragment>
        <h1>标题</h1>
        <p>内容</p>
      </React.Fragment>
      

image.png

function App(){
return(
<>
...
</>
)
}

一:组件导出的两种方式

在 React 中,组件的导出有两种主要方式,它们在语法和使用上有所不同:

1. 默认导出 (Default Export)

特点:一个文件只能有一个默认导出

import './App.css'

function App() {
  return (
    <>
      <h1>你的第一个组件!!!</h1>
    </>
  )
}

// 默认导出 - 方式一:分开写(推荐)
export default App

// 默认导出 - 方式二:直接写在函数声明前
// export default function App() { ... }

导入方式

import App from './App'  // 导入时可以随意命名
import MyComponent from './App'  // 这样也是可以的

2. 具名导出 (Named Export)

特点:一个文件可以有多个具名导出

import './App.css'

function App() {
  return (
    <>
      <h1>你的第一个组件!!!</h1>
    </>
  )
}

// 具名导出 - 方式一:单独导出
export { App }

// 具名导出 - 方式二:直接在函数前导出
// export function App() { ... }

// 还可以同时导出其他内容
// export const config = { title: "我的应用" }
// export function helper() { ... }

导入方式

import { App } from './App'  // 必须使用相同的名称
import { App as MyApp } from './App'  // 可以使用别名

image.png

image.png

image.png

3. 两种方式可以同时存在

import './App.css'

function App() {
  return (
    <>
      <h1>你的第一个组件!!!</h1>
    </>
  )
}

const Version = "1.0.0"

// 同时使用两种导出方式
export default App      // 默认导出
export { App, Version } // 具名导出

导入方式

import App, { Version } from './App'  // 混合导入

4.语法对比表

导出方式 语法 导入语法 特点
默认导出 export default App import App from './App' 一个文件只能有一个
具名导出 export { App } import { App } from './App' 可以有多个
具名导出 export function App() {} import { App } from './App' 声明时直接导出

5.重要注意事项

  • 一个组件中只能由一个默认导出,但是可以有多个具名导出
  • 如何导出的,在你导入对应的组件时,你也应该如何导入。默认导出---默认导入,具名导出---具名导入
  • 如果使用两种方式导出,那也可以使用两种方式导入

导入时的对应关系

// 如果使用 export default App
import App from './App'          // ✅

// 如果使用 export { App }
import { App } from './App'      // ✅

// 如果同时使用两种方式
import App, { App as NamedApp } from './App'  // ✅

二: 组件渲染到页面

要让React组件真正显示在浏览器页面上,我们需要完成以下几个步骤:

  1. 创建根容器:在HTML中指定一个DOM元素作为React应用的根容器
  2. 渲染组件:使用ReactDOM将组件渲染到根容器中

完整示例:

1.index.html (HTML入口文件)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>React App</title>
</head>
<body>
    <!-- 这是React应用的根容器 -->
    <div id="root"></div>
</body>
</html>
2.index.js (JavaScript入口文件)
// 1. 导入必要的库和组件
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

// 2. 获取HTML中的根元素
const rootElement = document.getElementById('root');

// 3. 创建React根(React 18+的写法)
const root = ReactDOM.createRoot(rootElement);

// 4. 渲染App组件到根容器中
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
3.App.jsx (你的组件文件)
import './App.css'

function App() {
  return (
    <>
      <h1>你的第一个组件!!!</h1>
      <p>恭喜你成功渲染了React组件!</p>
    </>
  )
}

export default App

三、React组件渲染原理详解

要理解React组件是如何渲染到页面的,我们需要了解其底层工作原理:

1. JSX的本质

你在组件中写的JSX代码并不是直接插入到DOM中的。实际上,JSX是一种语法糖,它会被Babel(或TypeScript编译器)编译为普通的JavaScript代码:

// JSX写法
function App() {
  return (
    <div className="container">
      <h1>Hello, React!</h1>
    </div>
  );
}

// 编译后的JavaScript(相当于)
function App() {
  return React.createElement(
    'div',
    { className: 'container' },
    React.createElement('h1', null, 'Hello, React!')
  );
}

React.createElement()函数会创建一个React元素对象,这个对象描述了你想在屏幕上看到的内容:

// React.createElement()返回的对象类似于:
{
  type: 'div',
  props: {
    className: 'container',
    children: {
      type: 'h1',
      props: {
        children: 'Hello, React!'
      }
    }
  }
}
2. 虚拟DOM(Virtual DOM)

React使用虚拟DOM来提高渲染性能。虚拟DOM是一个轻量级的JavaScript对象树,它是真实DOM的抽象表示:

  1. 首次渲染时

    • React调用组件的render()方法(函数组件就是直接调用函数)
    • 创建虚拟DOM树(React元素树)
    • ReactDOM将虚拟DOM转换为真实DOM节点
    • 将真实DOM插入到页面容器中
  2. 更新时

    • 当状态或属性改变时,组件重新渲染
    • 创建新的虚拟DOM树
    • React对比新旧虚拟DOM树(Diff算法)
    • 只更新发生变化的部分到真实DOM
3. 渲染流程图解
JSX代码 → 编译为React.createElement() → 创建React元素对象 → 形成虚拟DOM树
                                                                  ↓
首次渲染:虚拟DOM → ReactDOM.render() → 创建真实DOM → 插入到页面容器
                                                                  ↓
更新时:新虚拟DOM → 与旧虚拟DOM对比(Diff算法) → 计算最小变更 → 更新真实DOM
4. Diff算法(协调算法)

React使用高效的Diff算法来决定如何更新DOM,主要策略包括:

  • 同级比较:React只会比较同一层次的节点,不会跨层次比较
  • 元素类型不同:如果元素类型不同,React会销毁整个子树并重建
  • 元素类型相同:如果元素类型相同,React会保留DOM节点,只更新有变化的属性
  • Key属性优化:为列表元素添加key属性,帮助React识别哪些元素发生了变化
// 列表渲染时使用key属性
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}
5. React 18的并发渲染

React 18引入了并发渲染(Concurrent Rendering):

  • 可中断渲染:React可以中断、暂停和恢复渲染工作
  • 优先级调度:高优先级的更新可以打断低优先级的更新
  • 自动批处理:多个状态更新会被自动合并为一次渲染
// React 18的并发模式
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
6. 组件渲染的生命周期(简化版)

对于函数组件,渲染过程可以简化为:

组件函数调用 → 执行组件逻辑 → 返回JSX → 编译为React元素 → 形成虚拟DOM
                                    ↓
Diff对比 → 计算DOM变更 → 执行DOM更新 → 完成渲染

注意事项:

  1. React未导入问题: 在React 17+之前,使用JSX必须导入React,因为JSX会被编译为React.createElement()

    // React 17+在某些构建工具中可以省略React导入
    // 但为了兼容性,建议始终导入
    import React from 'react';
    
  2. 性能优化

    • 使用React.memo()包裹组件,避免不必要的重新渲染
    • 使用useMemo()useCallback()缓存计算和函数
    • 避免在组件内部创建对象/函数,除非使用useMemo/useCallback
  3. 开发环境与生产环境

    • 开发环境包含额外的错误检查和警告
    • 生产环境进行了代码优化和压缩,体积更小

常见问题:

  1. 为什么需要虚拟DOM?

    • 直接操作DOM非常昂贵(重排、重绘)
    • 虚拟DOM在内存中操作,速度快
    • 通过Diff算法最小化DOM操作,提高性能
  2. React如何知道何时重新渲染?

    • 当组件状态(useState)或属性(props)发生变化时
    • 父组件重新渲染会导致子组件重新渲染(除非使用React.memo优化)
  3. 渲染是同步还是异步的?

    • React 17及之前:渲染通常是同步的
    • React 18:默认使用并发渲染,可以是异步的

通过理解React的渲染原理,你可以编写更高效的组件,避免不必要的重新渲染,并更好地调试React应用。组件化开发结合虚拟DOM和Diff算法,使得React能够高效地管理复杂的用户界面更新。

这是本系列的第一篇,后期会持续更新,下期内容---props组件通信。

望学习愉快!!!

前端服务端渲染 SSR

作者 Aniugel
2025年12月25日 23:53

什么是SSR?它解决了什么问题?怎么做SSR?⭐️⭐️ 实操

SSR(Server-Side Rendering,服务端渲染)是一种 将Html内容在服务器端生成并返回给客户端 的技术,与传统的客户端渲染(CSR)相比,它能显著 优化首屏加载速度SEO(搜索引擎优化)

一、核心概念:什么是 SSR?

在 客户端渲染(CSR)  中,浏览器从服务器获取的是几乎空白的 HTML仅包含 <div id="app"></div> 等容器),然后通过加载 JavaScript 脚本,在客户端动态生成 DOM 并渲染页面(典型如 Vue、React 单页应用)。

首屏渲染:SPA(Single Page Application)将页面渲染放到客户端执行,并且在渲染之前要加载大量的Javascript代码,所以首屏渲染需要花费较长时间。而SSR直接在服务端渲染完再返回客户端,使用户能够快速看到页面内容。

而 SSR 则是在 服务器端 提前将 Vue、React 等框架的组件渲染成完整的 HTML 字符串,包含页面的所有内容,然后将这个完整的 HTML 直接返回给浏览器。浏览器收到后无需等待 JavaScript 加载完成即可展示页面,后续再通过客户端脚本 “激活”(hydrate)页面,使其具备交互能力。

二、工作流程:SSR 如何运作?

以 Vue/React 框架的 SSR 为例,核心流程如下:

  1. 客户端发起请求:用户访问某个 URL(如 https://example.com/page)。
  2. 服务器接收请求:Node.js 服务器(如 Express、Koa)接收请求,解析路由。
  3. 数据预获取:服务器根据路由,提前请求所需数据(如接口调用、数据库查询)。
  4. 组件渲染:将数据注入到 Vue/React 组件中,在服务器端通过框架的 SSR 引擎(如 vue-server-rendererReactDOMServer将组件渲染为完整的 HTML 字符串
  5. 返回完整 HTML:服务器将渲染好的 HTML 字符串(包含 <html><head><body> 及所有内容)返回给客户端。
  6. 客户端 “激活” :浏览器展示 HTML 内容(首屏快速可见),同时加载客户端 JavaScript 脚本,脚本执行后 “激活” 页面(绑定事件、初始化状态),使页面从静态变为可交互的动态页面。

三、SSR 与 CSR 的关键区别

特性 客户端渲染(CSR) 服务端渲染(SSR)
首屏加载速度 慢(需加载 JS 后动态渲染 快(直接返回完整 HTML,浏览器可立即解析
SEO 友好性 差(搜索引擎可能,无法解析动态生成的内容 好(搜索引擎可直接抓取完整 HTML 内容
服务器压力 小(仅提供静态资源和接口) 大(需处理渲染和数据请求,需更高性能服务器

四、SSR 的优势

  1. 优化首屏加载速度:客户端无需等待 JavaScript 下载、解析和执行,直接展示服务器返回的完整 HTML,尤其对网络条件差或设备性能弱的用户更友好。用户能更快看到页面内容,减少 “白屏时间”,降低跳出率
  2. 提升 SEO 效果:搜索引擎爬虫更易抓取服务器返回的静态 HTML 内容(传统 CSR 中,爬虫可能只看到空白容器,导致页面内容无法被索引)。

五、SSR 的劣势

  1. 增加服务器负载:服务器需要处理渲染逻辑和数据请求,相比仅提供静态资源的 CSR 服务器,CPU 和内存消耗更高,需要更强的服务器性能或负载均衡策略。

  2. 开发复杂度提高

    • 代码需兼容: “服务端渲染” 和 “客户端激活” 两个环境
    • 需处理数据同步问题:(服务端预获取的数据需传递给客户端,避免客户端重新请求)。
    • 构建和部署流程更复杂(需 Node.js 服务器环境,无法直接部署到纯静态文件服务器)。
  3. 部分浏览器 API 限制:服务端渲染时无法访问浏览器特有的 API(如 windowlocalStorage),需在客户端激活后使用。

六、适用场景

  • 对首屏加载速度和 SEO 要求高的网站(如电商首页、博客、新闻网站)。

不适合的场景:

  • 后台管理系统,用户对首屏速度不敏感,SEO 无关紧要。

七、实现方式:如何落地 SSR?

1. 基于框架的原生方案
  • Vue 2/3:使用官方的 vue-server-renderer(Vue 2)或 @vue/server-renderer(Vue 3),配合 Node.js 服务器(如 Express)实现。
  • React:使用 ReactDOMServer 进行服务端渲染,ReactDOM.hydrate 进行客户端激活,常见搭配 Next.js 框架简化开发。
2. 成熟的 SSR 框架(降低开发成本)
  • Next.js(React 生态):最流行的 SSR 框架之一,内置 SSR、静态生成(SSG)、增量静态再生(ISR)等功能,简化路由、数据预获取等逻辑。
  • Nuxt.js(Vue 生态):类似 Next.js,为 Vue 提供 SSR 解决方案,支持自动路由、数据预获取(asyncDatafetch 方法)等。
  • SvelteKit(Svelte 生态):支持 SSR 和静态生成,以轻量高效为特点。
3. 核心实现要点(以 Vue + Express 为例)
  • 服务端配置:通过 vue-server-renderer 创建渲染器(createRenderer),将组件渲染为 HTML。
  • 数据预获取:在服务端路由中提前获取数据(如 asyncData 方法),并注入到组件的 context 中。
  • 客户端激活:客户端引入同一份组件代码,通过 hydrate 方法激活页面,复用服务端渲染的 DOM。
  • 避免浏览器 API:服务端渲染时,需通过 process.env.VUE_ENV 区分环境,避免在服务端调用 window 等 API。

总结

SSR 通过服务端提前渲染 HTML 解决了 CSR 的首屏速度和 SEO 问题,但其代价是更高的服务器负载和开发复杂度。实际开发中,需根据业务需求(如 SEO 重要性、首屏速度要求)选择是否使用,或结合 SSG、CSR 混合使用(如 Next.js 的 “混合渲染” 模式)。对于 React/Vue 开发者,推荐直接使用 Next.js/Nuxt.js 等成熟框架,降低 SSR 落地难度。

Vue 服务端渲染框架(SSR)

Nuxt.js 和 vue-server-renderer 均服务于 Vue 生态的服务端渲染(SSR),核心关系是 “Nuxt.js 基于 vue-server-renderer 封装,是更高层的框架”。

vue-server-renderer 是 Vue 官方提供的底层工具库,核心功能是将 Vue 实例(组件树)渲染为 HTML 字符串,是 Vue 实现 SSR 的 “基石”。

Nuxt.js 作为 Vue 生态的 SSR 框架,本质是对 vue-server-renderer 的封装:它在底层调用 vue-server-renderer 的 createBundleRenderer 等 API 完成服务端渲染的核心逻辑(将 Vue 组件转换为 HTML),但在此之上增加了大量工程化能力(如自动路由、数据预取、构建配置等),简化了开发者的使用成本。

  • 选择建议

    • 若需高度定制 SSR 逻辑(如复杂的缓存策略、中间层架构),用 vue-server-renderer 手动搭建;
    • 若追求开发效率,快速实现 Vue SSR/SSG 应用,优先用 Nuxt.js。

以下是使用 Vue 实现 SSR 的基本步骤:

  1. 安装相关依赖:确保你的项目中已经安装了 Vue、Vue Router、Vue Server Renderer 等依赖。

  2. 创建服务器入口文件:创建一个服务器入口文件,如 server.js。在文件中引入必要的模块,包括 Vue、Vue Server Renderer、Express(或其他后端框架)等。

  3. 编写服务器端渲染逻辑

    • 创建一个 Vue 实例,配置路由、数据等。
    • 使用 Vue Server Renderer 的 createRenderer 方法创建一个 renderer 实例。
    • 在路由处理器中调用 renderer 实例的 renderToString 方法来将 Vue 实例渲染为字符串。
  4. 处理静态资源:在服务器端渲染时,需要处理静态资源的加载和引用。可以使用 Webpack 进行服务器端渲染的配置,以处理静态资源的导出和加载。

  5. 客户端激活:在服务器端渲染后,需要在客户端激活 Vue 实例,以便能够响应交互事件和更新页面。可以在 HTML 中插入一个 JavaScript 脚本,并在脚本中使用 createApp 方法来创建客户端应用程序实例。

在 Vue2项目中 Nuxt.js 框架实现SSR

在 Vue2 项目中用 Nuxt.js 实现 SSR 非常简单,因为 Nuxt.js 2 本身就是基于 Vue2 设计的 SSR 框架,能直接复用 Vue2 的语法和组件。以下是针对已有 Vue2 项目的改造步骤(如果是新项目,直接按步骤 1 创建即可),核心逻辑是 “用 Nuxt2 的约定式配置替代 Vue2 的手动配置”。

一、如果是新建项目(最简单,推荐)

直接创建基于 Vue2 的 Nuxt2 项目,自带 SSR 能力:

1. 创建 Nuxt2 项目(确保基于 Vue2)
# 安装 Nuxt2 脚手架
npm install -g create-nuxt-app

# 创建项目(命名为 vue2-nuxt-ssr)
create-nuxt-app vue2-nuxt-ssr

关键配置选择(其他默认):

  • Choose Vue version → 选 2.x(绑定 Vue2);
  • Choose rendering mode → 选 Universal (SSR / SSG)(开启 SSR);
2. 写一个 SSR 页面(用 Vue2 语法)

Nuxt2 会自动将 pages 目录下的 .vue 文件转为路由,在 pages/index.vue 中写页面(完全兼容 Vue2 选项式 API):

<!-- pages/index.vue -->
<template>
  <div>
    <h1>Vue2 + Nuxt2 SSR 示例</h1>
    <!-- 服务端渲染的动态数据 -->
    <p>服务端生成时间:{{ serverTime }}</p>
    <p>服务端随机数:{{ randomNum }}</p>
  </div>
</template>

<script>
// 完全遵循 Vue2 选项式 API
export default {
  // asyncData:Nuxt2 服务端数据预取方法(Vue2 项目中核心)
  // 作用:在服务端渲染前执行,返回的数据直接注入页面 HTML
  asyncData() {
    return {
      // 服务端获取当前时间(服务端无 window,直接用 Date)
      serverTime: new Date().toLocaleString(),
      // 服务端生成随机数(每次刷新由服务端重新计算)
      randomNum: Math.random().toFixed(4)
    }
  }
}
</script>
3. 运行并验证 SSR 生效

cd vue2-nuxt-ssr
npm run dev  # 启动服务(默认 http://localhost:3000)

验证 SSR:访问页面后,右键 → “查看页面源代码”,如果能在源码中直接看到 服务端生成时间:xxx 和 服务端随机数:xxx(而非空标签),说明 SSR 成功(服务端已将数据渲染到 HTML 中)。

二、如果是改造已有 Vue2 项目(核心步骤)

如果已有一个 Vue2 项目(如 Vue-CLI 创建),只需 3 步改造为 Nuxt2 SSR 项目:

1. 安装 Nuxt2 依赖
# 进入现有 Vue2 项目
cd your-vue2-project

# 安装 Nuxt2 核心依赖(依赖 Vue2)
npm install nuxt@2.x --save
2. 调整目录结构(按 Nuxt 约定)

Nuxt2 用约定式目录替代手动配置,需添加 / 修改:

your-vue2-project/
├── pages/           # 新增:路由页面(Nuxt 自动生成路由)
│   └── index.vue    # 首页(内容同上一步的 index.vue)
├── package.json     # 修改启动脚本
└── nuxt.config.js   # 新增:Nuxt 配置文件(极简版)
  • nuxt.config.js(极简配置):

    module.exports = {
      mode: 'universal'  // 关键:开启 SSR 模式
    }
    
  • package.json(添加启动脚本):

    "scripts": {
      "dev": "nuxt dev"  // 启动 Nuxt 开发服务
    }
    
3. 迁移核心代码
  • 将原 Vue2 项目的 src/App.vue 核心内容迁移到 pages/index.vue
  • 原路由(src/router)无需保留,Nuxt 会根据 pages 目录自动生成路由;
  • 原组件(src/components)可直接复用(复制到项目根目录的 components 文件夹)。
4. 运行验证
npm run dev

访问 http://localhost:3000,查看页面源码验证 SSR 生效(同新建项目步骤)。

核心逻辑总结

Vue2 + Nuxt2 实现 SSR 的本质是:

  1. 用 Nuxt2 的 universal 模式开启服务端渲染
  2. 通过 asyncData 方法在服务端预取数据(替代 Vue2 客户端请求数据的逻辑);
  3. 依赖 Nuxt2 自动处理 “服务端渲染 HTML + 客户端激活” 的全流程(无需手动配置 vue-server-renderer 或 Webpack)。

核心验证方式:页面源码中能看到动态数据(服务端已渲染),而非客户端 JS 动态插入的空容器。验证方法blog.csdn.net/csdn_girl/a…

面试题

Vue SSR 的工作流程是什么

  1. 在服务器端运行 Vue 实例,根据路由生成 HTML。
  2. HTML 包含渲染好的内容和状态,并发送到客户端。
  3. 客户端接管页面,进行 “激活”(hydration),绑定交互逻辑。

为什么需要将 Vue 的组件和状态序列化到 HTML 中

在 SSR 中,Vue 的状态(如 Vuex 的 state)需要通过 script 标签嵌入到 HTML 中,供客户端使用

客户端和服务器端的应用实例有何不同?

  • 服务端实例专注于生成 HTML,不能直接操作 DOM
  • 客户端实例负责挂载到已有的 DOM 上,并补充交互逻辑

服务端渲染需要哪些基本配置?

如何处理路由和数据预取?

  • 路由:基于 Vue Router,确保客户端和服务端的路由一致。
  • 数据预取:在服务端预取数据后,将其传递到客户端。

SSR 如何在服务端预取数据?

  • 使用 asyncData 或在路由守卫中预取数据:
const fetchData = async () => {
  const data = await fetch('api/data')
  return data.json();
};

服务端获取的数据如何传递给客户端?

  • 使用 renderToString 时,将数据注入到 HTML:
<script>window.__INITIAL_STATE__ =${JSON.stringify(state)}

如何避免客户端与服务端状态不一致?

  • 客户端同步:客户端在挂载时,通过 window.INITIAL_STATE 初始化状态。

Vue SSR 如何提升 SEO?

  • 服务端返回的 HTML 包含完整内容,便于爬虫抓取。
  • 配置动态 meta 标签(如 title 和 description)提升页面权重。

如何提高 Vue SSR 的性能?

  • 使用缓存(如 Redis)减少重复渲染。
  • 压缩 HTML 和资源文件。
  • 避免不必要的组件加载,使用按需加载。

SSR 中如何避免重复渲染和多次数据请求?

  • 确保数据预取只在服务端执行,客户端通过传递的状态初始化。

SSR 项目如何处理第三方库的依赖?

  • 许多第三方库依赖 DOM,例如操作 document 或 window,在服务端渲染时可能会报错。
  • 解决方案:在 mounted 中引入这些库,确保只在客户端运行。

如何解决 SSR 与浏览器特性冲突(如 window 或 document 不存在)?

  • 浏览器特性冲突:○ 在代码中检查环境:
if (typeof window !== 'undefind'// 浏览器相关代码
}

什么是闪屏问题?如何解决?

  • 问题:服务端生成的 HTML 和客户端激活过程中样式或内容不一致。
  • 解决方案:确保服务端和客户端使用相同的组件路由状态

Nuxt.js 如何简化 Vue SSR 的实现?

  • Nuxt.js 提供开箱即用的 SSR 支持,集成了路由、数据预取和打包优化

Nuxt.js 的 asyncData 和 fetch 有什么区别?

  • asyncData:在组件渲染前获取数据,用于静态内容。
  • fetch:在客户端和服务端均可执行,用于动态内容。

如何配置 Nuxt.js 的动态路由和 SEO?

  • 使用动态路由文件(pages/_id.vue)。
  • 在页面中定义 head 方法动态配置 meta 信息:
export default {
  head() {
    return {
      title: '动态标题',
      meta: [{ name: 'description',content:'页面描述'}]
    };
  }
};

SSR 与 SSG 有什么区别?

在什么场景下应该选择 SSR 或 SSG?

SSR

  • 每次请求动态生成页面。
  • 适用于需要实时数据的应用,如电商、动态内容。

SSG

  • 在构建时生成静态 HTML 文件,部署到 CDN。
  • 适用于静态内容较多的应用,如博客、文档

面试题清单

  1. 什么是 SSR?与传统客户端渲染(CSR)有什么区别?
  2. SSR 的优点和缺点是什么?
  3. Vue SSR 的工作流程是什么?
  4. 为什么需要将 Vue 的组件和状态序列化到 HTML 中?
  5. 客户端和服务器端的应用实例有何不同?
  6. 如何使用 Vue 提供的 @vue/server-renderer 或 Nuxt.js 实现 SSR?
  7. 服务端渲染需要哪些基本配置?
  8. 如何处理路由和数据预取?
  9. SSR 如何在服务端预取数据?
  10. 服务端获取的数据如何传递给客户端?
  11. 如何避免客户端与服务端状态不一致?
  12. Vue SSR 如何提升 SEO?
  13. 如何提高 Vue SSR 的性能?
  14. SSR 中如何避免重复渲染和多次数据请求?
  15. SSR 项目如何处理第三方库的依赖?
  16. 如何解决 SSR 与浏览器特性冲突(如 window 或 document 不存在)?
  17. 什么是闪屏问题?如何解决?
  18. Nuxt.js 如何简化 Vue SSR 的实现?
  19. Nuxt.js 的 asyncData 和 fetch 有什么区别?
  20. 如何配置 Nuxt.js 的动态路由和 SEO?
  21. SSR 与 SSG 有什么区别?
  22. 在什么场景下应该选择 SSR 或 SSG?

ngix开启跨域

作者 icestone_kai
2025年12月25日 22:57

只会前端,我怕这些忘了,就记录下
C:\runtime\nginx-1.29.4\conf\nginx.conf


#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    server {
        listen       80;
        server_name  localhost;

        #access_log  logs/host.access.log  main;

        location / {
            root   html;
            index  index.html index.htm;
            
            # CORS配置
            add_header Access-Control-Allow-Origin *;
            add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
            add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
            
            # 处理预检请求
            if ($request_method = 'OPTIONS') {
                return 204;
            }
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ~ \.php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ~ \.php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /\.ht {
        #    deny  all;
        #}
    }


    # another virtual host using mix of IP-, name-, and port-based configuration
    #
    #server {
    #    listen       8000;
    #    listen       somename:8080;
    #    server_name  somename  alias  another.alias;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}


    # HTTPS server
    #
    #server {
    #    listen       443 ssl;
    #    server_name  localhost;

    #    ssl_certificate      cert.pem;
    #    ssl_certificate_key  cert.key;

    #    ssl_session_cache    shared:SSL:1m;
    #    ssl_session_timeout  5m;

    #    ssl_ciphers  HIGH:!aNULL:!MD5;
    #    ssl_prefer_server_ciphers  on;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}

}

使用 AI Workflow 规范化团队 Commit 信息:从混乱到有序

作者 葛葵葵
2025年12月25日 22:50

📖 背景:Commit 信息的痛点

在团队协作开发中,你是否遇到过这些问题?

# 😱 混乱的 commit 历史
git log --oneline
a1b2c3d 修复bug
e4f5g6h update
i7j8k9l 临时提交
m0n1o2p 改了点东西
q3r4s5t fix

这样的 commit 信息会导致:

  • ❌ 无法快速了解每次提交的目的
  • ❌ 难以追踪 bug 的引入时间
  • ❌ 无法自动生成有意义的 changelog
  • ❌ 代码审查效率低下
  • ❌ 团队协作混乱

💡 解决方案:AI Workflow + 规范化 Commit

本文将介绍如何使用 AI Workflow 来解决这个问题,让团队的每一次提交都清晰、规范、有意义。

什么是 AI Workflow?

想象一下,你有一个非常靠谱的助手,你只需要告诉他:"帮我写 commit 信息",他就会:

  1. 自动检查你改了哪些代码
  2. 分析改动的类型和目的
  3. 按照团队规范生成完美的 commit 信息
  4. 提供可直接执行的命令

AI Workflow 就是这样一个"智能助手"!

通过在项目中创建 .agent/workflows/*.md 文件,你可以:

  • ✅ 定义标准化的工作流程
  • ✅ 让 AI 自动分析代码改动
  • ✅ 生成符合规范的输出
  • ✅ 提高团队协作效率

🎯 动手实践:搭建你的 AI 助手

💡 温馨提示:这部分会手把手教你配置,就像组装乐高一样简单!即使你是新手也完全 OK。

🎬 开始之前

想象一下,你正在给自己的项目配一个"智能秘书",它能帮你:

  • 📝 自动写规范的 commit 信息
  • 🤖 永远不会忘记团队规范
  • ⚡ 每次提交节省 3-5 分钟

听起来很酷?让我们开始吧!


第一步:告诉 AI "我们的规矩是什么"

就像制定游戏规则

你玩过桌游吗?每个游戏都有规则手册。我们要做的就是给 AI 写一本"commit 规则手册"。

我们的规则很简单:

类型(范围): 做了什么

详细说明(可选)

举个栗子 🌰:

# 😊 好的 commit(一看就懂)
feat(登录): 添加微信登录功能

# 😱 糟糕的 commit(完全看不懂)
修复bug

类型速查表(记不住也没关系,AI 会帮你):

你做了什么 用什么类型 举个例子
🎉 加了新功能 feat feat(支付): 添加支付宝支付
🐛 修了个 bug fix fix(登录): 修复登录失败
♻️ 重构了代码 refactor refactor(用户): 优化用户模块
📝 改了文档 docs docs(readme): 更新安装说明
🎨 调了格式 style style(代码): 统一缩进

💡 小贴士:这些规则不用死记硬背,因为 AI 会帮你选!


第二步:创建 AI 的"工作手册"

就像给助手写工作流程

想象你雇了一个助手,你会给他一份工作流程说明书,对吧?

1. 先建个文件夹

# 在项目根目录执行(就是有 package.json 的那个文件夹)
mkdir -p .agent/workflows

💬 解释.agent 是 AI 编辑器的专属文件夹,workflows 存放工作流程。

2. 创建工作手册

创建一个文件:.agent/workflows/commit.md

💬 你可以这样做

  • 方法 1:用 VS Code 或任何编辑器新建文件
  • 方法 2:复制我下面提供的模板

📄 模板内容(复制粘贴即可):

---
description: 生成中文 commit 信息
---

# 生成中文 Commit 信息 Workflow

嘿,AI!当我说 `/commit` 的时候,请帮我:

## 第 1 步:看看我改了什么

```bash
git status
git diff --cached
```

## 第 2 步:分析我的改动

- 改了哪些文件?
- 是新功能、修 bug、还是重构?
- 主要目的是什么?

## 第 3 步:确定改动类型和范围

根据分析结果,确定:

- type:改动类型(feat/fix/refactor 等)
- scope:影响范围(登录/支付/用户等)

## 第 4 步:生成规范的 commit

按照这个格式:

```
<类型>(<范围>): <简短描述>

<详细说明>
```

## 第 5 步:给我可以直接执行的命令

生成一个 `git commit -m "..."` 命令,让我直接复制执行。

## 示例

**简单的:**

```bash
feat(登录): 添加微信登录
```

**详细的:**

```bash
feat(登录): 添加微信登录功能

- 集成微信 OAuth 认证
- 添加用户信息同步
- 实现自动登录
```

## 注意事项

- 描述要用中文,简单明了
- 一次 commit 只做一件事
- 如果改动很多,建议详细说明

✅ 完成! 你已经给 AI 写好工作手册了!


🧪 测试一下

来,试试你的新玩具!

1. 创建一个测试改动

# 随便改点东西
echo "测试" > test.txt
git add test.txt

2. 在 AI 助手中输入

/commit

3. 看看 AI 的反应

AI 应该会说:

✅ 我看到你添加了 test.txt 文件
✅ 这看起来是一个文档改动
✅ 类型:docs
✅ 范围:test

4. AI 会生成

好的!为你生成了 commit 信息:

git commit -m "docs(test): 添加测试文档

- 创建测试文件 test.txt"

请复制上面的命令执行即可!

5. 清理测试文件

git reset HEAD test.txt
rm test.txt

✅ 完美!你的 AI 助手已经上岗了!


🎓 给新手的小贴士

常见问题 Q&A

Q: 我不会用 AI 编辑器怎么办?

A: 推荐使用 Antigravity(免费)或 Trae CN(免费),下载后直接用,就像用 VS Code 一样简单。

Q: 我输入 /commit 没反应?

A: 检查三点:

  1. 确认 .agent/workflows/commit.md 文件存在
  2. 重启一下 AI 编辑器
  3. 确认你用的编辑器支持 workflow(Antigravity/Cursor/Windsurf/Trae CN 都支持)

Q: 我们团队的规范不一样怎么办?

A: 直接修改 .agent/workflows/commit.md 文件,改成你们的规范就行!

Q: 能不能添加任务编号?

A: 可以!修改 workflow 文件,在格式中添加 <task-id> 部分即可。


🎉 总结

通过 AI Workflow,我们将 commit 规范从"团队约定"变成了"自动化流程":

  • 降低门槛:新人无需记忆复杂规范
  • 提高效率:AI 自动分析和生成
  • 保证质量:统一的格式和标准
  • 易于推广:一次配置,全团队受益

立即开始使用 /commit workflow,让你的团队 Git 历史从此告别混乱!


📚 相关资源

完整配置文件

本文使用的完整 workflow 配置文件已开源,可以直接使用:

📄 GitHub 地址: .agent/workflows/commit.md

你可以:

  • ✅ 直接复制使用
  • ✅ 根据团队需求修改
  • ✅ 提交 PR 贡献改进

支持的 AI 编辑器


在 Monorepo 中如何让一个 TypeScript Shared 模块同时服务前后端 ,一次三天的挣扎与最终解法

2025年12月25日 22:43

背景

在我的 monorepo 项目 pawHaven 中,前端和后端并不是完全割裂的两套系统。

它们天然地共享了一部分代码,例如:

  • 常量定义

  • 配置结构

  • 枚举 / 字典

  • 一些纯函数工具

于是,一个看似理所当然的想法出现了:

把这些公共代码抽成一个 shared package,供前后端共同使用。****

在最开始,这个架构让我感到非常兴奋——

TypeScript、pnpm workspace、monorepo 都已经就位,这似乎是一个“马上就能实现”的设计。


问题开始出现

真正的问题,并不是在编写 shared 代码的时候,而是在运行的时候。

当我:

  • 分别打包前端和后端

  • 再分别运行它们

问题开始接连出现:

  • Node 运行时报错:Unexpected token 'export'

  • 前端构建通过,但运行时提示模块找不到

  • 有时是 export 不被支持

  • 有时是 require 找不到目标文件

这些错误表面上看起来零散、毫无关联,但本质上都指向同一个问题:

前端和后端对“模块系统”的期望是完全不同的。****


我最初的错误假设

一开始,我的假设是:

能不能在 shared 里打包出一个产物,同时兼容 CommonJS 和 ESM?****

于是我开始不断尝试各种组合:

  • module: ESNext

  • module: CommonJS

  • "type": "module"

  • 不同的 moduleResolution(node / nodenext / bundler)

  • 各种 tsconfig 的排列组合

结果是——

三天时间,我反复在不同的报错之间循环。****

直到某一刻我意识到一个事实:

试图用“一个构建产物”同时满足 CommonJS 和 ESM,本身就是一个互相矛盾的目标。****


关键认知转变

真正的转折点,来自一个简单但重要的问题:

为什么 shared package 一定要“只产出一个结果”?****

前端和后端的运行环境,本来就是不同的:

环境 模块期望
前端(Vite / Webpack) ESM
Node 后端(Nest / require) CommonJS
既然需求不同,那么结论其实非常自然:

shared package 不应该妥协成“一个都不完全适配的产物”,

而是为不同环境提供各自合适的构建结果。****


最终解决方案:双构建(Dual Build)

最终的方案并不“取巧”,而是非常工程化。 具体实现请参考我的真实monorepo流浪动物救助项目pawhaven中的shared模块 shared

1️⃣ 一份源码(TypeScript,ESM 写法)

shared 中只维护一份源码,全部使用标准的 ESM 写法:

/**
 * Remove all types of whitespace:
 * spaces, full-width spaces, tabs, line breaks.
 *
 * @param value string | undefined | null
 * @returns cleaned string
 */
export function stringTrim(value: string): string {
  // Return empty string for null or undefined
  if (value === null || value === undefined) return '';

  // Convert to string safely
  const str = String(value);

  // Normalize full-width spaces to normal spaces
  const normalized = str.replace(/\u3000/g, ' ');

  // Remove all whitespace: spaces, tabs, newlines, full-width spaces
  return normalized.replace(/\s+/g, '');
}

2️⃣ 两个 tsconfig,对应两种构建目标

为 shared package 分别维护两个 tsconfig:

packages/shared/
├─ tsconfig.esm.json
├─ tsconfig.cjs.json
// tsconfig.esm.json
{
  "extends": "@pawhaven/tsconfig/base",
  "compilerOptions": {
    "outDir": "dist/esm",
    "module": "ESNext",
    "moduleResolution": "bundler"
  },
  "exclude": ["node_modules", "dist"]
}

// tsconfig.cjs.json
{
  "extends": "@pawhaven/tsconfig/base",
  "compilerOptions": {
    "outDir": "dist/cjs",
    "module": "CommonJS",
    "moduleResolution": "node"
  },
  "exclude": ["node_modules", "dist"]
}

这样可以做到:

  • ESM 构建:供前端和 bundler 使用
  • CJS 构建:供 Node 后端使用

3️⃣ 通过 package.json 精准分流

{
  "name": "@pawhaven/shared",

  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "types": "./dist/index.d.ts",

  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  }
}

main / module / types

各自的职责

  • main****

    • 给 Node(CommonJS)使用
    • 当使用 require('@pawhaven/shared') 时加载
    • 指向 dist/cjs/index.js
  • module****

    • 给 bundler(Webpack / Rollup / Vite)使用
    • 声明这是一个 ESM 入口
    • 用于 tree-shaking
    • Node 本身不会读取该字段
  • types****

    • 给 TypeScript 使用
    • 只在编译期生效
    • 前后端共用一份类型声明

真正的“裁判”:

exports

如果说 main 和 module 更像是“建议”,

那么 exports 才是严格的规则定义

"exports": {
  ".": {
    "types": "./dist/index.d.ts",
    "import": "./dist/esm/index.js",
    "require": "./dist/cjs/index.js"
  }
}

实际加载行为如下:

使用方式 命中字段 加载产物
import ... from '@pawhaven/shared' exports.import ESM 构建
require('@pawhaven/shared') exports.require CJS 构建
TypeScript 类型解析 exports.types .d.ts

这意味着:

  • 前端和后端在无感知的情况下拿到各自正确的实现
  • 不需要 runtime 判断
  • 不需要环境变量
  • 行为在 CI 和本地完全一致

为什么这套方案是稳定的

因为模块的选择发生在:

解析阶段(resolve time),而不是运行阶段(runtime)****

这带来了几个关键好处:

  • 没有运行时分支逻辑
  • 没有 hack 或条件判断
  • 构建结果完全可预测

最终总结

这三天的踩坑,让我真正理解了一件事:

Monorepo 中 shared package 的难点,不在“代码共享”,

而在“模块边界的清晰定义”。**** 一个成熟、稳定的 shared 模块应该具备:

  • 一份源码
  • 多个明确的构建产物
  • 严格通过 exports 进行消费分流

而不是试图通过某种“神奇配置”,

让一个产物兼容所有运行环境。

这也是目前在大型 monorepo 项目中,

shared 模块最可靠、最可维护的实践之一。

手写 `new`:揭开 JavaScript 实例化背后的秘密

作者 Zyx2007
2025年12月25日 21:44

在 JavaScript 中,new 是一个看似简单却承载着面向对象核心机制的关键字。当我们写下 new Person() 时,引擎并非只是“调用一个函数”,而是在背后完成了一系列精密的初始化操作:创建对象、绑定原型、执行构造逻辑、返回实例。理解这一过程,不仅能帮助我们掌握原型继承的本质,也为实现高级框架(如 Vue 的响应式系统)打下基础。本文将从零开始,手写一个 new 的模拟函数,并深入剖析其每一步的作用。

new 到底做了什么?

使用 new 调用构造函数时,JavaScript 引擎会按以下顺序执行:

  1. 创建一个全新的空对象;
  2. 将该对象的 __proto__ 指向构造函数的 prototype
  3. 将构造函数内部的 this 绑定到这个新对象,并执行函数体;
  4. 如果构造函数没有显式返回一个对象,则自动返回新创建的对象。

这四步构成了 JavaScript 原型式面向对象的基石。为了验证我们的理解,可以尝试手动复现这一过程。

手写 objectFactory 模拟 new

function objectFactory() {
  const obj = {};
  const Constructor = Array.prototype.shift.call(arguments);
  obj.__proto__ = Constructor.prototype;
  Constructor.apply(obj, arguments);
  return obj;
}

这段代码精炼地还原了 new 的行为。首先,通过 shiftarguments 中取出第一个参数(即构造函数),其余参数作为实参传递给构造函数。接着,将新对象的原型链指向构造函数的 prototype,确保实例能访问其方法。最后,用 applythis 绑定到 obj 并执行构造逻辑。

测试如下:

function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.sayHi = function() {
  console.log('你好,我是' + this.name);
};

const p1 = new Person('张三', 18);
const p2 = objectFactory(Person, '张三', 18);
console.log(p1.sayHi === p2.sayHi); // true

两个实例不仅属性一致,连原型方法也完全共享,证明我们的模拟是成功的。

关于 arguments:类数组的灵活与局限

objectFactory 中,我们使用了 arguments 对象来获取传入的所有参数。arguments 是函数内部的一个类数组对象:它拥有 length 属性,可通过索引访问每个参数,但不具备数组的原生方法(如 mapreduce)。

function add() {
  const args = [...arguments];
  return args.reduce((sum, val) => sum + val, 0);
}
console.log(add(1, 2, 3)); // 6

通过扩展运算符 [...arguments],我们可以将其转换为真正的数组,从而使用现代数组方法。这种灵活性使得 JavaScript 函数天然支持可变参数,无需预先声明参数个数。

为什么需要手动处理 arguments

在模拟 new 时,无法预知用户会传入多少个参数,也无法提前定义形参列表。因此,必须依赖 arguments 动态获取所有实参。而 Array.prototype.shift.call(arguments) 是一种经典技巧:它将 arguments 视为数组,从中“弹出”第一个元素(构造函数),剩余部分自然成为构造函数的参数列表。

值得注意的是,arguments 并非真正的数组,其 __proto__ 指向 Object.prototype,而非 Array.prototype。这也是为何不能直接调用 arguments.shift()——必须借助 callapply 借用数组的方法。

返回值的边界情况

标准 new 运算符还有一个细节:如果构造函数显式返回一个对象,则忽略新创建的实例,直接返回该对象。例如:

function Test() {
  this.value = 1;
  return { value: 2 };
}
console.log(new Test().value); // 2

我们的 objectFactory 目前未处理此情况。若要完全兼容,可补充判断:

const result = Constructor.apply(obj, arguments);
return (typeof result === 'object' && result !== null) ? result : obj;

但在大多数实际场景中,构造函数不返回值或仅返回基本类型,因此简化版已足够使用。

总结

手写 new 不仅是一道常见的面试题,更是深入理解 JavaScript 对象模型的关键实践。通过模拟其实例化过程,我们清晰地看到:对象的创建、原型的链接、上下文的绑定是如何协同工作的。同时,对 arguments 的操作也展示了 JavaScript 在参数处理上的动态特性。

尽管现代开发中 class 语法已普及,但其底层依然依赖 new 和原型链。掌握这些原始机制,意味着你不仅能写出更健壮的代码,还能在阅读框架源码、调试复杂继承关系时游刃有余。毕竟,真正的 JavaScript 功力,往往藏在这些“基础却深刻”的细节之中。

❌
❌